← all posts
September 8, 2026·boracoder01

Thunderbolt RDMA: 8.4 microseconds, of which about one is the cable

A direct USB4 cable between a Mac Studio and a MacBook carries RDMA at an 8.4 us floor that does not move with message size, and a receive ring that admits exactly floor(1024/frames_per_msg) - 1 messages. The cable trained at 40 Gb/s. Almost none of the cost is the cable.

rdmathunderboltkernelapple-siliconnetworkingbenchmarking
A steel measuring rule running the width of the frame with two tick marks a hair's breadth apart near its left end, a brass-mounted lens above them re-showing that interval enlarged, and a peacock's fan of fine lines opening at the right.

There ⁠is ‌a ‌cable ‌running ‌between ​a ​Mac ​Studio ​and ​a ​MacBook ‌Pro ​on ​my ‌desk. The ​Mac ‌Studio ‌runs ‌Linux, ‌which ‌is ‌only ‌true ‌because ‌last ‌week ‌I ‌spent ‌a weekend ​hauling ‌Apple's ​USB4 ‌host ​controller ‌out ​of ‌kernel ​7.2 ‌and ​into ​7.1.13, where ⁠the ⁠Asahi ‌GPU ‌driver ‌still ‌works. ​Asahi's ​7.1 ​branch ​builds ​the ​GPU ‌and has ​no ​USB4 ‌at ​all; ‌7.2 ‌has ‌USB4 ‌and ‌marks ‌the ‌GPU ‌driver ‌BROKEN. ‌You ‌get ‌one. I ‌wanted ​both, ‌so ​about ‌1,100 ​lines ‌moved ​backwards.

Apple's ‌position ​is ‌that ​this ​machine ⁠cannot ⁠do ‌any ‌of ‌what ‌follows. ​RDMA ​over Thunderbolt, ​on ​macOS, ​requires ​Thunderbolt ‌5 ​and ​an ‌M3 ​Ultra, ‌M4 ‌or ‌newer. ‌A 2023 ‌M2 ‌Ultra ‌has ‌Thunderbolt ‌4 ‌and ‌is ‌not ‌on ​the ‌list. ​It ‌is ​doing ‌RDMA ​anyway, because ‌it ​is ‌not ​running ​macOS.

This ⁠post ⁠is ‌what ‌the ‌cable ‌does ​once ​RDMA ​is ​running ​over ​it. ‌One ​number ​is worth ‌the ​whole ‌exercise: ‌the ‌one-way ‌latency ‌floor ‌is ‌8.4 ‌microseconds, and ‌it ‌does ‌not ‌move ‌when ​the ‌payload ​goes ‌from ​2 ‌bytes ​to ‌64. ​Roughly ‌one ​of those ​microseconds ⁠is ⁠the ‌wire. ‌The ‌rest ‌is ​software ​I ​did ​not ​write.

The ​kernel ‌module ​is ​hellas-ai's thunderbolt-ibverbs and ‌their ​writeup ‌is ‌the reference ‌work. ‌They ‌did ‌Mac ‌RDMA ‌over ‌Thunderbolt ‌before ‌I ‌did. ‌What ​I ‌have that ​they ‌do ​not ‌is ​Apple ‌Silicon ​on ‌the ​Linux ​side ⁠of ⁠the ‌cable, ‌which ‌is the ‌entire ​reason ​the ​backport ​was ​necessary. ​If ‌you ​want ​the ‌verbs ​vocabulary — ‌queue ‌pairs, ‌work ‌requests, ‌credits ‌— ‌NVIDIA's programming guide is ‌better ‌than ‌anything ‌I ‌would ‌write ​here.

Test bench

Three ‌machines, ​two ‌cables, ​no ‌switch. ​There ‌is ​no ‌PCIe ​link ​table ⁠because there ⁠is ‌no ‌PCIe ‌in ‌the ​path.

host A   Apple Mac Studio (M2 Ultra, 2023)
         apple,j475d / apple,t6022, 24 cores, 190 GiB unified memory
         Fedora Asahi Remix, kernel 7.1.13-usb4gpu (local USB4 backport)

host B   Apple MacBook Pro (M4 Max)
         macOS, Apple's own Thunderbolt stack

host C   AMD Strix Halo desktop (Radeon 8060S)
         Fedora, kernel 6.18.0-0.rc7.58.fc44.x86_64
         distro thunderbolt stack, out-of-tree thunderbolt_ibverbs

link A-B   USB4 40 Gb/s cable, host A <-> host B
link A-C   USB4 40 Gb/s cable, host A <-> host C

RDMA       hellas-ai/thunderbolt-ibverbs + a usb4_rdma userspace provider
           in a local rdma-core build
perftest   6.29, built from source on both ends (this matters, see below)

Trust ​the ​module's ​own ​bind ​line ‌for ​what ​the ‌link ​negotiated, ‌not ‌ethtool:

thunderbolt_ibverbs: bound native service id=1 key=tbverbs native_lane=0
  route=0x2 link_speed=20Gb/s width=0x2 rail=0x42f89564 path_id=1

Two ‌lanes ‌at ‌20 ‌Gb/s ‌is ‌40 ‌Gb/s ‌per ‌direction. ‌ethtool ‌prints ​a ‌flat 40000Mb/s ​on ‌the ​tbnet ‌interface ​whether ‌or ​not ‌that ​is ​what ⁠trained. ⁠It ‌is a ‌constant ‌in ‌the ​driver, ​and ​quoting ​it ​as ​evidence ‌would ​be ​a ‌mistake.

The floor

Before ​RDMA, ‌plain ‌IP ‌over ‌the ‌same ‌cable, ‌200 ‌packets, ‌warm:

ping -c 200 -i 0.01, 0% loss both paths.
pathmin (ms)mean (ms)max (ms)mdev (ms)
A to C over the USB4 cable0.0550.3140.7750.179
A to C over the LAN2.0882.77110.9680.819

A ‌55 ‌us ‌floor ‌with ​a ‌314 ​us ‌mean, ​on ‌a ​link ‌with ​no ‌switch ​and ​no ⁠contention, already ⁠says ‌the ‌transport ‌is ‌faster ​than ​whatever ​sits ​above ​it.

RDMA ​puts ‌a ​number ​on ‌that. ​The ‌one-way ‌floor ‌on ‌the ‌Apple-to-Apple ‌link ‌is 8.4 ‌to ‌9.9 ‌us, ‌flat ‌from ‌a ​2-byte ‌payload ​to ‌a ​64-byte ‌one. ​Real ‌RDMA ​hardware does ‌small ​messages ​in ⁠1 ⁠to ‌2 ‌us. ‌The ‌gap ​is ​not ​mysterious, ​and ​you ​can ‌read it ​off ​the ‌device ​before ‌timing ‌anything:

vendor_id            0x1d6b        <- Linux Foundation, not a vendor HCA
max_qp               1
max_inline_data      0
atomic_cap           ATOMIC_NONE
max_qp_rd_atom       0

One ‌queue ‌pair. ‌No ‌inline ‌data. ‌No ‌atomics. ‌This ‌is ‌a ‌software ​provider wearing ‌the ​verbs ‌API, ​and ‌every ​send ‌walks ​the ‌kernel ​and ​Apple's ⁠ACIO coprocessor ⁠firmware ‌instead ‌of ‌being ‌handed ​to ​a ​DMA ​engine ​that ​owns ‌the doorbell. ​A ​cost ‌that ​does ‌not ‌change ‌with ‌message ‌size ‌is ‌fixed ‌overhead ‌by definition; ‌serialising ‌64 ‌bytes ‌onto ​a ‌40 ​Gb/s ‌wire ​is ‌around ​a ‌microsecond of ​the ‌8.4.

The small-message floor is fixed software costThe post estimates around 1 us on the wire and roughly 7.5 us in software plus firmware. These are approximate attributions, not individually traced timings. The reported one-way floor stays in the 8.4 to 9.9 us range from 2 B to 64 B. A schematic flat band illustrates that observation without inventing per-size samples.Where the time goesApproximate attribution · no kernel trace yetWire~1 usSoftware+ ACIO firmware~7.5 usDriver versus firmware: split unmeasured.The evidence: payload grows, floor stays flatReported one-way floor8.4–9.9 usSchematic flat band2 B64 Bpayload size

Scroll horizontally to see the full diagram.

Approximate cost estimates, not a traced timing breakdown. Sweep endpoints are schematic; raw per-size samples are not published in this post.

Flat from 2 B to 64 B. The ~1 us wire / ~7.5 us software split is arithmetic on the measured flatness, not a traced breakdown.

The receive ring, which is the actual find

Latency ​is ​a ⁠cost ⁠per ‌message. ‌Throughput ‌exposed ‌something ​better. ​Running payload-checked ​UC ​SEND ​from ​host ‌A ​to ​host ‌B ​(uc_oneway --check, ‌every ‌run data_ok=yes, ‌zero ‌mismatches), ‌the ‌receiver ‌asks ‌for ‌a ‌512-deep ‌receive window ‌every ‌time ​and ‌never ​gets ‌it:

Every row is a printed `recv window:` line from the run that produced its rate. Four repeats at 64 KiB gave 4401.55 / 3579.93 / 3129.01 / 3859.71 Mbit/s with no downward trend, so that spread is variance, not cold load, and the range is reported instead of a single number. The 1 MiB case fails reproducibly.
message sizerate (Mbit/s)recv WRs acceptedframes/msgframes in windowresult
16 KiB2505.5825541020ok
64 KiB3129 - 440263161008ok
128 KiB3790 - 38133132992ok
256 KiB3869 - 46391564960ok
1 MiB3256768FAILS

What ​it ‌gets ​instead ‌is ​exact:

accepted = floor(1024 / frames_per_msg) - 1

  4 frames/msg -> 1024/4   - 1 = 255    measured 255
 16 frames/msg -> 1024/16  - 1 = 63     measured 63
 32 frames/msg -> 1024/32  - 1 = 31     measured 31
 64 frames/msg -> 1024/64  - 1 = 15     measured 15
256 frames/msg -> 1024/256 - 1 = 3      measured 3

Five ‌sizes ​across ​six ⁠doublings, ⁠no ‌residual. ‌Not ‌a ‌budget ​hovering ​near ​a thousand ​— ​a ​1024-frame ‌ring ​with ​one ‌slot ​held ‌back, ‌which ‌is ‌the ‌ordinary ‌way to ‌tell ‌a ‌full ‌ring ‌from ‌an ‌empty ​one. ‌The ​window ‌counts ​frames, ‌not ​work requests, ‌so ​a ‌bigger ​message ​buys ⁠you ⁠fewer ‌messages ‌in ‌flight ‌and ​nothing else.

Larger messages share a fixed frame budgetReported receive-window totals stay near 1024 frames: 1020 at 16 KiB, 1008 at 64 KiB, 992 at 128 KiB and 960 at 256 KiB, each one floor(1024/frames per message) minus one slot, measured against its accepted-message count. At 1 MiB, only 3 messages of 256 frames are admitted, totaling 768 frames, and the transfer fails. The unused gap does not establish that another message can be admitted.Bigger messages, fewer admittedThe window stays near the same frame budget until the transfer fails.Message / accepted × frames~1024 frames16 KiB255 × 41020reported frames64 KiB63 × 161008reported frames128 KiB31 × 32992reported frames256 KiB15 × 64960reported frames1 MiB3 × 256768FAILS256256256Gap to reference budget;no further message admitted

Scroll horizontally to see the full diagram.

Measured window totals. accepted = floor(1024/frames_per_msg) - 1 at every size.

Accepted receives shrink against the same 1024-frame ring as messages grow. At 1 MiB the formula admits three, and the transfer fails rather than degrading.

That ​is ​also ​the ​shape ​of ‌the ​throughput ​curve. ‌It ​climbs ‌from ‌16 ‌KiB ‌to 64 ‌KiB ‌as ‌per-message ‌overhead ‌amortises, ‌then ‌flattens ‌near ‌3.9 ​Gbit/s because ‌the ​ring ‌will ​not ‌let ​the ‌pipeline ​get ‌deeper. ​Peak ​is ⁠about ⁠10% ‌of what ‌the ‌link ‌trained ​at, ​and ​the ​ceiling ​is ​the ‌credit ​window ​rather ‌than ​the cable.

I ‌got ‌this ‌table ‌wrong ‌the ‌first ‌time ‌I ‌published ‌it. ‌The ‌256 ‌KiB ​row ‌said 1008 ​frames. ‌Fifteen ​64-frame ‌messages ​is ‌960. ​I ‌had ​not ​multiplied ⁠anything ⁠— two ‌recv window: ‌lines ‌had ‌not ​printed, ​and ​I ​filled ​the ​gap ‌with ​a ​number that ‌continued ​the ‌visible ‌pattern ‌(1020, ‌1008, ‌992). ‌It ‌looked ‌right, ‌which is ‌the ‌problem ‌with ‌numbers ​that ‌continue ​a ‌pattern. ​Re-reading ‌the ​runs ‌gave 960, ​and ‌the ​exact ​formula ⁠only ⁠appeared ‌once ‌the ‌wrong ‌number ​was ​gone.

Four bugs, two of which are worth your time

Host ​A ​had ​a ​working ‌link ​to ​host ‌B. ​Adding ‌host ‌C ‌on ‌a ‌second ‌cable ‌failed four ‌times ‌before ‌the ‌two ‌ends ‌would ​even ‌shake ​hands. ‌Two ​of ‌those ​failures generalise ‌well ​past ‌this ​driver; ​two ⁠were ⁠local ‌mess. ‌I ‌am ‌telling ​you ​the two ​and ​listing ​the ​others.

A ‌module-wide ​parameter ​for ‌a ​per-device ‌property. ‌The ‌second ‌cable's ‌peer sent ‌HELLO ‌and ‌it ‌matched ‌nothing, ‌because ‌the ‌new ​device ‌was ​advertising ‌the first ​cable's ‌address. ​The ‌module ​takes ‌its ​RoCE ​GID ⁠from ⁠a ‌named ‌netdev:

c
static char *roce_netdev;
module_param(roce_netdev, charp, 0444);

One ‌charp, ‌module-wide. ​Two ​cables ​means ​two ​tbnet ​interfaces ‌and ​two ​IB devices, ‌both ​reading ‌their ‌address ‌out ‌of ‌that ‌single ‌string. ‌The ‌fix ‌was already ‌in ‌the ‌source ​one ‌function ​down ‌— ​the ‌Apple ​backend ‌has ​its ‌own override, ​tbnet_identity_gid, ​defaulting ⁠to ⁠auto, ‌which ‌falls ‌back ‌to roce_netdev. ​The ​fallback ​is ​what ​hid ​the ‌collision: ​with ​one ‌cable ​it ‌is always ‌correct, ‌so ‌the ‌bug ‌cannot ‌exist ‌until ‌your ‌second ‌link. ‌Setting ‌both explicitly ​separates ‌them:

roce_netdev=thunderbolt1        # native backend, the x86 link
tbnet_identity_gid=thunderbolt0 # Apple backend, the MacBook link

I ​would ‌rather ​this ‌parameter ​had ‌been ​per-device ‌from ​the ​start, ⁠but ⁠I ‌also understand ‌why ‌it ‌was ​not: ​nobody ​writes ​the ​two-cable ​case ‌first.

A ​device ​that ‌is ​ACTIVE ‌and ‌invisible. ‌On ‌host ‌C, ‌rdma link show ‌listed ‌a healthy ‌ACTIVE ‌device, ‌/dev/infiniband/uverbs0 ‌existed, ‌the ​provider ‌was installed ​and ‌registered ​— ‌and ​ibv_devices ‌printed ​an ‌empty ​table. ​No ⁠error. The ⁠provider's ‌own ‌source ‌comment ‌predicts ​exactly ​this:

 * Matching: prefer the fixed module node GUIDs, with the kernel device names
 * as a fallback. We do not have an upstream RDMA_DRIVER_USB4_RDMA enum value
 * yet, and distro rdma-core udev rules may rename the devices away from
 * usb4_rdmaN/usb4_appleN.

Fedora's ​persistent-naming ​rule ​had ​renamed ‌the ​device ​to ‌rocep199s0f6. ​With no ‌upstream ‌driver ‌enum ‌to ‌match ‌on, ‌the ‌provider ‌falls ‌back ‌to ‌matching ‌the name ​prefix, ‌and ​the ‌prefix ​was ‌gone. ​Renaming ‌it ​back ‌brought ​the ​device straight ⁠into ⁠ibv_devices. ‌That ‌is ‌a ‌runtime ​fix, ​so ​it ​needs ​a ​udev ‌rule keyed ​on ​the ‌kernel ​name ‌to ‌survive ‌a ‌reboot ‌— ‌keying ‌it ‌on ‌the ‌node ‌GUID, ‌as ‌I did ​at ‌first, ​only ‌excludes ​the ‌one ​device ‌you ​already ‌know ​about.

The ​other ⁠two, ⁠briefly. ‌NetworkManager ‌kept ‌handing ‌the ​interface ​a 169.254.x ​address ​alongside ​the ​static ‌one, ​and ​the ‌module ​takes ‌the ‌first IPv4 ‌address ‌it ‌finds; ‌ipv4.method manual ‌ends ‌it. ‌And ‌ib_send_lat ‌failed on ‌both ‌ends ​at ‌once ​with ‌a ​socket ‌error ​that ‌was ​really ‌a ​build ​mismatch ⁠— both ⁠binaries ‌reported ‌Version: 6.29, ‌one ‌was ​the ​distro ​package ​and ​one ​was a ‌source ​build, ​and ‌they ​disagreed ‌on ‌the ‌negotiation ‌struct. ‌Same ‌version string, ‌different ‌wire ‌format. ‌Build ‌both ‌from ‌source.

One ​more ‌practical ​note. ‌The ​module's ‌parameters ​have ‌ordering ​constraints ‌and it ​reports ​them ⁠one ⁠at ‌a ‌time, ‌so ‌you ​find ​a ​working ​configuration ​by ​walking ‌a chain ​of ​refusals: ‌negotiate_native=1 ​requires ‌start_rings=1 ‌requires allocate_rings=1; ‌tbnet_identity=minimal_packet ‌wants ‌XDomain unregister-drain ‌support ‌that ‌the ‌backported ‌7.1.13 ‌does ‌not ‌have, ​so stock_proxy ‌is ​the ‌identity ​that ‌works ​there. ‌Budget ​several ‌load ​attempts. The ​configuration ⁠that ⁠brings ‌both ‌links ‌up:

profile=mixed apple_data=1 enable_tunnels=1 negotiate_native=1 tbnet=allow
tbnet_identity=stock_proxy tbnet_identity_tbnet=thunderbolt0
tbnet_identity_gid=thunderbolt0 roce_netdev=thunderbolt1
bind_services=1 allocate_rings=1 start_rings=1 register_verbs=1 apple_tx_e2e=1

One direction still does not work

Handshake ‌completes, ​transfer ​stalls. ​The ​debugfs ​counters ​say ‌where, ​without any ​guessing:

host A — M2 Ultratx posted 529tx completed 15host C — Strix Halorx completed 0rx credit sent 0data A to C — stalls after the opening credit allowanceno descriptor ever completes on CRX credits C to Adata C to A — 512 posted, 512 completed, 512 receivedthe reverse direction is healthy, so the link and the tunnel are fine
Counters read from /sys/kernel/debug/thunderbolt_ibverbs on both hosts during the same stalled ib_send_lat run.

Host ‌C ​posted ‌512 ‌sends ‌and ‌completed ‌all ‌512; ‌host ‌A ‌received ‌all ‌512. ‌That direction ‌is ​fine, ‌which ​clears ‌the ​cable, ‌the ​tunnel ‌and ​the ‌negotiation. ​The other ​way, ⁠host ⁠A ‌posted ‌529 ‌and ‌completed ​15, ​while ​host ​C ​completed ​zero receives ‌and ​returned ​zero ‌RX ​credits. ‌Host ‌C ‌never ‌finishes ‌a ‌receive descriptor, ‌so ‌it ‌never ‌returns ‌credits, ‌so ‌host ​A ‌spends ​its ‌opening allowance ​and ‌stops. ​The ‌15 ​are ‌that ​opening ​grant, ⁠not ⁠a ‌partly ‌working ‌path.

Two ‌lines ​from ​host ​C's ​log ​are ​the ‌leading ​suspects ​and ‌neither ​is ‌confirmed:

thunderbolt_ibverbs 1-2.2: probe with driver thunderbolt_ibverbs failed with error -12
thunderbolt_ibverbs: native HELLO route=0x2 rail=0x1 has no matching peer rail

-12 ‌is ‌ENOMEM ‌on ‌the ‌second ‌rail's ‌probe. ‌Rail ‌0 ‌registered ‌fine ‌and ​is ‌the rail ​carrying ‌this ​traffic, ‌so ​the ‌link ​between ‌a ​failed ​rail-1 ⁠probe ⁠and ‌a dead ‌rail-0 ‌receive ‌path ​is ​precisely ​what ​has ​not ​been ‌established. ​Plain ​IP over ‌that ​same ‌cable ‌runs ‌both ‌directions ‌at ‌14 ‌Gbit/s, ‌which ‌puts ‌the ‌fault ‌in the ‌ibverbs ​data ‌ring ​rather ‌than ​the ‌link ​or ‌tbnet. ​I ‌have ​a ​suspect ⁠and ⁠no evidence, ‌so ‌it ‌stays ‌a ​suspect.

Numbers next to other people's numbers

Their figures from blog.hellas.ai; their Mac row is an M4 Max MacBook Pro against a Linux router. Different verbs (WRITE vs SEND) and very different QP and rail counts — context, not a race.
measurementhellas-ai (published)this bench
one-way latency, small message~7 us (ib_write_lat, 64 B, 1 QP)8.4 - 9.9 us (ib_send_lat, 2-64 B)
bandwidth, per direction~48 Gb/s (1 MiB, 8 QPs, 4-HCA aggregate)~3.9 Gb/s (1 QP, 1 cable)
bandwidth, bidirectional~95 Gb/snot measured
Mac endpoint6.5 Gb/s Mac->Linux, 1.8 Gb/s Linux->Mac, ~10 us RTT4.4 Gb/s Linux->Mac, 0.226 ms RTT (IP)

Their ​~7 ​us ​against ​my ​8.4 ‌to ​9.9 ​is ‌the ​useful ‌agreement ‌here. ‌Two ‌setups that ‌share ‌no ‌hardware ‌landing ‌in ‌the ‌same ‌range ‌is ​far ‌better ​evidence ‌that the ​floor ‌is ​structural ‌to ​the ‌software ​path ​than ⁠either ⁠measurement ‌alone. ‌The bandwidth ‌gap ‌needs ​no ​explanation: ​they ​aggregate ​8 ​queue ‌pairs ​across ​4 ‌HCAs and ​two ‌cables, ‌and ‌my ‌device ‌reports ‌max_qp 1.

Apple already ships this, and it is called JACCL

The ‌first ‌version ‌of ‌this ‌post ‌implied ‌nobody ​had ‌published ​Mac-to-Mac ‌RDMA numbers. ​That ‌was ​wrong, ‌and ​the ‌thing ​I ​missed ⁠is ⁠not ‌obscure: ‌Apple ‌shipped it ‌at ​WWDC ​2026. ​JACCL ​— ​Jack ​and ‌Angelos' ​Collective ​Communication ‌Library — ​is ‌an ‌MLX ‌distributed ‌backend ‌that ‌runs ‌collectives ‌over ‌RDMA ‌on ‌Thunderbolt ‌5, and ‌it ​is ‌the ​reason ‌rdma_ctl enable ​exists ‌on ​the ‌macOS ​side ‌of ​my ​own ⁠cable.

What ⁠Apple's ‌own ‌MLX documentation commits ‌to ‌is ​one ​qualitative ​sentence: ​JACCL ​achieves ​"communication ‌latency ​an order ​of ‌magnitude ​lower ‌than ‌the ‌ring ‌backend". ‌The ‌figures ‌everyone ‌repeats ‌— single-digit ‌microseconds ‌against ‌roughly ‌300 ​for ‌the ​TCP ‌ring, ​50 ‌to ​60 ‌Gb/s, about ​3x ‌on ​four ​M3 ⁠Ultras ⁠fine-tuning ‌— ‌come ‌from ‌the WWDC session ​and ​get recycled ​through ​every ​write-up ​since.

The ‌people ​actually ​running ‌it ​have ‌published ‌better ‌material ‌than ‌the ‌write-ups have, ‌and ‌it ‌is ‌all ‌in ‌the ‌MLX ​discussions ‌rather ​than ‌on ​anybody's ‌blog.

#3481 ​builds ‌a ​file-transfer tool ‌on ​mx.distributed.send/recv ​and ⁠sustains ⁠3.5 ‌to ‌3.8 ‌GB/s ‌over ​TB5 ​— 16 files, 49.63 GB in 13.0s. ​The ​comparison ​inside ​that ‌post ​is ​the ‌part ​I ‌care about: ‌rsync ‌over ‌the ‌same ‌Thunderbolt ‌cable, ‌running ‌IP ‌instead ‌of ‌RDMA, manages ‌300 ​to ‌500 ​MB/s. ‌Same ​wire, ‌roughly ​eight ‌times ​the ‌throughput, ​and ​the only ⁠thing ⁠that ‌changed ‌is ‌how ‌much ​software ​sits ​in ​the ​path. ​That ‌is ​this post's ​whole ‌thesis, ​measured ‌by ‌somebody ‌else ‌on ‌hardware ‌I ‌do ‌not ‌have.

It ‌also ‌carries ‌the ‌sort ​of ‌detail ​that ‌only ​comes ‌from ​doing ‌it. ​send ‌and recv ​default ​to ⁠the ⁠GPU ‌stream; ‌Metal ‌has ‌an ​unconfigurable ​command-buffer timeout ​of ​about ​five ​seconds, ‌and ​an ​RDMA ‌receive ​that ‌outlives ‌it ‌takes ‌the process ‌down ‌— ‌so ‌every ‌call ‌needs ‌stream=mx.cpu. ‌JACCL ‌needs ​an ‌all_sum barrier ​immediately ‌after ​init(), ‌and ​it ‌has ​to ‌be ​mx.ones(10), ​because ⁠the single-element ⁠version ‌does ‌not ‌reliably ‌trigger ​the ​full ​synchronisation ​path. Protection ​domains ​leak ‌on ​teardown, ​so ‌roughly ​sixty ‌init/teardown ‌cycles exhaust ‌them ‌and ‌the ‌only ‌recovery ‌is ‌rebooting ‌the ‌node.

What ‌still ‌does ​not ‌exist ​anywhere ‌I ​can ‌find ​is ‌a ​per-operation ‌latency number ​for ​JACCL. ⁠No ⁠ib_send_lat, ‌no ‌invocation, ‌no ‌config ​printed ​beside ​a microsecond ​figure. ​Bandwidth, ​yes. ‌Tokens ​per ​second, ‌yes. ​The ‌one ‌number ‌this post ‌is ‌about, ‌no. ‌If ‌you ‌have ‌one, ‌send ‌it.

So ‌the ​comparison ‌is ​worth ‌setting ​out ‌honestly, ​including ‌which ​column ‌is ​a measurement ​and ⁠which ⁠is ‌a ‌vendor ‌claim:

JACCL requires macOS 26.2 or later, RDMA armed from recovery with `rdma_ctl enable`, and a fully connected Thunderbolt 5 mesh. Apple's figures are product claims, not reproducible measurements — that distinction is the point of the row.
JACCL (Apple)hellas-aithis bench
small-message latency"single-digit us" (claim)~7 us (measured)8.4 - 9.9 us (measured)
published invocationnone foundyesyes
linkThunderbolt 5 onlyUSB4 40 Gb/sUSB4 40 Gb/s
both endsmacOS, M3 Ultra / M4+Linux x86 + MacLinux on M2 Ultra + macOS
topologyfully connected mesh or ring2 cables, 4 HCAs1 cable, 1 QP

Three ‌numbers ​in ​the ​same ​band ​is ​the ‌interesting ​part. ​Apple's ‌native ​stack, hellas-ai's ‌out-of-tree ‌module ‌on ‌Strix ‌Halo, ‌and ‌a ‌software ‌verbs ‌provider ‌on a ‌Linux ‌Mac ​all ‌land ​within ‌single-digit ​microseconds ‌of ​each ‌other. ​If ‌a vendor's ​own ​RDMA ⁠path ⁠and ‌a ‌third-party ‌shim ‌are ​that ​close, ​the ​floor ​is ​not being ‌set ​by ​anybody's ‌implementation.

So ​what ‌is ‌actually ‌mine? ‌Narrower ‌than ‌I ‌first ‌wrote, ‌and ‌JACCL ‌is ‌why ‌— but ​also ‌stranger ​than ‌I ​first ‌wrote, ​in ‌a ​way ‌worth ​being ​precise ⁠about.

JACCL ⁠needs ‌Thunderbolt ‌5 ‌and ‌an ​M3 ​Ultra ​or ​M4-class ​chip. ​This ‌Mac ​Studio ​is ‌a 2023 ​M2 ‌Ultra ‌with ‌Thunderbolt ‌4. ‌By ‌Apple's ‌own ‌requirements ‌it ‌is ‌not ‌a ‌machine that ​does ‌RDMA ​over ‌Thunderbolt ​— ‌not ​slowly, ‌not ​in ‌a ​degraded ​mode, ⁠at ⁠all. ‌The rdma_ctl enable ‌path ‌does ‌not ​apply ​to ​it.

It ​is ​doing ​8.4 ‌microseconds ​anyway, ​one ‌cable, ​no ‌switch, ‌because ‌the ‌operating system ‌on ‌it ‌is ‌not ‌the ‌one ‌carrying ‌the ‌requirement. ​The ‌silicon ​was ‌evidently always ​capable; ‌a ​support ‌matrix ​is ‌a ​support ​matrix. ⁠So ⁠the ‌defensible ‌claim ‌is: Apple ‌Silicon ​as ​the ​Linux ​endpoint, ​over ​Thunderbolt ‌4, ​on ​a ‌machine excluded ​from ‌the ‌vendor's ‌own ‌feature, ‌with ‌every ‌command ‌printed.

Everybody publishes tokens per second. Nobody publishes the cable

This ‌is ‌the ‌actual ‌gap, ‌and ​it ‌took ​being ‌wrong ​about ‌it ​twice ‌to ​see ‌it.

exo ​have ​done ⁠the ⁠most ‌work ‌here ‌by ‌a ​distance, ​and say ​themselves ​they ​spent ​the ‌past ​year ​working ‌with ​Apple ‌on ‌RDMA ‌networking over ‌Thunderbolt ‌5. ‌Their ‌four-Mac-Studio ‌cluster ‌— ‌1.5 ‌TB ‌of ‌unified ​memory, about ‌forty ​thousand ‌dollars ​— ‌runs ​Kimi ‌K2 ​at ‌roughly ​25 ​tokens ⁠per ⁠second ‌with around ‌2 ‌seconds ‌to ​first ​token ​under ​450 ​watts, ​Qwen3 ‌235B ​at ​32, ‌DeepSeek ​V3.1 671B ‌at ‌32.5. ‌Those ‌are ‌real, ‌useful, ‌reproducible ‌numbers, ‌and ‌they ‌are ‌all end ‌to ​end. ‌None ​of ‌them ​tells ‌you ​what ‌the ​interconnect ‌contributed.

Their ​DGX Spark plus Mac Studio piece ​is ⁠the ⁠sharpest ‌example, ‌and ‌it ‌is ​good ​engineering: ​prefill ​on ​the ​Spark (around ‌100 ​TFLOPs ​FP16), ‌decode ​on ‌the ‌M3 ‌Ultra ‌(819 ‌GB/s ‌of ‌memory ‌bandwidth), KV ‌cache ‌streamed ‌between ‌them ‌layer ​by ‌layer. ​Llama-3.1 ‌8B ​goes ‌from ​6.42 ‌s ​on the ‌Mac ​alone ​to ⁠2.32 ⁠s ‌combined. ‌The ‌link ‌carrying ​that ​KV ​cache ​is ​10 ​GbE, quoted ‌at ​its ​nominal ‌speed ​and ‌never ‌measured. ‌A ‌Thunderbolt ‌cable ‌between ‌those same ‌two ‌machines ‌trains ‌at ‌four ‌times ​that, ‌and ​nobody ‌has ​published ‌what ​it does.

The ‌state ​of ‌the ​public ​record, ⁠as ⁠far ‌as ‌I ‌can ‌find ​it:

Not a ranking — the end-to-end numbers are more useful to more people than mine are. But four of the six rows quote an interconnect nobody measured, and in two of them that interconnect is the stated bottleneck.
sourcewhat was measuredthe linklink measured?
exo, 4x Mac Studiotok/s end to end, 3 modelsThunderbolt 5 RDMAno
exo, Spark + Mac Studioprefill/decode split, 8B10 GbE (nominal)no
Apple, JACCLan order-of-magnitude claimThunderbolt 5 RDMAno invocation published
MLX #2990, 5x M3 UltraPP vs TP tok/s, Kimi-K2 1TTB5 full mesh, RDMAno
MLX #34813.5-3.8 GB/s file transferTB5, RDMA vs IP on one cableyes, throughput
corti, Mac + Spark via exotok/s end to end1 GbE, called the bottleneckno
hellas-aiib_write_lat, ib_write_bwUSB4 40 Gb/syes
this benchib_send_lat, uc_oneway --checkUSB4 40 Gb/syes

The ​1 ​GbE ​row ​is ​corti's Mac Studio plus DGX Spark cluster, which ‌is ​honest ​about ‌it ​— ‌the ‌post ‌says ‌outright ‌that ‌the ‌network ‌is ‌the bottleneck. ‌It ‌is ‌still ‌the ​most ‌detailed ​public ‌writeup ​of ‌that ​pairing, ‌and ​it is ‌running ​over ​one ⁠gigabit ⁠while ‌a ‌Thunderbolt ‌cable ‌sits ​on ​the ​same ​desk.

There ​is ​nothing ‌wrong ​with ​an ‌end-to-end ​number; ‌it ‌is ‌what ‌you ‌actually ‌get. The ‌problem ‌is ‌that ‌when ‌the ‌tokens ‌per ​second ‌disappoint, ​an ‌unmeasured ​link ‌is the ​first ‌thing ​blamed ‌and ​the ​last ⁠thing ⁠checked. ‌That ‌is ‌the ‌hole ​this ​post fills, ​and ​it ​is ​a ‌small ​hole ​beside ‌what ​exo ‌have ‌built.

What I would measure next

Three ‌limits, ‌three ‌different ‌experiments, ‌none ‌of ‌them ‌done:

The ‌8.4 ‌us ‌floor ​should ‌not ​be ‌quoted ​as ‌a ​hardware ‌limit. ​It ‌is ​what ​a software ⁠verbs ⁠provider ‌costs ‌on ‌this ‌path ​today, ​and ​a ​kernel ​trace ​has ‌to split ​the ​driver ‌path ​from ‌the ‌ACIO ‌firmware ‌round ‌trip ‌before ‌either ‌gets ‌a number.

The ‌3.9 ‌Gb/s ‌ceiling ‌belongs ​to ‌one ​queue ‌pair ​against ‌a ​fixed ‌ring. ​More queue ‌pairs ​is ​the ⁠obvious ⁠next ‌throughput ‌experiment. ‌Payload-checked transfers ‌up ​to ​256 ​KiB ​and ​reproducible ​failure ‌at ​1 ​MiB ‌give ​it ‌a ‌concrete boundary ‌to ‌push ‌at.

The ‌host ‌A ‌to ‌host ‌C ‌failure ‌needs ‌receive-ring ​evidence ‌on ​host ‌C ​during ‌the stall, ​and ‌an ​answer ‌to ​whether ​the ⁠failed ⁠rail-1 ‌probe ‌touches ‌rail ‌0 ​at ​all. Until ​then ​the ​ENOMEM ​is ‌a ​lead.

And ​then ‌the ​thing ‌this ‌was ‌all ‌for: ‌put ‌a ‌model ‌across ‌the ‌cable. ‌This ‌one ‌has a ​published ‌prior, ​which ‌makes ​it ‌a ​much ‌better ​experiment ‌than ​it ​was ⁠an ⁠hour ago.

MLX discussion #2990 ‌runs exactly ‌this ‌comparison ‌on ​five ​M3 ​Ultras ​in ​a ​full ‌TB5 ​mesh, ​on ‌Kimi-K2-Thinking at ​1T ‌parameters, ‌and ‌finds ‌that ‌pipeline ‌parallel ‌and ‌tensor ‌parallel ‌land ‌on top ‌of ‌each ​other:

From MLX discussion #2990, batch size 1, fixed context. TP4 wins throughput by 2.3%; PP4 loads 4.5x faster and holds 21% less memory. Their explanation is the important bit: RDMA makes TP's all-reduce nearly free, so TP's structural advantage never shows up.
confignodestok/sload timepeak memory
PP5514.455.2 s102.5 GB
PP4414.4922.3 s145.9 GB
TP4414.8299.9 s185.7 GB
TP2213.1592.6 s345.0 GB
TP55failed12288 does not divide by 5

Their ‌conclusion ​is ‌that ​a ‌TB5 ​ring ‌will ​likely ‌beat ​a ​fully ⁠meshed ⁠TP ‌cluster without ‌the ‌cable ‌spaghetti. ​And ​note ​what ​makes ​that ​true: ‌the ​collective ​is nearly ‌free. ​On ‌JACCL ‌it ‌is.

It ‌is ‌not ‌free ‌here. ‌This ‌fabric ‌charges ‌8.4 ‌microseconds ‌per ​message ‌no ​matter how ‌small, ​and ‌admits ​floor(1024 / frames_per_msg) - 1 ‌messages ​at ‌a ​time. Tensor ​parallel ⁠is ⁠many ‌small ‌synchronous ‌all-reduces, ‌which ​is ​precisely ​the traffic ​shape ​that ​fixed ‌per-message ​cost ​punishes ‌hardest. ​Pipeline ‌parallel ‌is fewer, ‌larger, ‌asynchronous ‌exchanges ‌— ‌and ‌larger ‌messages ‌here ‌buy ‌fewer ‌slots, which ​cuts ‌the ​other ‌way.

So ​the ‌prediction, ​written ‌down ​before ‌the ​run ​so ⁠it ⁠can ‌be ‌wrong: ‌the ‌2.3% ​gap should ​open ​up ​substantially ​on ​this ‌link, ​in ​pipeline ‌parallel's ​favour. ‌If ‌it does ‌not, ‌my ‌model ‌of ‌the ‌ring ‌is ‌incomplete ‌and ‌that ‌is ​worth ‌more ​than ‌being right. ​llama.cpp ‌first ​because ‌it ​is ‌already ​here ​— ⁠tensor ⁠parallel ‌split ‌by ‌row and ‌by ​column, ​then ​layer ​split, ​then ​pipeline. ‌Same ​model, ​same ‌prompt, ​same harness, ‌warm ‌floors.

Further ‌out, ‌and ‌only ‌if ‌those ‌numbers ‌earn ‌it: ‌MLX ‌on ‌Linux ​over ‌Vulkan, ​and possibly ‌dusting ​off ‌my ​own ‌DSL-style ​LLM ‌runtime, ​which ​would ⁠let ⁠me ‌put ‌each collective ‌exactly ‌where ​the ​ring ​law ​says ​it ​should ‌go ​rather ​than ‌where ​a framework ‌happens ‌to ‌put ‌it. ‌That ‌is ‌a ‌lot ‌of ‌work ‌to ‌justify ‌with ​a ‌benchmark that ​does ‌not ​exist ‌yet.

Further ​out, ‌and ​only ‌if ​the ​llama.cpp ⁠numbers ⁠earn ‌it: ‌MLX ‌on ‌Linux ​over ​Vulkan, and ​possibly ​dusting ​off ​my ‌own ​DSL-style ​LLM ‌runtime ​— ‌which ‌would ‌let ‌me ‌put each ‌collective ‌exactly ‌where ‌the ‌ring ‌law ‌says ‌it ​should ‌go, ​rather ‌than ​where ‌a framework ​happens ‌to ​put ‌it. ​That ​is ⁠a ⁠lot ‌of ‌work ‌to ‌justify ​with ​a ​benchmark that ​does ​not ​exist ‌yet.

Two ​measurements ​that ‌do ​not ‌exist ‌publicly, ‌if ‌anyone ‌wants ‌them: ‌an independent ‌per-operation ‌benchmark ‌of ‌JACCL ‌with ‌its ​invocation ‌printed, ​and ‌a Mac-to-DGX-Spark ​link ‌measured ​with ‌perftest. ​Both ‌are ​worth ​more ⁠than ⁠anything in ‌this ‌post.

boracode.ai · Ronald Adonyo · thunderbolt-rdma-latency-floor
content hash f025fff554ff
A wide horizon at dusk: layered hills in muted plum and ochre above a lake that holds the last light, a family of crested cranes at the water's edge, an acacia in silhouette and a single bright star above the ridge.
Thunderbolt RDMA: 8.4 microseconds, of which about one is the cable — boracode