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/// `ATLAS_CUBLAS_SCALE_LAYOUT` — which VEC128 activation-scale layout the
12/// cuBLASLt block-scaled arm feeds the library.
13///
14/// * `kmajor` (DEFAULT) — `[K/128, ceil16(M)]`, tokens contiguous. What the
15///   cuBLAS manual's "Scaling factors layouts" specifies for the B operand
16///   ("N-major for B with shape N x L"); see
17///   `spark_runtime::cublaslt::scale_layout` for the full quotes.
18/// * `rowmajor` — the quantizer's `[M, K/128]` handed over untransposed, i.e.
19///   the pre-fix reading. KEPT ONLY as a measurement control: it is what the
20///   2026-09-11 H100 run measured at rel_rms 7.7e-2 / cosine 0.996 vs the
21///   in-tree kernel, and an operator comparing the two arms on one box should
22///   not have to check out an old commit to reproduce it.
23///
24/// `OnceLock`-cached for the same reason `ffn_w8a16_only()` is: the selector
25/// runs per projection per layer per prefill and `env::var_os` walks the
26/// environment block every call.
27pub fn cublas_scale_layout_kmajor() -> bool {
28    static KMAJOR: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
29    *KMAJOR.get_or_init(|| {
30        !matches!(
31            std::env::var("ATLAS_CUBLAS_SCALE_LAYOUT").as_deref(),
32            Ok("rowmajor")
33        )
34    })
35}
36
37/// Rewrite the quantizer's row-major `[M, K/128]` FP32 activation scales into
38/// the `[K/128, M_pad]` cuBLASLt documents for a VEC128 B operand, zero-filling
39/// the `M..M_pad` pad rows.
40///
41/// The index math is pinned on the CPU by
42/// `spark_runtime::cublaslt::scale_layout` (SSOT, with the doc quotes); this is
43/// only its launcher. The quantizer's own output is left in place — the
44/// in-tree `fp8_gemm_t_blockscaled` still reads it directly.
45pub fn fp8_act_scale_to_kmajor(
46    gpu: &dyn spark_runtime::gpu::GpuBackend,
47    kernel: spark_runtime::gpu::KernelHandle,
48    a_scale: spark_runtime::gpu::DevicePtr,
49    a_scale_kmajor: spark_runtime::gpu::DevicePtr,
50    m: u32,
51    m_pad: u32,
52    k: u32,
53    stream: u64,
54) -> anyhow::Result<()> {
55    use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
56    let l = k / 128;
57    KernelLaunch::new(gpu, kernel)
58        .grid([div_ceil(m_pad, 256), l, 1])
59        .block([256, 1, 1])
60        .arg_ptr(a_scale)
61        .arg_ptr(a_scale_kmajor)
62        .arg_u32(m)
63        .arg_u32(m_pad)
64        .arg_u32(l)
65        .launch(stream)
66}
67
68/// Route a projection through native-FP8 cuBLASLt block-scaled matmul: quantize
69/// the activation to FP8 + per-[token,128-of-K] scales (the existing
70/// `per_token_group_quant_fp8` kernel), adapt those scales to the layout
71/// cuBLASLt documents, then feed the FP8 weight + its per-128×128 block scales
72/// directly (zero dequant, zero extra weight memory). Both operands
73/// 128-block-scaled (cuBLASLt requires it). ~1.8× the bf16 path (152 vs 85 TF).
74///
75/// `act_fp8_scratch`/`act_scale_scratch`/`act_scale_kmajor_scratch` must hold
76/// the padded extents (the `buffers.fp8_act`/`fp8_act_scale` arena buffers,
77/// sized for max_batch_tokens).
78#[allow(clippy::too_many_arguments)]
79pub fn cublas_fp8_proj(
80    gpu: &dyn spark_runtime::gpu::GpuBackend,
81    ptg_quant_k: Fp8ActQuant,
82    scale_kmajor_k: spark_runtime::gpu::KernelHandle,
83    act_bf16: spark_runtime::gpu::DevicePtr,
84    act_fp8_scratch: spark_runtime::gpu::DevicePtr,
85    act_scale_scratch: spark_runtime::gpu::DevicePtr,
86    act_scale_kmajor_scratch: spark_runtime::gpu::DevicePtr,
87    fp8w: &crate::weight_map::Fp8Weight,
88    out: spark_runtime::gpu::DevicePtr,
89    m: u32,
90    n: u32,
91    k: u32,
92    stream: u64,
93) -> anyhow::Result<()> {
94    // Quantize the real M tokens → fp8 bytes + VEC128 scales [M, K/128].
95    per_token_group_quant_fp8(
96        gpu,
97        ptg_quant_k,
98        act_bf16,
99        act_fp8_scratch,
100        act_scale_scratch,
101        m,
102        k,
103        stream,
104    )?;
105    cublas_fp8_proj_prequant(
106        gpu,
107        scale_kmajor_k,
108        act_fp8_scratch,
109        act_scale_scratch,
110        act_scale_kmajor_scratch,
111        fp8w,
112        out,
113        m,
114        n,
115        k,
116        stream,
117    )
118}
119
120/// [`cublas_fp8_proj`] for an activation that is ALREADY quantized — the
121/// caller ran `per_token_group_quant_fp8` itself.
122///
123/// WHY the split (#917/#928): the dense FFN's gate and up projections consume
124/// the SAME `[M, K]` input, so quantizing inside the GEMM helper would pay the
125/// per-token quant twice per layer. The FFN quantizes once and calls this for
126/// both, then quantizes the post-SiLU intermediate once for `down`.
127///
128/// ⚠ SCALE LAYOUT. cuBLASLt reads the VEC128 B-scale tensor with the TOKEN
129/// index contiguous (`[K/128, ceil16(M)]`), not the `[M, K/128]` the quantizer
130/// writes — cuBLAS "Scaling factors layouts", and the reason this helper needs
131/// `act_scale_kmajor` at all. Handing the quantizer's buffer over directly is
132/// what the 2026-09-11 H100 run measured at rel_rms 7.7e-2 / ~33 000 BF16 ULP
133/// against the in-tree kernel on identical FP8 bytes; `ATLAS_CUBLAS_SCALE_LAYOUT
134/// =rowmajor` reproduces that reading deliberately.
135///
136/// ⚠ PADDED-M EXTENTS. cuBLASLt is handed `ceil16(M)`, so:
137///
138/// * `out` must hold `ceil16(M) * N` BF16 elements — the phantom rows are
139///   WRITTEN (well-defined: their activation scales are zeroed below).
140/// * `act_fp8` must hold `ceil16(M) * K` bytes, `act_scale`
141///   `M * (K/128)` f32 and `act_scale_kmajor` `ceil16(M) * (K/128)` f32 — the
142///   phantom rows are READ.
143///
144/// The arena sizes that headroom in; see the sizing notes in
145/// `spark_runtime::buffers::sizes` (`fp8_act`, `ffn_act_a`, `ffn_act_scale`,
146/// `ffn_act_scale_kmajor`, `expert_gate_out`, `moe_output`).
147#[allow(clippy::too_many_arguments)]
148pub fn cublas_fp8_proj_prequant(
149    gpu: &dyn spark_runtime::gpu::GpuBackend,
150    scale_kmajor_k: spark_runtime::gpu::KernelHandle,
151    act_fp8: spark_runtime::gpu::DevicePtr,
152    act_scale: spark_runtime::gpu::DevicePtr,
153    act_scale_kmajor: spark_runtime::gpu::DevicePtr,
154    fp8w: &crate::weight_map::Fp8Weight,
155    out: spark_runtime::gpu::DevicePtr,
156    m: u32,
157    n: u32,
158    k: u32,
159    stream: u64,
160) -> anyhow::Result<()> {
161    // cuBLASLt requires the scale-tensor M extent to be a multiple of 4; pad to
162    // 16 (TC-friendly) and zero the padding so the phantom output columns
163    // (ignored by the caller) are well-defined.
164    let m_pad = cublas_fp8_m_pad(m);
165    let kg = (k / 128) as usize;
166    if m_pad > m {
167        // A zero scale kills the phantom rows' CONTRIBUTION, but the FP8 dot
168        // product still runs over whatever bytes are there and `NaN * 0.0` is
169        // `NaN`. Same reasoning (and same fix) as the row-wise sibling in
170        // `dispatch_proj_rowwise.rs`.
171        gpu.memset_async(
172            act_fp8.offset(m as usize * k as usize),
173            0,
174            (m_pad - m) as usize * k as usize,
175            stream,
176        )?;
177    }
178    let b_scale = if cublas_scale_layout_kmajor() {
179        if scale_kmajor_k.0 == 0 || act_scale_kmajor.0 == 0 {
180            anyhow::bail!(
181                "cuBLASLt block-scaled FP8 needs the fp8_act_scale_to_kmajor adapter \
182                 (kernel={:#x}, scratch={:#x}) — see cublas_scale_layout_kmajor()",
183                scale_kmajor_k.0,
184                act_scale_kmajor.0
185            );
186        }
187        // Writes every [K/128, m_pad] slot, pad rows included, so no separate
188        // memset of the scale pad is needed.
189        fp8_act_scale_to_kmajor(
190            gpu,
191            scale_kmajor_k,
192            act_scale,
193            act_scale_kmajor,
194            m,
195            m_pad,
196            k,
197            stream,
198        )?;
199        act_scale_kmajor
200    } else {
201        // Measurement control only (`ATLAS_CUBLAS_SCALE_LAYOUT=rowmajor`): the
202        // pad rows are a contiguous tail in THIS layout, so zero them here.
203        if m_pad > m {
204            gpu.memset_async(
205                act_scale.offset(m as usize * kg * 4),
206                0,
207                (m_pad - m) as usize * kg * 4,
208                stream,
209            )?;
210        }
211        act_scale
212    };
213    spark_runtime::cublaslt::fp8_gemm_act_weight_t_blkscaled(
214        act_fp8.0,
215        b_scale.0,
216        fp8w.weight.0,
217        fp8w.row_scale.0,
218        out.0,
219        m_pad,
220        n,
221        k,
222        stream,
223    )
224}
225
226/// The M extent [`cublas_fp8_proj_prequant`] actually hands cuBLASLt. SSOT for
227/// the callers that must bounds-check their output buffer against it.
228pub fn cublas_fp8_m_pad(m: u32) -> u32 {
229    m.div_ceil(16) * 16
230}
231
232/// Dequantize a block-scaled OR per-row FP8 weight `[N,K]` → BF16 into a
233/// CALLER-OWNED buffer of `n*k*2` bytes. Allocates nothing.
234///
235/// The kernel reads `scale[(n / block_n) * sk + (k / block_k)]`, so the SAME
236/// kernel serves both layouts — the block geometry is what selects between
237/// them, not a second kernel:
238///
239///   block-scaled   block_n = block_k = 128, sk = K/128
240///   PER-ROW        block_n = 1, block_k = K, sk = 1
241///                  -> offset = n * 1 + 0 = n, one multiplier per row
242///
243/// That per-row case is what a mixed-precision compressed-tensors checkpoint
244/// ships, and dequantising it is lossless: every FP8 E4M3 value is exactly
245/// representable in BF16, so this is the fold's no-double-quant path even
246/// though the GEMM downstream is BF16.
247///
248/// SSOT for every FP8→BF16 weight expansion in this file. It takes a
249/// destination rather than producing one because the #917 H100 receipt
250/// (2026-09-11, `Qwen/Qwen3.8-27B-FP8`) was a `gpu.alloc` hidden in here:
251/// `167772160` B per GDN layer with no `BufferSizes` entry, invisible to
252/// `--gpu-memory-utilization`, which killed a 28-token prefill at layer 36
253/// with `cuMemAlloc_v2 failed: status 2`. Who owns the bytes is now the
254/// caller's decision, and the row-wise GDN arms answer it with the ledgered
255/// `buffers.take_ssm_rowwise_w_bf16` slab.
256pub fn dequant_fp8_bf16_into(
257    gpu: &dyn spark_runtime::gpu::GpuBackend,
258    fp8w: &crate::weight_map::Fp8Weight,
259    dst: spark_runtime::gpu::DevicePtr,
260    stream: u64,
261) -> anyhow::Result<()> {
262    use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
263    let (n, kk) = (fp8w.n, fp8w.k);
264    let per_row = fp8w.scale_format == crate::weight_map::WeightQuantFormat::Fp8PerRow;
265    let (block_n, block_k, sk) = if per_row {
266        (1u32, kk, 1u32)
267    } else {
268        (128u32, 128u32, kk / 128)
269    };
270    let kernel = gpu.kernel(
271        "dequant_fp8_blockscaled_bf16",
272        "dequant_fp8_blockscaled_bf16",
273    )?;
274    KernelLaunch::new(gpu, kernel)
275        .grid([div_ceil(kk, 64), div_ceil(n, 4), 1])
276        .block([64, 4, 1])
277        .arg_ptr(fp8w.weight)
278        .arg_ptr(fp8w.row_scale)
279        .arg_ptr(dst)
280        .arg_u32(n)
281        .arg_u32(kk)
282        .arg_u32(block_n)
283        .arg_u32(block_k)
284        .arg_u32(sk)
285        .arg_u32(1) // scale_is_fp32
286        .launch(stream)
287}
288
289/// BF16 bytes [`dequant_fp8_bf16_into`] writes for `fp8w`.
290pub fn dequant_fp8_bf16_bytes(fp8w: &crate::weight_map::Fp8Weight) -> usize {
291    fp8w.n as usize * fp8w.k as usize * 2
292}
293
294/// [`dequant_fp8_bf16_into`] into a FRESH allocation, memoised by FP8 weight
295/// pointer (weights are immutable after load).
296///
297/// ⚠ OFF-LEDGER. The allocation has no `spark_runtime::buffers::sizes::BufferSizes`
298/// entry, so `--gpu-memory-utilization` cannot see it — the #917 defect named
299/// on [`dequant_fp8_bf16_into`]. The last caller is [`cutlass_bf16_proj`], a
300/// benchmark-only reference path that a shipping recipe cannot reach; the GDN
301/// row-wise arms moved to the ledgered slab in
302/// `qwen3_ssm/rowwise_bf16.rs`. Do NOT add callers — take a ledgered
303/// destination and call [`dequant_fp8_bf16_into`] instead.
304fn dequant_fp8_bf16_cached(
305    gpu: &dyn spark_runtime::gpu::GpuBackend,
306    derived: &super::DerivedWeights,
307    fp8w: &crate::weight_map::Fp8Weight,
308    stream: u64,
309) -> anyhow::Result<u64> {
310    let cache_key = fp8w.weight.0;
311    if let Some(hit) = derived.get_ptr(super::Derivation::Bf16, cache_key) {
312        return Ok(hit);
313    }
314    let out = gpu.alloc(dequant_fp8_bf16_bytes(fp8w))?; // BF16 [N,K]
315    dequant_fp8_bf16_into(gpu, fp8w, out, stream)?;
316    derived.insert_ptr(super::Derivation::Bf16, cache_key, out.0);
317    Ok(out.0)
318}
319
320/// [`dequant_fp8_bf16_into`] into a FRESH allocation the caller FREES — the
321/// NVFP4 packer's transient, which never outlives the pack.
322fn dequant_fp8_bf16_uncached(
323    gpu: &dyn spark_runtime::gpu::GpuBackend,
324    fp8w: &crate::weight_map::Fp8Weight,
325    stream: u64,
326) -> anyhow::Result<spark_runtime::gpu::DevicePtr> {
327    let out = gpu.alloc(dequant_fp8_bf16_bytes(fp8w))?;
328    dequant_fp8_bf16_into(gpu, fp8w, out, stream)?;
329    Ok(out)
330}
331
332/// Route a projection `out[M,N] = act[M,K] @ weightᵀ` through cuBLASLt BF16 for
333/// a weight that is already BF16 `[N,K]`. Two kinds of caller: models whose
334/// attention/shared-expert weights ship unquantized (e.g. Laguna), and the
335/// row-wise GDN prefill arms, which hand it the ledgered BF16 dequant
336/// `qwen3_ssm/rowwise_bf16.rs` writes once per layer.
337///
338/// There is deliberately NO `cublas_bf16_proj` beside it any more — the
339/// dequant-and-cache variant that used to own the FP8→BF16 expansion is the
340/// #917 off-ledger allocation (see [`dequant_fp8_bf16_into`]). Splitting
341/// "who owns the BF16 bytes" from "multiply them" is what keeps the ledger
342/// honest: this function cannot allocate.
343pub fn cublas_bf16_proj_dense(
344    act: spark_runtime::gpu::DevicePtr,
345    weight_bf16: spark_runtime::gpu::DevicePtr,
346    out: spark_runtime::gpu::DevicePtr,
347    m: u32,
348    n: u32,
349    k: u32,
350    stream: u64,
351) -> anyhow::Result<()> {
352    spark_runtime::cublaslt::bf16_gemm_act_weight_t(act.0, weight_bf16.0, out.0, m, n, k, stream)
353}
354
355/// Route a projection `out[M,N] = act[M,K] @ weightᵀ` through CUTLASS BF16.
356///
357/// ★ A REFERENCE PATH FOR BENCHMARKING, NOT A SHIPPING ONE. Opt-in behind
358/// `ATLAS_CUTLASS_GEMM=1` and OFF by default; a build without `CUTLASS_HOME`
359/// cannot reach it at all. It exists so a shape can be A/B'd against the
360/// industry reference on the same box — if CUTLASS wins a shape, the fix is
361/// a faster Atlas kernel, not a promotion. See the module docs on
362/// `spark_runtime::cutlass` for the full rationale (SSOT).
363#[allow(clippy::too_many_arguments)]
364pub fn cutlass_bf16_proj(
365    gpu: &dyn spark_runtime::gpu::GpuBackend,
366    derived: &super::DerivedWeights,
367    act: spark_runtime::gpu::DevicePtr,
368    fp8w: &crate::weight_map::Fp8Weight,
369    out: spark_runtime::gpu::DevicePtr,
370    m: u32,
371    n: u32,
372    k: u32,
373    stream: u64,
374) -> anyhow::Result<()> {
375    let w_bf16 = dequant_fp8_bf16_cached(gpu, derived, fp8w, stream)?;
376    spark_runtime::cutlass::bf16_gemm_act_weight_t(act.0, w_bf16, out.0, m, n, k, stream)
377}
378
379/// Route a projection `out[M,N] = act[M,K] @ weightᵀ` through native CUTLASS
380/// NVFP4. The activation is packed to CUTLASS NVFP4 inside the runtime wrapper.
381/// `weight_t` must be Atlas's transposed NVFP4 layout `[K/2,N]` plus
382/// `[K/16,N]` scales, as produced by `QuantizedWeight::transpose_for_gemm`.
383#[allow(clippy::too_many_arguments)]
384/// Transpose a native NVFP4 checkpoint weight from Atlas `[K/2,N]` into the
385/// CUTLASS `[N,K/2]` byte layout the GEMM consumes, caching the result by
386/// source weight ptr. Without this the ColumnMajor B operand is read
387/// transposed and the GEMM produces garbage (cos≈0 vs reference).
388fn cutlass_nvfp4_weight_transposed_cached(
389    gpu: &dyn spark_runtime::gpu::GpuBackend,
390    derived: &super::DerivedWeights,
391    weight_t: &crate::weight_map::QuantizedWeight,
392    n: u32,
393    k: u32,
394    stream: u64,
395) -> anyhow::Result<u64> {
396    let cache_key = weight_t.weight.0;
397    if let Some(hit) = derived.get_ptr(super::Derivation::CutlassNvfp4Transposed, cache_key) {
398        return Ok(hit);
399    }
400    let dst = gpu.alloc((n as usize) * (k as usize) / 2)?;
401    spark_runtime::cutlass::transpose_nvfp4_packed_kton(weight_t.weight.0, dst.0, n, k, stream)?;
402    gpu.synchronize(stream)?;
403    derived.insert_ptr(super::Derivation::CutlassNvfp4Transposed, cache_key, dst.0);
404    Ok(dst.0)
405}
406
407#[allow(clippy::too_many_arguments)]
408pub fn cutlass_nvfp4_proj(
409    // The backend and this model's derived-weight cache travel together
410    // everywhere they are used; taking the context instead of the pair keeps
411    // the call sites one line each.
412    ctx: &crate::layer::ForwardContext<'_>,
413    act: spark_runtime::gpu::DevicePtr,
414    weight_t: &crate::weight_map::QuantizedWeight,
415    out: spark_runtime::gpu::DevicePtr,
416    m: u32,
417    n: u32,
418    k: u32,
419    stream: u64,
420) -> anyhow::Result<()> {
421    let (gpu, derived) = (ctx.gpu, ctx.derived);
422    let packed = cutlass_nvfp4_weight_transposed_cached(gpu, derived, weight_t, n, k, stream)?;
423    spark_runtime::cutlass::nvfp4_gemm_bf16_act_weight_t(
424        act.0,
425        packed,
426        weight_t.weight_scale.0,
427        weight_t.weight_scale_2,
428        out.0,
429        m,
430        n,
431        k,
432        stream,
433    )
434}
435
436fn cutlass_nvfp4_weight_from_fp8_cached(
437    gpu: &dyn spark_runtime::gpu::GpuBackend,
438    derived: &super::DerivedWeights,
439    fp8w: &crate::weight_map::Fp8Weight,
440    stream: u64,
441) -> anyhow::Result<(u64, u64)> {
442    let cache_key = fp8w.weight.0;
443    if let Some(hit) = derived.get_pair(super::Derivation::CutlassNvfp4FromFp8, cache_key) {
444        return Ok(hit);
445    }
446
447    let n = fp8w.n as usize;
448    let k = fp8w.k as usize;
449    let w_bf16 = dequant_fp8_bf16_uncached(gpu, fp8w, stream)?;
450    let packed_t = gpu.alloc(n * k / 2)?;
451    let scale_t = gpu.alloc(n * k / 16)?;
452    spark_runtime::cutlass::pack_bf16_weight_to_nvfp4_t(
453        w_bf16.0, packed_t.0, scale_t.0, fp8w.n, fp8w.k, stream,
454    )?;
455    gpu.synchronize(stream)?;
456    gpu.free(w_bf16)?;
457    derived.insert_pair(
458        super::Derivation::CutlassNvfp4FromFp8,
459        cache_key,
460        (packed_t.0, scale_t.0),
461    );
462    Ok((packed_t.0, scale_t.0))
463}
464
465/// Native CUTLASS NVFP4 projection for FP8 checkpoint weights. The FP8 weight
466/// is dequantized to BF16 using the existing cache, then packed once into
467/// Atlas-transposed NVFP4 data/scales and reused for future calls.
468#[allow(clippy::too_many_arguments)]
469pub fn cutlass_nvfp4_proj_from_fp8(
470    // The backend and this model's derived-weight cache travel together
471    // everywhere they are used; taking the context instead of the pair keeps
472    // the call sites one line each.
473    ctx: &crate::layer::ForwardContext<'_>,
474    act: spark_runtime::gpu::DevicePtr,
475    fp8w: &crate::weight_map::Fp8Weight,
476    out: spark_runtime::gpu::DevicePtr,
477    m: u32,
478    n: u32,
479    k: u32,
480    stream: u64,
481) -> anyhow::Result<()> {
482    let (gpu, derived) = (ctx.gpu, ctx.derived);
483    let (packed_t, scale_t) = cutlass_nvfp4_weight_from_fp8_cached(gpu, derived, fp8w, stream)?;
484    spark_runtime::cutlass::nvfp4_gemm_bf16_act_weight_t(
485        act.0, packed_t, scale_t, 1.0, out.0, m, n, k, stream,
486    )
487}