Skip to content

perf(decode): n>=4 tiled QKV + o_proj GEMV for NVFP4 batched decode (extract of #214 onto main)#266

Open
rsafier wants to merge 1 commit into
mainfrom
feat/qkv-batched-tiled-on-main
Open

perf(decode): n>=4 tiled QKV + o_proj GEMV for NVFP4 batched decode (extract of #214 onto main)#266
rsafier wants to merge 1 commit into
mainfrom
feat/qkv-batched-tiled-on-main

Conversation

@rsafier

@rsafier rsafier commented Jul 5, 2026

Copy link
Copy Markdown
Collaborator

Focused extraction of the still-unabsorbed batched-decode feature from #214 onto post-#229 main.

Why a focused extraction (not a rebase of #214)

#214's base predates #229. A mechanical rebase --onto main produces ~39K deletions because #229 added the MMQ/q4k-vendor/NVFP4 FFN stack that #214 doesn't have — the loader/kernel surface diverged too far to replay safely. Comparing #214's own .rs against current main shows that main already absorbed the n=2/n=3 batched QKV paths (ms_qkv_batch2/ms_qkv_batch3 live in multi_seq/qkv.rs). The one genuinely-novel, still-absent piece is the n≥4 tiling that extends those proven kernels to higher decode concurrency. This PR ports exactly that, adapted to main's current multi_seq structure.

What it does

For n≥4 concurrent decode with NVFP4 q/k/v/o weights, tile the proven batch3/batch2 GEMV kernels — greedy 3s, then a 2-or-1 remainder. Each tile reads the projection weight once for its 2–3 tokens, so n=8 costs 3 weight-matrix reads instead of 8. This amortizes the attention-projection bandwidth that otherwise makes decode collapse back to one weight read per token at C≥4.

  • multi_seq/qkv.rs: new ms_qkv_batch_tiled + a n >= 4 && <all nvfp4> dispatch branch. Scratch layout is byte-identical to ms_qkv_batch3 (same ssm_qkvz()/attn_output() scratch, same q_proj_bytes/kv_bytes/per_seq_qkv offsets, all stream-serialized), so it inherits that path's correctness. The k==1 remainder falls back to the per-seq projection.
  • multi_seq/attn.rs: matching n≥4 o_proj tiling (attn_out/o_out are contiguous, so each tile is just an offset into the same batch3/batch2 kernels the n==2/3 branches already use).

No behavior change for n<4 or non-NVFP4 weights (existing branches untouched; the new branch sits between the n==2 case and the seq-loop fallback).

Correctness argument

Correct by construction: identical kernels + offsets to the already-shipped ms_qkv_batch3, which this repo already trusts for n=3. The scratch buffers are already sized for k≤3 (main's ms_qkv_batch3 uses attn_output().offset(3*kv_bytes)); the tiled path never exceeds a k=3 tile. Cross-tile scratch reuse is safe because each tile's copy_d2d_async into qkv_buf is ordered before the next tile's GEMV on the same stream.

Validation

  • cargo fmt -p spark-model --check clean.
  • cargo clippy -p spark-model -- -D warnings clean.
  • cargo test -p spark-model --lib → 79 passed, 0 failed.
  • GPU numeric A/B recommended before merge: tiled-vs-per-seq output identity + a needle recall at C4/C8 on an NVFP4 Holo/AgentWorld model. GPU kernel numerics aren't covered by the CPU CI suite (all #[ignore]), so this is a manual pre-merge step.

Supersedes the batched-decode portion of #214 (the rest was absorbed by #229). Signed commit.

@SeedSource

Copy link
Copy Markdown
Collaborator

[BETA] Automated PR review — SeedSource AI pipeline

Advisory only, not a merge gate. AI-generated (internal risk-triage, Atlas serve gate, GLM-5.2 review), attributed per Atlas convention.

Triage

  • Risk: med - serve-test-required (triage advisory): True - path-heuristic (authority): True
  • Rationale: Adds n>=4 NVFP4 tiled GEMV paths for o_proj and qkv projections using proven batch kernels.
  • Serving-path files: crates/spark-model/src/layers/qwen3_attention/trait_impl/multi_seq/attn.rs, crates/spark-model/src/layers/qwen3_attention/trait_impl/multi_seq/qkv.rs

Serve gate

  • Build: 18s - Qwen3.6-35B-A3B-FP8 on GB10 node1
  • Top-k KL vs main: 0.0006 (no shift vs main) - decode delta +0.1%
  • Coherence probe: coherence=pass ping=10/10 (note: our test_coherence.py is flaky here; KL is the reliable serve signal)

GLM-5.2 code review

Correctness concerns:

  1. Scratch buffer aliasing (HIGH): q_scratch = ssm_qkvz(), k_scratch = attn_output(). v_scratch = k_scratch.offset(k * kv_bytes). If attn_output buffer isn't sized for k*kv_bytes + k*kv_bytes (k+v scratch), this OOB-writes. The batch3/batch2 paths presumably validated this, but tiling reuses the same buffers per tile — fine if sized for max tile (3), but no assertion guards it.

  2. o_proj branch condition: else if !self.attn.o_proj.is_null() — this catches n>=4 AND n==1 (falls through from the n==2/3 branches above). For n==1 this tiles into a single k==1 GEMV call, which is functionally correct but a pointless wrapper vs. the existing else fallback. Verify the else branch is now truly dead for NVFP4 o_proj or that n==1 still hits it.

  3. D2D copies not batched: 3 separate copy_d2d_async per token in the scatter loop (9 for k==3 tile). These are on the same stream so serialized; minor perf but not a correctness issue.

  4. No synchronization between tiles: Tiles reuse the same scratch buffers with no stream sync between iterations. Correct only if all kernels are enqueued on the same stream (appears so), but worth confirming no cross-stream dependencies exist.

Risk: Medium. Buffer sizing is the critical unverified assumption.

Quality: Tiling logic is clean; the k==1 remainder fallback duplicating the sequential path is acceptable but could call into a shared helper to avoid drift.

Pipeline: build+serve on GB10, GLM-5.2 via NVIDIA NIM, internal risk-triage classifier. External diff treated as untrusted input. Public repo.

@rsafier rsafier force-pushed the feat/qkv-batched-tiled-on-main branch from 4c29f45 to dd1e987 Compare July 12, 2026 23:11
Extends the proven n=2/n=3 batched NVFP4 GEMV paths to n>=4 concurrent
decode by tiling: greedy 3s then a 2-or-1 remainder. Each tile reads the
q/k/v/o projection weights ONCE for its 2-3 tokens, so n=8 costs 3
weight-matrix reads instead of 8 — amortizing the attention-projection
bandwidth that otherwise caps decode scaling at C>=4.

- multi_seq/qkv.rs: add `ms_qkv_batch_tiled` + dispatch branch
  (n>=4 && all-nvfp4). Byte-identical scratch layout to `ms_qkv_batch3`
  (same ssm_qkvz/attn_output scratch, same q_proj_bytes/kv_bytes/
  per_seq_qkv offsets, stream-serialized), so it inherits that path's
  correctness; k==1 remainder falls back to the per-seq projection.
- multi_seq/attn.rs: matching n>=4 o_proj tiling (attn_out/o_out are
  contiguous, so each tile is just an offset into the same batch3/batch2
  kernels the n==2/3 branches use).

Focused extraction of the still-unabsorbed batched-decode feature from
loader/MMQ rework). No behavior change for n<4 or non-NVFP4 weights.

fmt + clippy -D warnings + spark-model lib tests (79) all pass. GPU
numeric A/B (tiled vs per-seq, C4/C8 + needle) recommended before merge.
@rsafier rsafier force-pushed the feat/qkv-batched-tiled-on-main branch from dd1e987 to 2020785 Compare July 13, 2026 02:12
@rsafier

rsafier commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

GPU A/B — tiled-GEMV (this PR) vs wide-GEMM (#305), on current main

Fulfilling the "GPU numeric A/B recommended before merge" item. Since #305 (DFlash commit_ctx) landed on main, the n≥4 o_proj/QKV branch this PR targets is now occupied by #305's wide_verify_gemm (single M=n GEMM) — so this is a direct head-to-head. Both variants built on the identical current-main base, differing only in the n≥4 kernel.

Model: AEON-7/Ornith-1.0-35B-AEON-…-NVFP4 (uniform NVFP4 attn) · GB10/sm_121 · temp 0 · 256-token decode-dominant · bf16 KV · max-num-seqs 16. Concurrency = simultaneous identical requests (batched decode). per-seq = server-reported response_token/s; agg = wall-clock aggregate.

Concurrency wide-GEMM (#305) per-seq tiled (this PR) per-seq speedup wide agg tiled agg
C=1 91.2 90.6 1.00× (n<4 → shared path) 89.2 88.6
C=4 15.8 26.7 1.69× 59.9 98.6
C=8 9.9 15.1 1.53× 78.3 119.0
C=16 5.9 8.8 1.49× 91.2 134.7

Result: the tiled batch3/batch2 GEMV beats the wide GEMM by ~1.5–1.7× at every batched size — opposite to the "one weight read wins" intuition. The wide GEMM's M-tile padding (94% waste at M=4) outweighs its single-read advantage; note wide's C=4 aggregate (59.9) is actually below its C=1 (89.2) — it degrades under moderate batch, which the tiling fixes. Even at C=16 (≈ #305's γ=16 target) tiled wins.

Resolution in this sync

This branch is now rebased onto current main with the n≥4 conflict resolved toward tiled — i.e. it supersedes wide_verify_gemm for that branch (the function is removed; the n==2/3 batch kernels are untouched). Justified by the table above.

Caveats / remaining before merge

  • Single model / prompt / greedy / one run. Directionally very strong (and since this is a MoE+SSM model where attention is only part of decode, the raw attention-kernel win is likely larger than the end-to-end 1.5×), but a multi-model sweep would firm up the exact factor.
  • Numeric identity: the outstanding tiled-vs-per-seq output-identity check (this PR's own correctness argument) is still the must-do — temp-0 output diverges from the wide kernel (expected: different accumulation order; both coherent/correct), so it must be pinned against the n==1 per-seq reference, not against wide.
  • DFlash interaction: wide_verify_gemm was introduced by feat(dflash): unified context commit (commit_ctx), env-gated (stacked on #304) #305 for the DFlash γ=16 verify step, which flows through this same n≥4 branch. Removing it routes DFlash verify through the tiled path too. That's functionally fine (tiled handles any n≥4) and faster here, but a --dflash-on run should confirm no verify-path regression before this lands, since feat(dflash): unified context commit (commit_ctx), env-gated (stacked on #304) #305's kernel was tuned for that specific call site.

@rsafier

rsafier commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

DFlash verify-path check — no regression from removing wide_verify_gemm

Closing the third caveat from the A/B above. wide_verify_gemm was introduced by #305 for the DFlash γ=16 verify step; this branch removes it and routes verify (an n=γ+1=16 batch) through the tiled path. Ran both binaries with --dflash (Ornith-1.0-35B-NVFP4 target + z-lab/Qwen3.6-35B-A3B-DFlash drafter, full DFlash correctness flags, γ=16):

binary verify step decode accept crash
wide-GEMM (#305) ~87.0 ms 58.5 tok/s 0–13% none
tiled (this PR) 86.8 ms 59.8 tok/s 0–7% none
  • Verify-step timing identical within noise (~87 ms). feat(dflash): unified context commit (commit_ctx), env-gated (stacked on #304) #305's wide GEMM shows no advantage even in its own DFlash-verify context — the verify step is dominated by drafter-propose + SSM/MoE forward, so the o_proj/QKV kernel washes out there (unlike concurrent batched decode, where tiled is 1.5×).
  • Accept rate is kernel-independent (identical math, ~5–13% — that low rate is the known 35B-A3B/drafter divergence, unrelated to this PR).
  • Both coherent, no illegal-address / crash through the tiled verify batch.

Conclusion: removing wide_verify_gemm and using tiled everywhere is safe — faster for concurrent decode, equal for DFlash verify, no downside. This PR is synced onto current main with that resolution. Remaining pre-merge item is the tiled-vs-n==1-per-seq numeric-identity check.

@rsafier

rsafier commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

High-acceptance verify test — tiled still wins

The DFlash run above had low accept (~5%, adaptive suspended), so the verify path barely ran. To stress a productive n≥4 verify, ran MTP forced past its throughput gate (ATLAS_MTP_GATE_FORCE=1, --num-drafts 3 → K=4, verify batch n=4) on unsloth/Qwen3.6-35B-A3B-NVFP4 (the config that gets high spec-accept). Both binaries, identical flags:

MTP K=4 (verify n=4, productive) decode mean accepted / 3
wide-GEMM (#305) 62.3 tok/s 1.72 (~57%)
tiled (this PR) 73.2–73.5 tok/s 1.93 (~64%)

Tiled is ~1.18× faster end-to-end even with the verify running every step at high acceptance, and accept is at parity (the 1.72↔1.93 spread is temp-0 numeric drift between the two accumulation kernels over a single run, not a systematic edge). Both coherent, no crash.

Verify-path summary across regimes:

  • Concurrent batched decode (C=4/8/16): tiled 1.49–1.69×.
  • DFlash verify (n=16, low accept, verify-dominated-by-forward): tiled ≈ wide (no regression).
  • MTP verify (n=4, ~60% accept, productive): tiled ~1.18×.

Net: tiled is faster or equal in every regime tested — concurrent decode, low-accept DFlash verify, and high-accept MTP verify. Removing wide_verify_gemm in favor of tiled carries no downside on the evidence here. Remaining pre-merge item stays the tiled-vs-n==1-per-seq numeric-identity check.

@rsafier

rsafier commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Second model (Holo-3.1) — the win is model-dependent

Ran the same n≥4 concurrent-decode sweep on Hcompany/Holo-3.1-35B-A3B-NVFP4 (qwen3_5_moe). (MTP-force is N/A here — Holo ships no MTP head: 0 MTP weight keys, mtp_num_hidden_layers: None.)

C wide per-seq tiled per-seq speedup
1 78.5 78.4 1.00×
4 26.7 29.4 1.10×
8 17.7 18.5 1.05×
16 10.4 10.5 1.01×

On Holo the tiled advantage is marginal (1.0–1.10×) — not the 1.49–1.69× seen on Ornith. The cause is in the wide-GEMM baseline: on Ornith (qwen3_6_moe) wide degraded at moderate batch (C=4 aggregate below C=1) and tiling recovered it; on Holo (qwen3_5_moe) wide already scales cleanly (C=4 agg 104.6 vs C=1 76.6), leaving little for tiling to gain.

Revised takeaway

The tiled kernel win is model/shape-dependent, not universal: large on Ornith, near-parity on Holo, and never a regression across everything tested (concurrent decode on 2 models, low-accept DFlash verify, high-accept MTP verify). So tiled is a safe drop-in — it helps some models substantially and costs nothing elsewhere — but the honest headline is "up to ~1.7×, ≥ parity everywhere," not a flat 1.5×. Whether that justifies carrying the tiled path over #305's wide GEMM is a maintainer call; the data says there's no downside to doing so.

@rsafier

rsafier commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Tiebreaker: the win is the BF16→FP4 requant path, NOT model/shape

Ran the A/B on AEON-7/Qwen3.6-35B-A3B-heretic-NVFP4 — same qwen3_6 arch and byte-identical attention shape as Ornith (16 heads / 2 KV / head_dim 256 / hidden 2048), but native NVFP4-packed attention on disk instead of Ornith's BF16 (which gets runtime-requantized to FP4 under ATLAS_HOLO_FP4_PROJ_DECODE=1).

qwen3_6, identical shape attn source wide C=4 per-seq tiled C=4 per-seq speedup
Ornith-35B BF16 → FP4 requant 15.8 (pathological) 26.7 1.69×
heretic-35B native NVFP4 27.6 (healthy) 29.5 1.07×

Full heretic sweep: C=4 1.07×, C=8 1.06×, C=16 1.01× — i.e. near-parity, like Holo, not Ornith's 1.5–1.7×.

Conclusion: the earlier "model/shape-dependent" framing was wrong. Same shape, opposite result. The discriminator is the attention weight source:

  • Native NVFP4/FP8 attention (heretic, Holo, unsloth, etc.): wide-GEMM is already fast → tiled ≈ parity.
  • BF16 attention runtime-requantized to FP4 (Ornith): wide-GEMM is pathological (its fast pipelined m128_v2 needs a transposed weight copy the requant path apparently doesn't build, so it falls to the slow M64 GEMM). Tiling — which uses the transpose-free batch3/batch2 GEMV — sidesteps that and wins ~1.7×.

Implication for the PR: the tiled kernel is only a meaningful win for runtime-requantized-BF16 attention checkpoints (a niche: e.g. the AEON "Ultimate" BF16 repacks). For the mainstream native-NVFP4/FP8 releases it's parity. That narrows the value proposition considerably — and suggests the alternative fix worth weighing is building the transposed NVFP4 copy during the BF16→FP4 requant (so wide_verify_gemm's fast path works too), rather than carrying a second kernel. Either way, tiled remains ≥ parity everywhere, so it's a safe merge — but it's not the broad speedup the Ornith-only numbers suggested.

@rsafier

rsafier commented Jul 13, 2026

Copy link
Copy Markdown
Collaborator Author

Follow-up: keeping Ornith's attention in native BF16 doesn't help either

Since the Ornith win traced to the BF16→FP4 requant (not shape), the obvious question was: just don't requant — drop ATLAS_HOLO_FP4_PROJ_DECODE and serve Ornith's attention in its native BF16. Result: no better.

Ornith per-seq, all attention paths (Qwen3.6-35B-A3B, C = concurrent decode):

Ornith attention path C=4 C=8
native BF16 (no fp4_proj_decode) 15.8 10.1
FP4-requant → wide-GEMM (#305) 15.8 9.9
FP4-requant → tiled (this PR) 26.7 15.1
heretic, native NVFP4 (reference) 27.6 17.7

Native BF16 lands at exactly 15.8 — same as the pathological wide path. BF16 attention has no fast batched kernel at n≥4 either; it falls to the per-token seq-loop (n full-weight GEMVs at 2 bytes/weight), which is just as slow as the transpose-less wide-GEMM and forfeits the FP4 bandwidth savings. So dropping the requant is a net loss, not a fix.

Complete picture for the n≥4 attention path

  • Native NVFP4/FP8 attention (heretic, Holo, unsloth, mainstream releases): wide already fast → tiled ≈ parity (1.0–1.1×).
  • BF16-repack attention runtime-requantized to FP4 (Ornith-class "Ultimate" repacks): only three options at n≥4 — BF16 seq-loop (15.8), requant+wide (15.8), or requant+tiled (26.7). Tiling is the only one that's fast.

Bottom line for merge: tiled is ≥ parity everywhere tested (2 native-quant models, DFlash verify, MTP verify) and the only fast option for BF16-repack checkpoints at concurrency. It's a safe drop-in with a real (if niche) benefit. The equally-valid alternative — emit the transposed NVFP4 copy during the BF16→FP4 requant so wide_verify_gemm's fast path fires (matching heretic's native 27.6) — would remove the need for a second kernel; worth weighing, but out of scope for this PR.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants