rpc: direct server-to-server tensor transfer for a layer split - #196
Draft
danielhanchen wants to merge 2 commits into
Draft
rpc: direct server-to-server tensor transfer for a layer split#196danielhanchen wants to merge 2 commits into
danielhanchen wants to merge 2 commits into
Conversation
…ient A layer split over several RPC devices moved every hidden state through the host that runs the scheduler: ggml_backend_sched has no direct path between two RPC buffers on different endpoints, so it fell back to reading the tensor into the client's memory and writing it out again, two transfers and a synchronize per stage boundary. RPC_CMD_COPY_TENSOR_TO tells the server that holds the source tensor to write it into a tensor on another server. The source server opens a connection to the destination with the same HELLO negotiation a client uses (RDMA when both rails allow it, TCP otherwise), pushes the data as an ordinary RPC_CMD_SET_TENSOR so the destination applies its own tensor deserialization and buffer range checks, and waits for RPC_CMD_PEER_BARRIER before answering the client, so the destination cannot compute before the write has landed. Connections to other servers are cached per destination endpoint and closed when the client disconnects. Serving several connections at once is what this needs, so a server now runs one thread per connection over a shared buffer registry and a shared execution mutex; a session still owns and frees only the buffers it allocated. The client uses the command from the RPC backend's cpy_tensor_async when the source and the destination are RPC buffers on different endpoints and both servers report protocol minor 3 or higher; everything else, including two devices of one server, keeps its previous path. GGML_RPC_P2P=0 forces the old path. Protocol minor 2 -> 3, every existing command unchanged.
…/rpc-p2p # Conflicts: # ggml/src/ggml-rpc/ggml-rpc.cpp
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 does
A layer split over several RPC devices used to move every hidden state through the host that runs
the scheduler.
ggml_backend_sched_compute_splitshas no direct path between two RPC buffers ondifferent endpoints (
ggml_backend_rpc_buffer_cpy_tensorreturns false as soon as the socketsdiffer), so each stage boundary fell back to
ggml_backend_tensor_copy: aGET_TENSORinto ahost allocation on the coordinator, then a
SET_TENSORback out. Two transfers, a synchronize,and the hidden state of every boundary crossing the coordinator's memory.
With this change the coordinator still issues every control command, but for a boundary between
two remote servers it issues one command instead of two and the data goes straight from one
server to the other. P stages form a ring: the coordinator sends the embeddings to stage 1,
stage 1 sends its output to stage 2, and so on back to the device that holds the output layer.
Protocol
Minor version 2 -> 3. Every existing command byte and struct is unchanged, so old clients and
servers interoperate for everything else, and the new commands are only used after the HELLO
version check reports minor >= 3 on both endpoints.
RPC_CMD_COPY_TENSOR_TOis sent to the server that holds the source tensor. Payload is| src rpc_tensor | dst rpc_tensor | size | endpoint_len | destination endpoint |. The sourceserver validates the source region exactly as
RPC_CMD_GET_TENSORdoes (deserialize_tensorplus the buffer range check), reads it, opens or reuses a connection to the destination
endpoint using the same connect and HELLO negotiation a client uses (so RDMA when both rails
allow it and TCP otherwise), and pushes the data as an ordinary
RPC_CMD_SET_TENSOR. Nothingabout the destination pointer is trusted on the source side: the destination applies its own
deserialize_tensorand buffer range checks, the same ones it applies to a client.RPC_CMD_PEER_BARRIERis an empty request with a one byte response. A connection is servedstrictly in order, so its response proves the
SET_TENSORthat preceded it has completed.The source server waits for it before answering the client, which is the ordering guarantee:
the destination cannot compute before the write has landed, because the scheduler only sends
RPC_CMD_GRAPH_COMPUTEto the destination afterRPC_CMD_COPY_TENSOR_TOhas returned.Connections to other servers are cached per destination endpoint and closed when the client that
asked for them disconnects. Recoverable failures (destination unreachable, destination too old,
write rejected) come back as
result = 0and the client falls back to the previous path; only amalformed request or an out of bounds source closes the connection.
Serving several connections at once is what this needs, so a server now runs one thread per
connection over a shared buffer registry and a shared execution mutex; a session still owns and
frees only the buffers it allocated. The execution mutex is released before the peer connection
is used, so a ring of servers cannot deadlock on each other.
Client side, the RPC backend's
cpy_tensor_asynctakes the RPC to RPC case when the twoendpoints differ. Two devices of one server keep the server-local
RPC_CMD_COPY_TENSOR, andevery other combination returns false so the previous path runs unchanged.
GGML_RPC_P2P=0forces the old path for A/B.
Commands and bytes per decode step
Client side counters (
GGML_RPC_STATS=1), decode window, Qwen3.8-27B UD-Q4_K_XL, 32 rows,three stages (
--device RPC0,RPC1,CUDA0 -sm layer --tensor-split 1,1,1), so one RPC to RPCboundary per step. The hidden state is 32 x 5120 x 4 B = 655360 B.
One
GET_TENSORplus oneSET_TENSORbecome oneCOPY_TENSOR_TOper boundary, and 1.31 MB perstep stops crossing the coordinator: 2.69 MB of RPC traffic per step becomes 1.38 MB, a 49
percent reduction. For P stages the saving is (P - 2) boundaries per step, so it grows with the
number of nodes while the coordinator's work stays flat.
CPU-only harness (
tests/cpu/rpc_p2p.sh, three local rpc-servers on the CPU backend,--device RPC0,RPC1,RPC2 -sm layer, the process host doing the embedding lookup), single stream,exact per-step counts over the decode window:
Two RPC to RPC boundaries, two
COPY_TENSOR_TO, and the SET_TENSOR payload halves.Correctness
Greedy output over five prompts, 48 tokens, temperature 0, top_k 1, on the CPU-only harness:
md5
177dc61e0703eba3bdaf7bf1131f0458for all four arms - three stage and two stage,GGML_RPC_P2P=0andGGML_RPC_P2P=1- which is the same md5 the existing RPC harnesses record.tools/server/tests: 368 passed, 6 skipped, 199 deselected.Numbers on two GB10 nodes
Qwen3.8-27B UD-Q4_K_XL, 32 concurrent closed loop clients, npp 128 / ntg 256, 64 requests,
-c 16384(16386 for the three group cells),--cache-ram 0,-fa on,-t 6. Local CUDA0 holdsthe last third and the output layer; two
ggml-rpc-serverprocesses on the peer (ports 50052 and50053, one GPU between them) hold the first two thirds,
--tensor-split 1,1,1.Two stage reference on the same binaries,
--device RPC0,CUDA0, one rpc-server on the peer:Reading these honestly: throughput does not move. The peer's mean SM clock falls monotonically
through each round (2466, 1856, 1690 MHz), so the first cell of a round is always the fastest one,
and the ranking of the two arms flips when the order is reversed: hub is ahead by 16 percent when
it runs first (round a) and behind by 8 percent when the direct arm runs first (round b). Both
arms are inside that band, so this pair and this topology do not separate them on tok/s. That is
expected here, because the two remote stages share one GPU on one node: the boundary the change
removes from the coordinator becomes a loopback transfer on the peer, and nothing is freed on the
one link that matters. What is unambiguous is the protocol accounting above, which is what scales:
for P stages on P nodes the change removes (P - 2) hidden state round trips per step from the
coordinator's link and memory, so the coordinator stops being a per-boundary relay and the wire
cost of a stage boundary no longer depends on where the scheduler runs.
Also visible in the table: three stages over the same two GPUs is worse than two stages
(143.2 tok/s at two stages with two groups against 77 to 88 at three stages with three groups),
so a third stage should only be added with a third node.
Known limitation, not fixed here:
RPC_CMD_COPY_TENSOR_TOblocks the source server's connectionthread for the whole transfer plus the barrier, so with several pipeline groups over one
connection the groups serialise behind it. Moving the transfer to a per-destination worker and
letting the destination enforce the ordering with a sequence number, instead of the client
waiting for the acknowledgement, would remove that; it needs a second command on the destination
and another measurement window.
Non-RPC workloads
Every line of this change is in
ggml/src/ggml-rpc/:ggml-backend.cpp, the scheduler, and the CUDA, Metal, Vulkan, CPU and HIP backends are nottouched, so a build with
-DGGML_RPC=OFFcontains none of it andlibggml-baseandlibllamagain no new behaviour when the RPC backend is not loaded. Checks on one GB10:
-DGGML_RPC=OFF -DGGML_CUDA=ONconfigures and builds clean.test-backend-ops -b CUDA0: 13572/13572 tests passed, OK, on this branch and on the base, samecount.
Greedy generation on one GPU with no
--rpc, three prompts, 64 tokens, temperature 0, top_k 1:md5
1f99f8da109f7a456073901232e1f014on this branch and on the base, byte identical.llama-batched-benchon one GPU with no--rpc, npp 512, ntg 128, npl 1 / 8 / 32, run as abase / new / base bracket in one window (whole run tok/s):
The first run of the bracket is the fastest in every row (a cold GPU at the start of the
window); the new binary matches the second base run to within 0.2 percent at npl 1 and 8 and
sits between the two base runs at npl 32.