Skip to content

spec : cost-aware MTP draft length, MTP draft vocabulary subset, flat get_rows and shape-aware graph key - #58

Open
SimonTeixidor wants to merge 5 commits into
halo-box:masterfrom
SimonTeixidor:pr/mtp-costaware-draft
Open

SimonTeixidor wants to merge 5 commits into
halo-box:masterfrom
SimonTeixidor:pr/mtp-costaware-draft

Conversation

@SimonTeixidor

Copy link
Copy Markdown

Branch pr/mtp-costaware-draft (2eeae5f84), 3 commits on halo/master 69946438a.

Overview

  1. 8a1f66cd3 ggml-cuda : flat get_rows for small rows, shape-aware graph cache key. get_rows on float rows with ne00 <= 32 launches over (row, element), not one workgroup per row. The graph key adds node count, last node and three node shapes: graphs sharing a meta arena got one key from nodes[0] and never replayed. Both unconditional.
  2. 94c706a82 spec : MTP draft vocabulary subset (--spec-draft-mtp-vocab N). The MTP head only scores token ids below N, which makes each draft step much cheaper while losing little acceptance. Qwen's BPE token ids follow merge order, so lower ids are roughly the more frequent tokens. On Qwen3.6-35B-A3B with N = 65536, the draft step went from 3.13 to 1.42 ms and acceptance from 74.4 % to 72.5 % (57.07 -> 63.98 t/s). Verification still uses the full vocabulary. For models whose ids do not follow merge order the flag is ignored with an error.
  3. 2eeae5f84 spec : cost-aware MTP draft length (--spec-draft-n-min > 0). Improves on the existing --spec-draft-adaptive, which sizes drafts from measured acceptance, by adding a cost model and per-token acceptance statistics. The draft step time, the verify cost per extra draft position and the time per output token are all measured online, so nothing needs tuning per model or context depth. For every draft token it estimates whether drafting one more is worth it, or whether the extra draft step and verify column are likely to cost more than the expected payoff. Enabled by --spec-draft-n-min, up to --spec-draft-n-max.

Measurements

Device:     HP ZBook Ultra G1a 14 inch Mobile Workstation, AMD RYZEN AI MAX+ PRO 395 w/ Radeon 8060S (gfx1151)
Memory:     64 GB LPDDR5X unified (62 GiB visible to Linux; speed not read, no root)
Power:      AC, ACPI platform_profile=balanced, amdgpu power_dpm_force_performance_level=auto (GPU clocks not pinned)
BIOS:       UMA carve-out 512 MiB VRAM, GTT 60 GiB
Kernel:     7.1.13-200.fc44.x86_64, cmdline amdgpu.gttsize=61440 ttm.pages_limit=15728640 amd_iommu=off
Backend:    ROCm 10.0.0 + patched HIP runtime 7.16.26353-c4b77ac5cb (pwilkin rocm-systems, retained PM4), image localhost/strix-halo:stackv2-2d7825b37 (df09292858be); env HSA_OVERRIDE_GFX_VERSION=11.5.1 GGML_HIP_ENABLE_UNIFIED_MEMORY=1 DEBUG_HIP_GRAPH_PM4=1; HIP_LAUNCH_BLOCKING unset
Build:      localhost/strix-halo:builder (f1db10cdcd29), CMAKE_BUILD_TYPE=Release GGML_HIP=ON GPU_TARGETS=gfx1151 GGML_HIP_GRAPHS=ON GGML_HIP_MMQ_MFMA=ON GGML_HIP_NO_VMM=ON GGML_CUDA_FA=ON LLAMA_BUILD_TESTS/EXAMPLES/TOOLS=ON, -j 20
Baseline:   654803517 (halo/master), built and run in this session
Change:     2eeae5f84 (pr/mtp-costaware-draft)
Model:      Qwen3.8-27B-UD-IQ4_XS.gguf (built-in MTP head, sha256 40fac4050e94...)

Stock 654803517 vs this branch, same image. llama-server with production sampling (temperature 0.6, thinking on), llama-benchy with a 2048-token prompt and 2000 generated tokens, n = 4 per cell in mirrored blocks. Stock --spec-draft-n-min 1 --spec-draft-n-max 10 --spec-draft-adaptive vs this branch --spec-draft-n-min 1 --spec-draft-n-max 10, with and without --spec-draft-mtp-vocab 65536. Each cell: tg t/s (sd), acceptance, accepted tokens per draft step.

depth stock adaptive cost-aware cost-aware + subset
0 20.84 (4.91), 60.9 %, 2.20 24.94 (3.97), 67.8 %, 2.41 26.09 (3.88), 61.3 %, 2.51
32000 14.48 (2.20), 53.3 %, 1.64 18.29 (3.36), 48.8 %, 2.28 22.84 (4.80), 66.8 %, 2.50
vs stock adaptive depth 0 depth 32000
cost-aware +19.7 % [-19.3, +58.6] +26.3 % [-9.4, +62.0]
cost-aware + subset +25.2 % [-13.4, +63.8] +57.7 % [+7.1, +108.3]

The branch is ahead in every cell and gets more accepted tokens per draft step. Only cost-aware + subset at depth 32000 is statistically resolved. With sampled text, acceptance varies from 33 % to 82 % between runs of the same arm.

In an informal test with pi in non-interactive mode on three coding tasks, cost-aware drafting was 11 % faster than stock adaptive: 30.66 t/s against 27.54 t/s.

Correctness:

  • No speculation: byte-identical to stock on 27B (logits and token ids 5/5, KLD-base files 4/4, greedy decode with top-10 log-probs 5/5) and 35B-A3B (3/3, 2/2, 3/3). With identical --spec-type draft-mtp --spec-draft-n-max 3, greedy: tokens, log-probs and draft counters identical on both.
  • New flags, greedy: code identical over 256 tokens, prose diverges at step 61. Verify is not width-invariant on this tree: stock with and without n_max 3 diverge at step 97 on the 13480-token prompt.
  • test-backend-ops -b ROCm0: GET_ROWS 215/215, SET_ROWS 159/159, expansion 4/4, scatter/gather at real shapes 6/6. Scatter vs old gather expansion: identical draft counters and output hashes.
Raw results

llama-benchy --base-url http://127.0.0.1:18091/v1 --model q --pp 2048 --tg 2000 --exact-tg --depth D --runs 2 --no-warmup --skip-coherence --no-adapt-prompt --latency-mode api --tokenizer <local Qwen3.8-27B tokenizer> (0.4.0), depths 0 and 32000, prompts seeded per depth; one warm-up chat request per server. Server: -fa on -c 131072 -b 1024 -ub 1024 --parallel 1 -ngl 999 --load-mode none -ctkd q8_0 -ctvd q8_0, --temp 0.6 --top-p 0.95 --top-k 20 --min-p 0, qwen-sharp.jinja, thinking on, one server per arm. Drafted/accepted are /metrics deltas. Arms ran in two blocks, A and B, in mirrored order.

build arm depth tg t/s pp t/s e2e TTFT ms acceptance (pooled) accepted / step
base adapt 0 20.84 (4.91, 4) 245.0 (11.4, 4) 9050 (422, 4) 60.9 % (5494/9023) 2.20
mtp ctl 0 24.94 (3.97, 4) 262.6 (22.3, 4) 8475 (734, 4) 67.8 % (5649/8331) 2.41
mtp ctlsub 0 26.09 (3.88, 4) 251.8 (6.3, 4) 8796 (221, 4) 61.3 % (5718/9321) 2.51
base adapt 32000 14.48 (2.20, 4) 215.3 (13.4, 4) 159404 (10537, 4) 53.3 % (4969/9330) 1.64
mtp ctl 32000 18.29 (3.36, 4) 236.5 (7.7, 4) 144800 (4667, 4) 48.8 % (5558/11386) 2.28
mtp ctlsub 32000 22.84 (4.80, 4) 232.0 (1.4, 4) 147454 (911, 4) 66.8 % (5712/8555) 2.50
comparison depth stock tg (sd, n) candidate tg (sd, n) gain % 95 % CI % stock acc cand acc
controller only vs stock adaptive 0 20.84 (4.91, 4) 24.94 (3.97, 4) +19.7 -19.3 .. +58.6 60.9 % 67.8 %
controller only vs stock adaptive 32000 14.48 (2.20, 4) 18.29 (3.36, 4) +26.3 -9.4 .. +62.0 53.3 % 48.8 %
controller + subset vs stock adaptive 0 20.84 (4.91, 4) 26.09 (3.88, 4) +25.2 -13.4 .. +63.8 60.9 % 61.3 %
controller + subset vs stock adaptive 32000 14.48 (2.20, 4) 22.84 (4.80, 4) +57.7 +7.1 .. +108.3 53.3 % 66.8 %
subset effect: ctl+sub vs ctl 0 24.94 (3.97, 4) 26.09 (3.88, 4) +4.6 -24.0 .. +33.2 67.8 % 61.3 %
subset effect: ctl+sub vs ctl 32000 18.29 (3.36, 4) 22.84 (4.80, 4) +24.9 -16.3 .. +66.1 48.8 % 66.8 %

Per run:

session depth run tg t/s pp t/s e2e TTFT ms drafted accepted acceptance gen tokens
A-base-adapt 0 1 20.30 253.5 8731 2264 1346 59.5 % 2000
A-base-adapt 0 2 18.78 256.1 8642 2357 1352 57.4 % 2000
A-base-adapt 32000 1 12.38 221.3 154569 2308 1039 45.0 % 2000
A-base-adapt 32000 2 13.48 195.3 175203 2307 1186 51.4 % 2000
B-base-adapt 0 1 27.81 234.5 9441 2103 1594 75.8 % 2000
B-base-adapt 0 2 16.47 235.9 9385 2299 1202 52.3 % 2000
B-base-adapt 32000 1 14.57 222.4 153845 2354 1281 54.4 % 2000
B-base-adapt 32000 2 17.50 222.2 154000 2361 1463 62.0 % 2000
A-mtp-ctl 0 1 30.75 283.0 7824 1940 1522 78.5 % 2000
A-mtp-ctl 0 2 21.84 278.8 7943 2623 1351 51.5 % 2000
A-mtp-ctl 32000 1 22.66 246.1 139020 2057 1469 71.4 % 2000
A-mtp-ctl 32000 2 16.58 239.2 143011 2576 1288 50.0 % 2000
A-mtp-ctlsub 0 1 29.85 247.6 8943 2229 1512 67.8 % 2000
A-mtp-ctlsub 0 2 27.96 258.6 8562 2003 1450 72.4 % 2000
A-mtp-ctlsub 32000 1 26.43 233.9 146246 1942 1528 78.7 % 2000
A-mtp-ctlsub 32000 2 21.63 232.2 147332 2411 1444 59.9 % 2000
B-mtp-ctl 0 1 23.21 235.7 9389 1997 1388 69.5 % 2000
B-mtp-ctl 0 2 23.97 253.1 8745 1771 1388 78.4 % 2000
B-mtp-ctl 32000 1 19.00 230.6 148355 2703 1459 54.0 % 2000
B-mtp-ctl 32000 2 14.92 229.9 148812 4050 1342 33.1 % 2000
B-mtp-ctlsub 0 1 25.71 245.4 9021 2599 1459 56.1 % 2000
B-mtp-ctlsub 0 2 20.86 255.7 8658 2490 1297 52.1 % 2000
B-mtp-ctlsub 32000 1 26.75 231.4 147862 1881 1540 81.9 % 2000
B-mtp-ctlsub 32000 2 16.55 230.6 148376 2321 1200 51.7 % 2000

Prompt processing: llama-bench -m Qwen3.8-27B-UD-IQ4_XS.gguf -p 2048 -d 0,12000,32000 -b 1024 -ub 1024 -n 0 -r 2 -ngl 99 -fa on -ctk f16 -ctv f16 --load-mode none -o jsonl, blocks (stock, branch) and (branch, stock).

invocation test ub t/s sd n mean GPU power/temp
M-1-base pp2048 1024 277.68 1.01 2 45.8W/70C
M-1-base pp2048 @ d12000 1024 249.80 0.36 2 45.8W/70C
M-1-base pp2048 @ d32000 1024 209.29 3.10 2 45.8W/70C
M-2-mtp pp2048 1024 276.39 0.19 2 45.8W/70C
M-2-mtp pp2048 @ d12000 1024 248.55 0.53 2 45.8W/70C
M-2-mtp pp2048 @ d32000 1024 210.86 0.88 2 45.8W/70C
N-1-mtp pp2048 1024 274.28 1.46 2 45.7W/70C
N-1-mtp pp2048 @ d12000 1024 249.10 0.19 2 45.7W/70C
N-1-mtp pp2048 @ d32000 1024 211.32 0.81 2 45.7W/70C
N-2-base pp2048 1024 273.83 2.65 2 45.8W/70C
N-2-base pp2048 @ d12000 1024 246.06 0.71 2 45.8W/70C
N-2-base pp2048 @ d32000 1024 211.67 0.16 2 45.8W/70C
test ub stock t/s (n, sd) build candidate t/s (n, sd) gain % 95 % CI % Welch t resolved
pp2048 1024 275.75 (4, 2.76) mtp 275.34 (4, 1.49) -0.15 -1.73 .. +1.42 -0.27 no
pp2048 @ d12000 1024 247.93 (4, 2.20) mtp 248.82 (4, 0.45) +0.36 -1.08 .. +1.80 +0.80 no
pp2048 @ d32000 1024 210.48 (4, 2.26) mtp 211.09 (4, 0.74) +0.29 -1.50 .. +2.09 +0.52 no

Token generation: same with -p 0 -n 128 -d 0,32000.

invocation test ub t/s sd n mean GPU power/temp
M-1-base tg128 1024 12.99 0.09 2 45.6W/70C
M-1-base tg128 @ d32000 1024 11.31 0.04 2 45.6W/70C
M-2-mtp tg128 1024 13.04 0.00 2 45.5W/70C
M-2-mtp tg128 @ d32000 1024 11.30 0.04 2 45.5W/70C
N-1-mtp tg128 1024 13.04 0.07 2 45.4W/69C
N-1-mtp tg128 @ d32000 1024 11.31 0.03 2 45.4W/69C
N-2-base tg128 1024 12.96 0.07 2 45.3W/69C
N-2-base tg128 @ d32000 1024 11.29 0.03 2 45.3W/69C
test ub stock t/s (n, sd) build candidate t/s (n, sd) gain % 95 % CI % Welch t resolved
tg128 1024 12.98 (4, 0.07) mtp 13.04 (4, 0.04) +0.46 -0.39 .. +1.32 +1.50 no
tg128 @ d32000 1024 11.30 (4, 0.03) mtp 11.31 (4, 0.03) +0.08 -0.40 .. +0.56 +0.43 no

Correctness commands: llama-debug --save-logits -b 16384 -ub 512 (four tests/corpus files and a 13480-token concatenation); llama-perplexity -c 512 -b/-ub 512 --chunks 4 KLD-base files (PPL prose 5.4246, code 1.9202, structured 2.4814, numeric 1.9349 on both builds); greedy llama-server /completion, 256 tokens, top-10 log-probs. Speculative checks at -b/-ub 512. Scatter/gather: Qwen3.8-27B, --spec-draft-n-max 3 --spec-draft-mtp-vocab 65536, temp 0, 2 x 512 tokens.

Speculative draft counters with identical --spec-type draft-mtp --spec-draft-n-max 3 (identical on both builds): 27B 151/308, 189/198, 80/140; 35B-A3B 178/230, 81/136, 137/350 (code, long, prose). New flags on the 13480-token prompt match stock n_max 3 over all 128 tokens. Scatter vs gather draft counters: 326/555, 328/548.

Additional information

  • Memory (experiment build): n_max 10 needs +748 MiB recurrent state over n_max 5; the subset head adds 220 MiB (27B) / 136 MiB (35B).
  • Subset head owned by llama_model, used only when n_outputs == 1.

Requirements

  • I have read and agree with the contributing guidelines
  • This change is justified by measurements on Strix Halo (gfx1151)
  • AI usage disclosure: AGENT-AUTHORED. Claude (Claude Code) wrote the code, commits and description and ran the measurements; the owner reviews.
  • What was NOT verified:
    • Stock measured on 654803517. The branch was then rebased onto 69946438a, whose 6 new commits touch only ggml/rocmfpx, one Vulkan shader and tests/CMakeLists.txt; not re-measured.
    • Qwen3.6-35B-A3B: correctness only, plus a greedy smoke of the new flags with the learned costs logged; llama-benchy and subset-head output not compared.
    • llama-bench PP at depth 64000 not run; llama-benchy at depths 0 and 32000 only, n = 4 per cell.
    • Full test-backend-ops not run (GET_ROWS, SET_ROWS only).
    • Merge-order guard failure path untested (no model on disk fails it).
    • Controller untested with --parallel > 1 (log line sums all slots) and on the CLI.
    • CUDA build of get_rows kernel and graph key not compiled or run on NVIDIA.
    • Status per AGENTS.md step 7: TARGET PASS (Qwen3.8-27B, ROCm0); speed gain over stock adaptive resolved only for cost-aware + subset at depth 32000.

https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC

…che key

get_rows: for float rows with ne00 <= 32, launch over (row, element) instead of one workgroup per row. f32 ne00 = 1 over 248320 rows measured 986 us -> 20 us on gfx1151.

graph key: nodes[0] alone gives the same key to graphs that share one meta arena (different topologies, or one topology at a varying verify width), so those graphs never replay. Mix the node count, the last node and three node shapes into the key.

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC
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
With draft-mtp and --spec-draft-n-min > 0, the draft length is chosen token by token between n_min and n_max. A position is kept, and the next one drafted, when its expected value in output tokens times the current time per output token covers its verify column cost and draft step cost. Acceptance per drafter confidence, draft step time, time per output token and the verify cost are all measured online, so no model-specific tuning is needed.

The verify cost V(w) is timed per round from the end of the draft to accept() and kept per verify width with decay, on top of a level term that follows context depth. V is fit non-decreasing in the width with a weak linear prior, and the column cost of position j is the smoothed difference V(j+1) - V(j), clamped to a small positive minimum. A verify width next to the stopping point that has had almost no recent samples is drafted now and then, rate-limited.

Only single-head MTP with its own KV cache (qwen35, qwen35moe). --spec-draft-n-max alone keeps the fixed draft length. A summary line per request is logged with the speculative statistics and the learned verify and column costs.

Assisted-by: Claude Opus 5
Claude-Session: https://claude.ai/code/session_01MkuKwgPNgpAg7EbxKR2iWC

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

Correctness blocker: mtp_draft_vocab_build() stores the first subset in mutable llama_model state. The Qwen MTP graph builders select model.mtp_draft_head from that shared pointer and output shape, without checking the requesting context's mtp_draft_vocab.

Reproduction by inspection: create MTP context A with mtp_draft_vocab=N, then context B over the same model with the default 0 (or a different N). B silently uses A's subset, so its behavior depends on initialization order.

Please make the subset context/config-owned, or key an immutable cache by requested N and backend/buffer ownership, and gate graph use on that context's setting. Add regressions for A=N then B=0 and A=N1 then B=N2, including simultaneous contexts.

This also bundles draft-vocabulary API, cost controller, get_rows kernel, and CUDA graph cache-key work. Please split it; most is generic llama.cpp/CUDA work and belongs in upstream staging. Diff whitespace check passed. I have not independently reproduced the performance claim; resolve the correctness blocker first.

comment generated by my clanker Codex

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

Copy link
Copy Markdown
Author

Fixed the reported bug and added a test.

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.

2 participants