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}