Skip to content

perf(core): tune the AVX-512 Keccak-f[1600] permutation - #12844

Open
benaadams wants to merge 5 commits into
masterfrom
perf/keccak-avx512
Open

perf(core): tune the AVX-512 Keccak-f[1600] permutation#12844
benaadams wants to merge 5 commits into
masterfrom
perf/keccak-avx512

Conversation

@benaadams

@benaadams benaadams commented Aug 16, 2026

Copy link
Copy Markdown
Member

Changes

  • Restructure KeccakF1600Avx512F into a shared inlined Round helper with shared lane-index vectors for Theta/Chi and pre-broadcast round constants (removes the per-round scalar broadcast)
  • Start Pi permute chains from their own column, saving a register copy per row
  • Drop the lane 5-7 masking of the row loads: those lanes never reach result lanes 0-4 through Theta/Rho/Pi/Chi, and the overlapping stores overwrite them
  • Hoist the round-constant ref out of the loop and pass the vector into Round, eliminating the last bounds check
  • Make KeccakF1600Avx512F internal (it skips bounds checks under a documented 25-lane contract; the KeccakF dispatch and tests are the only callers) and rename the portable path to KeccakF1600Scalar (internal)
  • Add an AVX-512-vs-scalar equivalence test
  • Clean up the outer methods:
    • Take the permutation state as ref ulong in the internal AVX-512 and scalar entry points; the KeccakF(Span<ulong>) dispatch extracts the ref once (the ZK_EVM partial is untouched), so call sites stop materializing a Span for every permutation call
    • Replace the stackalloc state in ComputeHash with an [InlineArray] struct local: localloc pinned the method permanently at Tier0-FullOpts (no tiering, no dynamic PGO, ineligible for inlining) and added a GS-cookie check and stack probe per call
    • Add [SkipLocalsInit] to the hot wrappers (ValueKeccak.Compute, InternalCompute, ComputeHash, Update, UpdateFinalTo, GenerateValueHash) - Unsafe.SkipInit alone does not skip the frame zero-init
    • Drop checked from GetRoundSize: the entry guard bounds the output length to [1, 200], so the arithmetic cannot overflow

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: Description

Testing

Requires testing

  • Yes
  • No

If yes, did you write tests?

  • Yes
  • No

Notes on testing

  • Avx512_permutation_matches_scalar checks the AVX-512 path against the scalar reference over 64 states (all-zero, all-ones, pseudo-random lanes) and executes for real on AVX-512 hardware; it now drives the ref ulong entry points directly
  • Full KeccakTests suite: 1051/1051 passed on x64 with AVX-512, re-verified after each codegen cleanup and again after the outer-method commit
  • End-to-end ValueKeccak.Compute over the same input produces byte-identical hashes on a master build and this branch
  • Nethermind.Core also builds with -p:EnableZkEvm=true against the unchanged zkevm KeccakF partial

Documentation

Requires documentation update

  • Yes
  • No

Requires explanation in Release Notes

  • Yes
  • No

Remarks

Measured Tier-1 codegen, same machine (x64/AVX-512), same .NET 10 runtime, DOTNET_JitDisasm after driving the method to Tier-1 through ValueKeccak.Compute:

Tier-1 KeccakF1600Avx512F master this PR
Code size (bytes) 1724 1528
Stack frame (bytes) 728 632
Instructions, whole body 247 222
Instructions, loop body (2 rounds) 157 143
Iota constant, per round vmovq + vpinsrq/vinserti64x4 + vxorps rebuild one vpxord with a memory operand
Range-check branches / RNGCHKFAIL blocks 1 / 1 0 / 0
Constant spill reloads in loop 11 10

The core vector work is unchanged (per 2 rounds: 40 permute-pair ops, 24 vpermq, 14 vpternlogq, 10 vprolvq on both sides); the savings are the per-round constant rebuild, the masking, and the checks. Both Round call sites inline into a straight-line vector loop with all five state rows resident in zmm registers - no calls remain in the Tier-1 body. The frozen RoundConstantVec length folds to an immediate and the static readonly index vectors fold into JIT rodata exactly like inline Vector512.Create constants.

The outer-method commit was verified the same way: the stackalloc had kept ComputeHash at Tier0-FullOpts for the life of the process (localloc blocks tiering, dynamic PGO, and inlining). As an [InlineArray] struct local it reaches Tier-1 and inlines into ValueKeccak.Compute, where the constant 32-byte output folds the round size, eliminates the second padding store's bounds check, specializes the 136-byte absorb loop, and selects the vector output copy statically. The GS-cookie check, stack probe, and checked overflow branches are gone, and permutation call sites pass the state pointer directly instead of building a Span per call.

Wall-clock is unchanged within run-to-run noise (Ryzen 9 9950X / Zen 5, .NET 10.0.11, BenchmarkDotNet 0.15.8, ValueKeccak.Compute over random inputs, zero allocations on both sides): a single f[1600] permutation costs ~243 ns either way and dominates every input below the 136-byte rate. The permutation is latency-bound on the serial Theta-Rho-Pi-Chi-Iota round chain; the removed work sat off that critical path, so the improvement is code size, frame size, and check elimination rather than time on this microarchitecture.

Extracted from #12843, which additionally carries the Arm SHA-3 permutation and the .NET 11 preview bump it requires; this PR is the x64-only part and builds on the current SDK.

Copilot AI lite review requested due to automatic review settings August 16, 2026 11:58
@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 7m 40s —— View job


Review: perf(core): tune the AVX-512 Keccak-f[1600] permutation

  • Gather context (diff, files, callers)
  • Verify AVX-512 permutation correctness vs. the master implementation
  • Check bounds-check removal / unsafe access safety
  • Review test coverage
  • Post review

Verdict

The algorithmic restructuring is correct, but the second commit ("drop bounds checks from the AVX-512 permutation entry") introduces a memory-safety regression on a public API that should be fixed before merge. Several of the perf claims also need measurements to back them.

1 High · 2 Medium · 4 Low — 6 inline comments posted.


Correctness — verified ✅

I traced all twenty PermuteVar8x64x2 index chains symbolically against master, modelling vpermi2q as res[i] = idx[i] & 8 ? b[idx[i] & 7] : a[idx[i] & 7]. The row-wise Pi mapping is unchanged:

row lane 0 lane 1 lane 2 lane 3 lane 4
0 c0[0] c1[1] c2[2] c3[3] c4[4]
1 c0[3] c1[4] c2[0] c3[1] c4[2]
2 c0[1] c1[2] c2[3] c3[4] c4[0]
3 c0[4] c1[0] c2[1] c3[2] c4[3]
4 c0[2] c1[3] c2[4] c3[0] c4[1]

Specifically checked and clean:

  • Starting each chain from its own row (the register-copy saving) is sound. The old c1Pi chain began PermuteVar8x64x2(c0, Create(3UL, 8+4, …), c1); the new one begins from c1 and folds c0[3] in as the last step. Reading c0-c4 in those later steps is safe because the batch assignment (c0 = c0Pi; c1 = c1Pi; …) was moved to after all five chains complete. master interleaved those assignments into the c4Pi chain, which was the fragile part — the new ordering is strictly easier to reason about.
  • Lanes 5-7 stay inert through Theta (index vectors never map ≥5 into 0-4), Rho (rotate-by-0), Pi (all lane-0-4 indices are <5 or 8 + <5) and Chi. They are also always overwritten by the overlapping stores at 0/5/10/15/20/24.
  • All memory accesses stay inside 25 elements for a well-formed state: the Vector512 reads at 0/5/10/15 touch 0-7, 5-12, 10-17, 15-22.
  • LaneShift1 genuinely is both Theta's [1,2,3,4,0] and Chi's permute1 — that dedup is valid.
  • Vector512.Create(8 + 2, 1, 2, 3, 1UL, …) resolves to the ulong overload (ulongulong identity beats ulongdouble), so no silent lane-width surprise.
  • Renaming KeccakF1600KeccakF1600Scalar and widening privateinternal is safe: no other callers in the repo, and Nethermind.Core.Test has InternalsVisibleTo (InternalsVisibility.cs:6).

Findings

🔴 High — Debug.Assert guard on a public API is elided in Release

KeccakHash.std.cs:265

KeccakF1600Avx512F is public. Replacing _ = state[24] with Debug.Assert(state.Length == 25) means Release builds have no guard, while the body now uses MemoryMarshal.GetReference + Unsafe.Add throughout. A caller passing a short span gets 64-byte OOB writes at offsets 0/5/10/15 — silent heap corruption instead of the IndexOutOfRangeException master throws. The cost being saved is a single predictable compare against 24 rounds of AVX-512 work. Either restore a real check with a [DoesNotReturn] throw helper, or narrow the method to internal (breaking).

Fix this →

🟠 Medium — RoundConstantVec[round] may cost more than the vmovq it removes

KeccakHash.std.cs:37-38 → used at line 348

Per round this is a static-base load + an array bounds check the JIT can't eliminate (RoundConstantVec.Length isn't a JIT constant) + a 64-byte load from an 8-byte-aligned heap array that will frequently straddle a cache line. That's traded against one 8-byte vmovq. Suggested fix in the inline comment: hoist MemoryMarshal.GetArrayDataReference out of the loop and pass the Vector512<ulong> into Round by value.

🟠 Medium — perf-only PR with no measurements

Every claim in the description ("removes the per-round scalar broadcast", "saving a register copy per row", "kills the per-round vmovq") is a plausible-but-unverified microarchitectural assertion, and at least two of them look like they could go the other way. The repo has Benchmarks.slnx for exactly this. Please add before/after BenchmarkDotNet numbers (and ideally the relevant disasm) — for a 123/-186 diff to hot crypto code, that's what makes the tradeoffs reviewable.

🟡 Low


Style / repo rules

No violations of coding-style.md that I found: no var, no LINQ, no #region, Array.Empty/collection-expression rules respected. The multi-statement lines (c0 = …; c1 = …; at 302-303, 338) are unusual for this codebase but dotnet format whitespace won't object — reviewer's call.

robustness.md §Safety asks that unsafe blocks carry a justifying comment. There's no unsafe keyword here, but the Unsafe.As-based reads/writes are the same class of hazard, and this PR removed the comments that documented the invariant (// Can straight load and over-read for start elements, // Can't over-write for last elements…). Since the guard is gone too, a short remark on the entry point stating the state.Length == 25 precondition and the over-read/overlapping-store scheme would be worth keeping.


What I could not verify

This review is static analysis only:

  • No AVX-512 hardware — the runner is an AMD EPYC 7763 (Zen 3, AVX2 only), so Avx512_permutation_matches_scalar would Assert.Ignore here even if I could run it. I'm relying on the author's reported 1051/1051 on AVX-512 hardware, plus the symbolic trace above.
  • dotnet build / dotnet test / scripting were not permitted in this session (permission prompts denied), so I could not compile the branch or run dotnet format whitespace. If you want those executed on future runs, they need adding to --allowedTools.

· branch perf/keccak-avx512

- Restructure into a shared inlined Round helper with shared lane-index
  vectors for Theta/Chi and pre-broadcast round constants (removes the
  per-round scalar broadcast)
- Start Pi permute chains from their own column, saving a register copy
  per row
- Guard the state length with Debug.Assert, matching the scalar path,
  and access all lanes unchecked
- Rename the portable path to KeccakF1600Scalar (internal) and add an
  AVX-512-vs-scalar equivalence test

Copilot AI 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.

Pull request overview

Optimizes the x64 AVX-512 Keccak-f[1600] permutation in Nethermind.Core by restructuring the permutation into an inlined round helper, sharing permute-index vectors, and pre-broadcasting round constants; also renames/exposes the scalar reference path for testing and adds an AVX-512-vs-scalar equivalence test.

Changes:

  • Refactors the AVX-512 permutation into an inlined Round helper and introduces shared lane-index vectors plus pre-broadcast round constants.
  • Renames the portable permutation implementation to KeccakF1600Scalar (internal) and updates dispatch accordingly.
  • Adds an AVX-512 vs scalar permutation equivalence test.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 2 comments.

File Description
src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs AVX-512 Keccak-f[1600] round refactor + scalar rename + constant/vector hoisting.
src/Nethermind/Nethermind.Core.Test/KeccakTests.cs Adds an AVX-512 permutation equivalence test against the scalar reference.

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs Outdated
Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs
Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs Outdated
The method skips bounds checks under a Debug.Assert length contract, so
restrict callers to the assembly and friend test assemblies, matching
KeccakF1600Scalar.
Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs
Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs
Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs
Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs
Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs Outdated
Comment thread src/Nethermind/Nethermind.Core.Test/KeccakTests.cs
- Lanes 5-7 of the over-read row loads never reach result lanes 0-4
  (Theta/Rho/Pi/Chi map lanes 0-4 only from lanes 0-4) and the stores
  overwrite them, so the vpandq masking was dead
- Pass the pre-broadcast round constant into Round via
  GetArrayDataReference, eliminating the round + 1 bounds check

Tier-1 body shrinks 1562 -> 1528 bytes with no range checks left.
@benaadams

Copy link
Copy Markdown
Member Author

@claude re-review

@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

Claude finished @benaadams's task in 3m 30s —— View job


Re-review: perf(core): tune the AVX-512 Keccak-f[1600] permutation

  • Diff the two new commits (d1e1404, 04c2649) against the reviewed state
  • Verify the internal narrowing closes the memory-safety finding
  • Re-verify permutation correctness after the mask removal
  • Check the GetArrayDataReference hoist for new hazards
  • Post verdict

Verdict

Ready to merge. The High and both Mediums from the first pass are resolved or answered with evidence; the two applied Lows are correct. The one genuinely risky follow-up commit — dropping the lane masks — I re-verified independently and it holds. 0 Critical · 0 High · 0 Medium · 1 new Low (non-blocking).


First-pass findings — status

# Sev Finding Status
1 🔴 High Debug.Assert guard on a public API Resolvedd1e1404 narrows to internal
2 🟠 Med RoundConstantVec[round] indirection Resolved — hoisted in 04c2649; disasm rebuts the rest
3 🟠 Med Perf-only PR with no measurements Answered — Tier-1 disasm + EXPB run
4 🟡 Low Dead mask / four vpandq Applied in 04c2649
5 🟡 Low [SkipLocalsInit] no-op / inline risk Answered — both call sites confirmed inlined
6 🟡 Low Inconsistent constant strategy Answered — both forms hit JIT rodata; my premise was wrong
7 🟡 Low Test coverage gaps Deferred with rationale

On #1: internal is the right call. Confirmed the only two call sites are the KeccakF dispatch (KeccakHash.std.cs:45) and the test via InternalsVisibleTo, both passing exactly 25 lanes, so the unchecked body is unreachable from outside the assembly and Debug.Assert is now a defensible contract check rather than a hole.

On #6: your disasm shows ThetaRot4/LaneShift1/LaneShift2 loading from [reloc @RWD…] exactly like the inline Creates — the frozen-object folding I claimed wouldn't happen does happen. My analysis was wrong there; the mixed strategy costs nothing.


Re-verification of 04c2649 (mask removal)

This is the commit that can silently corrupt hashes, so I re-derived the invariant rather than taking the passing tests as proof. It holds. Lanes 5-7 are a closed set — nothing in a round moves them into lanes 0-4:

  • ThetaThetaRot4, LaneShift1, LaneShift2 at result positions 0-4 select source lanes {4,0,1,2,3}, {1,2,3,4,0}, {2,3,4,0,1}; all < 5.
  • RhoRotateLeftVariable is element-wise.
  • Pi — checked all 20 PermuteVar8x64x2 index vectors (lines 315-338); every entry at positions 0-4 is n or 8 + n with n < 5.
  • ChiLaneShift1/LaneShift2 again.
  • IotaVector512.CreateScalar(rc) zeroes lanes 1-7, so garbage in c0's upper lanes cannot leak into lane 0.

And the store sequence (lines 286-291) is exhaustive and correctly ordered — each garbage triple is overwritten by the next row's store:

store writes garbage left fixed by
c0 @0 s[0..7] s[5..7] c1 store
c1 @5 s[5..12] s[10..12] c2 store
c2 @10 s[10..17] s[15..17] c3 store
c3 @15 s[15..22] s[20..22] c4 v256 store
c4 v256 @20 + scalar @24 s[20..24]

Max write extent is s[22], max read extent s[24] — both inside 25, so with the Length == 25 contract there is no OOB access in either direction. Since all five rows are loaded before any store, the overlap is safe.

Also checked the hoist itself: MemoryMarshal.GetArrayDataReference yields a GC-tracked interior byref, so it survives a relocation across the 24-round loop — no pinning needed.


New finding

🟡 Low — ROUNDS / table-length coupling is now unguarded

KeccakHash.std.cs:279-284

Unsafe.Add(ref roundConstants, round + 1) drops the array bounds check that was latent protection against ROUNDS and RoundConstantVec.Length drifting apart (and against ROUNDS becoming odd). Both assumptions hold today. The asymmetry is the concern: the scalar path still indexes RoundConstants[round] and would throw, while this path would silently read past the array and emit a wrong-but-well-formed digest. Debug.Assert(RoundConstantVec.Length >= ROUNDS && ROUNDS % 2 == 0) next to the existing assert closes it for free.

Also informational, no action needed: publicinternal is technically a surface-breaking change to Nethermind.Core while the PR's "Breaking change" box is unticked. For a raw permutation primitive with no in-repo external callers that's the right trade, just noting the checkbox mismatch.


Benchmarks

The EXPB run is neutral across all three payload sets — fusaka -1.43% AVG, realblocks +0.28%, superblocks +0.03%, all inside run-to-run noise. That is the expected shape: Keccak is a thin slice of end-to-end block processing, so EXPB can't confirm the micro-optimization, but it does confirm no regression, which combined with the Tier-1 disasm is enough for a change this size.


Correction to my previous review

I said the runner was "an AMD EPYC 7763 (Zen 3, AVX2 only)" and that the equivalence test would Assert.Ignore here. That was wrong — this runner is an AMD EPYC 9V74 (Zen 5) and /proc/cpuinfo reports the AVX-512 flags, so Avx512_permutation_matches_scalar would genuinely execute in this environment. It matters because it means CI on these runners does cover the AVX-512 path, rather than silently skipping it.

What I still could not verify

dotnet build and dotnet test remain outside --allowedTools (both denied this session), so this is again static analysis only — no compile, no test execution, no dotnet format whitespace. Given the hardware above, adding those two to the allowlist would let a future run actually execute the differential test rather than reason about it.


· branch perf/keccak-avx512

Comment thread src/Nethermind/Nethermind.Core/Crypto/KeccakHash.std.cs
Unsafe.Add dropped the latent bounds check tying RoundConstantVec to
the loop, so assert the length and evenness next to the state assert.

Bounding the loop by RoundConstantVec.Length instead was measured with
JitAsm and rejected: the non-const bound stops the JIT hoisting the 28
vector constants, re-loading all of them from rodata every iteration
(loop grows 143 -> 165 instructions per 2 rounds).
@benaadams

Copy link
Copy Markdown
Member Author

@claude re-review, note PR description has been updated

@claude

claude Bot commented Aug 16, 2026

Copy link
Copy Markdown
Contributor

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


Re-review (3rd pass): perf(core): tune the AVX-512 Keccak-f[1600] permutation

  • Diff 6e309b0 against the previously reviewed state
  • Re-verify the full permutation from scratch (Theta / Rho / Pi / Chi / Iota) against the spec, not just against master
  • Verify the Debug.Assert(state.Length == 25) contract holds for every real caller
  • Cross-check the updated PR description against the code
  • Final verdict

Verdict

Ready to merge. 6e309b0 is a 3-line assert + comment and is exactly the fix requested; nothing in it introduces new risk. 0 Critical · 0 High · 0 Medium · 0 new Low. One informational note below, no action needed.

All seven findings from the two earlier passes are now closed:

# Sev Finding Status
1 🔴 High Debug.Assert guard on a public API Resolved — internal (d1e1404)
2 🟠 Med RoundConstantVec[round] indirection Resolved — hoisted (04c2649)
3 🟠 Med No measurements Answered — Tier-1 disasm table now in the description + EXPB run
4 🟡 Low Dead mask / vpandq Applied (04c2649)
5 🟡 Low [SkipLocalsInit] no-op Answered — both call sites inline
6 🟡 Low Constant strategy Answered — my premise was wrong
7 🟡 Low ROUNDS / table coupling Applied (6e309b0)

6e309b0 review

Debug.Assert(RoundConstantVec.Length == ROUNDS && ROUNDS % 2 == 0) — correct and sufficient. ROUNDS % 2 == 0 folds to true at compile time for today's value, but it still fires at runtime in Debug if someone sets ROUNDS = 25, so it isn't inert as a drift guard. Length == ROUNDS (rather than my suggested >=) is the stronger and better choice here since the table is derived from RoundConstants — they should track exactly.

The rejection of the RoundConstantVec.Length loop bound is well-argued and matches the pre-existing // Use constant for loop so Jit expects to loop comment on master (KeccakHash.std.cs:280 there). Recording the why in a comment rather than silently keeping ROUNDS is the right call — that's exactly the kind of non-obvious constraint AGENTS.md wants a comment for.


Independent re-derivation of the permutation

This pass I checked the code against FIPS-202 directly rather than only diffing against master, since a diff-only argument can't catch a bug the restructure inherited. All five steps check out:

  • Thetaparity = TernaryLogic(TernaryLogic(c0,c1,c2,0x96), c3, c4, 0x96); 0x96 is 3-input XOR, so parity[x] = C[x]. ThetaRot4 = (4,0,1,2,3) gives C[x-1]; LaneShift1 = (1,2,3,4,0) rotl-1 gives rot(C[x+1],1). D[x] = C[x−1] ⊕ rot(C[x+1],1)
  • Rho — offsets per row (c_y holds A[0..4][y]):
    c0: 0,1,62,28,27 · c1: 36,44,6,55,20 · c2: 3,10,43,25,39 · c3: 41,45,15,21,8 · c4: 18,2,61,56,14 — exact match to the FIPS-202 r[x][y] table ✓
  • Pi — re-derived all 20 vpermt2q chains (res[i] = idx[i] < 8 ? a[idx[i]] : b[idx[i]−8]):
    row0=(c0[0],c1[1],c2[2],c3[3],c4[4]), row1=(c0[3],c1[4],c2[0],c3[1],c4[2]), row2=(c0[1],c1[2],c2[3],c3[4],c4[0]), row3=(c0[4],c1[0],c2[1],c3[2],c4[3]), row4=(c0[2],c1[3],c2[4],c3[0],c4[1])
    Each chain's intermediate lanes survive to the end — verified position-by-position, including the c4Pi chain where Create(8+2, 1, 2, 3, 1UL, …) seeds pos4 from c4[1] and never touches it again.
  • ChiTernaryLogic(a, a[x+1], a[x+2], 0xD2). Truth table for A ⊕ (¬B ∧ C) is 1101 0010 = 0xD2
  • IotaVector512.CreateScalar(rc) zeroes lanes 1-7, so the unmasked garbage in c0[5..7] can't leak into lane 0 ✓

Memory extent, exhaustively: max read index is 24 (c3 load reads s[15..22], c4 reads s[20..23] + s[24]), max write index is 24. Nothing touches memory outside the 25-lane state in either direction — the "over-read" in the new comment is over-read of neighbouring rows, never past the array. So the mask removal is safe under a weaker precondition than I assumed last pass.

Overload resolution on the mixed-literal Create calls — re-checked the corner case Vector512.Create(8 + 2, 1, 2, 3, 1UL, 5, 6, 7) where the first arg is an int literal. Create(long,…) is inapplicable (ulonglong has no implicit conversion); between Create(ulong,…) and Create(double,…), ulong is the better conversion target (§12.6.4.7: ulongdouble exists, doubleulong does not), so the ulong overload wins for every argument. No silent Vector512<double> reinterpretation.


Debug.Assert(state.Length == 25) — contract verified end-to-end

Traced all five KeccakF call sites (KeccakHash.cs:109,129,217,242,294) to their state buffers:

  • KeccakHash.cs:86stackalloc ulong[STATE_SIZE / sizeof(ulong)] = 25
  • Pool.RentState() (KeccakHash.cs:501) — new ulong[STATE_SIZE / sizeof(ulong)] = 25, and the return path (ReturnState) re-enqueues the same arrays, so the thread-static queue can only ever hold length-25 arrays. Notably this is a hand-rolled queue, not ArrayPool.Rent, so there's no over-sized-buffer path that would make Length == 25 false.

So the exact-equality assert is satisfiable by construction. (The scalar path already carried this assert on master, so it was implicitly proven — but the AVX path previously accepted Length >= 25, so it was worth confirming rather than assuming.)


Description accuracy

Every bullet matches the code as merged, and the stat line (+131/−189) matches git diff origin/master...HEAD. The disasm table is internally consistent with 6e309b0's commit message (143 instructions/2 rounds is both the "after" figure in the table and the baseline in the rejected-alternative measurement). Two things the description doesn't mention, both harmless: the new RoundConstantVec.Length assert, and the const-loop-bound rationale comment.

The "Breaking change" checkbox is still unticked despite publicinternal on KeccakF1600Avx512F. As noted last pass, for a raw permutation primitive with no in-repo external callers this is the right trade — just flagging that the checkbox drives the automatic PR labelling, so if you want a breaking change label it won't appear.


Informational — new coupling between the test and the .std build flavour

KeccakTests.cs:198-199 now references KeccakF1600Scalar / KeccakF1600Avx512F directly. Both live only in KeccakHash.std.cs, which src/Nethermind/Directory.Build.targets:5-10 excludes when EnableZkEvm=true, while KeccakTests.cs itself is always compiled. So dotnet build -p:EnableZkEvm=true on Nethermind.Core.Test would now fail where it previously wouldn't.

I checked and no CI workflow does thisnethermind-tests.yml's zkevmTest is an EEST fixture type, unrelated to the EnableZkEvm build property, which is only used for the ZisK guest build (the runner, not the test projects). So there is nothing to fix; noting it only because it's a new constraint that didn't exist before this PR.


Benchmarks

EXPB is neutral across all three payload sets (fusaka −1.43%, realblocks +0.28%, superblocks +0.03% AVG) — inside noise, as expected for a permutation that's a thin slice of block processing. Combined with the Tier-1 disasm table in the description (1724→1528 bytes, 247→222 instructions, zero range checks), that's adequate evidence for a change of this size: the micro-level win is measured, and the macro level confirms no regression.


What I could not verify

Static analysis only again — dotnet build and dotnet test were both denied this session (permission prompt on dotnet build src/Nethermind/Nethermind.Core/Nethermind.Core.csproj -c Release), so no compile, no test execution, no dotnet format whitespace. Since these runners do report AVX-512, adding those to --allowedTools would let a future pass actually execute Avx512_permutation_matches_scalar rather than reason about it.


· branch perf/keccak-avx512

@github-actions

Copy link
Copy Markdown
Contributor

EXPB Benchmark Comparison

Run: View workflow run

superblocks

Scenario: nethermind-flat-superblocks-perf-keccak-avx512-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 930.22 912.84 +1.90%
MEDIAN (ms) 883.8 888.2 -0.50%
P90 (ms) 1128.6 1084.3 +4.09%
P95 (ms) 1385.5 1208.1 +14.68%
P99 (ms) 2980.6 3106.1 -4.04%
MIN (ms) 616.2 591.7 +4.14%
MAX (ms) 2980.6 3106.1 -4.04%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 1658.08 1630.85 +1.67%
MEDIAN (ms) 1132.84 1071.52 +5.72%
P90 (ms) 2931.87 2966.56 -1.17%
P95 (ms) 3442.38 3810.28 -9.66%
P99 (ms) 5151.05 5387.15 -4.38%
MIN (ms) 727.07 690.22 +5.34%
MAX (ms) 10421.46 11083.56 -5.97%

realblocks

Scenario: nethermind-flat-realblocks-perf-keccak-avx512-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 21.57 21.08 +2.32%
MEDIAN (ms) 18.6 18.2 +2.20%
P90 (ms) 35.9 35.0 +2.57%
P95 (ms) 42.4 41.4 +2.42%
P99 (ms) 66.8 65.9 +1.37%
MIN (ms) 0.3 0.2 +50.00%
MAX (ms) 185.6 187.4 -0.96%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 25.45 24.82 +2.54%
MEDIAN (ms) 22.18 21.51 +3.11%
P90 (ms) 40.12 39.55 +1.44%
P95 (ms) 45.41 45.53 -0.26%
P99 (ms) 72.63 70.82 +2.56%
MIN (ms) 1.76 0.59 +198.31%
MAX (ms) 543.00 453.39 +19.76%

fusaka

Scenario: nethermind-flat-fusaka-perf-keccak-avx512-delay0s

Client Processing (SSE)

Metric PR Master (cached) Delta
AVG (ms) 30.27 30.78 -1.66%
MEDIAN (ms) 27.4 28.1 -2.49%
P90 (ms) 46.7 47.8 -2.30%
P95 (ms) 57.2 58.0 -1.38%
P99 (ms) 79.4 79.3 +0.13%
MIN (ms) 4.4 4.0 +10.00%
MAX (ms) 329.9 340.9 -3.23%
K6 TTFB
Metric PR Master (cached) Delta
AVG (ms) 38.22 37.87 +0.92%
MEDIAN (ms) 33.06 33.80 -2.19%
P90 (ms) 57.24 57.53 -0.50%
P95 (ms) 66.19 64.92 +1.96%
P99 (ms) 114.86 101.93 +12.69%
MIN (ms) 6.50 5.37 +21.04%
MAX (ms) 574.79 604.81 -4.96%

@LukaszRozmej

Copy link
Copy Markdown
Member

@benaadams any actual benchmarks on avx-512 hardware (Zen 5) you can post?

Comment thread src/Nethermind/Nethermind.Core.Test/KeccakTests.cs
- Take the Keccak-f[1600] state as `ref ulong` in the internal AVX-512 and
  scalar permutations; the KeccakF dispatch extracts the ref once, so call
  sites stop materializing a Span for every permutation call
- Replace the stackalloc state in ComputeHash with an [InlineArray] struct
  local: localloc pinned the method at Tier0-FullOpts (no tiering, dynamic
  PGO, or inlining) and added GS-cookie and stack-probe overhead per call;
  as a struct local it tiers to Tier1 and inlines into ValueKeccak.Compute,
  folding the round size, padding index, and output copy for 32-byte outputs
- Add [SkipLocalsInit] to the hot wrappers (ValueKeccak.Compute,
  InternalCompute, ComputeHash, Update, UpdateFinalTo, GenerateValueHash),
  removing frame zero-init that Unsafe.SkipInit alone does not skip
- Drop `checked` from GetRoundSize: the entry guard bounds the output length
  to [1, 200], so the arithmetic cannot overflow

Verified with DOTNET_JitDisasm at Tier-1: ComputeHash now reaches Tier1 (was
permanently Tier0-FullOpts) with an rsp frame, no GS cookie check, no stack
probe, and no overflow branches; permutation call sites pass the state
pointer directly with no span stores. Wall-clock is unchanged within noise
for 20-532 byte inputs (the permutation dominates at ~240 ns per block).

Tests: KeccakTests 1051/1051 on x64 with AVX-512, including the
AVX-512-vs-scalar equivalence test; Nethermind.Core also builds with
-p:EnableZkEvm=true against the unchanged zkevm KeccakF partial.
@benaadams

Copy link
Copy Markdown
Member Author

@LukaszRozmej measured on a Ryzen 9 9950X (Zen 5, full AVX-512), .NET 10.0.11, Windows 11, BenchmarkDotNet 0.15.8 (DefaultJob, MemoryDiagnoser): ValueKeccak.Compute over random inputs at the sizes the client actually hashes, base = the master merge-base, runs alternated back to back, two per side:

Input master this PR
20 B (address -> account trie key) 241.9 / 244.7 ns 252.1 / 244.1 ns
32 B (storage slot, log topic) 244.6 / 244.0 ns 245.4 / 248.8 ns
64 B (pubkey -> address, mapping key) 243.1 / 244.5 ns 243.1 / 246.0 ns
100 KiB (~754 permutations) 183.6 / 185.6 us 183.9 / 191.0 us

Zero allocations on both sides; every delta is inside this box's 2-4% run-to-run noise floor, in both directions - so no wall-clock win claimed. A single f[1600] permutation costs ~243 ns on Zen 5 either way and dominates everything below the 136-byte rate. The permutation is latency-bound on the serial Theta-Rho-Pi-Chi-Iota dependency chain, and what this PR removes (the per-round constant rebuild, the lane masks, the bounds check) sits off that critical path where Zen 5's execution width was already hiding it - hence the gains land in the codegen table in the description (smaller body and frame, zero range checks), not in time. A port-narrower AVX-512 implementation might convert some of it into time; not measurable on this machine.

Also pushed a follow-up commit cleaning up the outer methods (ref ulong permutation entry points, [InlineArray] state instead of stackalloc so ComputeHash can tier and inline, [SkipLocalsInit], unchecked round size - details in the updated description). Interleaved before/after pairs at the same sizes measured neutral as expected; the structural win is that ComputeHash now reaches Tier-1 with dynamic PGO and inlines into ValueKeccak.Compute instead of being pinned at Tier0-FullOpts by the localloc.

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.

7 participants