socket, iroh: coalesce receives with UDP_GRO and stagger dial targets - #7
Conversation
|
Fantastic to see this! I'll do some of my own testing and let you know what I find. |
|
So that's my final numbers, my arm gets stiff from holding my laptop into the fan to make sure it doesn't get power throttled during measurements on this hot evening. Cheers. |
|
i did my own testing on a GCE c3-standard-8 box -- this almost all looks good, couple issues: e212207: there's a race on the cursor. I know the comment says quic-go reads from a single Fine either way on the fix: lock the cursor, or keep it single-reader, say so on the type, 288e939: your CPU number result seems better than your commit message says — I got What's holding it is what seems to be a regression at the 32B datagram size: -2.99% and 847be27: small one. GetBuf(n) does *p = (*p)[:n] against a 65536-byte pool and nothing Want to pull e212207 and 288e939 onto a separate branch for further iteration so the rest can go in? |
The first eleven commits of #7, by Jörg Thalheim, based on v0.1.0. They reuse buffers on the relay send and receive paths and coalesce relay datagrams into batch frames.
|
I just merged 11 of the commits. |
|
I'm going to rebase this branch/PR so it just contains the unmerged commits (note: this means my shas in the previous comment are outdated). |
The last of #7's reviewed commits, by Jörg Thalheim. It needed to move below the three that are still open in that PR.
|
Merged most of this. Two of the three things I flagged are now fixed on our side, nothing needed from you:
What's left is the two real ones:
Also, CI is red on something unrelated to any of that: That dial-staggering commit is new since I last looked, and it's a different topic from the relay work. Happy to review it — it'd just be easier as its own PR, so the two remaining commits here can land without waiting on it. |
The one commit from #7 that is ready to land, by Jörg Thalheim. It was waiting on GetBuf holding any length (7df95ed), so a max-size batch no longer panics in enqueue. The two socket commits are still open on that PR, and the dial staggering one is held back: it drops the early-data window from ConnectEarly whenever a peer has more than one dial target.
|
Rebased onto
The two socket commits are unchanged from my last comment: GRO needs the 32-byte regression addressed (an opt-out for the small-datagram path), and the batch commit needs the cursor race resolved either way. One data point on the latter — the single-reader premise does hold for our own use, On It removes the application's early-data window from
That 252ms is a healthy path paying the stagger, and the caller can't prevent it. Racing the dials rather than the handshakes would keep Happy to push the multi-target 0-RTT test to Last thing: now that thirteen of the sixteen commits have landed, the title and description describe mostly-merged work — the relay ~3x and direct +24% numbers are for changes that are already in. Could you retitle to cover what's actually left (something like "socket: receive with UDP_GRO and pass whole batches to ReadFrom") and trim the description to match? Happy to do it myself if you'd rather, I just didn't want to rewrite your benchmark claims without asking. |
IpTransport.Serve did one recvmsg per QUIC packet. With UDP_GRO the
kernel hands over runs of equally sized datagrams from one peer in a
single read (about 8 per call against a GSO sender on loopback). Read
into a pooled 64 KiB buffer, split by the GRO segment size from the
control message and return the buffer to the pool once ReadFrom has
copied out the last segment. Other platforms keep the per-packet loop.
AMD Ryzen AI 7 350, GOMAXPROCS=4, interleaved n=9, per 64 KiB op:
ConnStreamThroughput 905 -> 977 MB/s (+8%)
183 -> 160 cpu-us (-12%)
0 allocs/op unchanged
Both the GRO receive loop and the relay transport split a multi-segment
read into one recvBatch per datagram, so every QUIC packet still cost a
channel send, a select wakeup and an address lookup in ReadFrom. Queue
the whole read with its stride instead and let ReadFrom hand out one
segment per call from a cursor (quic-go reads from a single goroutine
per Transport), resolving the path address once per batch.
AMD Ryzen AI 7 350, GOMAXPROCS=4, interleaved n=9, per 64 KiB op:
ConnStreamThroughput 150 -> 140 cpu-us (-6%), throughput ~1 GB/s unchanged
RelayConnStreamThroughput 297 -> 308 MB/s (+4%)
424 -> 406 cpu-us (-4%)
connectEarly walked the dial targets in turn, so a peer advertising N unreachable direct addresses cost dialAttemptTimeout each before the relay path was tried. Start a handshake per target dialAttemptDelay apart and take the first that completes; a target already proven by the path selector still short-circuits to the 0-RTT window.
The GRO receive loop had no test: it is compiled out off Linux and on Linux nothing drove a coalesced read. Send a UDP_SEGMENT batch to a GRO-enabled IpTransport over loopback and check Serve queues it as one strided recvBatch that splits back into the datagrams sent.
The GRO receive loop is a second loop, so it does not inherit the arrival-address bookkeeping the ordinary one does; a run coalesced by the kernel must still record the local address it reached. Without the record a reply to that peer leaves from whatever address the route picks, which is the failure the arrival-address table exists to prevent.
|
I'm going to rebase this and rework it so the remainder is mergable, FYI. Thanks for your contribution! |
Rebased onto
mainand narrowed to the part that is still outstanding: the relay-path work from the original series has already merged, so the numbers that used to head this description no longer describe this branch. What is left is the direct path.Three commits from @Mic92, plus one test:
socket: receive with UDP_GRO on Linux— onerecvmsgreturns a run of equally sized datagrams from the same peer instead of one datagram per syscall.socket: pass whole receive batches to ReadFrom— the run is queued as a single stridedrecvBatchand handed out a datagram at a time from a cursor, rather than one batch per datagram.iroh: stagger dial targets instead of trying them sequentially—Connectstarts a handshake per target 250 ms apart and takes the first that completes, instead of spending a full handshake timeout per address before falling back to the relay. This fixes connect latency to peers advertising unreachable direct addresses.socket: test that GRO records arrival addresses— see the rebase note below.Measurements
The 32-byte regression raised against the first commit does not reproduce. Re-measured with the GRO commit isolated against its parent,
-count=10, on three hosts, each with a base-vs-base null control run around the comparison to establish its own noise floor:ConnMessageRate/size=32ConnMessageRateWritev/size=32/batch=2ConnMessageRateWritev/size=32/batch=8ConnDatagramMessageRate/size=32(negative = faster; all cells shown are p < 0.01,
~= no significant difference)Small datagrams get faster on the stream path, on every host. The earlier −24% came from an unpinned big.LITTLE machine scheduling the two halves of the benchmark onto different core types between runs; pinned, it does not appear. The one real cost is
ConnDatagramMessageRateat 32 bytes on x86, +3.2% — that host's null control was clean on that cell, so it is a genuine effect, though neither of the other two hosts reproduces it. The QUIC datagram path has no stream reassembly for the receive-side batching to pay back, which is the likely reason.Rebase note
mainnow replies to each peer from the local address its datagrams arrived at (#24), which the ordinary receive loop maintains as it reads.serveGROis a second receive loop, so it does not inherit that bookkeeping — merged as-is, GRO on a wildcard-bound Linux socket would silently undo #24 and replies would again leave from whatever address the route picks. The conflict is invisible togit rebase: the damage is in a loop neither side edits.The resolution records the arrival address once per coalesced read — a coalesced run is by construction one peer's, so one address covers every datagram in it — and widens the control buffer to leave headroom beside the GRO control message. The final commit adds a test that binds wildcard and GRO together and asserts the arrival address survives a coalesced run; without the record it fails, as do four of #24's own tests.
Verified with
-raceon linux/arm64 and darwin/arm64;internal/socketcross-vets on linux/amd64, linux/arm64, darwin/arm64, windows/amd64, freebsd/amd64 and js/wasm.