Skip to content

spec: MTP draft-head subset + even drafts / small-batch verify for qwen4exp - #108

Open
gaetan-puleo wants to merge 252 commits into
masterfrom
split/pr58-mtp-draft-vocab
Open

gaetan-puleo wants to merge 252 commits into
masterfrom
split/pr58-mtp-draft-vocab

Conversation

@gaetan-puleo

Copy link
Copy Markdown
Collaborator

Summary

Speculative-decoding (MTP) improvements for qwen4exp (Qwen3.8-Flash-Next UD-IQ4_XS) on gfx1151. Two kinds of change: the --spec-draft-mtp-vocab draft-head row subset (previously accepted only for qwen35/qwen35moe), and three fixes that make MTP actually pay off at several sequences and on small verify steps.

Measured on the same machine, server interleaved A/B, medians.

Changes

  1. Enable the MTP draft-head row subset for qwen4exp (src/llama-model.cpp, src/models/qwen4exp.cpp). --spec-draft-mtp-vocab <n> scores drafted tokens against only the first n rows of the target LM head (a load-time row copy, no GGUF/weight change) and scatters them into a full-vocab -inf row. On a qwen4exp MTP sidecar the flag used to be silently ignored, leaving the draft on the full 248k-row Q6_K head. N=65536 is the sweet spot.
  2. Even-length MTP drafts across sequences (STRIX_SPEC_EVEN_DRAFTS, default on; common/speculative.cpp, tools/server/server-context.cpp). The target's hybrid memory splits a batch into ubatches that need the same token count per sequence, so drafts of different lengths turned one verify into extra whole-model passes, each a fresh graph. All drafting sequences now pad to the longest confident run. Multi-sequence aggregate: 31.4 -> 36.3 t/s (adaptive).
  3. Vector-kernel chunks for the small-batch MoE router and experts (STRIX_F32_VEC_CHUNK_MAX=32, STRIX_MOE_VEC_CHUNK=4 / _MAX_T=16; ggml/src/ggml-cuda/ggml-cuda.cu). A 5-16 token verify step's F32 router [2560x512] and its routed experts (past the vector limits: IQ3_S 4, IQ4_NL 6) fell to tiny GEMM/MMQ tiles; they now run the vector kernel over column chunks. Multi-sequence -np 3: 37.0 -> 42.7 t/s.
  4. Small products of a verify step off one-workgroup kernels (STRIX_Q_VEC_CHUNK_MAX/ROWS, STRIX_CONCAT_T_MIN; ggml-cuda.cu, concat.cu). Quant weights of <=1024 output rows (hc_down [10240->320], attn k/v, shared-expert gate/up) chunked through the vector kernel, and the GDN conv concat uses the tiled transpose from 2 tokens. Multi-sequence np=3: +2.6 %.

Results

area before after delta
MTP multi-sequence (-np 3) 37.05 t/s 44.3 t/s +19.6 % (now level with plain batched decode, 45.2)
MTP multi-sequence, adaptive drafts 31.4 t/s 36.3 t/s +16 %
MTP single-stream, --spec-draft-mtp-vocab 65536 38.0 t/s 40.0 t/s +5.9 % (measured on the same harness)

Correctness

  • The row-subset change is numerically inert on the default path (no subset) and byte-identical output with the subset on 3 prompts (same content sha1), acceptance unchanged at N=65536.
  • The three speculative fixes change which kernel small batches take, not the arithmetic; identical content sha1 on single-sequence, and on the measured multi-sequence outputs.
  • test-backend-ops test -b ROCm0 -o MUL_MAT,MUL_MAT_ID: 14577/14577.

Not included

The adaptive/cost-aware draft path (#102) is intentionally not part of this change.

Note

This branch was split out cleanly: the non-speculative decode kernels (Q8_0 deep-prefetch, HIP graph keys, IQ3_S vec-dot) live on the decode PR #106, not here.

Laurent Zuijdwijk and others added 30 commits August 27, 2026 20:22
Removed the prohibition on using AI for writing posts.
Revise AI usage guidelines in CONTRIBUTING.md
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…#6)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…ngs, grace, usage telemetry) (#5)

Co-authored-by: voidsurfer <18734507+voidsurfer@users.noreply.github.com>
…#4)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: voidsurfer <18734507+voidsurfer@users.noreply.github.com>
Co-authored-by: Marshall <assistant@llama.cpp>
Co-authored-by: Harkirat Gill <harkirat.gill@amd.com>
Co-authored-by: Bartowski <3266127+bartowski1182@users.noreply.github.com>
Co-authored-by: Xuan Son Nguyen <son@huggingface.co>
Co-authored-by: Aparna M P <aparmp@qti.qualcomm.com>
Co-authored-by: cqderek <cqderek@gmail.com>
Co-authored-by: Daniele Zannotti <d.zannotti@me.com>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Ruixiang Wang <wangruixiang07@outlook.com>
Co-authored-by: Sigbjørn Skjæret <sigbjorn.skjaeret@huggingface.co>
Co-authored-by: Georgi Gerganov <ggerganov@gmail.com>
Co-authored-by: Brad Smith <1472326+infinitewarp@users.noreply.github.com>
Co-authored-by: Nathanw1014 <67372905+Nathanw1014@users.noreply.github.com>
Co-authored-by: Titaniumtown <titaniumtown@proton.me>
Co-authored-by: Ozymandias_EBON <112784549+johnkarlhill@users.noreply.github.com>
Co-authored-by: Zijun Yu <zijun.yu@intel.com>
Co-authored-by: Mostafa Faheem <mostafaaafaheem@gmail.com>
Co-authored-by: Ravi Panchumarthy <ravi.panchumarthy@intel.com>
Co-authored-by: zhaixuejun1993 <xuejun.zhai@intel.com>
Co-authored-by: Strongtut <Strongtut@users.noreply.github.com>
Co-authored-by: Strongtut <8432058+Strongtut@users.noreply.github.com>
Co-authored-by: ravel7524 <58877666+ravel7524@users.noreply.github.com>
Co-authored-by: Jeff Bolz <jbolz@nvidia.com>
Co-authored-by: Tekin Ertekin <tekin.ertekin@gmail.com>
Co-authored-by: Eric A Stalee <87948564+Eric-A-Stalee@users.noreply.github.com>
Co-authored-by: kurquhar <kurquhar@qti.qualcomm.com>
Co-authored-by: Kristopher Urquhart <kurquhar@qti.qualcom.com>
Co-authored-by: Jhen-Jie Hong <iainst0409@gmail.com>
Co-authored-by: Nick Farrell <nick.farrell@aiven.io>
Co-authored-by: Niklas Wenzel <dev@nikwen.de>
Co-authored-by: Hongqiang Wang <wangh@qti.qualcomm.com>
Co-authored-by: Li He <lih@qti.qualcomm.com>
Co-authored-by: codemonkey <441345965@qq.com>
Co-authored-by: Aman Gupta <amangupta052@gmail.com>
Co-authored-by: fairydreaming <166155368+fairydreaming@users.noreply.github.com>
Co-authored-by: Stanisław Szymczyk <sszymczy@gmail.com>
Co-authored-by: AesSedai <7980540+AesSedai@users.noreply.github.com>
Co-authored-by: QuintinShaw <github@xyt.email>
Co-authored-by: Max Krasnyansky <maxk@qti.qualcomm.com>
Co-authored-by: Marco Colombo <mcolombo@qti.qualcomm.com>
Co-authored-by: Alexander Lu <alexlu@qti.qualcomm.com>
Co-authored-by: Ehsan Bateni <ebateni@qti.qualcomm.com>
Co-authored-by: Ryan C <ryan5rdx@users.noreply.github.com>
Co-authored-by: Alessandro de Oliveira Faria (A.K.A.CABELO) <cabelo@opensuse.org>
Co-authored-by: Daya Adianto <addianto@users.noreply.github.com>
Co-authored-by: Nils Gladitz <nilsgladitz@gmail.com>
Co-authored-by: JamePeng <jame_peng@sina.com>
Co-authored-by: LunalFresh <165352784+LunalFresh@users.noreply.github.com>
Co-authored-by: itterative <190138728+itterative@users.noreply.github.com>
Co-authored-by: Pascal <admin@serveurperso.com>
Co-authored-by: Marshall <assistant@llama.cpp>
#11)

Co-authored-by: Marshall <assistant@llama.cpp>
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
#13)

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
…UM_COLS > 4) (#15)

Co-authored-by: Marshall <assistant@llama.cpp>
Co-authored-by: Marshall <assistant@llama.cpp>
Tune MMQ and MMVQ scheduling, add routed expert kernels, share Q8_1 activation quantization, and add measured four-column kernels for quant formats that outperform the generic path on gfx1151.

Assisted-by: OpenCode
Add gfx1151 paths for paired matmuls, SwiGLU quantization, expert aggregation, contiguous binary operations, transposed concat, normalization, and conversion operations.

Assisted-by: OpenCode
Dequantize Q8 KV data directly in tiles, specialize GQA decode grouping, reduce D256 register pressure, and reuse masks across grouped heads on gfx1151.

Assisted-by: OpenCode
Increase gfx1151 column parallelism, tile KDA inputs in LDS, preserve recurrent graph ordering, and schedule compatible Qwen projections together.

Assisted-by: OpenCode
Use full MTP rollback checkpoints and retain speculative sequence state on the device to avoid unnecessary host transfers.

Assisted-by: OpenCode
pwilkin and others added 21 commits September 26, 2026 15:44
init_tensor zeroed the padding of quantized tensors with a synchronous
cudaMemset. On HIP, a synchronous memset or memcpy fails with
hipErrorStreamCaptureImplicit while any stream in the process is
capturing a graph, in every capture mode including relaxed, and it
invalidates that capture too (CHECK_STREAM_CAPTURING in clr). CUDA only
rejects this for blocking streams. test-backend-ops -j <n> allocates
tensors on one worker while another captures, so with GGML_HIP_GRAPHS
every -j run aborted after a random number of cases ("operation would
make the legacy stream depend on a capturing blocking stream" from
init_tensor, then "operation failed due to a previous error during
capture" from hipStreamEndCapture). Upstream master has the same memset
and aborts the same way.

Use cudaMemsetAsync on cudaStreamPerThread plus a sync of that stream,
as memset_tensor and set_tensor already do. Thread-local capture mode
would not help: HIP applies the check in every mode.

Checked on gfx1151 (ROCm 7.15 runtime), test-backend-ops -b ROCm0 on
halo-box master 0fc7226: without the fix -j 8 aborted in 2/2 runs,
and passed 29800/29800 with GGML_CUDA_DISABLE_GRAPHS=1; with the fix
-j 8 passed 29800/29800 in 5/5 runs with graphs on (about 10.5k captures
per run), and serial passed 29800/29800. One other serial run failed
only ADD_ADD(type=f16,type_addend=f16,ne=[64,5,4,3]) on NMSE 1.03e-7 >
1e-7; that case fails 5/100 runs without the fix and 3/100 on upstream
master too.

Assisted-by: Claude Opus 5.5
cuda: enable the BF16 WMMA matmul path only for qwen4exp
fix: guard the qwen4exp PLE prefetch on the model architecture (SIGSEGV at ubatch >= 4096)
The non-hipCUB fallback for rows wider than 1024 used an atomic gather for scores equal to the k-th largest. QSA scores often tie at exactly zero, so different schedules selected different blocks and changed long-context results. Use the existing stable-tie gather and cover the 1025/2047/2048/2049-column boundary with exact selected-index checks.

Assisted-by: OpenCode (gpt-6-sol)
When the allocator reuses dead logits storage for routing outputs, decline the fusion only if non-logit inputs overlap. Copy contiguous logits to the device pool before the fused kernel when needed; otherwise unrelated buffer placement chooses between fused and unfused routing and changes the model output. Existing TOPK_MOE_LIFETIME scheduler-allocation tests cover the production 512-expert shape and alias layouts.

Assisted-by: OpenCode (gpt-6-sol)
The detached prefetch thread captured the model and hparams by reference; a context can release them before prefetch finishes. Hold a shared pointer to the disk table and copy the small immutable hash arrays into the thread. This addresses the detached-thread lifetime hazard without changing row selection or prefetch I/O. The reported dense-model crash in issue #79 occurs before thread launch and is not claimed fixed here.

Assisted-by: OpenCode (gpt-6-sol)
With --spec-draft-mtp-vocab N, the MTP context copies the LM head rows of token ids < N plus control and user-defined tokens into a smaller head when it is created. The qwen35 and qwen35moe MTP graphs compute the draft logits over that head and scatter them into a full-vocabulary row of -inf (ggml_fill + ggml_set_rows). Only the draft changes: the draft cannot propose other tokens, verification is unchanged.

The subset is built only when token ids follow BPE merge order, so that low ids are the frequent tokens; otherwise the option is ignored with an error. Probe contexts with unallocated weights skip the build.

New field llama_context_params::mtp_draft_vocab (0 = off).

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC
The subset was stored in mutable llama_model state by the first MTP context that asked for one, and the qwen35 and qwen35moe MTP graphs used it whenever it existed. A second context over the same model with mtp_draft_vocab = 0, or a different N, silently drafted over the first context's subset.

The subset is now an immutable llama_mtp_draft_vocab obtained with llama_model::mtp_draft_vocab_get(n_keep). The model keeps weak references keyed by (n_keep, buffer type) under a mutex, so contexts that ask for the same N at the same time share one copy, and it is freed with the last context holding it. Each context holds its own reference and passes it to its graphs through llm_graph_params; the MTP graphs use only that one, and graph reuse compares it. llama_cparams::mtp_draft_vocab records the N actually in use (0 when no subset was built: probe contexts with unallocated weights, a tokenizer that fails the merge-order guard, or a model whose MTP block has its own LM head, which now logs a warning instead of building an unused subset).

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC
Contexts over one model with different mtp_draft_vocab settings (N then 0, 0 then N, N1 then N2, the same N twice, two contexts created from two threads at once, a subset requested again after its last context was freed) are decoded interleaved with the same inputs. A context with N > 0 must have -inf exactly outside its own draft vocabulary and match a full-vocabulary context inside it; a context with N = 0 must have finite logits everywhere.

Needs a qwen35 or qwen35moe model with an MTP head (-m), so it is built but not registered with ctest.

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC
err(a, b) receives the backend under test as a and the CPU reference as b.
The stable-tie check read b, so it validated the CPU's selection and passed
regardless of what ROCm returned.

Assisted-by: Claude Opus 5.5
Claude-Session: https://claude.ai/code/session_014qCLEUsSbxtwb71y5qRXAi
spec : MTP draft vocabulary subset --spec-draft-mtp-vocab (split 3/4 of #58)
qwen4exp: keep detached PLE prefetch inputs alive
hip: keep topk-moe fusion stable across buffer aliasing
hip: make tied TOP_K block selection deterministic
The HC residual has an extra stream dimension; using ggml_nrows to decide when its BF16-only store is safe applies the mark to small microbatches and makes the output depend on scheduler split placement. Check the actual token dimension for batches of at most 512, then allow incremental indexer-key updates through 512 tokens. QSA_FAST_MAX=127 retains the full re-pool comparison path. With the MoE alias fix as the base, 256- and 512-token runs at 4096 context produced identical probability files with both paths.

Assisted-by: OpenCode (gpt-6-sol)
A residual whose next fused combine lands in a later split used to lose
its BF16-only mark: ggml_cuda_marks_readers_local only sees one split, so
precision depended on where the input-count cuts (GGML_SCHED_MAX_SPLIT_INPUTS)
fell. The QSA fast path adds three host inputs per layer and moves those
cuts, which is what made 128..512-token incremental keys disagree with a
full re-pool. Optimize still runs per split, but marks live for the whole
graph and every split is optimized before any is computed, so carry the
remaining reader count forward (mmb_res16_pending) and mark from the split
that completes the check. Restores the res16 store at 512-token ubatches
that 063d804 had disabled and removes the now-redundant ne[2] test.

Add test-qsa-fast: CPU-only regression for the lifted qsa_fast gate
(size boundaries 1..2048, incremental block selection, prefix/commit/
truncate gates, QSA_FAST_MAX parsing).

Validated on gfx1151 (ROCm 10): fast vs full re-pool logits byte-identical
at ub 64/128/256/512/1024/4096, with and without split cuts; ctest -R
^test-qsa passes; test-backend-ops MUL_MAT/GET_ROWS passes.

Assisted-by: OpenCode (kimi-k3)
qwen4exp: stabilize 128–512-token incremental QSA keys
--spec-draft-mtp-vocab was accepted for qwen35/qwen35moe only, so on a
qwen4exp MTP sidecar the flag was silently ignored and every drafted token
was scored against the full 248k-row Q6_K LM head.

Allow LLM_ARCH_QWEN4EXP in mtp_draft_vocab_get and add the row-subset head
branch to graph_mtp, mirroring qwen35moe: score build_lora_mm on the runtime
subset (rows copied from the existing head at load - no file or weight change)
and scatter its logits into a full-vocab -inf row, so token ids and the
verification path stay unchanged.

Measured on Qwen3.8-Flash-Next UD-IQ4_XS + mtp-...-Q4_K_M sidecar, 128
greedy tokens, same-session A/B: 38.0 -> 41.5 t/s at N=65536 (+8%), identical
output and acceptance. N=131072 is slower than the control; N=32768/16384 are
about equal with a small acceptance loss. With the subset the draft phase is
340 ms of ~3.4 s and the target verify is the remainder.
The target hybrid memory splits a verify batch into ubatches with an equal
number of tokens per sequence, so unequal drafts (e.g. 2,2,1) become extra
whole-model passes. The MTP drafter now pads an unconfident sequence and cuts
all drafts to the longest confident run; the server cuts every draft to the
shortest when a slot drafted nothing (unless one replays accepted tokens).

STRIX_SPEC_EVEN_DRAFTS=0 restores the previous behaviour. Single sequence is
unchanged (even only applies to >1 drafting sequence).

Measured, 4 sequences adaptive drafting, 192 tokens each, same build A/B:
  even off: 31.39 31.51 31.17 tok/s summed
  even on : 34.18 36.36 38.24 tok/s summed
A verify step of several conversations carries 5-16 tokens, one more than the
vector kernel takes for some types. The F32 MoE router [2560x512] at 9-32
columns now runs MMVF over column chunks (STRIX_F32_VEC_CHUNK_MAX, default 32)
instead of the GEMM path; quantized routed experts at 5-16 tokens run MMVQ over
chunks (STRIX_MOE_VEC_CHUNK, default 4; STRIX_MOE_VEC_CHUNK_MAX_T, default 16)
instead of the tiled MMQ kernel. Batches of 1-4 tokens and >32 columns are
unchanged.

test-backend-ops MUL_MAT,MUL_MAT_ID: OK (ROCm0, 14576/14577, one flaky q4_1
case which also fails intermittently on the control build).

Measured, 3 sequences fixed drafting, 128 tokens each, same build A/B:
  before: 37.3 / 35.4 tok/s summed (patch 1 only)
  after : 43.1 / 42.2 tok/s summed
Single sequence (4-token verify) byte-exact unchanged.
Quantized weights of at most STRIX_Q_VEC_CHUNK_ROWS (1024) output rows at 9-32
columns (the HC down projection, attention k/v, shared expert) now run MMVQ over
8-column chunks instead of a handful of MMQ tiles (STRIX_Q_VEC_CHUNK_MAX=0
disables). Taller weights stay on MMQ.

The GDN conv input concat takes the tiled transpose from 2 tokens on RDNA3.5 F32
(STRIX_CONCAT_T_MIN, default 2) instead of the non-contiguous kernel.

The patch's BF16 vec-chunk hunk does not map: our tree has no combined
vec_chunks block, MMB is disabled below T=512 and BF16 small batches use MMF.

Measured, 3 sequences fixed drafting, 384 tokens each, same build A/B:
  off: 46.6 / 50.3 tok/s summed
  on : 48.9 / 50.5 tok/s summed
Single sequence byte-exact unchanged.

@dzannotti dzannotti left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tested on Strix Halo (gfx1151, HIP), base ec01a7dfc vs this PR. Our Flash-Next GGUF has no MTP head, so the speculative part was measured on Qwen3.6-35B-A3B (MTP).

3-slot MTP serving (-np 3 --spec-type draft-mtp --spec-draft-n-max 3, llama-benchy pp512/tg256, concurrency 3, ABBA):

base PR Δ
decode throughput 109.2 t/s 125.6 t/s +15.0%
prefill throughput 1174 t/s 1134 t/s −3.4%
TTFT 1317 ms 1386 ms +5.3%

Non-speculative batches (no draft model; -r 8 ABBA, 16 samples per arm, slowest quarter dropped as warm-up):

tokens Qwen3-1.7B Q8_0 (dense) gpt-oss-20b MXFP4 (MoE)
4 – 167.8 → 167.1 (−0.4%)
8 – 228.7 → 225.1 (−1.6%)
9 759.0 → 749.4 (−1.3%) –
12 – 360.5 → 294.1 (−18.4%)
16 1334.6 → 1287.6 (−3.5%) 452.6 → 327.2 (−27.7%)
24 1458.6 → 1432.5 (−1.8%) –
32 1910.6 → 1818.3 (−4.8%) –

Regressions:

  • Chunked dispatch, not gated: the chunked MMVQ / F32 vector / MoE vector dispatch in ggml-cuda.cu applies to every CUDA/HIP device (only the concat change checks RDNA3.5). It slows models it was not tuned for, including a 28% loss on gpt-oss at 16 tokens, a common multi-slot and verify width.
  • Even drafts: default-on in both common/speculative.cpp and the server, for every spec type and architecture.

Please gate these to the measured target, and make even drafts opt-in.

Minor: 7 new STRIX_* env vars are parsed with unvalidated atoi, and concat.cu calls getenv on every concat.


Comment generated via automated review of clanker Claude Code.

@Nathanw1014

Copy link
Copy Markdown

Nice one. One catch we hit: with the shared Flash-Next MTP sidecar (Unsloth's mtp-...-shared), --spec-draft-mtp-vocab quietly does nothing. That file has no output.weight of its own and borrows the target's LM head, so the subset never gets built or used.

The fix is small: take the subset rows from the target's head when the draft has none, and use the subset whenever the MTP block scores with the model head. With it, decode goes from 32.5 to 34.3 t/s on a Strix Halo (Vulkan), with the same acceptance.

It also needs a one-line rebase after #111, which renamed the batch size accessor in common/speculative.cpp:

            if (batch.size() == 0 || (even && n_confident == 0)) {

I have both on a branch (your four commits rebased on master, plus the fix as a fifth). Happy to push it here if that's easiest, or I can leave suggestions instead.

Assisted-by: Claude Opus 5.5

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.