rpc: one tensor read per decode step and asynchronous split copies - #193
Draft
danielhanchen wants to merge 9 commits into
Draft
rpc: one tensor read per decode step and asynchronous split copies#193danielhanchen wants to merge 9 commits into
danielhanchen wants to merge 9 commits into
Conversation
A layer split over two nodes is a two-stage pipeline that a single llama_context feeds one batch at a time, so each stage sits idle while the other one computes. With --pipeline-groups N the server creates N llama_contexts from the one model, partitions its slots between them and gives each group its own batch and its own decode thread, so there are N batches in flight and both stages have work. Each context is created with n_seq_max = n_parallel / N and n_ctx = n_ctx / N, so the per-slot context and the total KV memory are unchanged. Slots are partitioned contiguously and carry the sequence id they use inside their own context. Slot selection for a new task still runs over all slots, so prompt cache similarity, the slot endpoints and the KV prefix reuse behave exactly as before. The model weights, the task queue, the results queue and the HTTP layer are shared. Task processing pauses the decode loops for the moment it looks at the slots. Speculative decoding, multimodal and idle sleeping are refused with N > 1 rather than half supported. With the default N = 1 there is one context, one batch and one update loop on the main thread, no locks and no extra threads.
- the unlock around llama_decode is now RAII, so a throwing decode cannot leave a group marked busy (which would wedge every later task) nor return to the error handler without the engine lock - n_cmpl is rejected when it exceeds the slots of one group, instead of being deferred forever: the child slots take their KV from the parent, so they have to live in the parent's context - refuse --control-vector with more than one group, common_init_from_params only applies it to the context it creates - the queued prompt stats and the empty batch kill switch move into the group, they were shared counters flushed per group - post_decode uses the group's context, and the detokenize calls in the result path use the slot's own context - free the contexts already created if a later one fails, and do not index groups[0] when no model is loaded
…onnection One socket is cached per endpoint and is therefore shared by every backend of that endpoint, including the backends of different llama_contexts. A message is written as three unlocked send_data calls, so two contexts interleave their command streams and the server sees a malformed request within seconds. Make a whole message atomic on the wire, and hand the responses out in request order with a ticket, so a thread waiting for its response does not hold the send lock and the other contexts can keep submitting. last_graph_uid was kept per endpoint device while the graph it refers to is stored by the server per connection, and it was read and written without a lock, so two contexts on one connection could make RPC_CMD_GRAPH_RECOMPUTE re-run the other one's graph. Track it per connection and device and check it under the send lock. server: pause only the group that owns the slot a task touches process_single_task stopped every pipeline group for every task and waited for all the in-flight decodes. Holding the engine is already enough to keep the slot state stable, so only wait for the group whose context the task touches: the owning group for completions, cancel, control and slot save / restore / erase, every group for --cache-idle-slots and SET_LORA, none for metrics, /slots and get-lora.
…chronously The RPC backend was fully synchronous. Two consequences on a two node layer split: a decode step with backend sampling read four small tensors per sequence, one round trip each (about 130 at 32 sequences), and the hidden state that crosses the split was staged through a host malloc after a full synchronize of the producing device. - get_tensor_async queues the read and the queue is drained as a single RPC_CMD_GET_TENSORS at the next flush point, so a step reads once. - cpy_tensor_async takes the device to device copies. Device to RPC copies into pinned staging on the producing stream, records an event and sends from the staging once the event completed, without synchronizing the producing device. RPC to device reads into pinned staging and hands it to an asynchronous host to device copy on the consuming stream. - ggml_backend now asks the source backend for an asynchronous copy when the destination cannot take it, which is what the RPC to device direction needs. - GGML_RPC_STATS=1 prints the client side command counts and bytes. Tensors are serialized when an operation is queued, not when it is flushed: one llama_decode allocates and resets a graph per ubatch, so the pointer can be gone by the next flush point.
The asynchronous entry points of a backend assert that the tensor lives in that backend's default buffer type, so a tensor in a host buffer that the same backend can also reach has to stay on the synchronous path.
…end declined Two backends of the same type share one cpy_tensor_async, so asking the source after the destination of the same type declined would just repeat the same call with the same answer. Comparing the implementations keeps every single-type setup, one GPU or several, on exactly the previous path.
A server serves the connections of a client one at a time, so opening a second connection to an endpoint that already has a live one blocks until the first closes. synchronize now looks the connection up without creating one (with nothing connected there is nothing queued either), and the asynchronous read and copy take the connection the tensor's buffer already holds instead of asking for one by endpoint.
The RDMA transport is not a byte stream: a receive completion carries exactly one send and recv_data copies all of it, so reading one sent message back in several pieces overruns the first destination and then blocks for a completion that never arrives. The batched read took the response apart with one recv_data per entry, which worked over TCP and hung over RDMA as soon as a step read more than one tensor, which is every step with backend sampling. Receive the response once and scatter it in memory.
The measurement scripts write their cells and their nvidia-smi samples into the worktree, and a blanket add swept them in. They are results, not source.
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.
What this changes
On a two node layer split the RPC backend was fully synchronous, and that cost two things per
decode step.
Reads.
--backend-samplingbuilds one sampler subgraph per output row, andllm_graph_context::build_samplingfills four per-row vectors (t_sampled,t_sampled_probs,t_sampled_logits,t_candidates).llama_context::decodethen walks each vector withcopy_tensor_async_rows, which issues oneggml_backend_tensor_get_asyncper row and pervector. The RPC backend had
get_tensor_async = NULL, so every one of those became its ownsynchronous
RPC_CMD_GET_TENSORround trip: four per sequence, about 130 at 32 sequences.That is why keeping the sampler on the remote device was slower than shipping a megabyte of
logits per row back to the client and sampling on the CPU.
The RPC backend now implements
get_tensor_async. It queues the read and drains the queue asone new
RPC_CMD_GET_TENSORSat the next flush point, which is asynchronize, a graphcompute, or any other command that has to keep its place in the wire order. One decode step of
any batch size is one read.
The queued operation carries the serialised tensor, not the pointer: one
llama_decodeallocates and resets a graph per ubatch, so by the time a later flush point is reached the
tensor of an earlier ubatch can be gone.
Writes. The hidden state that crosses the split went
ggml_backend_synchronize(CUDA0)->host
malloc-> blockingggml_backend_tensor_get-> synchronousSET_TENSOR, becausecpy_tensor_asyncwasNULLandggml_backend_sched_compute_splitsfalls back to that.cpy_tensor_asyncis now implemented for both directions:producing device's host buffer type, an event recorded on the producing stream, and the
SET_TENSORsent by the dispatcher once that event has completed. The producing device isnever fully synchronized and the host thread serialises the graph while the copy is still in
flight.
set_tensor_asyncon the consumingstream, so the consuming device is not synchronized either.
ggml_backend_tensor_copy_asyncand the scheduler now ask the source backend for the copywhen the destination declines. Only the destination used to be asked, which left the
RPC -> device direction on the synchronous fallback even though the RPC backend can accelerate
reads out of itself.
GGML_RPC_STATS=1prints the client side command counts and bytes (period fromGGML_RPC_STATS_MS, default 5000).Compatibility
The protocol minor version goes 1 -> 2 and the client only sends the batched read to a server
that reported minor >= 2 in
HELLO, so an older server keeps working. TCP is unchanged.GGML_RPC_NO_BATCHED_GET=1andGGML_RPC_NO_ASYNC_COPY=1turn each half off for A/B testing.RPC commands per decode step
CPU only harness: two local
ggml-rpc-serverinstances on the CPU backend,llama-serverwith--device RPC0,RPC1 -sm layer, 8 concurrent, npp 128, ntg 256, counted over a decode-onlywindow (prefill excluded).
The read count is now one per step at any batch size. What is left is the per-row
SET_TENSORthat each
distsampler uses for its own four byte uniform input; those are one-way messages,not round trips, and merging them needs a single uniform input tensor shared by the sampler
chains, which is follow-up work.
Correctness
Greedy equivalence on the CPU two-RPC harness, five prompts,
temperature 0,top_k 1,seed 1234: md5177dc61e0703eba3bdaf7bf1131f0458for the default CPU sampling path at--pipeline-groups1 and 2, and the same md5 for--backend-samplingon both the base treeand this branch, so backend sampled greedy is byte-identical to the CPU sampler here.
Numbers on two DGX Sparks
Qwen3.8-27B UD-Q4_K_XL layer split over two DGX Sparks, 32 concurrent, npp 128, ntg 256,
--cache-ram 0, RDMA transport, one clock state (local 2388 to 2394 MHz, peer 2393 to 2398 MHz),device order
CUDA0,RPC0so the output layer, the logits and the sampler are on the remote nodeand only token ids come back. Whole-cell tok/s from a closed-loop client, best of the cells taken
in one window. "base" is this branch with both features switched off, which is the branch base's
behaviour on the same binary.
Qwen3.5-4B UD-Q4_K_XL, same split and workload with ntg 128, all ten arms:
CUDA0,RPC0base, N=1 / N=2CUDA0,RPC0async copy only, N=1 / N=2CUDA0,RPC0backend sampling, N=1 / N=2RPC0,CUDA0both features, N=1 / N=2RPC0,CUDA0async copy only, N=1 / N=2What the numbers say:
pair and workload it used to cost 16 percent (80.0 against 95.2 tok/s); it is now 4.6 percent
faster than CPU sampling on the 27B (101.35 against 96.90) and 13.7 percent faster on the 4B
(366.65 against 322.45), with the logits never crossing the wire.
Each
distsampler sets its own four byte uniform input, one write per output row per group,and each carries a ~380 byte serialised tensor header. The reads are fixed, the writes are not.
The fix is a single uniform input tensor shared by the sampler chains and viewed per row, which
needs a small change to the sampler backend interface and is left as follow-up. Until then,
backend sampling belongs at one context.
2.4 percent with two (138.09 against 134.87). It removes a full
ggml_backend_synchronizeofthe producing device and a malloc per split, but with one context the step is a serial chain,
so there is little for the saved time to overlap with. There is no GPUDirect RDMA on GB10 and
this transport is a send/receive byte stream over its own registered frames, so the path is
device to pinned host to wire; on GB10 that pinned staging is physically the same memory the
GPU uses, which is why it costs so little to begin with.
Two hazards this uncovered
Both are in the notes because anything added to this protocol will hit them.
get_socketconnects when the cachedconnection has expired, and an
rpc-serverserves the connections of a client one at a time,so a second connection blocks until the first closes. The asynchronous read and copy take the
connection the tensor's buffer already holds, and
synchronizelooks up without creating.recv_datacopies all of it, so a sent message must be read back in onerecv_dataof thesame size. The first version of the batched read scattered the response straight into its
destinations with one receive per entry. That is correct over TCP and hangs over RDMA as soon
as a step reads more than one tensor, which is every step with backend sampling, with both ends
spinning on their completion queues. The response is now received once and scattered in memory.
Impact on non-RPC workloads
The change is in
ggml/src/ggml-rpc/except for 33 lines inggml/src/ggml-backend.cpp. Nothingin
src/llama-context.cpp,src/llama-sampling.cpp,src/llama-graph.cpportools/server/istouched; the diff against the branch base is four files:
The
ggml-backend.cppchange factors the existing "ask the destination backend for anasynchronous copy" into a helper that then also asks the source backend, but only when the two
backends have different
cpy_tensor_asyncimplementations. Two backends of the same type shareone implementation, which already saw the pair and declined, so on any single-type setup (one GPU,
several GPUs of the same type, CPU only) the helper makes exactly the calls the old code made, in
the same order. The extra call can only happen when two different backend types meet, which on
this tree means an RPC backend on one side.
Evidence:
cmake -DGGML_RPC=OFF -DGGML_CUDA=OFFconfigures and builds clean.temperature 0,top_k 1,seed 1234): md5177dc61e0703eba3bdaf7bf1131f0458on the branch base, on this branch, and onthis branch built with
GGML_RPC=OFF. On a single GPU with the 27B and no--rpc, md574926c4ef135f3cc89ad20cd5ec7e445on the branch base and on this branch.pytest -q -m "not slow"run serially: branch base 368 passed, 6 skipped, 199deselected; this branch 368 passed, 6 skipped, 199 deselected. Same set, no new failures.
llama-batched-benchbracket base/new/base on the 27B, npp 512, ntg 128, npl 1/8/32,S t/s: 55.23 / 54.43 / 53.56, then 231.91 / 228.22 / 211.38, then 367.34 / 334.29 / 336.27. The
GPU cooled between passes, so the two base passes differ by up to 8.5 percent; the new binary
sits inside that spread on every row and within 0.6 percent of the second base pass.
Compatibility
The protocol minor version goes 1 to 2 and the client only sends the batched read to a server that
reported minor 2 or higher in
HELLO, so an older server keeps working and an older client keepsworking against a new server. TCP is unchanged.
GGML_RPC_NO_BATCHED_GET=1andGGML_RPC_NO_ASYNC_COPY=1turn each half off, which is how the arms above were taken on onebinary.