Performance¶
How much does Tunnel Whisperer cost compared with the two things people use instead — a plain SSH connection, and SSH over a WireGuard mesh? This page is generated from a reproducible benchmark (make bench) on the same Docker Compose topology the end-to-end suite uses: a real relay provisioned by the tw install script, a real enrolled server, a real client tunnel.
One-line takeaway
On a LAN, tw moves 0.97 Gbit/s single-stream against 3.64 (Pure SSH) and 1.20 (WireGuard) Gbit/s; scp over the same LAN moves 122 MB/s against 359 and 149 MB/s; per busy CPU core tw sustains 0.12 Gbit/s against 1.37 and 0.29. On a 30 ms / 0.1 % loss WAN link every single-stream transfer is loss-bound at the same ≈0.03 Gbit/s / 2–4 MB/s; only multi-stream separates them, and there tw does not scale.
What is compared¶
| Transport | Path |
|---|---|
| Pure SSH | client → sshd on server, directly over the network (the ceiling) |
| SSH over WireGuard | kernel WireGuard, one direct peer-to-peer link (mesh-style, no relay) |
| SSH over Tunnel Whisperer | client → tw client connect → VLESS/XHTTP/mTLS :443 → relay (Caddy client_auth → Xray) → reverse SSH → tw serve → sshd |
Instruments, every one moving bytes server → client:
- iperf3, TCP, 1 and 4 streams, 10 s — the transport's own ceiling, no OpenSSH in the way.
- netperf TCP_RR, 64-byte request/response, 10 s — transactions per second and p50/p99 latency, the cost every database query or RPC pays. (
pingcannot cross tw, which forwards ports, not packets; TCP_RR is the port-level equivalent.) - scp of a 5 GiB random file to
/dev/null— the real-world number. Through tw this is SSH inside tw's own end-to-end SSH inside TLS.
Two network conditions: LAN (the Compose bridge, ~0 RTT) isolates encryption and framing cost. WAN adds tc netem delay and loss on the client's and server's egress only — 15 ms delay and 0.1 % loss per leg, so every transport sees the same 30 ms RTT and 0.1 % loss end to end. The relay carries no netem of its own; for tw specifically that means the relay is modelled mid-path, splitting that same 30 ms budget into two 15 ms legs instead of adding a hop's worth of extra latency. Any advantage split TCP might show under loss is therefore a topology effect of where the relay sits, not a product win — the WAN results below are read with that in mind.
NIC offloads (tso/gso/gro) are disabled on the client, server and relay containers for the whole run, and gso_max_segs 1 is set on all three. Linux TCP still builds multi-segment GSO skbs even with the offloads off (sk_setup_caps forces GSO for TCP), and netem drops per skb, not per wire packet: without capping segments to 1, the direct path was measured at roughly 6× fewer loss events per byte than WireGuard on this harness before the cap was applied — well short of the ≈40× theoretical bound for full 64 KiB super-packets — and tw's wire bytes were under-counted at the relay hop. With the cap in place, netem's per-packet loss lands on real 1500-byte packets for every transport equally. One consequence: all of the LAN throughput ceilings below, including Pure SSH's 3.64 Gbit/s, are software-segmentation ceilings of the Docker bridge, not product or NIC numbers. The ordering within this run is meaningful — the absolute LAN values are not: the segmentation cap costs the plain-TCP transports (Pure SSH, and tw's outer TCP) more than WireGuard, whose inner TCP still uses GSO on wg0 (see Diagnostics: gso_max_segs 65535), so the SSH-to-tunnel throughput ratios on the LAN are on the pessimistic side for plain SSH.
Environment¶
| Item | Value |
|---|---|
| CPU | Intel(R) Core(TM) i9-14900K (32 logical cores) |
| Host kernel | 6.6.123.2-microsoft-standard-WSL2 |
| Docker | 29.7.2 |
| Container image | debian:bookworm-slim (e2e/images/tw) |
| tw | tw version v3.0.0-2-gc8b8195-dirty |
| OpenSSH | OpenSSH_9.2p1 Debian-2+deb12u10, OpenSSL 3.0.20 7 Apr 2026 |
| iperf3 | iperf 3.12 (cJSON 1.7.15) |
| netperf | Netperf version 2.7.0 |
| WireGuard | kernel module 1.0.0 |
| WAN emulation | 30ms:0.1% |
| NIC offloads | tso/gso/gro off + gso_max_segs 1 on client, server and relay eth0 for the whole run (TCP always builds GSO skbs unless gso_max_segs caps it; netem drops per skb, so this makes loss per 1500-byte packet for every transport; LAN ceilings are therefore software-segmentation ceilings) |
| scp form / options | scp · -c aes128-gcm@openssh.com -o Compression=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -i /bench/id_ed25519 |
| Runs per cell | 3 timed (after 1 warm-up); cells show median (min–max) |
| scp payload | 5 GiB of /dev/urandom on LAN, the 256 MiB warm-up file on WAN (TCP loss-bound ≈2 MB/s makes 5 GiB impractical); served from tmpfs, written to /dev/null |
Results — LAN (0 ms RTT, no loss)¶
| Transport | iperf3 1 stream (Gbit/s) | iperf3 4 streams (Gbit/s) | TCP_RR (trans/s) | TCP_RR p50 / p99 (µs) | scp 5 GiB (MB/s) | scp wall (s) | wire overhead | Gbit/s per busy core | CPU-s per scp run client / server / relay |
|---|---|---|---|---|---|---|---|---|---|
| Pure SSH | 3.64 (3.63–3.65) | 3.68 (3.65–3.73) | 30511 (23558–31056) | 29 / 75 | 359 (342–361) | 14.9 | 4.7 % | 1.37 | 12.9 / 17.1 / 0.0 |
| SSH over WireGuard | 1.20 (1.14–1.22) | 1.20 (1.18–1.21) | 6567 (6539–6586) | 135 / 336 | 149 (147–149) | 36.1 | 9.4 % | 0.29 | 23.1 / 9.0 / 0.0 |
| SSH over Tunnel Whisperer | 0.97 (0.96–0.99) | 1.09 (1.09–1.14) | 1339 (1333–1340) | 682 / 1162 | 122 (121–125) | 43.9 | 6.0 % | 0.12 | 93.0 / 86.6 / 227.8 |
Results — WAN (30 ms RTT, 0.1 % loss)¶
| Transport | iperf3 1 stream (Gbit/s) | iperf3 4 streams (Gbit/s) | TCP_RR (trans/s) | TCP_RR p50 / p99 (µs) | scp 256 MiB (MB/s) | scp wall (s) | wire overhead | Gbit/s per busy core | CPU-s per scp run client / server / relay |
|---|---|---|---|---|---|---|---|---|---|
| Pure SSH | 0.03 (0.03–0.03) | 0.10 (0.07–0.11) | 32 (31–33) | 30503 / 30993 | 2 (2–2) | 143.3 | 4.7 % | 0.04 | 0.9 / 0.9 / 0.0 |
| SSH over WireGuard | 0.03 (0.03–0.03) | 0.10 (0.08–0.10) | 33 (32–33) | 30510 / 32666 | 2 (2–2) | 144.9 | 9.3 % | 0.03 | 1.7 / 0.9 / 0.0 |
| SSH over Tunnel Whisperer | 0.04 (0.03–0.04) | 0.03 (0.03–0.03) | 31 (31–32) | 31524 / 32909 | 4 (3–4) | 76.7 | 6.1 % | 0.04 | 4.4 / 3.7 / 12.9 |
The WAN scp column is the 256 MiB warm-up file, not the 5 GiB LAN file. At the measured WAN scp throughput (2–4 MB/s median across the three transports) a 5 GiB copy would take roughly 22–45 minutes per run on a link this lossy, for any of the three — that is why the WAN scp payload is 256 MiB instead.
Memory footprint¶
Peak resident set (VmHWM) of the transport processes. The baseline rows are idle listeners sampled after the SSH transport's runs (the first transport) closed their connections; VmHWM is a since-start high-water mark, so a higher peak reached later by these shared listeners is not captured. The tw and WireGuard rows are transfer-time peaks sampled at the end of that transport's own runs. WireGuard has no userspace process: its footprint is kernel memory, reported as 0.
| Transport | Process | Peak RSS |
|---|---|---|
| Baseline (all transports; idle listeners after the runs) | server / iperf3 | 3.8 MiB |
| Baseline (all transports; idle listeners after the runs) | server / netserver | 1.4 MiB |
| Baseline (all transports; idle listeners after the runs) | server / sshd | 4.6 MiB |
| SSH over WireGuard | kernel (no userspace process) | 0.0 MiB |
| SSH over Tunnel Whisperer | client / tw client connect | 55.6 MiB |
| SSH over Tunnel Whisperer | relay / caddy | 68.4 MiB |
| SSH over Tunnel Whisperer | relay / xray | 41.4 MiB |
| SSH over Tunnel Whisperer | server / tw server start | 45.8 MiB |
The report's ## Diagnostics section (e2e/bench-report.md) records the raw tc -s qdisc show output captured on both legs of every WAN cell, plus wg0's link state, so a result can be checked against netem's own counters instead of taken on faith. Across every transport and every cell the drop ratio netem itself reports is 0.090–0.100 %, matching the configured 0.1 % target — evidence that loss was applied per packet, uniformly, regardless of transport. The same capture shows wg0 running at MTU 1420.
Reading the numbers¶
- Where tw's cost goes. The CPU-s-per-scp-run columns put most of tw's cost on the relay, not the endpoints: on the LAN scp run the relay burns 227.8 CPU-s against 93.0 (client) and 86.6 (server) — that's Caddy and Xray doing TLS termination and mTLS-gated forwarding for every byte. On the WAN run the same shape holds at a smaller scale (12.9 relay vs. 4.4 client / 3.7 server). scp through tw also pays for an extra inner OpenSSH layer — tw's own end-to-end SSH tunnel running inside the TLS tunnel — on top of whatever the client's own scp session costs.
- iperf3 and scp agree. tw's LAN single-stream iperf3 figure (0.97 Gbit/s) and its scp run (122 MB/s ≈ 0.98 Gbit/s) agree within noise.
- Throughput vs. latency. TCP_RR p50 through tw is 682 µs against 29 µs for plain SSH on the LAN — the two relay hops and XHTTP framing add about 653 µs; p99 is 1162 µs against 75 µs. On the WAN condition the 30 ms RTT dominates: TCP_RR lands in the same 31–33 trans/s range for all three transports (32 Pure SSH, 33 WireGuard, 31 tw) — indistinguishable at three runs.
- Under loss, single-stream is a wash. Single-stream iperf3 on the WAN link is 0.03 Gbit/s (Pure SSH, range 0.03–0.03), 0.03 (WireGuard, 0.03–0.03) and 0.04 (tw, 0.03–0.04) — indistinguishable at three runs. WAN scp shows tw a little higher (4 MB/s, range 3–4) than Pure SSH and WireGuard (2 MB/s, range 2–2 each), but three runs at this spread isn't enough to call tw the fastest under loss as a headline.
- Under loss, 4 streams is a real difference — and tw doesn't scale. At 4 parallel iperf3 streams on the WAN link, Pure SSH reaches 0.10 Gbit/s (0.07–0.11) and WireGuard 0.10 (0.08–0.10), while tw stays flat at 0.03 (0.03–0.03) — the same as its own single-stream number. tw multiplexes every stream over one outer TCP connection to the relay, so parallel iperf3 streams share one congestion-controlled pipe instead of getting independent loss recovery. That is a property of the design, not noise, and it is a limitation under loss: tw does not gain from parallelism the way a transport with independent per-stream connections does. It is a separate effect from the relay's mid-path placement described above — the flat 4-stream number is about multiplexing, not about the extra 15 ms leg.
- WireGuard is one hop shorter — and its numbers here are harness-specific. It runs peer-to-peer in this benchmark; tw always crosses a relay. That's a property of the products, not a flaw in the measurement — the comparison table explains why you'd still pick tw (UDP blocked, port-only access, identities on your own server). WireGuard's figures above (1.20 Gbit/s LAN single-stream, 0.29 Gbit/s per busy core) are this harness's numbers: kernel WireGuard running inside a nested WSL2 VM, with all NIC offloads off and an MTU of 1420 (see Diagnostics, 0.094–0.097 % netem drops on its WAN cells) — well below what kernel WireGuard achieves on bare metal. Don't extrapolate these numbers to a bare-metal WireGuard deployment.
- Historical context. An earlier manual measurement on v1.5.x over a real ~30 ms Hetzner relay saw ~168 Mbps bulk and ~530 ms pgbench latency at 50 concurrent synchronous clients; the pgbench workload is not part of the automated benchmark.
Reproduce it¶
sudo modprobe wireguard sch_netem # once per boot; kernel WireGuard + netem
make bench # ~35 min from a clean topology; tears down any existing e2e topology first
Knobs: BENCH_SIZE_GB (5), BENCH_RUNS (3), BENCH_WAN (30ms:0.1%, or off for LAN only), E2E_KEEP=1 to leave the topology up. Only Docker and Go are needed; the relay, server and client are provisioned by the same code paths as make e2e. Writes e2e/bench-report.md and e2e/bench-results.json.
The benchmark is e2e/bench_test.go; its design is documented in the repository's spec .claude/superpowers/specs/2026-08-29-tunnel-benchmark-design.md.