metrale_model_layers/
speculative.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2
3//! 2026-09-25: The draft-proposer interface for speculative decoding
4//! ([`DraftProposer`], [`ProposerState`]), and the MTP knobs, ladder, tree
5//! shape and verify keys in its submodules.
6//!
7//! Owner: model-layers (speculative).
8//! Invariants: none beyond the types.
9
10pub mod draft_stop;
11pub mod ladder;
12pub mod tree_shape;
13pub mod verify_key;
14
15pub use ladder::{
16    DEFAULT_MTP_MAX_SEQS, MAX_MTP_MAX_SEQS, MtpMaxSeqsSource, ladder_drafts_from_steps,
17    mtp_ladder_disabled, mtp_ladder_drafts, mtp_ladder_pinned, mtp_max_seqs, parse_mtp_k_ladder,
18    resolve_mtp_max_seqs, set_mtp_k_ladder, set_mtp_max_seqs,
19};
20mod knobs;
21pub use knobs::{
22    EP_CMD_MTP_PROPOSE, hidden_fingerprint, mtp_accept_debug, mtp_catchup_enabled,
23    mtp_ep_propose_enabled, mtp_multi_seq_mode, mtp_refeed_accepted_enabled, mtp_refeed_debug,
24    mtp_refeed_shift, shadow_topk,
25};
26
27use std::any::Any;
28
29use anyhow::Result;
30use metrale_config::ModelConfig;
31use metrale_gpu_runtime::buffers::BufferArena;
32use metrale_gpu_runtime::gpu::{DevicePtr, GpuBackend};
33
34use crate::layer::ForwardContext;
35
36/// 2026-09-25: Per-sequence state of a [`DraftProposer`], reached through
37/// `as_any` downcasts like `LayerState`.
38pub trait ProposerState: Send + Sync {
39    fn as_any(&self) -> &dyn Any;
40    fn as_any_mut(&mut self) -> &mut dyn Any;
41}
42
43/// 2026-09-25: A draft-token proposer. The model calls `propose` for draft
44/// tokens, verifies them with the target, then calls `after_verify` with the
45/// accepted count so the proposer can trim its state.
46pub trait DraftProposer: Send + Sync {
47    /// 2026-09-25: Allocate per-sequence proposer state.
48    fn alloc_state(&self, gpu: &dyn GpuBackend) -> Result<Box<dyn ProposerState>>;
49
50    /// 2026-09-25: [`Self::alloc_state`] given the sequence's token budget
51    /// (prompt length plus `max_tokens`), so state that scales with context
52    /// can be sized to the request instead of `--max-seq-len`. `usize::MAX`
53    /// means unknown. The default ignores the budget.
54    fn alloc_state_for(
55        &self,
56        gpu: &dyn GpuBackend,
57        budget_tokens: usize,
58    ) -> Result<Box<dyn ProposerState>> {
59        let _ = budget_tokens;
60        self.alloc_state(gpu)
61    }
62
63    /// 2026-09-25: The block size γ of a block-diffusion (DFlash) drafter;
64    /// `None` for other proposers. With `--dflash`, serve sets the draft count
65    /// to γ - 1, at least 1.
66    fn block_gamma(&self) -> Option<usize> {
67        None
68    }
69
70    /// 2026-09-25: Rows of `mtp_prefill_hidden` this proposer can use, given
71    /// the served `--max-seq-len`; the model sizes that `[rows, hidden]` BF16
72    /// buffer from it. The default is `max_seq_len`.
73    ///
74    /// Return less only when the proposer can never be given a later
75    /// position. A smaller cap corrupts nothing: a prompt longer than the
76    /// buffer is not fully captured, so its drafter prefill is skipped, which
77    /// costs acceptance.
78    fn prefill_hidden_rows(&self, max_seq_len: usize) -> usize {
79        max_seq_len
80    }
81
82    /// 2026-09-25: Whether this proposer's block is sharded across ranks, so
83    /// its forward is given the communicator. The default is false; the
84    /// GLM-5.3 MTP head returns [`mtp_ep_propose_enabled`]. When it is true on
85    /// a multi-rank serve, the model sends the worker [`EP_CMD_MTP_PROPOSE`]
86    /// before each propose, so both ranks run the same forward and
87    /// collectives.
88    fn needs_comm(&self) -> bool {
89        false
90    }
91
92    /// 2026-09-25: Whether this proposer's context prefill uses the shared
93    /// forward scratch (`ctx.buffers`). If so, the model skips the prefill at
94    /// the end of the target's prefill, while the target still owns those
95    /// buffers, and it runs at the first `propose` instead. Measured
96    /// 2026-08-29 on GLM-5.3 (2x GB10): running it at the end of prefill
97    /// changed the target's output on 2 of 6 probes.
98    fn prefill_uses_shared_buffers(&self) -> bool {
99        false
100    }
101
102    /// 2026-09-25: Drafter KV rows held in `state`; 0 when unknown.
103    fn drafter_rows(&self, _state: &mut dyn ProposerState) -> usize {
104        0
105    }
106
107    /// 2026-09-25: Sequence-space pair key of the newest drafter row; `None`
108    /// when untracked. Rows are compacted, so the row count does not give the
109    /// sequence position.
110    fn last_pair_key(&self, _state: &mut dyn ProposerState) -> Option<usize> {
111        None
112    }
113
114    /// 2026-09-25: Move this sequence's drafter KV blocks out of its state, so
115    /// `free_state` does not release them and the model can carry them to the
116    /// next turn. Returns `(blocks, rows, last_pair_key)`, or `None` when
117    /// unsupported or empty. Afterwards the state must behave as freshly
118    /// allocated.
119    fn take_drafter_kv(
120        &self,
121        _state: &mut dyn ProposerState,
122    ) -> Option<(Vec<u32>, usize, Option<usize>)> {
123        None
124    }
125
126    /// 2026-09-25: Install carried blocks into a fresh state, the inverse of
127    /// [`Self::take_drafter_kv`]. Returns false when unsupported; the caller
128    /// then still owns the blocks and must free them.
129    fn install_drafter_kv(
130        &self,
131        _state: &mut dyn ProposerState,
132        _blocks: Vec<u32>,
133        _rows: usize,
134        _last_pair_key: Option<usize>,
135    ) -> bool {
136        false
137    }
138
139    /// 2026-09-25: Release drafter KV blocks that no proposer state owns, such
140    /// as a carried entry being replaced or dropped.
141    fn free_drafter_kv(&self, _blocks: &[u32]) {}
142
143    /// 2026-09-25: Append drafter rows at KV slots `row_base ..` with RoPE
144    /// positions `pos_base ..` from `(tokens, hiddens)` pairs. Returns the
145    /// rows written; the default writes none.
146    #[allow(clippy::too_many_arguments)]
147    fn catchup_drafter(
148        &self,
149        _tokens: &[u32],
150        _hiddens: DevicePtr,
151        _row_base: usize,
152        _pos_base: usize,
153        _state: &mut dyn ProposerState,
154        _ctx: &ForwardContext,
155        _stream: u64,
156    ) -> Result<usize> {
157        Ok(0)
158    }
159
160    /// 2026-09-25: Propose up to `num_drafts` tokens autoregressively.
161    ///
162    /// - `last_token`: the last verified token.
163    /// - `target_hidden`: the target's `[1, hidden_size]` BF16 hidden for it,
164    ///   saved before the final norm.
165    /// - `position`: the sequence position, for RoPE.
166    /// - `grammar_bitmask`: when `Some`, one bit per token id in i32 words;
167    ///   drafts are limited to tokens whose bit is set.
168    /// - `target_hidden_stack`: the model's DFlash hidden capture, when it has
169    ///   one; the MTP head ignores it.
170    fn propose(
171        &self,
172        last_token: u32,
173        target_hidden: DevicePtr,
174        position: usize,
175        num_drafts: usize,
176        state: &mut dyn ProposerState,
177        ctx: &ForwardContext,
178        stream: u64,
179        draft_embed_target: Option<DevicePtr>,
180        grammar_bitmask: Option<&[i32]>,
181        target_hidden_stack: Option<DevicePtr>,
182    ) -> Result<Vec<u32>>;
183
184    /// 2026-09-25: Propose `num_drafts` tokens for each of
185    /// `last_tokens.len()` sequences at once. Index i of every slice belongs to
186    /// sequence i; `target_hiddens[i]` is its `[1, hidden]` BF16 hidden, and
187    /// the pointers need not be contiguous. There is no grammar mask.
188    ///
189    /// `Ok(None)` when unsupported, and the caller proposes per sequence;
190    /// otherwise `drafts[i]` holds sequence i's drafts.
191    #[allow(clippy::too_many_arguments)]
192    fn propose_batch(
193        &self,
194        _last_tokens: &[u32],
195        _target_hiddens: &[DevicePtr],
196        _positions: &[usize],
197        _num_drafts: usize,
198        _states: &mut [&mut dyn ProposerState],
199        _ctx: &ForwardContext,
200        _stream: u64,
201        _out_conf: Option<&mut Vec<Vec<f32>>>,
202    ) -> Result<Option<Vec<Vec<u32>>>> {
203        Ok(None)
204    }
205
206    /// 2026-09-25: The widest batch [`Self::propose_batch`] accepts; 1 means
207    /// per-sequence only. The scheduler groups batched proposes by it.
208    fn propose_batch_max(&self, _buffers: &BufferArena, _config: &ModelConfig) -> usize {
209        1
210    }
211
212    /// 2026-09-25: Append one drafter row per accepted draft of the last
213    /// verify, for several sequences; the model calls it only with
214    /// `ModelLevers::mtp_kv_exact` on. `tokens[i]` are sequence i's accepted
215    /// drafts, `hiddens[i][k]` the target hidden paired with `tokens[i][k]`,
216    /// and `first_pos[i]` the RoPE position of `tokens[i][0]`. Returns the rows
217    /// written; the default writes none.
218    fn catchup_batch(
219        &self,
220        _tokens: &[Vec<u32>],
221        _hiddens: &[Vec<DevicePtr>],
222        _first_pos: &[usize],
223        _states: &mut [&mut dyn ProposerState],
224        _ctx: &ForwardContext,
225        _stream: u64,
226    ) -> Result<usize> {
227        Ok(0)
228    }
229
230    /// 2026-09-25: Prefill the drafter's KV over the prompt before the
231    /// sequence's first `propose`. `hiddens` is `[P, hidden_size]` BF16, row
232    /// `i` the target's hidden for `prompt_tokens[i]` before the final norm.
233    /// Returns the drafter rows written; the default writes none.
234    fn prefill_drafter(
235        &self,
236        prompt_tokens: &[u32],
237        hiddens: DevicePtr,
238        state: &mut dyn ProposerState,
239        ctx: &ForwardContext,
240        stream: u64,
241    ) -> Result<usize> {
242        let _ = (prompt_tokens, hiddens, state, ctx, stream);
243        Ok(0)
244    }
245
246    /// 2026-09-25: The draft token id the last `propose` with
247    /// `draft_embed_target` left on the device; the default returns 0.
248    fn read_deferred_draft_token(&self, gpu: &dyn GpuBackend) -> Result<u32> {
249        let _ = gpu;
250        Ok(0)
251    }
252
253    /// 2026-09-25: Trim the proposer's state after a verify that accepted
254    /// `num_accepted` drafts.
255    fn after_verify(
256        &self,
257        num_accepted: usize,
258        state: &mut dyn ProposerState,
259        stream: u64,
260    ) -> Result<()>;
261
262    /// 2026-09-25: Free per-sequence proposer state when a sequence finishes.
263    /// `DevicePtr` has no `Drop`, so device memory `alloc_state` allocated
264    /// leaks unless it is freed here; the default frees nothing.
265    fn free_state(&self, gpu: &dyn GpuBackend, state: &mut dyn ProposerState) -> Result<()> {
266        let _ = (gpu, state);
267        Ok(())
268    }
269
270    /// 2026-09-29: This proposer's draft head as the circuit executor binds it; `None` for a
271    /// proposer the circuit does not model.
272    fn circuit_draft(
273        &self,
274        _config: &ModelConfig,
275        _levers: &crate::layers::ops::ModelLevers,
276    ) -> Option<crate::circuit_exec::DraftBinding> {
277        None
278    }
279
280    /// 2026-09-29: Run `runner` in place of the draft head's own forward from now on, or its
281    /// own forward again with `None`. A proposer without a circuit binding ignores it.
282    fn set_circuit_draft(
283        &self,
284        _runner: Option<std::sync::Arc<dyn crate::circuit_exec::DraftRunner>>,
285    ) {
286    }
287}