Skip to content

perf(l1): track trie path position with a cursor instead of a second vector - #7162

Open
diegokingston wants to merge 1 commit into
mainfrom
perf/trie-nibbles-cursor
Open

perf(l1): track trie path position with a cursor instead of a second vector#7162
diegokingston wants to merge 1 commit into
mainfrom
perf/trie-nibbles-cursor

Conversation

@diegokingston

Copy link
Copy Markdown
Contributor

Motivation

Nibbles keeps the traversal position by moving nibbles between two vectors: data holds what is left to match, already_consumed what has been walked past. Their concatenation is invariant, so every advance pays to shift bytes between them:

next()        -> data.remove(0)   // memmove of the whole remaining path
skip_prefix() -> data.drain(..n)  // memmove, plus extend of already_consumed
offset(n)     -> two fresh vectors and two copies

next() runs once per branch descended, so walking a 64-nibble path does 64 memmoves of up to 64 bytes to consume 64 nibbles — quadratic work for what is logically an index bump.

Description

Keep one buffer and a cursor: data[..cursor] is consumed (what current() returns) and data[cursor..] remains. Advancing becomes an integer add, and offset allocates once instead of twice.

The public API and its semantics are unchanged. Every method that reads "the nibbles" still means the remaining ones — len, at, is_leaf, AsRef<[u8]>, the comparison impls and the compact encoding.

Measurements

On zkVM guests, where allocation and memmove are executed work:

  • ZisK, trie-heavy workload (1200-leaf trie, 1200 lookups, full hash): 6,956,595 → 6,751,333 steps (−2.95%), dma_memcpy −22.8%, RAM use −7.4%. The keccak count is identical before and after, so no work is skipped, and the guest's committed output is byte-identical.
  • LambdaVM, transfer blocks: −0.26% to −0.74%. Small, and worth stating plainly: those blocks barely touch the trie and are dominated by signature recovery, so there is little for this to win.
  • Host, 1200-account trie: 12.5% fewer allocations.

The argument for the change is mostly the algorithmic one — it removes a quadratic memmove from every traversal — rather than the size of the win on transfer-heavy blocks.

Validation

The existing suite passes (61 trie tests, plus ethrex-storage and ethrex-common), clippy clean.

Beyond that, a differential harness compared this against a verbatim transcription of the previous implementation: 115,975 randomized operations over 3,161,843 assertions, no divergence. It covers next, skip_prefix, offset, slice, concat, append_new, current, take, prepend, extend, the ordering/equality/hash impls and the compact encoding.

Note for integrators

Nibbles derives rkyv::Archive, so replacing already_consumed: Vec<u8> with cursor: usize changes the archived layout. Anything holding pre-serialized ExecutionWitness bytes has to regenerate them; host and guest built together are unaffected. As a side effect the serialized witness gets slightly smaller.

This touches the same file as #7155, so whichever lands second will need a trivial rebase.

…vector

`Nibbles` kept the traversal position by moving nibbles between two vectors:
`data` held what was left to match and `already_consumed` what had been walked
past. Their concatenation is invariant, so every advance paid to shift bytes
between them:

  next()        -> data.remove(0)   memmove of the whole remaining path
  skip_prefix() -> data.drain(..n)  memmove, plus extend of already_consumed
  offset(n)     -> two fresh vectors and two copies

`next()` runs once per branch descended, so walking a 64-nibble path did 64
memmoves of up to 64 bytes to consume 64 nibbles — quadratic work for what is
logically an index bump.

Keep one buffer and a cursor instead: `data[..cursor]` is consumed (what
`current()` returns) and `data[cursor..]` remains. Advancing is now an integer
add, and `offset` allocates once rather than twice.

The public API and its semantics are unchanged. Every method that reads "the
nibbles" still means the remaining ones, including `len`, `at`, `is_leaf`,
`AsRef<[u8]>`, the comparison impls and the compact encoding.

Correctness: the existing suite passes, and a differential harness comparing
this against a transcription of the previous implementation ran 115,975
randomized operations over 3,161,843 assertions with no divergence, covering
`next`, `skip_prefix`, `offset`, `slice`, `concat`, `append_new`, `current`,
`take`, `prepend`, `extend`, the ordering/equality/hash impls and the compact
encoding.

Measured on zkVM guests, where allocation and memmove are executed work:

  - ZisK, trie-heavy workload (1200-leaf trie, 1200 lookups, full hash):
    6,956,595 -> 6,751,333 steps (-2.95%), `dma_memcpy` -22.8%, RAM use -7.4%,
    with an identical keccak count, so no work is skipped.
  - LambdaVM, transfer blocks: -0.26% to -0.74%. Small, as expected — those
    blocks barely touch the trie and are dominated by signature recovery.
  - Host, 1200-account trie: 12.5% fewer allocations.

Note for integrators: `Nibbles` derives `rkyv::Archive`, so replacing
`already_consumed: Vec<u8>` with `cursor: usize` changes the archived layout.
Anything holding pre-serialized `ExecutionWitness` bytes has to regenerate
them; host and guest built together are unaffected.
@diegokingston
diegokingston requested a review from a team as a code owner August 19, 2026 18:06
@github-actions

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 L1 Ethereum client performance Block execution throughput and performance in general labels Aug 19, 2026
@github-actions

Copy link
Copy Markdown

🤖 Kimi Code Review

This is a well-structured optimization PR that replaces the already_consumed: Vec<u8> buffer with a cursor: usize index. This eliminates O(n) shifting operations in next() and skip_prefix(), significantly improving trie traversal performance—critical for zkVM contexts as noted in the comments.

Issues Found

1. concat() capacity miscalculation

File: crates/common/trie/nibbles.rs, Lines: 675-682

The capacity reservation includes the consumed prefix bytes unnecessarily:

let mut data = Vec::with_capacity(self.data.len() + other.len()); // Includes consumed prefix
data.extend_from_slice(&self.data); // Full buffer including consumed
data.extend_from_slice(other);

Suggestion: Reserve only for the actual resulting data:

let remaining_self = self.as_ref();
let remaining_other = other.as_ref();
let mut data = Vec::with_capacity(self.cursor + remaining_self.len() + remaining_other.len());
data.extend_from_slice(&self.data[..self.cursor]); // Keep consumed prefix for path tracking
data.extend_from_slice(remaining_self);
data.extend_from_slice(remaining_other);

Note: This preserves the existing behavior of keeping the consumed prefix in the result (important for current()), but avoids overallocation.

2. Unnecessary allocation in RLPEncode

File: crates/common/trie/nibbles.rs, Lines: 656-664

The to_vec() allocation in the non-zero cursor branch can likely be avoided:

} else {
    Encoder::new(buf)
        .


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

@github-actions

Copy link
Copy Markdown

🤖 Codex Code Review

  1. crates/common/trie/nibbles.rs:418-428,465-467,513-520
    Nibbles now has a private invariant (cursor <= data.len()), but it still derives both serde::Deserialize and rkyv::Deserialize with no validation. A malformed archived/serialized value can therefore construct cursor > data.len(), after which remaining() panics and len() underflows in release builds. Since Nibbles is embedded in trie nodes, this turns corrupted witness/archive input into a crash path. I’d strongly recommend a checked deserialization path that rejects invalid cursors, plus a regression test for an invalid payload.

The rest of the path-tracking rewrite looks internally consistent: skip_prefix, current, compact encoding, and RLPEncode all preserve the old “remaining nibbles only” semantics.

I couldn’t run the Rust tests in this environment because cargo/rustc attempted to write under a read-only ~/.rustup directory.


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

@MegaRedHand MegaRedHand left a comment

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.

LGTM. There's probably a better way to address this issue, though

Encoder::new(buf).encode_field(&self.data).finish();
} else {
Encoder::new(buf)
.encode_field(&self.as_ref().to_vec())

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.

Does this need the to_vec?

}
// ─────────────────────────────────────────────────────────────────────────────

// TODO: move path-tracking logic somewhere else

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 is still valid

@github-project-automation github-project-automation Bot moved this to In Review in ethrex_l1 Aug 19, 2026
@github-actions

Copy link
Copy Markdown

🤖 Claude Code Review

Review: perf(l1): track trie path position with a cursor instead of a second vector

Correctness

I traced every mutator of the new cursor-based representation (next, skip_prefix, offset, prepend, append, extend, concat, append_new, into_vec, current, take) and the cursor <= data.len() invariant documented at crates/common/trie/nibbles.rs:425 holds in all of them — no path leads to remaining() (nibbles.rs:465) indexing out of bounds.

A few spots worth double-checking (I verified all are correct):

  • concat (nibbles.rs:675-684) clones self.data in full (consumed prefix included) rather than just self.as_ref(), then reuses self.cursor. This is intentional and correct: new.data[..new.cursor] == self.data[..self.cursor] (the consumed prefix is preserved) and new.data[new.cursor..] == self.remaining() + other.remaining(). Worked through an example by hand and it matches the old already_consumed/data split exactly.
  • offset (nibbles.rs:566-576) now does one self.data.clone() (full buffer) instead of the old two allocations (already_consumed extend + data[offset..].to_vec()). Total bytes copied is the same either way, but it's one memcpy/allocation instead of two — matches the "allocates once instead of twice" claim in the PR description.
  • The manual RLPEncode/RLPDecode impls (nibbles.rs:714-733) still only ever encode/decode the remaining nibbles as a single data field, so the on-wire/on-disk RLP format is byte-identical to before. Only the rkyv archived layout changes, which the author already calls out explicitly for ExecutionWitness integrators — good, that's the risky part and it's flagged.
  • PartialEq/Ord/Hash (nibbles.rs:434-458) all switched from self.data to self.as_ref(), preserving the pre-existing behavior of comparing only "remaining" nibbles, ignoring consumed position — consistent with the old already_consumed-ignoring impls.
  • is_leaf() (nibbles.rs:652-654) simplified to self.as_ref().last() == Some(&16), correctly folding in the old explicit is_empty() check (.last() returns None on empty, so the comparison is false) — safe, and to_bytes() (nibbles.rs:657-664) relies on this correctly (no underflow since is_leaf() can't be true when remaining is empty).

No functional bugs found in the refactor itself.

Test coverage gap

The PR description cites an extensive differential-testing harness (115,975 randomized operations, 3.16M assertions) validating this against a transcription of the old implementation — but that harness isn't part of the diff. The #[cfg(test)] module in nibbles.rs (lines 770-871) only covers the SIMD helper functions (expand_bytes_to_nibbles, pack_nibble_pairs, count_common_prefix) plus one leaf-flag test; there are no committed unit tests exercising the cursor logic directly (skip_prefix/offset/concat/current/prepend interactions, ordering/equality with a non-zero cursor, etc.). Correctness today rests on pre-existing higher-level trie tests plus a one-off local harness that isn't preserved for regression protection. Worth committing at least a handful of targeted unit tests (or a trimmed version of the differential harness) so future edits to this file don't silently reintroduce a cursor > data.len() bug.

Stale docs

docs/perf/architecture.md (lines 557-562, 593-614, 770-772) still describes the old two-Vec already_consumed design and lists it as a performance problem to fix (this PR is that fix). Not part of the code diff, but should be updated alongside or in a fast follow so the doc doesn't contradict the code.

Minor

  • offset()'s new explicit assert! with a descriptive panic message (nibbles.rs:567-571) is a nice improvement over the old implicit slice-index panic — same panic conditions, better diagnostics.
  • The expanded module-level doc comment (nibbles.rs:401-413) is a good addition given how easy it'd be to misuse data vs. remaining() in future edits.

Overall: a clean, well-reasoned allocation-elimination refactor with matching semantics and a real complexity win (O(1) next()/skip_prefix vs. the old O(n) memmoves). Main ask is closing the test-coverage gap before the safety net (the local differential harness) is gone for good.


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

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.

2 participants