speculative : draft performance improvement (+10% t/s with deeper draft depth possible with this) + token rollback bugfix - #27173
Conversation
… rounds Speed up draft-mtp speculative decoding on multi-GPU tensor-split setups. Measured on Qwen3.5-27B Q8_0 with 2x RTX 5090 (-sm tensor -fa on, greedy sampling): 138 -> 170 t/s on mixed content and 165 -> 232 t/s on echo-heavy content, with identical output. All new behavior is opt-in through environment variables; the default path does not change. Changes: - LLAMA_SPEC_CHAIN=1: draft all n_max tokens with one decode of the MTP layer. The first batch row carries the real (token, hidden) inputs. Each later row takes its inputs from the previous row's in-graph argmax and hidden state. This replaces n_max sequential draft decodes and their host round-trips with one decode. The chain decode also absorbs deferred catch-up rows, so a full round is two evals: one chained draft decode and one verify decode. - In-graph draft sampling: the chain graph emits packed (token id, top probability) pairs instead of full logit rows. The host reads back 2 floats per draft token instead of n_vocab. - LLAMA_SPEC_CHAIN_SUB (default 32768): the chain argmax runs over a view of the most frequent vocabulary rows. The target model verifies against the full vocabulary, so acceptance decisions do not change. - LLAMA_META_MIRROR_OUTPUT=1: keep the output head whole on every device instead of vocab-split. Removes the AllReduce and the split-logits gather from every verify eval. - LLAMA_SCHED_POOL=N: keep one scheduler per batch shape. Repeated shapes reuse their scheduler's allocation instead of re-splitting and re-allocating the graph each round. - Graph plan cache for the meta backend: cache measured allocation plans per graph topology and shadow tensor containers per graph, validated by per-node fingerprints. Repeated topologies skip measure and split work. - Chunked AllReduce kernel: drop per-chunk event bookkeeping; slot reuse safety comes from the in-kernel arrival handshake. The host can enqueue a full token's worth of subgraphs ahead of the GPUs. - Fix conv-state snapshot writes for gated-delta-net models: a batch with fewer tokens than the snapshot depth no longer overwrites deeper rollback slots (test-recurrent-state-rollback). Debug aids: LLAMA_GRAPH_RESULT_DEBUG>1 now prints which condition blocked graph reuse; LLAMA_CHAIN_DEBUG and GGML_META_DEBUG trace the chain and the meta plan cache. Assisted-by: Claude Code
- remove the unused per-step chain mask input and its debug dump - remove the LLAMA_SCHED_TG scheduler and the LLAMA_GRAPH_REALLOC re-allocation path; the scheduler pool supersedes both - remove the standalone LLAMA_SPEC_DEFER opt-in and its debug split mode; the chain enables row deferral itself - remove the per-eval logits checksum debug block - abort the chain round when the deferred-row flush fails - validate the node count before the plan fingerprint check - correct the sub-head comment: the logits tail is not padded, and the emitted probability normalizes over the sub-head only - fix include order and log level in the meta backend Assisted-by: Claude Code
|
Hi @PatrickWalther, thanks for your contribution! Per our contribution guidelines, the automated PR checker found the following issue(s) that need your attention:
Please note that maintainers reserve the right to make final decisions on PRs. If you believe there is a mistake, please comment below. |
|
This was the largest runtime improvement in my Qwen3.8-27B experiment. I applied commit 2c9cb8f on top of my #26001 + #26048 + #26705 runtime. With the same model, command, prompt and ten-run deterministic workload: before #27173: 45.866 tok/s Each arm produced one stable response hash. Weighted draft acceptance changed from 42.46% to 37.59%, but throughput still increased sharply because chained drafting removed enough per-token GPU launches and synchronization overhead to make n_max=8 useful. In a separate greedy gate, target-only decoding reached 21.189 tok/s and embedded MTP reached 59.456 tok/s, or 2.81x. I am not mixing that separate result into the 21.97% runtime A/B. Hardware: RTX PRO 4000 Blackwell SFF, sm120a, 24,467 MiB, CUDA 12.9.86, driver 610.57.04. Context 262,144, Q4_0 target KV, F16 draft KV, batch 512, ubatch 256, four recurrent checkpoints. Full write-up: Released model: |
Chained MTP drafting (PR ggml-org#27173 backport): - All N draft tokens produced in one fused GPU decode via in-graph argmax - Deferred catch-up rows merged into first draft decode - --spec-chain N flag enables chain mode and sets depth (default: off) - New llama_set_mtp_chain() API for graph mode switching - Per-shape scheduler pool (LLAMA_SPEC_CHAIN env var still works) Adaptive MTP draft depth (PR ggml-org#27210 backport): - --spec-type draft-mtp-adaptive with hysteresis state machine - Depth climbs after consecutive full accepts, drops on misses - --spec-draft-n-min-adaptive for floor depth (default: 3) Results on RTX 5090, Qwen3.8-27B Q4_K_P: - Code: 206 t/s (chain n=8) vs 156 t/s (MTP n=3) vs 69 t/s (no MTP) - Chain delivers 3.0x over no-MTP, +31% over standard MTP on code Assisted-by: Claude
…for this vocab Adopts PR ggml-org/llama.cpp#27173, which replaces autoregressive drafting (one llama_decode plus a host round trip per drafted token) with a single decode that produces the whole chain and picks each token on the GPU, and which drafts against a leading slice of the output tensor rather than the whole 248320-wide thing. The PR's default slice is 32768. That is 21% of the 151k vocabulary it was tuned against and only 13% of this model's 248320, and at that width mean accepted length drops from 2.89 to 2.64 and the entire gain is given back: 29.19 tok/s against a 29.47 control. Swept: 32768 13% len 2.64 29.19 65536 26% len 2.79 30.47 81920 33% len 2.87 31.03 98304 40% len 2.91 31.22 <- shipped 114688 46% len 2.91 31.03 0 100% len 2.90 29.69 98304 is where accepted length has fully recovered while the head is still 40% of full width. Full benchmark: 30.97 -> 32.41 tok/s, accepted length 3.01, accuracy 40/40, zero restarts. Depth still peaks at 5. Serving now builds the PR branch rather than a pinned commit; scripts/startup.sh takes qwen-lcpp-pr and qwen-chain-sub from instance metadata. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016F5KdwM7BegNCHyAGGpmPv
| it = stc->simple_tensors.end(); | ||
| } | ||
| if (it == stc->simple_tensors.end()) { | ||
| if (ggml_backend_meta_buffer_init_tensor_impl(*stc, (ggml_tensor *) tensor) != GGML_STATUS_SUCCESS) { |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
There was a problem hiding this comment.
ggml/src/ggml-backend-meta.cpp:605:77: warning: cast from 'const ggml_tensor *' to 'ggml_tensor *' drops const qualifier [-Wcast-qual]
Should be fixed with meta : fix -Wcast-qual warnings
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
- take a const tensor in ggml_backend_meta_buffer_init_tensor_impl. The function only reads the tensor, so the shadow lookup no longer casts the const away. - pass the split-state userdata through const_cast in llama_context. The ggml callback type takes a mutable pointer, but the context holds the model by const reference.
This comment was marked as low quality.
This comment was marked as low quality.
|
|
||
| auto * inp_attn = build_attn_inp_kv(); | ||
|
|
||
| const float kq_scale_c = hparams.f_attention_scale == 0.0f |
This comment was marked as low quality.
This comment was marked as low quality.
Sorry, something went wrong.
|
create 1 PR for each change if you intend to get any of this merged |
|
Validation on V100 + multi-turn function calling — fix works, but ~3.4× throughput regression Tested this PR against the exact scenario of #27296 (intermittent stream cancellation + corrupted tool-call args in multi-turn function calling with Setup: 2× Tesla V100-PCIE-32GB, CUDA 11.5, master Production incident (unpatched): Aug 25, on a multi-turn FC agent session (~155k ctx, tool loop running 21 min), the server canceled the stream mid-thinking — observed twice 5s apart (client SDK retry killed as well). This matches the #27296 / #26425 family. With this PR: behaviorally clean on identical harness scenarios (valid tool-call JSON, no cancellations) — no functional regression found. However, generation throughput drops ~3.4×: identical prompt/sampling, thinking-heavy generation:
The deferred/chained catch-up path appears to make each speculative pass much more expensive on this setup. Would it be possible to make the chaining opt-in (or otherwise reduce its cost)? My reading is the delta-net rollback fix itself (preserving deep snapshot slots) is separable from the chaining refactor — the former is what fixes #27296 for us, the latter is what costs the throughput. Happy to re-bench any revision on this V100 rig and share logs. |
|
Follow-up: minimal variant of this fix without the chaining — same functional outcome, no throughput regression Following up on my perf comment above: we isolated the delta-net snapshot fix from the chaining refactor, and added a small complementary reset. This 21-line variant (on top of master
Measured on the rig from my previous comment (V100 ×2, Qwen3.8-27B,
So on this hardware the entire regression comes from the deferred/chained catch-up path, not from the delta-net fix itself. Would a split (rollback fix + optional chaining behind a flag) be acceptable? Happy to turn this into a proper PR with a test if that helps. diff --git a/common/speculative.cpp b/common/speculative.cpp
index 4eef2212..c0bd599b 100644
--- a/common/speculative.cpp
+++ b/common/speculative.cpp
@@ -1428,6 +1428,16 @@ struct common_speculative_impl_draft_mtp : public common_speculative_impl {
"(need_embd / logits=1 on every prompt position?). "
"Drafts may degrade.\n",
(int) pos_max, N - 1);
+
+ // [local fix-min #27296] the new prompt does not extend this slot draft
+ // state: the per-sequence MTP carryover belongs to a previous request.
+ // Keep it from poisoning the first catch-up row pairing (stale h-row
+ // paired with the first token of an unrelated conversation).
+ if (pending_h.size() > (size_t) seq_id) {
+ pending_h[seq_id].assign(n_embd, 0.0f);
+ verify_h[seq_id].clear();
+ verify_h_rows[seq_id] = 0;
+ }
}
}
diff --git a/src/models/delta-net-base.cpp b/src/models/delta-net-base.cpp
index ad661264..99591bc3 100644
--- a/src/models/delta-net-base.cpp
+++ b/src/models/delta-net-base.cpp
@@ -499,10 +499,18 @@ ggml_tensor * llm_build_delta_net_base::build_conv_state(
// this logic assumes that the last (n_rs_seq + 1) tokens of a sequence in a batch are inside
// the same ubatch, which `split_equal()` guarantees via its n_keep_tail argument
- const int64_t K = (int64_t) cparams.n_rs_seq + 1;
+ const int64_t n_tok = conv_input->ne[0] - conv_states->ne[0];
+
+ // slot s holds the conv state as of s tokens back. A batch with n_tok tokens defines
+ // slots 0..n_tok. Deeper slots keep their previous content: an earlier, larger batch
+ // may have written them, and a rollback that returns to that batch's position still
+ // reads them (test-recurrent-state-rollback). The old full-depth loop overwrote those
+ // slots with clamped duplicates of slot n_tok. The gated-delta-net snapshot write has
+ // the same bound.
+ const int64_t K = std::min<int64_t>((int64_t) cparams.n_rs_seq, n_tok) + 1;
for (int64_t t = 1; t <= K; ++t) {
- const int64_t s_idx = std::max<int64_t>(0, conv_input->ne[0] - conv_states->ne[0] - K + t);
+ const int64_t s_idx = n_tok - K + t;
const int64_t s_slot = K - t;
ggml_tensor * conv_state_last = |
|
@Flo5k5 FYI I've tried to apply the patch of your last comment (in speculative.cpp and delta-net-base.cpp) but it looks like I still have poisoning across prompts with only this change. |
|
Reference baseline from a single RTX 3090: Qwen3.6-27B IQ4_XS, draft-mtp n-max 4, FA on, q4_0/q8_0 KV, 130K context, mem clock locked at 5001: ~45 t/s generation, 38-71% acceptance depending on context. Happy to test this PR on our end and report the delta. The regression report in the thread so far is V100-only, so an Ampere data point might help triage. |
Overview
Implemented draft improvements that make higher draft depth faster. For my setup, draft depth 3 was the best. With the changes, draft depth 5 delivers ~10% more t/s than draft depth 3.
My setup: Qwen3.8-27B Q8_0, 2x RTX 5090, llama.cpp draft-mtp speculative decoding,
-sm tensor -fa on -c 32768 -np 1, temperature 0.LLAMA_SPEC_CHAIN=1)LLAMA_SPEC_CHAIN_SUB); the main model still checks against the full vocabulary, so output does not change.LLAMA_META_MIRROR_OUTPUT=1)LLAMA_SCHED_POOL=N)Additional information
"Remember how work was split across the GPUs" and "Remove extra synchronization" are always-on, other changes are behind a flag.
Benchmarks:
Test script starts each binary with (
-sm tensor -fa on -c 32768 -np 1 --spec-type draft-mtp, depth per config),GGML_CUDA_PDL=1,temperature 0.
It runs four fixed workloads - code_gen (fresh code),
code_edit (modify given code), code_echo (verbatim rewrite), prose -
and takes generation t/s from the server's own timings.
With no environment variables set, behavior is unchanged
Chain config =
LLAMA_SPEC_CHAIN=1 LLAMA_META_MIRROR_OUTPUT=1 LLAMA_SCHED_POOL=8,--spec-draft-n-max 5.Requirements