spark_model/layers/ops/
dispatch_proj.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! cuBLAS / CUTLASS projection routers + their cached weight-prep helpers.
4//! Extracted from `dispatch_helpers.rs` during the ≤500-line split. Re-exported
5//! at `crate::layers::ops::*` via `ops.rs`.
6
7#![allow(unused_imports)]
8
9use super::*;
10
11// CUTLASS-backed reference/native projection paths (benchmark-only BF16 +
12// native NVFP4), split out to keep this file under the 500-LoC cap.
13//
14// `dispatch_proj.rs` was itself loaded via `#[path = "ops/dispatch_proj.rs"]`
15// in `ops.rs`, so an unqualified `mod cutlass;` here would resolve relative
16// to `ops/` (the including file's directory), not `ops/dispatch_proj/` — an
17// explicit `#[path]` is required to nest the submodule under this file.
18#[path = "dispatch_proj/cutlass.rs"]
19mod cutlass;
20pub use cutlass::*;
21
22/// `AVAROK_CUBLAS_SCALE_LAYOUT` — which VEC128 activation-scale layout the
23/// cuBLASLt block-scaled arm feeds the library.
24///
25/// * `kmajor` (DEFAULT) — `[K/128, ceil16(M)]`, tokens contiguous. What the
26///   cuBLAS manual's "Scaling factors layouts" specifies for the B operand
27///   ("N-major for B with shape N x L"); see
28///   `spark_runtime::cublaslt::scale_layout` for the full quotes.
29/// * `rowmajor` — the quantizer's `[M, K/128]` handed over untransposed, i.e.
30///   the pre-fix reading. KEPT ONLY as a measurement control: it is what the
31///   2026-09-11 H100 run measured at rel_rms 7.7e-2 / cosine 0.996 vs the
32///   in-tree kernel, and an operator comparing the two arms on one box should
33///   not have to check out an old commit to reproduce it.
34///
35/// `OnceLock`-cached for the same reason `ffn_w8a16_only()` is: the selector
36/// runs per projection per layer per prefill and `env::var_os` walks the
37/// environment block every call.
38pub fn cublas_scale_layout_kmajor() -> bool {
39    static KMAJOR: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
40    *KMAJOR.get_or_init(|| {
41        !matches!(
42            std::env::var("AVAROK_CUBLAS_SCALE_LAYOUT").as_deref(),
43            Ok("rowmajor")
44        )
45    })
46}
47
48/// Rewrite the quantizer's row-major `[M, K/128]` FP32 activation scales into
49/// the `[K/128, M_pad]` cuBLASLt documents for a VEC128 B operand, zero-filling
50/// the `M..M_pad` pad rows.
51///
52/// The index math is pinned on the CPU by
53/// `spark_runtime::cublaslt::scale_layout` (SSOT, with the doc quotes); this is
54/// only its launcher. The quantizer's own output is left in place — the
55/// in-tree `fp8_gemm_t_blockscaled` still reads it directly.
56pub fn fp8_act_scale_to_kmajor(
57    gpu: &dyn spark_runtime::gpu::GpuBackend,
58    kernel: spark_runtime::gpu::KernelHandle,
59    a_scale: spark_runtime::gpu::DevicePtr,
60    a_scale_kmajor: spark_runtime::gpu::DevicePtr,
61    m: u32,
62    m_pad: u32,
63    k: u32,
64    stream: u64,
65) -> anyhow::Result<()> {
66    use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
67    let l = k / 128;
68    KernelLaunch::new(gpu, kernel)
69        .grid([div_ceil(m_pad, 256), l, 1])
70        .block([256, 1, 1])
71        .arg_ptr(a_scale)
72        .arg_ptr(a_scale_kmajor)
73        .arg_u32(m)
74        .arg_u32(m_pad)
75        .arg_u32(l)
76        .launch(stream)
77}
78
79/// Route a projection through native-FP8 cuBLASLt block-scaled matmul: quantize
80/// the activation to FP8 + per-[token,128-of-K] scales (the existing
81/// `per_token_group_quant_fp8` kernel), adapt those scales to the layout
82/// cuBLASLt documents, then feed the FP8 weight + its per-128×128 block scales
83/// directly (zero dequant, zero extra weight memory). Both operands
84/// 128-block-scaled (cuBLASLt requires it). ~1.8× the bf16 path (152 vs 85 TF).
85///
86/// `act_fp8_scratch`/`act_scale_scratch`/`act_scale_kmajor_scratch` must hold
87/// the padded extents (the `buffers.fp8_act`/`fp8_act_scale` arena buffers,
88/// sized for max_batch_tokens).
89#[allow(clippy::too_many_arguments)]
90pub fn cublas_fp8_proj(
91    gpu: &dyn spark_runtime::gpu::GpuBackend,
92    ptg_quant_k: Fp8ActQuant,
93    scale_kmajor_k: spark_runtime::gpu::KernelHandle,
94    act_bf16: spark_runtime::gpu::DevicePtr,
95    act_fp8_scratch: spark_runtime::gpu::DevicePtr,
96    act_scale_scratch: spark_runtime::gpu::DevicePtr,
97    act_scale_kmajor_scratch: spark_runtime::gpu::DevicePtr,
98    fp8w: &crate::weight_map::Fp8Weight,
99    out: spark_runtime::gpu::DevicePtr,
100    m: u32,
101    n: u32,
102    k: u32,
103    stream: u64,
104) -> anyhow::Result<()> {
105    // Quantize the real M tokens → fp8 bytes + VEC128 scales [M, K/128].
106    per_token_group_quant_fp8(
107        gpu,
108        ptg_quant_k,
109        act_bf16,
110        act_fp8_scratch,
111        act_scale_scratch,
112        m,
113        k,
114        stream,
115    )?;
116    cublas_fp8_proj_prequant(
117        gpu,
118        scale_kmajor_k,
119        act_fp8_scratch,
120        act_scale_scratch,
121        act_scale_kmajor_scratch,
122        fp8w,
123        out,
124        m,
125        n,
126        k,
127        stream,
128    )
129}
130
131/// [`cublas_fp8_proj`] for an activation that is ALREADY quantized — the
132/// caller ran `per_token_group_quant_fp8` itself.
133///
134/// WHY the split (#917/#928): the dense FFN's gate and up projections consume
135/// the SAME `[M, K]` input, so quantizing inside the GEMM helper would pay the
136/// per-token quant twice per layer. The FFN quantizes once and calls this for
137/// both, then quantizes the post-SiLU intermediate once for `down`.
138///
139/// ⚠ SCALE LAYOUT. cuBLASLt reads the VEC128 B-scale tensor with the TOKEN
140/// index contiguous (`[K/128, ceil16(M)]`), not the `[M, K/128]` the quantizer
141/// writes — cuBLAS "Scaling factors layouts", and the reason this helper needs
142/// `act_scale_kmajor` at all. Handing the quantizer's buffer over directly is
143/// what the 2026-09-11 H100 run measured at rel_rms 7.7e-2 / ~33 000 BF16 ULP
144/// against the in-tree kernel on identical FP8 bytes; `AVAROK_CUBLAS_SCALE_LAYOUT
145/// =rowmajor` reproduces that reading deliberately.
146///
147/// ⚠ PADDED-M EXTENTS. cuBLASLt is handed `ceil16(M)`, so:
148///
149/// * `out` must hold `ceil16(M) * N` BF16 elements — the phantom rows are
150///   WRITTEN (well-defined: their activation scales are zeroed below).
151/// * `act_fp8` must hold `ceil16(M) * K` bytes, `act_scale`
152///   `M * (K/128)` f32 and `act_scale_kmajor` `ceil16(M) * (K/128)` f32 — the
153///   phantom rows are READ.
154///
155/// The arena sizes that headroom in; see the sizing notes in
156/// `spark_runtime::buffers::sizes` (`fp8_act`, `ffn_act_a`, `ffn_act_scale`,
157/// `ffn_act_scale_kmajor`, `expert_gate_out`, `moe_output`).
158#[allow(clippy::too_many_arguments)]
159pub fn cublas_fp8_proj_prequant(
160    gpu: &dyn spark_runtime::gpu::GpuBackend,
161    scale_kmajor_k: spark_runtime::gpu::KernelHandle,
162    act_fp8: spark_runtime::gpu::DevicePtr,
163    act_scale: spark_runtime::gpu::DevicePtr,
164    act_scale_kmajor: spark_runtime::gpu::DevicePtr,
165    fp8w: &crate::weight_map::Fp8Weight,
166    out: spark_runtime::gpu::DevicePtr,
167    m: u32,
168    n: u32,
169    k: u32,
170    stream: u64,
171) -> anyhow::Result<()> {
172    // cuBLASLt requires the scale-tensor M extent to be a multiple of 4; pad to
173    // 16 (TC-friendly) and zero the padding so the phantom output columns
174    // (ignored by the caller) are well-defined.
175    let m_pad = cublas_fp8_m_pad(m);
176    let kg = (k / 128) as usize;
177    if m_pad > m {
178        // A zero scale kills the phantom rows' CONTRIBUTION, but the FP8 dot
179        // product still runs over whatever bytes are there and `NaN * 0.0` is
180        // `NaN`. Same reasoning (and same fix) as the row-wise sibling in
181        // `dispatch_proj_rowwise.rs`.
182        gpu.memset_async(
183            act_fp8.offset(m as usize * k as usize),
184            0,
185            (m_pad - m) as usize * k as usize,
186            stream,
187        )?;
188    }
189    let b_scale = if cublas_scale_layout_kmajor() {
190        if scale_kmajor_k.0 == 0 || act_scale_kmajor.0 == 0 {
191            anyhow::bail!(
192                "cuBLASLt block-scaled FP8 needs the fp8_act_scale_to_kmajor adapter \
193                 (kernel={:#x}, scratch={:#x}) — see cublas_scale_layout_kmajor()",
194                scale_kmajor_k.0,
195                act_scale_kmajor.0
196            );
197        }
198        // Writes every [K/128, m_pad] slot, pad rows included, so no separate
199        // memset of the scale pad is needed.
200        fp8_act_scale_to_kmajor(
201            gpu,
202            scale_kmajor_k,
203            act_scale,
204            act_scale_kmajor,
205            m,
206            m_pad,
207            k,
208            stream,
209        )?;
210        act_scale_kmajor
211    } else {
212        // Measurement control only (`AVAROK_CUBLAS_SCALE_LAYOUT=rowmajor`): the
213        // pad rows are a contiguous tail in THIS layout, so zero them here.
214        if m_pad > m {
215            gpu.memset_async(
216                act_scale.offset(m as usize * kg * 4),
217                0,
218                (m_pad - m) as usize * kg * 4,
219                stream,
220            )?;
221        }
222        act_scale
223    };
224    spark_runtime::cublaslt::fp8_gemm_act_weight_t_blkscaled(
225        act_fp8.0,
226        b_scale.0,
227        fp8w.weight.0,
228        fp8w.row_scale.0,
229        out.0,
230        m_pad,
231        n,
232        k,
233        stream,
234    )
235}
236
237/// The M extent [`cublas_fp8_proj_prequant`] actually hands cuBLASLt. SSOT for
238/// the callers that must bounds-check their output buffer against it.
239pub fn cublas_fp8_m_pad(m: u32) -> u32 {
240    m.div_ceil(16) * 16
241}
242
243/// Dequantize a block-scaled OR per-row FP8 weight `[N,K]` → BF16 into a
244/// CALLER-OWNED buffer of `n*k*2` bytes. Allocates nothing.
245///
246/// The kernel reads `scale[(n / block_n) * sk + (k / block_k)]`, so the SAME
247/// kernel serves both layouts — the block geometry is what selects between
248/// them, not a second kernel:
249///
250///   block-scaled   block_n = block_k = 128, sk = K/128
251///   PER-ROW        block_n = 1, block_k = K, sk = 1
252///                  -> offset = n * 1 + 0 = n, one multiplier per row
253///
254/// That per-row case is what a mixed-precision compressed-tensors checkpoint
255/// ships, and dequantising it is lossless: every FP8 E4M3 value is exactly
256/// representable in BF16, so this is the fold's no-double-quant path even
257/// though the GEMM downstream is BF16.
258///
259/// SSOT for every FP8→BF16 weight expansion in this file. It takes a
260/// destination rather than producing one because the #917 H100 receipt
261/// (2026-09-11, `Qwen/Qwen3.8-27B-FP8`) was a `gpu.alloc` hidden in here:
262/// `167772160` B per GDN layer with no `BufferSizes` entry, invisible to
263/// `--gpu-memory-utilization`, which killed a 28-token prefill at layer 36
264/// with `cuMemAlloc_v2 failed: status 2`. Who owns the bytes is now the
265/// caller's decision, and the row-wise GDN arms answer it with the ledgered
266/// `buffers.take_ssm_rowwise_w_bf16` slab.
267pub fn dequant_fp8_bf16_into(
268    gpu: &dyn spark_runtime::gpu::GpuBackend,
269    fp8w: &crate::weight_map::Fp8Weight,
270    dst: spark_runtime::gpu::DevicePtr,
271    stream: u64,
272) -> anyhow::Result<()> {
273    use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
274    let (n, kk) = (fp8w.n, fp8w.k);
275    let per_row = fp8w.scale_format == crate::weight_map::WeightQuantFormat::Fp8PerRow;
276    let (block_n, block_k, sk) = if per_row {
277        (1u32, kk, 1u32)
278    } else {
279        (128u32, 128u32, kk / 128)
280    };
281    let kernel = gpu.kernel(
282        "dequant_fp8_blockscaled_bf16",
283        "dequant_fp8_blockscaled_bf16",
284    )?;
285    KernelLaunch::new(gpu, kernel)
286        .grid([div_ceil(kk, 64), div_ceil(n, 4), 1])
287        .block([64, 4, 1])
288        .arg_ptr(fp8w.weight)
289        .arg_ptr(fp8w.row_scale)
290        .arg_ptr(dst)
291        .arg_u32(n)
292        .arg_u32(kk)
293        .arg_u32(block_n)
294        .arg_u32(block_k)
295        .arg_u32(sk)
296        .arg_u32(1) // scale_is_fp32
297        .launch(stream)
298}
299
300/// BF16 bytes [`dequant_fp8_bf16_into`] writes for `fp8w`.
301pub fn dequant_fp8_bf16_bytes(fp8w: &crate::weight_map::Fp8Weight) -> usize {
302    fp8w.n as usize * fp8w.k as usize * 2
303}
304
305/// Route a projection `out[M,N] = act[M,K] @ weightᵀ` through cuBLASLt BF16 for
306/// a weight that is already BF16 `[N,K]`. Two kinds of caller: models whose
307/// attention/shared-expert weights ship unquantized (e.g. Laguna), and the
308/// row-wise GDN prefill arms, which hand it the ledgered BF16 dequant
309/// `qwen3_ssm/rowwise_bf16.rs` writes once per layer.
310///
311/// There is deliberately NO `cublas_bf16_proj` beside it any more — the
312/// dequant-and-cache variant that used to own the FP8→BF16 expansion is the
313/// #917 off-ledger allocation (see [`dequant_fp8_bf16_into`]). Splitting
314/// "who owns the BF16 bytes" from "multiply them" is what keeps the ledger
315/// honest: this function cannot allocate.
316pub fn cublas_bf16_proj_dense(
317    act: spark_runtime::gpu::DevicePtr,
318    weight_bf16: spark_runtime::gpu::DevicePtr,
319    out: spark_runtime::gpu::DevicePtr,
320    m: u32,
321    n: u32,
322    k: u32,
323    stream: u64,
324) -> anyhow::Result<()> {
325    spark_runtime::cublaslt::bf16_gemm_act_weight_t(act.0, weight_bf16.0, out.0, m, n, k, stream)
326}
327
328/// [`cublas_bf16_proj_dense`] writing an **FP32** output buffer.
329///
330/// For consumers whose downstream kernel reads FP32 — GLM's DSA indexer `wq_b` — which
331/// otherwise cannot use a batched GEMM at all and degrade to one M=1 GEMV per row.
332pub fn cublas_bf16_proj_dense_f32_out(
333    act: spark_runtime::gpu::DevicePtr,
334    weight_bf16: spark_runtime::gpu::DevicePtr,
335    out: spark_runtime::gpu::DevicePtr,
336    m: u32,
337    n: u32,
338    k: u32,
339    stream: u64,
340) -> anyhow::Result<()> {
341    spark_runtime::cublaslt::bf16_gemm_act_weight_t_f32_out(
342        act.0,
343        weight_bf16.0,
344        out.0,
345        m,
346        n,
347        k,
348        stream,
349    )
350}