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;