-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathBench.php
More file actions
165 lines (156 loc) · 6.99 KB
/
Copy pathBench.php
File metadata and controls
165 lines (156 loc) · 6.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
<?php
declare(strict_types=1);
namespace Testo;
use Testo\Bench\Internal\Pipeline\BenchInterceptor;
use Testo\Pipeline\Attribute\FallbackInterceptor;
use Testo\Pipeline\Attribute\Interceptable;
/**
* Marks a method or function as a benchmark subject and compares its performance against
* the provided callables.
*
* The marked method (or function) automatically receives the alias `current`.
* All callables listed in {@see Bench::$callables} are benchmarked under the same conditions.
* Results are ranked by filtered average time — the fastest callable takes first place — while the
* relative percentages are measured against `current`, the baseline the alternatives are compared to.
* The fastest callable and the baseline are independent: `current` need not be the fastest.
*
* ## How it works
*
* 1. Each callable is invoked `$calls` times per iteration (to reduce measurement noise).
* 2. This is repeated for `$iterations` iterations (to collect statistical data).
* 3. Outliers are detected and filtered using the MAD (Median Absolute Deviation) method.
* 4. Results are ranked by filtered average time — the fastest callable wins.
*
* ## Aliases
*
* - The marked method always has the alias `current` and is the baseline for relative percentages.
* - For other callables, use **string keys** in the `$callables` array to assign aliases.
* These aliases appear in the results table for easy identification.
*
* ## Usage examples
*
* Basic — compare the current method against one alternative:
*
* ```
* #[Bench(['array_sum' => [self::class, 'sumWithArraySum']])]
* public static function sumWithLoop(int $a, int $b): int { ... }
* ```
*
* With arguments and tuning parameters:
*
* ```
* #[Bench(
* [
* 'shift' => [self::class, 'secondImpl'],
* 'multi' => [self::class, 'thirdImpl'],
* ],
* arguments: [1, 20_000],
* calls: 100_000,
* iterations: 10,
* )]
* public static function firstImpl(int $a, int $b): int { ... }
* ```
*
* The attribute is repeatable — you can attach multiple `#[BenchWith]` to a single method
* to run independent benchmark scenarios with different callables or arguments.
*
* ## Stability
*
* Read the RStDev column to judge a result yourself: below 2% it is reproducible at one
* significant figure. That bar is stricter than the diagnostic engine, which stays silent
* until variance is pronounced (RStDev around 10% and up) and only then emits reports with
* actionable advice (e.g., increase `$calls` or `$iterations`) — a clean report means "nothing
* worth acting on", not "below 2%".
*
* @link https://php-testo.github.io/blog/collider.md
*
* @api
*/
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
#[FallbackInterceptor(BenchInterceptor::class)]
final readonly class Bench implements Interceptable
{
public function __construct(
/**
* Functions to benchmark against the current (marked) method.
*
* Each entry can be:
* - A callable (closure, function name, `[object, method]`, etc.)
* - An array `[class-string, non-empty-string]` to reference a non-public method by class and
* name. A static method is called as is; a non-static one is bound to the current test case
* instance, so its class must be the one under test.
*
* Use **string keys** to assign aliases that will appear in the results table:
*
* ```
* ['shift' => [self::class, 'altImpl'], 'naive' => 'str_replace']
* ```
*
* Numeric keys are converted to string and used as-is.
*
* @var array<callable|array{class-string, non-empty-string}>
*/
public array $callables,
/**
* Arguments passed to every benchmarked function (including `current`).
*
* All functions receive the same arguments to ensure a fair comparison.
*
* @var array
*/
public array $arguments = [],
/**
* Number of warmup calls before the actual benchmark iterations; their results are discarded.
*
* The default `1` covers first-call resolution: autoloading, file includes, opcache compilation
* and other lazy one-time initialization, which a single call already triggers. It is intentionally
* low because the per-call cost is unknown up front. Raise it for JIT-sensitive or cache-sensitive
* code, where warming needs many calls to reach a steady state.
*
* @var int<0, max>
*/
public int $warmup = 1,
/**
* Number of function calls per iteration.
*
* Higher values reduce the impact of measurement overhead and produce more stable results.
* If RStDev is too high, try increasing this value.
*
* Every measurement includes a fixed per-call harness cost (a closure call and argument
* forwarding). It is the same for every callable, so relative ranking stays correct, but for
* sub-microsecond functions it dominates the timing and compresses the reported percentages.
* It is not subtracted: a faithful baseline would itself depend on argument count and runtime,
* so raise `$calls` (or compare relatively) rather than trusting absolute figures at that scale.
*
* @var int<1, max>
*/
public int $calls = 1_000,
/**
* Number of iterations (rounds).
*
* Each iteration runs all functions {@see $calls} times and records timing and memory data.
* More iterations improve statistical reliability and outlier detection.
*
* @var int<1, max>
*/
public int $iterations = 10,
/**
* How much slower `current` may be than the fastest callable before the benchmark fails.
*
* Expressed as a fraction of the fastest filtered mean: `current` passes while its own
* filtered mean stays within `fastest * (1 + $tolerance)`. The default matches the 2% stability
* target, leaving that much headroom for measurement noise; raise it for noisier environments,
* or set it to `0.0` to demand that `current` be no slower than every alternative. Set it to
* `\INF` to compare implementations of equivalent speed without gating on which one wins.
*
* @var float
*/
public float $tolerance = 0.02,
) {
$warmup >= 0 or throw new \InvalidArgumentException('Warmup must be greater than or equal to 0.');
\count($callables) >= 1 or throw new \InvalidArgumentException('At least one callable must be provided.');
$calls > 0 or throw new \InvalidArgumentException('Calls must be greater than 0.');
$iterations > 0 or throw new \InvalidArgumentException('Iterations must be greater than 0.');
$tolerance >= 0.0 or throw new \InvalidArgumentException('Tolerance must be greater than or equal to 0.');
}
}