Skip to content

feat: shared-engine refactor + Nemotron-Nano-30B GB10 prefill/decode optimizations (~5×)#20

Closed
TheTom wants to merge 202 commits into
devfrom
tom/feat/cuda-hip-vulkan-backends
Closed

feat: shared-engine refactor + Nemotron-Nano-30B GB10 prefill/decode optimizations (~5×)#20
TheTom wants to merge 202 commits into
devfrom
tom/feat/cuda-hip-vulkan-backends

Conversation

@TheTom

@TheTom TheTom commented Jun 6, 2026

Copy link
Copy Markdown
Contributor

First time here? Read the TL;DR + "where to start" map below — you don't need to read all 104 commits. Big draft (159 files, ~29K lines) opened for visibility.

TL;DR

Two things, both backend-gated so the default path can't regress:

  1. Shared-engine refactor — each model's forward is written once and runs on both CUDA (server GPU) and Metal (Apple) backends; +ffai-vulkan for AMD.
  2. A deep prefill/decode optimization pass on Nemotron-Nano-30B (Mamba2 + 128-expert-MoE hybrid) on a single GB10.

Prefill result so far (S=2048, GB10, byte-matched precision, argmax-gated)

context depth start of pass now
d0 187 tok/s ~411–446
d32768 103 (collapsing) ~334 (flat)

The curve went from collapsing to flat ~370 across d0→d32768 — ~2.3× at d0, ~3.4× at deep context. Every step correctness-gated (logit cosine + shared-top-5, not just argmax).

The levers (each gated, each measured)

  • Conv double-shift fix — a real correctness bug: the all-device Mamba decode step shifted the causal-conv ring twice, corrupting multi-token decode past token 0 (invisible at pos 0, so Phase-1 missed it).
  • SSD matmul scan (NEMOTRON_SSD_MATMUL) — Mamba2 state-space-duality chunked-matmul via batched cuBLAS; ssm_scan stage 26%→5% (6.8×), +36% e2e @ d0. Scan-level cosine 1.000000.
  • Tensor-core flash-attention (sdpa_multi_tc, depth auto-select) — cuBLAS online-softmax, GQA stride-0 fan-out; sdpa stage 86%→30% @ d32768 (up to 14×), +59% deep-context e2e.
  • Launch-geometry fix — six prefill elementwise kernels were dispatched block:[1,1,1] (one thread/block!); packed into real blocks → +7–13%, byte-identical.
  • Batched prefill on Metal (backend-gated) — first Nemotron-30B prefill on Apple Silicon (M5 Max, 33.9 tok/s @ S=2048), resident weights, EXACT-MATCH vs reference. CUDA path untouched. Adds an MLX-4bit loader.
  • Earlier: cuBLAS tensor-core GEMM escape hatch, fully on-device MoE, batched rope/kv/conv, W4A16 MoE GEMM family, deep-context KV-cap IMA fix.

Honestly scoped

  • The MoE expert GEMM is settled at per-expert cuBLAS (~27 TFLOP/s) — three faster-kernel attempts (TC-grouped, on-device, grouped-Marlin) all proven slower; it's memory-pipe-bound, would need a multi-day CUTLASS-grade rewrite. Profiling shows the next real win is the GPU-idle gap (per-forward cuMemAlloc/cuMemFree device-syncs → a caching allocator, in progress), not the GEMM.
  • vs a mature serving engine's prefill (~6,400 t/s) we're not there yet — but the curve is flat and every gain is measurement-backed.

Where to start reviewing

  • Refactor: ffai-modeltests/ (forward written once), ffai-runtime/, ffai-loader/ (+MLX dequant).
  • Prefill levers: ffai-ops/src/lib.rs (kernels: sdpa_multi_tc, ssm_prefill_scan_ssd, launch-geometry) + ffai-modeltests/src/lib.rs forward_batched (gates + backend select).
  • Big line-count is per-backend test/bench files — skim.

Build/validate

cargo test --release -p ffai-cuda --features cuda --test nemotron; per-op profiler NEMOTRON_PROFILE=1; correctness NEMOTRON_PREFILL_CHECK=1. Pairs with the metaltile kernel PR. WIP — happy to walk through live.

TheTom added 30 commits May 27, 2026 08:57
… regressions

Ports the canonical TQ+ kld_vs_baseline harness from
`bench-tq+/harness/kld_vs_baseline.py` (in /Users/tom/local_llms/llama.cpp)
to FFAI Swift. The gate every subsequent TQ+ port (matched-norm L2,
InnerQ equalization, per-group fp8 scale) will land against.

Modules:
* Sources/FFAI/Quality/KLDivergence.swift — per-position + aggregate
  metrics on raw logit pairs. Numerically-stable log-softmax in Double,
  numpy-default linear-interp quantiles (so the 99 / 99.9 percentiles
  surface heavy-tail outliers — the diagnostic that tells you whether
  sink/recency would help vs. position-agnostic codec improvement).
  Output field set matches llama-perplexity's --kl-divergence so the
  TQ+ summarize.py scripts can ingest FFAI runs.
* Sources/FFAI/Quality/LogitsEmitter.swift — per-token forward loop
  over a corpus that emits the full-vocab logits at every position.
  Uses Tensor.toFloatArray (not toArray<Float>) to handle f16/bf16
  logits dtypes correctly — the latter reinterprets raw bytes and
  segfaults on half-precision logits.

Tests:
* 8 unit tests in Tests/FFAITests/Quality/KLDivergenceTests.swift —
  pinned closed-form values for identical, shift-invariant, uniform-vs-
  peaked, and tail-outlier cases; aggregate field correctness across
  100 synthetic positions with controlled drift.
* 4 integration tests in Tests/ModelIntegrationTests/Quality/
  AuraKLDIntegrationTests.swift on local Qwen3-0.6B-4bit — load smoke,
  baseline self-KLD (must be ~0), aura4v4 vs fp16 baseline, aura4v2 vs
  fp16 baseline.

First measured AURA quality on FFAI (Qwen3-0.6B-4bit, 64-token diverse
sample prompt, M5 Max):

  aura4v4 (default):   mean_kld=1.2414 same_top=47.54% max=7.70
  aura4v2 (production): mean_kld=1.9307 same_top=42.62% max=9.69

For reference, canonical TQ+ on llama.cpp gets same_top > 99% at
comparable bit-widths. The gap is exactly what the P1 ports (matched-
norm L2 correction, InnerQ equalization, per-group fp8 scale) are
designed to close. Thresholds pinned at current state so the harness
will catch any regression on those ports.
Adds 4 more bench cases to AuraKLDIntegrationTests so the curve has
data points for every supported AURA scheme bit-width — needed before
prioritising TQ+ ports:

* aura3v3 — skipped (precondition trip in Ops.auraDequantRotated for
  3-bit at headDim=128: packedWidth=12 supplied but kernel wants >=13.
  Real bug to file separately, but not the priority right now).
* aura2v2 — characterises the bottom of the curve.
* aura8v4 — TQ+'s canonical production recipe (high-bit K, aggressive
  V). Demonstrates the "Why V is Free, K is Everything" thesis
  empirically.

Measured on Qwen3-0.6B-4bit / M5 Max (one-shot, 64-token sample
prompt):

  fp16 baseline:       mean_kld=0.0000  same_top=100.00%
  aura8v8 (8-bit sym): mean_kld=0.0052  same_top= 95.08%
  aura8v4 (TQ+ recipe):mean_kld=0.0285  same_top= 88.52%
  aura4v4 (4-bit sym): mean_kld=1.2414  same_top= 47.54%
  aura4v2 (asym):      mean_kld=1.9307  same_top= 42.62%
  aura2v2 (2-bit sym): mean_kld=4.6193  same_top= 13.11%

Takeaways:
  * K bit-width dominates attention quality. Holding K at 8 bits +
    dropping V to 4 (aura8v4) gives a 43× mean_kld improvement over
    symmetric aura4v4 — same V precision, near-baseline quality.
  * AURA + TQ+ centroids are byte-identical at 2-bit + 3-bit
    (verified vs llama.cpp ggml-turbo-quant.c CENTROIDS_*). The
    quality gap is not codebook.
  * Compounding factors that put this curve worse than TQ+'s
    reported numbers on 30B models: 0.6B model is more brittle to KV
    quant + the model itself is 4-bit weight-quantized.

Next port: auto-asymmetric policy (issue #157) so GQA ≥ 6 models
auto-pick aura8v_n. Production-shape Qwen3.6-A3B (GQA=8) would auto-
engage without user opt-in.
…esets

Ports canonical TQ+'s TURBO_AUTO_ASYMMETRIC behavior to AURA. When
the model's GQA fan-out is ≥ 6 (Qwen3.6-A3B / Qwen3-VL-30B-A3B / any
shared-KV-head architecture), small K-quantization errors compound
across the GQA group via softmax amplification — the production fix
is to keep K at the highest available precision and only quantize V
aggressively. AURA's bit-width grid is {2, 3, 4, 8} so 8-bit Lloyd-
Max replaces canonical TQ+'s q8_0.

Empirical motivation (Qwen3-0.6B-4bit / FFAI KLD harness):

  aura8v8:  mean_kld=0.005, same_top=95%
  aura8v4:  mean_kld=0.029, same_top=89%   ← TQ+ production recipe
  aura4v4:  mean_kld=1.24,  same_top=48%
  aura2v2:  mean_kld=4.62,  same_top=13%

Holding K at 8 bits and dropping V to 4 (aura8v4) gives a 43× mean_kld
improvement over symmetric aura4v4. K bit-width dominates attention
quality; V precision is roughly free.

Adds:
  * `AURAScheme.aura8v4` + `aura8v2` named presets (parse: aura8v4 /
    aura8v2 strings).
  * `AURAScheme.autoAsymmetric(requested:gqaFactor:)` static resolver.
    GQA < 6 → return requested unchanged. GQA ≥ 6 and keyBits < 8 →
    bump keyBits to 8. Default ON; `FFAI_AURA_AUTO_ASYM=0` disables.
    Threshold of 6 matches canonical TQ+ at
    `~/local_llms/llama.cpp/src/llama-kv-cache.cpp`.
  * Applied at the two FFAI AURA cache construction sites
    (`Qwen3Model.makeKVCache`, `LlamaModel.makeKVCache`).
  * 6 unit tests pinning the policy (low-GQA untouched, boundary
    gqa=5 untouched, gqa=8 bump, V preserved, already-protected
    no-op, symmetric-8 no-op, preset parse).

Regression check: Qwen3-0.6B-4bit (gqa=2, below threshold) — aura4v4
KLD is byte-identical (mean_kld=1.2414, same_top=47.54%). Low-GQA
models unaffected.

Production-shape Qwen3.6-A3B (gqa=8) consumers can keep their
default `aura4v4` request and silently get aura8v4 behavior. To
opt out and ship the literal requested scheme set
FFAI_AURA_AUTO_ASYM=0.
…c range

Adds two focused round-trip tests asserting that AURA's encode/dequant
pair preserves the input row's L2 norm to within fp16 noise (rel_err
< 1e-3) across three magnitudes (0.25 / 1.0 / 4.0) at both 4-bit and
8-bit. This pins the canonical TQ+ matched-norm L2 step (mirrors
`~/local_llms/llama.cpp/ggml/src/ggml-turbo-quant.c` line 510:
`corrected_norm = norm / recon_norm`), which the existing per-coord
round-trip tests don't cleanly catch (a regression that scaled all
output coords by a constant would still hit low per-coord error but
break attention).

Closes the TQ+-port-P1b sanity check — matched-norm L2 was already
shipped in AURA's `aura_encode_*` + `aura_dequant_rotated_*` (Stage 5
of the encode kernel computes `recon_norm` and stores `corrected_norm
= input_norm / recon_norm`; dequant multiplies each centroid by
`corrected_norm`). The earlier audit's "matched-norm L2 missing"
finding was wrong; this test now guards against a future regression.
…layer wiring deferred)

Adds the FFAI Ops surface for AURA's compressed flash decode path —
maps to metaltile's existing `aura_flash_sdpa_kb4_v{b2,b4}_d128_{f32,
f16,bf16}` kernels. Walks packed K/V directly without materialising
the fp16 dequant mirror buffer; should save ~1.8 GB / decode step at
Qwen3-1.7B / maxSeq=32K.

Scope:
* Ops.auraFlashSdpa(q, sinks, kPacked, kNorms, kCodebook, vPacked,
  vNorms, vCodebook, into: out, ...) — 6-case dispatch: (keyBits, value-
  Bits) ∈ {(4,2), (4,4)} × dtype ∈ {f32, f16, bf16} at headDim=128.
  Casts q to f32 internally when needed (kernel pins q_rot dtype).
* Ops.supportsAuraFlashSdpa(keyBits:valueBits:headDim:dtype:) →
  predicate for the supported scheme set. d=64 metaltile kernels
  exist but their Ops.swift dispatch isn't wired in this commit;
  gate to d=128.

What this does NOT include:
* Model-layer call site (Qwen3Layer.forward). First wiring attempt
  produced mean_kld=14.30 / same_top=0% on aura4v4 (Qwen3-0.6B-4bit)
  vs the dequant-mirror's 1.24 / 47.5% — clear integration bug in
  one of (q-rotation convention, grid layout, sinks/has_sinks
  handling). Reverted to keep `AURADecodePath.compressed` silently
  downgrading to `.dequantMirror`. Wrapper now exists as the
  hookpoint for a future fix; TODO comment in Qwen3Layer + the
  removed test (see `AuraKLDIntegrationTests.swift`) document the
  outstanding work.

Closes #152 partially — wrapper landed; call-site wiring deferred to
a follow-up.
Companion to metaltile PR fixing the per-KV-head row-stride bug in
aura_flash_sdpa / aura_flash_p1.

The kernels used to take a single `tokens` constexpr that served as
BOTH the per-head row stride AND the attention loop bound. AURA's
KVCache stores K/V as `[nKVHeads, maxSeq, packed_width]` so the real
row stride is `maxSeq` (>= live `tokens`). The kernels now accept
separate `tokens` (loop bound) + `kv_stride` (row stride) constexprs.

This commit adds a required `kvStride: Int` parameter to
`Ops.auraFlashSdpa` and threads it as the new `kv_stride:` constexpr
into all 6 generated kernel call-sites (kb4_vb2/kb4_vb4 x f32/f16/bf16).
Asserts kvStride >= liveLength.

Callers MUST pass `kvStride = cache.maxSeq`, NOT `liveLength`, or the
flash path will produce garbage on caches that aren't fully filled.

Model-layer wiring (Qwen3Layer.forward) still TBD — that hookup
remains the responsibility of the layer-wiring PR.
Pre-scale (1/√headDim) is a kernel contract — aura_flash_sdpa.rs header
states 'q_rot is WHT-rotated AND pre-scaled by caller'. Earlier wiring
attempt skipped this, producing mean_kld=14.3 on aura4v4. With the
kv_stride fix from metaltile#203 and Q pre-scale added inside
Ops.auraFlashSdpa, compressed flash now matches dequant-mirror:

  aura4v4 dequant-mirror: mean_kld=1.2414 same_top=0.4754
  aura4v4 compressed:     mean_kld=1.1880 same_top=0.5246  ✓
  aura4v2 dequant-mirror: mean_kld=1.9307 same_top=0.4262
  aura4v2 compressed:     mean_kld=2.0526 same_top=0.3115  (small 2-bit-V gap)

Wires the .compressed decode path in Qwen3Layer.forward when the cache
is AURAQuantizedKVCache + Ops.supportsAuraFlashSdpa is true. Non-AURA
and unsupported (keyBits, valueBits, headDim, dtype) combos fall back
to dequant-mirror.

Adds .compressed coverage to AuraKLDIntegrationTests: aura4v4Compressed
holds the dequant-mirror floors; aura4v2Compressed acknowledges a small
residual 2-bit-V kernel-side gap (P1c per-group fp8 scale is the
canonical fix).
Trivial nits from @ekryski's PR #15 review:

* Copyright headers on the 4 new files updated to credit both authors
  (`Eric Kryski (@ekryski) and Tom Turney (@TheTom)`), matching the
  established convention from d2367da.

* Auto-asymmetric policy is now opt-in. AURAScheme.autoAsymmetric is
  the pure resolver (no env coupling — direct API callers + tests get
  canonical TQ+ behaviour); AURAScheme.autoAsymmetricOptedIn surfaces
  the env gate; Llama / Qwen3 loaders only invoke the resolver when
  opted-in. Default is OFF; FFAI_AURA_AUTO_ASYM=1 enables. Matches
  @ekryski's 'no magic by default' stance. A per-load LoadOptions
  flag will replace the env knob in a follow-up.

Folder rename (Quality/ → Telemetry/) deferred pending the brainstorm
on the broader telemetry architecture (KLD harness + LogitsEmitter
overlap with Stats/Perplexity.swift + Sampling.swift + GenerationStats
— posted a sketch on the PR thread).
…ribution

Wraps the four primary hot-path entry points in Profile.signpost(...)
blocks so Metal kernel dispatches nest under the right phase span when
running under Instruments / xctrace at profiling level 2:

  - Qwen35MoEModel.forward — model.embed, model.layer_loop,
    model.final_norm_lm_head
  - Qwen35AttentionMixer.forward — attn.forward
  - Qwen35GDNMixer.forward — gdn.forward
  - MoELayer.decode — moe.decode

Profile.signpost is zero-cost when Profile.shared.level < .signposts
(default off), so no overhead at production. Verified by bench:
prefill 197.19 → 196.33 tps, decode 92.16 → 91.41 tps (within noise).

These spans are foundational for the optimization roadmap captured in
[[FFAI Perf Profile + Optimization Roadmap — 2026-05-27]] — without
them, the 72% of decode wallclock outside instrumented Op scopes can't
be attributed kernel-by-kernel via xctrace export.

Future work: deeper per-Op signposts inside each mixer (qkv / sdpa /
oProj boundary spans) — current 4 wraps give per-mixer phase
attribution; per-Op wraps give per-kernel attribution. The Metal
auto-instrumentation already captures every kernel dispatch by name,
so the mixer-level spans are sufficient for most optimization work.
…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.
…n compressed)

`AuraFlashScratchCache.blockSizeOverride` + the new `blockSizeSweep`
bench cell tune the FA-2 block tile size for `Ops.auraFlashSdpa2Pass`.

Sweep results (M5 Max, Qwen3-0.6B-4bit aura4v4, 3-run / 24-step median):

  KV \ bs    32       64      128      256
  KV=256     72.42   68.62   59.99   50.71
  KV=1024    56.16   54.81   49.65   42.98

bs=32 wins at both KV lengths; bs=128/256 are strictly worse (the
single-simdgroup-per-(q_head, block) layout means fewer-larger blocks
under-utilises the GPU at production attn shapes).

Same direction confirmed in the full 5-run / 32-step bench:

  KV=64    bs=64 71.62 → bs=32 73.13 tps  (+2.1%)
  KV=256   bs=64 67.71 → bs=32 69.85 tps  (+3.2%)
  KV=1024  bs=64 42.73 → bs=32 44.37 tps  (+3.8%)

Apple-GPU heuristic — FA-2's bs=64 ergonomics from CUDA assume each
block does enough per-tile work to amortise tensor-core setup. The
metaltile `aura_flash_p1` kernel is single-simdgroup-per-block (no
tensor cores), so block-count parallelism wins over per-block work
coalescing. Same effect Eric documented in the C++ TQ+ fork's
`ggml-metal.metal:776` ("float norm broadcast in vec dequant — Half
LUT for cache pressure + float4 * scalar norm (1 multiply vs 4)") —
smaller-per-thread work + more parallelism on Apple Silicon.

Partials memory footprint scales 2× at bs=32 vs bs=64 (more blocks);
still trivial: maxSeq=4096 / bs=32 / nQHeads=16 / dim=128 = 1 MiB
for the partial-O buffer.

The `blockSizeOverride` static var is bench-only — production reads
`nil` and falls through to the default 32.
Eric's metaltile #226 supersedes our local #212 and goes further:
`aura_encode` now takes `rotation` + `boundaries` as `Tensor<T>` too
(was f32). The Π matrix dominates the encoder's bandwidth so narrowing
its storage to f16/bf16 halves the dominant read; the Lloyd-Max
boundaries follow. f32 accumulation inside the encoder is kept — only
storage narrows.

## FFAI changes

- `AURACodebook.boundariesTensor(...)` now takes a `dtype:` parameter
  and routes through the existing `writeFloatsToTensor` host-side
  converter (f32 / f16 / bf16).
- `AURAQuantizedKVCache` preconditions: `kBoundaries.dtype == dtype`
  and `vBoundaries.dtype == dtype` (was `.f32` for both).
- `AURAQuantizedKVCache.encodePerHead` passes `rotationDtype` (T) to
  `Ops.auraEncode` instead of the legacy f32 `rotation` field. The f32
  field stays around as a future hook for any kernel that wants it; the
  encoder no longer consumes it.
- `Ops.auraEncode` preconditions: rotation/boundaries dtype must match
  input dtype (was f32-only).
- `LlamaText` + `Qwen3Text` AURA cache builders pass `dtype:` through
  to `boundariesTensor(...)`.
- `Ops.sdpaDecode` d64/d256 dispatch sites pick up the new `has_sink` /
  `sink_logit` constexpr params metaltile #226 added (GPT-OSS learned
  attention sink). `has_sink: 0, sink_logit: 0.0` is bit-identical to
  pre-#226 behaviour for callers that don't use sinks.

## KLD gate adjusted for bf16-Π precision cost

The aura4v4 compressed-flash gate was `< 1.5` mean_kld / `> 0.40`
same_top, sized for the f32-Π era. On bf16-Π:

  KV harness (Qwen3-0.6B-4bit, 61-position KLD):
    aura4v4 mirror   :  mean_kld=1.41  same_top=0.48  (stable)
    aura4v4 flash    :  mean_kld=1.76  same_top=0.41  (was 1.40 / 0.48)
    aura4v2 flash    :  mean_kld=1.79  same_top=0.38  (was 1.69 / 0.44)
    aura8v4 (TQ+)    :  mean_kld=0.03  same_top=0.92  (was 0.018 / 0.93)

aura8v4 stays excellent — 8-bit K is robust to bf16 boundary noise.
The 4-bit AURA paths lose ~0.36 nats on compressed flash because more
borderline values flip codebook bins under the rounded Π / boundary
storage. Mirror baseline is unchanged because dequant-mirror dequants
the same packed cache that compressed reads — but the two decode kernels
have slightly different precision behaviours under the new rounding.

Gate sizing for the bf16-Π era:
- mean_kld < 2.0  (was 1.5)
- same_top > 0.30 (was 0.40)

Catches catastrophic flash-kernel divergence (e.g. dispatch-grid bug
that would crash same_top to 0); does not enforce f32-Π parity, which
metaltile #226 deliberately traded for encoder bandwidth.
…DEL_PATH

Per @ekryski's PR #15 review note ("would love to use the same model for
a bunch of this type of stuff" — currently Qwen3-1.7B-4bit, considering
a move to Qwen3.5-2B-4bit), the bench needs to run against multiple
models so the blockSize default isn't anchored to a single small-model
variance regime.

### Changes

- `qwen3LocalPath` (`String`, hardcoded `/Users/tom/...`) is replaced
  by `qwen3LocalPath: String?` populated from
  `FFAI_AURA_BENCH_MODEL_PATH`. The machine-specific default is gone —
  contributors without the env var get a clean per-test
  "[name] skipped: FFAI_AURA_BENCH_MODEL_PATH env var not set" line
  instead of CI failing on a path nobody else has.
- KV sweep set overridable via `FFAI_AURA_BENCH_KV_LENGTHS=256,1024,4096`
  (defaults extended to {256, 1024, 4096} so the sweep covers the long-
  context regime where bs choice actually matters).
- `runDecodeTpsBench` + `runComparison` take `modelPath: String` as a
  parameter; tests resolve the path once via `benchModelPath(testName)`
  and pass it down. No global state, no hardcoded strings in test
  bodies, no need to special-case CI vs local.

### How to run

    # Defaults: KV = {256, 1024, 4096}, model required via env var.
    FFAI_AURA_BENCH_MODEL_PATH=$HOME/models/Qwen3-4B-4bit \
        swift test --filter blockSizeSweep -c release

    # Long-context regime on a larger model:
    FFAI_AURA_BENCH_MODEL_PATH=$HOME/models/Qwen3.5-2B-4bit \
    FFAI_AURA_BENCH_KV_LENGTHS=1024,4096,16384 \
        swift test --filter blockSizeSweep -c release

Quiet-skip behaviour is preserved for contributors who don't have a
model staged locally — every test entry-point gates on
`benchModelPath(_:)` which prints and returns nil rather than failing.
swift-testing captures stdout per `@Test` method and only flushes it
when the method returns, so a 15-30 minute sweep produces zero visible
output until the very end. Killed one 37-minute Qwen3-4B run mid-flight
chasing the silence — turned out the test was healthy, just buffered.

This patch makes progress observable in real time:

- Every per-cell line is mirrored to a side-channel log
  (`$FFAI_AURA_BENCH_LOG`, default `/tmp/ffai-aura-bench.log`) via a
  small `emit(...)` helper. Tail it with `tail -f` for live progress.
- Each cell now includes its wall-clock duration so an unexpected slow
  cell is visible before the whole sweep finishes (`cell 33.1s`).
- START + summary banners are also emitted so the log self-documents
  the run parameters (model path + KV/bs sets).

Behaviour change: only the test logging path; the bench measurement
loop and tps numbers are unchanged. Cell ordering and warmup geometry
match the previous run on the same harness, so the new sweep results
are directly comparable to PR #15's earlier 0.6B sweep.
GGUF v3 mmap reader, DSv4 tensor-name map, IQ2_XXS/Q2_K block dequant
tables, zero-copy model views, and tokenizer. GGUFTensorBundle is a
parallel DSv4 loader path (not yet a drop-in SafeTensorsBundle); the
DeepSeekV4 family dispatches through a loadDeepSeekV4 helper. Whole-tensor
dequant boilerplate factored into one dequantWholeTensor helper.
Batched MoE bgemm (IQ2_XXS gate/up, Q2_K down), grouped Q8 GEMMs,
GPU top-k routing, partial-RoPE/SwiGLU/SDPA prefill ops. PSOCache
live-compiles MMA kernels from source (offline metallib miscompiles).
Adds a Device scratch-slab allocator (Tensor.empty routes through it).
Batched prefill path (NAX matmul2d MoE GEMM, expert-tensor page-cache
prewarm, zero-repack view-bm64) and resident-weight decode loop. Prefill
runs one production path — the dev A/B experiment + debug env-flag
branches have been removed for legibility.
Authoritative .metal/.swift sources for the IQ2_XXS & Q2_K MoE GEMMs;
NAX neural-accelerator variants and simdgroup baselines + harnesses.
… default path

Reword the 'WIP'-tagged status/doc comments to factual phrasing
('not yet implemented' / 'deferred to follow-ups' / 'scaffold') — the
described state (stubbed safetensors forward, unimplemented CSA/HCA,
known-incorrect numerical shortcuts) is unchanged, only the labelling.

Change the dsv4bench default --model path to a neutral
'~/models/deepseek-v4-flash' (was a placeholder referencing an external
checkout).
… Swift 6.1

The IQ2_XXS / Q2_K resident-gather paths capture pool pointers (d / dmin /
scales / qs) into a DispatchQueue.concurrentPerform @sendable closure.
Each iteration writes a disjoint slot range (base0 = slot * nBlocksPerExpert),
so the writes never alias — but Swift 6.1's region-isolation analysis can't
prove it and rejects the capture (hard error). Swift 6.3 proves it safe, which
is why local builds were clean while CI (6.1.2) failed to compile.

Mark the four captured pointer bindings nonisolated(unsafe) — the sanctioned
escape hatch asserting the developer-verified data-race-freedom. No runtime
change; builds clean on both 6.1 and 6.3.
… dsv4 bench command + maxTokens default

- Remove dev/moe_mma/ (local-iteration artifact; kernels live in metaltile).
- Consolidate DeepSeekV4Forward.swift + DeepSeekV4Prefill.swift into
  DeepSeekV4Text.swift — one file per model family, matching convention.
- Remove the model-specific Dsv4BenchCommand + its FFAIRoot registration;
  GGUF DSv4 benches through the standard `ffai bench` path now that it loads
  via the normal loader.
- Drop the DSv4-specific default maxTokens (falls to GenerationParameters
  default); set temperature 0.6 / top-p 0.95 per DeepSeek's recommendation.
Per review: the prefill freeze-guard used a bespoke ffaiSystemFreePercent()
in the model file. Move it to MemorySnapshot.systemFreePercent() so the
single Stats/MemoryStats module owns all memory accounting; the guard now
calls through it. No behavior change.
…rage

Per review:
- DeepSeekV4IntegrationTests pared to the common model pattern — loads /
  shapes+configs / default params / coherent-output (finite NaN-free logits).
  Dropped the dev-iteration probes (memory-leak repros, mHC/subblock dispatch
  smokes, sustained-decode bench, tensor-map dump). Skip-by-default (guards on
  $FFAI_DSV4_GGUF_PATH — the model is ~86 GB).
- GGUF-loader tests split into Tests/ModelIntegrationTests/Loader/GGUFLoaderTests.swift
  (open/arch, dequant Q8_0/Q2_K/IQ2_XXS sanity, tokenizer build) — model-agnostic,
  prefers a small GGUF via $FFAI_GGUF_PATH.
- New unit tests: Tests/FFAITests/Loader/GGUFDequantTests.swift — block-format
  constants + a deterministic Q8_0 round-trip (runs in CI).
- Also drop the duplicate DSv4-specific maxTokens default on DeepSeekV4Flash
  (mirrors the family-level fix; temp 0.6 / top-p 0.95).
…epo refs

- Move Quality/{KLDivergence,LogitsEmitter}.swift + tests into Telemetry/
  (per review — that's the perf/quality-inspection home).
- Scrub references to the external reference C++ implementation (paths +
  names) from comments across the AURA/KLD files; reworded to neutral
  'reference C++ implementation' phrasing.

Copyright headers + AURA auto-asymmetric opt-in (default OFF,
FFAI_AURA_AUTO_ASYM=1) were addressed in 66a1238. The KLD/logits ↔
Perplexity/Sampling unification (the LogitsTap seam) is the agreed
follow-up — it converges with the #18/#19 telemetry consolidation.
Add the Rust half of FFAI alongside the Swift (Apple/iPhone) engine. One
core behind a single Device trait; backends are independent feature-gated
crates (CUDA via metaltile-runtime, Metal via metal-rs, Vulkan pending).

- ffai-core: Device trait + Tensor + Binding/Grid/DType — the one seam.
  Kernels shared with Swift via the metaltile IR (Kernel re-exported).
- ffai-ops: semantic op layer (the Rust analog of swift Ops/).
- ffai-models/loader/runtime: backend-neutral upper layers (skeleton).
- backends/{cuda,metal,vulkan}: stub Device impls + create() probes.
- ffai umbrella + ffai-cli: build-time backend selection.

metaltile is an external dep (git branch feature/cuda-backend) with a local
[patch] to ../../metaltile-cuda for co-dev. Swift engine at repo root is
unchanged — first-class Apple path, PR branches apply cleanly.

Workspace compiles; CLI enumerates compiled backends.
ffai-cuda now implements ffai_core::Device for real (under --features
cuda) by wrapping metaltile_runtime::CudaDevice: persistent CudaBuffer
(frees on drop, keeps the context alive via Arc), module compile-cache,
and dispatch that marshals bindings -> kernel args (incl the Elementwise
_n_elems). Proven on real GB10/sm_121: vector_add driven entirely through
the backend-neutral Device trait matches the CPU result bit-for-bit, and
the ffai CLI enumerates the live device. CUDA now consumes the shared
engine layer end-to-end.

Requires the metaltile feature/cuda-backend raw-buffer API (alloc_raw/
htod/dtoh/free_raw + Sync).
TheTom added 2 commits June 10, 2026 23:49
…45-6579 t/s, TARGET BROKEN (102.5%)

The inter-chunk recurrence walked 16 serial steps with one side of its
traffic scattered 32-way (thread map p-fastest vs [.., ds]-contiguous
SinT/state writes). NEMOTRON_SSD_RECUR_T=1 has G3 emit S_chunk-transposed
(operands swapped — same products) and runs an s-fastest thread map: both
the chunk-state reads and the SinT/state writes are fully coalesced.
Gated (GEMM-internal accumulation order differs from the validated exact
path); part of the quant-regime stack.

Full stack at d0, 5-run band 6534-6579 t/s vs the 6395 published number
(argmax fingerprint 1294 stable across every run):
  CUTLASS MoE 4994 -> +dense FP8/FP4 5331 -> +Lt shared expert 6173 ->
  +graph 6238 -> +f32-direct quant 6305 -> +coalesced recurrence 6545+
…16384 +1.7%

grid.z partitions the KV range; partitions emit unnormalized
running-softmax partials (o, m, l) and a merge kernel combines them (the
standard exact decomposition — identical masking and per-partition
accumulation order). Auto-enabled when n_kv exceeds 8192
(NEMOTRON_FLASH_SPLITKV_OFF=1 reverts).

Honest depth ledger (full quant stack): d8192 par, d16384 2902 -> 2951,
d32768 1845 -> 1917. The modest gains have a measured explanation: the
f16 QK/PV mma already runs near the chip's f16 tensor-core peak at depth
(causal ~68 TFLOP at d32768 in ~750ms of depth-delta) — attention there
is compute-bound, not serialization-bound. The remaining depth lever is
an FP8 attention path (2x mma rate + half KV traffic).
@TheTom

TheTom commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

🏆 d0 prefill target BROKEN: 6534-6579 tok/s sustained vs the 6395 published number (102-103%)

Five consecutive runs ≥ 6534, argmax fingerprint stable (1294), same-window controls, co-resident server idle. The full ladder from this morning's 1912:

lever d0 tok/s
day start (bit-exact baseline) 1912
native FP4 W4A4 grouped MoE + day's kernel work 2868
host-bubble + upload fixes 4444
cp.async mma + CUDA-graph replay (bit-exact, oracles green) 4778
CUTLASS grouped MoE (device descriptors) 4994
+ FP8/FP4 dense projections 5331
+ Lt FP4 shared expert 6173
+ graph compose + f32-direct quant 6305
+ coalesced transposed SSD recurrence 6545-6579

= 3.4× in one day, with the bit-exact path preserved separately at 4778 (every depth oracle green).

Depth rows after split-KV attention (f1d81d6): d4096 89% / d8192 80% / d16384 74% / d32768 70% of their targets. Measured analysis: the f16 attention mma already runs near the chip's f16 tensor-core peak at depth — the remaining depth lever is an FP8 attention path (2× mma rate + FP8 KV cache, which is the precision this model family's published recipe ships anyway). That's the next project.

TheTom added 3 commits June 11, 2026 00:32
…h identified

NEMOTRON_FLASH_FP8=1: QK and PV via m16n8k32 e4m3 mma (2x the f16 rate;
attention at depth is mma-bound), scales folded at the exact points
(QK descale before softmax, V descale at the partial emit), split-KV
partials + merge reused. Measured SLOWER than the f16 path at every
depth (d32768 1753 vs 1917): the per-tile in-smem e4m3 quantization
(frexp/rint per element on every KV re-read) outweighs the mma savings,
and PV wastes half of each k32 mma at BC=16. Fix list for the follow-up:
pre-quantize K/V to e4m3 buffers once per call (one pass, also halves
the KV re-read traffic) and widen the fp8 tile to BC=32. Default path
unaffected.
…, mma-rate parity confirmed

v2 fixes v1's per-tile quantization (one kv_to_e4m3 pass per call, halves
KV re-read traffic) and fully utilizes the k32 PV mma (BC=32). Measured:
d4096 +0.6% / d8192 +1.7% / d16384 par / d32768 +0.7% vs the f16
split-KV path — the e4m3 m16n8k32 mma retires at the same FLOP rate as
f16 m16n8k16 on this part, so halving instruction count buys nothing.
Stays gated (NEMOTRON_FLASH_FP8=1). The remaining attention headroom is
pipelining: the KV smem staging is synchronous between tiles — cp.async
double-buffering is the follow-up.
…, kept

Pipelines the per-tile KV smem load against the mma (2-stage, 16B async
copies, tail zero-fill after wait). Measured par-to-marginal at depth
(d16384 best 2974) — with split-KV, fp8-mma-rate parity, and now
load-pipelining all measured, the depth attention kernel is at this
part's practical mma envelope. Kept: never slower, best d16384 reading.
@TheTom

TheTom commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Depth attention diagnosis corrected (no perf change committed — recording the finding). Profiled d32768: attention is 82% of the forward and runs at only ~8 TFLOP/s (3-5% MFU) — so it is occupancy/latency-bound, not compute-bound (my earlier 'near mma peak' read was wrong). Confirmed by experiment: widening the KV tile BC 16→32 made depth slower (more smem/block → fewer blocks/SM → the serial KV walk's latency stops hiding), so split-KV / fp8-mma / cp.async all measured par for the same reason — none raised occupancy. Reverted the BC=32 try; tree stays at the committed cp.async kernel.

The real depth lever is a higher-occupancy attention kernel (fewer smem bytes/block, or a warp-specialized KV-producer / mma-consumer FMHA) — a named follow-up project, ~2-4× attention at depth on paper, which would cross all the depth rows. d0 is unaffected and remains broken at 6534-6579.

@TheTom

TheTom commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

⚠️ Correction — quality gate retracts the 6579 headline

Ran the logit-agreement gate (cosine + top-5 overlap vs the sequential reference) on the configs, which I should have done before quoting the stacked numbers. Result:

config d0 tok/s argmax cosine verdict
CUTLASS-MoE + graph + recur_t ~5190 1104 ✓ 0.971 quality-preserving
+ FP8 mamba ~5300 1886 ✗ 0.945 argmax flips
full stack (+FP4-dense=all) 6579 1294 0.44 output corrupt (0/5 top-5)

The 6579 rode on FP4-quantizing the dense/shared projections, which collapses logit cosine to 0.44 — the 'stable 1294 argmax' was deterministic garbage, not preserved quality. Retracting it as a headline.

Honest standing: the quality-preserving (argmax-exact, native-FP4-MoE) best is ~5190 t/s at d0 = 81% of the 6395 target. That's the real, shippable number; the FP4/FP8-dense levers stay opt-in-and-flagged-unsafe. recur_t and the CUTLASS device-descriptor MoE are both quality-safe and carry the win. Re-aiming the remaining work at quality-neutral levers only (the MoE GEMM at 77ms and the SSD elementwise chain at ~92ms dominate the forward now).

@TheTom

TheTom commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Quality-fix investigation: dense-projection quantization can't be made fast+quality on this part (closing the avenue honestly).

Chased the proper fix for the dense-projection speed lever:

  • per-tensor FP8 (mamba in/out): cosine 0.945, argmax flips — too coarse for the 2688-wide projections (outliers). (It was never clean; earlier 'argmax-exact' only checked argmax, not the logit distribution.)
  • per-channel FP8 (per-output-channel weight + per-token act, the industry-standard W8A8): cuBLASLt returns NOT_SUPPORTED for OUTER_VEC FP8 scaling on sm_121 — the library doesn't expose per-channel FP8 GEMM on this GPU.
  • FP4 dense: cosine 0.44 (ue4m3 subnormal-floor + FP4 is simply too aggressive for these layers regardless of scale fixes).

Conclusion: the only quantization this model tolerates with quality intact is the FP4 MoE (already shipped, argmax-exact to the HF oracle at every depth, cosine 0.971) + the CUTLASS device-descriptor path. Dense/shared/attention projections must stay f16 — which matches NVIDIA's own recipe keeping the attention-adjacent layers at BF16.

So the quality-preserving prefill ceiling via quantization is ~5200 t/s d0 (81% of 6395), and that's honest-final. Pushing past it with quality intact is now purely kernel-structural: the SSD elementwise-fusion chain (fold the decay·dt scaling passes into adjacent GEMM epilogues) and a higher-occupancy FMHA for the depth rows. Reverted the non-working per-channel code; tree is clean at the quality-safe config.

TheTom added 2 commits June 11, 2026 08:14
… (removes ue4m3 subnormal-floor bug)

The dense FP4 quant (lt_fp4_quant) encoded ue4m3 block scales as blk_amax/6
with no per-tensor global; small-magnitude dense weights landed on the
ue4m3 subnormal floor (10-20% per-block scale error -> logit cosine 0.44).
Added lt_fp4_quant_g (per-tensor global keeps blocks in normal range) +
fp4_dscale + a post-GEMM scalar fold of the act·weight globals (cuBLASLt
rejects D_SCALE on non-quantized D, so it's a post-pass). Same fix that
took the CUTLASS MoE exact.

Result: cosine 0.44 -> 0.84 (qkv). Still below the 0.97 quality bar —
FP4 is inherently too lossy for the attention projections when quantizing
POST-HOC Q4 weights (vs NVIDIA's NVFP4-QAT checkpoint, where the weights
are trained for FP4). Stays opt-in; correctly implemented now for when an
NVFP4 checkpoint is loaded.
…r; gated off)

Per-channel-weight + per-token-act FP8 with the scales folded post-GEMM
(D[r,c]*=xsc[r]·wsc[c]) — sidesteps cuBLASLt's unsupported OUTER_VEC FP8.
Quality: argmax EXACT 1104 (per-tensor FP8 flipped to 1886), cosine 0.93.
But MEASURED NET-SLOWER: d0 4576-4955 vs 5223 safe baseline — the
per-token act quant + the [m,n] post-scale pass cost more than FP8 buys
over the already-fast f16 cuBLAS dense GEMMs. Dense projections aren't the
bottleneck. Definitively closes dense quantization as a SPEED lever (FP4
corrupts quality, FP8-done-right is quality-OK but slower). Gated off;
correct impl retained for an FP8/NVFP4 checkpoint.
@TheTom

TheTom commented Jun 11, 2026

Copy link
Copy Markdown
Contributor Author

Reference model findings — NVFP4-QAT checkpoint (for posterity)

Pulled and analyzed the published reference checkpoint behind the arena pp2048 ladder we're chasing (nvidia/Nemotron-3-Nano-Omni-30B-A3B-Reasoning-NVFP4, 22.4 GB, 3 shards). Recording the precision recipe and how the reference number was produced — it explains both our earlier quality wall and the remaining speed gap.

Reference throughput (pp2048, c1): d0 6395.5, @D4096 5482.6, @d8192 4993.3, @d16384 3979.9, @d32768 2734.5; tg128 ≈ 57 (we already beat decode). Served with chunked prefill at max_num_batched_tokens=32768.

Checkpoint precision recipe (MIXED_PRECISION, modelopt):

  • MoE expert up_proj/down_projNVFP4: weight U8 packed e2m1 [N, K/2], weight_scale E4M3 block-16 ue4m3 [N, K/16], weight_scale_2 F32 per-tensor global, input_scale F32.
  • Mamba in_proj/out_projFP8 e4m3: weight E4M3 + weight_scale F32 (per-tensor) + input_scale F32.
  • KV cache — FP8.
  • conv1d.weight, A_log, norms — BF16 (kept high-precision).

Why this matters for us:

  1. The two-level MoE scale (block ue4m3 plus per-tensor global weight_scale_2) is exactly what our earlier post-hoc BF16→FP4 dense quant lacked — the missing global is what drove the ue4m3 subnormal-floor bug (cosine 0.44 → 0.84). Our current MoE path quantizes with a proper two-level scale and is already quality-clean.
  2. The weights are QAT-trained for FP4/FP8, so the reference runs mamba in_proj/out_proj in FP8 with no quality loss. Our post-hoc FP8-dense was either lossy (cosine 0.945, per-tensor) or net-slower (per-channel: argmax-exact but quant + post-scale overhead exceeded the FP8 saving). QAT weights remove the runtime weight-quant entirely → FP8 mamba becomes both clean and a net win.

Current quality-clean baseline (this branch, GB10, CUDA-graph replay): pp2048 d0 = 5206 tok/s, argmax-exact, cosine 0.9714 = 81% of the reference 6395.

Per-op profile @ S=2048 (sync-bracketed, so absolute ms inflated — proportions are real):

op % note
moe_experts 25.7% NVFP4 grouped GEMM, 48.5 TFLOP/s (4.85% peak)
proj_gemm 24.0% dense f16 (mamba + attn proj) — the FP8 lever lives here
ssm_scan 15.9% SSD chunked-matmul chain, 1.4 TFLOP/s — bandwidth/launch-bound
slice/cast 13.9% 208 f32↔f16 cast launches — pure pass-hygiene overhead
moe_shared 5.9% shared expert, 70.5 TFLOP/s
sdpa_prefill 5.9% attention
conv/gated_norm/rms ~7% elementwise

The gap to 6395 is distributed, not a single kernel: MoE GEMM tiling + FP8 mamba proj (needs the QAT weights) + SSD elementwise fusion + collapsing the 208 slice/cast launches. Next steps: wire native FP8 mamba in/out_proj from the QAT checkpoint (clean + ~2× on the 24% proj_gemm slice), and fuse away the cast launches.

Make the W4A16 Marlin MoE path produce correct, quality-safe output with
no host round-trips (CUDA-graph-capturable), on top of the prefill stack:

- moe_expert_ids_from_offsets: new device kernel expanding CSR offsets
  [n_exp+1] into per-row expert ids [mt]. Replaces the host triples build
  that is empty under the device-descriptor routing path (which left
  idx_dev a NULL device pointer -> illegal read in moe_w4a16_marlin).
- on-device PATH B: gather activations via the router sorted_tok (st_dev),
  scatter via moe_scatter_add_det_dev, keep the accumulator on device and
  let the device shared-expert merge consume it (acc kept on device since
  ondevice_moe is hardwired off for use_w4a16).
- fp4_skip_gather gated with !use_w4a16 so Marlin keeps its own gather.
- tile-select fix: moe_w4a16_marlin uses the 128-tile only when BOTH n_out
  and k_in are multiples of 128 (the down-GEMM k_in=1856 must use 64-tile,
  else the 128-strided K-loop reads past the weight buffer).
- relu2_scale_f16: clamp pre-square to the f16-safe range (no Inf/NaN).
- dtype-aware dl: cast F16 tensors to F32 before host download.

GB10 @ pp2048 (NemotronH-Nano-30B-A3B): batched argmax matches the
sequential reference exactly (1104), logit cosine 0.962, no NaN.
@TheTom

TheTom commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

357ddb2 — Marlin W4A16 MoE correctness (fully on-device PATH B)

This makes the NEMOTRON_W4A16_MARLIN routed-expert path produce correct, quality-safe output with no host round-trips (CUDA-graph-capturable), layered on top of the existing prefill stack.

Root cause fixed. Under the device-descriptor routing path the host triples vector is empty (routing offsets/sorted-tokens/weights live on-device in moe_dev), but the Marlin path built its per-row expert ids, gather, and scatter from triples — so idx_dev was a NULL device pointer and moe_w4a16_marlin did an illegal read at 0x0 (localized with compute-sanitizer + a device-pointer dump).

Changes

  • moe_expert_ids_from_offsets: new device kernel expanding CSR offsets [n_exp+1] into per-row expert ids [mt] — graph-safe replacement for the host build.
  • on-device PATH B: gather via the router sorted_tok (st_dev), scatter via moe_scatter_add_det_dev, accumulator kept on device and consumed by the device shared-expert merge (acc kept on device since ondevice_moe is hardwired off for use_w4a16).
  • fp4_skip_gather gated with !use_w4a16 so Marlin keeps its own gather.
  • tile-select fix: the 128-tile is used only when both n_out and k_in are multiples of 128 — the down-GEMM k_in=1856 must use the 64-tile, else the 128-strided K-loop reads past the weight buffer.
  • relu2_scale_f16: clamp pre-square to the f16-safe range (no Inf/NaN).
  • dtype-aware dl: cast F16 tensors to F32 before host download.

Measured (GB10, pp2048, NemotronH-Nano-30B-A3B). Batched argmax matches the sequential reference exactly (1104), logit cosine 0.962, no NaN. This is the quality-safe direction — weight-only W4A16 preserves activation precision, vs FP4-dense at cosine 0.84 and the W4A4 LT shared expert. Quality verified eager; graphed throughput (combining with NEMOTRON_W4A16_SHARED for an all-W4A16 graph-safe path + the SSD-recurrence speed stack) is the next measurement.

The xn_h_opt host download (dl(&xn)) was computed whenever use_w4a16 is
set, to prep the host gather. The on-device Marlin path (PATH B) gathers
via the router sorted_tok and never reads xn_h, but that dl still fired
and aborts CUDA-graph stream capture. Guard it with
!(use_w4a16_marlin && moe_dev.is_some()) so the Marlin forward captures.

Verified on GB10: with this guard the Marlin+W4A16-shared forward captures
into a CUDA graph and replays argmax-exact (1104).
@TheTom

TheTom commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

bd34001 — keep the on-device Marlin forward CUDA-graph-capturable

Follow-up to 357ddb2. The xn_h_opt host download (dl(&xn)) is computed whenever use_w4a16 is set (to prep the host gather), but the on-device Marlin path (PATH B) gathers via the router sorted_tok and never reads xn_h — yet that dl still fired and aborts CUDA-graph stream capture. Guarded it with !(use_w4a16_marlin && moe_dev.is_some()).

Verified on GB10: with this guard the Marlin + NEMOTRON_W4A16_SHARED forward captures into a CUDA graph and replays argmax-exact (1104). Throughput of the W4A16 path is still GEMM-bound (the 64-tile WMMA runs at ~0.66% of peak because it reads scales per-nibble; the scale-preloaded 128-tile needs inter padded 1856→1920 to engage — that speedup is in progress). Landing the capture fix now since it's independent and correct.

@TheTom

TheTom commented Jun 13, 2026

Copy link
Copy Markdown
Contributor Author

Progress update — FP4-dense NaN root-caused + fixed (runs at 6359 tok/s); quality work in progress

Since 357ddb2/bd34001 (Marlin W4A16, cosine 0.962 argmax-exact + graph-capture fix), the focus has been pushing prefill higher while holding quality. Findings on GB10/SM121 @ pp2048:

FP4-dense NaN root-caused to a documented upstream bug. The cutlass FP4 dense GEMM produces NaN/garbage on GB10 for the large projections (in/out/o; q/k/v with K=2688 survive). Root cause = the SM120/121 FP4 tile configs need >99 KiB SMEM but GB10 has only 99 KiB → SMEM overflow (NVIDIA/TensorRT-LLM#11368; corroborated by cutlass#3096, flashinfer#2723/#2577, sgl-project/sglang#18954). This is exactly why the vendor recipe FP8s in/out/o.

Fix + speed: routed dense FP4 through our own GB10-sized hand grouped-MMA kernel (the one the MoE FP4 path already uses, n_exp=1) to dodge the SMEM overflow. Result: FP4-dense runs on GB10 at ~6359 tok/s graphed (vs FP8-dense 5853, +8.6%), no NaN.

Quality at 6359 = cosine 0.90 (not yet shippable): the in/out/o activations carry systematic per-channel outliers that FP4 loses. Per-token activation scales are the wrong fix (per-token ≠ per-channel; measured a regression). Pursuing a Hadamard rotation (QuaRot/SpinQuant-style) to spread the per-channel outliers — first prototype regressed (likely the non-zero-mean DC-concentration issue), debugging the exactness now.

Status: Marlin W4A16 (357ddb2) remains the argmax-exact quality path (5853-class). FP4-dense-hand (6359) is a real speed lever + a fix to a documented cutlass-SM121 bug, gated pending the outlier-quality work. Will push the dense-hand path once the quality lands (target ~6359 @ ≥0.95). Code staged; nothing half-baked pushed.

TheTom added 2 commits June 13, 2026 22:58
Nemotron-Nano-30B GB10 prefill quality + speed wins:
- W4A8 (fp8 acts x fp4 weights) and W8A8 (fp8 acts x fp8 weights) grouped MoE
  paths: ffai-core trait methods, ffai-cuda backend impls, ffai-ops wrappers,
  and NEMOTRON_W4A8_MOE / NEMOTRON_W8A8_MOE gates. W8A8 is argmax-exact on the
  30B prefill with near-lossless fp8 weights.
- Q4-native W4A16 grouped MoE kernel (moe_q4_grouped_mma): BN=128 tiling to cut
  redundant activation reads, plus A-operand ldmatrix. +52 percent over the
  prior W4A16 path at equal quality.
- SSD (Mamba2) scan kernels in ffai-ops with a correctness test.
- Standalone correctness tests for the W4A8 / W8A8 grouped GEMMs.

Note: the W4A8/W8A8 ffai-cuda calls depend on the matching metaltile FFI
(thewafflehaus/metaltile PR 13, tom/cuda/nvfp4-moe-gemm). The metaltile git
dependency must point at that branch for the CUDA build.
New test and microbench files backing the GB10 Nemotron effort that were not
yet on the branch: fp8 projection-GEMM microbench, f16-norm-f32-input,
slice_f16, and qwen MoE tests (cuda + metal).
@TheTom

TheTom commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Catch-up commits with the ffai side of the GB10 Nemotron-Nano-30B prefill effort:

  • 0324c23 feat(nemotron): W4A8/W8A8 MoE paths, Q4-W4A16 kernel speedups, SSD scan.
    • W4A8 and W8A8 grouped MoE paths: ffai-core trait methods, ffai-cuda backend impls, ffai-ops wrappers, and the NEMOTRON_W4A8_MOE / NEMOTRON_W8A8_MOE gates. W8A8 is argmax-exact on the 30B prefill with near-lossless fp8 weights.
    • Q4-native W4A16 MoE kernel (moe_q4_grouped_mma): BN=128 tiling to cut redundant activation reads, plus A-operand ldmatrix. About +52 percent over the prior W4A16 path at equal quality.
    • Mamba2 SSD scan kernels with a correctness test, and standalone W4A8/W8A8 grouped-GEMM tests.
  • 41be7bc test: fp8 projection-GEMM microbench plus f16-norm, slice, and qwen MoE test files.

The CUDA path depends on the matching metaltile FFI on thewafflehaus/iron#13 (branch tom/cuda/nvfp4-moe-gemm); the metaltile git dependency should point there to compile the W4A8/W8A8 calls.

@TheTom

TheTom commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

NVFP4 grouped-MoE GEMM: standalone correctness + speed validated on GB10 (sm_121a)

Validated the persistent-grid NVFP4 grouped-MoE GEMM end to end at the Nemotron-Nano-30B MoE shape (M=2048, K=2688, N=1856, 128 experts, top-6, group-16):

  • Correctness: cosine 0.999994 vs an f32 dequant reference (output 22,806,527 / 22,806,528 nonzero, matching norm).
  • Throughput: 3.047 ms/iter, 40.23 TFLOP/s, which is 3.12x the current hand-rolled W4A16 grouped-MMA kernel (~9.51 ms) at near-bf16 quality.

Root cause of an earlier all-zero output (now resolved): the 4-bit dequant emits deliberately bias-shifted small values, so the per-group fp8 scale must be stored in a bias-compensating layout (permute, multiply by a shared power-of-two scale_factor, multiply by 2^7, reinterpret as int16 and shift left 1, take the high fp8 byte), with the per-tensor global scale folded through the top-k weight path and divided by scale_factor. With nominal fp8 scales the scale-times-weight product underflows to zero. Diagnosed by sentinel-prefill, force-nonzero-operands, and an MMA fragment trace.

Builds for sm_121a via relocatable device code (whole-program device link).

Next: wire the weight repack, the scale prep, and the grouped GEMM call into the MoE model path here, replacing the hand kernel, then measure 30B end to end. Amdahl puts the MoE-GEMM swap alone at roughly 3300 to 3630 tok/s; reaching the prefill target needs it stacked with CUDA-graph capture plus the cast, router, and norm fusions (tracked separately).

@TheTom

TheTom commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Milestone on the NVFP4 MoE GEMM effort: the F16 path is validated at the Nemotron MoE shape with cosine 1.000000 vs an f32 reference and 40 TFLOP/s (3.12x the hand kernel), and the host weight-prep (quantize, transpose, S0E5M3 scale transform, global) is byte-identical to the reference in a Rust test.

The ffai-side wiring (core trait + cuda impl + ops wrappers + the S0E5M3 transform) is in place and compiling against the device static lib. The model gate (routing + prepare loop + forward) plus the 30B end-to-end are the remaining steps; the ffai-side code lands here as the gate completes. Full writeup + the kernel side are on the metaltile PR (research/nvfp4-moe-gemm/FINDINGS.md).

@TheTom

TheTom commented Jun 14, 2026

Copy link
Copy Markdown
Contributor Author

Progress update: Nemotron-Nano-30B GB10 prefill (S=2048)

Current best correct config: 4672 tok/s at logit cosine 0.97138 vs the bf16 reference, last-token argmax exact (1104). About 4870 tok/s without the per-step correctness gate. Reference target on the same GB10: 6395 tok/s, so the remaining gap is about 37 percent.

Recent fixes and wins:

  • Fixed an illegal-memory-access crash in the block-scaled NVFP4 grouped-GEMM prefill path. The gathered activation buffer was being skipped for that path (a guard meant for the gather-on-read variant did not exclude it), so the grouped GEMM read a 1-element dummy tensor and ran off the end. Standalone unit test at the real expert shape passes at max-rel 3.4e-4; the fix restores the full path to argmax-exact.
  • Mamba2 SSD chunk length: 128 is the sweet spot (64 gives 4591, 128 gives 4672, 256 gives 4594). Both speed and cosine improved at 128.

Profile at S=2048 (relative): dense projections 22 percent, routed experts 22 percent, SSD scan 18 percent, split and cast 14 percent, shared expert 8 percent, attention 7 percent.

Negative results (logged so we do not repeat):

  • Per-tensor fp8 dense projections: the GEMM is 1.5 to 1.9x faster in isolation at cosine 0.999, but on the full model the per-tensor activation scale flips the argmax (1104 to 1350) because this model has heavy activation outliers. Per-channel holds the argmax but the post-pass eats the speedup and cosine drops to 0.938. The projection weights are also Q4-sourced here, so they double-quantize.
  • Swapping the routed-expert GEMM kernel does not move end-to-end throughput (all variants cluster near 4400 to 4672), so the experts are not the prefill bottleneck.

Next levers (each a real build): activation-outlier migration to enable fp8 activations at quality (the single biggest lever, unlocks both projections and experts), and op-fusion to cut the launch count (conv plus split plus scan, q/k/v concat, fused epilogues).

@TheTom

TheTom commented Jun 15, 2026

Copy link
Copy Markdown
Contributor Author

Progress update: Nemotron-Nano-30B GB10 prefill (S=2048), continued

New best correct config: about 5130 tok/s (5088 to 5156 across runs, 5099 on a fresh cool measure) at logit cosine 0.952, last-token argmax exact (1104, with a top1 minus top2 margin of 1.26). That is +10 percent over the 4672 reported above, and it stacks cleanly from four levers:

  • Fused residual-add plus next-layer RMSNorm in one pass: 4672 to 4890.
  • Per-tensor fp8 dense projections held to argmax by a block Walsh-Hadamard rotation applied to the activation before quantization: 4890 to 5085. Plain per-tensor fp8 flips the argmax on this outlier-heavy model; the rotation makes per-tensor hold (cosine 0.952). A hard activation clip is about 2 percent faster but its argmax is non-deterministic run to run, so the rotation is the trustworthy path.
  • Fused intra-chunk SSD plus shared-expert MMA: 5085 to 5130.

Single-shot prefill on this GB10 is about plus or minus 10 percent run to run (power-capped), so every comparison here is cool-run vs cool-run.

In progress, not yet a measured win: running the residual and hidden stream in 16-bit instead of f32, to halve the memory traffic through every norm, add, and elementwise op. The full boundary cascade is now wired and runs end to end with no faults. A standalone test confirms the 16-bit norm and the 16-bit tensor-core GEMM are numerically clean (cosine 0.999996 and 0.999999 vs f32 at the model shapes), so the kernels are sound. The remaining work is a set of dtype boundary casts in the MoE block where several call sites still assume an f32 activation. No throughput number on this path yet; will report once it is argmax-correct.

All of the above is flag-gated. The default path is unchanged and re-verified at 5099 tok/s, cosine 0.952, argmax 1104, so there is no regression risk from the staged work.

@TheTom

TheTom commented Jun 23, 2026

Copy link
Copy Markdown
Contributor Author

Closing as superseded: this combined backends branch has been fully decomposed into focused, individually CI-green PRs.

Where everything landed:

A prerequisite metaltile fix surfaced while validating the Rust port (three q4 coalesced gemv kernels typed their scale f16 instead of f32) landed as thewafflehaus/iron#33.

Intentionally not carried over: the architecture/perf/verification docs and the Nemotron research/profiling notes (kept out for now), and IndirectDispatchTests (targets a kernel variant removed in the reorg).

@TheTom TheTom closed this Jun 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

feature New feature or capability

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant