Skip to content

keccak: defer squeeze permutation until needed - #23

Draft
yperbasis wants to merge 1 commit into
masterfrom
review/lazy-squeeze
Draft

keccak: defer squeeze permutation until needed#23
yperbasis wants to merge 1 commit into
masterfrom
review/lazy-squeeze

Conversation

@yperbasis

Copy link
Copy Markdown
Member

Summary

  • permute at the start of the next squeeze block instead of at the end of the current block
  • avoid unused work when Read stops exactly on a 136-byte boundary
  • add an invariant test and a boundary benchmark

Why

The current loop always computes the next Keccak permutation after copying byte 136, even when the caller asks for no more output. Moving that check to the top of the next loop iteration keeps the output unchanged and computes the next block only when it is requested.

This is an independent, focused split of the squeeze optimization in #13.

TDD

The regression test initially failed with:

read index after one full block = 0, want 136

Benchmark

Apple M2 Max, Go 1.25.7, representative results:

name              before       after
SpongeRead/135    154.7 ns/op   165.8 ns/op
SpongeRead/136    293.4 ns/op   163.4 ns/op
SpongeRead/137    298.5 ns/op   297.0 ns/op

The exact-boundary case avoids one full permutation. Adjacent sizes remain in the same range; the 135-byte variation is benchmark noise across runs.

Verification

  • go test ./...
  • go test -race ./...
  • go test -tags purego ./...
  • go vet ./...
  • go mod tidy -diff
  • golangci-lint run

Comment on lines +10 to +11
func TestSpongeReadDefersBoundaryPermutation(t *testing.T) {
var h sponge

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.

Missing the useASM hardware guard. This test drives sponge directly, bypassing the useASM check that Hasher.Write/Hasher.Read perform, so padAndSqueeze -> keccakF1600 reaches the platform assembly unconditionally. On amd64 without BMI1/BMI2 that is keccakF1600BMI2; on non-Darwin arm64 without cpu.ARM64.HasSHA3 (Cortex-A72 / Neoverse N1 — Raspberry Pi 4, Graviton2) that is keccakF1600Sha3. Either way go test ./... dies with SIGILL and takes the whole test binary down instead of skipping. TestXorAndPermute (keccak_asm_test.go:14) already guards this exact case.

Suggested change
func TestSpongeReadDefersBoundaryPermutation(t *testing.T) {
var h sponge
func TestSpongeReadDefersBoundaryPermutation(t *testing.T) {
if !useASM {
t.Skip("hardware acceleration unavailable on this CPU")
}
var h sponge

Comment on lines +26 to +28
func BenchmarkSpongeRead(b *testing.B) {
for _, size := range []int{rate - 1, rate, rate + 1} {
b.Run(strconv.Itoa(size), func(b *testing.B) {

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.

Same SIGILL exposure as the test above: the benchmark builds sponge directly and calls the assembly permutation regardless of useASM. go test -bench . ./... on an amd64 CPU without BMI2, or a non-Darwin arm64 without the SHA3 extension, crashes with an illegal instruction.

Suggested change
func BenchmarkSpongeRead(b *testing.B) {
for _, size := range []int{rate - 1, rate, rate + 1} {
b.Run(strconv.Itoa(size), func(b *testing.B) {
func BenchmarkSpongeRead(b *testing.B) {
if !useASM {
b.Skip("hardware acceleration unavailable on this CPU")
}
for _, size := range []int{rate - 1, rate, rate + 1} {
b.Run(strconv.Itoa(size), func(b *testing.B) {

Comment on lines +15 to +23
if h.readIdx != rate {
t.Fatalf("read index after one full block = %d, want %d", h.readIdx, rate)
}

var next [1]byte
h.Read(next[:])
if h.readIdx != 1 {
t.Fatalf("read index after next byte = %d, want 1", h.readIdx)
}

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.

The test pins readIdx but never checks the squeezed bytes, and no existing test covers the case this PR reorders. TestReadMatchesXCrypto does one single Read per hasher; TestReadMultipleCalls chunks by 37, so it never lands a call boundary exactly on 136. So Read(136) followed by Read(k) — the one sequence where the moved permutation could change output — is unverified against a reference. If the reorder were wrong (e.g. the guard used >=/> incorrectly, or the permutation were dropped), the suite would stay green while producing wrong extended output.

Adding a byte-level assertion to this test would close the gap, e.g. build the reference with sha3.NewLegacyKeccak256().(KeccakState).Read(want) over rate+k bytes and compare against Read(rate) + Read(k) from a Hasher. I ran that locally for first-read sizes 135/136/137/272 and the output matches x/crypto, so the production change itself is correct — this is only about locking it in.

@@ -0,0 +1,39 @@
//go:build (amd64 || arm64) && !purego

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.

New file duplicates the build tag and package of keccak_asm_test.go, which already holds the asm-only tests. Preference in this repo is to append to the existing _test.go for the package rather than add a parallel file — it keeps the useASM skip convention and the imports in one place.

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.

2 participants