|
| 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. |
0 commit comments