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
29.0 vs 15.0 Gbit/s
−0%
Peak memory while downloading
26 vs 65 MiB
0.0×
Connections per second
5,736 vs 4,118 conn/s
−0%
Round-trip latency
0.053 vs 0.072 ms
Throughput & latency
Higher throughput. Lower latency.
Download, upload and bidirectional at 1 / 4 / 8 TCP streams, measured with iperf3; connection rate at 1 / 4 flows and round trip at 1 / 4 / 8 flows with the probe. 8 seconds per cell, medians of 3 rounds. All engines on default settings, in TUN mode at MTU 15680.
PandaCoreOpen-source engine 1Open-source engine 2
Download · 1 stream
1.9×vs Open-source engine 1
7.3×vs Open-source engine 2
29.015.04.0Gbit/s
Download · 4 streams
1.4×vs Open-source engine 1
3.0×vs Open-source engine 2
36.225.611.9Gbit/s
Download · 8 streams
1.1×vs Open-source engine 1
2.3×vs Open-source engine 2
34.130.014.7Gbit/s
Upload · 1 stream
1.2×vs Open-source engine 1
2.1×vs Open-source engine 2
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
1.5×vs Open-source engine 1
2.6×vs Open-source engine 2
32.821.712.8Gbit/s
Both ways · 4 streams
1.3×vs Open-source engine 1
1.4×vs Open-source engine 2
28.522.720.6Gbit/s
Both ways · 8 streams
1.1×vs Open-source engine 1
1.2×vs Open-source engine 2
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 data14 cells
Every cell’s three raw rounds, median, verdict and difference from open-source engine 1, plus the engine’s peak memory and CPU use during that cell.
Cell
PandaCore
Open-source engine 1
Open-source engine 2
Verdict
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
PandaCore ahead+93%
18 MiB·98%−67%·−33%
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
PandaCore ahead+41%
22 MiB·235%−63%·−37%
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
PandaCore ahead+14%
26 MiB·266%−59%·−32%
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
PandaCore ahead+21%
27 MiB·127%−60%·−52%
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
PandaCore ahead+60%
28 MiB·222%−58%·−16%
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
PandaCore ahead+70%
28 MiB·227%−58%·−23%
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
PandaCore ahead+52%
28 MiB·182%−58%·−40%
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
PandaCore ahead+26%
28 MiB·259%−58%·−29%
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
PandaCore ahead+8%
33 MiB·279%−54%·−26%
72 MiB · 378%
55 MiB · 437%
Connect · 1 flowconn/s · socketbench
5,7365,737 / 5,736 / 5,591
4,1184,165 / 4,118 / 4,093
2,3562,302 / 2,403 / 2,356
PandaCore ahead+39%
33 MiB·21%−54%·−55%
72 MiB · 46%
55 MiB · 42%
Connect · 4 flowsconn/s · socketbench
7,6007,708 / 7,600 / 7,431
6,9857,307 / 6,591 / 6,985
4,9644,964 / 5,512 / 4,878
PandaCore ahead+9%
33 MiB·79%−54%·−42%
72 MiB · 135%
55 MiB · 143%
lower is better
RTT · 1 flowms · socketbench
0.05280.0528 / 0.0523 / 0.0532
0.07210.0721 / 0.0708 / 0.0726
0.07370.0737 / 0.0731 / 0.0760
PandaCore ahead−27%
34 MiB·25%−54%·−51%
73 MiB · 51%
55 MiB · 46%
RTT · 4 flowsms · socketbench
0.09040.0885 / 0.1032 / 0.0904
0.11120.1102 / 0.1112 / 0.1289
0.11040.1096 / 0.1104 / 0.1316
PandaCore ahead−19%
34 MiB·82%−54%·−42%
73 MiB · 140%
55 MiB · 133%
RTT · 8 flowsms · socketbench
0.15180.1450 / 0.1542 / 0.1518
0.18910.1867 / 0.1891 / 0.1972
0.18940.1894 / 0.1855 / 0.1901
PandaCore ahead−20%
34 MiB·139%−54%·−42%
73 MiB · 240%
56 MiB · 256%
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%
−43%
8-stream download · peak memory
26.4
65.1
48.0
−59%
−45%
8-stream both ways · peak memory
33.2
72.3
54.7
−54%
−39%
The binary is 69% smaller and peak memory during an 8-stream download is 59% lower.
CPU · iperf3, 8 streams
Engine CPU use
% · lower is better
PandaCoreOpen-source engine 1Open-source engine 2
8-stream download
266
392
375
−32%
−29%
8-stream both ways
279
378
437
−26%
−36%
CPU use during the 8-stream download is 266% vs 392%. Per Gbit/s, PandaCore spends 7.8% and open-source engine 1 13.1%.
TCP connect · socketbench probe
Connection rate
conn/s · higher is better
PandaCoreOpen-source engine 1Open-source engine 2
1 flow
5,736
4,118
2,356
1.4×
2.4×
4 flows
7,600
6,985
4,964
1.1×
1.5×
1.4× open-source engine 1 at 1 flow, 1.1× at 4.
TCP round trip · median · socketbench probe
Round-trip latency
ms · lower is better
PandaCoreOpen-source engine 1Open-source engine 2
1 flow
0.053
0.072
0.074
1.4×
1.4×
4 flows
0.090
0.111
0.110
1.2×
1.2×
8 flows
0.152
0.189
0.189
1.2×
1.2×
Median round trip is 27% lower at 1 flow, 19% at 4 and 20% at 8.
These are same-Mac comparisons. They reflect the overhead of the engines themselves, not real-world speed, which 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 figures reflect the overhead of the engines themselves. Results vary with the device, operating system, network environment and many other factors, so your own test results may differ. Real-world speed also depends on the route, protocol and server.
Measured
2026-09-04
Machine
MacBook Pro · Apple M5 Max · 64 GB · macOS 27.0
PandaCore
1.8.2
Open-source engine 1
1.14.0 · gvisor
Open-source engine 2
v1.19.30 · gvisor
MTU
15680
Schedule
8 s per cell × 3 rounds
Run it yourself
sudo bash bench-macos.sh
View the scriptbench-macos.sh · 481 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. Only the sentinel /32 is ever routed. During every cell 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" MODES="download upload bidirectional connect rtt"
# 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}"
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}"
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; }
[[ "$(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"
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" "$@" <<'PY'
import json, sys, datetime
out, rnd, engine, mode, flows, value, unit, rss_peak, cpu_pct, utun_bytes, payload_bytes = sys.argv[1:12]
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),
"at": datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds")}
open(out, "a").write(json.dumps(row) + "\n")
PY
}
record_meta() {
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)}
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"
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"]}
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 · $DURATION s × $ROUNDS rounds · 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
if [[ "$mode" == connect ]]; then
for flows in $CONNECT_FLOWS; do run_cell "$rnd" "$engine" "$mode" "$flows"; done
else
for flows in $FLOWS; do run_cell "$rnd" "$engine" "$mode" "$flows"; done
fi
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.