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}