Files
3x-ui/internal/amneziawgnet/netstack_test.go
T
Sanaei 24cb6bfe1f perf(amneziawg): return gVisor's pooled buffers on the embedded data path
Every packet crossing the embedded AmneziaWG interface allocated instead of
reusing gVisor's pools, in both directions. stackTun.Write injected each
decrypted packet and never called DecRef, so the packet buffer and its chunk
were never returned; stackTun.Read copied each view out and never released
it. gVisor's own link endpoints settle the ownership question -- loopback.go
and sharedmem.go both DecRef immediately after DeliverNetworkPacket, because
the injector owns the buffer.

AttachUDPHandler compounded it by cloning a packet buffer it then dropped on
the floor, on top of a Data().AsRange().ToSlice() that already returns an
owned copy, so the clone bought nothing and stranded a pooled buffer plus a
cloned view per datagram.

Measured with the benchmarks added here:

  stackTunWrite (upload)     794ns -> 107ns   4 -> 0 allocs
  stackTunRead  (download)   707ns -> 129ns   3 -> 0 allocs
  UDP datagram, end to end  2.69us -> 1.58us  8 -> 2 allocs

The remaining UDP allocation is the ToSlice copy itself. Through a real
handshaked tunnel -- both devices in one process over loopback, so
ChaCha20-Poly1305 and the UDP syscalls dominate -- it is worth -48% bytes/op
and -33% allocs/op, and about +4.8% throughput in each direction (n=18,
p<=0.01). On a small VPS, where the allocation pressure is not spread over
24 idle cores, the throughput share should be larger; that part is reasoning,
not something measured here.

The three regression tests assert allocations per packet rather than timing,
since the defect is the pool miss, not the nanoseconds. Thresholds leave room
for the extra allocation -race adds.
2026-09-04 14:56:54 +02:00

131 lines
4.3 KiB
Go

package amneziawgnet
import (
"testing"
"gvisor.dev/gvisor/pkg/buffer"
"gvisor.dev/gvisor/pkg/tcpip/link/channel"
)
// TestStackTunReadDrainsBufferedBatch is a regression test for a real
// throughput bug: Read used to always return exactly one packet per call
// no matter how many were already queued, forcing amneziawg-go's TUN
// reader to pay a full peer-lookup+staging+syscall cycle per packet on the
// download path while the upload path (via the UDP bind's own
// recvmmsg/sendmmsg batching) amortized that cost across up to 128
// packets. Confirmed live: this alone took real download throughput from
// 30-40 Mbit/s to 130-250 Mbit/s on a real test connection (see commit
// 6436fd9c's message and internal/amneziawgnet/netstack.go's own comment
// on tunQueueDepth for the full story) -- this test locks in the second,
// finer-grained fix on top of that: Read must actually drain what's
// already buffered instead of returning after the first packet.
func TestStackTunReadDrainsBufferedBatch(t *testing.T) {
t.Parallel()
tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
packets := [][]byte{{1, 2, 3}, {4, 5}, {6, 7, 8, 9}}
for _, p := range packets {
tun.incomingPacket <- buffer.NewViewWithData(p)
}
buf := make([][]byte, 8)
sizes := make([]int, 8)
for i := range buf {
buf[i] = make([]byte, 64)
}
n, err := tun.Read(buf, sizes, 0)
if err != nil {
t.Fatalf("Read: %v", err)
}
if n != len(packets) {
t.Fatalf("Read returned %d packets, want %d (all buffered packets in one call)", n, len(packets))
}
for i, want := range packets {
got := buf[i][:sizes[i]]
if string(got) != string(want) {
t.Errorf("packet %d = %v, want %v", i, got, want)
}
}
}
// TestStackTunReadStopsAtBufCapacity confirms Read never returns more
// packets than the caller's buf can hold, and that whatever didn't fit is
// still there (in order) for the next call -- draining must respect the
// caller's batch size, not just gulp everything queued.
func TestStackTunReadStopsAtBufCapacity(t *testing.T) {
t.Parallel()
tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
packets := [][]byte{{1}, {2}, {3}}
for _, p := range packets {
tun.incomingPacket <- buffer.NewViewWithData(p)
}
buf := make([][]byte, 2)
sizes := make([]int, 2)
for i := range buf {
buf[i] = make([]byte, 64)
}
n, err := tun.Read(buf, sizes, 0)
if err != nil {
t.Fatalf("first Read: %v", err)
}
if n != 2 {
t.Fatalf("first Read returned %d, want 2 (buf capacity)", n)
}
n, err = tun.Read(buf, sizes, 0)
if err != nil {
t.Fatalf("second Read: %v", err)
}
if n != 1 {
t.Fatalf("second Read returned %d, want 1 (the leftover packet)", n)
}
if got := buf[0][:sizes[0]]; string(got) != "\x03" {
t.Errorf("leftover packet = %v, want [3]", got)
}
}
// TestStackTunWriteReturnsPacketBuffersToPool locks in gVisor's ownership rule
// for the upload path: whoever calls InjectInbound must DecRef the packet.
func TestStackTunWriteReturnsPacketBuffersToPool(t *testing.T) {
tun := &stackTun{ep: channel.New(tunQueueDepth, 1420, ""), mtu: 1420}
defer tun.ep.Close()
packet := make([]byte, 1400)
packet[0] = 0x45 // IPv4, version nibble is all Write inspects
bufs := [][]byte{packet}
allocs := testing.AllocsPerRun(1000, func() {
if _, err := tun.Write(bufs, 0); err != nil {
t.Fatalf("Write: %v", err)
}
})
// 0 once pooled, 4 when every packet buffer is stranded; -race adds ~1.
if allocs > 1 {
t.Fatalf("Write allocates %v times per packet, want <=1: injected packet buffers are not being returned to gVisor's pools", allocs)
}
}
// TestStackTunReadReturnsViewsToPool is the download-path counterpart: a view
// that is copied out but never released strands its pooled chunk.
func TestStackTunReadReturnsViewsToPool(t *testing.T) {
tun := &stackTun{incomingPacket: make(chan *buffer.View, tunQueueDepth)}
packet := make([]byte, 1400)
buf := [][]byte{make([]byte, 2048)}
sizes := make([]int, 1)
allocs := testing.AllocsPerRun(1000, func() {
tun.incomingPacket <- buffer.NewViewWithData(packet)
if _, err := tun.Read(buf, sizes, 0); err != nil {
t.Fatalf("Read: %v", err)
}
})
// 0 once the drained view goes back to viewPool, 3 when it does not.
if allocs > 1 {
t.Fatalf("Read allocates %v times per packet, want <=1: drained views are not being released", allocs)
}
}