Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@

## Perf

### 2026-08-03

- Size the default RocksDB shared block cache from the memory the process may actually use — 40% of the smaller of physical memory and the cgroup limit, clamped to 512 MiB..=12 GiB — instead of a flat 12 GiB. The flat default was 71% of a 16 GiB host, leaving no headroom for trie layers, execution and the mempool; `--rocksdb.block-cache-size` still overrides it

### 2026-07-22

- Unify full-sync batch import onto the per-block execution pipeline, validating every block's state root and reusing the pipeline's BAL-driven parallel execution instead of the bespoke "execute all, apply once" batch path [#7008](https://github.com/lambdaclass/ethrex/pull/7008)
Expand Down
24 changes: 6 additions & 18 deletions cmd/ethrex/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -101,25 +101,13 @@ pub struct Options {
#[arg(
long = "rocksdb.block-cache-size",
value_name = "BYTES",
default_value_t = ethrex_storage::DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
help = "RocksDB shared block cache size in bytes (default 12 GiB). \
Bounds RocksDB resident memory; lower only on memory-constrained hosts.",
long_help = "RocksDB shared block cache size in bytes. With cache_index_and_filter_blocks \
enabled it holds data blocks plus the per-SST index and bloom-filter blocks, \
so it is the effective ceiling on RocksDB's resident memory.\n\
\n\
Default 12 GiB keeps the filter/index working set resident plus hot EVM state. \
A sweep on a synced mainnet node (32 GiB cap) found 8-16 GiB all keep up with \
head-following (filters resident, disk near-idle, no slow blocks); larger gives \
no gain because the OS page cache backstops the uncompressed state CFs, and \
~8 GiB is the floor where the filter set starts to thrash.\n\
\n\
Lower only on memory-constrained hosts, accepting reduced throughput. \
ETHREX_ROCKSDB_BLOCK_CACHE_SIZE sets the same value.",
help = "RocksDB shared block cache size in bytes, the effective ceiling on RocksDB's \
resident memory. Defaults to 40% of the memory available to the process \
(physical or cgroup limit, whichever is lower), clamped to 512 MiB..=12 GiB.",
help_heading = "Storage options",
env = "ETHREX_ROCKSDB_BLOCK_CACHE_SIZE",
env = "ETHREX_ROCKSDB_BLOCK_CACHE_SIZE"
)]
pub rocksdb_block_cache_size: usize,
pub rocksdb_block_cache_size: Option<usize>,
#[arg(long = "syncmode", default_value = "snap", value_name = "SYNC_MODE", value_parser = utils::parse_sync_mode, help = "The way in which the node will sync its state.", long_help = "Can be either \"full\" or \"snap\" with \"snap\" as default value.", help_heading = "P2P options", env = "ETHREX_SYNCMODE")]
pub syncmode: SyncMode,
#[arg(
Expand Down Expand Up @@ -539,7 +527,7 @@ impl Default for Options {
network: Default::default(),
bootnodes: Default::default(),
datadir: Default::default(),
rocksdb_block_cache_size: ethrex_storage::DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
rocksdb_block_cache_size: None,
syncmode: Default::default(),
metrics_addr: "0.0.0.0".to_owned(),
metrics_port: Default::default(),
Expand Down
8 changes: 5 additions & 3 deletions cmd/ethrex/initializers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -26,8 +26,8 @@ use ethrex_p2p::{
utils::public_key_from_signing_key,
};
use ethrex_storage::{
DB_COMMIT_THRESHOLD, EngineType, Store, StoreConfig, error::StoreError, has_valid_db,
read_chain_id_from_db,
DB_COMMIT_THRESHOLD, EngineType, Store, StoreConfig, default_rocksdb_block_cache_size,
error::StoreError, has_valid_db, read_chain_id_from_db,
};
use local_ip_address::{local_ip, local_ipv6};
use rand::rngs::OsRng;
Expand Down Expand Up @@ -740,7 +740,9 @@ pub async fn init_l1(
ethrex_crypto::kzg::warm_up_trusted_setup();

let store_config = StoreConfig {
rocksdb_block_cache_size: opts.rocksdb_block_cache_size,
rocksdb_block_cache_size: opts
.rocksdb_block_cache_size
.unwrap_or_else(default_rocksdb_block_cache_size),
..StoreConfig::default()
};
let store_result = if opts.skip_genesis_validation {
Expand Down
5 changes: 4 additions & 1 deletion cmd/ethrex/l2/initializers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,10 @@ pub async fn init_l2(

let genesis = network.get_genesis()?;
let store_config = StoreConfig {
rocksdb_block_cache_size: opts.node_opts.rocksdb_block_cache_size,
rocksdb_block_cache_size: opts
.node_opts
.rocksdb_block_cache_size
.unwrap_or_else(ethrex_storage::default_rocksdb_block_cache_size),
..StoreConfig::default()
};
let store = init_store_with_config(&datadir, genesis.clone(), store_config).await?;
Expand Down
16 changes: 6 additions & 10 deletions crates/storage/backend/rocksdb.rs
Original file line number Diff line number Diff line change
Expand Up @@ -586,11 +586,9 @@ mod tests {
#[test]
fn merge_operator_survives_flush_and_compaction() {
let dir = tempfile::tempdir().unwrap();
let backend = RocksDBBackend::open(
dir.path(),
crate::store::DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
)
.unwrap();
let backend =
RocksDBBackend::open(dir.path(), crate::store::MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES)
.unwrap();
let cf = backend.db.cf_handle(TRANSACTION_LOCATIONS).unwrap();

let tx_hash = H256::from_low_u64_be(0xabcd);
Expand Down Expand Up @@ -633,11 +631,9 @@ mod tests {
#[test]
fn merge_operator_dedupes_across_compaction() {
let dir = tempfile::tempdir().unwrap();
let backend = RocksDBBackend::open(
dir.path(),
crate::store::DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
)
.unwrap();
let backend =
RocksDBBackend::open(dir.path(), crate::store::MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES)
.unwrap();
let cf = backend.db.cf_handle(TRANSACTION_LOCATIONS).unwrap();

let tx_hash = H256::from_low_u64_be(0x1234);
Expand Down
8 changes: 5 additions & 3 deletions crates/storage/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -78,9 +78,11 @@ pub mod utils;

pub use layering::apply_prefix;
pub use store::{
AccountUpdatesList, BATCH_COMMIT_THRESHOLD, DB_COMMIT_THRESHOLD,
DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES, EngineType, Store, StoreConfig, UpdateBatch,
has_valid_db, hash_address, hash_key, read_chain_id_from_db,
AccountUpdatesList, BATCH_COMMIT_THRESHOLD, DB_COMMIT_THRESHOLD, EngineType,
MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES, MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT, Store, StoreConfig, UpdateBatch,
default_rocksdb_block_cache_size, has_valid_db, hash_address, hash_key, read_chain_id_from_db,
rocksdb_block_cache_size_for,
};

/// Store Schema Version, must be updated on any breaking change.
Expand Down
88 changes: 83 additions & 5 deletions crates/storage/store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,7 +75,7 @@ const IN_MEMORY_COMMIT_THRESHOLD: usize = 10000;
/// import only ever extend a single canonical chain (no competing forks to mis-commit).
pub const BATCH_COMMIT_THRESHOLD: usize = 4;

/// Default size in bytes of the RocksDB shared block cache: 12 GiB.
/// Ceiling on the RocksDB shared block cache: 12 GiB.
///
/// This cache holds both data blocks AND the index/bloom-filter blocks for every
/// open SST file (because we enable `cache_index_and_filter_blocks`), so its size
Expand All @@ -84,7 +84,85 @@ pub const BATCH_COMMIT_THRESHOLD: usize = 4;
/// synced mainnet node (32 GiB cap) found 8-16 GiB all keep up with head-following,
/// with larger giving no gain (the OS page cache backstops the uncompressed state
/// CFs) and ~8 GiB the floor where the filter set starts to thrash.
pub const DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES: usize = 12 * 1024 * 1024 * 1024;
pub const MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES: usize = 12 * 1024 * 1024 * 1024;

/// Floor on the RocksDB shared block cache: 512 MiB. Below this the per-SST
/// index and filter blocks no longer stay resident and every trie read pays an
/// extra disk seek, which is worse than the memory it saves. Applied even when
/// it exceeds [`ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT`] of a very small host.
pub const MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES: usize = 512 * 1024 * 1024;

/// Share of the host's (or cgroup's) memory the block cache may claim by default.
///
/// The rest has to cover the in-memory trie-layer backlog, block execution, the
/// mempool, peer buffers and allocator slack, so the cache cannot have most of the
/// machine. At 40% a 32 GiB host lands on the 12 GiB ceiling and a 16 GiB host gets
/// ~6.4 GiB — under the ~8 GiB thrash floor, but that is the memory-constrained
/// tradeoff the ceiling's docs describe, and it leaves the node room not to be
/// OOM-killed.
pub const ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT: usize = 40;

/// Default RocksDB shared block cache size, derived from the memory this process
/// is actually allowed to use.
///
/// A fixed default cannot serve both a 64 GiB validator and a 16 GiB CI runner: the
/// ceiling alone is 71% of a 16 GiB host, which leaves no headroom for the rest of
/// the node. Sizes to [`ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT`] of the detected limit,
/// clamped to [`MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES`] ..=
/// [`MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES`]. Falls back to the ceiling when the limit
/// cannot be detected, preserving the previous behavior.
pub fn default_rocksdb_block_cache_size() -> usize {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Nothing logs the detected limit or the size this returns, and that is the one thing the PR'''s own motivation argues for.

The old value was a constant: wrong on small hosts, but knowable from the source. This replaces it with a number derived from /proc/meminfo, cgroup v2 memory.max, or cgroup v1 memory.limit_in_bytes, whichever is smallest — and None from all of them silently restores the old 12 GiB. So the cache size is now environment-dependent and invisible, on a code path that exists because a mis-sized cache was only discovered via an OOM kill.

Concretely, an operator debugging memory today cannot answer: was a cgroup limit detected at all, or did we fall back to physical memory? Did detection fail entirely and hand back 12 GiB? Was the result clamped? The clamp matters because it is silent in both directions — below ~1.3 GiB of detected limit, MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES (512 MiB) wins and the cache is more than 40% of the limit.

One line at startup would close it:

info!(detected_limit_bytes = ?limit, source = %source, cache_bytes = size, "sized RocksDB block cache");

Worth noting the blast radius is small, which is a point in the PR'''s favour and not stated: with a 40% factor clamped to 12 GiB, the default only moves below ~30 GiB of detected memory. docs/getting-started/hardware_requirements.md puts the RAM minimum at 32 GB for every network, so on any spec-compliant host this is a no-op (32 GB -> 12.8 GiB -> clamped to 12 GiB, unchanged). It changes behaviour only for under-spec machines and containers — which is exactly the intent, and worth saying explicitly so reviewers know a spec-compliant deployment sees no change.

rocksdb_block_cache_size_for(host_memory_limit_bytes())
}

/// Pure part of [`default_rocksdb_block_cache_size`]: the clamp, with the detected
/// memory limit supplied by the caller. `None` means detection failed.
pub fn rocksdb_block_cache_size_for(memory_limit: Option<usize>) -> usize {
let Some(limit) = memory_limit else {
return MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES;
};
(limit / 100 * ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT).clamp(
MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
)
}

/// Memory this process may use: the smaller of the host's physical memory and the
/// cgroup memory limit, so a container gets sized against its own limit rather than
/// the machine it happens to run on. `None` when nothing could be read (non-Linux,
/// or an unreadable/absent `/proc`), which callers treat as "unknown".
fn host_memory_limit_bytes() -> Option<usize> {
[physical_memory_bytes(), cgroup_memory_limit_bytes()]
.into_iter()
.flatten()
.min()
}

/// `MemTotal` from `/proc/meminfo`, in bytes.
fn physical_memory_bytes() -> Option<usize> {
let meminfo = std::fs::read_to_string("/proc/meminfo").ok()?;
let kib: usize = meminfo
.lines()
.find_map(|line| line.strip_prefix("MemTotal:"))?
.split_whitespace()
.next()?
.parse()
.ok()?;
kib.checked_mul(1024)
}

/// The cgroup memory limit, in bytes: `memory.max` under cgroup v2, falling back to
/// `memory.limit_in_bytes` under v1. Both report "no limit" out of band — v2 with the
/// literal `max`, v1 with a sentinel near `u64::MAX` — which yields `None` here (v1's
/// sentinel simply exceeds any real `MemTotal`, so the `min` above discards it).
fn cgroup_memory_limit_bytes() -> Option<usize> {
[
"/sys/fs/cgroup/memory.max",
"/sys/fs/cgroup/memory/memory.limit_in_bytes",
]
.into_iter()
.find_map(|path| std::fs::read_to_string(path).ok()?.trim().parse().ok())

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Nested cgroup limit is ignored

If ethrex runs in a nested cgroup with a memory limit, these fixed mount-root paths read the root cgroup rather than the process's cgroup. The limit is therefore ignored and startup can select the 12 GiB cache ceiling inside a smaller allocation, causing the node to be OOM-killed.

Knowledge Base Used:

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/storage/store.rs
Line: 158-164

Comment:
**Nested cgroup limit is ignored**

If ethrex runs in a nested cgroup with a memory limit, these fixed mount-root paths read the root cgroup rather than the process's cgroup. The limit is therefore ignored and startup can select the 12 GiB cache ceiling inside a smaller allocation, causing the node to be OOM-killed.

**Knowledge Base Used:**
- [Storage Layer](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethrex/-/docs/storage-layer.md)
- [CLI Entrypoint and Node Startup](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethrex/-/docs/cli-entrypoint.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

}

/// Tunable configuration for [`Store::new_with_config`] and related constructors.
///
Expand All @@ -107,7 +185,7 @@ pub struct StoreConfig {
impl Default for StoreConfig {
fn default() -> Self {
Self {
rocksdb_block_cache_size: DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
rocksdb_block_cache_size: default_rocksdb_block_cache_size(),
persist_channel_capacity: DEFAULT_PERSIST_CHANNEL_CAPACITY,
}
}
Expand Down Expand Up @@ -5018,8 +5096,8 @@ pub fn read_chain_id_from_db(path: &Path) -> Option<u64> {
#[cfg(feature = "rocksdb")]
{
// The cache size is irrelevant for this one-shot chain-id read (the LRU
// is sized as a ceiling, not pre-allocated), so we use the default.
let backend = match RocksDBBackend::open(path, DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES) {
// is sized as a ceiling, not pre-allocated), so we use the ceiling.
let backend = match RocksDBBackend::open(path, MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES) {
Ok(backend) => backend,
Err(e) => {
warn!("Failed to open RocksDB at {path:?} to read chain ID: {e}");
Expand Down
7 changes: 1 addition & 6 deletions docs/CLI.md
Original file line number Diff line number Diff line change
Expand Up @@ -202,14 +202,9 @@ P2P options:

Storage options:
--rocksdb.block-cache-size <BYTES>
RocksDB shared block cache size in bytes. With cache_index_and_filter_blocks enabled it holds data blocks plus the per-SST index and bloom-filter blocks, so it is the effective ceiling on RocksDB's resident memory.

Default 12 GiB keeps the filter/index working set resident plus hot EVM state. A sweep on a synced mainnet node (32 GiB cap) found 8-16 GiB all keep up with head-following (filters resident, disk near-idle, no slow blocks); larger gives no gain because the OS page cache backstops the uncompressed state CFs, and ~8 GiB is the floor where the filter set starts to thrash.

Lower only on memory-constrained hosts, accepting reduced throughput. ETHREX_ROCKSDB_BLOCK_CACHE_SIZE sets the same value.
RocksDB shared block cache size in bytes, the effective ceiling on RocksDB's resident memory. Defaults to 40% of the memory available to the process (physical or cgroup limit, whichever is lower), clamped to 512 MiB..=12 GiB.

[env: ETHREX_ROCKSDB_BLOCK_CACHE_SIZE=]
[default: 12884901888]

RPC options:
--http.addr <ADDRESS>
Expand Down
1 change: 1 addition & 0 deletions test/tests/storage/mod.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod deferred_persistence_tests;
mod fcu_race_tests;
mod rocksdb_block_cache_tests;
mod store_tests;
mod trie_db_tests;
75 changes: 75 additions & 0 deletions test/tests/storage/rocksdb_block_cache_tests.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
//! Sizing of the RocksDB shared block cache default.

use ethrex_storage::{
MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES, MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT, default_rocksdb_block_cache_size,
rocksdb_block_cache_size_for,
};

const GIB: usize = 1024 * 1024 * 1024;

/// An undetectable memory limit SHALL fall back to the ceiling, preserving the
/// behavior from before the default became memory-aware.
#[test]
fn undetected_memory_limit_falls_back_to_the_ceiling() {
assert_eq!(
rocksdb_block_cache_size_for(None),
MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES
);
}

/// Hosts large enough that the percentage exceeds the ceiling SHALL get the ceiling,
/// so a big machine keeps the tuned 12 GiB rather than scaling without bound.
#[test]
fn large_hosts_are_capped_at_the_ceiling() {
for limit in [32 * GIB, 64 * GIB, 1024 * GIB] {
assert_eq!(
rocksdb_block_cache_size_for(Some(limit)),
MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
"{limit} bytes must clamp to the ceiling"
);
}
}

/// The case this sizing exists for: on a 16 GiB host the flat 12 GiB default was 71% of
/// the machine, leaving no headroom for trie layers, execution and the mempool. The
/// memory-aware default SHALL leave the majority of such a host to the rest of the node.
#[test]
fn memory_constrained_host_keeps_headroom() {
let limit = 16 * GIB;
let cache = rocksdb_block_cache_size_for(Some(limit));

assert_eq!(cache, limit / 100 * ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT);
assert!(
cache < MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
"a 16 GiB host must not be handed the ceiling"
);
assert!(
limit - cache > limit / 2,
"more than half of a 16 GiB host must stay available to the node"
);
}

/// Tiny hosts SHALL still get the floor: below it the per-SST index and filter blocks
/// stop staying resident and every trie read pays an extra seek.
#[test]
fn tiny_hosts_get_the_floor() {
assert_eq!(
rocksdb_block_cache_size_for(Some(GIB)),
MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES
);
assert_eq!(
rocksdb_block_cache_size_for(Some(0)),
MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES
);
}

/// Whatever this machine reports, the resolved default SHALL land inside the clamp.
#[test]
fn resolved_default_is_within_the_clamp() {
let cache = default_rocksdb_block_cache_size();
assert!(
(MIN_ROCKSDB_BLOCK_CACHE_SIZE_BYTES..=MAX_ROCKSDB_BLOCK_CACHE_SIZE_BYTES).contains(&cache),
"resolved default {cache} outside the clamp"
);
}
2 changes: 1 addition & 1 deletion tooling/migrations/src/bin/bench_migration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ fn main() {

let backend = ethrex_storage::backend::rocksdb::RocksDBBackend::open(
db_path,
ethrex_storage::DEFAULT_ROCKSDB_BLOCK_CACHE_SIZE_BYTES,
ethrex_storage::default_rocksdb_block_cache_size(),
)
.expect("Failed to open RocksDB");

Expand Down
Loading