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}