LlamaCppEx.MTP (LlamaCppEx v0.8.45)

Copy Markdown View Source

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

t()

@type t() :: %LlamaCppEx.MTP{
  main_ctx: LlamaCppEx.Context.t(),
  mtp_ctx: LlamaCppEx.Context.t(),
  n_draft: pos_integer(),
  spec_ref: reference()
}

Functions

generate(mtp, prompt, opts \\ [])

@spec generate(t(), String.t(), keyword()) :: {:ok, String.t()} | {:error, term()}

Synchronously generates text. Equivalent to running stream/3 and joining the pieces into a single binary.

Accepts the same options as stream/3.

init(model, opts \\ [])

@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 separate LlamaCppEx.Model holding 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.gguf plus mtp-Qwen3.8-27B-Q4_0.gguf). It must be loaded with load_mtp: true. Defaults to nil, 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 to 3. 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 to 2048.
  • Any LlamaCppEx.Context option (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}.

stats(mtp)

@spec stats(t()) :: map()

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}. :ckpt is 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. :other is 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.

stream(mtp, prompt, opts \\ [])

@spec stream(t(), String.t(), keyword()) :: Enumerable.t()

Returns a lazy stream of generated text pieces.

Options

  • :max_tokens - Maximum tokens to generate (default 256).
  • :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 this String.t() stream — to consume them use stream_events/3 instead. Default 0 (off).
  • :timeout - Receive timeout in milliseconds (default 60_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.

stream_events(mtp, prompt, opts \\ [])

@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.