Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

LRU cache utility #1090

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/evmone/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ add_library(evmone
instructions_storage.cpp
instructions_traits.hpp
instructions_xmacro.hpp
lru_cache.hpp
tracing.cpp
tracing.hpp
vm.cpp
Expand Down
159 changes: 159 additions & 0 deletions lib/evmone/lru_cache.hpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
// evmone: Fast Ethereum Virtual Machine implementation
// Copyright 2024 The evmone Authors.
// SPDX-License-Identifier: Apache-2.0
#pragma once

#include <list>
#include <optional>
#include <unordered_map>

namespace evmone
{
/// Least Recently Used (LRU) cache.
///
/// A map of Key to Value with a fixed capacity. When the cache is full, a newly inserted entry
/// replaces (evicts) the least recently used entry.
/// All operations have O(1) complexity.
template <typename Key, typename Value>
class LRUCache

Check warning on line 18 in lib/evmone/lru_cache.hpp

View check run for this annotation

Codecov / codecov/patch

lib/evmone/lru_cache.hpp#L18

Added line #L18 was not covered by tests
{
struct LRUEntry

Check warning on line 20 in lib/evmone/lru_cache.hpp

View check run for this annotation

Codecov / codecov/patch

lib/evmone/lru_cache.hpp#L20

Added line #L20 was not covered by tests
{
/// Reference to the existing key in the map.
///
/// This is needed to get the LRU element in the map when eviction is needed.
/// Pointers to node-based map entries are always valid.
/// TODO: Optimal solution would be to use the map iterator. They are also always valid
/// because the map capacity is reserved up front and rehashing never happens.
/// However, the type definition would be recursive: Map(List(Map::iterator)), so we need
/// to use some kind of type erasure. We prototyped such implementation, but decided not
/// to include it in the first version. Similar solution is also described in
/// https://stackoverflow.com/a/54808013/725174.
const Key& key;

/// The cached value.
Value value;
};

using LRUList = std::list<LRUEntry>;
using LRUIterator = typename LRUList::iterator;
using Map = std::unordered_map<Key, LRUIterator>;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it be possible/better to store values in the map entries? Then there'd be just one link LRUEntry => MapNode.
Now it's double-direction: LRUEntry::key => MapNode::key and MapNode::value -> LRUEntry

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah I see a comment below that there's no performance difference. But it seems more convoluted to me.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, this is possible. Then the LRU list has only the pointer/reference/iterator to the map. And the map is Key => (LRUIterator, Value).

I'm kind of on your side here, but because the performance difference was negligible I decided to keep the "classic" layout for now. There is also small nuance: you cannot have std::list<const Key&> so you need to wrap the reference or use a pointer.

This is to be revisited in case we are going to use intrusive list and/or map iterators.


/// The fixed capacity of the cache.
const size_t capacity_;

/// The list to order the cache entries by the usage. The front element is the least recently
/// used entry.
///
/// In classic implementations the order in the list is reversed (the front element is the most
/// recently used entry). We decided to keep the order as is because
/// it simplifies the implementation and better fits the underlying list structure.
///
/// TODO: The intrusive list works better here but such implementation variant has been omitted
/// from the initial version.
LRUList lru_list_;

/// The map of Keys to Values via the LRU list indirection.
///
/// The Value don't have to be in the LRU list but instead can be placed in the map directly
/// next to the LRU iterator. We decided to keep this classic layout because we didn't notice
/// any performance difference.
Map map_;

/// Marks an element as the most recently used by moving it to the back of the LRU list.
void move_to_back(LRUIterator it) noexcept { lru_list_.splice(lru_list_.end(), lru_list_, it); }
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: the name kinda leaks internal representation (order of elements in the list). Maybe better something like touch(), use(), access() ?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actually I didn't notice it's a private method, then it's fine

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think I thought about it, e.g. naming it bump_usage(). In the end I decided not be because we also use lru_list_ directly in other places. E.g. we should also replace ru_list_.emplace with something like new_use()?

The main motivation was to wrap splice() which takes me usually some time to figure out.


public:
/// Constructs the LRU cache with the given capacity.
///
/// @param capacity The fixed capacity of the cache.
explicit LRUCache(size_t capacity) : capacity_(capacity)
{
// Reserve map to the full capacity to prevent any rehashing.
map_.reserve(capacity);
}

/// Clears the cache by deleting all the entries.
void clear() noexcept
{
map_.clear();
lru_list_.clear();
}


/// Retrieves the copy of the value associated with the specified key.
///
/// @param key The key of the entry to retrieve.
/// @return An optional containing the copy of the value if the key is found,
/// or an empty optional if not.
std::optional<Value> get(const Key& key) noexcept
{
if (const auto it = map_.find(key); it != map_.end())
{
move_to_back(it->second);
return it->second->value;
}
return {};
}

/// Inserts or updates the value associated with the specified key.
///
/// @param key The key of the entry to insert or update.
/// @param value The value to associate with the key.
void put(Key key, Value value)
{
// Implementation is split into two variants: cache full or not.
// Once the cache is full, its size never shrinks therefore from now on this variant is
// always executed.

if (map_.size() == capacity_)
{
// When the cache is full we avoid erase-emplace pattern by using the map's node API.

using namespace std; // for swap usage with ADL

// Get the least recently used element.
auto lru_it = lru_list_.begin();

// Extract the map node with the to-be-evicted element and reuse it for the new
// key-value pair. This makes the operation allocation-free.
auto node = map_.extract(lru_it->key); // node.key() is LRU key
swap(node.key(), key); // node.key() is insert key, key is LRU key
if (auto [it, inserted, node2] = map_.insert(std::move(node)); !inserted)
{
// Failed re-insertion means the element with the new key is already in the cache.
// Rollback the eviction by re-inserting the node with original key back.
// node2 is the same node passed to the insert() with unchanged .key().
swap(key, node2.key()); // key is existing insert key, node2.key() is LRU key
map_.insert(std::move(node2));

// Returned iterator points to the element matching the key
// which value must be updated.
lru_it = it->second;
}
lru_it->value = std::move(value); // Replace/update the value.
move_to_back(lru_it);
}
else
{
// The cache is not full. Insert the new element into the cache.
if (const auto [it, inserted] = map_.try_emplace(std::move(key)); !inserted)
{
// If insertion failed, the key is already in the cache so just update the value.
it->second->value = std::move(value);
move_to_back(it->second);
}
else
{
// After successful insertion also create the LRU list entry and connect it with
// the map entry. This reference is valid and unchanged through
// the whole cache lifetime.
// TODO(clang): no matching constructor for initialization of 'LRUEntry'
it->second =
lru_list_.emplace(lru_list_.end(), LRUEntry{it->first, std::move(value)});
}
}
}
};

} // namespace evmone
4 changes: 3 additions & 1 deletion test/internal_benchmarks/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ add_executable(
evmmax_bench.cpp
find_jumpdest_bench.cpp
memory_allocation.cpp
lru_cache_bench.cpp
)

target_link_libraries(evmone-bench-internal PRIVATE evmone::evmmax benchmark::benchmark)
target_link_libraries(evmone-bench-internal PRIVATE evmone evmone::evmmax benchmark::benchmark)
target_include_directories(evmone-bench-internal PRIVATE ${evmone_private_include_dir})
150 changes: 150 additions & 0 deletions test/internal_benchmarks/lru_cache_bench.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
// evmone: Fast Ethereum Virtual Machine implementation
// Copyright 2024 The evmone Authors.
// SPDX-License-Identifier: Apache-2.0

#include "../state/hash_utils.hpp"
#include <benchmark/benchmark.h>
#include <evmone/lru_cache.hpp>
#include <memory>

using evmone::hash256;

namespace
{
template <typename Key, typename Value>
void lru_cache_not_found(benchmark::State& state)

Check warning on line 15 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L15

Added line #L15 was not covered by tests
{
const auto capacity = static_cast<size_t>(state.range(0));
evmone::LRUCache<Key, Value> cache(capacity);

Check warning on line 18 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L17-L18

Added lines #L17 - L18 were not covered by tests

std::vector<Key> keys(capacity + 1, Key{});
for (size_t i = 0; i < keys.size(); ++i)
keys[i] = static_cast<Key>(i);

Check warning on line 22 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L20-L22

Added lines #L20 - L22 were not covered by tests
benchmark::ClobberMemory();

for (size_t i = 0; i < capacity; ++i)
cache.put(keys[i], {});

Check warning on line 26 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L25-L26

Added lines #L25 - L26 were not covered by tests

const volatile auto key = &keys[capacity];

Check warning on line 28 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L28

Added line #L28 was not covered by tests

for ([[maybe_unused]] auto _ : state)

Check warning on line 30 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L30

Added line #L30 was not covered by tests
{
auto v = cache.get(*key);

Check warning on line 32 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L32

Added line #L32 was not covered by tests
benchmark::DoNotOptimize(v);
if (v.has_value()) [[unlikely]]
state.SkipWithError("found");

Check warning on line 35 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L34-L35

Added lines #L34 - L35 were not covered by tests
}
}

Check warning on line 37 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L37

Added line #L37 was not covered by tests
BENCHMARK(lru_cache_not_found<int, int>)->Arg(5000);
BENCHMARK(lru_cache_not_found<hash256, std::shared_ptr<char>>)->Arg(5000);


template <typename Key, typename Value>
void lru_cache_get_same(benchmark::State& state)

Check warning on line 43 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L43

Added line #L43 was not covered by tests
{
const auto capacity = static_cast<size_t>(state.range(0));
evmone::LRUCache<Key, Value> cache(capacity);

Check warning on line 46 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L45-L46

Added lines #L45 - L46 were not covered by tests

std::vector<Key> keys(capacity, Key{});
for (size_t i = 0; i < keys.size(); ++i)
keys[i] = static_cast<Key>(i);

Check warning on line 50 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L48-L50

Added lines #L48 - L50 were not covered by tests
benchmark::ClobberMemory();

for (const auto key : keys)
cache.put(key, {});

Check warning on line 54 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L53-L54

Added lines #L53 - L54 were not covered by tests

const volatile auto key = &keys[capacity / 2];

Check warning on line 56 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L56

Added line #L56 was not covered by tests

for ([[maybe_unused]] auto _ : state)

Check warning on line 58 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L58

Added line #L58 was not covered by tests
{
auto v = cache.get(*key);

Check warning on line 60 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L60

Added line #L60 was not covered by tests
benchmark::DoNotOptimize(v);
if (!v.has_value()) [[unlikely]]
state.SkipWithError("not found");

Check warning on line 63 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L62-L63

Added lines #L62 - L63 were not covered by tests
}
}

Check warning on line 65 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L65

Added line #L65 was not covered by tests
BENCHMARK(lru_cache_get_same<int, int>)->Arg(5000);
BENCHMARK(lru_cache_get_same<hash256, std::shared_ptr<char>>)->Arg(5000);


template <typename Key, typename Value>
void lru_cache_get(benchmark::State& state)

Check warning on line 71 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L71

Added line #L71 was not covered by tests
{
const auto capacity = static_cast<size_t>(state.range(0));
evmone::LRUCache<Key, Value> cache(capacity);

Check warning on line 74 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L73-L74

Added lines #L73 - L74 were not covered by tests

std::vector<Key> data(capacity, Key{});
for (size_t i = 0; i < data.size(); ++i)
data[i] = static_cast<Key>(i);

Check warning on line 78 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L76-L78

Added lines #L76 - L78 were not covered by tests
benchmark::ClobberMemory();

for (const auto& key : data)
cache.put(key, {});

Check warning on line 82 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L81-L82

Added lines #L81 - L82 were not covered by tests

auto key_it = data.begin();
for ([[maybe_unused]] auto _ : state)

Check warning on line 85 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L84-L85

Added lines #L84 - L85 were not covered by tests
{
auto v = cache.get(*key_it++);

Check warning on line 87 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L87

Added line #L87 was not covered by tests
benchmark::DoNotOptimize(v);
if (!v.has_value()) [[unlikely]]
state.SkipWithError("not found");

Check warning on line 90 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L89-L90

Added lines #L89 - L90 were not covered by tests

if (key_it == data.end())
key_it = data.begin();

Check warning on line 93 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L92-L93

Added lines #L92 - L93 were not covered by tests
}
}

Check warning on line 95 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L95

Added line #L95 was not covered by tests
BENCHMARK(lru_cache_get<int, int>)->Arg(5000);
BENCHMARK(lru_cache_get<hash256, std::shared_ptr<char>>)->Arg(5000);


template <typename Key, typename Value>
void lru_cache_put_empty(benchmark::State& state)

Check warning on line 101 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L101

Added line #L101 was not covered by tests
{
const auto capacity = static_cast<size_t>(state.range(0));
evmone::LRUCache<Key, Value> cache(capacity);

Check warning on line 104 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L103-L104

Added lines #L103 - L104 were not covered by tests

std::vector<Key> data(capacity, Key{});
for (size_t i = 0; i < data.size(); ++i)
data[i] = static_cast<Key>(i);

Check warning on line 108 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L106-L108

Added lines #L106 - L108 were not covered by tests
benchmark::ClobberMemory();

while (state.KeepRunningBatch(static_cast<benchmark::IterationCount>(capacity)))

Check warning on line 111 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L111

Added line #L111 was not covered by tests
{
for (const auto& key : data)

Check warning on line 113 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L113

Added line #L113 was not covered by tests
{
cache.put(key, {});

Check warning on line 115 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L115

Added line #L115 was not covered by tests
}
state.PauseTiming();
cache.clear();
state.ResumeTiming();

Check warning on line 119 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L117-L119

Added lines #L117 - L119 were not covered by tests
}
}

Check warning on line 121 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L121

Added line #L121 was not covered by tests
BENCHMARK(lru_cache_put_empty<int, int>)->Arg(5000);
BENCHMARK(lru_cache_put_empty<hash256, std::shared_ptr<char>>)->Arg(5000);


template <typename Key, typename Value>
void lru_cache_put_full(benchmark::State& state)

Check warning on line 127 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L127

Added line #L127 was not covered by tests
{
const auto capacity = static_cast<size_t>(state.range(0));
evmone::LRUCache<Key, Value> cache(capacity);

Check warning on line 130 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L129-L130

Added lines #L129 - L130 were not covered by tests

std::vector<Key> keys(capacity, Key{});
for (size_t i = 0; i < keys.size(); ++i)
keys[i] = static_cast<Key>(i);

Check warning on line 134 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L132-L134

Added lines #L132 - L134 were not covered by tests
benchmark::ClobberMemory();

for (const auto& key : keys)
cache.put(key, {});

Check warning on line 138 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L137-L138

Added lines #L137 - L138 were not covered by tests

auto key_index = keys.size();
for ([[maybe_unused]] auto _ : state)

Check warning on line 141 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L140-L141

Added lines #L140 - L141 were not covered by tests
{
cache.put(static_cast<Key>(key_index), {});
++key_index;

Check warning on line 144 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L143-L144

Added lines #L143 - L144 were not covered by tests
}
}

Check warning on line 146 in test/internal_benchmarks/lru_cache_bench.cpp

View check run for this annotation

Codecov / codecov/patch

test/internal_benchmarks/lru_cache_bench.cpp#L146

Added line #L146 was not covered by tests
BENCHMARK(lru_cache_put_full<int, int>)->Arg(5000);
BENCHMARK(lru_cache_put_full<hash256, std::shared_ptr<char>>)->Arg(5000);

} // namespace
1 change: 1 addition & 0 deletions test/unittests/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ target_sources(
exportable_fixture.cpp
instructions_test.cpp
jumpdest_analysis_test.cpp
lru_cache_test.cpp
precompiles_blake2b_test.cpp
precompiles_bls_test.cpp
precompiles_kzg_test.cpp
Expand Down
Loading