net/batching: use vectored writes on Linux (#19054)

On Linux batching.Conn will now write a vector of
coalesced buffers via sendmmsg(2) instead of copying
fragments into a single buffer.

Scatter-gather I/O has been available on Linux since the
earliest days (reworked in 2.6.24). Kernel passes fragments
to the driver if it supports it, otherwise linearizes
upon receiving the data.

Removing the copy overhead from userspace yields up to 4-5%
packet and bitrate improvement on Linux with GSO enabled:
46Gb/s 4.4m pps vs 44Gb/s 4.2m pps w/32 Peer Relay client flows.

Updates tailscale/corp#36989


Change-Id: Idb2248d0964fb011f1c8f957ca555eab6a6a6964

Signed-off-by: Alex Valiushko <alexvaliushko@tailscale.com>
This commit is contained in:
Alex Valiushko
2026-03-25 16:38:54 -07:00
committed by GitHub
parent 18983eca66
commit 330a17b7d7
2 changed files with 59 additions and 30 deletions
+27 -19
View File
@@ -152,10 +152,12 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
geneve.VNI.Set(1)
cases := []struct {
name string
buffs [][]byte
geneve packet.GeneveHeader
wantLens []int
name string
buffs [][]byte
geneve packet.GeneveHeader
// Each wantLens slice corresponds to the Buffers of a single coalesced message,
// and each int is the expected length of the corresponding Buffer[i].
wantLens [][]int
wantGSO []int
}{
{
@@ -163,7 +165,7 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
buffs: [][]byte{
withGeneveSpace(1, 1),
},
wantLens: []int{1},
wantLens: [][]int{{1}},
wantGSO: []int{0},
},
{
@@ -172,7 +174,7 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(1, 1),
},
geneve: geneve,
wantLens: []int{1 + packet.GeneveFixedHeaderLength},
wantLens: [][]int{{1 + packet.GeneveFixedHeaderLength}},
wantGSO: []int{0},
},
{
@@ -181,7 +183,7 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(1, 2),
withGeneveSpace(1, 1),
},
wantLens: []int{2},
wantLens: [][]int{{1, 1}},
wantGSO: []int{1},
},
{
@@ -191,7 +193,7 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(1, 1),
},
geneve: geneve,
wantLens: []int{2 + (2 * packet.GeneveFixedHeaderLength)},
wantLens: [][]int{{1 + packet.GeneveFixedHeaderLength, 1 + packet.GeneveFixedHeaderLength}},
wantGSO: []int{1 + packet.GeneveFixedHeaderLength},
},
{
@@ -200,7 +202,7 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(2, 3),
withGeneveSpace(1, 1),
},
wantLens: []int{3},
wantLens: [][]int{{2, 1}},
wantGSO: []int{2},
},
{
@@ -210,7 +212,7 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(1, 1),
},
geneve: geneve,
wantLens: []int{3 + (2 * packet.GeneveFixedHeaderLength)},
wantLens: [][]int{{2 + packet.GeneveFixedHeaderLength, 1 + packet.GeneveFixedHeaderLength}},
wantGSO: []int{2 + packet.GeneveFixedHeaderLength},
},
{
@@ -220,7 +222,7 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(1, 1),
withGeneveSpace(2, 2),
},
wantLens: []int{3, 2},
wantLens: [][]int{{2, 1}, {2}},
wantGSO: []int{2, 0},
},
{
@@ -231,7 +233,7 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(2, 2),
},
geneve: geneve,
wantLens: []int{3 + (2 * packet.GeneveFixedHeaderLength), 2 + packet.GeneveFixedHeaderLength},
wantLens: [][]int{{2 + packet.GeneveFixedHeaderLength, 1 + packet.GeneveFixedHeaderLength}, {2 + packet.GeneveFixedHeaderLength}},
wantGSO: []int{2 + packet.GeneveFixedHeaderLength, 0},
},
{
@@ -241,8 +243,8 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(2, 2),
withGeneveSpace(2, 2),
},
wantLens: []int{4, 2},
wantGSO: []int{2, 0},
wantLens: [][]int{{2, 2, 2}},
wantGSO: []int{2},
},
{
name: "three messages limited cap coalesce vni.isSet",
@@ -252,8 +254,8 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
withGeneveSpace(2, 2),
},
geneve: geneve,
wantLens: []int{4 + (2 * packet.GeneveFixedHeaderLength), 2 + packet.GeneveFixedHeaderLength},
wantGSO: []int{2 + packet.GeneveFixedHeaderLength, 0},
wantLens: [][]int{{2 + packet.GeneveFixedHeaderLength, 2 + packet.GeneveFixedHeaderLength, 2 + packet.GeneveFixedHeaderLength}},
wantGSO: []int{2 + packet.GeneveFixedHeaderLength},
},
}
@@ -276,10 +278,16 @@ func Test_linuxBatchingConn_coalesceMessages(t *testing.T) {
if msgs[i].Addr != addr {
t.Errorf("msgs[%d].Addr != passed addr", i)
}
gotLen := len(msgs[i].Buffers[0])
if gotLen != tt.wantLens[i] {
t.Errorf("len(msgs[%d].Buffers[0]) %d != %d", i, gotLen, tt.wantLens[i])
if len(msgs[i].Buffers) != len(tt.wantLens[i]) {
t.Fatalf("len(msgs[%d].Buffers) %d != %d", i, len(msgs[i].Buffers), len(tt.wantLens[i]))
}
for j := range tt.wantLens[i] {
gotLen := len(msgs[i].Buffers[j])
if gotLen != tt.wantLens[i][j] {
t.Errorf("len(msgs[%d].Buffers[%d]) %d != %d", i, j, gotLen, tt.wantLens[i][j])
}
}
// coalesceMessages calls setGSOSizeInControl, which uses a cmsg
// type of UDP_SEGMENT, and getGSOSizeInControl scans for a cmsg
// type of UDP_GRO. Therefore, we have to use the lower-level