[draft-mtp] Draft acceptance collapses to 0.0 under -np N with multi-ubatch batches — async t_h_nextn device→host copy race
Summary
Self-speculative decoding (--spec-type draft-mtp) silently breaks under parallel slots (-np 4): as soon as concurrent requests carry long prompts (decode batches spanning multiple ubatches), draft acceptance drops from a healthy 0.48–0.85 to exactly 0.00000 on all slots, generation falls back below no-MTP speed, and consumers of the endpoint see empty message.content completions (finish_reason=stop after a handful of reasoning_content tokens).
The trigger is a race between the async device→host copy of t_h_nextn (the MTP draft's input hidden states) and any later graph that reuses the t_h_nextn extra buffer — the next ubatch within the same decode(), or the next decode() call under -np N continuous batching. When the copy loses, the MTP draft conditions on NaN h rows, its logits go NaN, and every draft is rejected.
Verified at f5b9bd39b (build 10176), HIP backend. The same binary is correct at -np 1 and for single-ubatch batches.
Environment
|
|
| llama.cpp |
f5b9bd39b (b10176), HIP build, AMDGPU_TARGETS=gfx1151 |
| ROCm |
7.2.2 (pinned; gfx1151 native) |
| GPU |
AMD Radeon 8060S (Strix Halo, gfx1151), 256 GB unified |
| Model |
unsloth/Qwen3.8-27B-GGUF UD-Q4_K_XL + mmproj-F16 (qwen35 hybrid: Gated DeltaNet + Gated Attention, embedded nextn/MTP layer) |
| OS |
Ubuntu 24.04, kernel 6.17 |
Server flags:
llama-server -m Qwen3.8-27B-UD-Q4_K_XL.gguf --mmproj mmproj-F16.gguf --jinja \
--host 0.0.0.0 --port 9999 -ngl all -fit off -np 4 \
-c 1048576 -n 131072 -b 1024 -ub 256 -fa on -ctk q4_0 -ctv q4_0 \
--load-mode dio --spec-type draft-mtp --spec-draft-n-max 4 -rea on \
--reasoning-format deepseek --temp 1.0 --top-p 0.95 --top-k 20 --min-p 0.0 --metrics
Reproduction
Self-contained script (stdlib only, no client framework needed) — it fires 4 concurrent chat completions, each with a ~19k-token system prompt, and prints per-request OK/EMPTY plus finish reasons.
repro_mtp_np4.py (click to expand)
#!/usr/bin/env python3
"""Reproduce the draft-mtp acceptance collapse under -np N concurrent load."""
import json, sys, threading, time, urllib.request
URL = "http://127.0.0.1:9999/v1/chat/completions"
N_CONC = int(sys.argv[1]) if len(sys.argv) > 1 else 4
TARGET_TOKENS = int(sys.argv[2]) if len(sys.argv) > 2 else 19000
unit = ("### Skill {i}: skill_{i} handles topic {i}. Steps: analyze, plan, "
"execute, report. Constraints: verify outputs, cite sources.\n")
SYS = "You are an agent. Available skills:\n" + "".join(
unit.format(i=i) for i in range(TARGET_TOKENS // 9))
def one(idx, results):
body = {"model": "local_llm", "max_tokens": 1200, "messages": [
{"role": "system", "content": SYS},
{"role": "user", "content": f"[{idx}] Say hello and list what you can help with, one line."}]}
req = urllib.request.Request(URL, json.dumps(body).encode(),
{"Content-Type": "application/json"})
t0 = time.time()
try:
r = json.load(urllib.request.urlopen(req, timeout=1200))
c = r["choices"][0]
content = (c["message"].get("content") or "").strip()
results.append((idx, "OK" if content else "EMPTY", c["finish_reason"],
r["usage"]["completion_tokens"], time.time() - t0))
except Exception as e:
results.append((idx, "ERROR", type(e).__name__, 0, time.time() - t0))
def round_test(round_n):
results = []
ts = [threading.Thread(target=one, args=(i, results)) for i in range(N_CONC)]
for t in ts: t.start()
for t in ts: t.join()
empty = sum(1 for r in results if r[1] != "OK")
for r in results:
print(f" round {round_n} req{r[0]}: {r[1]:5s} finish={r[2]:6s} ct={r[3]:5d} {r[4]:7.1f}s")
print(f" round {round_n}: empty {empty}/{N_CONC}")
return empty
if __name__ == "__main__":
total = sum(round_test(n + 1) for n in range(2))
print(f"TOTAL empty: {total}/{2 * N_CONC}")
Expected output on an affected build
Server log (--metrics, per slot, at task end):
slot print_timing: id 0 | draft acceptance = 0.00000 ( 0 accepted / 4786 generated), mean len = 1.00
slot print_timing: id 1 | draft acceptance = 0.00000 ( 0 accepted / 4786 generated), mean len = 1.00
slot print_timing: id 2 | draft acceptance = 0.00000 ( 0 accepted / 4786 generated), mean len = 1.00
slot print_timing: id 3 | draft acceptance = 0.00000 ( 0 accepted / 4786 generated), mean len = 1.00
The same server, sequential single request on a cold slot:
slot print_timing: id 3 | draft acceptance = 0.57865 ( 206 accepted / 356 generated), mean len = 3.31
Client side, affected requests return finish_reason=stop with empty content after a few reasoning tokens, or run away in reasoning until the token cap — both surface as "empty completion" to an agent/chat client.
What does not trigger it
-np 4 with short prompts (single-ubatch batches): 0/12 failures over 3 rounds
- Long prompts processed sequentially (one slot active): healthy acceptance
- Warm prefix cache (prefill mostly cached): healthy
-np 1 with MTP: always healthy
→ the failure requires concurrent requests producing decode calls whose batches split across ubatches (long prompts), under multi-slot continuous batching.
Root-cause analysis
Instrumentation adds two probes (patch inlined at the end of this section):
- In
llama_context::decode()'s per-ubatch t_h_nextn extraction: after the async copy, ggml_backend_sched_synchronize() then compare the device tensor contents (via synchronous ggml_backend_tensor_get) against the host buffer rows.
- At
common_speculative_impl_draft_mtp::process() entry: NaN-probe of the target-context embd_nextn row 0 (the rows the draft is about to consume).
Findings during a failing concurrent round:
ctx DBG nextn extract: ub_tokens=15 n_seqs_unq=3 n_rows=15 off=0 tensor[ne0=5120 ne1=15] dev[nanrows=0/15] host[nanrows=0/15] ← extraction time: clean
spec DBG ENTRY: n_tokens=10 n_act=2 tgt_row0[sq8=0.000e+00 nan=8] ← hook entry: NaN
spec DBG seq=2 beg=0 end=4 h[sq8=0.0000e+00 nan=40] pend[sq8=0.0000e+00 nan=8]
i.e. the graph computed t_h_nextn correctly (device rows have sane norms), the extraction-time copy landed cleanly for the batch it ran on, but by the time the speculative hook reads embd_nextn, the rows it needs are NaN. With -lv 5 the draft candidates during failure are junk tokens with nan probabilities:
D spec draft: - seq_id 3, draft candidate 0, pos 1: 8 (nan) ')'
D spec draft: - seq_id 3, draft candidate 1, pos 1: 9 (nan) '*'
The gap is ggml_backend_tensor_get_async(backend_h, t_h_nextn, embd_nextn_out, 0, …) in the unmasked nextn-embeddings path of src/llama-context.cpp. t_h_nextn lives in a scheduler extra buffer that subsequent graph executions reuse:
- the next ubatch of the same
decode() call (long prompts: ~75 ubatches at -ub 256 for a 19k prompt), and
- the next
decode() call (another slot's prefill/verify under -np N continuous batching).
The async copy is not ordered against those later graphs. When it executes after the buffer has been overwritten, NaN/garbage lands in embd_nextn, the MTP head produces NaN logits, and every draft is rejected — acceptance drops to exactly zero. Sequential and single-ubatch cases complete the copy before any overwrite (probabilistically), which explains the selective trigger conditions above.
instrumentation.patch (applies to b10176, click to expand)
diff --git a/common/speculative.cpp b/common/speculative.cpp
index 5653a90b8..2cfc13763 100644
--- a/common/speculative.cpp
+++ b/common/speculative.cpp
@@ -16,6 +16,7 @@
#include <cstring>
#include <iomanip>
#include <map>
+#include <cmath>
#include <cinttypes>
#define SPC_DBG(fmt, ...) LOG_DBG("spec %12.*s: " fmt, 12, __func__, __VA_ARGS__)
@@ -1404,6 +1405,27 @@ struct common_speculative_impl_draft_mtp : public common_speculative_impl {
return true;
}
+ // [instrumentation] probe target h_nextn rows for NaN at hook entry
+ if (batch_in.n_tokens <= 40) {
+ int n_act = 0;
+ for (llama_seq_id s = 0; s < (llama_seq_id) n_seq; ++s) {
+ for (int k = 0; k < batch_in.n_tokens; ++k) {
+ if (batch_in.seq_id[k][0] == s) { n_act++; break; }
+ }
+ }
+ if (n_act > 1) {
+ const float * h0 = llama_get_embeddings_nextn_ith(this->params.ctx_tgt, 0);
+ if (h0) {
+ int nn = 0; double sq = 0;
+ for (int e = 0; e < 8; ++e) {
+ if (std::isnan(h0[e])) nn++; else sq += (double) h0[e]*h0[e];
+ }
+ LOG_WRN("spec DBG ENTRY: n_tokens=%d n_act=%d tgt_row0[sq8=%.3e nan=%d]\n",
+ batch_in.n_tokens, n_act, sq, nn);
+ }
+ }
+ }
+
// TODO: how to make it work with vision tokens?
if (batch_in.token == nullptr || batch_in.embd != nullptr) {
return true;
diff --git a/src/llama-context.cpp b/src/llama-context.cpp
index 9b399d609..d418a261a 100644
--- a/src/llama-context.cpp
+++ b/src/llama-context.cpp
@@ -1992,6 +1992,35 @@ int llama_context::decode(const llama_batch & batch_inp) {
GGML_ASSERT((offset + n_rows)*n_embd <= (int64_t) embd_nextn.size);
ggml_backend_tensor_get_async(backend_h, t_h_nextn, embd_nextn_out, 0, n_rows*n_embd*sizeof(float));
+
+ // [instrumentation] compare device tensor vs host buffer after full sync
+ {
+ static int64_t s_calls = 0;
+ if (s_calls < 20000) {
+ s_calls++;
+ ggml_backend_sched_synchronize(sched.get());
+ int nchk = n_rows < 15 ? (int) n_rows : 15;
+ std::vector<float> dbg_rows((size_t) nchk * n_embd, 0.0f);
+ ggml_backend_tensor_get(t_h_nextn, dbg_rows.data(), 0,
+ (size_t) nchk * n_embd * sizeof(float));
+ auto nan_rows = [&](const float * base) {
+ int nr = 0;
+ for (int r = 0; r < nchk; ++r) {
+ for (int e = 0; e < 8; ++e) {
+ if (std::isnan(base[(size_t) r * n_embd + e])) { nr++; break; }
+ }
+ }
+ return nr;
+ };
+ LLAMA_LOG_WARN("ctx DBG nextn extract: this=%p ub_tokens=%d n_seqs_unq=%d "
+ "n_rows=%lld off=%lld tensor[ne0=%lld ne1=%lld] "
+ "dev[nanrows=%d/%d] host[nanrows=%d/%d]\n",
+ (void *) this, ubatch.n_tokens, ubatch.n_seqs_unq,
+ (long long) n_rows, (long long) offset,
+ (long long) t_h_nextn->ne[0], (long long) t_h_nextn->ne[1],
+ nan_rows(dbg_rows.data()), nchk, nan_rows(embd_nextn_out), nchk);
+ }
+ }
}
}
Workaround (local patch, verified)
--- a/src/llama-context.cpp
+++ b/src/llama-context.cpp
@@
ggml_backend_tensor_get_async(backend_h, t_h_nextn, embd_nextn_out, 0, n_rows*n_embd*sizeof(float));
+ ggml_backend_sched_synchronize(sched.get());
With this, repeated cold-start runs of the repro (4 × 19k concurrent, multiple rounds) complete 16/16 healthy, acceptance 0.26–0.55, no empty completions.
Known cost (A/B, single-stream, temp 0, identical server flags):
| prompt tokens |
pp t/s before → after |
tg t/s before → after |
| 620 |
291.6 → 290.2 |
16.3 → 15.5 |
| 4603 |
339.9 → 338.4 |
17.7 → 16.6 |
| 9154 |
552.1 → 547.1 |
25.3 → ~15 |
| 18256 |
507.6 → 503.9 |
24.9 → ~14 |
pp is unaffected; long-prompt single-stream tg loses ~40% because the sched-wide synchronize disturbs the scheduler's graph-reuse state (acceptance flattens at ~0.40 instead of rising to 0.85–0.94 with prompt length). A proper fix should make the copy safe without a host-side synchronize — e.g. event-ordering the copy against subsequent graph launches, or rotating the t_h_nextn extra buffer — rather than this workaround.
Alternative approaches tried (all rejected)
- Synchronize only full ubatches (
ubatch.n_tokens >= n_ubatch): insufficient — the verify tokens' rows sit in the last, partial ubatch, whose copy still races with the next decode() call under concurrent load. Reproduced acceptance 0.0.
ggml_backend_tensor_get (backend sync) on every extraction: tg regressed to 11–12 t/s — the masked (draft-context) extraction runs several times per draft round on tiny decodes and is latency-critical.
- Backend-sync only the unmasked (target-context) extraction: single-stream fully restored (25.2 / 24.7 t/s at 9k/18k) but concurrency still collapsed to acceptance 0.0 → there appears to be a second corruption path under
-np N. Prime suspect: output_reorder() applies output_swaps (indices recorded for logits output rows) unconditionally to embd_nextn, whose rows are token rows in unmasked mode — cross-sequence row shuffling. However, simply excluding embd_nextn from the swaps collapsed acceptance even single-stream (~5 t/s), so the interaction is subtler than a one-line gate and we did not chase it further.
Full per-variant benchmark tables and logs available on request.
Related regressions observed at current master (2115b73d8, b10581)
While bisecting we hit two further draft-mtp issues on the same hardware/model — possibly related, filed here for completeness:
- HIP: draft acceptance is 0.00000 from the very first request, even sequentially (0 accepted / 4786 generated, mean len 1.00). Something between b10176 and b10581 regressed embedded-nextn drafting outright for
qwen35. Candidate commits in the range (not yet bisected):
2c6b141ef — common: fix draft-mtp with embeddings (#27400)
1692f9e50 — ggml: recurrent state rollback for ggml_ssm_scan (#26623) — qwen35 is a hybrid with Gated DeltaNet (ssm_scan) layers
1d2869c6e / f65e568fd — spec type auto-detection changes
- Vulkan (RADV, mainline build b10581):
--spec-type draft-mtp aborts during draft-context creation, with and without --mmproj:
common_speculative_init_result: creating MTP draft context against the target model '…'
ggml-backend.cpp:348: GGML_ASSERT(tensor->data != NULL && "tensor not allocated") failed
Happy to provide full logs, the instrumentation patch output, per-variant benchmarks, and anything else useful.
[draft-mtp] Draft acceptance collapses to 0.0 under
-np Nwith multi-ubatch batches — asynct_h_nextndevice→host copy raceSummary
Self-speculative decoding (
--spec-type draft-mtp) silently breaks under parallel slots (-np 4): as soon as concurrent requests carry long prompts (decode batches spanning multiple ubatches), draft acceptance drops from a healthy 0.48–0.85 to exactly 0.00000 on all slots, generation falls back below no-MTP speed, and consumers of the endpoint see emptymessage.contentcompletions (finish_reason=stopafter a handful ofreasoning_contenttokens).The trigger is a race between the async device→host copy of
t_h_nextn(the MTP draft's input hidden states) and any later graph that reuses thet_h_nextnextra buffer — the next ubatch within the samedecode(), or the nextdecode()call under-np Ncontinuous batching. When the copy loses, the MTP draft conditions on NaNhrows, its logits go NaN, and every draft is rejected.Verified at
f5b9bd39b(build 10176), HIP backend. The same binary is correct at-np 1and for single-ubatch batches.Environment
f5b9bd39b(b10176), HIP build,AMDGPU_TARGETS=gfx1151UD-Q4_K_XL+mmproj-F16(qwen35hybrid: Gated DeltaNet + Gated Attention, embedded nextn/MTP layer)Server flags:
Reproduction
Self-contained script (stdlib only, no client framework needed) — it fires 4 concurrent chat completions, each with a ~19k-token system prompt, and prints per-request
OK/EMPTYplus finish reasons.repro_mtp_np4.py (click to expand)
Expected output on an affected build
Server log (
--metrics, per slot, at task end):The same server, sequential single request on a cold slot:
Client side, affected requests return
finish_reason=stopwith empty content after a few reasoning tokens, or run away in reasoning until the token cap — both surface as "empty completion" to an agent/chat client.What does not trigger it
-np 4with short prompts (single-ubatch batches): 0/12 failures over 3 rounds-np 1with MTP: always healthy→ the failure requires concurrent requests producing decode calls whose batches split across ubatches (long prompts), under multi-slot continuous batching.
Root-cause analysis
Instrumentation adds two probes (patch inlined at the end of this section):
llama_context::decode()'s per-ubatcht_h_nextnextraction: after the async copy,ggml_backend_sched_synchronize()then compare the device tensor contents (via synchronousggml_backend_tensor_get) against the host buffer rows.common_speculative_impl_draft_mtp::process()entry: NaN-probe of the target-contextembd_nextnrow 0 (the rows the draft is about to consume).Findings during a failing concurrent round:
i.e. the graph computed
t_h_nextncorrectly (device rows have sane norms), the extraction-time copy landed cleanly for the batch it ran on, but by the time the speculative hook readsembd_nextn, the rows it needs are NaN. With-lv 5the draft candidates during failure are junk tokens withnanprobabilities:The gap is
ggml_backend_tensor_get_async(backend_h, t_h_nextn, embd_nextn_out, 0, …)in the unmasked nextn-embeddings path ofsrc/llama-context.cpp.t_h_nextnlives in a scheduler extra buffer that subsequent graph executions reuse:decode()call (long prompts: ~75 ubatches at-ub 256for a 19k prompt), anddecode()call (another slot's prefill/verify under-np Ncontinuous batching).The async copy is not ordered against those later graphs. When it executes after the buffer has been overwritten, NaN/garbage lands in
embd_nextn, the MTP head produces NaN logits, and every draft is rejected — acceptance drops to exactly zero. Sequential and single-ubatch cases complete the copy before any overwrite (probabilistically), which explains the selective trigger conditions above.instrumentation.patch (applies to b10176, click to expand)
Workaround (local patch, verified)
With this, repeated cold-start runs of the repro (4 × 19k concurrent, multiple rounds) complete 16/16 healthy, acceptance 0.26–0.55, no empty completions.
Known cost (A/B, single-stream,
temp 0, identical server flags):pp is unaffected; long-prompt single-stream tg loses ~40% because the sched-wide synchronize disturbs the scheduler's graph-reuse state (acceptance flattens at ~0.40 instead of rising to 0.85–0.94 with prompt length). A proper fix should make the copy safe without a host-side synchronize — e.g. event-ordering the copy against subsequent graph launches, or rotating the
t_h_nextnextra buffer — rather than this workaround.Alternative approaches tried (all rejected)
ubatch.n_tokens >= n_ubatch): insufficient — the verify tokens' rows sit in the last, partial ubatch, whose copy still races with the nextdecode()call under concurrent load. Reproduced acceptance 0.0.ggml_backend_tensor_get(backend sync) on every extraction: tg regressed to 11–12 t/s — the masked (draft-context) extraction runs several times per draft round on tiny decodes and is latency-critical.-np N. Prime suspect:output_reorder()appliesoutput_swaps(indices recorded for logits output rows) unconditionally toembd_nextn, whose rows are token rows in unmasked mode — cross-sequence row shuffling. However, simply excludingembd_nextnfrom the swaps collapsed acceptance even single-stream (~5 t/s), so the interaction is subtler than a one-line gate and we did not chase it further.Full per-variant benchmark tables and logs available on request.
Related regressions observed at current master (
2115b73d8, b10581)While bisecting we hit two further draft-mtp issues on the same hardware/model — possibly related, filed here for completeness:
qwen35. Candidate commits in the range (not yet bisected):2c6b141ef— common: fix draft-mtp with embeddings (#27400)1692f9e50— ggml: recurrent state rollback forggml_ssm_scan(#26623) — qwen35 is a hybrid with Gated DeltaNet (ssm_scan) layers1d2869c6e/f65e568fd— spec type auto-detection changes--spec-type draft-mtpaborts during draft-context creation, with and without--mmproj:Happy to provide full logs, the instrumentation patch output, per-variant benchmarks, and anything else useful.