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: &atlas_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            token_ids: ctx.token_ids,
341            host_token_ids: None,
342            routed_lora_layers: None, // #30: MTP draft body; no prefill LoRA route.
343            midchunk_capture: None,
344            moe_lora_route: crate::layer::MoeLoraRoute::Skip, // MTP draft body: no lora installed here; Skip = no fold (safe/inert)
345        };
346
347        // `decode_inner_hc` reads the persistent multi-stream state from
348        // `ctx.buffers.hc_streams()` directly (already populated by `hc_expand`
349        // above) and uses the `hidden` ARG as a single-stream scratch (hc_pre
350        // collapses into it). So `hidden` must be a SEPARATE buffer, NOT
351        // `hc_streams` — aliasing them corrupts the persistent state. Reuse
352        // `hidden_states()` (= the now-consumed `h_in` scratch).
353        let body_scratch = ctx.buffers.hidden_states();
354        let mut disk_block_ids: Vec<u32> = Vec::new();
355        let mut disk_last_offloaded: Vec<u32> = vec![0u32; 1];
356        let residual = ctx.buffers.residual();
357        self.module.body.decode(
358            body_scratch,
359            residual,
360            state.body_state.as_mut(),
361            &mut kv_cache,
362            state.seq_len,
363            &mut state.block_table,
364            &mut disk_block_ids,
365            &mut disk_last_offloaded,
366            &mtp_ctx,
367            stream,
368        )?;
369        drop(kv_cache);
370
371        // ── 5. mHC head: collapse hc_mult streams → single h_out (is_last) ──
372        let h_out = ctx.buffers.hidden_states();
373        if let Some(ref head) = self.module.hc_head {
374            // This path stays on DeepSeek's Sinkhorn launch. `hc_head`'s
375            // low-rank twin takes a different argument list behind the same
376            // kernel name, so a low-rank head arriving here would be
377            // dispatched as Sinkhorn and read `hc_fn`/`hc_scale`/`hc_base`,
378            // which are NULL on that variant. Qwen's MTP is dropped for v1
379            // (Avarok #753 item I); if it is ever revived this becomes a
380            // dispatch, not an assert.
381            anyhow::ensure!(
382                head.lowrank.is_none(),
383                "deepseek_v4_mtp: low-rank mHC head reached the Sinkhorn MTP \
384                 path; this module has no low-rank dispatch"
385            );
386            ops::hc_head(
387                ctx.gpu,
388                self.hc_head_k,
389                hc_streams,
390                head.hc_fn,
391                head.hc_scale,
392                head.hc_base,
393                h_out,
394                1,
395                h,
396                hc_mult,
397                eps,
398                ctx.config.hc_eps,
399                stream,
400            )?;
401        } else {
402            // No mHC (hc_mult == 0): hc_expand was a no-op replicate of 1 ⇒
403            // the body left the result in hc_streams' single stream.
404            ctx.gpu
405                .copy_d2d_async(hc_streams, h_out, row_bytes, stream)?;
406        }
407
408        // ── 6. Final norm + shared LM head → logits ──
409        let final_normed = ctx.buffers.norm_output();
410        ops::rms_norm(
411            ctx.gpu,
412            self.rms_norm_k,
413            h_out,
414            &self.module.norm,
415            final_normed,
416            1,
417            h,
418            eps,
419            stream,
420        )?;
421        let v = if self.mtp_vocab_size > 0 {
422            self.mtp_vocab_size.min(ctx.config.vocab_size as u32)
423        } else {
424            ctx.config.vocab_size as u32
425        };
426        let logits = ctx.buffers.logits();
427        ops::dense_gemv(
428            ctx.gpu,
429            self.dense_gemv_k,
430            final_normed,
431            &self.lm_head,
432            logits,
433            v,
434            h,
435            stream,
436        )?;
437
438        // ── 7. Argmax (grammar-masked when a bitmask is supplied) ──
439        let out_ptr = ctx.buffers.scratch();
440        let token_id = if let Some(bitmask) = grammar_bitmask {
441            argmax_grammar_masked(ctx.gpu, logits, v as usize, bitmask, position)?
442        } else {
443            ops::argmax_bf16(ctx.gpu, self.argmax_k, logits, out_ptr, v, stream)?;
444            let mut buf = [0u8; 4];
445            ctx.gpu.copy_d2h(out_ptr, &mut buf)?;
446            u32::from_le_bytes(buf)
447        };
448
449        state.seq_len += 1;
450        Ok(token_id)
451    }
452}
453
454/// CPU grammar-masked argmax over the BF16 logits (mirrors `MtpHead`): D2H the
455/// logit vector, mask off (→ -inf) tokens the grammar rejects, argmax on CPU.
456/// Returns `0` (pad) when the matcher's allowed set is empty so the draft is
457/// rejected at verify rather than emitting a possibly-special token.
458fn argmax_grammar_masked(
459    gpu: &dyn GpuBackend,
460    logits: DevicePtr,
461    vocab: usize,
462    bitmask: &[i32],
463    position: usize,
464) -> Result<u32> {
465    let mut bf16_buf = vec![0u8; vocab * 2];
466    gpu.copy_d2h(logits, &mut bf16_buf)?;
467
468    let mut best_tok = 0u32;
469    let mut best_val = f32::NEG_INFINITY;
470    let mut any_allowed = false;
471    for tok in 0..vocab {
472        let word = tok / 32;
473        let bit = tok % 32;
474        let allowed = word < bitmask.len() && (bitmask[word] & (1i32 << bit)) != 0;
475        if !allowed {
476            continue;
477        }
478        any_allowed = true;
479        // BF16 → f32: BF16 is the upper 16 bits of an f32.
480        let hi = u16::from_le_bytes([bf16_buf[2 * tok], bf16_buf[2 * tok + 1]]);
481        let val = f32::from_bits((hi as u32) << 16);
482        if val > best_val {
483            best_val = val;
484            best_tok = tok as u32;
485        }
486    }
487    if !any_allowed {
488        tracing::warn!(
489            "V4 MTP grammar mask allowed zero tokens at pos {position}; \
490             returning 0 as pad-draft (will be rejected at verify)."
491        );
492        return Ok(0);
493    }
494    Ok(best_tok)
495}
496
497impl DraftProposer for DeepseekV4MtpHead {
498    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>> {
499        Ok(Box::new(self.alloc_state_inner(gpu)?))
500    }
501
502    fn propose(
503        &self,
504        last_token: u32,
505        target_hidden: DevicePtr,
506        position: usize,
507        num_drafts: usize,
508        state: &mut dyn ProposerState,
509        ctx: &ForwardContext,
510        stream: u64,
511        _draft_embed_target: Option<DevicePtr>,
512        grammar_bitmask: Option<&[i32]>,
513        _target_hidden_stack: Option<DevicePtr>,
514    ) -> Result<Vec<u32>> {
515        let v4_state = state
516            .as_any_mut()
517            .downcast_mut::<DeepseekV4MtpProposerState>()
518            .ok_or_else(|| anyhow::anyhow!("Invalid V4 MTP proposer state"))?;
519
520        let mut drafts = Vec::with_capacity(num_drafts);
521        let mut current_token = last_token;
522        let mut current_hidden = target_hidden;
523        for i in 0..num_drafts {
524            if grammar_bitmask.is_some() && i > 0 {
525                tracing::warn!(
526                    "V4 MTP grammar-masked drafting with num_drafts>1 (i={i}); \
527                     mask held fixed across draft positions — acceptance may drop."
528                );
529            }
530            let draft = self.forward_one(
531                current_token,
532                current_hidden,
533                position + i,
534                v4_state,
535                ctx,
536                stream,
537                grammar_bitmask,
538            )?;
539            tracing::debug!(
540                "V4 MTP propose[{i}]: token={current_token} pos={} mtp_seq_len={} → draft={draft}",
541                position + i,
542                v4_state.seq_len,
543            );
544            drafts.push(draft);
545            current_token = draft;
546            // Subsequent drafts feed on the MTP head's own collapsed hidden.
547            current_hidden = ctx.buffers.hidden_states();
548        }
549        v4_state.last_num_drafted = drafts.len();
550        Ok(drafts)
551    }
552
553    fn after_verify(
554        &self,
555        num_accepted: usize,
556        state: &mut dyn ProposerState,
557        _stream: u64,
558    ) -> Result<()> {
559        let v4_state = state
560            .as_any_mut()
561            .downcast_mut::<DeepseekV4MtpProposerState>()
562            .ok_or_else(|| anyhow::anyhow!("Invalid V4 MTP proposer state"))?;
563        // Trim `drafted - accepted` rejected entries from the MTP KV cache by
564        // rolling back `seq_len` (the slots are overwritten on the next
565        // propose). Mirrors `MtpHead::after_verify`.
566        let num_drafted = v4_state.last_num_drafted.max(1);
567        let num_to_trim = num_drafted.saturating_sub(num_accepted);
568        let old_sl = v4_state.seq_len;
569        if num_to_trim > 0 {
570            v4_state.seq_len = v4_state.seq_len.saturating_sub(num_to_trim);
571        }
572        tracing::debug!(
573            "V4 MTP after_verify: accepted={num_accepted} drafted={num_drafted} \
574             trim={num_to_trim} mtp_seq_len: {old_sl} → {}",
575            v4_state.seq_len,
576        );
577        Ok(())
578    }
579
580    fn free_state(&self, _gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
581        let v4_state = state
582            .as_any_mut()
583            .downcast_mut::<DeepseekV4MtpProposerState>()
584            .ok_or_else(|| anyhow::anyhow!("Invalid V4 MTP proposer state"))?;
585        if !v4_state.block_table.is_empty() {
586            self.kv_cache.lock().free_blocks(&v4_state.block_table);
587            v4_state.block_table.clear();
588        }
589        v4_state.seq_len = 0;
590        Ok(())
591    }
592}