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}