Conversation
…hared prefix The finish-time soft pin kept windowed (SWA) KV only for `[prompt_len - sliding_window - _SWA_RETAIN_GAP, prompt_len)`, freeing the head eagerly. That covers a follow-up turn, which diverges at the prompt end, but not FAN-OUT: many requests sharing a system prompt (and tool schemas), each carrying its own message, diverge a whole message before the previous prompt's end. Reuse needs windowed KV live at the divergence point, so every such request re-prefilled the shared prefix. Measured on an 8x RTX 4090 DSV4 deployment, one shared 8192-token system prompt: append (history + reply + new turn) : cached 8832/8916 = 99%, 2.68 s fan-out (same system, new question) : cached 0/8274 = 0%, 4.58 s and after this change, the same test: fan-out : cached 8192/8274 = 99%, 0.77 s four sessions, one system prompt : 99% / 99% / 99% / 99% The head stays unlocked and un-tombstoned, so under real pressure it is still the first thing evict_swa reclaims -- the pool reclaims it lazily instead of eagerly. Window-pool usage on that deployment peaked at 0.27 after the change (0.15 before), with zero "SWA pool exhausted", zero evictions and zero OOM. Assisted-by: Claude Code
test_swa_fanout_retention.py drives the real CacheManager on CPU: prefill a request with a long tail, finish it, then match a second request sharing only the head. It fails on the old code with "reused 0 of 600 tokens" and passes after the fix, while the append path keeps reusing as before -- that pair is the whole regression surface. test_swa_fanout_stress.py covers the risk the change introduces: keeping the head live must not starve the pool. It checks multi-round fan-out at a roomy pool, fan-out under a pool too small to retain anything (must evict, not raise -- issue FlashML-org#202), mixed fan-out + append, page_size > 1 alignment, and a bounded chain. Both files hide the GPU (`CUDA_VISIBLE_DEVICES=""`, asserted by a guard test) because `_maybe_pinned` calls `pin_memory()` whenever torch.cuda.is_available(), so a CPU-only test on a serving box would otherwise allocate real device memory and can OOM the engine next to it. Assisted-by: Claude Code
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes #200.
Summary
The finish-time soft pin in
_cache_req_swakept windowed (SWA) KV only forfreeing everything below that eagerly. The comment states the intent: a follow-up turn diverges at the prompt end when the client drops reasoning, so only the trailing window needs to stay live. That holds for append. It does not hold for fan-out — many requests sharing a system prompt (and tool schemas), each carrying its own message, diverge a whole user message before the previous prompt's end. Reuse needs windowed KV live at the divergence point, so every such request re-prefilled the shared prefix.
This removes the eager free. The head stays unlocked and un-tombstoned, so
ensure_swa_slots→evict_swastill reclaims it under real pressure — the pool reclaims it lazily instead of eagerly.trim_head_swaitself is untouched; only this one caller goes away, so its own unit tests and the radix driver/adapters keep exercising it.Measured on hardware
8x RTX 4090, DSV4-Flash, one shared 8192-token system prompt,
temperature: 0.After the change, same test:
Concurrently, 8 sessions sharing one 8192-token prompt: 5 reused 99% and 3 reused 0%, and the engine log shows why — the first batch admits them together, before anything is in the tree:
That same-batch race is expected and left alone: it is structural (
match_reqonly sees what is already inserted, and a request enters the tree at commit). Quantified against this deployment's logs it accounts for ~1% of recomputed tokens as an upper bound — not worth a change, and noted here so it is not mistaken for a regression.Risk: does keeping the head break the pool?
That is the one thing this change can get wrong, so it is what the tests target. Keeping the head live must not turn "reclaimed under pressure" into "never reclaimed" (issue #202's unhandled
RuntimeError).test_swa_fanout_stress.py: multi-round fan-out at a roomy pool; fan-out under a pool too small to retain anything (must evict, not raise); mixed fan-out + append;page_size > 1alignment; bounded chain.SWA pool exhausted, zero evictions, zero OOM.Testing
tests/scheduler/test_swa_fanout_retention.py— realCacheManageron CPU; fails before the fix withreused 0 of 600 tokensand passes after; the append case is asserted alongside so the fix cannot silently trade one for the other.tests/scheduler tests/kvcache tests/engine: 471 passed, 8 skipped; unmodifiedmainat68a81ffgives 462 passed, 8 skipped. The delta is exactly the 9 new tests. The 2 failures (tests/engine/test_cache_budget.py,RuntimeError: Attention backend 'fi' requires flashinfer) fail identically on unmodifiedmain.CUDA_VISIBLE_DEVICES=""with a guard assertion:_maybe_pinnedcallspin_memory()whenevertorch.cuda.is_available(), so a "CPU-only" test on a serving box allocates real device memory (I hitAcceleratorError: CUDA error: out of memoryon a live engine that way).Note on #200's open question
The issue says it could not fully reconcile the measured cliff (~7 divergent tokens) with
_SWA_RETAIN_GAP = 16. The reproduction here is consistent with the mechanism as stated: what matters is whether the divergence point falls inside the retained[prompt_len - window - gap, prompt_len), and with a long tail it falls far below it. Two earlier attempts of mine failed to reproduce for exactly that reason — the first request's tail has to exceedwindow + gap(144 tokens here) before the fan-out diverges past the retained region; with an 83-token tail the match still landed inside it and reused fine.Assisted-by: Claude Code