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

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.

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

CellPandaCoreOpen-source engine 1Open-source engine 2ComparisonPandaCore 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.947.3× · vs Open-source engine 2+629% · PandaCore ahead1.9× · vs Open-source engine 1+93% · PandaCore ahead18 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 256 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.543.0× · vs Open-source engine 2+204% · PandaCore ahead1.4× · vs Open-source engine 1+41% · PandaCore ahead22 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 261 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.822.3× · vs Open-source engine 2+132% · PandaCore ahead1.1× · vs Open-source engine 1+14% · PandaCore ahead26 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 265 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.822.1× · vs Open-source engine 2+115% · PandaCore ahead1.2× · vs Open-source engine 1+21% · PandaCore ahead27 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 266 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.001.6× · vs Open-source engine 1+60% · PandaCore ahead1.3× · vs Open-source engine 2+32% · PandaCore ahead28 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 166 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.831.7× · vs Open-source engine 1+70% · PandaCore ahead1.2× · vs Open-source engine 2+18% · PandaCore ahead28 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 166 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.922.6× · vs Open-source engine 2+157% · PandaCore ahead1.5× · vs Open-source engine 1+52% · PandaCore ahead28 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 266 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.591.4× · vs Open-source engine 2+39% · PandaCore ahead1.3× · vs Open-source engine 1+26% · PandaCore ahead28 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 167 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.731.2× · vs Open-source engine 2+18% · PandaCore ahead1.1× · vs Open-source engine 1+8% · PandaCore ahead33 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 172 MiB · 378%55 MiB · 437%
Connect · 1 flowconn/s · socketbench4,2573,893 / 4,257 / 4,3513,6443,644 / 3,265 / 3,8252,9473,071 / 2,813 / 2,9471.4× · vs Open-source engine 2+44% · PandaCore ahead1.2× · vs Open-source engine 1+17% · PandaCore ahead16 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 151 MiB · 52%36 MiB · 61%
Connect · 4 flowsconn/s · socketbench7,7918,070 / 7,791 / 7,6026,9137,041 / 6,913 / 6,4526,6456,645 / 6,549 / 6,9201.2× · vs Open-source engine 2+17% · PandaCore ahead1.1× · vs Open-source engine 1+13% · PandaCore ahead17 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 155 MiB · 142%43 MiB · 165%
Connect · 8 flowsconn/s · socketbench6,0166,016 / 5,884 / 6,2715,7255,824 / 5,245 / 5,7255,5225,419 / 5,522 / 5,6551.1× · vs Open-source engine 2+9% · PandaCore ahead1.1× · vs Open-source engine 1+5% · PandaCore ahead18 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 167 MiB · 154%52 MiB · 178%
lower is betterRTT · 1 flowms · socketbench0.05580.0554 / 0.0558 / 0.06170.07480.0763 / 0.0747 / 0.07480.07820.0782 / 0.0782 / 0.07701.4× · vs Open-source engine 2−29% · PandaCore ahead1.3× · vs Open-source engine 1−25% · PandaCore ahead16 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 246 MiB · 45%34 MiB · 45%
RTT · 4 flowsms · socketbench0.08440.0836 / 0.0844 / 0.08440.11250.1122 / 0.1125 / 0.11320.11510.1151 / 0.1145 / 0.11771.4× · vs Open-source engine 2−27% · PandaCore ahead1.3× · vs Open-source engine 1−25% · PandaCore ahead17 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 148 MiB · 143%37 MiB · 152%
RTT · 8 flowsms · socketbench0.14610.1461 / 0.1498 / 0.14480.19230.1923 / 0.1893 / 0.19230.19300.1930 / 0.1908 / 0.19321.3× · vs Open-source engine 2−24% · PandaCore ahead1.3× · vs Open-source engine 1−24% · PandaCore ahead17 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 153 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

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

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

TCP connect · socketbench probe

Connection rate

conn/s · higher is better

1 flow 1.4× (vs Open-source engine 2) · 4 flows 1.2× (vs Open-source engine 2) · 8 flows 1.1× (vs Open-source engine 2)

TCP round trip · median · socketbench probe

Round-trip latency

ms · lower is better

1 flow −29% (vs Open-source engine 2) · 4 flows −27% (vs Open-source engine 2) · 8 flows −24% (vs Open-source engine 2)

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

Measured
Throughput 2026-09-04; connect 2026-09-05; round trip 2026-09-05
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 throughput cell; 3 rounds per test
Connection conditions
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.

  • 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