spark_model/layers/
mtp_head.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! MTP (Multi-Token Prediction) head implementing [`DraftProposer`].
4//!
5//! Single transformer decoder layer trained jointly with the target model.
6//! Forward pass: embed+hidden concat → fc → attention → MoE → norm → lm_head → argmax.
7//!
8//! Weight precision is parameterized via [`MtpQuantization`]: NVFP4 (4-bit),
9//! FP8 (8-bit), or BF16 (16-bit). Higher precision improves draft acceptance
10//! at the cost of increased MTP forward latency.
11
12use parking_lot::Mutex;
13use std::any::Any;
14
15use anyhow::Result;
16use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
17use spark_runtime::kv_cache::PagedKvCache;
18
19use crate::layer::ForwardContext;
20use crate::layers::MoeLayer;
21use crate::layers::ops;
22use crate::layers::w4a16_gemv_tiers::W4a16BatchmTiers;
23use crate::speculative::{DraftProposer, ProposerState};
24use crate::weight_map::{
25    DenseWeight, Fp8DenseWeight, Fp8Weight, QuantizedWeight, quantize_to_fp8, quantize_to_nvfp4,
26};
27
28/// Drafter context prefill — **ON by default**, cached once.
29///
30/// The target prefill captures every position's final-layer hidden and the MTP
31/// drafter's KV cache is batch-prefilled over the whole prompt before the first
32/// propose(), mirroring vLLM's MTP proposer prefill. The drafter's KV entries
33/// are pure functions of its input pair `(embed(token_{i+1}), target_hidden_i)`
34/// — a single-layer drafter's K/V do not depend on its own attention outputs —
35/// so the prefill needs only the fc + k/v projections + norms + RoPE + cache
36/// write, no attention pass.
37///
38/// Policy, including the kill switch and the coupling to the cross-turn carry
39/// (which this half is useless without), lives in
40/// `crate::model::drafter_context` — the single source of truth.
41pub fn mtp_drafter_prefill_enabled(levers: &crate::layers::ops::ModelLevers) -> bool {
42    levers.drafter.prefill
43}
44
45/// Dedicated scratch for the batched drafter prefill (allocated in
46/// `MtpHead::new` only when [`mtp_drafter_prefill_enabled`]). All buffers are
47/// sized for [`prefill::PREFILL_CHUNK`] rows; dedicated (not aliased onto the
48/// shared arena) so the pass has no aliasing hazards against target buffers.
49pub(crate) struct MtpPrefillScratch {
50    pub embed: DevicePtr,
51    pub normed_embed: DevicePtr,
52    pub normed_hidden: DevicePtr,
53    pub concat: DevicePtr,
54    pub fc_out: DevicePtr,
55    pub normed2: DevicePtr,
56    pub k_out: DevicePtr,
57    pub v_out: DevicePtr,
58    /// RoPE rotates Q and K in one launch; prefill discards Q, but the kernel
59    /// still needs a writable [chunk, nq*hd] region.
60    pub q_scratch: DevicePtr,
61    /// u32 RoPE positions, one per row.
62    pub pos_dev: DevicePtr,
63    /// i64 KV slot mapping, one per row.
64    pub slot_dev: DevicePtr,
65}
66
67/// MTP head weight precision.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum MtpQuantization {
70    /// NVFP4 E2M1 (0.5 bytes/weight) — fastest MTP forward, lowest accuracy.
71    Nvfp4,
72    /// FP8 E4M3 (1 byte/weight) — balanced.
73    Fp8,
74    /// BF16 (2 bytes/weight) — highest accuracy, slowest MTP forward.
75    Bf16,
76}
77
78impl MtpQuantization {
79    /// Whether the batched drafter prefill can run at this precision.
80    ///
81    /// NECESSARY, not sufficient — `prefill::prefill_drafter` remains the
82    /// authority and re-checks the actual weight variants and kernel handles.
83    /// This predicate exists so the caller can skip the `max_seq_len x hidden`
84    /// BF16 prompt-hidden buffer (335 MB at 32k/h=5120, 2.7 GB at 256k) for a
85    /// head that could never use it. It is exact by construction:
86    /// `quantize_proj` produces `ProjectionWeight::Bf16` for, and only for,
87    /// [`MtpQuantization::Bf16`].
88    pub fn supports_drafter_prefill(self) -> bool {
89        matches!(self, Self::Bf16)
90    }
91}
92
93impl std::str::FromStr for MtpQuantization {
94    type Err = anyhow::Error;
95    fn from_str(s: &str) -> Result<Self> {
96        match s.to_lowercase().as_str() {
97            "nvfp4" | "fp4" => Ok(Self::Nvfp4),
98            "fp8" => Ok(Self::Fp8),
99            "bf16" => Ok(Self::Bf16),
100            _ => anyhow::bail!("Unknown MTP quantization: {s}. Expected: nvfp4, fp8, bf16"),
101        }
102    }
103}
104
105/// Weight storage that can hold any supported precision.
106#[allow(dead_code)]
107enum ProjectionWeight {
108    Nvfp4(QuantizedWeight),
109    Fp8(Fp8DenseWeight),
110    /// FP8 E4M3 block-scaled from checkpoint (w8a16_gemv LUT kernel).
111    /// Used when the checkpoint is FP8 native (native FP8 serving).
112    Fp8BlockScaled(Fp8Weight),
113    Bf16(DenseWeight),
114}
115
116/// Per-sequence MTP proposer state.
117pub struct MtpProposerState {
118    /// Block table for MTP's own KV cache.
119    pub block_table: Vec<u32>,
120    /// Current sequence length in MTP's KV cache.
121    pub seq_len: usize,
122    /// Number of drafts produced in the last propose() call.
123    /// Used by after_verify to know how many entries to trim.
124    pub last_num_drafted: usize,
125    /// Sequence-space pair key of the newest drafter row (a `forward_one`
126    /// call with RoPE position `p` writes pair key `p - 1`). `seq_len` alone
127    /// cannot locate the drafter in the sequence — without drafter prefill
128    /// the row space is compacted (accepted pairs only) and drifts from the
129    /// sequence position. `None` until the first row is written.
130    pub last_pair_key: Option<usize>,
131}
132
133impl ProposerState for MtpProposerState {
134    fn as_any(&self) -> &dyn Any {
135        self
136    }
137    fn as_any_mut(&mut self) -> &mut dyn Any {
138        self
139    }
140}
141
142/// MTP prediction head.
143#[allow(dead_code)]
144pub struct MtpHead {
145    // Norms (always BF16)
146    pre_fc_norm_embedding: DenseWeight,
147    pre_fc_norm_hidden: DenseWeight,
148    input_layernorm: DenseWeight,
149    post_attn_layernorm: DenseWeight,
150    norm: DenseWeight,
151
152    // Projections (precision depends on MtpQuantization)
153    fc: ProjectionWeight,
154    q_proj: ProjectionWeight,
155    k_proj: ProjectionWeight,
156    v_proj: ProjectionWeight,
157    o_proj: ProjectionWeight,
158
159    // BF16 fallbacks for Q/K norms
160    q_norm: DenseWeight,
161    k_norm: DenseWeight,
162
163    // MoE: NVFP4 uses fused MoeLayer; FP8/BF16 uses per-expert storage
164    moe_nvfp4: Option<MoeLayer>,
165    moe_experts_generic: Option<Vec<(ProjectionWeight, ProjectionWeight, ProjectionWeight)>>,
166    moe_shared_generic: Option<(ProjectionWeight, ProjectionWeight, ProjectionWeight)>,
167    moe_gate: DenseWeight,
168    shared_expert_gate: DenseWeight,
169
170    /// Dense FFN triple `(gate_proj, up_proj, down_proj)` for MTP heads
171    /// bundled with dense (non-MoE) checkpoints. When `Some`, the forward
172    /// path skips routing/expert dispatch and runs a single MLP. The MoE
173    /// fields above are unused/None in that mode.
174    dense_ffn_generic: Option<(ProjectionWeight, ProjectionWeight, ProjectionWeight)>,
175
176    // Precision mode
177    quant: MtpQuantization,
178
179    /// Reduced vocab size for MTP LM head GEMV (0 = full vocab).
180    mtp_vocab_size: u32,
181
182    // Shared weights from target model
183    embed_tokens: DenseWeight,
184    lm_head_nvfp4: QuantizedWeight,
185
186    // KV cache for MTP attention (1 layer, separate from target)
187    kv_cache: Mutex<PagedKvCache>,
188    attn_layer_idx: usize,
189
190    // Kernel handles (always needed)
191    rms_norm_k: KernelHandle,
192    rms_norm_residual_k: KernelHandle,
193    w4a16_gemv_k: KernelHandle,
194    /// Single-warp `w4a16_gemv_sw`. `KernelHandle(0)` on miss → base GEMV.
195    w4a16_gemv_sw_k: KernelHandle,
196    /// Cached `ModelLevers::gemv_sw` (resolved at construction). MTP `gemv`
197    /// has no `ForwardContext` on every arm.
198    gemv_sw: bool,
199    w4a16_gemv_qg_k: KernelHandle,
200    w4a16_gemv_dual_k: KernelHandle,
201    rope_k: KernelHandle,
202    reshape_cache_k: KernelHandle,
203    paged_decode_k: KernelHandle,
204    /// MTP KV cache dtype: true = BF16 (matches the main model), false = FP8.
205    /// The FP8 path hard-passed k_scale=v_scale=1.0 which collapsed the MTP
206    /// attention output to a constant on Qwen3.6-A3B (large deep-layer K/V
207    /// magnitudes) → constant draft token 0 → 0% acceptance. BF16 KV (this
208    /// head is a single tiny attention layer) fixes it. Gated by mtp_quant.
209    kv_bf16: bool,
210    residual_add_k: KernelHandle,
211    residual_add_rms_norm_k: KernelHandle,
212    sigmoid_gate_mul_k: KernelHandle,
213    bf16_concat_k: KernelHandle,
214    argmax_k: KernelHandle,
215    embed_from_argmax_k: KernelHandle,
216    /// Fixed device buffer (4 bytes) for deferred draft token ID readback.
217    draft_token_id_dev: DevicePtr,
218    /// Chain confidence of the last propose (f32 bits; min top-1 softmax
219    /// prob across drafts). Written by `forward_one` when
220    /// `draft_conf_tau() > 0`; reset to 1.0 at each propose start.
221    pub(super) last_conf_bits: std::sync::atomic::AtomicU32,
222    // BF16/FP8 kernel handles (None if NVFP4 mode)
223    dense_gemv_k: Option<KernelHandle>,
224    dense_gemv_fp8w_k: Option<KernelHandle>,
225    w8a16_gemv_k: Option<KernelHandle>,
226    deinterleave_qg_k: Option<KernelHandle>,
227    moe_topk_k: Option<KernelHandle>,
228    moe_silu_mul_k: Option<KernelHandle>,
229    moe_weighted_sum_blend_k: Option<KernelHandle>,
230    /// Batched BF16 GEMM for the drafter-prefill pass (0 when absent).
231    dense_gemm_k: KernelHandle,
232    /// Tensor-core pipelined BF16 GEMM (`dense_gemm_bf16_pipelined`) for the
233    /// batched cross-sequence propose (0 when absent). Measured at M=4 on the
234    /// drafter shapes: 2.7x the 4x-GEMV per-seq loop (5.1 vs 14.4 ms per
235    /// draft position).
236    dense_gemm_pipelined_k: KernelHandle,
237    /// `dense_gemv_bf16_batchm` — ONE pass over each BF16 drafter weight
238    /// producing all M rows — for the batched propose at M in 2..=8 (0 when
239    /// the target's kernel set lacks it, which falls back to the pipelined
240    /// GEMM). The pipelined GEMM above is the right tool at the C=16/32
241    /// propose widths but costs 5.43 ms/draft-position at M=2 against
242    /// 3.57 ms for the M=1 GEMV — 1.52x for two rows on a path that streams
243    /// the weights once. See [`row_dispatch`] for the measurements, the
244    /// 2..=8 band and the numerics statement.
245    dense_gemv_batchm_k: KernelHandle,
246    /// `w4a16_gemv_batch{4..8}` (narrow family) and `_batch{16,32}` (wide) for
247    /// the batched-propose LM head (0 when absent): reads the shared NVFP4 LM
248    /// head once for up to MAX_M sequences. Selected per batch width by
249    /// [`MtpHead::lm_head_batch_kernel`]; per-row accumulation order is
250    /// identical across instantiations (one `w4a16_gemv_batchm_impl`), so
251    /// output is bit-identical at matching M.
252    w4a16_batchm: W4a16BatchmTiers,
253    w4a16_gemv_batch16_k: KernelHandle,
254    w4a16_gemv_batch32_k: KernelHandle,
255    /// Padded transposed twin of the SHARED main LM head for the batched
256    /// propose at n >= 5: `(weight_t, ldb)` with `ldb = align_up(vocab, 128)`
257    /// (248192 on the 27B checkpoint — vocab 248077 is ODD, so every 16-byte
258    /// `cp.async` B load MUST use the padded row stride; the unpadded stride
259    /// is the campaign's sticky CUDA-716). `None` when the drafter has a
260    /// DEDICATED draft head (`mtp_lm_head_nvfp4` — the twin describes the
261    /// main head only), when the main twin was not built
262    /// (`ATLAS_NO_LMHEAD_TGEMM=1`), or under the propose-local kill switch
263    /// `ATLAS_NO_MTP_LMHEAD_TGEMM` (PRESENCE — `=0` is NOT off). Zero extra
264    /// memory: this aliases the twin `impl_a1` already allocated.
265    pub(super) lm_head_nvfp4_t: Option<(QuantizedWeight, u32)>,
266    /// `w4a16_gemm_t` tile GEMM for the twin (3-deep pipeline variant when
267    /// present, same resolver as decode_a2's lm_head). 0-handle = twin path
268    /// dead, batched GEMV fallback unchanged.
269    pub(super) w4a16_gemm_t_k: KernelHandle,
270    /// Drafter attention metadata for the batched propose:
271    /// `[PROPOSE_META_SEQS, propose_meta_stride]` bytes, one slab per
272    /// sequence. A dedicated allocation, NOT an offset into the shared
273    /// `scratch` arena — the old fixed `scratch + 49152 + i*2048` layout ran
274    /// past the end of a 27B-shaped scratch at n > 8 (silent out-of-range
275    /// H2D → sticky CUDA-700, the #110 failure mode).
276    propose_meta: DevicePtr,
277    /// Per-sequence stride of `propose_meta`, computed at construction from
278    /// `max_seq_len` (`batch_caps::propose_meta_stride_env`, floor 2048,
279    /// override `ATLAS_PROPOSE_META_STRIDE=<bytes>`). The fixed 2048 capped
280    /// the block table at 448 entries = 7,168 tokens — sized in the 4K era;
281    /// 10-20K agentic contexts made the batched propose fall back
282    /// permanently (PROGRESS_LOG 5.2/6.17).
283    propose_meta_stride: usize,
284    /// `argmax_bf16_batch` for the batched-propose per-row argmax (0 when
285    /// absent; falls back to the serial per-row scan).
286    argmax_batch_k: KernelHandle,
287    /// `argmax_bf16_batch_lp` — the same batched argmax that ALSO emits each
288    /// row's top-1 log-probability. Resolved with `try_kernel`, so 0 means the
289    /// module predates this kernel; D-Cut gates on it and declines rather than
290    /// silently proposing without confidences.
291    argmax_batch_lp_k: KernelHandle,
292    /// Drafter-prefill scratch; `None` unless ATLAS_MTP_DRAFTER_PREFILL=1.
293    prefill_scratch: Option<MtpPrefillScratch>,
294}
295
296impl MtpHead {
297    /// Acquire the MTP KV cache mutex. Used by the multi-module
298    /// dispatcher (`mtp_multi`) to reclaim blocks during free_state.
299    /// `parking_lot::Mutex` does not poison, so this can never fail.
300    pub(crate) fn kv_cache_lock(&self) -> parking_lot::MutexGuard<'_, PagedKvCache> {
301        self.kv_cache.lock()
302    }
303
304    /// Dispatch GEMV to the appropriate kernel based on weight precision.
305    fn gemv(
306        &self,
307        gpu: &dyn GpuBackend,
308        input: DevicePtr,
309        proj: &ProjectionWeight,
310        output: DevicePtr,
311        n: u32,
312        k: u32,
313        stream: u64,
314    ) -> Result<()> {
315        match proj {
316            ProjectionWeight::Nvfp4(w) => ops::w4a16_decode_gemv(
317                gpu,
318                self.w4a16_gemv_k,
319                self.w4a16_gemv_sw_k,
320                self.gemv_sw,
321                input,
322                w,
323                output,
324                n,
325                k,
326                stream,
327            ),
328            ProjectionWeight::Fp8(w) => ops::dense_gemv_fp8w(
329                gpu,
330                self.dense_gemv_fp8w_k.unwrap(),
331                input,
332                w,
333                output,
334                n,
335                k,
336                stream,
337            ),
338            ProjectionWeight::Fp8BlockScaled(w) => ops::w8a16_gemv(
339                gpu,
340                self.w8a16_gemv_k.unwrap(),
341                input,
342                w.weight,
343                w.row_scale,
344                output,
345                n,
346                k,
347                stream,
348            ),
349            ProjectionWeight::Bf16(w) => ops::dense_gemv(
350                gpu,
351                self.dense_gemv_k.unwrap(),
352                input,
353                w,
354                output,
355                n,
356                k,
357                stream,
358            ),
359        }
360    }
361
362    /// Quantize a BF16 weight to the target precision.
363    fn quantize_proj(
364        bf16: &DenseWeight,
365        n: usize,
366        k: usize,
367        quant: MtpQuantization,
368        gpu: &dyn GpuBackend,
369        absmax_k: KernelHandle,
370        nvfp4_k: KernelHandle,
371        fp8_k: KernelHandle,
372        stream: u64,
373    ) -> Result<ProjectionWeight> {
374        match quant {
375            MtpQuantization::Nvfp4 => Ok(ProjectionWeight::Nvfp4(quantize_to_nvfp4(
376                bf16, n, k, gpu, absmax_k, nvfp4_k, stream,
377            )?)),
378            MtpQuantization::Fp8 => Ok(ProjectionWeight::Fp8(quantize_to_fp8(
379                bf16, n, k, gpu, fp8_k, stream,
380            )?)),
381            MtpQuantization::Bf16 => Ok(ProjectionWeight::Bf16(*bf16)),
382        }
383    }
384}
385
386mod batch_caps;
387mod draft_proposer;
388mod forward;
389mod forward_batch;
390mod moe_forward;
391mod new;
392mod prefill;
393pub(crate) mod row_dispatch;
394
395#[cfg(test)]
396mod tests {
397    use super::*;
398
399    #[test]
400    fn test_mtp_proposer_state_downcast() {
401        let state: Box<dyn ProposerState> = Box::new(MtpProposerState {
402            block_table: vec![0, 1, 2],
403            seq_len: 42,
404            last_num_drafted: 0,
405            last_pair_key: None,
406        });
407        let mtp = state.as_any().downcast_ref::<MtpProposerState>().unwrap();
408        assert_eq!(mtp.seq_len, 42);
409        assert_eq!(mtp.block_table.len(), 3);
410    }
411}
412
413/// How many drafter KV rows `after_verify` must drop.
414///
415/// * Rejected rows always go: `num_drafted - num_accepted`.
416/// * With `refeed_accepted` (ATLAS_MTP_REFEED_ACCEPTED), the ACCEPTED rows
417///   that were written with the drafter's own hidden also go — that is every
418///   accepted draft except the first. Draft 1 consumed the target's verified
419///   hidden (`mtp_hidden_save`) and is correct; drafts 2.. each consumed the
420///   previous draft's drafter-side residual. Those rows are rebuilt from the
421///   catch-up ring on the next propose, with the target's true hidden.
422///
423/// Never returns more than `num_drafted` — the drafter cannot un-write rows it
424/// never wrote, and over-trimming would corrupt the compacted row space by
425/// desynchronising `seq_len` from `last_pair_key`.
426pub(crate) fn mtp_rows_to_trim(
427    num_drafted: usize,
428    num_accepted: usize,
429    refeed_accepted: bool,
430) -> usize {
431    let rejected = num_drafted.saturating_sub(num_accepted);
432    let accepted_with_drafter_hidden = if refeed_accepted {
433        num_accepted.saturating_sub(1)
434    } else {
435        0
436    };
437    (rejected + accepted_with_drafter_hidden).min(num_drafted)
438}
439
440#[cfg(test)]
441mod refeed_trim_tests {
442    use super::mtp_rows_to_trim;
443
444    #[test]
445    fn flag_off_is_exactly_the_legacy_behaviour() {
446        // Legacy: trim only the rejected rows. These are the K=2/3/4 cases
447        // the schedulers actually produce.
448        assert_eq!(mtp_rows_to_trim(1, 0, false), 1); // K=2 reject
449        assert_eq!(mtp_rows_to_trim(1, 1, false), 0); // K=2 accept
450        assert_eq!(mtp_rows_to_trim(2, 0, false), 2); // K=3 reject
451        assert_eq!(mtp_rows_to_trim(2, 1, false), 1); // K=3 accept-1
452        assert_eq!(mtp_rows_to_trim(2, 2, false), 0); // K=3 accept-2
453        assert_eq!(mtp_rows_to_trim(3, 3, false), 0); // K=4 accept-3
454    }
455
456    #[test]
457    fn flag_on_also_drops_accepted_rows_past_the_first() {
458        // The first accepted draft used the TARGET hidden — it stays.
459        assert_eq!(mtp_rows_to_trim(1, 1, true), 0); // K=2 accept: nothing extra
460        assert_eq!(mtp_rows_to_trim(2, 1, true), 1); // K=3 accept-1: rejected only
461        assert_eq!(mtp_rows_to_trim(2, 2, true), 1); // K=3 accept-2: drop draft 2
462        assert_eq!(mtp_rows_to_trim(3, 2, true), 2); // K=4 accept-2: 1 rejected + 1
463        assert_eq!(mtp_rows_to_trim(3, 3, true), 2); // K=4 accept-3: drop drafts 2,3
464    }
465
466    #[test]
467    fn full_reject_is_identical_with_and_without_the_flag() {
468        // Nothing was accepted, so there is no drafter-hidden row to rebuild.
469        for d in 0..8 {
470            assert_eq!(mtp_rows_to_trim(d, 0, true), mtp_rows_to_trim(d, 0, false));
471        }
472    }
473
474    #[test]
475    fn never_trims_more_rows_than_were_drafted() {
476        for d in 0..8 {
477            for a in 0..=d + 2 {
478                assert!(mtp_rows_to_trim(d, a, true) <= d, "d={d} a={a}");
479                assert!(mtp_rows_to_trim(d, a, false) <= d, "d={d} a={a}");
480            }
481        }
482    }
483}