hayahash64: fast hashing without requiring SIMD or wide multiply
An AMD Zen 5 CCD (Ryzen 5 9600X): the silicon where vpmullq runs as a single µop, which matters later in this post. Die photograph by FritzchensFritz, released under CC0.
TL;DR#
- hayahash64 is an experimental, non-cryptographic 64-bit hash function: one C99 header, plus nine bit-exact ports (Rust, Go, Zig, Java, C#, Python, Swift, JS/TS, MIPS64 assembly). It passes all 188 default tests of the project’s pinned SMHasher3 revision.
- Its design constraint is unusual: the algorithm requires no SIMD, no AES instructions, no 64×64→128 widening multiply. Only operations every 64-bit platform can do. On native x86-64/ARM64 that usually makes it slower than rapidhash v3, and the project says so on its own front page.
- The payoff shows up where the wide instructions aren’t in play. In a scalar wasm build, rapidhash’s widening multiply becomes a multi-instruction software routine, and hayahash64 came out 3–4× faster in bulk in both browser engines I measured.
- One surprise: on an AMD Zen 5 with AVX-512DQ, the compiler vectorizes four of the eight lanes on its own, no intrinsics in the source. That build reaches 31.2 bytes/cycle in the pinned SMHasher3 bulk test, ahead of the wide-multiply hashes in that shootout that also pass the suite, and 61.3 GB/s in a separate 1 MiB benchmark.
- You can verify the browser claims yourself in about ten seconds: hayaha.sh/playground.html loads a prebuilt five-hash wasm module and races it on your machine.
A hash that doesn’t require the fast instructions#
Most fast hash functions earn their speed from one of three hardware features:
- a widening multiply, 64×64→128 bits (rapidhash, wyhash, komihash, a5hash),
- SIMD, wide registers processing many lanes at once (XXH3),
- AES rounds repurposed as a mixer (gxhash).
These features are useful but not uniformly cheap or available in every target. Portable C cannot assume a 64×64→128 type. The scalar WebAssembly build used later in this post has only the low-half i64.mul (modern wasm does standardize SIMD128, but this benchmark deliberately compiles without SIMD flags). Some managed runtimes expose wide products (Math.multiplyHigh on the JVM, Math.BigMul in .NET), but their availability and lowering vary by runtime and version. So I asked a narrower question: how fast can a hash go when its algorithm requires only ordinary 64×64→64 multiplication, shifts, rotates, additions, and XORs, with zero architecture-specific intrinsics?
The contract hayahash64 commits to:
- No SIMD requirement, no intrinsics, no CPU-specific instructions in the algorithm.
- No undefined behavior: every operation is defined by the C standard.
- Same hash value on little- and big-endian machines.
- One C99 header, and every port produces bit-identical output. Every port checks shared known-answer vectors; the language ports additionally run in a nightly differential fuzz against the C reference, while the MIPS64 assembly port currently relies on the shared vectors.
A note on scope before the numbers: SMHasher3 is a statistical and structural test gate, not a proof of security or of universal collision quality. Passing it means the hash cleared every distribution, avalanche, and structured-keyset test in that suite at the pinned revision, with verification value 0xF3C4A9B4. The full evidence trail lives in quality.md.
What is AVX-512DQ, and why does it matter here?#
AVX-512 is x86’s family of 512-bit SIMD extensions, sliced into feature flags: F (foundation), BW (byte/word), VL (shorter vector lengths), and so on. DQ stands for doubleword/quadword. Among other things it adds VPMULLQ: a packed multiply of 64-bit integer lanes that keeps the low half of each product, available at 128-, 256-, and 512-bit widths. AVX2 has no corresponding single packed 64-bit multiply (its integer multiplies top out at 32×32→64). In the builds tested here, the compilers left this loop scalar until the AVX-512 packed-qword multiply was available.
hayahash64’s bulk loop maintains eight 64-bit lane states, and its inner operations are adds, rotates, XORs, and multiplies. That looks like perfect vectorization food, but the interesting part is what the compiler actually does, which is more selective than “throw all eight lanes into one register”:
Instruction behavior per AMD’s Software Optimization Guide for Zen 5 (58455) and uops.info measurements.
On the measured Zen 5, the compiler uses a 4+4 split: the middle four states, h2..h5, become one 256-bit vector, while h0, h1, h6, and h7 stay scalar because they carry the previous-word chain and a per-block checkpoint (both explained in the next section). Clang discovers this split from the ordinary scalar spelling. GCC’s vectorizer can’t see it there, so the header carries an equivalent GCC-and-Zen-specific spelling, a tiny local array updated through a countable loop, that gives its vectorizer a usable seed. Either way the source contains scalar C operations only, no SIMD intrinsics, and the digest is bit-identical in every shape.
On that Zen 5, the compiler-generated vectorization lifts sustained bulk throughput from roughly 35 to 61 GB/s. And the win is a Zen 4/5 property rather than an AVX-512 property: Skylake-X-class servers execute vpmullq as microcode (three uops, ~15-cycle latency; compare the per-microarchitecture measurements on uops.info), which lands right on the loop’s xor-add-multiply chain and makes the “optimization” ~30% slower there, so the header deliberately suppresses the transform on those targets. The full record, with compiler output, is in pass-5-vectorization.md.
The eight lanes weren’t chosen for AVX-512, though. They exist for instruction-level parallelism: a 64-bit multiply has a few cycles of latency, and eight independent chains were enough to keep the multiplier pipeline busy on the M1 and Zen 5 systems I measured, even fully scalar.
How the algorithm actually works#
The header is long, but most of it is comments, portability helpers, and per-compiler dispatch shapes; the algorithmic core is compact. Input length picks one of three paths: keys of at most 16 bytes take a two-multiply short path, 17 to 319 bytes run a four-lane loop over 32-byte rounds, and 320 bytes up enter the eight-lane bulk loop. The 320-byte boundary is a fixed algorithm parameter, not a tuning knob: it bounds the four-lane path below the rotation’s 64 stripes, and changing it would change hash values. It also means the AVX-512 story above applies to long inputs, not to hash-table-sized keys. Four ideas carry the design.
1. Premix the seed and the length once.
s = seed ^ (len * K)
K = 0x9E3779B97F4A7C15 (2^64 / golden ratio, odd)
Every path folds s in. Mixing the length into the initial state makes otherwise identical overlapping-tail byte patterns length-dependent, so the tail-loading scheme in idea 4 cannot by itself create a trivial equivalence between different lengths. (It does not, of course, make a 64-bit hash collision-free.)
2. Bulk: eight lanes over 64-byte blocks, plus a per-block checkpoint.
Each 64-byte block is read as eight 64-bit words. Each lane absorbs its word plus a rotated copy of the preceding word, multiplies, and after the eight stripes the block’s last raw word is added into lane 0:
for each 64-byte block:
for each lane i in 0..7:
h[i] = (h[i] ^ (w[i] + rotl(prev, 27))) * K
prev = w[i]
h[0] += prev # per-block raw-word checkpoint
The per-lane carried work is one XOR and one multiply (lane 0 also carries the checkpoint add, which AArch64 folds into a single multiply-add). Loads, absorbs, and rotates are independent work the CPU can overlap across lanes and blocks. That combination, a short carried chain with plenty of independent work beside it, is where the bulk speed comes from.
Neither half of the absorb is decoration. w + rotl(w_prev, 27) makes the absorb sequence injective: at the first stripe where two messages differ, the values entering the lanes must differ, so no structural difference pattern can silently cancel. Earlier drafts used XOR here, and SMHasher3 found sparse-key collision classes, because XOR differences are linear over GF(2) and can be arranged to cancel. The checkpoint line closes a different hole: a known 64-stripe rotation-orbit construction could otherwise delay a difference until it rotated back to its starting lane, and adding one raw word per block into h0 breaks that ladder. Both defenses are part of the digest, not implementation details, and both are the reason lanes h0, h1, h6, h7 stay scalar in the vectorized shape. (The design notes have the full account, including the first-difference induction argument.)
3. One premixed value builds every lane. All lane states derive from s and K by rotations and shifts; there are no per-lane literal tables. Separately, the compiled artifacts are small: the five-hash playground module is about 14 KB of wasm, and the npm package’s single-hash module is about 1.4 KB.
4. Tails read whole words that overlap. For the final bytes, instead of a byte-at-a-time loop, hayahash64 reads full words aligned to the end of the buffer, overlapping bytes it already consumed (a trick popularized by wyhash). The premixed length disambiguates the overlap. Keys of 16 bytes or less take a dedicated path: two short-path words derived from overlapping head/tail reads, two independent multiplies, one moremur finalizer. Keys of 1–3 bytes are assembled from the head, middle, and tail byte, a neat trick borrowed from ChibiHash.
One more thing worth knowing: the header contains compiler- and CPU-specific spellings of the same dataflow (a compiler barrier for clang’s scheduler, a different dispatch layout for GCC, the vectorizable bulk shape for GCC on Zen 4/5). These change only the speed. Across all supported and tested builds the hash values are identical: the project verifies nine SMHasher3 builds across four hosts and five compilers, and CI additionally checks big-endian s390x, 32-bit wasm, MSVC x64, and MIPS64 under qemu against shared vectors.
So how fast is it?#
Three arenas, three different answers, and two distinct methodologies:
- Native results come from the project’s pinned SMHasher3 shootout: bulk is 256 KiB inputs reported in bytes/cycle, five replicates per cell, raw runs archived in paper/results. The 61.3 GB/s figure quoted earlier is from the benchmarks page’s separate custom 1 MiB comparison, not from this table.
- Browser results further down are a separate playground benchmark on 1 MiB inputs, with a different method (calibrated ~50 ms batches, three rounds, best kept).
Native bulk, 256 KiB, bytes per cycle, higher is better:
| hash | Apple M1 | AMD Zen 5 | full suite | needs for peak speed |
|---|---|---|---|---|
| rapidhash v3 | 15.2 | 28.6 | pass | 64×64→128 multiply |
| XXH3-64 | 12.6 | 48.9 | fail | SIMD |
| hayahash64 | 9.8 | 31.2* | pass | 64×64→64 multiply |
| ChibiHash v2 | 6.1 | 15.7 | pass | 64×64→64 multiply |
* with the compiler-vectorized shape; 17.6 without AVX-512DQ. M1 is Apple clang -O3 -mcpu=native; Zen 5 is GCC 16 -march=native.
AMD Zen 5: Apple M1:
XXH3-64 * ████████▏ 48.9 rapidhash v3 ██▌ 15.2
hayahash64+DQ █████▏ 31.2 XXH3-64 * ██ 12.6
rapidhash v3 ████▊ 28.6 hayahash64 █▋ 9.8
hayahash64 ██▉ 17.6 ChibiHash v2 █ 6.1
ChibiHash v2 ██▌ 15.7
* fails the full suite
Native, small keys: rapidhash wins. On seed-chained 1–31 byte keys it needs ~21.5 cycles on the M1 to hayahash’s 33.5, and ~9.8 to hayahash’s 12.0 on the Zen 5. If your workload is a native hash table on x86-64 or ARM64, rapidhash v3 is the better default, and the hayahash front page literally tells you so. Among hashes that share hayahash’s portability rules and pass the full suite, ChibiHash v2 is the nearest neighbor, and hayahash64 is 1.6–2× ahead of it in bulk on both machines.
The browser is a different planet. The wasm module used here is a deliberately scalar build (no SIMD flags), and in that target the only 64-bit multiply is i64.mul, which returns the low 64 bits. When rapidhash asks for its 128-bit product, the compiler must synthesize it. You can watch it happen:
// what rapidhash needs: the full 128-bit product
uint64_t mum128(uint64_t a, uint64_t b) {
unsigned __int128 r = (unsigned __int128)a * b;
return (uint64_t)(r >> 64) ^ (uint64_t)r;
}
// what hayahash needs: just the low 64 bits
uint64_t mum64(uint64_t a, uint64_t b) {
return a * b;
}
Compile both for wasm32 (zig cc --target=wasm32-freestanding -O2 -S) and the asymmetry jumps out:
;; mum64 - one instruction does the job
local.get 1
local.get 0
i64.mul
end_function
;; mum128 - same product, no instruction for it
global.get __stack_pointer
i32.const 16
i32.sub ;; stack frame for the wide result
...
call __multi3 ;; software 128-bit multiply
i64.load 0 ;; low half
i64.load 8 ;; high half
i64.xor
end_function
__multi3 is compiler-rt’s software widening multiply: several narrow multiplies plus shifts and carry adds, per call, in the hottest loop of the hash. Native CPUs produce the same result in one or two instructions (mulx on x86-64, mul+umulh on ARM64), which is exactly why rapidhash is the right choice there and pays a real tax here.
Measured on one Apple MacBook Air (M1, macOS, 2026-08-02), 1 MiB inputs, in GB/s. The five wasm rows share one module built from unmodified upstream sources with one compiler and one set of flags; SHA-256 is the browser’s WebCrypto (native code, cryptographic, a different job, shown for scale) and pure-JS is the npm package’s BigInt fallback:
| hash | Safari 26.6 / JSC | Chromium 148 / V8 |
|---|---|---|
| hayahash64 | 20.7 | 6.4 |
| ChibiHash v2 | 17.3 | 6.2 |
| XXH3-64 | 16.8 | 4.5 |
| XXH64 | 13.7 | 5.8 |
| rapidhash v3 | 6.1 | 1.7 |
| SHA-256 (WebCrypto) | 1.9 | 1.1 |
| hayahash64, pure JS | 0.07 | 0.03 |
Safari / JavaScriptCore (one █ = 1 GB/s):
hayahash64 ████████████████████▋ 20.7
ChibiHash v2 █████████████████▎ 17.3
XXH3-64 ████████████████▊ 16.8
XXH64 █████████████▋ 13.7
rapidhash v3 ██████ 6.1
Chromium / V8, same laptop, same module:
hayahash64 ██████▍ 6.4
ChibiHash v2 ██████▏ 6.2
XXH64 █████▊ 5.8
XXH3-64 ████▌ 4.5
rapidhash v3 █▋ 1.7
(The Chromium 148 build here is an Electron-embedded frame, not stock Chrome; treat the absolute numbers accordingly and run the playground in your own browser.)
Two things worth staring at. First, rapidhash’s 3–4× penalty in both engines: the __multi3 tax made visible. Second, the same module differs by roughly 3× between the two browser runs on the same laptop. That shows engine code generation and tiering can dominate wasm performance; this benchmark does not isolate the exact cause, which is precisely why the playground exists instead of a static table.
The pure-JS row is why the npm package ships wasm at all: the 1.4 KB wasm module ran ~200–300× faster than the same algorithm in BigInt JavaScript.
There’s a wasm postscript. The wide-arithmetic proposal adds i64.mul_wide_u (and a signed sibling) to close exactly this gap, and as of August 2026 engines are implementing it. When it ships by default, rapidhash-class hashes get faster in browsers, and this comparison narrows.
Try it yourself: the playground#
Numbers from someone else’s laptop are the least convincing kind. So the site has a playground that reruns the shootout on yours:
What it does, so you can trust it:
- The page loads one ~14 KB wasm module holding all five hashes (hayahash64, ChibiHash v2, rapidhash v3, XXH3-64, XXH64), built at deploy time from unmodified upstream sources at pinned revisions, one compiler, one set of flags. No hash gets special treatment. The SHA-256 and pure-JS rows live outside that module, as noted above.
- Each measured batch makes one JS→wasm call while all repeated hash invocations run inside wasm, so boundary overhead is amortized rather than paid per hash.
- Before measuring anything, the page recomputes 70 known-answer values and compares them; the hayahash subset is pinned to a native build of the reference header at deploy time. If anything drifts, the page tells you instead of showing pretty numbers.
- Each cell auto-calibrates its iteration count to ~50 ms rounds (browser timers are deliberately coarse), runs three rounds, and keeps the best.
- There’s also a live hash calculator and a local file hasher. Files never leave your browser; drop a video on it and watch the GB/s.
The whole run takes about ten seconds. Your mileage may vary, especially on older-generation hardware.
And if you want the hash itself, every port is one command away:
cargo add hayahashnpm install hayahashpip install hayahashgo get github.com/thevilledev/hayahash/go
Or just copy the header. The ports page has all ten implementations listed.
Caveats, honestly#
hayahash is experimental. The algorithm and the hash values can change between versions, so pin your version and don’t persist hash values anywhere. It is not cryptographic, and nothing here should be read as a security claim. On native x86-64/ARM64, use rapidhash v3 unless the portability contract is the thing you need. Everything above is reproducible from the repo: the SMHasher3 harness, the wasm shootout, and the playground build are pinned and scripted, and the archived runs behind each table are in paper/results. The code is released under the Unlicense.
Haya is the stem of Japanese hayai (速い), “fast”. In the right arena, it lives up to the name.