soft3/hemera/audit/permutation-performance-2026-10.md

permutation performance — 2026-10-09

Work package P of the proof-system repair: make the hemera permutation and its 2-to-1 node compression fast on Apple Silicon and in portable Rust, without changing an output bit. Branch perf/permutation-neon on 23f3bbc (hemera 0.3.1).

Machine: Apple M4 Max (12 P + 4 E cores), 48 GiB, macOS 26.4.1, rustc 1.95.0 (Homebrew), default release profile (no LTO, no target-cpu). The machine was shared with other sessions throughout: load average 8–19 during every measurement below. macOS offers no core pinning; absolute numbers carry ±10 % run-to-run noise, which is why the final comparison alternates binaries (§3).

1. bit-exactness

Every optimised path is compared on canonical values against reference.rs, a frozen oracle that keeps the 0.3.1 arithmetic and round order but computes x⁻¹ by plain square-and-multiply over the bits of p−2 (the definition, independent of any addition chain). rs/tests/differential.rs, release build, HEMERA_DIFF_N default 10⁶:

family inputs
permutation, single and permute_batch (flush sizes random 1..=40, so every lane group 16/8/4/2/1 runs) 1 000 000 states
hash (lengths 0..=200 B, across the 56-byte rate) 1 000 000 messages
hash_node, single and hash_node_batch, both root flags 1 000 000 pairs
hash_leaf 125 000 leaves
hash_leaf_batch (groups 1..=40, equal and unequal lengths) 250 000 leaves
x⁷ RF 6+6 / RP 48 profile shape, and the same shape with x⁻¹, single and batched 100 000 states each

Inputs include non-canonical representatives ([p, 2⁶⁴)), all-zero and all-p states, and edge limbs (0, 1, p−1, p, p+1, 2⁶⁴−1, 2³², 2³²−1). Plus 100 000 states checking that permute_traced and StepSponge end bit-identical (raw limbs) to permute. Total ≈ 3.7 × 10⁶ randomized inputs, 0 mismatches, 6.8 s on all cores. The same test passes with the portable arithmetic forced on aarch64 (RUSTFLAGS="--cfg hemera_portable"), and in a debug build (10⁴ per family, overflow checks on). The pinned vectors (rs/tests/vectors.rs) and all 247 unit tests pass unchanged.

Output representation: permute, permute_traced and the last StepSponge round now return canonical limbs. The 0.3.1 code could return a limb in [p, 2⁶⁴) (field-equal, probability ≈ 2⁻³² per limb); the consumers found (hash output encoding, the wgsl GPU test, zheng execution/hash.rs and ccs/root.rs) read canonical values. Goldilocks * Goldilocks returns the same representative as before, bit for bit.

2. steps, measured during development

examples/bench.rs (median of N timed batches) and examples/split.rs (median of 41). Single runs under load, so read the trend, not the last digit. "batch" = 1024 independent permutations through the best API at that step.

step change permute, single (ns) batch (ns / perm)
0 baseline 0.3.1 3 979 – 4 042 3 806 – 4 049 (loop)
1 Goldilocks mul: hand-scheduled aarch64 reduction (latency form) 3 654 3 258 (loop)
2 kernel generic over profile and lanes: u128 external layer, x' = (d₀+1)·y + (rest + c) carried directly, depth-69 inverse chain, Montgomery batch inversion, 8 lanes 2 961 – 3 155 1 022 – 1 201
2b lane count sweep — 4: 1 416 · 8: 1 132 · 16: 968 · 32: 912
3 9-instruction throughput mul form for S-boxes and the internal layer; 16 lanes 2 964 897
4 partial rounds scheduled per lane count (internal-layer products issued before the S-box for 1 lane; one fused multiply-add per element for many) 2 832 – 2 865 793 – 837
5 the 15 internal-layer rows of round r interleaved into round r+1's inversion chain (program order, so the OoO core overlaps them) 2 747 – 2 873 722 – 734
6 batch tails in groups of 8/4/2 instead of one by one — 20 states: 781 (before: 16 × 722 + 4 × 2 750 ⇒ 1 128 computed)

Breakdown at step 5 (split, median): single = full rounds 413 + partial rounds 2 347; batched = full rounds 388 + partial rounds 348 ns per permutation. Replacing the S-box by the identity (throwaway build) put the batched linear layers at 130 ns (external) and 196 ns (internal), so batched inversion costs ≈ 150–210 ns per permutation.

Goldilocks multiplication (scratch harness, 10⁷ random pairs checked per variant)

form dependent-chain latency (ns) throughput, 8–16 chains (ns)
0.3.1 reduce128 as compiled 2.35 – 2.54 0.43 – 0.45
asm, constant-time csel for the borrow 2.25 – 2.35 0.49 – 0.52
asm latency form (shipped arith::mul): borrow as predicted branch, t1 and t1+ε in parallel, carry → csel 1.92 – 2.03 0.42 – 0.51
asm throughput form (shipped arith::mul_t): shifted/extended-register operands, 9 instructions 2.16 – 2.42 0.38 – 0.42

The latency form is ~7.7 cycles at the measured ~3.9 GHz: umulh (3) + shift + subtract + add + select. The borrow branch fires when lo < hi_hi < 2³² (≈ 2⁻³² per product); Plonky3's Goldilocks reduction makes the same choice. It is a data-dependent branch in a hash that also runs keyed; the constant-time form costs ≈ 0.3 ns per serial multiply (≈ +16 × 70 × 0.3 ns ≈ +0.34 µs per single permutation). Recorded here so the trade is visible.

NEON (examples/neon_mul.rs)

aarch64 NEON has no 64×64→128 multiply; a 2-lane Goldilocks product takes four umull plus carry and reduction logic (~29 vector instructions). Checked equal to the scalar product on 10⁶ random pairs.

ns per lane-multiplication (median)
scalar, 16 independent chains 0.453
NEON, 8 vectors (16 lanes) 0.966
NEON, 16 vectors (32 lanes) 0.853
hybrid, 12 scalar + 2 vectors 0.577
hybrid, 16 scalar + 8 vectors 0.479
scalar latency, 1 chain 2.041
NEON latency, 1 vector (per lane) 4.464

NEON emulation is ~2× slower per lane than scalar, and adding NEON lanes beside scalar chains did not raise total throughput in either mix measured. Lane parallelism is therefore done as scalar interleave (ILP) in a structure-of-arrays layout, which the kernel does for 2/4/8/16 lanes. NEON for the addition-only external layer (≈ 130 ns of 720 per batched permutation) was not attempted; the ceiling of that change is below 18 %.

honeycrisp

honeycrisp/acpu/src/field/goldilocks.rs offers gl_mul (inline mul+umulh, then the same reduction as 0.3.1) and gl_mul_batch (4 interleaved scalar chains). No vector or matrix-unit field multiply exists there to use; gl_mul reduces exactly like the 0.3.1 row of the table above, which the shipped forms beat (acpu itself was not benchmarked). Not used.

nebu

strata/nebu/rs/field.rs::reduce128 is the 0.3.1 reduction (table above). Side finding, outside this package: a standalone copy of nebu/rs/algebra_impl.rs dot + its local reduce128 (lines 73–117) returns the wrong value for 10 000 of 10 000 random length-4 inputs (the u128 accumulator wraps after two products, and hi.wrapping_mul(ε) is only correct for hi < 2³²). Measured on the copy, not on nebu itself.

3. final numbers (alternating binaries)

Baseline = origin/main 23f3bbc with the same bench (batch APIs as plain loops). aarch64 = this branch. portable = this branch with --cfg hemera_portable (no asm; the code path non-aarch64 targets get). Five alternating rounds × 31 runs each, median of the five medians, ns per operation. Load average 9–17.

operation baseline aarch64 portable speedup aarch64 speedup portable
Goldilocks mul, dependent chain 2.7 2.0 2.6 1.35× 1.04×
permute, single (chained) 4 146 2 932 3 803 1.41× 1.09×
hash 64 B (2 permutations) 8 482 6 082 7 558 1.39× 1.12×
hash_node, single (chained) 4 166 3 026 3 648 1.38× 1.14×
1024 × permute, loop 4 132 2 992 3 953 1.38× 1.05×
1024 × permute_batch 4 084 832 914 4.91× 4.47×
1024 × hash_node_batch 4 079 784 998 5.20× 4.09×
1024 × hash_leaf 144 B, loop 16 799 12 160 15 851 1.38× 1.06×
1024 × hash_leaf_batch 144 B 16 775 3 268 3 929 5.13× 4.27×

The x⁷ RF 6+6 / RP 48 shape (profile-v2 proposal, hemera PR #15) through the same kernel (split, placeholder constants — cost only): single 1 375 ns, batched 1 242 ns per permutation. Against the live profile: half the single-permutation latency (no 70-deep inverse chain), 1.7× the batched cost (48 partial rounds × 16 multiplications against 16 × ~25).

4. lens WHIR verify

Permutations counted with a throwaway counter (hemera, not committed) in one Whir::verify, rate 1/16, lens feat/rs-whir-pcs 8f188a9:

num_vars permutations node compressions leaf permutations (leaves) other (transcript, PoW)
10 530 245 224 (56) 61
20 2 467 1 572 680 (114) 215

Verify time, three lens binaries alternated five times (15 runs each, median of medians, load 13–19):

num_vars baseline hemera this branch, lens unchanged this branch + lens batching (throwaway)
10 2.606 ms 1.913 ms 0.794 ms
20 11.928 ms 8.769 ms 3.592 ms

"lens batching" is a throwaway lens diff (not committed): merkle::verify hashes each level's nodes with one hash_node_batch call, and WHIR's check_opening hashes a round's opened leaves with one hash_leaf_batch call. cargo test --release -p cyber-lens-rspcs (28 tests: completeness to 2²⁰, wrong-claim rejection, bit-flip scans, cheating prover) passes with it.

Projection by count × cost (cost per permutation from §3: single 2.93 µs, batched 0.80 µs; non-hash residual = baseline verify − 530 or 2 467 × 4.15 µs = 0.41 ms at 2¹⁰, 1.69 ms at 2²⁰):

num_vars lens unchanged: residual + all × 2.93 µs lens batching: residual + (nodes + leaf) × 0.80 µs + other × 2.93 µs
10 0.41 + 1.55 = 1.96 ms (measured 1.91) 0.41 + 0.38 + 0.18 = 0.97 ms (measured 0.79)
20 1.69 + 7.23 = 8.92 ms (measured 8.77) 1.69 + 1.80 + 0.63 = 4.12 ms (measured 3.59)

The ≤ 1 ms target holds at 2¹⁰ once lens adopts the batch calls; at 2²⁰ it does not.

5. what limits further speedup

  • single permutation, live profile: the partial rounds are one serial chain, 16 × (69 squarings/multiplications of the inverse + 1 multiply-add) ≈ 1 100 dependent multiplications at ≈ 2.0 ns = 2.2 µs of the 2.75–2.93 µs. The chain depth is ≥ 64 for any exponentiation (log₂ p); 69 is within 5 of that bound. A further cut needs a non-exponentiation inverse (binary GCD / safe-gcd — not evaluated) or the x⁷ profile (§3: 1.38 µs).
  • batched: instruction throughput. A Goldilocks product is 9–13 scalar instructions (no 64-bit modular multiply, no useful NEON form); a batched permutation retires ≈ 14 k instructions. Full rounds are 512 S-box multiplications; partial rounds 15 internal-diagonal multiply-adds per round — the diagonal entries are full 64-bit values, so no shift-and-add form exists (the Poseidon2 reference picks small diagonals; changing them changes the hash).
  • lens: transcript and PoW permutations (61 / 215) are serial; the non-hash verifier work (sumcheck, folding, Fp3 arithmetic) is ≈ 0.4 ms at 2¹⁰ and ≈ 1.7 ms at 2²⁰ by the residual estimate — at 2²⁰ that alone exceeds 1 ms. The verifier is single-threaded; spreading the 2 252 batchable permutations at 2²⁰ across cores is the remaining hashing lever (≈ 1.8 ms single-core; not measured).

6. constant-time kernel for secret input (added 2026-10-09)

Decision (coordinator): keep the branchy fast multiplication for public hashing; every path that can see secret input runs a constant-time kernel, chosen by the entry point and not by the caller. The list of entry points is in specs/api.md § constant time: keyed_hash, derive_key, the new hash_secret (= hash, constant time — mudra's domain_scalar hashes secret entropy with plain hash today), the hashers behind them and their XOF readers, and permute_ct.

Implementation: rs/src/backend.rs — the kernel is generic over Fast and Ct arithmetic. Ct multiplies in 13 aarch64 instructions with both reduction fix-ups as csel (no branch by construction), adds and canonicalises with masks; the portable form uses masks. Zero lanes in the batch inversion are masked rather than branched on in both backends, and Goldilocks +, − and as_canonical_u64 are now mask-based (same bits; the sponge absorbs and encodes secret input with them).

Checks:

  • rs/tests/constant_time.rs: permute_ct = permute, raw limbs, on 10⁶ random states (edge values and non-canonical limbs included); hash_secret, hash, keyed_hash, derive_key and a secret XOF against a reference sponge on reference::permute, 10⁵ random inputs each. 0 mismatches; also with --cfg hemera_portable and in debug.
  • Disassembly of the compiled permute_ct (release, aarch64 asm path 2 354 lines; portable path 2 043 lines): 19 / 22 conditional branches, every one comparing a loop counter with a constant (round loops, the 15- and 33-squaring runs, the every-third-squaring interleave counter, the 15-row loop); every indexed memory access uses a row counter. No branch or address depends on a field value. This is an inspection of one compiler output (rustc 1.95.0), not a guarantee for other compilers — the asm multiply is the part that holds by construction.

Cost (examples/bench.rs, 5 runs × 31, median of medians, ns, load 9–30):

operation fast constant time ratio
permutation, single (aarch64) 2 991 3 833 1.28×
64-byte digest (hash vs hash_secret) 6 660 7 836 1.18×
keyed_hash, 64 B (2 permutations) — 7 873 —
permutation, single (portable, 3 runs) 4 176 4 406 1.06×
64-byte digest (portable) 7 931 8 589 1.08×

The constant-time single permutation is still faster than the 0.3.1 fast one (4 146 ns, §3). Its multiplication is ≈ 9 cycles on the serial chain against 7–8 for the fast form (the borrow fix-up returns to the dependency chain).

reproduce

cargo test  --release -p cyber-hemera --test differential -- --nocapture
cargo test  --release -p cyber-hemera --test constant_time -- --nocapture
cargo run   --release -p cyber-hemera --example bench [-- runs]
cargo run   --release -p cyber-hemera --example split
cargo run   --release -p cyber-hemera --example neon_mul
RUSTFLAGS="--cfg hemera_portable" cargo test --release -p cyber-hemera   # portable path

Graph