rpc: cut the protocol cost of uploading weights to a remote server - #199
Draft
danielhanchen wants to merge 1 commit into
Draft
rpc: cut the protocol cost of uploading weights to a remote server#199danielhanchen wants to merge 1 commit into
danielhanchen wants to merge 1 commit into
Conversation
Loading a 27B layer split spends most of its time in the weight upload, and most of that time is protocol, not wire. Measured on one node with an rpc-server on the RoCE interface (Qwen3.8-27B UD-Q4_K_XL, 15.7 GiB pushed to the remote backend, RDMA active), the upload phase cost 41.5 s and broke down as: hashing 12.6 s every tensor over 10 MiB hashed with FNV-1a at about 1.2 GiB/s staging 4.3 s a fresh zero filled buffer per tensor, then a copy into it wire 4.3 s other 20.4 s client side stalls outside the RPC calls Four changes, all inside ggml/src/ggml-rpc: - The server now says at HELLO whether it keeps a tensor cache, in the byte that used to be padding in the response. Without a cache the answer to SET_TENSOR_HASH is always "not cached", so the hash pass over every large tensor was pure cost. The client only hashes when the server can use it. The message keeps its size, an older server sends a zero byte and an older client ignores it, so both directions interoperate unchanged. - SET_TENSOR is written from the header and the caller's payload directly instead of being copied into one contiguous buffer first. That buffer cost a zero fill and a full copy of every tensor. The bytes on the wire are identical. - The server reads a SET_TENSOR message off the connection instead of into a vector sized to the whole message, and receives the payload straight into the destination when the backend buffer is host memory. Non host backends reuse one staging allocation that is never zero filled. - The RDMA transport keeps up to eight 256 KiB chunks in flight instead of posting one and polling it to completion before posting the next, and drains them at the message boundary that flush() already marks. Receives are now consumed byte by byte from the completed buffer, so a peer that frames a message differently keeps working instead of losing the remainder of a frame. After the change the same upload phase is 23.5 s, with hashing and staging at zero and 4.9 s on the wire. GGML_RPC_LOAD_OPT=0 restores the previous behaviour for A/B. GGML_RPC_LOADPROF=1 turns on a load profiler on both sides: per command counts and times on the client, and the split of the upload into hashing, staging, wire and the gaps between calls. It is off by default and costs one relaxed atomic load per call.
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.
Loading a model that is split across two machines spends most of its time pushing the
remote half of the weights over RPC, and most of that time is protocol overhead rather
than wire time. On a pair of DGX Sparks with 111 Gb/s RoCE rails a 27B Q4_K_XL layer
split takes about fourteen minutes to become ready while both CPUs sit at 99 percent and
both GPUs read zero. This branch attacks the protocol side of that.
Where the time goes
Measured on a single node, with an
ggml-rpc-serverbound to the RoCE interface so thewhole transport is exercised (RDMA active,
mtu=4096), pushing all 15.7 GiB ofQwen3.8-27B-UD-Q4_K_XL to the remote backend. Numbers from the load profiler added here
(
GGML_RPC_LOADPROF=1), for the span from the firstSET_TENSORto the last:876
SET_TENSORcalls carry the 15.7 GiB; 369 of them are over 10 MiB and were the onesbeing hashed. The client side stalls are four events, one of them 10.7 s, and are not per
tensor overhead: they are outside the RPC calls and are left for a separate change.
What changed
Everything is inside
ggml/src/ggml-rpc/.GGML_RPC_LOAD_OPT=0restores the previousbehaviour on both sides for A/B.
The hash pass is skipped when it cannot help.
SET_TENSOR_HASHlets a server thatkeeps a tensor cache answer "I already have this" instead of taking the upload. A server
started without
-chas no cache, so the answer is always no and the FNV-1a pass overevery tensor above 10 MiB is pure cost, about 1.2 GiB/s of pure serial multiply. The
server now advertises whether it has a cache in the byte of the HELLO response that used
to be padding, and the client only hashes when that bit is set. The message keeps its
size and its version, an older server sends a zero byte and an older client ignores it,
so old and new interoperate in both directions.
No staging copy for the upload. The client used to allocate a zero filled buffer the
size of header plus tensor, copy the header and then the tensor into it, and send that.
For a 27B split that is several gigabytes of zero fill and several more of copy. The
header and the caller's payload are now written straight to the socket. The bytes on the
wire are byte for byte what they were.
The server streams the message instead of buffering it.
RPC_CMD_SET_TENSORis readoff the connection: header first, then the payload directly into the destination when the
backend buffer is host memory, or into one reused staging allocation that is never zero
filled when it is not (CUDA, or when a cache dir means the payload has to be hashed). It
used to
resize()a fresh vector per message, which zero fills, and then copy out of it.The RDMA transport stops waiting for every chunk.
rdma_sendposted one 256 KiBchunk and polled it to completion before posting the next, so the link was idle for a
full round trip on every chunk and only one chunk was ever in flight. It now keeps up to
eight in flight against a ring of registered buffers and drains them at the message
boundary that
flush()already marks. The receive ring is 24 deep, so the sender cannotoutrun it, and the number of send slots degrades down to one if a tight memlock limit
refuses the registration.
Receives are consumed byte by byte.
rdma_recvused to copy a whole completedreceive into the caller's buffer and decrement by the full
byte_len, so a peer whosemessage framing differed would have the remainder of a frame dropped, or would overrun
the caller's buffer. A completion is now consumed across as many
recv_datacalls as ittakes. That is what makes the split write above safe against an older peer.
Interoperability
All four combinations were run on the 27B, each loading the whole model over RPC and
generating the same text:
GGML_RPC_LOAD_OPT=0New client against old server keeps the hash saving and the send side pipelining but not
the server side streaming receive, which is why its wire time is higher than new against
new. Old client against new server keeps the streaming receive only.
Non-RPC workloads
The whole diff is three files in
ggml/src/ggml-rpc/. No file outside that directory istouched, so a build without the RPC backend compiles identical code.
-DGGML_RPC=OFF -DGGML_CUDA=ONconfigures and builds clean, all 81 targets.test-backend-ops -b CUDA0on the new build:2/2 backends passed,OK.Single GPU greedy, no
--rpc, three prompts, 64 tokens, temperature 0, seed 1:md5
111e44cd31da973afbe5aa190b2fbed7for base and for this branch. The raw outputdiffers only in the tokens per second footer llama-cli prints, which is not the same
from run to run on either build.
Single GPU
llama-batched-bench, no--rpc, 27B,-npp 512 -ntg 128 -npl 1,4,base / new / base in one window with the new build inside the bracket:
Pair
Two DGX Sparks, 27B UD-Q4_K_XL split across both,
--device CUDA0,RPC0 -sm layer -c 16384 --parallel 32 --cache-ram 0 -t 6, 8538 MiB ofweights going to the peer, RDMA active on both ends. Load is the
model loadedstamp inthe llama-server log; the arms alternate base, new, base, new in one window, both nodes
uncapped:
Load 19.1 s to 8.6 s on the mean of two arms each. Prefill and decode are unchanged, as
expected: nothing on the inference path changed.
Greedy output over the split is byte identical in all four arms,
md5
371c236256c5c537b2e72ed4add8571b(400 token prompt, 64 tokens, temperature 0).The client side breakdown of a base load on the pair, from the profiler: of the 19.4 s,
6.85 s is hashing, 2.39 s is host staging and 3.06 s is wire, so 12.3 s of the load is
inside the RPC calls and a third of the whole load is a hash whose answer is thrown away.
Profiler
GGML_RPC_LOADPROF=1prints, on both client and server, the number ofSET_TENSORcalls, the bytes, and the split between hashing, staging, wire and the gaps between
calls, plus a per command count and time on the client. It is off by default and costs
one relaxed atomic load per call when off.