Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 118 additions & 0 deletions .claude/knowledge/crypto-lane-status.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# The crypto lane — what is proven, what is missing

> **Status: MEASURED, 2026-07-28.** Every claim here is backed by an
> assembly probe or a grep across all six backends. No estimates.

## READ BY:
- Anyone building on `crates/encryption` (argon2 / BLAKE3 / ChaCha20 / AEAD)
- Anyone asked "do we need new SIMD work for cipher X?"

---

## The u32 ARX lane: PROVEN, at the instruction floor

`crate::simd::U32x16` Add / BitXor / `rotate_left` is the ChaCha20 and BLAKE3
mixing triple. TD-T22 measured it (`td-t22-asm-investigation.md`): the
scalar-storage polyfill compiles to **8 `vpaddd` for 64 u32 lanes — the AVX2
instruction floor** — with no scalar op touching lane data, and
`rotate_left(16)` strength-reduced to `vpshufb`, cheaper than the
`shl|shr|or` triple an intrinsic emits.

**Consequence: ChaCha20 and BLAKE3 need no new SIMD work.** The lane they
ride is already optimal on the default tier.

The float side is likewise done: `add_mul_f32` emits real `vfmadd213ps` —
one rounding, mantissa preserved — and `array_chunks` / `array_windows`
(+ `_checked`) already exist as the slice-level primitives in `simd_ops.rs`.

## The u64 ARX lane: DOES NOT EXIST

Measured across `simd_avx512`, `simd_avx2`, `simd_scalar`, `simd_neon`,
`simd_wasm`, and `simd_nightly`: **zero `rotate_left` or `rotate_right`
methods on `U64x8` or `U64x4`. On any backend.**

Whole-crate census of rotate methods — `grep -rhoE "fn rotate_(left|right)" src/`:

| method | count |
|---|---|
| `rotate_left` | 8 (all u32 lanes) |
| `rotate_right` | **0, at any width** |

*(Search validated by control: the same pattern finds `U32x16::rotate_left`
in all four backends that define it, so the empty u64 result is a true
negative and not a broken query.)*

Note the second row. BLAKE2b specifies **right** rotations. `rotr(n)` is
expressible as `rotl(64 - n)`, so this is a naming/API gap rather than a
mathematical one — but a caller writing BLAKE2b today has neither.

This matters because **BLAKE2b is a 64-bit ARX cipher**, and BLAKE2b is what
**argon2** uses. Its G-function is
`a+=b; d=(d^a).rotr(32); c+=d; b=(b^c).rotr(24); a+=b; d=(d^a).rotr(16); c+=d; b=(b^c).rotr(63)`
— four u64 rotates per mixing step, none of which the crate can express
today.

`crates/encryption` currently references exactly one SIMD type:
`simd::U32x16`.

**This is a real gap, unlike the u32 one.** The distinction is the whole
lesson of TD-T22: a missing *source-level* lowering is not a gap when LLVM
already emits the instruction; a missing *method* is a gap regardless of
what LLVM would do with it.

### ANSWERED by the oracle: no, it does not vectorize

Measured on x86_64 v3 via `.claude/knowledge/simd-codegen-oracle/`:

| probe | packed | scalar lane-arith | verdict |
|---|---|---|---|
| `rot_u64x8` | **0** | 8 | one scalar `rorq %cl` per lane |
| `rot_u64x4` | **0** | 4 | one scalar `rorq %cl` per lane |
| `blake2b_g_u64x8` | 22 | ~88 | leading add only |

`blake2b_g_u64x8` in detail: the opening `a = a + b` vectorizes (`vpaddq`
across both ymm halves). The moment a rotate is needed LLVM extracts every
lane to a GPR (`vmovq` / `vpextrq`) and stays scalar (`rorxq`/`addq`/`xorq`)
through all four rotate stages, going packed again only for the return
struct's reassembly.

Two things make this a genuine finding rather than a shrug:

1. **The byte-granular amounts stay scalar too.** Rotates by 32/24/16 are
byte-aligned — the class LLVM folds to `vpshufb` for u32 — yet all four
BLAKE2b amounts (32/24/16/63) lowered identically to scalar `rorxq`.
2. **The mechanism exists and is unused.** AVX2 has `vpsllq`/`vpsrlq`, and
LLVM *does* use exactly that shift-or composition for u32's rotate-by-12
and rotate-by-7. It has the tools and declines to apply them at 64-bit
width.

**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.

Contrast with the u32 lane, where hand-writing intrinsics *lost* to the
optimizer. Same crate, same week, opposite answers — which is the argument
for the oracle existing at all.

## Decision gates (operator, not engineering)

Neither of these is blocked on SIMD work:

1. **FIPS.** If any deployment needs FIPS-adjacent claims, BLAKE3 is out and
SHA-384 stays the KDF hash. Settle before investing in a BLAKE3 lane.
2. **`x448` audit provenance.** Gates whether an X25519 port is worth it.
The tripwire test already on master
(`channel::tests::low_order_peer_keys_are_refused_and_honest_ones_are_not`)
asserts both halves, so a mechanical port cannot silently drop RFC 7748's
contributory check — `x448::x448()` returns `Option` where
`x25519_dalek::x25519()` returns a bare `[u8; 32]`.

## Summary

| lane | status | blocks |
|---|---|---|
| u32 ARX (ChaCha20, BLAKE3) | **proven optimal** | nothing |
| f32 FMA (`add_mul`) | **proven fused** | nothing |
| slice chunking | **exists** | nothing |
| **u64 ARX (BLAKE2b → argon2)** | **absent on all 6 backends** | argon2 SIMD |
117 changes: 117 additions & 0 deletions .claude/knowledge/simd-codegen-oracle/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
# SIMD codegen oracle — an on-demand instrument

Answers one question with evidence: **does this lane operation actually
vectorize, or does the source only look like it should?**

Lives here, not in `crates/` or `scripts/`, because it is an investigation
instrument rather than a product artifact. It ships no code, adds no
workspace member, and runs no CI job.

## When to use it

When a codegen question is genuinely open — before hand-writing intrinsics,
before "lowering" a scalar-looking polyfill, before claiming a lane is slow.
Answer it once, record the answer in a doc, move on.

**Not a standing check.** A permanent job that re-answers a settled question
on every commit is machinery guarding a lapse the design step should
prevent. Measure during design instead.

## Files

| file | role |
|---|---|
| `probes.rs` | 13 `#[inline(never)]` kernels over `ndarray::simd` types |
| `analyze.py` | extracts per-symbol instruction histograms from emitted asm, classifies, compares to baseline |
| `run.sh` | builds with `--emit asm`, locates the `.s`, invokes the analyzer |
| `baseline-x86_64-v3.toml` | expected class assertions per probe |

## Running it

The tool needs a throwaway crate to compile the probes against `ndarray`:

```sh
cd /path/to/ndarray
mkdir -p /tmp/oracle/src
cp .claude/knowledge/simd-codegen-oracle/probes.rs /tmp/oracle/src/main.rs
cat > /tmp/oracle/Cargo.toml <<'EOF'
[package]
name = "simd-codegen-oracle"
version = "0.0.0"
edition = "2021"
[dependencies]
ndarray = { path = "/path/to/ndarray", features = ["std"] }
[profile.release]
debug = false
EOF

cargo rustc --release --manifest-path /tmp/oracle/Cargo.toml -- \
--emit asm -C debuginfo=0 -C target-cpu=x86-64-v3
python3 .claude/knowledge/simd-codegen-oracle/analyze.py \
"$(ls -t /tmp/oracle/target/release/deps/simd_codegen_oracle-*.s | head -1)" \
.claude/knowledge/simd-codegen-oracle/baseline-x86_64-v3.toml
```

`run.sh` automates this against a crate laid out as above; adjust
`MANIFEST`/`BASELINE` at the top for your scratch location.

## Two properties that make the result trustworthy

**The baseline is declared, never inherited.** `-C target-cpu=x86-64-v3` is
passed on the final rustc invocation. This is load-bearing: cargo's
`RUSTFLAGS` env var *replaces* `[target.'cfg(…)'].rustflags` from
`.cargo/config.toml` — they do not merge — so a tool that inherits its
baseline measures a different machine depending on where it runs. Verified:

```sh
$ cargo build -p ndarray --lib -v | grep -o 'target-cpu=[a-z0-9-]*'
target-cpu=x86-64-v3
$ RUSTFLAGS="-D warnings" cargo build -p ndarray --lib -v | grep -o 'target-cpu=[a-z0-9-]*'
# (empty — silently dropped)
```

**Scalar arithmetic on lane data is separated from loop control**, so a
trip-counter `decl` is never miscounted as scalar lane work. The distinction
matters: "zero scalar instructions" is almost always false (a loop counter
exists); "no scalar op touches lane data" is the claim that means something.

## Recorded results

Measured on x86_64 + `x86-64-v3`, rustc 1.95.0. Full narrative in
`../td-t22-asm-investigation.md` and `../crypto-lane-status.md`.

| probe | packed | scalar (lane data) | lowering |
|---|---|---|---|
| `arx_rounds_u32x16` | 52 | 0 | `vpaddd`/`vpxor`/`vpshufb`; 8 `vpaddd` per round = the AVX2 floor for 64 lanes |
| `arx_u32x16` | 11 | 0 | `rotate_left(16)` → `vpshufb` |
| `reduce_u32x16` | 8 | 0 | logarithmic reduction tree |
| `fma_f32x16` | 28 | 0 | `vfmadd213ps` |
| `bitwise_u8x64` | 12 | 0 | packed |
| `saturating_abs_i8x32` | 4 | 0 | `vpxor`/`vpsubsb`/`vpblendvb` — synthesized the abs+clamp trick unprompted |
| `widening_u16_to_f32` | 6 | 0 | `vpmovzxwd` + `vcvtdq2ps` |
| `cross_lane_reverse_u8x64` | 9 | 0 | `vbroadcasti128`/`vpshufb`/`vpermq` — invented a cross-lane permute from a scalar index loop |
| **`rot_u64x8`** | **0** | 8 | scalar `rorq %cl`, one per lane |
| **`rot_u64x4`** | **0** | 4 | scalar `rorq %cl`, one per lane |
| **`blake2b_g_u64x8`** | 22 | ~88 | packed leading add; scalar through all four rotates |
| `gather_lookup_u8` | 0 | 0 | `movzbl` chain, no arithmetic |
| `serial_dependent_chain` | 0 | 27 | loop-carried dependency |

**The headline:** LLVM vectorizes far more than intuition suggests —
including cross-lane permutes, widening converts, and saturating
arithmetic, all from plain scalar loops. It does **not** vectorize u64
rotates, even for byte-granular amounts that fold to `vpshufb` at u32
width, and even though `vpsllq`/`vpsrlq` are available and it uses exactly
that shift-or for u32 `rotate_left(12)`/`(7)`.

That single row is why the tool was worth building: it is the one place a
hand-written intrinsic is currently justified, and it is what argon2 needs
(BLAKE2b is a 64-bit ARX cipher).

## A bug worth remembering

`analyze.py` originally returned its failure bitmask as the process exit
status. Unix truncates exit status to the low 8 bits, so a failure confined
to probe index ≥ 8 exits `0x100`/`0x200`/… and the shell observes **0** —
printing `FAILURES` while reporting success. Alphabetically that covered
both u64-rotate probes. Now it exits 1 on any failure, with the bitmask
kept as diagnostic text.
Loading
Loading