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. `ATLAS_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 `ATLAS_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: &atlas_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        // `ATLAS_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("ATLAS_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 `ATLAS_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 = std::env::var("ATLAS_GLM_MTP_PREFILL_FULL").ok().as_deref() == Some("1");
468        let mut kv = self.kv_cache.lock();
469        let Glm5NextMtpProposerState {
470            dsa,
471            seq_len,
472            block_table,
473            concat,
474            x,
475            ..
476        } = st;
477        for r in 0..rows {
478            let embed_row = self
479                .embed_tokens
480                .weight
481                .offset(tokens[r + 1] as usize * h * 2);
482            self.norm(gpu, embed_row, self.module.enorm, *concat, h, stream)?;
483            self.norm(
484                gpu,
485                hiddens.offset(r * h * 2),
486                self.module.hnorm,
487                concat.offset(h * 2),
488                h,
489                stream,
490            )?;
491            ops::dense_gemv(
492                gpu,
493                self.gemv_k,
494                *concat,
495                &self.module.eh_proj,
496                *x,
497                h as u32,
498                (2 * h) as u32,
499                stream,
500            )?;
501            let dsa_state: &mut dyn LayerState = dsa;
502            // DIAGNOSTIC ARM `ATLAS_GLM_MTP_PREFILL_FULL=1`: build the row through the SAME
503            // full-block path a propose uses, so "the KV-only shortcut is wrong" and "the
504            // drafter's attention over real context is wrong" become separable. The shortcut
505            // is the shipping path; this arm exists to convict or clear it.
506            if prefill_full {
507                self.module.layer.decode_one_for_drafter(
508                    *x,
509                    dsa_state,
510                    &mut kv,
511                    *seq_len,
512                    block_table,
513                    ctx,
514                    stream,
515                )?;
516            } else {
517                self.module.layer.drafter_write_kv_row(
518                    *x,
519                    dsa_state,
520                    &mut kv,
521                    *seq_len,
522                    block_table,
523                    ctx,
524                    stream,
525                )?;
526            }
527            if dbg {
528                let fp = crate::speculative::hidden_fingerprint(gpu, hiddens.offset(r * h * 2), h);
529                tracing::info!(
530                    "GLM_MTP_DBG ctx row slot={} key={} tok={} fp_hidden={fp:016x}",
531                    *seq_len,
532                    row_base + r,
533                    tokens[r + 1],
534                );
535            }
536            *seq_len += 1;
537        }
538        Ok(rows)
539    }
540}
541
542impl DraftProposer for Glm5NextMtpHead {
543    /// `self.max_seq_len` is ALREADY capped at `max_dsa_context` by `new`, so this both
544    /// rightsizes the model's capture buffer and keeps it in lockstep with the drafter's own
545    /// bounds checks — a capture longer than the drafter's row space could never be read.
546    /// ANOMALIES A59.
547    fn prefill_hidden_rows(&self, max_seq_len: usize) -> usize {
548        max_seq_len.min(self.max_seq_len)
549    }
550
551    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>> {
552        let dsa = match &self.module.layer.mixer {
553            crate::layers::glm5next_layer::Glm5NextMixer::Dsa(l) => {
554                Glm5NextDsaState::alloc(gpu, &l.cfg)?
555            }
556            _ => bail!("GLM MTP block is not a DSA layer"),
557        };
558        let h = self.hidden;
559        // Every block of the drafter's private pool, claimed up front: it serves one sequence
560        // and a mid-decode allocation inside a captured region is not an option.
561        let blocks = (self.max_seq_len / 16 + 2) as u32;
562        Ok(Box::new(Glm5NextMtpProposerState {
563            dsa,
564            seq_len: 0,
565            block_table: (0..blocks).collect(),
566            last_drafted: 0,
567            concat: gpu.alloc(2 * h * 2)?,
568            x: gpu.alloc(h * 2)?,
569            logits: gpu.alloc(self.vocab * 2)?,
570            arg: gpu.alloc(4)?,
571            head_xchg: gpu.alloc(16)?,
572            released: false,
573        }))
574    }
575
576    /// Release everything `alloc_state` allocated.
577    ///
578    /// Without this the head inherits `DraftProposer::free_state`'s no-op
579    /// default, whose own doc says: *"`DevicePtr` has no `Drop`, so anything
580    /// `alloc_state` allocated leaks unless it is explicitly freed here."* That
581    /// is exactly what happened — every finished sequence leaked its indexer
582    /// cache. The cache is sized from `serve_max_seq_len`, so the leak scales
583    /// with `--max-seq-len`: ~806 MB per sequence at `--max-seq-len 131072`,
584    /// which walks a unified-memory host into the ground in a handful of
585    /// requests (ANOMALIES A75). `DeepseekV4MtpHead` and `MultiModuleMtp`
586    /// already override this; the GLM port did not.
587    ///
588    /// ðŸ”ī Invariant L2 (slot reuse), not a line order: when this slot is re-occupied its
589    /// `decode_graph` and `verify2/3/4_graph` — which bake these exact pointers — must already
590    /// be destroyed AND these pointers freed and nulled. `free_sequence` satisfies both.
591    /// ANOMALIES A56 is the history; the invariant is slot reuse, not the order of the two
592    /// blocks. (The `released` flag below is what makes a second call safe.)
593    fn free_state(&self, gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
594        let st = state
595            .as_any_mut()
596            .downcast_mut::<Glm5NextMtpProposerState>()
597            .ok_or_else(|| anyhow::anyhow!("Invalid GLM MTP proposer state"))?;
598        if st.released {
599            return Ok(());
600        }
601        st.released = true;
602        st.dsa.free(gpu)?;
603        for p in [st.concat, st.x, st.logits, st.arg, st.head_xchg] {
604            gpu.free(p)?;
605        }
606        // The drafter's private pool is claimed whole by `alloc_state`
607        // (`(0..blocks).collect()`), not drawn from an allocator, so there is
608        // nothing to hand back — clearing it just stops a freed state from
609        // looking live.
610        st.block_table.clear();
611        st.seq_len = 0;
612        Ok(())
613    }
614
615    /// ðŸ”ī EP-sharded MoE (144 of 288 experts) + row-parallel DSA `o_proj`. Without the
616    /// communicator this block drafts from HALF of both. See the trait doc for why that is
617    /// only safe once the WORKER rank runs propose too.
618    fn needs_comm(&self) -> bool {
619        crate::speculative::mtp_ep_propose_enabled()
620    }
621
622    /// ðŸ”ī The GLM context prefill runs the block through `ctx.buffers`. See the trait doc —
623    /// running it from the end-of-prefill hook corrupts the TARGET's output.
624    fn prefill_uses_shared_buffers(&self) -> bool {
625        true
626    }
627
628    fn drafter_rows(&self, state: &mut dyn ProposerState) -> usize {
629        state
630            .as_any_mut()
631            .downcast_mut::<Glm5NextMtpProposerState>()
632            .map_or(0, |st| st.seq_len)
633    }
634
635    /// Dense row space: the newest row's slot IS its pair key.
636    fn last_pair_key(&self, state: &mut dyn ProposerState) -> Option<usize> {
637        state
638            .as_any_mut()
639            .downcast_mut::<Glm5NextMtpProposerState>()
640            .and_then(|st| st.seq_len.checked_sub(1))
641    }
642
643    fn prefill_drafter(
644        &self,
645        prompt_tokens: &[u32],
646        hiddens: DevicePtr,
647        state: &mut dyn ProposerState,
648        ctx: &ForwardContext,
649        stream: u64,
650    ) -> Result<usize> {
651        let t0 = std::time::Instant::now();
652        let rows = self.rows_impl(prompt_tokens, hiddens, 0, state, ctx, stream)?;
653        // Every later propose calls this and `rows_impl` fast-returns 0; only the real one logs.
654        if rows > 0 {
655            tracing::info!(
656                "GLM MTP drafter prefill: {rows} rows ({} prompt tokens) in {:.1} ms",
657                prompt_tokens.len(),
658                t0.elapsed().as_secs_f64() * 1e3,
659            );
660        }
661        Ok(rows)
662    }
663
664    /// ðŸŠĪ `pos_base` is ignored: this drafter's RoPE position is its slot (see `rows_impl`), so
665    /// the caller's sequence-space position is already `row_base` up to the uniform shift. A
666    /// feed that does not start exactly at `drafter_rows()` is refused by `rows_impl`.
667    fn catchup_drafter(
668        &self,
669        tokens: &[u32],
670        hiddens: DevicePtr,
671        row_base: usize,
672        _pos_base: usize,
673        state: &mut dyn ProposerState,
674        ctx: &ForwardContext,
675        stream: u64,
676    ) -> Result<usize> {
677        self.rows_impl(tokens, hiddens, row_base, state, ctx, stream)
678    }
679
680    #[allow(clippy::too_many_arguments)]
681    fn propose(
682        &self,
683        last_token: u32,
684        target_hidden: DevicePtr,
685        position: usize,
686        num_drafts: usize,
687        state: &mut dyn ProposerState,
688        ctx: &ForwardContext,
689        stream: u64,
690        _draft_embed_target: Option<DevicePtr>,
691        _grammar_bitmask: Option<&[i32]>,
692        _target_hidden_stack: Option<DevicePtr>,
693    ) -> Result<Vec<u32>> {
694        let st = state
695            .as_any_mut()
696            .downcast_mut::<Glm5NextMtpProposerState>()
697            .ok_or_else(|| anyhow::anyhow!("not a GLM MTP proposer state"))?;
698        // The drafter's own sequence must sit where the target's does, or its indexer selects
699        // over the wrong context. A gap means serial decode steps ran without a propose.
700        if st.seq_len > position {
701            st.dsa.rewind_to(position)?;
702            st.seq_len = position;
703        }
704        if crate::speculative::mtp_refeed_debug() {
705            let fp = crate::speculative::hidden_fingerprint(ctx.gpu, target_hidden, self.hidden);
706            tracing::info!(
707                "GLM_MTP_DBG propose position={position} drafter_rows={} tok={last_token} \
708                 fp_target={fp:016x}",
709                st.seq_len,
710            );
711        }
712        let mut drafts = Vec::with_capacity(num_drafts);
713        let mut token = last_token;
714        let mut hidden = target_hidden;
715        for i in 0..num_drafts {
716            let d = self.forward_one(token, hidden, position + i, st, ctx, stream)?;
717            drafts.push(d);
718            token = d;
719            // ðŸŠĪ Draft 1 consumes the TARGET's verified hidden; every later draft consumes the
720            // drafter's OWN block output. That handoff is where acceptance falls off, and it is
721            // inherent to running one module autoregressively.
722            hidden = st.x;
723        }
724        st.last_drafted = drafts.len();
725        Ok(drafts)
726    }
727
728    fn after_verify(
729        &self,
730        num_accepted: usize,
731        state: &mut dyn ProposerState,
732        _stream: u64,
733    ) -> Result<()> {
734        let st = state
735            .as_any_mut()
736            .downcast_mut::<Glm5NextMtpProposerState>()
737            .ok_or_else(|| anyhow::anyhow!("not a GLM MTP proposer state"))?;
738        // Rejected rows are simply unreachable: the indexer reads `[0, len)` and the next
739        // propose writes from `seq_len`, so rolling the counters back is the whole rollback.
740        //
741        // ðŸ”ī ROW 0 IS ALWAYS VALID and must NOT be trimmed. It pairs the last COMMITTED token
742        // with the target's own hidden — both facts at propose time — so a rejected DRAFT does
743        // not make its row wrong, only its output unused. Only rows 1.. depend on a draft
744        // having been accepted. Trimming row 0 (the Qwen head's `drafted - accepted` rule,
745        // written for a COMPACTED row space) drops a real row from this DENSE one, and every
746        // later RoPE position shifts. At `num_drafts = 1` that means: never trim.
747        //
748        // ðŸŠĪ At `num_drafts >= 2` a partial accept still trims, which DOES leave the dense row
749        // space one key short of the sequence — the catch-up feed refills it from the ring, so
750        // K>=3 must run with `ATLAS_MTP_CATCHUP=1`.
751        let keep = st.last_drafted.min(num_accepted + 1);
752        let trim = st.last_drafted - keep;
753        if trim > 0 {
754            st.seq_len = st.seq_len.saturating_sub(trim);
755            st.dsa.rewind_to(st.seq_len)?;
756        }
757        Ok(())
758    }
759}
760
761/// `ATLAS_GLM_MTP_SKIP=head`: stop the drafter after the block, before `shared_head.norm`,
762/// the `lm_head` gemv, the argmax and the D2H. Timing arm only — see `forward_one`.
763fn skip_head() -> bool {
764    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
765    *ON.get_or_init(|| std::env::var("ATLAS_GLM_MTP_SKIP").ok().as_deref() == Some("head"))
766}
767
768/// `ATLAS_GLM_MTP_SKIP=block`: skip `layers.45` itself and run only the head. Timing arm.
769fn skip_block() -> bool {
770    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
771    *ON.get_or_init(|| std::env::var("ATLAS_GLM_MTP_SKIP").ok().as_deref() == Some("block"))
772}
773
774#[cfg(test)]
775mod a59_sizing_tests {
776    use super::drafter_context_rows;
777    use crate::layers::glm5next_dsa::Glm5NextDsaConfig;
778
779    /// GLM-5.3's shape. Mirrors `glm5next_dsa::state::tests::cfg`.
780    fn cfg() -> Glm5NextDsaConfig {
781        Glm5NextDsaConfig {
782            hidden: 4096,
783            index_heads: 32,
784            index_head_dim: 128,
785            index_kpool: 4,
786            index_topk: 2048,
787            always_select_tail: true,
788            local_heads: 64,
789            q_lora_rank: 1536,
790            kv_lora_rank: 512,
791            qk_nope_head_dim: 256,
792            qk_rope_head_dim: 0,
793            v_head_dim: 256,
794            max_context: 16_384,
795        }
796    }
797
798    /// ðŸ”ī ANOMALIES A59. A declared context the drafter can never reach must not size its
799    /// buffers. At 524,288 the uncapped sizing cost 4.0 GiB of `mtp_prefill_hidden` plus a
800    /// 0.5 GiB private KV pool, neither of them in `inference_reserve`.
801    #[test]
802    fn a_declared_context_past_the_dsa_reservation_does_not_size_the_drafter() {
803        let c = cfg();
804        assert_eq!(drafter_context_rows(524_288, &c), 16_384);
805        assert_eq!(drafter_context_rows(262_144, &c), 16_384);
806    }
807
808    /// Below the ceiling nothing changes — the pre-A59 sizing is preserved exactly, which is
809    /// what keeps every served context up to the cap byte-identical.
810    #[test]
811    fn a_context_under_the_ceiling_is_untouched() {
812        let c = cfg();
813        assert_eq!(drafter_context_rows(8_192, &c), 8_192);
814        assert_eq!(drafter_context_rows(16_384, &c), 16_384);
815    }
816
817    /// The cap is DERIVED, not a literal: it is the DSA indexer cache's own reservation,
818    /// so raising `--max-seq-len` raises the drafter's sizing in lockstep — and rounding to
819    /// whole pools follows too. A hardcoded 16,384 passes the two tests above, fails this.
820    #[test]
821    fn the_cap_tracks_the_indexer_reservation_not_a_constant() {
822        let mut c = cfg();
823        c.max_context = 65_536;
824        assert_eq!(drafter_context_rows(524_288, &c), 65_536);
825        assert_eq!(drafter_context_rows(32_768, &c), 32_768);
826        c.max_context = 65_538;
827        assert_eq!(
828            drafter_context_rows(524_288, &c),
829            65_536,
830            "whole pools only"
831        );
832    }
833}