spark_model/layers/
glm5next_mtp_head.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! `Glm5NextMtpHead` — GLM-5.3's MTP block as a [`DraftProposer`].
4//!
5//! One draft token per `forward_one`:
6//!
7//! ```text
8//! x = eh_proj( concat( enorm(embed[token]), hnorm(target_hidden) ) )   [1, 2H] -> [1, H]
9//! x = layers.45(x)                       DSA + routed MoE, PLAIN residual (no mHC)
10//! logits = lm_head( shared_head.norm(x) )              the target's own BF16 head
11//! draft  = argmax(logits)
12//! ```
13//!
14//! ðŸ”ī **This block is SHARDED — EP-sharded routed MoE (144 of 288 experts per rank) and a
15//! row-parallel DSA `o_proj`** — unlike the Qwen and DeepSeek-V4 MTP modules, which load every
16//! expert on every rank. So it needs the communicator exactly as a text layer does.
17//!
18//! ðŸŠĪ Historically it ran WITHOUT one and on RANK 0 ONLY (`run_mtp_propose_multi_dispatch`:
19//! *"Rank 1 does not participate in MTP propose"*), which is correct for V4 and wrong here: the
20//! drafter proposed from half the routed sum and half the attention output. Lossless — the
21//! target verifies every draft — so the only symptom was acceptance. `AVAROK_MTP_EP_PROPOSE=1`
22//! turns on BOTH halves of the fix: the worker executes propose on `EP_CMD_MTP_PROPOSE`, and
23//! `needs_comm()` then hands the block a comm. Turning on only the second half is `t58`, which
24//! deadlocked.
25//!
26//! ðŸŠĪ The embedding is read as a POINTER into the shared table, not a gather: the row for token
27//! `t` is `embed_tokens + t * hidden * 2`. No kernel, no copy.
28
29use anyhow::{Result, bail};
30use parking_lot::Mutex;
31use std::any::Any;
32
33use spark_runtime::gpu::{DevicePtr, GpuBackend, KernelHandle};
34use spark_runtime::kernel_args::KernelLaunch;
35use spark_runtime::kv_cache::{KvCacheConfig, KvCacheDtype, PagedKvCache};
36
37use crate::layer::{ForwardContext, LayerState};
38use crate::layers::glm5next_dsa::state::Glm5NextDsaState;
39
40use crate::layers::ops;
41use crate::speculative::{DraftProposer, ProposerState};
42use crate::weight_loader::Glm5NextMtpModule;
43use crate::weight_map::DenseWeight;
44
45/// Per-sequence drafter state: the block's own indexer cache and KV blocks.
46pub struct Glm5NextMtpProposerState {
47    dsa: Glm5NextDsaState,
48    /// Tokens the drafter has written. Rolled back by `after_verify` on a rejected draft.
49    seq_len: usize,
50    block_table: Vec<u32>,
51    /// How many drafts the last `propose` wrote, so `after_verify` knows what to trim.
52    last_drafted: usize,
53    /// Scratch: `[2, hidden]` BF16 concat, `[hidden]` BF16 block input, `[vocab]` BF16 logits,
54    /// `[1]` u32 argmax.
55    concat: DevicePtr,
56    x: DevicePtr,
57    logits: DevicePtr,
58    arg: DevicePtr,
59    /// `[max_r0, max_r1, idx_r0, idx_r1]` f32, for the vocab-sharded head's cross-rank pick.
60    head_xchg: DevicePtr,
61    /// Once-only guard for `free_state`. `DevicePtr` has no `Drop`, so the
62    /// release is explicit; this makes a second call a no-op, preserving the
63    /// property the consuming `Glm5NextDsaState::free(self)` used to give.
64    released: bool,
65}
66
67impl ProposerState for Glm5NextMtpProposerState {
68    fn as_any(&self) -> &dyn Any {
69        self
70    }
71    fn as_any_mut(&mut self) -> &mut dyn Any {
72        self
73    }
74}
75
76pub struct Glm5NextMtpHead {
77    module: Glm5NextMtpModule,
78    embed_tokens: DenseWeight,
79    lm_head: DenseWeight,
80    /// One-layer pool of its own: the drafter's entries must be trimmable independently of the
81    /// target's, and the target's pool is sized to its own 11 KV-consuming layers.
82    kv_cache: Mutex<PagedKvCache>,
83    rms_norm_k: KernelHandle,
84    gemv_k: KernelHandle,
85    argmax_k: KernelHandle,
86    hidden: usize,
87    vocab: usize,
88    max_seq_len: usize,
89    /// Vocab shard of the shared `lm_head` this rank sweeps: `[head_v0, head_v0 + head_n)`.
90    /// `head_n == vocab` when the head is not sharded (single rank, or a vocab that does not
91    /// divide, or EP propose off).
92    head_rank: usize,
93    head_v0: usize,
94    head_n: usize,
95    /// FP8 E4M3 copy of THIS RANK'S vocab shard of `lm_head`, drafting only.
96    ///
97    /// ðŸ”ī Correctness-safe by construction and not a precision compromise: the target
98    /// verifies every drafted token with its own BF16 `lm_head_batched`, so an approximate
99    /// draft head can only move the ACCEPTANCE rate, never an emitted token. Same argument
100    /// the NVFP4 `mtp_lm_head` decouple already makes in `factory::lm_head_setup`.
101    ///
102    /// Worth 2.66 -> ~1.33 ms per draft sweep, twice a K=3 step, against the 8.14 ms/step
103    /// the whole drafter costs (nsys 2026-08-29). Kill switch `AVAROK_GLM_MTP_HEAD_FP8=0`.
104    head_fp8: Option<crate::weight_map::Fp8DenseWeight>,
105    gemv_fp8w_k: KernelHandle,
106}
107
108/// Rows the GLM drafter can ever be asked for: the served context, clamped to what its own
109/// DSA indexer cache can hold. ANOMALIES A59 — see the note in [`Glm5NextMtpHead::new`].
110///
111/// Derived from `max_dsa_context`, never a literal: the ceiling is a function of the top-k
112/// kernel's shared-memory budget and `index_kpool`, so a kernel or config change must move
113/// this sizing with it.
114fn drafter_context_rows(
115    max_seq_len: usize,
116    cfg: &crate::layers::glm5next_dsa::Glm5NextDsaConfig,
117) -> usize {
118    max_seq_len.min(crate::layers::glm5next_dsa::state::max_dsa_context(cfg))
119}
120
121impl Glm5NextMtpHead {
122    pub fn new(
123        module: Glm5NextMtpModule,
124        embed_tokens: DenseWeight,
125        lm_head: DenseWeight,
126        config: &avarok_core::config::ModelConfig,
127        gpu: &dyn GpuBackend,
128        max_seq_len: usize,
129    ) -> Result<Self> {
130        let dsa = match &module.layer.mixer {
131            crate::layers::glm5next_layer::Glm5NextMixer::Dsa(l) => l,
132            _ => bail!("GLM MTP block is not a DSA layer"),
133        };
134        // ðŸ”ī ANOMALIES A59. The drafter block is a DSA layer, so it can never reach a position
135        // past `max_dsa_context` — the indexer cache `Glm5NextDsaState::alloc` reserves and
136        // `advance` refuses to grow beyond (`glm5next_dsa/state.rs`). The TARGET's DSA layers
137        // cap the servable context at the same number, so a sequence that would need row
138        // `max_dsa_context` fails in the target before this block ever sees it. Everything
139        // sized off `max_seq_len` here — the private KV pool below, the pre-claimed block
140        // table in `alloc_state`, both bounds checks, and (through `prefill_hidden_rows`) the
141        // model's `mtp_prefill_hidden` capture — is therefore dead weight above the ceiling.
142        //
143        // At `--max-seq-len 524288` that dead weight was 4.0 GiB of capture buffer plus
144        // 0.5 GiB of drafter pool against the flat 4 GiB `cuda_headroom` that is the ONLY
145        // reserve covering them (`serve_phases/preflight.rs`, `inference_reserve`) — both are
146        // allocated AFTER the KV pool is sized, so nothing else accounts for them. The serve
147        // ran ~0.8 GiB past its own `--gpu-memory-utilization` ceiling: measured 2026-08-30,
148        // open128 -18.6 %, counting -10.6 %, TTFT 1.0 s -> 4.9 s, with acceptance, output and
149        // error count unchanged. Handing 1.5 GB back (GMU 0.89) restored every number.
150        //
151        // Deriving the cap from the same function that sets the ceiling keeps it honest: the
152        // day a segmented/radix select lifts `max_dsa_context`, this lifts with it.
153        let max_seq_len = drafter_context_rows(max_seq_len, &dsa.cfg);
154        // Matches the target's absorbed-MLA cache shape so the block's own `latent_write` and
155        // paged gather land at the strides they already assume.
156        let kv_config = KvCacheConfig {
157            block_size: 16,
158            num_kv_heads: 1,
159            head_dim: dsa.cfg.kv_lora_rank,
160            num_layers: 1,
161            dtype: KvCacheDtype::Fp8,
162            layer_dtypes: vec![],
163            layer_dims: vec![],
164            cache_blocks_per_seq: None,
165        };
166        let blocks = max_seq_len / kv_config.block_size + 2;
167        let kv_cache = PagedKvCache::new(kv_config, blocks, gpu)?;
168        // ðŸ”ī THE DRAFTER'S OWN `lm_head` IS 7.3 OF ITS 8.84 ms (measured 2026-08-29,
169        // `AVAROK_GLM_MTP_SKIP=head`: propose 8.84 -> 1.52 ms). It is a 1.27 GB BF16 sweep
170        // (154,880 x 4,096) and the block around it is only 1.5 ms.
171        //
172        // Both ranks now run propose in lockstep, so split the sweep by VOCAB: each reads its
173        // half of the rows and they exchange (max, argmax) through one 16-byte all-reduce. The
174        // drafted token is EXACTLY the unsharded argmax — each rank computes exact logits over
175        // full K for its own rows, so there are no partial sums to reassociate.
176        //
177        // ðŸŠĪ Vocab, not hidden. Rows of `[vocab, hidden]` are contiguous, so a vocab shard is a
178        // base-pointer offset and a smaller `n`. A hidden shard would need a row STRIDE the
179        // gemv kernel does not take — it assumes rows are packed at K.
180        let head_world = config.tp_world_size.max(1);
181        let head_rank = config.tp_rank;
182        let head_n = if head_world > 1 && config.vocab_size.is_multiple_of(head_world) {
183            config.vocab_size / head_world
184        } else {
185            config.vocab_size
186        };
187        // Quantise ONLY the rows this rank sweeps: `head_n * hidden` bytes, not the whole
188        // vocab. A failure here is not fatal — fall back to the BF16 sweep.
189        let gemv_fp8w_k = crate::layers::try_kernel(gpu, "gemv_fp8w", "dense_gemv_fp8w");
190        let head_fp8 = if std::env::var("AVAROK_GLM_MTP_HEAD_FP8").as_deref() == Ok("0")
191            || gemv_fp8w_k.0 == 0
192        {
193            None
194        } else {
195            let shard = DenseWeight {
196                weight: lm_head
197                    .weight
198                    .offset(head_rank * head_n * config.hidden_size * 2),
199            };
200            match gpu
201                .kernel("gemv_fp8w", "quantize_bf16_to_fp8")
202                .and_then(|qk| {
203                    crate::weight_map::quantize_to_fp8(
204                        &shard,
205                        head_n,
206                        config.hidden_size,
207                        gpu,
208                        qk,
209                        gpu.default_stream(),
210                    )
211                }) {
212                Ok(q) => {
213                    tracing::info!(
214                        "GLM MTP: draft lm_head shard quantised to FP8 ({} rows x {}, {} MB)",
215                        head_n,
216                        config.hidden_size,
217                        head_n * config.hidden_size / (1024 * 1024),
218                    );
219                    Some(q)
220                }
221                Err(e) => {
222                    tracing::warn!("GLM MTP: FP8 draft head unavailable ({e:#}); staying BF16");
223                    None
224                }
225            }
226        };
227
228        Ok(Self {
229            module,
230            embed_tokens,
231            lm_head,
232            kv_cache: Mutex::new(kv_cache),
233            rms_norm_k: gpu.kernel("rms_norm_vanilla", "rms_norm_vanilla")?,
234            gemv_k: gpu.kernel("gemv", "dense_gemv_bf16")?,
235            argmax_k: gpu.kernel("argmax", "argmax_bf16")?,
236            hidden: config.hidden_size,
237            vocab: config.vocab_size,
238            max_seq_len,
239            head_rank,
240            head_v0: head_rank * head_n,
241            head_n,
242            head_fp8,
243            gemv_fp8w_k,
244        })
245    }
246
247    fn norm(
248        &self,
249        gpu: &dyn GpuBackend,
250        x: DevicePtr,
251        w: DevicePtr,
252        out: DevicePtr,
253        n: usize,
254        stream: u64,
255    ) -> Result<()> {
256        KernelLaunch::new(gpu, self.rms_norm_k)
257            .grid([1, 1, 1])
258            .block([(n.min(1024)) as u32, 1, 1])
259            .arg_ptr(x)
260            .arg_ptr(w)
261            .arg_ptr(out)
262            .arg_u32(n as u32)
263            .arg_f32(self.module.layer.rms_eps)
264            .launch(stream)
265    }
266
267    /// One draft token. Advances the drafter's KV and indexer state by exactly one row.
268    fn forward_one(
269        &self,
270        token: u32,
271        hidden_in: DevicePtr,
272        position: usize,
273        st: &mut Glm5NextMtpProposerState,
274        ctx: &ForwardContext,
275        stream: u64,
276    ) -> Result<u32> {
277        let gpu = ctx.gpu;
278        let h = self.hidden;
279        if position >= self.max_seq_len {
280            bail!(
281                "GLM MTP drafter: position {position} is past the {} it was sized for",
282                self.max_seq_len
283            );
284        }
285        // ðŸŠĪ Embedding row by POINTER — `embed_tokens` is `[vocab, hidden]` BF16 and the row is
286        // contiguous, so there is nothing to gather.
287        let embed_row = self.embed_tokens.weight.offset(token as usize * h * 2);
288        self.norm(gpu, embed_row, self.module.enorm, st.concat, h, stream)?;
289        self.norm(
290            gpu,
291            hidden_in,
292            self.module.hnorm,
293            st.concat.offset(h * 2),
294            h,
295            stream,
296        )?;
297        ops::dense_gemv(
298            gpu,
299            self.gemv_k,
300            st.concat,
301            &self.module.eh_proj,
302            st.x,
303            h as u32,
304            (2 * h) as u32,
305            stream,
306        )?;
307
308        // The block writes its output back over `st.x` (plain residual, in place).
309        if skip_block() {
310            st.seq_len += 1;
311        } else {
312            let mut kv = self.kv_cache.lock();
313            let dsa_state: &mut dyn LayerState = &mut st.dsa;
314            self.module.layer.decode_one_for_drafter(
315                st.x,
316                dsa_state,
317                &mut kv,
318                st.seq_len,
319                &mut st.block_table,
320                ctx,
321                stream,
322            )?;
323            drop(kv);
324            st.seq_len += 1;
325        }
326
327        // TIMING ARM `AVAROK_GLM_MTP_SKIP=head`: everything from `shared_head.norm` on is
328        // skipped and the draft is a constant. Drafts become garbage (p1 -> ~0) — the point is
329        // the `propose` ms, which then reads as "the block alone". `=block` is the mirror arm.
330        // Neither is a deployment; both are byte-safe because the target verifies every draft.
331        if skip_head() {
332            return Ok(0);
333        }
334        // ðŸŠĪ `shared_head.norm`, then the TARGET's own `lm_head`. The drafter ships no head of
335        // its own — sharing it is what keeps a draft comparable to what the target would emit.
336        self.norm(gpu, st.x, self.module.final_norm, st.x, h, stream)?;
337        // Sharded only when this rank has a partner in the propose (`ctx.comm`); otherwise
338        // `head_n == vocab` and this is the original full sweep.
339        let sharded = ctx.comm.is_some() && self.head_n != self.vocab;
340        let (w, n, v0) = if sharded {
341            (
342                DenseWeight {
343                    weight: self.lm_head.weight.offset(self.head_v0 * h * 2),
344                },
345                self.head_n,
346                self.head_v0,
347            )
348        } else {
349            (self.lm_head, self.vocab, 0)
350        };
351        // ðŸŠĪ The FP8 copy covers `[head_v0, head_v0 + head_n)` ONLY, so it serves the sharded
352        // sweep and nothing else. Unsharded (no partner in the propose) falls back to BF16
353        // rather than reading rows that were never quantised.
354        match self.head_fp8.filter(|_| sharded && n == self.head_n) {
355            Some(q) => ops::dense_gemv_fp8w(
356                gpu,
357                self.gemv_fp8w_k,
358                st.x,
359                &q,
360                st.logits,
361                n as u32,
362                h as u32,
363                stream,
364            )?,
365            None => ops::dense_gemv(
366                gpu,
367                self.gemv_k,
368                st.x,
369                &w,
370                st.logits,
371                n as u32,
372                h as u32,
373                stream,
374            )?,
375        }
376        ops::argmax_bf16(gpu, self.argmax_k, st.logits, st.arg, n as u32, stream)?;
377        let mut out = [0u8; 4];
378        gpu.synchronize(stream)?;
379        gpu.copy_d2h(st.arg, &mut out)?;
380        let local = u32::from_le_bytes(out) as usize;
381        let Some(comm) = ctx.comm.filter(|_| sharded) else {
382            return Ok((v0 + local) as u32);
383        };
384        // Exchange (max, argmax) in 8 BF16 lanes: `[val_r0, val_r1, then 3 base-256 digits of
385        // each rank's global index]`. Each rank writes only its own lanes and leaves the
386        // others zero, so a SUM all-reduce delivers both ranks' values untouched (`x + 0.0`
387        // is exact).
388        //
389        // ðŸŠĪ `CommBackend::all_reduce` IS BF16-TYPED on this backend (`NcclDataType::Bfloat16`,
390        // and at 2 ranks a paired Send/Recv plus a local BF16 add) — the byte count is a BF16
391        // element count, not an opaque buffer. Packing f32s here instead reduced them as 8
392        // BF16 lanes and silently corrupted both the value and the index: p1 0.875 -> 0.636,
393        // measured. A token id needs 18 bits and BF16 carries 8, hence the digits; integers
394        // through 256 are exact in BF16, and the logit lane is already BF16 so it round-trips
395        // bit for bit.
396        let mut lb = [0u8; 2];
397        gpu.copy_d2h(st.logits.offset(local * 2), &mut lb)?;
398        let g = v0 + local;
399        let bf = |x: f32| ((x.to_bits() >> 16) as u16).to_le_bytes();
400        let mut pack = [0u8; 16];
401        pack[self.head_rank * 2..][..2].copy_from_slice(&lb);
402        for d in 0..3 {
403            let digit = ((g >> (8 * d)) & 0xFF) as f32;
404            pack[4 + (self.head_rank * 3 + d) * 2..][..2].copy_from_slice(&bf(digit));
405        }
406        gpu.copy_h2d(&pack, st.head_xchg)?;
407        comm.all_reduce_async(st.head_xchg.0, 16, stream)?;
408        gpu.synchronize(stream)?;
409        gpu.copy_d2h(st.head_xchg, &mut pack)?;
410        let lane = |i: usize| {
411            f32::from_bits(
412                (u16::from_le_bytes(pack[i * 2..][..2].try_into().unwrap()) as u32) << 16,
413            )
414        };
415        // `>=` makes the LOWER rank win a tie, identically on both ranks — the two drafter KV
416        // streams must not diverge on a coin flip.
417        let win = if lane(0) >= lane(1) { 0 } else { 1 };
418        let idx = (0..3).fold(0usize, |a, d| {
419            a + ((lane(2 + win * 3 + d) as usize) << (8 * d))
420        });
421        Ok(idx as u32)
422    }
423
424    /// Append `tokens.len() - 1` drafter CONTEXT rows: row `r` is pair key `row_base + r` =
425    /// `(embed(tokens[r + 1]), hiddens row r)`. Used for both the whole-prompt prefill and the
426    /// catch-up feed — the only difference between them is `row_base`.
427    ///
428    /// ðŸ”ī THE ROW SPACE IS DENSE HERE, unlike the Qwen head's. This block's KV slot, indexer
429    /// row and RoPE position are all `seq_len` (see `Glm5NextDsaLayer::write_kv_row`), so
430    /// decoupling slot from position would mean plumbing a second scalar through the DSA
431    /// layer. Instead every pair key from 0 up is written, which makes slot == key == RoPE and
432    /// the drafter's geometry a copy of the target's — one uniform −1 RoPE shift against the
433    /// convention (key `k` sits at RoPE `k`, not `k + 1`), which is invisible to a relative
434    /// attention. Density is what `after_verify`'s no-trim and the catch-up feed maintain.
435    ///
436    /// Cost: NO MoE, NO attention, NO `lm_head` — a context row's block output is discarded,
437    /// and both caches are pure functions of the row's input.
438    #[allow(clippy::too_many_arguments)]
439    fn rows_impl(
440        &self,
441        tokens: &[u32],
442        hiddens: DevicePtr,
443        row_base: usize,
444        state: &mut dyn ProposerState,
445        ctx: &ForwardContext,
446        stream: u64,
447    ) -> Result<usize> {
448        let st = match state
449            .as_any_mut()
450            .downcast_mut::<Glm5NextMtpProposerState>()
451        {
452            Some(s) => s,
453            None => return Ok(0),
454        };
455        // Rows must append exactly at the drafter's current length, or the dense row space
456        // grows a hole and every later RoPE position is wrong.
457        if st.seq_len != row_base || tokens.len() < 2 {
458            return Ok(0);
459        }
460        let h = self.hidden;
461        let rows = tokens.len() - 1;
462        if row_base + rows > self.max_seq_len {
463            return Ok(0);
464        }
465        let gpu = ctx.gpu;
466        let dbg = crate::speculative::mtp_refeed_debug();
467        let prefill_full =
468            std::env::var("AVAROK_GLM_MTP_PREFILL_FULL").ok().as_deref() == Some("1");
469        let mut kv = self.kv_cache.lock();
470        let Glm5NextMtpProposerState {
471            dsa,
472            seq_len,
473            block_table,
474            concat,
475            x,
476            ..
477        } = st;
478        for r in 0..rows {
479            let embed_row = self
480                .embed_tokens
481                .weight
482                .offset(tokens[r + 1] as usize * h * 2);
483            self.norm(gpu, embed_row, self.module.enorm, *concat, h, stream)?;
484            self.norm(
485                gpu,
486                hiddens.offset(r * h * 2),
487                self.module.hnorm,
488                concat.offset(h * 2),
489                h,
490                stream,
491            )?;
492            ops::dense_gemv(
493                gpu,
494                self.gemv_k,
495                *concat,
496                &self.module.eh_proj,
497                *x,
498                h as u32,
499                (2 * h) as u32,
500                stream,
501            )?;
502            let dsa_state: &mut dyn LayerState = dsa;
503            // DIAGNOSTIC ARM `AVAROK_GLM_MTP_PREFILL_FULL=1`: build the row through the SAME
504            // full-block path a propose uses, so "the KV-only shortcut is wrong" and "the
505            // drafter's attention over real context is wrong" become separable. The shortcut
506            // is the shipping path; this arm exists to convict or clear it.
507            if prefill_full {
508                self.module.layer.decode_one_for_drafter(
509                    *x,
510                    dsa_state,
511                    &mut kv,
512                    *seq_len,
513                    block_table,
514                    ctx,
515                    stream,
516                )?;
517            } else {
518                self.module.layer.drafter_write_kv_row(
519                    *x,
520                    dsa_state,
521                    &mut kv,
522                    *seq_len,
523                    block_table,
524                    ctx,
525                    stream,
526                )?;
527            }
528            if dbg {
529                let fp = crate::speculative::hidden_fingerprint(gpu, hiddens.offset(r * h * 2), h);
530                tracing::info!(
531                    "GLM_MTP_DBG ctx row slot={} key={} tok={} fp_hidden={fp:016x}",
532                    *seq_len,
533                    row_base + r,
534                    tokens[r + 1],
535                );
536            }
537            *seq_len += 1;
538        }
539        Ok(rows)
540    }
541}
542
543impl DraftProposer for Glm5NextMtpHead {
544    /// `self.max_seq_len` is ALREADY capped at `max_dsa_context` by `new`, so this both
545    /// rightsizes the model's capture buffer and keeps it in lockstep with the drafter's own
546    /// bounds checks — a capture longer than the drafter's row space could never be read.
547    /// ANOMALIES A59.
548    fn prefill_hidden_rows(&self, max_seq_len: usize) -> usize {
549        max_seq_len.min(self.max_seq_len)
550    }
551
552    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>> {
553        let dsa = match &self.module.layer.mixer {
554            crate::layers::glm5next_layer::Glm5NextMixer::Dsa(l) => {
555                Glm5NextDsaState::alloc(gpu, &l.cfg)?
556            }
557            _ => bail!("GLM MTP block is not a DSA layer"),
558        };
559        let h = self.hidden;
560        // Every block of the drafter's private pool, claimed up front: it serves one sequence
561        // and a mid-decode allocation inside a captured region is not an option.
562        let blocks = (self.max_seq_len / 16 + 2) as u32;
563        Ok(Box::new(Glm5NextMtpProposerState {
564            dsa,
565            seq_len: 0,
566            block_table: (0..blocks).collect(),
567            last_drafted: 0,
568            concat: gpu.alloc(2 * h * 2)?,
569            x: gpu.alloc(h * 2)?,
570            logits: gpu.alloc(self.vocab * 2)?,
571            arg: gpu.alloc(4)?,
572            head_xchg: gpu.alloc(16)?,
573            released: false,
574        }))
575    }
576
577    /// Release everything `alloc_state` allocated.
578    ///
579    /// Without this the head inherits `DraftProposer::free_state`'s no-op
580    /// default, whose own doc says: *"`DevicePtr` has no `Drop`, so anything
581    /// `alloc_state` allocated leaks unless it is explicitly freed here."* That
582    /// is exactly what happened — every finished sequence leaked its indexer
583    /// cache. The cache is sized from `serve_max_seq_len`, so the leak scales
584    /// with `--max-seq-len`: ~806 MB per sequence at `--max-seq-len 131072`,
585    /// which walks a unified-memory host into the ground in a handful of
586    /// requests (ANOMALIES A75). `DeepseekV4MtpHead` and `MultiModuleMtp`
587    /// already override this; the GLM port did not.
588    ///
589    /// ðŸ”ī Invariant L2 (slot reuse), not a line order: when this slot is re-occupied its
590    /// `decode_graph` and `verify2/3/4_graph` — which bake these exact pointers — must already
591    /// be destroyed AND these pointers freed and nulled. `free_sequence` satisfies both.
592    /// ANOMALIES A56 is the history; the invariant is slot reuse, not the order of the two
593    /// blocks. (The `released` flag below is what makes a second call safe.)
594    fn free_state(&self, gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
595        let st = state
596            .as_any_mut()
597            .downcast_mut::<Glm5NextMtpProposerState>()
598            .ok_or_else(|| anyhow::anyhow!("Invalid GLM MTP proposer state"))?;
599        if st.released {
600            return Ok(());
601        }
602        st.released = true;
603        st.dsa.free(gpu)?;
604        for p in [st.concat, st.x, st.logits, st.arg, st.head_xchg] {
605            gpu.free(p)?;
606        }
607        // The drafter's private pool is claimed whole by `alloc_state`
608        // (`(0..blocks).collect()`), not drawn from an allocator, so there is
609        // nothing to hand back — clearing it just stops a freed state from
610        // looking live.
611        st.block_table.clear();
612        st.seq_len = 0;
613        Ok(())
614    }
615
616    /// ðŸ”ī EP-sharded MoE (144 of 288 experts) + row-parallel DSA `o_proj`. Without the
617    /// communicator this block drafts from HALF of both. See the trait doc for why that is
618    /// only safe once the WORKER rank runs propose too.
619    fn needs_comm(&self) -> bool {
620        crate::speculative::mtp_ep_propose_enabled()
621    }
622
623    /// ðŸ”ī The GLM context prefill runs the block through `ctx.buffers`. See the trait doc —
624    /// running it from the end-of-prefill hook corrupts the TARGET's output.
625    fn prefill_uses_shared_buffers(&self) -> bool {
626        true
627    }
628
629    fn drafter_rows(&self, state: &mut dyn ProposerState) -> usize {
630        state
631            .as_any_mut()
632            .downcast_mut::<Glm5NextMtpProposerState>()
633            .map_or(0, |st| st.seq_len)
634    }
635
636    /// Dense row space: the newest row's slot IS its pair key.
637    fn last_pair_key(&self, state: &mut dyn ProposerState) -> Option<usize> {
638        state
639            .as_any_mut()
640            .downcast_mut::<Glm5NextMtpProposerState>()
641            .and_then(|st| st.seq_len.checked_sub(1))
642    }
643
644    fn prefill_drafter(
645        &self,
646        prompt_tokens: &[u32],
647        hiddens: DevicePtr,
648        state: &mut dyn ProposerState,
649        ctx: &ForwardContext,
650        stream: u64,
651    ) -> Result<usize> {
652        let t0 = std::time::Instant::now();
653        let rows = self.rows_impl(prompt_tokens, hiddens, 0, state, ctx, stream)?;
654        // Every later propose calls this and `rows_impl` fast-returns 0; only the real one logs.
655        if rows > 0 {
656            tracing::info!(
657                "GLM MTP drafter prefill: {rows} rows ({} prompt tokens) in {:.1} ms",
658                prompt_tokens.len(),
659                t0.elapsed().as_secs_f64() * 1e3,
660            );
661        }
662        Ok(rows)
663    }
664
665    /// ðŸŠĪ `pos_base` is ignored: this drafter's RoPE position is its slot (see `rows_impl`), so
666    /// the caller's sequence-space position is already `row_base` up to the uniform shift. A
667    /// feed that does not start exactly at `drafter_rows()` is refused by `rows_impl`.
668    fn catchup_drafter(
669        &self,
670        tokens: &[u32],
671        hiddens: DevicePtr,
672        row_base: usize,
673        _pos_base: usize,
674        state: &mut dyn ProposerState,
675        ctx: &ForwardContext,
676        stream: u64,
677    ) -> Result<usize> {
678        self.rows_impl(tokens, hiddens, row_base, state, ctx, stream)
679    }
680
681    #[allow(clippy::too_many_arguments)]
682    fn propose(
683        &self,
684        last_token: u32,
685        target_hidden: DevicePtr,
686        position: usize,
687        num_drafts: usize,
688        state: &mut dyn ProposerState,
689        ctx: &ForwardContext,
690        stream: u64,
691        _draft_embed_target: Option<DevicePtr>,
692        _grammar_bitmask: Option<&[i32]>,
693        _target_hidden_stack: Option<DevicePtr>,
694    ) -> Result<Vec<u32>> {
695        let st = state
696            .as_any_mut()
697            .downcast_mut::<Glm5NextMtpProposerState>()
698            .ok_or_else(|| anyhow::anyhow!("not a GLM MTP proposer state"))?;
699        // The drafter's own sequence must sit where the target's does, or its indexer selects
700        // over the wrong context. A gap means serial decode steps ran without a propose.
701        if st.seq_len > position {
702            st.dsa.rewind_to(position)?;
703            st.seq_len = position;
704        }
705        if crate::speculative::mtp_refeed_debug() {
706            let fp = crate::speculative::hidden_fingerprint(ctx.gpu, target_hidden, self.hidden);
707            tracing::info!(
708                "GLM_MTP_DBG propose position={position} drafter_rows={} tok={last_token} \
709                 fp_target={fp:016x}",
710                st.seq_len,
711            );
712        }
713        let mut drafts = Vec::with_capacity(num_drafts);
714        let mut token = last_token;
715        let mut hidden = target_hidden;
716        for i in 0..num_drafts {
717            let d = self.forward_one(token, hidden, position + i, st, ctx, stream)?;
718            drafts.push(d);
719            token = d;
720            // ðŸŠĪ Draft 1 consumes the TARGET's verified hidden; every later draft consumes the
721            // drafter's OWN block output. That handoff is where acceptance falls off, and it is
722            // inherent to running one module autoregressively.
723            hidden = st.x;
724        }
725        st.last_drafted = drafts.len();
726        Ok(drafts)
727    }
728
729    fn after_verify(
730        &self,
731        num_accepted: usize,
732        state: &mut dyn ProposerState,
733        _stream: u64,
734    ) -> Result<()> {
735        let st = state
736            .as_any_mut()
737            .downcast_mut::<Glm5NextMtpProposerState>()
738            .ok_or_else(|| anyhow::anyhow!("not a GLM MTP proposer state"))?;
739        // Rejected rows are simply unreachable: the indexer reads `[0, len)` and the next
740        // propose writes from `seq_len`, so rolling the counters back is the whole rollback.
741        //
742        // ðŸ”ī ROW 0 IS ALWAYS VALID and must NOT be trimmed. It pairs the last COMMITTED token
743        // with the target's own hidden — both facts at propose time — so a rejected DRAFT does
744        // not make its row wrong, only its output unused. Only rows 1.. depend on a draft
745        // having been accepted. Trimming row 0 (the Qwen head's `drafted - accepted` rule,
746        // written for a COMPACTED row space) drops a real row from this DENSE one, and every
747        // later RoPE position shifts. At `num_drafts = 1` that means: never trim.
748        //
749        // ðŸŠĪ At `num_drafts >= 2` a partial accept still trims, which DOES leave the dense row
750        // space one key short of the sequence — the catch-up feed refills it from the ring, so
751        // K>=3 must run with `AVAROK_MTP_CATCHUP=1`.
752        let keep = st.last_drafted.min(num_accepted + 1);
753        let trim = st.last_drafted - keep;
754        if trim > 0 {
755            st.seq_len = st.seq_len.saturating_sub(trim);
756            st.dsa.rewind_to(st.seq_len)?;
757        }
758        Ok(())
759    }
760}
761
762/// `AVAROK_GLM_MTP_SKIP=head`: stop the drafter after the block, before `shared_head.norm`,
763/// the `lm_head` gemv, the argmax and the D2H. Timing arm only — see `forward_one`.
764fn skip_head() -> bool {
765    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
766    *ON.get_or_init(|| std::env::var("AVAROK_GLM_MTP_SKIP").ok().as_deref() == Some("head"))
767}
768
769/// `AVAROK_GLM_MTP_SKIP=block`: skip `layers.45` itself and run only the head. Timing arm.
770fn skip_block() -> bool {
771    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
772    *ON.get_or_init(|| std::env::var("AVAROK_GLM_MTP_SKIP").ok().as_deref() == Some("block"))
773}
774
775#[cfg(test)]
776mod a59_sizing_tests {
777    use super::drafter_context_rows;
778    use crate::layers::glm5next_dsa::Glm5NextDsaConfig;
779
780    /// GLM-5.3's shape. Mirrors `glm5next_dsa::state::tests::cfg`.
781    fn cfg() -> Glm5NextDsaConfig {
782        Glm5NextDsaConfig {
783            hidden: 4096,
784            index_heads: 32,
785            index_head_dim: 128,
786            index_kpool: 4,
787            index_topk: 2048,
788            always_select_tail: true,
789            local_heads: 64,
790            q_lora_rank: 1536,
791            kv_lora_rank: 512,
792            qk_nope_head_dim: 256,
793            qk_rope_head_dim: 0,
794            v_head_dim: 256,
795            max_context: 16_384,
796        }
797    }
798
799    /// ðŸ”ī ANOMALIES A59. A declared context the drafter can never reach must not size its
800    /// buffers. At 524,288 the uncapped sizing cost 4.0 GiB of `mtp_prefill_hidden` plus a
801    /// 0.5 GiB private KV pool, neither of them in `inference_reserve`.
802    #[test]
803    fn a_declared_context_past_the_dsa_reservation_does_not_size_the_drafter() {
804        let c = cfg();
805        assert_eq!(drafter_context_rows(524_288, &c), 16_384);
806        assert_eq!(drafter_context_rows(262_144, &c), 16_384);
807    }
808
809    /// Below the ceiling nothing changes — the pre-A59 sizing is preserved exactly, which is
810    /// what keeps every served context up to the cap byte-identical.
811    #[test]
812    fn a_context_under_the_ceiling_is_untouched() {
813        let c = cfg();
814        assert_eq!(drafter_context_rows(8_192, &c), 8_192);
815        assert_eq!(drafter_context_rows(16_384, &c), 16_384);
816    }
817
818    /// The cap is DERIVED, not a literal: it is the DSA indexer cache's own reservation,
819    /// so raising `--max-seq-len` raises the drafter's sizing in lockstep — and rounding to
820    /// whole pools follows too. A hardcoded 16,384 passes the two tests above, fails this.
821    #[test]
822    fn the_cap_tracks_the_indexer_reservation_not_a_constant() {
823        let mut c = cfg();
824        c.max_context = 65_536;
825        assert_eq!(drafter_context_rows(524_288, &c), 65_536);
826        assert_eq!(drafter_context_rows(32_768, &c), 32_768);
827        c.max_context = 65_538;
828        assert_eq!(
829            drafter_context_rows(524_288, &c),
830            65_536,
831            "whole pools only"
832        );
833    }
834}