# [Bug] NEXTN/MTP draft acceptance decays to ~0 over server uptime on qwen4_exp (Qwen3.8-Flash-Next), fully restored by a restart
[Bug] NEXTN/MTP draft acceptance decays to ~0 over server uptime on qwen4_exp (Qwen3.8-Flash-Next), fully restored by a restart
Summary
On RadixArk/Qwen3.8-Flash-Next-NVFP4 (qwen4_exp, hybrid GDN linear-attention + QSA sparse
full-attention MoE) served with NEXTN speculative decoding, draft acceptance is healthy at boot and
decays to near-zero over roughly 24 hours of uptime. Throughput falls ~2.9x. There is no crash,
no error, and no warning in the log - the server just gets progressively slower while reportingaccept rate: 0.00. A full restart restores it completely and reproducibly.
This is distinct from #21138 (constant 0.33 from boot, NemotronH weight-loading, fixed by #30456)
and from #19796 (radix-cache prefix hit → NaN crash, not gradual decay).
Measurements
Identical prompt, max_tokens: 400, temperature: 0, single stream.
| server state | accept rate | accept len | throughput |
|---|---|---|---|
| fresh boot | 0.47 – 0.76 | 2.4 – 3.3 | 31 – 47 tok/s (74.5 peak logged) |
| after ~4 days uptime | 0.00 – 0.20 | 1.00 – 1.60 | 16.4 – 19.6 tok/s |
| after restart (same box, same args) | 0.48 – 0.74 | 2.45 – 3.23 | 40.6 – 47.1 tok/s |
Decode log at boot:
Decode batch, #running-req: 1, ..., accept len: 3.27, accept rate: 0.76, ..., gen throughput (token/s): 31.40
Decode log after ~4 days:
Decode batch, #running-req: 1, ..., accept len: 1.00, accept rate: 0.00, ..., gen throughput (token/s): 13.78
Onset: booted 08-27, first accept rate: 0.00 batches appear 08-28 (~24 h). Zeros then interleave
with occasional low non-zeros (0.05–0.27) rather than being uniformly zero, i.e. it is per-request
and degrades progressively, not a single hard break.
Ruled out
- Draft weight loading / quantization.
speculative_draft_model_quantizationresolves tomodelopt_fp4while the checkpoint'squantization_config.ignorelistsmtp.*. This is
identical in the healthy-at-boot run, so it is not the cause. Draft loads asQwen4ExpForCausalLMMTP, draft decode/extend CUDA graphs capture normally. - Thinking mode.
enable_thinking: trueandfalsedegrade equally. - Load/contention. Degraded numbers reproduce at
#running-req: 1.
Suspected cause
We believe this is the ghost-node path fixed by #35821 (commit 23e51dd, merged 2026-08-21),
which describes exactly the accumulate-over-time mechanism we observe: an empty node inserted withkey_len=0 creating stale copy-on-write sources that poison subsequent match_prefix calls.
Poisoned prefix matches would degrade draft quality progressively and be wiped by a restart.
Neither half of #35821 appears to be present in the lmsysorg/sglang:qwen38flashnext image
(digest sha256:12d3392b…, built 2026-08-26, sglang 0.0.0.dev1+gd91c3682b):
srt/speculative/spec_utils.pycomputesto_track_ith = torch.clamp(tracking_point - seq_lens_pre_verify - 1, min=0)— the lower bound
only. Thetorch.minimum(..., accept_lens - 1)upper bound from #35821 is absent; there is notorch.minimumin the file.srt/mem_cache/mamba_radix_cache.pyhasif len(key) == 0: return 0, True, which returns early
but does not free unprotected KV/mamba slots as #35821 describes.
So the day-0 qwen38flashnext image branch appears to have been cut without these fixes even
though they merged five days before the image was built.
Question for maintainers: is this the same root cause as #35821, or a separate uptime-decay
path specific to qwen4_exp? If the former, can the qwen38flashnext image be rebuilt to include
it?
Environment
- Model:
RadixArk/Qwen3.8-Flash-Next-NVFP4(176B total / 6B active, 512 experts top-10,
48 layers, GDN:QSA 3:1, MTP 1 layer, 262144 ctx) - Hardware: 2× NVIDIA DGX Spark (GB10, SM121), TP=2 across nodes over ConnectX-7 RoCE
- Image:
lmsysorg/sglang:qwen38flashnext@sha256:12d3392b…,sglang 0.0.0.dev1+gd91c3682b
Disclosure: we run a local derivative of this image that swaps the QSA packed-varlen attention
for a Triton FlashDecoding-style fallback, because the stock image's flash-attn-4 CuTe-DLS kernels
fail MLIR compilation on SM121. That patch touches the attention path, not the mamba radix cache orspec_utils. We can retest on the stock image if the QSA path is suspected, though it does not boot
here unpatched.
Launch args (abridged)
--tp-size 2 --nnodes 2 --node-rank 0 --quantization modelopt_fp4
--attention-backend flashinfer --page-size 64
--speculative-algorithm NEXTN --speculative-num-steps 3
--speculative-eagle-topk 1 --speculative-num-draft-tokens 4
--mamba-ssm-dtype bfloat16 --mamba-radix-cache-strategy extra_buffer
--mamba-track-interval 64 --mamba-full-memory-ratio 0.3
--mem-fraction-static 0.82 --chunked-prefill-size 1024
--max-running-requests 16 --context-length 262144
--disable-prefill-cuda-graph --enable-metrics
Reproducing / monitoring
# acceptance distribution across the server's whole uptime
docker logs <container> | grep -oE 'accept rate: [0-9.]+' | sort | uniq -c
# current state
curl -s localhost:8010/metrics -H "Authorization: Bearer $KEY" \
| grep -E 'spec_accept_length|spec_accept_rate'
Note --decode-log-interval defaults to 40, so the decode log samples every 40 steps.
Two additions after reading the #35821 diff itself, both of which I think strengthen the case.
1. The trigger in the PR's own comment matches our workload exactly.
The added comment in cache_finished_req names the condition that plants a ghost node:
cache_finished_reqof a short request with no track boundary
We run --mamba-track-interval 64, so any request that never crosses a 64-token boundary
qualifies. Our deployment generates these on a fixed schedule: a health-check chat completion
every 2 minutes (~720/day) from the service watchdog, plus short agent turns. If each plants onekey_len=0 node that then acts as a stale mamba COW source for later match_prefix calls, the
poisoning rate is roughly constant and the tree is emptied only by a restart — which is exactly
the ~24 h decay and the clean restart recovery we measured. That gives the mechanism a concrete
rate, not just a plausible direction.
This also predicts something testable that we have not yet tried: a deployment with no short
requests should decay far more slowly or not at all.
2. There is a second, structurally identical clamp site that #35821 does not patch.
In the qwen38flashnext image, srt/speculative/spec_utils.py contains the unboundedto_track_ith computation twice:
- line ~834 in
_verify_commit_step_indices()— variablesseq_lens_pre_verify,mamba_track_interval. This is the site #35821 patches. - line ~1006 in
commit_mamba_states_after_verify()- the same computation under different
names (seq_pre,ti):
to_track_ith = torch.clamp(tracking_point - seq_pre - 1, min=0).to(torch.int64)
candidate = accept_index[req_idx, to_track_ith] - accept_indices_offset
accept_lens is already a parameter of that function, so the sametorch.minimum(..., accept_lens - 1) bound applies directly.
Applying only the upstream hunk would therefore leave this path unbounded. Discussion #36891
describes commit 23e51dd as "adapting merged PR #35821 ... bounding accepted-state tracking
across Pennyroyal's eager, fused CUDA, and KDA paths", which reads like the same multi-path
problem was already hit downstream.
Question: does 23e51dd cover both sites? If so, taking that commit wholesale looks safer
than hand-porting #35821, and the practical ask becomes rebuilding the qwen38flashnext image on
top of it.
In the meantime we are mitigating operationally: probe the decode log's accept rate every 6 h and
restart both ranks only when it falls below 0.35. That restores 16.4 -> 47.1 tok/s reliably, but it
costs ~10 min offline per cycle, so a real fix is very much preferred.