← all posts
September 7, 2026·boracoder01

Backporting Apple USB4 into an Asahi kernel, and what broke on the way

No Asahi branch has both a working GPU and Apple USB4. Backporting the host controller down into 7.1.13 got two Macs talking over a direct cable at 11.9 Gbit/s and 0.254 ms — after four bugs, one of them upstream and one of them mine.

kernelasahithunderboltapple-siliconnetworking
A hillside cut by two level terraces: the upper one stacked with carved stone blocks and ending at a gorge, the lower one carrying an empty stone bridge across the water, and a carved bridge section on rollers halfway down a timber ramp between them; a crested crane stands at the head of the ramp.

No ⁠Asahi ‌Linux ‌branch ​has ‌both ​a ‌working ‌GPU ‌and ‌Apple ‌USB4. ​The ​7.1.x branches ​build ​DRM_ASAHI ​and ‌have ‌no ‌host ​Thunderbolt ‌controller; ​the ​7.2 branch ‌has ​the ‌controller ‌and ​marks ​the ​GPU ‌driver ‌depends on BROKEN. Backporting ​the ​controller ​down ​into ‌7.1.13 ‌got ‌a ​Mac ​Studio ⁠and ⁠a ‌MacBook ‌Pro talking ​over ‌a ​direct ‌USB4 ‌cable ‌at ‌11.9 ‌Gbit/s ​and ​0.254 ​ms ​round-trip — ​17.7x ‌lower ‌latency ‌than ​the ‌same ​two ​machines ‌over ​the ‌LAN. ‌Four ​bugs ​stood in ​the ‌way. ‌Only ​the ​first ​was ​a ‌missing ‌backport; ‌one ​was ​mine, ⁠one ⁠was ‌a ‌gap in ​the ‌older ​kernel, ‌and ‌one ‌is ‌an ‌upstream ​ordering ​bug ​that ​nobody ​had ‌hit because ‌the ‌code ​path ‌had ​never ​been ‌exercised.

Test bench

Two ​Apple ‌Silicon ‌machines ​and ​one ​cable. ‌There ‌is ​no ​PCIe ​link ​table ‌here because ‌there ‌is ​no ​PCIe ⁠in ⁠the ‌path ‌— ​the ‌equivalent ​evidence ‌is ‌the ‌USB4 ‌lane training ‌state ​and ​the ​negotiated ​generation, ​which ‌the ‌controller ‌firmware reports ​over ‌its ​RTKit ​syslog.

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

host B        MacBook Pro (M4 Max), Mac16,6
              16 cores, 128 GiB unified memory
              macOS 26.6.2 (25G83)

link          direct USB4 / Thunderbolt 4 cable, host-to-host
              no dock, no hub, no bridge

tools         iperf3 3.19.1 (host A) / 3.21 (host B)
              ping (macOS), 200 samples, 10 ms interval

The ‌link ​state, ‌read ‌from ​the ​running ​system ‌rather ‌than ​from ​the ​cable ​spec. Both ‌lanes ‌trained; ‌a ​retimer ​enumerated; ⁠the ⁠peer ‌identified ‌itself. ​The controller ‌firmware ​version ‌matters ‌because ‌it ‌is ‌signed ​Apple ​firmware ​loaded at ​link-up, ​not ‌something ‌the ‌kernel ​supplies:

ethtool speed        40000Mb/s
interface            thunderbolt0, parentbus thunderbolt, parentdev 0-1.0
MTU                  9000 both ends
lane training        gen=2, legacy=0, rs_fec=1, usb4=1, l0_en=1, l1_en=1
                     Gen2/3 lane=0 entered CL0!
                     Gen2/3 lane=1 entered CL0!
retimer              vendor 0x5ac device 0x2200
peer                 Apple Inc. / Mac16,6
controller firmware  AppleCIOFirmware-462.40.2~388__2025-09-05
topology             0-0  0-0:1.1  0-1  0-1.0  0-1.1  domain0

The chain that has to complete

Bringing ‌up ​Apple's ​Thunderbolt ‌host ​controller ‌is ‌not ​one ​driver ​probing. ‌It is ‌a ​relay: ​the ​Type-C ​power-delivery ‌controller ‌notices ‌the ​cable ​has negotiated ⁠USB4, ⁠and ‌hands ‌that ​fact ‌out-of-band ​to ‌a ‌switch ‌that ‌the Thunderbolt ‌driver ​registered. ​Only ​that ​callback ​starts ‌the ‌ACIO ‌complex, which ​boots ‌a ​coprocessor, ​which ‌creates ​the ‌host ‌interface, ​which ​allocates the ​domain. ‌Every ‌stage ​below ​was ​a ​separate ‌failure ‌this ‌session.

cable: PD reports USB4cd321x_typec_update_modetypec_thunderbolt_switch_setapple_cio_start()RTKit: ACIO FW READYNHI device createdhost DROM + domainrings + XDomainthunderbolt0 upBUG 1 — NO CALLERBUG 2 — NULL pdevBUGS 3, 4
The bring-up chain from cable negotiation to thunderbolt0. The labels mark the stage where each of the four bugs struck.

Bug 1 — the provider and the framework, but no consumer

After ​the ​backport ⁠compiled, ⁠everything ‌looked ‌complete ​and ‌nothing ​happened. All ‌six ‌ACIO ‌blocks ‌bound. ‌The ​host-interface ​driver ​registered. ​The deferred-probe ​list ‌was ‌empty. ‌The ​device ‌tree ​carried ​every ‌property. ​Zero oopses. ‌And ‌/sys/bus/thunderbolt/devices ​stayed ​empty.

The ​answer ‌was ‌in ​the ​kerneldoc ​of ​the ‌function ‌that ‌never ​ran: ​"Called ⁠by the ⁠Type-C ‌port ‌driver ​to ‌forward ​the ‌cable ‌details ‌out-of-band ‌to ‌the ​switch handler." ​The ​7.1.13 ​tree ​had ‌the ‌provider ‌that ​registers ‌a ​switch ​and ‌the framework ​that ‌implements ‌the ​plumbing, ​and ​nothing ‌that ‌called ​it. ​A ​grep ​for the ‌symbol ‌across ‌the ​whole ​tree ⁠matched ⁠four ‌files, ‌all ​of ‌them ​definitions:

$ grep -rn 'thunderbolt_switch' drivers/ include/ --include=*.c --include=*.h
drivers/thunderbolt/apple.c        registers one
drivers/usb/typec/mux.c            implements the plumbing
drivers/usb/typec/mux.h            declares it
include/linux/usb/typec_mux.h      declares it

# no caller anywhere. apple_cio_start() runs ONLY from that callback.

The ‌missing ‌piece ‌is ‌in ‌the ​power-delivery ​driver: ​a ​stored ​switch ‌reference and ‌six ‌call ​sites ‌that ​fire ​on ‌cable ​transitions. ‌Porting ‌it ​made ​the ​link come ‌up ‌— ​and ​wedged ​the ​machine ‌at ‌7.26 ‌seconds ​into ​boot, ⁠before ⁠systemd arms ‌the ‌watchdog, ​with ‌no ​ssh ‌and ‌no ‌SysRq, ‌costing ‌a ​physical ​power-cycle. Resolving ​the ​switch ​reference ‌inside ‌probe() ‌walks ​a connector-to-controller ‌device ​link ​that ‌the ​device ‌tree ‌already ​flags ​as ​a dependency ‌cycle. ‌Moving ​the ​resolution ​to ​the ‌first ‌cable ‌event ​— ​a workqueue, ⁠post-boot, ⁠where ‌the ‌60-second ​watchdog ‌still ​covers ‌the ‌machine ‌— removed ‌the ‌risk ​entirely, ​and ​made ​the ​whole ‌feature ‌switchable ‌at ​runtime instead ‌of ​at ​boot.

Bug 2 — a shortcut coming due, and the address that identified it

Upstream ‌7.2 ​replaced ‌struct pci_dev *pdev ‌with ​struct device *dev ​in ​the host-interface ‌struct, ‌because ​a ​platform ​controller ​has ‌no ‌PCI ‌device ​at all. ​That ⁠change ⁠cascades ‌to ‌92 ​call ‌sites. ​I ‌added ‌dev ‌alongside ‌pdev instead ‌and ​left ​the ​call ​sites ​alone. ‌That ‌held ‌right ​up ‌until ​a ​non-PCI controller ‌actually ​probed:

[0000000000000350] pgd=0000000000000000
Internal error: Oops: 0000000096000004
pc : dma_pool_create_node+0x168/0x2c0
  dma_pool_create_node <- tb_ctl_alloc <- tb_domain_alloc <- tb_probe
  <- apple_nhi_probe <- of_platform_populate <- apple_cio_start

The ‌useful ‌detail ​is ​the ​fault ‌address. ‌It ​is ​0x350, ​not ​0 ‌— ‌because &nullpdev->dev ‌is ​not ​NULL, ⁠it ⁠is ‌offsetof(struct pci_dev, dev) ‌plus whatever ​member ‌the ​code ‌then ‌reads. ‌A ‌small ‌non-zero ​fault ​address ​in ​a driver ​that ‌was ‌just ‌ported ​from ‌PCI ​to ​platform ‌is ​a ‌signature, ‌not ​a mystery. ​The ​same ‌signature ‌appeared ​a ​second ​time ​one ‌layer ‌out, ‌at ​0xd0, in ​the ⁠networking ⁠module ‌— ‌because ​the ‌fix ​had ‌covered ‌the ‌Thunderbolt ‌driver directory ‌but ​not ​the ​inline ​helper ​in ‌the ‌public ‌header ​that ‌modules ​use.

The ​86 ‌references ​split ‌cleanly: ‌61 ​want ​a ​struct device * ‌and ‌are ​pure substitutions; ​25 ​are ​genuine ‌PCI ‌operations ‌— ​pci_read_config_dword, pcie_find_root_port, ​msix_enabled ⁠— ⁠confined ‌to ‌files ​a ‌platform controller ​never ‌enters. ‌Only ‌the ‌61 ‌were ​rewritten.

Bug 3 — the DROM is in the device tree, and nothing read it

With ​the ​crash ​fixed, ​the ‌domain ‌allocated, ‌the ​rings ‌started, ​and ​the software ‌connection ​manager ‌talked ‌to ​the ​hardware ​— ‌it ‌enumerated ​DisplayPort input ​resources ​on ​two ‌ports, ‌which ‌is ​only ​possible ⁠over ⁠a ‌working ‌control channel. ​Then ‌it ​stopped:

nhi: using software connection manager
nhi: 0:5: DP IN resource available
nhi: 0:6: DP IN resource available
nhi: No valid host DROM in the device tree
nhi: stopping RX ring 0 ... freeing TX ring 0

The ‌device ‌tree ‌was ‌not ‌at ​fault. ​Every ​host-interface ​node ​carried ‌a ‌real 76-byte ‌apple,thunderbolt-drom ​with ‌a ​distinct ​UID. ‌Apple ​Silicon ‌has ‌no DROM ​EEPROM ​— ​the ‌boot ‌loader ​supplies ​it ​as ​a ‌property ‌— ‌and ​7.1.13's ​EEPROM code ⁠had ⁠no ‌path ‌to ​read ‌one, ​so ‌the ‌host ‌router's ‌descriptor ‌stayed ​NULL. ​Two small ​functions ​and ​one ‌branch. ‌It ‌only ​compiles ‌after ​bug ​2 ‌is ​fixed, ‌because it ‌reaches ​the ​controller ​through ‌the ‌field ​that ​fix ​introduced.

Bug 4 — an upstream ordering bug in a path nobody had exercised

The ​domain ‌came ‌up, ‌both ​lanes ​trained, ⁠the ⁠peer ‌Mac ‌identified ​itself ‌— ​and the ‌network ‌driver ‌could ‌not ‌allocate ​a ​transmit ​ring:

nhi: allocating TX ring -1 of size 256
nhi: error -EINVAL: request_irq(0) apple_cio_ring_irq 0x0
thunderbolt-net thunderbolt0: failed to allocate Tx ring

-1 ​is ​"allocate ‌any ‌free ‌hop". ​The ‌generic ​ring ​allocator ‌requested ​the ‌ring interrupt ‌before ​allocating ​the ​hop ‌ID, ‌while ​Apple's ​controller ​looks ​its interrupt ‌up ‌as ‌tx_irqs[ring->hop]. ​With ​the ⁠hop ⁠still ‌-1, ‌that ​is ‌an out-of-bounds ​read, ‌and ‌then ‌a ‌request ‌for ​whatever ​it ​found.

This ​one ​is ‌not ‌a ‌backport ​artefact. ‌The ​donor ​tree ‌has ​the ‌identical ‌ordering and ​the ​identical ​indexing ‌— ‌the ​PCI ​controller ​hands ​out ‌MSI-X ‌vectors ‌and never ​indexes ​by ⁠hop, ⁠so ‌it ‌is ​indifferent, ‌and ​the ‌Apple ‌ring-interrupt ‌path had ‌simply ‌never ​been ​run ​with ​the ​network ‌driver. ‌Swapping ‌the ​two ‌calls ​(and unwinding ​the ‌hop ​on ‌failure) ‌is ​safe ​for ​both.

Results

Settings: ‌MTU ‌9000 ​both ​ends, ​direct ​cable, ‌no ‌other ‌traffic ​on ​the interfaces. ⁠Throughput ⁠is ‌a ‌warm ​floor ‌from ​repeated ‌5-second ‌runs ‌— ‌three ‌per configuration, ​reported ​as ​the ​observed ​range, ‌not ‌a ‌first-run ​pair. ‌Latency is ​200 ​ICMP ‌samples ​at ‌a ‌10 ​ms ​interval.

Latency, 200 ICMP samples at a 10 ms interval, 0% loss. Payload sweep over USB4 is flat: 64 B -> 0.257 ms, 1024 B -> 0.286 ms.
pathmin (ms)avg (ms)max (ms)stddev (ms)
over USB40.1600.2540.3860.046
same host over the LAN3.5564.4878.7710.919

That ​is ‌17.7x ‌lower ​latency, ​20x ​tighter ​jitter ‌than ‌the ‌same ​two ​machines over ⁠the ⁠LAN.

TCP throughput, iperf3 over 5-second runs, three runs per configuration, reported as the observed range.
directionstreamsobserved range (Gbit/s)
B -> A111.0 - 11.9
B -> A411.4 - 11.8
B -> A811.0 - 11.5
A -> B410.5 - 11.7

Symmetric, ‌and ‌stream ​count ‌does ​not ‌matter: ‌one ‌stream ‌is ‌as ​fast ​as ​eight. That ​rules ​out ‌a ‌TCP ‌window ​or ‌a ​single-core ​send ‌path ​and ‌puts ‌the ​ceiling ​in the ​link ‌or ‌the ​driver.

A defect the throughput number hides

MTU ​9000 ​is ​configured ‌and ‌accepted ‌on ​both ​ends, ⁠and ⁠TCP ‌runs ‌at ​full ‌speed. But ​any ‌packet ‌above ‌roughly ‌8198 ‌bytes ​that ​is ​not ​segmented ​by ‌the ‌stack is ‌dropped ​completely:

ping -s 8170   (L3 8198)   0.0% loss
ping -s 8192   (L3 8220)   100.0% loss

The ‌cutoff ​is ​consistent ‌with ​the ‌transport's ‌4096-byte ​frames ​— ​two ‌frames plus ‌headers. ​TCP ​never ​notices ​because ‌segmentation ‌offload ‌produces ​segments below ​the ⁠ceiling, ⁠which ‌is ‌exactly ​what ‌makes ​this ‌worth ‌writing ‌down: ‌a throughput ‌benchmark ​will ​not ​find ​it, ​and ‌anything ‌sending ‌large ​unsegmented datagrams ‌over ​the ​link ‌will ​black-hole ‌silently.

Two traps that cost more time than the bugs

A ‌failed ​network ​ring ​leaves ‌a ‌cross-domain ​properties ​request ​stuck ​forever, and ‌that ‌jams ‌the ​workqueue ​system. ⁠The ⁠cascade ‌reaches ‌grub2-editenv, ​which lands ‌in ​uninterruptible ‌sleep ‌— ‌so ‌the ‌command ​that ​arms ​a ​one-shot ​boot entry ‌silently ‌fails, ‌and ​systemctl reboot ‌does ​nothing ​at ‌all. ​Two "reboots" ‌appeared ‌to ​succeed ​while ​uptime ‌kept ‌climbing ​and ​I ​kept ​testing ‌a kernel ‌I ‌thought ​I ​had ⁠replaced. ⁠Verify ‌a ‌reboot ​with ‌uptime -s, ​never ‌with "the ‌machine ‌answered ‌ssh ‌again".

Separately, ​and ​not ​caused ​by ​any ‌of ‌this: ‌the ​stock ‌distribution ​kernel ​takes a ‌display-driver ​oops ‌during ‌boot ​that ​kills ​a ‌udev ‌worker ​inside ​the ​module loader. ​Whatever ‌driver ‌happens ‌to ​be ​loading ⁠at ⁠that ‌moment ‌never ​appears. One ‌boot ​loses ‌both ‌network ‌interfaces, ‌the ‌next ​loses ​half ​of ​USB, ​the ‌next is ‌fine ‌— ​which ‌reads ​for ​days ‌as ​flaky ‌hardware. ‌Measured ​across ​boots: ​three USB ‌root ‌hubs ​instead ​of ​six, ​and ‌the ‌external ‌disk ​holding ​the ⁠kernel ⁠build tree ‌simply ‌absent. ​Blacklisting ‌the ​display ‌driver ‌gives ‌six ‌root ‌hubs ​and zero ​oopses, ​at ​the ​cost ‌of ‌the ‌desktop ​compositor ‌only. ​Do ​not ‌attribute ​that class ‌of ‌failure ​to ​your ​own ‌kernel ‌without ​booting ​a ​stock ​one ‌first.

Where it stands

TrackStatus
USB4 host-to-hostUp. 11.9 Gbit/s, 0.254 ms, symmetric, zero oopses through a full benchmark run.
GPU on same kernelIntact — the whole point of backporting down rather than forward.
Link efficiency30% of 40 Gbit/s. Open. Needs the cost decomposed, not guessed at.
RDMANot yet on this host — no verbs provider built for this kernel. Proven on a different pair over the same transport.

The ‌backport ‌is ​driven ​by ⁠an ⁠idempotent ‌script ‌rather ​than ‌a ​patch ‌series, with ‌every ‌hunk ‌anchored ‌on ​verbatim ​text ​and ​asserted ​to ‌match ‌exactly ‌once ​— it ‌refuses ​rather ​than ‌guesses ​when ‌a ‌tree ​does ​not ​match. ‌That ‌mattered ​more than ​expected: ​the ​source ‌moved ‌under ‌it ​constantly, ​and ⁠a ⁠step ‌that half-applies ‌to ​a ‌kernel ​you ‌then ‌boot ‌is ‌a ‌much ​worse ​outcome ​than ​one ​that stops ‌and ‌says ‌why.

Sources: ​the ‌AsahiLinux/linux ​branches (the ​GPU ‌driver ​lives ‌on ‌7.1.x; ​the ​USB4 ​host ‌controller ‌on ​the ​7.2 work-in-progress ​branch), ​and ‌the ‌Asahi Linux project's ‌reverse-engineering ​of ​the ⁠ACIO ⁠and ‌ATC ‌PHY ​blocks, ‌without ​which none ‌of ‌this ‌exists.

boracode.ai · Ronald Adonyo · apple-usb4-asahi-backport
content hash d7c1d2c61ccf
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.
Backporting Apple USB4 into an Asahi kernel, and what broke on the way — boracode