Skip to content

tun: opt-in batched reads to recover GSO batching for non-GSO traffic (VXLAN etc.)#69

Open
cagataygurturk wants to merge 1 commit into
tailscale:tailscalefrom
cagataygurturk:batched-tun-reads
Open

tun: opt-in batched reads to recover GSO batching for non-GSO traffic (VXLAN etc.)#69
cagataygurturk wants to merge 1 commit into
tailscale:tailscalefrom
cagataygurturk:batched-tun-reads

Conversation

@cagataygurturk

@cagataygurturk cagataygurturk commented Jun 16, 2026

Copy link
Copy Markdown

Problem

Userspace WireGuard only reaches multi-gigabit speeds because the kernel hands the tun device a single large GSO superframe per read call. The encrypt and send pipeline then splits that frame and coalesces the segments, so the per-packet cost of a syscall plus crypto is amortized over roughly 45 segments at once.

That batching breaks down for any traffic the tun cannot describe with a segmentation offload. The most common case is UDP encapsulated tunnels such as VXLAN and Geneve. The tun has no tunnel segmentation offload, because the virtio_net_hdr has no UDP tunnel GSO type, so the kernel pre-segments the inner stream into individual datagrams and delivers them to the tun one datagram per read. The pipeline then performs roughly one sendmmsg syscall per 1300 byte datagram, which is about 45 times more syscalls than the TCP path needs for the same number of bytes.

This shows up most often with Kubernetes overlay networks running over a tailnet underlay, for example Cilium, Calico or Flannel in VXLAN mode, but it is a general problem. Any burst of independent same-flow datagrams that the kernel cannot batch into a GSO frame hits the same wall.

A CPU profile of the sender at the capped rate spends about 48 percent of its time in sendmmsg on the single per-peer sender goroutine and only about 3 percent in crypto, so the bottleneck is one syscall per packet rather than the cipher. Throughput does not improve with parallel streams to the same peer, because they all funnel through that one serial sender.

Change

When TS_TUN_BATCHED_READ is set to 1, Read does an initial blocking read as before, then drains any packets that are already queued on the tun using non-blocking reads, packing them into the same bufs batch. The downstream pipeline then coalesces them the same way it coalesces a GSO superframe.

The drain does not add latency, because it only collects packets that are already waiting and stops as soon as a read returns EAGAIN. It preserves order, because reads stay sequential under the existing readOpMu lock. It reserves maxGSOSegmentsPerRead slots (64) before each additional read so that a drained GSO frame can never overflow the remaining buffers. The feature is off by default, and there is no behavior change unless the environment variable is set.

Results

These numbers come from a VXLAN over WireGuard path with a single TCP stream between two 8 vCPU nodes in the same region.

throughput
off (default) about 0.77 Gbit/s
TS_TUN_BATCHED_READ=1 about 2.5 Gbit/s
raw TCP over WireGuard, for reference about 3.3 Gbit/s

Instrumentation confirms the mechanism. The average send batch grows from 1 packet to around 57, the UDP GSO factor grows from 1 to around 31, and the number of sendmmsg syscalls drops by roughly 10 times. Raw TCP throughput is unchanged, since it already received GSO frames before this change. Sustained 30 second transfers are byte for byte identical end to end, and the result holds with rx-udp-gro-forwarding enabled.

Review notes

The drain issues a raw unix.Read through tunRawConn.Control while the runtime poller still owns the same fd through tunFile.Read. I believe this is safe. poll.FD.Read always attempts the syscall before parking on the poller, so a stale readiness flag costs at most one extra EAGAIN, and edge triggered epoll fires again whenever an emptied fd receives new data, regardless of how the fd was drained. That reasoning still leans on runtime internal behavior and epoll edge semantics, so it deserves a careful look. Keeping the feature behind an environment variable that defaults to off is intentional, so it can soak before anyone considers making it the default.

If you would rather surface this as a method on the device or tun instead of an environment variable, I am happy to change it.

Related

The main tracking issue is tailscale/tailscale#11026. That issue was effectively closed out around the kernel fix in 6.8.5 and the TS_TUN_DISABLE_UDP_GRO workaround, both of which address the packet corruption it reported. Neither addresses the throughput, which stays roughly five times below raw WireGuard because the sender still sends one datagram per syscall. This change is the part that closes that throughput gap. The same symptom shows up in tailscale/tailscale#11320 (VXLAN over Tailscale, high CPU and no speed) and tailscale/tailscale#18372 (Docker Swarm overlay over Tailscale).

This is the sender half of the problem. The receiver half is the companion change #70, which lets UDP GRO coalesce the zero-checksum datagrams that encapsulators like Cilium emit. Each change is independent and helps on its own, and both are needed to reach the full throughput, because a single TCP stream runs at the speed of the slower side. Unlike #70, this change has no kernel-version dependency.

The Linux tun read path returns at most one packet per read for traffic the
kernel cannot hand over as a GSO superframe. The common case is UDP
encapsulated tunnels such as VXLAN or Geneve, whose datagrams arrive at the
tun already segmented because the tun cannot advertise tunnel segmentation
offload. Each datagram then traverses the encrypt and send pipeline on its
own, costing roughly one sendmmsg syscall per packet, whereas bulk TCP
amortizes around 45 segments per read.

When TS_TUN_BATCHED_READ is set to 1, after the initial blocking read we drain
any packets already queued on the tun with non-blocking reads into the same
batch, so the downstream pipeline coalesces them the way it would a GSO
superframe. The drain does not add latency, since it only collects packets
that are already waiting and stops at EAGAIN. It preserves order, and it
reserves maxGSOSegmentsPerRead slots so a drained GSO frame cannot overflow
the buffers.

On a VXLAN over WireGuard path with a single TCP stream between two 8 vCPU
nodes, throughput went from about 0.77 to about 2.5 Gbit/s, close to the raw
TCP over WireGuard ceiling, with no regression to the TCP fast path. The
feature is off by default and opt-in while the raw read and poller interaction
gets broader review.

Signed-off-by: Cagatay Gurturk <963018+cagataygurturk@users.noreply.github.com>
@illotum

illotum commented Jun 25, 2026

Copy link
Copy Markdown

Unfortunately this will have to wait. #49 and its followups change the shape of tun reads significantly, and I'd rather we build on the new than delay it with the rebase. Performance gains and your approach will have to be re-evaluated after #62. One note right away, I believe newer kernels allow batches larger than 64.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants