Skip to content

perf(l1): give branch-node hashing a monomorphic RLP encoder - #7104

Merged
iovoid merged 1 commit into
mainfrom
perf/trie-branch-rlp-monomorphic
Aug 6, 2026
Merged

perf(l1): give branch-node hashing a monomorphic RLP encoder#7104
iovoid merged 1 commit into
mainfrom
perf/trie-branch-rlp-monomorphic

Conversation

@diegokingston

Copy link
Copy Markdown
Contributor

Motivation

RLPEncode::encode takes &mut dyn bytes::BufMut. Encoding a branch node writes ~41 items through that trait object (16 children — each an RLP length byte plus a 32-byte hash — plus the header and value), so each one is a vtable dispatch.

That lands on the hashing path: BranchNode::compute_hash_no_alloc encodes into a Vec<u8> before keccak, and every memoize_hashes walk re-encodes each dirty branch. Note encode_to_vec directly below it is already monomorphic (buf.push / extend_from_slice) — it just allocates a fresh buffer each call, so the hashing path can't use it.

Description

Add BranchNode::encode_into_vec(&self, buf: &mut Vec<u8>), a concrete-typed sibling that appends into a caller-owned Vec<u8>, and point compute_hash_no_alloc at it. RLPEncode::encode is untouched for every other caller.

Also resolve each child's hash once rather than twice — the payload-length pass and the encode pass each called compute_hash_ref, so a 16-choice branch performed 32 resolutions.

cargo test -p ethrex-trie (61 tests), fmt and clippy are clean.

Effect

The win scales with how poorly indirect calls are amortised. These numbers come from the LambdaVM zkVM guest executing mainnet block 25368371, where every RISC-V instruction is proven and there is no out-of-order core to hide a vtable dispatch:

cycles
BranchNode::encode + put_slice + put_u8 2,122,427 → 1,312,210 (−38%)
memcpy 908,885 → 586,969
whole-block execution 20,544,222 → 19,408,617 (−5.53%)

memcpy drops as well because extend_from_slice into a Vec with known capacity beats the trait object's put_slice.

Native gains will be considerably smaller — a modern core predicts and pipelines these calls well — but the change strictly removes indirection, so it should not regress. Happy to add a native benchmark before merging if you'd like a number for that path.

Companion to #7101 (same guest, same profiling run).

`RLPEncode::encode` takes `&mut dyn bytes::BufMut`, so encoding a branch node
pays a vtable dispatch for each of its ~41 `put_u8`/`put_slice` calls. That is
on the hashing path: `compute_hash_no_alloc` encodes into a `Vec<u8>` and every
`memoize_hashes` walk re-encodes each dirty branch.

Add `BranchNode::encode_into_vec`, a concrete-typed sibling that appends into a
`Vec<u8>` (mirroring the existing `encode_to_vec`, which is already monomorphic
but allocates a fresh buffer), and point `compute_hash_no_alloc` at it.
`RLPEncode::encode` is unchanged for every other caller.

Also resolve each child hash once instead of twice: the payload-length pass and
the encode pass each called `compute_hash_ref`, so a 16-choice branch did 32
resolutions.

Measured in the LambdaVM zkVM guest on mainnet block 25368371, where every
executed RISC-V instruction is proven and an indirect call cannot be hidden by
an out-of-order core:

  BranchNode::encode + put_slice + put_u8   2,122,427 -> 1,312,210  (-38%)
  memcpy                                      908,885 ->   586,969
  total block execution                    20,544,222 -> 19,408,617  (-5.53%)

`memcpy` falls too because `extend_from_slice` on a `Vec` with known capacity
beats the trait object's `put_slice`. Native gains will be much smaller, but the
change strictly removes indirection.
@diegokingston
diegokingston requested a review from a team as a code owner August 4, 2026 17:15
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

Stateless (zkEVM) Amsterdam+ EF tests skipped

Where: tooling/ef_tests/blockchain/test_runner.rsparse_and_execute skips
fixtures with network >= Fork::Amsterdam when running with a stateless backend.
Affects make test-stateless (the vectors_zkevm/ run); make test-levm is
unaffected.

Why: The stateless run uses the tests-zkevm@v0.5.0 bundle, filled against
glamsterdam-devnet v6.1.0, which predeploys the EIP-8282 builder deposit/exit
contracts at the OLD addresses (0x0000884d…d9008282 / 0x000014574a…0f008282).
This client uses the devnet-7 addresses (0x0000bff4…300d8282 /
0x000064d6…800e8282, matching the live tests-glamsterdam-devnet@v7.2.0 bundle
used by make test-levm). Every Amsterdam+ block runs the end-of-block EIP-8282
builder system call; with the new addresses absent from the v0.5.0 bundle, each
stateless Amsterdam+ block fails with
SystemContractCallFailed("System contract: 0x0000…8282 has no code after deployment").
The skip is by fork rather than by test name, since cross-fork directories such as
for_amsterdam/prague/... still execute at the Amsterdam fork.

Removal: Delete the skip_stateless_amsterdam branch in parse_and_execute
once a tests-zkevm bundle filled with the devnet-7 builder predeploy addresses is
released and .fixtures_url_zkevm is bumped to it.

@github-actions github-actions Bot added the performance Block execution throughput and performance in general label Aug 4, 2026
@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

Overall Assessment: This is a solid performance optimization that eliminates redundant hash computations and vtable dispatches in the trie's hot path. The logic is correct, but there are maintainability risks due to code duplication.

Specific Feedback:

1. Consensus-Critical Code Duplication Risk (crates/common/trie/rlp.rs)
The RLPEncode::encode and encode_into_vec implementations are nearly identical. Any future modification to branch node RLP encoding (e.g., EIP changes) risks updating one but not the other, causing consensus failures.

  • Line 29-49 and Line 77-101: Add explicit doc comments warning maintainers to keep both implementations synchronized.
  • Recommendation: Add a unit test that encodes random branch nodes using both methods and asserts byte-for-byte equality. This is critical for consensus safety.

2. Import Visibility (crates/common/trie/rlp.rs)

  • Line 31: Ensure use std::array; or use std::array::from_fn; is present (not visible in diff). If using Rust 2021 edition, std::array::from_fn is available via prelude, but explicit import is clearer.

3. Bounds Checking (crates/common/trie/rlp.rs)

  • Line 47 and Line 94: &encoded[..*len as usize] assumes len <= encoded.len(). While this is likely an invariant of NodeHash::Inline, a debug assertion or comment documenting this invariant would improve safety:
    debug_assert!((*len as usize) <= encoded.len(), "Inline hash length invariant violated");

4. Method Naming Consistency (crates/common/trie/node/branch.rs)

  • Line 284: The change from encode to encode_into_vec is correct given the Vec<u8> buffer type. However, consider whether compute_hash_no_alloc should be generic over BufMut or if specializing to Vec<u8> is intentional. The current approach is fine if this is exclusively used with Vec<u8>.

5. Optimization Validation

  • The comment at Line 76-83 correctly identifies the vtable dispatch overhead. Consider adding a #[inline] attribute to encode_into_vec since it's a hot path and only called from one location (line 284), though the compiler likely handles this.

Positive Aspects:

  • Line 31-32: Excellent use of array::from_fn to stack-allocate the hash cache, avoiding heap allocation.
  • Line 33-36: Correctly folding over pre-computed hashes eliminates the 2x redundant hash computation (16 for length + 16 for encoding).
  • Line 90: Correct coercion &mut *buf from concrete Vec<u8> to &mut dyn BufMut for the nested RLPEncode::encode call.

Security/Consensus: No vulnerabilities introduced. The encoding logic preserves exact byte-for-byte compatibility with the previous implementation.


Automated review by Kimi (Moonshot AI) · kimi-k2.5 · custom prompt

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

No findings.

The change in crates/common/trie/rlp.rs and crates/common/trie/node/branch.rs looks like a safe perf-only optimization: branch encoding still emits the same RLP shape, but it now resolves each child hash once and avoids BufMut trait-object dispatch on the hot hashing path. I do not see a consensus, gas-accounting, state-trie, or memory-safety regression from the diff itself.

I could not run the trie test suite in this environment because cargo test -p test trie:: --quiet failed when rustup tried to write under /home/runner/.rustup/tmp on a read-only filesystem. Optional follow-up: add a parity test around crates/common/trie/rlp.rs asserting encode_into_vec() is byte-for-byte identical to the existing RLPEncode::encode() path for representative branch nodes, since that encoding is consensus-critical.


Automated review by OpenAI Codex · gpt-5.4 · custom prompt

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 23
Total lines removed: 0
Total lines changed: 23

Detailed view
+----------------------------------+-------+------+
| File                             | Lines | Diff |
+----------------------------------+-------+------+
| ethrex/crates/common/trie/rlp.rs | 162   | +23  |
+----------------------------------+-------+------+

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR adds a concrete Vec-based RLP encoder for branch nodes and routes branch hashing through it to remove trait-object dispatch. It also resolves each child hash once and reuses those references for payload sizing and encoding.

  • Adds BranchNode::encode_into_vec as a monomorphic encoding path
  • Uses the new path in compute_hash_no_alloc
  • Reuses resolved child hashes in both branch encoders

Confidence Score: 4/5

The PR appears safe to merge, with a non-blocking recommendation to lock the duplicated consensus-critical encoders together using an equivalence test.

The new encoder currently matches the canonical branch RLP output for hashed, empty, and inline children, but the absence of a direct equivalence test leaves future divergence undetected.

Files Needing Attention: crates/common/trie/rlp.rs

Important Files Changed

Filename Overview
crates/common/trie/node/branch.rs Routes allocation-reusing branch hashing through the new concrete Vec encoder; the hashing and buffer lifecycle remain unchanged.
crates/common/trie/rlp.rs Adds a byte-equivalent monomorphic branch encoder and reuses child hashes, but lacks a direct regression test enforcing equivalence with the canonical encoder.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  B[BranchNode] --> E[encode_into_vec]
  E --> H[Resolve 16 child hashes]
  H --> L[Compute RLP payload length]
  L --> V[Append encoded branch to Vec]
  V --> K[NodeHash::from_encoded]
Loading
Prompt To Fix All With AI
### Issue 1
crates/common/trie/rlp.rs:86-106
**Test concrete encoder equivalence**

`compute_hash_no_alloc` now relies on a second, independently maintained branch encoder for consensus-critical bytes. Add a regression test comparing `encode_into_vec` with `RLPEncode::encode` across hashed, empty, and inline children and RLP length boundaries so future drift cannot silently produce inconsistent trie hashes or state roots.

---

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

Reviews (1): Last reviewed commit: "perf(trie): give branch-node hashing a m..." | Re-trigger Greptile

Comment thread crates/common/trie/rlp.rs
Comment on lines +86 to +106
pub fn encode_into_vec(&self, buf: &mut Vec<u8>) {
let hashes: [&NodeHash; 16] =
array::from_fn(|i| self.choices[i].compute_hash_ref(&NativeCrypto));

let value_len = <[u8] as RLPEncode>::length(&self.value);
let payload_len = hashes
.iter()
.fold(value_len, |acc, hash| acc + RLPEncode::length(*hash));

encode_length(payload_len, buf);
for hash in hashes {
match hash {
NodeHash::Hashed(hash) => hash.0.encode(&mut *buf),
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);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Test concrete encoder equivalence

compute_hash_no_alloc now relies on a second, independently maintained branch encoder for consensus-critical bytes. Add a regression test comparing encode_into_vec with RLPEncode::encode across hashed, empty, and inline children and RLP length boundaries so future drift cannot silently produce inconsistent trie hashes or state roots.

Knowledge Base Used: Common Types, Trie, Crypto, RLP, and Config

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/common/trie/rlp.rs
Line: 86-106

Comment:
**Test concrete encoder equivalence**

`compute_hash_no_alloc` now relies on a second, independently maintained branch encoder for consensus-critical bytes. Add a regression test comparing `encode_into_vec` with `RLPEncode::encode` across hashed, empty, and inline children and RLP length boundaries so future drift cannot silently produce inconsistent trie hashes or state roots.

**Knowledge Base Used:** [Common Types, Trie, Crypto, RLP, and Config](https://app.greptile.com/lambdaclass/-/custom-context/knowledge-base/lambdaclass/ethrex/-/docs/common-types.md)

---

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

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review: perf(trie): give branch-node hashing a monomorphic RLP encoder (PR 7104)

Summary: The PR adds BranchNode::encode_into_vec, a Vec<u8>-typed sibling of RLPEncode::encode, used only from compute_hash_no_alloc, and deduplicates the two compute_hash_ref calls per child into one. The stated goal is to remove per-call vtable dispatch from the branch-node hashing hot path. Logic is correct and output should match the existing encoders bit-for-bit; the concerns below are about whether the optimization achieves what it claims and about long-term maintainability.

Findings

  1. The "monomorphic" path still dispatches through a vtable for the dominant case (NodeHash::Hashed). hash.0.encode(&mut *buf) (rlp.rs:98) calls H256::encode(&self, buf: &mut dyn BufMut), whose body delegates to <[u8] as RLPEncode>::encode(&self, buf: &mut dyn BufMut) — both signatures are fixed by the RLPEncode trait, so buf is still coerced to a trait object and the inner buf.put_u8/buf.put_slice calls still go through the vtable, identically to the old path. Only the NodeHash::Inline arms (buf.push/buf.extend_from_slice) and empty-slot RLP_NULL writes are genuinely monomorphic. Since most non-empty branch children beyond the shallow trie levels are Hashed (their RLP length is ≥ 32 bytes), the measured win is probably coming mostly from empty choice slots (common, since fan-out is usually well under 16) rather than the full "every put_u8/put_slice call" monomorphization the doc comment (lines 78–85) claims. Worth inlining the Hashed write directly (e.g. buf.push(RLP_NULL + 32); buf.extend_from_slice(hash.0.as_bytes());) to actually close this gap, or adjusting the doc comment to be precise about what's monomorphized.

  2. encode_to_vec (rlp.rs:52–74) still resolves each child's hash twice, the exact redundancy this PR removes from encode() and encode_into_vec(). Not a soundness issue (the second compute_hash_ref call just reads an already-populated OnceLock), but it's an inconsistency given the PR's own stated rationale, and encode_to_vec sits on the write/commit path (db.rs:39, trie.rs:334,559), which is also fairly hot.

  3. Three near-duplicate encoders for the same on-disk/hash format. encode, encode_to_vec, and now encode_into_vec independently reimplement branch-node RLP layout with no shared helper. This is consensus-critical code (feeds NodeHash/state root computation) — a future edit applied to only one of the three would silently diverge the computed hash from the others, and there's no test that pins all three encoders to produce byte-identical output for the same node. Given the trait signature constraints this may be hard to fully unify, but at minimum a test asserting encode_to_vec() == { let mut v = vec![]; encode_into_vec(&mut v); v } for varied branch shapes (empty slots, inline children, hashed children) would catch future drift.

Things that look good

  • The single-resolution fix for compute_hash_ref in encode()/encode_into_vec() is correct and safe — OnceLock-backed caching means no behavior change, just fewer redundant lookups.
  • compute_hash_no_alloc in branch.rs:284 correctly clears the buffer before and after use; no aliasing/reentrancy concerns given &self is only read.
  • Existing NativeCrypto-hardcoding and its documented SAFETY rationale (rlp.rs:20–27) are unchanged and not affected by this diff.
  • array::from_fn usage for [&NodeHash; 16] is sound; lifetimes are correctly tied to &self.

Automated review by Claude (Anthropic) · sonnet · custom prompt

Comment thread crates/common/trie/rlp.rs
@@ -27,14 +27,19 @@ use crate::{Nibbles, NodeHash};
// where `NativeCrypto` is the correct provider.
impl RLPEncode for BranchNode {
fn encode(&self, buf: &mut dyn bytes::BufMut) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

I think we should try dropping BufMut from here first instead. encode_to_vec is duplicating logic from here

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

This could be done as a follow-up right?

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Yes. We should do it as a follow-up

@edg-l edg-l changed the title perf(trie): give branch-node hashing a monomorphic RLP encoder perf(l1): give branch-node hashing a monomorphic RLP encoder Aug 5, 2026
@github-actions github-actions Bot added the L1 Ethereum client label Aug 5, 2026
@github-project-automation github-project-automation Bot moved this to In Review in ethrex_l1 Aug 5, 2026
@iovoid
iovoid added this pull request to the merge queue Aug 6, 2026
Merged via the queue into main with commit c02289d Aug 6, 2026
72 of 73 checks passed
@iovoid
iovoid deleted the perf/trie-branch-rlp-monomorphic branch August 6, 2026 13:54
@github-project-automation github-project-automation Bot moved this from Todo to Done in ethrex_performance Aug 6, 2026
@github-project-automation github-project-automation Bot moved this from In Review to Done in ethrex_l1 Aug 6, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

L1 Ethereum client performance Block execution throughput and performance in general

Projects

Status: Done
Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants