server: remove two host side stalls in the decode loop at high concurrency - #200
Draft
danielhanchen wants to merge 2 commits into
Draft
server: remove two host side stalls in the decode loop at high concurrency#200danielhanchen wants to merge 2 commits into
danielhanchen wants to merge 2 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.
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.
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.
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_*.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.