From 42698dbd7d439ed0879e03717dd744570ade9f77 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Sun, 25 Oct 2026 18:02:41 +0100 Subject: [PATCH 01/14] Compare PR benchmarks against base on the same runner MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The PR job compared one run against a stored baseline from another runner, so runner variance raised alerts on every PR. Commenting on those alerts failed on fork PRs with a read-only token, which turned the check red. Run base and head in turns on the same runner, compare the median ratio of mode times over three passes, and fail above 1.25x. Report through the step summary, which needs no write token. Skip PRs that touch no benchmarked code. 🤖 Generated with Claude Code --- .github/workflows/benchmarks.yml | 56 ++++++++++------- benchmarks/Utils/BenchmarkResults.php | 62 ++++++++++++++++++ benchmarks/compare-results.php | 90 +++++++++++++++++++++++++++ benchmarks/convert-results.php | 58 ++++------------- 4 files changed, 197 insertions(+), 69 deletions(-) create mode 100644 benchmarks/Utils/BenchmarkResults.php create mode 100644 benchmarks/compare-results.php diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 7f6ca32de..1522699b9 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -2,6 +2,12 @@ name: Benchmarks on: pull_request: + paths: + - .github/workflows/benchmarks.yml + - benchmarks/** + - composer.json + - phpbench.json + - src/** push: branches: - master @@ -14,10 +20,16 @@ jobs: runs-on: ubuntu-latest permissions: contents: read - pull-requests: write steps: - uses: actions/checkout@v7 + with: + path: head + + - uses: actions/checkout@v7 + with: + ref: ${{ github.event.pull_request.base.sha }} + path: base - uses: shivammathur/setup-php@v2 with: @@ -25,30 +37,30 @@ jobs: php-version: "8.3" - uses: ramsey/composer-install@v4 + with: + working-directory: base - - name: Run PHPBench - run: | - php -d zend.assertions=-1 vendor/bin/phpbench run \ - --progress=none \ - --dump-file=benchmark.xml + - uses: ramsey/composer-install@v4 + with: + working-directory: head - - name: Convert results - run: php benchmarks/convert-results.php benchmark.xml > benchmark-results.json + - name: Run PHPBench on base and head in turns + run: | + for pass in 1 2 3; do + for side in base head; do + (cd "$side" && php -d zend.assertions=-1 vendor/bin/phpbench run \ + --progress=none \ + --dump-file="../$side-$pass.xml") + done + done - - uses: benchmark-action/github-action-benchmark@v1 - if: github.repository == 'webonyx/graphql-php' - with: - tool: customSmallerIsBetter - output-file-path: benchmark-results.json - github-token: ${{ secrets.GITHUB_TOKEN }} - auto-push: false - gh-pages-branch: benchmark-results - benchmark-data-dir-path: dev/bench - alert-threshold: '150%' - # TODO: switch to true once baseline variance is understood - fail-on-alert: false - comment-on-alert: true - comment-always: false + - name: Compare results + run: | + php head/benchmarks/compare-results.php \ + --base=base-1.xml --head=head-1.xml \ + --base=base-2.xml --head=head-2.xml \ + --base=base-3.xml --head=head-3.xml \ + | tee --append "$GITHUB_STEP_SUMMARY" benchmark-history: name: Track benchmark history diff --git a/benchmarks/Utils/BenchmarkResults.php b/benchmarks/Utils/BenchmarkResults.php new file mode 100644 index 000000000..4ac3214d2 --- /dev/null +++ b/benchmarks/Utils/BenchmarkResults.php @@ -0,0 +1,62 @@ +> + */ + public static function read(string $xmlFile): array + { + libxml_use_internal_errors(true); + $xml = simplexml_load_file($xmlFile, \SimpleXMLElement::class, LIBXML_NONET); + + if ($xml === false) { + throw new \RuntimeException("Failed to parse XML file: {$xmlFile}."); + } + + $results = []; + + foreach ($xml->suite as $suite) { + foreach ($suite->benchmark as $benchmark) { + $class = (string) $benchmark['class']; + $shortClass = str_replace(['\\GraphQL\\Benchmarks\\', 'GraphQL\\Benchmarks\\'], '', $class); + + foreach ($benchmark->subject as $subject) { + $subjectName = (string) $subject['name']; + $variants = $subject->variant; + + foreach ($variants as $variant) { + if (isset($variant->errors)) { + continue; + } + + if (! isset($variant->stats)) { + continue; + } + + $parameterSet = $variant->{'parameter-set'}; + $paramSetName = isset($parameterSet['name']) ? (string) $parameterSet['name'] : '0'; + $name = count($variants) > 1 + ? "{$shortClass}::{$subjectName}#{$paramSetName}" + : "{$shortClass}::{$subjectName}"; + + $stats = []; + foreach ($variant->stats->attributes() as $stat => $value) { + $stats[$stat] = (float) $value; + } + + $results[$name] = $stats; + } + } + } + } + + return $results; + } +} diff --git a/benchmarks/compare-results.php b/benchmarks/compare-results.php new file mode 100644 index 000000000..f95b8d534 --- /dev/null +++ b/benchmarks/compare-results.php @@ -0,0 +1,90 @@ + --head= [--base=... --head=...] [--threshold=1.25]\n"); + exit(1); +} + +/** + * @param array $numbers + */ +function median(array $numbers): float +{ + sort($numbers); + $middle = intdiv(count($numbers), 2); + + return count($numbers) % 2 === 1 + ? $numbers[$middle] + : ($numbers[$middle - 1] + $numbers[$middle]) / 2; +} + +$basePasses = array_map([BenchmarkResults::class, 'read'], $baseFiles); +$headPasses = array_map([BenchmarkResults::class, 'read'], $headFiles); + +$names = array_keys(array_intersect_key(...$basePasses, ...$headPasses)); + +$comparisons = []; +foreach ($names as $name) { + $baseModes = []; + $headModes = []; + $ratios = []; + foreach ($basePasses as $pass => $baseResults) { + $baseMode = $baseResults[$name]['mode']; + $headMode = $headPasses[$pass][$name]['mode']; + $baseModes[] = $baseMode; + $headModes[] = $headMode; + $ratios[] = $baseMode > 0 ? $headMode / $baseMode : 1.0; + } + + $comparisons[$name] = [ + 'base' => median($baseModes), + 'head' => median($headModes), + 'ratio' => median($ratios), + 'ratios' => $ratios, + ]; +} + +uasort($comparisons, static fn (array $a, array $b): int => $b['ratio'] <=> $a['ratio']); + +$regressions = array_filter($comparisons, static fn (array $comparison): bool => $comparison['ratio'] > $threshold); + +$passCount = count($basePasses); +echo $regressions === [] + ? "### No benchmark regressed by more than {$threshold}x\n\n" + : '### ' . count($regressions) . " benchmark(s) regressed by more than {$threshold}x\n\n"; +echo "Mode time per revolution, median of {$passCount} passes that ran base and head in turns on the same runner.\n\n"; +echo "| Benchmark | Base (µs) | Head (µs) | Ratio | Ratios per pass |\n"; +echo "| --- | ---: | ---: | ---: | --- |\n"; +foreach ($comparisons as $name => $comparison) { + $marker = isset($regressions[$name]) ? ' :warning:' : ''; + $ratiosPerPass = implode(', ', array_map(static fn (float $ratio): string => sprintf('%.2f', $ratio), $comparison['ratios'])); + printf( + "| %s | %.3f | %.3f | %.2f%s | %s |\n", + $name, + $comparison['base'], + $comparison['head'], + $comparison['ratio'], + $marker, + $ratiosPerPass + ); +} + +exit($regressions === [] ? 0 : 1); diff --git a/benchmarks/convert-results.php b/benchmarks/convert-results.php index 3718175c3..ca957b983 100644 --- a/benchmarks/convert-results.php +++ b/benchmarks/convert-results.php @@ -5,10 +5,12 @@ * expected by benchmark-action/github-action-benchmark. * * Usage: php benchmarks/convert-results.php benchmark.xml > benchmark-results.json - * - * stats/@mean is the mean time per revolution in microseconds, - * computed by phpbench over all non-warm-up iterations. */ + +use GraphQL\Benchmarks\Utils\BenchmarkResults; + +require_once __DIR__ . '/../vendor/autoload.php'; + $xmlFile = $argv[1] ?? null; if ($xmlFile === null) { @@ -16,51 +18,13 @@ exit(1); } -libxml_use_internal_errors(true); -$xml = simplexml_load_file($xmlFile, SimpleXMLElement::class, LIBXML_NONET); - -if ($xml === false) { - fwrite(STDERR, "Failed to parse XML file: {$xmlFile}\n"); - exit(1); -} - $results = []; - -foreach ($xml->suite as $suite) { - foreach ($suite->benchmark as $benchmark) { - $class = (string) $benchmark['class']; - $shortClass = str_replace(['\\GraphQL\\Benchmarks\\', 'GraphQL\\Benchmarks\\'], '', $class); - - foreach ($benchmark->subject as $subject) { - $subjectName = (string) $subject['name']; - $variants = $subject->variant; - - foreach ($variants as $variant) { - if (isset($variant->errors)) { - continue; - } - - if (! isset($variant->stats)) { - continue; - } - - $meanUs = (float) $variant->stats['mean']; - $valueMs = round($meanUs / 1000, 3); - - $parameterSet = $variant->{'parameter-set'}; - $paramSetName = isset($parameterSet['name']) ? (string) $parameterSet['name'] : '0'; - $name = count($variants) > 1 - ? "{$shortClass}::{$subjectName}#{$paramSetName}" - : "{$shortClass}::{$subjectName}"; - - $results[] = [ - 'name' => $name, - 'value' => $valueMs, - 'unit' => 'ms', - ]; - } - } - } +foreach (BenchmarkResults::read($xmlFile) as $name => $stats) { + $results[] = [ + 'name' => $name, + 'value' => round($stats['mean'] / 1000, 3), + 'unit' => 'ms', + ]; } echo json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; From 89e04e9fe3aa71a884e6e6fef1ca5a2d9f96e4f5 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 08:28:13 +0100 Subject: [PATCH 02/14] Fail benchmark check on comparator errors and bound its runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Enable pipefail so the exit code of compare-results.php survives tee. Show PHPBench progress and add a job timeout, so a hang shows in the log. 🤖 Generated with Claude Code --- .github/workflows/benchmarks.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 1522699b9..c2fe48f5e 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -18,6 +18,7 @@ jobs: name: Run benchmarks if: github.event_name == 'pull_request' runs-on: ubuntu-latest + timeout-minutes: 30 permissions: contents: read @@ -49,12 +50,12 @@ jobs: for pass in 1 2 3; do for side in base head; do (cd "$side" && php -d zend.assertions=-1 vendor/bin/phpbench run \ - --progress=none \ --dump-file="../$side-$pass.xml") done done - name: Compare results + shell: bash run: | php head/benchmarks/compare-results.php \ --base=base-1.xml --head=head-1.xml \ From 9ae258566592378402cc204e565c304d96a4441a Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 09:06:38 +0100 Subject: [PATCH 03/14] Stop PHPBench from retrying iterations without limit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit runner.retry_threshold reruns iterations until their deviation is below 2%. PHPBench has no retry limit by default, so on noisy runners this can hang. Base runs with the config of head, so both sides measure the same way. 🤖 Generated with Claude Code --- .github/workflows/benchmarks.yml | 1 + .github/workflows/test.yml | 1 + phpbench.json | 1 - 3 files changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index c2fe48f5e..9c4ff25b4 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -47,6 +47,7 @@ jobs: - name: Run PHPBench on base and head in turns run: | + cp head/phpbench.json base/phpbench.json for pass in 1 2 3; do for side in base head; do (cd "$side" && php -d zend.assertions=-1 vendor/bin/phpbench run \ diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index ca39583e5..bdfb8a403 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -86,6 +86,7 @@ jobs: benchmarks: name: Benchmarks runs-on: ubuntu-latest + timeout-minutes: 20 steps: - name: Checkout diff --git a/phpbench.json b/phpbench.json index de5b5c6f5..28c39ada6 100644 --- a/phpbench.json +++ b/phpbench.json @@ -2,7 +2,6 @@ "runner.bootstrap": "vendor/autoload.php", "runner.path": "benchmarks", "runner.file_pattern": "*Bench.php", - "runner.retry_threshold": 2, "runner.time_unit": "milliseconds", "runner.progress": "plain", "report.generators": { From fbdbe119a06915dbf520967d3a341495d3fce626 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 09:47:22 +0100 Subject: [PATCH 04/14] Compare PR benchmarks by instruction count instead of wall time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wall times of identical code drifted by up to 43% between passes on hosted runners. Valgrind instruction counts of identical code match within 0.01% for most subjects, and reveal changes of below 1% that wall times hide. 🤖 Generated with Claude Code --- .github/workflows/benchmarks.yml | 25 +++---- benchmarks/Utils/BenchmarkResults.php | 62 ---------------- benchmarks/compare-results.php | 102 ++++++++------------------ benchmarks/convert-results.php | 58 ++++++++++++--- benchmarks/count-instructions.php | 82 +++++++++++++++++++++ benchmarks/run-subject.php | 73 ++++++++++++++++++ 6 files changed, 241 insertions(+), 161 deletions(-) delete mode 100644 benchmarks/Utils/BenchmarkResults.php create mode 100644 benchmarks/count-instructions.php create mode 100644 benchmarks/run-subject.php diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 9c4ff25b4..010c194c8 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -6,7 +6,6 @@ on: - .github/workflows/benchmarks.yml - benchmarks/** - composer.json - - phpbench.json - src/** push: branches: @@ -15,7 +14,7 @@ on: jobs: benchmark-pr: - name: Run benchmarks + name: Count benchmark instructions if: github.event_name == 'pull_request' runs-on: ubuntu-latest timeout-minutes: 30 @@ -45,24 +44,18 @@ jobs: with: working-directory: head - - name: Run PHPBench on base and head in turns + - run: sudo apt-get install --yes valgrind + + - name: Count instructions on base and head run: | - cp head/phpbench.json base/phpbench.json - for pass in 1 2 3; do - for side in base head; do - (cd "$side" && php -d zend.assertions=-1 vendor/bin/phpbench run \ - --dump-file="../$side-$pass.xml") - done - done + php head/benchmarks/count-instructions.php base > base.json & + base_pid=$! + php head/benchmarks/count-instructions.php head > head.json + wait "$base_pid" - name: Compare results shell: bash - run: | - php head/benchmarks/compare-results.php \ - --base=base-1.xml --head=head-1.xml \ - --base=base-2.xml --head=head-2.xml \ - --base=base-3.xml --head=head-3.xml \ - | tee --append "$GITHUB_STEP_SUMMARY" + run: php head/benchmarks/compare-results.php base.json head.json | tee --append "$GITHUB_STEP_SUMMARY" benchmark-history: name: Track benchmark history diff --git a/benchmarks/Utils/BenchmarkResults.php b/benchmarks/Utils/BenchmarkResults.php deleted file mode 100644 index 4ac3214d2..000000000 --- a/benchmarks/Utils/BenchmarkResults.php +++ /dev/null @@ -1,62 +0,0 @@ -> - */ - public static function read(string $xmlFile): array - { - libxml_use_internal_errors(true); - $xml = simplexml_load_file($xmlFile, \SimpleXMLElement::class, LIBXML_NONET); - - if ($xml === false) { - throw new \RuntimeException("Failed to parse XML file: {$xmlFile}."); - } - - $results = []; - - foreach ($xml->suite as $suite) { - foreach ($suite->benchmark as $benchmark) { - $class = (string) $benchmark['class']; - $shortClass = str_replace(['\\GraphQL\\Benchmarks\\', 'GraphQL\\Benchmarks\\'], '', $class); - - foreach ($benchmark->subject as $subject) { - $subjectName = (string) $subject['name']; - $variants = $subject->variant; - - foreach ($variants as $variant) { - if (isset($variant->errors)) { - continue; - } - - if (! isset($variant->stats)) { - continue; - } - - $parameterSet = $variant->{'parameter-set'}; - $paramSetName = isset($parameterSet['name']) ? (string) $parameterSet['name'] : '0'; - $name = count($variants) > 1 - ? "{$shortClass}::{$subjectName}#{$paramSetName}" - : "{$shortClass}::{$subjectName}"; - - $stats = []; - foreach ($variant->stats->attributes() as $stat => $value) { - $stats[$stat] = (float) $value; - } - - $results[$name] = $stats; - } - } - } - } - - return $results; - } -} diff --git a/benchmarks/compare-results.php b/benchmarks/compare-results.php index f95b8d534..1ce3db725 100644 --- a/benchmarks/compare-results.php +++ b/benchmarks/compare-results.php @@ -1,90 +1,48 @@ [] * - * Prints a Markdown table and exits with 1 when the median head/base ratio - * of the passes exceeds the threshold for any benchmark. + * Prints a Markdown table and exits with 1 when head needs more than threshold times + * the instructions of base for any benchmark. */ - -use GraphQL\Benchmarks\Utils\BenchmarkResults; - -require_once __DIR__ . '/../vendor/autoload.php'; - -$options = getopt('', ['base:', 'head:', 'threshold:']); -$baseFiles = (array) ($options['base'] ?? []); -$headFiles = (array) ($options['head'] ?? []); -$threshold = (float) ($options['threshold'] ?? 1.25); - -if ($baseFiles === [] || count($baseFiles) !== count($headFiles)) { - fwrite(STDERR, "Usage: php benchmarks/compare-results.php --base= --head= [--base=... --head=...] [--threshold=1.25]\n"); +$baseFile = $argv[1] ?? null; +$headFile = $argv[2] ?? null; +$threshold = (float) ($argv[3] ?? 1.1); +if ($baseFile === null || $headFile === null) { + fwrite(STDERR, "Usage: php benchmarks/compare-results.php []\n"); exit(1); } -/** - * @param array $numbers - */ -function median(array $numbers): float -{ - sort($numbers); - $middle = intdiv(count($numbers), 2); +/** @var array $base */ +$base = json_decode((string) file_get_contents($baseFile), true, 2, JSON_THROW_ON_ERROR); +/** @var array $head */ +$head = json_decode((string) file_get_contents($headFile), true, 2, JSON_THROW_ON_ERROR); - return count($numbers) % 2 === 1 - ? $numbers[$middle] - : ($numbers[$middle - 1] + $numbers[$middle]) / 2; +$ratios = []; +foreach (array_intersect_key($head, $base) as $name => $headInstructions) { + $ratios[$name] = $base[$name] > 0 + ? $headInstructions / $base[$name] + : 1.0; } +arsort($ratios); -$basePasses = array_map([BenchmarkResults::class, 'read'], $baseFiles); -$headPasses = array_map([BenchmarkResults::class, 'read'], $headFiles); - -$names = array_keys(array_intersect_key(...$basePasses, ...$headPasses)); +$regressions = array_filter($ratios, static fn (float $ratio): bool => $ratio > $threshold); -$comparisons = []; -foreach ($names as $name) { - $baseModes = []; - $headModes = []; - $ratios = []; - foreach ($basePasses as $pass => $baseResults) { - $baseMode = $baseResults[$name]['mode']; - $headMode = $headPasses[$pass][$name]['mode']; - $baseModes[] = $baseMode; - $headModes[] = $headMode; - $ratios[] = $baseMode > 0 ? $headMode / $baseMode : 1.0; - } - - $comparisons[$name] = [ - 'base' => median($baseModes), - 'head' => median($headModes), - 'ratio' => median($ratios), - 'ratios' => $ratios, - ]; -} - -uasort($comparisons, static fn (array $a, array $b): int => $b['ratio'] <=> $a['ratio']); - -$regressions = array_filter($comparisons, static fn (array $comparison): bool => $comparison['ratio'] > $threshold); - -$passCount = count($basePasses); echo $regressions === [] - ? "### No benchmark regressed by more than {$threshold}x\n\n" - : '### ' . count($regressions) . " benchmark(s) regressed by more than {$threshold}x\n\n"; -echo "Mode time per revolution, median of {$passCount} passes that ran base and head in turns on the same runner.\n\n"; -echo "| Benchmark | Base (µs) | Head (µs) | Ratio | Ratios per pass |\n"; -echo "| --- | ---: | ---: | ---: | --- |\n"; -foreach ($comparisons as $name => $comparison) { + ? "### No benchmark needs more than {$threshold}x the instructions of base\n\n" + : '### ' . count($regressions) . " benchmark(s) need more than {$threshold}x the instructions of base\n\n"; +echo "Instructions per revolution, counted with Valgrind.\n\n"; +echo "| Benchmark | Base | Head | Ratio |\n"; +echo "| --- | ---: | ---: | ---: |\n"; +foreach ($ratios as $name => $ratio) { $marker = isset($regressions[$name]) ? ' :warning:' : ''; - $ratiosPerPass = implode(', ', array_map(static fn (float $ratio): string => sprintf('%.2f', $ratio), $comparison['ratios'])); - printf( - "| %s | %.3f | %.3f | %.2f%s | %s |\n", - $name, - $comparison['base'], - $comparison['head'], - $comparison['ratio'], - $marker, - $ratiosPerPass - ); + printf("| %s | %s | %s | %.4f%s |\n", $name, number_format($base[$name]), number_format($head[$name]), $ratio, $marker); +} +foreach (array_diff_key($head, $base) as $name => $headInstructions) { + printf("| %s | | %s | new |\n", $name, number_format($headInstructions)); } exit($regressions === [] ? 0 : 1); diff --git a/benchmarks/convert-results.php b/benchmarks/convert-results.php index ca957b983..3718175c3 100644 --- a/benchmarks/convert-results.php +++ b/benchmarks/convert-results.php @@ -5,12 +5,10 @@ * expected by benchmark-action/github-action-benchmark. * * Usage: php benchmarks/convert-results.php benchmark.xml > benchmark-results.json + * + * stats/@mean is the mean time per revolution in microseconds, + * computed by phpbench over all non-warm-up iterations. */ - -use GraphQL\Benchmarks\Utils\BenchmarkResults; - -require_once __DIR__ . '/../vendor/autoload.php'; - $xmlFile = $argv[1] ?? null; if ($xmlFile === null) { @@ -18,13 +16,51 @@ exit(1); } +libxml_use_internal_errors(true); +$xml = simplexml_load_file($xmlFile, SimpleXMLElement::class, LIBXML_NONET); + +if ($xml === false) { + fwrite(STDERR, "Failed to parse XML file: {$xmlFile}\n"); + exit(1); +} + $results = []; -foreach (BenchmarkResults::read($xmlFile) as $name => $stats) { - $results[] = [ - 'name' => $name, - 'value' => round($stats['mean'] / 1000, 3), - 'unit' => 'ms', - ]; + +foreach ($xml->suite as $suite) { + foreach ($suite->benchmark as $benchmark) { + $class = (string) $benchmark['class']; + $shortClass = str_replace(['\\GraphQL\\Benchmarks\\', 'GraphQL\\Benchmarks\\'], '', $class); + + foreach ($benchmark->subject as $subject) { + $subjectName = (string) $subject['name']; + $variants = $subject->variant; + + foreach ($variants as $variant) { + if (isset($variant->errors)) { + continue; + } + + if (! isset($variant->stats)) { + continue; + } + + $meanUs = (float) $variant->stats['mean']; + $valueMs = round($meanUs / 1000, 3); + + $parameterSet = $variant->{'parameter-set'}; + $paramSetName = isset($parameterSet['name']) ? (string) $parameterSet['name'] : '0'; + $name = count($variants) > 1 + ? "{$shortClass}::{$subjectName}#{$paramSetName}" + : "{$shortClass}::{$subjectName}"; + + $results[] = [ + 'name' => $name, + 'value' => $valueMs, + 'unit' => 'ms', + ]; + } + } + } } echo json_encode($results, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"; diff --git a/benchmarks/count-instructions.php b/benchmarks/count-instructions.php new file mode 100644 index 000000000..32e7cd541 --- /dev/null +++ b/benchmarks/count-instructions.php @@ -0,0 +1,82 @@ + > instructions.json + * + * Runs each subject once with 1 rev, once with 1 + a tenth of its @Revs, and divides the difference, + * so bootstrap, setup and first-run costs cancel out. + */ +$checkout = $argv[1] ?? null; +if ($checkout === null) { + fwrite(STDERR, "Usage: php benchmarks/count-instructions.php \n"); + exit(1); +} + +$runSubject = __DIR__ . '/run-subject.php'; + +/** + * @param list $command + * + * @return array{string, string} + */ +function run(array $command): array +{ + $process = proc_open( + implode(' ', array_map('escapeshellarg', $command)), + [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], + $pipes + ); + if (! is_resource($process)) { + throw new RuntimeException('Failed to start: ' . implode(' ', $command) . '.'); + } + + $stdout = (string) stream_get_contents($pipes[1]); + $stderr = (string) stream_get_contents($pipes[2]); + $exitCode = proc_close($process); + if ($exitCode !== 0) { + throw new RuntimeException("Exit code {$exitCode} from " . implode(' ', $command) . ":\n{$stdout}{$stderr}"); + } + + return [$stdout, $stderr]; +} + +/** + * @return array{int, int} Revs run and instructions counted + */ +function countInstructions(string $runSubject, string $checkout, string $subject, string $revs): array +{ + [$stdout, $stderr] = run([ + 'valgrind', + '--tool=cachegrind', + '--cache-sim=no', + '--cachegrind-out-file=/dev/null', + PHP_BINARY, + '-d', + 'zend.assertions=-1', + '-d', + 'opcache.enable_cli=0', + $runSubject, + $checkout, + $subject, + $revs, + ]); + if (preg_match('/I\s+refs:\s+([\d,]+)/', $stderr, $match) !== 1) { + throw new RuntimeException("No instruction count for {$subject}:\n{$stderr}"); + } + + return [(int) $stdout, (int) str_replace(',', '', $match[1])]; +} + +[$subjectList] = run([PHP_BINARY, $runSubject, $checkout, 'list']); + +$instructionsPerRev = []; +foreach (explode("\n", trim($subjectList)) as $subject) { + [$warmupRevs, $warmupInstructions] = countInstructions($runSubject, $checkout, $subject, '1'); + [$measuredRevs, $measuredInstructions] = countInstructions($runSubject, $checkout, $subject, 'annotated'); + $instructionsPerRev[$subject] = intdiv($measuredInstructions - $warmupInstructions, $measuredRevs - $warmupRevs); + fwrite(STDERR, "{$subject}: {$instructionsPerRev[$subject]}\n"); +} + +echo json_encode($instructionsPerRev, JSON_PRETTY_PRINT) . "\n"; diff --git a/benchmarks/run-subject.php b/benchmarks/run-subject.php new file mode 100644 index 000000000..f10e76dba --- /dev/null +++ b/benchmarks/run-subject.php @@ -0,0 +1,73 @@ + list + * php benchmarks/run-subject.php + * + * Revs "annotated" runs 1 more than a tenth of the @Revs of the subject. + * Prints the number of revs it ran. + */ +$checkout = $argv[1] ?? null; +$subject = $argv[2] ?? null; +if ($checkout === null || $subject === null) { + fwrite(STDERR, "Usage: php benchmarks/run-subject.php list| []\n"); + exit(1); +} + +require_once "{$checkout}/vendor/autoload.php"; + +function annotation(string $name, ReflectionMethod $method): ?string +{ + foreach ([$method->getDocComment(), $method->getDeclaringClass()->getDocComment()] as $docComment) { + if (is_string($docComment) && preg_match("/@{$name}\\((.+)\\)/", $docComment, $match) === 1) { + return $match[1]; + } + } + + return null; +} + +if ($subject === 'list') { + $files = glob("{$checkout}/benchmarks/*Bench.php"); + if ($files === false) { + throw new RuntimeException("Failed to list benchmarks in {$checkout}."); + } + + foreach ($files as $file) { + $className = 'GraphQL\\Benchmarks\\' . basename($file, '.php'); + if (! class_exists($className)) { + throw new RuntimeException("Failed to load benchmark class: {$className}."); + } + + $class = new ReflectionClass($className); + foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if (strpos($method->getName(), 'bench') === 0) { + echo "{$class->getShortName()}::{$method->getName()}\n"; + } + } + } + + exit(0); +} + +[$shortClass, $methodName] = explode('::', $subject); +$method = new ReflectionMethod("GraphQL\\Benchmarks\\{$shortClass}", $methodName); +$revs = ($argv[3] ?? 'annotated') === 'annotated' + ? 1 + (int) ceil((int) (annotation('Revs', $method) ?? 1) / 10) + : (int) $argv[3]; + +$bench = $method->getDeclaringClass()->newInstance(); +preg_match_all('/"(\w+)"/', annotation('BeforeMethods', $method) ?? '', $beforeMethods); +foreach ($beforeMethods[1] as $beforeMethod) { + (new ReflectionMethod($bench, $beforeMethod))->invoke($bench); +} + +$benchmark = $method->getClosure($bench); +for ($rev = 0; $rev < $revs; ++$rev) { + $benchmark(); +} + +echo $revs; From 59953935291ac5cf1061649d6ee92fe656aa6810 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 10:09:57 +0100 Subject: [PATCH 05/14] Keep the measurement rule of benchmark instruction counts in one script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit count-instructions.php now lists subjects and picks both rev counts itself, so run-subject.php only runs one subject and prints nothing. A @Revs that is not a single integer fails with a clear message. CONTRIBUTING.md explains how to reproduce the check locally. 🤖 Generated with Claude Code --- CONTRIBUTING.md | 10 +++++ benchmarks/count-instructions.php | 57 ++++++++++++++++++++------ benchmarks/run-subject.php | 67 ++++++++----------------------- 3 files changed, 70 insertions(+), 64 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index c941652c4..62a1d0e50 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,6 +151,16 @@ Check performance: composer bench ``` +On pull requests, CI counts the instructions per revolution of each benchmark with [Valgrind](https://valgrind.org) and fails when one needs more than 1.1x those of the base branch. +The counter supports only the `@Revs` and `@BeforeMethods` docblock annotations. +To reproduce the check, install Valgrind, check out the base branch into a second directory and run `composer install` there, then run: + +```sh +php benchmarks/count-instructions.php ../graphql-php-base > base.json +php benchmarks/count-instructions.php . > head.json +php benchmarks/compare-results.php base.json head.json +``` + ## Documentation We document this library by rendering the Markdown files in [docs](docs) with [MkDocs](https://www.mkdocs.org). diff --git a/benchmarks/count-instructions.php b/benchmarks/count-instructions.php index 32e7cd541..fe4e1bb6c 100644 --- a/benchmarks/count-instructions.php +++ b/benchmarks/count-instructions.php @@ -14,6 +14,8 @@ exit(1); } +require_once "{$checkout}/vendor/autoload.php"; + $runSubject = __DIR__ . '/run-subject.php'; /** @@ -42,12 +44,9 @@ function run(array $command): array return [$stdout, $stderr]; } -/** - * @return array{int, int} Revs run and instructions counted - */ -function countInstructions(string $runSubject, string $checkout, string $subject, string $revs): array +function countInstructions(string $runSubject, string $checkout, string $subject, int $revs): int { - [$stdout, $stderr] = run([ + [, $stderr] = run([ 'valgrind', '--tool=cachegrind', '--cache-sim=no', @@ -60,23 +59,55 @@ function countInstructions(string $runSubject, string $checkout, string $subject $runSubject, $checkout, $subject, - $revs, + (string) $revs, ]); if (preg_match('/I\s+refs:\s+([\d,]+)/', $stderr, $match) !== 1) { throw new RuntimeException("No instruction count for {$subject}:\n{$stderr}"); } - return [(int) $stdout, (int) str_replace(',', '', $match[1])]; + return (int) str_replace(',', '', $match[1]); +} + +function annotatedRevs(ReflectionMethod $method, string $subject): int +{ + foreach ([$method->getDocComment(), $method->getDeclaringClass()->getDocComment()] as $docComment) { + if (is_string($docComment) && preg_match('/@Revs\((.+)\)/', $docComment, $match) === 1) { + if (preg_match('/^\d+$/', $match[1]) !== 1) { + throw new RuntimeException("Only a single integer is supported in @Revs: {$subject}."); + } + + return (int) $match[1]; + } + } + + return 1; } -[$subjectList] = run([PHP_BINARY, $runSubject, $checkout, 'list']); +$files = glob("{$checkout}/benchmarks/*Bench.php"); +if ($files === false) { + throw new RuntimeException("Failed to list benchmarks in {$checkout}."); +} $instructionsPerRev = []; -foreach (explode("\n", trim($subjectList)) as $subject) { - [$warmupRevs, $warmupInstructions] = countInstructions($runSubject, $checkout, $subject, '1'); - [$measuredRevs, $measuredInstructions] = countInstructions($runSubject, $checkout, $subject, 'annotated'); - $instructionsPerRev[$subject] = intdiv($measuredInstructions - $warmupInstructions, $measuredRevs - $warmupRevs); - fwrite(STDERR, "{$subject}: {$instructionsPerRev[$subject]}\n"); +foreach ($files as $file) { + $className = 'GraphQL\\Benchmarks\\' . basename($file, '.php'); + if (! class_exists($className)) { + throw new RuntimeException("Failed to load benchmark class: {$className}."); + } + + $class = new ReflectionClass($className); + foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { + if (strpos($method->getName(), 'bench') !== 0) { + continue; + } + + $subject = "{$class->getShortName()}::{$method->getName()}"; + $extraRevs = (int) ceil(annotatedRevs($method, $subject) / 10); + $warmupInstructions = countInstructions($runSubject, $checkout, $subject, 1); + $measuredInstructions = countInstructions($runSubject, $checkout, $subject, 1 + $extraRevs); + $instructionsPerRev[$subject] = intdiv($measuredInstructions - $warmupInstructions, $extraRevs); + fwrite(STDERR, "{$subject}: {$instructionsPerRev[$subject]}\n"); + } } echo json_encode($instructionsPerRev, JSON_PRETTY_PRINT) . "\n"; diff --git a/benchmarks/run-subject.php b/benchmarks/run-subject.php index f10e76dba..6aa241efb 100644 --- a/benchmarks/run-subject.php +++ b/benchmarks/run-subject.php @@ -1,73 +1,38 @@ list - * php benchmarks/run-subject.php + * Usage: php benchmarks/run-subject.php * - * Revs "annotated" runs 1 more than a tenth of the @Revs of the subject. - * Prints the number of revs it ran. + * Supports only the @Revs and @BeforeMethods docblock annotations. */ $checkout = $argv[1] ?? null; $subject = $argv[2] ?? null; -if ($checkout === null || $subject === null) { - fwrite(STDERR, "Usage: php benchmarks/run-subject.php list| []\n"); +$revs = $argv[3] ?? null; +if ($checkout === null || $subject === null || $revs === null) { + fwrite(STDERR, "Usage: php benchmarks/run-subject.php \n"); exit(1); } require_once "{$checkout}/vendor/autoload.php"; -function annotation(string $name, ReflectionMethod $method): ?string -{ - foreach ([$method->getDocComment(), $method->getDeclaringClass()->getDocComment()] as $docComment) { - if (is_string($docComment) && preg_match("/@{$name}\\((.+)\\)/", $docComment, $match) === 1) { - return $match[1]; - } - } - - return null; -} - -if ($subject === 'list') { - $files = glob("{$checkout}/benchmarks/*Bench.php"); - if ($files === false) { - throw new RuntimeException("Failed to list benchmarks in {$checkout}."); - } +[$shortClass, $methodName] = explode('::', $subject); +$method = new ReflectionMethod("GraphQL\\Benchmarks\\{$shortClass}", $methodName); +$bench = $method->getDeclaringClass()->newInstance(); - foreach ($files as $file) { - $className = 'GraphQL\\Benchmarks\\' . basename($file, '.php'); - if (! class_exists($className)) { - throw new RuntimeException("Failed to load benchmark class: {$className}."); +foreach ([$method->getDocComment(), $method->getDeclaringClass()->getDocComment()] as $docComment) { + if (is_string($docComment) && preg_match('/@BeforeMethods\((.+)\)/', $docComment, $beforeMethodsAnnotation) === 1) { + preg_match_all('/"(\w+)"/', $beforeMethodsAnnotation[1], $beforeMethods); + foreach ($beforeMethods[1] as $beforeMethod) { + (new ReflectionMethod($bench, $beforeMethod))->invoke($bench); } - $class = new ReflectionClass($className); - foreach ($class->getMethods(ReflectionMethod::IS_PUBLIC) as $method) { - if (strpos($method->getName(), 'bench') === 0) { - echo "{$class->getShortName()}::{$method->getName()}\n"; - } - } + break; } - - exit(0); -} - -[$shortClass, $methodName] = explode('::', $subject); -$method = new ReflectionMethod("GraphQL\\Benchmarks\\{$shortClass}", $methodName); -$revs = ($argv[3] ?? 'annotated') === 'annotated' - ? 1 + (int) ceil((int) (annotation('Revs', $method) ?? 1) / 10) - : (int) $argv[3]; - -$bench = $method->getDeclaringClass()->newInstance(); -preg_match_all('/"(\w+)"/', annotation('BeforeMethods', $method) ?? '', $beforeMethods); -foreach ($beforeMethods[1] as $beforeMethod) { - (new ReflectionMethod($bench, $beforeMethod))->invoke($bench); } $benchmark = $method->getClosure($bench); -for ($rev = 0; $rev < $revs; ++$rev) { +for ($rev = 0; $rev < (int) $revs; ++$rev) { $benchmark(); } - -echo $revs; From 8dc0b42e7ea0d7d66337e470573b3d3118f528dc Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 10:10:23 +0100 Subject: [PATCH 06/14] Report removed benchmarks and fail on broken base counts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A base count of zero or less now throws instead of passing as ratio 1. Benchmarks that exist only on base show as removed. The threshold is fixed at 1.1, since nothing passed another one. The summary warns that edited benchmarks compare different workloads. 🤖 Generated with Claude Code --- benchmarks/compare-results.php | 23 +++++++++++++++-------- 1 file changed, 15 insertions(+), 8 deletions(-) diff --git a/benchmarks/compare-results.php b/benchmarks/compare-results.php index 1ce3db725..7c9326c0b 100644 --- a/benchmarks/compare-results.php +++ b/benchmarks/compare-results.php @@ -3,19 +3,20 @@ /** * Compares the instructions per revolution that count-instructions.php measured on base and head. * - * Usage: php benchmarks/compare-results.php [] + * Usage: php benchmarks/compare-results.php * - * Prints a Markdown table and exits with 1 when head needs more than threshold times + * Prints a Markdown table and exits with 1 when head needs more than 1.1x * the instructions of base for any benchmark. */ $baseFile = $argv[1] ?? null; $headFile = $argv[2] ?? null; -$threshold = (float) ($argv[3] ?? 1.1); if ($baseFile === null || $headFile === null) { - fwrite(STDERR, "Usage: php benchmarks/compare-results.php []\n"); + fwrite(STDERR, "Usage: php benchmarks/compare-results.php \n"); exit(1); } +$threshold = 1.1; + /** @var array $base */ $base = json_decode((string) file_get_contents($baseFile), true, 2, JSON_THROW_ON_ERROR); /** @var array $head */ @@ -23,9 +24,11 @@ $ratios = []; foreach (array_intersect_key($head, $base) as $name => $headInstructions) { - $ratios[$name] = $base[$name] > 0 - ? $headInstructions / $base[$name] - : 1.0; + if ($base[$name] <= 0) { + throw new RuntimeException("Expected a positive instruction count on base: {$name}."); + } + + $ratios[$name] = $headInstructions / $base[$name]; } arsort($ratios); @@ -34,7 +37,8 @@ echo $regressions === [] ? "### No benchmark needs more than {$threshold}x the instructions of base\n\n" : '### ' . count($regressions) . " benchmark(s) need more than {$threshold}x the instructions of base\n\n"; -echo "Instructions per revolution, counted with Valgrind.\n\n"; +echo "Instructions per revolution, counted with Valgrind.\n"; +echo "When this PR edits a benchmark, its ratio compares two different workloads.\n\n"; echo "| Benchmark | Base | Head | Ratio |\n"; echo "| --- | ---: | ---: | ---: |\n"; foreach ($ratios as $name => $ratio) { @@ -44,5 +48,8 @@ foreach (array_diff_key($head, $base) as $name => $headInstructions) { printf("| %s | | %s | new |\n", $name, number_format($headInstructions)); } +foreach (array_diff_key($base, $head) as $name => $baseInstructions) { + printf("| %s | %s | | removed |\n", $name, number_format($baseInstructions)); +} exit($regressions === [] ? 0 : 1); From 98b9374d602eb25b5b3cef7c9dec28817b1ad74a Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 10:30:40 +0100 Subject: [PATCH 07/14] Make benchmark instruction counts reproducible and fail on broken measurements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seed the random generator before setup, so generated schemas match between runs. Fail when more revs count no more instructions, when @BeforeMethods names no method, or when base and head share no benchmark. Run commands without a shell and read only their error output. 🤖 Generated with Claude Code --- benchmarks/compare-results.php | 33 +++++++---------- benchmarks/count-instructions.php | 60 +++++++++++++++---------------- benchmarks/run-subject.php | 21 +++++------ 3 files changed, 52 insertions(+), 62 deletions(-) diff --git a/benchmarks/compare-results.php b/benchmarks/compare-results.php index 7c9326c0b..5409b69fe 100644 --- a/benchmarks/compare-results.php +++ b/benchmarks/compare-results.php @@ -1,13 +1,5 @@ - * - * Prints a Markdown table and exits with 1 when head needs more than 1.1x - * the instructions of base for any benchmark. - */ $baseFile = $argv[1] ?? null; $headFile = $argv[2] ?? null; if ($baseFile === null || $headFile === null) { @@ -23,33 +15,34 @@ $head = json_decode((string) file_get_contents($headFile), true, 2, JSON_THROW_ON_ERROR); $ratios = []; -foreach (array_intersect_key($head, $base) as $name => $headInstructions) { - if ($base[$name] <= 0) { - throw new RuntimeException("Expected a positive instruction count on base: {$name}."); - } - - $ratios[$name] = $headInstructions / $base[$name]; +foreach (array_intersect_key($head, $base) as $comparedName => $comparedHeadInstructions) { + $ratios[$comparedName] = $comparedHeadInstructions / $base[$comparedName]; +} +if ($ratios === []) { + throw new RuntimeException("Found no benchmark in both {$baseFile} and {$headFile}."); } arsort($ratios); $regressions = array_filter($ratios, static fn (float $ratio): bool => $ratio > $threshold); +$regressionCount = count($regressions); +$comparedCount = count($ratios); echo $regressions === [] ? "### No benchmark needs more than {$threshold}x the instructions of base\n\n" - : '### ' . count($regressions) . " benchmark(s) need more than {$threshold}x the instructions of base\n\n"; + : "### {$regressionCount} of {$comparedCount} benchmarks need more than {$threshold}x the instructions of base\n\n"; echo "Instructions per revolution, counted with Valgrind.\n"; -echo "When this PR edits a benchmark, its ratio compares two different workloads.\n\n"; +echo "When this PR edits a benchmark, that benchmark's ratio compares two different workloads.\n\n"; echo "| Benchmark | Base | Head | Ratio |\n"; echo "| --- | ---: | ---: | ---: |\n"; foreach ($ratios as $name => $ratio) { $marker = isset($regressions[$name]) ? ' :warning:' : ''; printf("| %s | %s | %s | %.4f%s |\n", $name, number_format($base[$name]), number_format($head[$name]), $ratio, $marker); } -foreach (array_diff_key($head, $base) as $name => $headInstructions) { - printf("| %s | | %s | new |\n", $name, number_format($headInstructions)); +foreach (array_diff_key($head, $base) as $newName => $newInstructions) { + printf("| %s | | %s | new |\n", $newName, number_format($newInstructions)); } -foreach (array_diff_key($base, $head) as $name => $baseInstructions) { - printf("| %s | %s | | removed |\n", $name, number_format($baseInstructions)); +foreach (array_diff_key($base, $head) as $removedName => $removedInstructions) { + printf("| %s | %s | | removed |\n", $removedName, number_format($removedInstructions)); } exit($regressions === [] ? 0 : 1); diff --git a/benchmarks/count-instructions.php b/benchmarks/count-instructions.php index fe4e1bb6c..5e063ebf5 100644 --- a/benchmarks/count-instructions.php +++ b/benchmarks/count-instructions.php @@ -1,13 +1,5 @@ > instructions.json - * - * Runs each subject once with 1 rev, once with 1 + a tenth of its @Revs, and divides the difference, - * so bootstrap, setup and first-run costs cancel out. - */ $checkout = $argv[1] ?? null; if ($checkout === null) { fwrite(STDERR, "Usage: php benchmarks/count-instructions.php \n"); @@ -16,47 +8,41 @@ require_once "{$checkout}/vendor/autoload.php"; -$runSubject = __DIR__ . '/run-subject.php'; - /** * @param list $command * - * @return array{string, string} + * @return string The standard error output */ -function run(array $command): array +function runCommand(array $command): string { - $process = proc_open( - implode(' ', array_map('escapeshellarg', $command)), - [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], - $pipes - ); + $process = proc_open($command, [1 => ['file', '/dev/null', 'w'], 2 => ['pipe', 'w']], $pipes); + $commandLine = implode(' ', $command); if (! is_resource($process)) { - throw new RuntimeException('Failed to start: ' . implode(' ', $command) . '.'); + throw new RuntimeException("Failed to start: {$commandLine}."); } - $stdout = (string) stream_get_contents($pipes[1]); $stderr = (string) stream_get_contents($pipes[2]); $exitCode = proc_close($process); if ($exitCode !== 0) { - throw new RuntimeException("Exit code {$exitCode} from " . implode(' ', $command) . ":\n{$stdout}{$stderr}"); + throw new RuntimeException("Exit code {$exitCode} from {$commandLine}:\n{$stderr}"); } - return [$stdout, $stderr]; + return $stderr; } -function countInstructions(string $runSubject, string $checkout, string $subject, int $revs): int +function countInstructions(string $checkout, string $subject, int $revs): int { - [, $stderr] = run([ + $benchmarksDirectory = __DIR__; + $stderr = runCommand([ 'valgrind', '--tool=cachegrind', - '--cache-sim=no', '--cachegrind-out-file=/dev/null', PHP_BINARY, '-d', 'zend.assertions=-1', '-d', 'opcache.enable_cli=0', - $runSubject, + "{$benchmarksDirectory}/run-subject.php", $checkout, $subject, (string) $revs, @@ -65,12 +51,15 @@ function countInstructions(string $runSubject, string $checkout, string $subject throw new RuntimeException("No instruction count for {$subject}:\n{$stderr}"); } - return (int) str_replace(',', '', $match[1]); + $instructions = str_replace(',', '', $match[1]); + + return (int) $instructions; } function annotatedRevs(ReflectionMethod $method, string $subject): int { - foreach ([$method->getDocComment(), $method->getDeclaringClass()->getDocComment()] as $docComment) { + $docComments = [$method->getDocComment(), $method->getDeclaringClass()->getDocComment()]; + foreach ($docComments as $docComment) { if (is_string($docComment) && preg_match('/@Revs\((.+)\)/', $docComment, $match) === 1) { if (preg_match('/^\d+$/', $match[1]) !== 1) { throw new RuntimeException("Only a single integer is supported in @Revs: {$subject}."); @@ -90,7 +79,8 @@ function annotatedRevs(ReflectionMethod $method, string $subject): int $instructionsPerRev = []; foreach ($files as $file) { - $className = 'GraphQL\\Benchmarks\\' . basename($file, '.php'); + $shortClassName = basename($file, '.php'); + $className = "GraphQL\\Benchmarks\\{$shortClassName}"; if (! class_exists($className)) { throw new RuntimeException("Failed to load benchmark class: {$className}."); } @@ -103,11 +93,17 @@ function annotatedRevs(ReflectionMethod $method, string $subject): int $subject = "{$class->getShortName()}::{$method->getName()}"; $extraRevs = (int) ceil(annotatedRevs($method, $subject) / 10); - $warmupInstructions = countInstructions($runSubject, $checkout, $subject, 1); - $measuredInstructions = countInstructions($runSubject, $checkout, $subject, 1 + $extraRevs); - $instructionsPerRev[$subject] = intdiv($measuredInstructions - $warmupInstructions, $extraRevs); + // The 1-rev run holds bootstrap, setup and first-run costs, so the difference cancels them. + $baselineInstructions = countInstructions($checkout, $subject, 1); + $measuredInstructions = countInstructions($checkout, $subject, 1 + $extraRevs); + if ($measuredInstructions <= $baselineInstructions) { + throw new RuntimeException("Expected more instructions with more revs: {$subject}."); + } + + $instructionsPerRev[$subject] = intdiv($measuredInstructions - $baselineInstructions, $extraRevs); fwrite(STDERR, "{$subject}: {$instructionsPerRev[$subject]}\n"); } } -echo json_encode($instructionsPerRev, JSON_PRETTY_PRINT) . "\n"; +$json = json_encode($instructionsPerRev, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR); +echo "{$json}\n"; diff --git a/benchmarks/run-subject.php b/benchmarks/run-subject.php index 6aa241efb..fd0119f64 100644 --- a/benchmarks/run-subject.php +++ b/benchmarks/run-subject.php @@ -1,12 +1,6 @@ - * - * Supports only the @Revs and @BeforeMethods docblock annotations. - */ +// Runs a PHPBench subject without PHPBench, so count-instructions.php can measure it under Valgrind. $checkout = $argv[1] ?? null; $subject = $argv[2] ?? null; $revs = $argv[3] ?? null; @@ -21,11 +15,18 @@ $method = new ReflectionMethod("GraphQL\\Benchmarks\\{$shortClass}", $methodName); $bench = $method->getDeclaringClass()->newInstance(); -foreach ([$method->getDocComment(), $method->getDeclaringClass()->getDocComment()] as $docComment) { +mt_srand(1); +$docComments = [$method->getDocComment(), $method->getDeclaringClass()->getDocComment()]; +foreach ($docComments as $docComment) { if (is_string($docComment) && preg_match('/@BeforeMethods\((.+)\)/', $docComment, $beforeMethodsAnnotation) === 1) { preg_match_all('/"(\w+)"/', $beforeMethodsAnnotation[1], $beforeMethods); - foreach ($beforeMethods[1] as $beforeMethod) { - (new ReflectionMethod($bench, $beforeMethod))->invoke($bench); + if ($beforeMethods[1] === []) { + throw new RuntimeException("Found no method names in @BeforeMethods: {$subject}."); + } + + foreach ($beforeMethods[1] as $beforeMethodName) { + $beforeMethod = new ReflectionMethod($bench, $beforeMethodName); + $beforeMethod->invoke($bench); } break; From bfbfd2300bea5405e6865685fb2c132f1f94212f Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 10:30:40 +0100 Subject: [PATCH 08/14] State one fact per sentence in the benchmark check docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- CONTRIBUTING.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 62a1d0e50..052786766 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -151,9 +151,14 @@ Check performance: composer bench ``` -On pull requests, CI counts the instructions per revolution of each benchmark with [Valgrind](https://valgrind.org) and fails when one needs more than 1.1x those of the base branch. +On pull requests, CI counts each benchmark's instructions per revolution with [Valgrind](https://valgrind.org). +The check fails when any benchmark needs more than 1.1x the instructions of the base branch. The counter supports only the `@Revs` and `@BeforeMethods` docblock annotations. -To reproduce the check, install Valgrind, check out the base branch into a second directory and run `composer install` there, then run: + +To reproduce the check, install Valgrind. +Check out the base branch into a second directory. +Run `composer install` in that directory. +Then run: ```sh php benchmarks/count-instructions.php ../graphql-php-base > base.json From a75171196f1e7884d1433dfd2b57703cd4c466b2 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 10:30:40 +0100 Subject: [PATCH 09/14] Benchmark the first parent of the PR merge commit as base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The base SHA of the pull request event can lag behind the merge commit that the head checkout tests. 🤖 Generated with Claude Code --- .github/workflows/benchmarks.yml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 010c194c8..9cc4949d5 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -25,10 +25,14 @@ jobs: - uses: actions/checkout@v7 with: path: head + fetch-depth: 2 + + - id: base-sha + run: echo "sha=$(git -C head rev-parse HEAD^1)" >> "$GITHUB_OUTPUT" - uses: actions/checkout@v7 with: - ref: ${{ github.event.pull_request.base.sha }} + ref: ${{ steps.base-sha.outputs.sha }} path: base - uses: shivammathur/setup-php@v2 From 6fa47a2210ab00105c815786ad0be4f387988f64 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 10:43:50 +0100 Subject: [PATCH 10/14] Size benchmark instruction runs by an instruction budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A probe run with 2 revs estimates the cost per rev, and the measured run adds enough revs for 20 million instructions, so the setup noise of tiny subjects no longer dominates their count. @Revs no longer matters for the count. 🤖 Generated with Claude Code --- CONTRIBUTING.md | 2 +- benchmarks/count-instructions.php | 22 +++++----------------- 2 files changed, 6 insertions(+), 18 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 052786766..528e44391 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -153,7 +153,7 @@ composer bench On pull requests, CI counts each benchmark's instructions per revolution with [Valgrind](https://valgrind.org). The check fails when any benchmark needs more than 1.1x the instructions of the base branch. -The counter supports only the `@Revs` and `@BeforeMethods` docblock annotations. +Of the PHPBench annotations, the counter supports only `@BeforeMethods`. To reproduce the check, install Valgrind. Check out the base branch into a second directory. diff --git a/benchmarks/count-instructions.php b/benchmarks/count-instructions.php index 5e063ebf5..4e7e90c79 100644 --- a/benchmarks/count-instructions.php +++ b/benchmarks/count-instructions.php @@ -8,6 +8,9 @@ require_once "{$checkout}/vendor/autoload.php"; +// Extra revs add up to this many instructions, so the measured work outweighs the noise of setup. +const INSTRUCTION_BUDGET = 20_000_000; + /** * @param list $command * @@ -56,22 +59,6 @@ function countInstructions(string $checkout, string $subject, int $revs): int return (int) $instructions; } -function annotatedRevs(ReflectionMethod $method, string $subject): int -{ - $docComments = [$method->getDocComment(), $method->getDeclaringClass()->getDocComment()]; - foreach ($docComments as $docComment) { - if (is_string($docComment) && preg_match('/@Revs\((.+)\)/', $docComment, $match) === 1) { - if (preg_match('/^\d+$/', $match[1]) !== 1) { - throw new RuntimeException("Only a single integer is supported in @Revs: {$subject}."); - } - - return (int) $match[1]; - } - } - - return 1; -} - $files = glob("{$checkout}/benchmarks/*Bench.php"); if ($files === false) { throw new RuntimeException("Failed to list benchmarks in {$checkout}."); @@ -92,9 +79,10 @@ function annotatedRevs(ReflectionMethod $method, string $subject): int } $subject = "{$class->getShortName()}::{$method->getName()}"; - $extraRevs = (int) ceil(annotatedRevs($method, $subject) / 10); // The 1-rev run holds bootstrap, setup and first-run costs, so the difference cancels them. $baselineInstructions = countInstructions($checkout, $subject, 1); + $probeInstructions = countInstructions($checkout, $subject, 2); + $extraRevs = max(1, (int) ceil(INSTRUCTION_BUDGET / max(1, $probeInstructions - $baselineInstructions))); $measuredInstructions = countInstructions($checkout, $subject, 1 + $extraRevs); if ($measuredInstructions <= $baselineInstructions) { throw new RuntimeException("Expected more instructions with more revs: {$subject}."); From de4474593e4c545499fd8c6d42fbb73b9aa27c25 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 10:55:04 +0100 Subject: [PATCH 11/14] Reuse the probe run when one extra rev fills the instruction budget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- benchmarks/count-instructions.php | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/benchmarks/count-instructions.php b/benchmarks/count-instructions.php index 4e7e90c79..29210cb6c 100644 --- a/benchmarks/count-instructions.php +++ b/benchmarks/count-instructions.php @@ -82,8 +82,11 @@ function countInstructions(string $checkout, string $subject, int $revs): int // The 1-rev run holds bootstrap, setup and first-run costs, so the difference cancels them. $baselineInstructions = countInstructions($checkout, $subject, 1); $probeInstructions = countInstructions($checkout, $subject, 2); - $extraRevs = max(1, (int) ceil(INSTRUCTION_BUDGET / max(1, $probeInstructions - $baselineInstructions))); - $measuredInstructions = countInstructions($checkout, $subject, 1 + $extraRevs); + $probeRevInstructions = max(1, $probeInstructions - $baselineInstructions); + $extraRevs = (int) ceil(INSTRUCTION_BUDGET / $probeRevInstructions); + $measuredInstructions = $extraRevs === 1 + ? $probeInstructions + : countInstructions($checkout, $subject, 1 + $extraRevs); if ($measuredInstructions <= $baselineInstructions) { throw new RuntimeException("Expected more instructions with more revs: {$subject}."); } From 2e25cd0e223fdf25cf30c2397fdad9a2e3833b35 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 10:58:44 +0100 Subject: [PATCH 12/14] Provoke a benchmark regression to test the instruction check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Temporary, reverted in the next commit. 🤖 Generated with Claude Code --- src/Language/Lexer.php | 1 + 1 file changed, 1 insertion(+) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 841132271..9dd5cca06 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -86,6 +86,7 @@ public function __construct(Source $source, array $options = []) */ public function advance(): Token { + \array_sum(\range(1, 100)); $this->lastToken = $this->token; return $this->token = $this->lookahead(); From f6a4a2ec123201531024ad118cc2dd15bcc5208f Mon Sep 17 00:00:00 2001 From: "autofix-ci[bot]" <114827586+autofix-ci[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 10:00:15 +0000 Subject: [PATCH 13/14] Autofix --- src/Language/Lexer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 9dd5cca06..13623e9b6 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -86,7 +86,7 @@ public function __construct(Source $source, array $options = []) */ public function advance(): Token { - \array_sum(\range(1, 100)); + array_sum(range(1, 100)); $this->lastToken = $this->token; return $this->token = $this->lookahead(); From 41cd07e3e8a5dfe36cf6d4712080bfef5472f083 Mon Sep 17 00:00:00 2001 From: Benedikt Franke Date: Mon, 26 Oct 2026 11:10:20 +0100 Subject: [PATCH 14/14] Revert the provoked benchmark regression MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🤖 Generated with Claude Code --- src/Language/Lexer.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Language/Lexer.php b/src/Language/Lexer.php index 13623e9b6..841132271 100644 --- a/src/Language/Lexer.php +++ b/src/Language/Lexer.php @@ -86,7 +86,6 @@ public function __construct(Source $source, array $options = []) */ public function advance(): Token { - array_sum(range(1, 100)); $this->lastToken = $this->token; return $this->token = $this->lookahead();