Faster. Lighter. More efficient.Runs on every device.
PandaCore is the core engine PandaFan builds for its clients. We put it beside two open-source engines known for industry-leading performance on one Mac for throughput and latency tests.
These comparisons are here to help you understand the engine underneath PandaFan; a loopback test only shows the performance ceiling and says little about everyday use. We respect the open-source community and sponsor open-source projects from time to time. Our thanks to everyone who contributes.
macOS · TUN mode · single TCP download · iperf3 · replayed at measured rates
PandaCore29.01 Gbit/s
0.00GB
Open-source engine 115.01 Gbit/s
0.00GB
Open-source engine 23.98 Gbit/s
0.00GB
PandaCore1.9×
PandaCore7.3×
0 s5 s10 s
0.0×
Single-stream TCP download
vs Open-source engine 2 · 29.0 vs 4.0 Gbit/s
1.9× · vs Open-source engine 1
−0%
Peak memory while downloading
vs Open-source engine 1 · 26 vs 65 MiB
−45% · vs Open-source engine 2
0.0×
Connections per second
vs Open-source engine 2 · 4,257 vs 2,947 conn/s
1.2× · vs Open-source engine 1
−0%
Round-trip latency
vs Open-source engine 2 · 0.056 vs 0.078 ms
−25% · vs Open-source engine 1
Throughput & latency
Higher throughput. Lower latency.
Download, upload and bidirectional use iperf3 at 1 / 4 / 8 TCP streams for 8 seconds per cell. Connection rate and round trip also use 1 / 4 / 8 flows, with 200 connections and 1000 round trips per flow respectively. Medians of 3 rounds. Connection cells start after 30 seconds of idle, keeping the same engine process within a round. TUN mode, MTU 15680.
PandaCoreOpen-source engine 1Open-source engine 2
Download · 1 stream
7.3×vs Open-source engine 2
1.9×vs Open-source engine 1
29.015.04.0Gbit/s
Download · 4 streams
3.0×vs Open-source engine 2
1.4×vs Open-source engine 1
36.225.611.9Gbit/s
Download · 8 streams
2.3×vs Open-source engine 2
1.1×vs Open-source engine 1
34.130.014.7Gbit/s
Upload · 1 stream
2.1×vs Open-source engine 2
1.2×vs Open-source engine 1
23.219.310.8Gbit/s
Upload · 4 streams
1.6×vs Open-source engine 1
1.3×vs Open-source engine 2
26.716.620.2Gbit/s
Upload · 8 streams
1.7×vs Open-source engine 1
1.2×vs Open-source engine 2
25.715.121.8Gbit/s
Both ways · 1 stream
2.6×vs Open-source engine 2
1.5×vs Open-source engine 1
32.821.712.8Gbit/s
Both ways · 4 streams
1.4×vs Open-source engine 2
1.3×vs Open-source engine 1
28.522.720.6Gbit/s
Both ways · 8 streams
1.2×vs Open-source engine 2
1.1×vs Open-source engine 1
24.723.021.0Gbit/s
DownloadGbit/s · higher is betterUploadGbit/s · higher is betterBidirectionalGbit/s · higher is betterConnectconn/s · higher is betterRTTms · lower is better
Raw data15 cells
Every cell’s three raw rounds and median, plus multipliers, differences, memory and CPU use against both comparison engines. Comparisons are ordered by advantage and identify the corresponding engine. The JSON download also records each cell’s measurement date and the connection idle interval.
Cell
PandaCore
Open-source engine 1
Open-source engine 2
Comparison
PandaCore memory · CPU
Open-source engine 1 memory · CPU
Open-source engine 2 memory · CPU
higher is better
Download · 1 streamGbit/s · iperf3
29.0129.08 / 27.89 / 29.01
15.0115.01 / 15.31 / 14.95
3.983.98 / 3.99 / 3.94
7.3× · vs Open-source engine 2+629% · PandaCore ahead1.9× · vs Open-source engine 1+93% · PandaCore ahead
18 MiB·98%RSS −67% · vs Open-source engine 1RSS −56% · vs Open-source engine 2CPU −33% · vs Open-source engine 1CPU +19% · vs Open-source engine 2
56 MiB · 147%
42 MiB · 82%
Download · 4 streamsGbit/s · iperf3
36.2038.53 / 36.20 / 35.53
25.6425.64 / 26.23 / 25.13
11.9311.93 / 11.94 / 11.54
3.0× · vs Open-source engine 2+204% · PandaCore ahead1.4× · vs Open-source engine 1+41% · PandaCore ahead
22 MiB·235%RSS −63% · vs Open-source engine 1RSS −51% · vs Open-source engine 2CPU −37% · vs Open-source engine 1CPU −7% · vs Open-source engine 2
61 MiB · 373%
45 MiB · 254%
Download · 8 streamsGbit/s · iperf3
34.1035.21 / 34.10 / 32.17
30.0230.02 / 30.91 / 26.87
14.7114.42 / 14.71 / 14.82
2.3× · vs Open-source engine 2+132% · PandaCore ahead1.1× · vs Open-source engine 1+14% · PandaCore ahead
26 MiB·266%RSS −59% · vs Open-source engine 1RSS −45% · vs Open-source engine 2CPU −32% · vs Open-source engine 1CPU −29% · vs Open-source engine 2
65 MiB · 392%
48 MiB · 375%
Upload · 1 streamGbit/s · iperf3
23.2523.83 / 22.52 / 23.25
19.2919.35 / 19.29 / 13.24
10.8210.84 / 10.58 / 10.82
2.1× · vs Open-source engine 2+115% · PandaCore ahead1.2× · vs Open-source engine 1+21% · PandaCore ahead
27 MiB·127%RSS −60% · vs Open-source engine 1RSS −45% · vs Open-source engine 2CPU −52% · vs Open-source engine 1CPU −3% · vs Open-source engine 2
66 MiB · 263%
48 MiB · 131%
Upload · 4 streamsGbit/s · iperf3
26.6726.67 / 27.25 / 25.04
16.6516.65 / 16.90 / 12.32
20.2320.34 / 20.23 / 20.00
1.6× · vs Open-source engine 1+60% · PandaCore ahead1.3× · vs Open-source engine 2+32% · PandaCore ahead
28 MiB·222%RSS −58% · vs Open-source engine 1RSS −43% · vs Open-source engine 2CPU −35% · vs Open-source engine 2CPU −16% · vs Open-source engine 1
66 MiB · 265%
48 MiB · 341%
Upload · 8 streamsGbit/s · iperf3
25.6825.68 / 26.44 / 25.07
15.0917.59 / 15.09 / 8.97
21.8321.96 / 21.72 / 21.83
1.7× · vs Open-source engine 1+70% · PandaCore ahead1.2× · vs Open-source engine 2+18% · PandaCore ahead
28 MiB·227%RSS −58% · vs Open-source engine 1RSS −43% · vs Open-source engine 2CPU −44% · vs Open-source engine 2CPU −23% · vs Open-source engine 1
66 MiB · 295%
49 MiB · 409%
Both ways · 1 streamGbit/s · iperf3
32.8433.88 / 28.36 / 32.84
21.6822.14 / 21.68 / 18.71
12.7812.99 / 12.78 / 11.92
2.6× · vs Open-source engine 2+157% · PandaCore ahead1.5× · vs Open-source engine 1+52% · PandaCore ahead
28 MiB·182%RSS −58% · vs Open-source engine 1RSS −43% · vs Open-source engine 2CPU −40% · vs Open-source engine 1CPU −8% · vs Open-source engine 2
66 MiB · 301%
49 MiB · 197%
Both ways · 4 streamsGbit/s · iperf3
28.5330.66 / 28.53 / 27.08
22.6523.30 / 22.65 / 19.59
20.5920.63 / 20.35 / 20.59
1.4× · vs Open-source engine 2+39% · PandaCore ahead1.3× · vs Open-source engine 1+26% · PandaCore ahead
28 MiB·259%RSS −58% · vs Open-source engine 1RSS −43% · vs Open-source engine 2CPU −39% · vs Open-source engine 2CPU −29% · vs Open-source engine 1
67 MiB · 364%
50 MiB · 422%
Both ways · 8 streamsGbit/s · iperf3
24.7427.53 / 24.74 / 22.94
22.9823.10 / 22.98 / 20.45
20.9821.18 / 20.98 / 20.73
1.2× · vs Open-source engine 2+18% · PandaCore ahead1.1× · vs Open-source engine 1+8% · PandaCore ahead
33 MiB·279%RSS −54% · vs Open-source engine 1RSS −39% · vs Open-source engine 2CPU −36% · vs Open-source engine 2CPU −26% · vs Open-source engine 1
72 MiB · 378%
55 MiB · 437%
Connect · 1 flowconn/s · socketbench
4,2573,893 / 4,257 / 4,351
3,6443,644 / 3,265 / 3,825
2,9473,071 / 2,813 / 2,947
1.4× · vs Open-source engine 2+44% · PandaCore ahead1.2× · vs Open-source engine 1+17% · PandaCore ahead
16 MiB·28%RSS −68% · vs Open-source engine 1RSS −55% · vs Open-source engine 2CPU −55% · vs Open-source engine 2CPU −47% · vs Open-source engine 1
51 MiB · 52%
36 MiB · 61%
Connect · 4 flowsconn/s · socketbench
7,7918,070 / 7,791 / 7,602
6,9137,041 / 6,913 / 6,452
6,6456,645 / 6,549 / 6,920
1.2× · vs Open-source engine 2+17% · PandaCore ahead1.1× · vs Open-source engine 1+13% · PandaCore ahead
17 MiB·78%RSS −69% · vs Open-source engine 1RSS −60% · vs Open-source engine 2CPU −53% · vs Open-source engine 2CPU −45% · vs Open-source engine 1
55 MiB · 142%
43 MiB · 165%
Connect · 8 flowsconn/s · socketbench
6,0166,016 / 5,884 / 6,271
5,7255,824 / 5,245 / 5,725
5,5225,419 / 5,522 / 5,655
1.1× · vs Open-source engine 2+9% · PandaCore ahead1.1× · vs Open-source engine 1+5% · PandaCore ahead
18 MiB·80%RSS −74% · vs Open-source engine 1RSS −66% · vs Open-source engine 2CPU −55% · vs Open-source engine 2CPU −48% · vs Open-source engine 1
67 MiB · 154%
52 MiB · 178%
lower is better
RTT · 1 flowms · socketbench
0.05580.0554 / 0.0558 / 0.0617
0.07480.0763 / 0.0747 / 0.0748
0.07820.0782 / 0.0782 / 0.0770
1.4× · vs Open-source engine 2−29% · PandaCore ahead1.3× · vs Open-source engine 1−25% · PandaCore ahead
16 MiB·22%RSS −65% · vs Open-source engine 1RSS −53% · vs Open-source engine 2CPU −50% · vs Open-source engine 1CPU −50% · vs Open-source engine 2
46 MiB · 45%
34 MiB · 45%
RTT · 4 flowsms · socketbench
0.08440.0836 / 0.0844 / 0.0844
0.11250.1122 / 0.1125 / 0.1132
0.11510.1151 / 0.1145 / 0.1177
1.4× · vs Open-source engine 2−27% · PandaCore ahead1.3× · vs Open-source engine 1−25% · PandaCore ahead
17 MiB·83%RSS −66% · vs Open-source engine 1RSS −55% · vs Open-source engine 2CPU −45% · vs Open-source engine 2CPU −42% · vs Open-source engine 1
48 MiB · 143%
37 MiB · 152%
RTT · 8 flowsms · socketbench
0.14610.1461 / 0.1498 / 0.1448
0.19230.1923 / 0.1893 / 0.1923
0.19300.1930 / 0.1908 / 0.1932
1.3× · vs Open-source engine 2−24% · PandaCore ahead1.3× · vs Open-source engine 1−24% · PandaCore ahead
17 MiB·126%RSS −67% · vs Open-source engine 1RSS −57% · vs Open-source engine 2CPU −51% · vs Open-source engine 2CPU −48% · vs Open-source engine 1
53 MiB · 244%
40 MiB · 261%
Memory and CPU
Smaller. Lighter. Cheaper to run.
The same runs recorded each engine’s peak memory and CPU use; shown here for the 8-stream download and 8-stream bidirectional cells.
Footprint
Binary size and peak memory
MiB · lower is better
PandaCoreOpen-source engine 1Open-source engine 2
Binary
24.8
79.8
43.5
−69%vs Open-source engine 1
−43%vs Open-source engine 2
8-stream download · peak memory
26.4
65.1
48.0
−59%vs Open-source engine 1
−45%vs Open-source engine 2
8-stream both ways · peak memory
33.2
72.3
54.7
−54%vs Open-source engine 1
−39%vs Open-source engine 2
Binary size: −69% vs Open-source engine 1. Peak memory during the 8-stream download: −59% vs Open-source engine 1.
CPU · iperf3, 8 streams
Engine CPU use
% · lower is better
PandaCoreOpen-source engine 1Open-source engine 2
8-stream download
266
392
375
−32%vs Open-source engine 1
−29%vs Open-source engine 2
8-stream both ways
279
378
437
−36%vs Open-source engine 2
−26%vs Open-source engine 1
CPU during the 8-stream download: PandaCore 266%, Open-source engine 1 392%. CPU per Gbit/s: PandaCore 7.8%, Open-source engine 2 25.5%.
Results include overhead from both the tested engine and macOS. TCP connection management and lock contention in the macOS kernel can bottleneck concurrent short connections, so adding flows can reduce connection rate. All three engines measured lower rates at 8 flows than at 4. Internet speed also depends on the route, protocol and server.
The apps
A powerful engine. A simple app.
Speed is the foundation. Simplicity is the everyday.
Not connected
One tap. Connected.
Sign in, tap once, and you’re on. The best route is chosen for you.
Smart routing
Local sites go direct, overseas sites go through PandaFan. Rules are built in and kept current, and your own rules always apply.
Enhanced
Enhanced mode
One switch, and every app on your computer or phone goes through PandaFan. Nothing to configure, app by app.
Compatible · free
Your config stays. The engine is free.
PandaCore reads the same YAML config as mihomo. The engine itself is a free download and runs without a PandaFan account.
INFOpanda_listener::mixed: Mixed listener 'mixed' on 127.0.0.1:7890
INFOpanda_api: REST API listening on 127.0.0.1:9090
Your config, as is
Proxies, groups, rules, DNS and TUN are read exactly as mihomo writes them. Add -t to check before you start.
Same control API
The external API matches mihomo, so your dashboards and tools connect as they are.
Free. No account required.
Download and run your own config. Connecting a PandaFan account is a single login command.
A few protocols such as Shadowsocks, VMess, Snell and SSH are not supported and need to be removed from the config.
Method
How we tested
PandaCore and two open-source engines run in TUN mode on the same Mac, all on default settings and at the same MTU.
Test traffic targets a sentinel address in a reserved range; the engine’s TUN takes it over and hands it back to servers on the machine itself, so every byte crosses the engine under test. Download, upload and bidirectional are measured with iperf3; connection rate and round trip with the socketbench probe.
These measurements use a local TUN loopback path. Connection rate is a short burst after 30 seconds of idle before each cell, not sustained connection-churn capacity. Results vary with the device, OS and background load; internet speed also depends on the route, protocol and server.
Same process; 30 s idle before each cell; 200 connections per flow
Run it yourself
sudo bash bench-macos.sh
View the scriptbench-macos.sh · 507 lines
#!/usr/bin/env bash
# macOS loopback TUN comparison: PandaCore vs sing-box vs mihomo (the two open-source engines on the PandaFan page).
#
# A TEST-NET-2 sentinel /32 is claimed by each engine's TUN (route-address), the engine rewrites the
# destination to 127.0.0.1 (PandaCore tun.destination-override / sing-box route-options), and the
# servers listen on 127.0.0.1: two iperf3 instances for throughput and one socketbench for connection
# rate and round-trip time. Traffic therefore enters the TUN, crosses the engine, and lands on
# loopback. Bidirectional = two single-direction iperf3 clients at once. Every throughput cell checks
# that the sentinel routes to a utun and that the utun moved at least half of the bytes iperf3
# reports; otherwise it fails loudly. Routes cover the sentinel /32 and mihomo's fake-IP range. The
# engine's RSS is sampled (peak) and its CPU time delta is turned into an average CPU percentage.
set -Eeuo pipefail
# Requirements: macOS on Apple silicon or Intel, passwordless sudo (or run under `sudo -v` first),
# iperf3 (brew install iperf3), python3, curl, tar. Binaries are downloaded into WORK/bin unless
# PANDA / SING / PROBE point at local files. The probe can also be built from socketbench.go
# placed next to this script when Go is installed.
#
# mihomo cannot rewrite a destination address, so it is driven through its own fake-IP DNS: the client
# asks mihomo's DNS for bench.test, gets a fake IP that routes into the TUN, and mihomo resolves the
# domain back to 127.0.0.1 through a tiny local DNS responder started by this script.
#
# Knobs: ENGINES="pandacore singbox mihomo" (add mihomo-mixed for mihomo's mixed stack)
# FLOWS="1 4 8" CONNECT_FLOWS="1 4 8" MODES="download upload bidirectional connect rtt"
# CONNECT_PAUSE=30 FRESH_MODES="" (keep the same engine between cells)
# DURATION=8 ROUNDS=3 MTU=15680 SING_STACK=gvisor SING_BOX_VERSION=1.14.0
# MIHOMO_STACK=gvisor MIHOMO_VERSION=1.19.30 WORK=./pandacore-macos-bench
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
WORK="${WORK:-$PWD/pandacore-macos-bench}"
mkdir -p "$WORK/bin"
WORK="$(cd "$WORK" && pwd -P)"
ROOT="$WORK"
BIN="$WORK/bin"
SING_BOX_VERSION="${SING_BOX_VERSION:-1.14.0}"
PANDACORE_BASE_URL="${PANDACORE_BASE_URL:-https://build.bamboe.app/panda-core/latest}"
PROBE_BASE_URL="${PROBE_BASE_URL:-$PANDACORE_BASE_URL}"
SING_BOX_BASE_URL="${SING_BOX_BASE_URL:-https://github.com/SagerNet/sing-box/releases/download}"
MIHOMO_VERSION="${MIHOMO_VERSION:-1.19.30}"
MIHOMO_BASE_URL="${MIHOMO_BASE_URL:-https://github.com/MetaCubeX/mihomo/releases/download}"
MIHOMO_STACK="${MIHOMO_STACK:-gvisor}"
ENGINES="${ENGINES:-pandacore singbox mihomo}"
FAKE_RANGE=198.19.0.0/16
FAKE_DNS_PORT=1953
MIHOMO_DNS_PORT=1553
case "$(uname -m)" in
arm64) ARCH=arm64 ;;
x86_64) ARCH=amd64 ;;
*) echo "unsupported CPU architecture $(uname -m)" >&2; exit 1 ;;
esac
PANDA="${PANDA:-$BIN/pandacore-darwin-$ARCH}"
SING="${SING:-$BIN/sing-box-$SING_BOX_VERSION-darwin-$ARCH}"
PROBE="${PROBE:-$BIN/socketbench-darwin-$ARCH}"
MIHOMO="${MIHOMO:-$BIN/mihomo-darwin-$ARCH-v$MIHOMO_VERSION}"
SENTINEL=198.51.100.181
TARGET="$SENTINEL"
PORT_A=15201
PORT_B=15202
PROBE_PORT=15299
FLOWS="${FLOWS:-1 4 8}"
CONNECT_FLOWS="${CONNECT_FLOWS:-1 4 8}"
MODES="${MODES:-download upload bidirectional connect rtt}"
DURATION="${DURATION:-8}"
CONNECT_OPS="${CONNECT_OPS:-200}"
RTT_OPS="${RTT_OPS:-1000}"
ROUNDS="${ROUNDS:-3}"
MTU="${MTU:-15680}"
SING_STACK="${SING_STACK:-gvisor}"
COOLDOWN="${COOLDOWN:-5}"
# macOS short-connection churn also slows fresh processes. Let the host recover before each
# connect cell; the resulting rate describes a burst after idle, not sustained connection churn.
CONNECT_PAUSE="${CONNECT_PAUSE:-30}"
FRESH_MODES="${FRESH_MODES-}"
CELL_IDLE=0
ENGINE_CELLS=0
RUN_TAG="${RUN_TAG:-$(date -u +%Y%m%dT%H%M%SZ)}"
OUT="$ROOT/results-$RUN_TAG.jsonl"
SUMMARY="$ROOT/summary-$RUN_TAG.json"
LOGS="$ROOT/logs-$RUN_TAG"
mkdir -p "$LOGS"
log() { printf '\033[1;32m[mac-bench]\033[0m %s\n' "$*" >&2; }
die() { printf '\033[1;31m[mac-bench] error:\033[0m %s\n' "$*" >&2; exit 1; }
[[ "$CONNECT_PAUSE" =~ ^[0-9]+$ ]] || die "CONNECT_PAUSE must be a non-negative integer"
[[ "$(uname -s)" == Darwin ]] || die "macOS only"
sudo -n true 2>/dev/null || die "sudo is required: run \`sudo -v\` first, then start this script again"
for t in iperf3 python3 route netstat; do command -v $t >/dev/null || die "missing $t"; done
[[ " $ENGINES " != *" mihomo"* ]] || command -v dig >/dev/null || die "missing dig (needed for mihomo's fake-IP lookup)"
fetch_inputs() {
if [[ ! -x "$PANDA" ]]; then
log "downloading PandaCore (darwin $ARCH)"
curl -fLsS "$PANDACORE_BASE_URL/pandacore-darwin-$ARCH" -o "$PANDA" && chmod +x "$PANDA"
fi
if [[ ! -x "$SING" ]]; then
local archive="sing-box-$SING_BOX_VERSION-darwin-$ARCH.tar.gz"
log "downloading sing-box $SING_BOX_VERSION (darwin $ARCH)"
curl -fLsS "$SING_BOX_BASE_URL/v$SING_BOX_VERSION/$archive" -o "$BIN/$archive"
tar -xzf "$BIN/$archive" -C "$BIN" --strip-components=1 "sing-box-$SING_BOX_VERSION-darwin-$ARCH/sing-box"
mv "$BIN/sing-box" "$SING" && chmod +x "$SING"
fi
if [[ " $ENGINES " == *" mihomo"* && ! -x "$MIHOMO" ]]; then
log "downloading mihomo $MIHOMO_VERSION (darwin $ARCH)"
curl -fLsS "$MIHOMO_BASE_URL/v$MIHOMO_VERSION/mihomo-darwin-$ARCH-v$MIHOMO_VERSION.gz" -o "$MIHOMO.gz"
gunzip -f "$MIHOMO.gz" && chmod +x "$MIHOMO"
fi
if [[ ! -x "$PROBE" ]]; then
log "downloading socketbench probe (darwin $ARCH)"
if ! curl -fLsS "$PROBE_BASE_URL/socketbench-darwin-$ARCH" -o "$PROBE"; then
rm -f "$PROBE"
if [[ -f "$SCRIPT_DIR/socketbench.go" ]] && command -v go >/dev/null 2>&1; then
log "download failed; building the probe from socketbench.go with $(go version | cut -d' ' -f3)"
(cd "$BIN" && CGO_ENABLED=0 go build -trimpath -o "$PROBE" "$SCRIPT_DIR/socketbench.go")
else
die "could not download the probe and cannot build it (put socketbench.go next to this script and install Go, or set PROBE)"
fi
fi
chmod +x "$PROBE"
fi
}
fetch_inputs
[[ -x "$PANDA" ]] || die "PandaCore binary missing: $PANDA"
[[ -x "$SING" ]] || die "sing-box binary missing: $SING"
[[ -x "$PROBE" ]] || die "socketbench binary missing: $PROBE"
[[ " $ENGINES " != *" mihomo"* ]] || [[ -x "$MIHOMO" ]] || die "mihomo binary missing: $MIHOMO"
PANDA_NAME="$(basename "$PANDA")"
SING_NAME="$(basename "$SING")"
MIHOMO_NAME="$(basename "$MIHOMO")"
pgrep -x "$PANDA_NAME" >/dev/null && die "a $PANDA_NAME process is already running; stop it first"
pgrep -x "$SING_NAME" >/dev/null && die "a $SING_NAME process is already running; stop it first"
pgrep -x "$MIHOMO_NAME" >/dev/null && die "a $MIHOMO_NAME process is already running; stop it first"
ifconfig 2>/dev/null | grep -q "inet 198\.18\.0\.1 " && die "an interface already holds 198.18.0.1; a previous engine is still alive or left a utun behind"
sentinel_iface() { route -n get "$SENTINEL" 2>/dev/null | awk '/interface:/ {print $2}'; }
target_iface() { route -n get "$TARGET" 2>/dev/null | awk '/interface:/ {print $2}'; }
DEFAULT_IFACE="$(sentinel_iface)"
[[ "$DEFAULT_IFACE" != utun* ]] || die "sentinel $SENTINEL already routes to $DEFAULT_IFACE; refusing to start"
ENGINE_PID=""
CORE_PID=""
SERVER_PIDS=()
cleanup() {
set +e
stop_engine
for p in "${SERVER_PIDS[@]:-}"; do [[ -n "$p" ]] && kill "$p" 2>/dev/null; done
}
trap cleanup EXIT
now() { python3 -c 'import time; print(time.time())'; }
cpu_seconds() { ps -o cputime= -p "$1" 2>/dev/null | python3 -c 'import sys
t = sys.stdin.read().strip()
if not t:
print(0); raise SystemExit
parts = [float(x) for x in t.split(":")]
print(sum(v * 60 ** i for i, v in enumerate(reversed(parts))))'; }
rss_mib() { ps -o rss= -p "$1" 2>/dev/null | awk '{ printf "%.1f\n", $1 / 1024 }'; }
write_configs() {
cat > "$LOGS/pandacore.yaml" <<YAML
mode: direct
log-level: info
geodata:
url:
mmdb: http://127.0.0.1:9/country.mmdb
asn: http://127.0.0.1:9/asn.mmdb
geosite: http://127.0.0.1:9/geosite.dat
tun:
enable: true
mtu: $MTU
auto-route: true
auto-detect-interface: true
route-address:
- $SENTINEL/32
destination-override:
$SENTINEL: 127.0.0.1
YAML
cat > "$LOGS/sing-box.json" <<JSON
{
"log": { "level": "warn" },
"inbounds": [
{
"type": "tun", "tag": "tun-in",
"address": ["198.18.0.1/30"], "mtu": $MTU, "auto_route": true, "strict_route": false,
"stack": "$SING_STACK", "route_address": ["$SENTINEL/32"]
}
],
"outbounds": [ { "type": "direct", "tag": "direct" } ],
"route": {
"rules": [ { "ip_cidr": ["$SENTINEL/32"], "action": "route-options", "override_address": "127.0.0.1" } ],
"final": "direct", "auto_detect_interface": true
}
}
JSON
local stack name
for name in mihomo mihomo-mixed; do
stack="$MIHOMO_STACK"; [[ "$name" == mihomo-mixed ]] && stack=mixed
cat > "$LOGS/$name.yaml" <<YAML
mode: rule
log-level: warning
ipv6: false
profile:
store-selected: false
store-fake-ip: false
tun:
enable: true
stack: $stack
mtu: $MTU
auto-route: true
auto-detect-interface: true
route-address:
- $FAKE_RANGE
dns:
enable: true
listen: 127.0.0.1:$MIHOMO_DNS_PORT
ipv6: false
enhanced-mode: fake-ip
fake-ip-range: 198.19.0.1/16
fake-ip-filter: []
use-hosts: false
use-system-hosts: false
nameserver:
- 127.0.0.1:$FAKE_DNS_PORT
rules:
- MATCH,DIRECT
YAML
done
cat > "$LOGS/fakedns.py" <<'PY'
import socket, struct, sys
# Answers every A query with 127.0.0.1 so mihomo resolves the fake-IP domain back to loopback.
port = int(sys.argv[1])
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.bind(("127.0.0.1", port))
while True:
data, addr = sock.recvfrom(4096)
if len(data) < 17:
continue
i = 12
while data[i] != 0:
i += data[i] + 1
i += 1
qtype = struct.unpack("!H", data[i:i + 2])[0]
question = data[12:i + 4]
if qtype == 1:
answer = b"\xc0\x0c" + struct.pack("!HHIH", 1, 1, 60, 4) + bytes([127, 0, 0, 1])
count = 1
else:
answer = b""
count = 0
sock.sendto(data[:2] + b"\x81\x80" + struct.pack("!HHHH", 1, count, 0, 0) + question + answer, addr)
PY
}
start_engine() {
local name="$1" i
TARGET="$SENTINEL"
case "$name" in
pandacore) sudo -n "$PANDA" -f "$LOGS/pandacore.yaml" -d "$LOGS/panda-home" >>"$LOGS/pandacore.log" 2>&1 & ;;
singbox) sudo -n "$SING" run -c "$LOGS/sing-box.json" -D "$LOGS/sing-home" >>"$LOGS/singbox.log" 2>&1 & ;;
mihomo|mihomo-mixed) TARGET=198.19.0.2; sudo -n "$MIHOMO" -f "$LOGS/$name.yaml" -d "$LOGS/$name-home" >>"$LOGS/$name.log" 2>&1 & ;;
*) die "unknown engine $name" ;;
esac
ENGINE_PID=$!
for i in $(seq 1 120); do
if [[ "$(target_iface)" == utun* ]]; then break; fi
ps -p "$ENGINE_PID" >/dev/null 2>&1 || die "$name (sudo pid $ENGINE_PID) exited during startup; see $LOGS/$name.log"
sleep 0.25
done
[[ "$(target_iface)" == utun* ]] || die "$name never claimed $TARGET on a utun; see $LOGS/$name.log"
case "$name" in
pandacore) CORE_PID="$(pgrep -x "$PANDA_NAME" | head -n 1)" ;;
singbox) CORE_PID="$(pgrep -x "$SING_NAME" | head -n 1)" ;;
mihomo|mihomo-mixed)
CORE_PID="$(pgrep -x "$MIHOMO_NAME" | head -n 1)"
local fake
for i in $(seq 1 20); do
fake="$(dig @127.0.0.1 -p "$MIHOMO_DNS_PORT" bench.test A +short +time=1 +tries=1 2>/dev/null | head -n 1)"
[[ "$fake" == 198.19.* ]] && break
sleep 0.5
done
[[ "$fake" == 198.19.* ]] || die "mihomo DNS did not hand out a fake IP for bench.test; see $LOGS/$name.log"
TARGET="$fake"
[[ "$(target_iface)" == utun* ]] || die "fake IP $TARGET does not route to a utun"
log "$name fake IP for bench.test: $TARGET" ;;
esac
[[ -n "$CORE_PID" ]] || die "cannot find the $name engine process"
ENGINE_CELLS=0
sleep 2
}
stop_engine() {
if [[ -n "$ENGINE_PID" ]] && ps -p "$ENGINE_PID" >/dev/null 2>&1; then
# sudo is the direct child; signal the engine itself so it tears its routes down cleanly.
local real i
real="$( { pgrep -x "$PANDA_NAME"; pgrep -x "$SING_NAME"; pgrep -x "$MIHOMO_NAME"; } || true)"
for i in $real; do sudo -n kill -INT "$i" 2>/dev/null || true; done
for i in $(seq 1 80); do ps -p "$ENGINE_PID" >/dev/null 2>&1 || break; sleep 0.25; done
if ps -p "$ENGINE_PID" >/dev/null 2>&1; then
log "engine ignored SIGINT for 20s; killing"
for i in $real; do sudo -n kill -9 "$i" 2>/dev/null || true; done
sleep 1
fi
fi
ENGINE_PID=""
CORE_PID=""
sleep 1
local iface
iface="$(target_iface)"
if [[ "$iface" == utun* || "$(route -n get "$TARGET" 2>/dev/null | awk '/gateway:/ {print $2}')" == 198.1[89].* ]]; then
log "orphan route to $TARGET left behind (via $iface); deleting it"
if [[ "$TARGET" == "$SENTINEL" ]]; then
sudo -n route -n delete "$SENTINEL" >/dev/null 2>&1 || true
else
sudo -n route -n delete -net "$FAKE_RANGE" >/dev/null 2>&1 || true
fi
fi
[[ "$(target_iface)" == "$DEFAULT_IFACE" ]] || die "route to $TARGET did not return to $DEFAULT_IFACE after stopping the engine"
TARGET="$SENTINEL"
}
# Sum of input and output bytes on an interface. macOS prints one row per address family; the
# <Link#> row has no Address column, so take the Ibytes/Obytes pair by position from the end.
utun_bytes() { netstat -I "$1" -b 2>/dev/null | awk 'NR == 2 { print $(NF-4) + $(NF-1) }'; }
iperf_bps_and_bytes() {
python3 -c 'import json, sys
bps = bytes_ = 0
for path in sys.argv[1:]:
end = json.load(open(path))["end"]["sum_received"]
bps += end["bits_per_second"]; bytes_ += end["bytes"]
print("%.3f %d" % (bps / 1e9, bytes_))' "$@"
}
probe_measurement() {
python3 - "$1" <<'PY'
import json, sys
events = [json.loads(l) for l in open(sys.argv[1]) if l.strip()]
ms = [e for e in events if e.get("event") == "measurement"]
if not ms:
raise SystemExit("no measurement in " + sys.argv[1])
print(json.dumps(ms[-1]))
PY
}
record() {
python3 - "$OUT" "$@" "$CORE_PID" "$CELL_IDLE" <<'PY'
import json, sys, datetime
out, rnd, engine, mode, flows, value, unit, rss_peak, cpu_pct, utun_bytes, payload_bytes, cells_before, engine_pid, idle_seconds = sys.argv[1:15]
row = {"round": int(rnd), "engine": engine, "mode": mode, "flows": int(flows), "value": float(value), "unit": unit,
"rss_peak_mib": float(rss_peak), "cpu_percent": float(cpu_pct),
"utun_bytes": int(utun_bytes), "payload_bytes": int(payload_bytes),
"cells_served_before": int(cells_before),
"engine_pid": int(engine_pid), "idle_before_s": int(idle_seconds),
"at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")}
open(out, "a").write(json.dumps(row) + "\n")
PY
}
record_meta() {
CONNECT_PAUSE="$CONNECT_PAUSE" FRESH_MODES="$FRESH_MODES" CONNECT_OPS="$CONNECT_OPS" RTT_OPS="$RTT_OPS" python3 - "$OUT" "$@" <<'PY'
import json, sys, datetime, os
out, panda, sing, probe, mtu, sing_stack, duration, rounds, panda_version, sing_version = sys.argv[1:11]
row = {"meta": True, "at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds"),
"host": os.uname().sysname + " " + os.uname().release + " " + os.uname().machine,
"pandacore": {"path": panda, "version": panda_version, "size_mib": round(os.path.getsize(panda) / 1048576, 2)},
"singbox": {"path": sing, "version": sing_version, "size_mib": round(os.path.getsize(sing) / 1048576, 2), "stack": sing_stack},
"probe": probe, "mtu": int(mtu), "duration_s": int(duration), "rounds": int(rounds),
"connect_idle_s": int(os.environ["CONNECT_PAUSE"]), "fresh_modes": os.environ["FRESH_MODES"].split(),
"connect_ops": int(os.environ["CONNECT_OPS"]), "rtt_ops": int(os.environ["RTT_OPS"])}
if len(sys.argv) > 13:
mihomo, mihomo_version, mihomo_stack = sys.argv[11:14]
row["mihomo"] = {"path": mihomo, "version": mihomo_version, "size_mib": round(os.path.getsize(mihomo) / 1048576, 2), "stack": mihomo_stack}
open(out, "a").write(json.dumps(row) + "\n")
PY
}
run_cell() {
local rnd="$1" engine="$2" mode="$3" flows="$4"
local iface before after ja jb value unit payload_bytes t0 t1 c0 c1 cpu_pct rss_peak sampler
iface="$(target_iface)"
[[ "$iface" == utun* ]] || die "target $TARGET not on a utun before $engine $mode f$flows"
before="$(utun_bytes "$iface")"
ja="$LOGS/r$rnd-$engine-$mode-f$flows-a.json"
jb="$LOGS/r$rnd-$engine-$mode-f$flows-b.json"
: > "$LOGS/rss-r$rnd-$engine-$mode-f$flows.txt"
( while :; do rss_mib "$CORE_PID"; sleep 0.5; done >>"$LOGS/rss-r$rnd-$engine-$mode-f$flows.txt" 2>/dev/null ) &
sampler=$!
t0="$(now)"; c0="$(cpu_seconds "$CORE_PID")"
case "$mode" in
download)
iperf3 -c "$TARGET" -p "$PORT_A" -R -P "$flows" -t "$DURATION" -J >"$ja" || die "iperf3 failed: $ja"
read -r value payload_bytes <<<"$(iperf_bps_and_bytes "$ja")"; unit=Gbit/s ;;
upload)
iperf3 -c "$TARGET" -p "$PORT_A" -P "$flows" -t "$DURATION" -J >"$ja" || die "iperf3 failed: $ja"
read -r value payload_bytes <<<"$(iperf_bps_and_bytes "$ja")"; unit=Gbit/s ;;
bidirectional)
iperf3 -c "$TARGET" -p "$PORT_A" -R -P "$flows" -t "$DURATION" -J >"$ja" &
local pa=$!
iperf3 -c "$TARGET" -p "$PORT_B" -P "$flows" -t "$DURATION" -J >"$jb" || die "iperf3 upload half failed: $jb"
wait "$pa" || die "iperf3 download half failed: $ja"
read -r value payload_bytes <<<"$(iperf_bps_and_bytes "$ja" "$jb")"; unit=Gbit/s ;;
connect)
"$PROBE" client -mode tcp-connect -target "$TARGET:$PROBE_PORT" -flows "$flows" -operations "$CONNECT_OPS" -payload-size 1200 -timeout 5s >"$ja" 2>>"$LOGS/probe-client.err" \
|| die "socketbench tcp-connect failed: $ja"
value="$(probe_measurement "$ja" | python3 -c 'import json,sys; print("%.1f" % json.loads(sys.stdin.read())["operations_per_second"])')"
payload_bytes=0; unit=conn/s ;;
rtt)
"$PROBE" client -mode tcp-rtt -target "$TARGET:$PROBE_PORT" -flows "$flows" -operations "$RTT_OPS" -payload-size 1200 -timeout 5s >"$ja" 2>>"$LOGS/probe-client.err" \
|| die "socketbench tcp-rtt failed: $ja"
value="$(probe_measurement "$ja" | python3 -c 'import json,sys; print("%.4f" % json.loads(sys.stdin.read())["latency_ms"]["p50"])')"
payload_bytes=0; unit=ms ;;
*) die "unknown mode $mode" ;;
esac
t1="$(now)"; c1="$(cpu_seconds "$CORE_PID")"
kill "$sampler" 2>/dev/null || true
wait "$sampler" 2>/dev/null || true
cpu_pct="$(python3 -c 'import sys; c0,c1,t0,t1=map(float, sys.argv[1:]); print("%.1f" % (100*(c1-c0)/max(t1-t0,1e-6)))' "$c0" "$c1" "$t0" "$t1")"
rss_peak="$(sort -n "$LOGS/rss-r$rnd-$engine-$mode-f$flows.txt" | tail -n 1)"
[[ -n "$rss_peak" ]] || rss_peak="$(rss_mib "$CORE_PID")"
after="$(utun_bytes "$iface")"
local moved=$((after - before))
if ((payload_bytes > 0)) && ((moved * 2 < payload_bytes)); then
die "$engine $mode f$flows: utun $iface moved $moved bytes but iperf3 reports $payload_bytes; traffic is bypassing the TUN"
fi
record "$rnd" "$engine" "$mode" "$flows" "$value" "$unit" "$rss_peak" "$cpu_pct" "$moved" "$payload_bytes" "$ENGINE_CELLS"
ENGINE_CELLS=$((ENGINE_CELLS + 1))
printf ' r%s %-9s %-13s f%-2s %9s %-7s rss %6s MiB cpu %5s%%\n' "$rnd" "$engine" "$mode" "$flows" "$value" "$unit" "$rss_peak" "$cpu_pct" >&2
}
summarize() {
python3 - "$OUT" "$SUMMARY" <<'PY'
import json, sys, statistics, collections
rows = [json.loads(l) for l in open(sys.argv[1]) if l.strip()]
meta = [r for r in rows if r.get("meta")][-1]
cells = collections.defaultdict(list)
for r in rows:
if r.get("meta"): continue
cells[(r["mode"], r["flows"], r["engine"])].append(r)
lower_better = {"rtt"}
engines = [e for e in ("pandacore", "singbox", "mihomo", "mihomo-mixed") if any(k[2] == e for k in cells)]
summary = {"meta": meta, "engines": engines, "cells": []}
print()
print(f"{'cell':<20}" + "".join(f"{e:>13}" for e in engines) + " rss/cpu per engine raw rounds")
for mode in ("download", "upload", "bidirectional", "connect", "rtt"):
for flows in sorted({k[1] for k in cells if k[0] == mode}):
per = {e: cells.get((mode, flows, e)) for e in engines}
if any(v is None for v in per.values()): continue
lower = mode in lower_better
fmt = (lambda v: f"{v:.4f}") if lower else (lambda v: f"{v:.2f}")
cell = {"mode": mode, "flows": flows, "unit": per[engines[0]][0]["unit"],
"measured_at": max(r["at"][:10] for group in per.values() for r in group)}
for e, rows_ in per.items():
vals = [r["value"] for r in rows_]
cell[e] = {"median": statistics.median(vals), "rounds": vals,
"rss_peak_mib": statistics.median(r["rss_peak_mib"] for r in rows_),
"cpu_percent": statistics.median(r["cpu_percent"] for r in rows_)}
if "pandacore" in cell and "singbox" in cell:
pm, sm = cell["pandacore"]["median"], cell["singbox"]["median"]
cell["advantage"] = (sm / pm) if lower else (pm / sm)
delta = (pm / sm - 1) * 100
cell["verdict"] = "tie" if abs(delta) < 5 else ("pandacore" if (delta < 0) == lower else "singbox")
if "pandacore" in cell and "mihomo" in cell:
pm, mm = cell["pandacore"]["median"], cell["mihomo"]["median"]
cell["advantage_vs_mihomo"] = (mm / pm) if lower else (pm / mm)
summary["cells"].append(cell)
print(f"{mode + ' f' + str(flows):<20}" + "".join(f"{fmt(cell[e]['median']):>13}" for e in engines)
+ " " + " ".join(f"{cell[e]['rss_peak_mib']:.0f}/{cell[e]['cpu_percent']:.0f}" for e in engines)
+ " " + " ".join(f"{e[0].upper()}={[fmt(x) for x in cell[e]['rounds']]}" for e in engines))
json.dump(summary, open(sys.argv[2], "w"), indent=2)
print(f"\nsummary: {sys.argv[2]}")
PY
}
main() {
log "results: $OUT"
local pv sv
pv="$("$PANDA" -V 2>/dev/null | head -n 1 | awk '{print $NF}')"
sv="$("$SING" version 2>/dev/null | head -n 1 | awk '{print $3}')"
local mv=""
if [[ " $ENGINES " == *" mihomo"* ]]; then mv="$("$MIHOMO" -v 2>/dev/null | head -n 1 | awk '{print $3}')"; fi
log "PandaCore $pv · sing-box $sv${mv:+ · mihomo $mv} · MTU $MTU · sing stack $SING_STACK · throughput $DURATION s · $ROUNDS rounds · probe ops $CONNECT_OPS/$RTT_OPS · connect idle $CONNECT_PAUSE s · fresh modes: ${FRESH_MODES:-none} · flows $FLOWS · modes $MODES · engines $ENGINES"
write_configs
if [[ -n "$mv" ]]; then
record_meta "$PANDA" "$SING" "$PROBE" "$MTU" "$SING_STACK" "$DURATION" "$ROUNDS" "$pv" "$sv" "$MIHOMO" "$mv" "$MIHOMO_STACK"
else
record_meta "$PANDA" "$SING" "$PROBE" "$MTU" "$SING_STACK" "$DURATION" "$ROUNDS" "$pv" "$sv"
fi
iperf3 -s -B 127.0.0.1 -p "$PORT_A" -D -I "$LOGS/iperf-a.pid" --logfile "$LOGS/iperf-a.log"
iperf3 -s -B 127.0.0.1 -p "$PORT_B" -D -I "$LOGS/iperf-b.pid" --logfile "$LOGS/iperf-b.log"
"$PROBE" server -listen "127.0.0.1:$PROBE_PORT" >"$LOGS/probe-server.jsonl" 2>"$LOGS/probe-server.err" &
local probe_pid=$!
python3 "$LOGS/fakedns.py" "$FAKE_DNS_PORT" >"$LOGS/fakedns.log" 2>&1 &
local dns_pid=$!
sleep 0.5
SERVER_PIDS=("$(cat "$LOGS/iperf-a.pid")" "$(cat "$LOGS/iperf-b.pid")" "$probe_pid" "$dns_pid")
local rnd engine mode flows order
for rnd in $(seq 1 "$ROUNDS"); do
order="$(python3 -c 'import sys; e = sys.argv[1].split(); k = int(sys.argv[2]) % len(e); print(" ".join(e[k:] + e[:k]))' "$ENGINES" "$((rnd - 1))")"
for engine in $order; do
log "round $rnd · $engine"
start_engine "$engine"
for mode in $MODES; do
local list="$FLOWS"
[[ "$mode" == connect ]] && list="$CONNECT_FLOWS"
for flows in $list; do
if [[ " $FRESH_MODES " == *" $mode "* && "$ENGINE_CELLS" -gt 0 ]]; then
stop_engine
sleep 1
start_engine "$engine"
fi
CELL_IDLE=0
if [[ "$mode" == connect ]]; then
CELL_IDLE="$CONNECT_PAUSE"
log "idle $CELL_IDLE s before $engine connect f$flows (pid $CORE_PID)"
sleep "$CELL_IDLE"
fi
run_cell "$rnd" "$engine" "$mode" "$flows"
done
done
stop_engine
sleep "$COOLDOWN"
done
done
summarize
}
main "$@"
View the probe sourcesocketbench.go · Go
The full source of socketbench, the probe behind the connection-rate and round-trip cells. It depends only on the Go standard library; save it as socketbench.go next to the script and the script builds it with go build when the probe download is unavailable.
Six kinds of device. One engine.
PandaCore ships inside every official app: same behavior, updated together. Tap a platform to download.