metrale_model_layers/layers/
mtp_head.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The MTP (multi-token prediction) draft head, a [`DraftProposer`]. One
4//! decoder layer drafts from the token embedding and the target's hidden:
5//! pre-fc norms, concat, fc, gated attention over its own KV cache, MoE or
6//! dense FFN, final norm, the NVFP4 LM head and argmax. [`MtpQuantization`]
7//! selects the projection weights' precision.
8//!
9//! Owner: model-layers (MTP head).
10//! Invariants:
11//! - `mtp_rows_to_trim` never returns more than `num_drafted`.
12
13use parking_lot::Mutex;
14use std::any::Any;
15
16use anyhow::Result;
17use metrale_cache::kv_cache::PagedKvCache;
18use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
19
20use crate::layer::ForwardContext;
21use crate::layers::MoeLayer;
22use crate::layers::ops;
23use crate::layers::w4a16_gemv_tiers::W4a16BatchmTiers;
24use crate::speculative::{DraftProposer, ProposerState};
25use crate::weight_map::{
26    DenseWeight, Fp8DenseWeight, Fp8Weight, QuantizedWeight, quantize_to_fp8, quantize_to_nvfp4,
27};
28
29/// 2026-09-25: Whether the drafter prefill is on (`levers.drafter.prefill`, on unless
30/// `METRALE_NO_MTP_DRAFTER_CONTEXT` turns it off; policy in
31/// `crate::drafter_context`). The prefill builds the drafter's KV over the whole
32/// prompt before the first propose. The drafter's K/V depend only on its input
33/// pair, not on attention, so it runs the norms, fc, K/V projections, RoPE and
34/// the cache write, with no attention pass.
35pub fn mtp_drafter_prefill_enabled(levers: &crate::layers::ops::ModelLevers) -> bool {
36    levers.drafter.prefill
37}
38
39/// 2026-09-25: Scratch for the batched drafter-row writer (drafter prefill and
40/// catch-up), allocated by `MtpHead::new` when either is enabled. Every buffer
41/// holds `PREFILL_CHUNK` rows, and none aliases the shared arena.
42pub(crate) struct MtpPrefillScratch {
43    pub embed: DevicePtr,
44    pub normed_embed: DevicePtr,
45    pub normed_hidden: DevicePtr,
46    pub concat: DevicePtr,
47    pub fc_out: DevicePtr,
48    pub normed2: DevicePtr,
49    pub k_out: DevicePtr,
50    pub v_out: DevicePtr,
51    /// 2026-09-25: RoPE rotates Q and K in one launch; the row writer discards Q, but
52    /// the kernel still needs a writable `[chunk, nq * hd]` region.
53    pub q_scratch: DevicePtr,
54    /// 2026-09-25: u32 RoPE positions, one per row.
55    pub pos_dev: DevicePtr,
56    /// 2026-09-25: i64 KV slot mapping, one per row.
57    pub slot_dev: DevicePtr,
58}
59
60/// 2026-09-25: Precision of the MTP head's projection weights.
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
62pub enum MtpQuantization {
63    Nvfp4,
64    Fp8,
65    Bf16,
66}
67
68impl MtpQuantization {
69    /// 2026-09-25: Whether the batched drafter prefill can run at this precision. The
70    /// prefill itself re-checks the weight variants and kernel handles; this
71    /// lets the caller skip the `max_seq_len x hidden` BF16 prompt-hidden buffer
72    /// for a head that could never use it. `quantize_proj` produces
73    /// `ProjectionWeight::Bf16` only for [`MtpQuantization::Bf16`].
74    pub fn supports_drafter_prefill(self) -> bool {
75        matches!(self, Self::Bf16)
76    }
77
78    /// 2026-09-25: The precision the head's forward runs at (KV dtype, forward arms,
79    /// drafter prefill). A dense-FFN head under [`Self::Nvfp4`] runs the BF16
80    /// forward, so it reports [`Self::Bf16`]; every other combination reports
81    /// itself. `MtpHead::new`, the MTP KV-pool reserve and the prompt-capture
82    /// allocation all ask here.
83    pub fn effective_for_head(self, dense_ffn_head: bool) -> Self {
84        if dense_ffn_head && matches!(self, Self::Nvfp4) {
85            Self::Bf16
86        } else {
87            self
88        }
89    }
90}
91
92impl std::str::FromStr for MtpQuantization {
93    type Err = anyhow::Error;
94    fn from_str(s: &str) -> Result<Self> {
95        match s.to_lowercase().as_str() {
96            "nvfp4" | "fp4" => Ok(Self::Nvfp4),
97            "fp8" => Ok(Self::Fp8),
98            "bf16" => Ok(Self::Bf16),
99            _ => anyhow::bail!("Unknown MTP quantization: {s}. Expected: nvfp4, fp8, bf16"),
100        }
101    }
102}
103
104/// 2026-09-25: A projection weight in one of the head's precisions.
105#[allow(dead_code)]
106enum ProjectionWeight {
107    Nvfp4(QuantizedWeight),
108    Fp8(Fp8DenseWeight),
109    /// 2026-09-25: FP8 block-scaled weight, run by `w8a16_gemv`. No constructor in
110    /// the head builds this variant.
111    Fp8BlockScaled(Fp8Weight),
112    Bf16(DenseWeight),
113}
114
115/// 2026-09-25: Per-sequence MTP proposer state.
116pub struct MtpProposerState {
117    /// 2026-09-25: Block table in the head's own KV cache.
118    pub block_table: Vec<u32>,
119    /// 2026-09-25: Drafter rows written in the head's KV cache.
120    pub seq_len: usize,
121    /// 2026-09-25: Drafts produced by the last propose; `after_verify` trims from it.
122    pub last_num_drafted: usize,
123    /// 2026-09-25: Sequence-space pair key of the newest drafter row: a `forward_one`
124    /// at RoPE position `p` writes key `p - 1`. `seq_len` counts rows, which need
125    /// not match sequence positions. `None` until a row is written.
126    pub last_pair_key: Option<usize>,
127    /// 2026-09-25: The drafts the last propose returned, in order.
128    pub last_drafts: Vec<u32>,
129    pub pending_catchup: Vec<u32>,
130}
131
132impl ProposerState for MtpProposerState {
133    fn as_any(&self) -> &dyn Any {
134        self
135    }
136    fn as_any_mut(&mut self) -> &mut dyn Any {
137        self
138    }
139}
140
141/// 2026-09-25: The MTP draft head.
142#[allow(dead_code)]
143pub struct MtpHead {
144    pre_fc_norm_embedding: DenseWeight,
145    pre_fc_norm_hidden: DenseWeight,
146    input_layernorm: DenseWeight,
147    post_attn_layernorm: DenseWeight,
148    norm: DenseWeight,
149
150    fc: ProjectionWeight,
151    q_proj: ProjectionWeight,
152    k_proj: ProjectionWeight,
153    v_proj: ProjectionWeight,
154    o_proj: ProjectionWeight,
155
156    q_norm: DenseWeight,
157    k_norm: DenseWeight,
158
159    // 2026-09-25: FFN storage. An NVFP4 MoE head uses `moe_nvfp4`; an FP8/BF16 MoE
160    // head uses `moe_grouped` when the checkpoint ships FP8 experts (2026-10-02: or a BF16
161    // head BF16 experts), else the per-expert `*_generic` weights. A dense-FFN head leaves
162    // all of them `None`.
163    moe_nvfp4: Option<MoeLayer>,
164    moe_experts_generic: Option<Vec<(ProjectionWeight, ProjectionWeight, ProjectionWeight)>>,
165    moe_shared_generic: Option<(ProjectionWeight, ProjectionWeight, ProjectionWeight)>,
166    /// 2026-09-25: The checkpoint's FP8 block-scaled routed and shared experts as a
167    /// [`MoeLayer`] with FP8 pointer tables, built for an FP8/BF16 head when
168    /// `MtpWeights::fp8_experts` is present (2026-10-02: or, for a BF16 head, its BF16
169    /// experts with BF16 tables). `forward_one` runs it through `MoeLayer::forward`, and the
170    /// batched propose through `forward_grouped_decode`. `None` means `moe_forward_generic`
171    /// runs.
172    moe_grouped: Option<MoeLayer>,
173    moe_gate: DenseWeight,
174    shared_expert_gate: DenseWeight,
175
176    /// 2026-09-25: Dense FFN `(gate_proj, up_proj, down_proj)` for a head whose
177    /// checkpoint has a dense FFN. When `Some`, the forward runs this MLP and
178    /// the `Option` MoE fields are `None`.
179    dense_ffn_generic: Option<(ProjectionWeight, ProjectionWeight, ProjectionWeight)>,
180
181    quant: MtpQuantization,
182
183    /// 2026-09-25: LM-head rows the drafter scores; 0 means the full vocab.
184    mtp_vocab_size: u32,
185
186    embed_tokens: DenseWeight,
187    /// 2026-09-25: The target's final norm weight, applied to target-hidden rows before
188    /// `pre_fc_norm_hidden` under `ModelLevers::mtp_target_postnorm`.
189    target_final_norm: DenseWeight,
190    lm_head_nvfp4: QuantizedWeight,
191
192    // 2026-09-25: The head's own one-layer KV cache, separate from the target's.
193    kv_cache: Mutex<PagedKvCache>,
194    attn_layer_idx: usize,
195
196    rms_norm_k: KernelHandle,
197    rms_norm_residual_k: KernelHandle,
198    w4a16_gemv_k: KernelHandle,
199    /// 2026-09-25: `w4a16_gemv_sw`; while it is 0, `w4a16_decode_gemv` runs the base
200    /// GEMV.
201    w4a16_gemv_sw_k: KernelHandle,
202    /// 2026-09-25: False when `METRALE_NO_GEMV_SW=1`; read at construction because
203    /// `gemv` has no `ForwardContext`.
204    gemv_sw: bool,
205    w4a16_gemv_qg_k: KernelHandle,
206    w4a16_gemv_dual_k: KernelHandle,
207    rope_k: KernelHandle,
208    /// 2026-09-29: `rope_forward_strided` and `sigmoid_gate_mul_batched`, for the batched
209    /// propose's one-launch attention block; 0 when the target lacks them, which declines
210    /// the batched propose (`propose_batch_scope_ok`).
211    rope_strided_k: KernelHandle,
212    sigmoid_gate_mul_batched_k: KernelHandle,
213    /// 2026-09-29: `rms_norm_strided`, the batched propose's one-launch Q head norm.
214    rms_norm_strided_k: KernelHandle,
215    reshape_cache_k: KernelHandle,
216    paged_decode_k: KernelHandle,
217    /// 2026-09-25: KV cache dtype: BF16 for BF16 and FP8 heads, FP8 for NVFP4 heads.
218    kv_bf16: bool,
219    /// 2026-09-25: `ModelLevers::mtp_kv_exact`, stored at construction because
220    /// `after_verify` has no `ForwardContext`.
221    kv_exact: bool,
222    residual_add_k: KernelHandle,
223    residual_add_rms_norm_k: KernelHandle,
224    sigmoid_gate_mul_k: KernelHandle,
225    bf16_concat_k: KernelHandle,
226    argmax_k: KernelHandle,
227    embed_from_argmax_k: KernelHandle,
228    /// 2026-09-25: 4-byte device buffer that `embed_from_argmax` writes the draft id to,
229    /// for deferred readback.
230    draft_token_id_dev: DevicePtr,
231    /// 2026-10-02: Top-1 log-probability of the last single-sequence draft whose id was read
232    /// back, as f32 bits; NaN when it was not measured. Written by `forward_one` under
233    /// `--draft-confidence-stop`, read by `propose` to stop the chain.
234    pub(super) last_conf_bits: std::sync::atomic::AtomicU32,
235    dense_gemv_k: Option<KernelHandle>,
236    dense_gemv_fp8w_k: Option<KernelHandle>,
237    w8a16_gemv_k: Option<KernelHandle>,
238    deinterleave_qg_k: Option<KernelHandle>,
239    moe_topk_k: Option<KernelHandle>,
240    moe_silu_mul_k: Option<KernelHandle>,
241    moe_weighted_sum_blend_k: Option<KernelHandle>,
242    /// 2026-09-25: Batched BF16 GEMM for the drafter prefill; 0 when absent.
243    dense_gemm_k: KernelHandle,
244    /// 2026-09-27: `batched_embed`, the drafter prefill's embedding gather; 0 when
245    /// absent, and then the prefill copies one row per token.
246    batched_embed_k: KernelHandle,
247    /// 2026-09-25: `dense_gemm_bf16_pipelined` for the batched propose; 0 when absent,
248    /// and then the batched propose is out of scope.
249    dense_gemm_pipelined_k: KernelHandle,
250    /// 2026-09-25: `dense_gemv_bf16_batchm`, one pass over each BF16 weight for all M
251    /// rows, for batched-propose widths 2..=8; 0 when absent.
252    /// `row_dispatch::drafter_row_kernel` chooses between it and the others.
253    dense_gemv_batchm_k: KernelHandle,
254    /// 2026-09-25: Batched LM-head kernels: the `w4a16_batchm` tiers (widths 4 to
255    /// 8) here and `w4a16_gemv_batch16`/`32` below (0 when absent);
256    /// [`MtpHead::lm_head_batch_kernel`] picks one per batch width.
257    w4a16_batchm: W4a16BatchmTiers,
258    w4a16_gemv_batch16_k: KernelHandle,
259    w4a16_gemv_batch32_k: KernelHandle,
260    /// 2026-09-25: `(weight_t, ldb)`: the transposed LM-head copy the caller passes in,
261    /// with row stride `ldb`. The batched propose runs a tile GEMM on it for
262    /// n >= 5 when `w4a16_gemm_t_k` is non-zero. `None` when the caller passed
263    /// none or `METRALE_NO_MTP_LMHEAD_TGEMM` is set to any value.
264    pub(super) lm_head_nvfp4_t: Option<(QuantizedWeight, u32)>,
265    /// 2026-09-25: Tile GEMM for `lm_head_nvfp4_t`, from `tgemm_kernel`; while it is 0
266    /// the batched LM head uses `lm_head_batch_kernel`.
267    pub(super) w4a16_gemm_t_k: KernelHandle,
268    /// 2026-09-25: Drafter attention metadata for the batched propose,
269    /// `PROPOSE_META_SEQS * propose_meta_stride` bytes, one slab per sequence.
270    /// Its own allocation, not part of the shared scratch arena.
271    propose_meta: DevicePtr,
272    /// 2026-09-25: Per-sequence stride of `propose_meta`, from
273    /// `batch_caps::propose_meta_stride_env` at construction.
274    propose_meta_stride: usize,
275    /// 2026-09-25: `argmax_bf16_batch` for the batched propose; while it is 0, the
276    /// propose runs `argmax_bf16` once per row.
277    argmax_batch_k: KernelHandle,
278    /// 2026-09-25: `argmax_bf16_batch_lp`, the batched argmax that also writes each
279    /// row's top-1 log-probability; used instead of `argmax_batch_k` when the
280    /// caller asks for confidences and this handle is non-zero.
281    argmax_batch_lp_k: KernelHandle,
282    /// 2026-09-25: Drafter-row scratch; `None` unless the drafter prefill or the
283    /// catch-up is enabled.
284    prefill_scratch: Option<MtpPrefillScratch>,
285    /// 2026-09-29: The circuit executor's draft program, which `forward_one` runs in place of
286    /// its own step while installed (`--forward circuit`).
287    circuit_draft:
288        parking_lot::RwLock<Option<std::sync::Arc<dyn crate::circuit_exec::DraftRunner>>>,
289}
290
291impl MtpHead {
292    /// 2026-09-25: Lock the head's KV cache; `mtp_multi` frees blocks through it.
293    /// `parking_lot::Mutex` does not poison, so this cannot fail.
294    pub(crate) fn kv_cache_lock(&self) -> parking_lot::MutexGuard<'_, PagedKvCache> {
295        self.kv_cache.lock()
296    }
297
298    /// 2026-09-25: One-row GEMV with the kernel for `proj`'s precision.
299    fn gemv(
300        &self,
301        gpu: &dyn GpuBackend,
302        input: DevicePtr,
303        proj: &ProjectionWeight,
304        output: DevicePtr,
305        n: u32,
306        k: u32,
307        stream: u64,
308    ) -> Result<()> {
309        match proj {
310            ProjectionWeight::Nvfp4(w) => ops::w4a16_decode_gemv(
311                gpu,
312                self.w4a16_gemv_k,
313                self.w4a16_gemv_sw_k,
314                self.gemv_sw,
315                input,
316                w,
317                output,
318                n,
319                k,
320                stream,
321            ),
322            ProjectionWeight::Fp8(w) => ops::dense_gemv_fp8w(
323                gpu,
324                self.dense_gemv_fp8w_k.unwrap(),
325                input,
326                w,
327                output,
328                n,
329                k,
330                stream,
331            ),
332            ProjectionWeight::Fp8BlockScaled(w) => ops::w8a16_gemv(
333                gpu,
334                self.w8a16_gemv_k.unwrap(),
335                input,
336                w.weight,
337                w.row_scale,
338                output,
339                n,
340                k,
341                stream,
342            ),
343            ProjectionWeight::Bf16(w) => ops::dense_gemv(
344                gpu,
345                self.dense_gemv_k.unwrap(),
346                input,
347                w,
348                output,
349                n,
350                k,
351                stream,
352            ),
353        }
354    }
355
356    /// 2026-09-25: Quantize a BF16 weight to `quant` (a copy of the handle for BF16).
357    fn quantize_proj(
358        bf16: &DenseWeight,
359        n: usize,
360        k: usize,
361        quant: MtpQuantization,
362        gpu: &dyn GpuBackend,
363        absmax_k: KernelHandle,
364        nvfp4_k: KernelHandle,
365        fp8_k: KernelHandle,
366        stream: u64,
367    ) -> Result<ProjectionWeight> {
368        match quant {
369            MtpQuantization::Nvfp4 => Ok(ProjectionWeight::Nvfp4(quantize_to_nvfp4(
370                bf16, n, k, gpu, absmax_k, nvfp4_k, stream,
371            )?)),
372            MtpQuantization::Fp8 => Ok(ProjectionWeight::Fp8(quantize_to_fp8(
373                bf16, n, k, gpu, fp8_k, stream,
374            )?)),
375            MtpQuantization::Bf16 => Ok(ProjectionWeight::Bf16(*bf16)),
376        }
377    }
378}
379
380mod batch_caps;
381mod catchup_batch;
382mod chain_hidden;
383mod circuit;
384mod draft_proposer;
385mod forward;
386mod forward_batch;
387pub(crate) use forward_batch::{LmHeadRowsArm, lm_head_rows_arm, tc_lm_head};
388mod forward_batch_ffn;
389mod moe_forward;
390mod new;
391mod new_bf16_moe;
392pub use new_bf16_moe::{mtp_experts_nvfp4, set_mtp_experts_nvfp4_from_cli};
393mod new_native_fp8_moe;
394mod prefill;
395pub(crate) mod row_dispatch;
396
397#[cfg(test)]
398mod tests {
399    use super::*;
400
401    #[test]
402    fn test_mtp_proposer_state_downcast() {
403        let state: Box<dyn ProposerState> = Box::new(MtpProposerState {
404            block_table: vec![0, 1, 2],
405            seq_len: 42,
406            last_num_drafted: 0,
407            last_pair_key: None,
408            last_drafts: Vec::new(),
409            pending_catchup: Vec::new(),
410        });
411        let mtp = state.as_any().downcast_ref::<MtpProposerState>().unwrap();
412        assert_eq!(mtp.seq_len, 42);
413        assert_eq!(mtp.block_table.len(), 3);
414    }
415}
416
417/// 2026-09-25: How many drafter KV rows `after_verify` drops when exact KV is off.
418///
419/// * Rejected rows always go: `num_drafted - num_accepted`.
420/// * With `refeed_accepted` (`mtp_refeed_accepted_enabled`), the accepted rows
421///   built from a drafter hidden also go: every accepted draft except the first,
422///   whose row was built from the target's hidden.
423///
424/// Never more than `num_drafted`.
425pub(crate) fn mtp_rows_to_trim(
426    num_drafted: usize,
427    num_accepted: usize,
428    refeed_accepted: bool,
429) -> usize {
430    let rejected = num_drafted.saturating_sub(num_accepted);
431    let accepted_with_drafter_hidden = if refeed_accepted {
432        num_accepted.saturating_sub(1)
433    } else {
434        0
435    };
436    (rejected + accepted_with_drafter_hidden).min(num_drafted)
437}
438
439#[cfg(test)]
440mod refeed_trim_tests;