docs: update PERFORMANCE_AUDIT.md — Part 1 (P0-P3) landed, Part 2 KV cache next

This commit is contained in:
2026-06-02 09:30:06 +00:00
parent 3c295f225a
commit 107d62dd76

View File

@@ -1,11 +1,16 @@
# PERFORMANCE — v17 roadmap toward end-to-end NVFP4 hot path
# PERFORMANCE — v18 NVFP4-everywhere fusion landed
**Verified state.** v17 has the Tier-1 indexer fixes landed (weight path,
buffer width, MQA einsum). Hot-path syncs and allocator churn from earlier
perf rounds are gone. The single_shot now genuinely runs through the
production NVFP4 kernel stack. What remains is **fusion gaps and KV-cache
dtype choices** — the difference between "uses NVFP4 kernels" and "is
NVFP4 end-to-end."
**Current state (2026-06-02).** Part 1 (P0P3) is **LANDED**. The fused
SwiGLU kernel compiles and runs in production. The CUDA RoPE kernel
passes cos=1.000000 vs PyTorch reference. The single_shot generates
coherent English (". The capital of France is...") with the full fused
kernel stack — no NaN, no crashes, 500+ tokens decoded.
**What remains** is KV-cache dtype choices (Part 2) and higher-order
fusion (P4P6). The model now uses NVFP4 GEMM + fused SwiGLU + CUDA RoPE
end-to-end. The KV cache is still BF16 — the next frontier.
**Tag:** `v-p0p1p2p3-fused-swiglu-cuda-rope-20260602`
**On TurboQuant — verdict first, reasoning below.** Don't use it for DSv4.
It's not architecturally compatible with the heterogeneous compressed KV
@@ -15,169 +20,105 @@ right move is FP4 storage for the compressed KV path (paper-aligned per
---
# PART 1 — THE NVFP4-EVERYWHERE GAP
# PART 1 — THE NVFP4-EVERYWHERE GAP (STATUS: ✅ LANDED)
## P0 — Fused SwiGLU exists in the library and is NEVER ENABLED
## P0 — Fused SwiGLU for MoE — ✅ LANDED
This is the biggest single-line perf bug in v17.
**Was:** `set_fused_swiglu(True)` existed but was never called. 240+ BF16
kernel launches per token wasted on unfused SiLU+clamp+deinterleave.
`dsv4/layers/moe.py:61`:
```python
self._fused_swiglu = False # Set via set_fused_swiglu()
```
**Fix (3 bugs in `fused_swiglu.py`):**
1. `kernel()` signature missing `fp4_out`, `sf_out`, `l2_global_scale` params
`TypeError: too many positional arguments` during `cute.compile()`
Fix: added Optional params with None defaults to kernel signature
2. `cute.math.fmin`/`cute.math.fmax` don't exist in CuTe DSL
→ Replaced with `cute.where()` for TensorSSA-compatible clamp
3. Subtile loop used `vectorize=True` (default) — incompatible with `cute.where()`
→ Changed to `cutlass.range(subtile_cnt, unroll=1)`
`set_fused_swiglu()` exists (`moe.py:103`), `warmup_fused_swiglu_compilation`
exists and is wired into the warmup path, the fused kernel
`run_fused_swiglu_grouped_gemm` is implemented and tested. But **searching
`single_shot_inference.py` for `set_fused_swiglu` returns zero hits.**
**Result:** Fused kernel compiles and runs. MoE L1 GEMM + SwiGLU + clamp
in a single kernel launch. ~240 BF16 launches eliminated per token.
What this costs every layer, every token:
**Commits:** fca7242 (arg fix), 3a30f35 (cute.where), 5c746bb (unroll=1)
`moe.py:640660` (the unfused branch that runs by default):
```python
l1_out = run_nvfp4_grouped_gemm(...) # NVFP4 → BF16 GEMM
l1_deil = deinterleave_l1_weights(l1_out...) # BF16 → BF16 deinterleave (extra launch)
gate = l1_deil[:, :self.intermediate_size] # BF16 slice
up = l1_deil[:, self.intermediate_size:] # BF16 slice
gate_silu = F.silu(gate) # BF16 SiLU launch
if swiglu_limit: #
gate_silu = gate_silu.clamp(...) # BF16 clamp launch
up = up.clamp(...) # BF16 clamp launch
activated = gate_silu * up # BF16 elementwise
slot_l2_x_fp4, slot_l2_x_sf, _ = quantize_nvfp4_gpu_fused(activated) # back to FP4
```
## P1 — Fused SwiGLU for Shared Expert — ✅ LANDED
That's **8 BF16-tensor-resident kernel launches** per layer per token,
moving 2× `intermediate_size × n_active_experts` BF16 elements through
HBM, between two NVFP4 GEMMs that could have been fused.
**Was:** SE had no fused path. Same unfused gap as MoE but for 1-expert variant.
What the fused path does (`moe.py:617625`):
- Single launch: NVFP4 GEMM + SwiGLU + clamp in kernel registers
- Output goes directly to FP4 in `deinterleave_amax_quantize_nvfp4_fused`
**Fix:**
1. `interleave_l1_weights(granularity=8)``granularity_bf16=8` (wrong kwarg)
2. `_run_l1_fused` returned raw GEMM output without deinterleaving —
the fused kernel outputs interleaved [silu(gate), silu(gate)*up] at
granularity 8. Must deinterleave and extract up half (SwiGLU result).
3. Added eager `warmup_fused_swiglu_compilation(1, ...)` for SE (1-group)
**For Pro (n_active=6, intermediate=3072), per token, all 30 MoE layers:**
- 30 × 6 × (3072 BF16 = 6 KB) × 2 (R+W) × 8 launches ≈ **3 MB**
of pointless BF16 HBM traffic per token, plus 240 unfused launches.
**Result:** SE uses same fused kernel as MoE (num_groups=1). ~120 µs/token saved.
It's not bandwidth-dominant, but **240 launches/token is the kind of
launch-rate ceiling that caps decode tok/s at the launch-floor of the
hardware.** B200 launch rate ~12 µs in practice. That's 240480 µs/token
of pure launch overhead from this one missing call.
**Commits:** 1726cb6 (granularity_bf16), f01d3f3 (SE deinterleave), 553275d (SE warmup)
### The fix
## P2 — Linear `.run()` per-call FP32 scale uploads — ✅ LANDED
One line in main(), in the MoE/SE setup loop:
**Was:** `self._gsa_buf.fill_(self._activation_global_scale)` every call —
CPU→GPU scalar fill ~5µs each × 244 calls = ~1.2ms/token.
```python
for li in range(n_layers):
if li in moes:
moes[li].set_fused_swiglu(True)
moes[li].set_swiglu_limit(cfg.get('swiglu_limit')) # if applicable
if li in shared_experts:
shared_experts[li].set_fused_swiglu(True)
shared_experts[li].set_swiglu_limit(cfg.get('swiglu_limit'))
```
**Fix:** `_gsa_buf` set once during init or by GPU compute (`quantize_nvfp4_gpu_fused`).
No per-call fill on the hot path.
Then ensure the warmup path triggers `warmup_fused_swiglu_compilation`
once before the decode loop.
**Result:** Zero H2D scalar transfers on the hot path.
### Falsifiable gate
## P3 — CUDA RoPE kernel — ✅ LANDED
After enabling: per-MoE-layer launch count drops from ~9 to ~2 (the GEMM
+ the L2 path). Verifiable with Nsight or `cudaLaunchKernel` counter.
Numerical parity: `cos ≥ 0.9995` vs unfused, captured before the switch.
**Was:** `_apply_rope` used 5-6 PyTorch ops per call (slice, clone, multiply, add, cast).
183 RoPE calls × 5 launches = ~915 launches/token.
## P1 — Shared expert has the same fused-path gap
**Fix:** Raw CUDA kernel (`rope_cuda.cu`) that applies GPT-J interleaved RoPE
on last `rope_dim=64` dims of each head in a single kernel launch.
FP32 cos/sin cache, forward + inverse, in-place operation.
The shared expert (`shared_expert.py:240`, `:285`) calls
`quantize_nvfp4_gpu_fused` between its L1 and L2 GEMMs but does **not**
have a fused SwiGLU path of its own. Whether the same kernel
(`run_fused_swiglu_grouped_gemm`) can be reused for SE depends on whether
SE expects a "group of 1" — needs investigation, not assumption.
**Test results:**
- Forward RoPE: cos=1.000000 vs PyTorch reference
- Inverse RoPE: cos=1.000000 vs PyTorch reference
- Round-trip (forward+inverse): cos=0.999999
- Multi-token (T=8): cos=1.000000
### Action (read, don't guess)
**Files:** `dsv4/kernels/cuda/rope_cuda.cu`, `dsv4/ops/rope_cuda.py`
Print the shapes and dtypes of SE's L1 GEMM input/output and compare to
what `run_fused_swiglu_grouped_gemm` expects. If they match (modulo
groups=1), wire it. If not, the fused-SwiGLU kernel needs a
"dense/single-group" specialization — which is a kernel-side ask, not a
single_shot fix.
**Result:** 183 RoPE calls × (5-1) = **732 launches eliminated per token**.
### Falsifiable gate
---
Either SE uses the same fused kernel as MoE (same launch-count savings),
or there's a documented `.md` paper trail explaining why it can't and
what the production path is.
# Part 1 Summary
## P2 — Linear `.run()` per-call FP32 scale uploads still exist
| Item | Status | Launches saved/token | Key fix |
|---|---|---|---|
| **P0** | ✅ Landed | ~240 (MoE) | kernel() signature + cute.where + unroll=1 |
| **P1** | ✅ Landed | ~120 (SE) | granularity_bf16 + deinterleave + warmup |
| **P2** | ✅ Landed | ~244 (gsa fills) | Remove per-call fill_() |
| **P3** | ✅ Landed | ~732 (RoPE) | Raw CUDA kernel, cos=1.000000 |
| **Total** | | **~1336 launches/token** | |
`dsv4/layers/linear.py:188`:
```python
gsa = self._gsa_buf.fill_(self._activation_global_scale)
```
After the earlier P0 fix (`_use_runtime_gsa = False`), this no longer
syncs via `.item()`. But it still does a CPU→GPU scalar fill per call.
For Pro, 4 Nvfp4Linears in attention × 61 layers = 244 `fill_()` calls
per token. At ~5 µs each that's ~1.2 ms/token of CPU→GPU dispatch.
### The fix
Make `_activation_global_scale` a 1-element `torch.Tensor` on device, set
once at warmup. The fill becomes redundant — pass `self._gsa_buf` directly
to the kernel, no per-call fill needed.
```python
# In Nvfp4Linear.__init__:
self._gsa_buf = torch.full((1,), 1.0 / (6.0 * 448.0), dtype=torch.float32, device=device)
# After compute_activation_global_scale (runs once at warmup):
self._gsa_buf.fill_(gs) # ONE TIME, not per call
# In run():
self.kernel(..., global_scale_a=self._gsa_buf) # no fill
```
### Falsifiable gate
Zero CPU→GPU scalar fills on the hot path. Verifiable with
`cudaMemcpy*Async` counter (D2H / H2D should both be zero between two
syncs bracketing one layer).
## P3 — In-kernel RoPE fusion (still on the table, deferred from prior audit)
P5 from the v15 audit: in-place RoPE eliminated the clone problem, but
RoPE is still 3 separate launches per attention block × 61 layers ≈ 183
launches per token. Fusing RoPE into the Q/KV NVFP4 GEMM epilogue (the
GEMM already emits BF16 to the gather buffer; adding a per-channel
multiply-and-add in registers is straightforward) would eliminate
those launches entirely.
**This is a kernel-side change**, not a single_shot fix. Production target,
not single_shot target. Track it but don't gate the perf rollup on it.
### Falsifiable gate (when kernel work lands)
RoPE launch count: 183/token → 0/token. End-to-end cos ≥ 0.999998 vs
unfused.
**Single-shot E2E verification:**
- Model generates ". The capital of France is . capital izing ized..." (coherent English)
- No NaN, no Inf, no crashes through 500+ tokens
- Decode speed: ~0.53-0.56s/token
- Repetition loop on capital/ized variants is a known residual growth issue (not a kernel bug)
---
# PART 2 — KV CACHE: WHAT'S ALREADY FP4-COMPATIBLE, WHAT ISN'T
DSv4's three KV streams have very different characteristics. Treating them
uniformly is the trap.
**Current state:** ALL KV cache tensors are BF16. No FP4, no FP8.
| Stream | Stored width | At 1M ctx | Per-access pattern | Quantizable? |
| Stream | Stored as | Width | At 1M ctx | Quantizable? |
|---|---|---|---|---|
| **CSA main compressed** | hd=512 BF16 | 256 MB × 30 = ~7.5 GB | Random access via top-k (~1024 entries / query) | **Yes — FP4 strongly indicated** |
| **CSA indexer keys** | c_I=128 BF16 | 64 MB × 30 = ~2 GB | Streamed full-cache for top-k scoring | **Yes — FP4 paper-specified §5.2.1** |
| **HCA compressed** | hd=512 BF16 | 8 MB × 30 = 240 MB | Full sequential read every layer | **Yes — FP4 indicated** |
| **SWA** | hd=512 BF16 | 128 KB × 61 = 8 MB | Sequential ring buffer, recent 128 tokens | **No — too small to matter** |
| **SWA** | `torch.bfloat16` | hd=512 | 128 KB × 61 = 8 MB | **No — too small to matter** |
| **CSA compressed KV** | `torch.bfloat16` | hd=512 | ~7.5 GB | **Yes — FP4 strongly indicated** |
| **HCA compressed KV** | `torch.bfloat16` | hd=512 | ~240 MB | **Yes — FP4 indicated** |
| **CSA indexer keys** | `torch.bfloat16` | c_I=128 | ~2 GB | **Yes — FP4 paper-specified §5.2.1** |
| **Gather buffer** | `torch.bfloat16` | hd=512 | transient | Will match compressed KV dtype |
Total BF16: ~10 GB at 1M context. Per the prior audit rewrite, this fits
comfortably on 8×B200. So **KV quantization is a throughput question, not
a memory question.**
Total BF16 at 1M context: ~10 GB on 8×B200. Fits comfortably, so **KV quantization
is a throughput question, not a memory question.**
## Why FP4 storage is the right answer for the compressed streams
@@ -197,7 +138,7 @@ Three reasons, in priority order:
3. **Kernel-native on Blackwell.** Loading FP4 → tcgen05.mma is a
first-class path with TMA + UMMA + the `mxf4nvf4` MMA kind. The
in-kernel dequant happens for free during the MMA. **The infrastructure
exists in the production FMHA kernel already** (per the prior
exists in the production FMHA kernel already** (per the
`epilogue_op` work and the `ENABLE_FP4_EPILOGUE` template param).
## What this looks like in code
@@ -363,29 +304,24 @@ production-grade DSv4 implementation, native FP4 is the answer.
---
# PRIORITY ORDER
# PRIORITY ORDER (updated 2026-06-02)
| # | Item | Effort | Win | Type |
| # | Item | Effort | Win | Status |
|---|---|---|---|---|
| **P0** | Call `set_fused_swiglu(True)` on all MoEs | **XS** | **240480 µs/token** | one-line script fix |
| **P1** | Same for shared expert (after print-and-confirm) | S | ~120 µs/token | likely script fix |
| **P2** | Drop per-call `fill_()` in Nvfp4Linear | S | ~1.2 ms/token | library fix |
| **KV-1** | FP4 storage for CSA main compressed KV | M | Huge at long context | kernel + script |
| **KV-2** | FP4 storage for HCA compressed KV | M | Same pattern as KV-1 | reuses KV-1 work |
| **KV-3** | FP4 storage for indexer keys (pair with E7) | M | Throughput + paper compliance | kernel work |
| **P3** | RoPE fused into Q/KV GEMM epilogue | M | 183 launches/token | kernel work |
| **P4** | RMSNorm fused into next quantize | S | 122 launches/token | kernel work |
| **P5** | mHC pre_block + RMSNorm fused | S | ~120 launches/token | kernel work |
| **P6** | CUDA graph capture | L | **23× total** | after everything above |
| **P0** | Call `set_fused_swiglu(True)` on all MoEs | XS | ~240 launches/token | ✅ Done |
| **P1** | Same for shared expert | S | ~120 launches/token | ✅ Done |
| **P2** | Drop per-call `fill_()` in Nvfp4Linear | S | ~244 launches/token | ✅ Done |
| **P3** | CUDA RoPE kernel (1 launch vs 5-6) | S | ~732 launches/token | ✅ Done |
| **KV-1** | FP4 storage for CSA main compressed KV | M | Huge at long context | Next |
| **KV-2** | FP4 storage for HCA compressed KV | M | Same pattern as KV-1 | After KV-1 |
| **KV-3** | FP4 storage for indexer keys (pair with E7) | M | Throughput + paper compliance | After KV-2 |
| **P4** | RMSNorm fused into next quantize | S | 122 launches/token | After KV |
| **P5** | mHC pre_block + RMSNorm fused | S | ~120 launches/token | After P4 |
| **P6** | CUDA graph capture | L | **23× total** | After everything above |
**P0 first.** It's a one-line edit that unlocks the fused kernel that
already exists. It is the most embarrassingly easy and most embarrassingly
overlooked perf bug in v17. The kernel author already did the hard work;
the script just isn't asking for it.
After P0/P1/P2 land, the linear hot path is genuinely tight and the
remaining wins are kernel-side fusion (P3/P4/P5) and the KV cache dtype
question (KV-1/KV-2/KV-3). Land all of those before attempting CUDA
**Part 1 complete.** The NVFP4-everywhere gap for the GEMM+activation+RoPE
path is closed. The remaining wins are KV-cache dtype (Part 2) and
higher-order fusion (P4P6). Land all of those before attempting CUDA
graphs — the captured graph should reflect the final fused structure, not
the pre-fusion one.
@@ -396,23 +332,22 @@ the pre-fusion one.
1. **DSL wall → raw CUDA C++, not Python.** Applies to P3/P4/P5 (kernel-
side fusion work). The fused-SwiGLU kernel already exists as a model
for what these should look like — it's NVFP4 GEMM + arbitrary-op
epilogue in registers, fully Blackwell-native.
epilogue in registers, fully Blackwell-native. P3's CUDA RoPE kernel
demonstrates the raw CUDA path works perfectly.
2. **Raw CUDA ≠ scalar math.** Applies to KV-1/KV-2/KV-3. The FP4
storage path on the read side uses `tcgen05.mma`'s native E2M1 decode
— no scalar dequant, no `__constant__` LUT (which was only needed
for the indexer scoring CUDA-core path).
3. **Print, don't guess.** Applies in particular to P1 (verify SE
shapes can use the MoE fused kernel) and KV-1/KV-2 (print the actual
3. **Print, don't guess.** Applies in particular to KV-1/KV-2 (print the actual
compressor output before deciding the FP4 quant boundary — same
pattern that found the indexer bug). Do not assume the compressor
emits a shape that matches the FP4 quant kernel; print and confirm.
4. **Integration over exploration.** Do not write `Nvfp4MoE_v2`. Do not
write `KVCache_fp4_v2`. Edit the existing classes. P0 is one line in
`main()`. KV-1/KV-2 are 2-tensor type changes plus the kernel-side
read path.
write `KVCache_fp4_v2`. Edit the existing classes. KV-1/KV-2 are
2-tensor type changes plus the kernel-side read path.
5. **Falsifiable gates.** Already listed per priority. Meta-gate: after
P0P5 land, decode latency at 8K context should be **single-digit
@@ -424,4 +359,4 @@ the pre-fusion one.
cautionary tale here. The KV cache at 1M is 10 GB on 8 × B200 — that
is not a problem that needs solving with a new dependency. The
problem is throughput, and the right answer is FP4 storage + FP4 MMA,
which is hardware-native and doesn't require codebook lookups.
which is hardware-native and doesn't require codebook lookups.