Skip to content

Commit 3511135

Browse files
committed
Adding an utility command line to streamline profiling
1 parent 628cdcf commit 3511135

23 files changed

Lines changed: 1968 additions & 16 deletions

.php-cs-fixer.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
$finder = PhpCsFixer\Finder::create()
44
->in(__DIR__.'/src')
55
->in(__DIR__.'/benchmark')
6+
->in(__DIR__.'/bin')
67
;
78

89
$config = new PhpCsFixer\Config();

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,11 @@ All Notable changes to `bakame/aide-profiler` will be documented in this file.
88

99
- `Statistics` class
1010
- `Report` class and the `Profiler::report` method.
11+
- `ConsoleTableExporter::exportReport`
12+
- `ConsoleTableExporter::exportStatistics`
13+
- `ConsoleTableExporter::exportMetrics`
14+
- `JsonExporter`
15+
- `Profile` attribute, the `PathProfiler` class and the `phpProfiler` command to ease profiling
1116

1217
### Fixed
1318

README.md

Lines changed: 154 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@
1010
[![Sponsor development of this project](https://img.shields.io/badge/sponsor%20this%20package-%E2%9D%A4-ff69b4.svg?style=flat-square)](https://github.com/sponsors/nyamsprod)
1111

1212
A minimalist profiler for PHP. The profiler is embeddable, multi-metric, and framework-agnostic
13-
It fills the gap between a basic timer and full-blown profilers like [Xdebug](https://xdebug.org/) or [Blackfire](https://www.blackfire.io/).
13+
It fills the gap between a basic timer and full-blown profilers like: [PHPBench](https://phpbench.readthedocs.io/en/latest/),
14+
[Xdebug](https://xdebug.org/), [Blackfire](https://www.blackfire.io/).
1415

1516
## Installation
1617

@@ -121,7 +122,7 @@ and formatted.
121122
use Bakame\Aide\Profiler\Profiler;
122123

123124
// you create a new Profiler by passing the callback you want to profile
124-
$report = Profiler::report($service->calculateHeavyStuff(...));
125+
$report = Profiler::report($service->calculateHeavyStuff(...), 500);
125126

126127
// Access the raw statistical metrics
127128
$report->executionTime->minimum; // Minimum execution time (as float|int, in nanoseconds)
@@ -532,6 +533,25 @@ echo json_encode($marker), PHP_EOL;
532533
```
533534
See a [sample marker JSON output](./examples/marker-sample.json) for a complete structure.
534535

536+
In order to facilitate JSON export, the package has a dedicated `JsonExporter` class
537+
which will be able to store the generated json in the specified location. It supports
538+
streams, string path and `SplFileInfo` objects.
539+
540+
```php
541+
use Bakame\Aide\Profiler\JsonExporter;
542+
use Bakame\Aide\Profiler\Profiler;
543+
544+
$report = Profiler::report($service->calculateHeavyStuff(...), 500);
545+
$exporter = new JsonExporter('path/to/store/the/profile.json', JSON_PRETTY_PRINT|JSON_BIGINT_AS_STRING);
546+
$exporter->exportReport($report);
547+
```
548+
The report will be stored in the designated location.
549+
550+
> [!IMPORTANT]
551+
> If you try to store multiple export in the same file (specified by a string)
552+
> They will get overwritten and only the last export will be stored.
553+
> To get the data appended provide an already open `resource` or `SplFileObject`.
554+
535555
#### CLI
536556

537557
If you have the `symfony\console` package installed in your application, you can export
@@ -610,6 +630,138 @@ $exporter->exportProfilter($profiler);
610630

611631
Remember to change the `$tracerProvider` to connect to your own environment and server.
612632

633+
### CLI command
634+
635+
A CLI Command is available to allow you to benchmark PHP **functions and methods** located in a specific file or directory using the custom `#[Profile]` attribute.
636+
637+
This is especially useful for:
638+
639+
- Automating performance regressions in CI pipelines
640+
- Profiling code outside the context of an application
641+
642+
#### Usage
643+
644+
```bash
645+
php bin/phpProfiler --path=your/script.php [--output=cli|json] [--info] [--help]
646+
```
647+
648+
| Option | Description |
649+
|---------------------|-------------------------------------------------------------------------------|
650+
| `--path[=PATH]` | **(Required)** Path to the file to scan for profiled functions and methods. |
651+
| `--output[=OUTPUT]` | Output format: either `json` or `cli` (default) table. |
652+
| `-i`, `--info` | Additionally display system-level profiling metadata (PHP version, CPU, etc). |
653+
| `-h`, `--help` | Show help for the command. |
654+
655+
#### Example
656+
657+
let's assume you have the following file located in `/path/profiler/test.php`.
658+
659+
```php
660+
<?php
661+
662+
declare(strict_types=1);
663+
664+
namespace Foobar\Baz;
665+
666+
use Bakame\Aide\Profiler\Profile;
667+
use function random_int;
668+
use function usleep;
669+
670+
require 'vendor/autoload.php';
671+
672+
trait TimerTrait {
673+
#[Profile(type: Profile::METRICS, iterations: 10)]
674+
private function test() : int {
675+
usleep(100);
676+
677+
return random_int(1, 100);
678+
}
679+
}
680+
681+
enum Foobar
682+
{
683+
use TimerTrait;
684+
685+
case Foobar;
686+
}
687+
688+
#[Profile(type: Profile::REPORT, iterations: 20, warmup: 2)]
689+
function test() : int {
690+
usleep(100);
691+
692+
return random_int(1, 100);
693+
}
694+
```
695+
If you run the following command:
696+
697+
```bash
698+
php bin/phpProfiler --path=/path/profiler/test.php
699+
```
700+
It will output 2 console tables:
701+
702+
```bash
703+
PHPProfiler 0.11.0 by Ignace Nyamagana Butera and contributors.
704+
705+
Runtime: PHP 8.3.23
706+
Platform: Linux
707+
708+
Report for the function Foobar\Baz\test located in /path/profiler/test.php called 20 times
709+
+------------------------+---------------+------------+------------+--------------+------------+-----------+------------+------------+----------+-----------+
710+
| Metric | Nb Iterations | Min Value | Max Value | Median Value | Sum | Range | Average | Variance | Std Dev | Coef Var |
711+
+------------------------+---------------+------------+------------+--------------+------------+-----------+------------+------------+----------+-----------+
712+
| CPU Time | 20 | 7.000 µs | 32.000 µs | 8.000 µs | 183.000 µs | 25.000 µs | 9.150 µs | 28.128 μs² | 5.304 µs | 57.9621 % |
713+
| Execution Time | 20 | 132.125 µs | 158.208 µs | 133.292 µs | 2.701 ms | 26.083 µs | 135.029 µs | 32.436 μs² | 5.695 µs | 4.2178 % |
714+
| Memory Usage | 20 | 1.031 KB | 1.031 KB | 1.031 KB | 20.625 KB | 0.000 B | 1.031 KB | 0.000 B² | 0.000 B | 0.0000 % |
715+
| Peak Memory Usage | 20 | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B² | 0.000 B | 0.0000 % |
716+
| Real Memory Usage | 20 | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B² | 0.000 B | 0.0000 % |
717+
| Real Peak Memory Usage | 20 | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B | 0.000 B² | 0.000 B | 0.0000 % |
718+
+------------------------+---------------+------------+------------+--------------+------------+-----------+------------+------------+----------+-----------+
719+
Average metrics for the method Foobar\Baz\Foobar::test located in /path/profiler/test.php called 10 times
720+
+------------------------------------+
721+
| Execution Time: 140.213 µs |
722+
| CPU Time: 11.700 µs |
723+
| Memory Usage: 1.0 KB |
724+
| Real Memory Usage: 0.0 B |
725+
| Peak Memory Usage: 0.0 B |
726+
| Real Peak Memory Usage: 0.0 B |
727+
+------------------------------------+
728+
```
729+
730+
- one about the full report on the function `test` (this is equivalent as using `Profiler::report`)
731+
- the other about the average metrics for the `Foobar::test` method. (this is equivalent as using `Profiler::metrics`)
732+
733+
The `#[Profile]` attribute exposes the same arguments as the `Profiler` methods:
734+
735+
- `iterations`: Number of times to execute the function for statistical significance.
736+
- `warmup`: (Optional) Number of warmup iterations before measuring.
737+
- `type`: Either `Profile::METRICS` or `Profile::REPORT`; To determine if you want the `Profiler::report` or the `Profiler::metrics` output.
738+
739+
#### Notes
740+
741+
The command line supports **function-level** and **method-level** profiling, including methods defined
742+
via traits, even inside Enums.
743+
744+
- Functions or methods without a `#[Profile]` attribute will be ignored.
745+
- Functions or methods with arguments will also be ignored.
746+
747+
All required dependencies should be loaded in the target file (use `require`, `include` or Composer autoload).
748+
749+
#### Integration into CI
750+
751+
You can run the profiler command in your CI pipelines to detect regressions or performance anomalies.
752+
753+
```yaml
754+
- name: Run Profiler
755+
run: php bin/phpProfiler --path=/path/profiler/test.php --output=json
756+
```
757+
758+
> [!IMPORTANT]
759+
> The command line requires `symfony\console` and the `psr\log` interfaces to work.
760+
761+
> [!CAUTION]
762+
> The command line can scan your full codebase if you specify a directory instead of a path. But
763+
> favor the cli output as the json output will not return a valid json file.
764+
613765
### Helpers
614766

615767
#### Environment

bin/phpProfiler

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
#!/usr/bin/env php
2+
<?php
3+
4+
declare(strict_types=1);
5+
6+
use Bakame\Aide\Profiler\Console\Command;
7+
8+
if (is_file(__DIR__ . '/../vendor/autoload.php')) {
9+
require __DIR__ . '/../vendor/autoload.php';
10+
} elseif (is_file(__DIR__ . '/../../../autoload.php')) {
11+
require __DIR__ . '/../../../autoload.php';
12+
} else {
13+
fwrite(STDERR, 'Cannot find the vendor directory, have you executed composer install?' . PHP_EOL);
14+
fwrite(STDERR,'See https://getcomposer.org to get Composer.' . PHP_EOL);
15+
16+
exit(1);
17+
}
18+
19+
Command::run();

composer.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,9 @@
5050
"benchmark/Benchmark.php"
5151
]
5252
},
53+
"bin": [
54+
"bin/phpProfiler"
55+
],
5356
"scripts": {
5457
"phpcs": "php-cs-fixer fix -vvv --diff --dry-run --allow-risky=yes --ansi",
5558
"phpcs:fix": "php-cs-fixer fix -vvv --allow-risky=yes --ansi",
@@ -71,7 +74,7 @@
7174
},
7275
"suggest": {
7376
"psr/log": "to log the profiling process",
74-
"symfony/console": "to render the profiler in your CLI command",
77+
"symfony/console": "to use the CLI command",
7578
"open-telemetry/exporter-otlp": "to export the profiler results to an opentelemetry compatible server"
7679
},
7780
"extra": {

phpunit.xml

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,14 @@
11
<?xml version="1.0" encoding="UTF-8"?>
2-
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd" bootstrap="vendor/autoload.php" backupGlobals="false" colors="true" processIsolation="false" stopOnFailure="false" cacheDirectory=".phpunit.cache" backupStaticProperties="false">
2+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
3+
xsi:noNamespaceSchemaLocation="https://schema.phpunit.de/10.3/phpunit.xsd"
4+
bootstrap="vendor/autoload.php"
5+
backupGlobals="false"
6+
colors="true"
7+
processIsolation="false"
8+
stopOnFailure="false"
9+
cacheDirectory=".phpunit.cache"
10+
backupStaticProperties="false"
11+
>
312
<coverage>
413
<report>
514
<clover outputFile="build/clover.xml"/>

src/Console/Command.php

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Bakame\Aide\Profiler\Console;
6+
7+
use Bakame\Aide\Profiler\ConsoleTableExporter;
8+
use Bakame\Aide\Profiler\Environment;
9+
use Bakame\Aide\Profiler\PathProfiler;
10+
use Bakame\Aide\Profiler\Version;
11+
use RuntimeException;
12+
use Symfony\Component\Console\Output\OutputInterface;
13+
use Symfony\Component\Console\Output\StreamOutput;
14+
use Throwable;
15+
16+
use function class_exists;
17+
use function fwrite;
18+
19+
use const JSON_PRETTY_PRINT;
20+
use const PHP_EOL;
21+
use const PHP_SAPI;
22+
use const STDERR;
23+
use const STDOUT;
24+
25+
final class Command
26+
{
27+
public const SUCCESS = 0;
28+
public const ERROR = 1;
29+
30+
public static function run(): never
31+
{
32+
if (!class_exists(StreamOutput::class)) {
33+
fwrite(STDERR, 'The symfony/console package is required to use the command line.'.PHP_EOL);
34+
35+
exit(self::ERROR);
36+
}
37+
38+
if ('cli' !== PHP_SAPI) {
39+
fwrite(STDERR, 'This script must be run from the command line.'.PHP_EOL);
40+
41+
exit(self::ERROR);
42+
}
43+
44+
(new self(new StreamOutput(STDOUT), new StreamOutput(STDERR)))->handle();
45+
}
46+
47+
public function __construct(private readonly OutputInterface $stdout, private readonly OutputInterface $stderr)
48+
{
49+
}
50+
51+
public function handle(): never
52+
{
53+
exit($this->execute(Input::fromCli()));
54+
}
55+
56+
public function execute(Input $options): int
57+
{
58+
if ($options->showVersion) {
59+
$this->stdout->writeln('<info>'.Version::full().'</info>');
60+
61+
return self::SUCCESS;
62+
}
63+
64+
if (Input::CLI_FORMAT === $options->outputFormat) {
65+
$this->stdout->writeln($this->header());
66+
}
67+
68+
if ($options->showHelp) {
69+
$this->stdout->writeln($this->helpText());
70+
71+
return self::SUCCESS;
72+
}
73+
74+
$environment = Environment::current();
75+
if ($options->showInfo) {
76+
(new ConsoleTableExporter($this->stdout))->exportEnvironment($environment);
77+
$this->stdout->writeln('');
78+
}
79+
80+
if (null === $options->path) {
81+
if ($options->showInfo) {
82+
return self::SUCCESS;
83+
}
84+
$this->stderr->writeln('<error> Please specify a valid path. </error>');
85+
$this->stdout->writeln($this->helpText());
86+
87+
return self::ERROR;
88+
}
89+
90+
if (Input::CLI_FORMAT === $options->outputFormat && !$options->showInfo) {
91+
$this->stdout->writeln('<fg=green>Runtime:</> PHP '.$environment->phpVersion.' <fg=green>OS:</> '.$environment->os.' <fg=green>Memory Limit:</> '.$environment->rawMemoryLimit);
92+
$this->stdout->writeln('');
93+
}
94+
95+
try {
96+
(match ($options->outputFormat) {
97+
Input::JSON_FORMAT => PathProfiler::forJson(STDOUT, JSON_PRETTY_PRINT, new Logger($this->stderr)),
98+
Input::CLI_FORMAT => PathProfiler::forConsole($this->stdout, new Logger($this->stderr)),
99+
default => throw new RuntimeException('Unknown output format: '.$options->outputFormat),
100+
})->handle($options->path);
101+
102+
return self::SUCCESS;
103+
} catch (Throwable $e) {
104+
$this->stderr->writeln('<error> Execution Error: '.$e->getMessage().'</error>');
105+
106+
return self::ERROR;
107+
}
108+
}
109+
110+
private function header(): string
111+
{
112+
$version = Version::full();
113+
114+
return <<<HELP
115+
<fg=green>PHPProfiler $version</><fg=yellow> by Ignace Nyamagana Butera and contributors.</>
116+
117+
HELP;
118+
}
119+
120+
private function helpText(): string
121+
{
122+
return <<<'HELP'
123+
<fg=yellow>Description:</>
124+
Simple command to profile functions and methods located in a specific file.
125+
126+
<fg=yellow>Usage:</>
127+
phpProfiler --path[=PATH] --output[=OUTPUT] [--info] [--help]
128+
129+
<fg=yellow>Options:</>
130+
<fg=green> --path[=PATH]</> The path to the file to parse containing class and methods to profile
131+
<fg=green> --output[=OUTPUT]</> Output the data as Json or CLI table
132+
<fg=green> -h, --help</> Display help for the given command
133+
<fg=green> -i, --info</> Additionally display the system general information
134+
HELP;
135+
}
136+
}

0 commit comments

Comments
 (0)