spark_model/layers/mtp_head/
new.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! MTP head constructor.
4
5use anyhow::Result;
6use parking_lot::Mutex;
7use spark_runtime::gpu::GpuBackend;
8use spark_runtime::kv_cache::{KvCacheConfig, KvCacheDtype, PagedKvCache};
9
10use super::{MtpHead, MtpQuantization, ProjectionWeight};
11use crate::layers::MoeLayer;
12use crate::weight_map::{DenseWeight, MoeWeights, MtpWeights, QuantizedWeight, quantize_to_nvfp4};
13
14impl MtpHead {
15    pub fn new(
16        weights: MtpWeights,
17        embed_tokens: DenseWeight,
18        lm_head_nvfp4: QuantizedWeight,
19        // Padded transposed twin of the SHARED main head (see the field docs):
20        // `Some` only when the drafter head IS the main head, so routing the
21        // batched-propose lm_head through it is the exact weight at tile-GEMM
22        // bandwidth. Caller passes `None` for dedicated draft heads.
23        lm_head_nvfp4_t: Option<(QuantizedWeight, u32)>,
24        config: &atlas_core::config::ModelConfig,
25        gpu: &dyn GpuBackend,
26        quant: MtpQuantization,
27        mtp_vocab_size: u32,
28        max_seq_len: usize,
29        main_kv_blocks: usize,
30        // This model's levers — the drafter-prefill policy decides whether the
31        // dedicated prefill scratch is allocated at all.
32        levers: &crate::layers::ops::ModelLevers,
33    ) -> Result<Self> {
34        let stream = gpu.default_stream();
35        let absmax_k = gpu.kernel("quantize_nvfp4", "nvfp4_global_absmax")?;
36        let nvfp4_k = gpu.kernel("quantize_nvfp4", "quantize_bf16_to_nvfp4")?;
37        let fp8_k = gpu.kernel("gemv_fp8w", "quantize_bf16_to_fp8")?;
38
39        let h = config.hidden_size;
40        let nq = config.num_attention_heads;
41        let nkv = config.num_key_value_heads;
42        let hd = config.head_dim;
43        // Dense MTP heads use the dense `intermediate_size`; MoE MTP heads
44        // use `moe_intermediate_size`. The bundled `mtp.safetensors` always
45        // matches the main model's FFN width (Qwen3.6-27B FP8: 17408 dense;
46        // Qwen3.6-A3B-NVFP4: 1024 per expert).
47        let inter = if config.moe_intermediate_size > 0 {
48            config.moe_intermediate_size
49        } else {
50            config.intermediate_size
51        };
52
53        let q = |bf16: &DenseWeight, n: usize, k: usize| -> Result<ProjectionWeight> {
54            Self::quantize_proj(bf16, n, k, quant, gpu, absmax_k, nvfp4_k, fp8_k, stream)
55        };
56
57        // Quantize projections
58        let fc = q(&weights.fc, h, h * 2)?;
59        let q_proj = q(&weights.q_proj, nq * hd * 2, h)?;
60        let k_proj = q(&weights.k_proj, nkv * hd, h)?;
61        let v_proj = q(&weights.v_proj, nkv * hd, h)?;
62        let o_proj = q(&weights.o_proj, h, nq * hd)?;
63
64        // Dense FFN MTP heads (Qwen3.6-27B-FP8) bypass the MoE setup entirely.
65        // We quantize the dense gate/up/down triple and stash it; the MoE
66        // fields stay None and the forward path takes the dense shortcut.
67        let dense_ffn_generic = if let Some(dense_ffn) = weights.dense_ffn.as_ref() {
68            if matches!(quant, MtpQuantization::Nvfp4) {
69                anyhow::bail!(
70                    "MTP NVFP4 mode is not supported for dense FFN MTP heads yet \
71                     (Qwen3.6-27B-FP8 ships an FP8 MTP head — use \
72                     `--mtp-quantization fp8` or `bf16`)"
73                );
74            }
75            Some((
76                q(&dense_ffn.gate_proj, inter, h)?,
77                q(&dense_ffn.up_proj, inter, h)?,
78                q(&dense_ffn.down_proj, h, inter)?,
79            ))
80        } else {
81            None
82        };
83
84        // MoE: NVFP4 uses fused MoeLayer; FP8/BF16 stores per-expert weights
85        let (moe_nvfp4, moe_experts_generic, moe_shared_generic) = if dense_ffn_generic.is_some() {
86            (None, None, None)
87        } else {
88            match quant {
89                MtpQuantization::Nvfp4 => {
90                    let gate_nvfp4 = quantize_to_nvfp4(
91                        &weights.moe_gate,
92                        config.num_experts,
93                        h,
94                        gpu,
95                        absmax_k,
96                        nvfp4_k,
97                        stream,
98                    )?;
99                    let mut experts = Vec::with_capacity(weights.experts.len());
100                    for (i, de) in weights.experts.iter().enumerate() {
101                        let gate_proj = quantize_to_nvfp4(
102                            &de.gate_proj,
103                            inter,
104                            h,
105                            gpu,
106                            absmax_k,
107                            nvfp4_k,
108                            stream,
109                        )?;
110                        let up_proj = quantize_to_nvfp4(
111                            &de.up_proj,
112                            inter,
113                            h,
114                            gpu,
115                            absmax_k,
116                            nvfp4_k,
117                            stream,
118                        )?;
119                        let down_proj = quantize_to_nvfp4(
120                            &de.down_proj,
121                            h,
122                            inter,
123                            gpu,
124                            absmax_k,
125                            nvfp4_k,
126                            stream,
127                        )?;
128                        experts.push(crate::weight_map::ExpertWeight {
129                            gate_proj,
130                            up_proj,
131                            down_proj,
132                        });
133                        if (i + 1) % 128 == 0 {
134                            tracing::info!(
135                                "  MTP experts quantized: {}/{}",
136                                i + 1,
137                                weights.experts.len()
138                            );
139                        }
140                    }
141                    let shared_gate = quantize_to_nvfp4(
142                        &weights.shared_expert.gate_proj,
143                        inter,
144                        h,
145                        gpu,
146                        absmax_k,
147                        nvfp4_k,
148                        stream,
149                    )?;
150                    let shared_up = quantize_to_nvfp4(
151                        &weights.shared_expert.up_proj,
152                        inter,
153                        h,
154                        gpu,
155                        absmax_k,
156                        nvfp4_k,
157                        stream,
158                    )?;
159                    let shared_down = quantize_to_nvfp4(
160                        &weights.shared_expert.down_proj,
161                        h,
162                        inter,
163                        gpu,
164                        absmax_k,
165                        nvfp4_k,
166                        stream,
167                    )?;
168                    let moe_weights = MoeWeights {
169                        gate: weights.moe_gate,
170                        shared_expert: crate::weight_map::ExpertWeight {
171                            gate_proj: shared_gate,
172                            up_proj: shared_up,
173                            down_proj: shared_down,
174                        },
175                        shared_expert_gate: weights.shared_expert_gate,
176                        experts,
177                        router_pre_norm: None,
178                        correction_bias: None,
179                    };
180                    let moe = MoeLayer::new(
181                        moe_weights,
182                        config.num_experts,
183                        Some(gate_nvfp4),
184                        gpu,
185                        config,
186                    )?;
187                    (Some(moe), None, None)
188                }
189                MtpQuantization::Fp8 | MtpQuantization::Bf16 => {
190                    let mut experts_g = Vec::with_capacity(weights.experts.len());
191                    for (i, de) in weights.experts.iter().enumerate() {
192                        let gate_proj = q(&de.gate_proj, inter, h)?;
193                        let up_proj = q(&de.up_proj, inter, h)?;
194                        let down_proj = q(&de.down_proj, h, inter)?;
195                        experts_g.push((gate_proj, up_proj, down_proj));
196                        if (i + 1) % 128 == 0 {
197                            tracing::info!(
198                                "  MTP experts quantized: {}/{}",
199                                i + 1,
200                                weights.experts.len()
201                            );
202                        }
203                    }
204                    let shared = (
205                        q(&weights.shared_expert.gate_proj, inter, h)?,
206                        q(&weights.shared_expert.up_proj, inter, h)?,
207                        q(&weights.shared_expert.down_proj, h, inter)?,
208                    );
209                    (None, Some(experts_g), Some(shared))
210                }
211            }
212        };
213
214        // MTP KV cache: 1 attention layer. The FP8 KV path hard-codes
215        // k_scale=v_scale=1.0, which on Qwen3.6-A3B (large deep-layer K/V
216        // magnitudes) collapsed the single MTP attention layer's output to a
217        // constant → constant draft token 0 → ~0% acceptance, making
218        // --mtp-quantization fp8 a net slowdown. Use BF16 KV for both bf16
219        // and fp8 MTP heads — the MTP KV is one tiny layer, so BF16 cost is
220        // negligible. NVFP4 MTP keeps FP8 KV (measured-good acceptance;
221        // FP8-path changes must stay additive for NVFP4).
222        let kv_bf16 = matches!(quant, MtpQuantization::Bf16 | MtpQuantization::Fp8);
223        let kv_config = KvCacheConfig {
224            block_size: 16,
225            num_kv_heads: nkv,
226            head_dim: hd,
227            num_layers: 1,
228            dtype: if kv_bf16 {
229                KvCacheDtype::Bf16
230            } else {
231                KvCacheDtype::Fp8
232            },
233            layer_dtypes: vec![],
234            layer_dims: vec![],
235            cache_blocks_per_seq: None,
236        };
237        // The drafter's KV pool must admit EVERY concurrently-drafting
238        // sequence, not one: `max_seq_len/bs + 1` was sized before MTP propose
239        // went batched, and at C=16 x ~2K-token contexts it is ~2x short — the
240        // "KV cache exhausted" ERROR spam from run_mtp_propose_batched is this
241        // pool (the 15K-block MAIN pool never fills on that workload), and each
242        // hit degrades the batched propose to the per-step fallback. Scale by
243        // the MTP concurrency cap, bounded by the main pool's block count: the
244        // drafter cannot need more live tokens than the main KV can hold, so
245        // the cap keeps a 128K `--max-seq-len` config from blindly allocating
246        // seqs x 8K blocks. Cost at the 16K/16-seq bench config: 15,203 blocks
247        // x 64 KB = ~0.97 GB — pre-charged against the KV budget by
248        // `factory::build`'s MTP propose-pool reserve, which mirrors THIS
249        // arithmetic; change one and change both.
250        let per_seq_blocks = max_seq_len / kv_config.block_size + 1;
251        let mtp_num_blocks = per_seq_blocks
252            .saturating_mul(crate::speculative::mtp_max_seqs())
253            .min(main_kv_blocks.max(per_seq_blocks));
254        // Per-sequence `propose_meta` stride, from the SAME block size the
255        // drafter pool uses (so the two cannot drift). The old fixed 2048
256        // capped the block table at 448 entries = 7,168 tokens — sized in
257        // the 4K era; agentic contexts of 10-20K tripped the stride ensure!
258        // every step and made the batched propose permanently fall back to
259        // per-sequence mode (PROGRESS_LOG 5.2/6.17). Floor 2048; override
260        // ATLAS_PROPOSE_META_STRIDE=<bytes>.
261        let propose_meta_stride =
262            super::batch_caps::propose_meta_stride_env(max_seq_len, kv_config.block_size);
263        let kv_cache = PagedKvCache::new(kv_config, mtp_num_blocks, gpu)?;
264
265        // Extra kernel handles for BF16/FP8 paths
266        let (
267            dense_gemv_k,
268            dense_gemv_fp8w_k,
269            deinterleave_qg_k,
270            moe_topk_k,
271            moe_silu_mul_k,
272            moe_weighted_sum_blend_k,
273        ) = match quant {
274            MtpQuantization::Nvfp4 => (None, None, None, None, None, None),
275            MtpQuantization::Fp8 => (
276                // BF16 GEMV needed for gate (always BF16) + generic MoE dispatch
277                Some(gpu.kernel("gemv", "dense_gemv_bf16")?),
278                Some(gpu.kernel("gemv_fp8w", "dense_gemv_fp8w")?),
279                Some(gpu.kernel("ssm_preprocess", "deinterleave_qg")?),
280                Some(gpu.kernel("moe_topk", "moe_topk_softmax")?),
281                Some(gpu.kernel("moe_silu_mul", "moe_silu_mul")?),
282                Some(gpu.kernel("moe_expert_gemv", "moe_weighted_sum_blend")?),
283            ),
284            MtpQuantization::Bf16 => (
285                Some(gpu.kernel("gemv", "dense_gemv_bf16")?),
286                None,
287                Some(gpu.kernel("ssm_preprocess", "deinterleave_qg")?),
288                Some(gpu.kernel("moe_topk", "moe_topk_softmax")?),
289                Some(gpu.kernel("moe_silu_mul", "moe_silu_mul")?),
290                Some(gpu.kernel("moe_expert_gemv", "moe_weighted_sum_blend")?),
291            ),
292        };
293
294        let effective_vocab = if mtp_vocab_size > 0 {
295            (mtp_vocab_size as usize).min(config.vocab_size)
296        } else {
297            config.vocab_size
298        };
299        let ffn_kind: &str = if dense_ffn_generic.is_some() {
300            "dense FFN"
301        } else if moe_nvfp4.is_some() {
302            "MoE (NVFP4 fused)"
303        } else {
304            "MoE (per-expert)"
305        };
306        tracing::info!(
307            "MTP head: quant={:?}, fc=[{h},{h2}], attn Q=[{qd},{h}], ffn={ffn}, \
308             {ne} experts, vocab={ev}/{fv} (LM head {lm:.1} MB)",
309            quant,
310            h2 = h * 2,
311            qd = nq * hd * 2,
312            ffn = ffn_kind,
313            ne = if dense_ffn_generic.is_some() {
314                0
315            } else {
316                config.num_experts
317            },
318            ev = effective_vocab,
319            fv = config.vocab_size,
320            lm = (effective_vocab * h / 2) as f64 / (1024.0 * 1024.0),
321        );
322
323        // Dedicated batched-prefill scratch (~50 MB at h=5120/nq=32/hd=256,
324        // PREFILL_CHUNK=512 rows). Dedicated rather than aliased onto the
325        // shared arena so the pass has zero aliasing hazards; allocated only
326        // when a consumer exists.
327        // The catch-up feed (ATLAS_MTP_CATCHUP) runs through the same batched
328        // row writer as the drafter prefill and needs the same scratch.
329        let prefill_scratch = if super::mtp_drafter_prefill_enabled(levers)
330            || crate::speculative::mtp_catchup_enabled()
331        {
332            let c = super::prefill::PREFILL_CHUNK;
333            let bf16 = 2usize;
334            Some(super::MtpPrefillScratch {
335                embed: gpu.alloc(c * h * bf16)?,
336                normed_embed: gpu.alloc(c * h * bf16)?,
337                normed_hidden: gpu.alloc(c * h * bf16)?,
338                concat: gpu.alloc(c * 2 * h * bf16)?,
339                fc_out: gpu.alloc(c * h * bf16)?,
340                normed2: gpu.alloc(c * h * bf16)?,
341                k_out: gpu.alloc(c * nkv * hd * bf16)?,
342                v_out: gpu.alloc(c * nkv * hd * bf16)?,
343                q_scratch: gpu.alloc(c * nq * hd * bf16)?,
344                pos_dev: gpu.alloc(c * 4)?,
345                slot_dev: gpu.alloc(c * 8)?,
346            })
347        } else {
348            None
349        };
350
351        Ok(Self {
352            pre_fc_norm_embedding: weights.pre_fc_norm_embedding,
353            pre_fc_norm_hidden: weights.pre_fc_norm_hidden,
354            input_layernorm: weights.input_layernorm,
355            post_attn_layernorm: weights.post_attn_layernorm,
356            norm: weights.norm,
357            fc,
358            q_proj,
359            k_proj,
360            v_proj,
361            o_proj,
362            q_norm: weights.q_norm,
363            k_norm: weights.k_norm,
364            moe_nvfp4,
365            moe_experts_generic,
366            moe_shared_generic,
367            moe_gate: weights.moe_gate,
368            shared_expert_gate: weights.shared_expert_gate,
369            dense_ffn_generic,
370            quant,
371            mtp_vocab_size,
372            embed_tokens,
373            lm_head_nvfp4,
374            kv_cache: Mutex::new(kv_cache),
375            attn_layer_idx: 0,
376            rms_norm_k: gpu.kernel("norm", "rms_norm")?,
377            rms_norm_residual_k: gpu.kernel("norm", "rms_norm_residual")?,
378            w4a16_gemv_k: gpu.kernel("w4a16_gemv", "w4a16_gemv")?,
379            w4a16_gemv_sw_k: crate::layers::try_kernel(gpu, "w4a16_gemv", "w4a16_gemv_sw"),
380            gemv_sw: crate::layers::ops::gemv_sw_from(
381                std::env::var("ATLAS_NO_GEMV_SW").ok().as_deref(),
382            ),
383            w4a16_gemv_qg_k: gpu.kernel("w4a16_gemv", "w4a16_gemv_qg")?,
384            w4a16_gemv_dual_k: gpu.kernel("w4a16_gemv_fused", "w4a16_gemv_dual")?,
385            rope_k: gpu.kernel("rope", "rope_forward")?,
386            reshape_cache_k: if kv_bf16 {
387                gpu.kernel("reshape_and_cache", "reshape_and_cache_flash")?
388            } else {
389                gpu.kernel("reshape_and_cache", "reshape_and_cache_flash_fp8")?
390            },
391            paged_decode_k: if kv_bf16 {
392                gpu.kernel("paged_decode", "paged_decode_attn")?
393            } else {
394                gpu.kernel("paged_decode_fp8", "paged_decode_attn_fp8")?
395            },
396            kv_bf16,
397            residual_add_k: gpu.kernel("residual_add", "bf16_residual_add")?,
398            residual_add_rms_norm_k: gpu.kernel("norm", "residual_add_rms_norm")?,
399            sigmoid_gate_mul_k: gpu.kernel("residual_add", "sigmoid_gate_mul")?,
400            bf16_concat_k: gpu.kernel("residual_add", "bf16_concat")?,
401            argmax_k: gpu.kernel("argmax", "argmax_bf16")?,
402            embed_from_argmax_k: gpu.kernel("embed_from_argmax", "embed_from_argmax")?,
403            draft_token_id_dev: gpu.alloc(4)?,
404            last_conf_bits: std::sync::atomic::AtomicU32::new(1.0f32.to_bits()),
405            dense_gemv_k,
406            dense_gemv_fp8w_k,
407            w8a16_gemv_k: gpu.kernel("w8a16_gemv", "w8a16_gemv").ok(),
408            deinterleave_qg_k,
409            moe_topk_k,
410            moe_silu_mul_k,
411            moe_weighted_sum_blend_k,
412            // Batched BF16 GEMM for drafter prefill; 0-handle when the
413            // target's kernel set lacks it (prefill then no-ops).
414            dense_gemm_k: crate::layers::try_kernel(gpu, "gemm", "dense_gemm_bf16"),
415            dense_gemm_pipelined_k: crate::layers::try_kernel(
416                gpu,
417                "gemm",
418                "dense_gemm_bf16_pipelined",
419            ),
420            // Batched BF16 GEMV for the M=2..8 propose widths; 0-handle on
421            // targets whose kernel set predates it (dispatch falls back to
422            // the pipelined GEMM — see `row_dispatch`).
423            dense_gemv_batchm_k: crate::layers::try_kernel(
424                gpu,
425                "dense_gemv_bf16_batchm",
426                "dense_gemv_bf16_batchm",
427            ),
428            w4a16_batchm: crate::layers::w4a16_gemv_tiers::W4a16BatchmTiers::resolve(gpu),
429            w4a16_gemv_batch16_k: crate::layers::try_kernel(
430                gpu,
431                "w4a16_gemv",
432                "w4a16_gemv_batch16",
433            ),
434            w4a16_gemv_batch32_k: crate::layers::try_kernel(
435                gpu,
436                "w4a16_gemv",
437                "w4a16_gemv_batch32",
438            ),
439            // Propose-side tile-twin routing decided ONCE at construction
440            // (process-static: handle + env + weight presence), so per-n CUDA
441            // graph captures of the batched propose can never see the
442            // selection flip. Kill switch is PRESENCE-style per the house
443            // convention (`ATLAS_NO_MTP_LMHEAD_TGEMM=0` is NOT off).
444            lm_head_nvfp4_t: lm_head_nvfp4_t
445                .filter(|_| std::env::var_os("ATLAS_NO_MTP_LMHEAD_TGEMM").is_none()),
446            w4a16_gemm_t_k: crate::layers::tgemm_kernel(gpu),
447            argmax_batch_k: crate::layers::try_kernel(gpu, "argmax", "argmax_bf16_batch"),
448            argmax_batch_lp_k: crate::layers::try_kernel(gpu, "argmax", "argmax_bf16_batch_lp"),
449            // Drafter attention metadata for the batched propose — a
450            // dedicated allocation (32 x stride; 64 KB at the 2048 floor),
451            // never an offset into the shared scratch arena (see the
452            // `propose_meta` field docs). Sized from the dynamic stride so
453            // long-context configs keep the batched propose.
454            propose_meta: gpu.alloc(super::batch_caps::PROPOSE_META_SEQS * propose_meta_stride)?,
455            propose_meta_stride,
456            prefill_scratch,
457        })
458    }
459}