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;