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