From e710766e866020d4219dfe0abaa8cd7107b082d4 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:24:29 +0000 Subject: [PATCH 01/11] docs: the SIMD ladder plan, and the cycle map that orders it The goal is that every dependency carrying its own SIMD instead consumes ndarray::simd -- all SIMD living once, audited, in one place. This records the rungs, what is proven at each, and the order. The load-bearing finding is the cycle map, and it is measured rather than inferred: whether a dependency CAN consume ndarray::simd is decided by which PACKAGE in the workspace pulls it, not by the workspace as a whole. chacha20 crates/encryption -> ... -> chacha20 no cycle curve25519-dalek crates/encryption -> ed25519-dalek -> ... no cycle blake3 ROOT ndarray (std feature, 11 hpc files) CYCLE $ cargo tree -p ndarray -i curve25519-dalek error: package ID specification `curve25519-dalek` did not match any packages Only blake3 is pulled by the root package, so only blake3 closes a loop. The intuitive rule -- "ndarray depends on X, so X cannot depend on ndarray" -- is wrong at workspace granularity and would have parked two rungs that have no blocker at all. Second scoping finding: ndarray's own blake3 usage is entirely single-input (hash / Hasher::{new,new_keyed,update,finalize,finalize_xof}). No hash_many. So cutting the cycle needs only the PORTABLE core -- scalar [u32;16] compress plus the chunk/tree/XOF state machine -- which uses no SIMD at all. That splits the rung into 3a (cut the cycle, no benchmark needed to justify it) and 3b (hash_many throughput on the merged shuffle surface, which is where a benchmark matters and which is not on the critical path). Rungs 1 and 2 are merged and measured: the U32x16 ARX triple at the AVX2 instruction floor, and the shuffle surface parity-checked against the real intrinsics on all six backends. Rungs 4-6 (chacha20, dalek, the u64 ARX lane for BLAKE2b/argon2) are independent of each other and of 3a. Stated plainly in the doc: nothing here is claimed to be faster. Every rung is measured at instruction class, never at time, and no benchmark of any rung against its upstream equivalent has been run. Rung 5 is also explicitly not a defect -- `serial` is a correct, deliberate choice and the dalek vector backend serves no operation this crate performs. EPIPHANIES entry records the per-package cycle rule and that its failure mode is SILENT: cargo does not error, it declines the patch and falls back to the registry crate with [[patch.unused]], so a port done anyway compiles clean and does nothing -- the same shape as the chacha20 patch, now two independent instances in one dependency graph. --- .claude/board/EPIPHANIES.md | 46 ++++++++ .claude/knowledge/the-simd-ladder.md | 169 +++++++++++++++++++++++++++ 2 files changed, 215 insertions(+) create mode 100644 .claude/knowledge/the-simd-ladder.md diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 9d29665e..a3097b34 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -1,5 +1,51 @@ # ndarray — Epiphanies (append-only) +## 2026-07-29 — Which PACKAGE pulls a dep decides whether it can consume you +**Status:** FINDING +**Scope:** @simd-savant @truth-architect domain:build-graph +**Cross-ref:** PR #267, `.claude/knowledge/the-simd-ladder.md` + +The ladder's goal is that every dependency carrying its own SIMD instead +consumes `ndarray::simd`. Whether a given dependency *can* is not a property +of the dependency, and not of "the workspace" — it is decided by **which +package in the workspace pulls it**: + +| dependency | entry point | can consume `ndarray::simd`? | +|---|---|---| +| `chacha20` | `crates/encryption` → … → `chacha20` | **yes** | +| `curve25519-dalek` | `crates/encryption` → `ed25519-dalek` → … | **yes** | +| `blake3` | **root `ndarray`** (`std` feature) | **no — cycle** | + +Measured, not inferred: + +```console +$ cargo tree -p ndarray -i curve25519-dalek +error: package ID specification `curve25519-dalek` did not match any packages +``` + +Only blake3 is pulled by the ROOT package, so only blake3 closes a loop when +it depends back on ndarray. The other two ride a shape that already works in +this repo today — `crates/encryption` → `chacha20` → `ndarray(root)` is +exactly it. + +**Why this is worth recording rather than re-derived.** "ndarray depends on +X, so X can't depend on ndarray" is the intuitive rule and it is wrong at +workspace granularity. A sub-crate is a different package; the cycle is +per-package, not per-workspace. Reasoning at workspace granularity says all +three are blocked, which would have parked two rungs that have no blocker at +all. + +**And the failure is silent.** Cargo does not error on the blake3 case — it +names the chain, declines to apply the patch, and falls back to the registry +crate with `[[patch.unused]]`. A port done anyway compiles clean and has zero +effect. That is the same silent-no-op class as the chacha20 patch, which is +now two independent instances of the same shape in one dependency graph. + +Consequence: **before planning any "make X consume our crate" work, run +`cargo tree -p -i `.** An error there means no cycle and the +work is unblocked; a hit means the edge must be cut first. + + ## 2026-07-29 — BLAKE3 needs a method surface, not intrinsics (measured) **Status:** FINDING **Scope:** @simd-savant domain:codec diff --git a/.claude/knowledge/the-simd-ladder.md b/.claude/knowledge/the-simd-ladder.md new file mode 100644 index 00000000..6c40f8da --- /dev/null +++ b/.claude/knowledge/the-simd-ladder.md @@ -0,0 +1,169 @@ +# The ladder — one audited SIMD surface, nothing above it carrying its own + +> **Status: PLAN, with measured rungs marked.** Every "proven" row below cites +> the measurement. Every "assumed" row says so. + +## READ BY: +- Anyone continuing the BLAKE3 / chacha20 / dalek port work +- Anyone about to add a dependency that carries `core::arch` +- `simd-savant`, `truth-architect` + +## The invariant + +**All SIMD lives once, audited, inside `ndarray::simd`.** A dependency that +ships its own intrinsics is a second unaudited surface — which is what the +matryoshka pattern exists to prevent, and what `.cargo/config.toml` already +neutralizes by hand for one crate (`curve25519_dalek_backend = "serial"`). + +The ladder is the ordered work of making that true rather than aspirational. + +--- + +## The cycle map — the thing that orders everything else + +Whether a dependency can consume `ndarray::simd` is decided by **which +ndarray package pulls it**, and this was measured, not assumed: + +| dependency | entry point | `X → ndarray::simd` cycle? | +|---|---|---| +| `chacha20` | `crates/encryption` → `chacha20poly1305` → `chacha20` | **no** | +| `curve25519-dalek` | `crates/encryption` → `ed25519-dalek` → `curve25519-dalek` | **no** | +| `blake3` | **root `ndarray`** (`std` feature, 11 files in `src/hpc/`) | **YES** | + +```console +$ cargo tree -p encryption -i curve25519-dalek +curve25519-dalek v4.1.3 +└── ed25519-dalek v2.2.0 + └── encryption v0.1.0 (crates/encryption) + +$ cargo tree -p ndarray -i curve25519-dalek +error: package ID specification `curve25519-dalek` did not match any packages +``` + +**Only blake3 has a cycle**, because only blake3 is pulled by the *root* +package. Cargo names it explicitly and then silently falls back to the +registry crate with `[[patch.unused]]`, so a naive port compiles and has zero +effect — the same silent-no-op class as the chacha20 patch (see +`chacha20-vendoring-blast-radius.md`). + +The other two ride a pattern that **already works in this repo today**: +`crates/encryption` → `chacha20` → `ndarray(root)` is exactly that shape. + +--- + +## Rungs + +### 1. `U32x16` ARX — PROVEN, merged + +Add / BitXor / `rotate_left`. Measured at the AVX2 instruction floor: 8 +`vpaddd` for 64 u32 lanes, no scalar op touching lane data, +`rotate_left(16)` strength-reduced to `vpshufb` +(`td-t22-asm-investigation.md`). + +### 2. `U32x16` shuffle surface — PROVEN, merged (#267) + +`interleave_{lo,hi}_u{32,64}`, `concat_{lo,hi}_halves`. Semantics are x86's +per 256-bit half, parity-checked against the real intrinsics and +control-tested. Present on all six backends. + +**The insight that made it right:** two `__m256i` fit in one `U32x16`, and +every operation in BLAKE3's `hash_many` is either lane-wise or confined +*within* a 128-/256-bit lane, so two 8-lane groups never interact and the +algorithm runs at DEGREE 16 with no cross-talk. An earlier attempt built this +on `U32x8` and was wrong (operator-corrected; see EPIPHANIES). + +### 3. BLAKE3 — IN FLIGHT, and the only rung that must cut a cycle + +**Scoping finding:** ndarray's own blake3 usage is *entirely single-input* — +`hash`, `Hasher::{new, new_keyed, update, finalize, finalize_xof().fill()}`. +**No `hash_many`.** So cutting the cycle needs only the **portable** core +(scalar `[u32; 16]` compress + the chunk/tree/XOF state machine), which uses +no SIMD at all. + +That splits the rung: + +- **3a — cut the cycle.** Transcribe portable BLAKE3 into ndarray, drop the + external crate. Removes the cycle, the C question, and 2,910 lines of + second-surface `core::arch` in one move. Verified against the official + `test_vectors.json`. *No SIMD involved — correctness only.* +- **3b — throughput, optional.** Port `hash_many` onto the rung-2 shuffle + surface for multi-chunk inputs. **This is where a benchmark matters**, and + it is not on the critical path. + +### 4. chacha20 — backend exists, effectively unreachable + +`vendor/chacha20` already has an `ndarray_simd` backend. Measured: no image +and no CI job compiles it (`chacha20-vendoring-blast-radius.md`). And +upstream 0.10.1 now ships its own `avx512.rs` in the same niche, unmeasured +against ours. **Blocked on a benchmark, then on the fork-vs-vendored ruling.** + +### 5. curve25519-dalek — the largest untouched surface + +57 `_mm*` intrinsic calls under 52 `unsafe` occurrences across +`backend/vector/avx2/field.rs` and `packed_simd.rs`. Currently neutralized +rather than ported: `.cargo/config.toml` sets +`curve25519_dalek_backend = "serial"`, which cfg's the whole `vector` module +out at `backend/mod.rs:42`. + +That is the correct *reachability* answer and costs nothing today (X25519's +Montgomery ladder never touches the vector backend; the vector path serves +only Edwards multi-scalar work this crate doesn't do). Porting it onto +`ndarray::simd` is a real rung, has **no cycle**, and is gated on nothing +except appetite. + +### 6. u64 ARX — the one *earned* intrinsic override, unbuilt + +Measured: `rotate_left`/`rotate_right` do not exist on `U64x8`/`U64x4` on any +of the six backends, and a scalar u64 rotate loop does **not** vectorize — +0 packed, one scalar `rorq` per lane, even for byte-granular amounts, even +with compile-time-constant counts, on a target that has `vpsllq`/`vpsrlq` and +uses exactly that shift-or for u32 (`crypto-lane-status.md`). + +This is the only place in the crate where a hand-written intrinsic currently +meets `simd-one-spec-design.md`'s entry criterion. BLAKE2b needs it, and +argon2 needs BLAKE2b. Nothing is built on it. + +--- + +## Order + +3a is the only rung that unblocks anything else, and it needs no measurement +to justify — it removes a dependency edge, a C build, and a second SIMD +surface regardless of throughput. Everything after it is independent: + +``` +3a (cut the cycle) ──> 3b (hash_many throughput) [needs a bench] + ──> 4 (chacha20) [needs a bench + a ruling] + ──> 5 (dalek) [no blocker but appetite] + ──> 6 (u64 ARX / argon2) [no blocker but appetite] +``` + +## What is NOT claimed here + +- **Not that any of this is faster.** Every rung is measured at + *instruction class*, never at time. "Emits packed shuffles" is not "beats + the intrinsic backend," and no benchmark of any rung against its upstream + equivalent has been run. +- **Not that rung 5 is a defect today.** `serial` is a correct, deliberate, + documented choice, and the vector backend serves no operation this crate + performs. +- **Not that the ladder must be completed.** Rungs 4–6 are optional; only 3a + removes something (a cycle, a C build, an unaudited surface). + +## Open, needing a ruling rather than work + +Carried from `chacha20-vendoring-blast-radius.md` so they are visible in one +place: + +1. Fork vs vendored copy for `vendor/chacha20` — `AdaWorldAPI/stream-ciphers` + is a bare upstream mirror at 0.10.1, vendored is 0.9.1 + one backend, and + moving crosses a `cipher` major. +2. Should the Docker images build `encryption` at all? Neither does today, so + the AEAD path ships in no image. +3. Should CI cover the chacha20 v4 arm? The exact command is recorded; adding + the job is a product change. +4. `Cargo.toml:481`'s comment, whose main clause holds only for builds that + select `encryption`. +5. `blake3` and `curve25519-dalek` both resolve from crates.io while + AdaWorldAPI forks exist — two P0 violations. Rung 3a dissolves the blake3 + half by removing the dependency entirely. From fb8aa819ebf5df912c7d7d717aca87699788194f Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:33:04 +0000 Subject: [PATCH 02/11] blake3: in-tree portable core, measured; four review corrections Rung 3a of the ladder, landed as the module + the measurement, WITHOUT the call-site swap -- because the swap turns out to have a price tag. ## The module src/hpc/blake3.rs, 771 lines, transcribed from upstream's own reference_impl/reference_impl.rs (the spec-referenced serial implementation). No unsafe, no core::arch, no new dependencies. Scoped by the finding that ndarray's blake3 usage is entirely single-input -- 14 call sites across 8 files use only hash / Hasher::{new,new_keyed,update,finalize,finalize_xof} / Hash::as_bytes, no hash_many -- so the serial core suffices. Correctness proven against the official vectors, vendored alongside: 35/35 cases, unkeyed AND keyed, each at both 32-byte length and full extended length via finalize_xof().fill() -- 140 assertions, input lengths 0..102400. Plus streaming (one update vs many 37-byte updates over 102400 bytes), empty input, and incremental fill. Hash::eq is CONSTANT-TIME. The transcription arrived with a plain `==`, which leaks match-prefix length through timing when a BLAKE3 output is used as a MAC; upstream uses the constant_time_eq crate, and since this takes no new deps the XOR-fold is written inline with a black_box on the accumulator. No call site compares two Hash values today (seal.rs compares the truncated MerkleRoot), so it is a guard for future consumers rather than a live fix. ## The measurement, and why the swap is not in this commit First actual TIME number in this arc -- everything prior was instruction-class. `.claude/knowledge/blake3-ab-bench/`, release, two runs: 16 B 137 ns vs 100 ns 1.34x 256 B 445 ns vs 350 ns 1.28x 2 KB 4.3 us vs 2.7 us 1.34-1.60x 64 KB 115 us vs 23 us 4.6-5.1x Two gaps, different causes. The 64 KB gap is the absent hash_many (rung 3b). The 1.3x small-input gap is NOT -- at 16 B there is one compression and no parallelism to be had; the crate is faster because it SIMD-accelerates the single compress itself. Closing that needs a U32x4-shaped compress, a rung the plan did not have. Call it 3c. So the earlier framing -- "removes things, needs no benchmark to justify" -- was true about what it removes and silent about what it costs. Dropping the dependency is a trade, and it is the operator's call, so the call sites still use the external crate here. ## Four corrections from codex on #268, all valid - "No CI job compiles the chacha20 ndarray_simd backend" is FALSE. ci.yaml:141-142 builds vendor/chacha20 for wasm32+simd128, selecting that backend, under a comment calling it the wasm matryoshka guard. Only the AVX-512 arm is uncovered. Fixed here AND in the already-merged chacha20-vendoring-blast-radius.md. Fourth instance of the same scope-quantifier error: checked the x86 path, generalized to all targets. - 3a does NOT remove a C build. features = ["pure"] removed all C/ASM in #264 already. 3a removes the Rust dependency and its intrinsic surface. Overstating this matters when the counterweight is transcribing a crypto implementation. - The ordering arrows from 3a to rungs 4-6 contradicted the same section's own next sentence and would serialize independent work. Only 3a -> 3b is real; chacha20's backend already depends on ndarray, dalek has no cycle, and the u64 lane lives inside ndarray. - [[patch.unused]] is NOT cargo's cycle diagnostic. Cargo reports the cycle by naming the chain (cargo update -p blake3 did); an unused patch only means the patch was not selected, usually a version mismatch or a stale lockfile. Conflating them teaches misdiagnosis and undermines the cargo-tree check the entry prescribes. 4 blake3 tests pass; clippy clean; fmt clean. --- .claude/board/EPIPHANIES.md | 23 +- .../knowledge/blake3-ab-bench/blake3_ab.rs | 26 + .claude/knowledge/blake3-ab-bench/run.sh | 17 + .claude/knowledge/blake3-in-tree-measured.md | 126 +++ .../chacha20-vendoring-blast-radius.md | 15 +- .claude/knowledge/the-simd-ladder.md | 73 +- src/hpc/blake3.rs | 762 ++++++++++++++++++ src/hpc/blake3_test_vectors.json | 217 +++++ src/hpc/mod.rs | 1 + 9 files changed, 1233 insertions(+), 27 deletions(-) create mode 100644 .claude/knowledge/blake3-ab-bench/blake3_ab.rs create mode 100755 .claude/knowledge/blake3-ab-bench/run.sh create mode 100644 .claude/knowledge/blake3-in-tree-measured.md create mode 100644 src/hpc/blake3.rs create mode 100644 src/hpc/blake3_test_vectors.json diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index a3097b34..91c27675 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -35,11 +35,24 @@ per-package, not per-workspace. Reasoning at workspace granularity says all three are blocked, which would have parked two rungs that have no blocker at all. -**And the failure is silent.** Cargo does not error on the blake3 case — it -names the chain, declines to apply the patch, and falls back to the registry -crate with `[[patch.unused]]`. A port done anyway compiles clean and has zero -effect. That is the same silent-no-op class as the chacha20 patch, which is -now two independent instances of the same shape in one dependency graph. +**On the diagnostic — be precise, because two different things were +conflated here.** Cargo *does* report the cycle when the patched package +would be selected; `cargo update -p blake3` printed the chain +(`blake3 ... satisfies dependency of ndarray ... satisfies path dependency +ndarray of blake3`). What is NOT a cycle diagnostic is `[[patch.unused]]`. +That only says the patch was not selected, and the usual causes are a version +that does not satisfy the requirement or a stale lockfile. + +An earlier version of this entry read the unused patch as the cycle's +signature and called the failure "silent." Both halves were wrong, and codex +caught it on #268. Treating `[[patch.unused]]` as a cycle report teaches the +next reader to misdiagnose an ordinary stale patch — and undermines the very +check this entry prescribes. + +The check stands, on its own evidence: run +`cargo tree -p -i ` **before** planning the work. An error +there means no cycle and the work is unblocked; a hit means the edge must be +cut first. Consequence: **before planning any "make X consume our crate" work, run `cargo tree -p -i `.** An error there means no cycle and the diff --git a/.claude/knowledge/blake3-ab-bench/blake3_ab.rs b/.claude/knowledge/blake3-ab-bench/blake3_ab.rs new file mode 100644 index 00000000..d838a629 --- /dev/null +++ b/.claude/knowledge/blake3-ab-bench/blake3_ab.rs @@ -0,0 +1,26 @@ +//! 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 [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() { + // 16 B ~ a word (crystal_encoder); 256 B ~ text; 2 KB ~ VSA_BYTES; 64 KB ~ bulk. + for &n in &[16usize, 256, 2048, 65536] { + let data: Vec = (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); + } +} diff --git a/.claude/knowledge/blake3-ab-bench/run.sh b/.claude/knowledge/blake3-ab-bench/run.sh new file mode 100755 index 00000000..92847846 --- /dev/null +++ b/.claude/knowledge/blake3-ab-bench/run.sh @@ -0,0 +1,17 @@ +#!/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)" +mkdir -p "$REPO/examples" +trap 'rm -f "$REPO/examples/blake3_ab.rs"; rmdir "$REPO/examples" 2>/dev/null || true' EXIT +cp "$HERE/blake3_ab.rs" "$REPO/examples/blake3_ab.rs" +cd "$REPO" +cargo run --release --quiet --example blake3_ab diff --git a/.claude/knowledge/blake3-in-tree-measured.md b/.claude/knowledge/blake3-in-tree-measured.md new file mode 100644 index 00000000..ce48f0bc --- /dev/null +++ b/.claude/knowledge/blake3-in-tree-measured.md @@ -0,0 +1,126 @@ +# 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) | 137 ns | 100 ns | **1.34×** | +| 256 B (text) | 445 ns | 350 ns | **1.28×** | +| 2 KB (`VSA_BYTES`) | 4.3 µs | 2.7 µs | **1.34–1.60×** | +| 64 KB (bulk) | 115 µs | 23 µs | **4.6–5.1×** | + +**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: + +``` +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, +the C build question, and 2,910 lines of second-surface `core::arch`. + +**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. Part of the 1.3× is likely this choice rather than the SIMD + gap — `portable.rs` avoids a per-block staging copy — and that has **not** + been separated out. +- **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 1.3× is attributable to any single cause. The reference-impl + choice and the absent single-compress SIMD are confounded and were not + separated. +- 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. diff --git a/.claude/knowledge/chacha20-vendoring-blast-radius.md b/.claude/knowledge/chacha20-vendoring-blast-radius.md index ed66ad76..25323644 100644 --- a/.claude/knowledge/chacha20-vendoring-blast-radius.md +++ b/.claude/knowledge/chacha20-vendoring-blast-radius.md @@ -157,9 +157,18 @@ SIGILL on a runner without AVX-512 silicon. `cargo check --target=x86_64-unknown-linux-gnu -p ndarray --features approx,serde,rayon` (the second adding `hpc-extras`) — package-scoped to `ndarray`, so `crates/encryption` and therefore `vendor/chacha20` are outside -it. **No CI job compiles the chacha20 AVX-512 backend**, and none of the -coverage claimed here extends to it. Closing that gap would take an explicit -step such as: +it. **No CI job compiles the chacha20 AVX-512 backend.** + +**Correction (codex, #268): the wasm arm IS covered, and an earlier version of +this document said otherwise.** `ci.yaml:141-142`, inside the `wasm_simd` job, +runs `RUSTFLAGS="-C target-feature=+simd128" cargo build --manifest-path +vendor/chacha20/Cargo.toml --target wasm32-unknown-unknown --lib` — which +selects `backends::ndarray_simd` through the cfg's wasm arm, and whose own +comment calls it "the wasm matryoshka" guard. So the accurate statement is +**arm-specific, not backend-wide**: wasm32+simd128 is compiled and guarded in +CI; the x86_64 avx512f arm is compiled by nothing. + +Closing the AVX-512 gap would take an explicit step such as: ```console $ CARGO_TARGET_X86_64_UNKNOWN_LINUX_GNU_RUSTFLAGS="-Ctarget-cpu=x86-64-v4" \ diff --git a/.claude/knowledge/the-simd-ladder.md b/.claude/knowledge/the-simd-ladder.md index 6c40f8da..3e68cef4 100644 --- a/.claude/knowledge/the-simd-ladder.md +++ b/.claude/knowledge/the-simd-ladder.md @@ -41,10 +41,15 @@ error: package ID specification `curve25519-dalek` did not match any packages ``` **Only blake3 has a cycle**, because only blake3 is pulled by the *root* -package. Cargo names it explicitly and then silently falls back to the -registry crate with `[[patch.unused]]`, so a naive port compiles and has zero -effect — the same silent-no-op class as the chacha20 patch (see -`chacha20-vendoring-blast-radius.md`). +package. `cargo update -p blake3` reports it by naming the chain — the patched +`blake3` satisfies ndarray's dependency, and ndarray satisfies blake3's path +dependency. + +Do **not** read `[[patch.unused]]` as that diagnostic. It only means the patch +was not selected, and the ordinary causes are a version that does not satisfy +the requirement or a stale lockfile. The two were conflated in an earlier +draft (codex, #268); treating the unused patch as a cycle signature teaches +the next reader to misdiagnose a plain stale patch. The other two ride a pattern that **already works in this repo today**: `crates/encryption` → `chacha20` → `ndarray(root)` is exactly that shape. @@ -83,19 +88,42 @@ no SIMD at all. That splits the rung: - **3a — cut the cycle.** Transcribe portable BLAKE3 into ndarray, drop the - external crate. Removes the cycle, the C question, and 2,910 lines of - second-surface `core::arch` in one move. Verified against the official - `test_vectors.json`. *No SIMD involved — correctness only.* + external crate. Removes the cycle and ~2,910 lines of second-surface + `core::arch`. Verified against the official `test_vectors.json`. *No SIMD + involved — correctness only.* + + **It does NOT remove a C build.** `Cargo.toml:213` already sets + `default-features = false, features = ["pure"]`, which removed all C/ASM + compilation in #264 — 33 `.o` objects and `libblake3_avx512_assembly.a`, + measured at the time. Earlier wording here credited 3a with removing a C + build that a previous PR had already removed; codex caught it on #268. + Overstating the benefit matters especially here, because the thing being + weighed against it is transcribing a cryptographic implementation. - **3b — throughput, optional.** Port `hash_many` onto the rung-2 shuffle surface for multi-chunk inputs. **This is where a benchmark matters**, and it is not on the critical path. -### 4. chacha20 — backend exists, effectively unreachable +### 4. chacha20 — the wasm arm is live and CI-guarded; the AVX-512 arm is not + +`vendor/chacha20`'s `ndarray_simd` backend has two arms, and they are in +very different states: + +- **wasm32 + `simd128` — compiled and guarded.** `ci.yaml:141-142` builds + `vendor/chacha20` for `wasm32-unknown-unknown` with + `RUSTFLAGS="-C target-feature=+simd128"`, which selects `ndarray_simd`. + The job comment calls it "the wasm matryoshka" guard, and it sits directly + after the node parity step that proves the same `U32x16` lane bit-exact. +- **x86_64 + `avx512f` — compiled by nothing.** No image and no CI job + (`chacha20-vendoring-blast-radius.md`). -`vendor/chacha20` already has an `ndarray_simd` backend. Measured: no image -and no CI job compiles it (`chacha20-vendoring-blast-radius.md`). And -upstream 0.10.1 now ships its own `avx512.rs` in the same niche, unmeasured -against ours. **Blocked on a benchmark, then on the fork-vs-vendored ruling.** +An earlier version of this section said "no CI job compiles it" without +qualification. That was **false** — caught by codex on #268 — and is a fourth +instance of the scope-quantifier error this repo keeps hitting: the x86 path +was checked and the conclusion generalized to all targets. + +Upstream 0.10.1 now ships its own `avx512.rs` in the same niche, unmeasured +against ours. **The AVX-512 arm is blocked on a benchmark, then on the +fork-vs-vendored ruling. The wasm arm is already working.** ### 5. curve25519-dalek — the largest untouched surface @@ -127,15 +155,22 @@ argon2 needs BLAKE2b. Nothing is built on it. ## Order -3a is the only rung that unblocks anything else, and it needs no measurement -to justify — it removes a dependency edge, a C build, and a second SIMD -surface regardless of throughput. Everything after it is independent: +**There is almost no ordering.** An earlier version drew arrows from 3a to +everything, which contradicted this section's own next sentence and would +have serialized independent work; codex caught it on #268. + +The only real dependency is 3a → 3b, because `hash_many` needs an in-tree +BLAKE3 to live in. Everything else is genuinely parallel: chacha20's backend +already depends on `ndarray`, dalek has no cycle (established above), and the +u64 lane is implemented *inside* `ndarray`. None of the three is unblocked by +anything 3a does. ``` -3a (cut the cycle) ──> 3b (hash_many throughput) [needs a bench] - ──> 4 (chacha20) [needs a bench + a ruling] - ──> 5 (dalek) [no blocker but appetite] - ──> 6 (u64 ARX / argon2) [no blocker but appetite] +3a (cut the cycle) ──> 3b (hash_many throughput) [needs a bench] + +4 (chacha20 AVX-512 arm) [independent — needs a bench + a ruling] +5 (dalek) [independent — no blocker but appetite] +6 (u64 ARX / argon2) [independent — no blocker but appetite] ``` ## What is NOT claimed here diff --git a/src/hpc/blake3.rs b/src/hpc/blake3.rs new file mode 100644 index 00000000..af8a5697 --- /dev/null +++ b/src/hpc/blake3.rs @@ -0,0 +1,762 @@ +//! Pure-Rust, dependency-free BLAKE3 transcode. +//! +//! This module is a portable-only (no SIMD, no `unsafe`) transcription of +//! BLAKE3, based on the algorithm as published in the reference +//! implementation shipped by the upstream `BLAKE3-team/BLAKE3` project +//! (`reference_impl/reference_impl.rs`, referenced in Section 5.1 of the +//! BLAKE3 spec). That reference implementation is the serial, non-SIMD +//! tree-building algorithm — exactly the "single degree" code path that +//! `portable.rs` / `lib.rs`'s SIMD-parallel tree-walk (`compress_subtree_wide`, +//! `hash_many`, etc.) reduce to when `simd_degree() == 1`. It produces +//! byte-identical output to every other conformant BLAKE3 implementation; +//! only the parallel batching is left out, which is irrelevant here since we +//! have no SIMD/threading in scope (see module docs at the bottom of this +//! file for what was intentionally not transcribed). +//! +//! No `unsafe`, no `core::arch`, no external crates. `no_std`-friendly aside +//! from the tests, which use `std` (they run only under `cfg(test)`). + +use core::cmp::min; +use core::fmt; + +// --------------------------------------------------------------------- +// Constants +// --------------------------------------------------------------------- + +const OUT_LEN: usize = 32; +const KEY_LEN: usize = 32; +const BLOCK_LEN: usize = 64; +const CHUNK_LEN: usize = 1024; + +const CHUNK_START: u32 = 1 << 0; +const CHUNK_END: u32 = 1 << 1; +const PARENT: u32 = 1 << 2; +const ROOT: u32 = 1 << 3; +const KEYED_HASH: u32 = 1 << 4; +const DERIVE_KEY_CONTEXT: u32 = 1 << 5; +const DERIVE_KEY_MATERIAL: u32 = 1 << 6; + +const IV: [u32; 8] = [0x6A09E667, 0xBB67AE85, 0x3C6EF372, 0xA54FF53A, 0x510E527F, 0x9B05688C, 0x1F83D9AB, 0x5BE0CD19]; + +const MSG_PERMUTATION: [usize; 16] = [2, 6, 3, 10, 7, 0, 4, 13, 1, 11, 12, 5, 9, 14, 15, 8]; + +// --------------------------------------------------------------------- +// Compression function core (transcribed from portable.rs's `g`/`round`, +// specialized to the reference implementation's single-permutation-array +// style rather than portable.rs's per-round MSG_SCHEDULE table — the two +// are algebraically identical, just indexed differently.) +// --------------------------------------------------------------------- + +#[inline(always)] +fn g(state: &mut [u32; 16], a: usize, b: usize, c: usize, d: usize, mx: u32, my: u32) { + state[a] = state[a].wrapping_add(state[b]).wrapping_add(mx); + state[d] = (state[d] ^ state[a]).rotate_right(16); + state[c] = state[c].wrapping_add(state[d]); + state[b] = (state[b] ^ state[c]).rotate_right(12); + state[a] = state[a].wrapping_add(state[b]).wrapping_add(my); + state[d] = (state[d] ^ state[a]).rotate_right(8); + state[c] = state[c].wrapping_add(state[d]); + state[b] = (state[b] ^ state[c]).rotate_right(7); +} + +#[inline(always)] +fn round(state: &mut [u32; 16], m: &[u32; 16]) { + // Mix the columns. + g(state, 0, 4, 8, 12, m[0], m[1]); + g(state, 1, 5, 9, 13, m[2], m[3]); + g(state, 2, 6, 10, 14, m[4], m[5]); + g(state, 3, 7, 11, 15, m[6], m[7]); + // Mix the diagonals. + g(state, 0, 5, 10, 15, m[8], m[9]); + g(state, 1, 6, 11, 12, m[10], m[11]); + g(state, 2, 7, 8, 13, m[12], m[13]); + g(state, 3, 4, 9, 14, m[14], m[15]); +} + +#[inline(always)] +fn permute(m: &mut [u32; 16]) { + let mut permuted = [0u32; 16]; + for i in 0..16 { + permuted[i] = m[MSG_PERMUTATION[i]]; + } + *m = permuted; +} + +/// The core compression function. Returns the full 16-word state (already +/// XOR-finalized): the first 8 words are the chaining value output, and (when +/// `ROOT` is set) all 16 words are used as extendable-output bytes. +fn compress(chaining_value: &[u32; 8], block_words: &[u32; 16], counter: u64, block_len: u32, flags: u32) -> [u32; 16] { + let counter_low = counter as u32; + let counter_high = (counter >> 32) as u32; + #[rustfmt::skip] + let mut state = [ + chaining_value[0], chaining_value[1], chaining_value[2], chaining_value[3], + chaining_value[4], chaining_value[5], chaining_value[6], chaining_value[7], + IV[0], IV[1], IV[2], IV[3], + counter_low, counter_high, block_len, flags, + ]; + let mut block = *block_words; + + round(&mut state, &block); // round 1 + permute(&mut block); + round(&mut state, &block); // round 2 + permute(&mut block); + round(&mut state, &block); // round 3 + permute(&mut block); + round(&mut state, &block); // round 4 + permute(&mut block); + round(&mut state, &block); // round 5 + permute(&mut block); + round(&mut state, &block); // round 6 + permute(&mut block); + round(&mut state, &block); // round 7 + + for i in 0..8 { + state[i] ^= state[i + 8]; + state[i + 8] ^= chaining_value[i]; + } + state +} + +#[inline] +fn first_8_words(compression_output: [u32; 16]) -> [u32; 8] { + let mut out = [0u32; 8]; + out.copy_from_slice(&compression_output[0..8]); + out +} + +fn words_from_little_endian_bytes(bytes: &[u8], words: &mut [u32]) { + debug_assert_eq!(bytes.len(), 4 * words.len()); + for (four_bytes, word) in bytes.chunks_exact(4).zip(words.iter_mut()) { + let mut arr = [0u8; 4]; + arr.copy_from_slice(four_bytes); + *word = u32::from_le_bytes(arr); + } +} + +// --------------------------------------------------------------------- +// Output (chunk / parent finalization; also the extendable-output source) +// --------------------------------------------------------------------- + +/// Each chunk or parent node can produce either an 8-word chaining value or, +/// by setting the `ROOT` flag, any number of final output bytes. `Output` +/// captures the state just prior to choosing between those two possibilities. +#[derive(Clone)] +struct Output { + input_chaining_value: [u32; 8], + block_words: [u32; 16], + counter: u64, + block_len: u32, + flags: u32, +} + +impl Output { + fn chaining_value(&self) -> [u32; 8] { + first_8_words(compress(&self.input_chaining_value, &self.block_words, self.counter, self.block_len, self.flags)) + } + + fn root_output_bytes(&self, out_slice: &mut [u8]) { + let mut output_block_counter = 0u64; + for out_block in out_slice.chunks_mut(2 * OUT_LEN) { + let words = compress( + &self.input_chaining_value, + &self.block_words, + output_block_counter, + self.block_len, + self.flags | ROOT, + ); + // The output length might not be a multiple of 4. + for (word, out_word) in words.iter().zip(out_block.chunks_mut(4)) { + let bytes = word.to_le_bytes(); + out_word.copy_from_slice(&bytes[..out_word.len()]); + } + output_block_counter += 1; + } + } +} + +// --------------------------------------------------------------------- +// ChunkState +// --------------------------------------------------------------------- + +#[derive(Clone)] +struct ChunkState { + chaining_value: [u32; 8], + chunk_counter: u64, + block: [u8; BLOCK_LEN], + block_len: u8, + blocks_compressed: u8, + flags: u32, +} + +impl ChunkState { + fn new(key_words: [u32; 8], chunk_counter: u64, flags: u32) -> Self { + Self { + chaining_value: key_words, + chunk_counter, + block: [0u8; BLOCK_LEN], + block_len: 0, + blocks_compressed: 0, + flags, + } + } + + fn len(&self) -> usize { + BLOCK_LEN * self.blocks_compressed as usize + self.block_len as usize + } + + fn start_flag(&self) -> u32 { + if self.blocks_compressed == 0 { + CHUNK_START + } else { + 0 + } + } + + fn update(&mut self, mut input: &[u8]) { + while !input.is_empty() { + // If the block buffer is full, compress it and clear it. More + // input is coming, so this compression is not CHUNK_END. + if self.block_len as usize == BLOCK_LEN { + let mut block_words = [0u32; 16]; + words_from_little_endian_bytes(&self.block, &mut block_words); + self.chaining_value = first_8_words(compress( + &self.chaining_value, + &block_words, + self.chunk_counter, + BLOCK_LEN as u32, + self.flags | self.start_flag(), + )); + self.blocks_compressed += 1; + self.block = [0u8; BLOCK_LEN]; + self.block_len = 0; + } + + // Copy input bytes into the block buffer. + let want = BLOCK_LEN - self.block_len as usize; + let take = min(want, input.len()); + self.block[self.block_len as usize..][..take].copy_from_slice(&input[..take]); + self.block_len += take as u8; + input = &input[take..]; + } + } + + fn output(&self) -> Output { + let mut block_words = [0u32; 16]; + words_from_little_endian_bytes(&self.block, &mut block_words); + Output { + input_chaining_value: self.chaining_value, + block_words, + counter: self.chunk_counter, + block_len: self.block_len as u32, + flags: self.flags | self.start_flag() | CHUNK_END, + } + } +} + +impl fmt::Debug for ChunkState { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("ChunkState") + .field("len", &self.len()) + .field("chunk_counter", &self.chunk_counter) + .field("flags", &self.flags) + .finish() + } +} + +fn parent_output(left_child_cv: [u32; 8], right_child_cv: [u32; 8], key_words: [u32; 8], flags: u32) -> Output { + let mut block_words = [0u32; 16]; + block_words[..8].copy_from_slice(&left_child_cv); + block_words[8..].copy_from_slice(&right_child_cv); + Output { + input_chaining_value: key_words, + block_words, + counter: 0, // Always 0 for parent nodes. + block_len: BLOCK_LEN as u32, // Always BLOCK_LEN (64) for parent nodes. + flags: PARENT | flags, + } +} + +fn parent_cv(left_child_cv: [u32; 8], right_child_cv: [u32; 8], key_words: [u32; 8], flags: u32) -> [u32; 8] { + parent_output(left_child_cv, right_child_cv, key_words, flags).chaining_value() +} + +// --------------------------------------------------------------------- +// Public API: Hash +// --------------------------------------------------------------------- + +/// A 32-byte BLAKE3 output. +#[derive(Clone, Copy, Eq)] +pub struct Hash([u8; OUT_LEN]); + +impl Hash { + /// The raw bytes of this `Hash`. + #[inline] + pub const fn as_bytes(&self) -> &[u8; OUT_LEN] { + &self.0 + } +} + +impl From<[u8; OUT_LEN]> for Hash { + #[inline] + fn from(bytes: [u8; OUT_LEN]) -> Self { + Self(bytes) + } +} + +impl From for [u8; OUT_LEN] { + #[inline] + fn from(hash: Hash) -> Self { + hash.0 + } +} + +impl AsRef<[u8]> for Hash { + #[inline] + fn as_ref(&self) -> &[u8] { + &self.0 + } +} + +impl PartialEq for Hash { + /// Constant-time comparison. + /// + /// A BLAKE3 output is used as a MAC in keyed mode, so a short-circuiting + /// `==` leaks match-prefix length through timing. Upstream reaches for + /// the `constant_time_eq` crate; this crate takes no new dependencies, so + /// the fold is written here: accumulate the XOR of every byte pair and + /// test once at the end, with `read_volatile` on the accumulator to stop + /// the optimizer reintroducing an early exit. + /// + /// No call site in this crate currently compares two `Hash` values — + /// `seal.rs` compares the truncated `MerkleRoot` instead — so this is a + /// guard for future consumers rather than a fix for a live leak. + #[inline] + fn eq(&self, other: &Hash) -> bool { + let mut diff = 0u8; + for i in 0..OUT_LEN { + diff |= self.0[i] ^ other.0[i]; + } + // SAFETY-free volatile read of a local: prevents the compiler from + // proving an early return is equivalent. + core::hint::black_box(diff) == 0 + } +} + +impl fmt::Debug for Hash { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "Hash(")?; + for byte in self.0.iter() { + write!(f, "{:02x}", byte)?; + } + write!(f, ")") + } +} + +// --------------------------------------------------------------------- +// Public API: Hasher +// --------------------------------------------------------------------- + +/// An incremental BLAKE3 hasher. +#[derive(Clone)] +pub struct Hasher { + chunk_state: ChunkState, + key_words: [u32; 8], + cv_stack: [[u32; 8]; 54], // 2^54 * CHUNK_LEN = 2^64 + cv_stack_len: u8, + flags: u32, +} + +impl fmt::Debug for Hasher { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("Hasher") + .field("chunk_state", &self.chunk_state) + .field("cv_stack_len", &self.cv_stack_len) + .field("flags", &self.flags) + .finish() + } +} + +impl Default for Hasher { + fn default() -> Self { + Self::new() + } +} + +impl Hasher { + fn new_internal(key_words: [u32; 8], flags: u32) -> Self { + Self { + chunk_state: ChunkState::new(key_words, 0, flags), + key_words, + cv_stack: [[0u32; 8]; 54], + cv_stack_len: 0, + flags, + } + } + + /// Construct a new `Hasher` for the regular hash function. + pub fn new() -> Self { + Self::new_internal(IV, 0) + } + + /// Construct a new `Hasher` for the keyed hash function. + pub fn new_keyed(key: &[u8; KEY_LEN]) -> Self { + let mut key_words = [0u32; 8]; + words_from_little_endian_bytes(key, &mut key_words); + Self::new_internal(key_words, KEYED_HASH) + } + + /// Construct a new `Hasher` for the key-derivation function. The context + /// string should be hardcoded, globally unique, and application-specific. + pub fn new_derive_key(context: &str) -> Self { + let mut context_hasher = Self::new_internal(IV, DERIVE_KEY_CONTEXT); + context_hasher.update(context.as_bytes()); + let context_key = context_hasher.finalize(); + let mut context_key_words = [0u32; 8]; + words_from_little_endian_bytes(context_key.as_bytes(), &mut context_key_words); + Self::new_internal(context_key_words, DERIVE_KEY_MATERIAL) + } + + fn push_stack(&mut self, cv: [u32; 8]) { + self.cv_stack[self.cv_stack_len as usize] = cv; + self.cv_stack_len += 1; + } + + fn pop_stack(&mut self) -> [u32; 8] { + self.cv_stack_len -= 1; + self.cv_stack[self.cv_stack_len as usize] + } + + // Section 5.1.2 of the BLAKE3 spec explains this algorithm in detail. + fn add_chunk_chaining_value(&mut self, mut new_cv: [u32; 8], mut total_chunks: u64) { + while total_chunks & 1 == 0 { + new_cv = parent_cv(self.pop_stack(), new_cv, self.key_words, self.flags); + total_chunks >>= 1; + } + self.push_stack(new_cv); + } + + /// Add input to the hash state. May be called any number of times. + pub fn update(&mut self, mut input: &[u8]) -> &mut Self { + while !input.is_empty() { + if self.chunk_state.len() == CHUNK_LEN { + let chunk_cv = self.chunk_state.output().chaining_value(); + let total_chunks = self.chunk_state.chunk_counter + 1; + self.add_chunk_chaining_value(chunk_cv, total_chunks); + self.chunk_state = ChunkState::new(self.key_words, total_chunks, self.flags); + } + + let want = CHUNK_LEN - self.chunk_state.len(); + let take = min(want, input.len()); + self.chunk_state.update(&input[..take]); + input = &input[take..]; + } + self + } + + fn final_output(&self) -> Output { + let mut output = self.chunk_state.output(); + let mut parent_nodes_remaining = self.cv_stack_len as usize; + while parent_nodes_remaining > 0 { + parent_nodes_remaining -= 1; + output = parent_output( + self.cv_stack[parent_nodes_remaining], + output.chaining_value(), + self.key_words, + self.flags, + ); + } + output + } + + /// Finalize the hash and return the default 32-byte output. + pub fn finalize(&self) -> Hash { + let mut out = [0u8; OUT_LEN]; + self.final_output().root_output_bytes(&mut out); + Hash(out) + } + + /// Finalize the hash as an extendable-output stream. + pub fn finalize_xof(&self) -> OutputReader { + OutputReader { + inner: self.final_output(), + position: 0, + } + } +} + +// --------------------------------------------------------------------- +// Public API: OutputReader (extendable output) +// --------------------------------------------------------------------- + +/// A reader for extendable BLAKE3 output, produced by [`Hasher::finalize_xof`]. +#[derive(Clone)] +pub struct OutputReader { + inner: Output, + position: u64, +} + +impl fmt::Debug for OutputReader { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("OutputReader") + .field("position", &self.position) + .finish() + } +} + +impl OutputReader { + /// Fill `buf` with the next `buf.len()` bytes of output. Successive calls + /// continue from where the previous call left off. + pub fn fill(&mut self, mut buf: &mut [u8]) { + const BLOCK: u64 = 2 * OUT_LEN as u64; // 64 + while !buf.is_empty() { + let block_counter = self.position / BLOCK; + let block_offset = (self.position % BLOCK) as usize; + + let words = compress( + &self.inner.input_chaining_value, + &self.inner.block_words, + block_counter, + self.inner.block_len, + self.inner.flags | ROOT, + ); + let mut block_bytes = [0u8; BLOCK as usize]; + for (word, out_word) in words.iter().zip(block_bytes.chunks_mut(4)) { + out_word.copy_from_slice(&word.to_le_bytes()); + } + + let available = BLOCK as usize - block_offset; + let take = min(available, buf.len()); + buf[..take].copy_from_slice(&block_bytes[block_offset..block_offset + take]); + self.position += take as u64; + + let (_, rest) = buf.split_at_mut(take); + buf = rest; + } + } +} + +// --------------------------------------------------------------------- +// Public API: free functions +// --------------------------------------------------------------------- + +/// The default hash function. +pub fn hash(input: &[u8]) -> Hash { + let mut hasher = Hasher::new(); + hasher.update(input); + hasher.finalize() +} + +/// The keyed hash function. +pub fn keyed_hash(key: &[u8; KEY_LEN], input: &[u8]) -> Hash { + let mut hasher = Hasher::new_keyed(key); + hasher.update(input); + hasher.finalize() +} + +/// The key-derivation function. +pub fn derive_key(context: &str, key_material: &[u8]) -> [u8; OUT_LEN] { + let mut hasher = Hasher::new_derive_key(context); + hasher.update(key_material); + (*hasher.finalize().as_bytes()).into() +} + +// ======================================================================= +// NOT transcribed (out of scope for this module — documented per task spec): +// +// - `hash_many` / `compress_chunks_parallel` / `compress_parents_parallel` / +// `compress_subtree_wide` / `compress_subtree_to_parent_node`: these exist +// purely to batch multiple chunks/parents through SIMD-width compression +// calls (`platform.simd_degree()`), and to enable Rayon multithreading via +// the `join::Join` trait. With `simd_degree() == 1` and no threading, they +// reduce to exactly the serial one-chunk-then-merge-up-the-stack algorithm +// this module implements directly (`Hasher::update` / `add_chunk_chaining_value`). +// - `update_rayon` and anything behind the `rayon` feature. +// - `zeroize` support (feature-gated in the original; secrets are not a +// concern for ndarray's usage of this module). +// - Constant-time equality (`constant_time_eq`): the original `Hash::eq` is +// constant-time as a defense-in-depth measure for MAC comparisons. Adding +// that would mean hand-rolling a constant-time byte compare with no +// external crate; out of scope for what ndarray needs (a plain fingerprint +// equality check), so `PartialEq` here is a normal short-circuiting +// byte-array compare. Flagged explicitly since it is a behavioral +// difference from upstream `blake3::Hash`, not just a missing convenience. +// - Hex encoding/decoding (`to_hex`/`from_hex`) and `Display`/`FromStr`: not +// in the required API list. +// ======================================================================= + +#[cfg(test)] +mod tests { + use super::*; + use std::string::String; + use std::vec::Vec; + + const VECTORS_JSON: &str = include_str!("blake3_test_vectors.json"); + const KEY: &[u8; 32] = b"whats the Elvish word for friend"; + + struct Case { + input_len: usize, + hash_hex: String, + keyed_hash_hex: String, + } + + /// Hand-rolled extraction of the fields we need from the official BLAKE3 + /// test_vectors.json, without pulling in serde. The file's `cases` array + /// is a flat sequence of objects each with exactly the fields + /// `input_len` (integer), `hash` (hex string), `keyed_hash` (hex string), + /// `derive_key` (hex string, unused here). We scan for each field by name + /// in order, which is sufficient given the known, fixed shape of this + /// file (verified above by inspection). + fn parse_cases(json: &str) -> Vec { + let mut cases = Vec::new(); + let mut rest = json; + loop { + let Some(idx) = rest.find("\"input_len\":") else { + break; + }; + rest = &rest[idx + "\"input_len\":".len()..]; + let len_end = rest + .find(|c: char| c == ',' || c == '\n' || c == '}') + .expect("malformed input_len"); + let input_len: usize = rest[..len_end].trim().parse().expect("bad input_len"); + + let hash_idx = rest.find("\"hash\":").expect("missing hash field") + "\"hash\":".len(); + rest = &rest[hash_idx..]; + let hash_hex = extract_quoted(rest); + rest = &rest[hash_hex.len() + 2..]; + + let kh_idx = rest + .find("\"keyed_hash\":") + .expect("missing keyed_hash field") + + "\"keyed_hash\":".len(); + rest = &rest[kh_idx..]; + let keyed_hash_hex = extract_quoted(rest); + rest = &rest[keyed_hash_hex.len() + 2..]; + + cases.push(Case { + input_len, + hash_hex, + keyed_hash_hex, + }); + } + cases + } + + /// Given a string starting with optional whitespace then a `"..."` + /// quoted JSON string (no escapes present in this file's hex fields), + /// return the content between the quotes. + fn extract_quoted(s: &str) -> String { + let s = s.trim_start(); + assert!(s.starts_with('"'), "expected quoted string"); + let s = &s[1..]; + let end = s.find('"').expect("unterminated string"); + s[..end].to_string() + } + + fn hex_decode(s: &str) -> Vec { + assert_eq!(s.len() % 2, 0); + (0..s.len()) + .step_by(2) + .map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap()) + .collect() + } + + /// Reference input: byte i is (i % 251). + fn reference_input(len: usize) -> Vec { + (0..len).map(|i| (i % 251) as u8).collect() + } + + #[test] + fn official_test_vectors_hash_and_keyed_hash() { + let cases = parse_cases(VECTORS_JSON); + assert!(!cases.is_empty(), "no cases parsed"); + let mut checked = 0usize; + for case in &cases { + let input = reference_input(case.input_len); + let expected_hash = hex_decode(&case.hash_hex); + let expected_keyed = hex_decode(&case.keyed_hash_hex); + + // --- unkeyed hash, 32-byte default output --- + let got = hash(&input); + assert_eq!(got.as_bytes()[..], expected_hash[..32], "hash() mismatch at input_len={}", case.input_len); + + // --- unkeyed hash, extended output via finalize_xof --- + let mut hasher = Hasher::new(); + hasher.update(&input); + let mut xof = hasher.finalize_xof(); + let mut extended = std::vec![0u8; expected_hash.len()]; + xof.fill(&mut extended); + assert_eq!( + extended, expected_hash, + "finalize_xof extended output mismatch at input_len={}", + case.input_len + ); + + // --- keyed hash, 32-byte default output --- + let got_keyed = keyed_hash(KEY, &input); + assert_eq!( + got_keyed.as_bytes()[..], + expected_keyed[..32], + "keyed_hash() mismatch at input_len={}", + case.input_len + ); + + // --- keyed hash, extended output via finalize_xof --- + let mut khasher = Hasher::new_keyed(KEY); + khasher.update(&input); + let mut kxof = khasher.finalize_xof(); + let mut kextended = std::vec![0u8; expected_keyed.len()]; + kxof.fill(&mut kextended); + assert_eq!( + kextended, expected_keyed, + "keyed finalize_xof extended output mismatch at input_len={}", + case.input_len + ); + + checked += 1; + } + // Sanity: make sure we actually walked the whole official vector file. + assert_eq!(checked, 35, "expected 35 official test vector cases"); + } + + #[test] + fn streaming_matches_single_shot() { + let input = reference_input(102_400); + let whole = hash(&input); + + let mut hasher = Hasher::new(); + for chunk in input.chunks(37) { + hasher.update(chunk); + } + let streamed = hasher.finalize(); + assert_eq!(whole, streamed); + } + + #[test] + fn empty_input() { + let h = hash(&[]); + // From the official test vectors, input_len == 0 case (the first + // 32 bytes of its "hash" field). + let cases = parse_cases(VECTORS_JSON); + let case0 = cases.iter().find(|c| c.input_len == 0).unwrap(); + let expected = hex_decode(&case0.hash_hex); + assert_eq!(h.as_bytes()[..], expected[..32]); + } + + #[test] + fn fill_in_small_increments_matches_one_shot() { + let input = reference_input(5000); + let mut hasher = Hasher::new(); + hasher.update(&input); + + let mut one_shot = [0u8; 300]; + hasher.finalize_xof().fill(&mut one_shot); + + let mut incremental = [0u8; 300]; + let mut xof = hasher.finalize_xof(); + for chunk in incremental.chunks_mut(7) { + xof.fill(chunk); + } + assert_eq!(one_shot[..], incremental[..]); + } +} diff --git a/src/hpc/blake3_test_vectors.json b/src/hpc/blake3_test_vectors.json new file mode 100644 index 00000000..f6da9179 --- /dev/null +++ b/src/hpc/blake3_test_vectors.json @@ -0,0 +1,217 @@ +{ + "_comment": "Each test is an input length and three outputs, one for each of the hash, keyed_hash, and derive_key modes. The input in each case is filled with a repeating sequence of 251 bytes: 0, 1, 2, ..., 249, 250, 0, 1, ..., and so on. The key used with keyed_hash is the 32-byte ASCII string \"whats the Elvish word for friend\", also given in the `key` field below. The context string used with derive_key is the ASCII string \"BLAKE3 2019-12-27 16:29:52 test vectors context\", also given in the `context_string` field below. Outputs are encoded as hexadecimal. Each case is an extended output, and implementations should also check that the first 32 bytes match their default-length output.", + "key": "whats the Elvish word for friend", + "context_string": "BLAKE3 2019-12-27 16:29:52 test vectors context", + "cases": [ + { + "input_len": 0, + "hash": "af1349b9f5f9a1a6a0404dea36dcc9499bcb25c9adc112b7cc9a93cae41f3262e00f03e7b69af26b7faaf09fcd333050338ddfe085b8cc869ca98b206c08243a26f5487789e8f660afe6c99ef9e0c52b92e7393024a80459cf91f476f9ffdbda7001c22e159b402631f277ca96f2defdf1078282314e763699a31c5363165421cce14d", + "keyed_hash": "92b2b75604ed3c761f9d6f62392c8a9227ad0ea3f09573e783f1498a4ed60d26b18171a2f22a4b94822c701f107153dba24918c4bae4d2945c20ece13387627d3b73cbf97b797d5e59948c7ef788f54372df45e45e4293c7dc18c1d41144a9758be58960856be1eabbe22c2653190de560ca3b2ac4aa692a9210694254c371e851bc8f", + "derive_key": "2cc39783c223154fea8dfb7c1b1660f2ac2dcbd1c1de8277b0b0dd39b7e50d7d905630c8be290dfcf3e6842f13bddd573c098c3f17361f1f206b8cad9d088aa4a3f746752c6b0ce6a83b0da81d59649257cdf8eb3e9f7d4998e41021fac119deefb896224ac99f860011f73609e6e0e4540f93b273e56547dfd3aa1a035ba6689d89a0" + }, + { + "input_len": 1, + "hash": "2d3adedff11b61f14c886e35afa036736dcd87a74d27b5c1510225d0f592e213c3a6cb8bf623e20cdb535f8d1a5ffb86342d9c0b64aca3bce1d31f60adfa137b358ad4d79f97b47c3d5e79f179df87a3b9776ef8325f8329886ba42f07fb138bb502f4081cbcec3195c5871e6c23e2cc97d3c69a613eba131e5f1351f3f1da786545e5", + "keyed_hash": "6d7878dfff2f485635d39013278ae14f1454b8c0a3a2d34bc1ab38228a80c95b6568c0490609413006fbd428eb3fd14e7756d90f73a4725fad147f7bf70fd61c4e0cf7074885e92b0e3f125978b4154986d4fb202a3f331a3fb6cf349a3a70e49990f98fe4289761c8602c4e6ab1138d31d3b62218078b2f3ba9a88e1d08d0dd4cea11", + "derive_key": "b3e2e340a117a499c6cf2398a19ee0d29cca2bb7404c73063382693bf66cb06c5827b91bf889b6b97c5477f535361caefca0b5d8c4746441c57617111933158950670f9aa8a05d791daae10ac683cbef8faf897c84e6114a59d2173c3f417023a35d6983f2c7dfa57e7fc559ad751dbfb9ffab39c2ef8c4aafebc9ae973a64f0c76551" + }, + { + "input_len": 2, + "hash": "7b7015bb92cf0b318037702a6cdd81dee41224f734684c2c122cd6359cb1ee63d8386b22e2ddc05836b7c1bb693d92af006deb5ffbc4c70fb44d0195d0c6f252faac61659ef86523aa16517f87cb5f1340e723756ab65efb2f91964e14391de2a432263a6faf1d146937b35a33621c12d00be8223a7f1919cec0acd12097ff3ab00ab1", + "keyed_hash": "5392ddae0e0a69d5f40160462cbd9bd889375082ff224ac9c758802b7a6fd20a9ffbf7efd13e989a6c246f96d3a96b9d279f2c4e63fb0bdff633957acf50ee1a5f658be144bab0f6f16500dee4aa5967fc2c586d85a04caddec90fffb7633f46a60786024353b9e5cebe277fcd9514217fee2267dcda8f7b31697b7c54fab6a939bf8f", + "derive_key": "1f166565a7df0098ee65922d7fea425fb18b9943f19d6161e2d17939356168e6daa59cae19892b2d54f6fc9f475d26031fd1c22ae0a3e8ef7bdb23f452a15e0027629d2e867b1bb1e6ab21c71297377750826c404dfccc2406bd57a83775f89e0b075e59a7732326715ef912078e213944f490ad68037557518b79c0086de6d6f6cdd2" + }, + { + "input_len": 3, + "hash": "e1be4d7a8ab5560aa4199eea339849ba8e293d55ca0a81006726d184519e647f5b49b82f805a538c68915c1ae8035c900fd1d4b13902920fd05e1450822f36de9454b7e9996de4900c8e723512883f93f4345f8a58bfe64ee38d3ad71ab027765d25cdd0e448328a8e7a683b9a6af8b0af94fa09010d9186890b096a08471e4230a134", + "keyed_hash": "39e67b76b5a007d4921969779fe666da67b5213b096084ab674742f0d5ec62b9b9142d0fab08e1b161efdbb28d18afc64d8f72160c958e53a950cdecf91c1a1bbab1a9c0f01def762a77e2e8545d4dec241e98a89b6db2e9a5b070fc110caae2622690bd7b76c02ab60750a3ea75426a6bb8803c370ffe465f07fb57def95df772c39f", + "derive_key": "440aba35cb006b61fc17c0529255de438efc06a8c9ebf3f2ddac3b5a86705797f27e2e914574f4d87ec04c379e12789eccbfbc15892626042707802dbe4e97c3ff59dca80c1e54246b6d055154f7348a39b7d098b2b4824ebe90e104e763b2a447512132cede16243484a55a4e40a85790038bb0dcf762e8c053cabae41bbe22a5bff7" + }, + { + "input_len": 4, + "hash": "f30f5ab28fe047904037f77b6da4fea1e27241c5d132638d8bedce9d40494f328f603ba4564453e06cdcee6cbe728a4519bbe6f0d41e8a14b5b225174a566dbfa61b56afb1e452dc08c804f8c3143c9e2cc4a31bb738bf8c1917b55830c6e65797211701dc0b98daa1faeaa6ee9e56ab606ce03a1a881e8f14e87a4acf4646272cfd12", + "keyed_hash": "7671dde590c95d5ac9616651ff5aa0a27bee5913a348e053b8aa9108917fe070116c0acff3f0d1fa97ab38d813fd46506089118147d83393019b068a55d646251ecf81105f798d76a10ae413f3d925787d6216a7eb444e510fd56916f1d753a5544ecf0072134a146b2615b42f50c179f56b8fae0788008e3e27c67482349e249cb86a", + "derive_key": "f46085c8190d69022369ce1a18880e9b369c135eb93f3c63550d3e7630e91060fbd7d8f4258bec9da4e05044f88b91944f7cab317a2f0c18279629a3867fad0662c9ad4d42c6f27e5b124da17c8c4f3a94a025ba5d1b623686c6099d202a7317a82e3d95dae46a87de0555d727a5df55de44dab799a20dffe239594d6e99ed17950910" + }, + { + "input_len": 5, + "hash": "b40b44dfd97e7a84a996a91af8b85188c66c126940ba7aad2e7ae6b385402aa2ebcfdac6c5d32c31209e1f81a454751280db64942ce395104e1e4eaca62607de1c2ca748251754ea5bbe8c20150e7f47efd57012c63b3c6a6632dc1c7cd15f3e1c999904037d60fac2eb9397f2adbe458d7f264e64f1e73aa927b30988e2aed2f03620", + "keyed_hash": "73ac69eecf286894d8102018a6fc729f4b1f4247d3703f69bdc6a5fe3e0c84616ab199d1f2f3e53bffb17f0a2209fe8b4f7d4c7bae59c2bc7d01f1ff94c67588cc6b38fa6024886f2c078bfe09b5d9e6584cd6c521c3bb52f4de7687b37117a2dbbec0d59e92fa9a8cc3240d4432f91757aabcae03e87431dac003e7d73574bfdd8218", + "derive_key": "1f24eda69dbcb752847ec3ebb5dd42836d86e58500c7c98d906ecd82ed9ae47f6f48a3f67e4e43329c9a89b1ca526b9b35cbf7d25c1e353baffb590fd79be58ddb6c711f1a6b60e98620b851c688670412fcb0435657ba6b638d21f0f2a04f2f6b0bd8834837b10e438d5f4c7c2c71299cf7586ea9144ed09253d51f8f54dd6bff719d" + }, + { + "input_len": 6, + "hash": "06c4e8ffb6872fad96f9aaca5eee1553eb62aed0ad7198cef42e87f6a616c844611a30c4e4f37fe2fe23c0883cde5cf7059d88b657c7ed2087e3d210925ede716435d6d5d82597a1e52b9553919e804f5656278bd739880692c94bff2824d8e0b48cac1d24682699e4883389dc4f2faa2eb3b4db6e39debd5061ff3609916f3e07529a", + "keyed_hash": "82d3199d0013035682cc7f2a399d4c212544376a839aa863a0f4c91220ca7a6dc2ffb3aa05f2631f0fa9ac19b6e97eb7e6669e5ec254799350c8b8d189e8807800842a5383c4d907c932f34490aaf00064de8cdb157357bde37c1504d2960034930887603abc5ccb9f5247f79224baff6120a3c622a46d7b1bcaee02c5025460941256", + "derive_key": "be96b30b37919fe4379dfbe752ae77b4f7e2ab92f7ff27435f76f2f065f6a5f435ae01a1d14bd5a6b3b69d8cbd35f0b01ef2173ff6f9b640ca0bd4748efa398bf9a9c0acd6a66d9332fdc9b47ffe28ba7ab6090c26747b85f4fab22f936b71eb3f64613d8bd9dfabe9bb68da19de78321b481e5297df9e40ec8a3d662f3e1479c65de0" + }, + { + "input_len": 7, + "hash": "3f8770f387faad08faa9d8414e9f449ac68e6ff0417f673f602a646a891419fe66036ef6e6d1a8f54baa9fed1fc11c77cfb9cff65bae915045027046ebe0c01bf5a941f3bb0f73791d3fc0b84370f9f30af0cd5b0fc334dd61f70feb60dad785f070fef1f343ed933b49a5ca0d16a503f599a365a4296739248b28d1a20b0e2cc8975c", + "keyed_hash": "af0a7ec382aedc0cfd626e49e7628bc7a353a4cb108855541a5651bf64fbb28a7c5035ba0f48a9c73dabb2be0533d02e8fd5d0d5639a18b2803ba6bf527e1d145d5fd6406c437b79bcaad6c7bdf1cf4bd56a893c3eb9510335a7a798548c6753f74617bede88bef924ba4b334f8852476d90b26c5dc4c3668a2519266a562c6c8034a6", + "derive_key": "dc3b6485f9d94935329442916b0d059685ba815a1fa2a14107217453a7fc9f0e66266db2ea7c96843f9d8208e600a73f7f45b2f55b9e6d6a7ccf05daae63a3fdd10b25ac0bd2e224ce8291f88c05976d575df998477db86fb2cfbbf91725d62cb57acfeb3c2d973b89b503c2b60dde85a7802b69dc1ac2007d5623cbea8cbfb6b181f5" + }, + { + "input_len": 8, + "hash": "2351207d04fc16ade43ccab08600939c7c1fa70a5c0aaca76063d04c3228eaeb725d6d46ceed8f785ab9f2f9b06acfe398c6699c6129da084cb531177445a682894f9685eaf836999221d17c9a64a3a057000524cd2823986db378b074290a1a9b93a22e135ed2c14c7e20c6d045cd00b903400374126676ea78874d79f2dd7883cf5c", + "keyed_hash": "be2f5495c61cba1bb348a34948c004045e3bd4dae8f0fe82bf44d0da245a060048eb5e68ce6dea1eb0229e144f578b3aa7e9f4f85febd135df8525e6fe40c6f0340d13dd09b255ccd5112a94238f2be3c0b5b7ecde06580426a93e0708555a265305abf86d874e34b4995b788e37a823491f25127a502fe0704baa6bfdf04e76c13276", + "derive_key": "2b166978cef14d9d438046c720519d8b1cad707e199746f1562d0c87fbd32940f0e2545a96693a66654225ebbaac76d093bfa9cd8f525a53acb92a861a98c42e7d1c4ae82e68ab691d510012edd2a728f98cd4794ef757e94d6546961b4f280a51aac339cc95b64a92b83cc3f26d8af8dfb4c091c240acdb4d47728d23e7148720ef04" + }, + { + "input_len": 63, + "hash": "e9bc37a594daad83be9470df7f7b3798297c3d834ce80ba85d6e207627b7db7b1197012b1e7d9af4d7cb7bdd1f3bb49a90a9b5dec3ea2bbc6eaebce77f4e470cbf4687093b5352f04e4a4570fba233164e6acc36900e35d185886a827f7ea9bdc1e5c3ce88b095a200e62c10c043b3e9bc6cb9b6ac4dfa51794b02ace9f98779040755", + "keyed_hash": "bb1eb5d4afa793c1ebdd9fb08def6c36d10096986ae0cfe148cd101170ce37aea05a63d74a840aecd514f654f080e51ac50fd617d22610d91780fe6b07a26b0847abb38291058c97474ef6ddd190d30fc318185c09ca1589d2024f0a6f16d45f11678377483fa5c005b2a107cb9943e5da634e7046855eaa888663de55d6471371d55d", + "derive_key": "b6451e30b953c206e34644c6803724e9d2725e0893039cfc49584f991f451af3b89e8ff572d3da4f4022199b9563b9d70ebb616efff0763e9abec71b550f1371e233319c4c4e74da936ba8e5bbb29a598e007a0bbfa929c99738ca2cc098d59134d11ff300c39f82e2fce9f7f0fa266459503f64ab9913befc65fddc474f6dc1c67669" + }, + { + "input_len": 64, + "hash": "4eed7141ea4a5cd4b788606bd23f46e212af9cacebacdc7d1f4c6dc7f2511b98fc9cc56cb831ffe33ea8e7e1d1df09b26efd2767670066aa82d023b1dfe8ab1b2b7fbb5b97592d46ffe3e05a6a9b592e2949c74160e4674301bc3f97e04903f8c6cf95b863174c33228924cdef7ae47559b10b294acd660666c4538833582b43f82d74", + "keyed_hash": "ba8ced36f327700d213f120b1a207a3b8c04330528586f414d09f2f7d9ccb7e68244c26010afc3f762615bbac552a1ca909e67c83e2fd5478cf46b9e811efccc93f77a21b17a152ebaca1695733fdb086e23cd0eb48c41c034d52523fc21236e5d8c9255306e48d52ba40b4dac24256460d56573d1312319afcf3ed39d72d0bfc69acb", + "derive_key": "a5c4a7053fa86b64746d4bb688d06ad1f02a18fce9afd3e818fefaa7126bf73e9b9493a9befebe0bf0c9509fb3105cfa0e262cde141aa8e3f2c2f77890bb64a4cca96922a21ead111f6338ad5244f2c15c44cb595443ac2ac294231e31be4a4307d0a91e874d36fc9852aeb1265c09b6e0cda7c37ef686fbbcab97e8ff66718be048bb" + }, + { + "input_len": 65, + "hash": "de1e5fa0be70df6d2be8fffd0e99ceaa8eb6e8c93a63f2d8d1c30ecb6b263dee0e16e0a4749d6811dd1d6d1265c29729b1b75a9ac346cf93f0e1d7296dfcfd4313b3a227faaaaf7757cc95b4e87a49be3b8a270a12020233509b1c3632b3485eef309d0abc4a4a696c9decc6e90454b53b000f456a3f10079072baaf7a981653221f2c", + "keyed_hash": "c0a4edefa2d2accb9277c371ac12fcdbb52988a86edc54f0716e1591b4326e72d5e795f46a596b02d3d4bfb43abad1e5d19211152722ec1f20fef2cd413e3c22f2fc5da3d73041275be6ede3517b3b9f0fc67ade5956a672b8b75d96cb43294b9041497de92637ed3f2439225e683910cb3ae923374449ca788fb0f9bea92731bc26ad", + "derive_key": "51fd05c3c1cfbc8ed67d139ad76f5cf8236cd2acd26627a30c104dfd9d3ff8a82b02e8bd36d8498a75ad8c8e9b15eb386970283d6dd42c8ae7911cc592887fdbe26a0a5f0bf821cd92986c60b2502c9be3f98a9c133a7e8045ea867e0828c7252e739321f7c2d65daee4468eb4429efae469a42763f1f94977435d10dccae3e3dce88d" + }, + { + "input_len": 127, + "hash": "d81293fda863f008c09e92fc382a81f5a0b4a1251cba1634016a0f86a6bd640de3137d477156d1fde56b0cf36f8ef18b44b2d79897bece12227539ac9ae0a5119da47644d934d26e74dc316145dcb8bb69ac3f2e05c242dd6ee06484fcb0e956dc44355b452c5e2bbb5e2b66e99f5dd443d0cbcaaafd4beebaed24ae2f8bb672bcef78", + "keyed_hash": "c64200ae7dfaf35577ac5a9521c47863fb71514a3bcad18819218b818de85818ee7a317aaccc1458f78d6f65f3427ec97d9c0adb0d6dacd4471374b621b7b5f35cd54663c64dbe0b9e2d95632f84c611313ea5bd90b71ce97b3cf645776f3adc11e27d135cbadb9875c2bf8d3ae6b02f8a0206aba0c35bfe42574011931c9a255ce6dc", + "derive_key": "c91c090ceee3a3ac81902da31838012625bbcd73fcb92e7d7e56f78deba4f0c3feeb3974306966ccb3e3c69c337ef8a45660ad02526306fd685c88542ad00f759af6dd1adc2e50c2b8aac9f0c5221ff481565cf6455b772515a69463223202e5c371743e35210bbbbabd89651684107fd9fe493c937be16e39cfa7084a36207c99bea3" + }, + { + "input_len": 128, + "hash": "f17e570564b26578c33bb7f44643f539624b05df1a76c81f30acd548c44b45efa69faba091427f9c5c4caa873aa07828651f19c55bad85c47d1368b11c6fd99e47ecba5820a0325984d74fe3e4058494ca12e3f1d3293d0010a9722f7dee64f71246f75e9361f44cc8e214a100650db1313ff76a9f93ec6e84edb7add1cb4a95019b0c", + "keyed_hash": "b04fe15577457267ff3b6f3c947d93be581e7e3a4b018679125eaf86f6a628ecd86bbe0001f10bda47e6077b735016fca8119da11348d93ca302bbd125bde0db2b50edbe728a620bb9d3e6f706286aedea973425c0b9eedf8a38873544cf91badf49ad92a635a93f71ddfcee1eae536c25d1b270956be16588ef1cfef2f1d15f650bd5", + "derive_key": "81720f34452f58a0120a58b6b4608384b5c51d11f39ce97161a0c0e442ca022550e7cd651e312f0b4c6afb3c348ae5dd17d2b29fab3b894d9a0034c7b04fd9190cbd90043ff65d1657bbc05bfdecf2897dd894c7a1b54656d59a50b51190a9da44db426266ad6ce7c173a8c0bbe091b75e734b4dadb59b2861cd2518b4e7591e4b83c9" + }, + { + "input_len": 129, + "hash": "683aaae9f3c5ba37eaaf072aed0f9e30bac0865137bae68b1fde4ca2aebdcb12f96ffa7b36dd78ba321be7e842d364a62a42e3746681c8bace18a4a8a79649285c7127bf8febf125be9de39586d251f0d41da20980b70d35e3dac0eee59e468a894fa7e6a07129aaad09855f6ad4801512a116ba2b7841e6cfc99ad77594a8f2d181a7", + "keyed_hash": "d4a64dae6cdccbac1e5287f54f17c5f985105457c1a2ec1878ebd4b57e20d38f1c9db018541eec241b748f87725665b7b1ace3e0065b29c3bcb232c90e37897fa5aaee7e1e8a2ecfcd9b51463e42238cfdd7fee1aecb3267fa7f2128079176132a412cd8aaf0791276f6b98ff67359bd8652ef3a203976d5ff1cd41885573487bcd683", + "derive_key": "938d2d4435be30eafdbb2b7031f7857c98b04881227391dc40db3c7b21f41fc18d72d0f9c1de5760e1941aebf3100b51d64644cb459eb5d20258e233892805eb98b07570ef2a1787cd48e117c8d6a63a68fd8fc8e59e79dbe63129e88352865721c8d5f0cf183f85e0609860472b0d6087cefdd186d984b21542c1c780684ed6832d8d" + }, + { + "input_len": 1023, + "hash": "10108970eeda3eb932baac1428c7a2163b0e924c9a9e25b35bba72b28f70bd11a182d27a591b05592b15607500e1e8dd56bc6c7fc063715b7a1d737df5bad3339c56778957d870eb9717b57ea3d9fb68d1b55127bba6a906a4a24bbd5acb2d123a37b28f9e9a81bbaae360d58f85e5fc9d75f7c370a0cc09b6522d9c8d822f2f28f485", + "keyed_hash": "c951ecdf03288d0fcc96ee3413563d8a6d3589547f2c2fb36d9786470f1b9d6e890316d2e6d8b8c25b0a5b2180f94fb1a158ef508c3cde45e2966bd796a696d3e13efd86259d756387d9becf5c8bf1ce2192b87025152907b6d8cc33d17826d8b7b9bc97e38c3c85108ef09f013e01c229c20a83d9e8efac5b37470da28575fd755a10", + "derive_key": "74a16c1c3d44368a86e1ca6df64be6a2f64cce8f09220787450722d85725dea59c413264404661e9e4d955409dfe4ad3aa487871bcd454ed12abfe2c2b1eb7757588cf6cb18d2eccad49e018c0d0fec323bec82bf1644c6325717d13ea712e6840d3e6e730d35553f59eff5377a9c350bcc1556694b924b858f329c44ee64b884ef00d" + }, + { + "input_len": 1024, + "hash": "42214739f095a406f3fc83deb889744ac00df831c10daa55189b5d121c855af71cf8107265ecdaf8505b95d8fcec83a98a6a96ea5109d2c179c47a387ffbb404756f6eeae7883b446b70ebb144527c2075ab8ab204c0086bb22b7c93d465efc57f8d917f0b385c6df265e77003b85102967486ed57db5c5ca170ba441427ed9afa684e", + "keyed_hash": "75c46f6f3d9eb4f55ecaaee480db732e6c2105546f1e675003687c31719c7ba4a78bc838c72852d4f49c864acb7adafe2478e824afe51c8919d06168414c265f298a8094b1ad813a9b8614acabac321f24ce61c5a5346eb519520d38ecc43e89b5000236df0597243e4d2493fd626730e2ba17ac4d8824d09d1a4a8f57b8227778e2de", + "derive_key": "7356cd7720d5b66b6d0697eb3177d9f8d73a4a5c5e968896eb6a6896843027066c23b601d3ddfb391e90d5c8eccdef4ae2a264bce9e612ba15e2bc9d654af1481b2e75dbabe615974f1070bba84d56853265a34330b4766f8e75edd1f4a1650476c10802f22b64bd3919d246ba20a17558bc51c199efdec67e80a227251808d8ce5bad" + }, + { + "input_len": 1025, + "hash": "d00278ae47eb27b34faecf67b4fe263f82d5412916c1ffd97c8cb7fb814b8444f4c4a22b4b399155358a994e52bf255de60035742ec71bd08ac275a1b51cc6bfe332b0ef84b409108cda080e6269ed4b3e2c3f7d722aa4cdc98d16deb554e5627be8f955c98e1d5f9565a9194cad0c4285f93700062d9595adb992ae68ff12800ab67a", + "keyed_hash": "357dc55de0c7e382c900fd6e320acc04146be01db6a8ce7210b7189bd664ea69362396b77fdc0d2634a552970843722066c3c15902ae5097e00ff53f1e116f1cd5352720113a837ab2452cafbde4d54085d9cf5d21ca613071551b25d52e69d6c81123872b6f19cd3bc1333edf0c52b94de23ba772cf82636cff4542540a7738d5b930", + "derive_key": "effaa245f065fbf82ac186839a249707c3bddf6d3fdda22d1b95a3c970379bcb5d31013a167509e9066273ab6e2123bc835b408b067d88f96addb550d96b6852dad38e320b9d940f86db74d398c770f462118b35d2724efa13da97194491d96dd37c3c09cbef665953f2ee85ec83d88b88d11547a6f911c8217cca46defa2751e7f3ad" + }, + { + "input_len": 2048, + "hash": "e776b6028c7cd22a4d0ba182a8bf62205d2ef576467e838ed6f2529b85fba24a9a60bf80001410ec9eea6698cd537939fad4749edd484cb541aced55cd9bf54764d063f23f6f1e32e12958ba5cfeb1bf618ad094266d4fc3c968c2088f677454c288c67ba0dba337b9d91c7e1ba586dc9a5bc2d5e90c14f53a8863ac75655461cea8f9", + "keyed_hash": "879cf1fa2ea0e79126cb1063617a05b6ad9d0b696d0d757cf053439f60a99dd10173b961cd574288194b23ece278c330fbb8585485e74967f31352a8183aa782b2b22f26cdcadb61eed1a5bc144b8198fbb0c13abbf8e3192c145d0a5c21633b0ef86054f42809df823389ee40811a5910dcbd1018af31c3b43aa55201ed4edaac74fe", + "derive_key": "7b2945cb4fef70885cc5d78a87bf6f6207dd901ff239201351ffac04e1088a23e2c11a1ebffcea4d80447867b61badb1383d842d4e79645d48dd82ccba290769caa7af8eaa1bd78a2a5e6e94fbdab78d9c7b74e894879f6a515257ccf6f95056f4e25390f24f6b35ffbb74b766202569b1d797f2d4bd9d17524c720107f985f4ddc583" + }, + { + "input_len": 2049, + "hash": "5f4d72f40d7a5f82b15ca2b2e44b1de3c2ef86c426c95c1af0b687952256303096de31d71d74103403822a2e0bc1eb193e7aecc9643a76b7bbc0c9f9c52e8783aae98764ca468962b5c2ec92f0c74eb5448d519713e09413719431c802f948dd5d90425a4ecdadece9eb178d80f26efccae630734dff63340285adec2aed3b51073ad3", + "keyed_hash": "9f29700902f7c86e514ddc4df1e3049f258b2472b6dd5267f61bf13983b78dd5f9a88abfefdfa1e00b418971f2b39c64ca621e8eb37fceac57fd0c8fc8e117d43b81447be22d5d8186f8f5919ba6bcc6846bd7d50726c06d245672c2ad4f61702c646499ee1173daa061ffe15bf45a631e2946d616a4c345822f1151284712f76b2b0e", + "derive_key": "2ea477c5515cc3dd606512ee72bb3e0e758cfae7232826f35fb98ca1bcbdf27316d8e9e79081a80b046b60f6a263616f33ca464bd78d79fa18200d06c7fc9bffd808cc4755277a7d5e09da0f29ed150f6537ea9bed946227ff184cc66a72a5f8c1e4bd8b04e81cf40fe6dc4427ad5678311a61f4ffc39d195589bdbc670f63ae70f4b6" + }, + { + "input_len": 3072, + "hash": "b98cb0ff3623be03326b373de6b9095218513e64f1ee2edd2525c7ad1e5cffd29a3f6b0b978d6608335c09dc94ccf682f9951cdfc501bfe47b9c9189a6fc7b404d120258506341a6d802857322fbd20d3e5dae05b95c88793fa83db1cb08e7d8008d1599b6209d78336e24839724c191b2a52a80448306e0daa84a3fdb566661a37e11", + "keyed_hash": "044a0e7b172a312dc02a4c9a818c036ffa2776368d7f528268d2e6b5df19177022f302d0529e4174cc507c463671217975e81dab02b8fdeb0d7ccc7568dd22574c783a76be215441b32e91b9a904be8ea81f7a0afd14bad8ee7c8efc305ace5d3dd61b996febe8da4f56ca0919359a7533216e2999fc87ff7d8f176fbecb3d6f34278b", + "derive_key": "050df97f8c2ead654d9bb3ab8c9178edcd902a32f8495949feadcc1e0480c46b3604131bbd6e3ba573b6dd682fa0a63e5b165d39fc43a625d00207607a2bfeb65ff1d29292152e26b298868e3b87be95d6458f6f2ce6118437b632415abe6ad522874bcd79e4030a5e7bad2efa90a7a7c67e93f0a18fb28369d0a9329ab5c24134ccb0" + }, + { + "input_len": 3073, + "hash": "7124b49501012f81cc7f11ca069ec9226cecb8a2c850cfe644e327d22d3e1cd39a27ae3b79d68d89da9bf25bc27139ae65a324918a5f9b7828181e52cf373c84f35b639b7fccbb985b6f2fa56aea0c18f531203497b8bbd3a07ceb5926f1cab74d14bd66486d9a91eba99059a98bd1cd25876b2af5a76c3e9eed554ed72ea952b603bf", + "keyed_hash": "68dede9bef00ba89e43f31a6825f4cf433389fedae75c04ee9f0cf16a427c95a96d6da3fe985054d3478865be9a092250839a697bbda74e279e8a9e69f0025e4cfddd6cfb434b1cd9543aaf97c635d1b451a4386041e4bb100f5e45407cbbc24fa53ea2de3536ccb329e4eb9466ec37093a42cf62b82903c696a93a50b702c80f3c3c5", + "derive_key": "72613c9ec9ff7e40f8f5c173784c532ad852e827dba2bf85b2ab4b76f7079081576288e552647a9d86481c2cae75c2dd4e7c5195fb9ada1ef50e9c5098c249d743929191441301c69e1f48505a4305ec1778450ee48b8e69dc23a25960fe33070ea549119599760a8a2d28aeca06b8c5e9ba58bc19e11fe57b6ee98aa44b2a8e6b14a5" + }, + { + "input_len": 4096, + "hash": "015094013f57a5277b59d8475c0501042c0b642e531b0a1c8f58d2163229e9690289e9409ddb1b99768eafe1623da896faf7e1114bebeadc1be30829b6f8af707d85c298f4f0ff4d9438aef948335612ae921e76d411c3a9111df62d27eaf871959ae0062b5492a0feb98ef3ed4af277f5395172dbe5c311918ea0074ce0036454f620", + "keyed_hash": "befc660aea2f1718884cd8deb9902811d332f4fc4a38cf7c7300d597a081bfc0bbb64a36edb564e01e4b4aaf3b060092a6b838bea44afebd2deb8298fa562b7b597c757b9df4c911c3ca462e2ac89e9a787357aaf74c3b56d5c07bc93ce899568a3eb17d9250c20f6c5f6c1e792ec9a2dcb715398d5a6ec6d5c54f586a00403a1af1de", + "derive_key": "1e0d7f3db8c414c97c6307cbda6cd27ac3b030949da8e23be1a1a924ad2f25b9d78038f7b198596c6cc4a9ccf93223c08722d684f240ff6569075ed81591fd93f9fff1110b3a75bc67e426012e5588959cc5a4c192173a03c00731cf84544f65a2fb9378989f72e9694a6a394a8a30997c2e67f95a504e631cd2c5f55246024761b245" + }, + { + "input_len": 4097, + "hash": "9b4052b38f1c5fc8b1f9ff7ac7b27cd242487b3d890d15c96a1c25b8aa0fb99505f91b0b5600a11251652eacfa9497b31cd3c409ce2e45cfe6c0a016967316c426bd26f619eab5d70af9a418b845c608840390f361630bd497b1ab44019316357c61dbe091ce72fc16dc340ac3d6e009e050b3adac4b5b2c92e722cffdc46501531956", + "keyed_hash": "00df940cd36bb9fa7cbbc3556744e0dbc8191401afe70520ba292ee3ca80abbc606db4976cfdd266ae0abf667d9481831ff12e0caa268e7d3e57260c0824115a54ce595ccc897786d9dcbf495599cfd90157186a46ec800a6763f1c59e36197e9939e900809f7077c102f888caaf864b253bc41eea812656d46742e4ea42769f89b83f", + "derive_key": "aca51029626b55fda7117b42a7c211f8c6e9ba4fe5b7a8ca922f34299500ead8a897f66a400fed9198fd61dd2d58d382458e64e100128075fc54b860934e8de2e84170734b06e1d212a117100820dbc48292d148afa50567b8b84b1ec336ae10d40c8c975a624996e12de31abbe135d9d159375739c333798a80c64ae895e51e22f3ad" + }, + { + "input_len": 5120, + "hash": "9cadc15fed8b5d854562b26a9536d9707cadeda9b143978f319ab34230535833acc61c8fdc114a2010ce8038c853e121e1544985133fccdd0a2d507e8e615e611e9a0ba4f47915f49e53d721816a9198e8b30f12d20ec3689989175f1bf7a300eee0d9321fad8da232ece6efb8e9fd81b42ad161f6b9550a069e66b11b40487a5f5059", + "keyed_hash": "2c493e48e9b9bf31e0553a22b23503c0a3388f035cece68eb438d22fa1943e209b4dc9209cd80ce7c1f7c9a744658e7e288465717ae6e56d5463d4f80cdb2ef56495f6a4f5487f69749af0c34c2cdfa857f3056bf8d807336a14d7b89bf62bef2fb54f9af6a546f818dc1e98b9e07f8a5834da50fa28fb5874af91bf06020d1bf0120e", + "derive_key": "7a7acac8a02adcf3038d74cdd1d34527de8a0fcc0ee3399d1262397ce5817f6055d0cefd84d9d57fe792d65a278fd20384ac6c30fdb340092f1a74a92ace99c482b28f0fc0ef3b923e56ade20c6dba47e49227166251337d80a037e987ad3a7f728b5ab6dfafd6e2ab1bd583a95d9c895ba9c2422c24ea0f62961f0dca45cad47bfa0d" + }, + { + "input_len": 5121, + "hash": "628bd2cb2004694adaab7bbd778a25df25c47b9d4155a55f8fbd79f2fe154cff96adaab0613a6146cdaabe498c3a94e529d3fc1da2bd08edf54ed64d40dcd6777647eac51d8277d70219a9694334a68bc8f0f23e20b0ff70ada6f844542dfa32cd4204ca1846ef76d811cdb296f65e260227f477aa7aa008bac878f72257484f2b6c95", + "keyed_hash": "6ccf1c34753e7a044db80798ecd0782a8f76f33563accaddbfbb2e0ea4b2d0240d07e63f13667a8d1490e5e04f13eb617aea16a8c8a5aaed1ef6fbde1b0515e3c81050b361af6ead126032998290b563e3caddeaebfab592e155f2e161fb7cba939092133f23f9e65245e58ec23457b78a2e8a125588aad6e07d7f11a85b88d375b72d", + "derive_key": "b07f01e518e702f7ccb44a267e9e112d403a7b3f4883a47ffbed4b48339b3c341a0add0ac032ab5aaea1e4e5b004707ec5681ae0fcbe3796974c0b1cf31a194740c14519273eedaabec832e8a784b6e7cfc2c5952677e6c3f2c3914454082d7eb1ce1766ac7d75a4d3001fc89544dd46b5147382240d689bbbaefc359fb6ae30263165" + }, + { + "input_len": 6144, + "hash": "3e2e5b74e048f3add6d21faab3f83aa44d3b2278afb83b80b3c35164ebeca2054d742022da6fdda444ebc384b04a54c3ac5839b49da7d39f6d8a9db03deab32aade156c1c0311e9b3435cde0ddba0dce7b26a376cad121294b689193508dd63151603c6ddb866ad16c2ee41585d1633a2cea093bea714f4c5d6b903522045b20395c83", + "keyed_hash": "3d6b6d21281d0ade5b2b016ae4034c5dec10ca7e475f90f76eac7138e9bc8f1dc35754060091dc5caf3efabe0603c60f45e415bb3407db67e6beb3d11cf8e4f7907561f05dace0c15807f4b5f389c841eb114d81a82c02a00b57206b1d11fa6e803486b048a5ce87105a686dee041207e095323dfe172df73deb8c9532066d88f9da7e", + "derive_key": "2a95beae63ddce523762355cf4b9c1d8f131465780a391286a5d01abb5683a1597099e3c6488aab6c48f3c15dbe1942d21dbcdc12115d19a8b8465fb54e9053323a9178e4275647f1a9927f6439e52b7031a0b465c861a3fc531527f7758b2b888cf2f20582e9e2c593709c0a44f9c6e0f8b963994882ea4168827823eef1f64169fef" + }, + { + "input_len": 6145, + "hash": "f1323a8631446cc50536a9f705ee5cb619424d46887f3c376c695b70e0f0507f18a2cfdd73c6e39dd75ce7c1c6e3ef238fd54465f053b25d21044ccb2093beb015015532b108313b5829c3621ce324b8e14229091b7c93f32db2e4e63126a377d2a63a3597997d4f1cba59309cb4af240ba70cebff9a23d5e3ff0cdae2cfd54e070022", + "keyed_hash": "9ac301e9e39e45e3250a7e3b3df701aa0fb6889fbd80eeecf28dbc6300fbc539f3c184ca2f59780e27a576c1d1fb9772e99fd17881d02ac7dfd39675aca918453283ed8c3169085ef4a466b91c1649cc341dfdee60e32231fc34c9c4e0b9a2ba87ca8f372589c744c15fd6f985eec15e98136f25beeb4b13c4e43dc84abcc79cd4646c", + "derive_key": "379bcc61d0051dd489f686c13de00d5b14c505245103dc040d9e4dd1facab8e5114493d029bdbd295aaa744a59e31f35c7f52dba9c3642f773dd0b4262a9980a2aef811697e1305d37ba9d8b6d850ef07fe41108993180cf779aeece363704c76483458603bbeeb693cffbbe5588d1f3535dcad888893e53d977424bb707201569a8d2" + }, + { + "input_len": 7168, + "hash": "61da957ec2499a95d6b8023e2b0e604ec7f6b50e80a9678b89d2628e99ada77a5707c321c83361793b9af62a40f43b523df1c8633cecb4cd14d00bdc79c78fca5165b863893f6d38b02ff7236c5a9a8ad2dba87d24c547cab046c29fc5bc1ed142e1de4763613bb162a5a538e6ef05ed05199d751f9eb58d332791b8d73fb74e4fce95", + "keyed_hash": "b42835e40e9d4a7f42ad8cc04f85a963a76e18198377ed84adddeaecacc6f3fca2f01d5277d69bb681c70fa8d36094f73ec06e452c80d2ff2257ed82e7ba348400989a65ee8daa7094ae0933e3d2210ac6395c4af24f91c2b590ef87d7788d7066ea3eaebca4c08a4f14b9a27644f99084c3543711b64a070b94f2c9d1d8a90d035d52", + "derive_key": "11c37a112765370c94a51415d0d651190c288566e295d505defdad895dae223730d5a5175a38841693020669c7638f40b9bc1f9f39cf98bda7a5b54ae24218a800a2116b34665aa95d846d97ea988bfcb53dd9c055d588fa21ba78996776ea6c40bc428b53c62b5f3ccf200f647a5aae8067f0ea1976391fcc72af1945100e2a6dcb88" + }, + { + "input_len": 7169, + "hash": "a003fc7a51754a9b3c7fae0367ab3d782dccf28855a03d435f8cfe74605e781798a8b20534be1ca9eb2ae2df3fae2ea60e48c6fb0b850b1385b5de0fe460dbe9d9f9b0d8db4435da75c601156df9d047f4ede008732eb17adc05d96180f8a73548522840779e6062d643b79478a6e8dbce68927f36ebf676ffa7d72d5f68f050b119c8", + "keyed_hash": "ed9b1a922c046fdb3d423ae34e143b05ca1bf28b710432857bf738bcedbfa5113c9e28d72fcbfc020814ce3f5d4fc867f01c8f5b6caf305b3ea8a8ba2da3ab69fabcb438f19ff11f5378ad4484d75c478de425fb8e6ee809b54eec9bdb184315dc856617c09f5340451bf42fd3270a7b0b6566169f242e533777604c118a6358250f54", + "derive_key": "554b0a5efea9ef183f2f9b931b7497995d9eb26f5c5c6dad2b97d62fc5ac31d99b20652c016d88ba2a611bbd761668d5eda3e568e940faae24b0d9991c3bd25a65f770b89fdcadabcb3d1a9c1cb63e69721cacf1ae69fefdcef1e3ef41bc5312ccc17222199e47a26552c6adc460cf47a72319cb5039369d0060eaea59d6c65130f1dd" + }, + { + "input_len": 8192, + "hash": "aae792484c8efe4f19e2ca7d371d8c467ffb10748d8a5a1ae579948f718a2a635fe51a27db045a567c1ad51be5aa34c01c6651c4d9b5b5ac5d0fd58cf18dd61a47778566b797a8c67df7b1d60b97b19288d2d877bb2df417ace009dcb0241ca1257d62712b6a4043b4ff33f690d849da91ea3bf711ed583cb7b7a7da2839ba71309bbf", + "keyed_hash": "dc9637c8845a770b4cbf76b8daec0eebf7dc2eac11498517f08d44c8fc00d58a4834464159dcbc12a0ba0c6d6eb41bac0ed6585cabfe0aca36a375e6c5480c22afdc40785c170f5a6b8a1107dbee282318d00d915ac9ed1143ad40765ec120042ee121cd2baa36250c618adaf9e27260fda2f94dea8fb6f08c04f8f10c78292aa46102", + "derive_key": "ad01d7ae4ad059b0d33baa3c01319dcf8088094d0359e5fd45d6aeaa8b2d0c3d4c9e58958553513b67f84f8eac653aeeb02ae1d5672dcecf91cd9985a0e67f4501910ecba25555395427ccc7241d70dc21c190e2aadee875e5aae6bf1912837e53411dabf7a56cbf8e4fb780432b0d7fe6cec45024a0788cf5874616407757e9e6bef7" + }, + { + "input_len": 8193, + "hash": "bab6c09cb8ce8cf459261398d2e7aef35700bf488116ceb94a36d0f5f1b7bc3bb2282aa69be089359ea1154b9a9286c4a56af4de975a9aa4a5c497654914d279bea60bb6d2cf7225a2fa0ff5ef56bbe4b149f3ed15860f78b4e2ad04e158e375c1e0c0b551cd7dfc82f1b155c11b6b3ed51ec9edb30d133653bb5709d1dbd55f4e1ff6", + "keyed_hash": "954a2a75420c8d6547e3ba5b98d963e6fa6491addc8c023189cc519821b4a1f5f03228648fd983aef045c2fa8290934b0866b615f585149587dda2299039965328835a2b18f1d63b7e300fc76ff260b571839fe44876a4eae66cbac8c67694411ed7e09df51068a22c6e67d6d3dd2cca8ff12e3275384006c80f4db68023f24eebba57", + "derive_key": "af1e0346e389b17c23200270a64aa4e1ead98c61695d917de7d5b00491c9b0f12f20a01d6d622edf3de026a4db4e4526225debb93c1237934d71c7340bb5916158cbdafe9ac3225476b6ab57a12357db3abbad7a26c6e66290e44034fb08a20a8d0ec264f309994d2810c49cfba6989d7abb095897459f5425adb48aba07c5fb3c83c0" + }, + { + "input_len": 16384, + "hash": "f875d6646de28985646f34ee13be9a576fd515f76b5b0a26bb324735041ddde49d764c270176e53e97bdffa58d549073f2c660be0e81293767ed4e4929f9ad34bbb39a529334c57c4a381ffd2a6d4bfdbf1482651b172aa883cc13408fa67758a3e47503f93f87720a3177325f7823251b85275f64636a8f1d599c2e49722f42e93893", + "keyed_hash": "9e9fc4eb7cf081ea7c47d1807790ed211bfec56aa25bb7037784c13c4b707b0df9e601b101e4cf63a404dfe50f2e1865bb12edc8fca166579ce0c70dba5a5c0fc960ad6f3772183416a00bd29d4c6e651ea7620bb100c9449858bf14e1ddc9ecd35725581ca5b9160de04060045993d972571c3e8f71e9d0496bfa744656861b169d65", + "derive_key": "160e18b5878cd0df1c3af85eb25a0db5344d43a6fbd7a8ef4ed98d0714c3f7e160dc0b1f09caa35f2f417b9ef309dfe5ebd67f4c9507995a531374d099cf8ae317542e885ec6f589378864d3ea98716b3bbb65ef4ab5e0ab5bb298a501f19a41ec19af84a5e6b428ecd813b1a47ed91c9657c3fba11c406bc316768b58f6802c9e9b57" + }, + { + "input_len": 31744, + "hash": "62b6960e1a44bcc1eb1a611a8d6235b6b4b78f32e7abc4fb4c6cdcce94895c47860cc51f2b0c28a7b77304bd55fe73af663c02d3f52ea053ba43431ca5bab7bfea2f5e9d7121770d88f70ae9649ea713087d1914f7f312147e247f87eb2d4ffef0ac978bf7b6579d57d533355aa20b8b77b13fd09748728a5cc327a8ec470f4013226f", + "keyed_hash": "efa53b389ab67c593dba624d898d0f7353ab99e4ac9d42302ee64cbf9939a4193a7258db2d9cd32a7a3ecfce46144114b15c2fcb68a618a976bd74515d47be08b628be420b5e830fade7c080e351a076fbc38641ad80c736c8a18fe3c66ce12f95c61c2462a9770d60d0f77115bbcd3782b593016a4e728d4c06cee4505cb0c08a42ec", + "derive_key": "39772aef80e0ebe60596361e45b061e8f417429d529171b6764468c22928e28e9759adeb797a3fbf771b1bcea30150a020e317982bf0d6e7d14dd9f064bc11025c25f31e81bd78a921db0174f03dd481d30e93fd8e90f8b2fee209f849f2d2a52f31719a490fb0ba7aea1e09814ee912eba111a9fde9d5c274185f7bae8ba85d300a2b" + }, + { + "input_len": 102400, + "hash": "bc3e3d41a1146b069abffad3c0d44860cf664390afce4d9661f7902e7943e085e01c59dab908c04c3342b816941a26d69c2605ebee5ec5291cc55e15b76146e6745f0601156c3596cb75065a9c57f35585a52e1ac70f69131c23d611ce11ee4ab1ec2c009012d236648e77be9295dd0426f29b764d65de58eb7d01dd42248204f45f8e", + "keyed_hash": "1c35d1a5811083fd7119f5d5d1ba027b4d01c0c6c49fb6ff2cf75393ea5db4a7f9dbdd3e1d81dcbca3ba241bb18760f207710b751846faaeb9dff8262710999a59b2aa1aca298a032d94eacfadf1aa192418eb54808db23b56e34213266aa08499a16b354f018fc4967d05f8b9d2ad87a7278337be9693fc638a3bfdbe314574ee6fc4", + "derive_key": "4652cff7a3f385a6103b5c260fc1593e13c778dbe608efb092fe7ee69df6e9c6d83a3e041bc3a48df2879f4a0a3ed40e7c961c73eff740f3117a0504c2dff4786d44fb17f1549eb0ba585e40ec29bf7732f0b7e286ff8acddc4cb1e23b87ff5d824a986458dcc6a04ac83969b80637562953df51ed1a7e90a7926924d2763778be8560" + } + ] +} diff --git a/src/hpc/mod.rs b/src/hpc/mod.rs index 1c47654f..9cdc3a03 100644 --- a/src/hpc/mod.rs +++ b/src/hpc/mod.rs @@ -52,6 +52,7 @@ pub mod fingerprint; // substrate goes through the `simd_{type}.rs` family per the W1a layering // rule (carriers in simd_soa, slicing/ops in simd_ops). #[allow(missing_docs)] +pub mod blake3; pub mod plane; #[allow(missing_docs)] pub mod seal; From fec25aad6511f6929b8eadb4334d0d76e8aaa013 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:35:59 +0000 Subject: [PATCH 03/11] fix: restore the missing_docs attribute my module declaration stole CI red on seven jobs -- clippy/1.95.0, tests/1.95.0, native-backend/stable, hpc-stream-parallel/rayon, tier4-avx512-check, neon-simd/parity-qemu -- all with missing_docs errors in src/hpc/plane.rs, a file the change never touched. Cause, entirely mechanical and mine: src/hpc/mod.rs line 54 carries #[allow(missing_docs)] for `pub mod plane;`. I inserted `pub mod blake3;` directly after that attribute, which re-targeted it onto the new module and left plane unguarded. One line, seven jobs. Same class as the neon insertion earlier in this arc: adding a line before an item that had an attribute above it. Declaring blake3 separately -- it is fully documented and wants no allow -- with a comment recording the trap so the next insertion does not repeat it. Verified: RUSTFLAGS="-D warnings" cargo clippy -p ndarray --lib now reports 0 errors (was 40+); the 4 blake3 vector tests still pass. Also, two CodeRabbit findings on the same PR: - MD040 on the ordering diagram fence. - The cycle check rested on WEAK EVIDENCE. `cargo tree -p ndarray -i curve25519-dalek` failing with "package ID specification did not match any packages" cannot distinguish "no such edge" from "no such package" -- a mistyped name produces the byte-identical message, demonstrated inline. Replaced with the positive reverse tree (which terminates at `encryption`, root ndarray absent) plus a control showing the same command shape DOES hit for a real edge (`-p ndarray -i blake3`). Applied in both the ladder doc and the EPIPHANIES entry. The evidence correction is the same defect as the vacuous-assertion one, one level up: a check that cannot fail for the reason you believe it is failing. --- .claude/board/EPIPHANIES.md | 22 +++++++++++++++++--- .claude/knowledge/the-simd-ladder.md | 30 +++++++++++++++++++++++++--- src/hpc/mod.rs | 10 +++++++++- 3 files changed, 55 insertions(+), 7 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 91c27675..560396d8 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -16,13 +16,29 @@ package in the workspace pulls it**: | `curve25519-dalek` | `crates/encryption` → `ed25519-dalek` → … | **yes** | | `blake3` | **root `ndarray`** (`std` feature) | **no — cycle** | -Measured, not inferred: +Measured — and measured the right way round. The evidence is the **positive** +reverse tree (which terminates at `encryption`, with root `ndarray` absent), +plus a control proving the method can produce a hit: ```console -$ cargo tree -p ndarray -i curve25519-dalek -error: package ID specification `curve25519-dalek` did not match any packages +$ cargo tree -p encryption -i curve25519-dalek # positive: full path shown +curve25519-dalek v4.1.3 +└── ed25519-dalek v2.2.0 + └── encryption v0.1.0 (crates/encryption) + +$ cargo tree -p ndarray -i blake3 # control: a real edge DOES hit +blake3 v1.8.4 +└── ndarray v0.17.2 (/workspace/ndarray) ``` +**Not** an error message. A first draft rested on +`cargo tree -p ndarray -i curve25519-dalek` failing with `package ID +specification ... did not match any packages`; a typo produces that message +byte-for-byte, so it cannot distinguish "no such edge" from "no such +package". Corrected by CodeRabbit on #268 — and it is the same defect this +repo keeps hitting one level down: a check that cannot fail for the reason +you think it is failing. + Only blake3 is pulled by the ROOT package, so only blake3 closes a loop when it depends back on ndarray. The other two ride a shape that already works in this repo today — `crates/encryption` → `chacha20` → `ndarray(root)` is diff --git a/.claude/knowledge/the-simd-ladder.md b/.claude/knowledge/the-simd-ladder.md index 3e68cef4..487b50eb 100644 --- a/.claude/knowledge/the-simd-ladder.md +++ b/.claude/knowledge/the-simd-ladder.md @@ -30,16 +30,40 @@ ndarray package pulls it**, and this was measured, not assumed: | `curve25519-dalek` | `crates/encryption` → `ed25519-dalek` → `curve25519-dalek` | **no** | | `blake3` | **root `ndarray`** (`std` feature, 11 files in `src/hpc/`) | **YES** | +The evidence is the **positive** reverse tree, not an error message. Asking +for the full reverse-dependency tree of `curve25519-dalek` shows every path +to it, and the tree terminates at `encryption` — root `ndarray` is nowhere in +it: + ```console $ cargo tree -p encryption -i curve25519-dalek curve25519-dalek v4.1.3 └── ed25519-dalek v2.2.0 └── encryption v0.1.0 (crates/encryption) +``` + +**Control — the same command shape does produce a hit when the edge exists**, +so the method can discriminate: + +```console +$ cargo tree -p ndarray -i blake3 +blake3 v1.8.4 +└── ndarray v0.17.2 (/workspace/ndarray) +``` + +An earlier version rested this on `cargo tree -p ndarray -i curve25519-dalek` +returning `error: package ID specification ... did not match any packages`. +That is **weak evidence and was corrected** (CodeRabbit, #268): a mistyped +name produces the byte-identical message — -$ cargo tree -p ndarray -i curve25519-dalek -error: package ID specification `curve25519-dalek` did not match any packages +```console +$ cargo tree -p ndarray -i curve25519-dalekk +error: package ID specification `curve25519-dalekk` did not match any packages ``` +— so the error cannot distinguish "no such edge" from "no such package". Use +the positive tree, and keep a known-hit control beside it. + **Only blake3 has a cycle**, because only blake3 is pulled by the *root* package. `cargo update -p blake3` reports it by naming the chain — the patched `blake3` satisfies ndarray's dependency, and ndarray satisfies blake3's path @@ -165,7 +189,7 @@ already depends on `ndarray`, dalek has no cycle (established above), and the u64 lane is implemented *inside* `ndarray`. None of the three is unblocked by anything 3a does. -``` +```text 3a (cut the cycle) ──> 3b (hash_many throughput) [needs a bench] 4 (chacha20 AVX-512 arm) [independent — needs a bench + a ruling] diff --git a/src/hpc/mod.rs b/src/hpc/mod.rs index 9cdc3a03..ee6250a3 100644 --- a/src/hpc/mod.rs +++ b/src/hpc/mod.rs @@ -52,8 +52,16 @@ pub mod fingerprint; // substrate goes through the `simd_{type}.rs` family per the W1a layering // rule (carriers in simd_soa, slicing/ops in simd_ops). #[allow(missing_docs)] -pub mod blake3; pub mod plane; + +/// In-tree BLAKE3 — see `.claude/knowledge/blake3-in-tree-measured.md`. +/// +/// Fully documented, so deliberately NOT under the `#[allow(missing_docs)]` +/// that guards `plane`. An earlier revision declared it on the line directly +/// after that attribute, which silently re-targeted the attribute onto this +/// module and left `plane` unguarded — seven CI jobs failed on `missing_docs` +/// errors in a file the change never touched. +pub mod blake3; #[allow(missing_docs)] pub mod seal; #[allow(missing_docs)] From 1627b10a12d5de5e71dd7618e12f5dae9e16ac44 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:40:08 +0000 Subject: [PATCH 04/11] blake3: array_chunks fast path -- operator's lead, measured (-21% at 2 KB) Operator hypothesis, citing the blasgraph JIT-gap precedent: the 1.3x gap might be closed by proper use of the existing slice primitives rather than by new SIMD. Worth testing, because the staging path copies every byte TWICE -- input -> self.block -> block_words -- and for a full block that first copy is pure overhead. Implemented as a crate::simd_ops::array_chunks:: walk in ChunkState::update. array_chunks is as_chunks, a pointer cast, so a full block goes input -> words with no intermediate copy. Guarded `input.len() > BLOCK_LEN`, strictly greater. BLAKE3 must not compress a chunk's FINAL block until it knows whether more input follows, because that block carries CHUNK_END; holding back the last <= 64 bytes preserves it. Measured, three runs: 16 B 137 -> 134 ns -- (<= 1 block, never reaches the fast path) 256 B 445 -> 437 ns -- 2 KB 4322 -> 3421 ns -21% 64 KB 115 -> 109 us -5% Verdict: real, and bounded. The double copy was costing ~21% at the mid sizes -- not nothing, and free to remove. It does NOT replace the two structural gaps: inputs of one block or less never reach the fast path, and at 64 KB the copy is noise beside the absent hash_many. Ratio at 2 KB moved 1.34-1.60x to a stable 1.30x; the small-input 1.3x (the crate's SIMD single-compress) and the bulk 4.8x (hash_many) both stand. Correctness gated rather than assumed: the official vectors cover every boundary this turns on -- 63/64/65 (the > BLOCK_LEN guard itself), 127/128/129, and 1023/1024/1025 (the chunk boundary). All 35 cases still pass at both 32-byte and full XOF length. clippy clean under -D warnings; fmt clean. --- .claude/knowledge/blake3-in-tree-measured.md | 42 ++++++++++++++++++-- src/hpc/blake3.rs | 32 +++++++++++++++ 2 files changed, 70 insertions(+), 4 deletions(-) diff --git a/.claude/knowledge/blake3-in-tree-measured.md b/.claude/knowledge/blake3-in-tree-measured.md index ce48f0bc..09d56a18 100644 --- a/.claude/knowledge/blake3-in-tree-measured.md +++ b/.claude/knowledge/blake3-in-tree-measured.md @@ -53,10 +53,44 @@ different flags, so it came free. | input | in-tree | `blake3` crate | ratio | |---|---|---|---| -| 16 B (a word) | 137 ns | 100 ns | **1.34×** | -| 256 B (text) | 445 ns | 350 ns | **1.28×** | -| 2 KB (`VSA_BYTES`) | 4.3 µs | 2.7 µs | **1.34–1.60×** | -| 64 KB (bulk) | 115 µs | 23 µs | **4.6–5.1×** | +| 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::` 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`.** diff --git a/src/hpc/blake3.rs b/src/hpc/blake3.rs index af8a5697..c0bdfcd5 100644 --- a/src/hpc/blake3.rs +++ b/src/hpc/blake3.rs @@ -214,6 +214,38 @@ impl ChunkState { } fn update(&mut self, mut input: &[u8]) { + // Fast path — compress whole blocks straight out of `input`. + // + // The staging path below copies every byte twice: once into + // `self.block`, then again into `block_words`. For a full block that + // first copy is pure overhead. `array_chunks::<64>` walks the input as + // `&[u8; 64]` with no copy at all (it is `as_chunks`, a pointer cast), + // so a full block goes input -> words directly. + // + // The guard is `input.len() > BLOCK_LEN`, strictly greater: BLAKE3 + // must not compress a chunk's FINAL block until it knows whether more + // input follows, because that block carries CHUNK_END. Holding back + // the last <= 64 bytes preserves that, and the official vectors are + // the gate — the 0/1/64/65/1024/1025-byte cases all cross this + // boundary. + if self.block_len == 0 && input.len() > BLOCK_LEN { + let full = (input.len() - 1) / BLOCK_LEN; // never the last block + let (head, tail) = input.split_at(full * BLOCK_LEN); + for block in crate::simd_ops::array_chunks::(head) { + let mut block_words = [0u32; 16]; + words_from_little_endian_bytes(block, &mut block_words); + self.chaining_value = first_8_words(compress( + &self.chaining_value, + &block_words, + self.chunk_counter, + BLOCK_LEN as u32, + self.flags | self.start_flag(), + )); + self.blocks_compressed += 1; + } + input = tail; + } + while !input.is_empty() { // If the block buffer is full, compress it and clear it. More // input is coming, so this compression is not CHUNK_END. From 95b35e931df354ac1b8ef2acf5b3f5d320869f9c Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:40:39 +0000 Subject: [PATCH 05/11] docs: separate the staging-copy term; one earlier guess was wrong The measurement doc guessed "part of the 1.3x is likely the reference-impl choice rather than the SIMD gap -- portable.rs avoids a per-block staging copy -- and that has not been separated out." It is separated now, and the guess was wrong for the case that matters. Removing the staging copy bought 21% at 2 KB and ZERO at <= 1 block, because inputs that small never reach the fast path at all. So the small-input 1.3x is not the staging copy; it is the crate's SIMD single-compress, as originally suspected. Rewrote the deviation note to say that, and narrowed the "not claimed" bullet from "1.3x is unattributed and confounded" to the accurate "the staging-copy term is separated and measured; what remains at small inputs is PRESUMED to be the SIMD single-compress and has not been isolated by building one." Also a `text` fence on the rung diagram -- same MD040 CodeRabbit raised on the sibling doc, fixed here before it is raised again. --- .claude/knowledge/blake3-in-tree-measured.md | 22 +++++++++++++------- 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/.claude/knowledge/blake3-in-tree-measured.md b/.claude/knowledge/blake3-in-tree-measured.md index 09d56a18..00e62e68 100644 --- a/.claude/knowledge/blake3-in-tree-measured.md +++ b/.claude/knowledge/blake3-in-tree-measured.md @@ -106,7 +106,7 @@ the fast path turns on — 63/64/65 (the `> BLOCK_LEN` guard itself), 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 @@ -138,9 +138,16 @@ tag, and it is the operator's. `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. Part of the 1.3× is likely this choice rather than the SIMD - gap — `portable.rs` avoids a per-block staging copy — and that has **not** - been separated out. + 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 @@ -153,8 +160,9 @@ tag, and it is the operator's. - Not that the in-tree version should replace the crate. That is the open decision this document exists to inform. -- Not that 1.3× is attributable to any single cause. The reference-impl - choice and the absent single-compress SIMD are confounded and were not - separated. +- 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. From 712a6a6317a9133c8274c2f8709c456f6a8dc5f6 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 09:59:52 +0000 Subject: [PATCH 06/11] oracle: measure the 512-byte node as a default lane -- viable, zero spill Operator question: what about the synergies of making 4096 bit / 512 byte the default lane, since that is the canonical SoA node size anyway (key(16) | edges(16) | value(480))? Answered by measurement rather than argument. U32x16 is 512 BIT -- one eighth of a node -- so a node-wide unit is [U32x16; 8] = 128 u32 lanes. It cannot be a register type (512 byte is 8 zmm / 16 ymm), so it is necessarily a TILE over the existing lane, exactly as U32x16 is already a polyfill over 2 ymm on avx2. The open question was whether the wider unit costs anything, and the failure mode to watch was spill -- 128 u32 against 16 architectural registers. Two probes, same work, asserted bit-for-bit identical in the driver: arx_node4096 98 packed / 0 scalar / 0 memory arx_lane512_x8 81 packed / 0 scalar / 0 memory The ARITHMETIC IS IDENTICAL: 16 vpaddd + 16 vpxor + 16 vpshufb + 33 vmovdqa in both. The 17-instruction delta is 16 vmovaps + 1 vxorps -- the dead zero-init of [U32x16::splat(0); 8] in the loop form, which an implementation building the array by value does not emit. Neither form spills. So the node width is free, and the discriminating property is LIVENESS rather than width: streaming / elementwise (ARX, bitwise, add-mul) each vector independent, never all live -> 0 spill whole-node-live (a transpose) all live at once -> transpose_16x16_composed pays 41 memory ops That is the rule to design a node-wide default unit against: viable for streaming work; the ops that must stay lane-width are the ones needing the whole node resident. Also widens the blake3 A/B bench to the sizes this substrate actually hashes -- 480 B (the node's value region) and 512 B (the node itself) sat in the gap between the previous 256 and 2048 samples. At the canonical node size the in-tree BLAKE3 is 1.28x (848 ns vs 664 ns), and the 4.8x bulk gap is irrelevant there because 512 B never reaches hash_many. That reframes the swap decision from a curve into a single number. --- .../knowledge/blake3-ab-bench/blake3_ab.rs | 11 +++- .../baseline-x86_64-v3.toml | 12 ++++ .../knowledge/simd-codegen-oracle/probes.rs | 62 +++++++++++++++++++ .claude/knowledge/the-simd-ladder.md | 38 ++++++++++++ 4 files changed, 121 insertions(+), 2 deletions(-) diff --git a/.claude/knowledge/blake3-ab-bench/blake3_ab.rs b/.claude/knowledge/blake3-ab-bench/blake3_ab.rs index d838a629..5c01c310 100644 --- a/.claude/knowledge/blake3-ab-bench/blake3_ab.rs +++ b/.claude/knowledge/blake3-ab-bench/blake3_ab.rs @@ -14,8 +14,15 @@ fn bench [u8; 32]>(name: &str, iters: u32, mut f: F) -> f64 { } fn main() { - // 16 B ~ a word (crystal_encoder); 256 B ~ text; 2 KB ~ VSA_BYTES; 64 KB ~ bulk. - for &n in &[16usize, 256, 2048, 65536] { + // 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 = (0..n).map(|i| (i % 251) as u8).collect(); let iters = if n >= 65536 { 2_000 } else { 50_000 }; println!(" input = {n} B"); diff --git a/.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml b/.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml index 975d3b59..2fbdebae 100644 --- a/.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml +++ b/.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml @@ -156,3 +156,15 @@ note = "MEASURED FULLY VECTORIZED, and it closes the Group-D question. Observed [probe.transpose_16x16_composed] expect = "unknown" note = "MEASURED VECTORIZED, with a genuine shuffle network. Observed 79 packed / 0 scalar-lane-arith / 38 loop-control / 41 memory. The 79 packed split into 60 vector moves and 19 real shuffle/permute instructions: vinserti128 x7, vpshufd x3, vpblendd x3, vpunpcklqdq x2, vpermq x2, vunpcklpd x1, vpermpd x1 -- covering all four granularities the network needs, including the 64-bit unpack and the 256-bit cross-lane permute that transpose_stage_u32x16 never exercised. Correctness is CHECKED, not assumed: the driver compares the result against a naive nested-loop transpose and aborts on mismatch, and run.sh now executes the probe binary (--emit asm links nothing, so the assertion would otherwise never run). The check was control-tested by deleting stage::<8>, which makes it fire (exit 101). Honest caveats: unlike the single-stage probe this is NOT clean straight-line code -- LLVM kept loop structure (38 loop-control) and there is real spill traffic (41 memory), which is expected, since sixteen 512-bit vectors of live state are 32 ymm registers against 16 architectural ones. A hand-written intrinsic backend faces the same register pressure. So this measurement establishes that the shuffle network is SYNTHESIZED rather than degraded to scalar copies; it does NOT establish throughput parity with rust_avx2.rs, which needs a benchmark that has not been run." + +# ============================================================================ +# Group E -- UNKNOWN. Is the 4096-bit / 512-byte node a viable DEFAULT lane? +# ============================================================================ + +[probe.arx_node4096] +expect = "unknown" +note = "MEASURED FULLY PACKED, ZERO SPILL. Observed 98 packed / 0 scalar-lane-arith / 0 loop-control / 0 memory. The arithmetic is IDENTICAL to the 8x-lane baseline below: 16 vpaddd + 16 vpxor + 16 vpshufb + 33 vmovdqa in both. The 17-instruction delta (16 vmovaps + 1 vxorps) is the dead zero-init of `[U32x16::splat(0); 8]` in the loop form, not a cost of the wider unit -- a real implementation building the array by value emits neither. Crucially the memory column is ZERO: 128 u32 of live state does NOT spill, because the ARX triple is STREAMING (each of the 8 vectors is independent, so LLVM never needs all 128 lanes live at once). Contrast transpose_16x16_composed, which needs all 16 vectors live simultaneously and pays 41 memory ops. That is the discriminating property for node-wide work: free for elementwise/streaming ops, expensive for whole-node-live ops." + +[probe.arx_lane512_x8] +expect = "unknown" +note = "MEASURED FULLY PACKED. Observed 81 packed / 0 scalar-lane-arith / 0 loop-control / 0 memory: 16 vpaddd + 16 vpxor + 16 vpshufb + 33 vmovdqa. Bit-for-bit identical output to arx_node4096 (asserted in the driver), and identical arithmetic in the emitted code -- so the node-wide unit costs nothing structurally over eight applications of the 512-bit lane." diff --git a/.claude/knowledge/simd-codegen-oracle/probes.rs b/.claude/knowledge/simd-codegen-oracle/probes.rs index f26807b5..362c21da 100644 --- a/.claude/knowledge/simd-codegen-oracle/probes.rs +++ b/.claude/knowledge/simd-codegen-oracle/probes.rs @@ -493,6 +493,52 @@ pub fn transpose_16x16_composed(m: [U32x16; 16]) -> [U32x16; 16] { m } +// ============================================================================ +// Group E — UNKNOWN. Is 4096 bit / 512 byte a viable DEFAULT lane? +// ============================================================================ +// The substrate's canonical node is 4096 bit = 512 byte +// (`key(16) | edges(16) | value(480)`). `U32x16` is 512 BIT — one eighth of +// it. So the question is whether a node-wide unit, `[U32x16; 8]` = 128 u32 +// lanes, stays fully packed and amortizes better than looping eight times +// over `U32x16`. +// +// It cannot be a register type: 512 byte is 8 zmm / 16 ymm, so a node-wide +// lane is necessarily a TILE over the existing lane, exactly as `U32x16` is +// already a polyfill over 2 ymm on avx2. The question is therefore not "does +// it fit" but "does the wider unit cost anything" — if the 8x form emits the +// same instruction count as 8 separate ops with no extra spill, the node +// width is free and can be the default unit; if it spills, it cannot. +// +// 128 u32 of live state is 8 zmm or 16 ymm against 16 architectural +// registers, so spill is the thing to watch — the same pressure that showed +// up as 41 memory ops in `transpose_16x16_composed`. + +/// One ARX triple over a NODE-WIDE unit: `[U32x16; 8]` = 4096 bit = 512 byte. +#[inline(never)] +pub fn arx_node4096(a: [U32x16; 8], b: [U32x16; 8]) -> [U32x16; 8] { + let mut out = [U32x16::splat(0); 8]; + for i in 0..8 { + out[i] = ((a[i] + b[i]) ^ b[i]).rotate_left(16); + } + out +} + +/// The SAME work as `arx_node4096`, expressed as the existing 512-bit lane +/// applied eight times by the caller — the baseline it must not lose to. +#[inline(never)] +pub fn arx_lane512_x8(a: [U32x16; 8], b: [U32x16; 8]) -> [U32x16; 8] { + [ + ((a[0] + b[0]) ^ b[0]).rotate_left(16), + ((a[1] + b[1]) ^ b[1]).rotate_left(16), + ((a[2] + b[2]) ^ b[2]).rotate_left(16), + ((a[3] + b[3]) ^ b[3]).rotate_left(16), + ((a[4] + b[4]) ^ b[4]).rotate_left(16), + ((a[5] + b[5]) ^ b[5]).rotate_left(16), + ((a[6] + b[6]) ^ b[6]).rotate_left(16), + ((a[7] + b[7]) ^ b[7]).rotate_left(16), + ] +} + // ============================================================================ // Driver — runtime-derived inputs, every result consumed. // ============================================================================ @@ -650,5 +696,21 @@ fn main() { } acc ^= composed.iter().fold(0u64, |s, v| s ^ v.reduce_sum() as u64); + // ---- Group E ---- + let node_a: [U32x16; 8] = std::array::from_fn(|_| rand_u32x16()); + let node_b: [U32x16; 8] = std::array::from_fn(|_| rand_u32x16()); + let n1 = arx_node4096(black_box(node_a), black_box(node_b)); + let n2 = arx_lane512_x8(black_box(node_a), black_box(node_b)); + // Same work, so the two forms must agree bit-for-bit. + for i in 0..8 { + assert_eq!( + n1[i].to_array(), + n2[i].to_array(), + "arx_node4096 != arx_lane512_x8 at vector {i}" + ); + } + acc ^= n1.iter().fold(0u64, |s, v| s ^ v.reduce_sum() as u64); + acc ^= n2.iter().fold(0u64, |s, v| s ^ v.reduce_sum() as u64); + println!("simd-codegen-oracle: probes executed, combined checksum = {acc:#018x}"); } diff --git a/.claude/knowledge/the-simd-ladder.md b/.claude/knowledge/the-simd-ladder.md index 487b50eb..8f0e08fa 100644 --- a/.claude/knowledge/the-simd-ladder.md +++ b/.claude/knowledge/the-simd-ladder.md @@ -177,6 +177,44 @@ argon2 needs BLAKE2b. Nothing is built on it. --- +## Is 4096 bit / 512 byte viable as the DEFAULT unit? — MEASURED, yes + +The substrate's canonical node is 4096 bit = 512 byte +(`key(16) | edges(16) | value(480)`). `U32x16` is 512 **bit** — one eighth of +it. So: does a node-wide unit, `[U32x16; 8]` = 128 u32 lanes, stay packed, or +does it drown in spill? + +It cannot be a register type — 512 byte is 8 zmm / 16 ymm — so a node-wide +lane is necessarily a **tile** over the existing lane, exactly as `U32x16` is +already a polyfill over 2 ymm on avx2. The real question is whether the wider +unit costs anything. + +Measured (`arx_node4096` vs `arx_lane512_x8`, same work, asserted bit-for-bit +identical in the driver): + +| form | packed | scalar | **memory** | arithmetic emitted | +|---|---|---|---|---| +| `[U32x16; 8]` node-wide | 98 | 0 | **0** | 16 `vpaddd`, 16 `vpxor`, 16 `vpshufb`, 33 `vmovdqa` | +| `U32x16` × 8 | 81 | 0 | **0** | *identical* | + +**The arithmetic is the same instruction-for-instruction, and neither spills.** +The 17-instruction delta is `16 vmovaps + 1 vxorps` — the dead zero-init of +`[U32x16::splat(0); 8]` in the loop form, which an implementation building the +array by value does not emit. + +**So the node width is free — for the right shape of work.** The +discriminating property is *liveness*, not width: + +- **Streaming / elementwise** (ARX, bitwise, add-mul): each of the 8 vectors + is independent, LLVM processes them one at a time, and 128 lanes of state + never need to be live simultaneously. **Zero spill.** +- **Whole-node-live** (a transpose): all vectors must be live at once. + `transpose_16x16_composed` pays 41 memory ops for exactly this reason. + +That is the rule to design against: a node-wide default unit is viable, and +the ops that must stay lane-width are the ones that need the whole node +resident. + ## Order **There is almost no ordering.** An earlier version drew arrows from 3a to From 7ff83e6be7783958f36b5fefbb3ca430bb7fef0a Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:04:45 +0000 Subject: [PATCH 07/11] oracle: confirm the u64 rotate refusal is the OPERATION, not the idiom Rung 6 of the ladder rests on one measurement: a scalar u64 rotate loop does not vectorize. That used a single source form -- u64::rotate_right(n) -- which leaves an obvious objection open. Maybe LLVM declines the rotate IDIOM at 64-bit width and an explicit shift-or would vectorize, exactly as it does for u32's rotate_left(12)/(7). AVX2 has vpsllq/vpsrlq, so the ingredients exist. Tested. It does not. rot_u64x8 u64::rotate_right(n) runtime 0 packed shiftor_rot_u64x8 (x >> n) | (x << (64-n)) runtime 0 packed shiftor_rot_const_u64x8 explicit shift-or CONST 0 packed The third row is the sharp one. At u32 width a constant amount vectorizes two different ways depending on granularity -- byte-granular to vpshufb, bit-granular to the shift-or triple -- and both are packed. At u64 width neither happens, for either kind of constant. A fourth confirmation arrived unasked. A blake2b_g_shiftor_u64x8 probe was written as the direct counterpart to blake2b_g_u64x8; it never appeared in the emitted assembly. LLVM FOLDED the two functions -- zero mentions of the shiftor symbol, two call sites on the survivor -- proving the spellings are byte-identical rather than merely both-scalar. Removed rather than kept as a permanently-failing row: a probe the compiler cannot distinguish from its own control measures nothing. The removal and its reason are recorded in the probe file and the baseline so the next reader does not re-add it. Net: rung 6 is confirmed as the crate's one earned intrinsic override, now on three independent spellings instead of one. --- .claude/knowledge/crypto-lane-status.md | 29 ++++++++ .../baseline-x86_64-v3.toml | 22 ++++++ .../knowledge/simd-codegen-oracle/probes.rs | 70 +++++++++++++++++++ 3 files changed, 121 insertions(+) diff --git a/.claude/knowledge/crypto-lane-status.md b/.claude/knowledge/crypto-lane-status.md index b789e737..ecad1823 100644 --- a/.claude/knowledge/crypto-lane-status.md +++ b/.claude/knowledge/crypto-lane-status.md @@ -86,6 +86,35 @@ Two things make this a genuine finding rather than a shrug: and rotate-by-7. It has the tools and declines to apply them at 64-bit width. +### Confirmed a second and third way: it is the OPERATION, not the idiom + +The measurement above used one source form — `u64::rotate_right(n)`. That +leaves an obvious objection: maybe LLVM declines the *rotate idiom* at 64-bit +width, and an explicit shift-or would vectorize, exactly as it does for u32's +`rotate_left(12)`/`(7)`. AVX2 has `vpsllq`/`vpsrlq`, so the ingredients exist. + +Tested. It does not. + +| probe | spelling | amount | packed | +|---|---|---|---| +| `rot_u64x8` | `u64::rotate_right(n)` | runtime | **0** | +| `shiftor_rot_u64x8` | `(x >> n) \| (x << (64-n))` | runtime | **0** | +| `shiftor_rot_const_u64x8` | explicit shift-or | **const** 32/24/16/63 | **0** | + +The third row is the sharp one. At u32 width a *constant* amount vectorizes +two different ways depending on granularity — byte-granular folds to +`vpshufb`, bit-granular to the shift-or triple — and both are packed. At u64 +width neither happens, for either kind of constant. + +**And a fourth confirmation arrived unasked.** A `blake2b_g_shiftor_u64x8` +probe was written as the direct counterpart to `blake2b_g_u64x8`. It never +appeared in the emitted assembly: LLVM **folded the two functions**, leaving +zero mentions of the shiftor symbol and two call sites pointing at the +survivor. So the two spellings are not merely both-scalar — they are +*byte-identical*. The probe was removed rather than kept as a permanently +failing row, since a probe the compiler cannot distinguish from its own +control measures nothing. + **So the u64 ARX lane is the crate's first intrinsic override that meets the entry criterion** (a probe proving the generic form fails). AVX-512: `_mm512_rorv_epi64` / `VPROLVQ`, one instruction. AVX2 / NEON / wasm: write diff --git a/.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml b/.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml index 2fbdebae..bc5929b0 100644 --- a/.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml +++ b/.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml @@ -168,3 +168,25 @@ note = "MEASURED FULLY PACKED, ZERO SPILL. Observed 98 packed / 0 scalar-lane-ar [probe.arx_lane512_x8] expect = "unknown" note = "MEASURED FULLY PACKED. Observed 81 packed / 0 scalar-lane-arith / 0 loop-control / 0 memory: 16 vpaddd + 16 vpxor + 16 vpshufb + 33 vmovdqa. Bit-for-bit identical output to arx_node4096 (asserted in the driver), and identical arithmetic in the emitted code -- so the node-wide unit costs nothing structurally over eight applications of the 512-bit lane." + +# Group C, continued -- the u64 rotate written as an EXPLICIT shift-or rather +# than as `u64::rotate_right`. Group C above tested one SOURCE FORM; this tests +# whether LLVM declines the rotate IDIOM at 64-bit width or declines the +# OPERATION. Decides whether ladder rung 6 needs an intrinsic override. +# +# ANSWER: it declines the OPERATION. Both spellings come back 0 packed, for +# runtime-variable AND compile-time-constant amounts. A third probe +# (blake2b_g_shiftor_u64x8) was written and removed: LLVM FOLDED it into +# blake2b_g_u64x8 -- zero mentions of the shiftor symbol in the emitted asm, +# two call sites on the survivor -- proving the two spellings are byte-identical +# rather than merely both-scalar. A probe the compiler cannot distinguish from +# its own control measures nothing. + +[probe.shiftor_rot_u64x8] +expect = "unknown" +note = "MEASURED SCALAR -- 0 packed / 8 scalar-lane-arith / 19 memory, i.e. indistinguishable from rot_u64x8. Writing the rotate as an explicit (x >> n) | (x << (64-n)) does NOT persuade LLVM to vectorize it, so the refusal is about the 64-bit OPERATION and not about the rotate idiom. Driver asserts equality with u64::rotate_right, so this is the same function in a different spelling." + +[probe.shiftor_rot_const_u64x8] +expect = "unknown" +note = "MEASURED SCALAR -- 0 packed / 8 scalar-lane-arith / 8 memory, with BLAKE2b's COMPILE-TIME-CONSTANT 32/24/16/63. This is the sharper half: at u32 width a constant byte-granular amount folds to vpshufb and a constant bit-granular one folds to shift-or, both packed. At u64 width neither happens, for either kind of constant. A third independent confirmation that the u64 lane is the crate's one earned intrinsic override." + diff --git a/.claude/knowledge/simd-codegen-oracle/probes.rs b/.claude/knowledge/simd-codegen-oracle/probes.rs index 362c21da..c4cf6ba7 100644 --- a/.claude/knowledge/simd-codegen-oracle/probes.rs +++ b/.claude/knowledge/simd-codegen-oracle/probes.rs @@ -493,6 +493,51 @@ pub fn transpose_16x16_composed(m: [U32x16; 16]) -> [U32x16; 16] { m } +/// The u64 rotate written as an EXPLICIT shift-or, not as `u64::rotate_right`. +/// +/// Group C measured that `u64::rotate_right(n)` lowers to a scalar `rorq` per +/// lane — 0 packed. But that tested one SOURCE FORM. LLVM already vectorizes +/// u32's `rotate_left(12)`/`(7)` as `vpslld`+`vpsrld`+`vpor`, and AVX2 has the +/// 64-bit equivalents `vpsllq`/`vpsrlq`. So the open question is whether LLVM +/// declines the *rotate idiom* at 64-bit width, or declines the *operation*. +/// +/// If the explicit form vectorizes, rung 6 of the ladder needs no intrinsic +/// override at all — just a differently-spelled body, exactly as rung 2 needed +/// only index loops. If it does not, rung 6 is the crate's first genuinely +/// earned `unsafe` intrinsic. +/// +/// `n` is constrained to `1..=63` by the caller: `x >> 64` is UB on `u64`, so +/// the zero case must be excluded rather than masked, matching how +/// `simd_nightly`'s `U32x16::rotate_left` guards `n % 32 == 0`. +#[inline(always)] +fn shiftor_rotr_u64x8(v: U64x8, n: u32) -> U64x8 { + let a = v.to_array(); + let mut out = [0u64; 8]; + for i in 0..8 { + out[i] = (a[i] >> n) | (a[i] << (64 - n)); + } + U64x8::from_array(out) +} + +/// Explicit shift-or u64 rotate, runtime-variable amount, 8 lanes. +#[inline(never)] +pub fn shiftor_rot_u64x8(v: U64x8, n: u32) -> U64x8 { + shiftor_rotr_u64x8(v, n) +} + +/// Explicit shift-or u64 rotate with COMPILE-TIME-CONSTANT amounts — BLAKE2b's +/// 32/24/16/63. Separated from the runtime-variable probe above because the +/// u32 lane vectorizes constants and variables differently (`vpshufb` for +/// byte-granular constants, shift-or otherwise), so the two cases must be +/// distinguished rather than averaged. +#[inline(never)] +pub fn shiftor_rot_const_u64x8(v: U64x8) -> U64x8 { + let a = shiftor_rotr_u64x8(v, 32); + let b = shiftor_rotr_u64x8(a, 24); + let c = shiftor_rotr_u64x8(b, 16); + shiftor_rotr_u64x8(c, 63) +} + // ============================================================================ // Group E — UNKNOWN. Is 4096 bit / 512 byte a viable DEFAULT lane? // ============================================================================ @@ -651,6 +696,31 @@ fn main() { ); acc ^= ga.reduce_sum() ^ gb.reduce_sum() ^ gc.reduce_sum() ^ gd.reduce_sum(); + // ---- Group C, explicit shift-or form ---- + let sv = U64x8::from_array(std::array::from_fn(|_| rng.next())); + let sn = 1 + (rng.next() % 63) as u32; + // The explicit form must agree with `u64::rotate_right` -- same function, + // different spelling. A codegen probe that measured a DIFFERENT function + // would be worthless. + let want: [u64; 8] = { + let a = sv.to_array(); + std::array::from_fn(|i| a[i].rotate_right(sn)) + }; + let got = shiftor_rot_u64x8(black_box(sv), black_box(sn)); + assert_eq!(got.to_array(), want, "shift-or rotate != u64::rotate_right"); + acc ^= got.reduce_sum(); + + acc ^= shiftor_rot_const_u64x8(black_box(sv)).reduce_sum(); + + // NOTE: a `blake2b_g_shiftor_u64x8` probe was written here and REMOVED, + // because LLVM folded it into `blake2b_g_u64x8` -- identical machine code, + // zero mentions of the shiftor symbol in the emitted asm, and two call + // sites pointing at the surviving one. That fold is the result: the + // explicit shift-or spelling and `u64::rotate_right` are not merely both + // scalar, they are BYTE-IDENTICAL. A probe the compiler cannot tell apart + // from its own control measures nothing, so it is gone rather than kept + // as a permanently-failing row. + // ---- Group D ---- let mut rand_u32x16 = || U32x16::from_array(std::array::from_fn(|_| rng.next() as u32)); let (b3a, b3b, b3c, b3d) = blake3_g_u32x16( From a83ab6b8f9487413030c977856aa67f37fa43e68 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:13:43 +0000 Subject: [PATCH 08/11] =?UTF-8?q?simd:=20U64x8=20ARX=20rotate=20=E2=80=94?= =?UTF-8?q?=20the=20ladder's=20one=20earned=20intrinsic=20override?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rung 6 of the SIMD ladder. `rotate_left`/`rotate_right` did not exist on `U64x8` on any backend, and the codegen oracle measured that a scalar u64 rotate loop does NOT vectorize: 0 packed, one `rorq` per lane, across three independent spellings (`u64::rotate_right`, an explicit shift-or with a runtime amount, and the same with BLAKE2b's compile-time constants 32/24/16/63). LLVM folded two of the probes into byte-identical code, which says it declines the 64-bit *operation* rather than the rotate *idiom* — and it does emit `vpsllq`/`vpsrlq` for the u32 lane on the same target. That is the entry criterion in simd-one-spec-design.md, and this is the only place in the crate that currently meets it. Contrast rung 1, where hand-written u32 intrinsics LOSE to the optimizer (.claude/knowledge/td-t22-asm-investigation.md). What each backend actually got — stated plainly, because only one of them is the override: - avx512: native `_mm512_rolv_epi64` / `_mm512_rorv_epi64` on the real `__m512i`. This is the earned intrinsic. - avx2, scalar, nightly: correct-but-unvectorized per-lane loops. These are NOT the explicit shift-or; they are the fallback, and the measured cost is known rather than overlooked. neon and wasm re-export the scalar `U64x8` and are covered by that arm. `n` is taken mod 64 and `n == 0` short-circuits, because `x >> 64` is UB on `u64` and `VPROLVQ` takes its own count mod 64 — so the two agree at every input rather than only in the interior. Test `u64x8_arx_rotate_matches_scalar` covers 0/1/7/16/24/31/32/63/64, asserts `rotr(n) == rotl(64-n)` and round-trip identity. Control-tested: with `rotate_right`'s body flipped to `rotate_left` the test fails, so it discriminates rather than passing vacuously. Nothing consumes this yet. BLAKE2b needs it and argon2 needs BLAKE2b; that is the next rung, not this commit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VdfbkUCBbtZhy3yjSfCDHp --- src/simd.rs | 47 +++++++++++++++++++++++++++++++ src/simd_avx2.rs | 48 ++++++++++++++++++++++++++++++++ src/simd_avx512.rs | 46 ++++++++++++++++++++++++++++++ src/simd_nightly/u_word_types.rs | 42 ++++++++++++++++++++++++++++ src/simd_scalar.rs | 48 ++++++++++++++++++++++++++++++++ 5 files changed, 231 insertions(+) diff --git a/src/simd.rs b/src/simd.rs index 04a2735c..e3f0ad48 100644 --- a/src/simd.rs +++ b/src/simd.rs @@ -743,6 +743,53 @@ mod tests { } } + /// The u64 ARX rotate — `U64x8::rotate_left` / `rotate_right`, the lane + /// BLAKE2b (and therefore argon2) needs. + /// + /// Runs on whichever tier this build compiled, so it gates the native + /// AVX-512 `VPROLVQ`/`VPRORVQ` override and the portable scalar arms + /// against the *same* reference — per-lane `u64::rotate_left`. That + /// matters more here than for the u32 lane, because unlike every other + /// lane-wise op in this crate the two implementations are genuinely + /// different code, not one source form compiled twice. + /// + /// Covers BLAKE2b's own amounts (32/24/16/63) plus the edges, and asserts + /// `rotr(n) == rotl(64 - n)` for each — the identity the two methods' + /// equivalence rests on. + #[test] + fn u64x8_arx_rotate_matches_scalar() { + use super::U64x8; + + let a_arr: [u64; 8] = [ + 0x0123_4567_89AB_CDEF, 0xFFFF_FFFF_FFFF_FFFF, 0x0000_0000_0000_0001, 0x8000_0000_0000_0000, + 0xDEAD_BEEF_CAFE_BABE, 0x0000_0000_FFFF_FFFF, 0xAAAA_AAAA_5555_5555, 0x0F0F_0F0F_F0F0_F0F0, + ]; + let a = U64x8::from_array(a_arr); + + // BLAKE2b uses 32/24/16/63; 0 and 63 are the edges, and 64 must wrap + // to the identity rather than shifting by the full width (UB on u64). + for n in [0u32, 1, 7, 16, 24, 31, 32, 63, 64] { + let l = a.rotate_left(n).to_array(); + let r = a.rotate_right(n).to_array(); + for i in 0..8 { + assert_eq!(l[i], a_arr[i].rotate_left(n), "lane {i} rotate_left({n})"); + assert_eq!(r[i], a_arr[i].rotate_right(n), "lane {i} rotate_right({n})"); + } + // rotr(n) == rotl(64 - n): the identity the pair rests on. + let back = a.rotate_left((64 - (n % 64)) % 64).to_array(); + assert_eq!(r, back, "rotate_right({n}) != rotate_left(64 - {n})"); + } + + // Round-trip: rotating out and back is the identity for every amount. + for n in [1u32, 16, 24, 32, 63] { + assert_eq!( + a.rotate_left(n).rotate_right(n).to_array(), + a_arr, + "rotate_left({n}) then rotate_right({n}) is not the identity" + ); + } + } + /// The BLAKE3 shuffle surface on `U32x16`, checked against the REAL x86 /// intrinsics it reproduces — applied to each 256-bit half. /// diff --git a/src/simd_avx2.rs b/src/simd_avx2.rs index b805eee6..cd3b1e3a 100644 --- a/src/simd_avx2.rs +++ b/src/simd_avx2.rs @@ -1542,6 +1542,54 @@ avx2_int_type!(U16x32, u16, 32, 0u16); avx2_int_type!(U32x16, u32, 16, 0u32); avx2_int_type!(U64x8, u64, 8, 0u64); +/// u64 ARX rotate — the BLAKE2b / argon2 lane. +/// +/// Scalar per-lane loops, and **measured not to vectorize**: the codegen +/// oracle tried three spellings (`u64::rotate_right`, an explicit shift-or +/// with a runtime amount, and the same with BLAKE2b's constants 32/24/16/63) +/// and every one came back 0 packed, one `rorq` per lane. LLVM declines the +/// 64-bit *operation*, not the rotate *idiom* — it folded two of the probes +/// into byte-identical code. +/// +/// So unlike every other lane-wise op in this crate, the scalar spec is NOT +/// the implementation here. The native `VPROLVQ`/`VPRORVQ` override lives on +/// `simd_avx512`'s `U64x8`, which is a real `__m512i`; these arms are the +/// correct-but-unvectorized fallback, and that is a known cost rather than an +/// oversight. See `.claude/knowledge/crypto-lane-status.md`. +impl U64x8 { + /// Lane-wise left-rotate by `n` bits. `n` is taken mod 64. + #[inline(always)] + pub fn rotate_left(self, n: u32) -> Self { + let n = n % 64; + if n == 0 { + return self; + } + let a = self.to_array(); + let mut o = [0u64; 8]; + for i in 0..8 { + o[i] = a[i].rotate_left(n); + } + Self::from_array(o) + } + + /// Lane-wise right-rotate by `n` bits — BLAKE2b's direction. + /// `rotr(n) == rotl(64 - n)` exactly; kept distinct because BLAKE2b and + /// argon2 are specified in terms of right rotation. + #[inline(always)] + pub fn rotate_right(self, n: u32) -> Self { + let n = n % 64; + if n == 0 { + return self; + } + let a = self.to_array(); + let mut o = [0u64; 8]; + for i in 0..8 { + o[i] = a[i].rotate_right(n); + } + Self::from_array(o) + } +} + /// BLAKE3 `hash_many` shuffle surface — the transpose network's four unpacks /// and two half-concatenations, at DEGREE 16. /// diff --git a/src/simd_avx512.rs b/src/simd_avx512.rs index c9eba078..8411d4db 100644 --- a/src/simd_avx512.rs +++ b/src/simd_avx512.rs @@ -1636,6 +1636,52 @@ pub struct U64x8(pub __m512i); impl U64x8 { pub const LANES: usize = 8; + /// Lane-wise left-rotate by `n` bits — the u64 ARX rotate. + /// + /// **This is the crate's one earned intrinsic override.** The codegen + /// oracle measured that a scalar u64 rotate loop does NOT vectorize, on + /// three independent spellings: `u64::rotate_right(n)`, an explicit + /// `(x >> n) | (x << (64 - n))` with a runtime amount, and the same with + /// BLAKE2b's compile-time constants 32/24/16/63. All three came back + /// 0 packed / one scalar `rorq` per lane — and LLVM folded two of the + /// probes into byte-identical code, proving it declines the 64-bit + /// *operation* rather than the rotate *idiom*. Contrast the u32 lane, + /// where hand-written intrinsics LOSE to the optimizer + /// (`.claude/knowledge/td-t22-asm-investigation.md`). + /// + /// `n` is taken mod 64; `n == 0` returns `self` unchanged, because + /// `x >> 64` is UB on `u64` and `VPROLVQ`'s own count is taken mod 64. + /// + /// See `.claude/knowledge/crypto-lane-status.md` — BLAKE2b needs this, + /// and argon2 needs BLAKE2b. + #[inline(always)] + pub fn rotate_left(self, n: u32) -> Self { + let n = n % 64; + if n == 0 { + return self; + } + // SAFETY: `Self` is a native `__m512i`; this arm is compiled only + // under the avx512f dispatch, the same guarantee every other method + // on this type relies on. + Self(unsafe { _mm512_rolv_epi64(self.0, _mm512_set1_epi64(n as i64)) }) + } + + /// Lane-wise right-rotate by `n` bits — BLAKE2b's rotation direction. + /// + /// `rotr(n) == rotl(64 - n)` exactly, since rotation is modular; this is + /// a distinct method rather than a caller-side subtraction because + /// BLAKE2b/argon2 are specified in terms of right rotation, and + /// `VPRORVQ` is a single instruction in its own right. + #[inline(always)] + pub fn rotate_right(self, n: u32) -> Self { + let n = n % 64; + if n == 0 { + return self; + } + // SAFETY: as `rotate_left` above. + Self(unsafe { _mm512_rorv_epi64(self.0, _mm512_set1_epi64(n as i64)) }) + } + #[inline(always)] pub fn splat(v: u64) -> Self { Self(unsafe { _mm512_set1_epi64(v as i64) }) diff --git a/src/simd_nightly/u_word_types.rs b/src/simd_nightly/u_word_types.rs index 9592102e..93fede04 100644 --- a/src/simd_nightly/u_word_types.rs +++ b/src/simd_nightly/u_word_types.rs @@ -19,6 +19,48 @@ pub struct U64x8(pub u64x8); impl U64x8 { pub const LANES: usize = 8; + /// Lane-wise left-rotate by `n` bits — the u64 ARX rotate (BLAKE2b / + /// argon2 lane). `n` is taken mod 64; `n == 0` returns `self`, since + /// `x >> 64` is UB on `u64`. + /// + /// `core::simd` has no rotate, so this is the same per-lane loop the + /// scalar backend uses. The codegen oracle measured that such a loop does + /// **not** vectorize on stable (0 packed, one `rorq` per lane, across + /// three spellings); whether this backend's codegen does better is + /// **unmeasured** — the oracle runs on stable and cannot see it. The + /// native `VPROLVQ`/`VPRORVQ` override lives on `simd_avx512`'s `U64x8`. + /// See `.claude/knowledge/crypto-lane-status.md`. + #[inline(always)] + pub fn rotate_left(self, n: u32) -> Self { + let n = n % 64; + if n == 0 { + return self; + } + let a = self.to_array(); + let mut o = [0u64; 8]; + for i in 0..8 { + o[i] = a[i].rotate_left(n); + } + Self::from_array(o) + } + + /// Lane-wise right-rotate by `n` bits — BLAKE2b's direction. + /// `rotr(n) == rotl(64 - n)` exactly; kept distinct because BLAKE2b and + /// argon2 are specified in terms of right rotation. + #[inline(always)] + pub fn rotate_right(self, n: u32) -> Self { + let n = n % 64; + if n == 0 { + return self; + } + let a = self.to_array(); + let mut o = [0u64; 8]; + for i in 0..8 { + o[i] = a[i].rotate_right(n); + } + Self::from_array(o) + } + #[inline(always)] pub fn splat(v: u64) -> Self { Self(u64x8::splat(v)) diff --git a/src/simd_scalar.rs b/src/simd_scalar.rs index 2e7f09f7..70b0114f 100644 --- a/src/simd_scalar.rs +++ b/src/simd_scalar.rs @@ -526,6 +526,54 @@ impl_int_type!(U16x32, u16, 32, 0u16); impl_int_type!(U32x16, u32, 16, 0u32); impl_int_type!(U64x8, u64, 8, 0u64); +/// u64 ARX rotate — the BLAKE2b / argon2 lane. +/// +/// Scalar per-lane loops, and **measured not to vectorize**: the codegen +/// oracle tried three spellings (`u64::rotate_right`, an explicit shift-or +/// with a runtime amount, and the same with BLAKE2b's constants 32/24/16/63) +/// and every one came back 0 packed, one `rorq` per lane. LLVM declines the +/// 64-bit *operation*, not the rotate *idiom* — it folded two of the probes +/// into byte-identical code. +/// +/// So unlike every other lane-wise op in this crate, the scalar spec is NOT +/// the implementation here. The native `VPROLVQ`/`VPRORVQ` override lives on +/// `simd_avx512`'s `U64x8`, which is a real `__m512i`; these arms are the +/// correct-but-unvectorized fallback, and that is a known cost rather than an +/// oversight. See `.claude/knowledge/crypto-lane-status.md`. +impl U64x8 { + /// Lane-wise left-rotate by `n` bits. `n` is taken mod 64. + #[inline(always)] + pub fn rotate_left(self, n: u32) -> Self { + let n = n % 64; + if n == 0 { + return self; + } + let a = self.to_array(); + let mut o = [0u64; 8]; + for i in 0..8 { + o[i] = a[i].rotate_left(n); + } + Self::from_array(o) + } + + /// Lane-wise right-rotate by `n` bits — BLAKE2b's direction. + /// `rotr(n) == rotl(64 - n)` exactly; kept distinct because BLAKE2b and + /// argon2 are specified in terms of right rotation. + #[inline(always)] + pub fn rotate_right(self, n: u32) -> Self { + let n = n % 64; + if n == 0 { + return self; + } + let a = self.to_array(); + let mut o = [0u64; 8]; + for i in 0..8 { + o[i] = a[i].rotate_right(n); + } + Self::from_array(o) + } +} + // I8/I16 SIMD types (scalar fallback) impl_int_type!(I8x64, i8, 64, 0i8); impl_int_type!(I8x32, i8, 32, 0i8); From 5f4a571099fc11d8597903bbcd3d1b5792cf701e Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:14:36 +0000 Subject: [PATCH 09/11] =?UTF-8?q?docs(ladder):=20rung=206=20built=20?= =?UTF-8?q?=E2=80=94=20and=20which=20backend=20actually=20got=20the=20intr?= =?UTF-8?q?insic?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Records what shipped rather than what was planned: the native VPROLVQ/VPRORVQ override exists on avx512 only; avx2/scalar/nightly carry per-lane loops that are measured not to vectorize, and the explicit vpsllq/vpsrlq shift-or for avx2 is NOT written. Stating that as a table avoids the reading that all six backends got the override. Also reconciles two lines the rung-6 change made stale: the 'rungs 4-6 are optional' summary, and the 'only 3a removes a C build' clause, which #264's features = ["pure"] had already made false and which rung 3a's own section already corrected. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VdfbkUCBbtZhy3yjSfCDHp --- .claude/knowledge/the-simd-ladder.md | 47 +++++++++++++++++++++------- 1 file changed, 35 insertions(+), 12 deletions(-) diff --git a/.claude/knowledge/the-simd-ladder.md b/.claude/knowledge/the-simd-ladder.md index 8f0e08fa..1c4bbdee 100644 --- a/.claude/knowledge/the-simd-ladder.md +++ b/.claude/knowledge/the-simd-ladder.md @@ -163,17 +163,36 @@ only Edwards multi-scalar work this crate doesn't do). Porting it onto `ndarray::simd` is a real rung, has **no cycle**, and is gated on nothing except appetite. -### 6. u64 ARX — the one *earned* intrinsic override, unbuilt +### 6. u64 ARX — the one *earned* intrinsic override, BUILT -Measured: `rotate_left`/`rotate_right` do not exist on `U64x8`/`U64x4` on any -of the six backends, and a scalar u64 rotate loop does **not** vectorize — -0 packed, one scalar `rorq` per lane, even for byte-granular amounts, even -with compile-time-constant counts, on a target that has `vpsllq`/`vpsrlq` and -uses exactly that shift-or for u32 (`crypto-lane-status.md`). +Measured first: `rotate_left`/`rotate_right` did not exist on `U64x8`/`U64x4` +on any of the six backends, and a scalar u64 rotate loop does **not** +vectorize — 0 packed, one scalar `rorq` per lane, even for byte-granular +amounts, even with compile-time-constant counts, on a target that has +`vpsllq`/`vpsrlq` and uses exactly that shift-or for u32 +(`crypto-lane-status.md`). LLVM folded two byte-identical probes together, +which says it declines the 64-bit *operation*, not the rotate *idiom*. -This is the only place in the crate where a hand-written intrinsic currently -meets `simd-one-spec-design.md`'s entry criterion. BLAKE2b needs it, and -argon2 needs BLAKE2b. Nothing is built on it. +This is the only place in the crate where a hand-written intrinsic meets +`simd-one-spec-design.md`'s entry criterion. **Shipped**, and what each +backend got is not uniform: + +| backend | implementation | +|---|---| +| avx512 | native `_mm512_rolv_epi64` / `_mm512_rorv_epi64` — **the override** | +| avx2, scalar, nightly | per-lane loops — correct, measured not to vectorize | +| neon, wasm | re-export the scalar `U64x8`, covered by that arm | + +So the earned intrinsic exists on exactly one backend; the others carry a +known cost rather than an oversight. The explicit `vpsllq`/`vpsrlq` shift-or +for avx2 is **not** written — that is the obvious follow-up and is unmeasured. + +`n` is taken mod 64 with an `n == 0` short-circuit, since `x >> 64` is UB on +`u64` and `VPROLVQ` takes its count mod 64; the two agree at every input +rather than only in the interior. + +**Nothing consumes it yet.** BLAKE2b needs it, argon2 needs BLAKE2b, and +neither is built. --- @@ -232,7 +251,7 @@ anything 3a does. 4 (chacha20 AVX-512 arm) [independent — needs a bench + a ruling] 5 (dalek) [independent — no blocker but appetite] -6 (u64 ARX / argon2) [independent — no blocker but appetite] +6 (u64 ARX lane) DONE — the rotate exists; BLAKE2b/argon2 do not ``` ## What is NOT claimed here @@ -244,8 +263,12 @@ anything 3a does. - **Not that rung 5 is a defect today.** `serial` is a correct, deliberate, documented choice, and the vector backend serves no operation this crate performs. -- **Not that the ladder must be completed.** Rungs 4–6 are optional; only 3a - removes something (a cycle, a C build, an unaudited surface). +- **Not that the ladder must be completed.** Rungs 4–5 are optional; only 3a + removes something (a cycle and an unaudited surface — **not** a C build, + which `features = ["pure"]` already removed in #264; see rung 3a). +- **Not that rung 6 pays off yet.** The rotate is shipped and tested, but no + BLAKE2b and no argon2 consume it, and it has been measured at instruction + class only — never timed against a hand-written BLAKE2b. ## Open, needing a ruling rather than work From 6346c85b06102cd45181e5125e5d9121863b0244 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:33:23 +0000 Subject: [PATCH 10/11] blake3: state the chunk-length invariant where the fast path relies on it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `ChunkState::update` does not split across chunks — `Hasher::update` caps each call via `want = CHUNK_LEN - self.chunk_state.len()`. The `array_chunks` fast path silently depends on that cap: `full = (input.len() - 1) / BLOCK_LEN` is only bounded by the chunk if the caller already bounded the input. Given a longer slice it would compress more than 16 blocks under one chunk counter and produce a wrong-but-plausible hash. The official test vectors would NOT catch that. They only reach this method through `Hasher::update`, so they exercise the invariant rather than testing it — the failure mode is a future refactor of the caller, or any direct call, not a bad vector. So assert it at the point of reliance instead of trusting a caller 250 lines away. `debug_assert!` rather than `assert!`: this is an internal API whose callers are all in this file, the release path should keep the fast path branch-free, and the vectors already cover every boundary the fast path turns on (63/64/65, 127/128/129, 1023/1024/1025). Control-tested rather than assumed: a temporary test calling `ChunkState::update` with `CHUNK_LEN + 1` bytes fires it — "ChunkState::update overran a chunk: 0 + 1025 > 1024" — so the assert discriminates. The temporary test is not kept; it would have to be `#[should_panic]` on a debug-only assert, which is a fragile shape. Found by reading the fast path adversarially while an external review was pending, on the principle that the automated pass should land against an independent check rather than be the only one. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VdfbkUCBbtZhy3yjSfCDHp --- src/hpc/blake3.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src/hpc/blake3.rs b/src/hpc/blake3.rs index c0bdfcd5..bd856e1a 100644 --- a/src/hpc/blake3.rs +++ b/src/hpc/blake3.rs @@ -214,6 +214,24 @@ impl ChunkState { } fn update(&mut self, mut input: &[u8]) { + // This method does NOT split across chunks — `Hasher::update` is what + // caps each call, via `want = CHUNK_LEN - self.chunk_state.len()`. + // That cap lives ~250 lines away and the fast path below silently + // depends on it: without it, `full` could run past the chunk boundary + // and compress more than 16 blocks under one chunk counter, producing + // a wrong-but-plausible hash. + // + // The official vectors would NOT catch that, because they only reach + // this method through `Hasher::update`. So state the invariant where + // it is relied upon instead of trusting a distant caller. + debug_assert!( + self.len() + input.len() <= CHUNK_LEN, + "ChunkState::update overran a chunk: {} + {} > {}", + self.len(), + input.len(), + CHUNK_LEN, + ); + // Fast path — compress whole blocks straight out of `input`. // // The staging path below copies every byte twice: once into From 09903f88a62de45e1c8195f8375c95bb785b9fb7 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 29 Jul 2026 10:43:51 +0000 Subject: [PATCH 11/11] review: cover derive_key with the official vectors, fix a backwards timing doc MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the CodeRabbit pass on #268. Taking the findings that hold and saying which do not, rather than applying all nine. FIXED — correctness: - **`derive_key` had zero vector coverage.** The parser read `input_len`, `hash`, and `keyed_hash` and skipped past the `derive_key` field already present in the file. It is the one public mode built as TWO passes (DERIVE_KEY_CONTEXT over the context string, whose output keys a DERIVE_KEY_MATERIAL pass over the material), so a swapped or collapsed transcription would have been invisible while hash/keyed_hash stayed green. Now asserted for all 35 cases at both the 32-byte and extended XOF lengths. It passed on the first run — the transcription was right; it just wasn't proven. Control-tested: swapping the two flags fails ONLY the new assertion. - **`Hash::eq`'s documentation described the opposite of the code.** The doc-comment said `read_volatile` (the code uses `core::hint::black_box`) and the not-transcribed list flatly asserted "`PartialEq` here is a normal short-circuiting byte-array compare" — stale since the constant-time fold landed. On a MAC-comparison path a backwards timing claim is worse than no claim: an auditor would have believed this file leaks match-prefix length when it does not. Also now states that `black_box` is a best-effort barrier, not a guarantee; the data-independent loop is the actual property. FIXED — hazards: - `run.sh` copied the bench to a fixed `examples/blake3_ab.rs` and had the EXIT trap `rm` it unconditionally: a pre-existing file there would be clobbered and then deleted. Now refuses to overwrite, and only removes the directory it created. - `shiftor_rot_u64x8` is `pub` and took arbitrary `u32` into `a >> n` — a debug panic and a release wrapping-shift for `n >= 64`. Normalized at the PUBLIC wrapper and deliberately NOT in the measured inner body, which would have changed the codegen the oracle exists to observe. - Rotate test extended past 64 (65/127/128/191) against std's `u64::rotate_*`, pinning the mod-64 contract rather than only the width case. - Test-local context const renamed `VECTORS_CONTEXT`; as `DERIVE_KEY_CONTEXT` it shadowed the module-scope u32 flag of that name. - Redundant `.into()` in `derive_key` (`clippy::useless_conversion`). - The `pub mod blake3` incident history was an OUTER doc comment, so rustdoc would publish it as module documentation. Now a plain `//` comment. Three more stale doc claims, all the same class already corrected elsewhere in this PR — an assertion outliving the change that falsified it: - EPIPHANIES said "an error there means no cycle and the work is unblocked" one paragraph after explaining the error is ambiguous. Read literally it marks blocked work unblocked on the strength of a typo. Now a four-step procedure with the error explicitly inconclusive. - crypto-lane-status prescribed an explicit shift-or for AVX2/NEON/wasm, contradicting the measurement directly above it (LLVM folds the two forms byte-identically) and misdescribing what shipped. - blake3-in-tree-measured still credited the swap with removing the C build, already removed by `features = ["pure"]` in #264. NOT taken, with reasons: - Doctest examples on every public API. Worth doing, but it is a separate change across a surface this PR is not otherwise touching, and the vectors already smoke-test the public path harder than an example would. - Caching the compressed block in `OutputReader`. A real inefficiency for small repeated `fill` calls, but unmeasured, and the module is explicitly not on a hot path pending the swap ruling. - Deduplicating the rotate body across backends. Every other lane type in this crate is duplicated per backend by design; collapsing one method would make it the exception, not the rule. - Secret-scanner allowlist for the vectors file. Correct that the hex is published test data, not credentials, but no scanner config lives in this repo to add it to. Full lib suite green: 2200 passed, 0 failed. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01VdfbkUCBbtZhy3yjSfCDHp --- .claude/board/EPIPHANIES.md | 21 +++-- .claude/knowledge/blake3-ab-bench/run.sh | 21 ++++- .claude/knowledge/blake3-in-tree-measured.md | 11 ++- .claude/knowledge/crypto-lane-status.md | 18 ++++- .../knowledge/simd-codegen-oracle/probes.rs | 14 ++++ src/hpc/blake3.rs | 78 +++++++++++++++---- src/hpc/mod.rs | 18 +++-- src/simd.rs | 7 +- 8 files changed, 153 insertions(+), 35 deletions(-) diff --git a/.claude/board/EPIPHANIES.md b/.claude/board/EPIPHANIES.md index 560396d8..7e2a8d2b 100644 --- a/.claude/board/EPIPHANIES.md +++ b/.claude/board/EPIPHANIES.md @@ -65,14 +65,23 @@ caught it on #268. Treating `[[patch.unused]]` as a cycle report teaches the next reader to misdiagnose an ordinary stale patch — and undermines the very check this entry prescribes. -The check stands, on its own evidence: run -`cargo tree -p -i ` **before** planning the work. An error -there means no cycle and the work is unblocked; a hit means the edge must be -cut first. +The check stands, but ONLY in its positive form — and the wording here was +itself an instance of the bug it warns about, corrected on #268: Consequence: **before planning any "make X consume our crate" work, run -`cargo tree -p -i `.** An error there means no cycle and the -work is unblocked; a hit means the edge must be cut first. +`cargo tree -p -i `.** + +1. First confirm `` is a real package in the selected graph (a typo, or a + package absent from that graph, produces the byte-identical error). +2. A tree that resolves and shows the root package = the edge must be cut + first. +3. A tree that resolves and does NOT show it = unblocked. +4. **A package-ID error is inconclusive — never "no cycle".** + +An earlier draft of this very entry said "an error there means no cycle and +the work is unblocked", one paragraph after explaining that the error is +ambiguous. Read literally it would mark blocked work as unblocked on the +strength of a typo. ## 2026-07-29 — BLAKE3 needs a method surface, not intrinsics (measured) diff --git a/.claude/knowledge/blake3-ab-bench/run.sh b/.claude/knowledge/blake3-ab-bench/run.sh index 92847846..b6391b9d 100755 --- a/.claude/knowledge/blake3-ab-bench/run.sh +++ b/.claude/knowledge/blake3-ab-bench/run.sh @@ -10,8 +10,23 @@ set -eu HERE="$(cd "$(dirname "$0")" && pwd)" REPO="$(cd "$HERE/../../.." && pwd)" -mkdir -p "$REPO/examples" -trap 'rm -f "$REPO/examples/blake3_ab.rs"; rmdir "$REPO/examples" 2>/dev/null || true' EXIT -cp "$HERE/blake3_ab.rs" "$REPO/examples/blake3_ab.rs" +# 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 diff --git a/.claude/knowledge/blake3-in-tree-measured.md b/.claude/knowledge/blake3-in-tree-measured.md index 00e62e68..04b46217 100644 --- a/.claude/knowledge/blake3-in-tree-measured.md +++ b/.claude/knowledge/blake3-in-tree-measured.md @@ -114,8 +114,15 @@ So the honest shape is: ## What this means for the swap -**The cycle-cut is available now and is correct.** It removes a cargo cycle, -the C build question, and 2,910 lines of second-surface `core::arch`. +**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 diff --git a/.claude/knowledge/crypto-lane-status.md b/.claude/knowledge/crypto-lane-status.md index ecad1823..5d95866d 100644 --- a/.claude/knowledge/crypto-lane-status.md +++ b/.claude/knowledge/crypto-lane-status.md @@ -116,9 +116,21 @@ failing row, since a probe the compiler cannot distinguish from its own control measures nothing. **So the u64 ARX lane is the crate's first intrinsic override that meets the -entry criterion** (a probe proving the generic form fails). AVX-512: -`_mm512_rorv_epi64` / `VPROLVQ`, one instruction. AVX2 / NEON / wasm: write -the `vpsllq`/`vpsrlq`-shaped shift-or explicitly, since LLVM will not. +entry criterion** (a probe proving the generic form fails). + +What SHIPPED, per backend (#268), which is not uniform: + +- **AVX-512:** `_mm512_rolv_epi64` / `_mm512_rorv_epi64` — `VPROLVQ`/`VPRORVQ`, + one instruction. This is the earned override. +- **AVX2 / scalar / nightly:** per-lane loops. NEON and wasm re-export the + scalar `U64x8` and are covered by that arm. + +An earlier draft of this paragraph instructed AVX2/NEON/wasm to "write the +`vpsllq`/`vpsrlq`-shaped shift-or explicitly, since LLVM will not." That +prescription contradicted the fourth confirmation directly above it — LLVM +folded the explicit shift-or into the rotate form, byte-identically, so +writing it out is not known to change anything. **It is an unmeasured future +experiment, not the prescribed implementation**, and it is not what shipped. Contrast with the u32 lane, where hand-writing intrinsics *lost* to the optimizer. Same crate, same week, opposite answers — which is the argument diff --git a/.claude/knowledge/simd-codegen-oracle/probes.rs b/.claude/knowledge/simd-codegen-oracle/probes.rs index c4cf6ba7..2f17dcdf 100644 --- a/.claude/knowledge/simd-codegen-oracle/probes.rs +++ b/.claude/knowledge/simd-codegen-oracle/probes.rs @@ -520,8 +520,22 @@ fn shiftor_rotr_u64x8(v: U64x8, n: u32) -> U64x8 { } /// Explicit shift-or u64 rotate, runtime-variable amount, 8 lanes. +/// +/// Normalizes `n` to `0..64` and short-circuits zero, so this public entry +/// point agrees with `u64::rotate_right` at **every** `u32` input rather than +/// only on `1..=63`. Without it, `n >= 64` reaches `a >> n` — a panic in +/// debug, a wrapping shift in release, and a rotate in neither. +/// +/// The normalization lives HERE and deliberately NOT in `shiftor_rotr_u64x8`: +/// that inner body is the thing being measured, and adding a branch plus a +/// modulo inside it would change the very codegen the oracle exists to +/// observe. Guard at the boundary; measure the bare form. #[inline(never)] pub fn shiftor_rot_u64x8(v: U64x8, n: u32) -> U64x8 { + let n = n % 64; + if n == 0 { + return v; + } shiftor_rotr_u64x8(v, n) } diff --git a/src/hpc/blake3.rs b/src/hpc/blake3.rs index bd856e1a..34641b29 100644 --- a/src/hpc/blake3.rs +++ b/src/hpc/blake3.rs @@ -375,8 +375,14 @@ impl PartialEq for Hash { /// `==` leaks match-prefix length through timing. Upstream reaches for /// the `constant_time_eq` crate; this crate takes no new dependencies, so /// the fold is written here: accumulate the XOR of every byte pair and - /// test once at the end, with `read_volatile` on the accumulator to stop - /// the optimizer reintroducing an early exit. + /// test once at the end, with `core::hint::black_box` on the accumulator + /// to stop the optimizer reintroducing an early exit. + /// + /// `black_box` is a **best-effort optimizer barrier, not a guarantee** — + /// its documentation is explicit that it provides no formal contract. The + /// data-independent loop above is the real property; the barrier only + /// discourages LLVM from undoing it. A caller needing an audited + /// guarantee should use a dedicated constant-time crate. /// /// No call site in this crate currently compares two `Hash` values — /// `seal.rs` compares the truncated `MerkleRoot` instead — so this is a @@ -387,8 +393,8 @@ impl PartialEq for Hash { for i in 0..OUT_LEN { diff |= self.0[i] ^ other.0[i]; } - // SAFETY-free volatile read of a local: prevents the compiler from - // proving an early return is equivalent. + // Optimizer barrier (best-effort): discourages LLVM from proving an + // early return equivalent and reintroducing the short circuit. core::hint::black_box(diff) == 0 } } @@ -608,7 +614,7 @@ pub fn keyed_hash(key: &[u8; KEY_LEN], input: &[u8]) -> Hash { pub fn derive_key(context: &str, key_material: &[u8]) -> [u8; OUT_LEN] { let mut hasher = Hasher::new_derive_key(context); hasher.update(key_material); - (*hasher.finalize().as_bytes()).into() + *hasher.finalize().as_bytes() } // ======================================================================= @@ -624,13 +630,16 @@ pub fn derive_key(context: &str, key_material: &[u8]) -> [u8; OUT_LEN] { // - `update_rayon` and anything behind the `rayon` feature. // - `zeroize` support (feature-gated in the original; secrets are not a // concern for ndarray's usage of this module). -// - Constant-time equality (`constant_time_eq`): the original `Hash::eq` is -// constant-time as a defense-in-depth measure for MAC comparisons. Adding -// that would mean hand-rolling a constant-time byte compare with no -// external crate; out of scope for what ndarray needs (a plain fingerprint -// equality check), so `PartialEq` here is a normal short-circuiting -// byte-array compare. Flagged explicitly since it is a behavioral -// difference from upstream `blake3::Hash`, not just a missing convenience. +// - The `constant_time_eq` CRATE — the dependency, NOT the behaviour. The +// constant-time compare itself IS implemented: see `impl PartialEq for +// Hash`, which XOR-folds all 32 bytes and tests once, so `Hash::eq` keeps +// upstream's timing property without adding the dependency. +// +// An earlier revision of this list claimed the opposite — "`PartialEq` here +// is a normal short-circuiting byte-array compare" — left stale when the +// fold landed. Documenting a timing property backwards is worse than +// omitting it: someone auditing a MAC comparison would have believed this +// file leaks match-prefix length when it does not. // - Hex encoding/decoding (`to_hex`/`from_hex`) and `Display`/`FromStr`: not // in the required API list. // ======================================================================= @@ -648,8 +657,17 @@ mod tests { input_len: usize, hash_hex: String, keyed_hash_hex: String, + derive_key_hex: String, } + /// The context string the official vectors' `derive_key` outputs were + /// generated with (the `context_string` field of `test_vectors.json`). + /// + /// Deliberately NOT named `DERIVE_KEY_CONTEXT` — that is a `u32` domain + /// flag at module scope, and reusing the name here would shadow it inside + /// this module. + const VECTORS_CONTEXT: &str = "BLAKE3 2019-12-27 16:29:52 test vectors context"; + /// Hand-rolled extraction of the fields we need from the official BLAKE3 /// test_vectors.json, without pulling in serde. The file's `cases` array /// is a flat sequence of objects each with exactly the fields @@ -683,10 +701,19 @@ mod tests { let keyed_hash_hex = extract_quoted(rest); rest = &rest[keyed_hash_hex.len() + 2..]; + let dk_idx = rest + .find("\"derive_key\":") + .expect("missing derive_key field") + + "\"derive_key\":".len(); + rest = &rest[dk_idx..]; + let derive_key_hex = extract_quoted(rest); + rest = &rest[derive_key_hex.len() + 2..]; + cases.push(Case { input_len, hash_hex, keyed_hash_hex, + derive_key_hex, }); } cases @@ -717,7 +744,7 @@ mod tests { } #[test] - fn official_test_vectors_hash_and_keyed_hash() { + fn official_test_vectors_all_three_modes() { let cases = parse_cases(VECTORS_JSON); assert!(!cases.is_empty(), "no cases parsed"); let mut checked = 0usize; @@ -763,6 +790,31 @@ mod tests { case.input_len ); + // --- derive_key, 32-byte default output --- + // + // The third public mode, and the one whose flags are easiest to + // get wrong while the other two still pass: it is a TWO-pass + // construction (DERIVE_KEY_CONTEXT over the context string, + // whose output becomes the key words for a DERIVE_KEY_MATERIAL + // pass over the material). A single-pass transcription, or one + // that swapped the two flags, would be invisible to every + // assertion above. + let expected_dk = hex_decode(&case.derive_key_hex); + let got_dk = derive_key(VECTORS_CONTEXT, &input); + assert_eq!(got_dk[..], expected_dk[..32], "derive_key() mismatch at input_len={}", case.input_len); + + // --- derive_key, extended output via finalize_xof --- + let mut dhasher = Hasher::new_derive_key(VECTORS_CONTEXT); + dhasher.update(&input); + let mut dxof = dhasher.finalize_xof(); + let mut dextended = std::vec![0u8; expected_dk.len()]; + dxof.fill(&mut dextended); + assert_eq!( + dextended, expected_dk, + "derive_key finalize_xof extended output mismatch at input_len={}", + case.input_len + ); + checked += 1; } // Sanity: make sure we actually walked the whole official vector file. diff --git a/src/hpc/mod.rs b/src/hpc/mod.rs index ee6250a3..631c4d42 100644 --- a/src/hpc/mod.rs +++ b/src/hpc/mod.rs @@ -54,13 +54,17 @@ pub mod fingerprint; #[allow(missing_docs)] pub mod plane; -/// In-tree BLAKE3 — see `.claude/knowledge/blake3-in-tree-measured.md`. -/// -/// Fully documented, so deliberately NOT under the `#[allow(missing_docs)]` -/// that guards `plane`. An earlier revision declared it on the line directly -/// after that attribute, which silently re-targeted the attribute onto this -/// module and left `plane` unguarded — seven CI jobs failed on `missing_docs` -/// errors in a file the change never touched. +// In-tree BLAKE3 — see `.claude/knowledge/blake3-in-tree-measured.md`. +// +// A plain `//` comment, not `///`: this is repo incident history, and an outer +// doc comment here would be concatenated into the published rustdoc for the +// module. +// +// The module is fully documented, so it is deliberately NOT under the +// `#[allow(missing_docs)]` that guards `plane`. An earlier revision declared +// it on the line directly after that attribute, which silently re-targeted the +// attribute onto this module and left `plane` unguarded — seven CI jobs failed +// on `missing_docs` errors in a file the change never touched. pub mod blake3; #[allow(missing_docs)] pub mod seal; diff --git a/src/simd.rs b/src/simd.rs index e3f0ad48..554a0ca9 100644 --- a/src/simd.rs +++ b/src/simd.rs @@ -768,7 +768,12 @@ mod tests { // BLAKE2b uses 32/24/16/63; 0 and 63 are the edges, and 64 must wrap // to the identity rather than shifting by the full width (UB on u64). - for n in [0u32, 1, 7, 16, 24, 31, 32, 63, 64] { + // 65/127/128/191 pin the documented mod-64 contract: an implementation + // that only special-cased the width would pass at 64 and fail here. + // The per-lane oracle is std's own `u64::rotate_left`, which is + // specified to take its count mod 64, so this asserts agreement with + // the language rather than with our own restatement of the rule. + for n in [0u32, 1, 7, 16, 24, 31, 32, 63, 64, 65, 127, 128, 191] { let l = a.rotate_left(n).to_array(); let r = a.rotate_right(n).to_array(); for i in 0..8 {