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}