Skip to content

server: preempt a slot instead of ending every conversation when the KV pool fills - #184

Open
danielhanchen wants to merge 4 commits into
masterfrom
feat/server-side-preemption
Open

server: preempt a slot instead of ending every conversation when the KV pool fills#184
danielhanchen wants to merge 4 commits into
masterfrom
feat/server-side-preemption

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 5, 2026

Copy link
Copy Markdown
Member

Summary

With --kv-unified --parallel N, every slot is told it has the whole context while all of them share one pool of cells. Four chats that each fit on their own are admitted together, grow into the pool, and when the next decode does not fit update_slots() enters the KV-full retry ladder and ends with send_error on every processing slot. Four users lose four conversations at once, none of them anywhere near their own limit.

This change makes the server park a slot instead. When the cells the next decode will need do not fit, one running slot is chosen, its sequence is copied to host RAM with llama_state_seq_get_data_ext, its cells are released, and its task stays alive in a new SLOT_STATE_PREEMPTED state. When the pool has room again the state is copied back with llama_state_seq_set_data_ext and decoding continues from the same token, with the same sampler state. The client sees a pause in its stream and nothing else. The pause is invisible over the wire, so the OpenAI-compatible API, the built-in web UI and any third-party client all get it without changes.

All of it is in tools/server/server-context.cpp, inside server_slot and update_slots(), tagged [TAG_PREEMPT].

Policy

  • update_preemption() runs before pre_decode() on every iteration of update_slots(), only when kv_unified is set and there is more than one slot.
  • Idle slots holding a finished conversation's cached prompt are purged first, through the existing try_clear_idle_slots(). A running conversation is never asked to wait while a finished one is holding cells.
  • Victim choice: the slot with the most tokens is the leader and is never preempted, so one chat always makes progress and the pool cannot thrash. Among the rest, a slot already preempted PREEMPT_N_STARVED (3) times is passed over while any other candidate exists. The smallest remaining slot is parked, which frees the least work per pause.
  • Resume order: the most-preempted parked slot first, then the one that has waited longest, but a slot that does not fit yet does not hold up a smaller one that does. The smaller one is the first to be parked again if the pool fills, so the head of the line loses nothing.
  • A slot still processing its prompt is a victim too: between two chunks of a prompt is as clean a boundary as between two sampled tokens, and a slot that has not started yet holds at most a cached prefix. So two prompts that do not fit together do not fail together, and a large prompt arriving beside a running chat waits for it instead of ending it.
  • Parent/child (n_cmpl > 1) slots are not preempted. They share cells through seq_cp, so a per-sequence save and restore would free less than it costs to put back.
  • /metrics gains the counters n_preempt_total and n_resume_total and the gauges requests_preempted and preempt_ram_bytes; each /slots entry gains is_preempted and n_preempt. A client can tell a parked request from a slow one, and an operator can see the parked host RAM.
  • --preempt-ram N (env LLAMA_ARG_PREEMPT_RAM) bounds the host RAM parked sequences may hold, default 8192 MiB like --cache-ram. A slot that would not fit under the budget is not parked; when nothing can be parked the KV-full path runs as before. --preempt-ram 0 disables preemption.
  • A slot released while parked (cancelled or failed) frees its host mirror and clears its prompt so the next task on that slot cannot prefix-match against an empty cache.

Why the smallest slot

A discrete-step simulation of the pool (scripts/preempt_policy_sim.py in the Unsloth workspace, costs taken from the measured runs) compares victim choices with everything else held equal: keep the leader, restore most-preempted first, fit-first, the same anti-starvation rule. With four chats the choice barely matters (within 1 percent of makespan). With eight chats on 8192 or 16384 cells parking the smallest slot gives the shortest makespan, the shortest mean completion, the least waiting, the fewest preemptions and the fewest cells copied; parking the largest is the worst on every count (5 to 6 percent longer, 40 to 50 percent more cells copied); parking the newest arrival, which is what vLLM does, is within 1 percent of smallest. Restoring from host RAM beats recomputing the sequence by 3 to 7 percent of makespan at these sizes, which is the argument for a server-side save over a client-side resume.

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, no max_tokens, temperature 0, seed 1234.

build -c completed errors gen tokens wall preemptions
master (e9e0d99) 8192 1 of 4 3 6861 31.9 s n/a
master + #182 8192 0 of 4 4 0 6.7 s n/a
this branch, first cut 8192 4 of 4 0 22944 66.9 s 7
this branch 8192 4 of 4 0 17803 50.5 s 8
master + #182 16384 0 of 4 4 20.8 s n/a
this branch 16384 4 of 4 0 45.8 s 2

On master the retry ladder halves n_batch and three chats die on the speculative sub-batch index assertion (ggml-org#24840, fixed by #182). With #182 all four die together on Context size has been exceeded. With this branch failed to find free space in the KV cache is logged zero times: the ladder is never entered.

The watermark fires at 8185 to 8192 wanted cells out of 8192, so it is not preempting early. Releasing 2000 to 4000 cells takes 105 to 420 ms; restoring takes 28 to 85 ms into an empty pool and 300 to 420 ms into a nearly full one. Aggregate throughput across the four chats was 353 tok/s against 279 tok/s for running the same four chats one after another, because the server still batches whenever the pool allows. The first cut restored strictly in priority order; letting a smaller parked slot through when the head does not fit took the run from 66.9 s to 50.5 s on the same load.

Exactness

Four concurrent greedy streams do not reproduce four solo greedy streams even without this change, because the batch shape differs and the matmul reductions are not shape-invariant. A chat that was never preempted diverged from its solo run after 50 characters. So a concurrent-versus-solo comparison cannot measure the pause.

The env var LLAMA_SERVER_PREEMPT_EVERY=N preempts a slot every N generated tokens regardless of pressure. With one request at a time the batch shape is identical with and without it, so the pause is the only difference:

prompt forced preemptions chars identical to the unforced run
0 17 13449 yes
1 20 19454 yes
2 34 29889 yes
3 22 15545 yes

91 preemptions, byte-identical output, identical token counts, MTP drafting on throughout. Two further runs of the same check on prompts 0 and 2, one on the first cut (68 preemptions) and one on the final build (50 preemptions), also matched byte for byte. The save and restore round trip is exact and the sampler survives it.

A second model: Qwen3.6-35B-A3B UD-Q4_K_XL with its MTP head

Same flags and load, a mixture-of-experts model with the draft head active:

-c completed errors gen tokens wall preemptions aggregate tok/s
8192 4 of 4 0 23191 82.6 s 9 281
16384 4 of 4 0 42146 128.3 s 6 328

Forced preemption every 200 tokens on prompts 0 and 2, 69 park and restore cycles: byte-identical to the unforced run (7059 and 6915 tokens). After the run /metrics reported n_preempt_total 9, n_resume_total 9, requests_preempted 0, preempt_ram_bytes 0.

Cost when it does not fire

update_preemption() is a loop over the slots summing prompt.n_tokens() and returns immediately when the pool has room. One chat at a time on the same server flags, two prompts, two runs each, master against this branch:

prompt gen tokens master tok/s this branch tok/s output
0 3387 260.7, 263.6 267.1, 269.2 byte-identical
2 6917 317.0, 317.9 317.4, 314.4 byte-identical

Tests

tools/server/tests/unit/test_preempt.py, six tests on the two-slot unified pool with the stories260K model:

  • one request with LLAMA_SERVER_PREEMPT_EVERY=8 produces the same tokens as the same request without the knob, with at least six park and resume cycles in the log;
  • two requests that each fit alone (8 prompt plus 160 generated in 256 cells) but not together both finish with 160 tokens, no truncation and no context error;
  • two 150-token prompts that do not fit together both finish, so a slot still processing its prompt is parked and resumed;
  • a slot generating 230 tokens beside a 150-token prompt generating 90 both finish;
  • --preempt-ram 0 parks nothing and the requests fail the old way.
  • after a pressure run /metrics shows the preemptions and resumes, no request still parked and no parked RAM, and /slots shows no slot parked.

All pass on this branch with and without GPU offload, three runs each. The first two fail on master, the second with Context size has been exceeded.

Limitations

These are known and are the reason this is a first cut rather than the whole feature.

  1. Above --preempt-ram nothing more is parked; there is no fall back to recompute yet. The parked state is about 36 KiB per token for this model.
  2. A parked slot cannot evict anyone to make room for itself. It waits for the leader, which in the four-chat run meant waits of up to 24 s. A slot preempted three times is passed over while another candidate exists, but when it is the only one it is parked again rather than letting the pool fail.
  3. The pool estimate sums each slot's tokens. For SWA and recurrent models that overstates what the pool holds, so preemption fires early on those rather than late. Parent/child requests are not preempted at all and a group that outgrows the pool still fails the old way.
  4. Two models were tested, a dense 4B and a 35B-A3B mixture of experts, both with MTP drafting. SWA, recurrent and hybrid memory take different branches inside the state save and restore, and the interaction with context checkpoints on those models is unverified.
  5. A single chat that outgrows the whole pool on its own still gets Context size has been exceeded. That is now the only way this server ends a conversation on pool pressure.

Relation to #182 and #183

#182 fixes the speculative sub-batch index once the retry ladder is entered. #183 reduces the damage on a full pool from every slot to one slot. This change stops the pool from filling in the first place. All three are independent and merge cleanly; a prefill that cannot fit can still reach the ladder, so #182 is still needed.

Daniel Han and others added 3 commits September 5, 2026 01:04
…KV pool fills

With --parallel N --kv-unified there is one pool of cells and every slot believes it owns
all of them. When the pool fills, llama_decode returns 1, the retry ladder in decode()
halves n_batch down to 1, and the server calls send_error on EVERY processing slot:
"Context size has been exceeded". Four chats sharing a 8192-cell pool on Qwen3.5-4B-MTP
die together after six seconds, none of them anywhere near its own 8192 limit. The code
already says what should happen instead: "TODO: try to terminate only the largest active
slot/sequence and continue with the rest".

Terminate nothing. Once per update_slots(), before the batch is built, compare what the
pool holds against what the next decode will ask for. If it does not fit, take the cells
back from one slot: copy its sequence out with llama_state_seq_get_data_ext, release the
cells, and park the slot in a new SLOT_STATE_PREEMPTED. When the pool has room the copy
goes back with llama_state_seq_set_data_ext and the slot carries on. The task, the
sampler, the generated text and the position the stream has reached never left the slot,
so the continuation is the one the slot would have produced without the pause, and a
streaming client sees a gap and nothing else.

The check sits before the batch is built on purpose: at that point every slot is at a
token boundary, prompt.tokens is exactly what the cache holds for it, and no draft is in
flight, so a slot can be removed without unpicking a half-decoded batch. The speculative
draft is dropped with the cells, which costs the step its speedup and nothing else.

Victim policy: keep the slot that is furthest along, since it is the closest to finishing
and to giving its cells back, and among the rest prefer one that has not been preempted
three times already, then the smallest. A prompt cached on an idle slot is cheaper than a
conversation waiting to continue, so try_clear_idle_slots() is asked first, both before
preempting anyone and before deciding a resume does not fit.

Measured on Qwen3.5-4B-UD-Q4_K_XL with an embedded MTP head, --parallel 4 --kv-unified
-c 8192, four streaming chats with 1000-token prompts at temperature 0:

  base            4 of 4 chats killed by "Context size has been exceeded" after 6.7 s
  with this       4 of 4 chats completed, 0 errors, 7 preemptions, 7 resumes,
                  22944 tokens in 66.9 s (343 tok/s aggregate)

and the retry ladder never fires at all. At -c 16384 the same load still kills all four
on the base and still completes all four here.

LLAMA_SERVER_PREEMPT_EVERY=N preempts every generating slot every N generated tokens
regardless of pressure. With one request on an idle server the batch has the same shape at
every step, so it isolates the resume from batch nondeterminism: over 91 forced
preemptions across four prompts, every continuation is byte-identical to the same prompt
run without any.
Two tests on the two-slot unified pool. The first runs one request with
LLAMA_SERVER_PREEMPT_EVERY=8 and asserts the tokens match the same request
without the knob. The second runs two requests that each fit alone but not
together and asserts both finish with no context error. Both fail on master:
the knob is unknown there, and the second request dies with Context size has
been exceeded.
…, and bound the parked state with --preempt-ram

A slot still processing its prompt is between two chunks of it, which is as
clean a boundary as between two sampled tokens, so it is a victim too: two
prompts that do not fit together no longer fail together, and a large prompt
arriving beside a running chat waits for it instead of ending it. A slot that
has not started yet holds at most a cached prefix and is parked the same way,
which is how it waits.

Restoring takes the most-preempted parked slot first, but one that does not
fit yet no longer holds up a smaller one that does: the smaller one is the
first to be parked again if the pool fills, so the head of the line loses
nothing.

--preempt-ram N (LLAMA_ARG_PREEMPT_RAM) bounds the host RAM parked sequences
may hold, default 8192 MiB like --cache-ram. A slot that would not fit under
the budget is not parked, and when nothing can be parked the KV-full path runs
as before. --preempt-ram 0 disables preemption.

The prompt batching pass skips parked slots explicitly. Speculation is only
restarted on restore for a slot that was generating; one parked mid-prompt
starts it when its prompt is done, as it always did.

Tests: two prompts that overflow the pool together, a generating slot beside
a large prompt, and --preempt-ram 0 restoring the old behaviour.
@chatgpt-codex-connector

Copy link
Copy Markdown

You have reached your Codex usage limits for security reviews. Please try again later.

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 5, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-05T01:15:37.863608Z 32c0a77 PR opened
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

Counters n_preempt_total and n_resume_total, gauges requests_preempted and
preempt_ram_bytes, and is_preempted plus n_preempt on each /slots entry, so a
client can tell a parked request from a slow one and an operator can see the
parked host RAM. A parked slot no longer counts as busy in
n_busy_slots_per_decode, since it took no part in the decode.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 32c0a77e16

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

// continue, so give those cells up first - same call the KV-full path makes.
for (;;) {
for (auto * slot : parked) {
if (preempt_kv_used() + preempt_kv_reserve() + preempt_n_need(*slot) + PREEMPT_N_MARGIN <= n_cells) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Allow near-capacity parked prompts to resume

When a parked request needs more than n_cells - PREEMPT_N_MARGIN cells, this predicate can never select it even after every other sequence and idle cache have been removed. For example, with -c 256 and a logical batch large enough, two valid 252-token prompts cause one empty SLOT_STATE_STARTED slot to be parked; after the leader finishes, preempt_n_need() returns 252 and 252 + 8 <= 256 remains false. Because preempt_restore() is never attempted, the restore-failure timeout also never runs, leaving that request parked indefinitely. The margin must not prevent a sequence that fits the actual cache from being restored, or the requested prompt chunk must be reduced accordingly.

Useful? React with 👍 / 👎.

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.

1 participant