Skip to content

Misc. bug: [draft-mtp] Draft acceptance collapses to 0.0 under -np N with multi-ubatch batches — async t_h_nextn device→host copy race #27572

Description

@ByungHyun21

[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):

  1. 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.
  2. 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)

  1. 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.
  2. 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.
  3. 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:

  1. 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
  2. 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.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions