Skip to content

keccakf_arm64: drop unused 200-byte frame, mark NOSPLIT - #12

Closed
AskAlexSharov wants to merge 4 commits into
masterfrom
perf/arm64-nosplit-frame
Closed

keccakf_arm64: drop unused 200-byte frame, mark NOSPLIT#12
AskAlexSharov wants to merge 4 commits into
masterfrom
perf/arm64-nosplit-frame

Conversation

@AskAlexSharov

@AskAlexSharov AskAlexSharov commented Jul 16, 2026

Copy link
Copy Markdown
Contributor

The arm64 TEXT directive declared a 200-byte stack frame that the function never uses — the entire Keccak state lives in V0–V24 and never spills (this is likely a leftover from an earlier version that spilled to the stack). A non-zero frame makes the function non-leaf-like: the assembler emits a stack-bound check plus frame setup/teardown on every permutation call, i.e. once per 136-byte block.

Change to NOSPLIT, $0-16.

What actually changes

Disassembly of the prologue, before:

MOVD  16(R28), R16
SUB   $96, RSP, R17
CMP   R16, R17
BLS   150(PC)             // -> morestack
MOVD.W R30, -224(RSP)
MOVD  R29, -8(RSP)
SUB   $8, RSP, R29
MOVD  232(RSP), R0        // first real instruction

after:

MOVD  8(RSP), R0          // first real instruction

plus the matching epilogue and the morestack tail. $0 alone is what removes
them (a leaf with a zero frame gets no prologue at all); NOSPLIT is
documentation — it records the requirement for anyone who later adds a frame.

Benchmark

Honest answer: not measurable on a wide out-of-order core. Apple M4 Max,
-count=8, benchstat:

                     │   $200-16   │        NOSPLIT, $0-16       │
FasterKeccak/32B-16    125.5n ± 2%   124.4n ± 1%  ~ (p=0.161 n=8)
FasterKeccak/128B-16   128.7n ± 1%   129.6n ± 0%  +0.74% (p=0.001)

Seven instructions are noise next to 24 permutation rounds here. The change
stands on correctness and code size rather than a speedup, and it is more
likely to show up on narrow/in-order ARM cores where the removed check is a
larger share of per-call work. (An earlier revision of this description quoted
-6.8% on 32B; that did not reproduce and has been retracted.)

Verification

  • go vet ./... (asmdecl validates the frame/argument sizes against the Go
    declaration) — clean, native and GOOS=linux GOARCH=arm64
  • go test ./... on native arm64 — the path this assembly actually takes
  • disassembly diff above
  • asmfmt clean

The TEXT directive declared a 200-byte stack frame that the function
never touches — the whole state lives in V registers. The frame forces
a stack-bound check and frame setup/teardown on every permutation call
(once per 136-byte block).

Declare NOSPLIT $0-16 instead. ~7% faster on single-block hashes on
an Apple M-series (BenchmarkFasterKeccak/32B: 137.3ns -> 128.0ns,
p=0.000, n=8); larger sizes within noise.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR optimizes the arm64 Keccak-f[1600] permutation assembly by removing an unused stack frame and marking the function as NOSPLIT, reducing per-call overhead in the hot permutation path.

Changes:

  • Change TEXT ·keccakF1600Sha3 from a $200-16 frame to NOSPLIT, $0-16 on arm64.
  • Eliminate the implicit stack growth check and prologue/epilogue associated with a non-zero frame.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@AskAlexSharov
AskAlexSharov force-pushed the perf/arm64-nosplit-frame branch from 7dffb4d to f792e6b Compare July 20, 2026 08:16

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

lint.yml tells auditors to diff this file against Go's
crypto/internal/fips140/sha3/sha3_arm64.s. That file declares $200-8, so
the frame change here shows up as an unexplained difference and is easy to
revert on the next upstream re-sync. Record why it is there instead.

@domiwei domiwei left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Approved. The zero-frame ABI0 leaf is safe: the generated symbol has no stack-pointer writes, calls, or morestack path, and the argument layout remains correct. One non-blocking wording comment: NOSPLIT makes the current no-split intent explicit, but it does not mechanically require the frame to remain zero; a future edit can add a frame while retaining the flag. Please consider adjusting the comment so it does not imply that NOSPLIT guards against future spills or frames.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Copilot reviewed 1 out of 1 changed files in this pull request and generated no new comments.

@yperbasis yperbasis left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The $0 frame is correct for the current body — the state never leaves V0–V24, and arm64 CI genuinely exercises this path. Requesting changes for one runtime regression and a few accuracy/durability points.

Preemption regression (blocking). The dropped stack-growth check was the only cooperative preemption point in the absorb/squeeze block loops, and asm bodies are never async-preemptible, so one large Sum256/Write call now stalls stop-the-world for its full duration. Measured max GC pause (/sched/pauses/stopping/gc): from ~164 µs on master to ~42 ms at 32 MiB input and ~168 ms at 128 MiB, with every P in the process blocked meanwhile. Latent at erigon's ≤24 KiB inputs, but Sum256/Write are public and size-unbounded; stdlib's fips140 sha256 chunks its asm calls at 64 KiB for exactly this hazard. Please chunk the Go-side block loops (~64 KiB), or keep a ≥128-byte frame.

Claims that don't hold (each checked empirically):

  • "NOSPLIT records that requirement" — it doesn't: NOSPLIT, $64-16 with real frame use passes build, vet, and tests silently. Worse, a ≥128-byte frame added under a kept NOSPLIT silently loses the stack check it would otherwise get. Reword as unenforced intent, or drop the sentence (same wording in the file comment and the PR description).
  • "Keep the $0 when re-syncing" needs its condition: only while the synced body still never touches the stack. A named-local spill under $0 assembles into a below-SP store with no diagnostic — green on darwin/linux, memory corruption where below-SP isn't safe (ios/windows arm64).
  • Verification section: asmdecl validates argument size and FP offsets only, not the frame size — NOSPLIT, $16-16 passes go vet clean on this exact file. Please correct it, so the next re-syncer doesn't trust vet for the one field this PR changes.

Divergence durability. The next upstream re-sync is a hand merge (upstream renamed the function to keccakF1600NEON), and the comment sits in the exact hunk such a merge rewrites — nothing would catch $200 silently returning. Suggest a one-line CI grep pinning NOSPLIT, $0-16 (ci.yml's amd64 drift check exists for exactly this failure class) and a clause in lint.yml's diff -w audit note naming the intended TEXT-line delta. Filing the one-liner upstream would let the delta converge instead of being hand-carried forever. While reworking the comment: the prologue mechanics and the $200-8 literal will rot; the two load-bearing facts are "body never touches the stack" and "upstream declares a dead frame — don't restore it".

Minor. Commit f792e6b still says "~7% faster", which this description retracts — squash-merge with the corrected text or reword the commit. Optional: PCALIGN $16 before rounds: restores the loop-head 16-byte alignment the prologue removal shifts (loop head moves from entry+0x110 to entry+0xf4; cost is 3 one-time NOOPs) and is a plausible mechanism for the +0.74% (p=0.001) 128B datum. FYI: the frameless leaf drops the immediate caller from Linux perf FP-only callchains on arm64 (Go pprof unaffected).

@AskAlexSharov

Copy link
Copy Markdown
Contributor Author

Reproduced every point. The preemption regression is real, and chasing it turned up that the same bug already ships on amd64 — so it is now split into #32, and this PR looks like it should be dropped.

Preemption regression — confirmed

Max GC stopping pause while hashing, M4 Max, GOMAXPROCS=2, measured by diffing /sched/pauses/stopping/gc around only the hashing phase:

32 MiB 128 MiB
master 0.164 ms 0.229 ms
this PR 33.554 ms 100.663 ms

Same magnitude as your numbers.

The same bug already ships on amd64

keccakF1600BMI2 is NOSPLIT, $200-16. NOSPLIT drops the stack check whatever the frame size, so amd64 has had no preemption point in these loops all along — this PR does not introduce that, it extends it to arm64. On an EPYC 4344P, master measures 50.3 ms at 32 MiB and 67.1 ms at 128 MiB.

Fixed in #32 by removing NOSPLIT from the amd64 kernel: pause drops to 0.098 / 0.016 ms, throughput moves +0.25% geomean. Two lines, generator included.

I also built the chunking fix you asked for — absorb and squeeze returning every 16 KiB to a function that carries a stack check. It works (0.049 ms) but costs 5.5-8.9% geomean and ~16% at 32 B, and the cost persisted even when the small-input path executed code byte-identical to master, so it is package layout rather than the added logic. Removing NOSPLIT is strictly better, so I dropped the chunking.

This PR does not appear to buy anything

M4 Max, -count=8, Sum256:

Size master this PR
32 B 125.2 ns 125.7 ns ~ (p=0.487)
128 B 131.7 ns 131.8 ns ~ (p=0.622)
1 KB 1.026 us 1.030 us ~ (p=0.203)
500 KB 485.4 us 489.5 us +0.83% (p=0.001)
geomean +0.40%

No size improves, 500 KB regresses. That matches your +0.74% datum and your PCALIGN hypothesis about the loop head moving. Combined with the stall, the trade is a latency regression for no throughput gain, so my suggestion is to close this rather than repair it — the dead upstream frame is real but removing it is what costs the preemption point. Happy to keep it open with just the comment corrections if you would rather land those.

Your accuracy points — all confirmed

  • "NOSPLIT records that requirement" — it does not. NOSPLIT, $64-16 with real frame use builds, vets, and tests clean. Same wording flagged by @domiwei.
  • "Keep the $0 when re-syncing" — needs the condition, since a named-local spill under $0 assembles to a below-SP store with no diagnostic.
  • asmdecl — validates argument size and FP offsets only, never frame size; NOSPLIT, $16-16 passes go vet clean on this file. Worth correcting wherever it survives, since it is the one field a re-syncer would want vet to cover.
  • Divergence durability — agreed; keccakf_amd64: drop NOSPLIT so the runtime can preempt the block loops #32 puts the reason in the generator, next to the line, so a re-sync has to read it.
  • ~7% faster in f792e6b — retracted by the measurements above.

Left alone deliberately: the PCALIGN $16 experiment, since it only matters if this PR lands.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants