spark_model/layers/ops/dispatch_proj_rowwise.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Row-wise FP8 projection routing.
4//!
5//! Split from `dispatch_proj.rs` when the cluster this branch added took that
6//! file over the 500-LoC cap. It is a cohesive unit rather than an arbitrary
7//! cut: the passthrough decision, the cached block->row-wise requant it guards,
8//! and the router that consumes both.
9//!
10//! ⚠ The GEMM at the end of this path — `fp8_gemm_act_weight_t_rowwise` —
11//! returns NOT_SUPPORTED on sm_121 (measured 2026-08-15, reproduced through the
12//! block-scaled path with `ATLAS_CUBLAS_FP8=1`, so it is the GEMM and not the
13//! weights). The mixed-precision loader therefore routes through a ONE-TIME
14//! ledgered BF16 dequant instead (`qwen3_ssm/rowwise_bf16.rs`, #917); see
15//! `weight_loader/qwen35_dense/rowwise_fp8.rs`. This module stays because the
16//! passthrough is what a working per-row FP8 kernel would plug into — and it
17//! is the route that would retire that BF16 slab, since a per-row checkpoint
18//! reaches `fp8_gemm_act_weight_t_rowwise` with no conversion and no copy.
19
20// Everything here names its paths explicitly (`super::DerivedWeights`,
21// `crate::weight_map::…`), so this file needs no glob import — unlike
22// `dispatch_proj.rs`, which carries `#![allow(unused_imports)]` and a `use
23// super::*`.
24
25/// `(weight, scale)` verbatim when `fp8w` is ALREADY the row-wise pair the
26/// cuBLASLt row-wise GEMM wants, else `None`.
27///
28/// Pure, and split out from the GPU path so the invariant is testable on a
29/// CPU-only runner: the whole claim is "a row-wise checkpoint is passed
30/// through untouched", and that is a decision about a tag, not about a device.
31pub(super) fn rowwise_pair_passthrough(fp8w: &crate::weight_map::Fp8Weight) -> Option<(u64, u64)> {
32 use crate::weight_map::WeightQuantFormat;
33 (fp8w.scale_format == WeightQuantFormat::Fp8PerRow).then_some((fp8w.weight.0, fp8w.row_scale.0))
34}
35
36/// Re-quantize a block-scaled FP8 weight `[N,K]` → ROW-WISE FP8 (E4M3 + per-row
37/// FP32 scale `[N]`) on-GPU once, cached by the FP8 weight pointer. Path:
38/// block-fp8 → BF16 (transient) → row-wise fp8. Backs the GB10-supported
39/// `cublas_fp8_rowwise_proj`. Returns `(fp8_weight_ptr, per_row_scale_ptr)`.
40///
41/// A weight that is ALREADY row-wise returns its own pointers untouched — see
42/// the early return, which is the whole point of the `scale_format` tag here.
43fn requant_weight_rowwise_fp8_cached(
44 gpu: &dyn spark_runtime::gpu::GpuBackend,
45 derived: &super::DerivedWeights,
46 fp8w: &crate::weight_map::Fp8Weight,
47 stream: u64,
48) -> anyhow::Result<(u64, u64)> {
49 use crate::weight_map::WeightQuantFormat;
50 use spark_runtime::kernel_args::{KernelLaunch, div_ceil};
51
52 // ── Already row-wise: nothing to do, and nothing to lose ──────────────
53 //
54 // A mixed-precision compressed-tensors checkpoint (e.g.
55 // unsloth/Qwen3.8-27B-NVFP4, `format = mixed-precision`) ships its
56 // attention and GDN projections as FP8 E4M3 with a PER-CHANNEL scale —
57 // which is exactly `(weight, [N] f32)`, the pair this function exists to
58 // produce. Converting it would mean fp8 → bf16 → fp8, losing precision to
59 // manufacture something it already is.
60 //
61 // Without this arm those checkpoints take the loader's fallback instead:
62 // dequant to BF16 and RE-quantise to NVFP4, i.e. 8-bit weights served at
63 // 4 bits. Measured on the video benchmark's hardest leg, that fallback
64 // answered "Red, Blue" where the natively-loaded FP8 build of the same
65 // weights managed "Red, Blue, Yellow".
66 if let Some(pair) = rowwise_pair_passthrough(fp8w) {
67 return Ok(pair);
68 }
69 // The conversion below reads `row_scale` as a `[N/128, K/128]` FP32 grid.
70 // Anything else here is a caller bug, and a silent one — the buffer is
71 // smaller than the grid, so it reads in-bounds garbage rather than
72 // faulting. Assert instead.
73 fp8w.scale_format
74 .expect(WeightQuantFormat::Fp8BlockScaled, "rowwise-fp8 requant");
75 let cache_key = fp8w.weight.0;
76 if let Some(hit) = derived.get_pair(super::Derivation::RowwiseFp8, cache_key) {
77 return Ok(hit);
78 }
79 let (n, k) = (fp8w.n, fp8w.k);
80 // 1. block-fp8 → BF16 (transient scratch, freed after re-quant).
81 let bf16 = gpu.alloc(n as usize * k as usize * 2)?;
82 let block = 128u32;
83 let sk = k / block;
84 let dq = gpu.kernel(
85 "dequant_fp8_blockscaled_bf16",
86 "dequant_fp8_blockscaled_bf16",
87 )?;
88 KernelLaunch::new(gpu, dq)
89 .grid([div_ceil(k, 64), div_ceil(n, 4), 1])
90 .block([64, 4, 1])
91 .arg_ptr(fp8w.weight)
92 .arg_ptr(fp8w.row_scale)
93 .arg_ptr(bf16)
94 .arg_u32(n)
95 .arg_u32(k)
96 .arg_u32(block)
97 .arg_u32(block)
98 .arg_u32(sk)
99 .arg_u32(1)
100 .launch(stream)?;
101 // 2. BF16 → row-wise fp8 [N,K] + per-row scale [N].
102 let w_fp8 = gpu.alloc(n as usize * k as usize)?;
103 let w_scale = gpu.alloc(n as usize * 4)?;
104 let qk = gpu.kernel("quant_rowwise_fp8", "quant_rowwise_fp8")?;
105 KernelLaunch::new(gpu, qk)
106 .grid([n, 1, 1])
107 .block([256, 1, 1])
108 .arg_ptr(bf16)
109 .arg_ptr(w_fp8)
110 .arg_ptr(w_scale)
111 .arg_u32(n)
112 .arg_u32(k)
113 .launch(stream)?;
114 gpu.synchronize(stream)?; // re-quant must finish before the transient bf16 is freed
115 gpu.free(bf16)?;
116 derived.insert_pair(
117 super::Derivation::RowwiseFp8,
118 cache_key,
119 (w_fp8.0, w_scale.0),
120 );
121 Ok((w_fp8.0, w_scale.0))
122}
123
124/// Route a projection through ROW-WISE native-FP8 cuBLASLt (the fp8 path GB10
125/// supports). Weight is re-quantized once to per-row fp8 (cached); the activation
126/// is quantized per-token each call. ~1.8× the bf16 path (152 vs 85 TF), and
127/// frees the bf16-dequant memory the bf16 path holds.
128/// `act_fp8_scratch` ≥ m*k fp8 bytes; `act_scale_scratch` ≥ m f32 (e.g. the
129/// `buffers.fp8_act` / `fp8_act_scale` arena buffers).
130#[allow(clippy::too_many_arguments)]
131pub fn cublas_fp8_rowwise_proj(
132 gpu: &dyn spark_runtime::gpu::GpuBackend,
133 derived: &super::DerivedWeights,
134 act_bf16: spark_runtime::gpu::DevicePtr,
135 act_fp8_scratch: spark_runtime::gpu::DevicePtr,
136 act_scale_scratch: spark_runtime::gpu::DevicePtr,
137 fp8w: &crate::weight_map::Fp8Weight,
138 out: spark_runtime::gpu::DevicePtr,
139 m: u32,
140 n: u32,
141 k: u32,
142 stream: u64,
143) -> anyhow::Result<()> {
144 use spark_runtime::kernel_args::KernelLaunch;
145 let (w_fp8, w_scale) = requant_weight_rowwise_fp8_cached(gpu, derived, fp8w, stream)?;
146 // Per-token row-wise quant of the activation → fp8 [M,K] + scale [M].
147 let qk = gpu.kernel("quant_rowwise_fp8", "quant_rowwise_fp8")?;
148 KernelLaunch::new(gpu, qk)
149 .grid([m, 1, 1])
150 .block([256, 1, 1])
151 .arg_ptr(act_bf16)
152 .arg_ptr(act_fp8_scratch)
153 .arg_ptr(act_scale_scratch)
154 .arg_u32(m)
155 .arg_u32(k)
156 .launch(stream)?;
157 // ── M must be padded, exactly as the block-scaled sibling pads it ────
158 //
159 // Both scale vectors are declared `SCALE_MODE_OUTER_VEC_32F`, and
160 // cuBLASLt will not serve an outer-vector extent that is not a multiple
161 // of 4; `AlgoGetHeuristic` returns status 15 (NOT_SUPPORTED) rather than
162 // failing at launch. Unpadded, this path worked only for callers whose M
163 // happened to be aligned — a 23-token prompt through the row-wise GDN
164 // prefill arm is what surfaced it, since a chunk size is whatever the
165 // prompt is.
166 //
167 // Pad to 16 like `cublas_fp8_proj` (TC-friendly), and zero BOTH the
168 // padding scales and the padding activation rows: with a zero scale the
169 // phantom rows contribute nothing, and zeroed bytes cannot carry a NaN
170 // into an accumulator. The phantom output rows are ignored by the
171 // caller, same contract as the block-scaled path.
172 let m_pad = m.div_ceil(16) * 16;
173 if m_pad > m {
174 let pad_rows = (m_pad - m) as usize;
175 gpu.memset_async(
176 act_scale_scratch.offset(m as usize * 4),
177 0,
178 pad_rows * 4,
179 stream,
180 )?;
181 gpu.memset_async(
182 act_fp8_scratch.offset(m as usize * k as usize),
183 0,
184 pad_rows * k as usize,
185 stream,
186 )?;
187 }
188 spark_runtime::cublaslt::fp8_gemm_act_weight_t_rowwise(
189 act_fp8_scratch.0,
190 act_scale_scratch.0,
191 w_fp8,
192 w_scale,
193 out.0,
194 m_pad,
195 n,
196 k,
197 stream,
198 )
199}
200
201#[cfg(test)]
202mod rowwise_passthrough_tests {
203 use super::{requant_weight_rowwise_fp8_cached, rowwise_pair_passthrough};
204 use crate::layers::ops::DerivedWeights;
205 use crate::weight_map::{Fp8Weight, WeightQuantFormat};
206 use spark_runtime::gpu::DevicePtr;
207 use spark_runtime::gpu::mock::MockGpuBackend;
208
209 fn weight(scale_format: WeightQuantFormat) -> Fp8Weight {
210 Fp8Weight {
211 weight: DevicePtr(0xBEEF),
212 row_scale: DevicePtr(0x5CA1E),
213 n: 4096,
214 k: 5120,
215 scale_format,
216 }
217 }
218
219 /// ★ The point of the change: a checkpoint that already ships per-row
220 /// scales is handed to the row-wise GEMM untouched. Converting it would be
221 /// fp8 -> bf16 -> fp8, spending precision to produce what it already is.
222 #[test]
223 fn an_already_rowwise_weight_passes_through_verbatim() {
224 let w = weight(WeightQuantFormat::Fp8PerRow);
225 assert_eq!(
226 rowwise_pair_passthrough(&w),
227 Some((w.weight.0, w.row_scale.0)),
228 "the checkpoint's own pointers, not a converted copy"
229 );
230 }
231
232 /// Every other format still takes the requant path — in particular
233 /// block-scaled, which is what every current caller carries.
234 #[test]
235 fn other_formats_still_requantize() {
236 for f in [
237 WeightQuantFormat::Fp8BlockScaled,
238 WeightQuantFormat::Fp8SingleScale,
239 WeightQuantFormat::Bf16,
240 WeightQuantFormat::Nvfp4,
241 ] {
242 assert_eq!(
243 rowwise_pair_passthrough(&weight(f)),
244 None,
245 "{f:?} is not a row-wise pair and must not be passed through"
246 );
247 }
248 }
249
250 #[test]
251 fn cached_requant_returns_rowwise_checkpoint_pointers_without_gpu_work() {
252 let gpu = MockGpuBackend::new();
253 let w = weight(WeightQuantFormat::Fp8PerRow);
254
255 assert_eq!(
256 requant_weight_rowwise_fp8_cached(&gpu, &DerivedWeights::new(), &w, 0).unwrap(),
257 (w.weight.0, w.row_scale.0)
258 );
259 assert_eq!(gpu.alloc_count(), 0, "passthrough must not allocate a copy");
260 }
261}