From ba708a3a71c75c71b9811337fdda1582732c8737 Mon Sep 17 00:00:00 2001 From: Michal Rostecki Date: Tue, 10 Dec 2024 20:52:06 +0100 Subject: [PATCH] accounts-db: Benchmark cache evictions The already existing `concurrent_{read,scan}_write` benchmarks are not sufficient for benchmarking the eviction and evaluating what kind of eviction policy performs the best, because they don't fill up the cache, so eviction never happens. Add a new benchmark, which starts measuring the concurrent reads and writes on a full cache. --- Cargo.lock | 52 +++- Cargo.toml | 1 + accounts-db/Cargo.toml | 5 + .../benches/read_only_accounts_cache.rs | 270 ++++++++++++++++++ accounts-db/src/accounts_db.rs | 3 + accounts-db/src/lib.rs | 3 + accounts-db/src/read_only_accounts_cache.rs | 7 + 7 files changed, 340 insertions(+), 1 deletion(-) create mode 100644 accounts-db/benches/read_only_accounts_cache.rs diff --git a/Cargo.lock b/Cargo.lock index 019b08ba5c8ee9..93990f8610d001 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3683,6 +3683,16 @@ version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b87248edafb776e59e6ee64a79086f65890d3510f2c656c000bf2a7e8a0aea40" +[[package]] +name = "matrixmultiply" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9380b911e3e96d10c1f415da0876389aaf1b56759054eeb0de7df940c456ba1a" +dependencies = [ + "autocfg", + "rawpointer", +] + [[package]] name = "memchr" version = "2.6.3" @@ -3848,6 +3858,21 @@ dependencies = [ "tempfile", ] +[[package]] +name = "ndarray" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841" +dependencies = [ + "matrixmultiply", + "num-complex 0.4.6", + "num-integer", + "num-traits", + "portable-atomic", + "portable-atomic-util", + "rawpointer", +] + [[package]] name = "net2" version = "0.2.37" @@ -3908,7 +3933,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8536030f9fea7127f841b45bb6243b27255787fb4eb83958aa1ef9d2fdc0c36" dependencies = [ "num-bigint 0.2.6", - "num-complex", + "num-complex 0.2.4", "num-integer", "num-iter", "num-rational", @@ -3946,6 +3971,15 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-derive" version = "0.4.2" @@ -4441,6 +4475,15 @@ version = "1.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da544ee218f0d287a911e9c99a39a8c9bc8fcad3cb8db5959940044ecfc67265" +[[package]] +name = "portable-atomic-util" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507" +dependencies = [ + "portable-atomic", +] + [[package]] name = "ppv-lite86" version = "0.2.15" @@ -4887,6 +4930,12 @@ dependencies = [ "bitflags 2.6.0", ] +[[package]] +name = "rawpointer" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60a357793950651c4ed0f3f52338f53b2f809f32d83a07f72909fa13e4c6c1e3" + [[package]] name = "rayon" version = "1.10.0" @@ -5954,6 +6003,7 @@ dependencies = [ "memmap2", "memoffset 0.9.1", "modular-bitfield", + "ndarray", "num_cpus", "num_enum", "qualifier_attr", diff --git a/Cargo.toml b/Cargo.toml index 7657fe3dc21e45..035d910846219d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -367,6 +367,7 @@ merlin = "3" min-max-heap = "1.3.0" mockall = "0.11.4" modular-bitfield = "0.11.2" +ndarray = "0.16.1" nix = "0.29.0" num-bigint = "0.4.6" num-derive = "0.4" diff --git a/accounts-db/Cargo.toml b/accounts-db/Cargo.toml index 82a983ede37a20..24a104aa0b4266 100644 --- a/accounts-db/Cargo.toml +++ b/accounts-db/Cargo.toml @@ -67,6 +67,7 @@ assert_matches = { workspace = true } criterion = { workspace = true } libsecp256k1 = { workspace = true } memoffset = { workspace = true } +ndarray = { workspace = true } rand_chacha = { workspace = true } serde_bytes = { workspace = true } # See order-crates-for-publishing.py for using this unusual `path = "."` @@ -103,6 +104,10 @@ harness = false name = "bench_hashing" harness = false +[[bench]] +name = "read_only_accounts_cache" +harness = false + [[bench]] name = "bench_serde" harness = false diff --git a/accounts-db/benches/read_only_accounts_cache.rs b/accounts-db/benches/read_only_accounts_cache.rs new file mode 100644 index 00000000000000..4623da93babe53 --- /dev/null +++ b/accounts-db/benches/read_only_accounts_cache.rs @@ -0,0 +1,270 @@ +#![feature(test)] + +extern crate test; + +use { + criterion::{criterion_group, criterion_main, BatchSize, BenchmarkId, Criterion}, + rand::{rngs::SmallRng, seq::SliceRandom, SeedableRng}, + solana_accounts_db::{ + accounts_db::AccountsDb, + read_only_accounts_cache::{ReadOnlyAccountsCache, CACHE_ENTRY_SIZE}, + }, + solana_sdk::{ + account::{Account, ReadableAccount}, + pubkey, + }, + std::{ + iter, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, + thread::Builder, + }, +}; + +/// Sizes of accounts to bench. +const ACCOUNTS_SIZES: &[usize] = &[0, 512, 1024]; +/// Numbers of reader and writer threads to bench. +const NUM_READERS_WRITERS: &[usize] = &[ + 8, + 16, + // These parameters are likely to freeze your computer, if it has less than + // 32 cores. + // 32, 64, 128, 256, 512, 1024, +]; + +fn bench_read_only_accounts_cache(c: &mut Criterion) { + let mut group = c.benchmark_group("cache"); + let slot = 0; + + for account_size in ACCOUNTS_SIZES { + // Number of accounts to use in the benchmark. + let num_accounts_benched = AccountsDb::DEFAULT_MAX_READ_ONLY_CACHE_DATA_SIZE_HI + .div_ceil(CACHE_ENTRY_SIZE.saturating_add(*account_size)); + group.sample_size(num_accounts_benched); + + for num_readers_writers in NUM_READERS_WRITERS { + let cache = Arc::new(ReadOnlyAccountsCache::new( + AccountsDb::DEFAULT_MAX_READ_ONLY_CACHE_DATA_SIZE_LO, + AccountsDb::DEFAULT_MAX_READ_ONLY_CACHE_DATA_SIZE_HI, + AccountsDb::READ_ONLY_CACHE_MS_TO_SKIP_LRU_UPDATE, + )); + + // Benchmark the performance of loading and storing accounts in an + // initially empty cache. + let pubkeys: Vec<_> = iter::repeat_with(pubkey::new_rand) + .take(num_accounts_benched) + .collect(); + group.bench_function( + BenchmarkId::new( + "read_only_accounts_cache_store", + format!("{account_size}_bytes_{num_readers_writers}_threads"), + ), + |b| { + b.iter_batched( + || { + let accounts_data = iter::repeat( + Account { + lamports: 1, + data: vec![1; *account_size], + ..Default::default() + } + .to_account_shared_data(), + ); + pubkeys.clone().into_iter().zip(accounts_data) + }, + |accounts_iter| { + for (pubkey, account) in accounts_iter { + cache.store(pubkey, slot, account); + } + }, + BatchSize::PerIteration, + ) + }, + ); + group.bench_function( + BenchmarkId::new( + "read_only_accounts_cache_load", + format!("{account_size}_bytes_{num_readers_writers}_threads"), + ), + |b| { + b.iter_batched( + || pubkeys.clone().into_iter(), + |pubkeys_iter| { + for pubkey in pubkeys_iter { + test::black_box(cache.load(pubkey, slot)); + } + }, + BatchSize::SmallInput, + ) + }, + ); + } + } +} + +/// Benchmarks the read-only cache eviction mechanism. It does so by performing +/// multithreaded reads and writes on a full cache. Each write triggers +/// eviction. Background reads add more contention. +fn bench_read_only_accounts_cache_eviction(c: &mut Criterion) { + /// Number of accounts to use in the benchmark. That's the maximum number + /// of evictions observed on mainnet validators. + const NUM_ACCOUNTS_BENCHED: usize = 1000; + + let mut group = c.benchmark_group("cache_eviction"); + group.sample_size(NUM_ACCOUNTS_BENCHED); + + for account_size in ACCOUNTS_SIZES { + // Number of accounts needed to initially fill the cache. + let num_accounts_init = AccountsDb::DEFAULT_MAX_READ_ONLY_CACHE_DATA_SIZE_HI + .div_ceil(CACHE_ENTRY_SIZE.saturating_add(*account_size)); + + for num_readers_writers in NUM_READERS_WRITERS { + let cache = Arc::new(ReadOnlyAccountsCache::new( + AccountsDb::DEFAULT_MAX_READ_ONLY_CACHE_DATA_SIZE_LO, + AccountsDb::DEFAULT_MAX_READ_ONLY_CACHE_DATA_SIZE_HI, + AccountsDb::READ_ONLY_CACHE_MS_TO_SKIP_LRU_UPDATE, + )); + + // Prepare accounts for the cache fillup. + let pubkeys: Vec<_> = iter::repeat_with(pubkey::new_rand) + .take(num_accounts_init) + .collect(); + let accounts_data = iter::repeat( + Account { + lamports: 1, + data: vec![1; *account_size], + ..Default::default() + } + .into(), + ) + .take(num_accounts_init); + let accounts = pubkeys.iter().zip(accounts_data); + + // Fill up the cache. + let slot = 0; + for (pubkey, account) in accounts { + cache.store(*pubkey, slot, account); + } + + // Spawn the reader and writer threads in the background. They are + // going to read and write + let stop_threads = Arc::new(AtomicBool::new(false)); + let reader_handles = (0..*num_readers_writers).map(|i| { + let stop_threads = Arc::clone(&stop_threads); + let cache = Arc::clone(&cache); + let pubkeys = pubkeys.clone(); + + Builder::new() + .name(format!("reader{i:02}")) + .spawn({ + move || { + // Continuously read random accounts. + let mut rng = SmallRng::seed_from_u64(i as u64); + while !stop_threads.load(Ordering::Relaxed) { + let pubkey = pubkeys.choose(&mut rng).unwrap(); + test::black_box(cache.load(*pubkey, slot)); + } + } + }) + .unwrap() + }); + let slot = 1; + let writer_handles = (0..*num_readers_writers).map(|i| { + let stop_threads = Arc::clone(&stop_threads); + let cache = Arc::clone(&cache); + let pubkeys = pubkeys.clone(); + + Builder::new() + .name(format!("writer{i:02}")) + .spawn({ + move || { + // Continuously write to already existing pubkeys. + let mut rng = SmallRng::seed_from_u64(100_u64.saturating_add(i as u64)); + while !stop_threads.load(Ordering::Relaxed) { + let pubkey = pubkeys.choose(&mut rng).unwrap(); + cache.store( + *pubkey, + slot, + Account { + lamports: 1, + data: vec![1; *account_size], + ..Default::default() + } + .to_account_shared_data(), + ); + } + } + }) + .unwrap() + }); + + // Benchmark the performance of loading and storing accounts in a + // cache that is fully populated. This triggers eviction for each + // write operation. Background threads introduce contention. + let pubkeys: Vec<_> = iter::repeat_with(pubkey::new_rand) + .take(NUM_ACCOUNTS_BENCHED) + .collect(); + group.bench_function( + BenchmarkId::new( + "read_only_accounts_cache_eviction_store", + format!("{account_size}_bytes_{num_readers_writers}_threads"), + ), + |b| { + b.iter_batched( + || { + let accounts_data = iter::repeat( + Account { + lamports: 1, + data: vec![1; *account_size], + ..Default::default() + } + .to_account_shared_data(), + ); + pubkeys.clone().into_iter().zip(accounts_data) + }, + |accounts_iter| { + for (pubkey, account) in accounts_iter { + cache.store(pubkey, slot, account); + } + }, + BatchSize::PerIteration, + ) + }, + ); + group.bench_function( + BenchmarkId::new( + "read_only_accounts_cache_eviction_load", + format!("{account_size}_bytes_{num_readers_writers}_threads"), + ), + |b| { + b.iter_batched( + || pubkeys.clone().into_iter(), + |pubkeys_iter| { + for pubkey in pubkeys_iter { + test::black_box(cache.load(pubkey, slot)); + } + }, + BatchSize::SmallInput, + ) + }, + ); + + stop_threads.store(true, Ordering::Relaxed); + for reader_handle in reader_handles { + reader_handle.join().unwrap(); + } + for writer_handle in writer_handles { + writer_handle.join().unwrap(); + } + } + } +} + +criterion_group!( + benches, + bench_read_only_accounts_cache, + bench_read_only_accounts_cache_eviction +); +criterion_main!(benches); diff --git a/accounts-db/src/accounts_db.rs b/accounts-db/src/accounts_db.rs index 26923d5e05a224..bef148ed0372a6 100644 --- a/accounts-db/src/accounts_db.rs +++ b/accounts-db/src/accounts_db.rs @@ -1889,11 +1889,14 @@ impl AccountsDb { pub const DEFAULT_ACCOUNTS_HASH_CACHE_DIR: &'static str = "accounts_hash_cache"; // read only cache does not update lru on read of an entry unless it has been at least this many ms since the last lru update + #[cfg_attr(feature = "dev-context-only-utils", qualifiers(pub))] const READ_ONLY_CACHE_MS_TO_SKIP_LRU_UPDATE: u32 = 100; // The default high and low watermark sizes for the accounts read cache. // If the cache size exceeds MAX_SIZE_HI, it'll evict entries until the size is <= MAX_SIZE_LO. + #[cfg_attr(feature = "dev-context-only-utils", qualifiers(pub))] const DEFAULT_MAX_READ_ONLY_CACHE_DATA_SIZE_LO: usize = 400 * 1024 * 1024; + #[cfg_attr(feature = "dev-context-only-utils", qualifiers(pub))] const DEFAULT_MAX_READ_ONLY_CACHE_DATA_SIZE_HI: usize = 410 * 1024 * 1024; pub fn default_for_tests() -> Self { diff --git a/accounts-db/src/lib.rs b/accounts-db/src/lib.rs index 8e7b4faf926b75..27c41ccf27dcce 100644 --- a/accounts-db/src/lib.rs +++ b/accounts-db/src/lib.rs @@ -32,6 +32,9 @@ mod file_io; pub mod hardened_unpack; pub mod partitioned_rewards; pub mod pubkey_bins; +#[cfg(feature = "dev-context-only-utils")] +pub mod read_only_accounts_cache; +#[cfg(not(feature = "dev-context-only-utils"))] mod read_only_accounts_cache; mod rolling_bit_field; pub mod secondary_index; diff --git a/accounts-db/src/read_only_accounts_cache.rs b/accounts-db/src/read_only_accounts_cache.rs index 2431761bc5f535..9782866fa10a8b 100644 --- a/accounts-db/src/read_only_accounts_cache.rs +++ b/accounts-db/src/read_only_accounts_cache.rs @@ -1,5 +1,7 @@ //! ReadOnlyAccountsCache used to store accounts, such as executable accounts, //! which can be large, loaded many times, and rarely change. +#[cfg(feature = "dev-context-only-utils")] +use qualifier_attr::qualifiers; use { dashmap::{mapref::entry::Entry, DashMap}, index_list::{Index, IndexList}, @@ -22,6 +24,7 @@ use { }, }; +#[cfg_attr(feature = "dev-context-only-utils", qualifiers(pub))] const CACHE_ENTRY_SIZE: usize = std::mem::size_of::() + 2 * std::mem::size_of::(); @@ -65,6 +68,7 @@ struct AtomicReadOnlyCacheStats { evictor_wakeup_count_productive: AtomicU64, } +#[cfg_attr(feature = "dev-context-only-utils", qualifiers(pub))] #[derive(Debug)] pub(crate) struct ReadOnlyAccountsCache { cache: Arc>, @@ -93,6 +97,7 @@ pub(crate) struct ReadOnlyAccountsCache { } impl ReadOnlyAccountsCache { + #[cfg_attr(feature = "dev-context-only-utils", qualifiers(pub))] pub(crate) fn new( max_data_size_lo: usize, max_data_size_hi: usize, @@ -137,6 +142,7 @@ impl ReadOnlyAccountsCache { } } + #[cfg_attr(feature = "dev-context-only-utils", qualifiers(pub))] pub(crate) fn load(&self, pubkey: Pubkey, slot: Slot) -> Option { let (account, load_us) = measure_us!({ let mut found = None; @@ -175,6 +181,7 @@ impl ReadOnlyAccountsCache { CACHE_ENTRY_SIZE + account.data().len() } + #[cfg_attr(feature = "dev-context-only-utils", qualifiers(pub))] pub(crate) fn store(&self, pubkey: Pubkey, slot: Slot, account: AccountSharedData) { let measure_store = Measure::start(""); self.highest_slot_stored.fetch_max(slot, Ordering::Release);