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}