Skip to content

Commit b574ecc

Browse files
authored
Merge pull request #268 from AdaWorldAPI/claude/simd-ladder-plan
The SIMD ladder: the cycle map, portable BLAKE3, and the u64 ARX rotate
2 parents 21f9ae3 + 09903f8 commit b574ecc

17 files changed

Lines changed: 2179 additions & 6 deletions

.claude/board/EPIPHANIES.md

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,89 @@
11
# ndarray — Epiphanies (append-only)
22

3+
## 2026-07-29 — Which PACKAGE pulls a dep decides whether it can consume you
4+
**Status:** FINDING
5+
**Scope:** @simd-savant @truth-architect domain:build-graph
6+
**Cross-ref:** PR #267, `.claude/knowledge/the-simd-ladder.md`
7+
8+
The ladder's goal is that every dependency carrying its own SIMD instead
9+
consumes `ndarray::simd`. Whether a given dependency *can* is not a property
10+
of the dependency, and not of "the workspace" — it is decided by **which
11+
package in the workspace pulls it**:
12+
13+
| dependency | entry point | can consume `ndarray::simd`? |
14+
|---|---|---|
15+
| `chacha20` | `crates/encryption` → … → `chacha20` | **yes** |
16+
| `curve25519-dalek` | `crates/encryption``ed25519-dalek` → … | **yes** |
17+
| `blake3` | **root `ndarray`** (`std` feature) | **no — cycle** |
18+
19+
Measured — and measured the right way round. The evidence is the **positive**
20+
reverse tree (which terminates at `encryption`, with root `ndarray` absent),
21+
plus a control proving the method can produce a hit:
22+
23+
```console
24+
$ cargo tree -p encryption -i curve25519-dalek # positive: full path shown
25+
curve25519-dalek v4.1.3
26+
└── ed25519-dalek v2.2.0
27+
└── encryption v0.1.0 (crates/encryption)
28+
29+
$ cargo tree -p ndarray -i blake3 # control: a real edge DOES hit
30+
blake3 v1.8.4
31+
└── ndarray v0.17.2 (/workspace/ndarray)
32+
```
33+
34+
**Not** an error message. A first draft rested on
35+
`cargo tree -p ndarray -i curve25519-dalek` failing with `package ID
36+
specification ... did not match any packages`; a typo produces that message
37+
byte-for-byte, so it cannot distinguish "no such edge" from "no such
38+
package". Corrected by CodeRabbit on #268 — and it is the same defect this
39+
repo keeps hitting one level down: a check that cannot fail for the reason
40+
you think it is failing.
41+
42+
Only blake3 is pulled by the ROOT package, so only blake3 closes a loop when
43+
it depends back on ndarray. The other two ride a shape that already works in
44+
this repo today — `crates/encryption``chacha20``ndarray(root)` is
45+
exactly it.
46+
47+
**Why this is worth recording rather than re-derived.** "ndarray depends on
48+
X, so X can't depend on ndarray" is the intuitive rule and it is wrong at
49+
workspace granularity. A sub-crate is a different package; the cycle is
50+
per-package, not per-workspace. Reasoning at workspace granularity says all
51+
three are blocked, which would have parked two rungs that have no blocker at
52+
all.
53+
54+
**On the diagnostic — be precise, because two different things were
55+
conflated here.** Cargo *does* report the cycle when the patched package
56+
would be selected; `cargo update -p blake3` printed the chain
57+
(`blake3 ... satisfies dependency of ndarray ... satisfies path dependency
58+
ndarray of blake3`). What is NOT a cycle diagnostic is `[[patch.unused]]`.
59+
That only says the patch was not selected, and the usual causes are a version
60+
that does not satisfy the requirement or a stale lockfile.
61+
62+
An earlier version of this entry read the unused patch as the cycle's
63+
signature and called the failure "silent." Both halves were wrong, and codex
64+
caught it on #268. Treating `[[patch.unused]]` as a cycle report teaches the
65+
next reader to misdiagnose an ordinary stale patch — and undermines the very
66+
check this entry prescribes.
67+
68+
The check stands, but ONLY in its positive form — and the wording here was
69+
itself an instance of the bug it warns about, corrected on #268:
70+
71+
Consequence: **before planning any "make X consume our crate" work, run
72+
`cargo tree -p <our-root> -i <X>`.**
73+
74+
1. First confirm `<X>` is a real package in the selected graph (a typo, or a
75+
package absent from that graph, produces the byte-identical error).
76+
2. A tree that resolves and shows the root package = the edge must be cut
77+
first.
78+
3. A tree that resolves and does NOT show it = unblocked.
79+
4. **A package-ID error is inconclusive — never "no cycle".**
80+
81+
An earlier draft of this very entry said "an error there means no cycle and
82+
the work is unblocked", one paragraph after explaining that the error is
83+
ambiguous. Read literally it would mark blocked work as unblocked on the
84+
strength of a typo.
85+
86+
387
## 2026-07-29 — BLAKE3 needs a method surface, not intrinsics (measured)
488
**Status:** FINDING
589
**Scope:** @simd-savant domain:codec
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
//! A/B: ndarray's in-tree BLAKE3 vs the external `blake3` crate, on the input
2+
//! sizes ndarray actually hashes. Throwaway; deleted after the measurement.
3+
use std::time::Instant;
4+
5+
fn bench<F: FnMut() -> [u8; 32]>(name: &str, iters: u32, mut f: F) -> f64 {
6+
let mut sink = 0u8;
7+
for _ in 0..iters / 10 { sink ^= f()[0]; } // warm
8+
let t = Instant::now();
9+
for _ in 0..iters { sink ^= f()[0]; }
10+
let ns = t.elapsed().as_nanos() as f64 / iters as f64;
11+
std::hint::black_box(sink);
12+
println!(" {name:<12} {ns:>10.1} ns/op");
13+
ns
14+
}
15+
16+
fn main() {
17+
// Sizes chosen to match what this substrate ACTUALLY hashes:
18+
// 16 B a word (crystal_encoder)
19+
// 256 B short text
20+
// 480 B the SoA node's value region (512 - key(16) - edges(16))
21+
// 512 B THE canonical SoA node -- 4096 bit, the default unit
22+
// 1024 B one BLAKE3 chunk exactly (the hash_many threshold)
23+
// 2 KB VSA_BYTES
24+
// 64 KB bulk
25+
for &n in &[16usize, 256, 480, 512, 1024, 2048, 65536] {
26+
let data: Vec<u8> = (0..n).map(|i| (i % 251) as u8).collect();
27+
let iters = if n >= 65536 { 2_000 } else { 50_000 };
28+
println!(" input = {n} B");
29+
let ours = bench("in-tree", iters, || *ndarray::hpc::blake3::hash(&data).as_bytes());
30+
let theirs = bench("blake3 crate", iters, || *blake3::hash(&data).as_bytes());
31+
println!(" ratio {:>10.2}x (in-tree / crate)\n", ours / theirs);
32+
}
33+
}
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
#!/bin/sh
2+
# A/B: ndarray's in-tree BLAKE3 vs the external `blake3` crate.
3+
#
4+
# On-demand instrument, same posture as ../simd-codegen-oracle: run it when the
5+
# question is open, record the answer, stop. NOT a CI job.
6+
#
7+
# Requires the external `blake3` crate to still be a dependency -- once it is
8+
# dropped, this bench can no longer build, which is by design: at that point
9+
# there is nothing left to compare against.
10+
set -eu
11+
HERE="$(cd "$(dirname "$0")" && pwd)"
12+
REPO="$(cd "$HERE/../../.." && pwd)"
13+
# The bench needs its source under $REPO/examples/ for cargo to see it, and
14+
# removes it afterwards. Both halves must refuse to touch anything they did not
15+
# create: a fixed destination plus an unconditional `rm` in the EXIT trap would
16+
# clobber a pre-existing examples/blake3_ab.rs and then delete it.
17+
EXAMPLE_DIR="$REPO/examples"
18+
EXAMPLE="$EXAMPLE_DIR/blake3_ab.rs"
19+
created_example_dir=0
20+
if [ -e "$EXAMPLE" ] || [ -L "$EXAMPLE" ]; then
21+
echo "refusing to overwrite $EXAMPLE" >&2
22+
exit 1
23+
fi
24+
if [ ! -d "$EXAMPLE_DIR" ]; then
25+
mkdir "$EXAMPLE_DIR"
26+
created_example_dir=1
27+
fi
28+
# Only remove the directory if this script created it.
29+
trap 'rm -f "$EXAMPLE"; [ "$created_example_dir" -eq 0 ] || rmdir "$EXAMPLE_DIR" 2>/dev/null || true' EXIT
30+
cp "$HERE/blake3_ab.rs" "$EXAMPLE"
31+
cd "$REPO"
32+
cargo run --release --quiet --example blake3_ab
Lines changed: 175 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,175 @@
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.

.claude/knowledge/chacha20-vendoring-blast-radius.md

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -157,9 +157,18 @@ SIGILL on a runner without AVX-512 silicon.
157157
`cargo check --target=x86_64-unknown-linux-gnu -p ndarray --features
158158
approx,serde,rayon` (the second adding `hpc-extras`) — package-scoped to
159159
`ndarray`, so `crates/encryption` and therefore `vendor/chacha20` are outside
160-
it. **No CI job compiles the chacha20 AVX-512 backend**, and none of the
161-
coverage claimed here extends to it. Closing that gap would take an explicit
162-
step such as:
160+
it. **No CI job compiles the chacha20 AVX-512 backend.**
161+
162+
**Correction (codex, #268): the wasm arm IS covered, and an earlier version of
163+
this document said otherwise.** `ci.yaml:141-142`, inside the `wasm_simd` job,
164+
runs `RUSTFLAGS="-C target-feature=+simd128" cargo build --manifest-path
165+
vendor/chacha20/Cargo.toml --target wasm32-unknown-unknown --lib` — which
166+
selects `backends::ndarray_simd` through the cfg's wasm arm, and whose own
167+
comment calls it "the wasm matryoshka" guard. So the accurate statement is
168+
**arm-specific, not backend-wide**: wasm32+simd128 is compiled and guarded in
169+
CI; the x86_64 avx512f arm is compiled by nothing.
170+
171+
Closing the AVX-512 gap would take an explicit step such as:
163172

164173
```console
165174
$ CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-Ctarget-cpu=x86-64-v4" \

0 commit comments

Comments
 (0)