[TRTLLM-15344][feat] cache transceiver nixl bounce buffer - #15780
[TRTLLM-15344][feat] cache transceiver nixl bounce buffer#15780chuangz0 wants to merge 45 commits into
Conversation
5998a62 to
11e9711
Compare
08c3277 to
006072a
Compare
|
/bot run --disable-fail-fast |
|
PR_Github #56919 [ run ] triggered by Bot. Commit: |
|
PR_Github #56919 [ run ] completed with state
|
7e44ece to
1214785
Compare
|
/bot run --disable-fail-fast |
1214785 to
40c5a3b
Compare
fdff3d7 to
b7c648d
Compare
|
/bot run --disable-fail-fast |
|
PR_Github #61555 [ run ] triggered by Bot. Commit: |
|
PR_Github #61555 [ run ] completed with state
|
175552f to
2aef331
Compare
|
/bot run --disable-fail-fast |
|
PR_Github #61779 [ run ] triggered by Bot. Commit: |
|
PR_Github #61779 [ run ] completed with state
|
9eba889 to
5e6080e
Compare
136d661 to
be9dd02
Compare
Erasing the LAST flow from the round-robin ring left `mCursor %= mRing.size()` to execute with size()==0 -- modulo by zero (UB; a deterministic SIGFPE in -O0 builds, silently folded away at -O2). Any normal completion or reclaim of the only active flow hits this path. Reset the cursor and return early when the ring empties; the non-empty path is unchanged. Add a regression test that drains a single flow to empty the ring and verifies scheduling still rotates fairly on refill. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
- BounceTransport: exception boundary around the IO reactor tick; plan-entry capacity guard on the gather path; submit() resolves plan-build failures to kFAILURE instead of throwing out of submitTransferRequests - shouldUseBounce: screen every plan precondition (src/dst length pairs, per-descriptor size cap, per-side device uniformity) so ineligible requests fall back to the standard NIXL path - NixlNotifControlChannel: genNotif no longer runs under the channel mutex - ExecPool: constructor cleans up already-allocated CUDA resources on failure - BounceMessage: decodeHandshake bounds the endpoint length so malformed blobs return false instead of throwing - BounceConfig: document that REQUEST_TIMEOUT_MS <= 0 disables the timeout - tests: dedupe bounceNixlE2ETest via bounceTestNixlNode.h, CUDA guard in ZeroBuffersIsNoop, brace style fixes, owner-map assert, cppzmq CMake gate, pin UCX env in the Python transceiver test Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
The Python transceiver test asserted two log strings in the child's stdout, which required TLLM_LOG_LEVEL_BY_MODULE=debug:executor. A non-empty per-module level map can hang the child at exit (~15% repro in batch runs): static destruction order lets CudaMemPool's deleter TLLM_LOG_TRACE through an already-destroyed Logger module map, and the corrupted std::map::find never returns, so the child spins until the 180s subprocess timeout. Replace log parsing with a programmatic probe: - NixlTransferAgent::isBounceEnabled() / getBounceSubmitCount() (atomic counter bumped when a request is routed to the bounce fast path), exposed as bounce_enabled / bounce_submit_count on the nanobind agent and the Python wrapper — also usable for deployment checks - the test asserts them inside the child (all agents bounce-enabled, total submit count > 0) and the parent only checks the child's exit code; the by-module log env and both string assertions are gone The Logger static-destruction hang itself is a pre-existing main-library issue and will be addressed separately. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…r_enable Promote the bounce v2 on/off switch from the TRTLLM_NIXL_BOUNCE_ENABLE environment variable to a first-class CacheTransceiverConfig field. - agent_buffer_enable: Optional[bool] on the Python and C++ configs (backend-agnostic name; currently implemented by the NIXL agent). Unset keeps the env-var fallback; an explicit value overrides it. Mutually exclusive with kv_cache_bounce_size_mb (validator). - Plumbed as a first-class BaseAgentConfig field (not backendParams, which feeds NIXL plugin params) through both the C++ transceiver (AgentConnectionManager) and the Python transceiver (TransferWorkerConfig -> BindingsNixlTransferAgent). - Expert tuning knobs stay on TRTLLM_NIXL_BOUNCE_* env vars. - Serialization, nanobind bindings, pickle state, equality updated. - Tests: llm_args validator coverage, telemetry capture (True/False/ None), C++ serialization round-trip, config-overrides-env agent test, and the transceiver bounce e2e now enables via the config path instead of the env var. Golden manifest regenerated. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Plain scalar value fields ride the type-driven auto-enroll already covered by the generic capture tests; per-field tests are reserved for allowlist/redaction paths and type-shape regressions (e.g. transceiver_runtime's Literal-union unwrap). Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…unce arena TRTLLM_KV_TRANSFER_NUM_THREADS default 1 -> 4: each sender worker submits transfers synchronously (blocking wait per slice), so the old default serialized all cross-request KV transfers on one thread per rank. TRTLLM_NIXL_BOUNCE_ARENA_SIZE_BYTES default 256MiB -> 512MiB to match the higher in-flight slice count (arena demand scales with workers x window x chunk size). Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Remove the experimental TRTLLM_NIXL_BOUNCE_USE_NIXL_NOTIFICATIONS toggle and its NixlNotifControlChannel implementation. ZMQ is the only production control channel; the ControlChannel interface stays so an alternative transport remains pluggable, and the handshake keeps the controlKind wire field so incompatible peers still fall back to the standard NIXL path. Verified: all 13 bounce unit-test binaries (116 cases) pass on real GPU + NIXL RDMA, and the Python transceiver bounce test (test_python_nixl_cache_transceiver_uses_cpp_bounce, 4 params) passes against a freshly built wheel. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…gions A dead sender emits neither DATA nor a cancel, so its granted receiver regions leaked forever (no time-driven reclamation existed), and the receiver-initiated forget() path re-granted regions the gone peer's NIC may still be RDMA-writing (one-sided writes cannot be aborted). Fix with two concepts, single-source-of-truth in CreditScheduler: - lease: FlowState.lastProgress (stamped on WANT / grant / scatter done); staleFlows() reports region-holding flows idle beyond TRTLLM_NIXL_BOUNCE_RECEIVER_FLOW_TIMEOUT_MS (default 60s, 2x the sender request timeout, <=0 disables) - quarantine: receiver-initiated reclaims (forget / lease expiry) park non-busy regions for TRTLLM_NIXL_BOUNCE_QUARANTINE_MS (default 30s) before reuse instead of freeing them under a possible in-flight write; sender-cancel reclaims keep immediate free (writes drained by protocol) BounceReceiver::checkTimeouts() drives both from the IO tick, sweeping at a tenth of the smallest enabled timeout (clamped to [50ms, 1s]). The scheduler clock is injectable so the new unit tests advance a fake clock instead of sleeping; an e2e test covers grant -> silent sender -> lease expiry -> quarantined region re-granted, late DATA dropped. Also document that bounce admission is final (no automatic fallback to the standard NIXL path after a bounce failure), and switch the NIXL agent to NIXL_THREAD_SYNC_RW. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
Remove pure duplication and collapse copy-pasted setups; coverage is unchanged (every deleted case is subsumed by a surviving one): - gatherScatterKernelTest: the hand-written GatherThenScatterRoundTrip was byte-for-byte the runBackendRoundTrip(false,false) helper added later; call the helper instead. - Merge bounceNixlE2ETest into bounceTransportFailureTest: both build nodes via the shared bounce_test::makeNode. NoGrantTimesOutNotHang is upgraded to the stronger ghost-ROUTER variant (WANT delivered, nobody grants); ForgetPeerInFlightRecovers moves over unchanged. - bounceAgentE2ETest: drop its private AgentBufs/hasCuda/alignUp copies in favor of the shared bounceTestNixlNode.h helpers; collapse the three concurrency tests' identical thread bodies into runConcurrentFlows() and the five poll loops into waitTerminal(). - bounceTransportFailureTest: shared pumpChannel/waitGrant/countAcks helpers replace four hand-rolled channel-poll loops. - creditSchedulerTest: four 'one WANT grants min(cap, arena)' cases fold into one table-driven test. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
A failed bounce transfer previously collapsed into a bare kFAILURE: the
three failure modes an operator actually hits (request timeout, peer
invalidation, mid-flight RDMA write failure) produced no C++ log at all,
and Python's transceiver error detail printed nixl_status=<unavailable>.
- submit() now returns shared_future<BounceResult> {state, reason} — the
future is the single source of truth, no side channel to keep in sync.
- failRequest() takes the reason and logs ONE warning with progress
context (rid, peer, reason, chunks acked/posted/total); every
sender-side failure passes through it, so the formerly silent timeout
/ forgetPeer / write-failure paths are now logged.
- The abandon sites (GRANT mispair, plan overflow) tag kProtocolError on
the request so the eventual failure reports the specific cause instead
of the generic timeout.
- TransferStatus gains a default-empty getLastStatusStr() virtual;
BounceTransferStatus implements it and the base-class binding exposes
get_last_status_str — the exact attribute BindingsNixlTransferStatus.
last_status_str() resolves, so the existing Python error log picks up
the reason with no Python changes.
- Failure tests now assert the specific BounceFailReason, and the Python
bounce test asserts the binding attribute exists.
Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…g debug knob Two of the TRTLLM_NIXL_BOUNCE_* environment variables were experimental toggles that never ran with non-default values in production: - TRTLLM_NIXL_BOUNCE_USE_CUB_COPY: the cub::DeviceMemcpy::Batched backend never beat the custom batched-copy kernel; delete the backend (kernel entry points, ExecPool cub workspace, launchPrepared branch) along with its knob and tests. - TRTLLM_NIXL_BOUNCE_DISABLE_SCATTER_RUN_MERGING: a debug-only A/B switch documented 'never enable in production'; scatter-run merging is now unconditional (BounceTransferPlan::build keeps the parameter, defaulted). TRTLLM_NIXL_BOUNCE_USE_ZERO_COPY_ARGUMENTS stays: some machines read mapped-host plan arrays slower than a staged H2D. ENABLE_EAGER_GATHER and COPY_STREAM_COUNT stay as debug/tuning escape hatches. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…op NixlTransferEngine
Address the review point that bounce carried its own NIXL wrapper next
to NixlTransferAgent. The agent now exposes its low-level primitives
(below the VMM splitter) and bounce uses them directly:
- NixlTransferAgent gains postXferRequest (post one already-resolved
transfer; returns nullptr on failure instead of aborting) and
registerRegionImpl/deregisterRegionImpl (raw range registration
without VMM splitting or AgentDesc VRAM-region bookkeeping).
submitTransferRequests = bounce fork + split/coalesce +
postXferRequest.
- BounceTransport posts each chunk's RDMA write via postXferRequest
(the credit-granted remote address is already final, so the splitter
is skipped) and keeps the returned TransferStatus: poll is wait(0)
(one non-blocking three-state query), release-failed handles are
retained by the status object whose destructor retries.
- Delete NixlTransferEngine.{h,cpp}, bounce/TransferEngine.h and the
bounce::XferState enum; remove getRawAgent() (the raw nixlAgent no
longer escapes the agent).
- Fault injection moves to the agent seam: failure tests subclass
NixlTransferAgent (now non-final) and override postXferRequest via
FakeXferAgent/FakeXferStatus; the control plane and metadata exchange
stay real.
Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…e request timeout TRTLLM_NIXL_BOUNCE_RECEIVER_FLOW_TIMEOUT_MS and TRTLLM_NIXL_BOUNCE_QUARANTINE_MS were independent env knobs with a hard mathematical relationship to the request timeout: the receiver lease must EXCEED the peers' requestTimeoutMs (a live sender abandons and cancels first, so only dead peers hit the lease), and the quarantine uses the same time scale. Independent knobs made that a configuration trap (raise the request timeout, forget the lease -> the receiver reclaims regions from live senders). fromEnv() now derives them: receiverFlowTimeoutMs = 2 x requestTimeoutMs, quarantineMs = requestTimeoutMs (both disabled together when the request timeout is <= 0). Defaults are unchanged (30s -> 60s/30s). The struct fields stay, so white-box tests keep setting them directly. The derivation assumes both ends run the same TRTLLM_NIXL_BOUNCE_REQUEST_TIMEOUT_MS, now documented on the field. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…d startup paths - wake() now notifies under mJobMu: shutdown() sets stop without the job mutex, so a naked notify_all could fire between a worker's predicate check and its park, hanging joinWorkers() forever. - Reject a DATA run list whose raw piece count exceeds the plan capacity BEFORE the per-piece counting pass: a hostile/corrupt run (count ~2^32, bounceStride 0) could otherwise pin a scatter worker and its region. - Drop a duplicate non-empty WANT for a tracked flow with a warning: re-queueing re-grants over still-held regions (leaking them) and the lease refresh defeats the staleFlows() reclaim. - Derive receiverFlowTimeoutMs in 64-bit and clamp: 2 * requestTimeoutMs overflows int for timeouts above INT_MAX/2, wrapping the lease negative and silently disabling dead-sender reclaim. - Join already-spawned scatter workers when the constructor's thread startup throws partway, instead of letting their std::thread destructors call std::terminate. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…nstead of env vars Replace agent_buffer_enable with agent_buffer_size_mb (0 disables; >0 enables the C++ transfer-agent bounce fast path at that arena capacity) and add agent_bounce_params, a dict of expert tuning knobs forwarded to the bounce pipeline (precedence: dict > TRTLLM_NIXL_BOUNCE_* env > default). The TRTLLM_NIXL_BOUNCE_ENABLE and TRTLLM_NIXL_BOUNCE_ARENA_SIZE_BYTES env vars are retired (a deprecation warning fires if set); the remaining expert env vars stay as fallbacks. Unknown or orphaned params are rejected at the Pydantic boundary, with the valid-key list kept in tensorrt_llm/_torch/disaggregation/nixl/bounce_knobs.py and sync-tested against the C++ kEnvKnobs table. Legacy pickles with the retired Optional[bool] field deserialize as bounce-off. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…contract A WANT is untrusted peer input, but onWant passed its chunk sizes to the credit scheduler unvalidated. Two out-of-contract shapes each DoS the receiver: a zero-size chunk can never be allocated, so the flow blocks forever and, once maybeActivateDrain() latches it as the drain flow, schedule() stops granting to every peer with no reclaim path (a pending-only flow holds no regions, so the lease sweep skips it); a chunk above maxChunkSizeBytes buddy-rounds up to the whole usable arena, gets granted, and starves all peers until the lease sweep. Reject any WANT with a chunk outside (0, cfg.maxChunkSizeBytes] up front. The capability handshake pins both sides to the same effective maxChunkSizeBytes (already clamped to usable arena capacity in the ctor), so a compliant sender never trips the check, and every accepted size is allocatable from an empty arena, restoring drain liveness. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
…gic and tests Review cleanups from an adversarially-verified pass over the bounce v2 diff: Main code: - fix comments left stale by the cub-copy backend and NixlTransferEngine removals (engine->agent, copy-backend knobs, NVTX push/pop vs start/end, TLLM_BOUNCE_V2 gate description, dangling DESIGN.md reference) - drop dead code: OrphanLocal::peer, mergeScatterRuns knob, BounceMsgHeader::aux (header 44->40 bytes), hasBounceMagic(), arenaCapacity() accessor, unused ifaddrs.h include - dedup: extract abandonOnCreditMispair, reclaimAndFlagDeferred and issueGrant helpers; remove BounceArena::mIsFabric shadow field; log arenaUsableCapacityBytes in the handshake-incompat warning Tests: - make ConcurrentRequestsToSameReceiver actually concurrent (one pair, two in-flight submits) - replace a 200ms ordering sleep with the FIFO-sentinel technique in DuplicateDataProducesOneScatterAndAck - dedup: tryMakeAgent helper, runTransfer config overload, Mirror::freeOwnedBy, shared bounceTestUtils.h (hasCuda/alignUp), add_bounce_reactor_test() in CMake Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
209d03e to
5c94adc
Compare
|
/bot run --dsiable-fail-fast |
|
/bot run --disable-fail-fast |
|
PR_Github #68243 Bot args parsing error: usage: /bot [-h] |
|
PR_Github #68244 [ run ] triggered by Bot. Commit: |
… over a shared capacity Replace the agent_buffer_size_mb field with agent_bounce_buffer_enable: kv_cache_bounce_size_mb is now the single bounce capacity shared by both implementations (0 disables), and the bool picks the C++ transfer-agent bounce (single shared arena) over the default Python one (per-region pair). Flipping one bool A/B-tests the two implementations with the same capacity, and retiring the Python bounce later will not force config edits. The conversion to the agent's arena size happens only on the Python front end (size if enabled else 0); agent_buffer_size_mb survives unchanged as the internal pipeline value (TransferWorkerConfig, BaseAgentConfig, the tle property), so the C++ side sees no logic change. mirror_pybind_fields gains an excluded_fields parameter to exempt that internal-only property. Also fix a pre-existing test gap: the bounded-polling timeout test mocked the config with a SimpleNamespace missing the bounce fields, and add routing coverage for the three capacity/enable combinations plus a double-bounce guard in the single-process test. Signed-off-by: Chuang Zhu <111838961+chuangz0@users.noreply.github.com>
|
PR_Github #68244 [ run ] completed with state
|
Dev Engineer Review
AgentDescserialization with bounce handshake metadata.QA Engineer Review
test_python_nixl_cache_transceiver_uses_cpp_bounce.tests/integration/test_lists/test-db/l0_h100.yml.Description
NIXL Bounce-Buffer v2 — Design
1. Motivation
A single disaggregated-KV
submitTransferRequestsoften carries thousands to tens of thousands ofsmall (~4 KiB) scattered descriptors. Submitting them to NIXL/NIC one-by-one is dominated by
per-descriptor overhead, leaving the link far below line rate.
The data-path idea: the sender gathers the scattered small descriptors into one pre-registered
buffer → does a single RDMA write to a peer buffer → the receiver scatters back to the final
destinations:
The hard part is the control plane: how buffers are allocated across senders, flow-controlled,
recycled, and how errors are handled. Requirements:
(bounded buffers carry an unbounded transfer).
submit.wait()(SUCCESS/FAILURE) — never hangs.buffer — no request reserves a whole buffer, which would waste memory and cap concurrency.
R1 and R8 pull in opposite directions — carry one over-sized request and pack many small ones into
the same fixed buffer — and together motivate the variable-size region arena (§2).
2. Overview
BounceArena(a device buffer registered once) is sliced into variable-sizeregions: each chunk gets a region of exactly its byte size. Many small requests pack tightly
(high concurrency, no waste); a request larger than the arena streams chunk-by-chunk with recycling.
(exclusive write permission to a region) before writing. Credits are granted incrementally per chunk
(WANT/GRANT) and recycled on scatter completion (ACK) — bounded end to end.
submit()launches a chunk's gather before the GRANT arrives,overlapping the WANT→GRANT control round-trip with the gather kernel. Eager (credit-less) staging is
capped at half the arena, so on a bidirectional deployment both sides can always still grant
incoming regions (no mutual eager-starvation); credit-backed allocations are not capped.
notifications (UCX active messages on the RDMA fabric,
TRTLLM_NIXL_BOUNCE_USE_NIXL_NOTIFICATIONS),dropping a control hop from tens of microseconds to a few. Peers must use the same control kind —
enforced by the capability handshake (§5).
lock-free), plus M scatter workers.
notifMsg, no extra flush on the data plane: data-landed is decided purely by the senderpolling
poll==SUCCESS(the NIXL UCX backend already appendsucp_ep_flush_nbxper transfer, soSUCCESS ⇒ the data is visible at the remote target).
comes from a small
ExecPool(copyStreamCountcontexts), borrowed/returned per kernel — separatefrom a region's long lifetime (which lasts until ACK).
flowchart LR subgraph SND["Sender agent (IO thread)"] REQ["Request{ numChunks,<br/>nextPost, acked }"] OUT["shared BounceArena + ExecPool<br/>acquireLocal(bytes) → gather staging<br/>(eager: before GRANT, ≤ ½ arena)"] end subgraph RCV["Receiver agent (IO thread)"] SCH["CreditScheduler (single allocator)<br/>BuddyAllocator(arena)<br/>flows{ pending, held } + localHeld<br/>round-robin ring + drain mode"] IN["shared BounceArena<br/>variable-size regions (RDMA targets)"] SW["scatter workers ×M (borrow ExecPool)"] end REQ -- "① WANT(rid, chunk_bytes[])" --> SCH SCH -- "② GRANT(regionHandle, addr, len)" --> REQ REQ -- "③ gather→RDMA write→DATA(regionHandle, scatter runs)" --> IN IN --> SW SW -- "④ scatter_done" --> SCH SCH -- "⑤ ACK(regionHandle)" --> REQ SCH -. "owns/allocates arena regions" .-> IN REQ -. "③ occupy / ⑤ release" .-> OUTOne agent can be both sender and receiver, and both roles share the same arena (most disagg agents
only send or only receive, so one buffer halves memory; a dual-role agent still shares a single arena,
deadlock-free — the eager cap above is what makes the bidirectional case safe).
3. Terminology
BounceArena; its handle = byte offset within the arena (addr = baseAddr + offset).BounceTransferPlanby bin-packing scattered (src,dst) descriptors; ≤maxChunkSizeBytes, moved by one RDMA write.GRANT{regionHandle, addr, len}."peer\x1f rid"(concurrent requests from the same peer are distinct flows).maxInflightChunksPerRequest.BounceArena).{stream, event, scratch, hostPinned}needed to run one gather/scatter, borrowed/returned fromExecPool.GatherScatterKernel).BounceScatterRun): adjacent descriptors merged when contiguous or uniformly strided.AgentDesc(§5); peers engage bounce only when compatible.4. Modules
Pure logic (no GPU / threads / IO — unit-testable):
BounceConfigfromEnv); byte-valued vars accept binary K/M/G suffixes ("256MB", "1gb", "512KiB"); garbage values fall back to defaults instead of parsing to 0.BounceTransferPlanchunks(each ≤maxChunkSizeBytes, 32 B aligned, zero-length skipped); compute each descriptor's in-region offset andpackedBytes; coalesce the scatter view intoscatterRuns(contiguous or uniform-stride merge) so the DATA message shrinks from per-desc entries (hundreds of KB) to a handful of runs.BounceMessageencodeHandshake/decodeHandshake) and the cancel codec (encodeCancel/isCancelWant).BuddyAllocatoralloc(bytes)/free(offset), coalesces buddies, no external fragmentation, internal ≤ 2×.CreditSchedulerBuddyAllocatorover the arena; also serves local-senderacquireLocal(bytes)with the eager half-arena cap. Owned by the IO thread; the only cross-thread caller isacquireLocal()fromsubmit()app threads (eager gather staging).Device / IO / integration:
BounceArenaarenaSizeBytesdevice buffer (MNNVL viacommon::FabricMemory, elsecudaMalloc), registered once;base()/baseAddr()/at(offset).ExecPoolcopyStreamCountExecCtxs;tryAcquire()(non-blocking, nullptr when full) /release(), thread-safe.GatherScatterKernelTransferEngine(abstract)registerRegion/postWrite/poll/release.NixlTransferEngine(production, wraps onenixlAgent) is the sole implementation; transport tests run over real NIXL loopback.ControlChannel(abstract)addPeer(returns success) /removePeer/sendTo/recv/localEndpoint. Two implementations:ZmqControlChannel(default): a ROUTER for receive + one DEALER per peer for send;sendTois non-blocking (drops on fullkSendHwm, never blocks the IO thread).NixlNotifControlChannel: control messages as NIXL notifications on the RDMA fabric (no TCP sockets; "endpoint" is serialized NIXL metadata).BounceTransportBounceContext+BounceSender+BounceReceiver, routes control messages to the right role, drains both roles each tick;submit()/addPeer()/forgetPeer()/shutdown(); owns the capability handshake (localHandshakeBlob/registerPeerHandshake/hasPeerHandshake).BounceContextCreditScheduler(one arena serves both directions),sendGrants().BounceSendersubmit→WANT(+ eager gather),GRANT→attach credits / gather+write,ACK→resolve; holds the request table + send-side deferred-cleanup state (mOrphanLocal/mPendingCancel).BounceReceiverWANT→grant region,DATA→scatter, replyACK; holds scatter workers + job/done queues +mScattering(orphaned flag for in-flight scatters).BounceNvtxNixlTransferAgentintegrationmaybeInitBounce(build arena+exec+transport, register arena; any init failure warns and falls back to the standard NIXL path — never fails agent construction),shouldUseBounce(routing decision, gated on the peer handshake),AgentDesccarries the local handshake blob,invalidateRemoteAgent→forgetPeer. Built only when NIXL + zmq are available (TLLM_BOUNCE_V2); decoupled fromENABLE_UCX.5. Control Plane: Credit Flow Control + Fair Scheduling (R1/R3)
Messages (all over
ControlChannel— zmq by default, NIXL notifications opt-in):WANTGRANT{addr, len, devId(receiver), regionHandle}.DATAregionHandle+ coalesced scatter runs; sent only afterpoll==SUCCESS(the scatter trigger).ACKregionHandle; scatter done, region recyclable.Capability handshake (bootstrap + compatibility, key). Each agent's
AgentDesccarries a bouncehandshake blob:
{wireVersion, controlKind (ZMQ | NIXL_NOTIF), arenaUsableCapacityBytes, maxChunkSizeBytes, endpoint}.loadRemoteAgent→registerPeerHandshakevalidates it — version,control kind, and
maxChunkSizeBytesmust match the local config, and the peer's endpoint must beregistrable — and only then marks the peer bounce-capable.
shouldUseBouncerequireshasPeerHandshake(peer), so a peer with bounce disabled, a different control transport, or mismatchedchunking silently stays on the standard NIXL path (no WANT ever stalls to
requestTimeoutMs).An agent that cannot produce a usable local endpoint advertises no handshake (never breaks
metadata exchange).
Reverse-path bootstrap. Bounce needs a two-way control channel (sender sends WANT/DATA, receiver
replies GRANT/ACK), but the disagg metadata exchange is one-directional — the KV sender
loadRemoteAgents the receiver, the receiver never loads the sender. So WANT also carries thesender's control endpoint, and the receiver
addPeer(sender)s inonWantto bootstrap thereverse path. A malformed endpoint in a WANT is rejected on the reactor thread (warn, no grant, no
exception escapes); a cancel is still honored even when endpoint registration fails, so it can reclaim
flow state left by an earlier valid WANT. Cancel/abort uses an empty WANT (still carrying the
endpoint) — no separate handshake / RETURN message.
Endpoints are routable IPs (multi-node, ZMQ).
ZmqControlChannelmust not bind127.0.0.1(unreachable cross-node).
maybeInitBounceresolves the local routable IP via the sharedcommon::getLocalIp(getEnvNixlInterface(), rank)(TRTLLM_NIXL_INTERFACEpicks the NIC, elseauto-detect by egress route / hostname — identical to UCX/NIXL addressing) and binds
tcp://<ip>:*;localEndpoint()reads the actualtcp://<ip>:<port>from zmqlast_endpointand advertises it viathe handshake / WANT. Unit tests that construct
ZmqControlChanneldirectly keep thetcp://127.0.0.1:*default. IPv6: zmq disables IPv6 by default, so an IPv6 bind address isbracketed (
tcp://[<ip>]:*) and the ROUTER setsZMQ_IPV6; the DEALER (addPeer) setsZMQ_IPV6unconditionally (harmless for IPv4) — aligned with ucx_utils. The ROUTER additionally sets
ZMQ_ROUTER_HANDOVERso a peer that is forgotten (removePeerdrops its DEALER) and later reconnectswith the same routing id is accepted, rather than having its messages silently dropped while the stale
connection is reaped.
addPeervalidates the endpoint before connecting and reports failure to thecaller. With
NixlNotifControlChannelthe "endpoint" is serialized NIXL metadata; no TCP is involved.Receiver state (lives on the IO thread, lock-free):
BuddyAllocator arena+flows{ pending: per-chunk bytes, held: region offsets, blockedAtGrantSequence }+ a round-robinringof active flow keys + a cursor + a grant sequence counter. Fixed per-flow capW = maxInflightChunksPerRequest.schedule()— on-demand, round-robin fair, never poisons the queue, never deadlocks:flowchart TD Start([event triggers schedule:<br/>onWant / onScatterDone / reclaimFlow / reclaimByPrefix / releaseLocal]) --> D0{drain mode active?<br/>a flow bypassed ≥2 full rounds} D0 -- yes --> DAlloc{"arena.alloc(drain flow's head)<br/>fits?"} DAlloc -- no --> Hold([no NEW remote grants until it fits<br/>existing regions keep freeing]) DAlloc -- yes --> DGrant[grant it, exit drain mode] --> C1 D0 -- no --> C1{ring non-empty?} C1 -- no --> Done([return accumulated GRANTs]) C1 -- yes --> Sweep[one round-robin sweep from the cursor] Sweep --> Find{current flow:<br/>pending non-empty AND held.size < W?} Find -- "none in the whole sweep" --> Done Find -- yes --> Alloc{"arena.alloc(pending.front())<br/>fits?"} Alloc -- no --> Mark[mark flow blocked at current grant sequence] --> Sweep Alloc -- yes --> Grant["off = the allocation<br/>pending.pop_front(), held += off<br/>accumulate GRANT{off, base+off, len}<br/>advance cursor past this flow"] Grant --> C1Intuition.
schedule()answers: many remote senders want to write into the same shared arena —how to hand out arena space fairly and bounded. Three constraints:
Win-flight regions (held.size() < W);more must wait for an ACK to free one — this is the pipeline depth.
doesn't fit, skip it for now (backpressure, not an error).
Think of it as taking turns at a ticket counter:
ringis the queue of flows,cursoris "who'snext". Each inner sweep hands out exactly one region — to the flow that is next, still wants more
(
pendingnon-empty), is under its cap (held < W), and whose front chunk fits right now — thenadvances
cursorpast it and starts a fresh sweep. So grants alternate across flows ratherthan filling one flow's window first. The outer loop repeats until a whole sweep grants nothing
(all pending-empty / at-cap / can't-fit).
Example (
W=2, arena currently fits 4 regions; flow A has 3 chunks c1/c2/c3, flow B has 2 chunksd1/d2, cursor starts at A):
A/c1B/d1A/c2B/d2Grant order
A,B,A,B(strict alternation); A'sc3stays inpendinguntil one of A's regions isACKed (
onScatterDone→schedule()) and the nextschedule()grants it. Eachschedule()returnsthe new batch of GRANTs, which
sendGrantssplits by flow key and sends to the right peer.Notes:
Wbounds a single flow's pipeline depth; arena capacitybounds aggregate concurrency (
allocfailure ⇒ backpressure, not deadlock). There is no "divide capby active flow count" logic.
the current grant sequence. When other grants have bypassed it for ≥2 full rounds
(
kBypassRounds), the receiver enters drain mode for the oldest such flow: no new remote grantsare issued until that head chunk fits (existing regions keep progressing and freeing space).
acquireLocal()is deliberately unaffected — this is a receiver-only admission barrier and cannotintroduce a bidirectional circular wait. Config guarantees
maxChunkSizeBytes ≤ arena usable capacity(clamped at init), so a drained arena can always fit any chunk.rid; when bothpendingand
heldare empty,eraseIfDonedrops the flow immediately — otherwiseflows/ringgrowunbounded on a long-running server and
schedule()degrades to O(historical requests).acquireLocal(bytes)takes a gather-staging region from the sameBuddyAllocator; eager (credit-less) staging is capped at half the arena (§2); the conservationinvariant holds across
{free bytes, each flow's held, localHeld}.6. Data Plane & Pipeline (R2)
heldup toW(when space allows, one GRANT message batches multiplecredits) → the sender holds W credits at once → W chunks in flight; each ACK frees a region and
the receiver refills to keep
held = W.enableEagerGather(default),submit()immediatelystages and launches gathers for the first chunks (up to the in-flight cap and the eager half-arena
budget) before any GRANT arrives;
attachCreditslater binds arriving credits toalready-gathered chunks in strict chunk order, promoting their staging regions out of the eager
budget. The WANT→GRANT round-trip and the gather kernel overlap instead of serializing.
W ≥ ⌈(write + getXferStatus + DATA + scatter + ACK/GRANT return) / single-chunk write⌉(bandwidth-delay product); gather/scatter (D2D ~TB/s) hide in the shadow of theprevious chunk's RDMA (IB ~25 GB/s), so the NIC stays the bottleneck.
ExecCtxstream, so different chunks' gather/write/scatteroverlap in wall-clock.
(bounceOffset, dstPtr) advance contiguously or by a uniform stride collapse into one
BounceScatterRun(the fully-dense case, e.g. ctx tp1 → gen tp4, collapses thousands of descs to ahandful of runs).
TRTLLM_NIXL_BOUNCE_DISABLE_SCATTER_RUN_MERGINGrestores per-desc entries forcontrol-plane A/B debugging only.
notifMsg/ no GPUDirect flush:getXferStatus==SUCCESSalready includes NIXL's per-transferucp_ep_flush_nbx(complete at both origin and target). The sender sends DATA as soon as it pollsSUCCESS; the receiver scatters on receipt.
Single-chunk timing across both planes (control = solid, data = dashed; eager gather runs ① before
②'s GRANT when enabled):
sequenceDiagram autonumber participant App as Sender app thread participant SIO as Sender IO thread participant NIC as Data plane RDMA/NIC participant RIO as Receiver IO thread participant SW as Receiver scatter worker App->>RIO: WANT(rid, chunk_bytes[]) App-->>App: ① eager GATHER (D2D): N small src → arena.at(o) (launch + eventRecord, no sync) Note over RIO: schedule(): arena.alloc carves region s, held[flow]+=s RIO->>SIO: GRANT(rid, regionHandle=s, addr, len) Note over SIO: onGrant: attachCredits to the eager-gathered chunk (or gather now if not eager) Note over SIO: drainGatherReady: cudaEventQuery==success (gather done, no block) SIO-->>NIC: ② postWrite(arena.at(o) → addr) async RDMA, no notif, return ExecCtx on done NIC-->>RIO: data written into receiver region s Note over SIO: pollSenderHandles: poll==SUCCESS (incl. ucp_ep_flush_nbx ⇒ landed at remote target) SIO->>RIO: ③ DATA(rid, chunk, regionHandle=s, scatter runs) Note over RIO: onData: validate runs against the flow's region, enqueue ScatterJob(s, runs) RIO->>SW: ScatterJob(s) SW-->>SW: ④ SCATTER (D2D): arena.at(s) → final dst×N (borrow ExecCtx, launch + streamSync) SW->>RIO: scatter_done(s) Note over RIO: onScatterDone: arena.free(s), schedule() may GRANT the next chunk RIO->>SIO: ⑤ ACK(rid, chunk, regionHandle=s) Note over SIO: onAck: releaseLocal(region o), acked++, all ACKed ⇒ promise=SUCCESS SIO->>App: future ready (SUCCESS)For a large request (K > in-flight cap / arena capacity), the loop above repeats with both sides'
memory staying at O(W) regions while the transfer size is unbounded (R1).
7. Threading (R4/R5)
CreditScheduler+ sender request table (the keyto being lock-free; the one cross-thread entry is eager
acquireLocalfromsubmit()). Each tick:recvone control message and dispatch;drainGatherReady(gather event ready → postWrite + return ExecCtx);pollSenderHandles(poll==SUCCESS→ send DATA);drainScatterDone(worker report → send ACK + free region + reschedule);drainForgets/drainPendingPosts(retry parked credits) /checkTimeouts.thread.
submit()never blocks: registers a Request + sends WANT (+ launches eager gathers,fire-and-forget), returns a
shared_future; safe to call from multiple threads.recvuses a 0 ms timeout (low latency); fullyidle uses 1 ms; after long 0 ms spinning it backs off ~50 µs so a long model-kernel gather delay
can't busy-spin a core.
8. State Machines
Unified region lifecycle (one arena serves both roles; a region is held by exactly one kind of
owner at any time — the conservation invariant):
stateDiagram-v2 [*] --> FREE: arena init FREE --> INCOMING_HELD: GRANT(region→remote flow) 【schedule()】 FREE --> OUTGOING_HELD: acquireLocal(bytes) 【local gather staging — eager ≤ ½ arena】 INCOMING_HELD --> QUEUED: DATA(regionHandle, runs) received QUEUED --> SCATTERING: scatter worker picks it up (borrow ExecPool ctx) SCATTERING --> FREE: scatter_done → onScatterDone (reply ACK + reschedule) INCOMING_HELD --> FREE: forgetPeer / reclaimByPrefix (no in-flight DATA) OUTGOING_HELD --> FREE: ACK / failure → releaseLocal (reschedule — freed bytes go to a waiting flow) FREE --> [*]: shutdown note right of OUTGOING_HELD Invariant: every region is in exactly one of { free arena bytes } ∪ { some remote flow's held } ∪ { localHeld }. INCOMING_HELD = remote RDMA-write target (QUEUED/SCATTERING are its sub-states). OUTGOING_HELD = local gather source. Never held by both. end noteSender chunk/request lifecycle (never hangs). With eager gather a chunk may reach
Gathered(gather event signalled, ExecCtx returned) while its GRANT is still in flight;
attachCreditsthenpromotes it straight to the write:
stateDiagram-v2 [*] --> WANT_SENT: submit() registers Request + sends WANT(chunk_bytes[]) + eager gathers WANT_SENT --> GATHERING: eager acquireLocal + launch gather (no credit yet) GATHERING --> GATHERED: drainGatherReady sees cudaEventQuery==success (waiting for credit) WANT_SENT --> POSTING: GRANT received (onGrant, credits queued) GATHERED --> IN_FLIGHT: attachCredits → postWrite POSTING --> POSTING: arena/exec full → credit parked, retried by drainPendingPosts (no block) POSTING --> GATHERING: borrow ExecCtx + acquireLocal(bytes) → launch gather + eventRecord (no sync) GATHERING --> IN_FLIGHT: gather done + credit attached → postWrite + return ExecCtx IN_FLIGHT --> DATA_SENT: pollSenderHandles sees poll==SUCCESS → send DATA DATA_SENT --> POSTING: ACK received and chunks remain (releaseLocal that region) DATA_SENT --> SUCCESS: acked == numChunks WANT_SENT --> FAILURE: checkTimeouts, no progress beyond requestTimeoutMs POSTING --> FAILURE: forgetPeer / shutdown GATHERING --> FAILURE: gather launch/record/stream error / forgetPeer / shutdown IN_FLIGHT --> FAILURE: poll==kFailed / forgetPeer / shutdown SUCCESS --> [*]: promise=SUCCESS FAILURE --> [*]: promise=FAILURE (empty WANT retracts credits) note right of WANT_SENT Every terminal state resolves the promise → the caller's wait() always returns, never hangs (R5) end note9. Error Handling & Lifecycle (R5)
Every request reaches a terminal state;
wait()never hangs. Bounce failures degrade, never break:maybeInitBouncecatches any construction error (fabric alloc, zmq bind, NIXL registration) with awarning and leaves the agent on the standard per-desc NIXL path.
registerPeerHandshakeatloadRemoteAgentshouldUseBounceroutes to standard NIXL (no WANT is ever sent, no timeout burned).checkTimeoutsexceedsrequestTimeoutMsonWantaddPeerfails/throwspoll==kFailedpumpRequestflags it →drainGatherReadyinvalidateRemoteAgent→forgetPeerforgetPeerdrops the peer's DEALER + its handshake registration synchronously; the IO threadreclaimByPrefix("peer\x1f")reclaims all of the peer's flows + fails its in-flight requests. A freshloadRemoteAgentmust re-validate a new handshake.kSendHwm)sendToreturns EAGAINrequestTimeoutMs(no hang, no corruption).shutdowncudaDeviceSynchronize→ fail all in-flight requests.Concurrency-safety points:
mScatteringmap (region offset → isorphaned) of all scattering regions. If
forgetPeerreclaims a region still inmScattering(aworker is reading it),
reclaimByPrefixdefers the free (marks it orphaned) and onlyfreeOrphanRegions it on scatter completion — preventing "new sender's RDMA write ⊥ worker's read".cudaStreamSynchronizeits stream (so an abandoned gather can't write a re-granted region); a sync error WARNs and clears the
sticky error.
Writingmay still be read by the NIC asa source, so it cannot be freed immediately. It's recorded in
mOrphanLocal;drainOrphanLocal()polls its xfer to a terminal state beforerelease+releaseLocal(thesend-side orphan mechanism, symmetric to the receive-side
mScatteringorphaned flag).WANT; the receiver
reclaimFlowimmediately frees the flow's granted-but-unwritten regions(otherwise they stay held until peer loss, leaking on a long-running receiver), deferring any
scattering ones. Correspondingly
onDatavalidates withheldByFlow: if an empty WANT raced ahead ofa DATA so the region was freed/re-granted, that late DATA is dropped (never scatter a region now
owned by someone else).
launch the receiver checks every run's source range lies within this flow's granted region, and
that the expanded plan doesn't exceed local scratch capacity. Any out-of-bounds → no launch, no ACK.
cudaGetErrorString).Entry routing (transparent to callers; disabling is byte-equivalent to the original NIXL path):
flowchart TD S[submitTransferRequests] --> E{shouldUseBounce?<br/>WRITE + both-VRAM + no syncMsg<br/>+ peer handshake OK + descCount/avg<br/>+ per-side uniform deviceId} E -- no --> N[standard NIXL path] E -- yes --> SUB[submit: register Request + send WANT + eager gathers + return future] SUB --> POST[IO thread: GRANT→attach/gather+postWrite→DATA, pipelined] POST --> ERR{poll==kFailed / scatter fail / peer gone / stalled beyond requestTimeoutMs?} ERR -- yes --> F[Request → FAILURE] ERR -- no --> OK{all chunks poll SUCCESS AND all ACKed?} OK -- yes --> SU[wait = SUCCESS] F --> W[wait = FAILURE → caller task.fail]Bootstrap: the bounce handshake blob is serialized with the
AgentDesc(getLocalAgentDesc/loadRemoteAgent(AgentDesc)→registerPeerHandshake), i.e. the path production disagg already uses;the first WANT starts directly with no separate handshake round-trip.
10. Configuration (env)
All prefixed
TRTLLM_NIXL_BOUNCE_. Byte-valued variables accept case-insensitive binary suffixes(
K/KB/KiB,M/MB/MiB,G/GB/GiB; all powers of two), e.g.ARENA_SIZE_BYTES=512MB. Unparsablevalues fall back to the default (never silently become 0).
enabledENABLEarenaSizeBytesARENA_SIZE_BYTESarenaAllocationGranularityBytesARENA_ALLOCATION_GRANULARITY_BYTESmaxChunkSizeBytesMAX_CHUNK_SIZE_BYTESmaxInflightChunksPerRequestMAX_INFLIGHT_CHUNKS_PER_REQUESTcopyStreamCountCOPY_STREAM_COUNTscatterWorkerCountSCATTER_WORKER_COUNTminDescriptorCountMIN_DESCRIPTOR_COUNTmaxAverageDescriptorSizeBytesMAX_AVERAGE_DESCRIPTOR_SIZE_BYTESrequestTimeoutMsREQUEST_TIMEOUT_MSdisableFabricMemoryDISABLE_FABRIC_MEMORYcudaMallocinstead of MNNVL fabric memory (CI/x86).enableEagerGatherENABLE_EAGER_GATHERsubmit()before GRANT (overlap control RTT); eager staging capped at ½ arena.useNixlNotificationsUSE_NIXL_NOTIFICATIONSuseZeroCopyArgumentsUSE_ZERO_COPY_ARGUMENTSuseCubCopyUSE_CUB_COPYcub::DeviceMemcpy::Batchedinstead of the custom copy kernel (experimental).disableScatterRunMergingDISABLE_SCATTER_RUN_MERGINGshouldUseBouncefires when: op isWRITE, src/dst are bothVRAM, no syncMessage, the peer passedthe capability handshake,
descCount ≥ minDescriptorCount, all srcs are on this agent's device andall dsts on one device, and average desc bytes
≤ maxAverageDescriptorSizeBytes; otherwise thestandard NIXL path is used.
11. Test Coverage
Tests live under
cpp/tests/unit_tests/executor/bounce/.buddyAllocatorTest(split/coalesce/fragmentation/boundaries/overflow),creditSchedulerTest(in-flight cap/fairness/drain-mode anti-starvation/reclaim/conservation/reclaim-defer/orphan/eager budget),
bounceMessageCodecTest(round-trip/truncation/magic/cross-type-reject/large-count/handshake codec),
bounceTransferPlanTest(bin-pack boundaries +scatter-run merging),
bounceConfigTest(env parsing, byte suffixes, garbage fallback).bounceArenaTest,execPoolTest,gatherScatterKernelTest(custom kernel /zero-copy args / cub backend).
zmqControlChannelTest(incl. endpoint validation),bounceTransportTest(end-to-end, byte-exact; handshake compatibility; malformed-WANT rejection),bounceTransportFailureTest(no-GRANT timeout / engine failure / shutdown in-flight / forgetPeerin-flight / multi-peer shared-arena over-subscription no-deadlock / multi-threaded submit).
nixlTransferEngineTest;bounceNixlE2ETest(RealRdmaLoopbacksingletransfer /
ConcurrentBidirectionalRealRdma8-thread bidirectional /MultiAgentManySendersToOneReceiver/ForgetPeerInFlightRecovers);bounceAgentE2ETest(production
submitTransferRequestspath: single transfer +ConcurrentSubmitUsesBounce).All e2e tests verify byte-exactly (seed-distinct pattern per transfer, ruling out cross-talk).
test_cache_transceiver_single_process.pydrives the NIXL bounce paththrough the Python cache transceiver (added to
l0_h100.yml).12. perf compare
https://docs.google.com/document/d/1J8ROqb1D-TQryIEyqLoYtP4Z3Hqk_7AreluczjdtH5w/edit?usp=sharing
gptoss gb200_gpt-oss-120b-fp4_8k1k_con128_ctx1_tp1_gen1_tp4_eplb0_mtp0_ccb-NIXL
con128
con1024
PR Checklist
Please review the following before submitting your PR:
PR description clearly explains what and why. If using CodeRabbit's summary, please make sure it makes sense.
PR Follows TRT-LLM CODING GUIDELINES to the best of your knowledge.
Test cases are provided for new code paths (see test instructions)
If PR introduces API changes, an appropriate PR label is added - either
api-compatibleorapi-breaking. Forapi-breaking, includeBREAKINGin the PR title.Any new dependencies have been scanned for license and vulnerabilities
CODEOWNERS updated if ownership changes
Documentation updated as needed
Update tava architecture diagram if there is a significant design change in PR.
The reviewers assigned automatically/manually are appropriate for the PR.
Please check this after reviewing the above items as appropriate for this PR.
GitHub Bot Help
To see a list of available CI bot commands, please comment
/bot help.