spark_model/layers/qwen3_attention/
prefill_weights.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `Qwen3AttentionLayer` prefill-side weight setup: transposed NVFP4 /
4//! FP8 copies, FP8 weight installation, FP8 transpose for fast prefill,
5//! and NVFP4→FP8 pre-dequant for zero-overhead prefill GEMMs. Also
6//! hosts the W4A16 M=128 GEMM dispatcher (selects v1/v2/v3 by env).
7
8use anyhow::Result;
9use spark_runtime::gpu::{DevicePtr, GpuBackend};
10
11use super::types::Qwen3AttentionLayer;
12use crate::weight_map::{Fp8Weight, QuantWeight, QuantizedWeight};
13
14impl Qwen3AttentionLayer {
15    /// Dispatch the M=128 W4A16 prefill GEMM. Routes to the v2 shadow
16    /// kernel when available (MiniMax-only), otherwise to the v1 kernel.
17    /// Args mirror [`crate::layers::ops::w4a16_gemm_n128_m128`].
18    #[allow(clippy::too_many_arguments)]
19    pub(crate) fn w4a16_gemm_m128_dispatch(
20        &self,
21        gpu: &dyn GpuBackend,
22        dispatch: &crate::layers::ops::GemmDispatch,
23        input: DevicePtr,
24        weight: &crate::weight_map::QuantizedWeight,
25        output: DevicePtr,
26        m: u32,
27        n: u32,
28        k: u32,
29        stream: u64,
30    ) -> anyhow::Result<()> {
31        // ATLAS_W4A16_VARIANT: "v1"/"v2"/"v3" pin a kernel; 0 = auto (v2 — 3
32        // CTAs/SM, 8 warps; v3 with K_STEP=64 is slower in practice, kept for
33        // A/B). Resolved once per model into `GemmDispatch`, which the forward
34        // pass already carries.
35        let v = dispatch.w4a16_variant;
36        // LOSSLESS opt-in: route QKV/o projection prefill through the BF16-TC
37        // kernel (FP4→BF16 dequant + BF16 MMA, bit-identical to base w4a16_gemm)
38        // instead of the default t_m128 which crushes activations to FP8 E4M3.
39        // Gated by ATLAS_BF16_TC_PROJ (default off → unchanged). Removes the
40        // FP8 prefill perturbation on the attention projections.
41        // Load-time weight prep runs before any `TransformerModel` exists to
42        // carry the levers, so this resolves at the point of use. The
43        // interpretation stays SSOT in `ModelLevers`.
44        let bf16_proj = crate::layers::ops::ModelLevers::from_env().bf16_tc_proj;
45        if bf16_proj && self.w4a16_gemm_t_m128_bf16_k.0 != 0 {
46            return crate::layers::ops::w4a16_gemm_n128_m128_bf16(
47                gpu,
48                self.w4a16_gemm_t_m128_bf16_k,
49                input,
50                weight,
51                output,
52                m,
53                n,
54                k,
55                stream,
56            );
57        }
58        if v == 3 && self.w4a16_gemm_t_m128_v3_k.0 != 0 {
59            crate::layers::ops::w4a16_gemm_n128_m128_v3(
60                gpu,
61                self.w4a16_gemm_t_m128_v3_k,
62                input,
63                weight,
64                output,
65                m,
66                n,
67                k,
68                stream,
69            )
70        } else if v != 1 && self.w4a16_gemm_t_m128_v2_k.0 != 0 {
71            crate::layers::ops::w4a16_gemm_n128_m128_v2(
72                gpu,
73                self.w4a16_gemm_t_m128_v2_k,
74                input,
75                weight,
76                output,
77                m,
78                n,
79                k,
80                stream,
81            )
82        } else {
83            crate::layers::ops::w4a16_gemm_n128_m128(
84                gpu,
85                self.w4a16_gemm_t_m128_k,
86                input,
87                weight,
88                output,
89                m,
90                n,
91                k,
92                stream,
93            )
94        }
95    }
96
97    /// Set transposed NVFP4 weight copies for prefill GEMM
98    /// (`w4a16_gemm_t`, N_TILE=128).
99    pub fn set_prefill_weights(
100        &mut self,
101        q_nvfp4_t: Option<QuantizedWeight>,
102        k_nvfp4_t: Option<QuantizedWeight>,
103        v_nvfp4_t: Option<QuantizedWeight>,
104        o_nvfp4_t: Option<QuantizedWeight>,
105    ) {
106        self.q_nvfp4_t = q_nvfp4_t;
107        self.k_nvfp4_t = k_nvfp4_t;
108        self.v_nvfp4_t = v_nvfp4_t;
109        self.o_nvfp4_t = o_nvfp4_t;
110    }
111
112    /// Install keep-packed ternary Q2_0 q/k/v/o weights (Tier-1c,
113    /// `ATLAS_GGUF_NATIVE_Q2=1`). Decode dispatches `q2_0_gemv_vec` (2-bit
114    /// resident, no NVFP4); prefill transient-dequants each to BF16 via
115    /// `Self::q2_prefill_gemm`. Replaces the NVFP4 decode weights (which are
116    /// NULL on this path — no NVFP4 was allocated).
117    pub fn set_packed_q2_weights(
118        &mut self,
119        q: crate::weight_map::PackedQ2Weight,
120        k: crate::weight_map::PackedQ2Weight,
121        v: crate::weight_map::PackedQ2Weight,
122        o: crate::weight_map::PackedQ2Weight,
123        gpu: &dyn spark_runtime::gpu::GpuBackend,
124    ) {
125        self.q_weight = Some(QuantWeight::PackedQ2(q));
126        self.k_weight = Some(QuantWeight::PackedQ2(k));
127        self.v_weight = Some(QuantWeight::PackedQ2(v));
128        self.o_weight = Some(QuantWeight::PackedQ2(o));
129        // Resolved here, not in the constructor: these ship only in
130        // GGUF-serving targets and the boot audit fails closed on an
131        // unconditional probe everywhere else.
132        self.q2_0_mmq_nc_k = crate::layers::try_kernel(gpu, "q2_0_mmq", "atlas_q2_0_mmq128_nc");
133        self.q2_0_mmq_wc_k = crate::layers::try_kernel(gpu, "q2_0_mmq", "atlas_q2_0_mmq128_wc");
134        self.q4k_quant_act_k =
135            crate::layers::try_kernel(gpu, "q4k_mmq", "atlas_q8_1_quantize_ds4_bf16");
136    }
137
138    /// Transient-dequant prefill GEMM for a keep-packed Q2_0 projection: dequant
139    /// the 2-bit weight `[n, k]` into the caller-provided PERSISTENT BF16
140    /// `scratch` (the arena `q2_dequant_scratch`, sized to the largest packed
141    /// projection), run the BF16 `dense_gemm` (`out[m,n] = in[m,k] @ w^T`).
142    /// Mirrors `DenseFfnLayer`'s FFN prefill — the resident weight stays 2-bit.
143    /// No per-matmul alloc/sync/free: the dequant is ordered before the GEMM on
144    /// the same `stream`, and consecutive projections reuse `scratch` because
145    /// each GEMM consumes it before the next dequant overwrites it. Returns an
146    /// error if the dequant kernel is absent in this build.
147    #[allow(clippy::too_many_arguments)]
148    pub(crate) fn q2_prefill_gemm(
149        &self,
150        gpu: &dyn GpuBackend,
151        w: &crate::weight_map::PackedQ2Weight,
152        input: DevicePtr,
153        out: DevicePtr,
154        scratch: DevicePtr,
155        act_q8: DevicePtr,
156        m: u32,
157        stream: u64,
158    ) -> Result<()> {
159        let (n, k) = (w.n, w.k);
160
161        // Tier-2 native MMQ (ATLAS_GGUF_NATIVE_Q2_MMQ=1): quantize `input` to q8_1
162        // then run the packed 2-bit MMQ GEMM — no BF16 weight dequant, no shared
163        // `q2_dequant_scratch` race. Group-128 only (else fall through).
164        if self.q2_0_mmq_nc_k.0 != 0
165            && self.q4k_quant_act_k.0 != 0
166            && crate::layers::ops::native_q2_mmq_enabled()
167            && w.group == 128
168        {
169            crate::layers::ops::quantize_act_q8_1(
170                gpu,
171                self.q4k_quant_act_k,
172                input,
173                act_q8,
174                m,
175                k,
176                stream,
177            )?;
178            return crate::layers::ops::q2_0_mmq_gemm(
179                gpu,
180                self.q2_0_mmq_nc_k,
181                self.q2_0_mmq_wc_k,
182                act_q8,
183                w.weight,
184                out,
185                m,
186                n,
187                k,
188                stream,
189            );
190        }
191
192        if self.dequant_q2_0_gn_k.0 == 0 {
193            anyhow::bail!(
194                "dequant_q2_0_gn_to_bf16 kernel missing — packed-Q2 attention prefill unavailable"
195            );
196        }
197        crate::layers::ops::dequant_q2_0_gn_to_bf16(
198            gpu,
199            self.dequant_q2_0_gn_k,
200            w.weight,
201            scratch,
202            n,
203            k,
204            w.group as u32,
205            stream,
206        )?;
207        let dw = crate::weight_map::DenseWeight { weight: scratch };
208        if self.dense_gemm_pipelined_k.0 != 0 {
209            crate::layers::ops::dense_gemm_bf16_pipelined(
210                gpu,
211                self.dense_gemm_pipelined_k,
212                input,
213                &dw,
214                out,
215                m,
216                n,
217                k,
218                stream,
219            )?;
220        } else {
221            crate::layers::ops::dense_gemm(
222                gpu,
223                self.dense_gemm_k,
224                input,
225                &dw,
226                out,
227                m,
228                n,
229                k,
230                stream,
231            )?;
232        }
233        Ok(())
234    }
235
236    /// Keep-packed Q2_0 (Tier-1c) prefill dispatch guard, shared by the QKV
237    /// (`paged_qkv` / `cache_skip_qkv`) and o_proj call sites: when `weight`
238    /// is the keep-packed variant, run [`Self::q2_prefill_gemm`] with the
239    /// arena scratch buffers and return `Some(result)`. `None` = not packed
240    /// Q2_0 — callers fall through to their NVFP4/FP8/dense arms. Must be
241    /// checked FIRST: those fallbacks all read NULL pointers on this path.
242    pub(crate) fn try_q2_prefill(
243        &self,
244        ctx: &crate::layer::ForwardContext,
245        weight: Option<&QuantWeight>,
246        input: DevicePtr,
247        out: DevicePtr,
248        m: u32,
249        stream: u64,
250    ) -> Option<Result<()>> {
251        let q2 = weight.and_then(|w| w.as_packed_q2())?;
252        debug_assert!(
253            (q2.n as usize) * (q2.k as usize) * 2 <= ctx.buffers.q2_dequant_scratch_bytes(),
254            "packed-Q2 prefill dequant scratch too small"
255        );
256        let scratch = ctx.buffers.q2_dequant_scratch();
257        let act_q8 = ctx.buffers.q2_act_q8();
258        Some(self.q2_prefill_gemm(ctx.gpu, q2, input, out, scratch, act_q8, m, stream))
259    }
260
261    /// Install the fused [q|k|v] transposed twin. Separate from
262    /// `set_prefill_weights` so the fused path is opt-in per loader and the
263    /// separate twins stay available as the fallback.
264    pub fn set_fused_qkv_prefill_weight(&mut self, qkv_nvfp4_t: Option<QuantizedWeight>) {
265        self.qkv_nvfp4_t = qkv_nvfp4_t;
266    }
267    /// Set native FP8 checkpoint weights for the `w8a16_gemv` decode path.
268    ///
269    /// The block-scaled FP8 weights stored here (weight + per-128 `row_scale`)
270    /// are ALSO consumed by block-scaled prefill: `fp8_gemm_t_blockscaled`
271    /// folds both the per-token activation scale and the per-block weight
272    /// scale in an FP32 epilogue. (Historical note: the older single-scale
273    /// `fp8_gemm_t`/`fp8_gemm_n128` prefill could not apply block scales, so
274    /// prefill used to fall through to the NVFP4/BF16 dequant path — that is
275    /// no longer the case; block-scaled prefill is the default, see
276    /// `ops::fp8_blockscaled_prefill_enabled`.)
277    pub fn set_fp8_weights(
278        &mut self,
279        q: Option<Fp8Weight>,
280        k: Option<Fp8Weight>,
281        v: Option<Fp8Weight>,
282        o: Option<Fp8Weight>,
283    ) {
284        // Overwrite decode weights with FP8 variant. Replaces any NVFP4
285        // weights set during construction.
286        if let Some(qw) = q {
287            self.q_weight = Some(QuantWeight::Fp8(qw));
288        }
289        if let Some(kw) = k {
290            self.k_weight = Some(QuantWeight::Fp8(kw));
291        }
292        if let Some(vw) = v {
293            self.v_weight = Some(QuantWeight::Fp8(vw));
294        }
295        if let Some(ow) = o {
296            self.o_weight = Some(QuantWeight::Fp8(ow));
297        }
298    }
299
300    /// Install the startup-static LoRA adapter overlay (post-construction,
301    /// mirroring [`Self::set_fp8_weights`]). `attn` carries the K/V/O pairs;
302    /// `ffn` (when Some) is routed into this layer's dense FFN component —
303    /// it lives here rather than on the model because `self.ffn` is
304    /// `pub(super)`. M0: weights are stored only; compute reads land in M1.
305    pub fn set_lora_weights(
306        &mut self,
307        attn: crate::layers::ops::lora_delta::LoraAttnWeights,
308        ffn: Option<crate::layers::ops::lora_delta::LoraFfnWeights>,
309    ) -> Result<()> {
310        self.lora = Some(attn);
311        if let Some(f) = ffn {
312            match &mut self.ffn {
313                crate::layers::FfnComponent::Dense(d) => d.set_lora_weights(f)?,
314                _ => anyhow::bail!("LoRA: FFN targets on a non-dense FFN layer"),
315            }
316        }
317        Ok(())
318    }
319
320    /// Feature-1: install this layer's MoE router + routed-expert LoRA onto its
321    /// `FfnComponent::Moe`. The MoE FFN lives in `self.ffn` or (some loaders)
322    /// `self.moe_ffn` — try both, else the adapter targeted experts on a layer
323    /// with no MoE FFN (hard reject). Scratch is allocated inside
324    /// `crate::layers::MoeLayer::set_lora_weights`.
325    pub fn set_moe_lora_weights(
326        &mut self,
327        router: Option<crate::layers::ops::lora_delta::LoraPair>,
328        experts: crate::lora::ExpertLoraLayer,
329        kernels: crate::layers::ops::lora_delta::LoraKernels,
330        gpu: &dyn GpuBackend,
331    ) -> Result<()> {
332        if let crate::layers::FfnComponent::Moe(m) = &mut self.ffn {
333            return m.set_lora_weights(router, experts, kernels, gpu);
334        }
335        if let Some(crate::layers::FfnComponent::Moe(m)) = &mut self.moe_ffn {
336            return m.set_lora_weights(router, experts, kernels, gpu);
337        }
338        anyhow::bail!("LoRA: router/expert deltas installed on a layer with no MoE FFN component")
339    }
340
341    /// Transpose FP8 weights for fast prefill (`w8a16_gemm_t`: coalesced
342    /// reads). Must be called after [`Self::set_fp8_weights`]. Allocates
343    /// new GPU buffers.
344    pub fn transpose_fp8_for_prefill(
345        &mut self,
346        gpu: &dyn GpuBackend,
347        stream: u64,
348    ) -> anyhow::Result<()> {
349        // Load-time decision, taken in the weight loader before any
350        // `TransformerModel` exists to carry the config. Resolved at the point
351        // of use rather than cached in a static: the resolution logic stays
352        // SSOT in `GemmDispatch`, and one getenv per layer at load is free.
353        if crate::layers::ops::GemmDispatch::from_env().cutlass_nvfp4_gemm {
354            tracing::info!(
355                "Skipping attention FP8 prefill transposes because ATLAS_CUTLASS_NVFP4_GEMM=1"
356            );
357            return Ok(());
358        }
359        if self.w8a16_gemm_t_k.0 == 0 {
360            return Ok(()); // kernel not available
361        }
362        let transpose_k = gpu.kernel("w8a16_gemm_t", "transpose_fp8")?;
363        let transpose_scale_k = gpu.kernel("w8a16_gemm_t", "transpose_block_scale")?;
364
365        if let Some(w) = self.q_weight.as_ref().and_then(|w| w.as_fp8()) {
366            self.q_fp8w_t =
367                Some(w.transpose_for_gemm(gpu, transpose_k, transpose_scale_k, stream)?);
368        }
369        if let Some(w) = self.k_weight.as_ref().and_then(|w| w.as_fp8()) {
370            self.k_fp8w_t =
371                Some(w.transpose_for_gemm(gpu, transpose_k, transpose_scale_k, stream)?);
372        }
373        if let Some(w) = self.v_weight.as_ref().and_then(|w| w.as_fp8()) {
374            self.v_fp8w_t =
375                Some(w.transpose_for_gemm(gpu, transpose_k, transpose_scale_k, stream)?);
376        }
377        if let Some(w) = self.o_weight.as_ref().and_then(|w| w.as_fp8()) {
378            self.o_fp8w_t =
379                Some(w.transpose_for_gemm(gpu, transpose_k, transpose_scale_k, stream)?);
380        }
381        Ok(())
382    }
383
384    /// Pre-dequant NVFP4 → FP8 for Q/K/V/O transposed weights.
385    pub fn predequant_for_prefill(
386        &mut self,
387        gpu: &dyn GpuBackend,
388        config: &atlas_core::config::ModelConfig,
389        stream: u64,
390    ) -> Result<()> {
391        // Under native NVFP4 prefill (ATLAS_CUTLASS_NVFP4_GEMM=1) all of Q/K/V/O
392        // take the CUTLASS NVFP4 path; the FP8 predequant outputs (q_fp8..o_fp8)
393        // are read only by the legacy FP8 prefill path and decode never reads
394        // them (decode attention uses its own weights), so they'd be allocated
395        // at load and never used. Skip them — saves ~260MB and a wasted per-
396        // prefill BF16->FP8 activation conversion. Mirrors transpose_fp8_for_prefill.
397        // Load-time decision, taken in the weight loader before any
398        // `TransformerModel` exists to carry the config. Resolved at the point
399        // of use rather than cached in a static: the resolution logic stays
400        // SSOT in `GemmDispatch`, and one getenv per layer at load is free.
401        if crate::layers::ops::GemmDispatch::from_env().cutlass_nvfp4_gemm {
402            tracing::info!(
403                "Skipping attention FP8 prefill predequant because ATLAS_CUTLASS_NVFP4_GEMM=1"
404            );
405            return Ok(());
406        }
407        let predequant_k = gpu.kernel("w4a16", "predequant_nvfp4_to_fp8")?;
408        let h = config.hidden_size;
409        let nq = config.num_attention_heads;
410        let nkv = config.num_key_value_heads;
411        let hd = config.head_dim;
412        let q_dim = nq * hd;
413        let q_proj_dim = if self.gated { q_dim * 2 } else { q_dim };
414        let kv_dim = nkv * hd;
415
416        // Use NON-transposed weights for predequant.
417        // `predequant_nvfp4_to_fp8` assumes [N, K/2] input layout.
418        if let Some(nvfp4) = self.q_weight.as_ref().and_then(|w| w.as_nvfp4()) {
419            self.q_fp8 = Some(nvfp4.predequant_to_fp8(gpu, predequant_k, q_proj_dim, h, stream)?);
420        }
421        if let Some(nvfp4) = self.k_weight.as_ref().and_then(|w| w.as_nvfp4()) {
422            self.k_fp8 = Some(nvfp4.predequant_to_fp8(gpu, predequant_k, kv_dim, h, stream)?);
423        }
424        if let Some(nvfp4) = self.v_weight.as_ref().and_then(|w| w.as_nvfp4()) {
425            self.v_fp8 = Some(nvfp4.predequant_to_fp8(gpu, predequant_k, kv_dim, h, stream)?);
426        }
427        // O proj: use attn.o_proj (non-transposed QuantizedWeight)
428        if self.o_nvfp4_t.is_some() {
429            self.o_fp8 =
430                Some(
431                    self.attn
432                        .o_proj
433                        .predequant_to_fp8(gpu, predequant_k, h, q_dim, stream)?,
434                );
435        }
436        Ok(())
437    }
438}