Files
shadowsocks-libev/docs/performance.md
T
Max Lv 002b4093fd C++20 port 14/14: remove the C shims, report, fixes (#3077)
* Remove ss_event and the C resolver shim

Each program's loop, signals, plugin watcher and stats timer now go
through ss::event::Program (src/event/program.hpp), held on main()'s
stack; ss-manager uses it too. ss-server resolves the manager address
with the relay resolver. With no C caller left, ss_event.cpp/.h,
resolv.cpp/.h and their C tests go; test_resolver_cpp.cpp replaces the
resolver tests against the same delayed local DNS server, and ss_ev is
now an interface target over libuv.

* Reinitialize the event loop after daemonize()

The loop is created before the programs fork into the background, and
kqueue/epoll descriptors do not survive fork(), so ss-server -f (which
ss-manager always uses) died as soon as it ran. Call uv_loop_fork() in
the child.

* Move the C codecs to tests/reference and drop uthash from ss-manager

The programs now use only the C++ codecs. The C stream/AEAD/AEAD-2022
implementations stay as the reference the differential tests and the C
unit tests compare against. Key derivation and the cipher tables used by
the doc snippets move to src/crypto/keys.{hpp,cpp}.

ss-manager keeps its servers in an ss_list (at most MAX_PORT_NUM,
looked up only on control messages) instead of a uthash table, and
tests/test_manager.py covers add/ping/list/remove end to end.

* Replace uthash and ss_list with the C++ containers

The C cache (now used only by the reference codecs) moves to
tests/reference and src/uthash.h is deleted. ACL rules and ss-manager's
servers are ListNodes on IntrusiveLists; destroying an element unlinks
it, so the free paths no longer have to remember to. core.h keeps only
ss_ip. test_rule is ported to C++ and also covers literal rules.

* Describe the C++20/libuv build in the Debian and snap metadata

* Keep clang-tidy clean in the new C++ tests

* Add --max-dns-queries / max_dns_queries

Sizes the resolver's query pool, the last of the capacity settings the
no-heap-after-startup design calls for. Defaults are unchanged: the
connection limit (plus 64 on ss-server, which also resolves for its
statistics).

* Report the C++20 rewrite: allocation, size, throughput and memory

docs/performance.md compares master (C) with the C++ engine on macOS
arm64: zero heap allocations per connection or datagram (from 27-89),
TCP throughput within noise, and about 20 MiB more RSS at 1,000 open
connections from the inline per-connection buffers. docs/cpp-core.md
and CLAUDE.md now describe the finished layout.

* Fix the MinGW build of ss-server: stat_resolver is POSIX-only

* Fix the fully static image and the build image for the C++ sources

docker/static: the C++ unit tests link the static libstdc++, whose error
paths need an unwinder that --rtlib=compiler-rt does not provide; link
llvm-libunwind. The programs themselves still need no C++ runtime.

docker/build: build in the default bundled mode with CMake instead of
against libev/udns (Alpine's Mbed TLS 4 is not supported by system
mode).

* Fall back to the system heap when a library realloc cannot be served

heap_realloc returned NULL when a block outgrew the largest size class or
the budget, and libuv aborts when growing its watcher array fails: a poll
on fd 8190 or above (about 4,000 connections) killed the process.
Move the block to the system heap instead, as malloc and calloc already
do. test_heap_guard_cpp polls fd 16380 with the heaps installed.

* Leave pool room for UDP sessions that are still closing

An evicted session keeps its pool slot until its socket's close callback
runs, so with pool capacity equal to the table's the acquire after an
eviction failed: a full table dropped new-session packets after evicting
a live session for nothing. The AEAD-2022 shared-session pool had the
same problem with entries still referenced by relay sessions.

* Time client-side TCP connections when idle, not only while connecting

ss-local, ss-tunnel and ss-redir only timed the upstream connect (as the
C relays did), so a client that never finished its SOCKS handshake, or a
stream whose peer vanished without a FIN, held a pool slot forever. With
the pool now a hard cap that pauses accepting when full, enough of them
stopped the listener for good. Every connection now starts with the idle
deadline, connected() re-arms it, and traffic refreshes it for all kinds
(the idle timeout keeps its 24 h floor, MIN_TCP_IDLE_TIMEOUT).

* Include <algorithm> for std::min in crypto/keys.cpp (CLANG64)

* test.py: accept host:port for --dns

The tunnel test hard-coded port 53, so it could only use a public
resolver or root. With a port it runs against a local DNS stand-in.

* Use the Mbed TLS compat wrappers for MD5 key derivation

* Fix the documentation build after moving the codecs

utils.cpp's \snippet aead.cpp and stream.cpp now live in src/crypto/,
which was not on Doxygen's EXAMPLE_PATH, so the man1 and HTML builds
failed. List the documentation sources explicitly instead of guessing
their extension.
2026-10-15 10:11:56 +08:00

6.9 KiB
Raw Blame History

Modernization measurements

C++20 rewrite (2026-10-14)

Local macOS arm64 (Apple clang), Release, bundled dependencies. Baseline: master at 553cdc7 (C). Comparison: the C++20 engine branch stack. Raw JSON is in measurements (cpp20-*). These are short loopback runs on one machine, not capacity guarantees.

Heap allocations per relayed connection

tests/alloc_audit/measure_alloc.py --target 127.0.0.1: 200 TCP connections with a small payload each, after warm-up. Each value is allocations per connection as ss-server / ss-local.

Method C C++
aes-256-gcm 39.0 / 39.0 0 / 0
chacha20-ietf-poly1305 33.1 / 33.0 0 / 0
2022-blake3-aes-256-gcm 34.0 / 32.9 0 / 0
aes-256-cfb 27.1 / 26.4 0 / 0

With a host-name target (localhost, resolved by ss-server) the C++ programs are still at 0, because c-ares allocates from its fixed heap. The C ss-server rises to 89.1 per connection. UDP relaying is also 0 per datagram, where the C relay allocated a 64 KiB buffer for every one. ctest enforces all of this (alloc_steady_*, including alloc_steady_programs_*).

Size

Unstripped Release executables, in bytes:

Program C C++
ss-local 1,086,888 1,129,960
ss-server 1,086,952 1,130,408
ss-tunnel 542,200 789,144

ss-tunnel grows because it now shares the full relay engine with the other programs, including the resolver, the UDP relay and every codec.

TCP throughput and memory

tests/stress_test.py --size 100 --repeat 3: median of three runs, server RSS after the transfer.

Cipher C Mbps C++ Mbps C server RSS, KiB C++ server RSS, KiB
aes-128-gcm 6,353 6,285 3,472 3,648
aes-256-gcm 6,829 6,637 3,440 3,616
chacha20-ietf-poly1305 3,037 2,891 3,456 3,664

The throughput differences are within the run-to-run spread, which is about ±20% for these 0.1–0.3 s transfers.

RSS with 1,000 established, idle connections through ss-local and ss-server (aes-256-gcm):

C server C++ server C local C++ local
idle 2,624 KiB 2,992 KiB 2,272 KiB 2,576 KiB
1,000 connections 60,256 KiB 80,912 KiB 54,416 KiB 74,752 KiB

Each connection slot carries about 104 KiB of inline buffers, against roughly 58 KiB that the C relay allocated on demand. The pool is reserved virtually at startup and only touched slots become resident. A shared buffer pool with read backpressure would cut this; it is the main open trade-off of the rewrite.

UDP

tests/measure_udp.py: median payload Mbps over three runs of 2,000 echoed 1,200-byte packets.

Cipher C C++
aes-128-gcm 80.9 81.4
aes-256-gcm 81.1 55.1
chacha20-ietf-poly1305 56.2 65.6

This benchmark is dominated by Python and SOCKS round trips. Single runs vary by more than 30% in both builds (for example, C aes-256-gcm ranged from 64 to 81), so no UDP throughput change is claimed either way.

Reproduce

python3 tests/alloc_audit/measure_alloc.py --bin build/bin --shim build/lib/libss_alloc_audit.dylib \
    --method aes-256-gcm --target 127.0.0.1
python3 tests/stress_test.py --bin build/bin --size 100 --repeat 3 --json tcp.json
python3 tests/measure_udp.py --bin build/bin --output udp.json

Bundled dependencies (2026-09-10)

Local macOS arm64 results, 2026-09-10. Base commit: 8fe386b; comparison: feature/self-contained-portable-c. Raw JSON lives in measurements. These are short loopback measurements on one machine, not capacity guarantees.

Size and runtime dependencies

Unstripped Release executable sizes in bytes:

Program Old system-shared Old static dependencies Bundled Minimal
ss-local 295,912 1,326,328 756,920 416,424
ss-server 314,360 1,500,968 962,632 622,024
ss-tunnel 268,680 828,712 416,440 397,704

Bundled programs link only macOS OS libraries (libSystem, and where needed libresolv). They need no separately installed libev, c-ares, PCRE2, libsodium or Mbed TLS. Compared with the old static-dependency build, ss-server shrinks about 36%; minimal shrinks about 59%. Compared with a shared-dependency binary alone, bundling makes the executable larger; that comparison excludes the old libraries' disk footprint. Bundled source archives add roughly 12 MB to checkout and release size. There is no configure-time network access or submodule fetch.

TCP and resident memory

Median of three 100 MiB transfers using tests/stress_test.py, old shared dependencies versus bundled. Both sets pass all nine transfers.

Cipher Before Mbps Bundled Mbps Before server RSS after, KiB Bundled server RSS after, KiB
AES-128-GCM 7,002 7,003 3,488 3,040
AES-256-GCM 7,776 8,498 3,488 2,992
ChaCha20-IETF-Poly1305 3,439 3,268 3,472 3,072

Bundled tunnel RSS after transfer was about 2,000–2,032 KiB versus 2,576–2,592 KiB before. RSS includes OS accounting and shared-page effects; this is not a proof of bounded memory under arbitrary workloads. Transfer times are short (roughly 0.1–0.3 s), so the apparent AES-256 improvement and ChaCha regression need longer controlled measurements before drawing performance claims.

UDP

Median of three runs, 2,000 sequential 1,200-byte echo packets per run through SOCKS5 UDP ASSOCIATE. Every packet's payload and relay source port are checked.

Cipher Before payload Mbps Bundled payload Mbps
AES-128-GCM 77.43 75.01
AES-256-GCM 81.34 63.33
ChaCha20-IETF-Poly1305 78.65 67.89

This round-trip benchmark includes Python thread creation, scheduling and SOCKS overhead. It shows lower observed UDP rates in this run, up to 22%; modernization is not claimed to improve UDP throughput. The portable libsodium backend is a maintenance/performance tradeoff. Optimized backends require separate compiler probes and cross-platform known-answer tests before adoption.

Independently of these timings, the new interoperability test exposed the old MTU-sized receive buffer truncating 4 KiB UDP payloads. Full datagram reception now passes 1, 128, 1,200 and 4,096-byte interoperability cases against shadowsocks-rust in both directions.

Reproduce

python3 tests/stress_test.py --bin build-bundled/bin --size 100 --repeat 3 --json tcp.json
python3 tests/measure_udp.py --bin build-bundled/bin --output udp.json
python3 tests/interop.py --bin build-bundled/bin
python3 tests/interop.py --self --bin build-bundled/bin --plugin tests/sip003_fixture.py

Run comparisons serially. The baseline was rebuilt from its own worktree using the preserved original dependency checkouts; no modernization fixes were applied to that source. The historical curl interop harness obeyed no_proxy and could bypass SOCKS, so its old results are excluded from compatibility evidence.