metrale_model_arch/
deepseek_v4_mtp.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: DeepSeek-V4-Flash multi-token-prediction (MTP) draft proposer.
4//!
5//! Implements [`DraftProposer`] over the `DeepseekV4MtpModule` loaded by
6//! `load_v4_mtp_module`. Where
7//! [`metrale_model_layers::layers::MtpHead`] builds its own attention + MoE
8//! block, the V4 MTP module's body is a reused V4 layer (MLA + mHC
9//! hyper-connections + MoE), so the proposer delegates the bulk of the
10//! forward to `body.decode()` and wraps it with the MTP-specific pieces.
11//!
12//! One draft (`forward_one`; `propose` runs it `num_drafts` times):
13//!
14//! ```text
15//!   embed   = embed_tokens[token]                            // [hidden] bf16
16//!   h_in    = e_proj · rms_norm(embed,  enorm)
17//!           + h_proj · rms_norm(hidden, hnorm)               // combiner
18//!   hc_expand(h_in → hc_streams)                             // first mHC stage
19//!   body.decode(hc_streams, …, mtp_kv_cache, state.seq_len)  // middle mHC + MLA + MoE
20//!   hc_head(hc_streams → h_out)                              // last mHC stage
21//!   logits  = lm_head(rms_norm(h_out, norm))
22//!   draft   = argmax(logits)                                 // grammar-masked when Some
23//! ```
24//!
25//! The body was assembled with `layer_idx = num_hidden_layers`, so its
26//! `decode_inner_hc` is neither the first nor the last model layer: it runs
27//! the middle mHC mixing (hc_pre → attn → hc_post → hc_pre → ffn → hc_post)
28//! on `hc_streams` and calls neither `hc_expand` nor `hc_head`. The proposer
29//! supplies both ends.
30//!
31//! ## Separate KV cache and metadata
32//!
33//! The MTP attention writes into its own MLA-shaped [`PagedKvCache`]
34//! (num_kv_heads = 1, head_dim = kv_lora_rank + qk_rope_head_dim), never the
35//! target's. The decode attention reads positions / slot / seq_len /
36//! block_table from `ctx.attn_metadata`, so the proposer uploads its own
37//! metadata to `scratch().offset(MTP_META_OFFSET)`, away from the target's
38//! at 32768, and passes it in a derived [`ForwardContext`].
39//!
40//! Owner: model-arch, DeepSeek-V4 MTP.
41//! Invariants: none beyond the types.
42
43use parking_lot::Mutex;
44use std::any::Any;
45
46use anyhow::Result;
47use metrale_cache::kv_cache::{KvCacheConfig, KvCacheDtype, PagedKvCache};
48use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
49
50use crate::weight_loader::deepseek_v4::DeepseekV4MtpModule;
51use metrale_model_layers::layer::{AttnMetadataDev, ForwardContext, LayerState};
52use metrale_model_layers::layers::mtp_meta::{MTP_META_OFFSET, pack_mtp_attn_meta};
53use metrale_model_layers::layers::ops;
54use metrale_model_layers::speculative::{DraftProposer, ProposerState};
55use metrale_model_layers::weight_map::DenseWeight;
56
57mod proposer;
58
59/// 2026-09-25: Per-sequence state for the DeepSeek-V4 MTP proposer: the block
60/// table and length of the sequence in the MTP KV cache, the draft count of
61/// the last `propose` (for `after_verify`), and the body's layer state from
62/// `body.alloc_state`.
63pub struct DeepseekV4MtpProposerState {
64    pub block_table: Vec<u32>,
65    pub seq_len: usize,
66    pub last_num_drafted: usize,
67    pub body_state: Box<dyn LayerState>,
68}
69
70impl ProposerState for DeepseekV4MtpProposerState {
71    fn as_any(&self) -> &dyn Any {
72        self
73    }
74    fn as_any_mut(&mut self) -> &mut dyn Any {
75        self
76    }
77}
78
79/// 2026-09-25: DeepSeek-V4 MTP draft proposer. `embed_tokens` and `lm_head`
80/// are the target model's bf16 tables; `mtp_vocab_size` limits the draft
81/// LM-head GEMV (0 = full vocab); `kv_cache` is the MTP attention's own cache.
82pub struct DeepseekV4MtpHead {
83    module: DeepseekV4MtpModule,
84    embed_tokens: DenseWeight,
85    lm_head: DenseWeight,
86    mtp_vocab_size: u32,
87    kv_cache: Mutex<PagedKvCache>,
88
89    rms_norm_k: KernelHandle,
90    dense_gemv_k: KernelHandle,
91    residual_add_k: KernelHandle,
92    hc_expand_k: KernelHandle,
93    hc_head_k: KernelHandle,
94    argmax_k: KernelHandle,
95}
96
97impl DeepseekV4MtpHead {
98    /// 2026-09-25: Build the proposer from a loaded `DeepseekV4MtpModule` and the
99    /// target's bf16 embedding and LM head.
100    pub fn new(
101        module: DeepseekV4MtpModule,
102        embed_tokens: DenseWeight,
103        lm_head: DenseWeight,
104        config: &metrale_config::ModelConfig,
105        gpu: &dyn GpuBackend,
106        mtp_vocab_size: u32,
107        max_seq_len: usize,
108    ) -> Result<Self> {
109        // 2026-09-25: The target's MLA cache shape (num_kv_heads = 1, head_dim
110        // = kv_lora_rank + qk_rope_head_dim), so the reused V4 body writes and
111        // reads it at the right strides; bf16.
112        let mla_cache_dim = config.kv_lora_rank + config.qk_rope_head_dim;
113        // 2026-09-25: The body was built with layer index `num_hidden_layers`
114        // and its decode indexes the cache at that layer, so the cache has
115        // `num_hidden_layers + 1` layer slots of which only the last is used.
116        let num_layers = config.num_hidden_layers + 1;
117        let kv_config = KvCacheConfig {
118            block_size: 16,
119            num_kv_heads: 1,
120            head_dim: mla_cache_dim,
121            num_layers,
122            dtype: KvCacheDtype::Bf16,
123            layer_dtypes: vec![],
124            layer_dims: vec![],
125            cache_blocks_per_seq: None,
126        };
127        let mtp_num_blocks = max_seq_len / kv_config.block_size + 1;
128        let kv_cache = PagedKvCache::new(kv_config, mtp_num_blocks, gpu)?;
129
130        Ok(Self {
131            module,
132            embed_tokens,
133            lm_head,
134            mtp_vocab_size,
135            kv_cache: Mutex::new(kv_cache),
136            // 2026-09-25: The norm weights are plain (`w * x_normed`); the shared
137            // `rms_norm` kernel would apply `1 + w`.
138            rms_norm_k: gpu.kernel("rms_norm_vanilla", "rms_norm_vanilla")?,
139            dense_gemv_k: gpu.kernel("gemv", "dense_gemv_bf16")?,
140            residual_add_k: gpu.kernel("residual_add", "bf16_residual_add")?,
141            hc_expand_k: gpu.kernel("hyper_connection", "hc_expand")?,
142            hc_head_k: gpu.kernel("hyper_connection", "hc_head")?,
143            argmax_k: gpu.kernel("argmax", "argmax_bf16")?,
144        })
145    }
146
147    /// 2026-09-25: Allocate per-sequence state; the body's part comes from
148    /// `body.alloc_state`.
149    pub fn alloc_state_inner(&self, gpu: &dyn GpuBackend) -> Result<DeepseekV4MtpProposerState> {
150        Ok(DeepseekV4MtpProposerState {
151            block_table: Vec::new(),
152            seq_len: 0,
153            last_num_drafted: 0,
154            body_state: self.module.body.alloc_state(gpu)?,
155        })
156    }
157
158    /// 2026-09-25: One MTP draft step at `position`. Returns the drafted token id.
159    #[allow(clippy::too_many_arguments)]
160    fn forward_one(
161        &self,
162        token: u32,
163        target_hidden: DevicePtr,
164        position: usize,
165        state: &mut DeepseekV4MtpProposerState,
166        ctx: &ForwardContext,
167        stream: u64,
168        grammar_bitmask: Option<&[i32]>,
169    ) -> Result<u32> {
170        let h = ctx.config.hidden_size as u32;
171        let eps = ctx.config.rms_norm_eps as f32;
172        let hc_mult = ctx.config.hc_mult as u32;
173        let row_bytes = h as usize * 2;
174
175        // 2026-09-25: 1. Embed the input token (a row copy from the shared table).
176        let embed_out = ctx.buffers.ssm_qkvz();
177        let src = self.embed_tokens.weight.offset(token as usize * row_bytes);
178        ctx.gpu.copy_d2d_async(src, embed_out, row_bytes, stream)?;
179
180        // 2026-09-25: 2. Combiner: h_in = e_proj·rms_norm(embed, enorm)
181        // + h_proj·rms_norm(target_hidden, hnorm).
182        let normed_embed = ctx.buffers.ssm_deinterleaved();
183        ops::rms_norm(
184            ctx.gpu,
185            self.rms_norm_k,
186            embed_out,
187            &self.module.enorm,
188            normed_embed,
189            1,
190            h,
191            eps,
192            stream,
193        )?;
194        let normed_hidden = ctx.buffers.ssm_gates();
195        ops::rms_norm(
196            ctx.gpu,
197            self.rms_norm_k,
198            target_hidden,
199            &self.module.hnorm,
200            normed_hidden,
201            1,
202            h,
203            eps,
204            stream,
205        )?;
206
207        // 2026-09-25: The embedding branch goes into `h_in`, the hidden branch
208        // into a temp, then `bf16_residual_add` does h_in += temp in place.
209        let h_in = ctx.buffers.hidden_states();
210        let h_branch = ctx.buffers.norm_output();
211        ops::dense_gemv(
212            ctx.gpu,
213            self.dense_gemv_k,
214            normed_embed,
215            &self.module.e_proj,
216            h_in,
217            h,
218            h,
219            stream,
220        )?;
221        ops::dense_gemv(
222            ctx.gpu,
223            self.dense_gemv_k,
224            normed_hidden,
225            &self.module.h_proj,
226            h_branch,
227            h,
228            h,
229            stream,
230        )?;
231        ops::residual_add(ctx.gpu, self.residual_add_k, h_in, h_branch, h, stream)?;
232
233        // 2026-09-25: 3. mHC expand: replicate h_in into hc_mult streams.
234        let hc_streams = ctx.buffers.hc_streams();
235        ops::hc_expand(
236            ctx.gpu,
237            self.hc_expand_k,
238            h_in,
239            hc_streams,
240            1,
241            h,
242            hc_mult,
243            stream,
244        )?;
245
246        // 2026-09-25: 4. Body decode: the middle mHC + MLA attention (writing
247        // the MTP KV cache) + MoE, on `hc_streams`.
248        let mut kv_cache = self.kv_cache.lock();
249        let bs = kv_cache.block_size();
250        let blocks_needed = (state.seq_len / bs) + 1;
251        while state.block_table.len() < blocks_needed {
252            state.block_table.push(kv_cache.alloc_block()?);
253        }
254
255        // 2026-09-25: The MTP attention metadata goes to `MTP_META_OFFSET`, away
256        // from the target's at 32768: pos(u32)@0, slot(i64)@8, seq_len(i32)@16,
257        // block_table(i32[])@256 (`pack_mtp_attn_meta`).
258        let meta_base = ctx.buffers.scratch().offset(MTP_META_OFFSET);
259        let max_blocks = state.block_table.len() as u32;
260        let block_idx = state.block_table[state.seq_len / bs];
261        let global_slot = (block_idx as i64) * (bs as i64) + ((state.seq_len % bs) as i64);
262        let actual_seq_len = (state.seq_len + 1) as i32;
263
264        // 2026-09-25: `pack_mtp_attn_meta` refuses a block table that does not
265        // fit the scratch after `MTP_META_OFFSET`.
266        let meta_buf = pack_mtp_attn_meta(
267            position as u32,
268            global_slot,
269            actual_seq_len,
270            &state.block_table,
271            ctx.buffers.scratch_bytes().saturating_sub(MTP_META_OFFSET),
272        )?;
273        ctx.gpu.copy_h2d_async(&meta_buf, meta_base, stream)?;
274
275        let mtp_meta = AttnMetadataDev {
276            positions: meta_base,
277            positions_h: meta_base,
278            positions_w: meta_base,
279            slot: meta_base.offset(8),
280            seq_len: meta_base.offset(16),
281            block_table: meta_base.offset(256),
282            max_blocks_per_seq: max_blocks,
283            num_seqs: 1,
284            seq_slot: metrale_gpu_runtime::gpu::DevicePtr(0),
285            moe_row_adapter: metrale_gpu_runtime::gpu::DevicePtr::NULL,
286        };
287
288        // 2026-09-25: The body's hash-routed MoE (if any) reads the decode token
289        // id from `token_ids[0]`, so this draft's input token goes there.
290        if let Some(tid_buf) = ctx.token_ids {
291            ctx.gpu
292                .copy_h2d_async(&token.to_le_bytes(), tid_buf, stream)?;
293        }
294
295        // 2026-09-25: A ForwardContext carrying the MTP metadata, with graph
296        // capture off: the metadata is built on the host for every call.
297        let mtp_ctx = ForwardContext {
298            buffers: ctx.buffers,
299            hc_row_offset: ctx.hc_row_offset,
300            gpu: ctx.gpu,
301            config: ctx.config,
302            dispatch: ctx.dispatch,
303            derived: ctx.derived,
304            levers: ctx.levers,
305            stats: ctx.stats,
306            attn_metadata: Some(mtp_meta),
307            profile: ctx.profile,
308            // 2026-09-25: No communicator: the MTP draft runs only on rank 0, so
309            // its MoE must not issue an EP all-reduce. The body is loaded with
310            // every expert local (`force_all_experts` in `load_v4_mtp_module`).
311            comm: None,
312            graph_capture: false,
313            decode_step: false,
314            gdn_exact_replay: false,
315            gdn_write_on_accept: false,
316            token_ids: ctx.token_ids,
317            host_token_ids: None,
318            routed_lora_layers: None,
319            midchunk_capture: None,
320            moe_lora_route: metrale_model_layers::layer::MoeLoraRoute::Skip,
321        };
322
323        // 2026-09-25: `decode_inner_hc` reads the streams from
324        // `ctx.buffers.hc_streams()` and collapses into its `hidden` argument,
325        // so `hidden` must not alias `hc_streams`; `hidden_states()` held
326        // `h_in`, which `hc_expand` has already consumed.
327        let body_scratch = ctx.buffers.hidden_states();
328        let mut disk_block_ids: Vec<u32> = Vec::new();
329        let mut disk_last_offloaded: Vec<u32> = vec![0u32; 1];
330        let residual = ctx.buffers.residual();
331        self.module.body.decode(
332            body_scratch,
333            residual,
334            state.body_state.as_mut(),
335            &mut kv_cache,
336            state.seq_len,
337            &mut state.block_table,
338            &mut disk_block_ids,
339            &mut disk_last_offloaded,
340            &mtp_ctx,
341            stream,
342        )?;
343        drop(kv_cache);
344
345        // 2026-09-25: 5. mHC head: collapse the hc_mult streams into h_out.
346        let h_out = ctx.buffers.hidden_states();
347        if let Some(ref head) = self.module.hc_head {
348            // 2026-09-25: Only the Sinkhorn `hc_head` launch is here; a low-rank
349            // head takes other arguments (`hc_head_site` dispatches on
350            // `lowrank`), so it is refused rather than launched as Sinkhorn.
351            anyhow::ensure!(
352                head.lowrank.is_none(),
353                "deepseek_v4_mtp: low-rank mHC head reached the Sinkhorn MTP \
354                 path; this module has no low-rank dispatch"
355            );
356            ops::hc_head(
357                ctx.gpu,
358                self.hc_head_k,
359                hc_streams,
360                head.hc_fn,
361                head.hc_scale,
362                head.hc_base,
363                h_out,
364                1,
365                h,
366                hc_mult,
367                eps,
368                ctx.config.hc_eps,
369                stream,
370            )?;
371        } else {
372            // 2026-09-25: No head weights (no mHC): take the first stream's row.
373            ctx.gpu
374                .copy_d2d_async(hc_streams, h_out, row_bytes, stream)?;
375        }
376
377        // 2026-09-25: 6. Final norm and the shared LM head -> logits.
378        let final_normed = ctx.buffers.norm_output();
379        ops::rms_norm(
380            ctx.gpu,
381            self.rms_norm_k,
382            h_out,
383            &self.module.norm,
384            final_normed,
385            1,
386            h,
387            eps,
388            stream,
389        )?;
390        let v = if self.mtp_vocab_size > 0 {
391            self.mtp_vocab_size.min(ctx.config.vocab_size as u32)
392        } else {
393            ctx.config.vocab_size as u32
394        };
395        let logits = ctx.buffers.logits();
396        ops::dense_gemv(
397            ctx.gpu,
398            self.dense_gemv_k,
399            final_normed,
400            &self.lm_head,
401            logits,
402            v,
403            h,
404            stream,
405        )?;
406
407        // 2026-09-25: 7. Argmax, grammar-masked when a bitmask is given.
408        let out_ptr = ctx.buffers.scratch();
409        let token_id = if let Some(bitmask) = grammar_bitmask {
410            argmax_grammar_masked(ctx.gpu, logits, v as usize, bitmask, position)?
411        } else {
412            ops::argmax_bf16(ctx.gpu, self.argmax_k, logits, out_ptr, v, stream)?;
413            let mut buf = [0u8; 4];
414            ctx.gpu.copy_d2h(out_ptr, &mut buf)?;
415            u32::from_le_bytes(buf)
416        };
417
418        state.seq_len += 1;
419        Ok(token_id)
420    }
421}
422
423/// 2026-09-25: Grammar-masked argmax over the bf16 logits on the CPU: copy the
424/// logits to the host, skip the tokens the bitmask rejects, take the argmax.
425/// Returns 0 (with a warning) when the bitmask allows no token.
426fn argmax_grammar_masked(
427    gpu: &dyn GpuBackend,
428    logits: DevicePtr,
429    vocab: usize,
430    bitmask: &[i32],
431    position: usize,
432) -> Result<u32> {
433    let mut bf16_buf = vec![0u8; vocab * 2];
434    gpu.copy_d2h(logits, &mut bf16_buf)?;
435
436    let mut best_tok = 0u32;
437    let mut best_val = f32::NEG_INFINITY;
438    let mut any_allowed = false;
439    for tok in 0..vocab {
440        let word = tok / 32;
441        let bit = tok % 32;
442        let allowed = word < bitmask.len() && (bitmask[word] & (1i32 << bit)) != 0;
443        if !allowed {
444            continue;
445        }
446        any_allowed = true;
447        // 2026-09-25: bf16 -> f32: bf16 is the upper 16 bits of an f32.
448        let hi = u16::from_le_bytes([bf16_buf[2 * tok], bf16_buf[2 * tok + 1]]);
449        let val = f32::from_bits((hi as u32) << 16);
450        if val > best_val {
451            best_val = val;
452            best_tok = tok as u32;
453        }
454    }
455    if !any_allowed {
456        tracing::warn!(
457            "V4 MTP grammar mask allowed zero tokens at pos {position}; \
458             returning 0 as pad-draft (will be rejected at verify)."
459        );
460        return Ok(0);
461    }
462    Ok(best_tok)
463}