Skip to content

perf(l1): keep the memoize_hashes guard out of the recursive call - #7101

Open
diegokingston wants to merge 1 commit into
mainfrom
perf/trie-memoize-hashes-inline-guard
Open

perf(l1): keep the memoize_hashes guard out of the recursive call#7101
diegokingston wants to merge 1 commit into
mainfrom
perf/trie-memoize-hashes-inline-guard

Conversation

@diegokingston

Copy link
Copy Markdown
Contributor

Motivation

NodeRef::memoize_hashes already skips subtries whose hash is memoized, and NodeRef::Hash references have nothing to memoize at all. The problem is where that check lives: Node::memoize_hashes visits all 16 children of a branch node, and because the body is recursive LLVM will not inline it — so every child costs a real function call even when the answer is immediately "nothing to do".

How lopsided that is, measured on a real mainnet block execution witness (block 25368371):

child references count share
NodeRef::Node (embedded, must be hashed) 1,273 7.3%
NodeRef::Hash (hash only, nothing to do) 13,102 75.1%
empty slots 3,086 17.6%

So ~93% of the references a branch iterates over have no work behind them. Counting actual calls during a stateless execution of that block:

NodeRef::memoize_hashes entered   54,810 times
hash actually computed             2,094 times   -> 96.2% of calls were no-ops

Description

Split the guard into an #[inline] shell and move the recursive body into a separate function, so the overwhelmingly common no-op case becomes a load and a branch at the call site instead of a call.

Behaviour is unchanged — this is purely about where the code lives. cargo test -p ethrex-trie passes (61 tests), fmt and clippy are clean.

Effect

The win scales with how poorly the call overhead is amortised. The measurement above comes from the LambdaVM zkVM guest, where every executed RISC-V instruction is proven and there is no out-of-order core to hide the call:

cycles
before 26,794,880
after 25,314,781
−1,480,099 (−5.52% of total block execution)

memoize_hashes disappeared from the guest profile entirely (1,786,030 → 0 self cycles), and the trie went from 20.5% to 15.9% of the block.

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

`NodeRef::memoize_hashes` already skips subtries whose hash is memoized, and
`NodeRef::Hash` references have nothing to memoize at all. But the check sits
behind a call: `Node::memoize_hashes` visits all 16 children of a branch, and
because the body is recursive LLVM will not inline it, so every child costs a
real call even when the answer is immediately 'nothing to do'.

Measured on a mainnet block execution witness, 93% of child references are
`NodeRef::Hash` or empty slots, and only 7.3% are embedded nodes. Counting
calls in a stateless run of block 25368371: `memoize_hashes` was entered 54,810
times while a hash was actually computed 2,094 times — 96.2% of the calls did
nothing but test the guard and return.

Splitting the guard into an `#[inline]` shell and moving the recursive body to
a separate function makes the common case a load and a branch at the call site.
Behaviour is unchanged; this is purely where the code lives.

Effect is largest where call overhead is not amortised by a big out-of-order
core: in the LambdaVM zkVM guest, where the measurement above comes from, this
removed 1,480,099 of 26,794,880 cycles (-5.52% of total execution) and
`memoize_hashes` disappeared from the profile entirely. Native gains should be
smaller, but the change strictly removes work.
@diegokingston
diegokingston requested a review from a team as a code owner August 4, 2026 15:04
@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

⚠️ 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 commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Kimi Code Review

The changes represent a correct performance optimization using the "outline cold path" pattern.

Analysis:

  1. Correctness: The refactoring preserves behavior exactly. The guard (hash.get().is_none()) remains in the inlineable fast path, while the recursive computation moves to memoize_hashes_uncached.

  2. Optimization validity: The rationale is sound—recursive functions prevent inlining, so extracting the hot guard into #[inline] memoize_hashes allows the compiler to collapse the common case (93% of trie child slots are empty or cached hashes) into a single branch at call sites.

  3. Thread safety: The existing check-then-set pattern on OnceLock is preserved. While there's a theoretical race where two threads could both enter memoize_hashes_uncached for the same node, this existed in the original code and is harmless (the second hash.set simply returns Err which is ignored, and OnceLock guarantees safe publication).

Minor suggestion (optional):
Consider marking the cold path explicitly:

#[cold]
fn memoize_hashes_uncached(...)

This further hints to LLVM that the function is unlikely to be called, though the compiler likely infers this from the #[inline] + non-inline recursive structure.

Verdict: LGTM. Clean optimization with clear documentation.


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

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Claude Code Review

Review: perf(trie): keep the memoize_hashes guard out of the recursive call

Summary: Splits NodeRef::memoize_hashes into an #[inline] guard and a separate memoize_hashes_uncached cold path, so the common case (already-memoized or Hash-only child) becomes an inlined load+branch instead of a real function call in the branch-node iteration loop.

Correctness

The refactor is behavior-preserving — the new memoize_hashes_uncached body is a verbatim move of the two lines previously inside the if let, and the guard condition (NodeRef::Node + hash.get().is_none()) is unchanged. No logic, ordering, or error-handling difference. Good.

Performance

The reasoning is sound: Node::memoize_hashes (node.rs:557-567) recurses through NodeRef::memoize_hashes for all 16 branch children, and a recursive function body defeats LLVM's normal inlining heuristics even when trivially small. Pulling the guard into its own non-recursive, #[inline]-hinted function is a legitimate way to get the guard inlined at each of the 16 call sites while keeping the actual recursive descent (memoize_hashes_uncached) as a real call. The profiling numbers in the PR description (93% no-op children, 96.2% no-op calls) support the motivation.

One suggestion: since the entire point of this PR is guaranteeing the guard gets inlined rather than hoping the optimizer decides to, consider #[inline(always)] instead of #[inline]. Plain #[inline] is only a hint, so at -O2/default release settings LLVM will very likely honor it here given the tiny body, but if the guarantee matters (e.g. for the zkVM guest build path this PR is largely motivated by), #[inline(always)] removes that uncertainty.

Minor/style

  • memoize_hashes_uncached takes node: &Arc<Node>, hash: &OnceLock<NodeHash> instead of &self — reasonable since the caller already has both fields destructured via the if let, avoids re-matching. No issue.
  • Naming nit: "uncached" is a little imprecise since the function computes and caches the hash; something like memoize_hashes_cold or _slow might better signal "this is the branch that does the actual work," but this is bikeshedding and not worth blocking on.

Consensus/state impact

None — this only touches hash memoization caching behavior, not the trie structure, RLP encoding, or hash computation itself. No consensus-relevant change.

Overall: a clean, well-justified, low-risk micro-optimization with good measurement backing it up. No blocking issues.


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

@greptile-apps

greptile-apps Bot commented Aug 4, 2026

Copy link
Copy Markdown

Greptile Summary

The PR preserves trie hash memoization behavior while moving the recursive cold path behind an inline guard to avoid function-call overhead for cached and hash-only references.

  • Adds an inline fast-path shell for NodeRef::memoize_hashes.
  • Extracts recursive traversal and hash caching into the private memoize_hashes_uncached helper.

Confidence Score: 5/5

The PR appears safe to merge because the refactor preserves the existing memoization behavior while optimizing the no-op path.

The same uncached-node guard, recursive traversal, hash computation, and OnceLock update execute in the same order with unchanged arguments and ownership.

Important Files Changed

Filename Overview
crates/common/trie/node.rs Refactors the existing guarded memoization body into a private helper without changing traversal, hashing, buffer use, or cache-update order.

Reviews (1): Last reviewed commit: "perf(trie): keep the memoize_hashes guar..." | Re-trigger Greptile

@github-actions

github-actions Bot commented Aug 4, 2026

Copy link
Copy Markdown

🤖 Codex Code Review

No findings.

The change in crates/common/trie/node.rs looks behavior-preserving: NodeRef::memoize_hashes still only descends for NodeRef::Node entries with an empty OnceLock, and the extracted helper at line 346 keeps the same post-order traversal and final hash.set(node.compute_hash_no_alloc(...)) as before. I don’t see consensus, trie, gas, or memory-safety risk from this refactor.

Testing gap: I could not run a local cargo test sanity check in this environment because the toolchain/dependency path tries to write under read-only ~/.rustup and ~/.cargo.


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: 9
Total lines removed: 0
Total lines changed: 9

Detailed view
+-----------------------------------+-------+------+
| File                              | Lines | Diff |
+-----------------------------------+-------+------+
| ethrex/crates/common/trie/node.rs | 473   | +9   |
+-----------------------------------+-------+------+

Comment on lines +345 to +346
/// whose hash is not memoized yet, so descend into it and compute it.
fn memoize_hashes_uncached(

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.

Might be worth having an inline(never) to ensure it behaves as expected.

@iovoid iovoid changed the title perf(trie): keep the memoize_hashes guard out of the recursive call perf(l1): keep the memoize_hashes guard out of the recursive call Aug 6, 2026
@github-actions github-actions Bot added the L1 Ethereum client label Aug 6, 2026
AnkushinDaniil pushed a commit to AnkushinDaniil/ethrex that referenced this pull request Aug 6, 2026
…lass#7104)

**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](https://github.com/yetanotherco/lambda_vm) 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 lambdaclass#7101 (same guest, same profiling run).

@ElFantasma ElFantasma left a comment

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.

Correct and behaviour-identical; one durability nit inline, and I don't think a native benchmark is needed since the change strictly removes work.


/// Cold path of [`Self::memoize_hashes`]: this reference is an embedded node
/// whose hash is not memoized yet, so descend into it and compute it.
fn memoize_hashes_uncached(

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.

nit: worth pinning this with #[inline(never)], since the whole benefit depends on the two functions staying apart and nothing in the code says so.

The split works because the recursion cycle is cut here: the shell can be inlined into Node::memoize_hashes's 16-child loop precisely because the call to this function is opaque to it. If LLVM ever decides to inline this body back into the #[inline] shell — it's only two calls, so it's not an absurd choice — the shell becomes mutually recursive again (Node::memoize_hashes → shell → here → Node::memoize_hashes), stops being inlinable at the hot call site, and the win silently evaporates. Nothing would fail; the cycle count would just quietly go back up on some future toolchain bump.

#[inline(never)]
fn memoize_hashes_uncached(

The doc comment already explains the intent — this just makes the compiler honour it.

One thing I'd avoid: #[cold]. It reads like the natural fit given the 3.8% hit rate, but it also marks the callee optsize, and this is the path that does all the real work (recursing the subtrie and hashing it). Optimising it for size would pessimise exactly the calls that matter.

@github-project-automation github-project-automation Bot moved this to In Review 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: In Review
Status: Todo

Development

Successfully merging this pull request may close these issues.

4 participants