From f2508549a0d06f3856f61299a3d8741fd50726ed Mon Sep 17 00:00:00 2001 From: Cagatay Gurturk <963018+cagataygurturk@users.noreply.github.com> Date: Tue, 16 Jun 2026 14:36:01 +0200 Subject: [PATCH] tun: coalesce zero-checksum UDP datagrams in UDP GRO 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> --- tun/offload_linux.go | 17 ++++++++++++-- tun/offload_linux_test.go | 49 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 64 insertions(+), 2 deletions(-) diff --git a/tun/offload_linux.go b/tun/offload_linux.go index a72cad680..152370e4d 100644 --- a/tun/offload_linux.go +++ b/tun/offload_linux.go @@ -495,6 +495,19 @@ func checksumValid(pkt []byte, iphLen, proto uint8, isV6 bool) bool { return ^Checksum(pkt[iphLen:], cSum) == 0 } +// udpCsumOKForGRO reports whether the UDP datagram may be coalesced. An IPv4 UDP +// datagram with a zero checksum field carries no checksum per RFC 768 and must +// not be validated; VXLAN encapsulators (e.g. Cilium) commonly emit these. +func udpCsumOKForGRO(pkt []byte, iphLen uint8, isV6 bool) bool { + udpCsumOff := int(iphLen) + 6 + if !isV6 && udpCsumOff+2 <= len(pkt) { + if pkt[udpCsumOff] == 0 && pkt[udpCsumOff+1] == 0 { + return true + } + } + return checksumValid(pkt, iphLen, unix.IPPROTO_UDP, isV6) +} + // coalesceResult represents the result of attempting to coalesce two packets. type coalesceResult int @@ -511,11 +524,11 @@ func coalesceUDPPackets(pkt []byte, item *udpGROItem, wi *groToWrite, isV6 bool) headersLen := int(item.iphLen) + udphLen 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) { return coalesceItemInvalidCSum } } - if !checksumValid(pkt, item.iphLen, unix.IPPROTO_UDP, isV6) { + if !udpCsumOKForGRO(pkt, item.iphLen, isV6) { return coalescePktInvalidCSum } *iov = append(*iov, pkt[headersLen:]) diff --git a/tun/offload_linux_test.go b/tun/offload_linux_test.go index b4c9aead0..209a2972a 100644 --- a/tun/offload_linux_test.go +++ b/tun/offload_linux_test.go @@ -274,6 +274,55 @@ func flipUDP4Checksum(b []byte) []byte { return b } +// zeroUDP4Checksum clears the UDP checksum field, which per RFC 768 signals +// "no checksum" for IPv4 UDP. VXLAN encapsulators commonly emit such datagrams. +func zeroUDP4Checksum(b []byte) []byte { + at := virtioNetHdrLen + 20 + 6 // 20 byte ipv4 header; udp csum offset is 6 + b[at] = 0 + b[at+1] = 0 + return b +} + +// Test_handleGRO_zeroChecksumUDPCoalesces verifies that IPv4 UDP datagrams +// 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) { + pktsIn := [][]byte{ + zeroUDP4Checksum(udp4Packet(ip4PortA, ip4PortB, 100)), // udp4 flow 1, no checksum + zeroUDP4Checksum(udp4Packet(ip4PortA, ip4PortB, 100)), // udp4 flow 1, no checksum + zeroUDP4Checksum(udp4Packet(ip4PortA, ip4PortB, 100)), // udp4 flow 1, no checksum + flipUDP4Checksum(udp4Packet(ip4PortA, ip4PortC, 100)), // udp4 flow 2, genuinely bad checksum + } + want := [][]int{ + {virtioNetHdrLen, 128, 100, 100}, // flow 1: three zero-csum datagrams coalesced + {virtioNetHdrLen, 128}, // flow 2: bad csum, unmerged + } + + wi := newGROToWrite() + pkts := make([][]byte, len(pktsIn)) + for k, p := range pktsIn { + pkts[k] = slices.Clone(p) + } + if err := handleGRO(pkts, offset, newTCPGROTable(), newUDPGROTable(), 0, &wi); err != nil { + t.Fatalf("handleGRO: %v", err) + } + if len(wi.iovs) != len(want) { + t.Fatalf("got %d outputs, want %d", len(wi.iovs), len(want)) + } + for i, wantFragLens := range want { + iov := wi.iovs[i] + if len(iov) != len(wantFragLens) { + t.Fatalf("output[%d]: got %d fragments, want %d", i, len(iov), len(wantFragLens)) + } + for j, wantLen := range wantFragLens { + if len(iov[j]) != wantLen { + t.Errorf("output[%d][%d]: got len %d, want %d", i, j, len(iov[j]), wantLen) + } + } + } +} + func Fuzz_handleGRO(f *testing.F) { pkt0 := tcp4Packet(ip4PortA, ip4PortB, header.TCPFlagAck, 100, 1) pkt1 := tcp4Packet(ip4PortA, ip4PortB, header.TCPFlagAck, 100, 101)