You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
tun: host packet loop throughput leads after #478 #479
Follow-up to #478 (advanced network mode, host side). The tun path's per-packet loop allocates nothing, but with a faster CH vsock backend it is now the limit of the tun path. These are leads, measured on two bare-metal hosts with interleaved arms; none are fixed yet.
Numbers (4 GiB TCP each way, local exit unless noted)
CH vsock backend
tun up / down Gbit/s
proxy up / down Gbit/s
current dev
2.57–2.61 / 2.10–2.20
4.22–5.68 / 2.10–2.18
rewritten test build
2.57–2.58 / 2.50
4.08–4.94 / 4.01–4.35
Leads
Upload stops at 2.6 Gbit/s per stream for every CH build and both exits. Through a remote exit sandboxd uses about 0.6 core (1.9 s/GiB), and a 16 MiB guest send buffer only reaches 2.77–2.84 Gbit/s with the queue full, so it is a rate limit in the per-stream pipeline, not CPU and not the TCP window. Suspects: one goroutine per stream that reads a frame and then blocks in sendmsg (106 µs per 64 KB frame with a local veth exit, 36 µs to a remote exit); the ACK return path (every ACK is its own guest frame: about 140k per GiB, recvmmsg returns about 4 per call). Next: pipeline read and send, then measure per-stage latency.
Download sends one sendto per guest ACK frame. Frames read in one vsock read could go out in one sendmmsg.
Remote-exit download is 1.47–1.73 Gbit/s at 3.9–4.0 s/GiB.recvmmsg returns about 15 datagrams per call there instead of about 30, so only 5.2 segments merge per guest frame (7.3 locally) and the guest ACKs every ~10 KB. Check NIC GRO for the VXLAN UDP flow and whether the merge state should survive across receive batches.
With a local veth exit, sandboxd's CPU includes the exit's decapsulation, NAT and the receiver's TCP stack (veth runs them inside sendmsg); compare on a gateway exit before reading local-exit CPU numbers as sandboxd's own.
Measured three send-path variants against the #478 head on two bare-metal hosts. Arms were interleaved by swapping only the sandboxd binary, 4 GiB per leg, on a VM restored on a test CH build with a faster vsock backend (the build where the host side limits the tun path).
pipelined sender goroutine + sendmmsg of all ready frames
2.21–2.23
4.11–4.24
2.42–2.53 / 1.53–1.62
pipelined, one frame per send
2.19–2.22
2.56–2.60
2.46–2.51 / 1.60–1.68
serial, batching frames already in the reader's buffer
2.60–2.62
2.56–2.59
2.57–2.60 / 1.56–1.72
serial, batching while the socket has queued bytes (SIOCINQ)
2.11–2.14
2.54–4.40
2.54–2.56 / 1.60–1.71
Findings:
Remote-exit upload is bimodal, about 2.6 or about 4.2 Gbit/s, independent of the build. The unchanged sandboxd: advanced network mode host side (VXLAN to exits, overlay addresses, root-only switch) #478 binary hit 4.20/4.23 in one run and 2.56–2.60 in others. Each connection's flow hash picks the outer source port and with it the NIC queue and CPU at the receiving exit. The exit host was also shared with another workload during part of the runs. So the earlier +60% for the pipelined variant is not attributable to it.
Every variant that overlaps or batches sends lowers local-exit upload by about 15% (2.6 → 2.1–2.2 Gbit/s), and costs sandboxd more CPU (4.4 → 5.4–6.0 s/GiB). Pipelining alone does it too.
Downloads did not move.
Nothing from this is adopted. Next steps if this is picked up again:
pin the outer source port, or measure per-queue at the exit, to make remote upload reproducible before comparing builds;
measure on a gateway exit, not a local veth one;
look at the guest side: with a 16 MiB guest send buffer the queue fills and upload stays at about 2.8, so the per-stream limit may sit in silkd/CH rather than in sandboxd.
More leads from the static audit of 2026-11-06 (PR #481), unmeasured:
One extra 64 KiB copy per guest frame (tunnet/router.go read path): io.ReadFull copies each frame from the bufio reader into the link's buffer; Peek/Discard would forward from the bufio buffer directly. The docs' "one copy" holds only for the payload rewrite.
USO is offered as USO4 only (router.gosegmentable): the kernel enables UDP segmentation offload on tun0 only with both USO4 and USO6, so a guest never sends a UDP GSO frame and the host's UDP GSO path never runs. One token, but it needs a QUIC upload A/B.
Per-packet activity stamp (fix: count TUN traffic as sandbox activity #480): every admitted inbound packet and successful send stores the claim's last-activity time; it now reuses the flow check's timestamp, but the atomic store on a shared cache line is per packet in both directions.
Queue bound by frames, not bytes (sendQueue): each queued inbound frame holds a 64 KiB pool buffer; MTU-sized buffers for frames the coalescer does not merge would cut the worst case.
Lead 6 is done: #539 (v0.1.28) offers USO6 next to USO4, so the guest kernel enables UDP segmentation offload on tun0. Hardware A/B (8 runs per arm): guest UDP_SEGMENT to a server netns went from 972 to 2438 Mbit/s, and to the host veth from 878 to 1897 Mbit/s. Plain UDP did not change, and no frames were dropped. A guest frame now carries 42 datagrams.
Still open: leads 1–5, 7 and 8. Lead 5 (the extra 64 KiB copy per guest frame through io.ReadFull) is still in tunnet/router.go.
Activity
CMGS commentedon Nov 5, 2026
Measured three send-path variants against the #478 head on two bare-metal hosts. Arms were interleaved by swapping only the sandboxd binary, 4 GiB per leg, on a VM restored on a test CH build with a faster vsock backend (the build where the host side limits the tun path).
sendmmsgof all ready framesSIOCINQ)Findings:
Nothing from this is adopted. Next steps if this is picked up again:
CMGS commentedon Nov 6, 2026
More leads from the static audit of 2026-11-06 (PR #481), unmeasured:
tunnet/router.goread path):io.ReadFullcopies each frame from the bufio reader into the link's buffer;Peek/Discardwould forward from the bufio buffer directly. The docs' "one copy" holds only for the payload rewrite.router.gosegmentable): the kernel enables UDP segmentation offload on tun0 only with both USO4 and USO6, so a guest never sends a UDP GSO frame and the host's UDP GSO path never runs. One token, but it needs a QUIC upload A/B.sendQueue): each queued inbound frame holds a 64 KiB pool buffer; MTU-sized buffers for frames the coalescer does not merge would cut the worst case.CMGS commentedon Nov 10, 2026
Lead 6 is done: #539 (v0.1.28) offers USO6 next to USO4, so the guest kernel enables UDP segmentation offload on tun0. Hardware A/B (8 runs per arm): guest UDP_SEGMENT to a server netns went from 972 to 2438 Mbit/s, and to the host veth from 878 to 1897 Mbit/s. Plain UDP did not change, and no frames were dropped. A guest frame now carries 42 datagrams.
Still open: leads 1–5, 7 and 8. Lead 5 (the extra 64 KiB copy per guest frame through
io.ReadFull) is still intunnet/router.go.