server: tell a streaming client when its slot is parked and restored - #190
server: tell a streaming client when its slot is parked and restored#190danielhanchen wants to merge 2 commits into
Conversation
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.
47767ad to
65f8f54
Compare
…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.
|
One more commit,
Measured with two streaming completions on the 4B at 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. |
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-keepaliveevery 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_EVERYtest knob) and at the restore site, through aserver_task_result_preempt_noticepushed 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 8192and 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-pingpath 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.