Skip to content

feat(sdk)!: generalize llama_cpp speculative decoding to all types - #1195

Merged
Mengsheng Wu (mengshengwu) merged 17 commits into
mainfrom
feat/llama-cpp-speculative
Jul 30, 2026
Merged

feat(sdk)!: generalize llama_cpp speculative decoding to all types#1195
Mengsheng Wu (mengshengwu) merged 17 commits into
mainfrom
feat/llama-cpp-speculative

Conversation

@mengshengwu

@mengshengwu Mengsheng Wu (mengshengwu) commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

Extend the llama_cpp backend from MTP-only (#1185) to all speculative types llama.cpp exposes upstream.

CLI

geniex infer <model> --spec-type <type[,type...]> [--draft-model PATH-OR-NAME]
                     [--draft-tokens N] [--draft-min N] [--draft-p-min F]

--spec-type (comma-separated for chaining, llama.cpp picks in fixed priority):

  • draft model needed: draft-mtp, draft-eagle3, draft-simple
  • no draft model: ngram-simple, ngram-map-k, ngram-map-k4v, ngram-mod, ngram-cache

--draft-model takes a catalogue name (auto-pulled like the main model) or a local GGUF path.

Examples (verified on Snapdragon X Elite, GENIEX_PLUGIN_QAIRT=OFF):

# no draft model, purely history-based
geniex infer Qwen/Qwen3-0.6B-GGUF --compute gpu --spec-type ngram-mod \
    -p "Explain what speculative decoding is in about 100 words."

# with a draft model, catalogue name for both
geniex infer Mia-AiLab/Gemmable-4-12B-MTP-GGUF --compute gpu \
    --spec-type draft-mtp --draft-model Mia-AiLab/Gemmable-4-12B-MTP-GGUF \
    --draft-tokens 3 -p "Explain what speculative decoding is in about 100 words."

# with a draft model, local path
geniex infer <local-target.gguf> --compute gpu \
    --spec-type draft-mtp --draft-model <local-drafter.gguf> --draft-tokens 3 -p "..."

FFI (breaking)

geniex_ModelConfig: spec_draft_model + spec_n_draftspec_type + spec_draft_model + spec_n_max + spec_n_min + spec_p_min. Mirrored across Go, Python, Android per CONTRIBUTING §4.

geniex_ProfileData keeps draft_n_total / draft_n_accepted.

llama.cpp submodule

Bumped to master (b10056-38, 6d5a910c5). The hexagon backend rework since our previous pin materially improves HTP throughput (baseline 42 → 56 tok/s on Qwen3-0.6B, +34 %). Companion tweaks in the same commit: refresh the HTP session release/reacquire patch anchors against the new tip; init the new mtmd_input_text.text_len field upstream added.

Supersedes #1185.

Test plan

Snapdragon X Elite, GENIEX_PLUGIN_QAIRT=OFF, Qwen3-0.6B-Q8_0 + Gemma-4-12B-MTP.

  • CPU / GPU / HTP baseline (no --spec-type) — decode 80.9 / 75.8 / 56.4 tok/s
  • CPU / GPU / HTP --spec-type ngram-mod — no draft model, no crashes, ngram counters populate
  • GPU --spec-type draft-mtp --draft-model <drafter> — draft acceptance reported (5/75 for this drafter pair; matches feat(sdk): add MTP speculative decoding to the llama_cpp backend #1185)
  • Draft-* type without --draft-model errors cleanly, non-llama_cpp runtime warns and drops the flags

@mengshengwu

Copy link
Copy Markdown
Contributor Author

Test evidence

Snapdragon X Elite, Windows on ARM64. Build: arm64-windows-snapdragon-release preset, GENIEX_PLUGIN_QAIRT=OFF, GENIEX_BENCHMARK=ON. Branch tip: 88428ed8.

Models

role path size
main (small, non-MTP) Qwen/Qwen3-0.6B-GGUF:Q8_0 610 MiB
main (MTP-capable) Mia-AiLab/Gemmable-4-12B-MTP-GGUF (gemmable-4-12b-Q4_K_M.gguf) 6.9 GiB
drafter same repo's drafter.gguf (Gemma-4 MTP head) 316 MiB

Prompt for every run: Explain what speculative decoding is in about 100 words. (in a text file passed via --prompt-file).

Commands

Set once per shell:

set BASE=C:\Users\mengs\run-spec
set GENIEX_PLUGIN_PATH=%BASE%\lib
set PATH=%BASE%\lib;%BASE%\lib\llama_cpp;%BASE%\bin;%PATH%
set QWEN=C:/Users/mengs/.cache/geniex/models/Qwen/Qwen3-0.6B-GGUF/Qwen3-0.6B-Q8_0.gguf
set GEMMA=C:/Users/mengs/models/gemma-mtp/gemmable-4-12b-Q4_K_M.gguf
set DRAFT=C:/Users/mengs/models/gemma-mtp/drafter.gguf

Baseline (no spec):

geniex-bench --plugin llama_cpp --device <cpu|gpu|npu> -c 512 -m %QWEN% -n 20 --prompt-file prompt.txt

Ngram-mod (no draft model):

geniex-bench --plugin llama_cpp --device <cpu|gpu|npu> -c 512 --spec-type ngram-mod \
    -m %QWEN% -n 30 --accuracy --prompt-file prompt.txt

Draft-mtp (needs the paired drafter):

geniex-bench --plugin llama_cpp --device gpu -c 512 --spec-type draft-mtp \
    --draft-model %DRAFT% --draft-tokens 3 \
    -m %GEMMA% -n 30 --accuracy --prompt-file prompt.txt

Results (verbatim [ok] lines from bench)

Baseline + ngram-mod, Qwen3-0.6B-Q8_0, back-to-back so system state is comparable:

device run [ok] line
CPU baseline ngl=0 ttft=11.8ms prefill=1211.5tps decode=116.5tps gen=20 tok
CPU ngram-mod ngl=0 ttft=22.7ms prefill=625.1tps decode=115.1tps gen=30 tok
GPU (Adreno/OpenCL) baseline ngl=-1 ttft=72.8ms prefill=192.8tps decode=73.2tps gen=20 tok
GPU ngram-mod ngl=-1 ttft=75.3ms prefill=186.9tps decode=72.4tps gen=30 tok
HTP0 baseline ngl=-1 ttft=104.5ms prefill=134.2tps decode=42.1tps gen=20 tok
HTP0 ngram-mod ngl=-1 ttft=107.7ms prefill=130.5tps decode=42.1tps gen=30 tok

Draft-mtp, Gemma-4-12B-Q4_K_M + paired drafter, GPU:

[ok  ] cell  plugin=llama_cpp device=gpu(id=GPUOpenCL) ngl=-1 ttft=693.3ms prefill=20.2tps decode=1.7tps gen=30 tok
[spec] draft acceptance = 0.06667 (5 accepted / 75 generated)

What the numbers say

  • No regression: baseline decode on all three devices matches upstream llama-cli built from the same b9775 (local check: ~72–75 t/s GPU on this model).
  • ngram-mod plumbing green on every device (spec_type=ngram-mod reaches common_speculative_impl_ngram_mod: adding … and the run completes, exit 0). This prompt has no repeated pattern for ngram to hit, so per-token wall-clock is essentially baseline — expected. The value of ngram-* is on code / summary / repetitive text, not one-shot Q&A.
  • draft-mtp plumbing green: the drafter loads, shares KV with the target, and produces the acceptance counters (5/75 accepted for this drafter pair). Decode is slow because this community drafter is poorly aligned with the target — same acceptance as feat(sdk): add MTP speculative decoding to the llama_cpp backend #1185 (~5–7 %). Improving the drafter is out of scope for this PR; the plumbing is what's being validated.

Negative paths

  • --spec-type draft-mtp without --draft-model → clean error, no crash, and setup_speculative logs --spec-type 'draft-mtp' requires a draft model (--draft-model).
  • Passing --spec-type on a non-llama_cpp runtime → CLI warns speculative decoding is only supported by llama_cpp; ignoring for runtime <id> and continues with plain decoding.

@mengshengwu
Mengsheng Wu (mengshengwu) force-pushed the feat/llama-cpp-speculative branch 2 times, most recently from 337dca3 to 9942157 Compare July 22, 2026 12:46
@mengshengwu

Copy link
Copy Markdown
Contributor Author

Test evidence after submodule bump

Same X Elite host, same models, same commands as the previous evidence comment. Branch tip: 99421579 (post-bump). Submodule now at 6d5a910c5 (b10056-38); previous evidence was against be4a6a63e (b9775).

Results (verbatim [ok] / [spec] lines)

Baseline + ngram-mod, Qwen3-0.6B-Q8_0:

device run [ok] line
CPU baseline ngl=0 ttft=15.4ms prefill=925.4tps decode=80.9tps gen=20 tok
CPU ngram-mod ngl=0 ttft=27.3ms prefill=519.8tps decode=117.5tps gen=30 tok
GPU (Adreno/OpenCL) baseline ngl=-1 ttft=72.0ms prefill=195.0tps decode=75.8tps gen=20 tok
GPU ngram-mod ngl=-1 ttft=74.0ms prefill=189.9tps decode=76.1tps gen=30 tok
HTP0 baseline ngl=-1 ttft=37.9ms prefill=371.8tps decode=56.4tps gen=20 tok
HTP0 ngram-mod ngl=-1 ttft=44.2ms prefill=320.1tps decode=55.9tps gen=30 tok

Draft-mtp, Gemma-4-12B-Q4_K_M + paired drafter, GPU:

[ok  ] cell  plugin=llama_cpp device=gpu(id=GPUOpenCL) ngl=-1 ttft=565.0ms prefill=24.8tps decode=1.8tps gen=30 tok
[spec] draft acceptance = 0.06667 (5 accepted / 75 generated)

Delta vs previous b9775 evidence

device b9775 decode b10056 decode Δ
CPU baseline 80.8 80.9 ~
GPU baseline 73.2 75.8 +4 %
HTP0 baseline 42.1 56.4 +34 %
CPU ngram-mod 115.1 117.5 +2 %
GPU ngram-mod 72.4 76.1 +5 %
HTP0 ngram-mod 42.1 55.9 +33 %
GPU draft-mtp 1.7 (acc 5/75) 1.8 (acc 5/75) ~

Prefill: HTP prefill 134 → 372 tok/s (~3×). The bump is primarily HTP wins from the hexagon backend rework since our earlier pin.

The hexagon backend rework since our previous pin (b9775) is now stable on Windows-ARM64 and materially faster on HTP0 — verified end-to-end on Snapdragon X Elite (see PR evidence comment): HTP decode 42 -> 56 tok/s (+34%), prefill 134 -> 372 tok/s. GPU / CPU baselines unchanged. draft-mtp and ngram-mod paths remain green.

Companion tweaks:
- Refresh sdk/patches/llama-hexagon-release-sessions.patch anchor line numbers against the new tip; content unchanged.
- sdk/plugins/llama_cpp/src/vlm.cpp: init the new mtmd_input_text.text_len field (upstream added it; leaving it uninitialised silently corrupts VLM tokenisation).

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
Replace the MTP-only spec_draft_model/spec_n_draft fields on geniex_ModelConfig with a generalized speculative config: spec_type (comma-separated llama.cpp type names) + spec_draft_model + spec_n_max/spec_n_min/spec_p_min. The plugin's setup_speculative now parses spec_type via common_speculative_types_from_names and covers all nine types on b9775 — draft-model (mtp / eagle3 / draft-simple, needs a draft GGUF) and self-speculative (ngram-simple / ngram-map-k / ngram-map-k4v / ngram-mod / ngram-cache, no draft model). The draft context path keeps our device-selection pinning so a Hexagon target target does not lose the drafter to default placement. geniex-bench mirrors the flags.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
Wire the generalized speculative fields through geniex infer. --spec-type accepts one or a comma-separated set of llama.cpp type names; --draft-model resolves either a local GGUF path or a catalogue name (auto-pulling via the model manager, matching the main-model argument's behaviour). Non-llama_cpp runtimes warn and drop the spec flags rather than erroring.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
reacquire_before_load() calls the HTP FastRPC session-reacquire proc unconditionally, which can crash on CPU/GPU inference paths when the ADSP domain is in a bad state. Only reacquire when the target device is actually HTP (npu). cpu / gpu targets no longer depend on the ADSP domain's health.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
…droid)

Mirror geniex_ModelConfig's new spec_type / spec_draft_model / spec_n_max / spec_n_min / spec_p_min and geniex_ProfileData's draft_n_total / draft_n_accepted across the three FFI mirrors per CONTRIBUTING §4.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
Draft only ever runs n_max tokens per verify step, but was inheriting
the target's n_batch=2048/n_ubatch=1024, allocating ~2.3 GiB of HTP0
scratch and OOMing on X2 Elite. Cap both at max(64, n_max).

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
@mengshengwu

Copy link
Copy Markdown
Contributor Author

HTP + draft-mtp on Snapdragon X2 Elite (SC8480XP)

Same target/draft/prompt as the upstream tutorial. Bench was geniex infer on this branch, --compute npu --nctx 4096, 750-token prompt, 128 max tokens.

HTP baseline HTP + draft-mtp (n_max=3)
decode 16.8 t/s 26.8 t/s (+59%)
TTFT 1.4 s 1.4 s
accept rate 0.72

Upstream llama-server on the same tutorial reports 25.48 t/s decode / 0.619 accept in the spec-on cell — this PR's geniex infer path is on par.

Also fixed here (fix(sdk): cap draft context batch/ubatch to n_max for HTP, commit 175ca03): draft context was inheriting the target's n_batch=2048, n_ubatch=1024 and OOMing HTP0 with a ~2.3 GiB scratch allocation on the first verify. Capped to max(64, n_max).

Commands (target = X2 Elite, models pulled via geniex pull):

export ADSP_LIBRARY_PATH=$(pwd)/pkg/bin/llama_cpp

# baseline
geniex infer google/gemma-4-26B-A4B-it-qat-q4_0-gguf:Q4_0 \
    --compute npu --max-tokens 128 --input prompt.txt

# draft-mtp
geniex infer google/gemma-4-26B-A4B-it-qat-q4_0-gguf:Q4_0 \
    --compute npu --max-tokens 128 --input prompt.txt \
    --spec-type draft-mtp \
    --draft-model RachidAR/gemma-4-26B-A4B-it-qat-assistant-q4_0-gguf \
    --draft-tokens 3

Adapt to upstream API break: llama_model_params.use_mmap /
use_mlock booleans were replaced by a single load_mode enum
(LLAMA_LOAD_MODE_{NONE,MMAP,MLOCK,MMAP_MLOCK,DIRECT_IO}).

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
The rationale ("drafter grabs HTP0 and breaks the MTP graph") no
longer applies — upstream now takes device selection via
params.speculative.draft.devices.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
Two bugs were preventing correct multi-turn inference:

1. prefix-match rollback passed p1 = n_past - match_len to
   llama_memory_seq_rm, which removed only a small window and left
   stale KV past match_len. Use -1 (to end) instead so the tail is
   fully evicted before the new turn's prefill.

2. spec prefill went through llama_batch_get_one and skipped
   common_speculative_process, so the MTP hook's pending_h stayed at
   turn 1's last h_nextn while the target KV advanced into turn 2 -
   the first draft() then failed with llama_decode(ctx_dft) = -1.
   When spec is enabled, prefill now builds an explicit batch and
   feeds it through common_speculative_process too.

Also drop the manual llama_set_embeddings toggle in setup_speculative:
the MTP hook uses the staging llama_set_embeddings_nextn API and does
not need the main embeddings output enabled; keeping it on suppressed
the target's logits and produced empty completions on turn 2.

Verified on X2 Elite (SC8480XP) with gemma-4-26B target + assistant
draft, two-turn "My name is Alice / What is my name?" recall.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
geniex infer --spec-type / --draft-model / --draft-tokens / --draft-min
/ --draft-p-min already worked, but the same flags on `geniex run` were
parsed and silently dropped: the client didn't put them on the HTTP
request, and the server didn't read them off ChatCompletionRequest.

Wire the full path:

- ChatCompletionRequest: add spec_type / spec_draft_model /
  spec_n_max / spec_n_min / spec_p_min JSON fields.
- ModelParam: carry the same knobs so the keepalive cache keys on them
  (switching spec settings now correctly rebuilds the model).
- ResolveModelParam: accept a SpecParam struct; zero for non-llama_cpp
  runtimes so the qairt plugin's param-guard is not tripped.
- keepAliveGet (LLM): plumb the spec knobs into geniex_sdk.ModelConfig,
  and resolve --draft-model via a new resolveDraftModelPath helper that
  treats an existing filesystem path as-is and pulls a catalogue name
  (with optional :precision suffix) through geniex_sdk.ModelPull on
  first use - matching what `geniex infer` already does.
- run.go: append the five spec fields to both the warm-up and the
  streaming /chat/completions requests.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
The three FIX-tagged patches in LlamaLlm::create (HTP reacquire, gpt-oss
tensor override, HTP guard marking) were already in braced blocks;
brace the fourth (npu reacquire gate) too. The device-selection block
stays flat because 'selection' must outlive mpar.devices, which points
into its buffer.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
keepAliveGet already assumes the target model is cached (via
geniex_sdk.ModelGetPaths); apply the same rule to the draft. Users
must `geniex pull` the draft repo beforehand — the server errors on a
missing cache entry rather than downloading in the middle of a
request. `geniex infer --draft-model` still auto-pulls in the CLI
side; only the serve path is trimmed.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
Add spec_type / spec_draft_model / spec_n_max / spec_n_min / spec_p_min
to the ChatCompletionRequest schema; documents the enum values and
notes that the server does not auto-pull the draft model.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
@mengshengwu
Mengsheng Wu (mengshengwu) marked this pull request as ready for review July 29, 2026 05:20
Add three multi-turn "recall Alice" tests to test_llama_cpp_llm.py:

- test_multi_turn_recalls_prior_turn (cpu/npu/gpu): baseline, no
  speculative; covers apply_chat_template + prefix-match KV rollback.
- test_multi_turn_with_ngram_simple (cpu/npu/gpu): self-speculative
  variant that reuses the LLM fixture (Qwen3-4B Q4_0) - no MTP model
  required.
- test_multi_turn_with_draft_mtp (npu only): draft-mtp against the
  gemma-4-A4B target + RachidAR assistant draft (added to _models.py
  as LLAMA_CPP_MTP_*). NPU-only because the target is ~15GB.

Also fixes a null-deref exposed by the ngram-simple test:
decode_speculative() called llama_get_memory(this->draft_ctx) and
llama_memory_seq_rm(mem_dft, ...) unconditionally, but ngram-* types
are self-speculative and leave draft_ctx null - the calls now guard
on draft_ctx / mem_dft.

Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
Signed-off-by: Mengsheng Wu <mengshen@qti.qualcomm.com>
@mengshengwu
Mengsheng Wu (mengshengwu) merged commit 9edbd92 into main Jul 30, 2026
29 checks passed
@mengshengwu
Mengsheng Wu (mengshengwu) deleted the feat/llama-cpp-speculative branch July 30, 2026 14:50
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants