Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
Build the distributed turbo binaries profile-guided
`make pgo` runs the three stages locally or in CI: an instrumented build,
a training run (bin/pgo-train.sh: PHPStan analysing its own analyser, rule
and type sources with that build loaded through an ini scan directory,
which survives the CLI's OPcache restart), and the final build using the
recorded profile — clang's .profraw files merged by llvm-profdata, GCC's
.gcda files read back from beside the objects. phar.yml's compile jobs
install the Composer dependencies first and build with `make pgo`; the
Windows job is unchanged.

Measured on the current head (macOS, clang, 8 ABBA pairs of the same
workload): -0.72 % user CPU, paired t -7.7, identical output, a 20 %
smaller binary.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MShHKdUB19w38vboJLPKXy
  • Loading branch information
ondrejmirtes committed Sep 22, 2026
commit d39c663f9617a82120389a2d1a274fb6c8a37a3f
74 changes: 41 additions & 33 deletions .github/workflows/phar.yml
Original file line number Diff line number Diff line change
Expand Up @@ -456,8 +456,34 @@ jobs:
if: matrix.target.family == 'zts'
run: "php -r 'exit((bool) PHP_ZTS ? 0 : 1);'"

- name: "Compile phpstan_turbo with strict warnings"
# The training run of `make pgo` (bin/pgo-train.sh) runs bin/phpstan on
# the checkout, so the dependencies must be in place before the build.
- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0
if: matrix.target.family == 'macos'
with:
ignore-cache: true

- name: "Install Composer dependencies (pinned composer)"
if: matrix.target.family != 'macos'
# checksum-pinned composer, run under the matrix PHP so platform
# requirements are validated against the interpreter the tests below
# run on. COMPOSER_HOME is isolated: on the runner (ZTS) legs,
# setup-php seeds ~/.composer/auth.json with the workflow's GitHub
# token, which this pinned composer rejects at startup ("github oauth
# token contains invalid characters") — everything here comes from
# packagist dists, no auth needed.
run: |
curl -fsSLo composer.phar "https://getcomposer.org/download/$COMPOSER_VERSION/composer.phar"
echo "$COMPOSER_PHAR_SHA256 composer.phar" | sha256sum -c -
COMPOSER_HOME="$(mktemp -d)" php composer.phar install --no-interaction --no-progress
rm composer.phar

- name: "Compile phpstan_turbo with strict warnings (profile-guided)"
working-directory: "turbo-ext"
# `make pgo`: an instrumented build, a training run of PHPStan over
# its own sources with that build loaded, then the final build using
# the recorded profile — the same three stages `make pgo` runs
# locally, so a profile-guided binary can be reproduced outside CI.
# Exemptions, each caused by third-party macro expansions, not our code:
# -Wno-assume: zend's parameter-parsing macros expand __builtin_assume
# with (potential) side effects
Expand All @@ -467,7 +493,7 @@ jobs:
# clang lexes the \N in "PhpParser\NodeVisitor" as a universal
# character name (GCC ignores the unknown -Wno- flag)
run: |
make WARN_FLAGS="-Wall -Wextra -Werror -Wno-assume -Wno-unused-parameter -Wno-unicode" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)"
make pgo WARN_FLAGS="-Wall -Wextra -Werror -Wno-assume -Wno-unused-parameter -Wno-unicode" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)"

# On arm64 AMFI refuses to map unsigned code, so the binary must carry
# at least an ad-hoc signature. The linker applies one itself, but
Expand All @@ -489,26 +515,6 @@ jobs:
echo "built extension reports: $REPORTED, enabler expects: $EXPECTED"
[ "$REPORTED" = "$EXPECTED" ]

- uses: "ramsey/composer-install@65e4f84970763564f46a70b8a54b90d033b3bdda" # v4.0.0
if: matrix.target.family == 'macos'
with:
ignore-cache: true

- name: "Install Composer dependencies (pinned composer)"
if: matrix.target.family != 'macos'
# checksum-pinned composer, run under the matrix PHP so platform
# requirements are validated against the interpreter the tests below
# run on. COMPOSER_HOME is isolated: on the runner (ZTS) legs,
# setup-php seeds ~/.composer/auth.json with the workflow's GitHub
# token, which this pinned composer rejects at startup ("github oauth
# token contains invalid characters") — everything here comes from
# packagist dists, no auth needed.
run: |
curl -fsSLo composer.phar "https://getcomposer.org/download/$COMPOSER_VERSION/composer.phar"
echo "$COMPOSER_PHAR_SHA256 composer.phar" | sha256sum -c -
COMPOSER_HOME="$(mktemp -d)" php composer.phar install --no-interaction --no-progress
rm composer.phar

- name: "Smoke test (differential: native vs PHP implementations)"
run: php -d extension="$PWD/turbo-ext/phpstan_turbo.so" turbo-ext/tests/smoke.php

Expand Down Expand Up @@ -609,10 +615,21 @@ jobs:
- name: "Trust the checkout despite the container/host uid mismatch"
run: docker exec alpine-build git config --global --add safe.directory /work

- name: "Compile phpstan_turbo with strict warnings"
# The training run of `make pgo` runs bin/phpstan on the checkout, so
# the dependencies must be in place before the build.
- name: "Install Composer dependencies (pinned composer)"
run: |
docker exec -i alpine-build sh -e <<'EOF'
curl -fsSLo composer.phar "https://getcomposer.org/download/$COMPOSER_VERSION/composer.phar"
echo "$COMPOSER_PHAR_SHA256 composer.phar" | sha256sum -c -
COMPOSER_HOME="$(mktemp -d)" php composer.phar install --no-interaction --no-progress
rm composer.phar
EOF

- name: "Compile phpstan_turbo with strict warnings (profile-guided)"
run: |
docker exec -i -w /work/turbo-ext alpine-build sh -e <<'EOF'
make WARN_FLAGS="-Wall -Wextra -Werror -Wno-assume -Wno-unused-parameter -Wno-unicode" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)"
make pgo WARN_FLAGS="-Wall -Wextra -Werror -Wno-assume -Wno-unused-parameter -Wno-unicode" -j"$(getconf _NPROCESSORS_ONLN 2>/dev/null || nproc)"
EOF

- name: "Verify the built extension reports the expected version"
Expand All @@ -624,15 +641,6 @@ jobs:
[ "$REPORTED" = "$EXPECTED" ]
EOF

- name: "Install Composer dependencies (pinned composer)"
run: |
docker exec -i alpine-build sh -e <<'EOF'
curl -fsSLo composer.phar "https://getcomposer.org/download/$COMPOSER_VERSION/composer.phar"
echo "$COMPOSER_PHAR_SHA256 composer.phar" | sha256sum -c -
COMPOSER_HOME="$(mktemp -d)" php composer.phar install --no-interaction --no-progress
rm composer.phar
EOF

- name: "Smoke test (differential: native vs PHP implementations)"
run: docker exec alpine-build php -d extension=/work/turbo-ext/phpstan_turbo.so turbo-ext/tests/smoke.php

Expand Down
5 changes: 5 additions & 0 deletions turbo-ext/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -39,3 +39,8 @@
/src/parser/*.lo
/src/.libs/
/src/parser/.libs/

# profile-guided build artifacts (make pgo)
/pgo/
/src/*.gcda
/src/parser/*.gcda
56 changes: 52 additions & 4 deletions turbo-ext/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,12 @@
# phpstan_turbo — a plain Zend extension, no framework dependencies.
#
# make builds phpstan_turbo.so
# make clean
# make pgo builds it profile-guided: an instrumented build, a
# training run (bin/pgo-train.sh: PHPStan analysing its own
# sources with the instrumented extension loaded), then the
# final build using the recorded profile — what the
# distributed CI binaries are built with (phar.yml)
# make clean also drops the recorded profile
#

PHP_CONFIG ?= php-config
Expand All @@ -18,6 +23,24 @@ CXXFLAGS := $(WARN_FLAGS) -O2 -std=c++17 -fPIC \
-DZEND_ENABLE_STATIC_TSRMLS_CACHE=1 \
`$(PHP_CONFIG) --includes`

# Profile-guided optimisation. PGO_FLAGS is empty for a plain build; `make
# pgo` drives the two instrumented/optimised builds below with the flags of
# the compiler in use (clang writes .profraw files merged by llvm-profdata,
# GCC writes .gcda files next to the objects and reads them back from
# there). The flags go on the link line too: the instrumented runtime is
# linked in by them.
PGO_DIR := pgo
PGO_FLAGS ?=
CXXFLAGS += $(PGO_FLAGS)
CXX_IS_CLANG := $(shell $(CXX) --version 2>/dev/null | grep -qi clang && echo 1)
ifeq ($(CXX_IS_CLANG),1)
PGO_GEN_FLAGS := -fprofile-instr-generate
PGO_USE_FLAGS := -fprofile-instr-use=$(PGO_DIR)/turbo.profdata -Wno-profile-instr-out-of-date -Wno-profile-instr-unprofiled
else
PGO_GEN_FLAGS := -fprofile-generate -fprofile-update=atomic
PGO_USE_FLAGS := -fprofile-use -fprofile-correction -Wno-missing-profile
endif

# The extension version is the short SHA of the last commit touching
# turbo-ext/src/ (the same computation the CI version job enforces against
# TurboExtensionEnabler::EXPECTED_EXTENSION_VERSION),
Expand Down Expand Up @@ -56,7 +79,7 @@ SOURCES := $(wildcard src/*.cpp) $(wildcard src/parser/*.cpp)
OBJECTS := $(SOURCES:.cpp=.o)

phpstan_turbo.so: $(OBJECTS)
$(CXX) `$(PHP_CONFIG) --ldflags` -shared $(LINK_FLAGS) -o $@ $(OBJECTS)
$(CXX) `$(PHP_CONFIG) --ldflags` -shared $(LINK_FLAGS) $(PGO_FLAGS) -o $@ $(OBJECTS)
@# a shared object links with undefined symbols allowed (the engine's are
@# resolved at load time), so a helper declared but never defined only
@# surfaces as a jump to NULL at run time — fail the build instead
Expand All @@ -80,7 +103,32 @@ $(filter src/parser/%.o,$(OBJECTS)): src/parser/ParserEngine.h src/zv.h

src/parser/ParserRunner.o: src/parser/ParserRunnerActionsSplit.h

clean:
# The profile-guided build, in three sub-makes so each stage gets its own
# flags: instrumented objects + .so, the training run, the optimised
# objects + .so. Objects never survive a stage — an object compiled with
# other flags than its neighbours is exactly the mismatch PGO cannot detect.
# bin/pgo-train.sh needs the monorepo checkout with its Composer
# dependencies installed (it runs bin/phpstan); the recorded profile is kept
# in $(PGO_DIR) (clang) or next to the objects as .gcda files (GCC).
pgo:
$(MAKE) pgo-clean
$(MAKE) PGO_FLAGS="$(PGO_GEN_FLAGS)" phpstan_turbo.so
PGO_DIR="$(PGO_DIR)" bin/pgo-train.sh
rm -f $(OBJECTS) phpstan_turbo.so
ifeq ($(CXX_IS_CLANG),1)
$(LLVM_PROFDATA) merge -output=$(PGO_DIR)/turbo.profdata $(PGO_DIR)/*.profraw
endif
$(MAKE) PGO_FLAGS="$(PGO_USE_FLAGS)" phpstan_turbo.so

# llvm-profdata must match the clang in use: Xcode ships it behind xcrun,
# Linux distributions suffix it with the LLVM major version
LLVM_PROFDATA ?= $(shell command -v llvm-profdata 2>/dev/null || (command -v xcrun > /dev/null 2>&1 && echo "xcrun llvm-profdata") || ls /usr/bin/llvm-profdata-* 2>/dev/null | sort -V | tail -1)

pgo-clean:
rm -f $(OBJECTS) phpstan_turbo.so version.stamp
rm -rf $(PGO_DIR)
find src -name '*.gcda' -delete

clean: pgo-clean

.PHONY: clean FORCE
.PHONY: clean pgo pgo-clean FORCE
55 changes: 55 additions & 0 deletions turbo-ext/bin/pgo-train.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
#!/bin/sh
# The training run of `make pgo`: PHPStan analyses its own analyser, rule
# and type sources with the instrumented extension loaded, so the recorded
# profile reflects the real workload (Type-kernel and scope operations
# dominate). Runs from the monorepo checkout with Composer dependencies
# installed; a single analyser process (--debug) so one process writes the
# whole profile (forked workers _exit() without writing theirs).
#
# The extension is loaded through an ini scan directory rather than `-d
# extension=`: bin/phpstan may re-exec itself to enable OPcache, and the
# environment survives that restart while -d flags do not.
set -e

EXT_DIR="$(cd "$(dirname "$0")/.." && pwd)"
ROOT="$(cd "$EXT_DIR/.." && pwd)"
SO="$EXT_DIR/phpstan_turbo.so"
PGO_DIR="${PGO_DIR:-pgo}"
case "$PGO_DIR" in /*) ;; *) PGO_DIR="$EXT_DIR/$PGO_DIR" ;; esac

[ -f "$SO" ] || { echo "pgo-train: $SO is missing — build the instrumented extension first" >&2; exit 1; }
[ -f "$ROOT/vendor/autoload.php" ] || { echo "pgo-train: $ROOT/vendor is missing — run composer install first" >&2; exit 1; }

mkdir -p "$PGO_DIR"
INI_DIR="$(mktemp -d)"
trap 'rm -rf "$INI_DIR"' EXIT
printf 'extension=%s\n' "$SO" > "$INI_DIR/phpstan-turbo-pgo.ini"
# a leading empty entry keeps the default scan directory (system ini files)
export PHP_INI_SCAN_DIR="${PHP_INI_SCAN_DIR:-}:$INI_DIR"
# clang's instrumented runtime; ignored by a GCC build (which writes .gcda
# files next to the objects at exit)
export LLVM_PROFILE_FILE="$PGO_DIR/turbo-%p.profraw"

cd "$ROOT"
REPORTED="$(php -r 'echo phpversion("phpstan_turbo");')"
EXPECTED="$(sed -n "s/.*EXPECTED_EXTENSION_VERSION = '\([^']*\)'.*/\1/p" src/Turbo/TurboExtensionEnabler.php)"
if [ "$REPORTED" != "$EXPECTED" ]; then
echo "pgo-train: the instrumented extension reports '$REPORTED' but the enabler expects '$EXPECTED' — the training run would leave it inactive" >&2
exit 1
fi

php bin/phpstan clear-result-cache --no-interaction -q
php -d memory_limit=4G bin/phpstan analyse src/Analyser src/Rules src/Type --debug --no-progress --no-interaction -q --error-format=raw > /dev/null || true

if [ "$(php -r 'echo (int) (phpversion("phpstan_turbo") !== false);')" != "1" ]; then
echo "pgo-train: the extension did not load in the training process" >&2
exit 1
fi
if ls "$PGO_DIR"/*.profraw > /dev/null 2>&1; then
echo "pgo-train: recorded $(ls "$PGO_DIR"/*.profraw | wc -l | tr -d ' ') clang profile(s) in $PGO_DIR"
elif find "$EXT_DIR/src" -name '*.gcda' | grep -q .; then
echo "pgo-train: recorded $(find "$EXT_DIR/src" -name '*.gcda' | wc -l | tr -d ' ') GCC profile files next to the objects"
else
echo "pgo-train: no profile was written — was the extension built with the instrumentation flags?" >&2
exit 1
fi