-
Notifications
You must be signed in to change notification settings - Fork 0
The SIMD ladder: the cycle map, portable BLAKE3, and the u64 ARX rotate #268
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
11 commits
Select commit
Hold shift + click to select a range
e710766
docs: the SIMD ladder plan, and the cycle map that orders it
claude fb8aa81
blake3: in-tree portable core, measured; four review corrections
claude fec25aa
fix: restore the missing_docs attribute my module declaration stole
claude 1627b10
blake3: array_chunks fast path -- operator's lead, measured (-21% at …
claude 95b35e9
docs: separate the staging-copy term; one earlier guess was wrong
claude 712a6a6
oracle: measure the 512-byte node as a default lane -- viable, zero s…
claude 7ff83e6
oracle: confirm the u64 rotate refusal is the OPERATION, not the idiom
claude a83ab6b
simd: U64x8 ARX rotate — the ladder's one earned intrinsic override
claude 5f4a571
docs(ladder): rung 6 built — and which backend actually got the intri…
claude 6346c85
blake3: state the chunk-length invariant where the fast path relies o…
claude 09903f8
review: cover derive_key with the official vectors, fix a backwards t…
claude File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,33 @@ | ||
| //! A/B: ndarray's in-tree BLAKE3 vs the external `blake3` crate, on the input | ||
| //! sizes ndarray actually hashes. Throwaway; deleted after the measurement. | ||
| use std::time::Instant; | ||
|
|
||
| fn bench<F: FnMut() -> [u8; 32]>(name: &str, iters: u32, mut f: F) -> f64 { | ||
| let mut sink = 0u8; | ||
| for _ in 0..iters / 10 { sink ^= f()[0]; } // warm | ||
| let t = Instant::now(); | ||
| for _ in 0..iters { sink ^= f()[0]; } | ||
| let ns = t.elapsed().as_nanos() as f64 / iters as f64; | ||
| std::hint::black_box(sink); | ||
| println!(" {name:<12} {ns:>10.1} ns/op"); | ||
| ns | ||
| } | ||
|
|
||
| fn main() { | ||
| // Sizes chosen to match what this substrate ACTUALLY hashes: | ||
| // 16 B a word (crystal_encoder) | ||
| // 256 B short text | ||
| // 480 B the SoA node's value region (512 - key(16) - edges(16)) | ||
| // 512 B THE canonical SoA node -- 4096 bit, the default unit | ||
| // 1024 B one BLAKE3 chunk exactly (the hash_many threshold) | ||
| // 2 KB VSA_BYTES | ||
| // 64 KB bulk | ||
| for &n in &[16usize, 256, 480, 512, 1024, 2048, 65536] { | ||
| let data: Vec<u8> = (0..n).map(|i| (i % 251) as u8).collect(); | ||
| let iters = if n >= 65536 { 2_000 } else { 50_000 }; | ||
| println!(" input = {n} B"); | ||
| let ours = bench("in-tree", iters, || *ndarray::hpc::blake3::hash(&data).as_bytes()); | ||
| let theirs = bench("blake3 crate", iters, || *blake3::hash(&data).as_bytes()); | ||
| println!(" ratio {:>10.2}x (in-tree / crate)\n", ours / theirs); | ||
| } | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,32 @@ | ||
| #!/bin/sh | ||
| # A/B: ndarray's in-tree BLAKE3 vs the external `blake3` crate. | ||
| # | ||
| # On-demand instrument, same posture as ../simd-codegen-oracle: run it when the | ||
| # question is open, record the answer, stop. NOT a CI job. | ||
| # | ||
| # Requires the external `blake3` crate to still be a dependency -- once it is | ||
| # dropped, this bench can no longer build, which is by design: at that point | ||
| # there is nothing left to compare against. | ||
| set -eu | ||
| HERE="$(cd "$(dirname "$0")" && pwd)" | ||
| REPO="$(cd "$HERE/../../.." && pwd)" | ||
| # The bench needs its source under $REPO/examples/ for cargo to see it, and | ||
| # removes it afterwards. Both halves must refuse to touch anything they did not | ||
| # create: a fixed destination plus an unconditional `rm` in the EXIT trap would | ||
| # clobber a pre-existing examples/blake3_ab.rs and then delete it. | ||
| EXAMPLE_DIR="$REPO/examples" | ||
| EXAMPLE="$EXAMPLE_DIR/blake3_ab.rs" | ||
| created_example_dir=0 | ||
| if [ -e "$EXAMPLE" ] || [ -L "$EXAMPLE" ]; then | ||
| echo "refusing to overwrite $EXAMPLE" >&2 | ||
| exit 1 | ||
| fi | ||
| if [ ! -d "$EXAMPLE_DIR" ]; then | ||
| mkdir "$EXAMPLE_DIR" | ||
| created_example_dir=1 | ||
| fi | ||
| # Only remove the directory if this script created it. | ||
| trap 'rm -f "$EXAMPLE"; [ "$created_example_dir" -eq 0 ] || rmdir "$EXAMPLE_DIR" 2>/dev/null || true' EXIT | ||
| cp "$HERE/blake3_ab.rs" "$EXAMPLE" | ||
| cd "$REPO" | ||
| cargo run --release --quiet --example blake3_ab |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,175 @@ | ||
| # In-tree BLAKE3 — correct, and what it costs | ||
|
|
||
| > **Status: MEASURED, 2026-07-29.** Correctness against the official vectors; | ||
| > throughput against the external crate. Both numbers below are reproducible | ||
| > with the instruments in this directory. | ||
|
|
||
| ## READ BY: | ||
| - Anyone about to drop the external `blake3` dependency | ||
| - Anyone continuing rung 3 of `the-simd-ladder.md` | ||
|
|
||
| ## P0 TRIGGER | ||
| About to swap `blake3::` call sites onto `crate::hpc::blake3`? **The swap is | ||
| correct but costs 1.3× on typical inputs and ~5× at 64 KB. Read the table.** | ||
|
|
||
| --- | ||
|
|
||
| ## Why it exists | ||
|
|
||
| Root `ndarray` depends on `blake3`, so `blake3 → ndarray::simd` is a cargo | ||
| cycle — the only rung of the ladder that has one (`the-simd-ladder.md`). | ||
| Cutting it means ndarray owning BLAKE3 rather than consuming the crate. | ||
|
|
||
| Scoping finding that made this small: **ndarray's usage is entirely | ||
| single-input.** 14 call sites across 8 files use only `hash`, | ||
| `Hasher::{new, new_keyed, update, finalize, finalize_xof().fill()}`, | ||
| `Hash::as_bytes`, and `Hash` as a signature type. **No `hash_many`.** So the | ||
| serial core suffices, and it needs no SIMD at all. | ||
|
|
||
| ## Correctness — proven | ||
|
|
||
| `src/hpc/blake3.rs`, 771 lines, transcribed from upstream's own | ||
| `reference_impl/reference_impl.rs` (the spec-referenced serial | ||
| implementation, BLAKE3 spec §5.1). No `unsafe`, no `core::arch`, no new | ||
| dependencies. | ||
|
|
||
| Against the official `test_vectors.json`, vendored to | ||
| `src/hpc/blake3_test_vectors.json`: | ||
|
|
||
| - **35/35** cases, unkeyed `hash` **and** keyed `keyed_hash`, | ||
| - each checked at **both** 32-byte length and the full extended length via | ||
| `finalize_xof().fill()` — 140 assertions, | ||
| - input lengths 0 … 102 400. | ||
|
|
||
| Plus: streaming (one `update` vs many 37-byte `update`s over 102 400 bytes), | ||
| empty input, and incremental `fill` (7 bytes at a time vs one shot). | ||
|
|
||
| `derive_key` was included too — it is another `Hasher` invocation with | ||
| different flags, so it came free. | ||
|
|
||
| ## Throughput — the cost, measured | ||
|
|
||
| `sh .claude/knowledge/blake3-ab-bench/run.sh`, release build, two runs: | ||
|
|
||
| | input | in-tree | `blake3` crate | ratio | | ||
| |---|---|---|---| | ||
| | 16 B (a word) | 134 ns | 100 ns | **1.34–1.39×** | | ||
| | 256 B (text) | 437 ns | 350 ns | **1.25–1.29×** | | ||
| | 2 KB (`VSA_BYTES`) | 3.4 µs | 2.6 µs | **1.30×** | | ||
| | 64 KB (bulk) | 109 µs | 23 µs | **4.7–4.9×** | | ||
|
|
||
| ### The `array_chunks` fast path — operator's lead, measured | ||
|
|
||
| Hypothesis (operator, citing the blasgraph JIT-gap precedent): the gap might | ||
| be closed by proper use of the existing slice primitives rather than by new | ||
| SIMD. The staging path copies every byte **twice** — input → `self.block` → | ||
| `block_words` — and for a full block the first copy is pure overhead. | ||
|
|
||
| Implemented as a `crate::simd_ops::array_chunks::<u8, 64>` fast path in | ||
| `ChunkState::update`, guarded `input.len() > BLOCK_LEN` so a chunk's final | ||
| block is never compressed early (it carries `CHUNK_END`). **Measured, three | ||
| runs:** | ||
|
|
||
| | input | before | after | change | | ||
| |---|---|---|---| | ||
| | 16 B | 137 ns | 134 ns | — (never reaches the fast path) | | ||
| | 256 B | 445 ns | 437 ns | — | | ||
| | **2 KB** | **4322 ns** | **3421 ns** | **−21 %** | | ||
| | 64 KB | 114.8 µs | 109.0 µs | −5 % | | ||
|
|
||
| **Verdict: real, and bounded.** The double copy was costing ~21 % at the mid | ||
| sizes — not nothing, and free to remove. But it does **not** replace the two | ||
| structural gaps: inputs ≤ 1 block never reach the fast path at all, and at | ||
| 64 KB the copy is noise beside the absent `hash_many`. The ratio at 2 KB | ||
| moved 1.34–1.60× → a stable 1.30×; the small-input 1.3× and the bulk 4.8× | ||
| both stand. | ||
|
|
||
| So the answer to "does it just need proper `array_chunks` use?" is **partly, | ||
| and the part it fixes is now fixed.** Rungs 3b (`hash_many`) and 3c (SIMD | ||
| single-compress) remain the load-bearing ones. | ||
|
|
||
| Correctness is gated, not assumed: the official vectors cover every boundary | ||
| the fast path turns on — 63/64/65 (the `> BLOCK_LEN` guard itself), | ||
| 127/128/129, and 1023/1024/1025 (the chunk boundary). | ||
|
|
||
| **The two gaps have different causes, and only one is about `hash_many`.** | ||
|
|
||
| - **The 64 KB gap is `hash_many`.** Above one chunk (1024 B) the crate | ||
| switches to its degree-8/16 parallel path with the transpose. We have none. | ||
| This is exactly rung 3b, and the `U32x16` shuffle surface merged in #267 is | ||
| what it would be built on. | ||
| - **The 1.3× small-input gap is NOT.** At 16 B there is a single compression | ||
| and no parallelism to be had — the crate is still faster because it | ||
| SIMD-accelerates *the single compress itself* (its sse41 backend). Closing | ||
| that needs a `U32x4`-shaped compress, which is a rung the ladder plan does | ||
| not currently have. Call it 3c. | ||
|
|
||
| So the honest shape is: | ||
|
|
||
| ```text | ||
| 3a in-tree core, correct DONE, costs 1.3x typical / 5x bulk | ||
| 3b hash_many on U32x16 closes the bulk gap | ||
| 3c SIMD single-compress (U32x4) closes the small-input gap | ||
| ``` | ||
|
|
||
| ## What this means for the swap | ||
|
|
||
| **The cycle-cut is available now and is correct.** It removes a cargo cycle | ||
| and 2,910 lines of second-surface `core::arch`. | ||
|
|
||
| It does **not** remove a C build — `Cargo.toml:213` already sets | ||
| `default-features = false, features = ["pure"]`, which removed all C/ASM | ||
| compilation back in #264. An earlier revision of this line credited the swap | ||
| with that too; overstating the benefit matters here specifically, because | ||
| what it is being weighed against is transcribing a cryptographic | ||
| implementation. | ||
|
|
||
| **It is not free**, and the previous framing ("removes things, needs no | ||
| benchmark to justify") was true about what it *removes* and silent about what | ||
| it *costs*. With the numbers in hand that framing is incomplete: this is a | ||
| trade, and which side wins depends on how hot ndarray's hashing actually is. | ||
|
|
||
| Where the call sites sit on the curve: `crystal_encoder` hashes a word | ||
| (16 B band), `vsa` XOF-expands to 2 KB, `merkle_tree`/`seal`/`spo_bundle` | ||
| hash small nodes, `deepnsm`/`compression_curves` small. So ndarray's real | ||
| exposure is the **1.3–1.6× band**, not the 5× one — but 1.3× on a hot encoder | ||
| path is a real cost, not a rounding error. | ||
|
|
||
| **Not swapped here.** The module lands and is tested; the call sites still | ||
| use the external crate. Flipping them is a decision with a measured price | ||
| tag, and it is the operator's. | ||
|
|
||
| ## One deliberate deviation from upstream, and one restored | ||
|
|
||
| - **Deviated:** transcribed from `reference_impl.rs` rather than | ||
| `portable.rs` + `lib.rs`. Upstream ships the reference implementation as | ||
| the readable, algorithmically-identical serial version, which is what | ||
| "take the serial branch everywhere" reduces to. Correctness is proven by | ||
| the vectors. | ||
|
|
||
| An earlier revision of this document guessed that "part of the 1.3× is | ||
| likely this choice rather than the SIMD gap — `portable.rs` avoids a | ||
| per-block staging copy." **That guess is now separated out and was wrong | ||
| for the small-input case.** Removing the staging copy (the `array_chunks` | ||
| fast path above) bought 21 % at 2 KB and **nothing at ≤ 1 block**, because | ||
| inputs that small never reach the fast path. So the small-input 1.3× is not | ||
| the staging copy — it is the crate's SIMD single-compress, as originally | ||
| suspected. | ||
| - **Restored:** `Hash::eq` is **constant-time**. Upstream uses the | ||
| `constant_time_eq` crate; the transcription initially used a plain `==`, | ||
| which leaks match-prefix length through timing when a BLAKE3 output is used | ||
| as a MAC. Rewritten as an XOR-fold with a `black_box` on the accumulator, | ||
| no dependency added. No call site in this crate compares two `Hash` values | ||
| today — `seal.rs` compares the truncated `MerkleRoot` — so this is a guard | ||
| for future consumers, not a live-leak fix. | ||
|
|
||
| ## Not claimed | ||
|
|
||
| - Not that the in-tree version should replace the crate. That is the open | ||
| decision this document exists to inform. | ||
| - Not that the remaining 1.3× has been fully attributed. The staging-copy | ||
| term IS now separated (measured at 21 % of the 2 KB cost, 0 % at ≤ 1 | ||
| block); what is left at small inputs is *presumed* to be the crate's SIMD | ||
| single-compress, and that has not been isolated by building one. | ||
| - Not that the bench is rigorous. It is a warm-loop wall-clock A/B, adequate | ||
| for a 1.3× vs 5× distinction and not for anything finer. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.