You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
perf(aura): unify cache to activation dtype + wire 2-pass FA decode (closes -58%→-9% gap)
End-to-end FFAI side of the AURA dtype unification (metaltile sigs
0e4cb1a + 3fdadb3, PR 0xClandestine/metaltile#212). Replaces the
intermediate `.dequantMirror` default (originally bf16'd as a stopgap
because the single-pass `aura_flash_sdpa` kernel starved the GPU with
one simdgroup per query) with the right architecture: a single source
of truth in the activation dtype, and the token-parallel FA-2 kernel
pair.
## Cache schema — single source of truth (AURAQuantizedKVCache)
- kNorms / vNorms allocated in `dtype` (was f32-only).
- kCodebook / vCodebook allocated in `dtype` (was f32-only).
- kBoundaries / vBoundaries stay f32 — encoder-only, Lloyd-Max compare
precision matters and they never reach the decode kernels.
- encodePerHead view stride now keys off `dtype.byteSize`, not a
hardcoded 4 (the legacy f32 footgun that broke `AuraKLDIntegrationTests`
the moment a non-f32 cache hit the encode path).
## Loaders (LlamaText / Qwen3Text)
- New `AURACodebook.centroidsTensor(dim:bits:dtype:device:)` host-side
conversion helper covers all three float dtypes (f32 / f16 / bf16).
- `AURACodebook.boundariesTensor(...)` mirrors the helper for the
encoder-only boundaries buffer.
- Both Qwen3 and Llama AURA cache builders use the helpers — no more
copy-pasted f32 `Tensor.empty + copyIn` block per loader.
## Ops surface
- `Ops.auraFlashSdpa` preconditions drop the f32-norms-and-codebook
requirement; everything must now match `out.dtype` (the activation
dtype). Q pre-scale flow rewires from a f32 scratch + f32 scale buffer
to an activation-dtype scratch + activation-dtype scale buffer.
- `AuraFlashScratchCache` keys both scratches on (count, dtype) — was
keyed on `count` alone with f32 hardcoded. Adds a `partials(...)`
scratch cache for the 2-pass partials triple.
- `Ops.auraEncode` + `Ops.auraDequantRotated` preconditions drop the
f32-norms-and-codebook requirement; the dequant-mirror path flows
through T now too.
- New `Ops.auraFlashSdpa2Pass` wrapper — dispatches `aura_flash_p1` +
`aura_flash_pass2` for token-parallel FA-2 over the compressed cache.
Caller-owned partials (mirrors `Ops.sdpaDecode2Pass`).
- New `Ops.supportsAuraFlashSdpa2Pass` predicate.
## Qwen3Layer.forward
- Prefer `Ops.auraFlashSdpa2Pass` when supported, fall back to
`Ops.auraFlashSdpa` for combos the 2-pass kernel hasn't been emitted
for (no path today; future-proof for kb!=4 / vb!=2,4 / d!=128).
- Block size 64 — matches the dense `sdpaDecode2Pass` per-block work
size and saturates the M5 Max class around liveLength ≈ 4K.
## Default — back to `.compressed`
`LoadOptions.auraDecodePath` defaults to `.compressed`. Matches
@ekryski's stance from the PR review — true compressed attention is
FFAI's quantized-attention story and should be the default-path users
load into. The dtype unification + 2-pass FA-2 closes the perf gap that
made the original `.dequantMirror` flip necessary.
## Quality (M5 Max, Qwen3-0.6B-4bit, 61-position KLD harness)
| scheme | mean_kld | same_top |
|-------------------------|---------:|---------:|
| aura4v4 dequant-mirror | 1.42 | 43% |
| aura4v4 2-pass flash | **1.40** | **48%** |
| aura4v2 2-pass flash | 1.69 | 44% |
| aura8v4 (TQ+ recipe) | 0.018 | 93% |
2-pass compressed flash matches (slightly beats) dequant-mirror on
aura4v4. KLD harness regression gate green for all schemes.
## Perf (M5 Max, Qwen3-0.6B-4bit decode tps, 5-run median)
| KV | dequant-mirror | compressed (2-pass) | gap | gap pre-unification |
|------|----------------|---------------------|--------|---------------------|
| 64 | 80.88 | 71.62 | -11.4% | -15.7% |
| 256 | 77.14 | 67.71 | -12.2% | **-43.7%** |
| 1024 | 46.87 | 42.73 | -8.8% | **-57.8%** |
Long-KV gap collapsed from -57.8% → -8.8%. Single-digit perf delta vs
dequant-mirror with 1.88× cache memory savings preserved (aura4v4 @
maxSeq=4096: 4352 KiB packed+norms vs 8192 KiB mirror).
## Why the C++ canonical pattern is safe
The fp16-stored norms / f32-at-use pattern this PR adopts mirrors the
production C++ `llama.cpp` TQ+ fork — commit b696c5da1 in that fork
shipped fp16 centroid LUTs + float-norm broadcast with measured zero
PPL impact ("Constant half LUT + float norm broadcast remains the
fastest approach on Apple Silicon", ggml-metal.metal:776). Internal
kernel arithmetic stays in f32 via cast-at-load; only the storage
narrows.
## Pass-2 dispatch shape note
`aura_flash_pass2`'s kernel header says `tg = (32, 1, 1) per q_idx`,
which means `q_idx = tgid_x`. Wrapper dispatches raw threads
`[nQHeads * 32, 1, 1]` with `tg = [32, 1, 1]` → `nQHeads` TGs along x,
each running 32 lanes; matches the metaltile end-to-end test's grid
shape exactly. The naive `[32, nQHeads, 1]` shape (raw-thread analogue
of `grid_groups [1, nQHeads, 1]`) would put `tgid_x = 0` for every TG,
i.e. every Q head's reduce reads q_idx=0's partials — produced
garbage same_top=0.0 / mean_kld=12+ output before the fix. Worth a
comment in the wrapper (added).
## Bench infra retained from the original perf pass
`AuraFlashScratchCache` (process-wide static, NSLock-guarded) memoizes
the Q scratch + scale buffer per (shape, dtype, scale) tuple. The
`AuraDecodeBenchIntegrationTests` side-by-side bench grid + memory
footprint asserter (KV=64 / 256 / 1024 + maxSeq=4096) are also kept
as regression catchers.
0 commit comments