spark_model/layers/qwen3_ssm/
init_fp8.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! FP8 weight-install setters and the NVFP4→FP8 prefill pre-dequant for
4//! [`Qwen3SsmLayer`]. Split out of `init.rs` (500-LoC cap).
5
6use anyhow::Result;
7use spark_runtime::gpu::{DevicePtr, GpuBackend};
8
9use super::Qwen3SsmLayer;
10use crate::weight_map::Fp8Weight;
11
12impl Qwen3SsmLayer {
13    /// Install native FP8 block-scaled weights for the decode GEMV path.
14    ///
15    /// Inputs MUST be tagged `WeightQuantFormat::Fp8BlockScaled` — that is
16    /// the canonical input format for the `w8a16_gemv` kernel
17    /// (`out[n] = sum_k A[k] * E4M3_LUT[B[n,k]] * block_scale[n/BS, k/BS]`,
18    /// see `kernels/gb10/common/w8a16_gemv.cu`). The kernel reads the
19    /// scale buffer at `[N/BS, K/BS]` BF16 — exactly the shape produced
20    /// by `load_fp8_block_scaled_as_fp8weight`.
21    ///
22    /// This setter does NOT install the raw FP8 DevicePtr fields used by
23    /// the prefill `fp8_gemm_n128` kernel — that kernel takes no scale
24    /// argument and assumes single-scale FP8 (baked-in scale) produced
25    /// by `bf16_to_fp8`. Block-scaled bytes would silently produce wrong
26    /// outputs there. For prefill, call `set_fp8_prefill_only_weights`
27    /// separately with single-scale FP8 derived from a BF16 dequant.
28    pub fn set_fp8_decode_weights(&mut self, qkvz: Option<Fp8Weight>, out_proj: Option<Fp8Weight>) {
29        if let Some(ref w) = qkvz {
30            w.scale_format.expect(
31                crate::weight_map::WeightQuantFormat::Fp8BlockScaled,
32                "set_fp8_decode_weights::qkvz (w8a16_gemv expects [N/BS,K/BS] BF16 block scales)",
33            );
34        }
35        if let Some(ref w) = out_proj {
36            w.scale_format.expect(
37                crate::weight_map::WeightQuantFormat::Fp8BlockScaled,
38                "set_fp8_decode_weights::out_proj (w8a16_gemv expects [N/BS,K/BS] BF16 block scales)",
39            );
40        }
41        self.qkvz_fp8w = qkvz;
42        self.out_proj_fp8w = out_proj;
43    }
44
45    /// Install PER-ROW FP8 weights for the row-wise cuBLASLt PREFILL arm
46    /// (`ATLAS_FP8_ROWWISE=1`, mixed-precision compressed-tensors
47    /// checkpoints). Decode is untouched and keeps the NVFP4 copy.
48    ///
49    /// The `Fp8PerRow` assertion is the mirror of `set_fp8_decode_weights`'s
50    /// `Fp8BlockScaled` one: each setter refuses the other's layout, so the
51    /// two FP8 shapes cannot cross into each other's kernels. That crossing
52    /// does not fault — the smaller buffer is read in-bounds — so an assert
53    /// is the only thing that catches it.
54    pub fn set_fp8_rowwise_prefill_weights(
55        &mut self,
56        qkvz: Option<Fp8Weight>,
57        out_proj: Option<Fp8Weight>,
58    ) {
59        for (w, what) in [(&qkvz, "qkvz"), (&out_proj, "out_proj")] {
60            if let Some(w) = w {
61                w.scale_format.expect(
62                    crate::weight_map::WeightQuantFormat::Fp8PerRow,
63                    "set_fp8_rowwise_prefill_weights (cuBLASLt row-wise expects [N] f32)",
64                );
65                let _ = what;
66            }
67        }
68        self.qkvz_fp8w_rowwise = qkvz;
69        self.out_proj_fp8w_rowwise = out_proj;
70    }
71
72    /// Set raw FP8 DevicePtrs for the prefill GEMM path ONLY (no decode GEMV
73    /// scale fields). Used by the Qwen3.6-27B-FP8 native-FP8 SSM prefill path:
74    /// the FP8 buffer here is a single-scale FP8 (BF16 → FP8 truncation; values
75    /// already in FP8 range) suitable for `fp8_gemm_n128`. Decode falls back to
76    /// the NVFP4/BF16 paths via the existing `qkvz_nvfp4*` fields. PCND:
77    /// caller decides whether to install — never set implicitly.
78    pub fn set_fp8_prefill_only_weights(
79        &mut self,
80        qkvz_fp8: Option<DevicePtr>,
81        out_proj_fp8: Option<DevicePtr>,
82    ) {
83        if qkvz_fp8.is_some() {
84            self.qkvz_fp8 = qkvz_fp8;
85        }
86        if out_proj_fp8.is_some() {
87            self.out_proj_fp8 = out_proj_fp8;
88        }
89    }
90
91    /// Pre-dequant NVFP4 → FP8 for QKVZ and out_proj transposed weights.
92    /// Eliminates per-inference dequant overhead in prefill GEMMs.
93    pub fn predequant_for_prefill(
94        &mut self,
95        gpu: &dyn GpuBackend,
96        config: &atlas_core::config::ModelConfig,
97        stream: u64,
98    ) -> Result<()> {
99        let predequant_k = gpu.kernel("w4a16", "predequant_nvfp4_to_fp8")?;
100        let h = config.hidden_size;
101        let qkvz_size = config.ssm_qkvz_size();
102        let value_dim = config.linear_num_value_heads * config.linear_value_head_dim;
103
104        // QKVZ FP8 predequant: tested at ISL=1019, FP8 is ~50% slower (1900µs vs 1228µs)
105        // because weight matrix [12288, 2048] is bandwidth-dominated at M=1024 — the 2×
106        // larger FP8 weights (25 MB vs 12.6 MB NVFP4) cost more than the dequant saves.
107        let _ = qkvz_size; // suppress unused warning
108        // Use NON-transposed out_proj (ssm.out_proj is [N, K/2] layout).
109        // predequant_nvfp4_to_fp8 assumes [N, K/2] input layout.
110        if self.out_proj_nvfp4_t.is_some() {
111            self.out_proj_fp8 = Some(self.ssm.out_proj.predequant_to_fp8(
112                gpu,
113                predequant_k,
114                h,
115                value_dim,
116                stream,
117            )?);
118        }
119        Ok(())
120    }
121}