diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index a329b727c6..abcb812622 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -11,6 +11,8 @@ /.github/workflows/trigger-tempest-app-qa.yml @brendt /.github/workflows/trigger-tempest-clean-qa.yml @brendt /.github/workflows/validate-packages.yml @aidan-casey +/.github/workflows/benchmark.yml @xHeaven +/.github/workflows/benchmark-comment.yml @xHeaven /src/ @brendt /docs/ @brendt @innocenzi /packages/auth/ @innocenzi diff --git a/.github/workflows/benchmark-comment.yml b/.github/workflows/benchmark-comment.yml new file mode 100644 index 0000000000..64d079e8ec --- /dev/null +++ b/.github/workflows/benchmark-comment.yml @@ -0,0 +1,66 @@ +name: Benchmark Comment + +on: + workflow_run: + workflows: [Benchmark] + types: [completed] + +jobs: + comment: + name: Post Benchmark Results + runs-on: ubuntu-latest + permissions: + actions: read + pull-requests: write + if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.event == 'pull_request' + + steps: + - name: Download benchmark artifact + uses: actions/download-artifact@v7 + with: + name: benchmark-result + path: benchmark-artifact/ + run-id: ${{ github.event.workflow_run.id }} + github-token: ${{ github.token }} + + - name: Read artifact data + id: bench + run: | + echo "pr-number=$(cat benchmark-artifact/pr-number.txt)" >> "$GITHUB_OUTPUT" + echo "head-ref=$(cat benchmark-artifact/head-ref.txt)" >> "$GITHUB_OUTPUT" + echo "base-ref=$(cat benchmark-artifact/base-ref.txt)" >> "$GITHUB_OUTPUT" + echo "base-sha=$(cat benchmark-artifact/base-sha.txt)" >> "$GITHUB_OUTPUT" + echo "head-sha=$(cat benchmark-artifact/head-sha.txt)" >> "$GITHUB_OUTPUT" + { + echo "result<> "$GITHUB_OUTPUT" + + - name: Find existing comment + uses: peter-evans/find-comment@v4 + id: find-comment + with: + issue-number: ${{ steps.bench.outputs.pr-number }} + comment-author: github-actions[bot] + body-includes: "## Benchmark Results" + + - name: Post benchmark results + uses: peter-evans/create-or-update-comment@v5 + with: + issue-number: ${{ steps.bench.outputs.pr-number }} + comment-id: ${{ steps.find-comment.outputs.comment-id }} + edit-mode: replace + body: | + ## Benchmark Results + + Comparison of `${{ steps.bench.outputs.head-ref }}` against `${{ steps.bench.outputs.base-ref }}` (`${{ steps.bench.outputs.base-sha }}`). + +
+ Open to see the benchmark results + + ${{ steps.bench.outputs.result }} + +
+ + Generated by phpbench against commit ${{ steps.bench.outputs.head-sha }} diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000000..446924ec15 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,59 @@ +name: Benchmark + +on: + pull_request: + branches: [main] + +concurrency: + group: benchmark-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + benchmark: + name: Performance Regression Check + runs-on: ubuntu-latest + + steps: + - name: Checkout PR branch + uses: actions/checkout@v6 + with: + fetch-depth: 0 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: 8.4 + extensions: dom, curl, libxml, mbstring, zip, pcntl, pdo, sqlite, pdo_sqlite, bcmath, soap, intl, gd, exif, iconv, imagick, fileinfo + coverage: none + + - name: Install dependencies + uses: ramsey/composer-install@v3 + + - name: Benchmark base branch + run: | + git checkout ${{ github.event.pull_request.base.sha }} + composer install --no-interaction --prefer-dist --quiet + vendor/bin/phpbench run --tag=base --store --progress=none + + - name: Benchmark PR branch + run: | + git checkout ${{ github.event.pull_request.head.sha }} + composer install --no-interaction --prefer-dist --quiet + vendor/bin/phpbench run --tag=pr --store --ref=base --report=aggregate --progress=none --output=markdown | tee benchmark-result.txt || true + + - name: Prepare artifact + run: | + mkdir -p benchmark-artifact + cp benchmark-result.txt benchmark-artifact/ + echo "${{ github.event.pull_request.number }}" > benchmark-artifact/pr-number.txt + echo "${{ github.head_ref }}" > benchmark-artifact/head-ref.txt + echo "${{ github.base_ref }}" > benchmark-artifact/base-ref.txt + echo "${{ github.event.pull_request.base.sha }}" > benchmark-artifact/base-sha.txt + echo "${{ github.event.pull_request.head.sha }}" > benchmark-artifact/head-sha.txt + + - name: Upload benchmark artifact + uses: actions/upload-artifact@v6 + with: + name: benchmark-result + path: benchmark-artifact/ + retention-days: 1 diff --git a/.gitignore b/.gitignore index 0e321ab344..78921a9b77 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ .tempest/ .cache/ .idea/ +.phpbench/ build/ sessions/ vendor/ diff --git a/composer.json b/composer.json index 75b6fa585e..0c5b1e590c 100644 --- a/composer.json +++ b/composer.json @@ -73,7 +73,7 @@ "nyholm/psr7": "^1.8", "patrickbussmann/oauth2-apple": "^0.3", "phpat/phpat": "^0.11.0", - "phpbench/phpbench": "84.x-dev", + "phpbench/phpbench": "^1.4", "phpstan/phpstan": "2.1.38", "phpunit/phpunit": "^12.5.8", "predis/predis": "^3.0.0", @@ -274,6 +274,7 @@ "composer phpunit", "composer phpstan", "composer exceptions:build" - ] + ], + "bench": "vendor/bin/phpbench run --report=aggregate" } } diff --git a/phpbench.json b/phpbench.json index a78fe30fd7..193bd46a55 100644 --- a/phpbench.json +++ b/phpbench.json @@ -1,4 +1,9 @@ { - "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", - "runner.bootstrap": "vendor/autoload.php" + "$schema": "./vendor/phpbench/phpbench/phpbench.schema.json", + "runner.bootstrap": "vendor/autoload.php", + "runner.path": "tests/Benchmark", + "runner.file_pattern": "*Bench.php", + "core.extensions": [ + "Tests\\Tempest\\Benchmark\\Extension\\MarkdownExtension" + ] } diff --git a/tests/Benchmark/Container/ContainerBench.php b/tests/Benchmark/Container/ContainerBench.php new file mode 100644 index 0000000000..0317fe9c30 --- /dev/null +++ b/tests/Benchmark/Container/ContainerBench.php @@ -0,0 +1,114 @@ +container = new GenericContainer(); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchAutowireSimple(): void + { + $this->container->get(ContainerObjectA::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchAutowireNested(): void + { + $this->container->get(ContainerObjectB::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchSingletonResolution(): void + { + $this->container->singleton(ContainerObjectA::class, new ContainerObjectA()); + $this->container->get(ContainerObjectA::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchSingletonAttribute(): void + { + $this->container->get(ClassWithSingletonAttribute::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchDefinitionResolution(): void + { + $this->container->register(ContainerObjectA::class, fn () => new ContainerObjectA()); + $this->container->get(ContainerObjectA::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchInitializerResolution(): void + { + $this->container->addInitializer(ContainerObjectDInitializer::class); + $this->container->get(ContainerObjectD::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchDynamicInitializerResolution(): void + { + $this->container->addInitializer(ContainerObjectEInitializer::class); + $this->container->get(ContainerObjectE::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchClosureSingletonResolution(): void + { + $this->container->singleton(ContainerObjectA::class, fn () => new ContainerObjectA()); + $this->container->get(ContainerObjectA::class); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchInvokeClosure(): void + { + $this->container->invoke(fn (ContainerObjectA $a) => $a); + } +} diff --git a/tests/Benchmark/Extension/MarkdownExtension.php b/tests/Benchmark/Extension/MarkdownExtension.php new file mode 100644 index 0000000000..e0108766ec --- /dev/null +++ b/tests/Benchmark/Extension/MarkdownExtension.php @@ -0,0 +1,37 @@ +register( + MarkdownRenderer::class, + function (Container $container) { + return new MarkdownRenderer( + $container->get(ConsoleExtension::SERVICE_OUTPUT_STD), + $container->get(ExpressionExtension::SERVICE_PLAIN_PRINTER), + ); + }, + [ + ReportExtension::TAG_REPORT_RENDERER => [ + 'name' => 'markdown', + ], + ], + ); + } +} diff --git a/tests/Benchmark/Extension/MarkdownRenderer.php b/tests/Benchmark/Extension/MarkdownRenderer.php new file mode 100644 index 0000000000..6d86d94779 --- /dev/null +++ b/tests/Benchmark/Extension/MarkdownRenderer.php @@ -0,0 +1,132 @@ +renderContent($reports); + $file = $config['file']; + + if ($file === null) { + $this->output->write($content); + + return; + } + + $this->writeFile($file, $content); + } + + public function configure(OptionsResolver $options): void + { + $options->setDefaults([ + 'file' => null, + ]); + $options->setAllowedTypes('file', ['null', 'string']); + } + + private function renderContent(Reports $reports): string + { + $lines = []; + + foreach ($reports->tables() as $table) { + array_push($lines, ...$this->renderTable($table)); + } + + return implode("\n", $lines) . "\n"; + } + + private function renderTable(Table $table): array + { + $lines = []; + $title = $table->title(); + + if ($title !== null && $title !== '') { + $lines[] = "## {$title}"; + $lines[] = ''; + } + + $columns = $table->columnNames(); + + if ($columns === []) { + return $lines; + } + + $lines[] = $this->renderRow($columns); + $lines[] = $this->renderSeparatorRow($columns); + + foreach ($table as $row) { + $lines[] = $this->renderDataRow($row); + } + + $lines[] = ''; + + return $lines; + } + + private function renderRow(array $cells): string + { + return '| ' . implode(' | ', $cells) . ' |'; + } + + private function renderSeparatorRow(array $columns): string + { + return $this->renderRow(array_map( + fn (string $column): string => str_repeat('-', max(3, mb_strlen($column))), + $columns, + )); + } + + private function renderDataRow(TableRow $row): string + { + $cells = array_map($this->formatCell(...), iterator_to_array($row)); + + return $this->renderRow($cells); + } + + private function formatCell(Node $node): string + { + return str_replace('|', '\\|', trim($this->printer->print($node))); + } + + private function writeFile(string $file, string $content): void + { + $this->createDirectory(dirname($file)); + + if (file_put_contents($file, $content) === false) { + throw new RuntimeException(sprintf('Could not write to file "%s"', $file)); + } + + $this->output->writeln("Written markdown report to: {$file}"); + } + + private function createDirectory(string $directory): void + { + if (is_dir($directory)) { + return; + } + + if (! mkdir($directory, 0o777, true) && ! is_dir($directory)) { + throw new RuntimeException(sprintf('Could not create directory "%s"', $directory)); + } + } +} diff --git a/tests/Benchmark/Http/GenericRouterBench.php b/tests/Benchmark/Http/GenericRouterBench.php new file mode 100644 index 0000000000..f769baaaac --- /dev/null +++ b/tests/Benchmark/Http/GenericRouterBench.php @@ -0,0 +1,138 @@ +routeConfig = self::makeRouteConfig(); + + $this->routeConfig->middleware = new Middleware( + HandleRouteExceptionMiddleware::class, + MatchRouteMiddleware::class, + ); + + $this->container = new GenericContainer(); + + $matcher = new GenericRouteMatcher($this->routeConfig); + + $this->container->singleton(Container::class, fn () => $this->container); + $this->container->singleton(RouteMatcher::class, fn () => $matcher); + $this->container->singleton(RouteConfig::class, fn () => $this->routeConfig); + + $this->router = new GenericRouter($this->container, $this->routeConfig); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[ParamProviders('provideDispatchCases')] + #[Revs(1000)] + #[Warmup(10)] + public function benchDispatch(array $params): void + { + $this->router->dispatch( + new GenericRequest(Method::GET, $params['uri']), + ); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[ParamProviders('provideDispatchCases')] + #[Revs(1000)] + #[Warmup(10)] + public function benchDispatchWithoutMiddleware(array $params): void + { + $this->routeConfig->middleware = new Middleware( + MatchRouteMiddleware::class, + ); + + $router = new GenericRouter($this->container, $this->routeConfig); + + $router->dispatch( + new GenericRequest(Method::GET, $params['uri']), + ); + } + + public function provideDispatchCases(): Generator + { + yield 'Static route' => ['uri' => '/test/5']; + yield 'Dynamic route' => ['uri' => '/test/key/5/edit']; + yield 'Dynamic short' => ['uri' => '/test/key/50']; + } + + public function handle(): Ok + { + return new Ok('OK'); + } + + public function handleWithParam(string $id): Ok + { + return new Ok('OK'); + } + + private static function makeRouteConfig(): RouteConfig + { + $handler = new MethodReflector(new ReflectionMethod(self::class, 'handle')); + $handlerWithParam = new MethodReflector(new ReflectionMethod(self::class, 'handleWithParam')); + + $configurator = new RouteConfigurator(); + + foreach (range(1, 100) as $i) { + $configurator->addRoute(DiscoveredRoute::fromRoute( + new Get("/test/{$i}"), + [], + $handler, + )); + $configurator->addRoute(DiscoveredRoute::fromRoute( + new Get("/test/{id}/{$i}"), + [], + $handlerWithParam, + )); + $configurator->addRoute(DiscoveredRoute::fromRoute( + new Get("/test/{id}/{$i}/delete"), + [], + $handlerWithParam, + )); + $configurator->addRoute(DiscoveredRoute::fromRoute( + new Get("/test/{id}/{$i}/edit"), + [], + $handlerWithParam, + )); + } + + return $configurator->toRouteConfig(); + } +} diff --git a/tests/Benchmark/Http/RouteConfigBench.php b/tests/Benchmark/Http/RouteConfigBench.php index 7e328bf93c..1cb2e2c607 100644 --- a/tests/Benchmark/Http/RouteConfigBench.php +++ b/tests/Benchmark/Http/RouteConfigBench.php @@ -4,6 +4,7 @@ namespace Tests\Tempest\Benchmark\Http; +use PhpBench\Attributes\Iterations; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; use Tempest\Router\RouteConfig; @@ -19,6 +20,7 @@ public function __construct() $this->config = self::makeRouteConfig(); } + #[Iterations(5)] #[Revs(1000)] #[Warmup(10)] public function benchSerialization(): void diff --git a/tests/Benchmark/Http/Routing/Construction/RouteConfiguratorBench.php b/tests/Benchmark/Http/Routing/Construction/RouteConfiguratorBench.php index a1e8d3f4f9..6bd75947a3 100644 --- a/tests/Benchmark/Http/Routing/Construction/RouteConfiguratorBench.php +++ b/tests/Benchmark/Http/Routing/Construction/RouteConfiguratorBench.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Benchmark\Http\Routing\Construction; use PhpBench\Attributes\BeforeMethods; +use PhpBench\Attributes\Iterations; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; use Tempest\Router\Routing\Construction\RouteConfigurator; @@ -20,6 +21,7 @@ public function __construct() } #[BeforeMethods('setupRouteConfig')] + #[Iterations(5)] #[Revs(1000)] #[Warmup(10)] public function benchRouteConfigConstructionToConfig(): void @@ -27,6 +29,7 @@ public function benchRouteConfigConstructionToConfig(): void $this->subject->toRouteConfig(); } + #[Iterations(5)] #[Revs(1000)] #[Warmup(10)] public function benchRouteConfigConstructionRouteAdding(): void diff --git a/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php b/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php index 17ea69b5b6..95794f418d 100644 --- a/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php +++ b/tests/Benchmark/Http/Routing/Matching/GenericRouteMatcherBench.php @@ -5,6 +5,7 @@ namespace Tests\Tempest\Benchmark\Http\Routing\Matching; use Generator; +use PhpBench\Attributes\Iterations; use PhpBench\Attributes\ParamProviders; use PhpBench\Attributes\Revs; use PhpBench\Attributes\Warmup; @@ -26,6 +27,7 @@ public function __construct() $this->matcher = new GenericRouteMatcher($config); } + #[Iterations(5)] #[ParamProviders('provideDynamicMatchingCases')] #[Revs(1000)] #[Warmup(10)] diff --git a/tests/Benchmark/Kernel/KernelBootBench.php b/tests/Benchmark/Kernel/KernelBootBench.php new file mode 100644 index 0000000000..77ad297ed8 --- /dev/null +++ b/tests/Benchmark/Kernel/KernelBootBench.php @@ -0,0 +1,28 @@ +root = dirname(__DIR__, 3); + } + + #[Iterations(5)] + #[Revs(10)] + #[Warmup(2)] + public function benchBoot(): void + { + FrameworkKernel::boot(root: $this->root); + } +} diff --git a/tests/Benchmark/View/Fixtures/component.view.php b/tests/Benchmark/View/Fixtures/component.view.php new file mode 100644 index 0000000000..266fa29002 --- /dev/null +++ b/tests/Benchmark/View/Fixtures/component.view.php @@ -0,0 +1,6 @@ + +

{{ $heading }}

+ +
diff --git a/tests/Benchmark/View/Fixtures/control-flow.view.php b/tests/Benchmark/View/Fixtures/control-flow.view.php new file mode 100644 index 0000000000..f516d98e0a --- /dev/null +++ b/tests/Benchmark/View/Fixtures/control-flow.view.php @@ -0,0 +1,21 @@ + + + {{ $title }} + + +
+

{{ $heading }}

+
+ + + +
+

Admin panel

+
+
+

Regular user

+
+ + diff --git a/tests/Benchmark/View/Fixtures/expressions.view.php b/tests/Benchmark/View/Fixtures/expressions.view.php new file mode 100644 index 0000000000..e245824cf3 --- /dev/null +++ b/tests/Benchmark/View/Fixtures/expressions.view.php @@ -0,0 +1,10 @@ + + + {{ $title }} + + +

{{ $heading }}

+

Welcome, {{ $name }}. You have {{ $count }} notifications.

+ + + diff --git a/tests/Benchmark/View/Fixtures/plain.view.php b/tests/Benchmark/View/Fixtures/plain.view.php new file mode 100644 index 0000000000..0cb92bab48 --- /dev/null +++ b/tests/Benchmark/View/Fixtures/plain.view.php @@ -0,0 +1,9 @@ + + + Benchmark + + +

Hello World

+

This is a plain static template with no dynamic content.

+ + diff --git a/tests/Benchmark/View/Fixtures/x-bench-layout.view.php b/tests/Benchmark/View/Fixtures/x-bench-layout.view.php new file mode 100644 index 0000000000..ac6a48123e --- /dev/null +++ b/tests/Benchmark/View/Fixtures/x-bench-layout.view.php @@ -0,0 +1,10 @@ + + + <?= $title ?? 'Benchmark' ?> + + + +
+ + + diff --git a/tests/Benchmark/View/ViewRenderBench.php b/tests/Benchmark/View/ViewRenderBench.php new file mode 100644 index 0000000000..c3e953bcf1 --- /dev/null +++ b/tests/Benchmark/View/ViewRenderBench.php @@ -0,0 +1,98 @@ +fixturesPath = __DIR__ . '/Fixtures'; + + $this->renderer = TempestViewRenderer::make(); + + $this->rendererWithComponents = TempestViewRenderer::make( + viewConfig: new ViewConfig()->addViewComponents( + $this->fixturesPath . '/x-bench-layout.view.php', + ), + ); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchPlainHtml(): void + { + $this->renderer->render( + new GenericView($this->fixturesPath . '/plain.view.php'), + ); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchExpressions(): void + { + $view = new GenericView($this->fixturesPath . '/expressions.view.php'); + $view->data( + title: 'Benchmark', + heading: 'Hello World', + name: 'Tempest', + count: '42', + footer: 'Copyright 2025', + ); + + $this->renderer->render($view); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchControlFlow(): void + { + $view = new GenericView($this->fixturesPath . '/control-flow.view.php'); + $view->data( + title: 'Benchmark', + heading: 'Hello World', + showHeader: true, + isAdmin: false, + items: ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'], + ); + + $this->renderer->render($view); + } + + #[BeforeMethods('setUp')] + #[Iterations(5)] + #[Revs(1000)] + #[Warmup(10)] + public function benchViewComponent(): void + { + $view = new GenericView($this->fixturesPath . '/component.view.php'); + $view->data( + title: 'Benchmark', + heading: 'Hello World', + items: ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5'], + ); + + $this->rendererWithComponents->render($view); + } +}