spark_model/layers/
mtp_multi.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Multi-module MTP proposer (MiniMax M2, DeepSeek-V3 style).
4//!
5//! Differs from the single-module `MtpHead` in one way only: each draft
6//! slot dispatches to a *different* transformer module with its own
7//! weights and its own KV cache. Draft `i` is produced by
8//! `modules[i].forward_one(previous_draft_token, previous_module_hidden)`.
9//!
10//! Module count matches `config.num_mtp_modules` (3 for MiniMax M2.7).
11//! When the verify loop requests fewer drafts than modules
12//! (e.g. `--num-drafts 1` for non-spec smoke), only the first K modules
13//! run — trailing modules stay idle but their state remains allocated.
14//!
15//! Weight-level validation is deferred: the public tiny-random variant
16//! ships no MTP module weights, so unit tests exercise the dispatcher
17//! plumbing from randomly-initialized `MtpHead` instances and defer
18//! end-to-end acceptance-rate measurement to a session with the full
19//! 229B checkpoint staged. See `docs/MINIMAX-M5-DESIGN.md` §"Open
20//! questions".
21
22use std::any::Any;
23
24use anyhow::Result;
25use spark_runtime::gpu::{DevicePtr, GpuBackend};
26
27use crate::layer::ForwardContext;
28use crate::layers::mtp_head::{MtpHead, MtpProposerState};
29use crate::speculative::{DraftProposer, ProposerState};
30
31/// Per-sequence state for `MultiModuleMtpHead`.
32///
33/// One inner `MtpProposerState` per module — each tracks its own
34/// block table and `seq_len`, since the modules do not share KV cache.
35pub struct MultiModuleMtpState {
36    /// `per_module[i]` belongs to `MultiModuleMtpHead::modules[i]`.
37    /// Length invariant: matches the parent head's `modules.len()`.
38    pub per_module: Vec<MtpProposerState>,
39    /// Number of drafts produced by the last `propose()` call.
40    /// `after_verify()` trims that many entries from KV cache.
41    pub last_num_drafted: usize,
42}
43
44impl ProposerState for MultiModuleMtpState {
45    fn as_any(&self) -> &dyn Any {
46        self
47    }
48    fn as_any_mut(&mut self) -> &mut dyn Any {
49        self
50    }
51}
52
53/// N independent MTP modules, one per draft slot.
54pub struct MultiModuleMtpHead {
55    /// Invariant: non-empty. Length equals `config.num_mtp_modules`.
56    modules: Vec<MtpHead>,
57}
58
59impl std::fmt::Debug for MultiModuleMtpHead {
60    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
61        f.debug_struct("MultiModuleMtpHead")
62            .field("modules", &self.modules.len())
63            .finish()
64    }
65}
66
67impl MultiModuleMtpHead {
68    /// Assemble a multi-module proposer from per-module heads.
69    ///
70    /// Callers construct each `MtpHead` via `MtpHead::new` with the
71    /// MTP weights for that module's prefix (e.g. `model.layers.62..64`
72    /// for MiniMax M2 with `num_hidden_layers=62, num_mtp_modules=3`).
73    pub fn new(modules: Vec<MtpHead>) -> Result<Self> {
74        anyhow::ensure!(
75            !modules.is_empty(),
76            "MultiModuleMtpHead requires at least one module (got 0); \
77             caller should not construct this type for single-module MTP"
78        );
79        Ok(Self { modules })
80    }
81
82    /// Number of MTP modules available (caps `num_drafts` in propose).
83    pub fn num_modules(&self) -> usize {
84        self.modules.len()
85    }
86}
87
88impl DraftProposer for MultiModuleMtpHead {
89    fn alloc_state(&self, _gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>> {
90        let per_module = (0..self.modules.len())
91            .map(|_| MtpProposerState {
92                block_table: Vec::new(),
93                seq_len: 0,
94                last_num_drafted: 0,
95                last_pair_key: None,
96            })
97            .collect();
98        Ok(Box::new(MultiModuleMtpState {
99            per_module,
100            last_num_drafted: 0,
101        }))
102    }
103
104    fn propose(
105        &self,
106        last_token: u32,
107        target_hidden: DevicePtr,
108        position: usize,
109        num_drafts: usize,
110        state: &mut dyn ProposerState,
111        ctx: &ForwardContext,
112        stream: u64,
113        draft_embed_target: Option<DevicePtr>,
114        grammar_bitmask: Option<&[i32]>,
115        _target_hidden_stack: Option<DevicePtr>,
116    ) -> Result<Vec<u32>> {
117        let mm_state = state
118            .as_any_mut()
119            .downcast_mut::<MultiModuleMtpState>()
120            .ok_or_else(|| anyhow::anyhow!("Invalid MultiModuleMtp state"))?;
121
122        // Cap at module count — caller asking for more drafts than we
123        // have modules is a config mismatch, clamp quietly (matches
124        // single-module MtpHead behavior when num_drafts > 1).
125        let k = num_drafts.min(self.modules.len());
126
127        let mut drafts = Vec::with_capacity(k);
128        let mut current_token = last_token;
129        let mut current_hidden = target_hidden;
130
131        for i in 0..k {
132            // Only the last draft's embedding is GPU-pre-staged — the
133            // verify loop uses it as the next step's input. Earlier
134            // drafts feed into the next MTP module's `target_hidden`
135            // in-process, no GPU embed needed.
136            let embed_target = if i == k - 1 { draft_embed_target } else { None };
137
138            // Grammar mask: the same single-position mask is passed to every
139            // module (MultiModule with grammar is untested — for num_drafts
140            // > 1 + grammar, MtpHead's caller-side warning fires).
141            let mask_for_draft = grammar_bitmask;
142
143            let draft = self.modules[i].forward_one(
144                current_token,
145                current_hidden,
146                position + i,
147                &mut mm_state.per_module[i],
148                ctx,
149                stream,
150                embed_target,
151                mask_for_draft,
152            )?;
153
154            tracing::debug!(
155                "MultiMTP propose[{i}/{k}]: token={current_token} pos={} module_seq_len={} → draft={draft}",
156                position + i,
157                mm_state.per_module[i].seq_len,
158            );
159
160            drafts.push(draft);
161            current_token = draft;
162            // Chain: module {i+1} consumes module i's hidden state.
163            // MtpHead writes its own hidden output into
164            // `ctx.buffers.hidden_states()` before the LM head GEMM.
165            current_hidden = ctx.buffers.hidden_states();
166        }
167
168        mm_state.last_num_drafted = drafts.len();
169        Ok(drafts)
170    }
171
172    fn read_deferred_draft_token(&self, gpu: &dyn GpuBackend) -> Result<u32> {
173        // The last draft came from modules[k-1] where k ≤ modules.len().
174        // Its deferred-token buffer is the one the next verify reads.
175        // Use the last module unconditionally — if a smaller k was
176        // requested, the stale value from a prior step in modules[k..]
177        // is never consulted.
178        self.modules
179            .last()
180            .expect("MultiModuleMtpHead::new enforces non-empty")
181            .read_deferred_draft_token(gpu)
182    }
183
184    fn after_verify(
185        &self,
186        num_accepted: usize,
187        state: &mut dyn ProposerState,
188        stream: u64,
189    ) -> Result<()> {
190        let mm_state = state
191            .as_any_mut()
192            .downcast_mut::<MultiModuleMtpState>()
193            .ok_or_else(|| anyhow::anyhow!("Invalid MultiModuleMtp state"))?;
194
195        // Each module sees exactly one token per propose() iteration.
196        // If the verifier accepted `num_accepted` of `k` drafts, each
197        // module trims `(1 if this slot was rejected else 0)`.
198        // Equivalent: modules[0..num_accepted] keep their last entry,
199        // modules[num_accepted..k] trim 1.
200        let k = mm_state.last_num_drafted;
201        for (i, per) in mm_state.per_module.iter_mut().take(k).enumerate() {
202            per.last_num_drafted = 1;
203            let trim = if i < num_accepted { 0 } else { 1 };
204            if trim > 0 {
205                per.seq_len = per.seq_len.saturating_sub(trim);
206            }
207        }
208        // Delegate to module 0 for any cross-module bookkeeping (stream
209        // is passed in case a future impl wants to enqueue GPU work).
210        let _ = stream;
211        tracing::debug!(
212            "MultiMTP after_verify: accepted={num_accepted} of {k}; per-module trim done"
213        );
214        Ok(())
215    }
216
217    fn free_state(&self, _gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
218        let mm_state = state
219            .as_any_mut()
220            .downcast_mut::<MultiModuleMtpState>()
221            .ok_or_else(|| anyhow::anyhow!("Invalid MultiModuleMtp state"))?;
222        // Wrap each module's MtpProposerState in a Box<dyn ProposerState>
223        // long enough for the single-module free_state to reclaim blocks.
224        for (i, per) in mm_state.per_module.iter_mut().enumerate() {
225            // free_state on MtpHead takes &mut dyn ProposerState, not the
226            // concrete type — re-use the single-module impl via a
227            // transient boxed pointer to `per`. But MtpProposerState has
228            // its own free path via the MtpHead's kv_cache. We can't
229            // borrow-split `per` through a trait object across modules,
230            // so inline the reclamation:
231            let head = &self.modules[i];
232            if !per.block_table.is_empty() {
233                head.kv_cache_lock().free_blocks(&per.block_table);
234                per.block_table.clear();
235            }
236            per.seq_len = 0;
237        }
238        Ok(())
239    }
240}
241
242#[cfg(test)]
243mod tests {
244    use super::*;
245
246    #[test]
247    fn test_empty_modules_rejected() {
248        let err = MultiModuleMtpHead::new(vec![]).unwrap_err();
249        assert!(
250            err.to_string().contains("at least one module"),
251            "unexpected error: {err}"
252        );
253    }
254}