Skip to content

tun: coalesce zero-checksum UDP datagrams in UDP GRO (VXLAN/Cilium)#70

Open
cagataygurturk wants to merge 1 commit into
tailscale:tailscalefrom
cagataygurturk:udp-gro-zero-checksum
Open

tun: coalesce zero-checksum UDP datagrams in UDP GRO (VXLAN/Cilium)#70
cagataygurturk wants to merge 1 commit into
tailscale:tailscalefrom
cagataygurturk:udp-gro-zero-checksum

Conversation

@cagataygurturk

Copy link
Copy Markdown

Problem

UDP GRO will not coalesce a datagram whose UDP checksum does not validate. That is the right call for a corrupt packet, but an IPv4 UDP datagram is allowed to carry a zero checksum field, which per RFC 768 means the sender computed no checksum at all. A zero checksum is not a failed checksum, it is the absence of one, and it must not be validated.

VXLAN encapsulators commonly emit the outer UDP datagram with a zero checksum. Cilium does this by default, because its eBPF datapath sets the tunnel without the checksum flag. The result is that every encapsulated datagram fails the validation in coalesceUDPPackets and is skipped, so UDP GRO never engages for that traffic and the receiver ends up writing one datagram per writev. On a Cilium over Tailscale path the receiver coalescing factor is exactly one.

Change

Treat an IPv4 UDP datagram whose checksum field is zero as valid for coalescing, rather than running it through checksum validation. The coalesced frame already carries NEEDS_CSUM with the pseudo-header sum, so when the kernel splits it back into segments it computes a correct checksum for each one. The datagrams that come out the far side carry a real checksum, not a zero, so nothing downstream is weakened.

IPv6 is left as is, since a zero UDP checksum is not generally valid there.

Results

VXLAN over WireGuard, single TCP stream, kernel 7.0. On its own this change takes the receiver from one datagram per write up to roughly thirty, and throughput from about 0.54 to about 0.71 Gbit/s, with retransmits dropping to single digits. The remaining limit at that point is the sender, which still sends one datagram per syscall. Combined with batched tun reads from the companion change, the path reaches about 2.66 Gbit/s.

Kernel dependency

This coalescing has the same kernel requirement as UDP GRO for encapsulated traffic in general. On kernels before 6.8.5 the vxlan and geneve drivers mishandle the coalesced frame, which is the corruption tracked in tailscale/tailscale#11026, and that affects valid-checksum encapsulation too. Because this change makes zero-checksum encapsulation take the same coalescing path, it inherits the same requirement, and it is governed by the same TS_TUN_DISABLE_UDP_GRO escape hatch. On 6.8.5 and later it is safe.

Related

This is the receiver half of the VXLAN over Tailscale throughput problem. The sender half is the companion change that batches tun reads. Each 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.

UDP GRO refuses to coalesce a datagram whose checksum does not validate. An
IPv4 UDP datagram is allowed to carry a zero checksum field, which per RFC
768 means the sender computed no checksum, and that is not an error. VXLAN
encapsulators such as Cilium commonly emit the outer UDP datagram with a zero
checksum, so today every one of them is treated as an invalid checksum and
skipped. That defeats UDP GRO for the traffic and leaves the receiver writing
one datagram at a time.

This treats an IPv4 UDP datagram with a zero checksum field as valid for
coalescing rather than validating it. The coalesced output already sets
NEEDS_CSUM with the pseudo-header sum, so the kernel computes a correct
checksum for each segment when it splits the frame, and the resulting
datagrams carry a real checksum rather than zero. IPv6 is unchanged, since a
zero UDP checksum is not generally valid there.

This coalescing depends on the same kernel support as UDP GRO in general. On
kernels before 6.8.5 the vxlan and geneve drivers mishandle the coalesced
frame, the same issue that affects valid-checksum encapsulated traffic, so it
is subject to the existing TS_TUN_DISABLE_UDP_GRO escape hatch and the same
minimum kernel.

Measured on a VXLAN over WireGuard path, single TCP stream, kernel 7.0: the
receiver coalesces around thirty datagrams per write instead of one,
throughput rises from about 0.54 to about 0.71 Gbit/s on its own, and
combined with batched tun reads it reaches about 2.66 Gbit/s.

Signed-off-by: Cagatay Gurturk <963018+cagataygurturk@users.noreply.github.com>
Comment thread tun/offload_linux.go
iov := &wi.iovs[item.outputIdx]
if len(*iov) == iovSinglePacketLen {
if item.cSumKnownInvalid || !checksumValid((*iov)[iovHeadPacketIdx], item.iphLen, unix.IPPROTO_UDP, isV6) {
if item.cSumKnownInvalid || !udpCsumOKForGRO((*iov)[iovHeadPacketIdx], item.iphLen, isV6) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

checksumValid takes the proto field, what benefit do you see in a UDP-specific wrapper?

Comment thread tun/offload_linux_test.go
// carrying a zero (absent, per RFC 768) checksum are coalesced by UDP GRO, while
// a genuinely bad checksum is still refused. Zero-checksum outer UDP is the
// common shape for VXLAN-encapsulated traffic (e.g. Cilium).
func Test_handleGRO_zeroChecksumUDPCoalesces(t *testing.T) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We have a table test in Test_handleGRO that can be extended with this case. Consider also extending Fuzz_handleGRO.

@jwhited

jwhited commented Jun 25, 2026

Copy link
Copy Markdown
Member

The question to be answered here is: are we ok with breaking symmetry with GSO?

There's no way to represent "I coalesced some datagrams together with zero value UDP header checksums" when handing to the kernel, so if the packet ends up being segmented later you get different outputs (valid, nonzero UDP header checksum).

The kernel does not currently GRO UDP datagrams together with zero value checksums, and they justify this decision around not breaking GSO symmetry.

So, are we ok with breaking GSO symmetry? Why or why not?

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.

3 participants