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}