Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
3850a52
perf(zkevm): install the hash seed instead of initialising it
LukaszRozmej Sep 4, 2026
55c4d86
test(zkevm): assert the seed is installed before the guest hashes
LukaszRozmej Sep 6, 2026
5b50358
docs: pair the host seed comment with the field it documents
LukaszRozmej Sep 6, 2026
f60b6cd
feat(zkevm): seed the guest hashes from the payload root
LukaszRozmej Sep 6, 2026
2e008de
refactor(zkevm): give the guest's slot keys a comparer of their own
LukaszRozmej Sep 6, 2026
b2538e4
Use full-width seeds in the guest multiply mixers
benaadams Sep 7, 2026
8626125
Unify full-width hash seeding and optimize safe scalar fallbacks
benaadams Sep 7, 2026
d7af2d5
Keep guest hash seeding compatible with published int256
benaadams Sep 7, 2026
1a08b73
Use low limb for validated panic code lookup
benaadams Sep 7, 2026
041076b
Simplify seed API and scalar tail reads
benaadams Sep 7, 2026
1a072ec
Share hash chaining across host and guest builds
benaadams Sep 7, 2026
65e79e1
Test hash chaining against seed-independent CRC collisions
benaadams Sep 7, 2026
22f4b61
Count distinct hash collision pairs and guard host CRC regression
benaadams Sep 7, 2026
b56b5bb
Merge remote-tracking branch 'origin/master' into perf/zkevm-seeded-l…
LukaszRozmej Sep 7, 2026
664d9b8
style: strip the UTF-8 BOM from two touched files
LukaszRozmej Sep 7, 2026
f8604c6
test: keep the reseeding zkEVM case off the parallel shift
LukaszRozmej Sep 7, 2026
24d03d0
fix(zkevm): seed the witness node store's hash code
LukaszRozmej Sep 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,9 @@ protected ZkEvmBlockchainTestFixture() : base(parallel: false, batchRead: false)
[TestCaseSource(nameof(LoadWitnessTests))]
public async Task WitnessMatchesFixture(BlockchainTest test) => Assert.That((await RunTest(test)).Pass, Is.True);

// 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]
Comment on lines +41 to +43

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.

[TestCaseSource(nameof(LoadStatelessTests))]
public void StatelessExecutorOutputMatchesFixture(string inputBytes, string expectedOutputBytes)
{
Expand Down
73 changes: 59 additions & 14 deletions src/Nethermind/Nethermind.Benchmark/Core/FastHashBenchmarks.cs
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,53 @@
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Runtime.Intrinsics;
#if !ZK_EVM
using System.IO.Hashing;
#endif
using BenchmarkDotNet.Attributes;
using Nethermind.Core.Crypto;
using Nethermind.Core.Extensions;
using Nethermind.Trie;
using Nethermind.Trie.Pruning;

namespace Nethermind.Benchmarks.Core;

[ShortRunJob]
[MemoryDiagnoser]
public class TinyTreePathHashBenchmarks
{
private const int OperationsPerInvoke = 1024;
private readonly HashAndTinyPath[] _keys = new HashAndTinyPath[OperationsPerInvoke];

[Params(false, true)]
public bool WithAddress;

[GlobalSetup]
public void Setup()
{
#if ZK_EVM
SpanExtensions.SeedHashes(new Int256.UInt256(0x243F6A8885A308D3UL, 0x13198A2E03707344UL, 0xA4093822299F31D0UL, 0x082EFA98EC4E6C89UL));
#endif
Random random = new(42);
for (int i = 0; i < _keys.Length; i++)
{
byte[] bytes = new byte[Hash256.Size];
random.NextBytes(bytes);
TinyTreePath path = new(new TreePath(new ValueHash256(bytes), i % (TinyTreePath.MaxNibbleLength + 1)));
random.NextBytes(bytes);
_keys[i] = new HashAndTinyPath(WithAddress ? new Hash256(bytes) : null, path);
}
}

[Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
public int HashAndTinyPath()
{
int hash = 0;
for (int i = 0; i < _keys.Length; i++) hash = unchecked(hash + _keys[i].GetHashCode());
return hash;
}
}

[ShortRunJob]
[DisassemblyDiagnoser]
[MemoryDiagnoser]
Expand All @@ -21,14 +63,17 @@ public class FastHashBenchmarks
#endif
private byte[] _data = null!;

[Params(16, 20, 32, 64, 128, 256, 512, 1024)]
[Params(8, 16, 20, 32, 64, 128, 256, 512, 1024)]
public int Size;

[GlobalSetup]
public void Setup()
{
_data = new byte[Size * OperationsPerInvoke];
Random.Shared.NextBytes(_data);
#if ZK_EVM
SpanExtensions.SeedHashes(new Int256.UInt256(0x243F6A8885A308D3UL, 0x13198A2E03707344UL, 0xA4093822299F31D0UL, 0x082EFA98EC4E6C89UL));
#endif
}

[Benchmark(Baseline = true, OperationsPerInvoke = OperationsPerInvoke)]
Expand Down Expand Up @@ -58,21 +103,19 @@ public int FastHashAes()
return hash;
}

#if ZK_EVM
[Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
public int FastHashCrc()
public int FastHashScalar()
{
int hash = 0;
ref byte data = ref MemoryMarshal.GetArrayDataReference(_data);
uint seed = SpanExtensions.ComputeSeed(Size);
for (int i = 0; i < OperationsPerInvoke; i++)
{
ref byte start = ref Unsafe.Add(ref data, i * Size);
hash = unchecked(hash + SpanExtensions.FastHashCrc(ref start, Size, seed));
hash = unchecked(hash + SpanExtensions.FastHashFallback(MemoryMarshal.CreateReadOnlySpan(ref start, Size)));
}
return hash;
}
#else
#if !ZK_EVM
[Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
public int FastHashXxHash3()
{
Expand All @@ -81,7 +124,8 @@ public int FastHashXxHash3()
for (int i = 0; i < OperationsPerInvoke; i++)
{
ReadOnlySpan<byte> input = MemoryMarshal.CreateReadOnlySpan(ref Unsafe.Add(ref data, i * Size), Size);
hash = unchecked(hash + SpanExtensions.FastHashXxHash3(input, XxHashSeed));
ulong next = XxHash3.HashToUInt64(input, XxHashSeed);
hash = unchecked(hash + (int)(next ^ (next >> 32)));
}
return hash;
}
Expand All @@ -107,6 +151,9 @@ public void Setup()
{
_data = new byte[Size * OperationsPerInvoke];
Random.Shared.NextBytes(_data);
#if ZK_EVM
SpanExtensions.SeedHashes(new Int256.UInt256(0x243F6A8885A308D3UL, 0x13198A2E03707344UL, 0xA4093822299F31D0UL, 0x082EFA98EC4E6C89UL));
#endif
}

[Benchmark(Baseline = true, OperationsPerInvoke = OperationsPerInvoke)]
Expand All @@ -125,24 +172,22 @@ public long FastHash64()
return hash;
}

#if ZK_EVM
[Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
public long FastHash64Crc()
public long FastHash64Scalar()
{
long hash = 0;
ref byte data = ref MemoryMarshal.GetArrayDataReference(_data);
uint seed = SpanExtensions.ComputeSeed(Size);
for (int i = 0; i < OperationsPerInvoke; i++)
{
ref byte start = ref Unsafe.Add(ref data, i * Size);
long next = Size == 20
? SpanExtensions.FastHash64For20BytesCrc(ref start, seed)
: SpanExtensions.FastHash64For32BytesCrc(ref start, seed);
? SpanExtensions.FastHash64For20BytesFallback(ref start)
: SpanExtensions.FastHash64For32BytesFallback(ref start);
hash = unchecked(hash + next);
}
return hash;
}
#else
#if !ZK_EVM
[Benchmark(OperationsPerInvoke = OperationsPerInvoke)]
public long FastHash64XxHash3()
{
Expand All @@ -151,7 +196,7 @@ public long FastHash64XxHash3()
for (int i = 0; i < OperationsPerInvoke; i++)
{
ref byte start = ref Unsafe.Add(ref data, i * Size);
hash = unchecked(hash + SpanExtensions.FastHash64XxHash3(ref start, Size, XxHashSeed));
hash = unchecked(hash + (long)XxHash3.HashToUInt64(MemoryMarshal.CreateReadOnlySpan(ref start, Size), XxHashSeed));
}
return hash;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

<ItemGroup>
<PackageReference Include="BenchmarkDotNet" />
<PackageReference Include="System.IO.Hashing" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Nethermind.Consensus.Ethash\Nethermind.Consensus.Ethash.csproj" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,13 @@
// SPDX-License-Identifier: LGPL-3.0-only

using System;
using System.Collections.Generic;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using Nethermind.Consensus.Stateless;
using Nethermind.Core;
using Nethermind.Core.Crypto;
using Nethermind.Core.Extensions;
using Nethermind.Db;
using Nethermind.Trie;
using NUnit.Framework;
Expand Down Expand Up @@ -98,19 +102,16 @@ public void Keeps_the_seeded_empty_root_whatever_is_written_to_it([Values] bool
}

[Test]
public void Separates_keys_that_share_their_leading_bytes()
public void Separates_keys_that_share_a_hash_code()
{
// The hash code is the keccak's leading four bytes, so equality is what has to tell these apart.
byte[] first = new byte[32];
byte[] second = new byte[32];
second[31] = 1;
(ValueHash256 first, ValueHash256 second) = FindHashCodeCollision();

HashKeyedNodeStorage storage = Storage();
storage.Set(null, TreePath.Empty, new ValueHash256(first), [0x01]);
storage.Set(null, TreePath.Empty, new ValueHash256(second), [0x02]);
storage.Set(null, TreePath.Empty, first, [0x01]);
storage.Set(null, TreePath.Empty, second, [0x02]);

Assert.That(storage.Get(null, TreePath.Empty, new ValueHash256(first)), Is.EqualTo(new byte[] { 0x01 }));
Assert.That(storage.Get(null, TreePath.Empty, new ValueHash256(second)), Is.EqualTo(new byte[] { 0x02 }));
Assert.That(storage.Get(null, TreePath.Empty, first), Is.EqualTo(new byte[] { 0x01 }));
Assert.That(storage.Get(null, TreePath.Empty, second), Is.EqualTo(new byte[] { 0x02 }));
}

[Test]
Expand Down Expand Up @@ -165,4 +166,37 @@ private static void Write(HashKeyedNodeStorage storage, bool throughBatch, in Va
storage.Set(null, TreePath.Empty, hash, data);
}
}

/// <summary>Finds two distinct keys the store buckets together, so equality is what tells them apart.</summary>
/// <remarks>
/// Searched rather than hard-coded: the hash code is seeded, so no fixed pair collides across runs.
/// A 32-bit birthday collision is overwhelmingly likely well inside <c>Attempts</c>.
/// </remarks>
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;
}
Comment on lines +175 to +197

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.


/// <summary>Mirrors the store's private key hash so the search targets the same buckets.</summary>
private static int NodeKeyHashCode(in ValueHash256 hash) =>
(int)SpanExtensions.FastHash64For32Bytes(ref Unsafe.As<ValueHash256, byte>(ref Unsafe.AsRef(in hash)));
}
Original file line number Diff line number Diff line change
Expand Up @@ -111,9 +111,11 @@ public void Compact() { }
/// Equality is spelled out word-wise rather than deferred to
/// <see cref="ValueHash256.Equals(ValueHash256)"/>, which compares
/// <see cref="System.Runtime.Intrinsics.Vector256{T}"/>s and so expands to a byte-at-a-time loop on
/// the guest's target. The hash code is the keccak's own leading bytes: they are already uniformly
/// distributed, so <see cref="ValueHash256.GetHashCode"/>'s re-mix of all 32 buys nothing. Reading
/// the leading word and truncating it instead measured 0.15% worse.
/// 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"/>.
Comment on lines +114 to +118

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.)

/// </remarks>
private readonly struct NodeKey(in ValueHash256 hash) : IEquatable<NodeKey>
{
Expand All @@ -132,6 +134,7 @@ public bool Equals(NodeKey other)

public override bool Equals(object? obj) => obj is NodeKey other && Equals(other);

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

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.

}
}
23 changes: 23 additions & 0 deletions src/Nethermind/Nethermind.Core.Test/AccountTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,29 @@ namespace Nethermind.Core.Test;

public class AccountTests
{
[TestCase(0)]
[TestCase(1)]
[TestCase(2)]
[TestCase(3)]
public void Hashing_includes_each_account_field(int field)
{
Account original = new(1UL, 2, TestItem.KeccakA, TestItem.KeccakB);
Account changed = field switch
{
0 => original.WithChangedNonce(0x100000000UL),
1 => original.WithChangedBalance(3),
2 => original.WithChangedStorageRoot(TestItem.KeccakC),
_ => original.WithChangedCodeHash(TestItem.KeccakC)
};
Account equal = new(1UL, 2, TestItem.KeccakA, TestItem.KeccakB);

using (Assert.EnterMultipleScope())
{
Assert.That(equal.GetHashCode(), Is.EqualTo(original.GetHashCode()));
Assert.That(changed.GetHashCode(), Is.Not.EqualTo(original.GetHashCode()));
}
}

[Test]
public void Test_totally_empty()
{
Expand Down
Loading
Loading