diff --git a/docs/metrics.md b/docs/metrics.md index 5e4e97c050..1a38cc658c 100644 --- a/docs/metrics.md +++ b/docs/metrics.md @@ -238,4 +238,8 @@ soroban.config.ledger-max-read-entry | counter | soroban config settin soroban.config.ledger-max-read-ledger-byte | counter | soroban config setting `ledger_max_read_bytes` soroban.config.ledger-max-write-entry | counter | soroban config setting `ledger_max_write_ledger_entries` soroban.config.ledger-max-write-ledger-byte | counter | soroban config setting `ledger_max_write_bytes` -soroban.config.bucket-list-target-size-byte | counter | soroban config setting `bucket_list_target_size_bytes` \ No newline at end of file +soroban.config.bucket-list-target-size-byte | counter | soroban config setting `bucket_list_target_size_bytes` +soroban.module-cache.num-entries | counter | current number of entries in module cache +soroban.module-cache.compilation-time | timer | times each contract compilation when adding to module cache +soroban.module-cache.rebuild-time | timer | times each rebuild of module cache (including all compilations) +soroban.module-cache.rebuild-bytes | counter | bytes of WASM bytecode compiled in last rebuild of module cache \ No newline at end of file diff --git a/docs/stellar-core_example.cfg b/docs/stellar-core_example.cfg index 77699bbb94..4e7fbaa17c 100644 --- a/docs/stellar-core_example.cfg +++ b/docs/stellar-core_example.cfg @@ -382,6 +382,12 @@ CATCHUP_RECENT=0 # merging and vertification. WORKER_THREADS=11 +# COMPILATION_THREADS (integer) default 6 +# Number of threads launched temporarily when compiling contracts at +# startup. These are short lived, CPU-bound threads that are not +# in competition with the worker threads. +COMPILATION_THREADS=6 + # QUORUM_INTERSECTION_CHECKER (boolean) default true # Enable/disable computation of quorum intersection monitoring QUORUM_INTERSECTION_CHECKER=true diff --git a/src/bucket/BucketBase.cpp b/src/bucket/BucketBase.cpp index 0917b20a82..b502ffc896 100644 --- a/src/bucket/BucketBase.cpp +++ b/src/bucket/BucketBase.cpp @@ -51,6 +51,12 @@ BucketBase::getOfferRange() const return getIndex().getOfferRange(); } +std::optional> +BucketBase::getContractCodeRange() const +{ + return getIndex().getContractCodeRange(); +} + void BucketBase::setIndex(std::unique_ptr&& index) { diff --git a/src/bucket/BucketBase.h b/src/bucket/BucketBase.h index bd472f6fef..480aa20344 100644 --- a/src/bucket/BucketBase.h +++ b/src/bucket/BucketBase.h @@ -93,6 +93,11 @@ class BucketBase : public NonMovableOrCopyable std::optional> getOfferRange() const; + // Returns [lowerBound, upperBound) of file offsets for all contract code + // entries in the bucket, or std::nullopt if no contract code exists + std::optional> + getContractCodeRange() const; + // Sets index, throws if index is already set void setIndex(std::unique_ptr&& index); diff --git a/src/bucket/BucketIndex.h b/src/bucket/BucketIndex.h index ee15031fc1..269e25cfad 100644 --- a/src/bucket/BucketIndex.h +++ b/src/bucket/BucketIndex.h @@ -130,6 +130,11 @@ class BucketIndex : public NonMovableOrCopyable virtual std::optional> getOfferRange() const = 0; + // Returns lower bound and upper bound for contract code entry positions in + // the given bucket, or std::nullopt if no contract code entries exist + virtual std::optional> + getContractCodeRange() const = 0; + // Returns page size for index. InidividualIndex returns 0 for page size virtual std::streamoff getPageSize() const = 0; diff --git a/src/bucket/BucketIndexImpl.cpp b/src/bucket/BucketIndexImpl.cpp index e010f93e65..f850ed2a6c 100644 --- a/src/bucket/BucketIndexImpl.cpp +++ b/src/bucket/BucketIndexImpl.cpp @@ -598,6 +598,20 @@ BucketIndexImpl::getOfferRange() const return getOffsetBounds(lowerBound, upperBound); } +template +std::optional> +BucketIndexImpl::getContractCodeRange() const +{ + // Get the smallest and largest possible contract code keys + LedgerKey lowerBound(CONTRACT_CODE); + lowerBound.contractCode().hash.fill(std::numeric_limits::min()); + + LedgerKey upperBound(CONTRACT_CODE); + upperBound.contractCode().hash.fill(std::numeric_limits::max()); + + return getOffsetBounds(lowerBound, upperBound); +} + #ifdef BUILD_TESTS template bool diff --git a/src/bucket/BucketIndexImpl.h b/src/bucket/BucketIndexImpl.h index 52630a70e6..8539f25ab5 100644 --- a/src/bucket/BucketIndexImpl.h +++ b/src/bucket/BucketIndexImpl.h @@ -100,6 +100,9 @@ template class BucketIndexImpl : public BucketIndex virtual std::optional> getOfferRange() const override; + virtual std::optional> + getContractCodeRange() const override; + virtual std::streamoff getPageSize() const override { diff --git a/src/bucket/BucketSnapshot.cpp b/src/bucket/BucketSnapshot.cpp index 05c43f31a3..68da64b258 100644 --- a/src/bucket/BucketSnapshot.cpp +++ b/src/bucket/BucketSnapshot.cpp @@ -282,6 +282,43 @@ LiveBucketSnapshot::scanForEviction( return Loop::INCOMPLETE; } +// Scans contract code entries in the bucket. +Loop +LiveBucketSnapshot::scanForContractCode( + std::function callback) const +{ + ZoneScoped; + if (isEmpty()) + { + return Loop::INCOMPLETE; + } + + auto range = mBucket->getContractCodeRange(); + if (!range) + { + return Loop::INCOMPLETE; + } + + auto& stream = getStream(); + stream.seek(range->first); + + BucketEntry be; + while (stream.pos() < range->second && stream.readOne(be)) + { + + if (((be.type() == LIVEENTRY || be.type() == INITENTRY) && + be.liveEntry().data.type() == CONTRACT_CODE) || + (be.type() == DEADENTRY && be.deadEntry().type() == CONTRACT_CODE)) + { + if (callback(be) == Loop::COMPLETE) + { + return Loop::COMPLETE; + } + } + } + return Loop::INCOMPLETE; +} + template XDRInputFileStream& BucketSnapshotBase::getStream() const diff --git a/src/bucket/BucketSnapshot.h b/src/bucket/BucketSnapshot.h index 0fdd6034e6..6e283552cf 100644 --- a/src/bucket/BucketSnapshot.h +++ b/src/bucket/BucketSnapshot.h @@ -87,6 +87,10 @@ class LiveBucketSnapshot : public BucketSnapshotBase std::list& evictableKeys, SearchableLiveBucketListSnapshot const& bl, uint32_t ledgerVers) const; + + // Scans contract code entries in the bucket. + Loop + scanForContractCode(std::function callback) const; }; class HotArchiveBucketSnapshot : public BucketSnapshotBase diff --git a/src/bucket/SearchableBucketList.cpp b/src/bucket/SearchableBucketList.cpp index 47fac8e742..cd16d149e1 100644 --- a/src/bucket/SearchableBucketList.cpp +++ b/src/bucket/SearchableBucketList.cpp @@ -63,6 +63,18 @@ SearchableLiveBucketListSnapshot::scanForEviction( return result; } +void +SearchableLiveBucketListSnapshot::scanForContractCode( + std::function callback) const +{ + ZoneScoped; + releaseAssert(mSnapshot); + auto f = [&callback](auto const& b) { + return b.scanForContractCode(callback); + }; + loopAllBuckets(f, *mSnapshot); +} + template std::optional> SearchableBucketListSnapshotBase::loadKeysInternal( diff --git a/src/bucket/SearchableBucketList.h b/src/bucket/SearchableBucketList.h index 41a737d630..ff7570e1d4 100644 --- a/src/bucket/SearchableBucketList.h +++ b/src/bucket/SearchableBucketList.h @@ -35,6 +35,9 @@ class SearchableLiveBucketListSnapshot std::shared_ptr stats, StateArchivalSettings const& sas, uint32_t ledgerVers) const; + void + scanForContractCode(std::function callback) const; + friend SearchableSnapshotConstPtr BucketSnapshotManager::copySearchableLiveBucketListSnapshot() const; }; diff --git a/src/crypto/ByteSlice.h b/src/crypto/ByteSlice.h index a03f547ef6..65b347670c 100644 --- a/src/crypto/ByteSlice.h +++ b/src/crypto/ByteSlice.h @@ -4,6 +4,8 @@ // under the Apache License, Version 2.0. See the COPYING file at the root // of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 +#include "rust/RustBridge.h" +#include #include #include #include @@ -71,6 +73,13 @@ class ByteSlice : mData(bytes.data()), mSize(bytes.size()) { } + ByteSlice(::rust::Vec const& bytes) + : mData(bytes.data()), mSize(bytes.size()) + { + } + ByteSlice(RustBuf const& bytes) : ByteSlice(bytes.data) + { + } ByteSlice(char const* str) : ByteSlice((void const*)str, strlen(str)) { } diff --git a/src/ledger/LedgerManager.h b/src/ledger/LedgerManager.h index e10f11ab3d..7499a78694 100644 --- a/src/ledger/LedgerManager.h +++ b/src/ledger/LedgerManager.h @@ -7,6 +7,7 @@ #include "catchup/LedgerApplyManager.h" #include "history/HistoryManager.h" #include "ledger/NetworkConfig.h" +#include "rust/RustBridge.h" #include namespace stellar @@ -200,6 +201,12 @@ class LedgerManager virtual void manuallyAdvanceLedgerHeader(LedgerHeader const& header) = 0; virtual SorobanMetrics& getSorobanMetrics() = 0; + virtual ::rust::Box getModuleCache() = 0; + + // Compiles all contracts in the current ledger, for ledger protocols + // starting at minLedgerVersion and running through to + // Config::CURRENT_LEDGER_PROTOCOL_VERSION (to enable upgrades). + virtual void compileAllContractsInLedger(uint32_t minLedgerVersion) = 0; virtual ~LedgerManager() { diff --git a/src/ledger/LedgerManagerImpl.cpp b/src/ledger/LedgerManagerImpl.cpp index affd40e066..c71d9fcbf7 100644 --- a/src/ledger/LedgerManagerImpl.cpp +++ b/src/ledger/LedgerManagerImpl.cpp @@ -20,12 +20,15 @@ #include "history/HistoryManager.h" #include "ledger/FlushAndRotateMetaDebugWork.h" #include "ledger/LedgerHeaderUtils.h" +#include "ledger/LedgerManager.h" #include "ledger/LedgerTxn.h" #include "ledger/LedgerTxnEntry.h" #include "ledger/LedgerTxnHeader.h" +#include "ledger/SharedModuleCacheCompiler.h" #include "main/Application.h" #include "main/Config.h" #include "main/ErrorMessages.h" +#include "rust/RustBridge.h" #include "transactions/MutableTransactionResult.h" #include "transactions/OperationFrame.h" #include "transactions/TransactionFrameBase.h" @@ -41,8 +44,10 @@ #include "util/XDRCereal.h" #include "util/XDRStream.h" #include "work/WorkScheduler.h" +#include "xdr/Stellar-ledger-entries.h" #include "xdrpp/printer.h" +#include #include #include "xdr/Stellar-ledger-entries.h" @@ -58,6 +63,8 @@ #include #include +#include +#include #include #include #include @@ -125,6 +132,27 @@ LedgerManager::ledgerAbbrev(LedgerHeaderHistoryEntry const& he) return ledgerAbbrev(he.header, he.hash); } +static std::vector +getModuleCacheProtocols() +{ + std::vector ledgerVersions; + for (uint32_t i = (uint32_t)REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION; + i <= Config::CURRENT_LEDGER_PROTOCOL_VERSION; i++) + { + ledgerVersions.push_back(i); + } + auto extra = getenv("SOROBAN_TEST_EXTRA_PROTOCOL"); + if (extra) + { + uint32_t proto = static_cast(atoi(extra)); + if (proto > 0) + { + ledgerVersions.push_back(proto); + } + } + return ledgerVersions; +} + LedgerManagerImpl::LedgerManagerImpl(Application& app) : mApp(app) , mSorobanMetrics(app.getMetrics()) @@ -157,6 +185,8 @@ LedgerManagerImpl::LedgerManagerImpl(Application& app) , mCatchupDuration( app.getMetrics().NewTimer({"ledger", "catchup", "duration"})) , mState(LM_BOOTING_STATE) + , mModuleCache(::rust_bridge::new_module_cache()) + , mModuleCacheProtocols(getModuleCacheProtocols()) { setupLedgerCloseMetaStream(); @@ -405,6 +435,9 @@ LedgerManagerImpl::loadLastKnownLedger(bool restoreBucketlist) updateNetworkConfig(ltx); mSorobanNetworkConfigReadOnly = mSorobanNetworkConfigForApply; } + + // Prime module cache with ledger content. + compileAllContractsInLedger(latestLedgerHeader->ledgerVersion); } Database& @@ -568,6 +601,118 @@ LedgerManagerImpl::getSorobanMetrics() return mSorobanMetrics; } +::rust::Box +LedgerManagerImpl::getModuleCache() +{ + std::lock_guard guard(mLedgerStateMutex); + finishAnyPendingCompilation(); + return mModuleCache->shallow_clone(); +} + +void +LedgerManagerImpl::finishAnyPendingCompilation() +{ + std::lock_guard guard(mLedgerStateMutex); + if (mCompiler) + { + auto newCache = mCompiler->wait(); + mSorobanMetrics.mModuleCacheRebuildBytes.set_count( + (int64)mCompiler->getBytesCompiled()); + mSorobanMetrics.mModuleCacheNumEntries.set_count( + (int64)mCompiler->getContractsCompiled()); + mSorobanMetrics.mModuleCacheRebuildTime.Update( + mCompiler->getCompileTime()); + mModuleCache.swap(newCache); + mCompiler.reset(); + mApp.getAppConnector().setModuleCache(mModuleCache->shallow_clone()); + } +} + +void +LedgerManagerImpl::compileAllContractsInLedger(uint32_t minLedgerVersion) +{ + startCompilingAllContracts(minLedgerVersion); + finishAnyPendingCompilation(); +} + +void +LedgerManagerImpl::startCompilingAllContracts(uint32_t minLedgerVersion) +{ + std::lock_guard guard(mLedgerStateMutex); + // Always stop a previous compilation before starting a new one. Can only + // have one running at any time. + finishAnyPendingCompilation(); + std::vector versions; + for (auto const& v : mModuleCacheProtocols) + { + if (v >= minLedgerVersion) + { + versions.push_back(v); + } + } + mCompiler = std::make_unique(mApp, versions); + mCompiler->start(); +} + +void +LedgerManagerImpl::maybeRebuildModuleCache(uint32_t minLedgerVersion) +{ + std::lock_guard guard(mLedgerStateMutex); + // There is (currently) a grow-only arena underlying the module cache, so as + // entries are uploaded and evicted that arena will still grow. To cap this + // growth, we periodically rebuild the module cache from scratch. + // + // We could pick various size caps, but we want to avoid rebuilding + // spuriously when there just happens to be "a fairly large" cache due to + // having a fairly large live BL. I.e. we want to allow it to get as big as + // we can -- or as big as the "natural" BL-limits-dictated size -- while + // still rebuilding fairly often in DoS-attempt scenarios or just generally + // if there's regular upload/expiry churn that would otherwise cause + // unbounded growth. + // + // Unfortunately we do not know exactly how much memory is used by each byte + // of contract we compile, and the size estimates from the cost model have + // to assume a worst case which is almost a factor of _40_ larger than the + // byte-size of the contracts. So for example if we assume 100MB of + // contracts, the cost model says we ought to budget for 4GB of memory, just + // in case _all 100MB of contracts_ are "the worst case contract" that's + // just a continuous stream of function definitions. + // + // So: we take this multiplier, times the size of the contracts we _last_ + // drew from the BL when doing a full recompile, times two, as a cap on the + // _current_ (post-rebuild, currently-growing) cache's budget-tracked + // memory. This should avoid rebuilding spuriously, while still treating + // events that double the size of the contract-set in the live BL as an + // event that warrants a rebuild. + + // We try to fish the current cost multiplier out of the soroban network + // config's memory cost model, but fall back to a conservative default in + // case there is no mem cost param for VmInstantiation (This should never + // happen but just in case). + uint64_t linearTerm = 5000; + + // linearTerm is in 1/128ths in the cost model, to reduce rounding error. + uint64_t scale = 128; + + auto const& cfg = getSorobanNetworkConfigForApply(); + auto const& memParams = cfg.memCostParams(); + if (memParams.size() > (size_t)stellar::VmInstantiation) + { + auto const& param = memParams[(size_t)stellar::VmInstantiation]; + linearTerm = param.linearTerm; + } + auto lastBytesCompiled = mSorobanMetrics.mModuleCacheRebuildBytes.count(); + uint64_t limit = 2 * lastBytesCompiled * linearTerm / scale; + if (mModuleCache->get_mem_bytes_consumed() > limit) + { + CLOG_DEBUG(Ledger, + "Rebuilding module cache: worst-case estimate {} " + "model-bytes consumed of {} limit", + mModuleCache->get_mem_bytes_consumed(), limit); + startCompilingAllContracts(minLedgerVersion); + } +} + void LedgerManagerImpl::publishSorobanMetrics() { @@ -811,6 +956,9 @@ LedgerManagerImpl::closeLedger(LedgerCloseData const& ledgerData, return; } + // Complete any pending wasm-module-compilation before closing the ledger. + finishAnyPendingCompilation(); + #ifdef BUILD_TESTS mLastLedgerTxMeta.clear(); #endif @@ -1157,11 +1305,14 @@ LedgerManagerImpl::setLastClosedLedger( advanceLedgerPointers(advanceLedgerStateSnapshot(lastClosed.header, has)); LedgerTxn ltx2(mApp.getLedgerTxnRoot()); - if (protocolVersionStartsFrom(ltx2.loadHeader().current().ledgerVersion, - SOROBAN_PROTOCOL_VERSION)) + auto lv = ltx2.loadHeader().current().ledgerVersion; + if (protocolVersionStartsFrom(lv, SOROBAN_PROTOCOL_VERSION)) { - mApp.getLedgerManager().updateNetworkConfig(ltx2); + updateNetworkConfig(ltx2); } + // This should not be additionally conditionalized on lv >= anything, + // since we want to support SOROBAN_TEST_EXTRA_PROTOCOL > lv. + compileAllContractsInLedger(lv); } void @@ -1812,6 +1963,8 @@ LedgerManagerImpl::transferLedgerEntriesToBucketList( ledgerCloseMeta->populateEvictedEntries(evictedState); } + evictFromModuleCache(lh.ledgerVersion, evictedState); + ltxEvictions.commit(); } @@ -1822,6 +1975,8 @@ LedgerManagerImpl::transferLedgerEntriesToBucketList( ltx.getAllEntries(initEntries, liveEntries, deadEntries); if (blEnabled) { + addAnyContractsToModuleCache(lh.ledgerVersion, initEntries); + addAnyContractsToModuleCache(lh.ledgerVersion, liveEntries); mApp.getBucketManager().addLiveBatch(mApp, lh, initEntries, liveEntries, deadEntries); } @@ -1880,6 +2035,75 @@ LedgerManagerImpl::ledgerClosed( res = advanceLedgerStateSnapshot(lh, has); }); + if (protocolVersionStartsFrom( + initialLedgerVers, REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) + { + maybeRebuildModuleCache(initialLedgerVers); + } + return res; } + +void +LedgerManagerImpl::evictFromModuleCache(uint32_t ledgerVersion, + EvictedStateVectors const& evictedState) +{ + std::vector keys; + for (auto const& key : evictedState.deletedKeys) + { + if (key.type() == CONTRACT_CODE) + { + keys.emplace_back(key.contractCode().hash); + } + } + for (auto const& entry : evictedState.archivedEntries) + { + if (entry.data.type() == CONTRACT_CODE) + { + Hash const& hash = entry.data.contractCode().hash; + keys.emplace_back(hash); + } + } + if (keys.size() > 0) + { + CLOG_DEBUG(Ledger, "evicting {} modules from module cache", + keys.size()); + for (auto const& hash : keys) + { + CLOG_DEBUG(Ledger, "evicting {} from module cache", binToHex(hash)); + ::rust::Slice slice{hash.data(), hash.size()}; + mModuleCache->evict_contract_code(slice); + mSorobanMetrics.mModuleCacheNumEntries.dec(); + } + } +} + +void +LedgerManagerImpl::addAnyContractsToModuleCache( + uint32_t ledgerVersion, std::vector const& le) +{ + for (auto const& e : le) + { + if (e.data.type() == CONTRACT_CODE) + { + for (auto const& v : mModuleCacheProtocols) + { + if (v >= ledgerVersion) + { + auto const& wasm = e.data.contractCode().code; + CLOG_DEBUG(Ledger, + "compiling wasm {} for protocol {} module cache", + binToHex(sha256(wasm)), v); + auto slice = + rust::Slice(wasm.data(), wasm.size()); + mSorobanMetrics.mModuleCacheNumEntries.inc(); + auto timer = + mSorobanMetrics.mModuleCompilationTime.TimeScope(); + mModuleCache->compile(v, slice); + } + } + } + } +} + } diff --git a/src/ledger/LedgerManagerImpl.h b/src/ledger/LedgerManagerImpl.h index 3538021a04..401e55f9f2 100644 --- a/src/ledger/LedgerManagerImpl.h +++ b/src/ledger/LedgerManagerImpl.h @@ -9,8 +9,10 @@ #include "ledger/LedgerCloseMetaFrame.h" #include "ledger/LedgerManager.h" #include "ledger/NetworkConfig.h" +#include "ledger/SharedModuleCacheCompiler.h" #include "ledger/SorobanMetrics.h" #include "main/PersistentState.h" +#include "rust/RustBridge.h" #include "transactions/TransactionFrame.h" #include "util/XDRStream.h" #include "xdr/Stellar-ledger.h" @@ -152,6 +154,15 @@ class LedgerManagerImpl : public LedgerManager // Update cached ledger state values managed by this class. void advanceLedgerPointers(CloseLedgerOutput const& output); + // The current reusable / inter-ledger soroban module cache. + ::rust::Box mModuleCache; + // Manager object that (re)builds the module cache in background threads. + // Only non-nullptr when there's a background compilation in progress. + std::unique_ptr mCompiler; + + // Protocol versions to compile each contract for in the module cache. + std::vector mModuleCacheProtocols; + protected: // initialLedgerVers must be the ledger version at the start of the ledger // and currLedgerVers is the ledger version in the current ltx header. These @@ -175,6 +186,11 @@ class LedgerManagerImpl : public LedgerManager void logTxApplyMetrics(AbstractLedgerTxn& ltx, size_t numTxs, size_t numOps); + void evictFromModuleCache(uint32_t ledgerVersion, + EvictedStateVectors const& evictedState); + void addAnyContractsToModuleCache(uint32_t ledgerVersion, + std::vector const& le); + public: LedgerManagerImpl(Application& app); @@ -251,5 +267,10 @@ class LedgerManagerImpl : public LedgerManager { return mCurrentlyApplyingLedger; } + void startCompilingAllContracts(uint32_t minLedgerVersion); + void finishAnyPendingCompilation(); + void maybeRebuildModuleCache(uint32_t minLedgerVersion); + ::rust::Box getModuleCache() override; + void compileAllContractsInLedger(uint32_t minLedgerVersion) override; }; } diff --git a/src/ledger/SharedModuleCacheCompiler.cpp b/src/ledger/SharedModuleCacheCompiler.cpp new file mode 100644 index 0000000000..1ab898677b --- /dev/null +++ b/src/ledger/SharedModuleCacheCompiler.cpp @@ -0,0 +1,221 @@ +// Copyright 2025 Stellar Development Foundation and contributors. Licensed +// under the Apache License, Version 2.0. See the COPYING file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +#include "ledger/SharedModuleCacheCompiler.h" +#include "bucket/SearchableBucketList.h" +#include "crypto/Hex.h" +#include "crypto/SHA.h" +#include "main/Application.h" +#include "rust/RustBridge.h" +#include "util/Logging.h" +#include "xdr/Stellar-ledger-entries.h" +#include +#include + +namespace stellar +{ + +size_t const SharedModuleCacheCompiler::BUFFERED_WASM_CAPACITY = + 100 * 1024 * 1024; + +SharedModuleCacheCompiler::SharedModuleCacheCompiler( + Application& app, std::vector const& ledgerVersions) + : mModuleCache(rust_bridge::new_module_cache()) + , mSnap(app.getBucketManager() + .getBucketSnapshotManager() + .copySearchableLiveBucketListSnapshot()) + + , mNumThreads(app.getConfig().COMPILATION_THREADS) + , mLedgerVersions(ledgerVersions) + , mStarted(std::chrono::steady_clock::now()) +{ +} + +SharedModuleCacheCompiler::~SharedModuleCacheCompiler() +{ + for (auto& t : mThreads) + { + t.join(); + } +} + +void +SharedModuleCacheCompiler::pushWasm(xdr::xvector const& vec) +{ + std::unique_lock lock(mMutex); + mHaveSpace.wait(lock, [&] { + return mBytesLoaded - mBytesCompiled < BUFFERED_WASM_CAPACITY; + }); + xdr::xvector buf(vec); + auto size = buf.size(); + mWasms.emplace_back(std::move(buf)); + mBytesLoaded += size; + lock.unlock(); + mHaveContracts.notify_all(); + LOG_DEBUG(DEFAULT_LOG, "Loaded contract with {} bytes of wasm code", size); +} + +bool +SharedModuleCacheCompiler::isFinishedCompiling( + std::unique_lock& lock) +{ + releaseAssert(lock.owns_lock()); + return mLoadedAll && mBytesCompiled == mBytesLoaded; +} + +void +SharedModuleCacheCompiler::setFinishedLoading(size_t nContracts) +{ + std::unique_lock lock(mMutex); + mLoadedAll = true; + mContractsCompiled = nContracts; + lock.unlock(); + mHaveContracts.notify_all(); +} + +bool +SharedModuleCacheCompiler::popAndCompileWasm(size_t thread, + std::unique_lock& lock) +{ + + releaseAssert(lock.owns_lock()); + + // Wait for a new contract to compile (or being done). + mHaveContracts.wait( + lock, [&] { return !mWasms.empty() || isFinishedCompiling(lock); }); + + // Check to see if we were woken up due to end-of-compilation. + if (isFinishedCompiling(lock)) + { + return false; + } + + xdr::xvector wasm = std::move(mWasms.front()); + mWasms.pop_front(); + lock.unlock(); + + auto start = std::chrono::steady_clock::now(); + auto slice = rust::Slice(wasm.data(), wasm.size()); + try + { + for (auto ledgerVersion : mLedgerVersions) + { + mModuleCache->compile(ledgerVersion, slice); + } + } + catch (std::exception const& e) + { + LOG_ERROR(DEFAULT_LOG, "Thread {} failed to compile wasm code: {}", + thread, e.what()); + } + auto end = std::chrono::steady_clock::now(); + auto dur_us = + std::chrono::duration_cast(end - start); + LOG_DEBUG(DEFAULT_LOG, + "Thread {} compiled {} byte wasm contract {} in {}us", thread, + wasm.size(), binToHex(sha256(wasm)), dur_us.count()); + lock.lock(); + mTotalCompileTime += dur_us; + mBytesCompiled += wasm.size(); + wasm.clear(); + mHaveSpace.notify_all(); + mHaveContracts.notify_all(); + return true; +} + +void +SharedModuleCacheCompiler::start() +{ + mStarted = std::chrono::steady_clock::now(); + + LOG_INFO(DEFAULT_LOG, + "Launching 1 loading and {} compiling background threads", + mNumThreads - 1); + + mThreads.emplace_back(std::thread([this]() { + std::set seenContracts; + this->mSnap->scanForContractCode([&](BucketEntry const& entry) { + Hash h; + switch (entry.type()) + { + case INITENTRY: + case LIVEENTRY: + h = entry.liveEntry().data.contractCode().hash; + if (seenContracts.find(h) == seenContracts.end()) + { + this->pushWasm(entry.liveEntry().data.contractCode().code); + } + break; + case DEADENTRY: + h = entry.deadEntry().contractCode().hash; + break; + default: + break; + } + seenContracts.insert(h); + return Loop::INCOMPLETE; + }); + this->setFinishedLoading(seenContracts.size()); + })); + + for (auto thread = 1; thread < this->mNumThreads; ++thread) + { + mThreads.emplace_back(std::thread([this, thread]() { + size_t nContractsCompiled = 0; + std::unique_lock lock(this->mMutex); + while (!this->isFinishedCompiling(lock)) + { + if (this->popAndCompileWasm(thread, lock)) + { + ++nContractsCompiled; + } + } + LOG_DEBUG(DEFAULT_LOG, "Thread {} compiled {} contracts", thread, + nContractsCompiled); + })); + } +} + +::rust::Box +SharedModuleCacheCompiler::wait() +{ + std::unique_lock lock(mMutex); + mHaveContracts.wait( + lock, [this, &lock] { return this->isFinishedCompiling(lock); }); + + auto end = std::chrono::steady_clock::now(); + LOG_INFO( + DEFAULT_LOG, + "Compiled {} contracts ({} bytes of Wasm) in {}ms real time, {}ms " + "CPU time", + mContractsCompiled, mBytesCompiled, + std::chrono::duration_cast(end - mStarted) + .count(), + std::chrono::duration_cast(mTotalCompileTime) + .count()); + return mModuleCache->shallow_clone(); +} + +size_t +SharedModuleCacheCompiler::getBytesCompiled() +{ + std::unique_lock lock(mMutex); + return mBytesCompiled; +} + +std::chrono::nanoseconds +SharedModuleCacheCompiler::getCompileTime() +{ + std::unique_lock lock(mMutex); + return mTotalCompileTime; +} + +size_t +SharedModuleCacheCompiler::getContractsCompiled() +{ + std::unique_lock lock(mMutex); + return mContractsCompiled; +} + +} diff --git a/src/ledger/SharedModuleCacheCompiler.h b/src/ledger/SharedModuleCacheCompiler.h new file mode 100644 index 0000000000..e8a6b5a0f6 --- /dev/null +++ b/src/ledger/SharedModuleCacheCompiler.h @@ -0,0 +1,68 @@ +#pragma once +// Copyright 2025 Stellar Development Foundation and contributors. Licensed +// under the Apache License, Version 2.0. See the COPYING file at the root +// of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 + +#include "bucket/BucketSnapshotManager.h" +#include "rust/RustBridge.h" +#include "util/NonCopyable.h" +#include "xdrpp/types.h" + +#include +#include +#include + +#include +#include +#include +#include + +namespace stellar +{ +class Application; + +// This class encapsulates a multithreaded strategy for loading contracts +// out of the database (on one thread) and compiling them (on N-1 others). +class SharedModuleCacheCompiler : NonMovableOrCopyable +{ + ::rust::Box mModuleCache; + stellar::SearchableSnapshotConstPtr mSnap; + std::deque> mWasms; + + size_t const mNumThreads; + std::vector mThreads; + // The loading thread will pause and wait for the compiling threads to catch + // up when it's more than BUFFERED_WASM_CAPACITY bytes ahead of them. + static size_t const BUFFERED_WASM_CAPACITY; + bool mLoadedAll{false}; + size_t mBytesLoaded{0}; + size_t mBytesCompiled{0}; + size_t mContractsCompiled{0}; + std::vector mLedgerVersions; + + std::mutex mMutex; + std::condition_variable mHaveSpace; + std::condition_variable mHaveContracts; + + std::chrono::steady_clock::time_point mStarted; + std::chrono::nanoseconds mTotalCompileTime{0}; + + void setFinishedLoading(size_t nContracts); + bool isFinishedCompiling(std::unique_lock& lock); + // This gets called in a loop on the loader/producer thread. + void pushWasm(xdr::xvector const& vec); + // This gets called in a loop on the compiler/consumer threads. It returns + // true if anything was actually compiled. + bool popAndCompileWasm(size_t thread, std::unique_lock& lock); + + public: + SharedModuleCacheCompiler(stellar::Application& app, + std::vector const& ledgerVersions); + ~SharedModuleCacheCompiler(); + void start(); + ::rust::Box wait(); + size_t getBytesCompiled(); + std::chrono::nanoseconds getCompileTime(); + size_t getContractsCompiled(); +}; +} \ No newline at end of file diff --git a/src/ledger/SorobanMetrics.cpp b/src/ledger/SorobanMetrics.cpp index 784d922551..f79a67412a 100644 --- a/src/ledger/SorobanMetrics.cpp +++ b/src/ledger/SorobanMetrics.cpp @@ -20,6 +20,11 @@ SorobanMetrics::SorobanMetrics(medida::MetricsRegistry& metrics) metrics.NewHistogram({"soroban", "ledger", "write-entry"})) , mLedgerWriteLedgerByte( metrics.NewHistogram({"soroban", "ledger", "write-ledger-byte"})) + , mLedgerHostFnCpuInsnsRatio(metrics.NewHistogram( + {"soroban", "host-fn-op", "ledger-cpu-insns-ratio"})) + , mLedgerHostFnCpuInsnsRatioExclVm(metrics.NewHistogram( + {"soroban", "host-fn-op", "ledger-cpu-insns-ratio-excl-vm"})) + /* tx-wide metrics */ , mTxSizeByte(metrics.NewHistogram({"soroban", "tx", "size-byte"})) /* InvokeHostFunctionOp metrics */ @@ -62,6 +67,8 @@ SorobanMetrics::SorobanMetrics(medida::MetricsRegistry& metrics) , mHostFnOpInvokeTimeFsecsCpuInsnRatioExclVm( metrics.NewHistogram({"soroban", "host-fn-op", "invoke-time-fsecs-cpu-insn-ratio-excl-vm"})) + , mHostFnOpDeclaredInsnsUsageRatio(metrics.NewHistogram( + {"soroban", "host-fn-op", "declared-cpu-insns-usage-ratio"})) , mHostFnOpMaxRwKeyByte(metrics.NewMeter( {"soroban", "host-fn-op", "max-rw-key-byte"}, "byte")) , mHostFnOpMaxRwDataByte(metrics.NewMeter( @@ -128,12 +135,17 @@ SorobanMetrics::SorobanMetrics(medida::MetricsRegistry& metrics) {"soroban", "config", "bucket-list-target-size-byte"})) , mConfigFeeWrite1KB( metrics.NewCounter({"soroban", "config", "fee-write-1kb"})) - , mLedgerHostFnCpuInsnsRatio(metrics.NewHistogram( - {"soroban", "host-fn-op", "ledger-cpu-insns-ratio"})) - , mLedgerHostFnCpuInsnsRatioExclVm(metrics.NewHistogram( - {"soroban", "host-fn-op", "ledger-cpu-insns-ratio-excl-vm"})) - , mHostFnOpDeclaredInsnsUsageRatio(metrics.NewHistogram( - {"soroban", "host-fn-op", "declared-cpu-insns-usage-ratio"})) + + /* Module cache related metrics */ + , mModuleCacheNumEntries( + metrics.NewCounter({"soroban", "module-cache", "num-entries"})) + , mModuleCompilationTime( + metrics.NewTimer({"soroban", "module-cache", "compilation-time"})) + , mModuleCacheRebuildTime( + metrics.NewTimer({"soroban", "module-cache", "rebuild-time"})) + , mModuleCacheRebuildBytes( + metrics.NewCounter({"soroban", "module-cache", "rebuild-bytes"})) + { } diff --git a/src/ledger/SorobanMetrics.h b/src/ledger/SorobanMetrics.h index 1e84227dd4..17d349ef3d 100644 --- a/src/ledger/SorobanMetrics.h +++ b/src/ledger/SorobanMetrics.h @@ -111,6 +111,12 @@ class SorobanMetrics medida::Counter& mConfigBucketListTargetSizeByte; medida::Counter& mConfigFeeWrite1KB; + // Module cache related metrics + medida::Counter& mModuleCacheNumEntries; + medida::Timer& mModuleCompilationTime; + medida::Timer& mModuleCacheRebuildTime; + medida::Counter& mModuleCacheRebuildBytes; + SorobanMetrics(medida::MetricsRegistry& metrics); void accumulateModelledCpuInsns(uint64_t insnsCount, diff --git a/src/ledger/test/LedgerTests.cpp b/src/ledger/test/LedgerTests.cpp index 495540d97d..0515cb920a 100644 --- a/src/ledger/test/LedgerTests.cpp +++ b/src/ledger/test/LedgerTests.cpp @@ -6,9 +6,15 @@ #include "ledger/LedgerManager.h" #include "ledger/LedgerTxn.h" #include "main/Application.h" +#include "test/TxTests.h" #include "test/test.h" +#include "transactions/TransactionUtils.h" +#include "transactions/test/SorobanTxTestUtils.h" +#include "util/Logging.h" +#include "util/ProtocolVersion.h" #include +#include using namespace stellar; @@ -45,3 +51,70 @@ TEST_CASE("cannot close ledger with unsupported ledger version", "[ledger]") } REQUIRE_THROWS_AS(applyEmptyLedger(), std::runtime_error); } + +bool +wasms_are_cached(Application& app, std::vector const& wasms) +{ + auto moduleCache = app.getLedgerManager().getModuleCache(); + for (auto const& wasm : wasms) + { + if (!moduleCache->contains_module( + app.getLedgerManager() + .getLastClosedLedgerHeader() + .header.ledgerVersion, + ::rust::Slice{wasm.data(), wasm.size()})) + { + return false; + } + } + return true; +} + +TEST_CASE("reusable module cache", "[ledger]") +{ + VirtualClock clock; + Config cfg = getTestConfig(0, Config::TESTDB_BUCKET_DB_PERSISTENT); + + cfg.OVERRIDE_EVICTION_PARAMS_FOR_TESTING = true; + cfg.TESTING_STARTING_EVICTION_SCAN_LEVEL = 1; + + // This test uses/tests/requires the reusable module cache. + if (!protocolVersionStartsFrom( + cfg.LEDGER_PROTOCOL_VERSION, + REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) + return; + + // First upload some wasms + std::vector testWasms = {rust_bridge::get_test_wasm_add_i32(), + rust_bridge::get_test_wasm_err(), + rust_bridge::get_test_wasm_complex()}; + + std::vector contractHashes; + uint32_t ttl{0}; + { + txtest::SorobanTest stest(cfg); + ttl = stest.getNetworkCfg().stateArchivalSettings().minPersistentTTL; + for (auto const& wasm : testWasms) + { + + stest.deployWasmContract(wasm); + contractHashes.push_back(sha256(wasm)); + } + // Check the module cache got populated by the uploads. + REQUIRE(wasms_are_cached(stest.getApp(), contractHashes)); + } + + // Restart the application and check module cache gets populated in the new + // app. + auto app = createTestApplication(clock, cfg, false, true); + REQUIRE(wasms_are_cached(*app, contractHashes)); + + // Crank the app forward a while until the wasms are evicted. + CLOG_INFO(Ledger, "advancing for {} ledgers to evict wasms", ttl); + for (int i = 0; i < ttl; ++i) + { + txtest::closeLedger(*app); + } + // Check the modules got evicted. + REQUIRE(!wasms_are_cached(*app, contractHashes)); +} \ No newline at end of file diff --git a/src/main/AppConnector.cpp b/src/main/AppConnector.cpp index a387154fa6..c7330b24be 100644 --- a/src/main/AppConnector.cpp +++ b/src/main/AppConnector.cpp @@ -9,12 +9,16 @@ #include "overlay/OverlayMetrics.h" #include "overlay/Peer.h" #include "util/Timer.h" +#include +#include namespace stellar { AppConnector::AppConnector(Application& app) - : mApp(app), mConfig(app.getConfig()) + : mApp(app) + , mConfig(app.getConfig()) + , mModuleCache(mApp.getLedgerManager().getModuleCache()) { } @@ -106,6 +110,20 @@ AppConnector::getConfig() const { return mConfig; } +void +AppConnector::setModuleCache( + rust::Box moduleCache) +{ + std::unique_lock guard(mModuleCacheMutex); + mModuleCache = std::move(moduleCache); +} + +rust::Box +AppConnector::getModuleCache() +{ + std::shared_lock guard(mModuleCacheMutex); + return mModuleCache->shallow_clone(); +} bool AppConnector::overlayShuttingDown() const diff --git a/src/main/AppConnector.h b/src/main/AppConnector.h index e01a940c10..981167708f 100644 --- a/src/main/AppConnector.h +++ b/src/main/AppConnector.h @@ -3,6 +3,8 @@ #include "bucket/BucketSnapshotManager.h" #include "main/Config.h" #include "medida/metrics_registry.h" +#include "rust/RustBridge.h" +#include namespace stellar { @@ -26,6 +28,12 @@ class AppConnector // Copy config for threads to use, and avoid warnings from thread sanitizer // about accessing mApp Config const mConfig; + // Copy of module cache handle, for threads to use. All copies of the module + // cache handle point to the same, shared, threadsafe module cache. It may + // periodically be replaced by a delete/rebuild cycle in the LedgerManager. + // This is always done under a mutex. + rust::Box mModuleCache; + std::shared_mutex mModuleCacheMutex; public: AppConnector(Application& app); @@ -50,6 +58,8 @@ class AppConnector std::string const& message); VirtualClock::time_point now() const; Config const& getConfig() const; + void setModuleCache(rust::Box moduleCache); + rust::Box getModuleCache(); bool overlayShuttingDown() const; OverlayMetrics& getOverlayMetrics(); // This method is always exclusively called from one thread diff --git a/src/main/ApplicationUtils.cpp b/src/main/ApplicationUtils.cpp index 9d3b4565c0..9770d199f7 100644 --- a/src/main/ApplicationUtils.cpp +++ b/src/main/ApplicationUtils.cpp @@ -24,6 +24,7 @@ #include "main/PersistentState.h" #include "main/StellarCoreVersion.h" #include "overlay/OverlayManager.h" +#include "rust/RustBridge.h" #include "scp/LocalNode.h" #include "util/GlobalChecks.h" #include "util/Logging.h" diff --git a/src/main/ApplicationUtils.h b/src/main/ApplicationUtils.h index ac0848bdb6..8bb11c00d0 100644 --- a/src/main/ApplicationUtils.h +++ b/src/main/ApplicationUtils.h @@ -64,4 +64,7 @@ void setAuthenticatedLedgerHashPair(Application::pointer app, std::string startHash); std::optional getStellarCoreMajorReleaseVersion(std::string const& vstr); + +int listContracts(Config const& cfg); +int compileContracts(Config const& cfg); } diff --git a/src/main/Config.cpp b/src/main/Config.cpp index e06ea891ae..98df393f3b 100644 --- a/src/main/Config.cpp +++ b/src/main/Config.cpp @@ -274,6 +274,13 @@ Config::Config() : NODE_SEED(SecretKey::random()) // // Worst case = 10 concurrent merges + 1 quorum intersection calculation. WORKER_THREADS = 11; + + // Compilation is a short process that runs at startup and is CPU limited. + // Empirically it tends to peak and start getting slower around 6 threads + // due to coordination overhead between the producer and consumer threads. + // This could probably be improved with some work but it's ok for now. + COMPILATION_THREADS = 6; + MAX_CONCURRENT_SUBPROCESSES = 16; NODE_IS_VALIDATOR = false; QUORUM_INTERSECTION_CHECKER = true; @@ -1345,6 +1352,8 @@ Config::processConfig(std::shared_ptr t) [&]() { QUERY_THREAD_POOL_SIZE = readInt(item, 1, 1000); }}, + {"COMPILATION_THREADS", + [&]() { COMPILATION_THREADS = readInt(item, 2, 1000); }}, {"QUERY_SNAPSHOT_LEDGERS", [&]() { QUERY_SNAPSHOT_LEDGERS = readInt(item, 0, 10); diff --git a/src/main/Config.h b/src/main/Config.h index 62e9c4fea0..0bd83e4257 100644 --- a/src/main/Config.h +++ b/src/main/Config.h @@ -661,6 +661,10 @@ class Config : public std::enable_shared_from_this // Number of threads to serve query commands int QUERY_THREAD_POOL_SIZE; + // Number of threads to use when compiling contracts + // at startup. + int COMPILATION_THREADS; + // Number of ledger snapshots to maintain for querying uint32_t QUERY_SNAPSHOT_LEDGERS; diff --git a/src/rust/Cargo.toml b/src/rust/Cargo.toml index 08af378b52..3dc581bfff 100644 --- a/src/rust/Cargo.toml +++ b/src/rust/Cargo.toml @@ -79,16 +79,16 @@ tracy-client = { version = "=0.17.0", features = [ # number grow gradually is also not the end of the world. # [dependencies.soroban-env-host-p22] -# version = "=22.0.0" +# version = "=22.1.3" # git = "https://github.com/stellar/rs-soroban-env" # package = "soroban-env-host" -# rev = "0497816694bef2b103494c8c61b7c8a06a72c7d3" +# rev = "ad0d6d27dab5a1711eaaf8af8783fdf203742eb3" # [dependencies.soroban-env-host-p21] -# version = "=21.2.0" +# version = "=21.2.2" # git = "https://github.com/stellar/rs-soroban-env" # package = "soroban-env-host" -# rev = "8809852dcf8489f99407a5ceac12625ee3d14693" +# rev = "7eeddd897cfb0f700f938b0c8d6f0541150d1fcb" # The test wasms and synth-wasm crate should usually be taken from the highest # supported host, since test material usually just grows over time. diff --git a/src/rust/soroban/p23 b/src/rust/soroban/p23 index 822727b37b..0b7883dbcd 160000 --- a/src/rust/soroban/p23 +++ b/src/rust/soroban/p23 @@ -1 +1 @@ -Subproject commit 822727b37b7ef2eea1fc0bafc558820dc450c67e +Subproject commit 0b7883dbcd2dc8b4870ecdf36efc0c8fcf7352fb diff --git a/src/rust/src/contract.rs b/src/rust/src/contract.rs index 358ba69f53..324043ae37 100644 --- a/src/rust/src/contract.rs +++ b/src/rust/src/contract.rs @@ -10,7 +10,7 @@ use crate::{ InvokeHostFunctionOutput, RustBuf, SorobanVersionInfo, XDRFileHash, }, }; -use log::{debug, trace, warn}; +use log::{debug, error, trace, warn}; use std::{fmt::Display, io::Cursor, panic, rc::Rc, time::Instant}; // This module (contract) is bound to _two separate locations_ in the module @@ -18,8 +18,8 @@ use std::{fmt::Display, io::Cursor, panic, rc::Rc, time::Instant}; // hi) version-specific definition of stellar_env_host. We therefore // import it from our _parent_ module rather than from the crate root. pub(crate) use super::soroban_env_host::{ - budget::Budget, - e2e_invoke::{self, extract_rent_changes, LedgerEntryChange}, + budget::{AsBudget, Budget}, + e2e_invoke::{extract_rent_changes, LedgerEntryChange}, fees::{ compute_rent_fee as host_compute_rent_fee, compute_transaction_resource_fee as host_compute_transaction_resource_fee, @@ -32,8 +32,9 @@ pub(crate) use super::soroban_env_host::{ LedgerEntryExt, Limits, ReadXdr, ScError, ScErrorCode, ScErrorType, ScSymbol, ScVal, TransactionEnvelope, TtlEntry, WriteXdr, XDR_FILES_SHA256, }, - HostError, LedgerInfo, VERSION, + HostError, LedgerInfo, Val, VERSION, }; +use super::{ErrorHandler, ModuleCache}; use std::error::Error; impl TryFrom<&CxxLedgerInfo> for LedgerInfo { @@ -336,6 +337,7 @@ pub(crate) fn invoke_host_function( ttl_entries: &Vec, base_prng_seed: &CxxBuf, rent_fee_configuration: &CxxRentFeeConfiguration, + module_cache: &crate::SorobanModuleCache, ) -> Result> { let res = panic::catch_unwind(panic::AssertUnwindSafe(|| { invoke_host_function_or_maybe_panic( @@ -350,6 +352,7 @@ pub(crate) fn invoke_host_function( ttl_entries, base_prng_seed, rent_fee_configuration, + module_cache, ) })); match res { @@ -405,6 +408,7 @@ fn invoke_host_function_or_maybe_panic( ttl_entries: &Vec, base_prng_seed: &CxxBuf, rent_fee_configuration: &CxxRentFeeConfiguration, + module_cache: &crate::SorobanModuleCache, ) -> Result> { #[cfg(feature = "tracy")] let client = tracy_client::Client::start(); @@ -432,7 +436,8 @@ fn invoke_host_function_or_maybe_panic( let (res, time_nsecs) = { let _span1 = tracy_span!("e2e_invoke::invoke_function"); let start_time = Instant::now(); - let res = e2e_invoke::invoke_host_function_with_trace_hook( + + let res = super::invoke_host_function_with_trace_hook_and_module_cache( &budget, enable_diagnostics, hf_buf, @@ -445,6 +450,7 @@ fn invoke_host_function_or_maybe_panic( base_prng_seed, &mut diagnostic_events, trace_hook, + module_cache, ); let stop_time = Instant::now(); let time_nsecs = stop_time.duration_since(start_time).as_nanos() as u64; @@ -630,3 +636,115 @@ pub(crate) fn can_parse_transaction(xdr: &CxxBuf, depth_limit: u32) -> bool { )); res.is_ok() } + +#[allow(dead_code)] +#[derive(Clone)] +struct CoreCompilationContext { + unlimited_budget: Budget, +} + +impl super::CompilationContext for CoreCompilationContext {} + +#[allow(dead_code)] +impl CoreCompilationContext { + fn new() -> Result> { + let unlimited_budget = Budget::try_from_configs( + u64::MAX, + u64::MAX, + ContractCostParams(vec![].try_into().unwrap()), + ContractCostParams(vec![].try_into().unwrap()), + )?; + Ok(CoreCompilationContext { unlimited_budget }) + } +} + +impl AsBudget for CoreCompilationContext { + fn as_budget(&self) -> &Budget { + &self.unlimited_budget + } +} + +impl ErrorHandler for CoreCompilationContext { + fn map_err(&self, res: Result) -> Result + where + super::soroban_env_host::Error: From, + E: core::fmt::Debug, + { + match res { + Ok(t) => Ok(t), + Err(e) => { + error!("compiling module: {:?}", e); + Err(HostError::from(e)) + } + } + } + + fn error(&self, error: super::soroban_env_host::Error, msg: &str, _args: &[Val]) -> HostError { + error!("compiling module: {:?}: {}", error, msg); + HostError::from(error) + } +} + +#[allow(dead_code)] +pub(crate) struct ProtocolSpecificModuleCache { + compilation_context: CoreCompilationContext, + pub(crate) module_cache: ModuleCache, +} + +#[allow(dead_code)] +impl ProtocolSpecificModuleCache { + pub(crate) fn new() -> Result> { + let compilation_context = CoreCompilationContext::new()?; + let module_cache = ModuleCache::new(&compilation_context)?; + Ok(ProtocolSpecificModuleCache { + compilation_context, + module_cache, + }) + } + + pub(crate) fn compile(&mut self, wasm: &[u8]) -> Result<(), Box> { + Ok(self.module_cache.parse_and_cache_module_simple( + &self.compilation_context, + get_max_proto(), + wasm, + )?) + } + + pub(crate) fn evict(&mut self, key: &[u8; 32]) -> Result<(), Box> { + let _ = self.module_cache.remove_module(&key.clone().into())?; + Ok(()) + } + + pub(crate) fn clear(&mut self) -> Result<(), Box> { + Ok(self.module_cache.clear()?) + } + + pub(crate) fn contains_module( + &self, + key: &[u8; 32], + ) -> Result> { + Ok(self.module_cache.contains_module(&key.clone().into())?) + } + + pub(crate) fn get_mem_bytes_consumed(&self) -> Result> { + Ok(self + .compilation_context + .unlimited_budget + .get_mem_bytes_consumed()?) + } + + // This produces a new `SorobanModuleCache` with a separate + // `CoreCompilationContext` but a clone of the underlying `ModuleCache`, which + // will (since the module cache is the reusable flavor) actually point to + // the _same_ underlying threadsafe map of `Module`s and the same associated + // `Engine` as those that `self` currently points to. + // + // This mainly exists to allow cloning a shared-ownership handle to a + // (threadsafe) ModuleCache to pass to separate C++-launched threads, to + // allow multithreaded compilation. + pub(crate) fn shallow_clone(&self) -> Result> { + let mut new = Self::new()?; + new.module_cache = self.module_cache.clone(); + Ok(new) + } +} diff --git a/src/rust/src/dep-trees/p23-expect.txt b/src/rust/src/dep-trees/p23-expect.txt index c8c3467dca..03e1d64b20 100644 --- a/src/rust/src/dep-trees/p23-expect.txt +++ b/src/rust/src/dep-trees/p23-expect.txt @@ -1,4 +1,4 @@ -soroban-env-host v22.1.3 (src/rust/soroban/p23/soroban-env-host) +soroban-env-host v23.0.0 (src/rust/soroban/p23/soroban-env-host) ├── ark-bls12-381 v0.4.0 │ ├── ark-ec v0.4.2 │ │ ├── ark-ff v0.4.2 @@ -222,17 +222,17 @@ soroban-env-host v22.1.3 (src/rust/soroban/p23/soroban-env-host) │ ├── digest v0.10.7 (*) │ └── keccak v0.1.4 │ └── cpufeatures v0.2.8 (*) -├── soroban-builtin-sdk-macros v22.1.3 (proc-macro) (src/rust/soroban/p23/soroban-builtin-sdk-macros) +├── soroban-builtin-sdk-macros v23.0.0 (proc-macro) (src/rust/soroban/p23/soroban-builtin-sdk-macros) │ ├── itertools v0.10.5 │ │ └── either v1.8.1 │ ├── proc-macro2 v1.0.69 (*) │ ├── quote v1.0.33 (*) │ └── syn v2.0.39 (*) -├── soroban-env-common v22.1.3 (src/rust/soroban/p23/soroban-env-common) +├── soroban-env-common v23.0.0 (src/rust/soroban/p23/soroban-env-common) │ ├── ethnum v1.5.0 │ ├── num-derive v0.4.1 (proc-macro) (*) │ ├── num-traits v0.2.17 (*) -│ ├── soroban-env-macros v22.1.3 (proc-macro) (src/rust/soroban/p23/soroban-env-macros) +│ ├── soroban-env-macros v23.0.0 (proc-macro) (src/rust/soroban/p23/soroban-env-macros) │ │ ├── itertools v0.10.5 (*) │ │ ├── proc-macro2 v1.0.69 (*) │ │ ├── quote v1.0.33 (*) diff --git a/src/rust/src/lib.rs b/src/rust/src/lib.rs index 1df96ef9a0..dffd47c37c 100644 --- a/src/rust/src/lib.rs +++ b/src/rust/src/lib.rs @@ -192,6 +192,7 @@ mod rust_bridge { ttl_entries: &Vec, base_prng_seed: &CxxBuf, rent_fee_configuration: CxxRentFeeConfiguration, + module_cache: &SorobanModuleCache, ) -> Result; fn init_logging(maxLevel: LogLevel) -> Result<()>; @@ -263,6 +264,20 @@ mod rust_bridge { xdr: &CxxBuf, depth_limit: u32, ) -> Result; + + type SorobanModuleCache; + + fn new_module_cache() -> Result>; + fn compile( + self: &mut SorobanModuleCache, + ledger_protocol: u32, + source: &[u8], + ) -> Result<()>; + fn shallow_clone(self: &SorobanModuleCache) -> Result>; + fn evict_contract_code(self: &mut SorobanModuleCache, key: &[u8]) -> Result<()>; + fn clear(self: &mut SorobanModuleCache) -> Result<()>; + fn contains_module(self: &SorobanModuleCache, protocol: u32, key: &[u8]) -> Result; + fn get_mem_bytes_consumed(self: &SorobanModuleCache) -> Result; } // And the extern "C++" block declares C++ stuff we're going to import to @@ -521,28 +536,127 @@ use log::partition::TX; #[path = "."] mod p23 { pub(crate) extern crate soroban_env_host_p23; + use super::SorobanModuleCache; + use soroban_env_host::{ + budget::Budget, + e2e_invoke::{self, InvokeHostFunctionResult}, + xdr::DiagnosticEvent, + HostError, LedgerInfo, TraceHook, + }; pub(crate) use soroban_env_host_p23 as soroban_env_host; pub(crate) mod contract; + // We do some more local re-exports here of things used in contract.rs that + // don't exist in older hosts (eg. the p21 & 22 hosts, where we define stubs for + // these imports). + pub(crate) use soroban_env_host::{CompilationContext, ErrorHandler, ModuleCache}; + // An adapter for some API breakage between p21 and p22. pub(crate) const fn get_version_pre_release(v: &soroban_env_host::Version) -> u32 { v.interface.pre_release } - pub(crate) const fn get_version_protocol(v: &soroban_env_host::Version) -> u32 { + pub(crate) const fn get_version_protocol(_v: &soroban_env_host::Version) -> u32 { // Temporarily hardcode the protocol version until we actually bump it // in the host library. 23 } + + pub fn invoke_host_function_with_trace_hook_and_module_cache< + T: AsRef<[u8]>, + I: ExactSizeIterator, + >( + budget: &Budget, + enable_diagnostics: bool, + encoded_host_fn: T, + encoded_resources: T, + encoded_source_account: T, + encoded_auth_entries: I, + ledger_info: LedgerInfo, + encoded_ledger_entries: I, + encoded_ttl_entries: I, + base_prng_seed: T, + diagnostic_events: &mut Vec, + trace_hook: Option, + module_cache: &SorobanModuleCache, + ) -> Result { + e2e_invoke::invoke_host_function_with_trace_hook_and_module_cache( + &budget, + enable_diagnostics, + encoded_host_fn, + encoded_resources, + encoded_source_account, + encoded_auth_entries, + ledger_info, + encoded_ledger_entries, + encoded_ttl_entries, + base_prng_seed, + diagnostic_events, + trace_hook, + Some(module_cache.p23_cache.module_cache.clone()), + ) + } } #[path = "."] mod p22 { pub(crate) extern crate soroban_env_host_p22; pub(crate) use soroban_env_host_p22 as soroban_env_host; - pub(crate) mod contract; + use super::SorobanModuleCache; + use soroban_env_host::{ + budget::{AsBudget, Budget}, + e2e_invoke::{self, InvokeHostFunctionResult}, + xdr::{DiagnosticEvent, Hash}, + Error, HostError, LedgerInfo, TraceHook, Val, + }; + + // Some stub definitions to handle API additions for the + // reusable module cache. + + #[allow(dead_code)] + const INTERNAL_ERROR: Error = Error::from_type_and_code( + soroban_env_host::xdr::ScErrorType::Context, + soroban_env_host::xdr::ScErrorCode::InternalError, + ); + + #[allow(dead_code)] + #[derive(Clone)] + pub(crate) struct ModuleCache; + #[allow(dead_code)] + pub(crate) trait ErrorHandler { + fn map_err(&self, res: Result) -> Result + where + Error: From, + E: core::fmt::Debug; + fn error(&self, error: Error, msg: &str, args: &[Val]) -> HostError; + } + #[allow(dead_code)] + impl ModuleCache { + pub(crate) fn new(_handler: T) -> Result { + Err(INTERNAL_ERROR.into()) + } + pub(crate) fn parse_and_cache_module_simple( + &self, + _handler: &T, + _protocol: u32, + _wasm: &[u8], + ) -> Result<(), HostError> { + Err(INTERNAL_ERROR.into()) + } + pub(crate) fn remove_module(&self, _key: &Hash) -> Result<(), HostError> { + Err(INTERNAL_ERROR.into()) + } + pub(crate) fn clear(&self) -> Result<(), HostError> { + Err(INTERNAL_ERROR.into()) + } + pub(crate) fn contains_module(&self, _key: &Hash) -> Result { + Err(INTERNAL_ERROR.into()) + } + } + #[allow(dead_code)] + pub(crate) trait CompilationContext: ErrorHandler + AsBudget {} // An adapter for some API breakage between p21 and p22. pub(crate) const fn get_version_pre_release(v: &soroban_env_host::Version) -> u32 { @@ -552,14 +666,100 @@ mod p22 { pub(crate) const fn get_version_protocol(v: &soroban_env_host::Version) -> u32 { v.interface.protocol } + + pub fn invoke_host_function_with_trace_hook_and_module_cache< + T: AsRef<[u8]>, + I: ExactSizeIterator, + >( + budget: &Budget, + enable_diagnostics: bool, + encoded_host_fn: T, + encoded_resources: T, + encoded_source_account: T, + encoded_auth_entries: I, + ledger_info: LedgerInfo, + encoded_ledger_entries: I, + encoded_ttl_entries: I, + base_prng_seed: T, + diagnostic_events: &mut Vec, + trace_hook: Option, + _module_cache: &SorobanModuleCache, + ) -> Result { + e2e_invoke::invoke_host_function_with_trace_hook( + &budget, + enable_diagnostics, + encoded_host_fn, + encoded_resources, + encoded_source_account, + encoded_auth_entries, + ledger_info, + encoded_ledger_entries, + encoded_ttl_entries, + base_prng_seed, + diagnostic_events, + trace_hook, + ) + } } #[path = "."] mod p21 { pub(crate) extern crate soroban_env_host_p21; pub(crate) use soroban_env_host_p21 as soroban_env_host; - pub(crate) mod contract; + use super::SorobanModuleCache; + use soroban_env_host::{ + budget::{AsBudget, Budget}, + e2e_invoke::{self, InvokeHostFunctionResult}, + xdr::{DiagnosticEvent, Hash}, + Error, HostError, LedgerInfo, TraceHook, Val, + }; + + // Some stub definitions to handle API additions for the + // reusable module cache. + + #[allow(dead_code)] + const INTERNAL_ERROR: Error = Error::from_type_and_code( + soroban_env_host::xdr::ScErrorType::Context, + soroban_env_host::xdr::ScErrorCode::InternalError, + ); + + #[allow(dead_code)] + #[derive(Clone)] + pub(crate) struct ModuleCache; + #[allow(dead_code)] + pub(crate) trait ErrorHandler { + fn map_err(&self, res: Result) -> Result + where + Error: From, + E: core::fmt::Debug; + fn error(&self, error: Error, msg: &str, args: &[Val]) -> HostError; + } + #[allow(dead_code)] + impl ModuleCache { + pub(crate) fn new(_handler: T) -> Result { + Err(INTERNAL_ERROR.into()) + } + pub(crate) fn parse_and_cache_module_simple( + &self, + _handler: &T, + _protocol: u32, + _wasm: &[u8], + ) -> Result<(), HostError> { + Err(INTERNAL_ERROR.into()) + } + pub(crate) fn remove_module(&self, _key: &Hash) -> Result<(), HostError> { + Err(INTERNAL_ERROR.into()) + } + pub(crate) fn clear(&self) -> Result<(), HostError> { + Err(INTERNAL_ERROR.into()) + } + pub(crate) fn contains_module(&self, _key: &Hash) -> Result { + Err(INTERNAL_ERROR.into()) + } + } + #[allow(dead_code)] + pub(crate) trait CompilationContext: ErrorHandler + AsBudget {} // An adapter for some API breakage between p21 and p22. pub(crate) const fn get_version_pre_release(v: &soroban_env_host::Version) -> u32 { @@ -569,6 +769,40 @@ mod p21 { pub(crate) const fn get_version_protocol(v: &soroban_env_host::Version) -> u32 { soroban_env_host::meta::get_ledger_protocol_version(v.interface) } + + pub fn invoke_host_function_with_trace_hook_and_module_cache< + T: AsRef<[u8]>, + I: ExactSizeIterator, + >( + budget: &Budget, + enable_diagnostics: bool, + encoded_host_fn: T, + encoded_resources: T, + encoded_source_account: T, + encoded_auth_entries: I, + ledger_info: LedgerInfo, + encoded_ledger_entries: I, + encoded_ttl_entries: I, + base_prng_seed: T, + diagnostic_events: &mut Vec, + trace_hook: Option, + _module_cache: &SorobanModuleCache, + ) -> Result { + e2e_invoke::invoke_host_function_with_trace_hook( + &budget, + enable_diagnostics, + encoded_host_fn, + encoded_resources, + encoded_source_account, + encoded_auth_entries, + ledger_info, + encoded_ledger_entries, + encoded_ttl_entries, + base_prng_seed, + diagnostic_events, + trace_hook, + ) + } } // We alias the latest soroban as soroban_curr to help reduce churn in code @@ -650,6 +884,7 @@ struct HostModule { ttl_entries: &Vec, base_prng_seed: &CxxBuf, rent_fee_configuration: &CxxRentFeeConfiguration, + module_cache: &SorobanModuleCache, ) -> Result>, compute_transaction_resource_fee: fn(tx_resources: CxxTransactionResources, fee_config: CxxFeeConfiguration) -> FeePair, @@ -747,6 +982,7 @@ pub(crate) fn invoke_host_function( ttl_entries: &Vec, base_prng_seed: &CxxBuf, rent_fee_configuration: CxxRentFeeConfiguration, + module_cache: &SorobanModuleCache, ) -> Result> { let hm = get_host_module_for_protocol(config_max_protocol, ledger_info.protocol_version)?; let res = (hm.invoke_host_function)( @@ -761,6 +997,7 @@ pub(crate) fn invoke_host_function( ttl_entries, base_prng_seed, &rent_fee_configuration, + module_cache, ); #[cfg(feature = "testutils")] @@ -779,6 +1016,7 @@ pub(crate) fn invoke_host_function( ttl_entries, base_prng_seed, rent_fee_configuration, + module_cache, ); res @@ -810,6 +1048,7 @@ mod test_extra_protocol { ttl_entries: &Vec, base_prng_seed: &CxxBuf, rent_fee_configuration: CxxRentFeeConfiguration, + module_cache: &SorobanModuleCache, ) { if let Ok(extra) = std::env::var("SOROBAN_TEST_EXTRA_PROTOCOL") { if let Ok(proto) = u32::from_str(&extra) { @@ -841,6 +1080,7 @@ mod test_extra_protocol { ttl_entries, base_prng_seed, &rent_fee_configuration, + module_cache, ); if mostly_the_same_host_function_output(&res1, &res2) { info!(target: TX, "{}", summarize_host_function_output(hm1, &res1)); @@ -1025,3 +1265,83 @@ pub(crate) fn compute_write_fee_per_1kb( let hm = get_host_module_for_protocol(config_max_protocol, protocol_version)?; Ok((hm.compute_write_fee_per_1kb)(bucket_list_size, fee_config)) } + +// The SorobanModuleCache needs to hold a different protocol-specific cache for +// each supported protocol version it's going to be used with. It has to hold +// all these caches _simultaneously_ because it might perform an upgrade from +// protocol N to protocol N+1 in a single transaction, and needs to be ready for +// that before it happens. +// +// Most of these caches can be empty at any given time, because we're not +// expecting core to need to replay old protocols, and/or if it does it's during +// replay and there's no problem stalling while filling a cache with new entries +// on a per-ledger basis as they are replayed. +// +// But for the current protocol version we need to have a cache ready to execute +// anything thrown at it once it's in sync, so we should prime the +// current-protocol cache as soon as we start, as well as the next-protocol +// cache (if it exists) so that we can upgrade without stalling. +struct SorobanModuleCache { + p23_cache: p23::contract::ProtocolSpecificModuleCache, +} + +impl SorobanModuleCache { + fn new() -> Result> { + Ok(Self { + p23_cache: p23::contract::ProtocolSpecificModuleCache::new()?, + }) + } + pub fn compile( + &mut self, + ledger_protocol: u32, + wasm: &[u8], + ) -> Result<(), Box> { + match ledger_protocol { + 23 => self.p23_cache.compile(wasm), + // Add other protocols here as needed. + _ => Err(Box::new(soroban_curr::contract::CoreHostError::General( + "unsupported protocol", + ))), + } + } + pub fn shallow_clone(&self) -> Result, Box> { + Ok(Box::new(Self { + p23_cache: self.p23_cache.shallow_clone()?, + })) + } + + pub fn evict_contract_code(&mut self, key: &[u8]) -> Result<(), Box> { + let hash: [u8; 32] = key + .as_ref() + .try_into() + .map_err(|_| "Invalid contract-code key length")?; + self.p23_cache.evict(&hash) + } + pub fn clear(&mut self) -> Result<(), Box> { + self.p23_cache.clear() + } + + pub fn contains_module( + &self, + protocol: u32, + key: &[u8], + ) -> Result> { + let hash: [u8; 32] = key + .as_ref() + .try_into() + .map_err(|_| "Invalid contract-code key length")?; + match protocol { + 23 => self.p23_cache.contains_module(&hash), + _ => Err(Box::new(soroban_curr::contract::CoreHostError::General( + "unsupported protocol", + ))), + } + } + pub fn get_mem_bytes_consumed(&self) -> Result> { + self.p23_cache.get_mem_bytes_consumed() + } +} + +fn new_module_cache() -> Result, Box> { + Ok(Box::new(SorobanModuleCache::new()?)) +} diff --git a/src/transactions/InvokeHostFunctionOpFrame.cpp b/src/transactions/InvokeHostFunctionOpFrame.cpp index 7de739ea74..03e23fc0f2 100644 --- a/src/transactions/InvokeHostFunctionOpFrame.cpp +++ b/src/transactions/InvokeHostFunctionOpFrame.cpp @@ -408,11 +408,11 @@ InvokeHostFunctionOpFrame::doApply( // If ttlLtxe doesn't exist, this is a new Soroban entry // Starting in protocol 23, we must check the Hot Archive for // new keys. If a new key is actually archived, fail the op. - if (isPersistentEntry(lk) && - protocolVersionStartsFrom( - ltx.getHeader().ledgerVersion, - HotArchiveBucket:: - FIRST_PROTOCOL_SUPPORTING_PERSISTENT_EVICTION)) + else if (isPersistentEntry(lk) && + protocolVersionStartsFrom( + ltx.getHeader().ledgerVersion, + HotArchiveBucket:: + FIRST_PROTOCOL_SUPPORTING_PERSISTENT_EVICTION)) { auto archiveEntry = hotArchive->load(lk); if (archiveEntry) @@ -521,7 +521,7 @@ InvokeHostFunctionOpFrame::doApply( basePrngSeedBuf.data = std::make_unique>(); basePrngSeedBuf.data->assign(sorobanBasePrngSeed.begin(), sorobanBasePrngSeed.end()); - + auto moduleCache = app.getModuleCache(); out = rust_bridge::invoke_host_function( appConfig.CURRENT_LEDGER_PROTOCOL_VERSION, appConfig.ENABLE_SOROBAN_DIAGNOSTIC_EVENTS, resources.instructions, @@ -529,7 +529,7 @@ InvokeHostFunctionOpFrame::doApply( toCxxBuf(getSourceID()), authEntryCxxBufs, getLedgerInfo(ltx, app, sorobanConfig), ledgerEntryCxxBufs, ttlEntryCxxBufs, basePrngSeedBuf, - sorobanConfig.rustBridgeRentFeeConfiguration()); + sorobanConfig.rustBridgeRentFeeConfiguration(), *moduleCache); metrics.mCpuInsn = out.cpu_insns; metrics.mMemByte = out.mem_bytes; metrics.mInvokeTimeNsecs = out.time_nsecs; diff --git a/src/transactions/test/InvokeHostFunctionTests.cpp b/src/transactions/test/InvokeHostFunctionTests.cpp index 58ed5998b8..13b706ee17 100644 --- a/src/transactions/test/InvokeHostFunctionTests.cpp +++ b/src/transactions/test/InvokeHostFunctionTests.cpp @@ -3,6 +3,7 @@ // of this distribution or at http://www.apache.org/licenses/LICENSE-2.0 #include "test/test.h" +#include "transactions/TransactionFrameBase.h" #include "util/Logging.h" #include "util/ProtocolVersion.h" #include "util/UnorderedSet.h" @@ -4740,3 +4741,278 @@ TEST_CASE("contract constructor support", "[tx][soroban]") REQUIRE(invocation.getReturnValue().u32() == 303); } } + +static TransactionFrameBasePtr +makeAddTx(TestContract const& contract, int64_t instructions, + TestAccount& source) +{ + auto fnName = "add"; + auto sc7 = makeI32(7); + auto sc16 = makeI32(16); + auto spec = SorobanInvocationSpec() + .setInstructions(instructions) + .setReadBytes(2'000) + .setInclusionFee(12345) + .setNonRefundableResourceFee(33'000) + .setRefundableResourceFee(100'000); + auto invocation = contract.prepareInvocation(fnName, {sc7, sc16}, spec); + return invocation.createTx(&source); +} + +static const int64_t INVOKE_ADD_UNCACHED_COST_PASS = 500'000; +static const int64_t INVOKE_ADD_UNCACHED_COST_FAIL = 400'000; + +static const int64_t INVOKE_ADD_CACHED_COST_PASS = 300'000; +static const int64_t INVOKE_ADD_CACHED_COST_FAIL = 200'000; + +TEST_CASE("Module cache across protocol versions", "[tx][soroban][modulecache]") +{ + + if (protocolVersionIsBefore(Config::CURRENT_LEDGER_PROTOCOL_VERSION, + REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) + { + LOG_INFO(DEFAULT_LOG, "Skipping test, compiled for protocol version " + "without reusable module cache"); + return; + } + + VirtualClock clock; + auto cfg = getTestConfig(0); + // Start in p22 + cfg.TESTING_UPGRADE_LEDGER_PROTOCOL_VERSION = + static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION) - 1; + auto app = createTestApplication(clock, cfg); + + // Deploy and invoke contract in protocol 22 + SorobanTest test(app); + auto const& addContract = + test.deployWasmContract(rust_bridge::get_test_wasm_add_i32()); + + auto invoke = [&](int64_t instructions) -> bool { + auto tx = makeAddTx(addContract, instructions, test.getRoot()); + auto res = test.invokeTx(tx); + return isSuccessResult(res); + }; + + REQUIRE(!invoke(INVOKE_ADD_UNCACHED_COST_FAIL)); + REQUIRE(invoke(INVOKE_ADD_UNCACHED_COST_PASS)); + + // The upload should have triggered a single compilation for the p23 module + // cache, which _exists_ in this version of stellar-core, and needs to be + // populated on each upload, is just not yet active. + REQUIRE(app->getLedgerManager() + .getSorobanMetrics() + .mModuleCacheNumEntries.count() == 1); + + // Upgrade to protocol 23 (with the reusable module cache) + auto upgradeTo23 = LedgerUpgrade{LEDGER_UPGRADE_VERSION}; + upgradeTo23.newLedgerVersion() = + static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION); + executeUpgrade(*app, upgradeTo23); + + // We can now run the same contract with fewer instructions + REQUIRE(!invoke(INVOKE_ADD_CACHED_COST_FAIL)); + REQUIRE(invoke(INVOKE_ADD_CACHED_COST_PASS)); +} + +TEST_CASE("Module cache miss on immediate execution", + "[tx][soroban][modulecache]") +{ + if (protocolVersionIsBefore(Config::CURRENT_LEDGER_PROTOCOL_VERSION, + REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION)) + { + LOG_INFO(DEFAULT_LOG, "Skipping test, compiled for protocol version " + "without reusable module cache"); + return; + } + + VirtualClock clock; + auto cfg = getTestConfig(0); + + auto app = createTestApplication(clock, cfg); + auto upgradeTo22 = LedgerUpgrade{LEDGER_UPGRADE_VERSION}; + upgradeTo22.newLedgerVersion() = + static_cast(REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION) - 1; + executeUpgrade(*app, upgradeTo22); + + SorobanTest test(app); + auto wasm = rust_bridge::get_test_wasm_add_i32(); + + SECTION("separate ledger upload and execution") + { + // First upload the contract + auto const& contract = test.deployWasmContract(wasm); + + // Confirm upload succeeded and triggered compilation + REQUIRE(app->getLedgerManager() + .getSorobanMetrics() + .mModuleCacheNumEntries.count() == 1); + + // Try to execute with low instructions since we can use cached module. + auto txFail = + makeAddTx(contract, INVOKE_ADD_CACHED_COST_FAIL, test.getRoot()); + REQUIRE(!isSuccessResult(test.invokeTx(txFail))); + + auto txPass = + makeAddTx(contract, INVOKE_ADD_CACHED_COST_PASS, test.getRoot()); + REQUIRE(isSuccessResult(test.invokeTx(txPass))); + } + + SECTION("same ledger upload and execution") + { + + // Here we're going to create 4 txs in the same ledger (so they have to + // come from 4 separate accounts). The 1st uploads a contract wasm, the + // 2nd creates a contract, and the 3rd and 4th run it. + // + // Because all 4 happen in the same ledger, there is no opportunity for + // the module cache to be populated between the upload and the + // execution. This should result in a cache miss and higher cost: the + // 3rd (invoking) tx fails and the 4th passes, but at the higher cost. + // + // Finally to confirm that the cache is populated, we run the same + // invocations in the next ledger and it should succeed at a lower cost. + + auto minbal = test.getApp().getLedgerManager().getLastMinBalance(1); + TestAccount A(test.getRoot().create("A", minbal * 1000)); + TestAccount B(test.getRoot().create("B", minbal * 1000)); + TestAccount C(test.getRoot().create("C", minbal * 1000)); + TestAccount D(test.getRoot().create("D", minbal * 1000)); + + // Transaction 1: the upload + auto uploadResources = defaultUploadWasmResourcesWithoutFootprint( + wasm, getLclProtocolVersion(test.getApp())); + auto uploadTx = makeSorobanWasmUploadTx(test.getApp(), A, wasm, + uploadResources, 1000); + + // Transaction 2: create contract + Hash contractHash = sha256(wasm); + ContractExecutable executable = makeWasmExecutable(contractHash); + Hash salt = sha256("salt"); + ContractIDPreimage contractPreimage = makeContractIDPreimage(B, salt); + HashIDPreimage hashPreimage = makeFullContractIdPreimage( + test.getApp().getNetworkID(), contractPreimage); + SCAddress contractId = makeContractAddress(xdrSha256(hashPreimage)); + auto createResources = SorobanResources(); + createResources.instructions = 5'000'000; + createResources.readBytes = + static_cast(wasm.data.size() + 1000); + createResources.writeBytes = 1000; + auto createContractTx = + makeSorobanCreateContractTx(test.getApp(), B, contractPreimage, + executable, createResources, 1000); + + // Transaction 3: invocation (with inadequate instructions to succeed) + TestContract contract(test, contractId, + {contractCodeKey(contractHash), + makeContractInstanceKey(contractId)}); + auto invokeFailTx = + makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_FAIL, C); + + // Transaction 4: invocation (with inadequate instructions to succeed) + auto invokePassTx = + makeAddTx(contract, INVOKE_ADD_UNCACHED_COST_PASS, C); + + // Run single ledger with all 4 txs. First 2 should pass, 3rd should + // fail, 4th should pass. + auto txResults = closeLedger( + *app, {uploadTx, createContractTx, invokeFailTx, invokePassTx}, + /*strictOrder=*/true); + + REQUIRE(txResults.results.size() == 4); + REQUIRE( + isSuccessResult(txResults.results[0].result)); // Upload succeeds + REQUIRE( + isSuccessResult(txResults.results[1].result)); // Create succeeds + REQUIRE(!isSuccessResult( + txResults.results[2].result)); // Invoke fails at 400k + REQUIRE(isSuccessResult( + txResults.results[3].result)); // Invoke passes at 500k + + // But if we try again in next ledger, the cost threshold should be + // lower. + auto invokeTxFail2 = + makeAddTx(contract, INVOKE_ADD_CACHED_COST_FAIL, C); + auto invokeTxPass2 = + makeAddTx(contract, INVOKE_ADD_CACHED_COST_PASS, D); + txResults = closeLedger(*app, {invokeTxFail2, invokeTxPass2}, + /*strictOrder=*/true); + REQUIRE(txResults.results.size() == 2); + REQUIRE(!isSuccessResult(txResults.results[0].result)); + REQUIRE(isSuccessResult(txResults.results[1].result)); + } +} + +TEST_CASE("Module cache cost with restore gaps", "[tx][soroban][modulecache]") +{ + VirtualClock clock; + auto cfg = getTestConfig(0); + + cfg.OVERRIDE_EVICTION_PARAMS_FOR_TESTING = true; + cfg.TESTING_STARTING_EVICTION_SCAN_LEVEL = 1; + + auto app = createTestApplication(clock, cfg); + auto& lm = app->getLedgerManager(); + SorobanTest test(app); + auto wasm = rust_bridge::get_test_wasm_add_i32(); + + auto minbal = lm.getLastMinBalance(1); + TestAccount A(test.getRoot().create("A", minbal * 1000)); + TestAccount B(test.getRoot().create("B", minbal * 1000)); + + auto contract = test.deployWasmContract(wasm); + auto contractKeys = contract.getKeys(); + + // Let contract expire + auto ttl = test.getNetworkCfg().stateArchivalSettings().minPersistentTTL; + auto proto = lm.getLastClosedLedgerHeader().header.ledgerVersion; + for (auto i = 0; i < ttl; ++i) + { + closeLedger(test.getApp()); + } + auto moduleCache = lm.getModuleCache(); + auto const wasmHash = sha256(wasm); + REQUIRE(!moduleCache->contains_module( + proto, ::rust::Slice{wasmHash.data(), wasmHash.size()})); + + SECTION("scenario A: restore in one ledger, invoke in next") + { + // Restore contract in ledger N+1 + test.invokeRestoreOp(contractKeys, 40096); + + // Invoke in ledger N+2 + // Because we have a gap between restore and invoke, the module cache + // will be populated and we need fewer instructions + auto tx1 = makeAddTx(contract, 200'000, A); + auto tx2 = makeAddTx(contract, 300'000, B); + auto txResults = closeLedger(*app, {tx1, tx2}, /*strictOrder=*/true); + REQUIRE(txResults.results.size() == 2); + REQUIRE(!isSuccessResult(txResults.results[0].result)); + REQUIRE(isSuccessResult(txResults.results[1].result)); + } + + SECTION("scenario B: restore and invoke in same ledger") + { + // Combine restore and invoke in ledger N+1 + // First restore + SorobanResources resources; + resources.footprint.readWrite = contractKeys; + resources.instructions = 0; + resources.readBytes = 10'000; + resources.writeBytes = 10'000; + auto resourceFee = 300'000 + 40'000 * contractKeys.size(); + auto tx1 = test.createRestoreTx(resources, 1'000, resourceFee); + + // Then try to invoke immediately + // Because there is no gap between restore and invoke, the module cache + // won't be populated and we need more instructions. + auto tx2 = makeAddTx(contract, 400'000, A); + auto tx3 = makeAddTx(contract, 500'000, B); + auto txResults = + closeLedger(*app, {tx1, tx2, tx3}, /*strictOrder=*/true); + REQUIRE(txResults.results.size() == 3); + REQUIRE(isSuccessResult(txResults.results[0].result)); + REQUIRE(!isSuccessResult(txResults.results[1].result)); + REQUIRE(isSuccessResult(txResults.results[2].result)); + } +} diff --git a/src/util/ProtocolVersion.h b/src/util/ProtocolVersion.h index 6fbeb957f8..81520c7ded 100644 --- a/src/util/ProtocolVersion.h +++ b/src/util/ProtocolVersion.h @@ -53,4 +53,6 @@ bool protocolVersionEquals(uint32_t protocolVersion, constexpr ProtocolVersion SOROBAN_PROTOCOL_VERSION = ProtocolVersion::V_20; constexpr ProtocolVersion PARALLEL_SOROBAN_PHASE_PROTOCOL_VERSION = ProtocolVersion::V_23; +constexpr ProtocolVersion REUSABLE_SOROBAN_MODULE_CACHE_PROTOCOL_VERSION = + ProtocolVersion::V_23; }