Skip to content

perf(zkevm): install the hash seed from the payload root - #13166

Open
LukaszRozmej wants to merge 17 commits into
masterfrom
perf/zkevm-seeded-lanes
Open

perf(zkevm): install the hash seed from the payload root#13166
LukaszRozmej wants to merge 17 commits into
masterfrom
perf/zkevm-seeded-lanes

Conversation

@LukaszRozmej

@LukaszRozmej LukaszRozmej commented Sep 4, 2026

Copy link
Copy Markdown
Member

Changes

Install the full 256-bit new_payload_request_root as the guest's hash seed immediately after SSZ Merkleization, before constructing the spec provider, block, witness, or execution state. Guest SpanExtensions has no static field initializers or class constructor.

  • Use full-width seeded AES and scalar multiply mixers for key hashing. Addresses have width-specific derived seeds; variable-width inputs include their length. Short inputs and variable-length tails share word reads and the 4/2/1-byte tail helper.
  • Centralize chaining in SpanExtensions.CombineHash(uint, ulong). Its standard partial uses CRC; its guest partial hashes the combined inputs with the run-seeded mixer. Hash, trie, synchronization, and guest RLP callers use this method without build-condition branches. Account hashing reaches it through ValueHash256.GetChainedHashCode and retains the full nonce.
  • Keep the new standalone key hashes on both builds. In particular, TinyTreePath.GetChainedHashCode combines the new GetHashCode() result, rather than applying CRC directly to the raw path bytes. Raw-path CRC collisions survive every preceding hash, including an AES-derived one; hashing the path first prevents that fixed collision family from carrying through. The merge base uses CRC directly on the raw tiny path, so this also fixes a pre-existing host weakness. Standard non-AES key hashing still uses the new scalar mixer instead of XxHash3; the AES changes also remain.
  • Route guest BAL storage-slot dictionaries and sets through UInt256Comparer. Share the existing span-based comparer from PersistentStorageProvider; host BAL containers retain their default comparer selection.
  • Remove the unused SeedHashes(in ValueHash256) overload and the production-dead XxHash3 helpers. XxHash3 remains a benchmark baseline through direct calls in the benchmark project, which now owns its package reference. Core no longer references System.IO.Hashing; tests exercise the actual scalar fallback.
  • Tighten zero-path length tests to require all maximum + 1 hashes to be distinct, extend them to chained tiny paths, and test every bit of the chaining seed. Add a guest regression case for the preceding-hash value that zeroed the old multiply-based combiner.
  • Keep panic reasons keyed by ulong, checking IsUint64 before reading panicCode.u0.

The seed choice follows the proposed EIP-8025 update and zkevm-standards#41. The payload root is public and identical across provers and retries; it does not provide private entropy per proof attempt. These are in-memory hashes, not cryptographic authentication functions or consensus outputs.

Seeding remains an ordering requirement. Reseeding invalidates populated hash-keyed containers and is not synchronized with hashing. In release guests, unseeded scalar 32-byte and other inputs longer than 16 bytes (except addresses) can hash silently with zero seed; address and short-input paths access uninitialized arrays. Missing seeding is not guaranteed to fail immediately.

The pinned int256 package still lacks runtime seeding. int256 #119 adds UInt256.SeedHashes(in UInt256); a package upgrade and explicit call remain future integration work. Guest storage-slot maps currently reach the seeded span mixer through UInt256Comparer.

Types of changes

What types of changes does your code introduce?

  • Bugfix (a non-breaking change that fixes an issue)
  • New feature (a non-breaking change that adds functionality)
  • Breaking change (a change that causes existing functionality not to work as expected)
  • Optimization
  • Refactoring
  • Documentation update
  • Build-related changes
  • Other: Payload-derived guest hash seeding

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

The latest follow-up changes tests and benchmark package metadata only: 384 host BytesTests and 93 TreePathTests passed, plus all five affected cases with AES disabled. The regression constructs 256 distinct valid tiny paths with identical raw CRCs under each of three chaining seeds and verifies that hashing before chaining breaks that family. The existing CRC-derived byte-input tests now also exercise CombineHash directly. The benchmark dependency is explicit and unconditional; it does not attempt to exclude the transitive package from guest builds. Guest checks below were run for the preceding production-code commit, not repeated for this test-only follow-up.

Local Release checks:

Check Result
Host BytesTests and AccountTests 392 passed
Host TreePathTests 93 passed
Guest GuestMixerTests, intrinsics enabled 1,843 passed
Guest GuestMixerTests, DOTNET_EnableHWIntrinsic=0 1,843 passed
Guest executor build, EnableZkEvm=true 0 warnings, 0 errors
Standard benchmark project build 0 warnings, 0 errors

Guest mixer coverage includes every seed bit, seed-specific collisions, independent multiply references, tail/block boundary vectors, dispatch consistency, and the absence of a class constructor. An assembly-scoped fixture installs the seed; seed-mutating tests are nonparallel and restore it.

TinyTreePathHashBenchmarks measures the production HashAndTinyPath.GetHashCode path, with and without a 32-byte address hash (Hash256). FastHashBenchmarks and FastHash64Benchmarks retain direct scalar and XxHash3 baseline measurements.

Native RISC-V execution was not rerun for this follow-up. A completed host payload benchmark at 65e79e1 is reported below; the subsequent commit 22f4b61 changes tests only. Earlier guest step counts and full-suite totals predate the final mixer and chaining changes and are not current-head validation.

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

SpanExtensions.InstanceRandom changes from uint to UInt256; its former field signature is not retained. In-memory key hash values change on both AES and non-AES hosts. CRC remains the host chaining operation through the shared combiner.

Measurements

Local BenchmarkDotNet 0.15.8 measurements on one Ryzen 9 9950X / Windows 11 host, x64 AES, .NET runtime 10.0.11. The external harness targets net10.0 and was built with SDK 11.0.100-preview.7. Each invocation hashes 1,024 deterministic inputs. Arms ran sequentially; base and pre-combiner runs used three warmup / five measured iterations of 200 ms, and final runs used five warmup / ten measured iterations of 500 ms.

HashAndTinyPath.GetHashCode Merge base 13a8178aff6 PR before shared combiner Final: new key hash + shared CRC Final vs base
No address hash, ns/key 0.511 7.434 1.541 +201.6% (3.02×)
32-byte address hash present, ns/key 1.951 8.558 2.933 +50.3% (1.50×)

The final column measures the production hashing implementation from 1a072ec, unchanged through 22f4b61. Both composite-key cases remain about 1 ns/key slower than the merge base. “Address hash present” means a Hash256 combined with a tiny path; it does not measure the 20-byte Address.GetHashCode() or AddressAsKey.GetHashCode(). Direct base/head measurements for those members and standalone ValueHash256.GetHashCode() remain outstanding.

Final standard deviations are 0.036 and 0.405 ns respectively; the address-present result is noisier. The shared CRC combiner substantially reduces the PR's chaining overhead. It does not restore base timings, because the new standalone key hashing is retained. In particular, the tiny path now hashes through GetHashCode() before chaining; CRC does not directly hash its raw path bytes.

Other final means: TinyTreePath.GetHashCode 1.225 ns, TreePath.GetHashCode 2.269 ns, and ValueHash256.GetHashCode64 1.097 ns. The tiny standalone override is not the production chained path and is not used as a proxy for it.

These are batched local hash-throughput measurements, not dependent-lookup latency, contention, native guest instruction counts, or block-processing timings. Intermediate experiments that restored standalone CRC hashing or only shortened the span are excluded from the final table.

Host payload results from EXPB run 34082560400, at 65e79e1 (same production hashing as 22f4b61). The workflow comparison reports client processing times from SSE for the flat layout:

Payload set PR average, ms Cached master average, ms Average delta
superblocks 757.47 753.66 +0.51%
realblocks 19.21 19.80 −2.98%
fusaka 27.03 27.17 −0.52%

These are one run per payload set against cached master measurements, not repeated paired A/B trials. They do not establish a reliable speedup or isolate Address hashing cost. The master comparison here differs from the pinned merge-base comparison in the local microbenchmarks. Values above are the workflow's reported results; this description update does not constitute a separate audit of the run logs.

The guest also currently hashes the tiny path before the shared seeded combiner. That extra pre-hash is not needed for the host CRC collision argument in the guest; its cost remains a follow-up measurement/optimization. All path bits influence the intermediate hash, which is folded to 32 bits before combining. The CRC collision regression is host-only. Its byte-pair collision count uses 1,024 distinct pairs and explicitly allows at most one collision, independent of the number of chaining seeds tested.

@claude

claude Bot commented Sep 4, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 5m 26s —— View job


Review: perf(zkevm): install the hash seed instead of initialising it


The change itself is sound and the reasoning behind it is right: a static readonly array initializer is exactly what forces NativeAOT to emit a class constructor and pay the init check on every static access, and moving the seed to a parameter is the correct shape for a payload-carried seed. I traced the guest ordering and it holds today — IO.ReadInput() is the only thing that runs before Execute, StatelessExecutor's own beforefieldinit cctor only builds a StatelessValidationResult with Hash256.Zero, and there is no static collection keyed by Address/Hash256 in the guest's reachable set that would be built before line 27. InstanceRandom has no consumer outside SpanExtensions. Three findings, one of which I think matters.

Medium

1. The stated failure mode is not the actual oneSpanExtensions.zkevm.cs:102

The PR description rests the whole trade on "it fails loudly — a NullReferenceException on the first hash." It doesn't:

  • MemoryMarshal.GetArrayDataReference is an intrinsic with no null check — its documented contract is that null is undefined behaviour. It yields a ref at a small fixed offset from address 0, and the multiply reads through it. That's an access violation / unmapped-address trap, not a managed exception.
  • More significantly, the short-input path never touches the arrays: FastHashCrc(…, ComputeSeed(input.Length)) at line 95 reads InstanceRandom, which is 0 before SeedHashes. A hash taken pre-seed silently succeeds with seed 0. Insert under seed 0, look up after seeding, and the lookup misses — silent, not loud, and on trie/state keys that is a correctness bug, not a slowdown.

This doesn't block the design, but the guarantee should be stated accurately, and a Debug.Assert(AddrLanes is not null) in MixAddress/Mix32 costs nothing in the release guest build while turning the ordering bug into a real failure in the ZkEvm test assemblies. That is the closest available substitute for the compiler-enforced guarantee the PR is knowingly giving up.

Low

2. Stale doc invalidated by this commitSpanExtensions.zkevm.cs:50 (outside the diff hunks, so no inline comment). The SeededLane remarks still say "InstanceRandom is a fixed literal in the guest, so the seeded multipliers are public constants." It is now a mutable static installed at run time, and the PR's stated direction is a payload-derived seed — at which point the multipliers are not public constants and the security argument in that paragraph changes shape. Worth rewording to "the guest's default seed is a compile-time constant, so with it …" while you're in the file.

3. Test seeding is per-fixture, the state is per-processGuestMixerTests.cs:33. SafeArrayPoolZkEvmTests and all of Nethermind.Trie.ZkEvm.Test are unseeded. Neither hashes today, so nothing is broken; an assembly-scoped [SetUpFixture] would make it un-forgettable for the next fixture.

4. InstanceRandom need not be publicSpanExtensions.zkevm.cs:26. readonly has to go, but public on a writable static is a wider surface than needed; nothing outside the type reads it.

Also considered, no finding

  • Re-seeding. Execute passes a constant, so repeated calls are idempotent and the host partial is a no-op — the fixture runners (ZkEvmTestsRunner, ZkEvmBlockchainTests) are host builds and never reach the guest partial. Once the seed comes from the payload this stops being true: re-seeding while a previous run's static caches are live would silently orphan every entry. The Call once, before anything hashes a key line in the XML doc already carries the contract, so this is a note for the follow-up, not a change request here.
  • ILC preinitialization regression. Removing the cctor means ILC can no longer constant-fold SpanExtensions, and any type whose cctor hashes (e.g. ProcessingStats._alternateMevPayees) now falls back to a lazy runtime cctor rather than being preinitialized. That's a cost, not a hazard — no hash value can be frozen under one seed and looked up under another, because preinit now fails outright. The measured net −0.34 % already accounts for it.
  • Stacked Nibbles work (perf(zkevm): pack nibbles a word at a time #13164 / perf(zkevm): expand nibbles from a whole word #13165, carried in this diff). I checked the SWAR PackNibbles fold by hand — the m8 masking, the (packed | packed >> 8) & m16 gather and the packed >> 16 merge produce b0,b2,b4,b6 in the low word, matching the scalar loop — and the HexPrefix.CopyToSpan call site: pathLength - pathIndex is always even, count == pathLength / 2 fits output.Length == pathLength / 2 + 1 at offset 1, and the 8-byte source reads never over-read because 4 remaining output bytes imply exactly 8 remaining nibbles. No findings; review those on their own PRs.

Not verified

Builds are not permitted in this environment (dotnet build -p:EnableZkEvm=true requires approval), so I did not compile either partial or run Nethermind.Core.ZkEvm.Test. The partial-method split reads as valid C# — one defining declaration in SpanExtensions.cs, exactly one implementing declaration per EnableZkEvm branch, and Directory.Build.targets guarantees the branches are mutually exclusive — but that is by inspection, not by compiler.
· branch perf/zkevm-seeded-lanes

Comment thread src/Nethermind/Nethermind.Core/Extensions/SpanExtensions.zkevm.cs Outdated
Comment thread src/Nethermind/Nethermind.Core.ZkEvm.Test/Extensions/GuestMixerTests.cs Outdated
Comment thread src/Nethermind/Nethermind.Core/Extensions/SpanExtensions.zkevm.cs Outdated
AnkushinDaniil
AnkushinDaniil previously approved these changes Sep 5, 2026
rubo
rubo previously approved these changes Sep 5, 2026

@rubo rubo 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.

Better to address Claude's nits.

@LukaszRozmej
LukaszRozmej changed the base branch from master to perf/zkevm-expand-nibbles September 5, 2026 09:34
wurdum
wurdum previously approved these changes Sep 6, 2026
Comment thread src/Nethermind/Nethermind.Core/Extensions/SpanExtensions.zkevm.cs Outdated
LukaszRozmej and others added 2 commits September 6, 2026 20:40
The seeded lane arrays were built by a static initializer, which gave the type a class constructor - so every mixer call, the hottest leaf in the guest, paid a class-initialisation check, a fence and a two-level static load. The guest now installs the seed once from StatelessExecutor before anything hashes a key, and the type carries no initializer at all, so no class constructor is emitted. The seed becomes a parameter rather than a literal, which is where it needs to be when the payload starts carrying it.
MemoryMarshal.GetArrayDataReference carries no null check, so an unseeded
lane array traps on an unmapped address rather than throwing, and the
short-input path never touches the arrays at all - it hashes with seed
zero and silently succeeds. A DEBUG-only assertion on the three guest
hash entries catches the ordering bug in the ZkEvm suites at no cost to
the release guest.

The seeding obligation moves from GuestMixerTests to an assembly-scoped
SetUpFixture, so a new fixture in the assembly cannot forget it, and a
test pins the type's lack of a class constructor - the property the whole
optimisation rests on, which a static field initializer in any partial
would silently undo. InstanceRandom, now mutable, drops to internal.
@LukaszRozmej
LukaszRozmej force-pushed the perf/zkevm-seeded-lanes branch from e9442de to 55c4d86 Compare September 6, 2026 18:51
@LukaszRozmej
LukaszRozmej changed the base branch from perf/zkevm-expand-nibbles to master September 6, 2026 18:51
@LukaszRozmej
LukaszRozmej dismissed stale reviews from wurdum, rubo, and AnkushinDaniil September 6, 2026 18:51

The base branch was changed.

@LukaszRozmej

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 3m 47s —— View job


Re-review: perf(zkevm): install the hash seed instead of initialising it

  • Diff the current branch against the prior review point (3850a5255c4d86)
  • Verify each earlier finding is actually discharged
  • Check the new AssertSeeded / SetUpFixture / TypeInitializer test for holes
  • Post findings

All four findings from the previous round are addressed, and the two that mattered are addressed properly rather than papered over.

prior finding state
Medium — "fails loudly" is wrong; short-input path silently hashes with seed 0 Fixed. AssertSeeded() guards MixAddress, Mix32 and FastHashFallback — the third is exactly the path that never touches the lane arrays. The <remarks> on AssertSeeded now records the trap and the seed-0 case as the real failure modes, and the PR body matches.
Low — per-fixture seeding, per-process state Fixed. Namespace-less [SetUpFixture] in SeedGuestHashes.cs wraps the whole assembly; the [OneTimeSetUp] is gone from GuestMixerTests.
Low — public on a writable static Fixed. internal static uint InstanceRandom in the guest partial, host partial keeps public static readonly. The correction about Hash256.cs:73 is right — same assembly, internal reaches it.
Low (from @wurdum) — nothing pins the no-cctor property Fixed. GuestHashSeedTests.Guest_hash_type_has_no_class_constructor. I checked the shared partial: all the PairRoundN members are properties, not fields, and the static lambda at SpanExtensions.cs:123 caches into the generated <>c nested type, not into SpanExtensions — so the assertion holds today and will genuinely fail if a field initializer is re-added.

Assert coverage is complete: FastHash64For20BytesFallback and FastHash64For32BytesFallback both route through MixAddress/Mix32, and ValueHash256.GetHashCode reaches the mixers via Bytes.FastHash(), so there is no fourth entry point past the guard.

Two Low findings, both about the guard's stated reach rather than the code.

Low

1. The [Conditional("DEBUG")] assert never runs in CISpanExtensions.zkevm.cs:86

stateless-tests.yml:32-34 dispatches all three ZkEvm suites with -c release, and Nethermind.Core is a ProjectReference built in the same configuration — so DEBUG is undefined and every AssertSeeded() call site is stripped. It fires only in a local Debug run. That undercuts the PR body's "turns a violation into a failed assert in the ZkEvm suites", and the stated reason for leaving Nethermind.Trie.ZkEvm.Test unseeded. No live hazard — that project is only GuestNibblesTests and Nethermind.Evm.ZkEvm.Test only GuestJumpDestinationTests, neither of which hashes — so this is a wording fix, unless you want the guard to be real in CI, which would take a symbol the ZkEvm test builds define and the guest publish does not.

2. A comment was separated from the field it documentsSpanExtensions.std.cs:15-20

The pre-existing "Ensure that hashes are different for every run of the node…" lines describe InstanceRandom on line 22; the new SeedHashes declaration was inserted between them, so they now read as documentation for the no-op partial. Placing SeedHashes after the field fixes it and shrinks the diff.

Also considered, no finding

  • ILC preinitialization now sees InstanceRandom == 0. This is the inverse of what I noted last round and worth stating correctly: before the change the compile-time value was the real literal, so anything ILC froze was consistent with runtime. Now a cctor that ILC preinitialized and that hashes would bake seed-0 hash codes into frozen data, then miss every lookup after SeedHashes runs — silent, not loud. I do not think it is reachable: the static tables that would matter are FrozenDictionary<AddressAsKey, CodeInfo> (EthereumPrecompileProvider.cs:15), whose construction is far past what ILC's preinit interpreter handles, and a frozen-with-seed-0 precompile table would have changed the guest output for block 25532382 rather than leaving it byte-identical. Flagging it as the thing to re-check if a static hash table is ever added to the guest's reachable set.
  • Re-seeding. Still idempotent (Execute passes DefaultHashSeed), still a no-op on the host. When the seed becomes payload-derived, re-seeding with live static caches orphans every entry; the Call once, before anything hashes a key line in the XML doc carries that contract. Follow-up, not this PR.
  • Thread safety of SeedHashes. Two non-atomic static writes, but the guest is single-threaded and the [SetUpFixture] runs before any fixture. Fine.
  • Comment quality. The four added comment blocks all explain why (the cctor/fence cost, the null-vs-trap distinction, the assembly-wide scope, the CI blind spot). None restate adjacent code; nothing to flag.

Not verified

Builds still require approval in this environment, so neither partial was compiled and no suite was run. In particular I did not confirm that <inheritdoc cref="SpanExtensions.SeedHashes" /> on the implementing declaration of a partial method resolves the way you'd want — Roslyn's handling of doc comments on partial implementations is worth a glance at the build warnings, since the <remarks> on the host partial is the only place the no-op behaviour is written down.

Nothing here blocks merge.
· branch perf/zkevm-seeded-lanes

Comment thread src/Nethermind/Nethermind.Trie/TinyTreePath.cs
Comment thread src/Nethermind/Nethermind.Core/Extensions/SpanExtensions.cs
Comment thread src/Nethermind/Nethermind.Benchmark/Nethermind.Benchmark.csproj Outdated
Comment thread src/Nethermind/Nethermind.Core.Test/BytesTests.cs
@benaadams

Copy link
Copy Markdown
Member

@claude re-review at head 65e79e1.

The latest commit adds CRC collision regressions and removes the ineffective benchmark dependency condition. Please assess the replies to the four latest findings, especially the evidence that passing raw tiny-path data to CRC recreates a seed-independent collision family. The new path hash before host CRC chaining is intentional.

Validation: 384 BytesTests, 93 TreePathTests, all five affected cases with AES disabled, and a clean benchmark build. The AES hot-member/base comparison and full payload A/B remain outstanding; please keep that performance finding open rather than treating these tests as performance evidence.

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 5m 56s —— View job


Re-review: perf(zkevm): install the hash seed from the payload root @ 65e79e1

  • Diff the new commit (1a072ec65e79e1, 3 files)
  • Verify the CRC collision-family evidence independently
  • Assess the four replies
  • Post findings

I checked the collision-family argument rather than taking it, and it holds. My Medium on TinyTreePath.GetChainedHashCode was wrong and I'm withdrawing it — the raw-_data substitution I suggested does recreate a seed-independent family, and the new test pins exactly that.

The evidence, verified

  • crcKernel = 0x1_05EC76F1 is precisely the bit-reversal of 0x1_1EDC6F41 (the CRC-32C generator including the x³² term) — I reversed all 33 bits by hand and every byte matches. So it is a genuine kernel element in the LSB-first ulong convention BitOperations.Crc32C(uint, ulong) uses, and SolveCrcInput(0) in BytesTests independently derives the same element by Gaussian elimination (bits 0–31 are independent, so the first dependency appears at i == 32). Two tests, two derivations, same constant.
  • Because CRC32C is GF(2)-affine in the message, Crc32C(s, d ^ k) == Crc32C(s, d) for every initial value s when k is in the kernel. That is the seed-independence claim, and it is exact rather than probabilistic.
  • The 256 constructed values are all valid tiny paths: crcKernel << bit for bit ≤ 7 tops out at bit 39, so bits 56–63 keep MaxNibbleLength = 14, and _data reconstructs byte-for-byte — the ctor copies pathSpan[..7] and writes selfSpan[7] = 14, which is exactly what data's byte 7 already held. Not a synthetic family that production could never produce.
  • The reply's operational point is the one that matters: within a single account, every path shares one preceding hash (and the no-address case is the fixed 0x55555555), so the family is fully effective against one account's slice of _persistedHashes.
  • Worth adding to the PR body: master has this familyGetChainedHashCode there is Crc32C(seed, (ulong)_data), the exact raw form. So this hunk fixes a pre-existing weakness rather than only buying seeding, which strengthens the case for keeping the measured cost considerably.

Also confirmed: ValueHash256 hash = Keccak.Zero copies (the implicit operator goes through keccak?.ValueHash256 ?? default, and ?. cannot yield a ref), so the test does not mutate the shared static despite [Parallelizable(ParallelScope.All)] on the fixture; Tiny_path_hash_includes_each_chained_seed_bit passes deterministically rather than by luck, since the CRC initial-value map for an 8-byte message is multiplication by x⁶⁴ mod G and therefore invertible; the csproj is CPM-clean (Directory.Packages.props:99, no inline Version); and System.IO.Hashing is now referenced only by Nethermind.State.Flat and Nethermind.Benchmark, with no using left in Nethermind.Core.

latest finding state
Medium — remove the pre-hash before CRC chaining Withdrawn. The evidence is correct and the substitution I proposed is unsafe. One residual point about the guest side below.
Medium — AES hot-member / payload A/B unmeasured Open, as requested. Nothing in this commit is treated as performance evidence.
Low — inert EnableZkEvm condition on the benchmark package Fixed. Condition dropped; the explicit direct reference is right, since FastHashBenchmarks calls XxHash3 itself and shouldn't rely on Nethermind.State.Flat continuing to expose it.
Low — CRC-derived inputs no longer targeted the function under test Fixed. They now probe the actual host CombineHash, plus a production-level TinyTreePath regression. Two notes on the mechanics below.

Medium (open, per request)

1. The extra AES round is still unmeasured on the members it hits hardestSpanExtensions.cs:394. Unchanged by this commit and explicitly left open. FastHashAes appends a round on every host span hash ≥ 16 bytes (so ValueHash256.GetHashCode() goes 2 → 3 rounds), FastHash64For20/32Bytes gained a seed XOR in round 2, and neither ValueHash256.GetHashCode() nor AddressAsKey.GetHashCode() appears in the table with a base comparison. The only EXPB run predates every hunk here and already showed realblocks +2.40 % / fusaka +1.61 %.

Low

2. The guest pays for the pre-hash without needing itTinyTreePath.cs:52. The guest CombineHash is a seeded 16-byte MixShortBytes — not affine, no kernel — so the host's reason doesn't apply there, and the guest instead pays an extra 8-byte MixShortBytes and loses 32 of _data's 64 bits to the int truncation. Not a change request: the only fix is a #if ZK_EVM branch here, and removing build-condition branches from the chaining sites is a stated goal of this PR. Recorded because guest step count is the number this PR exists to move.

3. The seed loop adds no independent samplesBytesTests.cs:787-795. CombineHash(seed, v) is injective for v < 2³² (every nonzero kernel element has its top bit at ≥ 32), so the new comparison is equivalent to hash0 == hash1 for every seed. equalPairs is therefore exactly 3× the collision count: Is.LessThan(4) now tolerates one collision where it tolerated three, and the count * HashSeeds.Length denominator reports 3,072 samples for 1,024 comparisons. Tightening is good; happening by accident isn't.

4. rawHashes.Count == 1 encodes host-CRC behaviour unguardedTreePathTests.cs:68. The BytesTests analogue is #if !ZK_EVM; this one isn't. Safe only because Nethermind.Trie.ZkEvm.Test is a separate directory holding one file, so the glob never reaches TreePathTests.cs.

Also considered, no finding

  • A cheaper nonlinear step. CombineHash(seed, (ulong)MumFold((ulong)_data, K)) would break affinity for one multiply instead of LoadShortInput + 2 AES rounds, and MumFold is already public. I'm not proposing it: with a fixed public K the map is offline-solvable, so it would trade a keyed hash (host AesHashSeed is per-process) for an unkeyed one — a different, weaker version of the same problem. Making it keyed needs InstanceRandom, which is public on the host but internal in the guest partial, so a uniform seeded form would mean either new surface or a #if. The retained FastHash is the defensible choice.
  • Hashing_distinguishes_zero_paths_of_different_lengths now covers (tiny, chained) = (false,false), (true,false), (true,true) and asserts exactly maximum + 1. The non-tiny arm is deterministic, not seed-lucky: nonzero CRC32C kernel elements have degree ≥ 32, so lengths 0…64 cannot collide.
  • Guest benchmark builds. FastHashBenchmarks.cs carries #if ZK_EVM blocks, so dropping the csproj condition means a guest-configuration build of Nethermind.Benchmark now resolves an unused System.IO.Hashing. Harmless — the project isn't in BFLAT_REFS and the guest arms don't call it.
  • Comment quality. One comment added this round (// Shifted copies of the reflected CRC32C generator leave the checksum unchanged) and it is exactly the kind that should exist: it states the invariant that makes the magic constant legible, and I verified it is accurate. Nothing to flag.

Not verified

Builds require approval in this environment, so nothing was compiled and no suite was run — the 384 / 93 / AES-disabled results and the clean benchmark build are taken from the PR body, not reproduced. Also unchecked by machine: <inheritdoc /> on the two partial implementing declarations of CombineHash/SeedHashes resolving without a doc warning, and the guest step-count effect, which the PR body correctly lists as outstanding.

Nothing new blocks merge. The one thing I'd still want before merge is finding 1's measurement, kept open as asked.
· branch perf/zkevm-seeded-lanes

Comment thread src/Nethermind/Nethermind.Trie/TinyTreePath.cs
Comment thread src/Nethermind/Nethermind.Core.Test/BytesTests.cs Outdated
Comment thread src/Nethermind/Nethermind.Trie.Test/TreePathTests.cs
@benaadams

Copy link
Copy Markdown
Member

@claude re-review the two test fixes in 22f4b61: count each collision pair once with an explicit threshold, and guard the host-only CRC regression. All five affected cases pass with AES enabled and disabled. The guest pre-hash cost is recorded as a follow-up, and the AES hot-member/payload performance finding remains open. No production hashing changed.

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 2m 13s —— View job


Re-review: test fixes in 22f4b61Count distinct hash collision pairs and guard host CRC regression

  • Diff 65e79e122f4b61 (2 files, tests only)
  • Verify the collision-pair counting and the explicit threshold
  • Verify the #if !ZK_EVM guard on the CRC regression
  • Confirm no production hashing changed
  • Post findings

Both fixes are correct, and the first one is better than the change I suggested — the invariant hoist is exactly equivalent to the per-word form, not merely a cheaper approximation. No new findings.

Fix 1 — collision counting, BytesTests.cs:775-794

The hoisted assertion changed shape (CombineHash(seed, pairedDelta) == CombineHash(seed, 0) rather than word ^ pairedDelta vs word), so it is worth stating why that loses nothing. BitOperations.Crc32C(uint, ulong) is affine: crc(s, m) = M(s) ^ N(m) with M, N GF(2)-linear and N(0) = 0. Therefore

  • crc(s, word ^ δ) == crc(s, word)N(δ) == 0, and
  • crc(s, δ) == crc(s, 0)M(s) ^ N(δ) == M(s)N(δ) == 0.

Same condition, independent of word — so one assertion per seed covers what 3,072 covered, and the "survives changing the seed" wording stays accurate.

The count is now one per pair with a fixed chaining seed. SolveCrcInput(0) returns the first dependent input in the elimination over i = 0…63, which for CRC32C is 0x1_05EC76F1 (bit 32 set), so no nonzero kernel element fits in 32 bits and CombineHash(0, v) is injective for v < 2³²equalPairs is exactly the number of colliding FastHash pairs, Is.LessThan(2) is exactly "at most one", and the {count} denominator now matches the number of comparisons. The bound no longer moves if someone appends to HashSeeds, which was the point.

Fix 2 — host-only guard, TreePathTests.cs:46,77

Tiny_path_chaining_breaks_raw_crc_collision_family and the System.Buffers.Binary using are both inside #if !ZK_EVM, matching the BytesTests counterpart. I checked the using guard is the right scope: Nethermind.Core.Extensions is still needed under ZK_EVM by the nine ToHexString call sites, and BinaryPrimitives appears only at line 64 inside the guarded body — so no unused-using lint exposure either way.

Open, unchanged by this commit

Medium — the extra AES round and the CRC→FastHash moves are unmeasured on the members they hit hardest (SpanExtensions.cs:394). Kept open as asked. ValueHash256.GetHashCode() and AddressAsKey.GetHashCode() still have no base/head comparison, and the only EXPB run predates every hunk here. Nothing in this commit is treated as performance evidence.

Low — the guest pays for the tiny-path pre-hash without needing it (TinyTreePath.cs:52). Recorded as a follow-up in the PR body, with the width caveat. Agreed as the right call for this PR.

Also considered, no finding

  • The CombineHash(0, …) wrapper is now a no-op for the comparison, by the injectivity above — hash0 == hash1 would be equivalent. Keeping it exercises the production combiner at the shape TinyTreePath.GetChainedHashCode uses, which is a reasonable reason to leave it.
  • The two unguarded new tests (Hashing_distinguishes_zero_paths_of_different_lengths, Tiny_path_hash_includes_each_chained_seed_bit) would compile under ZK_EVM but hash unseeded, since the SeedGuestHashes fixture lives in Nethermind.Core.ZkEvm.Test. Not reachable — Nethermind.Trie.ZkEvm.Test is a separate directory holding only GuestNibblesTests.cs — so this is a note for whoever links the file into a guest project, not a change request.
  • master carries the collision family. Worth keeping in the release notes: GetChainedHashCode on the merge base is Crc32C(seed, (ulong)_data), the exact raw form the new test collapses to one hash, so the retained pre-hash fixes a pre-existing host weakness rather than only buying seeding. The PR body now says this.
  • Comment quality. No comments added this commit; the one from 65e79e1 (// Shifted copies of the reflected CRC32C generator leave the checksum unchanged) still reads as the invariant that makes the constant legible. Nothing to flag.

Not verified

Builds require approval in this environment, so nothing was compiled and no suite was run — the 384 BytesTests / 93 TreePathTests results and the AES-disabled runs are taken from the PR body, not reproduced. The affine argument and the SolveCrcInput kernel degree were checked by hand, not by execution.

Nothing new blocks merge; the performance finding stays open as requested.
· branch perf/zkevm-seeded-lanes

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-perf-zkevm-seeded-lanes-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 757.47 753.66 +0.51%
MEDIAN (ms) 728.2 713.3 +2.09%
P90 (ms) 909.7 924.8 -1.63%
P95 (ms) 1005.7 968.5 +3.84%
P99 (ms) 2005.8 1976.0 +1.51%
MIN (ms) 511.6 524.8 -2.52%
MAX (ms) 2005.8 1976.0 +1.51%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1266.96 1256.17 +0.86%
MEDIAN (ms) 904.00 864.86 +4.53%
P90 (ms) 2203.04 2490.55 -11.54%
P95 (ms) 2961.08 3281.25 -9.76%
P99 (ms) 4443.44 3670.62 +21.05%
MIN (ms) 612.42 604.45 +1.32%
MAX (ms) 4617.90 4042.02 +14.25%

realblocks

Scenario: nethermind-flat-realblocks-perf-zkevm-seeded-lanes-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 19.21 19.80 -2.98%
MEDIAN (ms) 16.5 17.0 -2.94%
P90 (ms) 32.2 32.5 -0.92%
P95 (ms) 39.7 40.4 -1.73%
P99 (ms) 63.7 65.5 -2.75%
MIN (ms) 0.4 0.3 +33.33%
MAX (ms) 202.0 203.7 -0.83%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 23.07 23.92 -3.55%
MEDIAN (ms) 19.95 20.69 -3.58%
P90 (ms) 36.28 37.21 -2.50%
P95 (ms) 43.27 44.72 -3.24%
P99 (ms) 70.78 70.79 -0.01%
MIN (ms) 1.72 0.79 +117.72%
MAX (ms) 466.22 544.67 -14.40%

fusaka

Scenario: nethermind-flat-fusaka-perf-zkevm-seeded-lanes-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 27.03 27.17 -0.52%
MEDIAN (ms) 24.6 24.6 +0.00%
P90 (ms) 42.5 42.5 +0.00%
P95 (ms) 51.3 53.1 -3.39%
P99 (ms) 72.7 75.2 -3.32%
MIN (ms) 4.2 3.8 +10.53%
MAX (ms) 324.7 331.9 -2.17%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 35.76 34.41 +3.92%
MEDIAN (ms) 30.35 29.91 +1.47%
P90 (ms) 51.62 52.27 -1.24%
P95 (ms) 60.30 60.75 -0.74%
P99 (ms) 94.65 91.94 +2.95%
MIN (ms) 5.69 5.63 +1.07%
MAX (ms) 919.27 599.79 +53.27%

@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

EVM Opcode Benchmark Diff

Aggregated runs: base=3, pr=3
Noisy rerun opcodes: BLOBHASH, CREATE2, SELFBALANCE, SWAP9

Regressions (1)

Opcode Base Median (ns) PR Median (ns) Delta Abs Δ (ns) Base CV PR CV Threshold Uncertainty Effective
SELFBALANCE 218.760 241.849 +10.55% 23.089 2.9% 3.6% ±5.0% ±4.7% ±7.1%

…anes

# Conflicts:
#	src/Nethermind/Nethermind.Evm/TransactionSubstate.cs
`InputDecoder.Decode` installs the process-wide hash seed, and
`ZkEvmBlockchainTestFixture` inherits `ParallelScope.All` from
`PyspecBlockchainFixtureBase` - its `parallel: false` argument is
`ParallelExecutionOverride`, the block-processing knob, not NUnit's
scheduling. So a reseed could land while a `WitnessMatchesFixture` case
was mid-execution, leaving the containers it had already populated
unreachable; the seed API is explicitly not synchronised against
concurrent hashing. `StatelessSchemaTests` also decodes but is a plain
fixture in an assembly that marks no parallelism, so it already runs on
the non-parallel shift and needs nothing.
NodeKey hashed on the keccak's raw leading four bytes, the one guest
container whose bucket does not depend on the run seed. Node hashes that
share a bucket are then found by one offline grind and hold for every
block and payload, which is what SeedHashes exists to prevent, and the
store is the largest dictionary in the guest. Nothing rejects witness
nodes that no trie walk reaches, so they need not be reachable state.

Route the hash code through FastHash64For32Bytes, seeded per run in the
guest and per process on the host.

The leading-bytes test asserted a precondition that the seeded hash no
longer meets, so it searches for a colliding pair instead of assuming one.
@LukaszRozmej

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Claude finished @LukaszRozmej's task in 9m 33s —— View job


Re-review: perf(zkevm): install the hash seed from the payload root @ 24d03d0

  • Diff the new commits since the last review point (22f4b6124d03d0)
  • Review the witness node store hash-code seeding
  • Review the test parallelism change and the BOM strip
  • Verify the seed's preimage against the witness
  • Post findings

Three new commits since the last round, plus a merge of master. The substantive one is 24d03d0, and it closes a real gap I had not spotted: NodeKey.GetHashCode was Unsafe.As<ValueHash256, int> — the keccak's raw leading four bytes — which made the witness node store the one guest container whose buckets did not move with the run seed. Finding it and fixing it is the right call, and the two test commits are clean.

The BOM strip is a no-op against master: origin/master's copies of both files have no BOM, so 664d9b8 removes one this PR itself introduced. Net diff on line 1 is now empty. Nothing to flag.

Four findings. The first is the one I'd want a reply on, and it is about the argument rather than the code.

Medium

1. This is the one container where the payload-root seed is not a fixed pointHashKeyedNodeStorage.cs:114-118

The seed is Merkleize(input.NewPayloadRequest), and NewPayloadRequest is { ExecutionPayload, VersionedHashes, ParentBeaconBlockRoot, ExecutionRequests }StatelessInput.Witness is a sibling field, not merkleized into the root. So unlike the storage-slot maps, where changing the colliding key set changes the seed, the witness supplier fixes the payload, reads the seed off it, and grinds node blobs against a known seed. No circularity to break.

That leaves a per-payload O(n²) grind (~B keccaks per node to hit a chosen bucket) landing in the constructor at line 39, with unreachable padding nodes explicitly not rejected. Still a large improvement on a one-time universal grind — but the new <remarks> reads like the argument that covers the other containers, and that argument does not transfer here. Worth recording the actual bound.

2. The cheapest hash in the guest's largest dictionary becomes the most expensive one, unmeasuredHashKeyedNodeStorage.cs:137

One load → Mix32 → the [NoInlining] MixWords → 2 × MultiplyFold + MumFold, with MultiplyFold in its 4-way 32-bit ZK_EVM decomposition. ~12 multiplies plus a non-inlined call, per probe, on every trie node resolve and every constructor insertion. The sentence the diff deletes is what makes it worth a number: "Reading the leading word and truncating it instead measured 0.15% worse" — a far smaller variation, measured. A guest step count for one .ssz at 24d03d0 vs 22f4b61 settles it; the comment suggests a cheaper second arm that keeps the property.

3. The test rewrite drops the coverage the old test existed forHashKeyedNodeStorageTests.cs:175

The old pair (0…0 vs 0…01) differed only in the last byte, so the lookup could only succeed if the hand-rolled four-word Equals compared word 3 — the whole reason that Equals is spelled out with Unsafe.ReadUnaligned instead of deferred to ValueHash256.Equals. The new pair is two unrelated keccaks that share a hash code; they differ in word 0 almost surely, so an Equals truncated to words 0–2 passes. Nothing else in the file covers it — every other case puts its keys in different buckets, where Equals is never consulted.

Recoverable at the same cost, because Set takes an arbitrary ValueHash256 and the candidates need not be keccaks: write the counter into one word of a fixed value, birthday-collide on the hash code, [Range(0, 3)] over the word. That pins all four comparisons instead of none.

4. The AES hot-member / payload A/B, kept open per @benaadams' requestSpanExtensions.cs:394. Unchanged by this round. ValueHash256.GetHashCode() and AddressAsKey.GetHashCode() still have no base/head comparison, and EXPB run 34082560400 is at 65e79e1, so it does not cover 24d03d0 either.

Low

5. [NonParallelizable] is right; the reason given is the one that does not apply hereZkEvmBlockchainTests.cs:41. This project builds without EnableZkEvm, so SeedHashes is the empty std partial and no reseed happens. The live race is StatelessExecutor.FailureOutput, a public static set twice per Execute — which the attribute does fix. Everything else in the commit message checks out: parallel: false really is ParallelExecutionOverride (PyspecTestFixture.cs:23), [Parallelizable(ParallelScope.All)] really is inherited from PyspecBlockchainFixtureBase, and NUnit drains the parallel shift before a non-parallel item.

6. The PR description does not cover 24d03d0. It changes NodeKey.GetHashCode in Nethermind.Consensus — a behaviour change to in-memory hash values on both builds, and the release-notes section currently lists only SpanExtensions.InstanceRandom and the key-hash change. Worth a line, especially given finding 1's bound.

Also considered, no finding

  • Ordering holds for the new hash. HashKeyedNodeStorage is built from Witness.CreateNodeStorage(), reached through payload.Witness.ToWitness() at StatelessExecutor.cs:70 — long after InputDecoder.cs:51. The class's own cctor (EmptyRootKey) constructs a NodeKey but does not hash it, so it is safe under ILC preinitialization too.
  • The threat the commit describes is real for the old code. With hashCode = leading 4 bytes, placing n nodes in one bucket costs ~n keccaks apiece and the result held for every payload for ever, since nothing seeded it. Dictionary has no collision fallback for a custom key type, and node blobs are attacker-chosen. Good find.
  • FindHashCodeCollision will not flake. 2¹⁹ draws over a 32-bit space fails with probability ~e⁻³² ≈ 10⁻¹⁴. new ReadOnlySpan<int>(in i) is the .NET 9 ref readonly ctor and is valid here; ValueHash256 has operator != (Hash256.cs:102); the previous != candidate guard is unreachable but harmless.
  • The removed 0.15% comment was correctly removed, not just dropped — it described a comparison that no longer exists. My finding 2 is that the replacement wants its own number, not that deleting the old one was wrong.
  • Host impact of 24d03d0 is nil. HashKeyedNodeStorage is reached only through WitnessNodeStorage.zkevm.cs; the host partial keeps the MemDb form. So this hunk adds nothing to the open host measurement question.
  • BAL, UInt256Comparer, CombineHash, the seed derivation and the panicCode.IsUint64 guard are all untouched since 22f4b61; prior rounds' verification stands.
  • Comment quality. Three comment blocks added this round. The NodeKey remark states a threat model, the [NonParallelizable] one states why the attribute is there, and the two XML docs on the test helper explain why the pair is searched rather than fixed. All carry a why; none restate adjacent code. Nothing to flag — see finding 1 for accuracy rather than style.

Not verified

Builds require approval in this environment, so nothing was compiled and no suite was run. Specifically unchecked by machine: that <see cref="SpanExtensions.SeedHashes"/> on the new remark resolves without an ambiguity warning now that only one overload remains; that Nethermind.Consensus.Test reaches SpanExtensions.FastHash64For32Bytes (it is public, so it must); and the guest step-count effect of finding 2, which is the number I am asking for.

Findings 1–3 are new and unacknowledged; 4 is open by request. Nothing here is a correctness bug.
· branch perf/zkevm-seeded-lanes

Comment on lines +114 to +118
/// the guest's target. The hash code goes through the run-seeded mixer rather than the keccak's own
/// leading bytes: those are uniformly distributed, which answers accidental collisions but not a
/// chosen witness. Unseeded, one offline grind yields node hashes sharing a bucket for every block
/// and payload, and nothing here rejects unreachable witness nodes. See
/// <see cref="SpanExtensions.SeedHashes"/>.

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.

Medium — this is the one container where the payload-root seed is not a fixed point, so the protection is weaker than the remark implies.

The change is right and strictly better than the leading-bytes hash. But the threat model recorded here reads like the one that covers the storage-slot maps, and for this store it does not hold, because the seed's preimage excludes the witness:

// InputDecoder.cs:47-51
NewPayloadRequest<TExecutionPayload>.Merkleize(input.NewPayloadRequest, out UInt256 root);
SpanExtensions.SeedHashes(in root);

NewPayloadRequest is { ExecutionPayload, VersionedHashes, ParentBeaconBlockRoot, ExecutionRequests }StatelessInput.Witness is a sibling field and is not merkleized into root. Everywhere else in this PR the circularity is what does the work: change the colliding key set and you change the root that seeds the mixer. Here the witness supplier fixes the payload, reads the seed straight off it, and then grinds node blobs against a known seed. No fixed point to solve.

Cost of that grind, with n witness nodes and B ≈ n buckets: ~B keccaks to land one node in a chosen bucket, so ~ total. At n = 32k that's ~10⁹ keccaks — hours on commodity hardware, and it lands in the constructor (line 39), before execution. And as the remark itself says, nothing rejects unreachable nodes, so the padding is free.

So the honest statement is that seeding converts a one-time universal grind into a per-payload O(n²) one, rather than removing it. Worth saying, precisely because the rest of the PR's argument is stronger than that and a reader will carry it over. (No change requested to the code — closing the gap properly means bounding the witness or rejecting unreachable nodes, which is out of scope here.)

Comment on lines +137 to +138
public override int GetHashCode() =>
(int)SpanExtensions.FastHash64For32Bytes(ref Unsafe.As<ValueHash256, byte>(ref Unsafe.AsRef(in _hash)));

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.

Medium — the cheapest hash in the guest's largest dictionary becomes the most expensive one, and the line this replaced carried a measurement.

Before: Unsafe.As<ValueHash256, int> — one load. After, in the guest (Aes.IsSupported is false on riscv64):

FastHash64For32BytesFastHash64For32BytesFallbackMix32 (4 unaligned 64-bit loads + 4 seed XORs) → MixWords, which is [MethodImpl(NoInlining)] under ZK_EVM → 2 × MultiplyFold + MumFold, and MultiplyFold under ZK_EVM is the 4-way 32-bit decomposition. That's ~12 multiplies plus a non-inlined call, per probe, on Get/Set/KeyExists — i.e. every trie node resolve, plus n insertions in the constructor.

What makes this worth a number rather than a shrug is the sentence the diff deletes: "Reading the leading word and truncating it instead measured 0.15% worse." The team measured a variation far smaller than this one. The PR body says native RISC-V execution was not rerun, and this commit post-dates the body's description entirely.

A guest step count for 25532382.ssz at 24d03d0 vs 22f4b61 would settle it. If it turns out non-trivial, worth measuring a second arm: MumFold(Unsafe.ReadUnaligned<ulong>(ref …), InstanceRandom.u0) — one MultiplyFold instead of three. It keeps the property, because forcing a bucket without knowing the seed then requires an n-way multicollision on the full 64-bit leading word of keccak (~2⁶⁴), rather than the 32-bit prefix agreement the old form needed.

Comment on lines +175 to +197
private static (ValueHash256, ValueHash256) FindHashCodeCollision()
{
const int Attempts = 1 << 19;
Dictionary<int, ValueHash256> seen = new(Attempts);

for (int i = 0; i < Attempts; i++)
{
ValueHash256 candidate = ValueKeccak.Compute(MemoryMarshal.AsBytes(new ReadOnlySpan<int>(in i)));
int hashCode = NodeKeyHashCode(in candidate);

if (seen.TryGetValue(hashCode, out ValueHash256 previous))
{
if (previous != candidate) return (previous, candidate);
}
else
{
seen[hashCode] = candidate;
}
}

Assert.Fail($"No hash-code collision within {Attempts} keys.");
return default;
}

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.

Medium — the rewrite drops the coverage the old test existed for, and the fix is a two-line change to the search.

The old pair was first = 0…0, second = 0…01: identical in words 0–2, differing only in the last byte. Under the old hash code (leading four bytes) they shared a bucket, so the lookup could only succeed if the hand-rolled Equals compared the fourth word. That is exactly what the class's remarks say is spelled out by hand — Unsafe.ReadUnaligned<ulong> four times — and therefore the thing most likely to be got wrong.

The new pair is two unrelated keccaks that happen to share a 32-bit hash code. They differ in word 0 with probability ~1, so an Equals truncated to words 0–2 still passes this test. Nothing else in the file covers it: every other case uses keys that land in different buckets, where Equals is never consulted.

Both properties are recoverable at the same cost, because Set takes an arbitrary ValueHash256 — the candidates need not be real keccaks. Vary a counter in one word of an otherwise fixed value and birthday-collide on the hash code; the pair then differs only in that word:

[Test]
public void Separates_keys_that_share_a_hash_code([Range(0, 3)] int word)
{
    (ValueHash256 first, ValueHash256 second) = FindHashCodeCollision(word);}

private static (ValueHash256, ValueHash256) FindHashCodeCollision(int word)
{ValueHash256 candidate = default;
    BinaryPrimitives.WriteInt64LittleEndian(candidate.BytesAsSpan[(word * 8)..], i);}

That pins all four comparisons instead of none, and drops the keccak per candidate.

Two smaller notes on the helper while it is being touched:

  • new Dictionary<int, ValueHash256>(Attempts) pre-sizes to 2¹⁹ entries. ValueHash256 wraps a Vector256<byte>, so the entry struct aligns to 64 bytes — ~33 MB of entries plus 2 MB of buckets, allocated eagerly on the LOH, for a search that terminates around 2¹⁶ on average. new() peaks near 8 MB.
  • NodeKeyHashCode mirrors a private member. If NodeKey.GetHashCode ever changes, the search targets the wrong buckets, the pair no longer collides, and the test passes while asserting nothing. The remark is honest about the mirroring; a line saying the test degrades to vacuous rather than failing would make the next reader check it.

Comment on lines +41 to +43
// Decoding installs the process-wide hash seed; this fixture is ParallelScope.All, so the
// reseed would otherwise land while a WitnessMatchesFixture case is hashing.
[NonParallelizable]

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.

Low — the attribute is right; the reason given is the one that does not apply to this build.

Ethereum.Blockchain.Pyspec.Test builds without EnableZkEvm, so SpanExtensions.SeedHashes here is the std partial with an empty body (SpanExtensions.std.cs:28). InputDecoder.Decode installs nothing in this assembly, and the described reseed-mid-hash race cannot occur.

What is live is the other process-wide static this method touches:

// StatelessExecutor.cs:24, 50, 159
FailureOutput = output;
public static ReadOnlyMemory<byte> FailureOutput { get; private set; }

Two StatelessExecutorOutputMatchesFixture cases running concurrently under the inherited ParallelScope.All were racing on it. Not asserted today, so nothing was failing — but it is a genuine shared-mutable static in a parallel fixture, and [NonParallelizable] fixes it.

Everything else in the commit message checks out: parallel: false really is ParallelExecutionOverride (PyspecTestFixture.cs:23), the [Parallelizable(ParallelScope.All)] really is on PyspecBlockchainFixtureBase (line 20) and inherited, and NUnit's shift dispatcher does drain the parallel shift before running a non-parallel work item. Just worth pointing the comment at FailureOutput, or saying the seed part is forward-looking for a guest-configuration build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

5 participants