Skip to content

Commit 3e72c5d

Browse files
committed
oracle: measure the BLAKE3 shuffle gap; add Group D
Answers "what is missing to express BLAKE3 on ndarray::simd instead of raw intrinsics" with four probes rather than with intuition. Census first: BLAKE3's pure-Rust AVX2 backend uses 15 distinct intrinsics. Twelve are already expressible on U32x16 -- add, xor, or, the shift pair inside rotate_left, splat, set, load, store. The other three families (unpacklo/hi_epi32, unpacklo/hi_epi64, permute2x128 -- 18 call sites) exist solely to build hash_many's transpose, and U32x16 has no shuffle surface at all. Measured, at x86-64-v3: blake3_g_u32x16 72 packed / 0 scalar rotr16,rotr8 -> vpshufb; rotr12,rotr7 -> shift-or interleave_lo_u32x16 11 packed / 0 scalar vpermd + vpblendd transpose_16x16_u32 0 packed / 47 memory 1088B stack, 256-iter copy transpose_stage_u32x16 27 packed / 0 scalar vunpcklps / vunpckhps The compression core is free today. The interleave primitive is also free -- a plain scalar index loop compiles to a genuine two-source cross-lane permute, extending cross_lane_reverse_u8x64's single-source result. What fails is only the monolithic nested-index-loop spelling of the transpose, where the stride-64 scatter defeats the vectorizer; the same transpose composed from interleave calls emits literally the unpack family the intrinsic backend hand-writes. So no intrinsic override is earned under simd-one-spec-design.md's entry criterion. The gap is a method surface on U32x16 -- five interleave/permute methods, each a scalar index loop, no unsafe and no core::arch -- with the transpose written as a composition of them rather than as an index loop. Findings doc records the census, the four measurements, the scope limits (degree 16 not 8; codegen shape not transpose correctness; x86_64 only; no throughput benchmark) and the three questions still open.
1 parent de4e4d1 commit 3e72c5d

3 files changed

Lines changed: 400 additions & 0 deletions

File tree

Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
# BLAKE3 without C and without intrinsics — what is actually missing
2+
3+
> **Status: MEASURED, 2026-07-29.** Every number below comes from
4+
> `.claude/knowledge/simd-codegen-oracle/` on x86_64 @ `x86-64-v3`,
5+
> rustc 1.95.0. The intrinsic census comes from `AdaWorldAPI/BLAKE3`
6+
> at `8aa5145`.
7+
8+
## READ BY:
9+
- Anyone porting BLAKE3 onto `ndarray::simd`
10+
- Anyone about to hand-write shuffle intrinsics for a lane type
11+
- `simd-savant`, `truth-architect`
12+
13+
## P0 TRIGGER
14+
About to write `_mm256_unpacklo_epi32` wrappers because `U32x16` has no
15+
shuffle surface? **Don't. Measured below: the interleave primitive compiles
16+
to a real packed permute from a plain scalar index loop. What is missing is
17+
a method, not an intrinsic.**
18+
19+
---
20+
21+
## The starting position
22+
23+
Two constraints, both operator-set:
24+
25+
1. **No FFI with C.** `blake3` is now pinned to `default-features = false,
26+
features = ["pure"]`, which removed 33 `.o` objects and
27+
`libblake3_avx512_assembly.a` from the build. Measured, and already
28+
shipped.
29+
2. **No second SIMD surface.** `pure` gets rid of the C, but its Rust
30+
backends (`rust_avx2.rs`, `rust_sse41.rs`, `rust_sse2.rs`,
31+
`wasm32_simd.rs` — 2,910 lines) are raw `core::arch` intrinsics. That is
32+
exactly what the matryoshka pattern exists to prevent: all SIMD lives
33+
once, audited, inside `ndarray::simd`.
34+
35+
So the question is what a `ndarray::simd`-native BLAKE3 backend would need.
36+
37+
## The census: 15 intrinsics, and 12 already exist
38+
39+
`src/rust_avx2.rs` (496 lines) uses fifteen distinct intrinsics. Grouped by
40+
whether `crate::simd::U32x16` can already express them:
41+
42+
| intrinsic | uses | `U32x16` today |
43+
|---|---|---|
44+
| `_mm256_add_epi32` | 1 | `Add` |
45+
| `_mm256_xor_si256` | 1 | `BitXor` |
46+
| `_mm256_or_si256` | 4 | `BitOr` |
47+
| `_mm256_slli_epi32` / `_mm256_srli_epi32` | 4 / 4 | folded into `rotate_left` |
48+
| `_mm256_set1_epi32` | 1 | `splat` |
49+
| `_mm256_setr_epi32` / `_mm256_set_epi32` | 1 / 1 | `from_array` |
50+
| `_mm256_loadu_si256` / `_mm256_storeu_si256` | 1 / 1 | `from_slice` / `copy_to_slice` |
51+
| **`_mm256_unpacklo_epi32` / `_mm256_unpackhi_epi32`** | **4 / 4** | **absent** |
52+
| **`_mm256_unpacklo_epi64` / `_mm256_unpackhi_epi64`** | **4 / 4** | **absent** |
53+
| **`_mm256_permute2x128_si256`** | **2** | **absent** |
54+
55+
The twelve available ones cover the whole compression core. The three
56+
missing families — 18 call sites — exist for one thing: the transpose in
57+
`hash_many`, which turns N chunk states into word-major vectors so the
58+
rounds run N ways in parallel.
59+
60+
`U32x16` is generated by `avx2_int_type!` and exposes `splat`, `from_slice`,
61+
`from_array`, `to_array`, `copy_to_slice`, `reduce_sum`, the operators, and
62+
`rotate_left`. There is no shuffle surface at all.
63+
64+
## Four measurements
65+
66+
The tempting move is to hand-write the missing shuffles as intrinsics. TD-T22
67+
is the standing reason not to assume that: a 700-line intrinsic PR was closed
68+
after measurement showed LLVM was already at the instruction floor. So the
69+
shuffles got measured first.
70+
71+
| probe | packed | scalar (lane data) | what LLVM emitted |
72+
|---|---|---|---|
73+
| `blake3_g_u32x16` | **72** | 0 | `vpshufb` ×2 (rotr 16, rotr 8), `vpsrld`+`vpslld`+`vpor` ×2 (rotr 12, rotr 7), `vpaddd`/`vpxor` throughout |
74+
| `interleave_lo_u32x16` | **11** | 0 | `vbroadcasti128` + `vpmovzxdq` + `vpermd` (constant mask) + `vpblendd $170` |
75+
| `transpose_16x16_u32` | **0** | 1 | 1088 B of stack, a 256-iteration scalar element copy, stride-64 scatter |
76+
| `transpose_stage_u32x16` | **27** | 1 | **`vunpcklps` / `vunpckhps`** — the unpack family the intrinsic backend hand-writes |
77+
78+
The last two are the same transpose. The difference is only how it is
79+
written.
80+
81+
### The G function is free
82+
83+
72 packed instructions, zero scalar on lane data. BLAKE3 specifies *right*
84+
rotations by 16/12/8/7 and this crate has no `rotate_right` at any width, so
85+
each is written `rotate_left(32 - n)` — exact, since rotation is modular. The
86+
resulting amounts split as the u32 lane always does: byte-granular 16 and 24
87+
fold to `vpshufb`, non-byte-granular 20 and 25 take the shift-or triple.
88+
89+
**`compress_in_place` and `compress_xof` need nothing new.** That half of the
90+
port is a transcription.
91+
92+
### The interleave primitive is free
93+
94+
11 packed, zero scalar, from a plain `for i in 0..8 { out[2i]=a[i];
95+
out[2i+1]=b[i] }`. LLVM synthesized a two-source cross-lane permutation —
96+
`vpermd` against a constant mask, then `vpblendd`.
97+
98+
This extends `cross_lane_reverse_u8x64`'s earlier result (a *single*-source
99+
permute synthesized from a scalar reverse loop) to the two-source case, which
100+
was the open half. Both were written as index loops; both came out as real
101+
shuffles.
102+
103+
### The monolithic transpose is not free
104+
105+
Zero packed. LLVM allocated 1088 bytes of stack (`subq $1088,%rsp`, 64-byte
106+
aligned) and emitted a genuine 256-iteration element-at-a-time copy —
107+
`cmpq $256,%rax` / `jne` — striding the output by 64 bytes each step.
108+
109+
The stride-64 scatter is what defeats it: each output vector gathers one
110+
element from each of sixteen inputs. Nothing in the loop looks like a
111+
shuffle to the vectorizer.
112+
113+
### …but the composed transpose is
114+
115+
Same transpose, expressed as eight pairwise interleave calls per butterfly
116+
stage: **27 packed, and the instructions are `vunpcklps` / `vunpckhps`**
117+
literally the unpack family that `rust_avx2.rs` writes by hand as
118+
`_mm256_unpacklo_epi32` / `_mm256_unpackhi_epi32`.
119+
120+
## What this means
121+
122+
**No hand-written intrinsic is earned.** Under the entry criterion in
123+
`simd-one-spec-design.md`, an override needs a probe showing the generic form
124+
fails. The generic form does not fail here — the primitive vectorizes, and so
125+
does a composition of primitives. Only the monolithic index-loop spelling
126+
fails, and that is a spelling, not a capability.
127+
128+
So what is missing is a **method surface on `U32x16`**, each member
129+
implemented as the scalar index loop LLVM already handles:
130+
131+
| method | intrinsic role | replaces |
132+
|---|---|---|
133+
| `interleave_lo` / `interleave_hi` | 32-bit unpack | `_mm256_unpack{lo,hi}_epi32` (8 sites) |
134+
| `interleave_lo_u64` / `interleave_hi_u64` | 64-bit unpack | `_mm256_unpack{lo,hi}_epi64` (8 sites) |
135+
| `permute_halves` | 128-bit lane swap | `_mm256_permute2x128_si256` (2 sites) |
136+
137+
Roughly fifteen lines each. **Zero `unsafe`, zero `core::arch`, zero C.** The
138+
transpose is then written as a composition of them — which is what the
139+
intrinsic backends already are, just spelled in the lane vocabulary instead
140+
of in x86.
141+
142+
That is the whole gap. It is a method surface, not a porting effort.
143+
144+
## Deliberate scope limits
145+
146+
- **Degree 16, not 8.** `U32x16` is the lane the substrate uses, so the
147+
natural backend matches BLAKE3's AVX-512 degree rather than AVX2's degree 8.
148+
Degree 8 would want a `U32x8`, ruled out as a building block (operator
149+
ruling, 2026-07-28).
150+
- **`transpose_stage_u32x16` measures codegen shape, not correctness.** Its
151+
helpers use whole-vector interleave semantics rather than x86's
152+
per-128-bit-lane `unpack`, so four stages of *those* helpers do not compose
153+
a correct transpose. Getting the lane semantics right is a detail of the
154+
real implementation; the question the probe answers is whether composing
155+
interleave calls stays packed, and it does.
156+
- **Nothing here is measured on aarch64 or wasm32.** The oracle needs a
157+
baseline per target and only `baseline-x86_64-v3.toml` exists.
158+
- **No benchmark.** Every claim above is about *instruction class*, not
159+
throughput. "Emits packed shuffles" is not "is faster than the intrinsic
160+
backend" — that needs a bench against `rust_avx2.rs`, which has not been
161+
run.
162+
163+
## Still open
164+
165+
1. **Does the composed transpose beat `rust_avx2.rs`?** Unmeasured on both
166+
sides. The instruction-class result says a port is *possible* without
167+
intrinsics; it does not say it wins.
168+
2. **The FIPS gate.** If any deployment needs FIPS-adjacent claims, BLAKE3 is
169+
out and SHA-384 stays the KDF hash (`crypto-lane-status.md`). Settle
170+
before investing in the port.
171+
3. **`hash_many`'s degree contract.** BLAKE3's `Platform::simd_degree()`
172+
feeds chunk scheduling. A degree-16 backend on an AVX2 host is legal but
173+
changes the parallelism/transpose-cost balance — worth measuring, not
174+
assuming.

.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,3 +120,35 @@ note = "Same pattern as rot_u64x8, 4 lanes instead of 8, same verdict. Observed
120120
[probe.blake2b_g_u64x8]
121121
expect = "unknown"
122122
note = "MEASURED MIXED, and the most interesting single result in this oracle. The G-function's straight-line body has NO loop at all (fully unrolled by construction -- 8 lanes, no trip count, confirmed by 0 jumps in the disassembly). The LEADING a=a+b step DOES vectorize (vpaddq over both 32-byte ymm halves -- 2 of the function's 22 packed instructions; the rest of the 22 are the final vmovaps/vmovups reassembly into the return-struct). The moment a rotate is needed, LLVM extracts every lane out to a GPR (vmovq/vpextrq, in the 'other'/memory buckets) and the REST of the function -- all four rotate stages (32/24/16/63) and essentially every subsequent add/xor -- stays scalar (rorxq/addq/xorq on GPRs) all the way through, only reassembling into ymm registers at the very end for the tuple-return store. Byte-granular rotate amounts (32/24/16 -- whole bytes, the exact shape that folds to vpshufb for u32's rotate(16)/(8)) did NOT fold to a shuffle here either -- all four rotate amounts, byte-granular or not, lowered to scalar rorxq identically. This directly answers the motivating question: no, u64 rotate does not get the u32 lane's free ride, not even for the byte-granular special case, not even with compile-time-constant rotate amounts (unlike rot_u64x8/x4's runtime-variable case, ruling out 'maybe it only needed a constant amount' as an explanation). Observed 22 packed (the leading vpaddq pair + the trailing reassembly moves) / 74 scalar-lane-arith (rorxq/addq/xorq on GPRs, automated count) / 14 loop-control (automated count) / 98 memory (lane extract/insert + loads/stores) / 2 other. KNOWN UNDER-COUNT: the 14 instructions the automated classifier placed in loop-control are, on manual inspection, ALSO genuine scalar lane arithmetic (xorq/addq on %r8/%rsi) -- they were misclassified because those two registers were used as memory-operand address bases earlier in the function (dereferencing the incoming d-pointer) before being overwritten and reused to carry rotated lane data, and the whole-block heuristic in scripts/codegen_oracle_analyze.py cannot distinguish a register's role before vs. after such a reuse (documented in that script's module docstring, 'KNOWN LIMITATION' paragraph). The true scalar-lane-arith count is therefore ~88, not 74; the qualitative verdict (packed only for the leading add and the trailing reassembly; scalar for the entire rotate-dependent chain in between) is unaffected either way and was cross-checked by hand against the raw --verbose disassembly."
123+
124+
# ============================================================================
125+
# Group D -- UNKNOWN. Report only, no pass/fail. Does a fixed lane permutation
126+
# written as a scalar index loop vectorize? This is the one thing standing
127+
# between `ndarray::simd::U32x16` and a BLAKE3 backend with no raw intrinsics
128+
# and no C: BLAKE3's pure-Rust AVX2 backend uses 15 distinct intrinsics, 12 of
129+
# which U32x16 already expresses, and the other 3 families (unpacklo/hi_epi32,
130+
# unpacklo/hi_epi64, permute2x128 -- 18 call sites) exist solely to build
131+
# hash_many's transpose.
132+
#
133+
# `blake3_g_u32x16` carries no suspense and is here as the anchor: the same
134+
# ARX shape Group A already measured at the AVX2 instruction floor. It is
135+
# marked "unknown" rather than "vectorized" only so the Group-D block reads as
136+
# one measurement rather than a pass mixed with two observations; if it ever
137+
# stops vectorizing, arx_rounds_u32x16 fails first and louder.
138+
# ============================================================================
139+
140+
[probe.blake3_g_u32x16]
141+
expect = "unknown"
142+
note = "MEASURED FULLY VECTORIZED, as predicted. Observed 72 packed / 0 scalar-lane-arith / 0 loop-control (straight-line, fully unrolled) / 1 memory. The rotate split came out exactly as the u32 lane's known behaviour implies: rotr16 -> vpshufb with mask .LCPI5_0, rotr8 -> vpshufb with mask .LCPI5_1, rotr12 -> vpsrld $12 + vpslld $20 + vpor, rotr7 -> the same triple. Everything else is vpaddd / vpxor over both ymm halves. BLAKE3's compression core therefore needs NOTHING new from this crate -- compress_in_place and compress_xof are expressible on U32x16 today."
143+
144+
[probe.interleave_lo_u32x16]
145+
expect = "unknown"
146+
note = "MEASURED FULLY VECTORIZED. Observed 11 packed / 0 scalar-lane-arith / 0 loop-control. LLVM emitted vbroadcasti128 + vpmovzxdq + vpermd (constant permute mask from .LCPI10_0) + vpblendd $170 -- a genuine two-source cross-lane permutation synthesized from index arithmetic, extending cross_lane_reverse_u8x64's single-source result to the two-source case. The interleave primitive is FREE from scalar source; it earns no intrinsic override."
147+
148+
[probe.transpose_16x16_u32]
149+
expect = "unknown"
150+
note = "MEASURED SCALAR -- and this is the finding. Observed 0 packed / 1 scalar-lane-arith / 8 loop-control / 47 memory. LLVM did not vectorize any part of it: it allocated 1088 bytes of stack (subq $1088,%rsp with a 64-byte alignment andq $-64,%rsp) and emitted a real 256-iteration element-at-a-time copy loop (cmpq $256,%rax / jne), striding output by 64 bytes per step. The stride-64 scatter is what defeats it -- each output vector gathers one element from each of sixteen inputs. Compare transpose_stage_u32x16 below, which is the SAME transpose expressed as a composition of interleaves and stays fully packed. The lesson is about how the transpose is WRITTEN, not about what the hardware or the lane type can do."
151+
152+
[probe.transpose_stage_u32x16]
153+
expect = "unknown"
154+
note = "MEASURED FULLY VECTORIZED, and it closes the Group-D question. Observed 27 packed / 1 scalar-lane-arith / 5 loop-control / 9 memory. LLVM emitted vunpcklps / vunpckhps -- literally the unpack instruction family BLAKE3's rust_avx2.rs hand-writes as _mm256_unpacklo_epi32 / _mm256_unpackhi_epi32 -- four pairs per loop iteration over a 1024-byte stride. The single scalar-lane-arith is an xorl %esi,%esi zero-idiom from the [U32x16::splat(0); 16] initializer, not lane work. Read against transpose_16x16_u32's 0 packed: the transpose must be COMPOSED from interleave primitives rather than written as a nested index loop. Since the primitive itself vectorizes from scalar source (interleave_lo_u32x16), the conclusion is that a BLAKE3 backend on ndarray::simd needs no hand-written intrinsic at all -- only that the shuffle methods EXIST on U32x16."

0 commit comments

Comments
 (0)