1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
//! Multi-Token Prediction (MTP / NextN) support.
//!
//! ik_llama.cpp's MTP is an **embedded** mechanism: a single model carrying
//! NextN prediction layers (loaded with [`crate::LlamaModelParams::with_mtp`]),
//! driven by a low-level op-type state machine plus a `common_speculative` driver.
//!
//! * [`MtpOpType`] + [`crate::LlamaContext::set_mtp_op_type`] expose the raw
//! op-type primitive (available without `common`).
//! * [`MtpSpeculative`] (requires the **`common`** feature) is the full
//! draft/accept driver, backed by a C++ glue over ik's `common_speculative_*`
//! (`ik_llama_rs_mtp_*`). It reproduces the native `--spec-type mtp` flow.
use ik_llama_cpp_sys as sys;
/// MTP decode-graph operation mode (maps to ik's `llama_mtp_op_type`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MtpOpType {
/// Normal decode (no MTP graph).
None,
/// Prompt warmup — populate MTP KV + hidden state (no logits).
Warmup,
/// Advance MTP state after accepted tokens.
UpdateAccepted,
/// Generate draft tokens from the NextN head.
DraftGen,
}
impl MtpOpType {
/// Raw `llama_mtp_op_type` value.
#[must_use]
pub fn to_raw(self) -> sys::llama_mtp_op_type {
match self {
MtpOpType::None => sys::MTP_OP_NONE,
MtpOpType::Warmup => sys::MTP_OP_WARMUP,
MtpOpType::UpdateAccepted => sys::MTP_OP_UPDATE_ACCEPTED,
MtpOpType::DraftGen => sys::MTP_OP_DRAFT_GEN,
}
}
}
/// Parameters for MTP speculative decoding. Mirrors the shape of llama-cpp-2's
/// `MtpSpeculativeParams` (plus `temp`/`mtp_heads` for ik's driver).
#[derive(Debug, Clone, Copy)]
pub struct MtpSpeculativeParams {
/// Maximum draft tokens proposed per step. Defaults to `2`, the throughput
/// sweet spot measured on a 1-NextN-layer model (IQ4_K, ~1.2x over the plain
/// engine); higher values draft deeper but acceptance falls off for a
/// single-head NextN model. `n_max=1` = one NextN head.
pub n_max: i32,
/// Minimum draft tokens per step.
pub n_min: i32,
/// Minimum draft acceptance probability (0.0 = never early-stop drafting).
pub p_min: f32,
/// MTP heads to use (1 = default; >1 experimental).
pub mtp_heads: i32,
/// Target-sampler temperature (<= 0 = greedy).
pub temp: f32,
}
impl Default for MtpSpeculativeParams {
fn default() -> Self {
Self {
n_max: 2,
n_min: 0,
p_min: 0.0,
mtp_heads: 1,
temp: 0.0,
}
}
}
#[cfg(feature = "common")]
pub use driver::{MtpSpeculative, MtpStep};
#[cfg(feature = "common")]
mod driver {
use super::MtpSpeculativeParams;
use ik_llama_cpp_sys as sys;
use std::ptr::NonNull;
use crate::context::LlamaContext;
use crate::model::LlamaModel;
use crate::token::LlamaToken;
use crate::LlamaError;
/// Result of one [`MtpSpeculative::step`].
#[derive(Debug, Clone)]
pub struct MtpStep {
/// Tokens emitted this step — append to the output; each token is emitted
/// exactly once across steps.
pub tokens: Vec<LlamaToken>,
/// Number of draft tokens accepted this step (0..=n_max).
pub n_accepted: i32,
}
/// Full MTP speculative driver (requires the `common` feature).
///
/// Wraps ik's `common_speculative_*` via the `ik_llama_rs_mtp_*` C++ glue.
/// Two ways to drive it after `new` → `begin(prompt)`:
///
/// * **`step()`** — the convenience path: the glue owns the whole
/// draft→verify→**sample**→commit cycle (greedy / `temp`), returning the
/// committed tokens. Use this when you don't need to constrain sampling.
/// * **`draft()` + `commit()`** — the caller-driven path: `draft` returns the
/// NextN candidates without sampling; you build the verify batch
/// `[id_last] + drafts` (logits on every position), decode it on
/// [`target_context_mut`](Self::target_context_mut), pick each committed
/// token yourself (e.g. through a [`crate::LlamaGrammar`] + sampler over
/// [`target_context`](Self::target_context)'s logits), then `commit` them.
/// This is what lets MTP speculation run **together with** grammar-
/// constrained / custom sampling — the crate never samples on this path.
///
/// Either way ik owns all KV bookkeeping (the companion cache and the target
/// rollback happen inside the glue); the caller must not clear or roll back
/// either cache itself.
#[derive(Debug)]
pub struct MtpSpeculative<'model> {
// NOTE: `raw` (the C driver) references `ctx`, so it must be freed first.
// Fields drop in declaration order, but `Drop::drop` (which frees `raw`)
// runs before any field is dropped, so `ctx` is still alive then. Keep
// `raw` declared before `ctx` regardless, for clarity.
raw: NonNull<sys::ik_llama_rs_mtp>,
ctx: LlamaContext<'model>,
params: MtpSpeculativeParams,
}
impl<'model> MtpSpeculative<'model> {
/// Initialize the driver over a NextN model + its (mtp-enabled) context.
///
/// Takes ownership of `ctx` (so the driver is self-contained and can be
/// stored in a struct); read/decode it back out via
/// [`target_context`](Self::target_context) /
/// [`target_context_mut`](Self::target_context_mut).
///
/// `model` must have been loaded with `.with_mtp(true)` and `ctx` created
/// with `.with_mtp(true)`. Fails with [`LlamaError::MtpInit`] for a model
/// with 0 NextN layers (not an MTP model). Recurrent / openPangu targets
/// (which includes the Qwen3.5 NextN family) are supported: the driver
/// takes an internal rollback checkpoint before each verify so rejected
/// drafts restore the recurrent state correctly.
pub fn new(
model: &LlamaModel,
ctx: LlamaContext<'model>,
params: MtpSpeculativeParams,
) -> Result<Self, LlamaError> {
let cparams = ctx.raw_params; // the exact params ctx was built with
// SAFETY: valid model/ctx; cparams matches ctx; glue validates preconditions.
let raw = unsafe {
sys::ik_llama_rs_mtp_init(
model.model.as_ptr(),
ctx.context.as_ptr(),
&cparams,
params.n_max,
params.n_min,
params.p_min,
params.mtp_heads,
params.temp,
)
};
NonNull::new(raw)
.map(|raw| Self { raw, ctx, params })
.ok_or(LlamaError::MtpInit)
}
/// Prompt warmup + begin. Runs the prompt through the MTP path and prepares
/// the first draft. Returns `n_past`. Call once before [`Self::step`].
pub fn begin(&mut self, prompt: &[LlamaToken]) -> Result<usize, LlamaError> {
let toks: Vec<sys::llama_token> = prompt.iter().map(|t| t.0).collect();
// SAFETY: valid handle + token buffer.
let n =
unsafe { sys::ik_llama_rs_mtp_begin(self.raw.as_ptr(), toks.as_ptr(), toks.len()) };
if n < 0 {
return Err(LlamaError::MtpBegin(n as i32));
}
Ok(n as usize)
}
/// Run one draft→verify→accept→commit cycle. Returns the tokens emitted
/// this step (to append to the output) and how many drafts were accepted.
pub fn step(&mut self) -> Result<MtpStep, LlamaError> {
let cap = self.params.n_max.max(1) as usize + 2;
let mut out = vec![0 as sys::llama_token; cap];
let mut n_out: usize = 0;
let mut n_accepted: i32 = 0;
// SAFETY: out has `cap` slots; n_out/n_accepted are valid out-params.
let st = unsafe {
sys::ik_llama_rs_mtp_step(
self.raw.as_ptr(),
out.as_mut_ptr(),
cap,
&mut n_out,
&mut n_accepted,
)
};
if st as i32 != 0 {
// LLAMA_RS_STATUS_OK == 0
return Err(LlamaError::MtpStep(st as i32));
}
out.truncate(n_out);
Ok(MtpStep {
tokens: out.into_iter().map(LlamaToken).collect(),
n_accepted,
})
}
/// Propose up to `n_max` draft tokens following `id_last` (the last
/// committed token) at position `n_past`, **without sampling**.
///
/// This is the caller-driven entry point for running MTP speculation
/// alongside a grammar / custom sampler (see the module example). After
/// `draft`, the caller must:
/// 1. build the verify batch `[id_last] + drafts` (one sequence, logits on
/// **every** position), in that order;
/// 2. decode it on [`target_context_mut`](Self::target_context_mut);
/// 3. pick the committed tokens with its own grammar/sampler, reading
/// output index `i` via [`target_context`](Self::target_context) for
/// each verify position `i`;
/// 4. call [`commit`](Self::commit).
///
/// Use one round strictly as `draft` → verify-decode → `commit`: `commit`
/// takes its anchor position from the `n_past` you pass here, so do not
/// `draft` twice before committing, and build the verify batch with
/// `id_last` at exactly `n_past`. Do not mix this path with
/// [`step`](Self::step) on the same driver — they own the loop state
/// differently.
///
/// # Errors
///
/// [`LlamaError::MtpStep`] if the C glue rejects the draft.
pub fn draft(
&mut self,
n_past: i32,
id_last: LlamaToken,
) -> Result<Vec<LlamaToken>, LlamaError> {
let cap = self.params.n_max.max(1) as usize;
let mut out = vec![0 as sys::llama_token; cap];
let mut n_out: usize = 0;
// SAFETY: `out` has `cap` slots; `n_out` is a valid out-param.
let st = unsafe {
sys::ik_llama_rs_mtp_draft(
self.raw.as_ptr(),
n_past,
id_last.0,
out.as_mut_ptr(),
cap,
&mut n_out,
)
};
if st as i32 != 0 {
return Err(LlamaError::MtpStep(st as i32));
}
out.truncate(n_out);
Ok(out.into_iter().map(LlamaToken).collect())
}
/// Finalize one speculative round after the caller has decoded the verify
/// batch and chosen its committed tokens.
///
/// `id_last` is the anchor token that led the verify batch (the same one
/// passed to [`draft`](Self::draft)); `committed` is the accepted-draft
/// prefix followed by exactly **one** correction/bonus token (so
/// `committed.len() == n_accepted + 1`); `n_draft` is the number of tokens
/// the matching [`draft`](Self::draft) returned.
///
/// Advances the MTP companion by the accepted count and rolls the target
/// KV cache back to the committed prefix. The caller must **not** clear or
/// roll back either KV cache itself — ik owns that bookkeeping.
///
/// # Errors
///
/// [`LlamaError::MtpStep`] if `committed` is empty, longer than
/// `n_draft + 1` (more tokens than the verify batch produced — which would
/// desync the KV cache), or the glue rejects the commit.
pub fn commit(
&mut self,
id_last: LlamaToken,
committed: &[LlamaToken],
n_draft: usize,
) -> Result<(), LlamaError> {
// `committed` is the accepted-draft prefix (<= n_draft) plus exactly
// one correction/bonus token, so it can never exceed `n_draft + 1`.
// Rejecting a longer slice stops a caller from advancing `n_past` past
// positions the verify batch never decoded (a silent KV desync).
if committed.is_empty() || committed.len() > n_draft + 1 {
return Err(LlamaError::MtpStep(-1));
}
let ids: Vec<sys::llama_token> = committed.iter().map(|t| t.0).collect();
// SAFETY: `ids` is a valid `ids.len()`-long token array for the call.
let st = unsafe {
sys::ik_llama_rs_mtp_commit(
self.raw.as_ptr(),
id_last.0,
ids.as_ptr(),
ids.len(),
n_draft,
)
};
if st as i32 != 0 {
return Err(LlamaError::MtpStep(st as i32));
}
Ok(())
}
/// The configured parameters.
#[must_use]
pub fn params(&self) -> MtpSpeculativeParams {
self.params
}
/// Shared access to the target context — read per-position logits here for
/// a grammar-gated commit (e.g. [`LlamaContext::token_data_array_ith`]).
#[must_use]
pub fn target_context(&self) -> &LlamaContext<'model> {
&self.ctx
}
/// Mutable access to the target context — decode the verify batch here.
pub fn target_context_mut(&mut self) -> &mut LlamaContext<'model> {
&mut self.ctx
}
/// Mutable access to the wrapped context (only between full generations).
///
/// Alias of [`target_context_mut`](Self::target_context_mut), kept for
/// back-compat.
pub fn context_mut(&mut self) -> &mut LlamaContext<'model> {
&mut self.ctx
}
}
impl Drop for MtpSpeculative<'_> {
fn drop(&mut self) {
// SAFETY: frees the common_speculative + common_sampler owned by the
// glue. Runs before `self.ctx` is dropped, so the glue's reference to
// the target context is still valid here.
unsafe { sys::ik_llama_rs_mtp_free(self.raw.as_ptr()) };
}
}
}