spark_model/layers/ops/dispatch_proj_decode.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! W8A8 block-scaled cuBLASLt routing for the **5..16-row DECODE**
4//! projections — the SSM `in_proj_qkvz`/`out_proj` and the attention
5//! Q/K/V/O — plus the strided-output arithmetic those need and the dense FFN
6//! did not.
7//!
8//! WHY (#927). H100, 2026-09-11 round 7, `Qwen/Qwen3.8-27B-FP8`, batch 16,
9//! steady-state n=16 decode step **43.595 ms** (idle 4.2%), nsys with
10//! `--cuda-graph-trace=node`. The dense FFN at these SAME 16 rows already runs
11//! cuBLASLt W8A8 (`nvjet_sm90_…_Ablk128_Bvec128`, 128 launches = 5.8 ms +
12//! split-K 2.4 ms + reduce 0.14 ms for 64 layers × 2 GEMMs ≈ **128 µs/layer**
13//! for 267 MB of weights, i.e. ~2 100 GB/s-equivalent). The projections did
14//! not, and they are now the largest line in the step:
15//!
16//! | kernel | launches | µs/step | what |
17//! |---|---|---|---|
18//! | `w8a16_gemv_batch16` GrdX 4096 | 48 | 11 294 | SSM `in_proj_qkvz` N=16384 K=5120, 235 µs each @ **357 GB/s** |
19//! | `w8a16_gemv_batch16` GrdX 1280 | 64 | 6 814 | SSM `out_proj` + attn `o_proj`, N=5120, 106 µs each |
20//! | `w8a16_gemv_batch16_strided` GrdX 3072 | 16 | 2 893 | attn `q_proj` N=12288, 181 µs each |
21//! | `w8a16_gemv_batch16_strided` GrdX 256 | 32 | 846 | attn `k_proj`+`v_proj` N=1024, 26 µs each |
22//!
23//! 21.85 ms = **50.1% of the step** in one family of kernels at 357 GB/s
24//! against a 3 350 GB/s HBM3 roofline, while the neighbouring FFN reaches
25//! ~2 100 GB/s-equivalent on the same box in the same step. The GEMV reads the
26//! weight once and then pays ~16 scalar FFMA per weight byte; the cuBLASLt
27//! W8A8 path pays one `mma.sync.m16n8k32.e4m3` lane-slot instead. That is the
28//! whole change: same weights, same block scales, vLLM's dynamic per-token
29//! activation quant, a tensor-core MMA in place of the FFMA loop.
30//!
31//! NUMERICS. These rows move from W8A16 (BF16 activation × E4M3 weight) to
32//! W8A8 (E4M3 activation, per-token 1×128 FP32 scales × the checkpoint's
33//! 128×128 FP32 weight scales, FP32 epilogue) — **exactly the arithmetic the
34//! dense FFN already uses at these same widths**, and vLLM's. It is a
35//! deliberate precision trade, not a defect: E4M3 keeps 3 stored mantissa bits,
36//! so the floor of a W8A8-vs-W8A16 comparison on these shapes is ~2-2.6%
37//! relative RMS with cosine ~0.9997. The **M=1 path is untouched** (the
38//! bit-exact scalar `w8a16_gemv`), and the batch oracles still compare the
39//! GEMV tiers to the scalar loop bit-for-bit.
40//!
41//! GRAPH CAPTURE. Every selector here is a pure function of the PADDED ctx `n`
42//! (the `padded_batch_n` ladder the graph cache is keyed by), the model's
43//! resolved [`super::GemmDispatch`], and handles/capacities fixed at model
44//! build. Nothing reads the environment per step.
45
46use anyhow::Result;
47use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
48
49use super::{
50 Fp8ActQuant, cublas_fp8_m_pad, cublas_scale_layout_kmajor, fp8_act_scale_to_kmajor,
51 per_token_group_quant_fp8,
52};
53
54/// `ATLAS_NO_W8A8_DECODE_PROJ` kill switch: PRESENCE (any value, including
55/// empty) keeps every 5..16-row decode projection on today's `w8a16_gemv_batch16`
56/// tiers. Presence rather than `=1` for the same reason `ATLAS_FFN_W8A16_ONLY`
57/// is presence-checked — an operator reaches for it while a serve is
58/// misbehaving, and `...=0` meaning "on" is a trap.
59///
60/// `OnceLock`-cached and therefore constant for the life of the process, which
61/// is what makes it safe to branch on under CUDA-graph capture.
62pub fn w8a8_decode_proj_disabled() -> bool {
63 static OFF: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
64 *OFF.get_or_init(|| std::env::var_os("ATLAS_NO_W8A8_DECODE_PROJ").is_some())
65}
66
67/// The row band this family owns: 5..=16 PADDED decode rows.
68///
69/// * `<= 4` is `w8a16_gemv_batch4`'s, and M=1 is the bit-exact scalar GEMV —
70/// at those widths the GEMV streams each weight once and no MMA tile beats
71/// it, which the round-7 receipt measures directly (ncol2 at M=2/M=4 is 16%
72/// and 11% SLOWER than the GEMV it replaces).
73/// * `>= 17` is not a decode step on this ladder's hot rungs and would need a
74/// different M pad; it stays on whatever tier owns it today.
75pub const DECODE_W8A8_ROWS: std::ops::RangeInclusive<usize> = 5..=16;
76
77/// Last element written by a cuBLASLt GEMM whose output rows are `ldc`
78/// elements apart — SSOT for every strided call site's bounds check.
79///
80/// The D operand is a column-major `[N, M]` with leading dimension `ldc`,
81/// i.e. a row-major `[M, N]` at row pitch `ldc`. The library writes `n`
82/// elements of each of the `m_pad` columns, so the extent is
83/// `(m_pad - 1) * ldc + n` ELEMENTS — NOT `m_pad * ldc`, which would count a
84/// gap the GEMM never touches, and NOT `m_pad * n`, which would ignore the
85/// pitch entirely.
86///
87/// ⚠ `m_pad`, not `m`: `cublas_fp8_proj_prequant` hands cuBLASLt `ceil16(M)`
88/// and the phantom rows ARE written. With a contiguous output they land past
89/// the live rows in the same buffer; with a STRIDED one they land in decode
90/// slots `m..m_pad`, which belong to sequences that are not in this step. That
91/// is in-bounds and harmless — those slots are re-projected before anything
92/// reads them — but only while the buffer actually HAS `m_pad` slots, which is
93/// exactly what this extent is used to check.
94pub fn strided_out_extent_elems(m_pad: u32, ldc: u32, n: u32) -> usize {
95 debug_assert!(m_pad >= 1);
96 (m_pad as usize - 1) * ldc as usize + n as usize
97}
98
99/// Everything the decode W8A8 cuBLASLt arm needs, as a PURE function of shape,
100/// format, lever and handles — so the CPU tests pin every clause without a GPU
101/// and without touching the process environment.
102///
103/// Clauses, each load-bearing:
104///
105/// * `family_armed` — the caller's slice of [`super::CublasScope`]
106/// (`cublas.ssm` / `cublas.attn`). Arming the dense FFN must not arm these;
107/// that separation is the whole point of the scoped lever (#917's 10.3 GiB).
108/// * `!disabled` — the `ATLAS_NO_W8A8_DECODE_PROJ` kill switch.
109/// * `DECODE_W8A8_ROWS.contains(&rows)` — the 5..=16 band, on the PADDED n.
110/// * `Fp8BlockScaled` — cuBLASLt is told the weight scales are a BLK128x128
111/// grid; a per-row `row_scale` has a different shape and reads as garbage.
112/// * `n % 128 == 0`, `k % 128 == 0` — that grid is `[N/128, K/128]` and the
113/// activation quantizer emits one scale per 128-wide K group.
114/// * `blk128x128_stride_ok(k)` (i.e. `k % 512 == 0`) — cuBLAS requires the
115/// weight-scale column stride `K/128` to be a multiple of 4.
116/// * `ldc >= n` — a leading dimension shorter than the column is rejected by
117/// the library; for a contiguous output the caller passes `ldc = n`.
118/// * OUTPUT ROOM for the full padded write extent (see
119/// [`strided_out_extent_elems`]).
120/// * the quantizer kernel, and the VEC128 scale-layout adapter (kernel AND
121/// scratch AND its capacity, and the FP8/scale scratch capacities). cuBLASLt
122/// reads the activation scales token-contiguous; handing over the
123/// quantizer's `[M, K/128]` order is fast and WRONG (H100 2026-09-11:
124/// rel_rms 7.7e-2 / ~33 000 BF16 ULP on identical FP8 bytes). Falling back
125/// to the batch16 GEMV is the only safe answer when any of it is missing.
126#[derive(Clone, Copy, Debug)]
127pub struct DecodeW8a8Plan {
128 /// Padded decode rows — the ctx `n` the CUDA-graph cache is keyed by.
129 pub rows: usize,
130 /// Output width of this projection.
131 pub n: u32,
132 /// Contract width of this projection.
133 pub k: u32,
134 /// Output row pitch in BF16 ELEMENTS (`n` for a contiguous output).
135 pub ldc: u32,
136 /// Allocated size of the output buffer, in bytes.
137 pub out_capacity_bytes: usize,
138}
139
140impl DecodeW8a8Plan {
141 /// A contiguous `[rows, n]` output (SSM `in_proj_qkvz`, SSM `out_proj`,
142 /// attention `o_proj`).
143 pub fn contiguous(rows: usize, n: u32, k: u32, out_capacity_bytes: usize) -> Self {
144 Self {
145 rows,
146 n,
147 k,
148 ldc: n,
149 out_capacity_bytes,
150 }
151 }
152
153 /// A strided output: rows `ldc` BF16 elements apart (attention Q/K/V into
154 /// the `[n, per_seq_qkv]` multi-seq QKV buffer).
155 pub fn strided(rows: usize, n: u32, k: u32, ldc: u32, out_capacity_bytes: usize) -> Self {
156 Self {
157 rows,
158 n,
159 k,
160 ldc,
161 out_capacity_bytes,
162 }
163 }
164
165 /// The padded M cuBLASLt is actually handed.
166 pub fn m_pad(&self) -> u32 {
167 cublas_fp8_m_pad(self.rows as u32)
168 }
169
170 /// Bytes of `out` this projection may touch, phantom rows included.
171 pub fn write_extent_bytes(&self) -> usize {
172 strided_out_extent_elems(self.m_pad(), self.ldc, self.n) * 2
173 }
174}
175
176/// The activation-quant scratch triple + its capacities, read straight off the
177/// buffer arena at the call site. Bundled so the selector takes one argument
178/// instead of six and the tests can build it without an arena.
179#[derive(Clone, Copy, Debug)]
180pub struct DecodeW8a8Scratch {
181 pub act_fp8: DevicePtr,
182 pub act_fp8_bytes: usize,
183 pub act_scale: DevicePtr,
184 pub act_scale_bytes: usize,
185 pub act_scale_kmajor: DevicePtr,
186 pub act_scale_kmajor_bytes: usize,
187 pub quant_k: Fp8ActQuant,
188 pub scale_kmajor_k: KernelHandle,
189}
190
191impl DecodeW8a8Scratch {
192 /// Whether the scratch can hold `m_pad` rows of a K-wide activation.
193 fn fits(&self, m_pad: u32, k: u32) -> bool {
194 let rows = m_pad as usize;
195 let kg = k as usize / 128;
196 self.act_fp8.0 != 0
197 && self.act_scale.0 != 0
198 && self.quant_k.available()
199 && self.act_fp8_bytes >= rows * k as usize
200 && self.act_scale_bytes >= rows * kg * 4
201 && (!cublas_scale_layout_kmajor()
202 || (self.scale_kmajor_k.0 != 0
203 && self.act_scale_kmajor.0 != 0
204 && self.act_scale_kmajor_bytes >= rows * kg * 4))
205 }
206}
207
208/// Whether ONE decode projection takes the W8A8 cuBLASLt arm. See
209/// [`DecodeW8a8Plan`] for the clause-by-clause rationale.
210pub fn decode_w8a8_selected(
211 family_armed: bool,
212 disabled: bool,
213 plan: &DecodeW8a8Plan,
214 scale_format: crate::weight_map::WeightQuantFormat,
215 scratch: &DecodeW8a8Scratch,
216) -> bool {
217 let m_pad = plan.m_pad();
218 family_armed
219 && !disabled
220 && DECODE_W8A8_ROWS.contains(&plan.rows)
221 && scale_format == crate::weight_map::WeightQuantFormat::Fp8BlockScaled
222 && plan.n.is_multiple_of(128)
223 && plan.k.is_multiple_of(128)
224 && spark_runtime::cublaslt::scale_layout::blk128x128_stride_ok(plan.k as usize)
225 && plan.ldc >= plan.n
226 && plan.write_extent_bytes() <= plan.out_capacity_bytes
227 && scratch.fits(m_pad, plan.k)
228}
229
230/// Quantize `act[rows, k]` BF16 ONCE into the shared scratch: FP8 E4M3 bytes +
231/// per-token 1×128 FP32 scales, the phantom rows `rows..ceil16(rows)` zeroed,
232/// and the VEC128 scales re-laid-out K-major for cuBLASLt.
233///
234/// Split from the GEMM so a caller with several projections over the SAME
235/// activation pays it once — the attention layer's Q/K/V share `normed`, so
236/// quantizing inside the GEMM helper would run the quantizer and the scale
237/// transpose three times per layer per step. Exactly the split
238/// `dense_ffn_w8a8_prefill` makes for gate/up.
239///
240/// A zero scale kills the phantom rows' CONTRIBUTION, but the FP8 dot product
241/// still runs over whatever bytes are there and `NaN * 0.0` is `NaN` — hence
242/// the memset of the FP8 bytes and not only of the scales.
243pub fn decode_w8a8_quant_act(
244 gpu: &dyn GpuBackend,
245 scratch: &DecodeW8a8Scratch,
246 act_bf16: DevicePtr,
247 rows: u32,
248 k: u32,
249 stream: u64,
250) -> Result<()> {
251 per_token_group_quant_fp8(
252 gpu,
253 scratch.quant_k,
254 act_bf16,
255 scratch.act_fp8,
256 scratch.act_scale,
257 rows,
258 k,
259 stream,
260 )?;
261 let m_pad = cublas_fp8_m_pad(rows);
262 if m_pad > rows {
263 gpu.memset_async(
264 scratch.act_fp8.offset(rows as usize * k as usize),
265 0,
266 (m_pad - rows) as usize * k as usize,
267 stream,
268 )?;
269 }
270 if cublas_scale_layout_kmajor() {
271 // Writes every [K/128, m_pad] slot, pad rows included.
272 fp8_act_scale_to_kmajor(
273 gpu,
274 scratch.scale_kmajor_k,
275 scratch.act_scale,
276 scratch.act_scale_kmajor,
277 rows,
278 m_pad,
279 k,
280 stream,
281 )?;
282 } else {
283 // Measurement control only (`ATLAS_CUBLAS_SCALE_LAYOUT=rowmajor`): the
284 // pad rows are a contiguous tail in THIS layout, so zero them here.
285 let kg = k as usize / 128;
286 if m_pad > rows {
287 gpu.memset_async(
288 scratch.act_scale.offset(rows as usize * kg * 4),
289 0,
290 (m_pad - rows) as usize * kg * 4,
291 stream,
292 )?;
293 }
294 }
295 Ok(())
296}
297
298/// `out[rows, n] = act_fp8[rows, k] @ weight[n, k]ᵀ` at row pitch `plan.ldc`,
299/// both block-scale sets folded in an FP32 epilogue.
300///
301/// The activation must already be through [`decode_w8a8_quant_act`]; this is
302/// the GEMM alone, so N projections over one activation cost one quantize and
303/// N matmuls.
304pub fn decode_w8a8_gemm(
305 scratch: &DecodeW8a8Scratch,
306 fp8w: &crate::weight_map::Fp8Weight,
307 out: DevicePtr,
308 plan: &DecodeW8a8Plan,
309 stream: u64,
310) -> Result<()> {
311 let b_scale = if cublas_scale_layout_kmajor() {
312 scratch.act_scale_kmajor
313 } else {
314 scratch.act_scale
315 };
316 spark_runtime::cublaslt::fp8_gemm_act_weight_t_blkscaled_ldc(
317 scratch.act_fp8.0,
318 b_scale.0,
319 fp8w.weight.0,
320 fp8w.row_scale.0,
321 out.0,
322 plan.m_pad(),
323 plan.n,
324 plan.k,
325 plan.ldc,
326 stream,
327 )
328}
329
330#[cfg(test)]
331#[path = "dispatch_proj_decode_tests.rs"]
332mod tests;