RDMA design
This chapter records why the RDMA stack has its current shape. It covers the decision for UCX, the rejected alternatives, the invariants that the code depends on, and the faults found on the way. For how the path works, see Rendezvous and RDMA. For numbers, see RDMA performance.
The decision: vendored UCX, not raw ibverbs
Velo reaches RDMA through UCX, built from a vendored release tarball and linked statically by the ucx-rs crate. A pure-ibverbs messaging transport was designed in full and kept as the fallback (see The raw ibverbs design).
Round one of the research favored raw ibverbs. Two facts changed the result:
- The requirement grew. Rendezvous needed an RDMA GET path. The messenger stages every payload above 256 KiB through rendezvous, so one RMA substrate serves both subsystems.
- The UCX unknowns resolved in its favor. The vendored static build worked as a prototype. Two UCX copies in one process measured safe. The wrapper that velo owns priced out smaller than the like-for-like ibverbs scope.
| Axis | Raw ibverbs | Vendored UCX |
|---|---|---|
| New code, with tests | about 6,000 to 8,500 lines (messaging 4,000 to 6,000, plus RMA 1,800 to 2,700) | about 4,900 lines (plus or minus 30%) |
| What velo owns | The wire protocol: credits, SRQ sizing, RNR and timeout tuning, the QP state machine, a bootstrap listener, multi-NIC, GID, and pkey selection, restart races | A safe FFI wrapper: callback soundness, thread affinity, the vendored C build, protocol observability |
| CI with no RDMA hardware | SoftRoCE (rxe) on self-hosted runners, fidelity unproven | UCX_TLS=tcp on stock runners, the same code path, shown to work |
| Connection setup | TCP bootstrap listener plus a per-connection QP exchange | ucp_ep_create from the peer's address blob. No bootstrap subsystem. |
| Performance ceiling | Full control, plain verbs only | rc_mlx5 accelerated transports with no extra work, no WQE-level control |
| Flow control | Velo-owned credits, observable | UCX-internal, under velo's gate and the mux: three layers, opaque |
The UCX path costs less because the expensive half (worker, op state, admission, health, rendezvous protocol) is common to both paths. What UCX deletes is the wire-protocol half that ibverbs keeps.
The probe that decided it
UCX can drop RMA lanes under UCP_ERR_HANDLING_MODE_PEER, the mode that gives velo peer-death callbacks. Locally, PEER mode made self, posix, and sysv endpoints fail and silently dropped the rma_bw lane on cma. If RC lost rma_bw too, velo had to choose between peer-death detection and zero-copy RMA.
On a live ConnectX-7 InfiniBand host, with UCX 1.22.0 and 1.19.0, RC kept rma_bw in PEER mode. The mechanism is in UCX's lane selection. Under PEER mode with rendezvous, it adds UCT_MD_FLAG_INVALIDATE_RMA as a requirement. It then removes that requirement again for connect-to-endpoint lanes, because both sides close the connection on error. Every RC transport connects to an endpoint. The lane survived on rc_mlx5 with DEVX on, with UCX_IB_MLX5_DEVX=n, and on rc_verbs. A 1 MiB GET completed in 826 µs while the owner never ran its progress loop.
The invalidation requirement still applies to connect-to-interface transports. Shared-memory RMA inside one host under PEER mode therefore falls back to software emulation. For the same reason, the in-process UCX tests run over tcp: in PEER mode, the shared-memory lanes are not eligible.
Two more probe facts shaped the wrapper:
UCX_TLS=rc_mlx5alone cannot wire up (no auxiliary transport ... Destination is unreachable). Audtransport must be in the list. In PEER mode, theudlane does keepalive and wireup only.- Which exit
ucp_am_send_nbxtakes (see Completion-owned operations) changes between UCX versions. UCX 1.19 completes inline at sizes where 1.22 returns a request.
Rejected alternatives
Each entry below was tried or studied and failed. They are here so that nobody tries them again without new evidence.
NIXL for messaging
genNotifgives no ordering guarantee. That alone breaks velo's per-target ordered admission contract.- The Active Message header is removed (
hdr = nullptr, and the receiver assertsheader_length == 0). - Sends are pinned eager. There is no rendezvous.
- A notification payload is copied about five times. The C shim passes clean spans, but the C++ API takes
const std::string&andconst std::vector<int>&, which force the copies. Only an upstream C++ change removes them. - Delivery is poll-only, under an exclusive lock, with no file descriptor or callback. The default Rust
Agent::new()spins a progress thread atpoll(fds, n, 0), one core at 100%.
Notifications arrived byte-exact up to 8 MiB, so NIXL works. It has the wrong shape for messaging. A claim that NIXL "validates every address" and needs an unsafe bypass was false. nixlMemSection::populate() resolves addresses to rkeys, which is necessary work. prepXferDlist() amortizes it once per buffer set. One idea was kept: loading a peer's opaque metadata blob creates endpoints eagerly, which matches address() and register(PeerInfo).
A NIXL-based rendezvous prototype
An earlier rendezvous prototype on NIXL had these faults. The current design avoids each one:
- A 64 MiB arena that returned a hard error when full. Now, pool exhaustion falls back to chunked.
- Three slot fields (a mode flag, an optional descriptor, an optional buffer) for one fact. Every combination was representable, and one was meaningful. Now,
SlotBodyis an enum andStageModederives from it. - Pinned slots refused non-RDMA readers. Now, every pinned slot serves the chunked path.
velo_nixl::MemTypeon the wire. Now, no backend type crosses the wire. The descriptor carries a backend discriminator and opaque key bytes.- No lease deadline, so a consumer crash leaked the read lock and the pinned memory.
The two ideas kept from it: the owner decides the path at acquire time, and the two-process launcher became the rendezvous_rdma_two_proc example.
async-ucx
The published crate is unmaintained at 0.1.1. Its Drop calls ucp_request_free without ucp_request_cancel, a cancellation soundness hole that was never fixed. Its workers and endpoints are Rc-based and !Send. Velo's wrapper closes the hole by structure (see Completion-owned operations). From its unmerged modernization branch, three pieces were worth mining: the owned-Bytes worker address, the zeroed request-parameter builder, and the bindgen allowlist.
UCX's own rendezvous for large Active Messages
Letting UCX move large Active Messages with its internal rendezvous speeds up push messaging. It does not help pull-by-handle, it has no story for external memory registration, and it gives descriptor ownership back to user code across shutdown. Velo sets UCP_AM_SEND_FLAG_EAGER on every send, caps Active Messages at eager_max (1 MiB), and refuses a rendezvous-mode receive. Large payloads go through velo's rendezvous GET.
UCP_AM_FLAG_PERSISTENT_DATA does not prevent rendezvous receives, although ucp.h says so. ucp_am_rndv_process_rts sets FLAG_RNDV without a condition. An 8 MiB message arrived in rendezvous mode with PERSISTENT_DATA set, and in eager mode with the eager flag set.
UCX API traps
ucp_memh_packwithoutUCP_MEMH_PACK_FLAG_EXPORTcallsucs_fataland aborts the process. The working packer isucp_rkey_pack, marked deprecated, inucp_compat.h.ucx-rsbinds it andrkey_pack_canaryasserts it packs.ucp_initis a static inline inucp.hand does not exist in the archives. Callucp_init_version.ucp_get_nbxanducp_put_nbxneed protocol v2.UCX_PROTO_ENABLE=n, a common workaround for protocol-v2 faults, silently disables all RMA.- A multi-rail GET needs
UCX_MAX_RMA_RAILSof 2 or more. The default is 1. - Requesting
UCP_FEATURE_RMAatucp_initchanges Active Message lane selection. Messaging behavior can move when RMA is compiled in. - A zero-padded copy of a packed rkey does not contain
ucp_ep_rkey_unpack. Its second parse stage setsbuffer_end = UINTPTR_MAXand stops only on a0xFFbyte, so zero padding feeds the walk. Containment must come from a pre-parse of the blob itself.
Other rejected choices
rdma_cmfor connection setup (ibverbs design). It needs IPoIB or ibacm for address resolution on native InfiniBand, caps private data at 56 B, and brings its own event state machine. A 50-line exchange over the TCP infrastructure that velo already has is simpler.- Inactivity timers on memory registrations. A timer that evicts a registration while a peer holds its descriptor creates a stale key. UCX rcache, libfabric, MPICH, and NCCL all evict by byte or count ceiling. Timers apply only to empty arenas and to endpoints.
mokafor a future registration cache. Since 0.12 it has no background threads, so time-to-idle eviction and eviction listeners fire only when something touches the cache. An idle process never evicts.quick_cachewith a byte weigher and a pinned-while-in-flight lifecycle fits.- Per-slot registration by default. Registering arbitrary caller
Bytesin place is unsound as a default. The provenance is unknown, UCX pins outward past what the caller owns, andfrom_staticand sub-slices exist. --disable-loggingin the UCX build. It does not remove logging, it only lowers the maximum level.UCX_LOG_LEVEL=debugis the only field diagnostic for a half-initialized link.- A consumer-side unpacked-rkey cache, as an optimization. See A stale rkey aborts the process on InfiniBand.
The UCX transport
flowchart LR
S1[send_message] --> G1[per-peer AdmissionGate]
S2[send_message] --> G2[per-peer AdmissionGate]
R[RdmaEndpoint map / unmap / get] --> Ring
G1 --> Ring[(bounded flume ring)]
G2 --> Ring
Ring --> PT[UCX progress thread<br/>one ucp_worker, THREAD_MODE_SINGLE]
PT -->|oneshot / flume| T[tokio tasks]
PT <-->|ucp_am_send_nbx / ucp_get_nbx| NIC[(NIC)]
- No listener. The
WorkerAddressblob carries the packeducp_workeraddress (about 200 to 250 bytes, 239 B measured) and the negotiation fields.register()stores it.ucp_ep_createruns at first use, or atregister()witheager_endpoints. A restarted peer has a new address blob, so most restart races do not exist. - One progress thread owns the
ucp_contextand one worker inUCS_THREAD_MODE_SINGLE. UCX is built with--enable-mtandmt_workers_shared=1. In that build a SINGLE worker takes no lock: the lock macro reduces to a debug-only owner assert. Raw handles never leave the thread's main function. Stock UCX has--enable-mtoff, and its MULTI mode is one recursive lock around the whole worker. - Per-peer admission gates feed one bounded ring. The single consumer cannot reorder what the gates admitted, so per-target ordering holds. RMA commands bypass the gates, because gate semantics (eager caps, drain rejection,
SendOutcome) apply to Active Message frames. The bounded ring is the RMA backpressure. - Spin, then park. After the last activity, the loop spins for
spin_us(20 µs) and then arms the wakeup fd and parks inpoll(2). Submitters ring the doorbell only when the loop is parked. A ring push costs about 100 ns anducp_worker_signalcosts about 1 to 3 µs. The park has a bounded timeout as a backstop for a lost wakeup. - The maximum Active Message header measured 8101 B. The
Transporttrait cannot express this second cap, so the transport rejects an oversized header before the wire.
Completion-owned operations
Every posted operation owns its buffers until UCX completes it. One Arc<OpState> rides ucp_request_param_t.user_data. ucp_am_send_nbx has three exits, and exactly one of them drops the Arc:
NULL: completed inline. UCX ignores the callback even if one is set, so the poster drops theArc.- A request pointer: the callback fires once, drops the
Arc, and frees the request. - An error pointer: no callback. The poster drops the
Arcand reports.
Which exit a send takes is not monotonic in size. Over TCP: 64 B returned a request, 64 KiB and 1 MiB returned NULL, and 8 MiB returned a request. It also changes between UCX versions. All three exits are always live code. A dropped future only abandons the notification, and on_error still fires. Every extern "C" trampoline wraps its body in catch_unwind.
GETs post with UCP_OP_ATTR_FLAG_NO_IMM_CMPL, which removes the inline exit and leaves one completion path.
RMA invariants
GET, not PUT
ucp_get_nbx completion is final at both ends: the data has landed, and no flush is necessary. ucp_put_nbx completion frees only the local buffer. To tell the peer, the sender must first complete a ucp_ep_flush_nbx. Active Messages and RMA use different lanes with no ordering between them. Over UCX_TLS=tcp, a PUT flush also needs the peer to run its progress loop, which deadlocks shutdown paths. A put that is visible at completion over CMA is an accident of that transport, not a contract.
One-sidedness depends on the lane
With a native RMA lane, a GET completes with the owner idle (100 MiB in 19.9 ms over CMA, owner never progressed). Without one, UCX silently substitutes rma_am software emulation over the Active Message lane. The GET, the flush, and PUT visibility then hang forever on an owner that does not progress (measured: 1 s, 5.9 million polls, no completion). CI does not see this, because the owner that answered _rv_acquire is progressing. The owner's progress loop therefore stays live for the whole lease, and no separate unprogressed RMA worker exists.
Single-use rkeys
The packed rkey travels in the descriptor as plain bytes for each transfer. The consumer unpacks it on the progress thread just before the GET and destroys it in the completion callback. ucp_rkey_h never leaves the progress thread. No rkey cache and no invalidation protocol exist.
Destroying the rkey in the callback is safe, from the UCX 1.22 source:
ucp_rkey_destroytouches no endpoint. It releases each transport key through its component, a context-level object, and returns the descriptor to the worker's memory pool. What must outlive the rkey is the worker.- On the close path the endpoint is still alive. A FORCE close takes a discard reference on the endpoint, and the endpoint is freed only after the purge that drives the callbacks with
UCS_ERR_CANCELED. - Inside
ucp_worker_destroy, the purges run before the memory pools are destroyed.
Progress thread rules
- No completion callback enqueues onto the ring. The progress thread is the only consumer of a bounded ring. A callback that blocks on a full ring deadlocks the process. RMA completions resolve a
oneshotand hand follow-up work to the main loop through a side queue. - A parked unmap gates its region. An unmap of a region with GETs in flight parks its reply. From then on, new GETs on that region are refused. A repeat unmap attaches as another waiter. A caller that cancels and retries therefore never hears that a live, DMA-active region does not exist.
- Every RMA reply resolves, including at teardown.
ucp_worker_destroydoes not run user callbacks for operations still outstanding when the bounded drain expires. The worker keeps anArcof every posted operation, and teardown answers each survivor withShuttingDown. The reply sits in a take-once slot, so a late callback cannot answer twice. - Offsets are relative to the mapped pointer.
ucp_mem_maprounds the pinned range out to page boundaries. A GET destination offset is measured from the caller's pointer and checked against the caller's length. A caller cannot name a byte inside the registration but outside its allocation. - A cancelled map rolls back. The region id is minted before the command is pushed. A dropped
map_regionfuture pushes an unmap for that id, and the progress thread also rolls back when it finds the reply channel closed. - Endpoints close only after the ring is observed empty. Reply commands carry raw endpoint pointers. The parked-endpoint close, the failed-endpoint reaper, the revalidation pass, and the idle reaper all run after the post-progress drain sees an empty ring. An earlier order let a FORCE close free an endpoint that a queued reply still named.
What teardown cannot promise
Regions unmap before endpoints close for every region idle at teardown, and for every region whose GETs finish during the flush close. A GET posted to a peer that stopped progressing is the exception. The flush close never completes, and the later FORCE close is a no-op: ucp_ep_close_nbx returns UCS_ERR_NOT_CONNECTED because the flush close already set the closed flag. That GET completes only inside ucp_worker_destroy, after the forced unmap. Over TCP this is silent. On InfiniBand the straggler completes with an access error. The caller still gets an answer. The abandon_rma_ops path that covers this has no test.
A stale rkey aborts the process on InfiniBand
Over UCX_TLS=tcp, a well-formed but unusable rkey fails inside ucp_ep_rkey_unpack and returns an ordinary error. On InfiniBand, the unpack succeeds against a real local memory domain, the GET posts, and uct_rc_verbs turns the HCA completion error into ucs_fatal:
rc_verbs_impl.h:104 Fatal: receive completion[0] with error on mlx5_2/...: general error, vendor_err 0x0
(signal: 6, SIGABRT: process abort signal)
The expected cost of a stale rkey on RC was a dead queue pair and every transfer on it. The measured cost is a dead process, with no Rust error to fall back from. A TCP-only CI can never see this class.
Two things keep it out of reach, and the syntactic rkey pre-parse is not one of them:
- Single-use rkeys: an rkey cannot outlive its operation.
- Revalidation at every acquire: the owner checks the registration for each transfer.
The pre-parse checks framing. A stale rkey with perfect framing passes it. Consequences:
- The shutdown straggler, the reaper force-release, and shutdown demotion all release memory under a peer that can still hold a descriptor. On InfiniBand that peer's GET can abort the peer's process, not only fail.
- A consumer-side rkey cache removes both safety legs at once. It makes an rkey outlive its operation, and it skips revalidation. Arena reclamation is safe only because an empty arena has no live descriptor, and a cached rkey breaks that too. An rkey cache is a safety change, not a profile-gated optimization.
- The
mlx5completion path differs fromrc_verbs. This finding needs a rerun on the accelerated lane.
The mlx5 link-order defect
Symptom
On ConnectX-7, UCX_TLS=rc_mlx5,ud_mlx5 made UCX print:
UCX WARN transports 'rc_mlx5','ud_mlx5' are not available, please use one or more of:
ib, mm, posix, rc, rc_v, rc_verbs, self, shm, sm, sysv, tcp, ud, ud_v, ud_verbs
UCX then started with self only. It did not reach the peer, and every transfer fell back to chunked. The path_total metric showed it. Without that metric, the deployment looks like RDMA.
Evidence that ruled out the hardware
configurerecordedHAVE_MLX5_DV 1,HAVE_DEVX 1,HAVE_MLX5_HW_UD 1, andHAVE_INFINIBAND_MLX5DV_H 1.libuct_ib_mlx5.awas built and linked.uct_rc_mlx5_iface_tl_ops,uct_ud_mlx5_iface_tl_ops,uct_dc_mlx5_iface_tl_ops, anduct_mlx5_initwere in the binary.- The system UCX 1.18 on the same node and NIC reported
rc_mlx5,ud_mlx5, anddc_mlx5.
Root cause
uct_ib_init and uct_mlx5_init are ELF constructors. Both add their memory domains to the head of uct_ib_ops with ucs_list_add_head, so whichever runs later owns the head. uct_ib_component_md_open takes the first entry that opens, and uct_ib_verbs_md_open opens for any device unless DEVX is forced. It also continues past an entry only on UCS_ERR_UNSUPPORTED, so a verbs I/O error stops the open instead of falling through to mlx5.
For static= archives, constructor order is .init_array order, and that is the order in which build.rs emits cargo:rustc-link-lib=static=.... The build emitted uct_ib_mlx5 first. So uct_mlx5_init ran first, uct_ib_init ran second and put verbs at the head, and every mlx5 NIC opened a plain verbs domain. rc_mlx5, dc_mlx5, and ud_mlx5 found zero devices.
A shared UCX cannot hit this. uct_ib_init loads the mlx5 module with UCS_MODULE_FRAMEWORK_LOAD after verbs has registered.
The fix
build.rs emits static=uct_ib before static=uct_ib_mlx5. With four mlx5 devices at UCX_LOG_LEVEL=debug, md open by 'uct_ib_verbs_md_ops' became md open by 'uct_ib_mlx5_devx_md_ops'. This held under GNU ld 2.42 and under mold, the CI linker. The fix was measured on aarch64 with every port down. It proves which domain opens. It does not prove that rc_mlx5 carries traffic, and x86_64 is not measured.
crates/ucx-rs/tests/ctor_order.rs walks uct_ib_ops directly and fails if the verbs domain is at the head. It needs no binutils and no ELF parsing.
On a binary built before the fix, UCX_IB_MLX5_DEVX=y is a runtime workaround. It makes the verbs open refuse, so the DEVX domain opens.
What does not work
- Repeating the archive (
uct_ib, uct_ib_mlx5, uct_ib).static=bundles archive members into the rlib, and the final link carries no-lfor them. A repeated archive collapses to its last position and reproduces the fault exactly. This advice is correct for a C linker and wrong for rustc. +whole-archiveor+verbatim. They change inclusion and name resolution, not member position.- "Static archives are never loaded by the module loader." Disproven. The archive was in the binary and the transports registered. The fault was which domain won the open.
- "Version skew between the system UCX 1.18 and the vendored 1.22." Disproven. Swapping the archive order on the same 1.22 archives fixes it.
Building UCX from source inside cargo
The first recipe for the vendored build was wrong in four ways. Each was measured:
--with-picis required. Static-only libtool emits non-PIC objects. Linking them into any shared object fails on aarch64 withrelocation R_AARCH64_ADR_PREL_PG_HI21 ... recompile with -fPIC. Python extension modules are shared objects. A Rust cdylib link against non-PIC archives sometimes succeeded and produced a broken library.- The constructor set must be complete. In a static link, the linker pulls an archive member only if a symbol references it. Without
ucs_init, UCX links, loads, and failsucp_initwith a bareUCS_ERR_INVALID_PARAMand no log output, because logging registers from that constructor. The full set isucs_init,uct_init,ucp_global_init,uct_ib_init,uct_mlx5_init,uct_rdmacm_init, and optionallyuct_cma_init. It mirrors theLibs.privatemarkers in UCX's own pkg-config files. cargo:rustc-link-argdoes not cross a crate boundary. A downstream cdylib linked with no-lucpand failed withundefined symbol: ucs_status_string.-Wl,--undefined=cannot live in a build script.ucx-rsreferences the constructors from a#[used]static inlib.rsinstead, and emits archives withcargo:rustc-link-lib=static=. A crate that never namesucx_rsdrops it and every native library from the link, souse ucx_rs as _;is required in that case.- rustc's
-nodefaultlibsand one-pass archive resolution leave two symbols unresolved. On aarch64,__aarch64_ldclr4_synclives only in the staticlibgcc.a. On all targets,pthread_atforklives inlibc_nonshared.a.build.rsappendsstatic=gcc(aarch64) anddylib=cafter the UCX archives.
Other build facts:
- The top-level
makefails under--disable-shared, because it links the tools against shared libraries that do not exist.build.rsbuildssrc/ucm,src/ucs,src/uct, andsrc/ucpone at a time. - The tarball SHA-256 is pinned and enforced before anything runs from it.
links = "ucx-rs", becauselamellar-ucx-sysalready ownslinks = "ucx"on crates.io. The nameucx-sysbelongs to an unmaintained crate.ucx-rsis a publishable leaf crate outside velo's type graph. It exports no types thatveloorvelo-extshare, so it cannot cause the duplicate-type semver fault that the two-crate rule prevents. See Versioning.- With
UCX_DIR, the constructor references are compiled out. The transport modules of a shared UCX are plugins, and their init symbols are not in the core libraries. - Bindings are checked in.
--all-featuresturns on thebindgenfeature, but regeneration also needsUCX_RS_REGEN_BINDINGS=1, so CI never rewrites tracked files.
Symbols and a second UCX in one process
NIXL loads its own libucp in Dynamo, so two UCX copies in one process was the top operational risk. It measured safe:
- A Rust cdylib that links static UCX exports zero UCX symbols with no extra flags. rustc emits a version script that makes all UCX symbols (about 3,700) local, and the constructors still run. A second version script from the consumer is a link error:
anonymous version tag cannot be combined with other version tags. - Eight of eight coexistence cells passed: a static 1.22 cdylib and a
dlopened system 1.20, both load orders,RTLD_LOCALandRTLD_GLOBAL, UCM hooks on and off. Bothucp_initcalls succeeded, with no cross-binding and no crash. - With
UCX_MEM_EVENTS=nandUCX_RCACHE_ENABLE=n, UCX patches zero libc functions. This was verified by comparing libc entry-point bytes, not by reading logs. Velo sets both by default. - The claim "hooks off means a clean
dlcloseunload" did not hold. UCM pins itself withRTLD_NODELETEin a--with-picbuild. Velo never unloads UCX, so this does not matter to velo.
The raw ibverbs design
This design is the fallback if UCX hits a wall that velo cannot work around. It is also the reference for what UCX must beat. Only its core is recorded here.
Shape. An in-tree ibverbs transport on the jonhoo/rust-ibverbs binding (MIT or Apache-2.0, maintained, with extended-verbs batching and completion-channel file descriptors). RC queue pairs, one shared receive queue (SRQ) per device context, and per-peer credits. Eager messages only. No velo-ext change.
Connection setup. The WorkerAddress blob carries a small TCP bootstrap endpoint, device hints, and an incarnation number. register() stores the peer and connects nothing. The first send dials the bootstrap listener and exchanges {qpn, psn, gid, gid_index, mtu, credit window, eager ceiling, incarnation} once in each direction. A static address blob cannot carry RC state, because the QPN exists only after ibv_create_qp for each connection. NCCL and UCX also publish a listener and exchange this data per connection.
Wire. One RC SEND per message. The scatter list is an 8-byte fixed header (version, message type, flags, credits granted, header length), then the user header, then the payload. Messages up to about 828 B post inline, so the buffer is free on return. Medium messages copy into a registered slab. RC carries up to 1 GiB in one message on CX-7, so no fragmentation exists.
Flow control. A credit is one SRQ slab that the peer can consume. The window is 64 by default. Credits return in the header byte, or in a pure-grant message when the receiver is idle. With credits, RNR cannot occur in steady state.
Completions. One completion queue per worker, its completion channel in tokio::io::unix::AsyncFd. Arm first with ibv_req_notify_cq, then drain, then wait. This order closes the arm race.
RMA additions. A second RC queue pair per peer for bulk reads. RC processes work requests in order, so bulk reads on a shared queue pair block Active Messages. Also, one remote access error takes the whole queue pair to ERROR. The bootstrap struct must reserve the second QPN from the start, because a later addition breaks the wire format. max_rd_atomic must be set: the binding defaults it to 1, which serializes reads, and CX-7 allows 16. Estimated 1,800 to 2,700 extra lines.
Seven constraints from the research
- Never poll payload memory for arrival. Last-byte polling breaks InfiniBand spec rule o9-20, relaxed ordering, adaptive routing, and out-of-order placement. Arrival signals are SEND and RECV completions, or
RDMA_WRITE_WITH_IMM. - RNR is not backpressure.
min_rnr_timer = 0encodes 655.36 ms, andrnr_retry = 7retries forever. A stalled receiver wedges the sender with no error. Credits make RNR rare.min_rnr_timerof 1 to 6 (10 to 80 µs) makes the rare race cheap. - Verbs completions are not a liveness signal. CX-5 and later firmware enforce a minimum ack timeout of 16 (about 268 ms per try). With
retry_cnt = 7, a dead peer shows after 3 to 4 s. Health checks must be application-level. - A static address blob cannot carry RC connection state (see above).
- SRQ size is fixed at creation on CX-7.
SRQ_RESIZEis absent.max_srq_wris 32,767. The limit event works. - The inline limit on CX-7 is 64·k − 4 bytes: at most 828 B for RC and 956 B for UD. Requests of 912 B or more fail with
EINVAL. Inline cut latency 35% at 64 B (2.95 to 1.92 µs) and did nothing at 512 B. - Platform tuning is part of the transport. On aarch64 with default idle states, event-mode p50 at low rates rose from about 10 µs to 88 to 97 µs. A completion IRQ on a busy core put p99.9 at 726 to 1082 µs, against 11 to 15 µs with the IRQ isolated.
Measurements behind it
Measured on 2026-08-20. "Spark" is an aarch64 DGX-Spark-class host with four ConnectX-7 RoCE ports, all link-down, rdma-core 50, Linux 6.14: verbs objects are real, but no wire traffic occurred. "B200" is an x86_64 DGX B200 node, ConnectX-7 InfiniBand, MLNX OFED 25.07, over a two-HCA path inside the node (mlx5_0:1 to mlx5_2:1, MTU 512). The B200 path hit an unexplained ceiling near 11.6 Gb/s, so rows of 8 KiB and more are lower bounds.
Completion delivery on Spark, 100,000 messages per second, idle states limited and the IRQ isolated:
| Strategy | p50 | p99 | Cores |
|---|---|---|---|
| Busy poll | 2.06 µs | 2.64 µs | 1.000 |
Raw epoll | 8.02 µs | 8.96 µs | 0.409 |
tokio AsyncFd | 8.16 µs | 9.10 µs | 0.463 |
| Spin 5 µs, then park | 7.07 µs | 9.07 µs | 0.672 |
tokio adds 0.14 µs over raw epoll. The interrupt path is the cost. A shared busy-poll core delivers about 5.4 µs to workers, not 2 µs, because the dispatch hop adds 3.34 µs.
ib_send_lat, RC one-way, B200, depth 1:
| Size | No inline | Inline (828 B cap) |
|---|---|---|
| 64 B | 2.95 µs | 1.92 µs |
| 512 B | 3.61 µs | 3.62 µs |
| 1 KiB | 3.95 µs | not applicable |
| 8 KiB | 9.99 µs | not applicable |
| 64 KiB | 52.5 µs | not applicable |
| 1 MiB | 785 µs | not applicable |
Registration against memcpy, B200, median of 101, memory touched first:
| Size | Register, 4 KiB pages | Register, 2 MiB THP | Deregister, THP | memcpy |
|---|---|---|---|---|
| 4 KiB | 13.7 µs | 13.7 µs | 12.4 µs | 0.05 µs |
| 256 KiB | 18.7 µs | 14.5 µs | 12.4 µs | 4.6 µs |
| 1 MiB | 36.3 µs | 18.7 µs | 12.5 µs | 27.5 to 35.8 µs |
| 8 MiB | 250.9 µs | 36.5 µs | 12.9 µs | 486 to 494 µs |
Copying into a pre-registered pool beats register-then-transfer below about 0.5 MiB for a cold source and about 2 MiB for a cache-resident source. The memcpy cache cliff (76 to 17 GB/s) sets that crossover, not the registration cost. With 4 KiB pages, deregistration grows to 561 µs at 64 MiB, so pools use huge pages. UCX's cost model (16 µs plus 0.06 ns per byte) is about 2x pessimistic on this hardware.
Other measured facts: one queue pair costs 8.2 KB of host memory at depth 4 or less, and 16.2 KB at depth 16 (12.2 KB with an SRQ). Implicit on-demand paging (one key for the whole address space) works on CX-7 with an 11 ms one-time cost. It is a capability probe only, never the default.
Operational traps found during the probes: perftest -I prints the requested inline size, not the granted one. A single-host UCX benchmark silently selects shared memory and reports impossible numbers. A partial pkey membership breaks UCX while raw verbs works.
TCP faults found on the way
The baseline work for this design measured velo's existing transports and found two TCP faults. Both are fixed. A harness, examples/examples/tx_budget.rs, adds one layer per rung and attributes the cost of each. It found the faults and now guards against them. It asserts nothing, so a regression shows only when someone runs it.
Socket buffers sized after data was in flight
Symptom. At 16 KiB and more with 64 messages in flight, TCP throughput collapsed by 100 to 350 times. At 256 KiB x 64, TCP moved 22.9 MB/s against 2,648 MB/s for the same code over UDS. The p50 round trip was 1.45 s.
Mechanism. Velo's messenger connections are one-directional. The dialing peer writes as soon as connect() returns. The accepting side set SO_RCVBUF and SO_SNDBUF one task spawn later, after the first burst was in flight. On Linux, setting SO_RCVBUF then locks the buffer, turns off receive autotuning, and clamps it to net.core.rmem_max. With the common rmem_max of 212,992, the 2 MiB request becomes about 416 KiB. By then the window has already grown past that size, and the kernel collapses the advertised window for the life of the connection. ss showed snd_wnd:32640 with rwnd_limited:100.0%. The fault was per connection and racy, so results were bimodal across runs.
Correction to the first analysis. The first analysis applied the same options to an idle socket, saw an 18% cost, and called the buffer hypothesis refuted. That test removed the race, and the race is the mechanism.
Fix. The listening socket sets the buffer sizes, and accepted sockets inherit them at handshake time. The dialing side still sets its own before the first write, which cannot race. The streaming TCP transport had the same fault and the same fix. On aarch64 loopback, 256 KiB x 64 went from 8.8 to 27.9 MB/s (8 of 8 runs collapsed) to 2,470 to 4,173 MB/s (8 of 8 healthy). For request and reply, inherited sizes are also faster than no sizes at all, because autotuning starts cold. For one-way bulk traffic across nodes, autotuning is faster, and the TCP messenger transport has an option for it. See Transports.
Three writes per large frame
Above the 64 KiB coalesce threshold, the direct write path called write_all three times per frame (an 11 B preamble, the header, the payload) on a TCP_NODELAY socket. This added 10.2 µs one-way at 256 KiB. The preamble and header now go into a 256 B stack buffer and leave in one write, so a frame takes two writes. writev was rejected, because TCP write_vectored() can short-write past about 128 KiB. The decoder also reserves the whole announced frame after it parses the preamble. Before, Framed doubled its buffer from 8 KiB and copied everything received at each step. With both changes, the codec rung at 8 MiB went from 3.73 ms to 2.94 ms, and strace -c over 1,100 frames showed sendto fall from 6,644 to 4,476.
Where the per-message time goes
On aarch64 loopback, 64 B header plus 1 KB payload, depth 1, a four-worker tokio runtime on performance cores:
| Rung | Layers | One-way p50 |
|---|---|---|
| L0 | Blocking socket calls, OS threads | 5,996 ns |
| L1 | Plus tokio | 5,652 ns |
| L2 | Plus TcpFrameCodec | 5,668 ns |
| L3 | Full Transport and DataStreams | 11,368 to 11,599 ns |
The codec adds 16 ns, and AdmissionGate::send adds 16 ns over a raw flume::try_send. Of the 5,700 ns the full transport adds, 84% is two cross-thread wakes of a parked task (2,393 ns each, against 216 ns on a pinned current_thread runtime). About 3.7 µs of that is removable with no RDMA, by removing cross-core wakes. UDS at L3 is 5,235 ns. So inside one node, an RDMA messaging transport gains little over UDS. Between nodes, it gains 4 to 11 times: about 2 µs of wire plus 2.9 µs of software, against 15 to 50 µs of TCP wire plus 5.7 µs.
Open questions
- Three layers of flow control. The mux budgets, the admission gate, and UCX's internal window and arbiter all sit on one path. UCX's layer is not observable. Nobody has analyzed whether they deadlock or double-buffer. This is the largest open design question on the chosen path.
- Fabric prerequisites. RoCEv2 without PFC or ECN degrades under congestion through go-back-N. Kubernetes memlock budgets, device plugins, and the GID index inside a network namespace are not researched.
- Zero-copy receive. It depends on an audit that no downstream path turns inbound
BytesintoBytesMut, which copies owner-backed bytes. - libfabric, UCCL, and Mooncake were not scored.
- The accelerated lane. The threshold, the packed rkey size, the stale-rkey abort, and the
abandon_rma_opspath all need a run onrc_mlx5.