spark_model/speculative.rs
1// SPDX-License-Identifier: AGPL-3.0-only
2
3//! Speculative decoding abstraction (SDD).
4//!
5//! Defines the [`DraftProposer`] trait for speculative decoding strategies.
6//! MTP implements this first; EAGLE-3 can implement later without engine changes.
7
8pub mod ladder;
9pub mod tree_shape;
10pub mod verify_key;
11
12pub use ladder::{mtp_ladder_disabled, mtp_ladder_drafts, mtp_max_seqs};
13
14use std::any::Any;
15
16use anyhow::Result;
17use atlas_core::config::ModelConfig;
18use spark_runtime::buffers::BufferArena;
19use spark_runtime::gpu::{DevicePtr, GpuBackend};
20
21use crate::layer::ForwardContext;
22
23/// Per-sequence state owned by a [`DraftProposer`].
24///
25/// Stores KV cache, hidden states, or whatever the proposer needs
26/// across decode steps. Follows the same downcasting pattern as `LayerState`.
27pub trait ProposerState: Send + Sync {
28 fn as_any(&self) -> &dyn Any;
29 fn as_any_mut(&mut self) -> &mut dyn Any;
30}
31
32/// A draft token proposer for speculative decoding.
33///
34/// The engine calls `propose()` after each target decode to get draft tokens,
35/// then verifies them with the target model. `after_verify()` lets the
36/// proposer trim state (e.g., KV cache) based on how many drafts were accepted.
37/// Confidence floor for submitting drafts to verification
38/// (`ATLAS_MTP_DRAFT_CONF`, default 0.0 = disabled). When the drafter's
39/// chain confidence (min top-1 softmax prob across the drafts of one
40/// propose) is below this, the drafts are discarded and the next step
41/// decodes serially — skipping a verify that would most likely reject.
42/// Economics at K=1 on the 35B MoE: verify ≈ 35 ms for 1+acc tokens vs
43/// decode+propose ≈ 21 ms for 1, so a draft is only worth verifying when
44/// p(accept) ≳ 0.66 — the threshold to calibrate around. Staged OFF until
45/// its measured A/B (same discipline as ATLAS_SNAP_EVICT_ALPHA).
46pub fn draft_conf_tau() -> f32 {
47 std::env::var("ATLAS_MTP_DRAFT_CONF")
48 .ok()
49 .and_then(|v| v.parse::<f32>().ok())
50 .map(|t| t.clamp(0.0, 0.99))
51 .unwrap_or(0.0)
52}
53
54/// Shadow top-k draft instrumentation (`ATLAS_MTP_SHADOW_TOPK=k`, default
55/// 0 = off, clamp k ≤ 8). Observational only — token selection untouched.
56/// Each drafter `forward_one` D2H's its logits (same ~200 µs the conf path
57/// pays) and logs the top-k candidate ids + softmax probs per position;
58/// the verify steps log the target argmaxes under the same gate. Joining
59/// the two offline yields the per-depth conditional top-k coverage that
60/// gates the tree-speculation build (Phase 0 of the tree-spec plan).
61/// Value-parsed, not presence-checked (`=0` really is off).
62///
63/// The SSOT parse. Both `ModelLevers::shadow_topk` (spark-model) and
64/// `SchedLevers::shadow_topk` (spark-server) resolve through it once per run
65/// rather than caching the answer in a `OnceLock` that a swap would pin.
66pub fn shadow_topk() -> usize {
67 std::env::var("ATLAS_MTP_SHADOW_TOPK")
68 .ok()
69 .and_then(|v| v.parse::<usize>().ok())
70 .unwrap_or(0)
71 .min(8)
72}
73
74/// True when the MTP cap is raised above one sequence. The catchup ring
75/// (`types.rs` `mtp_catchup_ring` + meta), the refeed label convention, and
76/// the carry slot (`mtp_carry.rs`) are SINGLE-SEQUENCE structures — one ring,
77/// one label range, one slot. Running them with n concurrently-verifying
78/// sequences interleaves unrelated hiddens under one label space and breaks
79/// `after_verify`'s env-keyed trim contract (`mtp_rows_to_trim`). Disabling
80/// them process-wide when the cap > 1 is the only consistent option; a
81/// slot-keyed ring is recorded follow-up work (acceptance debt at n>1).
82pub fn mtp_multi_seq_mode() -> bool {
83 mtp_max_seqs() > 1
84}
85
86/// `ATLAS_MTP_ACCEPT_DEBUG` (PRESENCE): per-BATCH-WIDTH acceptance telemetry.
87///
88/// The shipped K ladder gives `k_drafts == 2` at n in [5, 8], and the existing
89/// positional counters (`k4_record_positional`) are gated on `k_drafts == 3`,
90/// so at the C=8 operating point NOTHING reported p1 — only the na histogram.
91/// This gate turns on a per-n line reporting p1, mean accepted and the derived
92/// tokens/step, which is the quantity the C=8 arithmetic is written in.
93/// Counters only (no D2H, no sync), but it logs per period, so keep it off in
94/// timed legs unless the leg IS the accept measurement.
95pub fn mtp_accept_debug() -> bool {
96 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
97 *ON.get_or_init(|| std::env::var("ATLAS_MTP_ACCEPT_DEBUG").is_ok())
98}
99
100/// Drafter catch-up feed on serial->speculative transitions
101/// (`ATLAS_MTP_CATCHUP=1`, staged off). During serial-decode stretches the
102/// scheduler rings the per-step final hiddens; on the next propose the gap
103/// rows are batch-fed into the drafter KV so it never runs stale. Wrong
104/// feeds cannot corrupt output (verification rejects bad drafts) — the
105/// stake is acceptance only, which is the flip gate's metric.
106/// Force-off in multi-seq MTP mode (single-sequence ring; see
107/// [`mtp_multi_seq_mode`]).
108pub fn mtp_catchup_enabled() -> bool {
109 std::env::var("ATLAS_MTP_CATCHUP").ok().as_deref() == Some("1") && !mtp_multi_seq_mode()
110}
111
112/// Re-feed ACCEPTED draft rows with the target's TRUE hidden state
113/// (`ATLAS_MTP_REFEED_ACCEPTED=1`, default OFF). Requires `ATLAS_MTP_CATCHUP=1`.
114///
115/// WHY. The MTP head is one module run autoregressively. Draft 1 consumes the
116/// TARGET's verified hidden (`mtp_hidden_save`); every later draft consumes the
117/// drafter's OWN single-block residual (`mtp_head.rs`, `current_hidden =
118/// ctx.buffers.hidden_states()`). The drafter KV row written for draft d >= 2
119/// therefore pairs the right token with the WRONG hidden — and on ACCEPT that
120/// row is kept forever: `after_verify` trims only REJECTED rows. So every
121/// accepted draft permanently contaminates the drafter's own context.
122///
123/// Measured on dgx2 (W4A4 27B, gate disarmed, seq_len ~10k, n=700/config):
124/// unconditional per-position acceptance 0.660 -> 0.485 -> 0.407, i.e. the
125/// FIRST autoregressive step costs x0.735 while the second costs only x0.838 —
126/// the loss is concentrated exactly at the hidden-state handoff. Neither
127/// existing lever touches it: `ATLAS_MTP_CATCHUP=1` alone is bit-identical
128/// (its ring is only written on SERIAL decode steps, and with the throughput
129/// gate disarmed there are none), and dropping `ATLAS_MTP_DRAFTER_PREFILL`
130/// costs only 0.017/0.030 (~1 sd).
131///
132/// WHAT THIS DOES. After a verify, the target's true hidden for every accepted
133/// position is sitting in the verify hidden buffer. Ring those hiddens under
134/// the same label convention the serial path uses, and have `after_verify`
135/// additionally drop the `num_accepted - 1` accepted rows that were written
136/// with a drafter hidden. The next propose's catch-up feed then rebuilds
137/// exactly those rows from the ring, with the TARGET's hidden, through the
138/// already-exercised `catchup_drafter` batch path. No new kernel, no new
139/// state machine — it reuses the gap-fill machinery for a gap that was never
140/// being detected.
141///
142/// SAFETY. A wrong feed cannot corrupt output: verification rejects bad
143/// drafts. The stake is acceptance only.
144///
145/// ## STATUS 2026-07-21 (SUPERSEDES the earlier "refuted" note). STAGED OFF.
146///
147/// The earlier note claimed the pair-key -> hidden mapping was wrong, inferred
148/// from a sign reversal between a 67%-delivery and a 99%-delivery arm at
149/// n=700 (+0.021 -> −0.023 on p2_uncond). **That inference is withdrawn.** The
150/// two arms differed by only ~1.7 sd, neither was more than 1 sd from the
151/// baseline, and they are not paired samples (each arm emits different text).
152///
153/// The mapping has since been VERIFIED DIRECTLY, with dumped hidden
154/// fingerprints (`ATLAS_MTP_REFEED_DEBUG=1`, FNV-1a over each BF16 row), on
155/// dgx2 / W4A4 27B / nd=2 / gate disarmed:
156///
157/// | check | result |
158/// |---|---|
159/// | ring D2D landed (`fp_src == fp_dst`) | 658 / 658 |
160/// | fed hidden == live ring content at that label | 422 / 422 |
161/// | `label == key + 1` and `RoPE == key + 1` on every feed | always |
162/// | `fp(ring[position]) == fp(mtp_hidden_save)` at each propose | 302 / 304 (the 2 are a run's first propose) |
163/// | **feed(key k) == `mtp_hidden_save` at the propose whose position was k+1** | **93 / 93** |
164///
165/// The last row is the non-tautological one: it compares the hidden this
166/// feature feeds for pair key `k` against the hidden the drafter's own
167/// `forward_one` consumed as `target_hidden` when it wrote pair key `k` —
168/// two different code paths, bit-identical on every checkable case. So the
169/// convention "ring label n holds hidden_{n−1}, hence pair key k reads label
170/// k+1" is confirmed against an independently-exercised consumer.
171///
172/// What the earlier session DID find is real and is now fixed: the exclusive
173/// `0..num_accepted` bound left one label unwritten per step, collapsing the
174/// ring's contiguous window (458 fed / 231 missed = 67%). The bound is now
175/// `0..=num_accepted` on both K=3 and K=4 (K=4 matters because
176/// `mtp_rows_to_trim`'s extra trim is K-agnostic — without a K=4 ring write,
177/// nd=3 would drop accepted drafter rows with nothing rebuilding them).
178///
179/// ## POWERED A/B (2026-07-21, dgx2): the pre-registered threshold is MET.
180///
181/// nd=2, gate disarmed, 16 documents x 8 turns, ~10k verify steps per arm
182/// (with `ATLAS_MTP_GATE_FORCE=1` the engine is bit-reproducible, so n rises
183/// only with NEW CONTENT, never with repetitions).
184///
185/// | arm | n | p1 | p2_uncond | tokens/verify step |
186/// |---|---|---|---|---|
187/// | OFF | 10,400 | 0.6100 | 0.4182 | 1.882 |
188/// | ON | 10,100 | 0.6262 | **0.4452** | 1.926 |
189/// | delta | | +0.016 (2.4 sd) | **+0.027 (3.9 sd)** | **+2.3%** |
190///
191/// Criterion, pre-registered before the run: `p2_uncond` up by ≥ 0.015 at
192/// ≥ 3 sd. Met. At n=700 — the sample that produced the earlier "refuted"
193/// verdict — this same effect is ~1.0 sd, i.e. invisible. That verdict was a
194/// power problem, not a mapping problem.
195///
196/// Caveat kept deliberately: the arms emit different text, so the binomial sd
197/// understates the true variance. Content is matched (identical documents and
198/// questions in both arms) but this is one measurement, not a replication.
199/// STAYS DEFAULT OFF pending the standard gates (C2 smoke, A 35B
200/// webserver_ok, B/D ST-995).
201///
202/// ## SIZE IT AGAINST THE REAL PRIZE BEFORE SPENDING ANY MORE TIME HERE
203///
204/// This lever is small BY CONSTRUCTION: it repairs at most
205/// `num_accepted − 1` drafter KV **history** rows per step, while the measured
206/// p1->p2 cliff happens WITHIN a single `propose`, where a history repair
207/// cannot act at all. Two larger effects were measured the same night:
208///
209/// 1. **The drafter's own INPUT hidden at draft position >= 2** (dgx1's
210/// teacher-forced oracle probe, `ATLAS_MTP_ORACLE_P2`): feeding draft 2 the
211/// TARGET's true hidden instead of the MTP head's own takes p2_cond
212/// 0.5265 -> 0.7196, McNemar z = +18.4, recovering 1.40x the p1−p2 gap.
213/// "Exposure bias" is refuted — the drafter is not mis-calibrated, it is
214/// fed the wrong vector. That is **+0.193**, about **7x** this flag's
215/// +0.027.
216/// 2. **Drafter context blindness on WARM turns** (dgx2): the drafter holds
217/// only **142 KV rows at sequence position 10,098**, because
218/// `try_mtp_prefill_capture` no-ops whenever a prefill starts at a
219/// reused-prefix boundary and the drafter prompt-prefill is then skipped.
220/// Prefilling it on every turn measured **+0.086 p1 / +0.101 p2_uncond /
221/// +10.2% accepted tokens per verify step** at n ~ 10k per arm, of which a
222/// de-confounding pair (drafter coverage held at zero, prefix caching the
223/// only variable) attributes **+0.079 p1 / +0.089 p2_uncond — 92% / 88% —
224/// to drafter coverage** and the small remainder to warm restore.
225///
226/// Both dwarf this flag, and (2) also changes what this flag is worth: a
227/// drafter that can actually see the prompt is a different drafter. **Build
228/// (2) first, then re-measure this.**
229///
230/// Force-off in multi-seq MTP mode: the refeed label space is single-sequence
231/// (see [`mtp_multi_seq_mode`]).
232pub fn mtp_refeed_accepted_enabled() -> bool {
233 std::env::var("ATLAS_MTP_REFEED_ACCEPTED").ok().as_deref() == Some("1") && !mtp_multi_seq_mode()
234}
235
236/// Deliberate off-by-N perturbation of the re-feed's ring LABEL
237/// (`ATLAS_MTP_REFEED_SHIFT`, default 0 = the derived mapping).
238///
239/// This is a MAPPING-VALIDATION hatch, not a tuning knob. The label
240/// convention (`label n holds hidden_{n-1}`, so pair key `k` reads label
241/// `k+1`) cannot be falsified by any self-consistent checksum: the only
242/// independently-exercised consumer of the verify hidden buffer is
243/// `save_hidden_for_mtp(num_accepted)`, which reads the SAME buffer at the
244/// SAME offset formula as the re-feed's `t = num_accepted` write, and the
245/// sequence positions strictly between two propose positions are never
246/// observed by any other code path. So the mapping is tested BEHAVIOURALLY
247/// instead: shift every re-fed label by ±1 and measure acceptance. A uniform
248/// shift keeps the ring contiguous (delivery is unchanged) but hands pair key
249/// `k` the hidden of position `k ± 1`. If the derived mapping is right,
250/// `shift = 0` must be the maximum of the three arms; if `+1` or `−1` wins,
251/// that arm IS the correct mapping. If all three are indistinguishable, the
252/// drafter's KV-history rows do not carry enough signal for this lever to
253/// work at all — which is itself the answer.
254pub fn mtp_refeed_shift() -> isize {
255 std::env::var("ATLAS_MTP_REFEED_SHIFT")
256 .ok()
257 .and_then(|v| v.parse::<isize>().ok())
258 .unwrap_or(0)
259 .clamp(-4, 4)
260}
261
262/// `ATLAS_MTP_REFEED_DEBUG=1`: fingerprint every hidden that enters and
263/// leaves the catch-up ring, so the pair-key -> hidden mapping can be read
264/// off the serve log instead of argued about. Costs a D2H of one hidden row
265/// (`h * 2` bytes) plus a stream sync per event — NEVER enable it in a timed
266/// leg. See `mtp_refeed_shift` for why the fingerprints alone cannot falsify
267/// the mapping, and what they DO establish (the ring's slot arithmetic and
268/// the pair-key bookkeeping round-trip).
269pub fn mtp_refeed_debug() -> bool {
270 std::env::var("ATLAS_MTP_REFEED_DEBUG").ok().as_deref() == Some("1")
271}
272
273/// FNV-1a over a BF16 GPU row, for `mtp_refeed_debug` fingerprints.
274pub fn hidden_fingerprint(gpu: &dyn GpuBackend, p: DevicePtr, h: usize) -> u64 {
275 let mut b = vec![0u8; h * 2];
276 if gpu.copy_d2h(p, &mut b).is_err() {
277 return 0;
278 }
279 let mut hash: u64 = 0xcbf2_9ce4_8422_2325;
280 for byte in &b {
281 hash ^= *byte as u64;
282 hash = hash.wrapping_mul(0x1000_0000_01b3);
283 }
284 hash
285}
286
287/// EP worker command: run one MTP propose in lockstep with rank 0.
288/// Payload after the code: `last_token`, `position`, `num_drafts` (3 x u32).
289pub const EP_CMD_MTP_PROPOSE: u32 = 0xFFFF_FFF5;
290
291/// Run the drafter on EVERY rank with the communicator, instead of rank-0-only
292/// with `comm: None`. **DEFAULT ON since 2026-08-29**; kill switch
293/// `ATLAS_NO_MTP_EP_PROPOSE=1` restores the rank-0-only path.
294///
295/// Both halves move together and neither is safe alone:
296/// * the head broadcasts [`EP_CMD_MTP_PROPOSE`] before every propose, so the
297/// worker runs the SAME drafter forward and issues the SAME collectives in
298/// the same stream order;
299/// * [`DraftProposer::needs_comm`] then hands the block a comm.
300///
301/// Only a proposer that returns true from [`DraftProposer::needs_comm`] is
302/// affected, and today that is GLM-5.3 alone — the Qwen and DeepSeek-V4 MTP
303/// modules load every expert on every rank and must keep `comm: None`.
304///
305/// Measured on 2 x GB10 (t67, six-probe gate byte-identical on every arm):
306/// open512 17.53 -> 19.11 tok/s, p1 0.747 -> 0.875.
307///
308/// 🪤 `ATLAS_MTP_EP_PROPOSE=1` (the opt-in name it shipped behind for one day)
309/// still reads as ON, so a launch script carrying it keeps working.
310pub fn mtp_ep_propose_enabled() -> bool {
311 static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
312 *ON.get_or_init(|| std::env::var("ATLAS_NO_MTP_EP_PROPOSE").ok().as_deref() != Some("1"))
313}
314
315pub trait DraftProposer: Send + Sync {
316 /// Allocate per-sequence proposer state.
317 fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>>;
318
319 /// [`Self::alloc_state`] with the sequence's KNOWN token budget
320 /// (`prompt_len + max_tokens`), so a proposer whose per-sequence state
321 /// scales with context can size to what this request can actually reach
322 /// instead of the global `--max-seq-len` ceiling. That distinction is what
323 /// OOMs a high-concurrency long-context serve: the ceiling is per-sequence
324 /// and paid n times, while a typical request needs a fraction of it.
325 ///
326 /// `usize::MAX` means "unknown, use the ceiling". Defaults to
327 /// `alloc_state`, so proposers with fixed-size state need not implement it.
328 fn alloc_state_for(
329 &self,
330 gpu: &dyn GpuBackend,
331 budget_tokens: usize,
332 ) -> Result<Box<dyn ProposerState>> {
333 let _ = budget_tokens;
334 self.alloc_state(gpu)
335 }
336
337 /// The proposer's trained block size γ, when it is a block-diffusion
338 /// drafter (DFlash/DFlash2). The serve layer derives num_drafts from
339 /// this — the head resolved it from the drafter checkpoint and is the
340 /// SSOT. `None` = not a block drafter.
341 fn block_gamma(&self) -> Option<usize> {
342 None
343 }
344
345 /// Chain confidence of the most recent `propose` (min top-1 softmax prob
346 /// across its drafts), when the proposer computes it (`draft_conf_tau` >
347 /// 0). `None` = not computed; callers must not gate on it then.
348 fn last_confidence(&self) -> Option<f32> {
349 None
350 }
351
352 /// Rows this proposer can actually consume from `mtp_prefill_hidden`, given
353 /// the served `--max-seq-len`.
354 ///
355 /// The model allocates that buffer as `[rows, hidden]` BF16 before it knows
356 /// anything about the proposer, so `max_seq_len` is the only bound it has —
357 /// 4.0 GiB at 524,288 and h=4096. A proposer whose own architecture caps the
358 /// position it can ever be asked for returns that cap instead, and the
359 /// difference stops being allocated. See ANOMALIES A59 and the note in
360 /// `Glm5NextMtpHead::new`.
361 ///
362 /// 🔴 Return a SMALLER number ONLY when the proposer can never be handed a
363 /// position past it. A cap below the reachable context does not corrupt
364 /// anything — the capture-coverage check at the propose site disables
365 /// drafter-prefill for a sequence whose rows are short — but it silently
366 /// costs acceptance on exactly the long prompts the feature exists for.
367 ///
368 /// Default: `max_seq_len`, i.e. the pre-A59 sizing, which is correct for any
369 /// proposer that can follow the target to the end of the served context.
370 fn prefill_hidden_rows(&self, max_seq_len: usize) -> usize {
371 max_seq_len
372 }
373
374 /// True when this proposer's block is SHARDED across ranks and its
375 /// forward therefore needs the communicator (a routed-MoE all-reduce and
376 /// a row-parallel `o_proj` reduce), like any target layer.
377 ///
378 /// Default false, which is correct for the Qwen and DeepSeek-V4 drafters:
379 /// their MTP modules load EVERY expert on EVERY rank, so the output is
380 /// already complete and passing a comm would DOUBLE it via SUM.
381 ///
382 /// 🔴 GLM-5.3 is the opposite and it is not a choice: `load_glm5next_mtp_module`
383 /// builds its MoE through the same `Glm5NextMlpConfig` the target layers use, so
384 /// `build_moe` walks `cfg.local_expert_range()` and loads 144 of 288 experts;
385 /// `DsaTpPlan::new(tp_rank, tp_world_size, ..)` splits the DSA heads the same way.
386 ///
387 /// 🪤 Returning true is NOT sufficient on its own — that is exactly what `t58` did and
388 /// it deadlocked at the first propose. The worker rank must ALSO execute the propose,
389 /// or rank 0's drafter collectives land against whatever the worker issues next. See
390 /// `EP_CMD_MTP_PROPOSE`.
391 fn needs_comm(&self) -> bool {
392 false
393 }
394
395 /// True when this proposer's context prefill uses the SHARED forward
396 /// scratch (`ctx.buffers`), so it must not run from the end-of-prefill
397 /// eager hook — only from the first `propose`, where the target owns
398 /// nothing.
399 ///
400 /// MEASURED 2026-08-29 (GLM-5.3, 2x GB10, t61): the eager call site with
401 /// the GLM drafter prefill engaged changed the TARGET's completion on 2 of
402 /// the 6 sealed probes and collapsed p1 from 0.625 to 0.045. The identical
403 /// prefill work moved to the first propose is byte-identical on all six and
404 /// takes p1 to 0.747. The call site is the only variable between the two
405 /// arms; the exact colliding buffer is UNVERIFIED (`norm_output` and
406 /// `moe_output` are the candidates the GLM block writes).
407 fn prefill_uses_shared_buffers(&self) -> bool {
408 false
409 }
410
411 /// Current drafter KV length (rows), for the catch-up append point.
412 /// 0 = unknown / not applicable (catch-up is skipped).
413 fn drafter_rows(&self, _state: &mut dyn ProposerState) -> usize {
414 0
415 }
416
417 /// Sequence-space pair key of the newest drafter row (`None` = untracked;
418 /// catch-up is skipped). The drafter row space is compacted, so `rows`
419 /// cannot locate the drafter in the sequence — this can.
420 fn last_pair_key(&self, _state: &mut dyn ProposerState) -> Option<usize> {
421 None
422 }
423
424 /// ATLAS_MTP_CARRY_DRAFTER: move this sequence's drafter KV blocks OUT of
425 /// its proposer state, so `free_state` releases nothing and the model can
426 /// hold them for the next turn. Returns `(blocks, rows, last_pair_key)`;
427 /// `None` = unsupported or nothing to carry. After this call the state
428 /// must behave as if freshly allocated.
429 fn take_drafter_kv(
430 &self,
431 _state: &mut dyn ProposerState,
432 ) -> Option<(Vec<u32>, usize, Option<usize>)> {
433 None
434 }
435
436 /// Inverse of [`Self::take_drafter_kv`]: install carried blocks into a fresh
437 /// proposer state. Returns false when unsupported (caller must then free
438 /// the blocks itself).
439 fn install_drafter_kv(
440 &self,
441 _state: &mut dyn ProposerState,
442 _blocks: Vec<u32>,
443 _rows: usize,
444 _last_pair_key: Option<usize>,
445 ) -> bool {
446 false
447 }
448
449 /// Release drafter KV blocks that no proposer state owns (a carried entry
450 /// being replaced or dropped).
451 fn free_drafter_kv(&self, _blocks: &[u32]) {}
452
453 /// Append drafter rows at KV slots `row_base ..` with RoPE positions
454 /// `pos_base ..` from `(tokens, hiddens)` pairs — the catch-up feed.
455 /// Returns rows written (0 = unsupported/no-op).
456 #[allow(clippy::too_many_arguments)]
457 fn catchup_drafter(
458 &self,
459 _tokens: &[u32],
460 _hiddens: DevicePtr,
461 _row_base: usize,
462 _pos_base: usize,
463 _state: &mut dyn ProposerState,
464 _ctx: &ForwardContext,
465 _stream: u64,
466 ) -> Result<usize> {
467 Ok(0)
468 }
469
470 /// Propose up to `num_drafts` tokens autoregressively.
471 ///
472 /// # Arguments
473 /// * `last_token` - The last verified token (target model output)
474 /// * `target_hidden` - Target model's hidden states after final norm [1, hidden_size] BF16
475 /// * `position` - Current sequence position (for RoPE)
476 /// * `num_drafts` - Maximum number of draft tokens to produce
477 /// * `state` - Per-sequence proposer state
478 /// * `ctx` - Shared forward context (buffers, gpu, config)
479 /// * `stream` - CUDA stream handle
480 /// * `grammar_bitmask` - Optional XGrammar bitmask (ceil(vocab_size/32) i32
481 /// words). When `Some`, drafts are constrained to tokens the grammar
482 /// accepts at the current matcher position; bit `tok` set ⇒ allowed.
483 /// `None` preserves the unconstrained fast path.
484 /// * `target_hidden_stack` - Optional pointer to a contiguous buffer of
485 /// `5 × target_hidden × bf16` containing the most-recently-decoded
486 /// token's hidden states captured at the drafter's `target_layer_ids`
487 /// (DFlash uses this; MTP ignores). Layout matches vLLM's
488 /// `combine_hidden_states` input: shallow-to-deep concatenation along
489 /// the feature axis.
490 fn propose(
491 &self,
492 last_token: u32,
493 target_hidden: DevicePtr,
494 position: usize,
495 num_drafts: usize,
496 state: &mut dyn ProposerState,
497 ctx: &ForwardContext,
498 stream: u64,
499 draft_embed_target: Option<DevicePtr>,
500 grammar_bitmask: Option<&[i32]>,
501 target_hidden_stack: Option<DevicePtr>,
502 ) -> Result<Vec<u32>>;
503
504 /// Batched cross-sequence propose: draft `num_drafts` tokens for each of
505 /// `n = last_tokens.len()` sequences, reading every drafter weight ONCE
506 /// per draft position instead of once per sequence (the measured C=4
507 /// serialization: 12 x ~5 ms per-seq drafter forwards per batched verify
508 /// step, ~62 ms of the ~180 ms step).
509 ///
510 /// Row i of every slice belongs to sequence i; `target_hiddens[i]` is
511 /// that sequence's accepted-position hidden ([1, hidden] BF16, may be
512 /// non-contiguous across i). Chains autoregressively per sequence like
513 /// `propose` — position j uses (draft_{j-1}, drafter's own hidden row i).
514 ///
515 /// Returns `Ok(None)` when unsupported (caller falls back to the per-seq
516 /// `propose` loop); `Ok(Some(drafts))` with `drafts[i].len() ==
517 /// num_drafts` on success. Grammar-constrained sequences must not reach
518 /// this path (callers gate on grammarless).
519 #[allow(clippy::too_many_arguments)]
520 fn propose_batch(
521 &self,
522 _last_tokens: &[u32],
523 _target_hiddens: &[DevicePtr],
524 _positions: &[usize],
525 _num_drafts: usize,
526 _states: &mut [&mut dyn ProposerState],
527 _ctx: &ForwardContext,
528 _stream: u64,
529 _out_conf: Option<&mut Vec<Vec<f32>>>,
530 ) -> Result<Option<Vec<Vec<u32>>>> {
531 Ok(None)
532 }
533
534 /// The widest batch [`Self::propose_batch`] can carry in ONE drafter
535 /// forward per draft position, derived from this proposer's resolved
536 /// kernels and the arena's row capacities. `1` = per-sequence only.
537 ///
538 /// Callers chunk by this instead of a hardcoded constant: a fixed cap of
539 /// 4 made a 16-sequence step run 4 drafter forwards per position, each
540 /// re-reading the whole drafter — the batched-propose lever's own cost
541 /// re-introduced by its caller.
542 fn propose_batch_max(&self, _buffers: &BufferArena, _config: &ModelConfig) -> usize {
543 1
544 }
545
546 /// Prefill the drafter's own context (KV cache) over the prompt, before
547 /// the first `propose()` of a sequence (ATLAS_MTP_DRAFTER_PREFILL).
548 ///
549 /// * `prompt_tokens` — the prompt token ids `t_0..t_{P-1}`.
550 /// * `hiddens` — device buffer `[P, hidden_size]` BF16; row `i` is the
551 /// target's final-layer (pre-final-norm) hidden after processing `t_i`.
552 ///
553 /// Returns the number of drafter positions written (0 = unsupported /
554 /// already prefilled / nothing to do). Default: no-op.
555 fn prefill_drafter(
556 &self,
557 prompt_tokens: &[u32],
558 hiddens: DevicePtr,
559 state: &mut dyn ProposerState,
560 ctx: &ForwardContext,
561 stream: u64,
562 ) -> Result<usize> {
563 let _ = (prompt_tokens, hiddens, state, ctx, stream);
564 Ok(0)
565 }
566
567 /// Read the draft token ID stored on GPU by the last `propose()` call
568 /// that used `draft_embed_target = Some(...)`. Returns 0 if not supported.
569 fn read_deferred_draft_token(&self, gpu: &dyn GpuBackend) -> Result<u32> {
570 let _ = gpu;
571 Ok(0)
572 }
573
574 /// Called after target verification to trim proposer state.
575 ///
576 /// `num_accepted` indicates how many draft tokens were accepted.
577 /// The proposer should trim its KV cache / state to match.
578 fn after_verify(
579 &self,
580 num_accepted: usize,
581 state: &mut dyn ProposerState,
582 stream: u64,
583 ) -> Result<()>;
584
585 /// Free per-sequence proposer state (KV cache blocks, device buffers, etc.).
586 ///
587 /// Must be called when a sequence is finished to avoid resource leaks.
588 /// `gpu` is threaded in (symmetric with `alloc_state`) so implementations
589 /// can release raw device allocations stored on the state — `DevicePtr`
590 /// has no `Drop`, so anything `alloc_state` allocated leaks unless it is
591 /// explicitly freed here.
592 fn free_state(&self, gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
593 let _ = (gpu, state);
594 Ok(())
595 }
596}