tun: coalesce zero-checksum UDP datagrams in UDP GRO (VXLAN/Cilium)#70
tun: coalesce zero-checksum UDP datagrams in UDP GRO (VXLAN/Cilium)#70cagataygurturk wants to merge 1 commit into
Conversation
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>
| 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) { |
There was a problem hiding this comment.
checksumValid takes the proto field, what benefit do you see in a UDP-specific wrapper?
| // 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) { |
There was a problem hiding this comment.
We have a table test in Test_handleGRO that can be extended with this case. Consider also extending Fuzz_handleGRO.
|
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? |
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_GROescape 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.