diff --git a/.github/workflows/pgo-profile.yaml b/.github/workflows/pgo-profile.yaml new file mode 100644 index 0000000000..9bc1c87f68 --- /dev/null +++ b/.github/workflows/pgo-profile.yaml @@ -0,0 +1,87 @@ +name: Refresh PGO profile +# Regenerates caddy/frankenphp/default.pgo by hammering frankenphp with wrk in +# both regular and worker mode (see build-pgo.sh) and opens a PR with the +# new merged profile. Direct `go build` and the Dockerfile auto-detect it; +# xcaddy users still need an explicit --pgo flag. +concurrency: + cancel-in-progress: false + group: ${{ github.workflow }}-${{ github.ref }} +on: + push: + branches: + - main + paths: + - "**/*.go" + - "frankenphp.c" + - "profiles/**" + - ".github/workflows/pgo-profile.yaml" + workflow_dispatch: +permissions: + contents: write + pull-requests: write +jobs: + refresh: + environment: pgo + name: Generate PGO profile + runs-on: ubuntu-latest + env: + GOTOOLCHAIN: local + GOFLAGS: "-tags=nobadger,nomysql,nopgx" + LIBRARY_PATH: ${{ github.workspace }}/watcher/target/lib + BENCH_SEC: "30" + steps: + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + # zizmor: ignore[artipacked] + # persist-credentials is intentionally left enabled because this + # workflow needs to push a branch via git push (mirrors translate.yaml). + - uses: actions/setup-go@v6 + with: + go-version: "1.26" + cache-dependency-path: | + go.sum + caddy/go.sum + - uses: shivammathur/setup-php@v2 + with: + php-version: "8.5" + ini-file: development + coverage: none + tools: none + env: + phpts: ts + debug: true + - name: Install e-dant/watcher + uses: ./.github/actions/watcher + - name: Set CGO flags + run: echo "CGO_CFLAGS=-I${PWD}/watcher/target/include $(php-config --includes)" >> "${GITHUB_ENV}" + - name: Install wrk + run: sudo apt-get update && sudo apt-get install -y wrk + - name: Generate profile + run: ./profiles/build-pgo.sh + - name: Show pprof summary + run: | + go install github.com/google/pprof@latest + "$(go env GOPATH)/bin/pprof" -top -cum -nodecount=25 caddy/frankenphp/default.pgo || true + - name: Open PR with refreshed profile + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ACTOR: ${{ github.actor }} + ACTOR_ID: ${{ github.actor_id }} + RUN_ID: ${{ github.run_id }} + SOURCE_SHA: ${{ github.sha }} + BENCH_SEC: ${{ env.BENCH_SEC }} + run: | + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + BRANCH="pgo/refresh-$RUN_ID" + git checkout -b "$BRANCH" + git add caddy/frankenphp/default.pgo + git diff --cached --quiet && exit 0 + git commit -m "perf(pgo): refresh PGO profile" --author="$ACTOR <$ACTOR_ID+$ACTOR@users.noreply.github.com>" + git push origin "$BRANCH" + gh pr create \ + --title "perf(pgo): refresh PGO profile" \ + --body "Automated refresh of \`caddy/frankenphp/default.pgo\` from \`$SOURCE_SHA\`, generated with \`BENCH_SEC=$BENCH_SEC\` across every script in \`profiles/app/\` in both regular and worker mode." \ + --label "perf" \ + --label "bot" diff --git a/.gitignore b/.gitignore index ecf194a98a..218ccd1e14 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ /caddy/frankenphp/Build /caddy/frankenphp/Caddyfile.test /caddy/frankenphp/frankenphp +/caddy/frankenphp/frankenphp-pgo /caddy/frankenphp/frankenphp.exe /caddy/frankenphp/public /dist diff --git a/caddy/frankenphp/default.pgo b/caddy/frankenphp/default.pgo new file mode 100644 index 0000000000..1122b9fa1d Binary files /dev/null and b/caddy/frankenphp/default.pgo differ diff --git a/frankenphp.c b/frankenphp.c index 0cc294e397..96e1c2c3cc 100644 --- a/frankenphp.c +++ b/frankenphp.c @@ -90,6 +90,10 @@ __thread HashTable *sandboxed_env = NULL; #ifndef PHP_WIN32 static bool is_forked_child = false; static void frankenphp_fork_child(void) { is_forked_child = true; } + +static void frankenphp_register_atfork(void) { + pthread_atfork(NULL, NULL, frankenphp_fork_child); +} #endif void frankenphp_update_local_thread_context(bool is_worker) { @@ -711,7 +715,9 @@ PHP_FUNCTION(frankenphp_log) { PHP_MINIT_FUNCTION(frankenphp) { register_frankenphp_symbols(module_number); #ifndef PHP_WIN32 - pthread_atfork(NULL, NULL, frankenphp_fork_child); + /* MINIT runs once per ZTS thread — guard the atfork registration */ + static pthread_once_t atfork_once = PTHREAD_ONCE_INIT; + pthread_once(&atfork_once, frankenphp_register_atfork); #endif zend_function *func; diff --git a/profiles/app/Caddyfile.regular b/profiles/app/Caddyfile.regular new file mode 100644 index 0000000000..795d027de1 --- /dev/null +++ b/profiles/app/Caddyfile.regular @@ -0,0 +1,15 @@ +# this is, intentionally, not an optimised configuration +# we're trying to cover as many codepaths as possible + +{ + skip_install_trust + admin localhost:22019 + grace_period 0s +} + +:22080 { + root * . + encode zstd br gzip + + php_server +} diff --git a/profiles/app/Caddyfile.worker b/profiles/app/Caddyfile.worker new file mode 100644 index 0000000000..79c0d6f5ed --- /dev/null +++ b/profiles/app/Caddyfile.worker @@ -0,0 +1,17 @@ +# this is, intentionally, not an optimised configuration +# we're trying to cover as many codepaths as possible + +{ + skip_install_trust + admin localhost:22019 + grace_period 0s +} + +:22080 { + root * . + encode zstd br gzip + + php_server { + worker index.php + } +} diff --git a/profiles/app/cookies_headers.php b/profiles/app/cookies_headers.php new file mode 100644 index 0000000000..7b06dbc99a --- /dev/null +++ b/profiles/app/cookies_headers.php @@ -0,0 +1,19 @@ + strlen($line) > 5); +sort($filtered); + +unlink($file); + +echo "OK\n"; diff --git a/profiles/app/frankenphp_log.php b/profiles/app/frankenphp_log.php new file mode 100644 index 0000000000..ae0e79e3eb --- /dev/null +++ b/profiles/app/frankenphp_log.php @@ -0,0 +1,11 @@ + 'fp_log']); + frankenphp_log('pgo profile request', FRANKENPHP_LOG_LEVEL_INFO); +} else { + error_log('frankenphp_log unavailable'); +} + +echo "ok\n"; diff --git a/profiles/app/helloworld.php b/profiles/app/helloworld.php new file mode 100644 index 0000000000..e38f915e28 --- /dev/null +++ b/profiles/app/helloworld.php @@ -0,0 +1,2 @@ +getBytesFromString("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/", 1023), "\n"; +} diff --git a/profiles/app/status_codes.php b/profiles/app/status_codes.php new file mode 100644 index 0000000000..1bb53277bd --- /dev/null +++ b/profiles/app/status_codes.php @@ -0,0 +1,12 @@ + 0) { + ob_end_flush(); +} + +for ($i = 0; $i < 32; $i++) { + echo "chunk-{$i} "; + echo str_repeat('x', 64); + echo "\n"; + flush(); +} diff --git a/profiles/benchmark.sh b/profiles/benchmark.sh new file mode 100755 index 0000000000..37b8178415 --- /dev/null +++ b/profiles/benchmark.sh @@ -0,0 +1,59 @@ +#!/bin/bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +APP="$ROOT/profiles/app" +MODE=${WORKER:+worker} +CADDYFILE="${1:-$APP/Caddyfile.${MODE:-regular}}" +FRANKENPHP_BIN="${FRANKENPHP_BIN:-$ROOT/caddy/frankenphp/frankenphp${PGO:+-pgo}}" +echo "$FRANKENPHP_BIN" +[ -x "$FRANKENPHP_BIN" ] || { + echo "FRANKENPHP_BIN not executable: $FRANKENPHP_BIN" >&2 + exit 1 +} + +SCRIPTS=() +for f in "$APP"/*.php; do + n=$(basename "$f" .php) + [ "$n" = "index" ] || SCRIPTS+=("$n") +done + +(cd "$APP" && exec "$FRANKENPHP_BIN" run --config "$CADDYFILE" >/dev/null 2>&1) & +SPID=$! +trap ' + set +e + if [ -n "${SPID:-}" ] && kill -0 "$SPID" 2>/dev/null; then + kill "$SPID" 2>/dev/null + for _ in $(seq 1 20); do + kill -0 "$SPID" 2>/dev/null || break + sleep 0.1 + done + kill -9 "$SPID" 2>/dev/null + fi + wait 2>/dev/null + exit 0 +' EXIT +DEADLINE=$((SECONDS + 3)) +until curl -fsS localhost:22019/config/ >/dev/null 2>&1; do + [ "$SECONDS" -ge "$DEADLINE" ] && { + echo "admin :22019 did not respond within 3s" >&2 + exit 1 + } + sleep 0.2 +done + +printf "%-20s %12s %10s %10s %10s\n" "script" "req/s" "avg" "p50" "p99" +sum=0 +for s in "${SCRIPTS[@]}"; do + out=$(wrk -t4 -c256 -d"${BENCH_SEC:-8}s" --latency "http://localhost:22080/index.php?s=$s" 2>/dev/null || true) + read -r rps avg p50 p99 < <(awk ' + /Requests\/sec:/ { rps = $2 } + /^ Latency / && !avg { avg = $2 } + / 50%/ { p50 = $2 } + / 99%/ { p99 = $2 } + END { print rps+0, avg, p50, p99 } + ' <<<"$out") + printf "%-20s %12s %10s %10s %10s\n" "$s" "$rps" "$avg" "$p50" "$p99" + sum=$(awk -v a="$sum" -v b="$rps" 'BEGIN { print a + b }') +done +printf "%-20s %12s\n" "total req/s" "$sum" diff --git a/profiles/build-pgo.sh b/profiles/build-pgo.sh new file mode 100755 index 0000000000..fd88b29e77 --- /dev/null +++ b/profiles/build-pgo.sh @@ -0,0 +1,35 @@ +#!/bin/bash +set -euo pipefail + +# Generate a PGO profile by hammering frankenphp with wrk in both regular and +# worker mode, then merging the two pprof samples into +# caddy/frankenphp/default.pgo. Go auto-detects ./default.pgo in the main +# package, so direct `go build` and the Dockerfile pick it up with no flag changes. +# xcaddy builds in a temp dir and needs +# `--pgo $(go mod download -json github.com/dunglas/frankenphp@latest | jq -r .Dir)/caddy/frankenphp/default.pgo`. + +HERE="$(cd "$(dirname "$0")" && pwd)" +ROOT="$(cd "$HERE/.." && pwd)" +export BENCH_SEC=${BENCH_SEC:-8} +N=$(find "$HERE/app" -maxdepth 1 -name "*.php" ! -name "index.php" | wc -l) +TOTAL=$((BENCH_SEC * N)) + +(cd "$ROOT/caddy/frankenphp" && go build -pgo=off -o frankenphp) + +collect() { + "$HERE/benchmark.sh" "$1" >/dev/null & + BPID=$! + until curl -fsS localhost:22019/config/ >/dev/null 2>&1; do sleep 0.2; done + curl -fsS --max-time $((TOTAL + 30)) "localhost:22019/debug/pprof/profile?seconds=$TOTAL" -o "$2" + wait $BPID +} + +collect "$HERE/app/Caddyfile.regular" "$HERE/regular.pgo" +collect "$HERE/app/Caddyfile.worker" "$HERE/worker.pgo" + +export CGO_CFLAGS="${CGO_CFLAGS:-} -fno-sanitize=undefined" +PPROF="$(go env GOPATH)/bin/pprof" +[ -x "$PPROF" ] || go install github.com/google/pprof@latest +"$PPROF" -proto "$HERE/regular.pgo" "$HERE/worker.pgo" >"$ROOT/caddy/frankenphp/default.pgo" + +(cd "$ROOT/caddy/frankenphp" && go build -o frankenphp-pgo) diff --git a/profiles/regular.pgo b/profiles/regular.pgo new file mode 100644 index 0000000000..445ee8ab90 Binary files /dev/null and b/profiles/regular.pgo differ diff --git a/profiles/worker.pgo b/profiles/worker.pgo new file mode 100644 index 0000000000..60cf7587af Binary files /dev/null and b/profiles/worker.pgo differ