Skip to content
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
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
224 changes: 219 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,131 @@ 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, and
/// even when it exceeds the detected limit outright: a node given less than this
/// much memory cannot follow the chain anyway, so the floor is the more useful
/// failure mode than a cache too small to hold the filters.
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.saturating_mul(ROCKSDB_BLOCK_CACHE_MEMORY_PERCENT) / 100).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 that applies to this process, 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).
///
/// The limit may sit on any cgroup between the one the process belongs to and the
/// mount root — a systemd slice, a Kubernetes pod's parent, an outer container — and
/// the mount root itself is usually unlimited, so reading the root alone would miss
/// it. The process's own cgroup comes from `/proc/self/cgroup` and every level up to
/// the root is read, with the smallest limit winning.
fn cgroup_memory_limit_bytes() -> Option<usize> {
let cgroups = std::fs::read_to_string("/proc/self/cgroup").ok()?;
let v2 = cgroups
.lines()
.find_map(|line| line.strip_prefix("0::"))
.and_then(|relative| {
cgroup_limit_up_to_root(Path::new("/sys/fs/cgroup"), relative, "memory.max")
});
v2.or_else(|| {
let relative = cgroups.lines().find_map(|line| {
// `hierarchy-ID:controller-list:cgroup-path`
let mut fields = line.splitn(3, ':');
let controllers = fields.nth(1)?;
let path = fields.next()?;
controllers
.split(',')
.any(|controller| controller == "memory")
.then_some(path)
})?;
cgroup_limit_up_to_root(
Path::new("/sys/fs/cgroup/memory"),
relative,
"memory.limit_in_bytes",
)
})
}

/// Smallest value of `file` across `<mount>/<relative>` and each of its ancestors up
/// to `mount`, skipping levels whose file is absent or reports no limit.
fn cgroup_limit_up_to_root(mount: &Path, relative: &str, file: &str) -> Option<usize> {
let mut dir = mount.join(relative.trim_start_matches('/'));
let mut limit: Option<usize> = None;
loop {
if let Some(value) = std::fs::read_to_string(dir.join(file))
.ok()
.and_then(|contents| contents.trim().parse::<usize>().ok())
{
limit = Some(limit.map_or(value, |current| current.min(value)));
}
if dir == mount || !dir.pop() {
return limit;
}
}
}

/// Tunable configuration for [`Store::new_with_config`] and related constructors.
///
Expand All @@ -107,7 +231,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 +5142,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 Expand Up @@ -6314,3 +6438,93 @@ mod flatkeyvalue_completeness_tests {
])));
}
}

#[cfg(test)]
mod cgroup_memory_limit_tests {
use super::*;
use std::fs;

/// Builds `<root>/<relative>` and writes `contents` into the `file` of every
/// directory the map names, keyed by path relative to `root` ("" is the root).
fn cgroup_tree(root: &Path, relative: &str, file: &str, limits: &[(&str, &str)]) {
fs::create_dir_all(root.join(relative)).expect("create cgroup tree");
for (dir, contents) in limits {
fs::write(root.join(dir).join(file), contents).expect("write limit");
}
}

/// A limit set on an ancestor cgroup (a systemd slice, a pod's parent) applies to
/// the process, so it must be found even though the process's own cgroup is
/// unlimited and the mount root reports `max`.
#[test]
fn ancestor_limit_applies() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
cgroup_tree(
root,
"system.slice/ethrex.service",
"memory.max",
&[("", "max"), ("system.slice", "4294967296")],
);

assert_eq!(
cgroup_limit_up_to_root(root, "/system.slice/ethrex.service", "memory.max"),
Some(4 * 1024 * 1024 * 1024)
);
}

/// With limits at several levels the tightest one is what the kernel enforces.
#[test]
fn smallest_limit_in_the_chain_wins() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
cgroup_tree(
root,
"kubepods/pod123/container",
"memory.max",
&[
("kubepods", "8589934592"),
("kubepods/pod123", "2147483648"),
("kubepods/pod123/container", "4294967296"),
],
);

assert_eq!(
cgroup_limit_up_to_root(root, "kubepods/pod123/container", "memory.max"),
Some(2 * 1024 * 1024 * 1024)
);
}

/// An unlimited chain is "unknown", not zero: v2 writes the literal `max` and v1 a
/// sentinel near `u64::MAX`, and absent files must not count either.
#[test]
fn unlimited_chain_yields_no_limit() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
cgroup_tree(root, "docker/abc", "memory.max", &[("", "max")]);

assert_eq!(
cgroup_limit_up_to_root(root, "docker/abc", "memory.max"),
None
);
// A cgroup path that does not exist under this mount (a v1 controller that is
// not mounted, a stale line in /proc/self/cgroup) reads as unknown too.
assert_eq!(
cgroup_limit_up_to_root(root, "not/here", "memory.max"),
None
);
}

/// The root cgroup is itself a level: a limit set there still applies.
#[test]
fn root_limit_applies_to_the_root_cgroup() {
let tmp = tempfile::tempdir().expect("tempdir");
let root = tmp.path();
cgroup_tree(root, "", "memory.limit_in_bytes", &[("", "536870912")]);

assert_eq!(
cgroup_limit_up_to_root(root, "/", "memory.limit_in_bytes"),
Some(512 * 1024 * 1024)
);
}
}
Loading
Loading