Skip to content

perf: dynamically adapt datagram payload size to connection PMTU (removes 1197 hard limit) - #21

Open
oluceps wants to merge 1 commit into
SagerNet:mainfrom
oluceps:expose-mtu
Open

oluceps wants to merge 1 commit into
SagerNet:mainfrom
oluceps:expose-mtu

Conversation

@oluceps

@oluceps oluceps commented Oct 8, 2026 •

Copy link
Copy Markdown

Dependency

This PR depends on the quic-go PR SagerNet/quic-go#1, which exposes (*quic.Conn).MaxDatagramPayloadSize() int64. It must be merged and the quic-go dependency bumped before this change builds against upstream.

The Existing Problem

Currently, sing-quic employs a hardcoded maximum datagram payload size of 1197 bytes (udpMTU = 1197) in the hysteria2 packet handler.

When a large packet (e.g., a standard 1350-byte MTU packet from a TUN interface) enters the proxy, sing-quic forcibly fragments it at the application layer into two smaller chunks before passing them to quic-go. Because quic-go's SendDatagram is never fed large enough packets, its internal DPLPMTUD (Datagram Packetization Layer Path MTU Discovery) mechanism is starved and fails to probe for larger path MTUs. This causes an artificial MTU bottleneck, breaking nested VPN protocols (like WireGuard) that rely on large packets and introducing unnecessary fragmentation overhead.

What This Optimizes

This PR refactors WriteTo and packet handling in Hysteria2 to utilize the newly exposed MaxDatagramPayloadSize() API from quic-go.

By dynamically querying the actual PMTU estimate of the current QUIC connection, sing-quic will now only fragment packets when strictly necessary. If the path supports a standard MTU (e.g., 1500), large packets (like a 1350-byte IP packet) are now encapsulated and transmitted as a single, contiguous QUIC datagram frame.

This change:

  1. Unlocks Full MTU: lets quic-go’s PMTU estimate actually be used and native large-packet throughput.
  2. Reduces CPU/Network Overhead: Eliminates pointless application-layer slicing and the resulting duplicated QUIC/UDP frame headers.

Proof of Optimization

I tested sending 1350-byte UDP payloads (1378-byte IP packets) through a TUN interface using both the old and new implementations, plotting the QUIC datagram packet lengths observed on the wire.

histogram
  • Top Row (Old Behavior): The 1378-byte IP packet is forcibly chopped by the 1197 inner limit. On the wire, this results in a bimodal distribution: one large packet (~1230 bytes, including QUIC+UDP overhead) and one small "remainder" packet (~228 bytes).
  • Bottom Row (New Behavior): The 1197 limit is lifted. The entire 1378-byte IP packet is sent intact as a single QUIC datagram, resulting in a single peak at ~1404 bytes on the wire, completely eliminating the fragmentation.

Packet loss amplification (10% random loss)

Because Hysteria2 datagrams are unreliable, splitting one inner packet into two QUIC datagrams means losing either fragment drops the whole inner packet, so the effective loss rate is amplified (1 - 0.9^2 = 19% at 10% link loss). Without fragmentation the effective loss equals the link loss.

Setup: NixOS VM, two network namespaces (client/server) joined by a veth pair, tc qdisc add dev <veth> root netem loss 10% on both ends, sing-box TUN client → Hysteria2 server, then iperf3 -u -b 20M -l 1350 -t 10 through the tunnel (1350-byte payloads). The only difference between runs is the sing-box binary (old = main, new = this PR).

Inner UDP loss (receiver)
Old (hardcoded 1197) 19% (3478/18520)
New (dynamic PMTU) 10% (1885/18520)

Raw iperf3 output, unmodified:

Running loss simulation for sing-box-old...
Connecting to host 198.18.0.1, port 5201
[  5] local 10.255.255.1 port 46836 connected to 198.18.0.1 port 5201
[ ID] Interval           Transfer     Bitrate         Total Datagrams
[  5]   0.00-1.00   sec  2.39 MBytes  20.0 Mbits/sec  1853  
[  5]   1.00-2.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   2.00-3.00   sec  2.38 MBytes  20.0 Mbits/sec  1853  
[  5]   3.00-4.00   sec  2.38 MBytes  20.0 Mbits/sec  1851  
[  5]   4.00-5.00   sec  2.38 MBytes  20.0 Mbits/sec  1851  
[  5]   5.00-6.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   6.00-7.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   7.00-8.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   8.00-9.00   sec  2.39 MBytes  20.0 Mbits/sec  1853  
[  5]   9.00-10.00  sec  2.38 MBytes  19.9 Mbits/sec  1851  
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Jitter    Lost/Total Datagrams
[  5]   0.00-10.00  sec  23.8 MBytes  20.0 Mbits/sec  0.000 ms  0/18520 (0%)  sender
[  5]   0.00-10.00  sec  19.4 MBytes  16.2 Mbits/sec  0.024 ms  3478/18520 (19%)  receiver

iperf Done.
Running loss simulation for sing-box-new...
Connecting to host 198.18.0.1, port 5201
[  5] local 10.255.255.1 port 35681 connected to 198.18.0.1 port 5201
[ ID] Interval           Transfer     Bitrate         Total Datagrams
[  5]   0.00-1.00   sec  2.39 MBytes  20.0 Mbits/sec  1854  
[  5]   1.00-2.00   sec  2.38 MBytes  20.0 Mbits/sec  1851  
[  5]   2.00-3.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   3.00-4.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   4.00-5.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   5.00-6.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   6.00-7.00   sec  2.38 MBytes  20.0 Mbits/sec  1851  
[  5]   7.00-8.00   sec  2.38 MBytes  20.0 Mbits/sec  1852  
[  5]   8.00-9.00   sec  2.38 MBytes  20.0 Mbits/sec  1853  
[  5]   9.00-10.00  sec  2.38 MBytes  19.9 Mbits/sec  1851  
- - - - - - - - - - - - - - - - - - - - - - - - -
[ ID] Interval           Transfer     Bitrate         Jitter    Lost/Total Datagrams
[  5]   0.00-10.00  sec  23.8 MBytes  20.0 Mbits/sec  0.000 ms  0/18520 (0%)  sender
[  5]   0.00-10.00  sec  21.4 MBytes  18.0 Mbits/sec  0.018 ms  1885/18520 (10%)  receiver

iperf Done.

Note on TUIC

tuic/packet.go in sing-quic has the same hardcoded 1200 - 3 limit and could adopt the same MaxDatagramPayloadSize() approach. This PR intentionally leaves TUIC untouched for now and only changes Hysteria2; TUIC can be handled in a follow-up.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant