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}