Skip to content

perf(l1): move trie traversal state out of Nibbles into a cursor - #7173

Draft
MegaRedHand wants to merge 10 commits into
mainfrom
refactor/nibbles-path-cursor
Draft

perf(l1): move trie traversal state out of Nibbles into a cursor#7173
MegaRedHand wants to merge 10 commits into
mainfrom
refactor/nibbles-path-cursor

Conversation

@MegaRedHand

Copy link
Copy Markdown
Collaborator

Motivation

Nibbles played two unrelated roles. It was an owned path, stored in LeafNode::partial and ExtensionNode::prefix and used as a database key, and it was also a mutable traversal cursor: next_choice/skip_prefix popped nibbles off the front of data and pushed them onto an already_consumed field, which current() then cloned to reconstruct the visited node's database key.

That cost an allocation per node visited on the hottest path in the client, forced five hand-written comparison impls whose only job was to hide already_consumed from equality, and left node prefixes built via offset/concat carrying a stale copy of somebody else's traversal state.

Description

Splits the two roles:

  • Nibbles is now an immutable single-vector path with derived PartialEq/Eq/PartialOrd/Ord/Hash. current, next, next_choice and skip_prefix are gone.
  • A new PathCursor<'a> owns traversal. It is Copy and allocation-free; consumed() yields the visited node's root-relative path as a slice (which is its database key) and remaining() what is left to match.
  • TrieDB::get and flatkeyvalue_computed, plus NodeRef::get_node{,_checked,_mut}, take &[u8], so consumed() never has to be materialized into an owned path.

Along the way it fixes a latent bug and adds the microbenchmark the trie was missing.

Seven commits, each standalone:

Commit What
fix(l1) Trie::get_node's extension arm built the child's DB key from partial_path, which skip_prefix had already advanced past the prefix, giving remaining ++ prefix instead of the root-relative path the node is stored under. Any lookup crossing an extension node with a hashed child failed with InconsistentTree. Reachable from the snap-sync GetTrieNodes server path, which had no test coverage at all.
bench(l1) Criterion microbenchmark over a 50k-leaf trie: lookups that hit and miss, insert, remove, root hashing.
perf(l1) The refactor above.
refactor(l1) Review hardening on the cursor: a checked add in advanced, dropping an ambiguous derived PartialEq, a Nibbles::cursor() bridge, an explicit invariant in trie_sorted, and the stale already_consumed documentation in docs/perf/architecture.md.
test(l1) A proptest that actually exercises the path-based database read surface. See below.
perf(l1) NodeRef::commit built a child path Vec for all 16 branch choices, including absent and already-hashed ones, but commit on a NodeRef::Hash is a no-op whose return value the loop discards.
perf(l1) apply_prefix_bytes built a temporary Nibbles only to copy it into the output.

The last two are independent of the refactor and can be dropped without touching the rest.

Numbers

Allocations per operation, counting GlobalAlloc, 50k-leaf trie, mean over 64 keys:

op before after
get hit 25.6 18.9 −26%
get miss 19.6 14.0 −28%
insert 30.0 22.0 −27%
remove 68.8 36.9 −46%

The saving on lookups grows with depth, since it is the per-level key that is gone: 19.8 → 14.6 at 1k leaves, 25.6 → 18.9 at 50k, 31 → 23 at 1M.

Commit-path allocations, from the NodeRef::commit change, measured over the shape a real block commit has (a trie reopened from its root hash and mutated, so the touched path is in memory and its siblings are hashes):

shape before after
1000 leaves, 5 keys touched 299 123 −59%
1000 leaves, new key inserted 163 76 −53%
1000 leaves, with removals 152 81 −47%
50k leaves, full fresh build 746549 545508 −27%
branch with all 16 children resident 161 161 ±0

The last row is the control: nothing is skipped when every child is in memory.

Wall clock, interleaved A/B against the pre-refactor binary, min of 6 rounds:

bench before after
get_hit 2.521 µs 2.323 µs −8%
get_miss 1.885 µs 1.743 µs −7%
insert (64 keys) 86.1 µs 76.6 µs −11%
remove (64 keys) 161.5 µs 124.4 µs −23%
root_hash (64 dirty) 124.8 µs 125.0 µs ±0

Two caveats worth stating plainly:

  • The descent still allocates roughly one Vec per level, because TrieDB::get returns Option<Vec<u8>>. What this PR removes is the key allocation per level; for the account trie (no address prefix) key construction is now allocation-free end to end. Removing the value allocation needs a borrowed read API, which is out of scope here.
  • The lookup numbers apply to reads that actually descend the trie. In production, once the flat-key-value generator has passed a key, Trie::get short-circuits to a single db.get and never descends, so the ~7-8% lands on pre-FKV storage tries, proofs and witnesses, get_node, and healing. insert, remove and merkleization always descend, so those carry over directly.
  • root_hash is unchanged by the refactor itself, correctly: commit builds paths from a root whose already_consumed was empty, so the old clone was free there. The commit-path allocation win above is a separate change and is not reflected in this benchmark, which calls hash_no_commit.

Correctness

The traversal rewrite was checked by building the same differential harness against bf94de769 and against this branch: 420 randomized cases, each one building a trie, committing, re-opening from the root hash so every read goes through TrieDB::get, then comparing root hashes, mutated-root hashes, proof digests and iteration digests. Key shapes included 20- and 31-byte shared prefixes so deep extension nodes actually form. Output is byte-identical apart from get_node, where base fails and this branch succeeds — the bug fixed in the first commit.

Database key layout is unchanged, byte for byte, in both directions: every one of the 23 old current() sites maps onto consumed(), and read keys still equal what NodeRef::commit writes at every level. Length-based column-family dispatch (classify_trie_key) sees the same lengths. A node with a database written by the previous binary reads correctly.

ethrex-prover, ethrex-guest-program, and the separate-workspace stateless-validator across all four of its feature combinations were built explicitly, since make lint-l2 excludes the first two and --workspace never sees the third. The no_std riscv64 zkVM target builds clean.

Test coverage gap this exposed

Every pre-existing trie proptest used Trie::new_temp(), which leaves all nodes as in-memory NodeRef::Node; commit does not evict them, so the descent never called TrieDB::get with a path key. That is why the get_node bug shipped. This PR adds a proptest that commits, re-opens from the root hash, mutates, and compares the root against a from-scratch build, so the path-based read surface is actually exercised.

Breaking changes

TrieDB::get and TrieDB::flatkeyvalue_computed change signature, so out-of-tree implementors need updating (ethrex-replay is named in db.rs's comments). NodeRef::get_node/get_node_checked, Trie::get_root_node, and the Node/BranchNode/ExtensionNode/LeafNode traversal methods likewise. Removing already_consumed changes the rkyv and serde shape of Nibbles; the only durable consumer is the L2 batch_prover_input blob, which is keyed by git commit hash, so a binary built from this commit never reads an old-layout row and the miss path regenerates the witness. RLP is unaffected: it only ever encoded data.

Follow-ups found while reviewing, deliberately not fixed here

  • ExtensionNode::insert, match_index == 0 arm with prefix == [16], reads its child at this node's own path instead of own_path ++ prefix — the same bug family as the one fixed here, but pre-existing and needing its own test.
  • Trie::get_node never returns a node for the full-32-byte-path shape it advertises: from_bytes appends the leaf flag, so partial_path is never empty on arrival at a leaf. The snap-sync server answers a full account hash with an empty node.
  • Its branch arm pops the leaf-flag nibble and filters < 16, so a path terminating at a branch's own value returns empty; branch.value is never consulted.
  • Trie::get_root_node's path parameter is vestigial: all callers pass an empty key.

Closes #5825

`get_node_inner`'s extension arm built the child's database key from
`partial_path`, which `skip_prefix` had already advanced past the prefix.
The resulting key was `remaining ++ prefix` rather than the root-relative
path the node is stored under, so any lookup crossing an extension node
with a hashed child failed with `InconsistentTree` instead of returning
the node. The branch arm alongside it already derives the key from
`current_path`; do the same here.

Reachable from the snap-sync `GetTrieNodes` server path, which had no
test coverage. Adds tests for a compact partial path crossing an
extension node, the full-32-byte path shape, and the paths that should
return an empty vec.
The trie had no microbenchmark, so allocation work on the descent path
had no number to move. Covers lookups that hit and miss, insert, remove,
and root hashing over a 50k-leaf trie with deterministic 32-byte keys,
which puts leaves at depth 6-7 like the real state trie.

Reads go through a re-opened trie so they take the node-traversal path
rather than the in-memory node cache, and a sanity check at fixture
build time asserts hits hit and misses miss: a `get_hit` that silently
always missed would measure a truncated descent and read as a speedup.
`Nibbles` played two unrelated roles. It was an owned path, stored in
`LeafNode::partial` and `ExtensionNode::prefix` and used as a database
key, and it was also a mutable traversal cursor: `next_choice` and
`skip_prefix` popped nibbles off the front of `data` and pushed them onto
an `already_consumed` field, which `current()` then cloned to reconstruct
the visited node's database key.

That cost an allocation per node visited on the hottest path in the
client, forced five hand-written comparison impls whose only job was to
hide `already_consumed` from equality, and left node prefixes built via
`offset`/`concat` carrying a stale copy of somebody else's traversal
state.

Split the roles. `Nibbles` is now an immutable single-vector path with
derived comparison impls. A new `PathCursor<'a>` owns traversal: it is
`Copy` and allocation-free, `consumed()` yields the visited node's
root-relative path as a slice and `remaining()` what is left to match.
`TrieDB::get` and `flatkeyvalue_computed` take `&[u8]` so `consumed()`
never has to be materialized, and `already_consumed` is gone.

Measured with a counting allocator over a 50k-leaf trie, averaged across
64 keys: a lookup that hits drops from 25.6 allocations to 18.9, one that
misses from 19.6 to 14.0, an insert from 30.0 to 22.0, and a remove from
68.8 to 36.9. The saving grows with depth, since it is the per-level key
that is gone: 19.8 to 14.6 at 1k leaves, 31 to 23 at 1M. Key construction
for the account trie is now allocation-free end to end; the descent still
allocates about one vector per level because `TrieDB::get` returns
`Option<Vec<u8>>`, which a borrowed read API would be needed to remove.

On the new microbenchmark, interleaved against the pre-refactor binary
and taking the minimum of six rounds, lookups are ~8% faster, insert ~11%
and remove ~23%, the last because `BranchNode::remove` was cloning both
vectors on every call. Root hashing is unchanged, correctly: `commit`
builds its paths from a root whose `already_consumed` was empty, so the
old clone was free there.

Closes #5825
Review follow-ups on the cursor introduced in the previous commit.

`advanced` computed `self.idx + n` and only then asserted the result was
in bounds. Release builds have no overflow checks, so a wrapped index
would sail past that assert and hand out silently wrong `consumed()` and
`remaining()` slices, which is the exact failure the assert exists to
prevent. It now checks the addition itself.

Dropped the derived `PartialEq`/`Eq`: they compared `(nibbles, idx)`, so
two cursors denoting the same position over different backing paths
compared unequal and nothing in the tree wanted either meaning.

Added `Nibbles::cursor()` and `From<&Nibbles>`, so call sites read
`path.cursor()` rather than `PathCursor::new(path.as_ref())`.

`trie_sorted` relied on `Nibbles::next()` returning `None` on an empty
path; `offset(1)` panics instead. The invariant that the path is exactly
one nibble does hold, so this makes it explicit with a `debug_assert` and
says in the comment that a violation now panics.

Also switches an eager `ok_or(TrieError::Verify(format!(..)))` in
`verify_range` to `ok_or_else`, and updates the five blocks of
`docs/perf/architecture.md` that still documented `already_consumed` as a
live field, one of which proposed this design as a recommended fix.
`NodeRef::commit` built `path.append_new(choice)` for all sixteen branch
choices, but `commit` on a `NodeRef::Hash` hands the hash straight back
without touching `acc`, and the loop discards the return value. For
absent and already-hashed choices that allocation was pure waste, up to
sixteen per branch node.

The shape this matters for is the one a real block commit has: a trie
reopened from its root hash and mutated, where the touched path is in
memory and every sibling is a hash. There, commit allocations fall by
about 55%: 299 to 123 over a 1000-leaf trie with five touched keys, 51 to
21 over a 16-leaf trie with one. A full fresh build, where most children
are in memory anyway, falls by about 28%. A branch with all sixteen
children resident is unchanged, as expected.

Verified equivalent over fourteen trie shapes, including five
reopen-and-mutate cases: identical root hashes and identical `acc`
contents in push order.
Every trie proptest used `Trie::new_temp()` and inserted into it, which
leaves every node an in-memory `NodeRef::Node`; `commit` does not evict
them, so the descent walked the node graph and `TrieDB::get` was never
called with a path key at all. Instrumenting the old shape over eight
keys counts zero database reads against forty-two for the shape added
here. That is why the `Trie::get_node` key bug earlier in this branch
shipped: nothing exercised the code it broke.

Adds a proptest that builds a trie, commits it, reopens it from the root
hash so the root is a `NodeRef::Hash` and every child must be fetched,
then reads every key, some absent keys, proofs and a full iteration
against the still-in-memory trie as oracle. It then mutates the reopened
trie and compares the root against a from-scratch build, which is what
catches a wrong key during `insert`/`remove`: a descent that reads the
wrong node takes a different structural branch and lands on a different
root. Finally it reopens from the mutated root, which covers write keys.

Uniformly random 32-byte keys diverge in the first nibble or two and
barely produce extension nodes, which is the node type the shipped bug
lived under. So the generator tags three key families by first nibble,
making them provably disjoint, and uses `btree_set` to guarantee at least
two distinct keys in each of the 20- and 31-byte-shared-prefix families.
Extension nodes spanning 39 and 61 nibbles are therefore a property of
the generator rather than a lucky draw, and the test asserts their
presence with an oracle that computes keys from `Nibbles` independently
of the cursor under test.

Confirmed the test fails against a deliberately wrong read key, both on
its read assertions and, with those neutralised, on the mutated-root
comparison alone.
`apply_prefix_bytes` expanded the account hash into a temporary `Nibbles`
only to copy it into the output vector and drop it. It runs once per
level for every storage-trie node read, so expand the 32 bytes straight
into the output instead.

The layout is unchanged, which is the whole risk here: `Nibbles::from_bytes`
appends a leaf-flag nibble, so a prefixed key is 64 nibbles, then 16, then
the 17 separator, then the path. The accompanying test keeps the previous
expression verbatim as an oracle over five hash/path pairs and pins the
layout offsets, and was checked to fail if the nibble order is swapped.
@MegaRedHand MegaRedHand added the performance Block execution throughput and performance in general label Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

⚠️ Known Issues — intentionally skipped tests

Source: docs/known_issues.md

rpc-compat log-bearing cases excluded

Where: KNOWN_EXCLUDED_TESTS in .github/scripts/check-hive-results.sh counts out
eight hive rpc-compat cases — the four eth_getLogs cases, eth_getBlockReceipts/get-block-receipts-latest,
and three eth_getTransactionReceipt cases. They are exactly the cases whose recorded
response contains at least one log object; every case with an empty log array still runs.
Note this leaves eth_getLogs with no rpc-compat coverage at all, since all four of its
cases are in the set.

Why: ethrex populates blockTimestamp on log objects, as geth, besu, nethermind, reth
and erigon all do. hive's rpc-compat compares responses byte-exactly (jsondiff.FullMatch;
the lenient checkJSONStructure path applies only to cases upstream marks speconly), and
the corpus is pinned to execution-apis d08382ae (2025-02-10), whose recordings predate the
field — it entered the schema in execution-apis#639 and the fixtures in #846 (2026-07-22).
So the extra key cannot match, and this is a property of the pin rather than of the response.

The pin cannot move, and this is not temporary. The pin sits one commit before
execution-apis#627, which moved the test chain to a pre-merge genesis: the current corpus has
~36 proof-of-work blocks before its terminal total difficulty. ethrex does not support
pre-merge chains and will not, so importing that chain.rlp fails at block 1 —
validate_block_header has no pre-London base-fee path. Every revision carrying
blockTimestamp in its fixtures also carries that chain, so there is no revision that
satisfies both. Nor can the corpus be patched locally: rpc-compat's Dockerfile clones
ethereum/execution-apis by hard-coded URL, so the branch buildarg cannot point at a fork.

Coverage: the field itself is pinned by
block_timestamp_is_on_the_log_and_not_on_the_receipt in
crates/networking/rpc/types/receipt.rs, which asserts it is present on each log and absent
from the receipt level.

Removal: delete the entries if ethrex ever gains pre-merge chain import, or if upstream
marks these cases speconly so they are type-checked instead of compared byte-for-byte.


The stateless schema id does not identify the encoding

Where: STATELESS_INPUT_SCHEMA_ID in crates/common/types/stateless_ssz.rs.

Upstream keeps the stateless input schema id at 0x1501
(fork_index 0x15 << 8 | revision 0x01) across incompatible body changes. Three
encodings have now shipped under it: tests-zkevm@v0.6.2, then #3248 + #3278,
then #3356, which moved state, codes and public_keys from SszList to
ProgressiveList. ethrex speaks the last one.

The consequence is that the 2-byte prefix cannot be used to detect a stale or
mismatched bundle. A wrong-dialect input is accepted by the id check and then
fails later — in SSZ decode, or on a root that does not match — rather than being
rejected up front for what it is. only_amsterdam_schema_id_decodes therefore
proves less than its name suggests.

Worth raising upstream: a revision field that does not move across a body change
provides no version negotiation at all.


ZisK guest program hash changes with the unsync_cell gate

Where: crates/common/types/block.rs, transaction.rs.

The gate on the single-threaded unsync_cell::OnceCell moved from
all(feature = "eip-8025", target_arch = "riscv64") to
all(feature = "zisk", target_arch = "riscv64") when the eip-8025 feature was removed.

The guest ELFs were previously built --features "<zkvm>-build-elf,ci", which never enabled
eip-8025, so they compiled the atomic once_cell variant. bin/zisk/Cargo.toml does enable
ethrex-common/zisk, so the ZisK guest now compiles the unsafe impl Sync cell instead.
That changes the ELF bytes and therefore the program hash and verification key.

This is intended (the guest is single-threaded, so the unsync cell is sound and cheaper), but it
is a VK change rather than a no-op refactor, and the diffstat presents it as a file rename
(eip8025_cell.rsunsync_cell.rs). Anyone pinning a ZisK VK across this change must
re-register it. The stateless-validator crate now forwards ethrex-common/zisk from its own
zisk feature so the two ZisK guests do not disagree on the cell type.


Release signing key is an unprotected repository secret

Where: .github/workflows/tag_release.yaml.

MINISIGN_SECRET_KEY is a plain repository secret. There is no environment: on
finalize-release or dry-run-release-assets, and gh api repos/lambdaclass/ethrex/rulesets
shows only branch-targeted rulesets, so the github.ref_type == 'tag' condition is a workflow
check rather than an enforced boundary: anyone who can push a tag can reach the signing key.

This is a repository-settings change, not a code change, so it is recorded here rather than
fixed in the tree. Recommended:

  1. Move MINISIGN_SECRET_KEY / MINISIGN_PASSWORD into a GitHub Environment with required
    reviewers, and add environment: to the two jobs that sign.
  2. Add a ruleset targeting refs/tags/v* restricting who may create release tags.

Until then, the compromise of that key is silent and durable: signatures would still verify
against the committed .github/minisign.pub.

@github-actions github-actions Bot added the L1 Ethereum client label Aug 20, 2026
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Lines of code report

Total lines added: 150
Total lines removed: 78
Total lines changed: 228

Detailed view
+---------------------------------------------+-------+------+
| File                                        | Lines | Diff |
+---------------------------------------------+-------+------+
| ethrex/crates/blockchain/blockchain.rs      | 3543  | -1   |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/db.rs             | 133   | +12  |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/nibbles.rs        | 543   | -62  |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/node.rs           | 470   | +6   |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/node/branch.rs    | 594   | +8   |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/node/extension.rs | 541   | -6   |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/node/leaf.rs      | 326   | +20  |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/path_cursor.rs    | 77    | +77  |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/trie.rs           | 827   | -9   |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/trie_iter.rs      | 155   | +1   |
+---------------------------------------------+-------+------+
| ethrex/crates/common/trie/trie_sorted.rs    | 435   | +3   |
+---------------------------------------------+-------+------+
| ethrex/crates/storage/layering.rs           | 1187  | +16  |
+---------------------------------------------+-------+------+
| ethrex/crates/storage/trie.rs               | 210   | +7   |
+---------------------------------------------+-------+------+

This branch added its unit tests inline, as `#[cfg(test)]` modules beside
the code. The repository keeps tests in the `ethrex-test` crate instead,
one file per area under `test/tests/<subsystem>/`, aggregated through a
`mod.rs`, so put them where they belong:

  crates/common/trie/path_cursor.rs  -> test/tests/trie/path_cursor_tests.rs
  crates/storage/layering.rs         -> test/tests/storage/layering_tests.rs

Test counts move accordingly and nothing is lost: `ethrex-trie` goes from
70 back to its pre-branch 61, `ethrex-storage` from 92 to 91, and the
integration binary picks up the same ten (trie 67 to 76, storage 24 to
25). The bodies are unchanged apart from unwrapping the `mod tests`
indentation and importing through the public crate root.

`apply_prefix_bytes` is now re-exported next to `apply_prefix`, which it
is the borrowing counterpart of, so its test can reach it from outside the
crate. The pre-existing inline tests in `layering.rs` stay put: they cover
private items and predate this branch.
@github-actions

github-actions Bot commented Aug 20, 2026

Copy link
Copy Markdown

Benchmark Block Execution Results Comparison Against Main

Command Mean [s] Min [s] Max [s] Relative
base 77.985 ± 0.317 77.492 78.485 1.02 ± 0.01
head 76.742 ± 0.397 76.050 77.383 1.00

# Conflicts:
#	crates/common/trie/db.rs
#	test/tests/storage/mod.rs
The merge adaptation left rustfmt-nonconforming wrapping in trie.rs, the
main-side multi_get order test still called TrieDB::get with an owned
Nibbles, and the changelog enforcer wants an entry for perf PRs.
@github-project-automation github-project-automation Bot moved this to In Review in ethrex_l1 Sep 1, 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.

Move current(), next_choice() etc out of Nibbles and into an iterator or similar structure

2 participants