Multi-Token Prediction (MTP) speculative decoding.
Drives a target/draft speculative loop where the draft model is an MTP head —
either embedded in the target GGUF, or shipped beside it as a sidecar file. On
Qwen 3.6 with n_draft: 3 this typically yields ~2x token-generation
throughput at ~75% draft acceptance.
Usage
:ok = LlamaCppEx.init()
{:ok, model} = LlamaCppEx.load_model("Qwen3.6-35B-A3B-MTP-Q4_K_M.gguf",
n_gpu_layers: 999, load_mtp: true)
{:ok, mtp} = LlamaCppEx.MTP.init(model, n_draft: 3, n_ctx: 8192)
mtp
|> LlamaCppEx.MTP.stream("Write a haiku about the sea:", max_tokens: 200)
|> Stream.each(&IO.write/1)
|> Stream.run()
stats = LlamaCppEx.MTP.stats(mtp)
IO.puts("acceptance: #{Float.round(stats.acceptance_rate * 100, 1)}%")Where the MTP head lives
The head is a set of *.nextn_predict_layers and comes in two shapes, and
either way the file carrying it must be loaded with load_mtp: true. Upstream
defaults that flag to false so non-speculative callers do not pay for the
head's tensors, and the layers cannot be attached afterwards, so init/2
refuses a model loaded without it.
In the target GGUF (e.g. ggml-org/Qwen3.6-35B-A3B-MTP-GGUF) — pass just
the model, as above.
In a sidecar GGUF — pass it as :draft_model. This is how Qwen 3.8 ships:
Qwen3.8-27B-Q4_K_M.gguf carries no head at all (n_layer_nextn == 0) and
mtp-Qwen3.8-27B-Q4_0.gguf carries nothing else. It is the binding's
equivalent of upstream's -hf <target> -hfd <draft> --spec-type draft-mtp.
{:ok, target} = LlamaCppEx.load_model("Qwen3.8-27B-Q4_K_M.gguf",
n_gpu_layers: 999, load_mtp: true)
{:ok, head} = LlamaCppEx.load_model("mtp-Qwen3.8-27B-Q4_0.gguf",
n_gpu_layers: 999, load_mtp: true)
{:ok, mtp} = LlamaCppEx.MTP.init(target, draft_model: head, n_draft: 1)Speculation is not always a win on hybrid models
A model that mixes recurrent (SSM) layers with attention ones — Qwen 3.8 is
48 SSM layers to 16 attention layers — cannot roll back part of a sequence
natively, so every speculative iteration snapshots and restores the whole
recurrent state. That state is over 100 MiB at Qwen 3.8's sizes, and the
cost lands in stats/1's timing_us.ckpt. Measured on an M1 Max (Metal,
Q4_K_M target + Q4_0 head), MTP was a net slowdown at every draft length:
0.89x at n_draft: 1 (75% acceptance) falling to 0.56x at n_draft: 5
(30%). Check timing_us.ckpt against timing_us.total before assuming
speculation is helping, and prefer small n_draft when it is not.
Upstream currently requires n_parallel = 1 for MTP. This module reflects
that — a single MTP session decodes one sequence at a time. Reuse the same
%MTP{} value across calls to stream/3 / generate/3 to avoid rebuilding
the contexts; KV caches are cleared on each call.
Do not reuse a session straight after abandoning a stream
Cancellation is asynchronous and unacknowledged: abandoning a stream/3
early (Enum.take/2, break, an exception) sets a flag that the draft loop
notices and then exits without reporting that it has done so. A session's two
contexts are long-lived and shared by every call on it, so starting the next
generate/3 or stream/3 immediately can put a second writer on a KV cache
the cancelled loop has not finished with — which aborts the VM rather than
returning an error. Let an abandoned stream run to a terminal event, or build
a fresh session with init/2, before using the session again.
MTP is the only speculative type this binding exposes. Upstream llama.cpp
also implements EAGLE-3, DFlash (block-diffusion drafting), n-gram
self-speculation and combinations of them behind the same
common_speculative API; the NIF pins the MTP type, so --spec-default-style
stacking of n-gram speculation on top of MTP is not reachable from here. See
the "Speculative decoding" section of the README for the current status of
DFlash on Apple Silicon.
Summary
Functions
Synchronously generates text. Equivalent to running stream/3 and joining
the pieces into a single binary.
Initializes an MTP speculative session: builds the target context, the MTP
draft context (ctx_type: :mtp), and the underlying common_speculative
state.
Writes upstream's own speculative stats summary to stdout (via llama.cpp logging). Useful when cross-checking acceptance rates against the upstream llama-server benchmark output.
Returns the current MTP statistics snapshot (lock-free read of atomic counters). Safe to call at any time — including from another process while a stream is in flight.
Returns a lazy stream of generated text pieces.
Like stream/3, but yields the raw event tuples emitted by the NIF
Types
@type t() :: %LlamaCppEx.MTP{ main_ctx: LlamaCppEx.Context.t(), mtp_ctx: LlamaCppEx.Context.t(), n_draft: pos_integer(), spec_ref: reference() }
Functions
Synchronously generates text. Equivalent to running stream/3 and joining
the pieces into a single binary.
Accepts the same options as stream/3.
@spec init( LlamaCppEx.Model.t(), keyword() ) :: {:ok, t()} | {:error, term()}
Initializes an MTP speculative session: builds the target context, the MTP
draft context (ctx_type: :mtp), and the underlying common_speculative
state.
Options
:draft_model- A separateLlamaCppEx.Modelholding the MTP head, for checkpoints that ship it as a sidecar GGUF rather than inside the target file (Qwen 3.8 is the current example:Qwen3.8-27B-Q4_K_M.ggufplusmtp-Qwen3.8-27B-Q4_0.gguf). It must be loaded withload_mtp: true. Defaults tonil, meaning the head is expected inside the target model and the draft context is built against it.:n_draft- Max draft tokens generated per iteration. Defaults to3. Larger values mean fewer model forward passes but lower per-iteration acceptance; 2–4 is the sweet spot in practice.:n_ctx- Context size for both contexts. Defaults to2048.- Any
LlamaCppEx.Contextoption (e.g.:n_threads,:flash_attn,:type_k/:type_v,:offload_kqv). The same options are applied to both the target and draft contexts.
Returns {:ok, %MTP{}} or {:error, reason}.
@spec print_stats(t()) :: :ok
Writes upstream's own speculative stats summary to stdout (via llama.cpp logging). Useful when cross-checking acceptance rates against the upstream llama-server benchmark output.
Returns the current MTP statistics snapshot (lock-free read of atomic counters). Safe to call at any time — including from another process while a stream is in flight.
Returns a map with keys:
:iters- speculative loop iterations completed:drafts_generated- draft tokens proposed by the MTP head:drafts_accepted- draft tokens accepted by the target model:acceptance_rate-drafts_accepted / drafts_generated(0.0–1.0):tokens_emitted- tokens streamed back to the caller:tokens_per_sec- throughput over the active generation window:timing_us-%{draft: μs, verify: μs, sample: μs, ckpt: μs, other: μs, total: μs}.:ckptis the recurrent-state save/restore that only hybrid models pay and is zero elsewhere; on Qwen 3.8 it is large enough to decide whether speculation helps at all.:otheris whatever falls outside the named buckets, dominated on Metal by GPU-sync waits from the previous iteration's async verify decode.:n_draft- max draft length configured at init
Counters are cumulative across all stream/3 / generate/3 calls on this
MTP value.
@spec stream(t(), String.t(), keyword()) :: Enumerable.t()
Returns a lazy stream of generated text pieces.
Options
:max_tokens- Maximum tokens to generate (default256).:emit_stats_every- When > 0, also emits{:stats, snapshot_map}events every Nth token via the underlying message stream. Note: these events are filtered out of thisString.t()stream — to consume them usestream_events/3instead. Default0(off).:timeout- Receive timeout in milliseconds (default60_000).- Any sampler option from
LlamaCppEx.Sampler.create/2(:temp,:top_k,:top_p,:min_p,:seed,:penalty_*,:grammar, etc.).
Each emitted element is the text piece for one accepted token. The stream ends on end-of-generation, max-tokens, or error.
@spec stream_events(t(), String.t(), keyword()) :: Enumerable.t()
Like stream/3, but yields the raw event tuples emitted by the NIF:
{:token, token_id, text_piece}- one accepted token{:stats, snapshot_map}- periodic stats (only when:emit_stats_every > 0){:done, final_stats_map}- generation completed normally{:eog, nil}- model emitted an end-of-generation token
The stream halts after :done / :eog / :error. The final stats map is
available via stats/1 on the MTP struct even after the stream ends.