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}