server: one preemption path, swap under --preempt-ram and recompute above it - #186
Draft
danielhanchen wants to merge 2 commits into
Draft
server: one preemption path, swap under --preempt-ram and recompute above it#186danielhanchen wants to merge 2 commits into
danielhanchen wants to merge 2 commits into
Conversation
…bove it #184 parks a slot by copying its sequence to host RAM and #185 by dropping its cells and re-prefilling them later. This makes them one mechanism: the victim choice, the restore order and the metrics are shared, and the host RAM budget decides how each victim is parked. A slot that fits under --preempt-ram is copied out and copied back, which is exact; one that does not is dropped and recomputed, which costs nothing to hold. Each slot records which way it was parked so the restore takes the matching path. --preempt-ram 0 now means never copy, always recompute, so preemption no longer has a way to be turned off through it: --preempt / --no-preempt is that switch. /metrics gains n_preempt_swap_total, n_preempt_recompute_total and n_recompute_tokens_total beside the existing counters, and /slots gains preempt_mode and n_recompute.
Review of the merged path found several places where the recompute half treated the two kinds of resume alike when they are not. A slot parked while it was still processing its prompt keeps no replay list: it starts that prompt over, and that is an ordinary prefill in every respect. A slot parked while generating carries the tokens it has already produced, and re-prefilling those must be invisible. preempt_replaying() now separates them, and: - init_sampler() runs again for a restarted prompt, which had never run it, and is still skipped for a replay, which must not lose its penalties, its grammar or its RNG. - the timings a client reads describe the request it made. A replayed token no longer counts into timings.prompt_n and no longer drags the prompt timestamp forward, which was reporting the whole parked wall clock as prompt time and inflating the generation rate by the same amount. n_recompute on /slots and n_recompute_tokens_total on /metrics carry the work instead, counted as the tokens are decoded so that an interrupted replay is not charged twice. - the start-of-processing chunk goes out once per task, tracked on the slot, so a slot parked before it ever sent one still sends it. - a restarted prompt is charged one batch, like any other prompt, in both preempt_n_need() and preempt_kv_reserve(). Charging it the whole prompt made a prompt near the size of the pool impossible to wake and then ended it with a context-overflow error it would never have hit had it not been parked. - a slot swapped part-way through a replay is charged the rest of that replay, which is what preempt_kv_reserve() already charged it. The give-up path also reports the sequence it is giving up on rather than the replay list of a slot that has none.
This was referenced Sep 5, 2026
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
#184 parks a slot by copying its sequence to host RAM and putting it back; #185 parks it by dropping its cells and re-prefilling them later. This makes them one mechanism with one policy, and lets the host RAM budget choose per victim: a slot that fits under
--preempt-ramis copied out and copied back, which is exact to the byte; a slot that does not fit has its cells dropped and its tokens re-prefilled when room returns, which holds nothing at all. That closes limitation 1 of #184, where above the budget nothing more was parked and the KV-full ladder ran as before.Each slot records which way it was parked, so the restore takes the matching path:
llama_state_seq_set_data_extfor a swapped slot, a replay through prompt processing for a recomputed one. Everything else is shared: oneupdate_preemption(), one victim choice, one restore order, one set of metrics.All of it is in
tools/server/server-context.cpp, insideserver_slotandupdate_slots(), tagged[TAG_PREEMPT].Policy
Unchanged from #184, with one change and one addition.
PREEMPT_N_STARVED(3) times passed over while another candidate exists, prompt-phase slots are victims, parent/child (n_cmpl > 1) are not. Idle finished slots are purged first throughtry_clear_idle_slots().preempt_kv_reserve()charges a resuming slot its whole remaining replay rather than one batch, so the next iteration cannot wake a second slot into cells the first has not claimed yet.Flags
--preempt-ram N(envLLAMA_ARG_PREEMPT_RAM) keeps its name and its 8192 MiB default, and gains a meaning at each end.-1is no limit, so nothing is ever recomputed and this is server: preempt a slot instead of ending every conversation when the KV pool fills #184 exactly.Nis the host RAM parked sequences may hold; over it, parks fall through to recompute.0no longer disables preemption, it means never copy, always recompute, which is server: KV preemption by recompute, a zero host RAM alternative to #184 #185 exactly.--preempt/--no-preempt(envLLAMA_ARG_PREEMPT), on by default, is the switch that--preempt-ram 0used to be. It is the one incompatible change here and the README says so next to the flag.LLAMA_SERVER_PREEMPT_EVERY=Nis kept, and parks through the same mode decision, which is what makes the exactness check below measure the hybrid rather than one half of it.Reporting
/metricskeepsn_preempt_total,n_resume_total,requests_preemptedandpreempt_ram_bytes, and gainsn_preempt_swap_total,n_preempt_recompute_total(which sum ton_preempt_total) andn_recompute_tokens_total. Each/slotsentry keepsis_preemptedandn_preemptand gainsn_recomputeandpreempt_mode, which is"swap","recompute", or null before the task has been parked at all.Results
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, four concurrent streaming chats with roughly 1000-token prompts, nomax_tokens, temperature 0, seed 1234. Three runs per cell. The GPU is shared, so wall time varies between identical runs and aggregate tok/s is the metric.--preempt-ram-c12 of 12 requests completed in every cell, 96 in all, 0 errors,
failed to find free space in the KV cacheandContext size has been exceededlogged zero times anywhere. The four budgets are within the run-to-run spread of each other; the choice is about host RAM and exactness, not throughput.The split is worth reading against the parked sizes rather than the counts. At
-c 8192a park that holds a real sequence is 118 to 194 MiB of host RAM for 1947 to 4083 cells, target state plus draft state. So at 150 MiB the boundary falls inside the distribution: of the 39 swaps, 15 carried a sequence of 1928 to 2185 cells at 118 to 127 MiB and the other 24 were slots parked before they had taken any cells at all, while all 27 recomputes were sequences of 2660 cells and up. At 64 MiB, and at either budget on-c 16384, every sequence is over the line and the only things copied are the empty slots. A budget only mixes where it lands inside the distribution of park sizes, andpreempt_ram_byteson/metricsis what an operator sizes it against.A recompute is also cheaper to park than a copy, 6 to 9 ms against 120 to 350 ms, and that shows in the counts: the tighter the budget the more parks a run does, because parking is nearly free and the pool is rebalanced more often. It costs re-prefill instead, which is batched work and the reason the throughput columns barely move.
Exactness
One request at a time,
LLAMA_SERVER_PREEMPT_EVERY=200, against the same request without the knob. With one request on an idle server the batch has the same shape at every step, so the pause is the only difference.--preempt-ramSo the hybrid keeps #184's guarantee wherever the budget holds and reproduces #185's divergence, to the same character, wherever it does not. 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. That is the trade the budget buys, and it is why the copy is preferred whenever there is room for it.
Cost when it does not fire
update_preemption()is a loop over the slots that returns as soon as the pool has room. One chat at a time on the same flags,-c 8192, two runs each, master (e9e0d992) against this branch:The GPU is shared with another tenant, so the spread within a build is wider than the difference between builds; the point of the row is the last column.
Tests
tools/server/tests/unit/test_preempt.pyfrom #184 runs unchanged except for its one test that asserted--preempt-ram 0restores the old failure. That is precisely the semantic this change alters, so the test now uses--no-preemptand keeps its assertions. The other five are untouched.tools/server/tests/unit/test_preempt_hybrid.pyis new, four tests on the stories260K model:/metricsshowsn_preempt_swap_total == n_preempt_totalwith no recomputes and no parked RAM;--preempt-ram 0two requests that overflow the pool together both finish with their full token count,/metricsshows only recomputes, and no request is left parked. On server: preempt a slot instead of ending every conversation when the KV pool fills #184 this configuration is the old failure, both requests dying withContext size has been exceeded;--preempt-ram 1on four slots, where 1 MiB is inside the range of park sizes for that model, both counters come back non-zero, they sum ton_preempt_total, and every request finishes;usage.prompt_tokensis still the prompt the client sent,timings.prompt_nis still its prompt andtimings.prompt_msis still its prefill, whilen_recompute_tokens_totalshows the work. That last pair is not a formality: on server: KV preemption by recompute, a zero host RAM alternative to #184 #185 the same request reportsprompt_n384 for a 20-token prompt and aprompt_mslarger than its whole generation time.Ten tests, three runs on GPU and one on CPU, all pass.
Limitations
--preempt-ram -1and accept the host RAM, or size the budget above the largest park they expect.preempt_ram_bytesand the two new counters are there to size it.usage.prompt_tokens,timings.prompt_nandtimings.prompt_msdescribe the request as it was made, and the work is reported on/slotsasn_recomputeand on/metricsasn_recompute_tokens_total. A request that was recomputed therefore looks, in its own timings, like one that was not, apart from the wall clock.Context size has been exceeded. Both are inherited from server: preempt a slot instead of ending every conversation when the KV pool fills #184. A sequence that grew to withinPREEMPT_N_MARGIN + 1 + n_speccells of the whole pool while parked is ended with that same error rather than left waiting; it was within a dozen tokens of the limit in any case.Relation to #184 and #185
This is #184 with #185 built into it as the path above the budget, on #184's branch.
--preempt-ram -1is #184 exactly and--preempt-ram 0is #185 exactly, both reproduced here: byte-identical output over 50 forced parks at one end, the same two divergence points as #185 measured at the other. #185 was opened as a draft proposing exactly this, so it can be closed in favour of this change.