@@ -289,6 +289,116 @@ client and decrypting with the configured PSK; see
289289` components/snell/quic.go ` and ` components/snell/quic_test.go ` (the
290290unit test includes a real captured 1359-byte envelope as a fixture).
291291
292+ ## Performance
293+
294+ We benchmarked OpenSnell against the official ` snell-server v5.0.1 ` (the
295+ same closed-source binary that ships behind the Surge client) on two
296+ co-located Linux hosts: one host runs ** both** servers on different
297+ ports so the upstream link, kernel, and CDN cooldown apply equally;
298+ the other host runs two snell-client instances pointed at each. All
299+ traffic goes through SOCKS5 via ` curl --socks5-hostname ` to the same
300+ upstream URL.
301+
302+ ### Method
303+
304+ Three phases, each run sequentially (never simultaneously) with a
305+ several-second pause between subjects so the upstream CDN doesn't
306+ throttle one side of the comparison:
307+
308+ 1 . ** Latency** — 50 sequential requests to a tiny endpoint
309+ (` cloudflare.com/cdn-cgi/trace ` , ~ 200 B response). Measure
310+ ` time_connect ` , TTFB, and total via ` curl -w ` .
311+ 2 . ** Concurrent throughput** — N = 2, 4, 8 parallel downloads of a
312+ 10 MB file. Measure aggregate MB/s = total bytes ÷ wall clock.
313+ 3 . ** Packet inspection** — ` tcpdump ` server-side during a single
314+ 10 MB download per variant; count full TCP segments vs. empty ACKs.
315+
316+ ### What the official binary actually is
317+
318+ We disassembled the official ` snell-server-v5.0.1-linux-amd64 `
319+ (1.2 MB, statically linked, section headers stripped). String
320+ analysis shows it is built with ** GCC** , links ** libuv** (the same
321+ async-I/O library curl and Node.js use), and uses ** OpenSSL** 's
322+ AES-NI GCM implementation (the distinctive ` GCM module for x86_64 `
323+ string is present). In short: ** C/C++ + libuv + OpenSSL** . That
324+ matters because libuv runs the whole proxy on a single event-loop
325+ thread — no per-connection goroutine, no GMP scheduling, no GC.
326+
327+ ### Initial finding (OpenSnell v1.0.1)
328+
329+ | Metric | OpenSnell v1.0.1 | Official v5.0.1 | Δ |
330+ | -------------------------------------------- | ---------------: | --------------: | ----------- |
331+ | TTFB median | within noise | within noise | ~ 0 |
332+ | Single-stream throughput | tied | tied | ~ 0 |
333+ | ** N = 8 concurrent throughput** | ** 6.49 MB/s** | ** 8.46 MB/s** | ** −30 %** |
334+ | Empty ACKs over a 10 MB transfer | 1444 | 1084 | ** +33 %** |
335+
336+ Single-stream and latency were already on par with the official
337+ server. The gap was concentrated in concurrent throughput.
338+
339+ ### Root cause
340+
341+ ` v4Reader.readFrame() ` deserialises every snell frame with ** two
342+ distinct ` io.ReadFull ` calls** — one for the 23-byte AEAD'd frame
343+ header, one for padding + payload + tag — and the underlying
344+ ` net.Conn ` was being read directly, with no userspace buffering. At a
345+ typical frame size of ~ 1.5 KB, a 10 MB transfer touches ~ 7300 frames
346+ and therefore costs ** ~ 14 000 ` recv() ` syscalls per direction** .
347+
348+ Two things follow from that:
349+
350+ 1 . ** Empty ACKs.** Linux delays ACKs when an application drains the
351+ receive buffer in big bursts, but issues them more aggressively
352+ when the buffer is drained through many small reads. Two syscalls
353+ per frame == many small reads == defeat delayed-ACK == ~ 33 % more
354+ empty ACKs on the wire than the C reference.
355+ 2 . ** Concurrent throughput.** Each snell connection runs two
356+ goroutines (one per direction). At N = 8 concurrent SOCKS5 sessions
357+ that is 16 goroutines, each doing thousands of small syscalls and
358+ trading off through Go's runtime scheduler. libuv pays none of that
359+ — its single epoll-driven thread can absorb new TCP data at full
360+ rate.
361+
362+ ### Fix
363+
364+ One line:
365+
366+ ``` go
367+ // components/snell/v4.go — initReader()
368+ c.r = &v4Reader{Reader: bufio.NewReaderSize (c.Conn , 64 *1024 ), aead: aead}
369+ ```
370+
371+ A 64 KB read-side buffer pulls ~ 40 max-sized snell frames into
372+ userspace per ` recv() ` , cutting syscalls on the read path by roughly
373+ ~ 90×. This is a wire-format-transparent change: the v4 frame parser
374+ still sees the exact same byte stream, just delivered through fewer
375+ syscalls.
376+
377+ ### After OpenSnell v1.0.2
378+
379+ | Metric | OpenSnell v1.0.2 | Official v5.0.1 | Δ |
380+ | -------------------------------------------- | ---------------: | --------------: | ----------- |
381+ | TTFB median | 17.9 ms | 17.1 ms | +4.7 % |
382+ | TTFB p95 | 25.4 ms | 24.5 ms | +3.7 % |
383+ | N = 2 throughput | 43.48 MB/s | 44.44 MB/s | −2.2 % |
384+ | ** N = 8 throughput** | ** 47.34 MB/s** | ** 48.19 MB/s** | ** −1.8 %** |
385+ | Empty ACKs over a 10 MB transfer | 2596 | 2343 | ** +10.8 %** |
386+
387+ The concurrent throughput gap collapsed from ** −30 %** to ** −1.8 %** ,
388+ and the empty-ACK excess dropped from ** +33 %** to ** +10.8 %** . The
389+ remaining ~ 11 % ACK excess and ~ 2 % throughput delta is plausibly
390+ attributable to Go's runtime overhead vs. a hand-written C event
391+ loop — and below the noise floor of any realistic workload.
392+
393+ ### Takeaway
394+
395+ On Surge's published wire (snell v5), OpenSnell's ` snell-server `
396+ runs at ** roughly 98 % of the official C reference under concurrency**
397+ and is ** indistinguishable in latency** . The bufio fix is ` +9/−1 `
398+ lines in ` components/snell/v4.go ` — a useful reminder that profiling
399+ the read path (and not just application logic) is where most of the
400+ gap to a native C/libuv implementation lives.
401+
292402## Interop with the real Surge ` snell-server `
293403
294404Tested against ` snell-server v5.0.1 (Nov 19 2025) ` :
0 commit comments