server: remove two host side stalls in the decode loop at high concurrency - #200
server: remove two host side stalls in the decode loop at high concurrency#200danielhanchen wants to merge 10 commits into
Conversation
server_response kept every pending result in one vector behind one condition variable. Each result was pushed after a linear walk of the waiting id set, then notify_all woke every waiting HTTP thread, and each of them took the same mutex and scanned the whole vector before going back to sleep. With N slots generating that is N wakeups and N vector scans per token, so N^2 per decode step, all of it contending for the mutex the decode thread needs to send the next token. Results are now queued on a per reader waiter. Ids registered together share one waiter, so a send is an O(1) lookup followed by a push and a wakeup of exactly the thread that asked for that task. Order is preserved: the waiter holds a FIFO and recv() takes the front, which is what scanning the shared vector from the start did. A reader whose ids have already been removed from the waiting list still waits out the poll interval it asked for rather than returning at once, so a caller that keeps polling does not spin, and the blocking recv() re-checks the running flag on a bounded wait so terminate() cannot leave it parked. Measured with llama-server at 32 slots, one request per slot, 128 prompt and 256 generated tokens: queue_results.send() 119.0 us to 3.6 us per call, and the whole result path per decode step 3.97 ms to 0.34 ms.
…them server_slot::generated_token_probs is read in exactly one place, send_final_response(), and only under n_probs > 0. Every other request still pushed a completion_token_output per token, each with a heap allocated string, into a list that grows for the whole generation and is then discarded. The output is unchanged: with n_probs <= 0 nothing ever reads the list.
|
@codex security review |
Codex Review SummaryThis comment shows the latest Codex review activity on this pull request.
ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings. |
|
Codex Review: Didn't find any major issues. 🎉 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
What this changes, in four questions1. Before the PR, what happened?
2. After the PR, what happens? One waiter per reader, shared by every task id that reader registered in one call. A result is queued on the waiter that owns the id, so sending it wakes only the thread waiting for it, and that thread finds its result without searching. The probability buffer is only filled when 3. Is this a real issue or a fake one? Real, and it gets worse exactly where it hurts. Run 4. If merged, does it break anything?
Verified: builds clean with RPC off; the server test suite passes. Note a fresh worktree has no Also carries a comment-reduction pass, AST-gated as comments-only against the pre-pass head. |
…aiting list recv() asserted that the waiter exists. GGML_ASSERT is GGML_ABORT, and recv() runs on the HTTP thread, so a single request whose ids had been dropped by a cancel or a cleanup took the whole server down for every other client. Before the per-waiter queues this case was harmless: recv() waited on a condition that no longer fires for those ids, which parks that one connection and nothing else. The assert came in with the per-waiter lookup in this branch, so it is a regression this branch introduced rather than existing behaviour. Restore the old outcome. The lookup moves inside the loop so a waiter re-added while we wait is picked up rather than waited out, and shutdown is still noticed because the running check stays at the top. recv_with_timeout() already tolerated the missing waiter and is unchanged. tests/test-server-queue.cpp covers it: recv() on ids that were never registered must leave the process alive. On the parent commit the test aborts with SIGABRT at server-queue.cpp:454 while main is only sleeping, which is the defect exactly. The target is behind LLAMA_BUILD_TESTS and needs no model.
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Simulation to destructionFault injected deliberately, never waited for. CPU only, no GPU at any point. This one needs three arms, not two, because the defect worth flipping was introduced and fixed inside this PR:
New in this branch:
The parent aborts on A base defect this PR fixes that the description does not mention
Measured in isolation, 200,000 reader lifecycles each leaving one result queued at teardown, which is what a client disconnect during generation does (
End to end: many concurrent requests, four wedge shapes at once
24 healthy clients through the storm, 8 more after it,
This does not flip and is not supposed to: base does not abort here either, it just does N wakeups and N scans per token. It is the control that says the storm changes nothing and the output is byte identical under it. ThreadSanitizer
I am reporting this as the negative result it is. All 23 base and all 22 head reports are in KV cache and prefix cache, checked rather than assumed29 token prompt, 8 slots,
Identical on every field, including after the storm.
|
| probe | probability entries | base vs head |
|---|---|---|
n_probs: 10 |
24 | identical |
n_probs: 1 |
24 | identical |
n_probs: 0 |
0 | identical |
no n_probs |
0 | identical |
n_probs: 5 with a stop word that fires (stop_type = word) |
1 | identical |
n_probs: 5, post_sampling_probs: true |
24 | identical |
n_probs: 5, stream: true |
identical |
Memory at equal request count
A time-based comparison is misleading because this branch serves more requests in the same wall time. 1,200 streaming requests each killed after 400 ms, 8 at a time, exactly 1,201 slot releases on both arms:
| arm | RSS start | RSS after | growth |
|---|---|---|---|
| base | 98,148 kB | 844,580 kB | 746,432 kB |
| head | 99,272 kB | 786,560 kB | 707,288 kB |
5% lower at equal work. The ~600 kB per request growth itself is present on both arms and is not the result queue (the isolated measurement above accounts for 56 bytes of it on base and 0 on head); it is a pre-existing leak elsewhere, most likely the prompt cache, and is outside this PR.
A latent API sharp edge, reported not blocking
recv() and recv_with_timeout() pop w->results.front() without checking the id is in id_tasks. Ids registered together share one waiter, so recv({A}) when the reader registered {A, B} returns B's result. The probe in the new test shows it:
base PROBE recv() with a subset of a shared waiter returns nullptr (id filtered)
parent PROBE recv() with a subset of a shared waiter RETURNS THE SIBLING'S RESULT (id=201)
head PROBE recv() with a subset of a shared waiter RETURNS THE SIBLING'S RESULT (id=201)
Unreachable today: server_response_reader::next() always passes the whole set, and post_task() / post_tasks() both assert id_tasks.empty() so a reader can only register once. Worth one line of comment on recv() at most.
Related and also benign: an id registered after a reader has parked takes up to 1 s longer to be noticed here (708 ms) than on base (10 ms), because the parked reader polls condition_gone rather than being woken by send(). Every caller in the tree registers its ids before posting the task and before calling recv, so the window does not occur.
Regression suite
tools/server/tests pytest, -k "not slow", curl-enabled builds: 6 failed, 361 passed, 6 skipped on base and identically here. The same 6 failures on both, all of them -DLLAMA_OPENSSL=OFF in my config. Note for anyone else running it: a fresh worktree has no tools/server/tests/tmp and you get 11 failures and 9 errors unrelated to any change until you mkdir it.
Answers to the four questions
- What happened before. On base every
send()woke every waiting HTTP thread, each of which re-took the same mutex and scanned the whole shared vector: N wakeups and N scans per token, N^2 per decode step, contending for the mutex the decode thread needs. Base also leaks any result still queued for a reader torn down throughremove_waiting_task_ids(). On the parent commit, arecv()for ids that had already left the waiting list was aGGML_ABORTon the HTTP thread. - What happens after. A send is a map lookup, a push and one wakeup; a reader whose ids have gone parks itself and nothing else; results die with their waiter.
- Real or fake. The abort is real and reproduced deliberately, twice, on the parent commit. The leak is real and quantified. The N^2 wakeup cost is real but is a performance claim and is the description's own evidence, not something I set out to re-measure.
- Does merging break anything. Nothing found. 32 of 32 concurrent requests complete through four simultaneous wedge shapes with byte identical output,
/healthnever fails, the server exits cleanly, prefix and KV cache figures are identical field by field including after a 90 s disconnect storm, all seven probability shapes are identical, RSS at equal request count is 5% lower, the pytest suite is identical, and ThreadSanitizer finds no race in the changed paths on either arm.
What I could not run
Windows and macOS: no machine for either. From source, the changed code is std::unordered_map, std::shared_ptr, std::deque, std::mutex and std::condition_variable, with no platform-specific threading primitives and no pthread calls, so the only portability question is whether condition_variable::wait_for is monotonic-clock based; libstdc++, libc++ and MSVC's STL all use a steady clock for wait_for (as opposed to wait_until with a system clock), so the 1 s poll cannot be skewed by a wall-clock change on any of them. x86-64 was not run either; nothing here is architecture dependent.
|
Correction to the last paragraph of my previous comment. I asserted from memory that On pthread platforms Practical effect on this change: on an old glibc without |
…terminate() Three review findings, all of them regressions against the parent of this branch that its own test masked: - recv() and recv_with_timeout() popped the front of a shared waiter's queue without checking the id, so a caller asking for one id of a registered set could be handed a sibling's result. take_result() now scans for a requested id, in arrival order, which is what scanning the shared vector did. The front normally matches. - a timed receive that started before its ids were registered waited on condition_gone, which nothing fired on registration, so a result arriving during the call was missed and the caller reported a spurious timeout. add_waiting_task_id(s) now notifies it. The old shared queue woke such a caller because every send notified the one condition. - the absent-waiter branch of recv_with_timeout() returned nullptr without rechecking running, so terminate() was ignored there and a caller whose stop predicate stays false could loop forever. Both recv paths now recheck it, and one deadline covers the whole call so waiting for a registration and then for a result cannot add up to twice the timeout the caller asked for.
…ble on Windows Two more review findings on the previous commit. Filtering results by the caller's ids made notify_one() insufficient: two threads taking disjoint subsets of one add_waiting_task_ids() call share a waiter, so waking one of them can wake the thread whose id has no result, which sleeps again while the thread whose result is queued waits out its timeout. Measured at 2800 ms and a null result with four sibling receivers parked ahead of the one being sent to. The waiter's cv is one reader's own condition, not the single global one the shared vector used, so notify_all() on it is still one wakeup in the common case of one thread per reader. The terminate() assertion needs fork() to observe that the caller terminates, which is not available with the Windows toolchain, and LLAMA_BUILD_TESTS defaults on for a standalone build. Guarded at source rather than excluding the target, so Windows keeps 18 of the 19 assertions. Verified by preprocessing the file with and without _WIN32: waitpid, WIFSIGNALED and pid_t are all absent under _WIN32 and the skip branch is present, and the reverse holds without it. I could not compile or run it on Windows.
Ids registered by separate add_waiting_task_id() calls sit in separate waiters, so no one waiter's condition covers a receive that names ids from both: find_waiter() picks one, and a result for an id in the other notifies a condition the reader is not on. Measured, with two separate registrations and a receive naming both: 2700 ms and a null result when the send goes to the id whose waiter was not picked, 0 ms when it goes to the other. Both directions are driven, because which one the lookup picks depends on the set's iteration order. Such a reader now parks on the shared condition instead, and send() notifies that as well. It is guarded by a counter rather than done unconditionally, so the ordinary path pays one integer compare and not a second notify: server_response_reader registers every id it wants in a single call, so n_split_readers is zero for every reader in the tree.
…iter is discarded Two more on the multi-waiter path. Results carry an arrival sequence now. Each waiter's queue is already in arrival order, so its first match is its oldest, and only those per-waiter winners are compared; a receive naming ids from one waiter, which is every reader in the tree, takes the same single scan as before and never builds the comparison list. Without this, iterating the requested id set picked an arbitrary waiter's deque, so sending A then B could return B first, where the shared vector scanned from the front and did not. Measured in both directions, because which waiter the lookup reaches first depends on the set's iteration order. remove_waiting_task_id() and remove_waiting_task_ids() now notify the waiter they discard. A reader that had already selected that waiter was parked on a condition nothing would fire again: re-registering the id built a new waiter, registration notified only condition_gone and the send notified only the new waiter, so the reader waited out its deadline and returned nullptr with a result sitting in the queue. Measured at 2700 ms and a null result, 0 ms after.
spans_waiters() ignored ids that are not registered, so a receive naming one registered id and one that did not exist yet parked on the registered id's condition. Registering the missing id put it on a different waiter, and the send for it notified only that waiter, so the result was missed until the deadline. Measured at 2700 ms and a null result, 0 ms after. Replaced by sole_waiter(), which returns a waiter only when it covers EVERY requested id, so an absent id keeps the reader on the shared condition until it appears. The counter that decides whether send() notifies the shared condition is now keyed on whether a result could already be delivered to that reader, rather than on the ids being spread over waiters, so the partly registered case is covered too. Still zero for every reader in the tree, which registers all of its ids in one call before it receives.
|
@codex review |
|
Codex Review: Didn't find any major issues. Keep them coming! Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
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". |
Two host side costs in
llama-serverthat stall the decode loop at high concurrency, found bytiming every phase of a step rather than by guessing. No backend, RPC or CUDA file is touched:
the change is measured on the CUDA backend and again on a
-DGGML_CUDA=OFF -DGGML_RPC=OFFbuild, and it does not depend on anything specific to this fork.
Where the host time goes
One DGX Spark, Qwen3.8-27B UD-Q4_K_XL,
--parallel 32, 32 concurrent requests, 128 prompt and256 generated tokens, greedy. Per decode iteration, milliseconds, and per call in
microseconds. This is host time inside the step, i.e. time in which the GPU has nothing to do.
post_decode(total)common_sampler_samplesend_partial_responsequeue_results.send()add_tokenqueue_results.send()is 63 times cheaper per call and the whole result path 22 times cheaper.The step's host stall drops from 13.0 ms to 7.0 ms, and what is left is almost entirely
common_sampler_sample, which is O(vocabulary) by construction and is not touched here.The tracer's own summary of the same two cells, per step over 526 steps:
Whole cell throughput
Same node, tracing off, base and new bracketed base / new / base, one server load per arm, all
three concurrencies against each load. Aggregate tokens per second over the closed loop:
That is what the phase table predicts: 6.0 ms saved out of a 303 ms step is 2%, and there is
nothing to save at 1 or 8 slots because the result path is a per slot cost. The gain grows with
the slot count, because the old path was O(slots^2) per step.
The changes
1.
server_response: deliver a result to the one thread waiting for it. Every pendingresult lived in one vector behind one condition variable. A send walked the waiting id set
linearly, pushed, then
notify_allwoke every waiting HTTP thread, each of which re-took thesame mutex and scanned the whole vector before going back to sleep. At N slots that is N
wakeups and N scans per token, N^2 per decode step, all contending for the mutex the decode
thread needs to send the next token. Results are now queued on a per reader waiter: ids
registered together share one waiter, so a send is an O(1) lookup, a push and one wakeup. FIFO
order per reader is preserved, which is what scanning the shared vector from the front did.
It also fixes a leak that came out of testing this.
remove_waiting_task_ids(), the pluralform, erased the ids from
waiting_task_idsbut, unlike the singular form, never purgedqueue_results. Anything already queued for those ids stayed in the shared vector for the lifeof the process, and was still returned by a later
recv()naming one of them.server_response_reader::stop()calls exactly that plural form, so a client that disconnectswith a result in flight leaves it behind. The per waiter queues cannot have the bug by
construction: the results live in the waiter and the waiter dies with its last id. Measured in
isolation over 200,000 reader lifecycles, each leaving one result queued at teardown, which is
what a disconnect during generation does (
test-server-response leak 200000):2. Only keep per token probabilities when the request asked for them.
server_slot::generated_token_probsis read in exactly one place,send_final_response(), andonly under
n_probs > 0. Every other request still pushed acompletion_token_outputpertoken, each with a heap allocated string, into a list that grows for the whole generation and
is then discarded.
Correctness
Greedy,
temperature 0,top_k 1,seed 42,cache_prompt false, eight requests (fourprompts, streamed and non streamed), one slot and one request in flight so the batch
composition is fixed, md5 of the concatenated output:
and with four slots and two of the requests streamed concurrently, which is the path the first
change touches, on the same
CUDA=OFF RPC=OFFbuilds:The base against base control is there because with several requests decoded in one batch this
model is not run to run reproducible, so a concurrent harness cannot be used as a correctness
control: it produces two different md5s from the same binary.
KV cache and prompt cache behaviour is untouched. Nothing here goes near
pre_decode(), thecommon prefix match, the cache reuse path or
llama_memory_*. Checked rather than asserted, ona 29 token prompt with 8 slots and
cache_prompt: true, reportingtokens_evaluated / tokens_cached / cache_n:cache_prompt: falseIdentical on every field, with identical content md5s, including after the storm.
generated_token_probswas checked the same way, since the second change gates it. Seven probeshapes, all identical before and after:
n_probs10, 1 and 0, non_probs,n_probs: 5with astopword that fires sostop_typeiswordand theSTOP_TYPE_WORDbranch trims bystop_word_toks.size()from the end,post_sampling_probs: true, andstream: true.Under fault
tools/server/tests/test-server-response.cppin this branch drives the publicserver_responseAPI directly: 15 assertions covering reader isolation, per id and bulk teardown, broadcast,
FIFO order, late registration, timeouts and concurrent churn. It reports one failure on the
parent of this branch, the leak above, and none here.
End to end,
stories15M-q4_0.ggufwith 8 slots and four wedge shapes injected at once: two rawsocket streaming clients with a 2 kB receive buffer that never read a byte and are held 25 s so
the HTTP writer blocks inside
send(), two that read 256 bytes then close withSO_LINGER {1, 0}(RST) while results are still being produced for their ids, one non streamingrequest abandoned with an RST mid generation, and repeated
/slots/{id}?action=erasewhile thewedges are held. 24 healthy clients run through the storm and 8 more after it, with
/healthpolled at 2 Hz throughout.
/healthprobes, all 200That one does not flip and is not meant to: it is the control saying the storm changes nothing
and the output is byte identical under it.
RSS under sustained client disconnects, 1,200 streaming requests each killed after 400 ms with
exactly 1,201 slot releases on both arms. Neither a time based nor a per request comparison is
meaningful here, because a faster server generates more tokens inside the same 400 ms window;
normalised by tokens actually generated the two arms are indistinguishable:
So the roughly 6.8 kB per generated token is a pre-existing leak that both arms share and that
is not the result queue: the isolated measurement above accounts for 56 bytes per reader of it
before and 0 after. It is out of scope here, most likely the prompt cache.
-DLLAMA_SANITIZE_THREAD=ONCPU builds of both arms, 120 s of load: 16 streaming clients killedat random sub-second intervals, 8 healthy clients,
/slotsand/metricspolled at 10 Hz.23 data races before, 22 after, and none of them in
server_responseor in the send, recv,add or remove region of
server-queue.cppon either arm. Reported as the negative result itis: TSan finds no race in the result queue before or after, so this is evidence that the rewrite
introduces none rather than a flip. All 45 reports are in
server_context_impl::load_model()andllm_graph_input_*::set_input/set_input_kq_mask_impl, reached throughserver_queue::yield_to_queue(), and are pre-existing.The full
tools/server/testspytest suite gives 361 passed on both arms, with the same sixfailures on both, all of them
-DLLAMA_OPENSSL=OFFin that build config. A fresh worktree has notools/server/tests/tmpand produces 11 failures and 9 errors unrelated to any change until itis created.
Six fixes to one subsystem, found serially
The six commits below all touch
server_response, and a reviewer seeing that should know theywere found one round at a time, not batched. The first three defects were found and fixed
together in
cdb0ecb8e; fixing them exposed a fourth, fixing that exposed a fifth and sixth, andso on through four further review rounds. Each fix narrowed the behaviour the next one was found
in, which is why the sequence is what it is:
cdb0ecb8eterminate()ignored on the absent-waiter branch2af4f9047notify_one()waking the wrong subset receiver, a direct consequence of the id filtering added above; and the new test'sfork()breaking the Windows build4e865432b031b480e55488483ebThe honest reading is that the per-waiter rewrite was buggier than one round of review showed,
and that each round only became visible once the previous one was fixed. The last change is the
one that stops the sequence:
sole_waiter()replaced the earlier "do these ids share awaiter" predicate rather than gaining another case, and by returning a waiter only when it covers
every requested id it collapses "spread over several waiters" and "not fully registered" into a
single condition. Two of the six defects lived in the gap between those two ideas.
Every one of them was reproduced against the base commit before any code was changed, and the
reproductions are all in
tools/server/tests/test-server-response.cpp.Three regressions this PR introduced, and the commit that fixes them
The per waiter queues, as first written here, broke three things that the base commit got right.
All three were found in review, all three were reproduced against base before any code was
changed, and
cdb0ecb8efixes them. Recording them explicitly rather than quietly, because theywere introduced by this PR and not inherited.
fbf9abcc7cdb0ecb8eterminate()is honoured while parked on ids that are not registeredrecv()returns only an id the caller asked forA shared waiter could hand a caller a sibling's result. Ids registered together share one
waiter, and both receive paths popped the front of its queue without looking at the id, so
recv({A})for a reader that registered{A, B}could return B's result.take_result()now scans for a result whose id the caller asked for, oldest first, which is exactly what
scanning the shared vector did. The front normally matches, and
send(), the path theperformance numbers above rest on, is untouched.
A single timed receive that started before its ids existed missed a result that arrived
during the call. It waited on
condition_gone, which nothing fired on registration, whilea later
send()only notified the newly created waiter's own condition. The caller slept outits whole timeout and reported a spurious
nullptr. The old shared queue woke such a callerbecause every send notified the one condition;
add_waiting_task_id()andadd_waiting_task_ids()now notifycondition_goneto restore that.The absent-waiter branch never rechecked
running, soterminate()was ignored there.recv_with_timeout()is now a single loop that checksrunningat the top and again after atimed-out wait on both branches, and takes one deadline for the whole call, so waiting for
a registration and then for a result cannot add up to twice the timeout the caller asked for,
which the earlier two-phase version could.
The test in this branch had been masking two of the three: it called
recv_with_timeout()in aretry loop, so it measured the 1 s poll instead of the missing wakeup and reported a pass at
708 ms.
test-server-responsenow makes a single timed call, and forks a child for theterminate()case so that "the caller terminates" can be asserted rather than assumed. Thatfork is guarded with
#ifndef _WIN32, so the target still builds whereLLAMA_BUILD_TESTSdefaults on and Windows keeps 18 of the 19 assertions; I could not compile it there, so the
guard was checked by preprocessing the file both ways and confirming
waitpid,WIFSIGNALEDand
pid_tare absent under_WIN32and the skip branch is present.Filtering by id then exposed a fourth problem, fixed in
2af4f9047. Two threads taking disjointsubsets of one
add_waiting_task_ids()call share a waiter, sonotify_one()could wake thethread whose id has no result; it sleeps again while the thread whose result is queued waits out
its timeout. Measured at 2800 ms and a null result with four sibling receivers parked ahead of
the one being sent to, 0 ms after.
send()andbroadcast()nownotify_all()on the waiter'scondition, which is one reader's own condition rather than the single global one the shared
vector used, so the common case of one thread per reader is still a single wakeup and the N^2
behaviour does not come back.
And a fifth, fixed in
4e865432b: ids registered by separateadd_waiting_task_id()calls sitin separate waiters, so no one waiter's condition covers a receive that names ids from both.
take_result()already scanned every waiter the ids map to, but the wait still parked onwhichever one the lookup returned, so a result for an id in the other waiter notified a
condition the reader was not on. Measured at 2700 ms and a null result when the send goes to the
id whose waiter was not picked, 0 ms after. Such a reader now parks on the shared condition and
send()notifies that too, guarded by a counter so the ordinary path pays one integercompare and not a second notify:
server_response_readerregisters every id it wants in onecall, so that counter is zero for every reader in the tree.
Two more on the same path, fixed in
031b480e5. Results now carry an arrival sequence, becauseiterating the requested id set otherwise picked an arbitrary waiter's queue and could return a
later result first, where the shared vector scanned from the front and could not. Each waiter's
queue is already in arrival order, so its first match is its oldest and only those per waiter
winners are compared; a receive naming ids from one waiter, which is every reader in the tree,
takes the same single scan as before. And
remove_waiting_task_id()andremove_waiting_task_ids()now notify the waiter they discard, because a reader that hadalready selected it was left parked on a condition nothing would fire again once the id was
re-registered onto a new waiter. Measured at 2700 ms and a null result, 0 ms after, in both
cases.
One more, fixed in
5488483eb, and by replacing the predicate rather than adding a case to it.The "do these ids share a waiter" test only looked at ids that are registered, so a receive
naming one live id and one that did not exist yet looked like a single waiter call and parked on
the live id's condition; registering the missing id put it on a different waiter and its send
notified only that one.
sole_waiter()now returns a waiter only when it covers everyrequested id, so an absent id keeps the reader on the shared condition until it appears, and
there is no longer a way to be "not spread over waiters" while still being incompletely covered.
The counter that decides whether
send()notifies the shared condition is keyed on whether aresult could already be delivered to that reader, so the partly registered case falls out of the
same mechanism. Measured at 2700 ms and a null result, 0 ms after.
All of these were reproduced before anything was changed, and the reproductions are in
tools/server/tests/test-server-response.cpp, 25 assertions. Three of them needed driving inboth directions to fail at all, because which waiter the lookup reaches first depends on the
id set's iteration order, and a single direction passes while the defect is present.
None of it costs the ordinary path anything.
server_response_readerregisters every id itwants in one
add_waiting_task_ids()call before it ever receives, sosole_waiter()returns onthe first lookup, the arrival sequence comparison list is never built, and the counter that would
make
send()do a second notify stays zero, leavingsend()with one integer compare on top ofthe O(1) lookup, push and wakeup.
Re-run against the fix: 18 of 18 assertions pass,
test-server-queuepasses, the isolated queueretention is unchanged at 68 kB per 200,000 reader lifecycles, the concurrency storm still
completes 32 of 32 with an identical content md5 set, cache and probability behaviour are
identical to base field by field, pytest is unchanged, and ThreadSanitizer reports 21 warnings
against 22 before the fix and 23 on base, none of them in
server_responseor in the receive andsend region of
server-queue.cppon any arm.Build coverage
-DGGML_CUDA=OFF -DGGML_RPC=OFF -DLLAMA_OPENSSL=OFFconfigures and builds clean, which is thebuild the second md5 above was produced with. The CUDA build used for the tables is the
ordinary one.
What is deliberately not in here
The largest single host stall on this workload is not in the decode step at all: it is
create_checkpoint()during prefill. On the same cell it is 331 of the 392 ms that a prefilliteration spends building its batch, 66 checkpoints of 149 MiB each for 32 prompts, 50.2 ms per
checkpoint, and the longest single batch build in the cell is 1.03 s, which is a direct time to
first token cost. Of those 50.2 ms, 43.7 are the
std::vector<uint8_t>::resize()that zerofills the buffer and only 6.5 are the state copy that follows.
Removing the zero fill looks obvious and is wrong: it made the copy 140 times slower, 6.5 ms to
917 ms per checkpoint, and cost 24% of whole cell throughput. The memset is doing real work, it
faults the destination pages in before the device to host copy touches them. The fix therefore
has to be reuse of an already resident buffer rather than removal of the memset, and that
carries a memory policy decision, so it is left out of this PR. For scale, running the same cell
with
--ctx-checkpoints 0moves median TTFT at 32 concurrent from 6239 ms to 3688 ms atunchanged throughput.