Drain buffered TUN packets in one Read call, not just one at a time

stackTun.Read always returned exactly one packet per call regardless
of how many the caller's buf could hold. amneziawg-go's
RoutineReadFromTUN sizes its buffers to device.BatchSize(), which on
Linux is the UDP bind's own batch size (128, conn.IdealBatchSize) --
so real batch capacity was already there and going unused on the
download path.

The UDP bind's Send/Receive both genuinely batch via recvmmsg/
sendmmsg (conn/bind_std.go, confirmed in vendored source). The upload
path exploits this end to end: bind.Receive returns up to 128
datagrams per syscall, decrypt processes them as a batch, and
stackTun.Write already loops over its whole buf. The download path
never reached that batching at all: capped to 1 packet at the TUN
read step, every downstream stage (peer lookup, per-peer staging,
eventual UDP send) paid a full cycle per packet instead of amortizing
it across up to 128.

Have Read block for the first packet, then opportunistically drain
whatever's already buffered (non-blocking), up to len(buf). This is
the second half of the throughput-asymmetry fix (see 6436fd9c, which
fixed the channel being fully unbuffered and blocking the producer on
every packet) -- confirmed live on a real test connection: download
went from 30-40 Mbit/s to 130-250 after the channel-buffering fix,
with a real-network-plausible sequential-speedtest gap remaining
against upload's 300+. This closes the remaining structural gap
between the two directions' per-packet processing cost.

Regression tests confirm the drain behavior directly (not just "it
doesn't crash"): stashed this fix alone and re-ran both new tests to
confirm they fail with the exact expected message first.
This commit is contained in:
Kuzz007
2026-08-04 02:29:00 +03:00
parent 6436fd9c5c
commit 3d4fda9a0d
2 changed files with 121 additions and 1 deletions
+33 -1
View File
@@ -132,6 +132,21 @@ func (t *stackTun) Events() <-chan awgtun.Event { return t.events }
func (t *stackTun) MTU() (int, error) { return t.mtu, nil }
func (t *stackTun) BatchSize() int { return 1 }
// Read blocks for the first packet, then opportunistically drains any more
// that are already buffered (non-blocking), up to len(buf). amneziawg-go's
// caller (RoutineReadFromTUN) sizes buf/sizes to device.BatchSize(), which
// is the UDP bind's own batch size (128 on Linux, see conn.IdealBatchSize)
// since that's larger than BatchSize()'s 1 below -- so real buffer capacity
// for a batch is already there. Without this drain loop, Read always
// returned exactly one packet no matter how many buf could hold, so every
// downstream step (peer lookup, per-peer staging, and ultimately the UDP
// bind's own genuinely batched Send/sendmmsg) processed the download
// direction one packet at a time while the upload direction's equivalent
// (bind.Receive/recvmmsg -> decrypt -> stackTun.Write, which already loops
// over its whole buf) processed up to 128 per cycle. That asymmetry is
// real, not gVisor/amneziawg-go's -- both the receive and send paths on the
// UDP bind support batching identically, only this Read implementation
// didn't use it.
func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
view, ok := <-t.incomingPacket
if !ok {
@@ -142,7 +157,24 @@ func (t *stackTun) Read(buf [][]byte, sizes []int, offset int) (int, error) {
return 0, err
}
sizes[0] = n
return 1, nil
count := 1
for count < len(buf) {
select {
case view, ok := <-t.incomingPacket:
if !ok {
return count, nil
}
n, err := view.Read(buf[count][offset:])
if err != nil {
return count, nil
}
sizes[count] = n
count++
default:
return count, nil
}
}
return count, nil
}
func (t *stackTun) Write(buf [][]byte, offset int) (int, error) {