Skip to content

Fix single-request generation being killed early on hybrid-SWA models under DFLASH/DSPARK speculative decoding - #33805

Open
ormandj wants to merge 1 commit into
sgl-project:mainfrom
ormandj:fix/dspark-swa-eviction
Open

ormandj wants to merge 1 commit into
sgl-project:mainfrom
ormandj:fix/dspark-swa-eviction

Conversation

@ormandj

@ormandj ormandj commented Aug 6, 2026

Copy link
Copy Markdown
Contributor

Motivation

spec_prepare_for_decode routes dflash-family algorithms (DFLASH, DSPARK) to DFlashDraftInputV2.prepare_for_decode, which never calls maybe_evict_swa and never advances decode_batch_idx — which the eviction gate requires to be >= 1. eagle_prepare_for_decode does both. On hybrid-SWA models the sliding-window eviction is therefore unreachable under DFLASH/DSPARK: SWA KV is retained for every generated token, so a single request exhausts the SWA pool after roughly swa_full_tokens_ratio * max_total_num_tokens generated tokens and is retracted by the scheduler mid-generation.

Reproduced on DeepSeek-V4-Flash-0731 (2x RTX PRO 6000, SM120, TP=2, DSPARK block size 5, swa_full_tokens_ratio=0.1, max_total_num_tokens=778496). #swa token tracks generated tokens 1:1, and the request is retracted at exactly 77,824 tokens — align_page_size(int(778496 * 0.1)), the full SWA pool — while the full pool is only 10% used:

#full token: 77824, full token usage: 0.10, #swa token: 77824, swa token usage: 1.00
KV cache pool is full. Retract requests. #new_tokens_gained: 77824

Long prompts are unaffected — a 131k-token prefill holds ~8.5K SWA tokens. Only generated tokens accumulate.

The same missing call also skips the DSV4 compress-state drain (maybe_evict_dsv4_state, documented "called every decode step from ScheduleBatch"), so the NPU + DSV4 + DFLASH combination leaks state-pool slots for the same reason.

Modifications

speculative/spec_utils.py: in the dflash-family branch, mirror eagle_prepare_for_decode — call batch.maybe_evict_swa() and advance decode_batch_idx. Eviction runs before the tick so the overlap-scheduler gate (decode_batch_idx >= 1) still skips the first decode iteration, exactly as on the eagle path.

test/registered/unit/spec/test_decode_bookkeeping_ownership.py: record the new bookkeeping owner in _OWNER_SITES, as that guard requires for a genuinely new owner.

test/registered/unit/speculative/test_spec_prepare_swa_eviction.py (new): asserts the dflash branch evicts, ticks, evicts before ticking, keeps advancing across iterations (so the counter stays a clock rather than a flag), and that the eagle path is unchanged.

Accuracy Tests

Eviction frees only SWA slots behind the window — max(sliding_window, page_size) back from the sequence on the radix path, page-floored pre_len - sliding_window on the chunk-cache path. SWA layers attend within the window, and both the eagle and non-spec paths already call this same code every decode step (eagle_utils.py, mem_cache/allocation.py). No output change observed.

Speed Tests and Profiling

The ignore_eos request that previously died at 77,809 tokens completed 100,000 tokens with the fix, at a sustained ~159 tok/s and zero retractions. #swa token sampled repeatedly across the run stayed between 512 and 1,024 (~1% of the SWA pool), against 77,824 at the point of failure before the fix.

Checklist

Prepared with AI assistance.


CI States

Latest PR Test (Base): ❌ Run #31830154702
Latest PR Test (Extra): ❌ Run #31830154341

@ormandj

ormandj commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Per MAINTAINER.md, CODEOWNERS has requested the Speculative decoding owners. Flagging the KV Cache oncalls as well — @ispobock @xiezhq-hermann — since the change is in srt/speculative but its correctness depends on the SWA eviction machinery in srt/mem_cache (free_swa_out_of_window_slots, SWATokenToKVPoolAllocator.free_swa) being safe to invoke from the dflash-family path. I could not add you as reviewers directly (no permission), hence the mention.

Two points that may help review:

  1. Cross-platform. The change makes maybe_evict_swa() run on the dflash-family path on every platform. maybe_evict_dsv4_state is a no-op unless the allocator has c4_state_attn_allocator (NPU only), so CUDA/ROCm are unaffected by that part. On NPU + DSV4 + DFLASH the state drain becomes reachable where it previously was not — by inspection every primitive is monotonic and idempotent (free_swa filters > 0 then zeroes full_to_swa_index_mapping; free_swa_out_of_window_slots only acts when swa_evicted_seqlen advances; _free_state_range returns early below its watermark), and DSV4NPUTokenToKVPoolAllocator.free documents that the prefix is expected to have already been returned by the eviction path. I have no NPU hardware and could not verify this at runtime — a check from someone who can would be welcome.

  2. test_decode_bookkeeping_ownership.py. The new bookkeeping owner is recorded in _OWNER_SITES as that guard requires. cc @fzyzcjy as its author, in case the two entries should be expressed differently.

Non-hybrid-SWA models are unaffected: the whole body is behind tree_cache.supports_swa(), which is False by default.

ehuaa added a commit to ehuaa/sglang that referenced this pull request Aug 14, 2026
…SH decode path

`spec_prepare_for_decode` routes the dflash family (which includes DSPARK) to
`batch.spec_info.prepare_for_decode`, and that handler omits two things the
EAGLE path does: `maybe_evict_swa()` and advancing `decode_batch_idx`. Both are
needed -- the eviction gate inside `maybe_evict_swa` is
`swa_maintenance_step and req.decode_batch_idx >= 1`, so adding the call alone
would still never fire.

The effect on a hybrid-SWA model is that SWA KV is retained for every generated
token and never reclaimed: measured on DeepSeek-V4-Flash-0731 (tp8 + CP8 +
DSPARK, A100), a single request at 33,792 tokens of context held 32,300 SWA
tokens instead of a bounded window. The pool then fills at
`swa_full_tokens_ratio * max_total_num_tokens` and the scheduler starts
retracting, which re-prefills `prompt + everything generated so far`. Pool
sizing already assumes this eviction runs (pool_configurator computes
`trailing_tokens = window + eviction_interval * draft_tokens + page_size`).

Measured at in=1024 / out=32768, ratio 0.1 unless noted, no retraction in any
row:

  before  conc=11              1113.6 tok/s
  before  conc=14, ratio 0.3   1331.9 tok/s   swa peak 0.91  <- best reachable
  after   conc=32              2220.1 tok/s   swa peak 0.14
  after   conc=48              2292.8 tok/s   swa peak 0.20

1.72x, and per-request SWA at full context drops 32,300 -> 1,024. Because SWA
stops being the scarce pool, `--swa-full-tokens-ratio` no longer needs raising:
0.1 measures the same as 0.3 (2220 vs 2186 tok/s, inside run-to-run noise), so
the default is right again and the memory goes back to the full pool.

Upstream PR is still open; applies clean on this branch.
ehuaa added a commit to ehuaa/sglang that referenced this pull request Aug 14, 2026
…EE_OUT_OF_WINDOW_SLOTS on

Frees out-of-window SWA slots when a chunked-prefill request is stashed into
the unified radix tree (`UnifiedRadixCache.cache_unfinished_req` ->
`SWAComponent.free_out_of_window_slots`). We do run UnifiedRadixCache
(`impl=UnifiedRadixCache`, components FULL+SWA), so the flag is live for us.

Measured no effect on our workload, in either direction. A/B on
DeepSeek-V4-Flash-0731 with in=13,513 (2 chunks at chunked_prefill_size=8192)
/ out=2048 / conc=32, with sgl-project#33805 also applied:

  on   1138.3 tok/s   prefill-phase swa peak 0.07
  off  1147.1 tok/s   prefill-phase swa peak 0.07

The chunking did happen (`#new-token: 8192` then `1792` per sequence), so this
is a real null result, not a missed code path. The reason is that
`max_prefill_tokens=16384` admits only one or two sequences per prefill batch,
so the out-of-window KV this frees is a per-request transient that never
accumulates across the batch -- and with sgl-project#33805 in place the decode side is
already reclaiming for every request that has moved on. It should matter with
a much wider prefill batch or much longer inputs.

Carried anyway to stay close to upstream default. Note the known issue behind
upstream sgl-project#34823: under EAGLE the new-leaf path computes
`split_pos = swa_evicted_seqlen - result.prefix_len`, mixing raw-token lengths
with bigram key units, and split_pos goes negative. That PR gates the option
off for EAGLE only and explicitly keeps it for DSpark/DFlash, which is what we
run -- but anyone switching this branch to EAGLE should set
SGLANG_OPT_UNIFIED_CACHE_FREE_OUT_OF_WINDOW_SLOTS=0 until sgl-project#34823 lands.
spec_prepare_for_decode routes dflash-family algorithms (DFLASH, DSPARK) to
their own prepare_for_decode, which never calls maybe_evict_swa and never
advances decode_batch_idx; eagle_prepare_for_decode does both. On hybrid-SWA
models this retains sliding-window KV for every generated token, so a single
request exhausts the SWA pool after swa_full_tokens_ratio *
max_total_num_tokens generated tokens and is retracted by the scheduler.
Pool sizing already assumes the eviction runs (pool_configurator:
trailing_tokens = sliding_window + eviction_interval * draft_tokens +
page_size).

Mirror eagle_prepare_for_decode: evict before advancing decode_batch_idx so
the overlap-scheduler gate (decode_batch_idx >= 1) keeps protecting the
first decode iteration.

Reproduced on DeepSeek-V4-Flash-0731 (SM120, TP=2, DSPARK block 5, ratio
0.1, pool 778,496): SWA occupancy tracked generated tokens 1:1 and the
request was retracted at exactly 77,824 tokens. With the fix, the same
request generated 100,000 tokens with SWA occupancy holding at ~512 tokens.
@ormandj
ormandj force-pushed the fix/dspark-swa-eviction branch from 26a2a39 to 40034c4 Compare August 14, 2026 18:45
ehuaa added a commit to ehuaa/sglang that referenced this pull request Aug 26, 2026
…SH decode path

`spec_prepare_for_decode` routes the dflash family (which includes DSPARK) to
`batch.spec_info.prepare_for_decode`, and that handler omits two things the
EAGLE path does: `maybe_evict_swa()` and advancing `decode_batch_idx`. Both are
needed -- the eviction gate inside `maybe_evict_swa` is
`swa_maintenance_step and req.decode_batch_idx >= 1`, so adding the call alone
would still never fire.

The effect on a hybrid-SWA model is that SWA KV is retained for every generated
token and never reclaimed: measured on DeepSeek-V4-Flash-0731 (tp8 + CP8 +
DSPARK, A100), a single request at 33,792 tokens of context held 32,300 SWA
tokens instead of a bounded window. The pool then fills at
`swa_full_tokens_ratio * max_total_num_tokens` and the scheduler starts
retracting, which re-prefills `prompt + everything generated so far`. Pool
sizing already assumes this eviction runs (pool_configurator computes
`trailing_tokens = window + eviction_interval * draft_tokens + page_size`).

Measured at in=1024 / out=32768, ratio 0.1 unless noted, no retraction in any
row:

  before  conc=11              1113.6 tok/s
  before  conc=14, ratio 0.3   1331.9 tok/s   swa peak 0.91  <- best reachable
  after   conc=32              2220.1 tok/s   swa peak 0.14
  after   conc=48              2292.8 tok/s   swa peak 0.20

1.72x, and per-request SWA at full context drops 32,300 -> 1,024. Because SWA
stops being the scarce pool, `--swa-full-tokens-ratio` no longer needs raising:
0.1 measures the same as 0.3 (2220 vs 2186 tok/s, inside run-to-run noise), so
the default is right again and the memory goes back to the full pool.

Upstream PR is still open; applies clean on this branch.

(cherry picked from commit 3226bc5)
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.

3 participants