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