Skip to content

PandaCore · built in-house

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.

Same 10 seconds. Quite a difference.

macOS · TUN mode · single TCP download · iperf3 · replayed at measured rates

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.

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 better
UploadGbit/s · higher is better
BidirectionalGbit/s · higher is better
Connectconn/s · higher is better
RTTms · 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.

CellPandaCoreOpen-source engine 1Open-source engine 2VerdictPandaCore memory · CPUOpen-source engine 1 memory · CPUOpen-source engine 2 memory · CPU
higher is betterDownload · 1 streamGbit/s · iperf329.0129.08 / 27.89 / 29.0115.0115.01 / 15.31 / 14.953.983.98 / 3.99 / 3.94PandaCore ahead+93%18 MiB·98%−67%·−33%56 MiB · 147%42 MiB · 82%
Download · 4 streamsGbit/s · iperf336.2038.53 / 36.20 / 35.5325.6425.64 / 26.23 / 25.1311.9311.93 / 11.94 / 11.54PandaCore ahead+41%22 MiB·235%−63%·−37%61 MiB · 373%45 MiB · 254%
Download · 8 streamsGbit/s · iperf334.1035.21 / 34.10 / 32.1730.0230.02 / 30.91 / 26.8714.7114.42 / 14.71 / 14.82PandaCore ahead+14%26 MiB·266%−59%·−32%65 MiB · 392%48 MiB · 375%
Upload · 1 streamGbit/s · iperf323.2523.83 / 22.52 / 23.2519.2919.35 / 19.29 / 13.2410.8210.84 / 10.58 / 10.82PandaCore ahead+21%27 MiB·127%−60%·−52%66 MiB · 263%48 MiB · 131%
Upload · 4 streamsGbit/s · iperf326.6726.67 / 27.25 / 25.0416.6516.65 / 16.90 / 12.3220.2320.34 / 20.23 / 20.00PandaCore ahead+60%28 MiB·222%−58%·−16%66 MiB · 265%48 MiB · 341%
Upload · 8 streamsGbit/s · iperf325.6825.68 / 26.44 / 25.0715.0917.59 / 15.09 / 8.9721.8321.96 / 21.72 / 21.83PandaCore ahead+70%28 MiB·227%−58%·−23%66 MiB · 295%49 MiB · 409%
Both ways · 1 streamGbit/s · iperf332.8433.88 / 28.36 / 32.8421.6822.14 / 21.68 / 18.7112.7812.99 / 12.78 / 11.92PandaCore ahead+52%28 MiB·182%−58%·−40%66 MiB · 301%49 MiB · 197%
Both ways · 4 streamsGbit/s · iperf328.5330.66 / 28.53 / 27.0822.6523.30 / 22.65 / 19.5920.5920.63 / 20.35 / 20.59PandaCore ahead+26%28 MiB·259%−58%·−29%67 MiB · 364%50 MiB · 422%
Both ways · 8 streamsGbit/s · iperf324.7427.53 / 24.74 / 22.9422.9823.10 / 22.98 / 20.4520.9821.18 / 20.98 / 20.73PandaCore ahead+8%33 MiB·279%−54%·−26%72 MiB · 378%55 MiB · 437%
Connect · 1 flowconn/s · socketbench5,7365,737 / 5,736 / 5,5914,1184,165 / 4,118 / 4,0932,3562,302 / 2,403 / 2,356PandaCore ahead+39%33 MiB·21%−54%·−55%72 MiB · 46%55 MiB · 42%
Connect · 4 flowsconn/s · socketbench7,6007,708 / 7,600 / 7,4316,9857,307 / 6,591 / 6,9854,9644,964 / 5,512 / 4,878PandaCore ahead+9%33 MiB·79%−54%·−42%72 MiB · 135%55 MiB · 143%
lower is betterRTT · 1 flowms · socketbench0.05280.0528 / 0.0523 / 0.05320.07210.0721 / 0.0708 / 0.07260.07370.0737 / 0.0731 / 0.0760PandaCore ahead−27%34 MiB·25%−54%·−51%73 MiB · 51%55 MiB · 46%
RTT · 4 flowsms · socketbench0.09040.0885 / 0.1032 / 0.09040.11120.1102 / 0.1112 / 0.12890.11040.1096 / 0.1104 / 0.1316PandaCore ahead−19%34 MiB·82%−54%·−42%73 MiB · 140%55 MiB · 133%
RTT · 8 flowsms · socketbench0.15180.1450 / 0.1542 / 0.15180.18910.1867 / 0.1891 / 0.19720.18940.1894 / 0.1855 / 0.1901PandaCore 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

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

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

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

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 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.

Three commands. Try it now.
# download (Linux x86_64)
curl -fL https://build.bamboe.app/panda-core/latest/pandacore-linux-amd64 -o pandacore && chmod +x pandacore
# check your existing config
./pandacore -f config.yaml -t
INFOpanda_config: Config loaded: mode=rule, proxies=6, rules=3
INFOpandacore: Configuration test passed
# start
./pandacore -f config.yaml
INFOpandacore: panda-core is running
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.

  • One account for every device. Nothing to buy twice.
  • Apps update themselves, so every engine improvement arrives on its own.
  • Support around the clock, whenever you need it.

Try it now.

Sign up, download the app and log in. Phone, tablet and computer. One account.

View Pricing