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}