|
| 1 | +# In-tree BLAKE3 — correct, and what it costs |
| 2 | + |
| 3 | +> **Status: MEASURED, 2026-07-29.** Correctness against the official vectors; |
| 4 | +> throughput against the external crate. Both numbers below are reproducible |
| 5 | +> with the instruments in this directory. |
| 6 | +
|
| 7 | +## READ BY: |
| 8 | +- Anyone about to drop the external `blake3` dependency |
| 9 | +- Anyone continuing rung 3 of `the-simd-ladder.md` |
| 10 | + |
| 11 | +## P0 TRIGGER |
| 12 | +About to swap `blake3::` call sites onto `crate::hpc::blake3`? **The swap is |
| 13 | +correct but costs 1.3× on typical inputs and ~5× at 64 KB. Read the table.** |
| 14 | + |
| 15 | +--- |
| 16 | + |
| 17 | +## Why it exists |
| 18 | + |
| 19 | +Root `ndarray` depends on `blake3`, so `blake3 → ndarray::simd` is a cargo |
| 20 | +cycle — the only rung of the ladder that has one (`the-simd-ladder.md`). |
| 21 | +Cutting it means ndarray owning BLAKE3 rather than consuming the crate. |
| 22 | + |
| 23 | +Scoping finding that made this small: **ndarray's usage is entirely |
| 24 | +single-input.** 14 call sites across 8 files use only `hash`, |
| 25 | +`Hasher::{new, new_keyed, update, finalize, finalize_xof().fill()}`, |
| 26 | +`Hash::as_bytes`, and `Hash` as a signature type. **No `hash_many`.** So the |
| 27 | +serial core suffices, and it needs no SIMD at all. |
| 28 | + |
| 29 | +## Correctness — proven |
| 30 | + |
| 31 | +`src/hpc/blake3.rs`, 771 lines, transcribed from upstream's own |
| 32 | +`reference_impl/reference_impl.rs` (the spec-referenced serial |
| 33 | +implementation, BLAKE3 spec §5.1). No `unsafe`, no `core::arch`, no new |
| 34 | +dependencies. |
| 35 | + |
| 36 | +Against the official `test_vectors.json`, vendored to |
| 37 | +`src/hpc/blake3_test_vectors.json`: |
| 38 | + |
| 39 | +- **35/35** cases, unkeyed `hash` **and** keyed `keyed_hash`, |
| 40 | +- each checked at **both** 32-byte length and the full extended length via |
| 41 | + `finalize_xof().fill()` — 140 assertions, |
| 42 | +- input lengths 0 … 102 400. |
| 43 | + |
| 44 | +Plus: streaming (one `update` vs many 37-byte `update`s over 102 400 bytes), |
| 45 | +empty input, and incremental `fill` (7 bytes at a time vs one shot). |
| 46 | + |
| 47 | +`derive_key` was included too — it is another `Hasher` invocation with |
| 48 | +different flags, so it came free. |
| 49 | + |
| 50 | +## Throughput — the cost, measured |
| 51 | + |
| 52 | +`sh .claude/knowledge/blake3-ab-bench/run.sh`, release build, two runs: |
| 53 | + |
| 54 | +| input | in-tree | `blake3` crate | ratio | |
| 55 | +|---|---|---|---| |
| 56 | +| 16 B (a word) | 134 ns | 100 ns | **1.34–1.39×** | |
| 57 | +| 256 B (text) | 437 ns | 350 ns | **1.25–1.29×** | |
| 58 | +| 2 KB (`VSA_BYTES`) | 3.4 µs | 2.6 µs | **1.30×** | |
| 59 | +| 64 KB (bulk) | 109 µs | 23 µs | **4.7–4.9×** | |
| 60 | + |
| 61 | +### The `array_chunks` fast path — operator's lead, measured |
| 62 | + |
| 63 | +Hypothesis (operator, citing the blasgraph JIT-gap precedent): the gap might |
| 64 | +be closed by proper use of the existing slice primitives rather than by new |
| 65 | +SIMD. The staging path copies every byte **twice** — input → `self.block` → |
| 66 | +`block_words` — and for a full block the first copy is pure overhead. |
| 67 | + |
| 68 | +Implemented as a `crate::simd_ops::array_chunks::<u8, 64>` fast path in |
| 69 | +`ChunkState::update`, guarded `input.len() > BLOCK_LEN` so a chunk's final |
| 70 | +block is never compressed early (it carries `CHUNK_END`). **Measured, three |
| 71 | +runs:** |
| 72 | + |
| 73 | +| input | before | after | change | |
| 74 | +|---|---|---|---| |
| 75 | +| 16 B | 137 ns | 134 ns | — (never reaches the fast path) | |
| 76 | +| 256 B | 445 ns | 437 ns | — | |
| 77 | +| **2 KB** | **4322 ns** | **3421 ns** | **−21 %** | |
| 78 | +| 64 KB | 114.8 µs | 109.0 µs | −5 % | |
| 79 | + |
| 80 | +**Verdict: real, and bounded.** The double copy was costing ~21 % at the mid |
| 81 | +sizes — not nothing, and free to remove. But it does **not** replace the two |
| 82 | +structural gaps: inputs ≤ 1 block never reach the fast path at all, and at |
| 83 | +64 KB the copy is noise beside the absent `hash_many`. The ratio at 2 KB |
| 84 | +moved 1.34–1.60× → a stable 1.30×; the small-input 1.3× and the bulk 4.8× |
| 85 | +both stand. |
| 86 | + |
| 87 | +So the answer to "does it just need proper `array_chunks` use?" is **partly, |
| 88 | +and the part it fixes is now fixed.** Rungs 3b (`hash_many`) and 3c (SIMD |
| 89 | +single-compress) remain the load-bearing ones. |
| 90 | + |
| 91 | +Correctness is gated, not assumed: the official vectors cover every boundary |
| 92 | +the fast path turns on — 63/64/65 (the `> BLOCK_LEN` guard itself), |
| 93 | +127/128/129, and 1023/1024/1025 (the chunk boundary). |
| 94 | + |
| 95 | +**The two gaps have different causes, and only one is about `hash_many`.** |
| 96 | + |
| 97 | +- **The 64 KB gap is `hash_many`.** Above one chunk (1024 B) the crate |
| 98 | + switches to its degree-8/16 parallel path with the transpose. We have none. |
| 99 | + This is exactly rung 3b, and the `U32x16` shuffle surface merged in #267 is |
| 100 | + what it would be built on. |
| 101 | +- **The 1.3× small-input gap is NOT.** At 16 B there is a single compression |
| 102 | + and no parallelism to be had — the crate is still faster because it |
| 103 | + SIMD-accelerates *the single compress itself* (its sse41 backend). Closing |
| 104 | + that needs a `U32x4`-shaped compress, which is a rung the ladder plan does |
| 105 | + not currently have. Call it 3c. |
| 106 | + |
| 107 | +So the honest shape is: |
| 108 | + |
| 109 | +```text |
| 110 | +3a in-tree core, correct DONE, costs 1.3x typical / 5x bulk |
| 111 | +3b hash_many on U32x16 closes the bulk gap |
| 112 | +3c SIMD single-compress (U32x4) closes the small-input gap |
| 113 | +``` |
| 114 | + |
| 115 | +## What this means for the swap |
| 116 | + |
| 117 | +**The cycle-cut is available now and is correct.** It removes a cargo cycle |
| 118 | +and 2,910 lines of second-surface `core::arch`. |
| 119 | + |
| 120 | +It does **not** remove a C build — `Cargo.toml:213` already sets |
| 121 | +`default-features = false, features = ["pure"]`, which removed all C/ASM |
| 122 | +compilation back in #264. An earlier revision of this line credited the swap |
| 123 | +with that too; overstating the benefit matters here specifically, because |
| 124 | +what it is being weighed against is transcribing a cryptographic |
| 125 | +implementation. |
| 126 | + |
| 127 | +**It is not free**, and the previous framing ("removes things, needs no |
| 128 | +benchmark to justify") was true about what it *removes* and silent about what |
| 129 | +it *costs*. With the numbers in hand that framing is incomplete: this is a |
| 130 | +trade, and which side wins depends on how hot ndarray's hashing actually is. |
| 131 | + |
| 132 | +Where the call sites sit on the curve: `crystal_encoder` hashes a word |
| 133 | +(16 B band), `vsa` XOF-expands to 2 KB, `merkle_tree`/`seal`/`spo_bundle` |
| 134 | +hash small nodes, `deepnsm`/`compression_curves` small. So ndarray's real |
| 135 | +exposure is the **1.3–1.6× band**, not the 5× one — but 1.3× on a hot encoder |
| 136 | +path is a real cost, not a rounding error. |
| 137 | + |
| 138 | +**Not swapped here.** The module lands and is tested; the call sites still |
| 139 | +use the external crate. Flipping them is a decision with a measured price |
| 140 | +tag, and it is the operator's. |
| 141 | + |
| 142 | +## One deliberate deviation from upstream, and one restored |
| 143 | + |
| 144 | +- **Deviated:** transcribed from `reference_impl.rs` rather than |
| 145 | + `portable.rs` + `lib.rs`. Upstream ships the reference implementation as |
| 146 | + the readable, algorithmically-identical serial version, which is what |
| 147 | + "take the serial branch everywhere" reduces to. Correctness is proven by |
| 148 | + the vectors. |
| 149 | + |
| 150 | + An earlier revision of this document guessed that "part of the 1.3× is |
| 151 | + likely this choice rather than the SIMD gap — `portable.rs` avoids a |
| 152 | + per-block staging copy." **That guess is now separated out and was wrong |
| 153 | + for the small-input case.** Removing the staging copy (the `array_chunks` |
| 154 | + fast path above) bought 21 % at 2 KB and **nothing at ≤ 1 block**, because |
| 155 | + inputs that small never reach the fast path. So the small-input 1.3× is not |
| 156 | + the staging copy — it is the crate's SIMD single-compress, as originally |
| 157 | + suspected. |
| 158 | +- **Restored:** `Hash::eq` is **constant-time**. Upstream uses the |
| 159 | + `constant_time_eq` crate; the transcription initially used a plain `==`, |
| 160 | + which leaks match-prefix length through timing when a BLAKE3 output is used |
| 161 | + as a MAC. Rewritten as an XOR-fold with a `black_box` on the accumulator, |
| 162 | + no dependency added. No call site in this crate compares two `Hash` values |
| 163 | + today — `seal.rs` compares the truncated `MerkleRoot` — so this is a guard |
| 164 | + for future consumers, not a live-leak fix. |
| 165 | + |
| 166 | +## Not claimed |
| 167 | + |
| 168 | +- Not that the in-tree version should replace the crate. That is the open |
| 169 | + decision this document exists to inform. |
| 170 | +- Not that the remaining 1.3× has been fully attributed. The staging-copy |
| 171 | + term IS now separated (measured at 21 % of the 2 KB cost, 0 % at ≤ 1 |
| 172 | + block); what is left at small inputs is *presumed* to be the crate's SIMD |
| 173 | + single-compress, and that has not been isolated by building one. |
| 174 | +- Not that the bench is rigorous. It is a warm-loop wall-clock A/B, adequate |
| 175 | + for a 1.3× vs 5× distinction and not for anything finer. |
0 commit comments