spark_model/layers/
deepseek_v4_mtp.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! DeepSeek-V4-Flash Multi-Token-Prediction (MTP) draft proposer.
4//!
5//! Implements [`DraftProposer`] over the `DeepseekV4MtpModule` loaded by
6//! `load_v4_mtp_module`. Unlike the
7//! Qwen-shaped [`crate::layers::MtpHead`] (a hand-rolled single attention +
8//! MoE block), the V4 MTP module's body is a full reused V4 layer
9//! (MLA + manifold-constrained hyper-connections (mHC) + 256-expert NVFP4
10//! MoE). The proposer therefore delegates the bulk of the forward to
11//! `body.decode()` and only wraps it with the MTP-specific pieces.
12//!
13//! Forward (`propose()`, K = 1 since `num_nextn_predict_layers == 1`):
14//!
15//! ```text
16//!   embed   = embed_tokens[last_token]                       // [hidden] BF16
17//!   h_in    = e_proj · rms_norm(embed,  enorm)
18//!           + h_proj · rms_norm(hidden, hnorm)               // combiner
19//!   hc_expand(h_in → hc_streams)                             // is_first mHC
20//!   body.decode(hc_streams, …, mtp_kv_cache, state.seq_len)  // MIDDLE mHC + MLA + MoE
21//!   hc_head(hc_streams → h_out)                              // is_last mHC
22//!   logits  = lm_head(rms_norm(h_out, norm))
23//!   draft   = argmax(logits)                                 // grammar-masked when Some
24//! ```
25//!
26//! The body was assembled with `layer_idx = num_hidden_layers`, so its
27//! `decode_inner_hc` sees `is_first_layer == false` AND `is_last_layer ==
28//! false`: it runs the middle mHC mixing (hc_pre → attn → hc_post → hc_pre →
29//! ffn → hc_post) reading/writing `hc_streams`, but does NOT call `hc_expand`
30//! or `hc_head`. The proposer supplies both ends.
31//!
32//! ## Separate KV cache + distinct metadata offset
33//!
34//! The MTP attention writes into its OWN single-layer MLA-shaped
35//! [`PagedKvCache`] (num_kv_heads = 1, head_dim = kv_lora_rank +
36//! qk_rope_head_dim), never the target's. The V4-Flash decode attention
37//! (`attention_forward_v4`) reads positions / slot / seq_len / block_table
38//! from `ctx.attn_metadata`, so the proposer uploads MTP-specific metadata to
39//! `scratch().offset(MTP_META_OFFSET)` — distinct from the target metadata at
40//! `32768` — and threads it through a derived [`ForwardContext`].
41
42use parking_lot::Mutex;
43use std::any::Any;
44
45use anyhow::Result;
46use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
47use spark_runtime::kv_cache::{KvCacheConfig, KvCacheDtype, PagedKvCache};
48
49use crate::layer::{AttnMetadataDev, ForwardContext, LayerState};
50use crate::layers::mtp_meta::{MTP_META_OFFSET, pack_mtp_attn_meta};
51use crate::layers::ops;
52use crate::speculative::{DraftProposer, ProposerState};
53use crate::weight_loader::deepseek_v4::DeepseekV4MtpModule;
54use crate::weight_map::DenseWeight;
55
56/// Per-sequence state for the DeepSeek-V4 MTP proposer.
57pub struct DeepseekV4MtpProposerState {
58    /// Block table for the MTP module's OWN KV cache.
59    pub block_table: Vec<u32>,
60    /// Current sequence length in the MTP KV cache.
61    pub seq_len: usize,
62    /// Drafts produced by the last `propose()` (for `after_verify` trimming).
63    pub last_num_drafted: usize,
64    /// Per-layer state for the reused V4 body. MLA attention layers use
65    /// `EmptyLayerState`, but we allocate it via `body.alloc_state` so any
66    /// future stateful body type is handled correctly (no hard-coded assumption).
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/// DeepSeek-V4 MTP draft proposer.
80pub struct DeepseekV4MtpHead {
81    /// The loaded MTP module: reused V4 body + combiner + final norm + hc_head.
82    module: DeepseekV4MtpModule,
83    /// Shared token embedding table (BF16), from the target model.
84    embed_tokens: DenseWeight,
85    /// Shared LM head (BF16 — DeepSeek-V4-Flash keeps the head in BF16), from the
86    /// target model. Every draft is re-verified by the target's head, so the
87    /// draft head only affects acceptance, never an accepted token.
88    lm_head: DenseWeight,
89    /// Reduced vocab size for the draft LM-head GEMV (0 = full vocab).
90    mtp_vocab_size: u32,
91    /// Single-layer MLA-shaped KV cache for the MTP attention.
92    kv_cache: Mutex<PagedKvCache>,
93
94    // Kernel handles.
95    rms_norm_k: KernelHandle,
96    dense_gemv_k: KernelHandle,
97    residual_add_k: KernelHandle,
98    hc_expand_k: KernelHandle,
99    hc_head_k: KernelHandle,
100    argmax_k: KernelHandle,
101}
102
103impl DeepseekV4MtpHead {
104    /// Build the proposer from a loaded `DeepseekV4MtpModule` and the shared
105    /// embedding + NVFP4 LM head.
106    pub fn new(
107        module: DeepseekV4MtpModule,
108        embed_tokens: DenseWeight,
109        lm_head: DenseWeight,
110        config: &avarok_core::config::ModelConfig,
111        gpu: &dyn GpuBackend,
112        mtp_vocab_size: u32,
113        max_seq_len: usize,
114    ) -> Result<Self> {
115        // MTP KV cache: single MLA-absorbed attention layer. Matches the
116        // target's MLA cache shape (num_kv_heads = 1, head_dim = kv_lora_rank
117        // + qk_rope_head_dim) so `write_kv_cache` / `run_paged_decode` in the
118        // reused V4 body land at the correct strides. BF16 (the MTP cache is
119        // one tiny layer — BF16 cost is negligible and avoids the FP8 unit-
120        // scale collapse seen on the Qwen path).
121        let mla_cache_dim = config.kv_lora_rank + config.qk_rope_head_dim;
122        // The MTP body is a single layer, but it was built with
123        // `attn_layer_idx = num_hidden_layers` (so its mHC/hash/compressor logic
124        // takes the "interior, no-compressor" path), and its decode indexes the
125        // KV cache pool at THAT index. So the cache pool must have
126        // `num_hidden_layers + 1` layer slots even though only the last is used.
127        // The extra slots are tiny (one MLA layer each at this seq len, ~2 MB).
128        let num_layers = config.num_hidden_layers + 1;
129        let kv_config = KvCacheConfig {
130            block_size: 16,
131            num_kv_heads: 1,
132            head_dim: mla_cache_dim,
133            num_layers,
134            dtype: KvCacheDtype::Bf16,
135            layer_dtypes: vec![],
136            layer_dims: vec![],
137            cache_blocks_per_seq: None,
138        };
139        let mtp_num_blocks = max_seq_len / kv_config.block_size + 1;
140        let kv_cache = PagedKvCache::new(kv_config, mtp_num_blocks, gpu)?;
141
142        Ok(Self {
143            module,
144            embed_tokens,
145            lm_head,
146            mtp_vocab_size,
147            kv_cache: Mutex::new(kv_cache),
148            // V4 ships HF-vanilla norm weights (enorm/hnorm/norm are loaded
149            // exactly) — the offset-from-1 kernel would apply `1 + w`.
150            rms_norm_k: gpu.kernel("rms_norm_vanilla", "rms_norm_vanilla")?,
151            dense_gemv_k: gpu.kernel("gemv", "dense_gemv_bf16")?,
152            residual_add_k: gpu.kernel("residual_add", "bf16_residual_add")?,
153            hc_expand_k: gpu.kernel("hyper_connection", "hc_expand")?,
154            hc_head_k: gpu.kernel("hyper_connection", "hc_head")?,
155            argmax_k: gpu.kernel("argmax", "argmax_bf16")?,
156        })
157    }
158
159    /// Allocate per-sequence state. Mirrors the body's own `alloc_state` for
160    /// the body sub-state.
161    pub fn alloc_state_inner(&self, gpu: &dyn GpuBackend) -> Result<DeepseekV4MtpProposerState> {
162        Ok(DeepseekV4MtpProposerState {
163            block_table: Vec::new(),
164            seq_len: 0,
165            last_num_drafted: 0,
166            body_state: self.module.body.alloc_state(gpu)?,
167        })
168    }
169
170    /// One MTP draft step. Returns the drafted token id.
171    #[allow(clippy::too_many_arguments)]
172    fn forward_one(
173        &self,
174        token: u32,
175        target_hidden: DevicePtr,
176        position: usize,
177        state: &mut DeepseekV4MtpProposerState,
178        ctx: &ForwardContext,
179        stream: u64,
180        grammar_bitmask: Option<&[i32]>,
181    ) -> Result<u32> {
182        let h = ctx.config.hidden_size as u32;
183        let eps = ctx.config.rms_norm_eps as f32;
184        let hc_mult = ctx.config.hc_mult as u32;
185        let row_bytes = h as usize * 2;
186
187        // ── 1. Embed last token (D2D gather from the shared table) ──
188        let embed_out = ctx.buffers.ssm_qkvz();
189        let src = self.embed_tokens.weight.offset(token as usize * row_bytes);
190        ctx.gpu.copy_d2d_async(src, embed_out, row_bytes, stream)?;
191
192        // ── 2. Combiner: h_in = e_proj·rms_norm(embed,enorm)
193        //                       + h_proj·rms_norm(target_hidden,hnorm) ──
194        let normed_embed = ctx.buffers.ssm_deinterleaved();
195        ops::rms_norm(
196            ctx.gpu,
197            self.rms_norm_k,
198            embed_out,
199            &self.module.enorm,
200            normed_embed,
201            1,
202            h,
203            eps,
204            stream,
205        )?;
206        let normed_hidden = ctx.buffers.ssm_gates();
207        ops::rms_norm(
208            ctx.gpu,
209            self.rms_norm_k,
210            target_hidden,
211            &self.module.hnorm,
212            normed_hidden,
213            1,
214            h,
215            eps,
216            stream,
217        )?;
218
219        // e_proj / h_proj are square [hidden, hidden] dense BF16. Compute the
220        // embedding branch into `h_in`, the hidden branch into a temp, then
221        // accumulate (`bf16_residual_add` does h_in += temp in place).
222        let h_in = ctx.buffers.hidden_states();
223        let h_branch = ctx.buffers.norm_output();
224        ops::dense_gemv(
225            ctx.gpu,
226            self.dense_gemv_k,
227            normed_embed,
228            &self.module.e_proj,
229            h_in,
230            h,
231            h,
232            stream,
233        )?;
234        ops::dense_gemv(
235            ctx.gpu,
236            self.dense_gemv_k,
237            normed_hidden,
238            &self.module.h_proj,
239            h_branch,
240            h,
241            h,
242            stream,
243        )?;
244        ops::residual_add(ctx.gpu, self.residual_add_k, h_in, h_branch, h, stream)?;
245
246        // ── 3. mHC expand: replicate h_in into hc_mult streams (is_first) ──
247        let hc_streams = ctx.buffers.hc_streams();
248        ops::hc_expand(
249            ctx.gpu,
250            self.hc_expand_k,
251            h_in,
252            hc_streams,
253            1,
254            h,
255            hc_mult,
256            stream,
257        )?;
258
259        // ── 4. Body decode: MIDDLE mHC + MLA attention (writes MTP KV cache)
260        //       + MoE. Reads/writes `hc_streams` (hidden is a single-stream
261        //       scratch). The body NEVER calls hc_expand/hc_head (layer_idx =
262        //       num_hidden_layers ⇒ is_first == is_last == false). ──
263        let mut kv_cache = self.kv_cache.lock();
264        let bs = kv_cache.block_size();
265        let blocks_needed = (state.seq_len / bs) + 1;
266        while state.block_table.len() < blocks_needed {
267            state.block_table.push(kv_cache.alloc_block()?);
268        }
269
270        // Upload MTP-specific attention metadata at the distinct scratch offset
271        // so it does not clobber the target metadata at 32768. Layout mirrors
272        // the target's `AttnMetadataDev`: pos(u32)@0, slot(i64)@8,
273        // seq_len(i32)@16, block_table(i32[])@256.
274        let meta_base = ctx.buffers.scratch().offset(MTP_META_OFFSET);
275        let max_blocks = state.block_table.len() as u32;
276        let block_idx = state.block_table[state.seq_len / bs];
277        let global_slot = (block_idx as i64) * (bs as i64) + ((state.seq_len % bs) as i64);
278        let actual_seq_len = (state.seq_len + 1) as i32;
279
280        // Shared with `MtpHead::forward`, which writes this exact layout to this
281        // exact scratch offset — hence the shared packer, which is also where
282        // the destination bound now lives. This site previously had none: the
283        // block table grows with the context, so a long enough sequence wrote
284        // past the scratch arena.
285        let meta_buf = pack_mtp_attn_meta(
286            position as u32,
287            global_slot,
288            actual_seq_len,
289            &state.block_table,
290            ctx.buffers.scratch_bytes().saturating_sub(MTP_META_OFFSET),
291        )?;
292        ctx.gpu.copy_h2d_async(&meta_buf, meta_base, stream)?;
293
294        let mtp_meta = AttnMetadataDev {
295            positions: meta_base,
296            positions_h: meta_base,
297            positions_w: meta_base,
298            slot: meta_base.offset(8),
299            seq_len: meta_base.offset(16),
300            block_table: meta_base.offset(256),
301            max_blocks_per_seq: max_blocks,
302            num_seqs: 1,
303            seq_slot: spark_runtime::gpu::DevicePtr(0),
304            moe_row_adapter: spark_runtime::gpu::DevicePtr::NULL,
305        };
306
307        // The body's hash-MoE (if any) reads the decode token id from
308        // `token_ids[0]`; upload this draft's input token there. The main
309        // decode loop uploaded the target token earlier in the step, so we
310        // must overwrite it for the MTP forward (and the main loop re-uploads
311        // before the next target step / graph replay).
312        if let Some(tid_buf) = ctx.token_ids {
313            ctx.gpu
314                .copy_h2d_async(&token.to_le_bytes(), tid_buf, stream)?;
315        }
316
317        // Derive a ForwardContext carrying the MTP metadata. CUDA-graph capture
318        // is forced off for the MTP forward (its block-table / metadata are
319        // host-built per call and the H2D uploads above are illegal under
320        // capture).
321        let mtp_ctx = ForwardContext {
322            buffers: ctx.buffers,
323            hc_row_offset: ctx.hc_row_offset,
324            gpu: ctx.gpu,
325            config: ctx.config,
326            dispatch: ctx.dispatch,
327            derived: ctx.derived,
328            levers: ctx.levers,
329            stats: ctx.stats,
330            attn_metadata: Some(mtp_meta),
331            profile: ctx.profile,
332            // comm = None: the MTP draft runs ONLY on rank 0, so its MoE must NOT
333            // issue an EP all-reduce (rank 1 never participates → the collective
334            // hangs ~35s then corrupts CUDA). The MTP body is loaded with ALL
335            // experts local (force_all_experts), so the no-EP MoE is correct.
336            comm: None,
337            graph_capture: false,
338            decode_step: false,
339            gdn_exact_replay: false,
340            gdn_write_on_accept: false,
341            token_ids: ctx.token_ids,
342            host_token_ids: None,
343            routed_lora_layers: None, // #30: MTP draft body; no prefill LoRA route.
344            midchunk_capture: None,
345            moe_lora_route: crate::layer::MoeLoraRoute::Skip, // MTP draft body: no lora installed here; Skip = no fold (safe/inert)
346        };
347
348        // `decode_inner_hc` reads the persistent multi-stream state from
349        // `ctx.buffers.hc_streams()` directly (already populated by `hc_expand`
350        // above) and uses the `hidden` ARG as a single-stream scratch (hc_pre
351        // collapses into it). So `hidden` must be a SEPARATE buffer, NOT
352        // `hc_streams` — aliasing them corrupts the persistent state. Reuse
353        // `hidden_states()` (= the now-consumed `h_in` scratch).
354        let body_scratch = ctx.buffers.hidden_states();
355        let mut disk_block_ids: Vec<u32> = Vec::new();
356        let mut disk_last_offloaded: Vec<u32> = vec![0u32; 1];
357        let residual = ctx.buffers.residual();
358        self.module.body.decode(
359            body_scratch,
360            residual,
361            state.body_state.as_mut(),
362            &mut kv_cache,
363            state.seq_len,
364            &mut state.block_table,
365            &mut disk_block_ids,
366            &mut disk_last_offloaded,
367            &mtp_ctx,
368            stream,
369        )?;
370        drop(kv_cache);
371
372        // ── 5. mHC head: collapse hc_mult streams → single h_out (is_last) ──
373        let h_out = ctx.buffers.hidden_states();
374        if let Some(ref head) = self.module.hc_head {
375            // This path stays on DeepSeek's Sinkhorn launch. `hc_head`'s
376            // low-rank twin takes a different argument list behind the same
377            // kernel name, so a low-rank head arriving here would be
378            // dispatched as Sinkhorn and read `hc_fn`/`hc_scale`/`hc_base`,
379            // which are NULL on that variant. Qwen's MTP is dropped for v1
380            // (Atlas #753 item I); if it is ever revived this becomes a
381            // dispatch, not an assert.
382            anyhow::ensure!(
383                head.lowrank.is_none(),
384                "deepseek_v4_mtp: low-rank mHC head reached the Sinkhorn MTP \
385                 path; this module has no low-rank dispatch"
386            );
387            ops::hc_head(
388                ctx.gpu,
389                self.hc_head_k,
390                hc_streams,
391                head.hc_fn,
392                head.hc_scale,
393                head.hc_base,
394                h_out,
395                1,
396                h,
397                hc_mult,
398                eps,
399                ctx.config.hc_eps,
400                stream,
401            )?;
402        } else {
403            // No mHC (hc_mult == 0): hc_expand was a no-op replicate of 1 ⇒
404            // the body left the result in hc_streams' single stream.
405            ctx.gpu
406                .copy_d2d_async(hc_streams, h_out, row_bytes, stream)?;
407        }
408
409        // ── 6. Final norm + shared LM head → logits ──
410        let final_normed = ctx.buffers.norm_output();
411        ops::rms_norm(
412            ctx.gpu,
413            self.rms_norm_k,
414            h_out,
415            &self.module.norm,
416            final_normed,
417            1,
418            h,
419            eps,
420            stream,
421        )?;
422        let v = if self.mtp_vocab_size > 0 {
423            self.mtp_vocab_size.min(ctx.config.vocab_size as u32)
424        } else {
425            ctx.config.vocab_size as u32
426        };
427        let logits = ctx.buffers.logits();
428        ops::dense_gemv(
429            ctx.gpu,
430            self.dense_gemv_k,
431            final_normed,
432            &self.lm_head,
433            logits,
434            v,
435            h,
436            stream,
437        )?;
438
439        // ── 7. Argmax (grammar-masked when a bitmask is supplied) ──
440        let out_ptr = ctx.buffers.scratch();
441        let token_id = if let Some(bitmask) = grammar_bitmask {
442            argmax_grammar_masked(ctx.gpu, logits, v as usize, bitmask, position)?
443        } else {
444            ops::argmax_bf16(ctx.gpu, self.argmax_k, logits, out_ptr, v, stream)?;
445            let mut buf = [0u8; 4];
446            ctx.gpu.copy_d2h(out_ptr, &mut buf)?;
447            u32::from_le_bytes(buf)
448        };
449
450        state.seq_len += 1;
451        Ok(token_id)
452    }
453}
454
455/// CPU grammar-masked argmax over the BF16 logits (mirrors `MtpHead`): D2H the
456/// logit vector, mask off (→ -inf) tokens the grammar rejects, argmax on CPU.
457/// Returns `0` (pad) when the matcher's allowed set is empty so the draft is
458/// rejected at verify rather than emitting a possibly-special token.
459fn argmax_grammar_masked(
460    gpu: &dyn GpuBackend,
461    logits: DevicePtr,
462    vocab: usize,
463    bitmask: &[i32],
464    position: usize,
465) -> Result<u32> {
466    let mut bf16_buf = vec![0u8; vocab * 2];
467    gpu.copy_d2h(logits, &mut bf16_buf)?;
468
469    let mut best_tok = 0u32;
470    let mut best_val = f32::NEG_INFINITY;
471    let mut any_allowed = false;
472    for tok in 0..vocab {
473        let word = tok / 32;
474        let bit = tok % 32;
475        let allowed = word < bitmask.len() && (bitmask[word] & (1i32 << bit)) != 0;
476        if !allowed {
477            continue;
478        }
479        any_allowed = true;
480        // BF16 → f32: BF16 is the upper 16 bits of an f32.
481        let hi = u16::from_le_bytes([bf16_buf[2 * tok], bf16_buf[2 * tok + 1]]);
482        let val = f32::from_bits((hi as u32) << 16);
483        if val > best_val {
484            best_val = val;
485            best_tok = tok as u32;
486        }
487    }
488    if !any_allowed {
489        tracing::warn!(
490            "V4 MTP grammar mask allowed zero tokens at pos {position}; \
491             returning 0 as pad-draft (will be rejected at verify)."
492        );
493        return Ok(0);
494    }
495    Ok(best_tok)
496}
497
498impl DraftProposer for DeepseekV4MtpHead {
499    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>> {
500        Ok(Box::new(self.alloc_state_inner(gpu)?))
501    }
502
503    fn propose(
504        &self,
505        last_token: u32,
506        target_hidden: DevicePtr,
507        position: usize,
508        num_drafts: usize,
509        state: &mut dyn ProposerState,
510        ctx: &ForwardContext,
511        stream: u64,
512        _draft_embed_target: Option<DevicePtr>,
513        grammar_bitmask: Option<&[i32]>,
514        _target_hidden_stack: Option<DevicePtr>,
515    ) -> Result<Vec<u32>> {
516        let v4_state = state
517            .as_any_mut()
518            .downcast_mut::<DeepseekV4MtpProposerState>()
519            .ok_or_else(|| anyhow::anyhow!("Invalid V4 MTP proposer state"))?;
520
521        let mut drafts = Vec::with_capacity(num_drafts);
522        let mut current_token = last_token;
523        let mut current_hidden = target_hidden;
524        for i in 0..num_drafts {
525            if grammar_bitmask.is_some() && i > 0 {
526                tracing::warn!(
527                    "V4 MTP grammar-masked drafting with num_drafts>1 (i={i}); \
528                     mask held fixed across draft positions — acceptance may drop."
529                );
530            }
531            let draft = self.forward_one(
532                current_token,
533                current_hidden,
534                position + i,
535                v4_state,
536                ctx,
537                stream,
538                grammar_bitmask,
539            )?;
540            tracing::debug!(
541                "V4 MTP propose[{i}]: token={current_token} pos={} mtp_seq_len={} → draft={draft}",
542                position + i,
543                v4_state.seq_len,
544            );
545            drafts.push(draft);
546            current_token = draft;
547            // Subsequent drafts feed on the MTP head's own collapsed hidden.
548            current_hidden = ctx.buffers.hidden_states();
549        }
550        v4_state.last_num_drafted = drafts.len();
551        Ok(drafts)
552    }
553
554    fn after_verify(
555        &self,
556        num_accepted: usize,
557        state: &mut dyn ProposerState,
558        _stream: u64,
559    ) -> Result<()> {
560        let v4_state = state
561            .as_any_mut()
562            .downcast_mut::<DeepseekV4MtpProposerState>()
563            .ok_or_else(|| anyhow::anyhow!("Invalid V4 MTP proposer state"))?;
564        // Trim `drafted - accepted` rejected entries from the MTP KV cache by
565        // rolling back `seq_len` (the slots are overwritten on the next
566        // propose). Mirrors `MtpHead::after_verify`.
567        let num_drafted = v4_state.last_num_drafted.max(1);
568        let num_to_trim = num_drafted.saturating_sub(num_accepted);
569        let old_sl = v4_state.seq_len;
570        if num_to_trim > 0 {
571            v4_state.seq_len = v4_state.seq_len.saturating_sub(num_to_trim);
572        }
573        tracing::debug!(
574            "V4 MTP after_verify: accepted={num_accepted} drafted={num_drafted} \
575             trim={num_to_trim} mtp_seq_len: {old_sl} → {}",
576            v4_state.seq_len,
577        );
578        Ok(())
579    }
580
581    fn free_state(&self, _gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
582        let v4_state = state
583            .as_any_mut()
584            .downcast_mut::<DeepseekV4MtpProposerState>()
585            .ok_or_else(|| anyhow::anyhow!("Invalid V4 MTP proposer state"))?;
586        if !v4_state.block_table.is_empty() {
587            self.kv_cache.lock().free_blocks(&v4_state.block_table);
588            v4_state.block_table.clear();
589        }
590        v4_state.seq_len = 0;
591        Ok(())
592    }
593}