From 0924459b854539e7c2f42107bc8e19f6359ab602 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:03:25 +0100 Subject: [PATCH 01/14] Add benchmarks --- .gitattributes | 2 + .github/workflows/benchmark-snapshot.yml | 40 ++ .github/workflows/benchmark.yml | 61 +++ .gitignore | 3 +- composer.json | 9 +- testbench.yaml | 7 + .../app/Console/Commands/BenchmarkCommand.php | 417 ++++++++++++++++++ workbench/app/Models/User.php | 48 ++ .../Providers/WorkbenchServiceProvider.php | 24 + .../views/bench/blade/attributes.blade.php | 3 + .../views/bench/blade/aware.blade.php | 6 + .../views/bench/blade/class.blade.php | 3 + .../views/bench/blade/forwarding.blade.php | 3 + .../views/bench/blade/merge.blade.php | 3 + .../views/bench/blade/named-slots.blade.php | 7 + .../views/bench/blade/no-attributes.blade.php | 3 + .../views/bench/blade/props.blade.php | 3 + .../views/bench/blade/slot.blade.php | 3 + .../views/bench/blaze/attributes.blade.php | 3 + .../views/bench/blaze/aware.blade.php | 6 + .../views/bench/blaze/class.blade.php | 3 + .../views/bench/blaze/forwarding.blade.php | 3 + .../views/bench/blaze/merge.blade.php | 3 + .../views/bench/blaze/named-slots.blade.php | 7 + .../views/bench/blaze/no-attributes.blade.php | 3 + .../views/bench/blaze/props.blade.php | 3 + .../views/bench/blaze/slot.blade.php | 3 + .../bench/blade/button-class.blade.php | 1 + .../bench/blade/button-inner.blade.php | 3 + .../bench/blade/button-merge.blade.php | 1 + .../blade/button-no-attributes.blade.php | 1 + .../bench/blade/button-outer.blade.php | 1 + .../bench/blade/button-props.blade.php | 3 + .../components/bench/blade/button.blade.php | 1 + .../bench/blade/card-full.blade.php | 9 + .../components/bench/blade/card.blade.php | 3 + .../bench/blade/menu-item.blade.php | 3 + .../components/bench/blade/menu.blade.php | 5 + .../bench/blaze/button-class.blade.php | 3 + .../bench/blaze/button-inner.blade.php | 4 + .../bench/blaze/button-merge.blade.php | 3 + .../blaze/button-no-attributes.blade.php | 3 + .../bench/blaze/button-outer.blade.php | 3 + .../bench/blaze/button-props.blade.php | 4 + .../components/bench/blaze/button.blade.php | 3 + .../bench/blaze/card-full.blade.php | 11 + .../components/bench/blaze/card.blade.php | 5 + .../bench/blaze/menu-item.blade.php | 4 + .../components/bench/blaze/menu.blade.php | 6 + 49 files changed, 754 insertions(+), 5 deletions(-) create mode 100644 .github/workflows/benchmark-snapshot.yml create mode 100644 .github/workflows/benchmark.yml create mode 100644 testbench.yaml create mode 100644 workbench/app/Console/Commands/BenchmarkCommand.php create mode 100644 workbench/app/Models/User.php create mode 100644 workbench/app/Providers/WorkbenchServiceProvider.php create mode 100644 workbench/resources/views/bench/blade/attributes.blade.php create mode 100644 workbench/resources/views/bench/blade/aware.blade.php create mode 100644 workbench/resources/views/bench/blade/class.blade.php create mode 100644 workbench/resources/views/bench/blade/forwarding.blade.php create mode 100644 workbench/resources/views/bench/blade/merge.blade.php create mode 100644 workbench/resources/views/bench/blade/named-slots.blade.php create mode 100644 workbench/resources/views/bench/blade/no-attributes.blade.php create mode 100644 workbench/resources/views/bench/blade/props.blade.php create mode 100644 workbench/resources/views/bench/blade/slot.blade.php create mode 100644 workbench/resources/views/bench/blaze/attributes.blade.php create mode 100644 workbench/resources/views/bench/blaze/aware.blade.php create mode 100644 workbench/resources/views/bench/blaze/class.blade.php create mode 100644 workbench/resources/views/bench/blaze/forwarding.blade.php create mode 100644 workbench/resources/views/bench/blaze/merge.blade.php create mode 100644 workbench/resources/views/bench/blaze/named-slots.blade.php create mode 100644 workbench/resources/views/bench/blaze/no-attributes.blade.php create mode 100644 workbench/resources/views/bench/blaze/props.blade.php create mode 100644 workbench/resources/views/bench/blaze/slot.blade.php create mode 100644 workbench/resources/views/components/bench/blade/button-class.blade.php create mode 100644 workbench/resources/views/components/bench/blade/button-inner.blade.php create mode 100644 workbench/resources/views/components/bench/blade/button-merge.blade.php create mode 100644 workbench/resources/views/components/bench/blade/button-no-attributes.blade.php create mode 100644 workbench/resources/views/components/bench/blade/button-outer.blade.php create mode 100644 workbench/resources/views/components/bench/blade/button-props.blade.php create mode 100644 workbench/resources/views/components/bench/blade/button.blade.php create mode 100644 workbench/resources/views/components/bench/blade/card-full.blade.php create mode 100644 workbench/resources/views/components/bench/blade/card.blade.php create mode 100644 workbench/resources/views/components/bench/blade/menu-item.blade.php create mode 100644 workbench/resources/views/components/bench/blade/menu.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/button-class.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/button-inner.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/button-merge.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/button-no-attributes.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/button-outer.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/button-props.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/button.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/card-full.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/card.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/menu-item.blade.php create mode 100644 workbench/resources/views/components/bench/blaze/menu.blade.php diff --git a/.gitattributes b/.gitattributes index 6945750..07e9055 100644 --- a/.gitattributes +++ b/.gitattributes @@ -5,5 +5,7 @@ .gitignore export-ignore phpunit.xml export-ignore README.md export-ignore +testbench.yaml export-ignore tests/ export-ignore UPGRADING.md export-ignore +workbench/ export-ignore diff --git a/.github/workflows/benchmark-snapshot.yml b/.github/workflows/benchmark-snapshot.yml new file mode 100644 index 0000000..e646dab --- /dev/null +++ b/.github/workflows/benchmark-snapshot.yml @@ -0,0 +1,40 @@ +name: Benchmark Snapshot + +on: + push: + branches: [ main ] + +permissions: + contents: write + +jobs: + snapshot: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer:v2 + coverage: none + extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Generate snapshot + run: vendor/bin/testbench benchmark --snapshot --ci + + - name: Commit snapshot + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .github/benchmark-snapshot.json + git diff --staged --quiet || git commit -m "Update benchmark snapshot" + git push diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml new file mode 100644 index 0000000..0d97b42 --- /dev/null +++ b/.github/workflows/benchmark.yml @@ -0,0 +1,61 @@ +name: Benchmark + +on: + pull_request: + branches: [ main ] + +permissions: + contents: read + pull-requests: write + +jobs: + benchmark: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer:v2 + coverage: none + extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run benchmarks + run: | + echo "## Benchmark Results" > benchmark-comment.md + echo "" >> benchmark-comment.md + + echo "### Baseline" >> benchmark-comment.md + echo "" >> benchmark-comment.md + vendor/bin/testbench benchmark --snapshot --ci | grep -v '^## ' >> benchmark-comment.md + echo "" >> benchmark-comment.md + + for i in 1 2 3 4 5; do + echo "### Run $i" >> benchmark-comment.md + echo "" >> benchmark-comment.md + vendor/bin/testbench benchmark --ci | grep -v '^## ' >> benchmark-comment.md + echo "" >> benchmark-comment.md + done + + - name: Find existing comment + uses: peter-evans/find-comment@v3 + id: find + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '## Benchmark Results' + + - name: Post or update comment + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find.outputs.comment-id }} + edit-mode: replace + body-path: benchmark-comment.md diff --git a/.gitignore b/.gitignore index cd37346..76f97fa 100644 --- a/.gitignore +++ b/.gitignore @@ -9,5 +9,4 @@ composer.lock phpunit.xml.dist .env .env.testing -tests/fixtures/compiled/ -tests/fixtures/blaze/ \ No newline at end of file +benchmark-snapshot.json \ No newline at end of file diff --git a/composer.json b/composer.json index 0e2d847..887719c 100644 --- a/composer.json +++ b/composer.json @@ -30,7 +30,8 @@ }, "autoload-dev": { "psr-4": { - "Livewire\\Blaze\\Tests\\": "tests/" + "Livewire\\Blaze\\Tests\\": "tests/", + "Workbench\\App\\": "workbench/app/" } }, "extra": { @@ -49,6 +50,8 @@ "minimum-stability": "dev", "prefer-stable": true, "scripts": { - "test": "vendor/bin/pest" + "test": "vendor/bin/pest", + "benchmark": "vendor/bin/testbench benchmark", + "benchmark:snapshot": "vendor/bin/testbench benchmark --snapshot" } -} +} \ No newline at end of file diff --git a/testbench.yaml b/testbench.yaml new file mode 100644 index 0000000..a28fae2 --- /dev/null +++ b/testbench.yaml @@ -0,0 +1,7 @@ +laravel: '@testbench' + +workbench: + install: true + discovers: + commands: true + views: true diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php new file mode 100644 index 0000000..8518e4a --- /dev/null +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -0,0 +1,417 @@ +iterations = (int) $this->option('iterations'); + $this->rounds = (int) $this->option('rounds'); + $this->warmupRounds = (int) $this->option('warmup'); + + Artisan::call('view:clear'); + + $results = $this->runBenchmarks(); + + if ($this->option('ci')) { + $this->outputMarkdown($results); + } else { + $this->displayResults($results); + } + + if ($this->option('snapshot')) { + $this->saveSnapshot($results); + } + + return Command::SUCCESS; + } + + protected function runBenchmarks(): array + { + $showProgress = ! $this->option('ci'); + $benchmarks = $this->getBenchmarks(); + $names = array_keys($benchmarks); + + if ($showProgress) { + $this->info("Running benchmarks ({$this->iterations} iterations x {$this->rounds} rounds)...\n"); + } + + ob_start(); + + // Warmup: compile views and stabilize CPU/opcache. + if ($showProgress) { + $this->output->write(' Warming up...'); + } + + foreach ($benchmarks as $benchmark) { + for ($w = 0; $w < $this->warmupRounds; $w++) { + $this->measureView($benchmark['blade']); + $this->measureView($benchmark['blaze']); + } + } + + if ($showProgress) { + $this->output->writeln(' done'); + } + + // Initialize per-benchmark storage. + $bladeTimes = array_fill_keys($names, []); + $blazeTimes = array_fill_keys($names, []); + + // Timed rounds: interleave all benchmarks within each round and + // shuffle the order to distribute thermal drift and system noise + // evenly instead of concentrating it on whichever benchmark runs last. + if ($showProgress) { + $this->output->write(' Benchmarking'); + } + + for ($r = 0; $r < $this->rounds; $r++) { + if ($showProgress) { + $this->output->write('.'); + } + + $order = $names; + shuffle($order); + + foreach ($order as $name) { + gc_collect_cycles(); + $bladeTimes[$name][] = $this->measureView($benchmarks[$name]['blade']); + gc_collect_cycles(); + $blazeTimes[$name][] = $this->measureView($benchmarks[$name]['blaze']); + } + } + + if ($showProgress) { + $this->output->writeln(' done'); + } + + ob_end_clean(); + + // Build results using the median of each benchmark's rounds. + $results = []; + + foreach ($names as $name) { + $results[$name] = [ + 'blade_ms' => $this->median($bladeTimes[$name]), + 'blaze_ms' => $this->median($blazeTimes[$name]), + 'blade_times' => $bladeTimes[$name], + 'blaze_times' => $blazeTimes[$name], + ]; + } + + return $results; + } + + // region Display + + protected function buildTable(array $results): array + { + $snapshot = $this->option('snapshot') ? null : $this->loadSnapshot(); + + $headers = ['Benchmark', 'Blade', 'Blaze', 'Improvement']; + $rows = []; + + foreach ($results as $name => $result) { + $blade = $this->formatTime($result['blade_ms']); + $blaze = $this->formatTime($result['blaze_ms']); + $improvement = $this->improvement($result) . '%'; + + if ($snapshot && isset($snapshot['benchmarks'][$name])) { + $prev = $snapshot['benchmarks'][$name]; + + $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms'], $result['blade_times']); + $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms'], $result['blaze_times']); + + $perRoundImprovements = array_map( + fn ($b, $z) => $b > 0 ? (1 - $z / $b) * 100 : 0, + $result['blade_times'], + $result['blaze_times'], + ); + + $improvement .= ' ' . $this->formatChange( + $prev['improvement'], + $this->improvement($result), + $perRoundImprovements, + ); + } + + $rows[] = [$name, $blade, $blaze, $improvement]; + } + + return [$headers, $rows, $snapshot]; + } + + protected function displayResults(array $results): void + { + [$headers, $rows, $snapshot] = $this->buildTable($results); + + $this->newLine(); + $this->table($headers, $rows); + + $this->newLine(); + $this->info("{$this->iterations} iterations x {$this->rounds} rounds per benchmark"); + + if ($snapshot) { + $rounds = $snapshot['rounds'] ?? 1; + $this->comment("Compared against snapshot ({$snapshot['iterations']} iterations x {$rounds} rounds)"); + } + } + + protected function outputMarkdown(array $results): void + { + [$headers, $rows, $snapshot] = $this->buildTable($results); + + // Calculate the max width for each column. + $widths = array_map('mb_strlen', $headers); + + foreach ($rows as $row) { + foreach ($row as $i => $cell) { + $widths[$i] = max($widths[$i], mb_strlen($cell)); + } + } + + $md = "## Benchmark Results\n\n"; + + // Header row. + $md .= '|'; + foreach ($headers as $i => $header) { + $md .= ' ' . str_pad($header, $widths[$i]) . ' |'; + } + $md .= "\n"; + + // Separator row. + $md .= '|'; + foreach ($widths as $width) { + $md .= ' ' . str_repeat('-', $width) . ' |'; + } + $md .= "\n"; + + // Data rows. + foreach ($rows as $row) { + $md .= '|'; + foreach ($row as $i => $cell) { + $md .= ' ' . str_pad($cell, $widths[$i]) . ' |'; + } + $md .= "\n"; + } + + $md .= "\n{$this->iterations} iterations x {$this->rounds} rounds per benchmark"; + + if ($snapshot) { + $md .= " — compared against committed snapshot"; + } + + $md .= ""; + + $this->output->writeln($md); + } + + // endregion + + // region Snapshot + + protected function saveSnapshot(array $results): void + { + $snapshot = [ + 'iterations' => $this->iterations, + 'rounds' => $this->rounds, + 'benchmarks' => [], + ]; + + foreach ($results as $name => $result) { + $snapshot['benchmarks'][$name] = [ + 'blade_ms' => $result['blade_ms'], + 'blaze_ms' => $result['blaze_ms'], + 'improvement' => $this->improvement($result), + ]; + } + + $path = $this->snapshotPath(); + + file_put_contents($path, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + + $this->newLine(); + $this->info("Snapshot saved to {$path}"); + } + + protected function loadSnapshot(): ?array + { + $path = $this->snapshotPath(); + + if (! file_exists($path)) { + return null; + } + + $data = json_decode(file_get_contents($path), true); + + if (! is_array($data) || ! isset($data['benchmarks'])) { + return null; + } + + return $data; + } + + protected function snapshotPath(): string + { + $root = dirname(__DIR__, 4); + + return $this->option('ci') + ? $root . '/.github/benchmark-snapshot.json' + : $root . '/benchmark-snapshot.json'; + } + + // endregion + + // region Helpers + + protected function improvement(array $result): float + { + return $result['blade_ms'] > 0 + ? round((1 - $result['blaze_ms'] / $result['blade_ms']) * 100, 1) + : 0; + } + + protected function median(array $values): float + { + sort($values); + + $count = count($values); + $mid = intdiv($count, 2); + + return $count % 2 === 0 + ? round(($values[$mid - 1] + $values[$mid]) / 2, 2) + : round($values[$mid], 2); + } + + protected function coefficientOfVariation(array $values): float + { + $count = count($values); + + if ($count < 2) { + return 0.0; + } + + $mean = array_sum($values) / $count; + + if ($mean == 0) { + return 0.0; + } + + $variance = array_sum(array_map(fn ($v) => ($v - $mean) ** 2, $values)) / ($count - 1); + + return sqrt($variance) / abs($mean) * 100; + } + + /** + * Format the percentage change between a snapshot value and the current + * median, suppressing changes that fall within the measurement noise. + * + * Returns "(~)" when the change is not statistically significant, or + * "(+X%)" / "(-X%)" when it exceeds the margin of error. + */ + protected function formatChange(float $old, float $new, array $samples): string + { + if ($old == 0) { + return '(~)'; + } + + $pctChange = ($new - $old) / abs($old) * 100; + $cv = $this->coefficientOfVariation($samples); + $n = count($samples); + + // Margin of error for the median at ~95% confidence. + // SE_median ≈ 1.253 × σ/√n, expressed as a percentage via CV. + $margin = 2 * 1.253 * $cv / sqrt(max($n, 1)); + + // Suppress changes within statistical noise or below practical significance. + // The 5% floor accounts for between-run variance (thermal drift, OS scheduling) + // that a single run's within-run CV cannot capture. + if (abs($pctChange) < max($margin, 5.0)) { + return '(~)'; + } + + $sign = $pctChange > 0 ? '+' : ''; + + return '(' . $sign . round($pctChange, 1) . '%)'; + } + + protected function getBenchmarks(): array + { + return [ + 'No attributes' => [ + 'blade' => 'bench.blade.no-attributes', + 'blaze' => 'bench.blaze.no-attributes', + ], + 'Attributes only' => [ + 'blade' => 'bench.blade.attributes', + 'blaze' => 'bench.blaze.attributes', + ], + 'Attributes + merge()' => [ + 'blade' => 'bench.blade.merge', + 'blaze' => 'bench.blaze.merge', + ], + 'Attributes + class()' => [ + 'blade' => 'bench.blade.class', + 'blaze' => 'bench.blaze.class', + ], + 'Props + attributes' => [ + 'blade' => 'bench.blade.props', + 'blaze' => 'bench.blaze.props', + ], + 'Default slot' => [ + 'blade' => 'bench.blade.slot', + 'blaze' => 'bench.blaze.slot', + ], + 'Named slots' => [ + 'blade' => 'bench.blade.named-slots', + 'blaze' => 'bench.blaze.named-slots', + ], + '@aware (nested)' => [ + 'blade' => 'bench.blade.aware', + 'blaze' => 'bench.blaze.aware', + ], + 'Attribute forwarding' => [ + 'blade' => 'bench.blade.forwarding', + 'blaze' => 'bench.blaze.forwarding', + ], + ]; + } + + protected function renderView(string $view): string + { + return View::make($view, ['iterations' => $this->iterations])->render(); + } + + protected function measureView(string $view): float + { + return Benchmark::measure(fn () => $this->renderView($view)); + } + + protected function formatTime(float $ms): string + { + return number_format($ms, 2) . 'ms'; + } + + // endregion +} diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php new file mode 100644 index 0000000..f9ffa76 --- /dev/null +++ b/workbench/app/Models/User.php @@ -0,0 +1,48 @@ + */ + use HasFactory, Notifiable; + + /** + * The attributes that are mass assignable. + * + * @var list + */ + protected $fillable = [ + 'name', + 'email', + 'password', + ]; + + /** + * The attributes that should be hidden for serialization. + * + * @var list + */ + protected $hidden = [ + 'password', + 'remember_token', + ]; + + /** + * Get the attributes that should be cast. + * + * @return array + */ + protected function casts(): array + { + return [ + 'email_verified_at' => 'datetime', + 'password' => 'hashed', + ]; + } +} diff --git a/workbench/app/Providers/WorkbenchServiceProvider.php b/workbench/app/Providers/WorkbenchServiceProvider.php new file mode 100644 index 0000000..e8cec9c --- /dev/null +++ b/workbench/app/Providers/WorkbenchServiceProvider.php @@ -0,0 +1,24 @@ + +@endfor diff --git a/workbench/resources/views/bench/blade/aware.blade.php b/workbench/resources/views/bench/blade/aware.blade.php new file mode 100644 index 0000000..987cd5c --- /dev/null +++ b/workbench/resources/views/bench/blade/aware.blade.php @@ -0,0 +1,6 @@ +@for ($i = 0; $i < $iterations; $i++) + + Item 1 + Item 2 + +@endfor diff --git a/workbench/resources/views/bench/blade/class.blade.php b/workbench/resources/views/bench/blade/class.blade.php new file mode 100644 index 0000000..9414ed3 --- /dev/null +++ b/workbench/resources/views/bench/blade/class.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blade/forwarding.blade.php b/workbench/resources/views/bench/blade/forwarding.blade.php new file mode 100644 index 0000000..9a3fe8c --- /dev/null +++ b/workbench/resources/views/bench/blade/forwarding.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + Click +@endfor diff --git a/workbench/resources/views/bench/blade/merge.blade.php b/workbench/resources/views/bench/blade/merge.blade.php new file mode 100644 index 0000000..5ddd2a5 --- /dev/null +++ b/workbench/resources/views/bench/blade/merge.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blade/named-slots.blade.php b/workbench/resources/views/bench/blade/named-slots.blade.php new file mode 100644 index 0000000..d46eee0 --- /dev/null +++ b/workbench/resources/views/bench/blade/named-slots.blade.php @@ -0,0 +1,7 @@ +@for ($i = 0; $i < $iterations; $i++) + + Header + Card content + Footer + +@endfor diff --git a/workbench/resources/views/bench/blade/no-attributes.blade.php b/workbench/resources/views/bench/blade/no-attributes.blade.php new file mode 100644 index 0000000..27491a8 --- /dev/null +++ b/workbench/resources/views/bench/blade/no-attributes.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blade/props.blade.php b/workbench/resources/views/bench/blade/props.blade.php new file mode 100644 index 0000000..caaad07 --- /dev/null +++ b/workbench/resources/views/bench/blade/props.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blade/slot.blade.php b/workbench/resources/views/bench/blade/slot.blade.php new file mode 100644 index 0000000..1aef101 --- /dev/null +++ b/workbench/resources/views/bench/blade/slot.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + Card content +@endfor diff --git a/workbench/resources/views/bench/blaze/attributes.blade.php b/workbench/resources/views/bench/blaze/attributes.blade.php new file mode 100644 index 0000000..1518894 --- /dev/null +++ b/workbench/resources/views/bench/blaze/attributes.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blaze/aware.blade.php b/workbench/resources/views/bench/blaze/aware.blade.php new file mode 100644 index 0000000..12c10a2 --- /dev/null +++ b/workbench/resources/views/bench/blaze/aware.blade.php @@ -0,0 +1,6 @@ +@for ($i = 0; $i < $iterations; $i++) + + Item 1 + Item 2 + +@endfor diff --git a/workbench/resources/views/bench/blaze/class.blade.php b/workbench/resources/views/bench/blaze/class.blade.php new file mode 100644 index 0000000..62e06d5 --- /dev/null +++ b/workbench/resources/views/bench/blaze/class.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blaze/forwarding.blade.php b/workbench/resources/views/bench/blaze/forwarding.blade.php new file mode 100644 index 0000000..63ec925 --- /dev/null +++ b/workbench/resources/views/bench/blaze/forwarding.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + Click +@endfor diff --git a/workbench/resources/views/bench/blaze/merge.blade.php b/workbench/resources/views/bench/blaze/merge.blade.php new file mode 100644 index 0000000..4fd9e9d --- /dev/null +++ b/workbench/resources/views/bench/blaze/merge.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blaze/named-slots.blade.php b/workbench/resources/views/bench/blaze/named-slots.blade.php new file mode 100644 index 0000000..461b0e1 --- /dev/null +++ b/workbench/resources/views/bench/blaze/named-slots.blade.php @@ -0,0 +1,7 @@ +@for ($i = 0; $i < $iterations; $i++) + + Header + Card content + Footer + +@endfor diff --git a/workbench/resources/views/bench/blaze/no-attributes.blade.php b/workbench/resources/views/bench/blaze/no-attributes.blade.php new file mode 100644 index 0000000..58d01f2 --- /dev/null +++ b/workbench/resources/views/bench/blaze/no-attributes.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blaze/props.blade.php b/workbench/resources/views/bench/blaze/props.blade.php new file mode 100644 index 0000000..c94f7dc --- /dev/null +++ b/workbench/resources/views/bench/blaze/props.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + +@endfor diff --git a/workbench/resources/views/bench/blaze/slot.blade.php b/workbench/resources/views/bench/blaze/slot.blade.php new file mode 100644 index 0000000..1eb197b --- /dev/null +++ b/workbench/resources/views/bench/blaze/slot.blade.php @@ -0,0 +1,3 @@ +@for ($i = 0; $i < $iterations; $i++) + Card content +@endfor diff --git a/workbench/resources/views/components/bench/blade/button-class.blade.php b/workbench/resources/views/components/bench/blade/button-class.blade.php new file mode 100644 index 0000000..cf310e6 --- /dev/null +++ b/workbench/resources/views/components/bench/blade/button-class.blade.php @@ -0,0 +1 @@ + diff --git a/workbench/resources/views/components/bench/blade/button-inner.blade.php b/workbench/resources/views/components/bench/blade/button-inner.blade.php new file mode 100644 index 0000000..2a0e5e9 --- /dev/null +++ b/workbench/resources/views/components/bench/blade/button-inner.blade.php @@ -0,0 +1,3 @@ +@props(['type' => 'submit']) + + diff --git a/workbench/resources/views/components/bench/blade/button-merge.blade.php b/workbench/resources/views/components/bench/blade/button-merge.blade.php new file mode 100644 index 0000000..25b156d --- /dev/null +++ b/workbench/resources/views/components/bench/blade/button-merge.blade.php @@ -0,0 +1 @@ + diff --git a/workbench/resources/views/components/bench/blade/button-no-attributes.blade.php b/workbench/resources/views/components/bench/blade/button-no-attributes.blade.php new file mode 100644 index 0000000..87957a9 --- /dev/null +++ b/workbench/resources/views/components/bench/blade/button-no-attributes.blade.php @@ -0,0 +1 @@ + diff --git a/workbench/resources/views/components/bench/blade/button-outer.blade.php b/workbench/resources/views/components/bench/blade/button-outer.blade.php new file mode 100644 index 0000000..c522c6e --- /dev/null +++ b/workbench/resources/views/components/bench/blade/button-outer.blade.php @@ -0,0 +1 @@ + diff --git a/workbench/resources/views/components/bench/blade/button-props.blade.php b/workbench/resources/views/components/bench/blade/button-props.blade.php new file mode 100644 index 0000000..f9d933d --- /dev/null +++ b/workbench/resources/views/components/bench/blade/button-props.blade.php @@ -0,0 +1,3 @@ +@props(['type' => 'button', 'variant' => 'primary']) + + diff --git a/workbench/resources/views/components/bench/blade/button.blade.php b/workbench/resources/views/components/bench/blade/button.blade.php new file mode 100644 index 0000000..59c114b --- /dev/null +++ b/workbench/resources/views/components/bench/blade/button.blade.php @@ -0,0 +1 @@ + diff --git a/workbench/resources/views/components/bench/blade/card-full.blade.php b/workbench/resources/views/components/bench/blade/card-full.blade.php new file mode 100644 index 0000000..85adb5c --- /dev/null +++ b/workbench/resources/views/components/bench/blade/card-full.blade.php @@ -0,0 +1,9 @@ +
+ @if(isset($header)) +
{{ $header }}
+ @endif +
{{ $slot }}
+ @if(isset($footer)) + + @endif +
diff --git a/workbench/resources/views/components/bench/blade/card.blade.php b/workbench/resources/views/components/bench/blade/card.blade.php new file mode 100644 index 0000000..b89a9b5 --- /dev/null +++ b/workbench/resources/views/components/bench/blade/card.blade.php @@ -0,0 +1,3 @@ +
+
{{ $slot }}
+
diff --git a/workbench/resources/views/components/bench/blade/menu-item.blade.php b/workbench/resources/views/components/bench/blade/menu-item.blade.php new file mode 100644 index 0000000..9e195e0 --- /dev/null +++ b/workbench/resources/views/components/bench/blade/menu-item.blade.php @@ -0,0 +1,3 @@ +@aware(['color' => 'gray']) + +
  • {{ $slot }}
  • diff --git a/workbench/resources/views/components/bench/blade/menu.blade.php b/workbench/resources/views/components/bench/blade/menu.blade.php new file mode 100644 index 0000000..a0c3204 --- /dev/null +++ b/workbench/resources/views/components/bench/blade/menu.blade.php @@ -0,0 +1,5 @@ +@props(['color' => 'gray']) + +
      + {{ $slot }} +
    diff --git a/workbench/resources/views/components/bench/blaze/button-class.blade.php b/workbench/resources/views/components/bench/blaze/button-class.blade.php new file mode 100644 index 0000000..518e140 --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/button-class.blade.php @@ -0,0 +1,3 @@ +@blaze + + diff --git a/workbench/resources/views/components/bench/blaze/button-inner.blade.php b/workbench/resources/views/components/bench/blaze/button-inner.blade.php new file mode 100644 index 0000000..92346c1 --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/button-inner.blade.php @@ -0,0 +1,4 @@ +@blaze +@props(['type' => 'submit']) + + diff --git a/workbench/resources/views/components/bench/blaze/button-merge.blade.php b/workbench/resources/views/components/bench/blaze/button-merge.blade.php new file mode 100644 index 0000000..7e4ac1b --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/button-merge.blade.php @@ -0,0 +1,3 @@ +@blaze + + diff --git a/workbench/resources/views/components/bench/blaze/button-no-attributes.blade.php b/workbench/resources/views/components/bench/blaze/button-no-attributes.blade.php new file mode 100644 index 0000000..cb8ec15 --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/button-no-attributes.blade.php @@ -0,0 +1,3 @@ +@blaze + + diff --git a/workbench/resources/views/components/bench/blaze/button-outer.blade.php b/workbench/resources/views/components/bench/blaze/button-outer.blade.php new file mode 100644 index 0000000..4ffd1f6 --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/button-outer.blade.php @@ -0,0 +1,3 @@ +@blaze + + diff --git a/workbench/resources/views/components/bench/blaze/button-props.blade.php b/workbench/resources/views/components/bench/blaze/button-props.blade.php new file mode 100644 index 0000000..f1032f8 --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/button-props.blade.php @@ -0,0 +1,4 @@ +@blaze +@props(['type' => 'button', 'variant' => 'primary']) + + diff --git a/workbench/resources/views/components/bench/blaze/button.blade.php b/workbench/resources/views/components/bench/blaze/button.blade.php new file mode 100644 index 0000000..1cd7f5e --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/button.blade.php @@ -0,0 +1,3 @@ +@blaze + + diff --git a/workbench/resources/views/components/bench/blaze/card-full.blade.php b/workbench/resources/views/components/bench/blaze/card-full.blade.php new file mode 100644 index 0000000..bd69da1 --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/card-full.blade.php @@ -0,0 +1,11 @@ +@blaze + +
    + @if(isset($header)) +
    {{ $header }}
    + @endif +
    {{ $slot }}
    + @if(isset($footer)) + + @endif +
    diff --git a/workbench/resources/views/components/bench/blaze/card.blade.php b/workbench/resources/views/components/bench/blaze/card.blade.php new file mode 100644 index 0000000..6d5de21 --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/card.blade.php @@ -0,0 +1,5 @@ +@blaze + +
    +
    {{ $slot }}
    +
    diff --git a/workbench/resources/views/components/bench/blaze/menu-item.blade.php b/workbench/resources/views/components/bench/blaze/menu-item.blade.php new file mode 100644 index 0000000..311e18c --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/menu-item.blade.php @@ -0,0 +1,4 @@ +@blaze +@aware(['color' => 'gray']) + +
  • {{ $slot }}
  • diff --git a/workbench/resources/views/components/bench/blaze/menu.blade.php b/workbench/resources/views/components/bench/blaze/menu.blade.php new file mode 100644 index 0000000..461a36b --- /dev/null +++ b/workbench/resources/views/components/bench/blaze/menu.blade.php @@ -0,0 +1,6 @@ +@blaze +@props(['color' => 'gray']) + +
      + {{ $slot }} +
    From c09c23c7ad850fe784fa1163e5f958f3f95bc7f2 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:18:27 +0100 Subject: [PATCH 02/14] Update thresholds --- workbench/app/Console/Commands/BenchmarkCommand.php | 11 +++++------ 1 file changed, 5 insertions(+), 6 deletions(-) diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php index 8518e4a..a369f8b 100644 --- a/workbench/app/Console/Commands/BenchmarkCommand.php +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -140,8 +140,8 @@ protected function buildTable(array $results): array if ($snapshot && isset($snapshot['benchmarks'][$name])) { $prev = $snapshot['benchmarks'][$name]; - $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms'], $result['blade_times']); - $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms'], $result['blaze_times']); + $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms'], $result['blade_times'], 10.0); + $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms'], $result['blaze_times'], 5.0); $perRoundImprovements = array_map( fn ($b, $z) => $b > 0 ? (1 - $z / $b) * 100 : 0, @@ -153,6 +153,7 @@ protected function buildTable(array $results): array $prev['improvement'], $this->improvement($result), $perRoundImprovements, + 0.5, ); } @@ -330,7 +331,7 @@ protected function coefficientOfVariation(array $values): float * Returns "(~)" when the change is not statistically significant, or * "(+X%)" / "(-X%)" when it exceeds the margin of error. */ - protected function formatChange(float $old, float $new, array $samples): string + protected function formatChange(float $old, float $new, array $samples, float $minThreshold = 5.0): string { if ($old == 0) { return '(~)'; @@ -345,9 +346,7 @@ protected function formatChange(float $old, float $new, array $samples): string $margin = 2 * 1.253 * $cv / sqrt(max($n, 1)); // Suppress changes within statistical noise or below practical significance. - // The 5% floor accounts for between-run variance (thermal drift, OS scheduling) - // that a single run's within-run CV cannot capture. - if (abs($pctChange) < max($margin, 5.0)) { + if (abs($pctChange) < max($margin, $minThreshold)) { return '(~)'; } From 27b1aaf44430132aa5043bf814c49cdff218014d Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:22:07 +0100 Subject: [PATCH 03/14] Update rounds --- workbench/app/Console/Commands/BenchmarkCommand.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php index a369f8b..5daafa9 100644 --- a/workbench/app/Console/Commands/BenchmarkCommand.php +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -11,7 +11,7 @@ class BenchmarkCommand extends Command { protected $signature = 'benchmark {--iterations=10000 : Number of component renders per benchmark} - {--rounds=25 : Number of timed rounds per benchmark} + {--rounds=10 : Number of timed rounds per benchmark} {--warmup=2 : Number of untimed warmup rounds} {--snapshot : Save results as the baseline snapshot} {--ci : Output a markdown table with no progress (for CI)}'; From 174b3112df450e5f3daa40f6847ffccc596d5df9 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:22:31 +0100 Subject: [PATCH 04/14] Update benchmark.yml --- .github/workflows/benchmark.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 0d97b42..8ba3c4a 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -37,7 +37,7 @@ jobs: vendor/bin/testbench benchmark --snapshot --ci | grep -v '^## ' >> benchmark-comment.md echo "" >> benchmark-comment.md - for i in 1 2 3 4 5; do + for i in 1 2 3; do echo "### Run $i" >> benchmark-comment.md echo "" >> benchmark-comment.md vendor/bin/testbench benchmark --ci | grep -v '^## ' >> benchmark-comment.md From b0e6deca8b2ae77bec7910d8bd0c1c98448edd75 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:25:35 +0100 Subject: [PATCH 05/14] Update filters --- .github/workflows/benchmark.yml | 2 +- .github/workflows/tests.yml | 10 +++++++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index 8ba3c4a..b9c18e2 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -2,7 +2,7 @@ name: Benchmark on: pull_request: - branches: [ main ] + branches: [main] permissions: contents: read diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e4d51f6..a4ac5dc 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -1,10 +1,14 @@ name: Tests on: - push: - branches: [ "**" ] pull_request: - branches: [ "**" ] + types: [ready_for_review, synchronize, opened] + paths-ignore: + - "README.md" + push: + branches: [main] + paths-ignore: + - "README.md" jobs: pest: From 6e28adaac604508aff687c3425d7788411461059 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:40:16 +0100 Subject: [PATCH 06/14] Refactor --- .../app/Console/Commands/BenchmarkCommand.php | 255 +++++------------- 1 file changed, 72 insertions(+), 183 deletions(-) diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php index 5daafa9..b4e45a3 100644 --- a/workbench/app/Console/Commands/BenchmarkCommand.php +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -5,7 +5,9 @@ use Illuminate\Console\Command; use Illuminate\Support\Benchmark; use Illuminate\Support\Facades\Artisan; +use Illuminate\Support\Facades\File; use Illuminate\Support\Facades\View; +use Illuminate\Support\Str; class BenchmarkCommand extends Command { @@ -34,11 +36,9 @@ public function handle(): int $results = $this->runBenchmarks(); - if ($this->option('ci')) { - $this->outputMarkdown($results); - } else { - $this->displayResults($results); - } + $this->option('ci') + ? $this->outputMarkdown($results) + : $this->displayResults($results); if ($this->option('snapshot')) { $this->saveSnapshot($results); @@ -54,73 +54,51 @@ protected function runBenchmarks(): array $names = array_keys($benchmarks); if ($showProgress) { - $this->info("Running benchmarks ({$this->iterations} iterations x {$this->rounds} rounds)...\n"); + $this->info("Running benchmarks ({$this->iterations} iterations x {$this->rounds} rounds)..."); + $this->newLine(); } - ob_start(); - - // Warmup: compile views and stabilize CPU/opcache. - if ($showProgress) { - $this->output->write(' Warming up...'); - } + $totalSteps = (count($benchmarks) * $this->warmupRounds) + ($this->rounds * count($benchmarks)); + $bar = $showProgress ? $this->output->createProgressBar($totalSteps) : null; + $bar?->setFormat(' %current%/%max% [%bar%] %message%'); + $bar?->setMessage('Warming up...'); + $bar?->start(); + // Warmup: compile views and stabilize opcache. foreach ($benchmarks as $benchmark) { for ($w = 0; $w < $this->warmupRounds; $w++) { $this->measureView($benchmark['blade']); $this->measureView($benchmark['blaze']); + $bar?->advance(); } } - if ($showProgress) { - $this->output->writeln(' done'); - } - - // Initialize per-benchmark storage. $bladeTimes = array_fill_keys($names, []); $blazeTimes = array_fill_keys($names, []); - // Timed rounds: interleave all benchmarks within each round and - // shuffle the order to distribute thermal drift and system noise - // evenly instead of concentrating it on whichever benchmark runs last. - if ($showProgress) { - $this->output->write(' Benchmarking'); - } + $bar?->setMessage('Benchmarking...'); for ($r = 0; $r < $this->rounds; $r++) { - if ($showProgress) { - $this->output->write('.'); - } - - $order = $names; - shuffle($order); - - foreach ($order as $name) { - gc_collect_cycles(); - $bladeTimes[$name][] = $this->measureView($benchmarks[$name]['blade']); - gc_collect_cycles(); - $blazeTimes[$name][] = $this->measureView($benchmarks[$name]['blaze']); + foreach ($benchmarks as $name => $benchmark) { + $bladeTimes[$name][] = $this->measureView($benchmark['blade']); + $blazeTimes[$name][] = $this->measureView($benchmark['blaze']); + $bar?->advance(); } } - if ($showProgress) { - $this->output->writeln(' done'); - } - - ob_end_clean(); - - // Build results using the median of each benchmark's rounds. - $results = []; + $bar?->setMessage('Done!'); + $bar?->finish(); - foreach ($names as $name) { - $results[$name] = [ - 'blade_ms' => $this->median($bladeTimes[$name]), - 'blaze_ms' => $this->median($blazeTimes[$name]), - 'blade_times' => $bladeTimes[$name], - 'blaze_times' => $blazeTimes[$name], - ]; + if ($showProgress) { + $this->newLine(2); } - return $results; + return collect($names)->mapWithKeys(fn ($name) => [ + $name => [ + 'blade_ms' => round(collect($bladeTimes[$name])->median(), 2), + 'blaze_ms' => round(collect($blazeTimes[$name])->median(), 2), + ], + ])->all(); } // region Display @@ -130,35 +108,20 @@ protected function buildTable(array $results): array $snapshot = $this->option('snapshot') ? null : $this->loadSnapshot(); $headers = ['Benchmark', 'Blade', 'Blaze', 'Improvement']; - $rows = []; - foreach ($results as $name => $result) { + $rows = collect($results)->map(function ($result, $name) use ($snapshot) { $blade = $this->formatTime($result['blade_ms']); $blaze = $this->formatTime($result['blaze_ms']); $improvement = $this->improvement($result) . '%'; - if ($snapshot && isset($snapshot['benchmarks'][$name])) { - $prev = $snapshot['benchmarks'][$name]; - - $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms'], $result['blade_times'], 10.0); - $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms'], $result['blaze_times'], 5.0); - - $perRoundImprovements = array_map( - fn ($b, $z) => $b > 0 ? (1 - $z / $b) * 100 : 0, - $result['blade_times'], - $result['blaze_times'], - ); - - $improvement .= ' ' . $this->formatChange( - $prev['improvement'], - $this->improvement($result), - $perRoundImprovements, - 0.5, - ); + if ($prev = $snapshot['benchmarks'][$name] ?? null) { + $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms']); + $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms']); + $improvement .= ' ' . $this->formatChange($prev['improvement'], $this->improvement($result)); } - $rows[] = [$name, $blade, $blaze, $improvement]; - } + return [$name, $blade, $blaze, $improvement]; + })->values()->all(); return [$headers, $rows, $snapshot]; } @@ -183,47 +146,28 @@ protected function outputMarkdown(array $results): void { [$headers, $rows, $snapshot] = $this->buildTable($results); - // Calculate the max width for each column. - $widths = array_map('mb_strlen', $headers); - - foreach ($rows as $row) { - foreach ($row as $i => $cell) { - $widths[$i] = max($widths[$i], mb_strlen($cell)); - } - } - - $md = "## Benchmark Results\n\n"; - - // Header row. - $md .= '|'; - foreach ($headers as $i => $header) { - $md .= ' ' . str_pad($header, $widths[$i]) . ' |'; - } - $md .= "\n"; - - // Separator row. - $md .= '|'; - foreach ($widths as $width) { - $md .= ' ' . str_repeat('-', $width) . ' |'; - } - $md .= "\n"; - - // Data rows. - foreach ($rows as $row) { - $md .= '|'; - foreach ($row as $i => $cell) { - $md .= ' ' . str_pad($cell, $widths[$i]) . ' |'; - } - $md .= "\n"; - } - - $md .= "\n{$this->iterations} iterations x {$this->rounds} rounds per benchmark"; - - if ($snapshot) { - $md .= " — compared against committed snapshot"; - } - - $md .= ""; + $allRows = collect([$headers, ...$rows]); + $widths = collect($headers)->keys()->map( + fn ($i) => $allRows->max(fn ($row) => mb_strlen($row[$i])) + ); + + $formatRow = fn ($cells) => '| ' . collect($cells) + ->map(fn ($cell, $i) => Str::padRight($cell, $widths[$i])) + ->implode(' | ') . ' |'; + + $separator = '| ' . $widths->map(fn ($w) => str_repeat('-', $w))->implode(' | ') . ' |'; + + $md = collect([ + '## Benchmark Results', + '', + $formatRow($headers), + $separator, + ...collect($rows)->map($formatRow), + '', + '' . "{$this->iterations} iterations x {$this->rounds} rounds per benchmark" + . ($snapshot ? ' — compared against committed snapshot' : '') + . '', + ])->implode("\n"); $this->output->writeln($md); } @@ -237,20 +181,16 @@ protected function saveSnapshot(array $results): void $snapshot = [ 'iterations' => $this->iterations, 'rounds' => $this->rounds, - 'benchmarks' => [], - ]; - - foreach ($results as $name => $result) { - $snapshot['benchmarks'][$name] = [ + 'benchmarks' => collect($results)->map(fn ($result) => [ 'blade_ms' => $result['blade_ms'], 'blaze_ms' => $result['blaze_ms'], 'improvement' => $this->improvement($result), - ]; - } + ])->all(), + ]; $path = $this->snapshotPath(); - file_put_contents($path, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); + File::put($path, json_encode($snapshot, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES) . "\n"); $this->newLine(); $this->info("Snapshot saved to {$path}"); @@ -260,26 +200,20 @@ protected function loadSnapshot(): ?array { $path = $this->snapshotPath(); - if (! file_exists($path)) { + if (! File::exists($path)) { return null; } - $data = json_decode(file_get_contents($path), true); - - if (! is_array($data) || ! isset($data['benchmarks'])) { - return null; - } + $data = File::json($path); - return $data; + return is_array($data) && isset($data['benchmarks']) ? $data : null; } protected function snapshotPath(): string { - $root = dirname(__DIR__, 4); - return $this->option('ci') - ? $root . '/.github/benchmark-snapshot.json' - : $root . '/benchmark-snapshot.json'; + ? base_path('.github/benchmark-snapshot.json') + : base_path('benchmark-snapshot.json'); } // endregion @@ -293,66 +227,21 @@ protected function improvement(array $result): float : 0; } - protected function median(array $values): float - { - sort($values); - - $count = count($values); - $mid = intdiv($count, 2); - - return $count % 2 === 0 - ? round(($values[$mid - 1] + $values[$mid]) / 2, 2) - : round($values[$mid], 2); - } - - protected function coefficientOfVariation(array $values): float - { - $count = count($values); - - if ($count < 2) { - return 0.0; - } - - $mean = array_sum($values) / $count; - - if ($mean == 0) { - return 0.0; - } - - $variance = array_sum(array_map(fn ($v) => ($v - $mean) ** 2, $values)) / ($count - 1); - - return sqrt($variance) / abs($mean) * 100; - } - - /** - * Format the percentage change between a snapshot value and the current - * median, suppressing changes that fall within the measurement noise. - * - * Returns "(~)" when the change is not statistically significant, or - * "(+X%)" / "(-X%)" when it exceeds the margin of error. - */ - protected function formatChange(float $old, float $new, array $samples, float $minThreshold = 5.0): string + protected function formatChange(float $old, float $new, float $threshold = 5.0): string { if ($old == 0) { return '(~)'; } - $pctChange = ($new - $old) / abs($old) * 100; - $cv = $this->coefficientOfVariation($samples); - $n = count($samples); - - // Margin of error for the median at ~95% confidence. - // SE_median ≈ 1.253 × σ/√n, expressed as a percentage via CV. - $margin = 2 * 1.253 * $cv / sqrt(max($n, 1)); + $change = ($new - $old) / abs($old) * 100; - // Suppress changes within statistical noise or below practical significance. - if (abs($pctChange) < max($margin, $minThreshold)) { + if (abs($change) < $threshold) { return '(~)'; } - $sign = $pctChange > 0 ? '+' : ''; + $sign = $change > 0 ? '+' : ''; - return '(' . $sign . round($pctChange, 1) . '%)'; + return "({$sign}" . round($change, 1) . '%)'; } protected function getBenchmarks(): array From 3648156adcb1141e81b972ad79426569f2077aab Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:44:29 +0100 Subject: [PATCH 07/14] Update thresholds --- workbench/app/Console/Commands/BenchmarkCommand.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php index b4e45a3..574cdb9 100644 --- a/workbench/app/Console/Commands/BenchmarkCommand.php +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -115,9 +115,9 @@ protected function buildTable(array $results): array $improvement = $this->improvement($result) . '%'; if ($prev = $snapshot['benchmarks'][$name] ?? null) { - $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms']); - $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms']); - $improvement .= ' ' . $this->formatChange($prev['improvement'], $this->improvement($result)); + $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms'], 5.0); + $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms'], 10.0); + $improvement .= ' ' . $this->formatChange($prev['improvement'], $this->improvement($result), 1.0); } return [$name, $blade, $blaze, $improvement]; @@ -227,7 +227,7 @@ protected function improvement(array $result): float : 0; } - protected function formatChange(float $old, float $new, float $threshold = 5.0): string + protected function formatChange(float $old, float $new, float $threshold): string { if ($old == 0) { return '(~)'; From c5bd9167886079c9df075ce04afbebe61f99370e Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:48:42 +0100 Subject: [PATCH 08/14] Fix path --- workbench/app/Console/Commands/BenchmarkCommand.php | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php index 574cdb9..1f26ec8 100644 --- a/workbench/app/Console/Commands/BenchmarkCommand.php +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -211,9 +211,11 @@ protected function loadSnapshot(): ?array protected function snapshotPath(): string { + $root = dirname(__DIR__, 4); + return $this->option('ci') - ? base_path('.github/benchmark-snapshot.json') - : base_path('benchmark-snapshot.json'); + ? $root . '/.github/benchmark-snapshot.json' + : $root . '/benchmark-snapshot.json'; } // endregion From 2aa8a20b6976d706644338306508a60694a73988 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 00:53:09 +0100 Subject: [PATCH 09/14] Update BenchmarkCommand.php --- workbench/app/Console/Commands/BenchmarkCommand.php | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php index 1f26ec8..b48f698 100644 --- a/workbench/app/Console/Commands/BenchmarkCommand.php +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -13,7 +13,7 @@ class BenchmarkCommand extends Command { protected $signature = 'benchmark {--iterations=10000 : Number of component renders per benchmark} - {--rounds=10 : Number of timed rounds per benchmark} + {--rounds=5 : Number of timed rounds per benchmark} {--warmup=2 : Number of untimed warmup rounds} {--snapshot : Save results as the baseline snapshot} {--ci : Output a markdown table with no progress (for CI)}'; @@ -115,8 +115,8 @@ protected function buildTable(array $results): array $improvement = $this->improvement($result) . '%'; if ($prev = $snapshot['benchmarks'][$name] ?? null) { - $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms'], 5.0); - $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms'], 10.0); + $blade .= ' ' . $this->formatChange($prev['blade_ms'], $result['blade_ms'], 10.0); + $blaze .= ' ' . $this->formatChange($prev['blaze_ms'], $result['blaze_ms'], 5.0); $improvement .= ' ' . $this->formatChange($prev['improvement'], $this->improvement($result), 1.0); } @@ -277,7 +277,7 @@ protected function getBenchmarks(): array 'blade' => 'bench.blade.named-slots', 'blaze' => 'bench.blaze.named-slots', ], - '@aware (nested)' => [ + '`@aware` (nested)' => [ 'blade' => 'bench.blade.aware', 'blaze' => 'bench.blaze.aware', ], From 870fca000c3b2c425f8137bb9d9569f1e6f11672 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 01:08:34 +0100 Subject: [PATCH 10/14] Finalize --- .github/workflows/benchmark.yml | 18 ++----------- workbench/app/Models/User.php | 48 --------------------------------- 2 files changed, 2 insertions(+), 64 deletions(-) delete mode 100644 workbench/app/Models/User.php diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index b9c18e2..e1c2e0a 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -27,22 +27,8 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-progress --no-interaction - - name: Run benchmarks - run: | - echo "## Benchmark Results" > benchmark-comment.md - echo "" >> benchmark-comment.md - - echo "### Baseline" >> benchmark-comment.md - echo "" >> benchmark-comment.md - vendor/bin/testbench benchmark --snapshot --ci | grep -v '^## ' >> benchmark-comment.md - echo "" >> benchmark-comment.md - - for i in 1 2 3; do - echo "### Run $i" >> benchmark-comment.md - echo "" >> benchmark-comment.md - vendor/bin/testbench benchmark --ci | grep -v '^## ' >> benchmark-comment.md - echo "" >> benchmark-comment.md - done + - name: Run benchmark + run: vendor/bin/testbench benchmark --ci > benchmark-comment.md - name: Find existing comment uses: peter-evans/find-comment@v3 diff --git a/workbench/app/Models/User.php b/workbench/app/Models/User.php deleted file mode 100644 index f9ffa76..0000000 --- a/workbench/app/Models/User.php +++ /dev/null @@ -1,48 +0,0 @@ - */ - use HasFactory, Notifiable; - - /** - * The attributes that are mass assignable. - * - * @var list - */ - protected $fillable = [ - 'name', - 'email', - 'password', - ]; - - /** - * The attributes that should be hidden for serialization. - * - * @var list - */ - protected $hidden = [ - 'password', - 'remember_token', - ]; - - /** - * Get the attributes that should be cast. - * - * @return array - */ - protected function casts(): array - { - return [ - 'email_verified_at' => 'datetime', - 'password' => 'hashed', - ]; - } -} From 9947423de851fb89734e98b58638848777be5933 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 01:11:36 +0100 Subject: [PATCH 11/14] Update benchmark.yml --- .github/workflows/benchmark.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml index e1c2e0a..d86ad18 100644 --- a/.github/workflows/benchmark.yml +++ b/.github/workflows/benchmark.yml @@ -2,7 +2,9 @@ name: Benchmark on: pull_request: - branches: [main] + types: [ready_for_review, synchronize, opened] + paths-ignore: + - "README.md" permissions: contents: read From 0ac8c3f64938fb2a1b5a94039f3f0d1657ec7b5f Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 01:12:11 +0100 Subject: [PATCH 12/14] Refactor --- workbench/app/Console/Commands/BenchmarkCommand.php | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php index b48f698..69fc495 100644 --- a/workbench/app/Console/Commands/BenchmarkCommand.php +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -101,8 +101,6 @@ protected function runBenchmarks(): array ])->all(); } - // region Display - protected function buildTable(array $results): array { $snapshot = $this->option('snapshot') ? null : $this->loadSnapshot(); @@ -172,10 +170,6 @@ protected function outputMarkdown(array $results): void $this->output->writeln($md); } - // endregion - - // region Snapshot - protected function saveSnapshot(array $results): void { $snapshot = [ @@ -218,10 +212,6 @@ protected function snapshotPath(): string : $root . '/benchmark-snapshot.json'; } - // endregion - - // region Helpers - protected function improvement(array $result): float { return $result['blade_ms'] > 0 @@ -302,6 +292,4 @@ protected function formatTime(float $ms): string { return number_format($ms, 2) . 'ms'; } - - // endregion } From be82b2e493a0a6a0bca1b468f4354ad1845cc14e Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 01:15:16 +0100 Subject: [PATCH 13/14] Update workflows --- .github/workflows/benchmark-snapshot.yml | 40 ------- .github/workflows/benchmark.yml | 49 --------- .github/workflows/ci.yml | 132 +++++++++++++++++++++++ .github/workflows/tests.yml | 55 ---------- 4 files changed, 132 insertions(+), 144 deletions(-) delete mode 100644 .github/workflows/benchmark-snapshot.yml delete mode 100644 .github/workflows/benchmark.yml create mode 100644 .github/workflows/ci.yml delete mode 100644 .github/workflows/tests.yml diff --git a/.github/workflows/benchmark-snapshot.yml b/.github/workflows/benchmark-snapshot.yml deleted file mode 100644 index e646dab..0000000 --- a/.github/workflows/benchmark-snapshot.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Benchmark Snapshot - -on: - push: - branches: [ main ] - -permissions: - contents: write - -jobs: - snapshot: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - with: - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - tools: composer:v2 - coverage: none - extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer - - - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-interaction - - - name: Generate snapshot - run: vendor/bin/testbench benchmark --snapshot --ci - - - name: Commit snapshot - run: | - git config user.name "github-actions[bot]" - git config user.email "github-actions[bot]@users.noreply.github.com" - git add .github/benchmark-snapshot.json - git diff --staged --quiet || git commit -m "Update benchmark snapshot" - git push diff --git a/.github/workflows/benchmark.yml b/.github/workflows/benchmark.yml deleted file mode 100644 index d86ad18..0000000 --- a/.github/workflows/benchmark.yml +++ /dev/null @@ -1,49 +0,0 @@ -name: Benchmark - -on: - pull_request: - types: [ready_for_review, synchronize, opened] - paths-ignore: - - "README.md" - -permissions: - contents: read - pull-requests: write - -jobs: - benchmark: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: '8.4' - tools: composer:v2 - coverage: none - extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer - - - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-interaction - - - name: Run benchmark - run: vendor/bin/testbench benchmark --ci > benchmark-comment.md - - - name: Find existing comment - uses: peter-evans/find-comment@v3 - id: find - with: - issue-number: ${{ github.event.pull_request.number }} - comment-author: 'github-actions[bot]' - body-includes: '## Benchmark Results' - - - name: Post or update comment - uses: peter-evans/create-or-update-comment@v4 - with: - issue-number: ${{ github.event.pull_request.number }} - comment-id: ${{ steps.find.outputs.comment-id }} - edit-mode: replace - body-path: benchmark-comment.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..1170f0d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,132 @@ +name: CI + +on: + pull_request: + types: [ready_for_review, synchronize, opened] + paths-ignore: + - "README.md" + push: + branches: [main] + paths-ignore: + - "README.md" + +jobs: + pest: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + php: [ '8.2', '8.3', '8.4' ] + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php }} + tools: composer:v2 + coverage: none + extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer, pdo, sqlite3 + ini-values: error_reporting=E_ALL, display_errors=On, log_errors=Off + + - name: Determine composer cache directory + id: composer-cache + run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT + + - name: Cache composer + uses: actions/cache@v3 + with: + path: ${{ steps.composer-cache.outputs.dir }} + key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} + restore-keys: | + ${{ runner.os }}-composer- + + - name: Validate composer.json + run: composer validate --no-check-publish + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run Pest + run: vendor/bin/pest --no-coverage + env: + CI: true + + benchmark: + needs: pest + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + permissions: + contents: read + pull-requests: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer:v2 + coverage: none + extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Run benchmark + run: vendor/bin/testbench benchmark --ci > benchmark-comment.md + + - name: Find existing comment + uses: peter-evans/find-comment@v3 + id: find + with: + issue-number: ${{ github.event.pull_request.number }} + comment-author: 'github-actions[bot]' + body-includes: '## Benchmark Results' + + - name: Post or update comment + uses: peter-evans/create-or-update-comment@v4 + with: + issue-number: ${{ github.event.pull_request.number }} + comment-id: ${{ steps.find.outputs.comment-id }} + edit-mode: replace + body-path: benchmark-comment.md + + snapshot: + needs: pest + if: github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: write + + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.4' + tools: composer:v2 + coverage: none + extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer + + - name: Install dependencies + run: composer install --prefer-dist --no-progress --no-interaction + + - name: Generate snapshot + run: vendor/bin/testbench benchmark --snapshot --ci + + - name: Commit snapshot + run: | + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" + git add .github/benchmark-snapshot.json + git diff --staged --quiet || git commit -m "Update benchmark snapshot" + git push diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml deleted file mode 100644 index a4ac5dc..0000000 --- a/.github/workflows/tests.yml +++ /dev/null @@ -1,55 +0,0 @@ -name: Tests - -on: - pull_request: - types: [ready_for_review, synchronize, opened] - paths-ignore: - - "README.md" - push: - branches: [main] - paths-ignore: - - "README.md" - -jobs: - pest: - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - php: [ '8.2', '8.3', '8.4' ] - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup PHP - uses: shivammathur/setup-php@v2 - with: - php-version: ${{ matrix.php }} - tools: composer:v2 - coverage: none - extensions: mbstring, dom, curl, json, libxml, xml, xmlwriter, simplexml, tokenizer, pdo, sqlite3 - ini-values: error_reporting=E_ALL, display_errors=On, log_errors=Off - - - name: Determine composer cache directory - id: composer-cache - run: echo "dir=$(composer config cache-files-dir)" >> $GITHUB_OUTPUT - - - name: Cache composer - uses: actions/cache@v3 - with: - path: ${{ steps.composer-cache.outputs.dir }} - key: ${{ runner.os }}-composer-${{ hashFiles('**/composer.lock') }} - restore-keys: | - ${{ runner.os }}-composer- - - - name: Validate composer.json - run: composer validate --no-check-publish - - - name: Install dependencies - run: composer install --prefer-dist --no-progress --no-interaction - - - name: Run Pest - run: vendor/bin/pest --no-coverage - env: - CI: true \ No newline at end of file From 8855226c31248f52ab010f165036c3908021bf19 Mon Sep 17 00:00:00 2001 From: Filip Ganyicz Date: Mon, 2 Mar 2026 01:18:41 +0100 Subject: [PATCH 14/14] Update BenchmarkCommand.php --- .../app/Console/Commands/BenchmarkCommand.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/workbench/app/Console/Commands/BenchmarkCommand.php b/workbench/app/Console/Commands/BenchmarkCommand.php index 69fc495..dd99226 100644 --- a/workbench/app/Console/Commands/BenchmarkCommand.php +++ b/workbench/app/Console/Commands/BenchmarkCommand.php @@ -172,6 +172,15 @@ protected function outputMarkdown(array $results): void protected function saveSnapshot(array $results): void { + $existing = $this->loadSnapshot(); + + if ($existing && ! $this->hasSignificantChange($results, $existing)) { + $this->newLine(); + $this->comment('Snapshot unchanged (no significant difference).'); + + return; + } + $snapshot = [ 'iterations' => $this->iterations, 'rounds' => $this->rounds, @@ -190,6 +199,25 @@ protected function saveSnapshot(array $results): void $this->info("Snapshot saved to {$path}"); } + protected function hasSignificantChange(array $results, array $snapshot): bool + { + if (array_keys($results) !== array_keys($snapshot['benchmarks'])) { + return true; + } + + return collect($results)->contains(function ($result, $name) use ($snapshot) { + $prev = $snapshot['benchmarks'][$name]; + + return $this->exceedsThreshold($prev['blade_ms'], $result['blade_ms'], 10.0) + || $this->exceedsThreshold($prev['blaze_ms'], $result['blaze_ms'], 5.0); + }); + } + + protected function exceedsThreshold(float $old, float $new, float $threshold): bool + { + return $old > 0 && abs(($new - $old) / $old * 100) >= $threshold; + } + protected function loadSnapshot(): ?array { $path = $this->snapshotPath();