Skip to content

rpc: direct server-to-server tensor transfer for a layer split - #196

Draft
danielhanchen wants to merge 11 commits into
feature/rpc-stage2from
feature/rpc-p2p
Draft

rpc: direct server-to-server tensor transfer for a layer split#196
danielhanchen wants to merge 11 commits into
feature/rpc-stage2from
feature/rpc-p2p

Conversation

@danielhanchen

@danielhanchen danielhanchen commented Sep 6, 2026

Copy link
Copy Markdown
Member

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_splits has no direct path between two RPC buffers on
different endpoints (ggml_backend_rpc_buffer_cpy_tensor returns false as soon as the sockets
differ), so each stage boundary fell back to ggml_backend_tensor_copy: a GET_TENSOR into a
host allocation on the coordinator, then a SET_TENSOR back 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_TO is sent to the server that holds the source tensor. Payload is
    | src rpc_tensor | dst rpc_tensor | size | endpoint_len | destination endpoint |. The source
    server validates the source region exactly as RPC_CMD_GET_TENSOR does (deserialize_tensor
    plus 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. Nothing
    about the destination pointer is trusted on the source side: the destination applies its own
    deserialize_tensor and buffer range checks, the same ones it applies to a client.
  • RPC_CMD_PEER_BARRIER is an empty request with a one byte response. A connection is served
    strictly in order, so its response proves the SET_TENSOR that 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_COMPUTE to the destination after RPC_CMD_COPY_TENSOR_TO has 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 = 0 and the client falls back to the previous path; only a
malformed 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_async takes the RPC to RPC case when the two
endpoints differ. Two devices of one server keep the server-local RPC_CMD_COPY_TENSOR, and
every other combination returns false so the previous path runs unchanged. GGML_RPC_P2P=0
forces 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 RPC
boundary per step. The hidden state is 32 x 5120 x 4 B = 655360 B.

per decode step hub path direct path
GET_TENSOR 2.00 1.00
SET_TENSOR 12.00 11.00
COPY_TENSOR_TO - 1.00
total commands 16.00 15.00
request bytes from the coordinator 1 382 840 727 488
response bytes to the coordinator ~1 310 720 ~655 360

One GET_TENSOR plus one SET_TENSOR become one COPY_TENSOR_TO per boundary, and 1.31 MB per
step 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:

per decode step, 3 RPC devices hub direct
GET_TENSOR 2.01 0.00
SET_TENSOR 18.99 17.01
COPY_TENSOR_TO - 1.99
SET_TENSOR bytes 33 375 16 240

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 177dc61e0703eba3bdaf7bf1131f0458 for all four arms - three stage and two stage,
GGML_RPC_P2P=0 and GGML_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 holds
the last third and the output layer; two ggml-rpc-server processes on the peer (ports 50052 and
50053, one GPU between them) hold the first two thirds, --tensor-split 1,1,1.

cell arm position in round tok/s TPOT ms TTFT med s util local util peer clocks.sm local / peer tmax local / peer
h1_a hub, N=1 1 101.52 292.0 6.55 34.1 58.5 2401 / 2445 55 / 69
d1_a direct, N=1 2 99.06 298.4 5.98 32.9 59.6 2398 / 1804 58 / 68
h3_a hub, 3 groups 3 87.92 342.7 5.45 46.4 92.4 2396 / 2466 60 / 74
d3_a direct, 3 groups 4 75.54 344.7 6.31 42.7 89.0 2392 / 1856 63 / 75
d3_b direct, 3 groups 1 83.38 359.4 6.47 43.8 91.8 2393 / 1856 65 / 65
h3_b hub, 3 groups 2 77.31 355.3 7.22 45.4 90.3 2392 / 1690 67 / 58
d1_b direct, N=1 3 96.98 302.4 6.33 31.3 60.2 2392 / 1690 63 / 56
h1_b hub, N=1 4 98.18 300.7 6.33 30.6 59.6 2392 / 1690 61 / 54

Two stage reference on the same binaries, --device RPC0,CUDA0, one rpc-server on the peer:

cell tok/s TPOT ms TTFT med s util local util peer clocks.sm local / peer
N=1 101.13 293.8 5.34 46.1 47.6 2389 / 2438
N=2 143.22 208.1 4.92 85.0 84.4 2400 / 2268

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_TO blocks the source server's connection
thread 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/include/ggml-rpc.h         |   2 +-
 ggml/src/ggml-rpc/ggml-rpc.cpp  | 325 +++++++++++++++++++++++++++++++-----
 ggml/src/ggml-rpc/transport.cpp |   2 +-

ggml-backend.cpp, the scheduler, and the CUDA, Metal, Vulkan, CPU and HIP backends are not
touched, so a build with -DGGML_RPC=OFF contains none of it and libggml-base and libllama
gain no new behaviour when the RPC backend is not loaded. Checks on one GB10:

  • -DGGML_RPC=OFF -DGGML_CUDA=ON configures and builds clean.

  • test-backend-ops -b CUDA0: 13572/13572 tests passed, OK, on this branch and on the base, same
    count.

  • Greedy generation on one GPU with no --rpc, three prompts, 64 tokens, temperature 0, top_k 1:
    md5 1f99f8da109f7a456073901232e1f014 on this branch and on the base, byte identical.

  • llama-batched-bench on one GPU with no --rpc, npp 512, ntg 128, npl 1 / 8 / 32, run as a
    base / new / base bracket in one window (whole run tok/s):

    npl base a new base b
    1 55.32 53.72 53.75
    8 232.26 212.29 211.87
    32 347.84 339.19 338.14

    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.

Scope: this does nothing on a two node pair

Worth stating plainly, because the byte table above is easy to read as a decode win and it is not one here. The saving is (P - 2) boundaries per step. On the two DGX Spark pair P = 2, so there is no RPC to RPC boundary at all and this change is inert: the one crossing goes to the device holding the output layer, which is the coordinator's own CUDA0. The three stage cells above were run with two rpc-servers sharing one GPU on the peer precisely because that is the only way to make a P = 3 boundary exist on two machines, and they show three stages over two GPUs is worse than two stages regardless of the arm.

There is also now a measurement that bounds what any interconnect change on the pair can be worth. A two node layer split moves 6.84 MB per decode step in total, which is 0.34 ms of a 220 ms step against the measured 20.31 GB/s NCCL bus bandwidth, under 0.005x of end to end speedup. Removing 1.31 MB of that would be worth under 0.001x even if the boundary existed. So this PR should be read as a three or more node capacity and scaling change, plus the per connection threaded rpc-server it introduces, and not as a throughput change for the pair. The honest reading of the tok/s table stands as written: the arms do not separate.

…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
Resolve the conflict in ggml/src/ggml-rpc/ggml-rpc.cpp by keeping this
branch's peer to peer copy code and the base branch's comment wording.
Verified with comment_tools check that only comments changed relative
to 6e00db6.
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex security review

@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Sep 9, 2026

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review Completed 2026-09-09T13:41:13.100594Z 4f54a47 Manual request
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
get_peer_socket opens a connection from inside a server, and get_socket ends in
negotiate_hello, whose failed exchange hits RPC_STATUS_ASSERT and aborts the
process. A destination that accepts the connection and then disconnects, or
returns a truncated HELLO, therefore took down the source server and every
client it was serving.

That also contradicted what copy_tensor_to documents: an unreachable or too old
destination is a recoverable condition reported as result = 0, which puts the
client back on its previous path. negotiate_hello and get_socket now take
may_fail, set only for peer connections, and return failure instead of
aborting. A client keeps aborting as before.
copy_tensor_to resized p2p_buf from hdr.size, which a client controls, before
any of the checks against the source tensor and its buffer. A large value
aborted the server on an uncaught bad_alloc, and a value near SIZE_MAX wrapped
msg_size so that the two header copies ran past the end of an undersized
vector.

The checks already existed further down. The sizing and the header copies now
happen after them, where hdr.size is known to fit both the buffer and the
tensor.
rpc_flush_deferred_guarded only puts queued commands on the wire. It does not
wait for them to be served, and the peer write arrives on a different
connection, so the two are separated by nothing but the destination's execution
mutex. An earlier GRAPH_COMPUTE or SET_TENSOR that has not yet taken that mutex
could run after the peer's SET_TENSOR and read or overwrite a split input
buffer that had just been reused.

RPC_CMD_PEER_BARRIER already means that everything received earlier on a
connection has been served, so it is now issued on dst_sock before the copy
starts. A failure there is recoverable and falls back to routing the tensor
through the client.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

Raising RPC_PROTO_MINOR_VERSION to 3 locked out every already-deployed client:
negotiate_hello rejects any server whose minor exceeds its own, so a 5.2 client
could not connect to a 5.3 server for any operation at all, even though the new
commands are appended compatibly.

The version is back to 2 and support is advertised in the byte of the HELLO
response that was previously pure padding. That byte is fixed size, already on
the wire, and read as padding by every existing client, so an old client
connects exactly as before and simply never sees the flag, while an old server
sends zero and is never asked for a peer copy. The two gates that keyed on
minor >= 3 now test the flag.

Note on why the flag does not live in conn_caps, which would otherwise be the
obvious place: update_caps treats any non-zero byte there as "the peer speaks
RDMA", and rdma_caps already fills all 24 bytes, so a spare bit would both be
unavailable and, if taken, make an RDMA-less peer look RDMA-capable.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

Two ways a recoverable peer-copy failure could take down the source server.

socket_t::connect() created a descriptor and then returned nullptr from three
later failure paths without closing it: TCP_NODELAY, name resolution, and the
connect() itself. Until now that cost at most one descriptor per process,
because a client that cannot connect aborts anyway. get_peer_socket() reaches
the same code once per tensor per boundary crossing, does not cache failed
peers, and treats a refused connection as recoverable, so the same leak becomes
unbounded and a long decode eventually exhausts the descriptor limit, at which
point the server can no longer connect to peers or accept clients.

socket_t::impl already closes the descriptor in its destructor, so the fix is to
adopt it into the socket_ptr immediately rather than carrying it raw until the
last line. accept() and create_server() had the same shape and are fixed the
same way; accept()'s is per client connection.

Separately, send() was called with no flags. On POSIX, writing to a socket whose
peer has gone away raises SIGPIPE, and neither this library nor the rpc-server
executable installs a handler, so the default action terminates the process. A
destination that was restarted while a source held a cached connection to it is
exactly the case that is supposed to end in result = 0; instead it killed the
source server and every client it was serving. Sends now pass MSG_NOSIGNAL where
it exists, and Apple, which has no MSG_NOSIGNAL, gets SO_NOSIGPIPE set once per
descriptor instead. Windows has no SIGPIPE and is unaffected.
Moving the buffer registry into rpc_server_shared so a peer copy could resolve
the destination buffer also widened every other command. deserialize_tensor()
validated against shared.buffers, the set of all live buffers in the process, so
any connection could name a buffer belonging to another connection and then read
it with GET_TENSOR, overwrite it with SET_TENSOR, or reference it from a graph.
Before the registry moved, buffers belonged to the connection's rpc_server and
the same request simply did not resolve.

deserialize_tensor() now validates against owned_buffers, this session's own
allocations, and takes an allow_foreign flag for the one path that genuinely
needs more. That path is SET_TENSOR: the destination of a peer copy receives it
on the connection the source server opened rather than on the connection of the
client that allocated the buffer, so it has to resolve a buffer another session
owns. The write stays bounded by the existing buffer range checks. GET_TENSOR,
GRAPH_COMPUTE, memset_tensor and the local copy path go back to being confined
to the session, as do buffer_get_base() and buffer_clear(), which were checking
process-wide liveness rather than ownership. free_buffer() already checked
ownership.

owned_buffers is maintained on allocation, on free, and on session teardown, so
it is the same lifetime the pre-existing per-connection registry had.

This restores the isolation that existed before peer copies for every command
except the peer write itself. It is not authentication: the RPC server has none,
and a caller that can open a connection can still address the destination buffer
of a peer copy. Narrowing that further needs the destination to be told which
writes to expect, which is a protocol change and not attempted here.
chatgpt-codex-connector[bot]

This comment was marked as resolved.

Two conflicts, both from the two branches having grown the same thing independently.

srv_flags bit assignment. Batched get and peer copy were developed separately and each took
bit 0. Resolving that by keeping one enum would have shipped a wire-level collision: a server
that implements only batched get sets bit 0, and a client that knows about peer copy would read
that same bit as peer-copy support and send it RPC_CMD_COPY_TENSOR_TO, which it does not
implement. The two features now occupy distinct bits. Batched get keeps bit 0 because that value
is already published on this branch; peer copy moves to bit 1. hello() advertises both, since
this server implements both.

rpc_conn_state::server_flags. Both branches added this member, so the textual auto-merge
succeeded while producing a struct with the field declared twice, which does not compile. Kept
one declaration alongside server_minor.

Verified on CPU rather than by inspection: the merged rpc-server advertises flags 0x03 on the
wire, and a llama-server generating over a real RPC socket against it completes normally. Both
client-side gates survived the merge and remain keyed to their own flag.
@danielhanchen

Copy link
Copy Markdown
Member Author

Merged the base branch in to clear the conflict (999cdbc). This was not a mechanical merge, so recording what changed and why.

feature/rpc-stage2 had moved nine commits since this branch forked: the async-copy event pool recycling, the source-side async copy interface entry, the backend API version bump, GET_TENSORS entry validation, batched get advertised as a server flag, the staging-event wait fix, and two server-side commits. Two conflicts, both from the branches independently growing the same thing.

The one worth reviewing: srv_flags bit 0 was claimed twice. Batched get and peer copy were developed on separate branches and each assigned itself 1 << 0. Taking either side of that conflict, or keeping both enumerators at bit 0, compiles cleanly and ships a wire-level collision. A server built with batched get but without peer copy sets bit 0; a client that knows about peer copy reads that same bit as peer-copy support and sends it RPC_CMD_COPY_TENSOR_TO, a command it does not implement. That is a cross-version failure between two builds that each look correct on their own, and nothing in the type system objects to it.

The features now occupy distinct bits. Batched get keeps bit 0, because that value is already published on this branch and anything already built against it would otherwise be wrong; peer copy moves to bit 1. hello() advertises both, since this server implements both.

The second: rpc_conn_state::server_flags was added on both branches. Git auto-merged transport.h without reporting a conflict and produced a struct with the field declared twice. That does not compile, so it was caught immediately, but it is worth noting that the clean auto-merge was wrong.

Checked rather than assumed, on CPU with no GPU involved: the merged ggml-rpc-server advertises flags: 0x03 on the wire, and a llama-server generating against it over a real RPC socket completes normally. Both client-side gates survived the merge and each remains keyed to its own flag, so GGML_RPC_NO_BATCHED_GET and the peer-copy gate still work independently.

The head moved, so this needs another look.

chatgpt-codex-connector[bot]

This comment was marked as resolved.

…able peers, bound connections

Three review items, each reproduced against this branch before anything was changed. All three are
CPU reproducible, so the numbers below are measured rather than argued.

Foreign writes. set_tensor() passed allow_foreign = true for every caller, because the destination
of a peer copy is the one path that has to resolve a buffer belonging to another session. Ordinary
client connections dispatch the same command, so any client could name any live buffer in the
process and write it. Demonstrated with two plain connections to one server: client A allocates a
buffer and fills it with 0x11, client B allocates nothing at all and sends SET_TENSOR naming A's
buffer, and A reads its own buffer back. Before, 4096 of 4096 bytes were B's 0xAA and none were
A's 0x11. After, 4096 of 4096 are still 0x11 and none are 0xAA.

The widening now applies only to a link that declared itself with RPC_CMD_PEER_LINK, which a source
server sends on the connection it opens to a destination and no ordinary client sends. To be clear
about what this is: the RPC protocol has no authentication of any kind, so this is isolation rather
than authorization and does not make the port safe to expose. What it changes is that the privilege
belongs to the one connection that asked for it instead of to every connection.

The control matters more than the fix here, because refusing too much would break peer copies
silently: a rejected write surfaces as result = 0 and a fallback, not as an error. Driving
RPC_CMD_COPY_TENSOR_TO between two servers, both before and after, gives result = 1 and 4096 of
4096 bytes of the source pattern at the destination. Identical on both sides of the change.

Unreachable peers. A destination the coordinator can reach but the source server cannot, which is
what asymmetric firewall or NAT rules produce, failed in get_peer_socket() on every single copy with
nothing remembered. Where packets are dropped rather than refused that is a full TCP timeout per
split boundary, which turns a mild fallback into a stall. Failures are now cached with a 1s, 2s, 4s
backoff to a 60s cap, cleared on the first success, so a destination that is briefly down is retried
promptly while one that is unreachable by routing stops costing anything measurable.

Connection threads. Every accepted socket got a detached thread before HELLO was validated, and that
thread can block in recv_data() forever because the transport sets no read timeout. Measured by
opening 200 connections that connect and then say nothing: before, 200 accepted, 0 refused, thread
count +200. After, 64 accepted, 136 refused, thread count +64, and back to baseline once the clients
close. The limit is 64 by default and GGML_RPC_MAX_CONNECTIONS raises it.

Not covered here: the backoff is reasoned from the code path rather than measured, because provoking
a genuinely unreachable destination needs the two-node setup, and the peer path was exercised only
between two servers on one host.
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@unslothai unslothai deleted a comment from chatgpt-codex-connector Bot Sep 9, 2026
@danielhanchen

Copy link
Copy Markdown
Member Author

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4f54a47d66

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

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".

Comment on lines +1213 to +1214
if (src_is_rpc && dst_is_rpc) {
return ggml_backend_rpc_cpy_tensor_p2p(backend_src, backend_dst, src, dst);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Avoid retrying the P2P copy from the source callback

When a cross-RPC transfer returns false (for example because the source server cannot reach the destination), ggml_backend_cpy_tensor_async_impl next invokes backend_src->iface.cpy_tensor_from_async; RPC registers this same function for both callbacks, so the entire destination barrier and COPY_TENSOR_TO exchange is attempted a second time before the generic fallback runs. On an unreachable peer this performs two failed direct-copy round trips—and currently two device-to-host staging reads—for every boundary copy. Use separate destination/source callback implementations, or make the source-side callback decline an RPC-to-RPC copy without retrying it.

Useful? React with 👍 / 👎.

Comment on lines +2304 to +2306
socket_ptr peer = get_peer_socket(endpoint);
if (peer == nullptr) {
return true;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Check peer availability before staging the tensor

When server-to-server routing is unavailable, get_peer_socket returns null—including immediately during its cached backoff—but it is called only after the source tensor has already been read into p2p_buf. The callback then returns false and the generic fallback reads the same activation from the source again, so every layer boundary continues paying a redundant device-to-host transfer and host allocation throughout the backoff period. Resolve the peer before staging so the intended fallback remains inexpensive when asymmetric firewall or NAT rules prevent direct connectivity.

Useful? React with 👍 / 👎.

rpc_msg_peer_barrier_rsp barrier;
if (!send_rpc_cmd(peer, RPC_CMD_PEER_BARRIER, nullptr, 0, &barrier, sizeof(barrier))) {
GGML_LOG_ERROR("[%s] %s did not acknowledge the write\n", __func__, endpoint.c_str());
peer_socks.erase(endpoint);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Evict failed peer sockets from the global cache

When multiple coordinator sessions have copied to the same destination, their peer_socks maps share the socket returned by the process-wide weak g_sockets cache. If the destination restarts, erasing only this session's reference leaves the failed socket alive while any other, possibly idle, session still holds it; subsequent retries retrieve that same dead socket from get_socket, fail the peer-link handshake, and can never establish a fresh connection until every other holder releases it. Explicitly invalidate the global cached socket, or give peer connections independent cache ownership, when a send or barrier fails.

Useful? React with 👍 / 👎.

@danielhanchen

Copy link
Copy Markdown
Member Author

Defect report, not a review of this PR

Issues are disabled on this repository, so this is filed here because this PR is where it was
reproduced most recently and most repeatedly. It is not caused by this PR and it is not fixed
by it; it needs an owner of its own.


What happens

At 32 concurrent streaming clients against llama-server, exactly one request of every 64
returns no tokens
. The client sees a completed HTTP response with no content; the server logs no
error. It is one request per cell, reproducibly, not a rate.

Where it has been seen

It is not a property of any one branch, which is why this is an issue rather than a comment on a
pull request:

  • PR rpc: direct server-to-server tensor transfer for a layer split #196, three stage split (--device RPC0,RPC1,CUDA0 -sm layer --tensor-split 1,1,1,
    Qwen3.8-27B UD-Q4_K_XL, -c 16386 --parallel 32 --cache-ram 0 -fa on -t 6), 32 concurrent
    closed loop clients, npp 128 / ntg 256, 64 requests per cell: all five cells reported
    n_err 1, in the hub arm (GGML_RPC_P2P=0) and in the direct arm (GGML_RPC_P2P=1) alike, so
    it is unaffected by the feature that branch adds.
  • PR server: --pipeline-groups, run the slots over several contexts of one model #187's own description records the same thing independently: "Every 32-user cell in every arm,
    including the two without speculation, had one request of 64 return no tokens; it is present in
    the arms this PR does not touch".

Two unrelated branches, two harnesses, the same one-in-sixty-four. Nobody has picked it up.

Reproduction

Any 27B layer split over two nodes, or any server configuration that sustains 32 concurrent
streaming completions, with a closed loop client that counts tokens per request:

llama-server -m Qwen3.8-27B-UD-Q4_K_XL.gguf -ngl 99 -fa on -c 16386 --parallel 32 \
    --cache-ram 0 -t 6 --metrics ...
# 32 concurrent clients, 2 requests each, npp 128, ntg 256, stream=true, ignore_eos

One of the 64 requests ends with zero streamed content chunks. The server's
llamacpp:tokens_predicted_total accounts for the tokens of the other 63 only, so the loss is
upstream of the counter rather than a client-side parse failure.

Why it matters

A serving deployment at this concurrency silently drops 1.6 percent of requests with a 200 and an
empty body. Nothing in the log marks it, so it is invisible without a client that checks token
counts per request, and every throughput figure taken at 32 rows is quoted over 63 requests while
being divided by the wall time of 64.

What is not yet known

Whether it is the slot handover, the streaming path, or the HTTP layer. PR #187 also records a
different but possibly related failure at 256 concurrent streams, where responses are truncated
mid-stream with no server-side error, which suggests the HTTP or streaming path rather than
sampling.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant