server: KV preemption by recompute, a zero host RAM alternative to #184 - #185
Draft
danielhanchen wants to merge 2 commits into
Draft
server: KV preemption by recompute, a zero host RAM alternative to #184#185danielhanchen wants to merge 2 commits into
danielhanchen wants to merge 2 commits into
Conversation
With --kv-unified the cells are one pool shared by every slot and each slot believes it has all of them. When the pool fills, the retry ladder halves n_batch and the server ends every conversation in flight with "Context size has been exceeded", including the ones nowhere near their own limit. This parks one slot instead. Its cells are dropped with llama_memory_seq_rm and nothing is copied out: the two things needed to rebuild the sequence are already in RAM, its token list (prompt.tokens, prompt plus everything generated) and its sampler. When the pool has room the slot re-enters the ordinary prompt-processing path, re-prefills its whole sequence and carries on sampling with the same sampler, from the same token. A streaming client sees a pause, not an error. No host RAM is held while it waits. The resume for a generating slot replays prompt.tokens plus `sampled`, the token it had sampled but not yet decoded, so the logits land at exactly the position the interrupted step was about to read them from and the DONE_PROMPT transition samples the token that step would have sampled. A slot parked while still processing its prompt starts its own prompt over and needs no replay list. usage.prompt_tokens stays task->n_tokens() and never grows; timings.prompt_n accumulates the recomputed tokens, which is what it counts, and /slots reports the split as n_recompute. The sampler is never reset on a resume, so penalties, grammar and RNG carry across the pause untouched. Policy: idle finished slots are purged first, the slot with the most tokens is never preempted, the smallest of the rest is parked, a slot parked three times is passed over while another candidate exists, resume is most-preempted first then longest parked with fit-first, prompt-phase slots are victims too, n_cmpl > 1 and multimodal slots are not. --preempt / --no-preempt (env LLAMA_ARG_PREEMPT), on by default and active only with --kv-unified and more than one slot. /metrics gains n_preempt_total, n_resume_total, n_recompute_tokens_total and requests_preempted; /slots gains is_preempted, n_preempt and n_recompute. LLAMA_SERVER_PREEMPT_EVERY=N forces a park every N generated tokens for the exactness test. tools/server/tests/unit/test_preempt_recompute.py covers the forced-preemption identity, the usage accounting, two generating slots and two prompt slots that overflow together, a generating slot beside a large prompt, --no-preempt, and the metrics.
…leaving it waiting A slot is parked with only its token list kept, so the cells it needs back are the whole sequence. If the pool shrank below that, or the sequence grew into the pool while it was running, no amount of waiting brings it back and the client would hang forever. Give up on it with the same context-overflow error the ordinary path raises. Nothing else in the resume loop can end a parked slot, so this is the only way one leaves without resuming.
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.
Summary
A second server-side design for the same problem as #184, built from scratch so the two can be compared on the same hardware: when the unified KV pool cannot fit the next decode, park a slot by dropping its cells and re-prefilling its tokens when room returns, instead of copying its state to host RAM. This is vLLM's default preemption mode. It holds zero bytes while parked; the price is that a resumed answer may differ from the uninterrupted one, which is measured below rather than assumed.
Everything is in
tools/server/server-context.cpp, insideserver_slotandupdate_slots(), tagged[TAG_PREEMPT].Mechanism
update_preemption()runs inupdate_slots()immediately beforepre_decode(), the same place as server: preempt a slot instead of ending every conversation when the KV pool fills #184: every slot is at a token boundary,prompt.tokensmatches the cache, no draft is in flight.prompt_clear(), which isseq_rmon acommon_memoryholding both the target and the draft context, so the MTP draft sequence goes with them.spec_draft,spec_i_batchandspec_ckptare dropped because a draft is a prediction, not a result. Nothing is copied out.prompt.tokens + sampled, the token the slot had sampled but not yet decoded. Re-prefilling exactly those puts the logits where the interrupted step was about to read them, and theDONE_PROMPTtransition samples the token that step would have sampled. A victim still in its prompt phase needs no replay list; it starts its own prompt over.update_prompt_start()only whent_start == 0, no secondis_beginor progress chunk into a live stream, nostats.n_gen = 0and noinit_sampler()atDONE_PROMPT, so penalties, grammar and the RNG carry on from where they were.common_speculative_beginin theDONE_PROMPTtoGENERATINGtransition.usage.prompt_tokensstays the client's prompt.timings.prompt_naccumulates the recompute, soprompt_per_secondis not meaningful for a recomputed request;/slotsreportsn_recomputefor the clean split.Policy
Identical to #184 so the comparison is fair: idle finished slots are purged first, the slot with the most tokens is never preempted, the smallest of the rest is parked, a slot parked
PREEMPT_N_STARVED(3) times is passed over while another candidate exists, restore is most-preempted first then longest parked with fit-first, slots still processing their prompt are victims, parent/child slots are not. Two additions RECOMPUTE forces: multimodal slots are excluded, since their token list holds placeholders rather than chunks, and a resuming slot is passed over as a victim while another candidate exists.preempt_kv_reserve()charges a resuming slot its whole remaining replay, not one batch, otherwise the next iteration wakes a second slot into cells the first has not claimed yet. A parked sequence that no longer fits the pool at all is ended with the ordinary context-overflow error rather than waiting forever.--preempt/--no-preempt(envLLAMA_ARG_PREEMPT), on by default, active only with--kv-unifiedand more than one slot./metricsgainsn_preempt_total,n_resume_total,n_recompute_tokens_totalandrequests_preempted;/slotsentries gainis_preempted,n_preemptandn_recompute.Exactness
Qwen3.5-4B UD-Q4_K_XL with the embedded MTP head,
--parallel 4 --kv-unified --spec-type draft-mtp --spec-draft-n-max 2 --flash-attn on, one request at a time, temperature 0, seed 1234. The unforced path is byte-identical to master on both prompts, twice each. Forced parks (LLAMA_SERVER_PREEMPT_EVERY) are not:The single-park row is the clean one: 11875 characters at about 3.97 characters per token is generated token 2991, against a park at 3002. The divergence lands at the resume. Prefill and decode are different kernels, the recomputed K/V differ in the last bits, and a greedy argmax flips at the first near tie after the resume. A park can also cost nothing, as the strict-prefix row shows. #184 is byte-identical over 160 forced cycles on the 4B and the 35B; RECOMPUTE cannot make that promise and does not.
Cost
Both builds from the same base (
e9e0d992), run back to back on the same GPU in one session, three runs per cell, four concurrent chats with roughly 1000-token prompts, nomax_tokens. Wall time is not comparable across builds because the answers differ, so aggregate tok/s is the metric.-c12 of 12 completions on both builds, 0 errors,
failed to find free spacelogged zero times. #184 reproduced its own PR row here (17803 generated tokens in 51.2 s, 8 preemptions, against 17803 / 50.5 s / 8 in its body), which validates the harness. RECOMPUTE costs about 3 percent of throughput at 8192 and is at parity at 16384 while re-running through the model more tokens than the whole load generated, because a recompute is batched prefill rather than decode.Solo cost when preemption never fires,
-c 8192, one chat, two runs each: master 264.0 / 236.9 and 289.8 / 312.4 tok/s on prompts 0 and 2; this branch 233.3 / 255.4 and 299.3 / 312.7. Output byte-identical to master.Tests
tools/server/tests/unit/test_preempt_recompute.py, seven tests on the two-slot unified pool with the stories260K model: forced-park identity (which does hold on that tiny model, so the 4B measurement above is the one that matters), two requests that fit alone but not together both finish, two prompts that do not fit together both finish, a generating slot beside a prefilling one,--no-preemptrestores the old failure,/metricsand/slotsreport the parked state, and usage does not double count the recompute. All pass on this branch; the first two fail on master, the second withContext size has been exceededon both requests.When to use which
SWAP (#184) where host RAM allows: it is exact and slightly faster. RECOMPUTE where host RAM is the binding constraint or the parked volume is large: at about 50 KiB per token on this 4B, four 8k chats park about 0.6 GiB, and a 128k-context deployment with 16 slots would pin tens of GiB and pay 300 to 420 ms of copy per restore into a full pool. The right shape is both, SWAP under
--preempt-ramand RECOMPUTE as the fallback above it, which is limitation 1 in #184. This PR is opened as a draft so that fallback can be built on it once #184 lands, or so it can stand alone where exactness matters less than memory.