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 @@ -27,6 +27,10 @@
- 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)
- Batch and stream the BAL contract-code prefetch: warm accounts and their code in chunks instead of reading every access-list account before the first bytecode, take code hashes from the account read rather than a second lookup per account, and add a batched bytecode read that resolves the buffer and code cache first, then either fans out parallel point gets or shards the remainder across concurrent `multi_get`s, whichever reaches the greater read queue depth for the batch size on this host [#7099](https://github.com/lambdaclass/ethrex/pull/7099)

### 2026-08-19

- Encode trie leaf and extension nodes directly into the hashing buffer, dropping the intermediate `Vec` that each node allocated and copied on every hash [#7155](https://github.com/lambdaclass/ethrex/pull/7155)

### 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
45 changes: 35 additions & 10 deletions crates/common/trie/nibbles.rs
Original file line number Diff line number Diff line change
Expand Up @@ -571,10 +571,36 @@ impl Nibbles {
self.data.push(nibble);
}

/// Number of bytes [`Self::encode_compact`] produces, without producing them.
///
/// The compact form is one header byte plus one byte per pair of remaining
/// nibbles. An odd nibble count folds its first nibble into the header, so
/// both parities land on the same expression.
///
/// Node encoders need this to size an RLP header before writing the payload,
/// which is what lets them skip the intermediate buffer entirely.
#[inline]
pub fn encode_compact_len(&self) -> usize {
(self.data.len() - usize::from(self.is_leaf())) / 2 + 1
}

/// Taken from https://github.com/citahub/cita_trie/blob/master/src/nibbles.rs#L56
/// Encodes the nibbles in compact form
#[allow(unsafe_code)]
pub fn encode_compact(&self) -> Vec<u8> {
let mut compact = Vec::with_capacity(self.encode_compact_len());
self.encode_compact_into(&mut compact);
compact
}

/// Appends the compact form to `out` instead of returning a fresh `Vec`.
///
/// Trie node hashing calls this once per leaf and extension node — roughly
/// 260k times per mainnet block in the zkVM guest — so the allocation the
/// returning form needs is worth avoiding. The guest's bump allocator never
/// reclaims and its `realloc` always copies, so each of those allocations is
/// permanently consumed heap.
#[allow(unsafe_code)]
pub fn encode_compact_into(&self, out: &mut Vec<u8>) {
let is_leaf = self.is_leaf();
let mut hex = if is_leaf {
&self.data[0..self.data.len() - 1]
Expand All @@ -596,20 +622,19 @@ impl Nibbles {
};

let pair_count = hex.len() / 2;
let mut compact = Vec::with_capacity(1 + pair_count);
compact.push(prefix_nibble + if is_leaf { 0x20 } else { 0x00 });
out.reserve(1 + pair_count);
out.push(prefix_nibble + if is_leaf { 0x20 } else { 0x00 });

// SIMD-accelerated packing of nibble pairs → bytes.
// SAFETY: compact has capacity for `pair_count` bytes beyond the one already pushed.
// pack_nibble_pairs writes exactly `pair_count` bytes starting at offset 1;
// set_len then exposes those initialized bytes.
// SAFETY: `reserve` guaranteed `1 + pair_count` bytes of spare capacity and
// the push consumed one, so `pair_count` bytes remain writable at `len()`.
// pack_nibble_pairs writes exactly that many; set_len then exposes them.
unsafe {
let out_ptr = compact.as_mut_ptr().add(1);
let len = out.len();
let out_ptr = out.as_mut_ptr().add(len);
pack_nibble_pairs(hex, out_ptr);
compact.set_len(1 + pair_count);
out.set_len(len + pair_count);
}

compact
}

/// Encodes the nibbles in compact form
Expand Down
12 changes: 11 additions & 1 deletion crates/common/trie/node.rs
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,7 @@ impl NodeRef {
&& hash.get().is_none()
{
node.memoize_hashes(buf, crypto);
let _ = hash.set(node.compute_hash_no_alloc(buf, crypto));
let _ = hash.set(node.hash_memoized(buf, crypto));
}
}

Expand Down Expand Up @@ -524,6 +524,16 @@ impl Node {
/// Computes the node's hash
pub fn compute_hash_no_alloc(&self, buf: &mut Vec<u8>, crypto: &dyn Crypto) -> NodeHash {
self.memoize_hashes(buf, crypto);
self.hash_memoized(buf, crypto)
}

/// Computes the node's hash, assuming its children are already memoized.
///
/// Split out from [`Self::compute_hash_no_alloc`] because `NodeRef::memoize_hashes`
/// memoizes the subtrie itself and then hashes the node — going back through
/// the full entry point made it walk all 16 children a second time, once per
/// branch, purely to find every hash already set.
pub(crate) fn hash_memoized(&self, buf: &mut Vec<u8>, crypto: &dyn Crypto) -> NodeHash {
match self {
Node::Branch(n) => n.compute_hash_no_alloc(buf, crypto),
Node::Extension(n) => n.compute_hash_no_alloc(buf, crypto),
Expand Down
4 changes: 1 addition & 3 deletions crates/common/trie/node/branch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -282,9 +282,7 @@ impl BranchNode {
pub fn compute_hash_no_alloc(&self, buf: &mut Vec<u8>, crypto: &dyn Crypto) -> NodeHash {
buf.clear();
self.encode_into_vec(buf);
let hash = NodeHash::from_encoded(buf, crypto);
buf.clear();
hash
NodeHash::from_encoded(buf, crypto)
}

/// Traverses own subtrie until reaching the node containing `path`
Expand Down
6 changes: 2 additions & 4 deletions crates/common/trie/node/extension.rs
Original file line number Diff line number Diff line change
Expand Up @@ -247,10 +247,8 @@ impl ExtensionNode {
/// Computes the node's hash, using the provided buffer
pub fn compute_hash_no_alloc(&self, buf: &mut Vec<u8>, crypto: &dyn Crypto) -> NodeHash {
buf.clear();
self.encode(buf);
let hash = NodeHash::from_encoded(buf, crypto);
buf.clear();
hash
self.encode_into_vec(buf);
NodeHash::from_encoded(buf, crypto)
}

/// Traverses own subtrie until reaching the node containing `path`
Expand Down
6 changes: 2 additions & 4 deletions crates/common/trie/node/leaf.rs
Original file line number Diff line number Diff line change
Expand Up @@ -148,10 +148,8 @@ impl LeafNode {
/// Computes the node's hash, using the provided buffer
pub fn compute_hash_no_alloc(&self, buf: &mut Vec<u8>, crypto: &dyn Crypto) -> NodeHash {
buf.clear();
self.encode(buf);
let hash = NodeHash::from_encoded(buf, crypto);
buf.clear();
hash
self.encode_into_vec(buf);
NodeHash::from_encoded(buf, crypto)
}

/// Encodes the node and appends it to `node_path` if the encoded node is 32 or more bytes long
Expand Down
142 changes: 140 additions & 2 deletions crates/common/trie/rlp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -95,14 +95,19 @@ impl BranchNode {
encode_length(payload_len, buf);
for hash in hashes {
match hash {
NodeHash::Hashed(hash) => hash.0.encode(&mut *buf),
// Written directly rather than through `RLPEncode::encode`, which
// would take a trait object and pay a virtual call per byte group.
NodeHash::Hashed(hash) => {
buf.push(RLP_NULL + 32);
buf.extend_from_slice(&hash.0);
}
NodeHash::Inline((_, 0)) => buf.push(RLP_NULL),
NodeHash::Inline((encoded, len)) => {
buf.extend_from_slice(&encoded[..*len as usize])
}
}
}
<[u8] as RLPEncode>::encode(&self.value, buf);
put_rlp_bytes(&self.value, buf);
}
}

Expand All @@ -123,6 +128,139 @@ impl RLPEncode for LeafNode {
}
}

// ── Allocation-free node encoding for the hashing path ───────────────────────
//
// `RLPEncode::encode` has to take a `&mut dyn BufMut`, and `Encoder` accumulates
// a node's payload in a fresh heap `Vec` so it can prepend the list header once
// the payload length is known — then copies the whole payload across. Branch
// nodes already sidestep both (see `BranchNode::encode_into_vec` above); leaf and
// extension did not, so hashing one cost an allocation for the hex-prefix path,
// several more growing the encoder's scratch buffer, and a full second pass over
// the bytes.
//
// Every node's payload length is computable before any of it is written, so the
// header can go down first and the node can be built in place. That matters most
// in the zkVM guest, whose bump allocator never reclaims and whose `realloc`
// always allocates fresh and memcpys, so allocation churn is permanently
// consumed heap rather than reused blocks.
//
// `RLPEncode::encode` is left untouched for every other caller.

/// RLP-encodes a byte string into a concrete `Vec`. Byte-identical to
/// `<[u8] as RLPEncode>::encode`, without the trait object.
#[inline]
fn put_rlp_bytes(value: &[u8], buf: &mut Vec<u8>) {
if let [single] = value
&& *single < RLP_NULL
{
buf.push(*single);
} else if value.len() < 56 {
buf.push(RLP_NULL + value.len() as u8);
buf.extend_from_slice(value);
} else {
let be = value.len().to_be_bytes();
let start = be.iter().position(|&x| x != 0).unwrap_or(be.len() - 1);
buf.push(0xb7 + (be.len() - start) as u8);
buf.extend_from_slice(&be[start..]);
buf.extend_from_slice(value);
}
}

/// Byte length of the RLP encoding of a hex-prefix path of `compact_len` bytes.
///
/// The compact form's first byte is the hex-prefix header — at most `0x3f` — so a
/// one-byte path is always its own RLP encoding, and that can be decided from the
/// length alone. A trie path is at most 64 nibbles, so `compact_len` never
/// exceeds 33 and the long-form arm is unreachable; it is spelled out anyway
/// rather than assumed.
#[inline]
const fn compact_path_rlp_len(compact_len: usize) -> usize {
if compact_len == 1 {
1
} else if compact_len < 56 {
1 + compact_len
} else {
1 + (compact_len.ilog2() as usize / 8 + 1) + compact_len
}
}

/// Writes the RLP header for a hex-prefix path of `compact_len` bytes; the
/// payload follows from `Nibbles::encode_compact_into`. Pairs with
/// [`compact_path_rlp_len`] — the two must agree byte for byte.
#[inline]
fn put_compact_path_header(compact_len: usize, buf: &mut Vec<u8>) {
if compact_len == 1 {
// Encoded as the bare byte, which `encode_compact_into` writes itself.
} else if compact_len < 56 {
buf.push(RLP_NULL + compact_len as u8);
} else {
let be = compact_len.to_be_bytes();
let start = be.iter().position(|&x| x != 0).unwrap_or(be.len() - 1);
buf.push(0xb7 + (be.len() - start) as u8);
buf.extend_from_slice(&be[start..]);
}
}

/// Bytes an extension node's child reference occupies, mirroring
/// `NodeHash::encode`: a 32-byte hash becomes an RLP byte string, an inline node
/// is spliced in verbatim. An empty inline hash contributes nothing, which is
/// what the `Encoder` path produces today — it only arises from a malformed
/// trie, and this is not the place to start rejecting one.
#[inline]
const fn extension_child_len(hash: &NodeHash) -> usize {
match hash {
NodeHash::Hashed(_) => 33,
NodeHash::Inline((_, len)) => *len as usize,
}
}

/// Writes an extension node's child reference. Pairs with
/// [`extension_child_len`].
#[inline]
fn put_extension_child(hash: &NodeHash, buf: &mut Vec<u8>) {
match hash {
NodeHash::Hashed(hash) => {
buf.push(RLP_NULL + 32);
buf.extend_from_slice(&hash.0);
}
NodeHash::Inline((encoded, len)) => buf.extend_from_slice(&encoded[..*len as usize]),
}
}

impl LeafNode {
/// Concrete-typed sibling of `<Self as RLPEncode>::encode`, building the node
/// directly into `buf`. See the module note above.
pub fn encode_into_vec(&self, buf: &mut Vec<u8>) {
let path_len = self.partial.encode_compact_len();
let payload_len =
compact_path_rlp_len(path_len) + <[u8] as RLPEncode>::length(&self.value);

encode_length(payload_len, buf);
put_compact_path_header(path_len, buf);
self.partial.encode_compact_into(buf);
put_rlp_bytes(&self.value, buf);
}
}

impl ExtensionNode {
/// Concrete-typed sibling of `<Self as RLPEncode>::encode`, building the node
/// directly into `buf`. See the module note above.
///
/// Reads the child's hash through `compute_hash_ref`, which returns the
/// memoized value on the hashing path — `memoize_hashes` populates children
/// before their parent is encoded, so `NativeCrypto` is never invoked here.
pub fn encode_into_vec(&self, buf: &mut Vec<u8>) {
let path_len = self.prefix.encode_compact_len();
let child = self.child.compute_hash_ref(&NativeCrypto);
let payload_len = compact_path_rlp_len(path_len) + extension_child_len(child);

encode_length(payload_len, buf);
put_compact_path_header(path_len, buf);
self.prefix.encode_compact_into(buf);
put_extension_child(child, buf);
}
}

impl RLPEncode for Node {
fn encode(&self, buf: &mut dyn bytes::BufMut) {
match self {
Expand Down
Loading