Skip to content

[Spec] DSPARK read_accept: return copies, not views of buffers the next verify replay rewrites (fixes #42465) - #42812

Open
divyvasal wants to merge 2 commits into
sgl-project:mainfrom
divyvasal:fix/dspark-read-accept-copy
Open

divyvasal wants to merge 2 commits into
sgl-project:mainfrom
divyvasal:fix/dspark-read-accept-copy

Conversation

@divyvasal

@divyvasal divyvasal commented Oct 6, 2026 •

Copy link
Copy Markdown
Contributor

Motivation

Fixes #42465. With DSPARK in greedy mode (folded accept) and the overlap scheduler on, TP ranks can commit different token counts for the same request.

DsparkVerifyEpilogue.read_accept() returns views of fixed buffers:

  • commit_lens_buf, out_tokens_buf, correct_len_buf, bonus_buf, cap_trim_lens_buf, new_seq_lens_buf;
  • the folded accept kernels write them inside the target-verify CUDA graph;
  • the next replay overwrites them.

Under event_loop_overlap:

  • the result device-to-host copy (accept_lens / next_token_ids) runs on copy_stream;
  • forward_stream never waits on that copy;
  • the loop launches step N+1 before it consumes step N's copy.

A copy that runs late therefore reads step N+1's values. The race resolves independently on each rank, so ranks disagree on kv_committed_len.

With SGLANG_ENABLE_RANK_CONSENSUS_CHECKER=1:

[TP0/1/2] Found rank divergence ... cache_finished_req ... owned_kv_len=531526
[TP3]     Found rank divergence ... cache_finished_req ... owned_kv_len=531528

With HiCache (write_through), the divergent inserts become divergent backups. The ranks end up one collective apart and the replica hangs, as described in #42465.

Why HiCache triggers it: HiCache's D2H write-through traffic delays the small result copy by a different amount on each rank. Under that traffic the race fires within minutes.

Without HiCache: the race is still present, but we did not observe it in ~21M tokens. If it does fire, it can also return the next step's next_token_ids on rank 0.

Modifications

  • read_accept() returns .clone() of each buffer.
  • The clones are made on the forward stream right after the verify replay, so they capture step N's values.
  • The allocator owns them, so async_d2h's record_stream keeps them alive until the copy has run.
  • Cost: six tiny device-to-device copies per verify step.
  • The overlap scheduler stays on. --disable-overlap-schedule also avoids the bug, but cost us 14-20% per-request decode throughput.

Accuracy / validation

Setup: DeepSeek-V4.1-Flash (MXFP4), TP4/EP4 on 4x B300, two replicas per node, nightly 4cbf290f plus this change.

Flags:

--speculative-algorithm DSPARK --speculative-dspark-block-size 5 --schedule-policy shortest-prefill-first
--chunked-prefill-size 8192 --enable-hierarchical-cache --hicache-ratio 2 --hicache-write-policy write_through
--mem-fraction-static 0.60

The checker was on. Workload: greedy (temperature 0) unique ~540K-token prompts, max_tokens 4; 1 in flight on one replica, 8 on the other.

Build Result
without this change both replicas hung (one within ~1 min, one within ~19 min); earlier runs logged the owned_kv_len divergence above within 8-12 min
with this change, run 1 + run 2 43.1M tokens per replica, 0 rank divergence, 0 hangs/restarts

Controls on the same hardware and workload, ~21M tokens each, checker on:

Config Rank divergence
HiCache without DSPARK none
DSPARK without HiCache none
HiCache + DSPARK with --disable-overlap-schedule none

So the bug needs DSPARK, HiCache and the overlap scheduler together.

New CPU unit test: test/registered/unit/spec/test_dspark_read_accept_no_alias.py.

  • It asserts that the returned tensors do not share storage with the buffers.
  • It asserts that they keep their values after the buffers are overwritten.
  • It fails on main and passes with this change.

Lint: isort / ruff format / ruff check --select=F401,F821,UP037 / codespell all clean.

Checklist

  • Unit test added
  • Formatted with the repo's pre-commit tools

CI States

Latest PR Test (Base): ❌ Run #40133351715
Latest PR Test (Extra): ❌ Run #40133351725
Latest PR Test (AMD ROCm 10): ❌ Run #40133351776

dg1kjd pushed a commit to dg1kjd/sglang-sxm2 that referenced this pull request Oct 7, 2026
…fers; DeepSeek-V4.1 frees a spilled conversation's host copy right away.

DSpark's accept step returned views of fixed buffers that the
target-verify CUDA graph writes and its next replay overwrites. Under the
overlap scheduler the copy of those results to the host can run after
that replay, so each tensor-parallel rank could read a different step's
values and commit a different number of tokens for the same request. The
accept step now returns copies made right after the replay
(sgl-project/sglang#42812, by Divy Vasal, with its unit test).
DeepSeek-V4.1's DSpark worker had copied the step lengths itself for the
same reason; that copy is gone.

DeepSeek-V4.1 keeps one conversation's sparse-attention (CSA2) state on
the GPU and writes it to disk when a request starts a different
conversation. torch.save leaves what it writes referenced from a
reference cycle, so the host copy of each spilled state, about 240 MB
per GPU process, stayed in memory until Python's next full garbage
collection. The engine freezes its startup objects, which makes those
collections rare, so a run that keeps switching conversations grew by
gigabytes and pushed the host into swap. The session store now runs a
full collection right after each spill; in the running engine one takes
about a millisecond. A unit test checks that nothing written outlives
the spill. On 58 single-turn requests, each one a conversation switch,
the eight GPU processes went from 122.7 to 145.5 GiB before the fix and
stayed at 121.1 to 120.9 GiB with it, with no swap growth.

The DeepSeek-V4.1 model page and the README said a second conversation
always prefills from zero. The serve script keeps the last four
conversations on disk, and a later request that continues one of them
loads it back. After switching away and back, a 300-token continuation
of a 3k-token conversation reached its first token in 3.1 s, against
9.5 s to prefill the 3k tokens cold. The page now has a row for this.

Two speculative-decoding unit tests follow upstream changes:
test_dflash2 drops the cases for helpers that no longer exist, and the
DP spec-prefill coordination test mocks the DSpark target worker.

Checked on 8x V100-SXM2. GLM-5.3-Flash: gsm8k 92.8% (all 1319
questions, 5-shot, greedy), 125 output tok/s at two requests in
parallel, 3.40 tokens per verify step. DeepSeek-V4.1-Flash: chat, tool
calls and image prompts pass; follow-up turns of 40 / 300 / 700 new
tokens reach the first token in 0.95 / 2.28 / 3.27 s; decode 11.2 tok/s
greedy and 10.5 at temperature 1 on merge_sorted (5.94 / 5.61 tokens
accepted per step), unchanged.
@divyvasal

Copy link
Copy Markdown
Contributor Author

@hnyls2002 @ch-wan could one of you take a look and add run-ci? This fixes the TP-rank deadlock in #42465: read_accept() returned views of buffers that the next verify replay overwrites, so under the overlap scheduler a late result copy could read the next step's accept lengths on some ranks only. The change returns copies instead; validated at 43M tokens per replica with 0 rank divergence (details in the description).

ormandj added a commit to ormandj/sglang-deepseek-v41-flash-sm120 that referenced this pull request Oct 17, 2026
…iting the static buffers

0.4.0-rc.2 crashed on the first folded DSpark verify: sgl-project/sglang#42812
makes read_accept return clones, and main's #43790 passes
out=self.read_accept(bs) to the in-graph accept, so every replay wrote
throwaway copies and commit_lens read back as 0 (a negative correct-draft
count in the scheduler). The carry now writes through _accept_buffers and
keeps the copies for the post-replay read (internal up/dspark-read-accept-no-alias
621aeac1, new regression test); main's test_dspark_accept_tail.py, which
fails on rc.2's tree, joins the CPU tests.
@shanemort1982

Copy link
Copy Markdown
Contributor

Heads-up before this lands: on current main, DsparkVerifyEpilogue._accept writes the in-graph results through build_accept_outs(..., out=self.read_accept(bs)) (dspark_verify.py ~L799 at 62cee1b). With this PR's change, read_accept returns clones, so those writes go into throwaway tensors, the persistent buffers stay at zero, and the folded path reads accept_len 0 on every step. We hit exactly that porting this PR onto a recent nightly: num_correct_drafts comes out as -1 and the scheduler dies with an IndexError in update_spec_correct_drafts_histogram on the first GPU decode step.

The PR head still applies against main's layout because it predates the build_accept_outs(out=...) refactor (the head branch fills the buffers with explicit copy_() calls), so it merges cleanly but breaks at runtime.

What worked for us: keep a views accessor as the write target and clone only for the reader.

def _accept_buffers(self, bs):   # views; out= target inside the graph
    return AcceptOuts(correct_len=self.correct_len_buf[:bs], ...)

def read_accept(self, bs):       # clones, as this PR intends
    b = self._accept_buffers(bs)
    return AcceptOuts(correct_len=b.correct_len.clone(), ...)

and _accept passes out=self._accept_buffers(bs). A CPU-only test that writes through _accept_buffers and asserts read_accept returns those values (and keeps them after the buffers are overwritten) catches the regression; the existing alias-only tests pass on the broken form.

divyvasal and others added 2 commits October 24, 2026 14:08
…xt verify replay rewrites

DsparkVerifyEpilogue.read_accept returned views of fixed buffers (commit_lens,
out_tokens, ...) that the folded accept kernels write inside the target-verify
CUDA graph and that the next replay overwrites. Under the overlap scheduler the
result D2H runs on copy_stream, which forward_stream never waits on, so a copy
that runs late reads step N+1's values. This happens per rank, so TP ranks can
commit different token counts for the same request (the rank-consensus checker
reports cache_finished_req owned_kv_len differing by 1-2 tokens) and HiCache
then hangs on the divergent inserts (sgl-project#42465).

Return clones made right after the replay; async_d2h's record_stream keeps
them alive until the copy has run.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
…ead_accept's copies

Since the build_accept_outs(out=...) refactor, both in-graph accept writers
(DsparkVerifyEpilogue._accept and the sampling accept region,
sampling_accept_commit_lens) passed out=read_accept(bs). With read_accept
returning copies, those writes landed in throwaway tensors: the static
accept buffers stayed at their old values, so the folded path read
accept_len 0 (num_correct_drafts -1, IndexError in
update_spec_correct_drafts_histogram), and the engram history commit and
future relay, which read the buffers directly, saw stale lengths.

Add accept_buffers(bs), the views, as the out= target of both writers;
read_accept clones from it for the readers, keeping the sgl-project#42465 fix.

The new unit test writes through accept_buffers and checks read_accept
returns those values and keeps them after the buffers are overwritten. It
fails when the write target is a copy; the alias-only tests do not.

Reported by shanemort1982 in the PR review.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
@divyvasal
divyvasal force-pushed the fix/dspark-read-accept-copy branch from 7a8f749 to 155fa07 Compare October 24, 2026 21:14
@divyvasal

Copy link
Copy Markdown
Contributor Author

@shanemort1982 thanks for catching this, and for the clear writeup. You're right: since the build_accept_outs(out=...) refactor, the in-graph writes were landing in the clones. sampling_accept_commit_lens in the sampling accept graph had the same problem.

I rebased onto main and added an accept_buffers(bs) views accessor that both writers now use as the out= target, while read_accept keeps returning copies, so the #42465 fix stays as it was. I also added the CPU test you described. It fails on the clone-as-target form, where the old alias-only tests still pass, and the existing accept-tail parity tests on main catch the break too.

@github-actions

Copy link
Copy Markdown
Contributor

Required checks failed on 155fa07; this PR cannot be merged until they pass.

PR title (run)

PR title must use "[Type] scope: title" or "[Type] [Hardware] scope: title". Type must be one of: Feat, Fix, Refactor, Perf, Doc, Test, CI, Chore. Scope is required and may be any module or model name, with unrestricted letter case (letters, numbers, dots, underscores, slashes, or hyphens). Hardware is optional; use a single tag without spaces. Use uppercase hardware tags (AMD, NVIDIA, NPU, MI300X), except for exactly these four spellings: Intel, Apple, Huawei, Ascend. Reverts must use GitHub's default format: Revert "original title". Use one space between fields and after the colon, with a non-empty title and no leading or trailing whitespace. Examples: "[Fix] attention: Fix KV cache indexing", "[Feat] qwen3: Add model support", "[Perf] [AMD] flash-attn: Optimize decode performance".
Edit the title; the check reruns on its own.

@0xtoward

Copy link
Copy Markdown

Hi, we hit the same aliasing from a different direction, in case it helps the review.

On 0.5.22 with --speculative-algorithm DSPARK --speculative-dspark-block-size 7 --speculative-dspark-sps-table-path

and SGLANG_RAGGED_VERIFY_MODE=compact, the synchronous loop (--disable-overlap-schedule) loses acceptance: 2.42 vs 3.09 under the overlap scheduler and 3.12 eager, on the same 80 MT-Bench prompts (Qwen3-8B-based target, greedy), while the outputs stay byte-identical.

Mechanism: the sync scheduler adopts GenerationBatchResult.new_seq_lens (a view of new_seq_lens_buf) as the next batch’s seq_lens; the next replay’s in-graph accept rewrites it right after the verify, so inject_ragged computes the draft-KV injection positions from post-commit lengths (positions [13..20] where [10..17] were correct, slots right, RoPE phase wrong). Static verify isn’t affected because its injection uses the window’s precomputed positions; eager returns fresh tensors; overlap keeps its own FutureMap buffer. Returning copies from the folded accept, as this PR does, brings the sync loop back to 3.09.

@divyvasal

Copy link
Copy Markdown
Contributor Author

Thanks @0xtoward, this is really helpful! It's the same aliasing showing up in a sneakier way: the outputs stay identical, so you'd only notice the drop in acceptance. That lines up with the mechanism too. Anything that keeps a read_accept result around past the next replay ends up reading the next step's values.

Heads up that I rebased yesterday (now at 155fa07c). On current main the in-graph accept writes its results through out=, so the PR now gives the writers a separate accept_buffers(bs) that returns views, and read_accept still hands out copies. If you have time to rerun your sync-loop MT-Bench check on the new head, I'd love to know it still gets you back to ~3.09.

@hnyls2002 @ch-wan this now covers two issues: the TP-rank deadlock in #42465 and this acceptance loss in the sync loop. Would one of you mind taking a look and adding run-ci?

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug] DeepSeek-V4 + HiCache write_through: TP ranks deadlock under concurrent long prefills (scheduler and detokenizer go silent, /health 503)

3 participants