Skip to content
Open
Show file tree
Hide file tree
Changes from 9 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-04

- Cut the cost of a cold contract-code access: store jump destinations as a 1-bit-per-byte bitmap instead of a persisted RLP list of `u32` offsets, count the bytecode in the code cache's byte budget, answer `EXTCODESIZE` from the code-length table instead of materializing the bytecode, and give the account-code column families a bloom filter (4KB data blocks on the blob-backed bytecode CF). Raises the code cache's byte budget from an effective 64 MiB of jump tables to 256 MiB of bytecode, and bumps the store schema version so an older binary warns rather than failing on the new value format. `COLD_ACCOUNT_CODE_ACCESS` drops from 7736 to 4652 gas in the EIP-8038 repricing fit, and `COLD_ACCOUNT_CODE_WRITE` from 10415 to 6355 [#7095](https://github.com/lambdaclass/ethrex/pull/7095)

### 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
47 changes: 31 additions & 16 deletions crates/blockchain/blockchain.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1802,15 +1802,23 @@ impl Blockchain {
}
}

// Store all the accessed evm bytecodes
for code_hash in logger
.code_accessed
.lock()
.map_err(|_e| {
// Store all the accessed evm bytecodes. `code_accessed` records one entry
// per read, and a contract read for both its bytecode and its length is
// recorded twice, so dedup before embedding: a repeated hash would put the
// same (up to 24KB) bytecode in the witness more than once.
let accessed_codes: Vec<H256> = {
let accessed = logger.code_accessed.lock().map_err(|_e| {
ChainError::WitnessGeneration("Failed to gather used bytecodes".to_string())
})?
.iter()
{
})?;
let mut seen =
FxHashSet::with_capacity_and_hasher(accessed.len(), Default::default());
accessed
.iter()
.copied()
.filter(|h| seen.insert(*h))
.collect()
};
for code_hash in &accessed_codes {
let code = self
.storage
.get_account_code(*code_hash)
Expand Down Expand Up @@ -2067,15 +2075,22 @@ impl Blockchain {
}
}

// Store all the accessed evm bytecodes
for code_hash in logger
.code_accessed
.lock()
.map_err(|_e| {
// Store all the accessed evm bytecodes. `code_accessed` records one entry
// per read, and a contract read for both its bytecode and its length is
// recorded twice, so dedup before embedding: a repeated hash would put the
// same (up to 24KB) bytecode in the witness more than once.
let accessed_codes: Vec<H256> = {
let accessed = logger.code_accessed.lock().map_err(|_e| {
ChainError::WitnessGeneration("Failed to gather used bytecodes".to_string())
})?
.iter()
{
})?;
let mut seen = FxHashSet::with_capacity_and_hasher(accessed.len(), Default::default());
accessed
.iter()
.copied()
.filter(|h| seen.insert(*h))
.collect()
};
for code_hash in &accessed_codes {
let code = self
.storage
.get_account_code(*code_hash)
Expand Down
111 changes: 78 additions & 33 deletions crates/common/types/account.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,11 @@ use ethrex_rlp::{
use super::GenesisAccount;
use crate::constants::{EMPTY_KECCAK_HASH, EMPTY_TRIE_HASH};

/// Shared empty jump-target table. `Code::default()` and any bytecode without a
/// Shared empty jumpdest bitmap. `Code::default()` and any bytecode without a
/// `JUMPDEST` clone this (a refcount bump) instead of allocating a fresh empty
/// `Arc` header each time. This matters because the per-tx `Code::default()`
/// placeholder and every EOA / empty-code load would otherwise each allocate.
static EMPTY_JUMP_TARGETS: LazyLock<Arc<[u32]>> = LazyLock::new(|| Arc::from(Vec::new()));
static EMPTY_JUMPDESTS: LazyLock<Arc<[u8]>> = LazyLock::new(|| Arc::from(Vec::new()));

/// Trailing STOP bytes appended to every bytecode so the dispatch loop can read
/// the next opcode without a bounds check. 33 is the widest single-opcode advance
Expand All @@ -42,13 +42,19 @@ pub struct Code {
bytecode: Bytes,
/// The real bytecode length, needed for some opcodes, `bytecode` is padded with 33 STOPs to avoid checked adds on hot loop.
bytecode_len: usize,
// `Arc<[u32]>` so cloning `Code` (hot: every message-call resolves and clones
// the callee's code) is a refcount bump instead of deep-copying the table.
/// One bit per bytecode byte, set when that offset holds a `JUMPDEST` that is not
/// part of a `PUSH` immediate. Costs `ceil(len / 8)` bytes regardless of how dense
/// the jump destinations are, and validating a jump is a bit test rather than a
/// search.
///
/// Bytecode with no jump destination stores a zero-length bitmap ([`EMPTY_JUMPDESTS`])
/// rather than an all-zero one, so this is not always `ceil(len / 8)` bytes long;
/// [`Code::is_valid_jumpdest`] reads a missing byte as "no jump destination".
//
// `Arc<[u8]>` so cloning `Code` (hot: every message-call resolves and clones
// the callee's code) is a refcount bump instead of deep-copying the bitmap.
// Serializes via serde's `rc` feature (enabled workspace-wide).
// The valid addresses are 32-bit because, despite EIP-3860 restricting initcode size,
// this does not apply to previous forks. This is tested in the EEST tests, which would
// panic in debug mode.
pub jump_targets: Arc<[u32]>,
jumpdests: Arc<[u8]>,
Comment on lines -51 to +57

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.

This changes the serialization of Code and therefore of AccountUpdate, breaking store_account_updates_by_block_number.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Right, thanks. Fixed in 9a4c1fd by dropping the jump destinations from the wire format entirely and recomputing them on deserialize: they are a pure function of the bytecode, so carrying them coupled AccountUpdates stored format to how they happen to be represented. The format is now hash plus bytecode, so a future representation change cannot break it again, and the payload loses a byte per eight bytes of code. Added a test asserting the field set.

Note this still does not make rows written by an older binary readable, and the rollup store has no schema version to gate on. Since those rows are consumed at batch commit the window is small, but tell me if you want a version guard instead.

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.

I would rather have a migration and not break things.

We could simply keep the field on CodeSerde and do the migration later if you want. For the encode path, transforming from a bitmap to a list should be easy.

}

impl Code {
Expand All @@ -59,26 +65,26 @@ impl Code {
// `code` is the logical, unpadded bytecode; `BYTECODE_PADDING` STOP bytes are
// appended internally by `from_parts_unchecked`.
pub fn from_bytecode_unchecked(code: Bytes, hash: H256) -> Self {
let jump_targets = Self::compute_jump_targets(&code);
Self::from_parts_unchecked(hash, &code, jump_targets)
let jumpdests = Self::compute_jumpdests(&code);
Self::from_parts_unchecked(hash, &code, jumpdests)
}

/// `code` is the logical, unpadded bytecode; `BYTECODE_PADDING` STOP bytes are
/// appended internally by `from_parts_unchecked`.
pub fn from_bytecode(code: Bytes, crypto: &dyn Crypto) -> Self {
let jump_targets = Self::compute_jump_targets(&code);
let jumpdests = Self::compute_jumpdests(&code);
let hash = H256(crypto.keccak256(code.as_ref()));
Self::from_parts_unchecked(hash, &code, jump_targets)
Self::from_parts_unchecked(hash, &code, jumpdests)
}

/// Builds a `Code` from precomputed parts. The caller must guarantee `hash`
/// and `jump_targets` correspond to `code`; neither is recomputed or validated.
/// and `jumpdests` correspond to `code`; neither is recomputed or validated.
///
/// `code` is the logical, unpadded bytecode: this function appends
/// `BYTECODE_PADDING` STOP bytes and records the original length in
/// `bytecode_len`. Never pass a pre-padded buffer, or the logical length and
/// every `JUMPDEST`/`PUSH` offset derived from it would be wrong.
pub fn from_parts_unchecked(hash: H256, code: &[u8], jump_targets: Arc<[u32]>) -> Self {
pub fn from_parts_unchecked(hash: H256, code: &[u8], jumpdests: Arc<[u8]>) -> Self {
let bytecode_len = code.len();
let mut padded_code = Vec::with_capacity(bytecode_len + BYTECODE_PADDING);
padded_code.extend_from_slice(code);
Expand All @@ -87,20 +93,38 @@ impl Code {
hash,
bytecode: Bytes::from_owner(padded_code),
bytecode_len,
jump_targets,
jumpdests,
}
}

fn compute_jump_targets(code: &[u8]) -> Arc<[u32]> {
debug_assert!(code.len() <= u32::MAX as usize);
let mut targets = Vec::new();
/// Builds the [`Code::jumpdests`] bitmap: one pass over the bytecode, setting the
/// bit for every `JUMPDEST` while skipping `PUSH` immediates.
///
/// The bits of a byte are accumulated in a register and written once the scan leaves
/// that byte, which the monotonic `i` makes safe. Reading the bitmap back inside the
/// loop instead would turn each `JUMPDEST` into a read-modify-write, and indexing it
/// would put a bounds-check panic path in the loop body, which inhibits optimization
/// of every iteration rather than only the ones that find a destination.
pub fn compute_jumpdests(code: &[u8]) -> Arc<[u8]> {
let mut bitmap = vec![0u8; code.len().div_ceil(8)];
let mut any = false;
let mut current_byte = usize::MAX;
let mut bits = 0u8;
let mut i = 0;
while i < code.len() {
// TODO: we don't use the constants from the vm module to avoid a circular dependency
match code[i] {
// OP_JUMPDEST
0x5B => {
targets.push(i as u32);
if i / 8 != current_byte {
if let Some(byte) = bitmap.get_mut(current_byte) {
*byte = bits;
}
current_byte = i / 8;
bits = 0;
}
bits |= 1 << (i % 8);
any = true;
}
// OP_PUSH1..32
c @ 0x60..0x80 => {
Expand All @@ -111,15 +135,34 @@ impl Code {
}
i += 1;
}
// Share the single empty table for jumpless bytecode (very common: EOAs,
// tiny contracts) so we don't allocate an `Arc` header for an empty slice.
if targets.is_empty() {
EMPTY_JUMP_TARGETS.clone()
if let Some(byte) = bitmap.get_mut(current_byte) {
*byte = bits;
}
// Share the single empty bitmap for jumpless bytecode (very common: EOAs,
// tiny contracts) so we don't allocate for an all-zero map; `is_valid_jumpdest`
// reads a missing byte as "no jump destination".
if any {
Arc::from(bitmap)
} else {
Arc::from(targets)
EMPTY_JUMPDESTS.clone()
}
}

/// Whether `offset` is a valid jump destination, i.e. it holds a `JUMPDEST` that is
/// not part of a `PUSH` immediate. Offsets past the bytecode are not valid.
#[inline]
pub fn is_valid_jumpdest(&self, offset: usize) -> bool {
self.jumpdests
.get(offset / 8)
.is_some_and(|byte| byte & (1 << (offset % 8)) != 0)
}

/// The raw [`Code::jumpdests`] bitmap, for persisting it alongside the bytecode.
#[inline]
pub fn jumpdests(&self) -> &[u8] {
&self.jumpdests
}

#[inline]
pub fn code(&self) -> &[u8] {
self.bytecode.get(..self.bytecode_len).unwrap_or_default()
Expand Down Expand Up @@ -151,16 +194,18 @@ impl Code {
/// Estimates the size of the Code struct in bytes
/// (including stack size and heap allocation).
///
/// Note: This is an estimation and may not be exact.
/// Note: an estimate. It ignores allocator overhead and the `Arc`/`Bytes` control
/// blocks, so it slightly under-counts, and a shared allocation is attributed in
/// full to every entry holding it.
///
/// # Returns
///
/// usize - Estimated size in bytes
pub fn size(&self) -> usize {
let hash_size = size_of::<H256>();
let bytes_size = size_of::<Bytes>();
let vec_size = size_of::<Arc<[u32]>>() + self.jump_targets.len() * size_of::<u32>();
hash_size + bytes_size + vec_size
let bytes_size = size_of::<Bytes>() + self.bytecode.len();
let bitmap_size = size_of::<Arc<[u8]>>() + self.jumpdests.len();
hash_size + bytes_size + bitmap_size
}
}

Expand All @@ -174,15 +219,15 @@ impl Code {
struct CodeSerde {
hash: H256,
code: Bytes,
jump_targets: Arc<[u32]>,
jumpdests: Arc<[u8]>,
}

impl Serialize for Code {
fn serialize<S: serde::Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
CodeSerde {
hash: self.hash,
code: self.code_bytes(),
jump_targets: self.jump_targets.clone(),
jumpdests: self.jumpdests.clone(),
}
.serialize(serializer)
}
Expand All @@ -193,9 +238,9 @@ impl<'de> Deserialize<'de> for Code {
let CodeSerde {
hash,
code,
jump_targets,
jumpdests,
} = CodeSerde::deserialize(deserializer)?;
Ok(Self::from_parts_unchecked(hash, &code, jump_targets))
Ok(Self::from_parts_unchecked(hash, &code, jumpdests))
}
}

Expand Down Expand Up @@ -263,7 +308,7 @@ impl Default for Code {
bytecode: Bytes::from_static(&[0u8; BYTECODE_PADDING]),
bytecode_len: 0,
hash: *EMPTY_KECCAK_HASH,
jump_targets: EMPTY_JUMP_TARGETS.clone(),
jumpdests: EMPTY_JUMPDESTS.clone(),
}
}
}
Expand Down
38 changes: 30 additions & 8 deletions crates/storage/backend/rocksdb.rs
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
use crate::api::tables::{
ACCOUNT_CODES, ACCOUNT_FLATKEYVALUE, ACCOUNT_TRIE_NODES, BLOCK_NUMBERS, BODIES,
CANONICAL_BLOCK_HASHES, FULLSYNC_HEADERS, HEADERS, RECEIPTS_V2, STORAGE_FLATKEYVALUE,
ACCOUNT_CODE_METADATA, ACCOUNT_CODES, ACCOUNT_FLATKEYVALUE, ACCOUNT_TRIE_NODES, BLOCK_NUMBERS,
BODIES, CANONICAL_BLOCK_HASHES, FULLSYNC_HEADERS, HEADERS, RECEIPTS_V2, STORAGE_FLATKEYVALUE,
STORAGE_TRIE_NODES, TRANSACTION_LOCATIONS,
};
use crate::api::{
Expand Down Expand Up @@ -208,18 +208,40 @@ impl RocksDBBackend {
configure_block_cache(&mut block_opts);
cf_opts.set_block_based_table_factory(&block_opts);
}
ACCOUNT_CODES => {
ACCOUNT_CODES | ACCOUNT_CODE_METADATA => {
cf_opts.set_write_buffer_size(128 * 1024 * 1024); // 128MB
cf_opts.set_max_write_buffer_number(3);
cf_opts.set_target_file_size_base(256 * 1024 * 1024); // 256MB

cf_opts.set_enable_blob_files(true);
// Small bytecodes should go inline (mainly for delegation indicators)
cf_opts.set_min_blob_size(32);
cf_opts.set_blob_compression_type(rocksdb::DBCompressionType::Lz4);
if cf_name == ACCOUNT_CODES {
cf_opts.set_enable_blob_files(true);
// Small bytecodes should go inline (mainly for delegation indicators)
cf_opts.set_min_blob_size(32);
cf_opts.set_blob_compression_type(rocksdb::DBCompressionType::Lz4);
}

let mut block_opts = BlockBasedOptions::default();
block_opts.set_block_size(32 * 1024); // 32KB
// Both CFs answer exact-key point lookups on the execution read path:
// EXT*/CALL* resolve a code hash to its bytecode or its length. The
// filter is what pays here, pruning the levels that cannot hold the
// hash instead of reading a data block per level to find out.
block_opts.set_bloom_filter(10.0, false); // 10 bits per key
if cf_name == ACCOUNT_CODES {
// With blob files the SST value is only a blob reference, so a
// large block buys nothing; page-sized keeps per-get read
// amplification down.
block_opts.set_block_size(4 * 1024); // 4KB
// Lookups here are almost always positive, since the hash comes
// from an account that references it. Dropping the last level's
// filter is most of the filter memory for no hit-rate loss.
cf_opts.set_optimize_filters_for_hits(true);
} else {
// Metadata rows are 32-byte key + 8-byte length with no blob
// indirection, so ~100 of them share a 4KB block and shrinking
// the block only multiplies index entries. Keep the 16KB this CF
// used before it had an arm of its own.
block_opts.set_block_size(16 * 1024); // 16KB
}
configure_block_cache(&mut block_opts);
cf_opts.set_block_based_table_factory(&block_opts);
}
Expand Down
2 changes: 1 addition & 1 deletion crates/storage/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ pub use store::{
/// When bumping this version, add a corresponding migration function to
/// `migrations::MIGRATIONS`. The migration framework will automatically
/// upgrade existing databases instead of requiring a full resync.
pub const STORE_SCHEMA_VERSION: u64 = 3;
pub const STORE_SCHEMA_VERSION: u64 = 4;

/// Name of the file storing the metadata about the database.
///
Expand Down
15 changes: 14 additions & 1 deletion crates/storage/migrations.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ pub type MigrationFn = fn(backend: &dyn StorageBackend) -> Result<(), StoreError
///
/// **Invariant**: `MIGRATIONS.len() == (STORE_SCHEMA_VERSION - 1) as usize`
/// (empty when `STORE_SCHEMA_VERSION == 1`, one entry when it's 2, etc.)
pub const MIGRATIONS: &[MigrationFn] = &[migrate_1_to_2, migrate_2_to_3];
pub const MIGRATIONS: &[MigrationFn] = &[migrate_1_to_2, migrate_2_to_3, migrate_3_to_4];

// Compile-time check: the number of migration functions must match the number
// of version gaps (i.e. STORE_SCHEMA_VERSION - 1).
Expand All @@ -44,6 +44,19 @@ fn migration_for_version(version: u64) -> MigrationFn {
MIGRATIONS[(version - 1) as usize]
}

/// v3 → v4: no data change.
///
/// `ACCOUNT_CODES` values carry their JUMPDEST positions as a bitmap rather than an RLP
/// list of `u32` offsets. Both forms are readable, so a v3 database needs no rewriting
/// and this migration only moves the version marker.
///
/// The bump exists for the other direction: a v3 binary cannot decode a bitmap, and
/// `run_pending_migrations` warns that the database is ahead of the binary instead of
/// letting it fail on the first code read.
fn migrate_3_to_4(_backend: &dyn StorageBackend) -> Result<(), StoreError> {
Ok(())
}

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.

Since there is no migration, the "recompute everything" fallback will be used on every cold read. This is likely going to cause a big performance hit.


/// Minimum interval between migration progress log lines.
const PROGRESS_LOG_INTERVAL: Duration = Duration::from_secs(10);

Expand Down
Loading
Loading