diff --git a/category/core/CMakeLists.txt b/category/core/CMakeLists.txt index cf90b7baf5..146a2649c1 100644 --- a/category/core/CMakeLists.txt +++ b/category/core/CMakeLists.txt @@ -121,6 +121,7 @@ add_library( "tl_tid.c" "tl_tid.h" "detail/start_lifetime_as_polyfill.hpp" + "lru/cache_stats.hpp" "lru/lru_cache.hpp" "lru/static_lru_cache.hpp" "mem/batch_mem_pool.hpp" diff --git a/category/core/lru/cache_stats.hpp b/category/core/lru/cache_stats.hpp new file mode 100644 index 0000000000..f7347c29a4 --- /dev/null +++ b/category/core/lru/cache_stats.hpp @@ -0,0 +1,111 @@ +// Copyright (C) 2025-26 Category Labs, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#pragma once + +#include + +#include +#include + +MONAD_NAMESPACE_BEGIN + +struct CacheStatsSnapshot +{ + uint64_t hits{0}; + uint64_t misses{0}; + uint64_t evictions{0}; // entries dropped to stay within the cache's bound +}; + +// Counters only ever increase, so a negative difference means the two +// snapshots came from different caches. Saturate rather than wrap: a window +// reading zero is a legible "no activity", where a wrap prints ~1.8e19 into +// the block metrics line and overflows the percentage back to a plausible 0%. +constexpr uint64_t +monotonic_delta(uint64_t const now, uint64_t const base) noexcept +{ + return now > base ? now - base : 0; +} + +constexpr CacheStatsSnapshot +operator-(CacheStatsSnapshot const &a, CacheStatsSnapshot const &b) noexcept +{ + return { + .hits = monotonic_delta(a.hits, b.hits), + .misses = monotonic_delta(a.misses, b.misses), + .evictions = monotonic_delta(a.evictions, b.evictions)}; +} + +// Reports cache activity over a window — a block, for the metrics log line — +// without disturbing the underlying counters, which stay cumulative. Before +// the first reset the window covers the whole history. +// +// The baseline is not atomic: reset() and since() must run on one thread, and +// both snapshots must come from the same cache. +class CacheStatsWindow +{ + CacheStatsSnapshot baseline_{}; + +public: + void reset(CacheStatsSnapshot const &now) noexcept + { + baseline_ = now; + } + + CacheStatsSnapshot since(CacheStatsSnapshot const &now) const noexcept + { + return now - baseline_; + } +}; + +// Hit/miss/eviction counts for the life of the cache object. Reads are +// non-destructive, so independent readers do not consume each other's counts. +// +// The counters are relaxed because they are statistics, not synchronization — +// they order nothing and a reader tolerates a slightly stale total. The three +// loads in snapshot() are independent, so a snapshot is not a single instant; +// each counter is monotonic, which is all the window subtraction needs. +class CacheStats +{ + std::atomic hits_{0}; + std::atomic misses_{0}; + std::atomic evictions_{0}; + +public: + void record_hit() noexcept + { + hits_.fetch_add(1, std::memory_order_relaxed); + } + + void record_miss() noexcept + { + misses_.fetch_add(1, std::memory_order_relaxed); + } + + void record_eviction() noexcept + { + evictions_.fetch_add(1, std::memory_order_relaxed); + } + + CacheStatsSnapshot snapshot() const noexcept + { + return { + .hits = hits_.load(std::memory_order_relaxed), + .misses = misses_.load(std::memory_order_relaxed), + .evictions = evictions_.load(std::memory_order_relaxed)}; + } +}; + +MONAD_NAMESPACE_END diff --git a/category/core/lru/lru_cache.hpp b/category/core/lru/lru_cache.hpp index 7022ad08e6..44bb3383f6 100644 --- a/category/core/lru/lru_cache.hpp +++ b/category/core/lru/lru_cache.hpp @@ -16,6 +16,7 @@ #pragma once #include +#include #include #include @@ -52,23 +53,7 @@ class LruCache Mutex mutex_; HashMap hmap_; Pool pool_; - -/// STATS MACROS -#ifdef MONAD_LRU_CACHE_STATS - #define STATS_EVENT_EVICT() stats_.event_evict() - #define STATS_EVENT_FIND_HIT() stats_.event_find_hit() - #define STATS_EVENT_FIND_MISS() stats_.event_find_miss() - #define STATS_EVENT_INSERT_FOUND() stats_.event_insert_found() - #define STATS_EVENT_INSERT_NEW() stats_.event_insert_new() - #define STATS_EVENT_UPDATE_LRU() stats_.event_update_lru() -#else - #define STATS_EVENT_EVICT() - #define STATS_EVENT_FIND_HIT() - #define STATS_EVENT_FIND_MISS() - #define STATS_EVENT_INSERT_FOUND() - #define STATS_EVENT_INSERT_NEW() - #define STATS_EVENT_UPDATE_LRU() -#endif + CacheStats stats_; public: using ConstAccessor = HashMap::const_accessor; @@ -92,10 +77,10 @@ class LruCache bool find(ConstAccessor &acc, Key const &key) { if (!hmap_.find(acc, key)) { - STATS_EVENT_FIND_MISS(); + stats_.record_miss(); return false; } - STATS_EVENT_FIND_HIT(); + stats_.record_hit(); ListNode *const node = acc->second.node_; try_update_lru(node); return true; @@ -106,7 +91,6 @@ class LruCache Accessor acc; HashMapKeyValue const hmkv(key, HashMapValue(value, nullptr)); if (!hmap_.insert(acc, hmkv)) { - STATS_EVENT_INSERT_FOUND(); acc->second.value_ = value; ListNode *const node = acc->second.node_; try_update_lru(node); @@ -136,7 +120,6 @@ class LruCache { if (node->check_lru_time()) { std::unique_lock const l(mutex_); - STATS_EVENT_UPDATE_LRU(); lru_.update_lru(node); } } @@ -147,7 +130,6 @@ class LruCache bool const evicted = (sz >= max_size_) && evict(); { std::unique_lock const l(mutex_); - STATS_EVENT_INSERT_NEW(); lru_.push_front(node); } if (!evicted) { @@ -171,12 +153,12 @@ class LruCache ListNode *target; { std::unique_lock const l(mutex_); - STATS_EVENT_EVICT(); target = lru_.evict(); } if (!target) { return false; } + stats_.record_eviction(); Accessor acc; bool const found = hmap_.find(acc, target->key_); MONAD_ASSERT(found); @@ -306,96 +288,11 @@ class LruCache } }; /// HashMapValue -/// STATS -#undef STATS_EVENT_EVICT -#undef STATS_EVENT_FIND_HIT -#undef STATS_EVENT_FIND_MISS -#undef STATS_EVENT_INSERT_FOUND -#undef STATS_EVENT_INSERT_NEW -#undef STATS_EVENT_UPDATE_LRU - public: - std::string print_stats() + CacheStatsSnapshot stats() const noexcept { - std::string str = - std::format("{:8}", size_.load(std::memory_order_acquire)); -#ifdef MONAD_LRU_CACHE_STATS - str += " / " + stats_.print_stats(); -#endif - return str; + return stats_.snapshot(); } - -private: -#ifdef MONAD_LRU_CACHE_STATS - /// CacheStats - struct CacheStats - { - std::atomic n_find_hit_{0}; - std::atomic n_find_miss_{0}; - std::atomic n_insert_found_{0}; - uint64_t n_insert_new_{0}; - uint64_t n_evict_{0}; - uint64_t n_update_lru_{0}; - - void event_find_hit() - { - n_find_hit_.fetch_add(1, std::memory_order_release); - } - - void event_find_miss() - { - n_find_miss_.fetch_add(1, std::memory_order_release); - } - - void event_insert_found() - { - n_insert_found_.fetch_add(1, std::memory_order_release); - } - - void event_insert_new() - { - ++n_insert_new_; - } - - void event_evict() - { - ++n_evict_; - } - - void event_update_lru() - { - ++n_update_lru_; - } - - void clear_stats() - { - // Not called concurrently with cache operations. - n_find_hit_.store(0, std::memory_order_release); - n_find_miss_.store(0, std::memory_order_release); - n_insert_found_.store(0, std::memory_order_release); - n_insert_new_ = 0; - n_evict_ = 0; - n_update_lru_ = 0; - } - - std::string print_stats() - { - std::string str = std::format( - "{:6} {:6} - {:6} {:6} - {:6} - {:6}", - n_find_hit_.load(std::memory_order_acquire), - n_find_miss_.load(std::memory_order_acquire), - n_insert_found_.load(std::memory_order_acquire), - n_insert_new_, - n_evict_, - n_update_lru_); - clear_stats(); - return str; - } - }; /// CacheStats - - CacheStats stats_; -#endif /// MONAD_LRU_CACHE_STATS - }; /// LruCache MONAD_NAMESPACE_END diff --git a/category/core/lru/lru_cache_test.cpp b/category/core/lru/lru_cache_test.cpp new file mode 100644 index 0000000000..3705b68f2e --- /dev/null +++ b/category/core/lru/lru_cache_test.cpp @@ -0,0 +1,149 @@ +// Copyright (C) 2025-26 Category Labs, Inc. +// +// This program is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// This program is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with this program. If not, see . + +#include +#include + +#include + +using Cache = monad::LruCache; + +TEST(lru_cache_test, find_counts_hit_and_miss) +{ + Cache cache{4}; + Cache::ConstAccessor acc; + + EXPECT_FALSE(cache.find(acc, 1)); + cache.insert(1, 0x111); + ASSERT_TRUE(cache.find(acc, 1)); + + auto const stats = cache.stats(); + EXPECT_EQ(stats.hits, 1u); + EXPECT_EQ(stats.misses, 1u); +} + +TEST(lru_cache_test, stats_are_not_reset_by_reading_them) +{ + Cache cache{4}; + Cache::ConstAccessor acc; + + cache.insert(1, 0x111); + ASSERT_TRUE(cache.find(acc, 1)); + + auto const first = cache.stats(); + auto const second = cache.stats(); + EXPECT_EQ(first.hits, 1u); + EXPECT_EQ(second.hits, first.hits); + EXPECT_EQ(second.misses, first.misses); + EXPECT_EQ(second.evictions, first.evictions); +} + +TEST(lru_cache_test, counts_evictions_once_capacity_is_exceeded) +{ + Cache cache{2}; + + cache.insert(1, 0x111); + cache.insert(2, 0x222); + EXPECT_EQ(cache.stats().evictions, 0u); + + cache.insert(3, 0x333); + EXPECT_EQ(cache.size(), 2u); + EXPECT_EQ(cache.stats().evictions, 1u); +} + +TEST(lru_cache_test, overwriting_an_existing_key_does_not_evict) +{ + Cache cache{2}; + + cache.insert(1, 0x111); + cache.insert(1, 0x222); + + EXPECT_EQ(cache.size(), 1u); + EXPECT_EQ(cache.stats().evictions, 0u); +} + +TEST(cache_stats_window_test, reports_activity_since_the_last_reset) +{ + Cache cache{4}; + Cache::ConstAccessor acc; + monad::CacheStatsWindow window; + + cache.insert(1, 0x111); + ASSERT_TRUE(cache.find(acc, 1)); + window.reset(cache.stats()); + + ASSERT_TRUE(cache.find(acc, 1)); + EXPECT_FALSE(cache.find(acc, 2)); + + EXPECT_EQ(cache.stats().hits, 2u); + EXPECT_EQ(cache.stats().misses, 1u); + + auto const since = window.since(cache.stats()); + EXPECT_EQ(since.hits, 1u); + EXPECT_EQ(since.misses, 1u); +} + +// A counter can only go backwards if a window is used against the wrong cache. +// Saturating keeps that bug legible as "no activity" instead of ~1.8e19. +TEST(cache_stats_window_test, a_backwards_counter_saturates_instead_of_wrapping) +{ + monad::CacheStatsSnapshot const earlier{ + .hits = 5, .misses = 5, .evictions = 5}; + monad::CacheStatsSnapshot const later{ + .hits = 10, .misses = 10, .evictions = 10}; + + auto const forward = later - earlier; + EXPECT_EQ(forward.hits, 5u); + + auto const backward = earlier - later; + EXPECT_EQ(backward.hits, 0u); + EXPECT_EQ(backward.misses, 0u); + EXPECT_EQ(backward.evictions, 0u); +} + +TEST(cache_stats_window_test, before_any_reset_the_window_is_the_whole_history) +{ + Cache cache{4}; + Cache::ConstAccessor acc; + monad::CacheStatsWindow const window; + + cache.insert(1, 0x111); + ASSERT_TRUE(cache.find(acc, 1)); + + EXPECT_EQ(window.since(cache.stats()).hits, 1u); +} + +TEST(lru_cache_test, clear_does_not_disturb_the_counters) +{ + Cache cache{4}; + + // Scoped: an accessor holds a lock on its element, and clear() destroys + // the element under it. + { + Cache::ConstAccessor acc; + EXPECT_FALSE(cache.find(acc, 1)); + } + cache.insert(1, 0x111); + { + Cache::ConstAccessor acc; + ASSERT_TRUE(cache.find(acc, 1)); + } + + cache.clear(); + + auto const stats = cache.stats(); + EXPECT_EQ(stats.hits, 1u); + EXPECT_EQ(stats.misses, 1u); +} diff --git a/category/core/lru/static_lru_cache.hpp b/category/core/lru/static_lru_cache.hpp index 0ed560f3d6..f1efe8daee 100644 --- a/category/core/lru/static_lru_cache.hpp +++ b/category/core/lru/static_lru_cache.hpp @@ -17,6 +17,7 @@ #include #include +#include #include @@ -52,6 +53,8 @@ class static_lru_cache boost::intrusive::list active_list_; boost::intrusive::list free_list_; Map map_; + // Derived caches evict on their own bound too, so they record here. + CacheStats stats_; public: using ConstAccessor = Map::const_iterator; @@ -93,6 +96,7 @@ class static_lru_cache else { // reuse the last node in active_list_ auto const list_it = std::prev(active_list_.end()); erased_value = list_it->val; + stats_.record_eviction(); map_.erase(list_it->key); node = &*list_it; active_list_.erase(list_it); @@ -111,17 +115,32 @@ class static_lru_cache { acc = map_.find(key); if (acc == map_.end()) { + stats_.record_miss(); return false; } + stats_.record_hit(); update_lru(acc->second); return true; } + // Existence check that is not a lookup: it neither counts nor updates the + // LRU order. For assertions and invariant checks, where find() would + // otherwise record a miss for a read nobody made. + bool contains(Key const &key) const noexcept + { + return map_.find(key) != map_.end(); + } + size_t size() const noexcept { return map_.size(); } + CacheStatsSnapshot stats() const noexcept + { + return stats_.snapshot(); + } + void clear() noexcept { map_.clear(); diff --git a/category/core/lru/static_lru_test.cpp b/category/core/lru/static_lru_test.cpp index 4065a005bc..474d6b6882 100644 --- a/category/core/lru/static_lru_test.cpp +++ b/category/core/lru/static_lru_test.cpp @@ -141,3 +141,53 @@ TEST(static_lru_test, clear) lru.insert(5, "world"); EXPECT_EQ(lru.size(), 1); } + +TEST(static_lru_test, counts_hits_misses_and_evictions) +{ + using LruCache = monad::static_lru_cache; + LruCache lru(2); + LruCache::ConstAccessor acc; + + EXPECT_FALSE(lru.find(acc, 1)); + lru.insert(1, 0x111); + lru.insert(2, 0x222); + ASSERT_TRUE(lru.find(acc, 1)); + EXPECT_EQ(lru.stats().evictions, 0u); + + // Capacity is 2, so this reuses the LRU tail. + lru.insert(3, 0x333); + + auto const stats = lru.stats(); + EXPECT_EQ(stats.hits, 1u); + EXPECT_EQ(stats.misses, 1u); + EXPECT_EQ(stats.evictions, 1u); +} + +// find() is used as an existence predicate in a few places that are not +// lookups; those must not move the hit rate. +TEST(static_lru_test, contains_does_not_count_as_a_lookup) +{ + using LruCache = monad::static_lru_cache; + LruCache lru(2); + + lru.insert(1, 0x111); + + EXPECT_TRUE(lru.contains(1)); + EXPECT_FALSE(lru.contains(2)); + + auto const stats = lru.stats(); + EXPECT_EQ(stats.hits, 0u); + EXPECT_EQ(stats.misses, 0u); +} + +TEST(static_lru_test, overwriting_an_existing_key_does_not_evict) +{ + using LruCache = monad::static_lru_cache; + LruCache lru(2); + + lru.insert(1, 0x111); + lru.insert(1, 0x222); + + EXPECT_EQ(lru.size(), 1); + EXPECT_EQ(lru.stats().evictions, 0u); +} diff --git a/category/execution/ethereum/db/db.hpp b/category/execution/ethereum/db/db.hpp index e5a32ccde6..7c8911b101 100644 --- a/category/execution/ethereum/db/db.hpp +++ b/category/execution/ethereum/db/db.hpp @@ -80,6 +80,12 @@ struct Db return {}; } + // Starts the window that print_stats() reports over. Call once per block, + // before execution. Pure so that an implementation reporting stats cannot + // forget it and silently report since-construction totals on a per-block + // line; implementations with no stats define it empty. + virtual void begin_block_stats() = 0; + protected: bytes32_t storage_lookup_key(bytes32_t const &key) const { diff --git a/category/execution/ethereum/db/db_cache.hpp b/category/execution/ethereum/db/db_cache.hpp index 589e8555dc..5afd827cd9 100644 --- a/category/execution/ethereum/db/db_cache.hpp +++ b/category/execution/ethereum/db/db_cache.hpp @@ -19,6 +19,7 @@ #include #include #include +#include #include #include #include @@ -46,6 +47,15 @@ enum class CacheReadStatus // -> can't prove finalized-consistent -> don't cache miss }; +// Counters for the two LRU caches. Reads answered by the proposal overlay, and +// reads that abort on a truncated chain, never reach the LRUs and count as +// neither hit nor miss. +struct DbCacheStats +{ + CacheStatsSnapshot accounts; + CacheStatsSnapshot storage; +}; + // Encoding-agnostic LRU + proposal cache for accounts and storage leaves. // Storage values are held as storage_page_t, keyed by the trie key the // caller passes: slot_key (single slot at index 0) for slot encoding, or @@ -68,6 +78,8 @@ class DbCache final AccountsCache accounts_{10'000'000}; StorageCache storage_{STORAGE_CACHE_MAX_BYTES}; Proposals proposals_; + CacheStatsWindow accounts_window_; + CacheStatsWindow storage_window_; public: DbCache() = default; @@ -187,18 +199,55 @@ class DbCache final } } - std::string accounts_stats() + // Cumulative since construction. + DbCacheStats stats() const + { + return {.accounts = accounts_.stats(), .storage = storage_.stats()}; + } + + // Since the last begin_block_stats(), for the per-block log line. + DbCacheStats block_stats() const + { + return { + .accounts = accounts_window_.since(accounts_.stats()), + .storage = storage_window_.since(storage_.stats())}; + } + + void begin_block_stats() { - return accounts_.print_stats(); + accounts_window_.reset(accounts_.stats()); + storage_window_.reset(storage_.stats()); } - std::string storage_stats() + std::string accounts_stats() const { return std::format( - "{:8} / {:10}", storage_.size(), storage_.approx_weight()); + "{:8} {}", accounts_.size(), format_stats(block_stats().accounts)); + } + + std::string storage_stats() const + { + return std::format( + "{:8} / {:10} {}", + storage_.size(), + storage_.approx_weight(), + format_stats(block_stats().storage)); } private: + static std::string format_stats(CacheStatsSnapshot const &s) + { + uint64_t const lookups = s.hits + s.misses; + return std::format( + "{:5.1f}% {:10} {:10} {:8}", + lookups == 0 ? 0.0 + : 100.0 * static_cast(s.hits) / + static_cast(lookups), + s.hits, + s.misses, + s.evictions); + } + void insert_in_lru_caches(ProposalPostState const &post_state) { for (auto const &[addr, acct] : post_state.accounts) { diff --git a/category/execution/ethereum/db/db_cache_test.cpp b/category/execution/ethereum/db/db_cache_test.cpp index 85eb828b33..a287d943b2 100644 --- a/category/execution/ethereum/db/db_cache_test.cpp +++ b/category/execution/ethereum/db/db_cache_test.cpp @@ -171,3 +171,117 @@ TEST(DbCacheTest, finalization_write_overwrites_readthrough_entry) cache.try_read_storage(ADDR, INC, KEY, 0, slot), CacheReadStatus::Hit); EXPECT_EQ(slot, VALUE2); } + +TEST(DbCacheTest, proposal_overlay_hits_are_not_counted_against_the_lru) +{ + DbCache cache; + cache.update_proposal_state( + make_post_state(ADDR, KEY, VALUE1), 1, bytes32_t{1}); + cache.set_block_and_prefix(1, bytes32_t{1}); + + std::optional account; + EXPECT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit); + + auto const stats = cache.stats(); + EXPECT_EQ(stats.accounts.hits, 0u); + EXPECT_EQ(stats.accounts.misses, 0u); +} + +namespace +{ + // Never written by any proposal, so reads of it fall through to the LRU. + constexpr auto UNWRITTEN_ADDR = + 0x00000000000000000000000000000000000000cc_address; + constexpr auto UNWRITTEN_KEY = + 0x00000000000000000000000000000000000000000000000000000000000000cc_bytes32; + + // Proposals 1 and 2 write ADDR/KEY and are finalized, promoting them into + // the LRUs; proposal 3 is the block being read from and writes neither, + // so a read of ADDR/KEY walks clean to the finalized base and the LRU + // answers. + void seed_finalized_lru(DbCache &cache) + { + cache.update_proposal_state( + make_post_state(ADDR, KEY, VALUE1), 1, bytes32_t{1}); + cache.update_proposal_state( + make_post_state(ADDR, KEY, VALUE2), 2, bytes32_t{2}); + cache.update_proposal_state( + make_post_state(OTHER_ADDR, OTHER_KEY, VALUE1), 3, bytes32_t{3}); + cache.on_finalize(1, bytes32_t{1}); + cache.on_finalize(2, bytes32_t{2}); + cache.set_block_and_prefix(3, bytes32_t{3}); + } +} + +TEST(DbCacheTest, counts_lru_account_hits_and_misses) +{ + DbCache cache; + seed_finalized_lru(cache); + + std::optional account; + EXPECT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit); + EXPECT_EQ( + cache.try_read_account(UNWRITTEN_ADDR, account), + CacheReadStatus::MissResolved); + + auto const stats = cache.stats(); + EXPECT_EQ(stats.accounts.hits, 1u); + EXPECT_EQ(stats.accounts.misses, 1u); +} + +// Both views must be available at once from the same cache. +TEST(DbCacheTest, block_stats_count_only_what_happened_since_the_snapshot) +{ + DbCache cache; + seed_finalized_lru(cache); + + std::optional account; + ASSERT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit); + + cache.begin_block_stats(); + ASSERT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit); + + EXPECT_EQ(cache.stats().accounts.hits, 2u); + EXPECT_EQ(cache.block_stats().accounts.hits, 1u); +} + +TEST(DbCacheTest, consecutive_blocks_do_not_accumulate_in_block_stats) +{ + DbCache cache; + seed_finalized_lru(cache); + + std::optional account; + bytes32_t slot; + + cache.begin_block_stats(); + ASSERT_EQ(cache.try_read_account(ADDR, account), CacheReadStatus::Hit); + ASSERT_EQ( + cache.try_read_storage(ADDR, INC, KEY, 0, slot), CacheReadStatus::Hit); + EXPECT_EQ(cache.block_stats().accounts.hits, 1u); + EXPECT_EQ(cache.block_stats().storage.hits, 1u); + + // A second block with no reads at all must report zero, not the first + // block's totals. + cache.begin_block_stats(); + EXPECT_EQ(cache.block_stats().accounts.hits, 0u); + EXPECT_EQ(cache.block_stats().storage.hits, 0u); + EXPECT_EQ(cache.stats().accounts.hits, 1u); + EXPECT_EQ(cache.stats().storage.hits, 1u); +} + +TEST(DbCacheTest, counts_lru_storage_hits_and_misses) +{ + DbCache cache; + seed_finalized_lru(cache); + + bytes32_t slot; + EXPECT_EQ( + cache.try_read_storage(ADDR, INC, KEY, 0, slot), CacheReadStatus::Hit); + EXPECT_EQ( + cache.try_read_storage(ADDR, INC, UNWRITTEN_KEY, 0, slot), + CacheReadStatus::MissResolved); + + auto const stats = cache.stats(); + EXPECT_EQ(stats.storage.hits, 1u); + EXPECT_EQ(stats.storage.misses, 1u); +} diff --git a/category/execution/ethereum/db/partial_trie_db.hpp b/category/execution/ethereum/db/partial_trie_db.hpp index 2f581db425..473b3b8540 100644 --- a/category/execution/ethereum/db/partial_trie_db.hpp +++ b/category/execution/ethereum/db/partial_trie_db.hpp @@ -107,6 +107,9 @@ class PartialTrieDb final : public Db return block_number_; } + // Reports no stats. + void begin_block_stats() override {} + void set_block_and_prefix( uint64_t const block_number, bytes32_t const &) override { diff --git a/category/execution/ethereum/db/trie_db.cpp b/category/execution/ethereum/db/trie_db.cpp index 0eba74a988..164c0438c4 100644 --- a/category/execution/ethereum/db/trie_db.cpp +++ b/category/execution/ethereum/db/trie_db.cpp @@ -404,6 +404,17 @@ BlockHeader TrieDb::read_eth_header() return std::move(decode_res.value()); } +void TrieDb::begin_block_stats() +{ + n_account_no_value_.store(0, std::memory_order_release); + n_account_value_.store(0, std::memory_order_release); + n_storage_no_value_.store(0, std::memory_order_release); + n_storage_value_.store(0, std::memory_order_release); + if (cache_) { + cache_->begin_block_stats(); + } +} + std::string TrieDb::print_stats() { std::string ret; @@ -413,10 +424,6 @@ std::string TrieDb::print_stats() n_account_value_.load(std::memory_order_acquire), n_storage_no_value_.load(std::memory_order_acquire), n_storage_value_.load(std::memory_order_acquire)); - n_account_no_value_.store(0, std::memory_order_release); - n_account_value_.store(0, std::memory_order_release); - n_storage_no_value_.store(0, std::memory_order_release); - n_storage_value_.store(0, std::memory_order_release); if (cache_) { ret += ",ac=" + cache_->accounts_stats() + ",sc=" + cache_->storage_stats(); diff --git a/category/execution/ethereum/db/trie_db.hpp b/category/execution/ethereum/db/trie_db.hpp index a9d3c67eb5..4f628dee2b 100644 --- a/category/execution/ethereum/db/trie_db.hpp +++ b/category/execution/ethereum/db/trie_db.hpp @@ -100,6 +100,7 @@ class TrieDb final : public ::monad::Db virtual bytes32_t transactions_root() override; virtual std::optional withdrawals_root() override; virtual std::string print_stats() override; + virtual void begin_block_stats() override; virtual uint64_t get_block_number() const override; nlohmann::json to_json(size_t concurrency_limit = 4096); diff --git a/category/execution/ethereum/db/trie_rodb.hpp b/category/execution/ethereum/db/trie_rodb.hpp index c4a0664102..b06b0949dd 100644 --- a/category/execution/ethereum/db/trie_rodb.hpp +++ b/category/execution/ethereum/db/trie_rodb.hpp @@ -206,6 +206,9 @@ class TrieRODb final : public ::monad::Db { return block_number_; } + + // Reports no stats. + virtual void begin_block_stats() override {} }; MONAD_NAMESPACE_END diff --git a/category/execution/runloop/runloop_ethereum.cpp b/category/execution/runloop/runloop_ethereum.cpp index 40af47e40e..d9a1a17f2e 100644 --- a/category/execution/runloop/runloop_ethereum.cpp +++ b/category/execution/runloop/runloop_ethereum.cpp @@ -99,6 +99,8 @@ Result process_ethereum_block( [[maybe_unused]] auto const block_start = std::chrono::system_clock::now(); auto const block_begin = std::chrono::steady_clock::now(); + db.begin_block_stats(); + vm.begin_block_stats(); record_block_start( exec_recorder, @@ -238,7 +240,7 @@ Result process_ethereum_block( "__exec_block,bl={:8},ts={}" ",tx={:5},rt={:4},rtp={:5.2f}%" ",sr={:>7},txe={:>8},cmt={:>8},tot={:>8},tpse={:5},tps={:5}" - ",gas={:9},gpse={:4},gps={:3}{}{}{}", + ",gas={:9},gpse={:4},gps={:3}{}{}{}{}", block.header.number, std::chrono::duration_cast( block_start.time_since_epoch()) @@ -262,7 +264,8 @@ Result process_ethereum_block( (uint64_t)std::max(1L, block_time.count()), db.print_stats(), vm.print_and_reset_block_counts(), - vm.print_compiler_stats()); + vm.print_compiler_stats(), + vm.print_varcode_cache_stats()); return outcome_e::success(); } diff --git a/category/execution/runloop/runloop_interface_monad.cpp b/category/execution/runloop/runloop_interface_monad.cpp index c79639e582..fc43548607 100644 --- a/category/execution/runloop/runloop_interface_monad.cpp +++ b/category/execution/runloop/runloop_interface_monad.cpp @@ -199,6 +199,11 @@ class MonadRunloopTrieDb : public Db return triedb_.print_stats(); } + virtual void begin_block_stats() override + { + triedb_.begin_block_stats(); + } + virtual uint64_t get_block_number() const override { return triedb_.get_block_number(); diff --git a/category/execution/runloop/runloop_monad.cpp b/category/execution/runloop/runloop_monad.cpp index 9eb7d5e344..1ad157813a 100644 --- a/category/execution/runloop/runloop_monad.cpp +++ b/category/execution/runloop/runloop_monad.cpp @@ -188,6 +188,8 @@ Result propose_block( { [[maybe_unused]] auto const block_start = std::chrono::system_clock::now(); auto const block_begin = std::chrono::steady_clock::now(); + db.begin_block_stats(); + vm.begin_block_stats(); auto const &block_hash_buffer = block_hash_chain.find_chain(consensus_header.parent_id()); @@ -392,7 +394,7 @@ Result propose_block( "__exec_block,bl={:8},id={},ts={}" ",tx={:5},rt={:4},rtp={:5.2f}%" ",sr={:>7},txe={:>8},cmt={:>8},tot={:>8},tpse={:5},tps={:5}" - ",gas={:9},gpse={:4},gps={:3}{}{}{}", + ",gas={:9},gpse={:4},gps={:3}{}{}{}{}", block.header.number, block_id, std::chrono::duration_cast( @@ -417,7 +419,8 @@ Result propose_block( (uint64_t)std::max(1L, block_time.count()), db.print_stats(), vm.print_and_reset_block_counts(), - vm.print_compiler_stats()); + vm.print_compiler_stats(), + vm.print_varcode_cache_stats()); return exec_output; } diff --git a/category/execution/runloop/runloop_monad_ethblocks.cpp b/category/execution/runloop/runloop_monad_ethblocks.cpp index 0afed6c069..3f9fc75259 100644 --- a/category/execution/runloop/runloop_monad_ethblocks.cpp +++ b/category/execution/runloop/runloop_monad_ethblocks.cpp @@ -133,6 +133,8 @@ Result process_monad_block( { [[maybe_unused]] auto const block_start = std::chrono::system_clock::now(); auto const block_begin = std::chrono::steady_clock::now(); + db.begin_block_stats(); + vm.begin_block_stats(); // This is exactly the same as the recording call in runloop_ethereum.cpp; // even though these are historical Monad block inputs, we don't have the @@ -282,7 +284,7 @@ Result process_monad_block( "__exec_block,bl={:8},ts={}" ",tx={:5},rt={:4},rtp={:5.2f}%" ",sr={:>7},txe={:>8},cmt={:>8},tot={:>8},tpse={:5},tps={:5}" - ",gas={:9},gpse={:4},gps={:3}{}{}{}", + ",gas={:9},gpse={:4},gps={:3}{}{}{}{}", block.header.number, std::chrono::duration_cast( block_start.time_since_epoch()) @@ -306,7 +308,8 @@ Result process_monad_block( (uint64_t)std::max(1L, block_time.count()), db.print_stats(), vm.print_and_reset_block_counts(), - vm.print_compiler_stats()); + vm.print_compiler_stats(), + vm.print_varcode_cache_stats()); return outcome_e::success(); } diff --git a/category/mpt/find_notify_fiber.cpp b/category/mpt/find_notify_fiber.cpp index 792449acdd..52b50e906a 100644 --- a/category/mpt/find_notify_fiber.cpp +++ b/category/mpt/find_notify_fiber.cpp @@ -138,10 +138,7 @@ namespace // to write new data. auto const virtual_offset_after = aux.physical_to_virtual(offset); if (virtual_offset_after == virtual_offset) { - { - NodeCache::ConstAccessor acc; - MONAD_ASSERT(node_cache.find(acc, virtual_offset) == false); - } + MONAD_ASSERT(!node_cache.contains(virtual_offset)); std::shared_ptr const node = detail::deserialize_node_from_receiver_result( std::move(buffer_), buffer_off, io_state); diff --git a/category/mpt/node_cache.hpp b/category/mpt/node_cache.hpp index 7a3b69eef3..8f00c26300 100644 --- a/category/mpt/node_cache.hpp +++ b/category/mpt/node_cache.hpp @@ -44,6 +44,7 @@ class NodeCache final while (used_bytes_ > max_bytes_ && !active_list_.empty()) { auto const list_it = std::prev(active_list_.end()); auto &node_to_erase = *list_it; + stats_.record_eviction(); map_.erase(list_it->key); used_bytes_ -= list_it->val.second; // move to empty list @@ -60,9 +61,19 @@ class NodeCache final using Base::ConstAccessor; using Base::list_node; - using Base::clear; + using Base::contains; using Base::find; using Base::size; + using Base::stats; + + // Bytes of cached nodes, against the max_bytes the cache was built with. + // Read alongside size() to tell which of the two bounds is binding: the + // slot count is max_bytes / AVERAGE_NODE_SIZE, so a cache whose nodes run + // larger than that average evicts on bytes with slots to spare. + size_t used_bytes() const noexcept + { + return used_bytes_; + } explicit NodeCache(size_t const max_bytes) : Base( @@ -75,21 +86,23 @@ class NodeCache final ~NodeCache() = default; - Map::iterator insert( + void insert( virtual_chunk_offset_t const &virt_offset, std::shared_ptr const &sp) noexcept { MONAD_ASSERT(virt_offset != virtual_chunk_offset_t::invalid_value()); - used_bytes_ += sp->get_mem_size(); - evict_until_under_limit(); - - auto const [it, erased_value] = - Base::insert(virt_offset, {sp, sp->get_mem_size()}); + auto const size = sp->get_mem_size(); + auto const [_, erased_value] = Base::insert(virt_offset, {sp, size}); + // Charge the net change. An overwrite replaces an entry already + // accounted for, so only the difference needs room; charging the full + // size before inserting would evict entries to make room this insert + // does not need. + used_bytes_ += size; if (erased_value.has_value()) { used_bytes_ -= erased_value->second; } - return it; + evict_until_under_limit(); } }; diff --git a/category/mpt/test/node_lru_cache_test.cpp b/category/mpt/test/node_lru_cache_test.cpp index 5ce0118c68..4bf340df51 100644 --- a/category/mpt/test/node_lru_cache_test.cpp +++ b/category/mpt/test/node_lru_cache_test.cpp @@ -102,3 +102,76 @@ TEST(NodeCache, works) ASSERT_TRUE(node_cache.find(acc, virtual_chunk_offset_t(1, 0, 0))); EXPECT_EQ(get_acc_value(), 0xdead); } + +TEST(NodeCache, counts_hits_misses_and_evictions) +{ + NodeCache node_cache(2 * NodeCache::AVERAGE_NODE_SIZE); + NodeCache::ConstAccessor acc; + + auto make_node = [] { + monad::byte_string value(84, 0); + return monad::mpt::make_node(0, {}, {}, std::move(value), 0, 0); + }; + + EXPECT_FALSE(node_cache.find(acc, virtual_chunk_offset_t(1, 0, 1))); + node_cache.insert(virtual_chunk_offset_t(1, 0, 1), make_node()); + node_cache.insert(virtual_chunk_offset_t(2, 0, 1), make_node()); + ASSERT_TRUE(node_cache.find(acc, virtual_chunk_offset_t(1, 0, 1))); + EXPECT_EQ(node_cache.stats().evictions, 0u); + + // Third node exceeds the byte budget and evicts the LRU tail. + node_cache.insert(virtual_chunk_offset_t(3, 0, 1), make_node()); + + auto const stats = node_cache.stats(); + EXPECT_EQ(stats.hits, 1u); + EXPECT_EQ(stats.misses, 1u); + EXPECT_EQ(stats.evictions, 1u); +} + +// Overwriting a key replaces an entry already counted against the budget, so +// it must not evict anything to make room it does not need. +TEST(NodeCache, overwriting_a_key_does_not_evict_to_make_room) +{ + NodeCache node_cache(2 * NodeCache::AVERAGE_NODE_SIZE); + NodeCache::ConstAccessor acc; + + auto make_node = [] { + monad::byte_string value(84, 0); + return monad::mpt::make_node(0, {}, {}, std::move(value), 0, 0); + }; + + node_cache.insert(virtual_chunk_offset_t(1, 0, 1), make_node()); + node_cache.insert(virtual_chunk_offset_t(2, 0, 1), make_node()); + auto const full = node_cache.used_bytes(); + ASSERT_EQ(node_cache.size(), 2); + ASSERT_EQ(node_cache.stats().evictions, 0u); + + // Same key, same size: the budget is unchanged, so the other entry stays. + node_cache.insert(virtual_chunk_offset_t(1, 0, 1), make_node()); + + EXPECT_EQ(node_cache.used_bytes(), full); + EXPECT_EQ(node_cache.size(), 2); + EXPECT_EQ(node_cache.stats().evictions, 0u); + EXPECT_TRUE(node_cache.find(acc, virtual_chunk_offset_t(2, 0, 1))); +} + +TEST(NodeCache, reports_used_bytes_tracking_the_byte_budget) +{ + NodeCache node_cache(4 * NodeCache::AVERAGE_NODE_SIZE); + + auto make_node = [] { + monad::byte_string value(84, 0); + return monad::mpt::make_node(0, {}, {}, std::move(value), 0, 0); + }; + + EXPECT_EQ(node_cache.used_bytes(), 0u); + + auto const first = make_node(); + auto const first_size = first->get_mem_size(); + node_cache.insert(virtual_chunk_offset_t(1, 0, 1), first); + EXPECT_EQ(node_cache.used_bytes(), first_size); + + auto const second = make_node(); + node_cache.insert(virtual_chunk_offset_t(2, 0, 1), second); + EXPECT_EQ(node_cache.used_bytes(), first_size + second->get_mem_size()); +} diff --git a/category/statesync/statesync_server_context.hpp b/category/statesync/statesync_server_context.hpp index 23feb4e728..012454b754 100644 --- a/category/statesync/statesync_server_context.hpp +++ b/category/statesync/statesync_server_context.hpp @@ -141,4 +141,7 @@ struct monad_statesync_server_context final : public monad::Db std::function) override; virtual uint64_t get_block_number() const override; + + // Reports no stats; reads here share the execution TrieDb's counters. + virtual void begin_block_stats() override {} }; diff --git a/category/vm/compiler.hpp b/category/vm/compiler.hpp index e289394fd2..62501a0e0f 100644 --- a/category/vm/compiler.hpp +++ b/category/vm/compiler.hpp @@ -216,6 +216,16 @@ namespace monad::vm varcode_cache_.size(), varcode_cache_.approx_weight()); } + CacheStatsSnapshot varcode_cache_block_stats() const noexcept + { + return varcode_cache_.block_stats(); + } + + void begin_varcode_cache_block_stats() noexcept + { + varcode_cache_.begin_block_stats(); + } + // For testing: wait for compile job queue to become empty. void debug_wait_for_empty_queue(); diff --git a/category/vm/utils/lru_weight_cache.hpp b/category/vm/utils/lru_weight_cache.hpp index 39a44f43ff..706ccab033 100644 --- a/category/vm/utils/lru_weight_cache.hpp +++ b/category/vm/utils/lru_weight_cache.hpp @@ -16,6 +16,7 @@ #pragma once #include +#include #include @@ -49,6 +50,7 @@ namespace monad::vm::utils std::atomic weight_; LruList lru_; HashMap hmap_; + CacheStats stats_; public: using ConstAccessor = HashMap::const_accessor; @@ -69,12 +71,19 @@ namespace monad::vm::utils bool find(ConstAccessor &acc, Key const &key) { if (!hmap_.find(acc, key)) { + stats_.record_miss(); return false; } + stats_.record_hit(); try_update_lru(&*acc); return true; } + CacheStatsSnapshot stats() const noexcept + { + return stats_.snapshot(); + } + /// Insert `value` with `weight` under `key`. Overwrites if there is /// already a value under `key`. bool insert(Key const &key, Value const &value, uint32_t const weight) @@ -179,6 +188,7 @@ namespace monad::vm::utils break; } int64_t const n = evict(target); + stats_.record_eviction(); weight_.fetch_sub(n, std::memory_order_acq_rel); evicted_weight += n; } diff --git a/category/vm/varcode_cache.hpp b/category/vm/varcode_cache.hpp index 6368acf216..a9fd8186d1 100644 --- a/category/vm/varcode_cache.hpp +++ b/category/vm/varcode_cache.hpp @@ -16,6 +16,7 @@ #pragma once #include +#include #include #include @@ -88,8 +89,24 @@ namespace monad::vm return weight_cache_.size(); } + CacheStatsSnapshot stats() const noexcept + { + return weight_cache_.stats(); + } + + CacheStatsSnapshot block_stats() const noexcept + { + return block_window_.since(weight_cache_.stats()); + } + + void begin_block_stats() noexcept + { + block_window_.reset(weight_cache_.stats()); + } + private: WeightCache weight_cache_; uint32_t warm_cache_kb_; + CacheStatsWindow block_window_; }; } diff --git a/category/vm/vm.hpp b/category/vm/vm.hpp index 7753443ffd..b56ca08312 100644 --- a/category/vm/vm.hpp +++ b/category/vm/vm.hpp @@ -157,6 +157,28 @@ namespace monad::vm return compiler_.find_varcode(code_hash); } + void begin_block_stats() noexcept + { + compiler_.begin_varcode_cache_block_stats(); + } + + // The block window, not the cumulative totals. Always emitted; + // print_compiler_stats() is empty unless + // utils::collect_monad_compiler_stats. + std::string print_varcode_cache_stats() const + { + auto const s = compiler_.varcode_cache_block_stats(); + uint64_t const lookups = s.hits + s.misses; + return std::format( + ",vc={:5.1f}%,vch={},vcm={},vce={}", + lookups == 0 ? 0.0 + : 100.0 * static_cast(s.hits) / + static_cast(lookups), + s.hits, + s.misses, + s.evictions); + } + SharedVarcode try_insert_varcode( bytes32_t const &code_hash, SharedIntercode const &icode) { diff --git a/rust/crates/monad-triedb/include/ffi.h b/rust/crates/monad-triedb/include/ffi.h index fe991e1a49..d81b62fa58 100644 --- a/rust/crates/monad-triedb/include/ffi.h +++ b/rust/crates/monad-triedb/include/ffi.h @@ -62,6 +62,29 @@ typedef struct triedb_storage_stats void triedb_storage_stats_read(TriedbRoInner *, triedb_storage_stats *out); +// Trie-node LRU counters for this handle. Totals since the handle was opened; +// reading does not reset them. `used_bytes` is against the node_lru_max_mem +// the handle was opened with, and `entries` against the slot count derived +// from it — compare the two to see which bound is binding. +// +// Covers triedb_async_read and the traverse calls only. triedb_read is a +// blocking path that consults no cache, so a caller using it exclusively sees +// zeros here — which is not the same as an unused cache. +// +// Returns false without writing `out` if no counters are available; all-zero +// is a legitimate reading for an idle cache, so it cannot double as an error. +typedef struct triedb_node_cache_stats +{ + uint64_t hits; + uint64_t misses; + uint64_t evictions; + uint64_t used_bytes; + uint64_t entries; +} triedb_node_cache_stats; + +bool triedb_node_cache_stats_read( + TriedbRoInner *, triedb_node_cache_stats *out); + // Compute the storage page key for a 32-byte slot key on a page-encoded db: // page_key = slot >> 7. Writes the 32-byte big-endian page key (the key the // storage trie is looked up by) to out_page_key. diff --git a/rust/crates/monad-triedb/src/ffi.cpp b/rust/crates/monad-triedb/src/ffi.cpp index c14d5407a8..410b397533 100644 --- a/rust/crates/monad-triedb/src/ffi.cpp +++ b/rust/crates/monad-triedb/src/ffi.cpp @@ -193,6 +193,23 @@ void triedb_storage_stats_read( out->disk_used_bytes = stats.disk_used_bytes; } +bool triedb_node_cache_stats_read( + TriedbRoInner *const db, triedb_node_cache_stats *const out) +{ + if (out == nullptr || db == nullptr) { + return false; + } + *out = {}; + auto const &cache = db->async_ctx.node_cache; + auto const stats = cache.stats(); + out->hits = stats.hits; + out->misses = stats.misses; + out->evictions = stats.evictions; + out->used_bytes = static_cast(cache.used_bytes()); + out->entries = static_cast(cache.size()); + return true; +} + void triedb_compute_page_key( uint8_t const *const slot_key, uint8_t *const out_page_key) { diff --git a/rust/crates/monad-triedb/src/ffi.rs b/rust/crates/monad-triedb/src/ffi.rs index c50bf275c9..18fec8ce20 100644 --- a/rust/crates/monad-triedb/src/ffi.rs +++ b/rust/crates/monad-triedb/src/ffi.rs @@ -23,9 +23,9 @@ pub(crate) use self::bindings::{ triedb_earliest_version, triedb_finalize, triedb_free_valset, triedb_is_page_encoded, triedb_latest_finalized_version, triedb_latest_proposed_block_id, triedb_latest_proposed_version, triedb_latest_verified_version, triedb_latest_voted_block_id, - triedb_latest_voted_version, triedb_migration_phase, triedb_open, triedb_poll, triedb_read, - triedb_read_valset, triedb_storage_stats, triedb_storage_stats_read, triedb_traverse, - TriedbRoInner, + triedb_latest_voted_version, triedb_migration_phase, triedb_node_cache_stats, + triedb_node_cache_stats_read, triedb_open, triedb_poll, triedb_read, triedb_read_valset, + triedb_storage_stats, triedb_storage_stats_read, triedb_traverse, TriedbRoInner, }; pub use self::bindings::{validator_data, validator_set}; diff --git a/rust/crates/monad-triedb/src/lib.rs b/rust/crates/monad-triedb/src/lib.rs index e60f3c09ee..d5adfb12b3 100644 --- a/rust/crates/monad-triedb/src/lib.rs +++ b/rust/crates/monad-triedb/src/lib.rs @@ -72,6 +72,20 @@ pub struct StorageStats { pub disk_used_bytes: u64, } +/// Trie-node LRU counters for one handle. Totals since the handle was opened; +/// reading does not reset them, so a scraper derives its own rates. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct NodeCacheStats { + pub hits: u64, + pub misses: u64, + pub evictions: u64, + /// Bytes of cached nodes, against the handle's `node_lru_max_mem`. + pub used_bytes: u64, + /// Cached nodes, against the slot count derived from `node_lru_max_mem`. + /// Compare with `used_bytes` to see which of the two bounds is binding. + pub entries: u64, +} + struct SenderContext { sender: Sender>>, completed_counter: Arc, @@ -303,6 +317,31 @@ impl TriedbHandle { } } + /// Trie-node LRU counters for this handle. Each handle owns an independent + /// cache, so these are per-handle and not comparable across handles. + /// + /// Only the async read and traverse paths consult the cache; [`Self::read`] + /// is blocking and uncached, so a caller using it exclusively sees zeros. + pub fn node_cache_stats(&self) -> Option { + let mut out = ffi::triedb_node_cache_stats { + hits: 0, + misses: 0, + evictions: 0, + used_bytes: 0, + entries: 0, + }; + if !unsafe { ffi::triedb_node_cache_stats_read(self.db_ptr, &mut out) } { + return None; + } + Some(NodeCacheStats { + hits: out.hits, + misses: out.misses, + evictions: out.evictions, + used_bytes: out.used_bytes, + entries: out.entries, + }) + } + pub fn read(&self, key: &[u8], key_len_nibbles: u8, block_id: u64) -> Option> { validate_nibble_key(key, key_len_nibbles, "Key")?; diff --git a/test/vm/unit/lru_weight_cache_tests.cpp b/test/vm/unit/lru_weight_cache_tests.cpp index 3886ecd8bb..cf5270a70c 100644 --- a/test/vm/unit/lru_weight_cache_tests.cpp +++ b/test/vm/unit/lru_weight_cache_tests.cpp @@ -318,3 +318,70 @@ TEST_F(LruWeightCacheTest, is_consistent) current_weight_ = 0; } } + +TEST(LruWeightCacheStats, counts_hits_misses_and_evictions) +{ + WeightCache cache{10}; + + // Each accessor is scoped: it holds a reader lock on its element, and + // erase() of that element blocks until every accessor on it is released. + { + WeightCache::ConstAccessor acc; + EXPECT_FALSE(cache.find(acc, 1)); + } + cache.insert(1, Value{0x111}, 4); + cache.insert(2, Value{0x222}, 4); + { + WeightCache::ConstAccessor acc; + ASSERT_TRUE(cache.find(acc, 1)); + } + EXPECT_EQ(cache.stats().evictions, 0u); + + // Total weight would be 12 against a max of 10, forcing an eviction. + cache.insert(3, Value{0x333}, 4); + + auto const stats = cache.stats(); + EXPECT_EQ(stats.hits, 1u); + EXPECT_EQ(stats.misses, 1u); + EXPECT_EQ(stats.evictions, 1u); +} + +// DbCache::on_finalize clears this cache wholesale on a truncated proposal. +// The counters must survive, or the block window would subtract a baseline +// larger than the current total. +TEST(LruWeightCacheStats, clear_does_not_disturb_the_counters) +{ + WeightCache cache{100}; + + { + WeightCache::ConstAccessor acc; + EXPECT_FALSE(cache.find(acc, 1)); + } + cache.insert(1, Value{0x111}, 4); + { + WeightCache::ConstAccessor acc; + ASSERT_TRUE(cache.find(acc, 1)); + } + + cache.clear(); + + auto const stats = cache.stats(); + EXPECT_EQ(stats.hits, 1u); + EXPECT_EQ(stats.misses, 1u); +} + +TEST(LruWeightCacheStats, stats_are_not_reset_by_reading_them) +{ + WeightCache cache{100}; + WeightCache::ConstAccessor acc; + + cache.insert(1, Value{0x111}, 4); + ASSERT_TRUE(cache.find(acc, 1)); + + auto const first = cache.stats(); + auto const second = cache.stats(); + EXPECT_EQ(first.hits, 1u); + EXPECT_EQ(second.hits, first.hits); + EXPECT_EQ(second.misses, first.misses); + EXPECT_EQ(second.evictions, first.evictions); +}