Skip to content

server: tell a streaming client when its slot is parked and restored - #190

Draft
danielhanchen wants to merge 2 commits into
feat/server-side-preemptionfrom
feat/server-side-preemption-notify
Draft

server: tell a streaming client when its slot is parked and restored#190
danielhanchen wants to merge 2 commits into
feat/server-side-preemptionfrom
feat/server-side-preemption-notify

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 5, 2026

Copy link
Copy Markdown
Member

Stacked on #184. Only the last commit is new; the rest is the swap branch.

Summary

A streaming client is told when its slot is parked and when it is restored, as SSE comments (: preempted, : resumed), with a : preempt-keepalive every 2 s while parked. Comments are legal SSE and invisible to every existing client, so the body is unchanged by preemption. Non-streaming requests see nothing.

Without this a parked slot is a silent connection: the client cannot tell "waiting for cells" from "slow" or "dead", and any client-side read timeout or proxy idle timeout fires during a wait that is long by design. Unsloth Studio uses the comments to show a paused line on the chat that was parked and to stand its own preemption down when the server can do it.

Policy

Unchanged. The notice is emitted at the two park sites (the KV-full victim and the LLAMA_SERVER_PREEMPT_EVERY test knob) and at the restore site, through a server_task_result_preempt_notice pushed to the task's result queue for streaming tasks only. Notices that precede the first result (a slot parked while still processing its prompt) are sent in front of it. While parked the ping period is 2 s regardless of --sse-ping.

Results

Raw stream under LLAMA_SERVER_PREEMPT_EVERY=64: park and resume pairs every 64 tokens, body identical to the unparked run. Through Unsloth Studio on Qwen3.5-4B with two MTP drafts at -c 8192 and four slots: four concurrent chats 4 of 4 with the pause shown in the GUI on every chat that was parked, zero errors.

Exactness

The generated tokens with forced parks equal the unparked run token for token (harness test), and through Studio both 1200 token prompts are byte-identical across 18 parks each (seed 0, temperature 0).

Cost when it does not fire

None. No notice is created unless a slot is parked, and the keepalive is the existing --sse-ping path with one extra branch.

Tests

tools/server/tests/unit/test_preempt_notify.py: comments arrive in order and the body is unchanged on /completion, the same on /v1/chat/completions, a non-streaming request is untouched, and two overflowing streams both finish with the parked one saying so. 10 passed together with the existing preemption tests.

Limitations

No keepalive before the first result, so a park during prompt processing is covered by the client's first-token deadline rather than the 2 s ping.

Follow-up commit

The keepalive promised above had never fired. The server's result queue notifies one condition variable for every result of every task, and the reader's timed wait restarted on each wakeup, so on any busy server the timeout never elapsed; the same wait serves the ordinary --sse-ping. The wait now targets a fixed deadline. Paired on this branch against an isolated build of the previous head, a 13.9 s park carried zero keepalives before and four after, spaced at the 2 s period plus the reader's 1 s polling granularity, none outside the park.

A slot parked by the preemption path produces nothing until its cells come back,
and to a client that is indistinguishable from a hung server: the stream goes
silent, read timeouts fire, and a chat that was merely waiting for room is torn
down as broken.

Push a small out-of-band result to the task's response queue when a streaming
slot is parked and when it is restored. The HTTP layer writes it as an SSE
comment, ": preempted" and ": resumed", which is legal SSE that every existing
client ignores, so the body of the response is unchanged by preemption. While
parked the ping runs every 2 s as ": preempt-keepalive" regardless of --sse-ping,
so proxies and client read timeouts survive a wait that is long by design.
Notices that arrive before the first real result (a slot parked while it was
still processing its prompt) are sent in front of it. Non-streaming requests
see nothing.

Harness test: forced parks every 8 tokens on /completion and /v1/chat/completions
carry the comments in park/resume order and generate the same tokens as the
unparked run; a non-streaming request is untouched; two streams that overflow
the pool together both finish and the parked one says so.
@danielhanchen
danielhanchen force-pushed the feat/server-side-preemption-notify branch from 47767ad to 65f8f54 Compare September 5, 2026 17:18
…is kept alive

Found by putting #190 and #192 together and then looking for the keepalive that
#190 promises. It never arrives.

Live, the 4B on one B200, two streaming completions that do not fit together so
one is parked until the other finishes, every SSE line timestamped as it arrives:

  6.19s  B  : preempted
 16.82s  B  : resumed

A 10.63 s silence on a stream whose whole point is that it says ": preempt-
keepalive" every 2 s. Four-chat runs at -c 8192 and -c 4096 show the same: parks
of up to 14.59 s by the server's own "resumed after" line, and not one keepalive
on any stream in any run.

server_response::send() notify_all()s a single condition variable for every result
of every task, and server_response::recv_with_timeout() waited with wait_for(),
which restarts on every wakeup. A reader waiting on a task that is producing
nothing is therefore woken by every token every other task produces, and its
wait_for() never elapses. On a server with any traffic at all the timeout is not
a timeout: whoever waits for a quiet task waits indefinitely.

A parked slot is the worst possible case for this, because a slot is only ever
parked while the others are busy, so the keepalive was unreachable by
construction. The same applies to the ordinary --sse-ping, which likewise only
fired on an otherwise idle server, and to the should_stop polling in
server_response_reader::next(), whose own comment says it happens every
polling_interval_seconds and did not.

Compute the deadline once and wait_until() it. Spurious wakeups then re-check the
queue and go back to waiting for the same instant, which is what every caller
already reads the argument as meaning.

After, the same two streams:

  6.26s  B  : preempted
  9.26s  B  : preempt-keepalive
 12.26s  B  : preempt-keepalive
 14.26s  B  : preempt-keepalive
 16.75s  B  : resumed

Three keepalives across a 10.49 s park, at the 2 s period plus the reader's 1 s
polling granularity. The probe is scripts/integ_keepalive_probe.py.

No harness test: stories260K generates several hundred tokens a second, and at the
context sizes the harness uses a park lasts two or three seconds, which is the
keepalive period itself. Every sizing I tried either parked for milliseconds at a
time as the pool oscillated around full, or did not park at all. A test that
straddles the period it is testing would be worse than none, so the regression is
pinned by the live probe above.
@danielhanchen

Copy link
Copy Markdown
Member Author

One more commit, 5a791e03c: the keepalive this PR promises never actually arrived, and the reason is in the result queue rather than in the notice path.

server_response::send() calls notify_all() on a single condition variable for every result of every task, and server_response::recv_with_timeout() waited with wait_for(), which restarts the wait on each wakeup. A reader waiting on a task that is producing nothing is therefore woken by every token every other task produces and its timeout never elapses, so on a server with any traffic at all the timeout is not a timeout. A parked slot is the worst possible case by construction, because a slot is only ever parked while the others are busy, which is exactly when the wakeups never stop. The same applies to the ordinary --sse-ping, which could only ever fire on an otherwise idle server, and to the should_stop polling in server_response_reader::next(), whose own comment says it happens every polling_interval_seconds and did not. The fix is to compute the deadline once and wait_until() it, so a spurious wakeup re-checks the queue and goes back to waiting for the same instant, which is what every caller already reads the argument as meaning.

Measured with two streaming completions on the 4B at -c 2560 with two slots, sized so they do not fit together and one is parked until the other finishes, every SSE line timestamped as it arrives. Before, on an isolated copy of 65f8f5464 with only this file reverted, the parked stream shows a park at 17.40 s and a resume at 31.27 s and nothing in between, a 13.87 s silence on a stream whose entire purpose is to say something every 2 s. After, on the same workload, the park lands at 17.39 s and the resume at 31.25 s with keepalives at 20.39, 23.39, 26.39 and 28.39 s, four across a 13.86 s park, spaced at the 2 s period plus the reader's 1 s polling granularity, and none outside the park. Both runs produced 1201 tokens on each stream.

Cost is none measurable: nothing extra happens per result, and on the branch this was found on the same four-chat control gives 139.6 tok/s before the change and 139.2 after. There is no harness test for it, because at the context sizes the test model runs at a park lasts about as long as the keepalive period itself, and a test straddling the period it is testing would be worse than none, so this is pinned by the probe above. tools/server/tests/unit/test_preempt_notify.py is 4 passed and test_preempt.py 6 passed on the new head.

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