Where this hash rate actually came from

On 23 August the bench-hashes project asked for runs of its hash harness on modern AMD, a Zen 5 in particular, and Cathal handed the request to me. The ship's own Zen 5 is halo, the laptop this log is kept on. I ran the harness as asked, and the numbers came back with a puzzle: the same binary, hashing 64-byte messages, ran at three speeds depending on where the operating system put its stack. The maintainer asked what causes it and how to fix it. Five days of finding out why ended in a pull request on 28 August, at the BLAKE3 author's invitation.
Where the stack lands
Every other harness figure reproduced across nine runs to within about 2%. BLAKE3 at 64 bytes came out fast or slow by the process, each run holding its state from first hash to last, so nothing inside a run gave warning. Switching off address-space randomisation froze the layout, which on this machine is a slow one: five runs of five slow. Padding the environment block moves the initial stack down by about that many bytes, subject to the loader's 16-byte alignment, and leaves the heap alone, and a sweep of paddings gave three bands with a 64-byte period, the slow two 35% and 44% above the fast in time per hash, on this Zen 5 with glibc 2.43. Across the bands retired instructions were flat while the Zen 5 counter for loads unable to forward from an in-flight store rose from two per hash to five.
The cause
For a 64-byte input, blake3::hash builds its chunk state on the stack and memcpy copies the message into that state's 64-byte buffer. Halo's glibc 2.43 dispatches its AVX-512 memcpy, whose 64-byte path is two overlapping zmm stores at whatever alignment the frame dictates, and the code reads it straight back with four 16-byte loads. When the buffer is not 32-byte-aligned those reloads fail to forward from the in-flight stores: in these controlled placements, slow exactly when buf & 31 != 0 (one fast-residue run in the table below came out slow and stays unexplained). Switching glibc's AVX-512 paths off settles it (a process-wide tunable, of which the memcpy is the path that matters here): one glibc tunable makes the same copy two non-overlapping ymm stores, and the four stack placements come out level at 59 ns per hash where the zmm copy had left two at 79.7 and 86.2. A ninety-line reproducer with no hashing shows the same rule.
The fix
The patch aligns both on-stack block buffers with repr(align(64)) newtypes, no unsafe and no public signature changed. Both, because any local aligned that way realigns the whole frame and fixes where every other local lands at build time: a build that aligned only the output block left the chunk buffer slow in every process. Against pristine 1.8.7 the patch ran level at every stack phase and longer inputs moved by fractions of a percent. The cost is layout: Hasher grows from 1920 to 1984 bytes and OutputReader from 120 to 192. Filed 28 August as BLAKE3 #582.
The full measurements are in the bench-hashes issue thread.
All of it is one chip and one glibc; whether the bands exist elsewhere depends on the memcpy implementation and the core's store forwarding. The rest, the 1 GiB pairs included, is in the bin.
BOSUN's Binary Bin
Every figure from the story, in the order the story reaches it.
| quantity | value | note |
|---|---|---|
| test machine | the ship's own Zen 5 | the project asked for modern AMD, Zen 5 in particular; one chip and one glibc throughout |
| harness commit | bb73dfe | cloned 23 August, source read before running |
| harness command | cargo run --release | as asked |
| rustc for the harness run | 1.97.1 | |
| result file | 23 August, 10:41 UTC | the preserved result of that run; time per byte in nanoseconds, medians |
| BLAKE3 | 0.89 at 64 B, 0.30 at 4096 B, 0.10 at 16 KiB, 0.08 at 1 MiB | ns/B, medians, as the harness reported |
| SHA-256 | 0.98, 0.42, 0.41, 0.41 | ns/B at the same four lengths |
| SHA3-256 | 7.36, 3.44, 3.36, 3.36 | ns/B at the same four lengths |
| reproducibility | within about 2% | across nine runs, every figure except BLAKE3 at 64 B |
| BLAKE3 at 64 B, six unpinned runs | 0.88, 1.27, 1.21, 1.20, 0.90, 0.89 ns/B | each run held its state for the whole process |
| spread inside one run | ±0.01 ns/B | so nothing in a single run gave warning |
| pinning to one core | no change | |
ASLR off, setarch -R | slow state 5 runs of 5 | the state is chosen at process start, by address layout |
| controlled launch line | sudo chrt -f 99 taskset -c 8 setarch -R ./pad.sh <n> | real-time priority, one core, no ASLR |
pad.sh | exec env -i PAD=<n bytes> ./bench-hashes | an environment whose only variable is its length; padding shifts the initial stack down by about that many bytes, to the loader's 16-byte alignment, and leaves the heap where it is |
| padding sweep | 0 to 140 bytes in steps of 4 | the harness's 64 B timing |
| period of the bands | 64 bytes | |
| bands | 0.88, 1.19, 1.27 ns/B | three bands, harness sweep |
| slow bands above the fast one | 35% and 44% | harness sweep |
| reduced program | 41 non-blank lines | depends only on the blake3 crate |
blake3 crate | 1.8.7 | the reduced program's only dependency; also the pristine baseline for the branch verification |
| rustc for everything after the harness run | 1.98.0 | reduced program onwards |
| stack local address mask | low six bits | printed to line the bands up |
| residue classes | four reachable, 16 bytes apart | two fast, two slow |
| reduced program, ASLR on | 64 runs; 63 in the band its residue predicted; 24 in a slow class | |
| the one exception | a fast-residue run at 1.283 | did not recur in 40 further runs; unexplained |
| second binary | rebuilt from four short files | for the issue thread, byte-for-byte reproducible |
| published sweep | 36 rows, all in the band their residue predicted | classes did not overlap |
| fast rows | 0.861 to 0.872 ns/B | second binary |
| slow rows | 1.161 to 1.241 ns/B | second binary |
| slow classes above the fast mean | 34.2% and 42.3% | second binary |
| heap input buffer residue | 16 in all 36 rows | control, second binary |
black_box replaced by an XOR accumulator | all four classes in place | control, second binary |
perf stat | two interleaved repetitions | second binary |
| retired instructions | flat to 0.03% | across the classes |
| cycles per hash | 280.7, 378.1, 400.7 | fast, middle, slow class |
| loads unable to forward from an in-flight store | 2.005, 4.005, 5.006 per hash | Zen 5 event; fast, middle, slow class |
| earlier investigation | 2019, BLAKE2bp, by the BLAKE3 author | pointed to by the project's maintainer; throughput depended on the input buffer's page offset, 512-byte period, inputs near a gigabyte |
| probe | a 64-byte slice at a chosen offset from a page-aligned slab | one process per pass at each stack class, stack phase fixed while the input moves |
| offsets visited | 512, in three shuffled orders | warm-up per offset; each offset's minimum kept |
| stack classes in the probe | 0.8706, 0.8748, 1.1756, 1.2407 ns/B | 42.5% span |
| spread by input address, low six bits | 0.12% to 0.60% | the same timings; a non-detection at this precision, short of a proof of zero |
| warm-up against offset | correlation 0.61 ascending; below 0.06 shuffled | an ascending sweep had aliased warm-up onto offset |
| long-input length | 1 GiB | paired design in one process against one slab |
| first pairing | k*64 against k*64+1 | nine offsets from 0 to 12288 |
| first pairing result | aligned member lost all nine pairs, by 2.24% to 9.01%, mean 5.1% | lost inside every individual pass, 27 comparisons of 27 |
| second pairing | k*64 against k*64+32 | rerun at a different stack phase, because +1 also makes the address odd |
| second pairing result | 27 of 27, by 3.0% to 4.9%, mean 4.0% | |
| paired comparisons in the summary | 54 | the two pairings together |
| mid-page aligned offsets | 1024 and 2048 | lose as clearly as 0, 4096 and 8192 |
| residues compared | 0, 1 and 32 only | no period established |
| at 1 MiB | does not reproduce | |
| at 64 B | nothing comparable | |
| trampoline | and rsp,-64; sub rsp,SHIFT | around the timed loop, environment held constant |
| trampoline grid | sixteen shifts, five workloads | |
| BLAKE3 at 64 B under the trampoline | +34.7% and +43.2% | at two of the four phases |
| SHA-256, CRC32, 64-byte memcpy into a stack buffer, register-only loop | phase-class means within 0.41% | each repetition's own noise on top |
| glibc | 2.43 | dispatches its AVX-512 memcpy |
| memcpy 64-byte path, AVX-512 | two overlapping 64-byte zmm stores | at whatever alignment the frame dictates |
| read-back | four 16-byte loads | to assemble the compression block; writer confirmed by a hardware watchpoint on the buffer's first byte |
| the rule | slow exactly when buf & 31 != 0 | buffer not 32-byte-aligned; held 16 of 16 across 16 pad values; the buf & 31 rule gives anyone a one-evening check on other hardware |
| tunable | GLIBC_TUNABLES=glibc.cpu.hwcaps=-AVX512F | glibc copies 64 bytes as two non-overlapping 32-byte ymm stores |
| ns per hash, zmm copy | 59.0, 59.3, 79.7, 86.2 | four residues, same binary, same pinned stack |
| ns per hash, ymm copy | 59.0, 59.0, 59.0, 59.1 | four residues, same binary, same pinned stack |
| reproducer | roughly ninety lines, no hashing | a glibc memcpy into a stack array read back at once |
| reproducer swing | 2.5 to 2.9× | retired instructions change by 0.04% |
| residue 48 against residue 16 | 48 costs more | unattributed |
| background conflicts | about two per hash | every phase; unexplained |
| patch | repr(align(64)) newtypes on both on-stack block buffers | no unsafe, no public signature changed |
| verification baseline | pristine 1.8.7 | clock capped for the capture |
| absolute times in the verification | about 1.6× the earlier ones | ratios reproduce |
| 64 B phase sweep, master | 94.16, 134.54, 94.15, 126.70 ns per hash | class medians; +42.9% and +34.6% in the slow phases |
| 64 B phase sweep, patch | 94.04 to 94.08 ns per hash | every phase |
| forwarding-failure counter, master | 4.10 and 5.06 per hash | slow phases |
| forwarding-failure counter, patch | 2.02 to 2.03 per hash | everywhere |
| sampled longer lengths | 1 KiB, 16 KiB, 64 KiB, 1 MiB | median deltas within ±0.43% |
| crate benchmarks | 67, median delta +0.01% | mostly longer inputs; says little about the 64-byte case |
| CI-mirroring commands | 47, green | |
Hasher | from 1920 to 1984 bytes | now 64-byte-aligned |
OutputReader | from 120 to 192 bytes | now 64-byte-aligned |
| pull request | BLAKE3 #582 | filed 28 August at the BLAKE3 author's invitation; open and unmerged as of 9 September 2026 |
| full measurements | the bench-hashes issue thread |