Skip to content

Commit 60b5201

Browse files
authored
feat(Bench): Add Attributed benchmarks PoC (#92, #94)
1 parent 99202ed commit 60b5201

File tree

9 files changed

+345
-0
lines changed

9 files changed

+345
-0
lines changed

composer.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
],
2424
"require": {
2525
"php": ">=8.1",
26+
"dragon-code/benchmark": "^2.6 || ^3.0",
2627
"internal/destroy": "^1.0",
2728
"internal/path": "^1.2",
2829
"psr/container": "1 - 2",

src/Bench/BenchWith.php

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench;
6+
7+
use Testo\Bench\Internal\BenchWithInterceptor;
8+
use Testo\Pipeline\Attribute\FallbackInterceptor;
9+
use Testo\Pipeline\Attribute\Interceptable;
10+
11+
/**
12+
* Attribute to specify additional functions to benchmark with.
13+
*
14+
* @api
15+
*/
16+
#[\Attribute(\Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION | \Attribute::IS_REPEATABLE)]
17+
#[FallbackInterceptor(BenchWithInterceptor::class)]
18+
final class BenchWith implements Interceptable
19+
{
20+
public function __construct(
21+
/**
22+
* @var array<callable|array{class-string, non-empty-string}> $callables Functions to benchmark with.
23+
* It might be a callable or an array with class name and non-public method name.
24+
*/
25+
public readonly array $callables,
26+
27+
/**
28+
* @var array Arguments to pass to the benchmarked functions.
29+
*/
30+
public readonly array $arguments = [],
31+
32+
/**
33+
* @var int<1, max> Number of iterations to run for each benchmark.
34+
*/
35+
public readonly int $iterations = 1000,
36+
37+
/**
38+
* @var int<1, max> Number of revolutions to run for each benchmark.
39+
* A revolution is a single execution of the benchmarked function.
40+
*/
41+
public readonly int $revolutions = 5,
42+
) {
43+
\count($callables) < 1 or throw new \InvalidArgumentException('At least one callable must be provided.');
44+
$iterations > 0 or throw new \InvalidArgumentException('Iterations must be greater than 0.');
45+
$revolutions > 0 or throw new \InvalidArgumentException('Revolutions must be greater than 0.');
46+
}
47+
}
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench\Exception;
6+
7+
use Testo\Bench\BenchWith;
8+
use Testo\Bench\Internal\BenchWithInterceptor;
9+
use Testo\Core\Context\TestInfo;
10+
11+
/**
12+
* Thrown when {@see BenchWith} attribute is missing in {@see BenchInvoker}.
13+
*
14+
* This indicates a broken pipeline where the {@see BenchWithInterceptor} middleware
15+
* did not execute as expected. The {@see BenchWith} attribute should have been set
16+
* by the interceptor before reaching the invoker.
17+
*
18+
* @internal
19+
*/
20+
final class BenchWithAttributeMissingException extends \LogicException
21+
{
22+
public static function fromTestInfo(TestInfo $info): self
23+
{
24+
return new self(
25+
\sprintf(
26+
'Target BenchWith attribute is missing for `%s%s()`. This indicates a broken test pipeline.',
27+
$info->caseInfo->definition->reflection === null
28+
? ''
29+
: $info->caseInfo->definition->reflection->getName() . '::',
30+
$info->testDefinition->reflection->getName(),
31+
),
32+
);
33+
}
34+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench\Internal;
6+
7+
use DragonCode\Benchmark\Benchmark;
8+
use Testo\Bench\BenchWith;
9+
use Testo\Bench\Exception\BenchWithAttributeMissingException;
10+
use Testo\Core\Context\TestInfo;
11+
12+
final class BenchInvoker
13+
{
14+
private static function normalizeCallable(TestInfo $info, callable|array $callable): \Closure
15+
{
16+
$fn = $callable(...);
17+
return static fn (): mixed => $fn(...$info->arguments);
18+
}
19+
20+
public function __invoke(TestInfo $info): mixed
21+
{
22+
$attr = $info->getAttribute(BenchWith::class);
23+
$attr instanceof BenchWith or throw BenchWithAttributeMissingException::fromTestInfo($info);
24+
25+
// Current function callable
26+
$fn = $info->caseInfo->instance === null || $info->testDefinition->reflection->isStatic()
27+
? $info->testDefinition->reflection->getClosure(null)
28+
: $info->testDefinition->reflection->getClosure($info->caseInfo->instance->getInstance());
29+
30+
31+
$functions = [static fn (): mixed => $fn(...$info->arguments)];
32+
33+
# Collect callables
34+
foreach ($attr->callables as $callable) {
35+
$f = self::normalizeCallable($info, $callable);
36+
$functions[] = $f;
37+
}
38+
39+
40+
Benchmark::start()
41+
->withoutData()
42+
->iterations($attr->iterations)
43+
->compare(
44+
...$functions,
45+
);
46+
47+
return null;
48+
}
49+
}
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench\Internal;
6+
7+
use Psr\EventDispatcher\EventDispatcherInterface;
8+
use Testo\Bench\BenchWith;
9+
use Testo\Core\Context\TestInfo;
10+
use Testo\Core\Context\TestResult;
11+
use Testo\Core\Value\Status;
12+
use Testo\Data\MultipleResult;
13+
use Testo\Event\Test\TestBatchFinished;
14+
use Testo\Event\Test\TestBatchStarting;
15+
use Testo\Event\Test\TestDataSetFinished;
16+
use Testo\Event\Test\TestDataSetStarting;
17+
use Testo\Pipeline\Attribute\InterceptorOptions;
18+
use Testo\Pipeline\Middleware\TestRunInterceptor;
19+
use Testo\Pipeline\Policy\ConflictPolicy;
20+
21+
/**
22+
* Handles {@see BenchWith} attribute by running
23+
*
24+
* @internal
25+
*/
26+
#[InterceptorOptions(order: InterceptorOptions::ORDER_DATA_PROVIDER, onConflict: ConflictPolicy::First)]
27+
final class BenchWithInterceptor implements TestRunInterceptor
28+
{
29+
public function __construct(
30+
private readonly EventDispatcherInterface $eventDispatcher,
31+
) {}
32+
33+
#[\Override]
34+
public function runTest(TestInfo $info, callable $next): TestResult
35+
{
36+
/** @var BenchWith[] $attributes */
37+
$attributes = $info->getAttribute(BenchWith::class);
38+
if ($attributes === []) {
39+
return $next($info);
40+
}
41+
42+
if (\count($attributes) === 1) {
43+
$attr = \reset($attributes);
44+
return $next($info->with(arguments: $attr->arguments)->withAttribute(BenchWith::class, $attr));
45+
}
46+
47+
# Dispatch batch starting event
48+
$this->eventDispatcher->dispatch(new TestBatchStarting($info));
49+
50+
# Run the test for each data set
51+
$results = [];
52+
$status = Status::Passed;
53+
foreach ($attributes as $index => $attr) {
54+
$newInfo = $info->with(arguments: $attr->arguments)->withAttribute(BenchWith::class, $attr);
55+
$label = "$index";
56+
57+
# Dispatch dataset starting event
58+
$this->eventDispatcher->dispatch(
59+
new TestDataSetStarting($newInfo, $label, null, $index),
60+
);
61+
62+
try {
63+
$result = $next($newInfo);
64+
} catch (\Throwable $throwable) {
65+
$result = new TestResult(info: $newInfo, status: Status::Error, failure: $throwable);
66+
}
67+
68+
# Dispatch dataset finished event
69+
$this->eventDispatcher->dispatch(
70+
new TestDataSetFinished($newInfo, $result, $label, null, $index),
71+
);
72+
73+
unset($attr, $newInfo);
74+
$result->status->isFailure() and ($status = Status::Failed);
75+
$results[] = $result;
76+
}
77+
78+
$results = new MultipleResult($results);
79+
80+
$finalResult = new TestResult(info: $info, status: $status, result: $results, attributes: [
81+
MultipleResult::class => $results,
82+
]);
83+
84+
# Dispatch batch finished event
85+
$this->eventDispatcher->dispatch(new TestBatchFinished($info, $finalResult));
86+
87+
return $finalResult;
88+
}
89+
}
Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,80 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Testo\Bench\Middleware;
6+
7+
use Testo\Bench\BenchWith;
8+
use Testo\Bench\Internal\BenchInvoker;
9+
use Testo\Common\Reflection;
10+
use Testo\Core\Context\TestInfo;
11+
use Testo\Core\Definition\CaseDefinitions;
12+
use Testo\Pipeline\Attribute\InterceptorOptions;
13+
use Testo\Pipeline\Middleware\CaseLocatorInterceptor;
14+
use Testo\Pipeline\Middleware\FileLocatorInterceptor;
15+
use Testo\Tokenizer\Reflection\FileDefinitions;
16+
use Testo\Tokenizer\Reflection\TokenizedFile;
17+
18+
/**
19+
* Finds benchmarks defined with the {@see BenchWith} attribute.
20+
*/
21+
#[InterceptorOptions(order: -20_000)]
22+
final class BenchFinder implements FileLocatorInterceptor, CaseLocatorInterceptor
23+
{
24+
/** @var \Closure(TestInfo): mixed Invoker for the test method. */
25+
private readonly \Closure $invoker;
26+
27+
public function __construct(BenchInvoker $invoker)
28+
{
29+
$this->invoker = $invoker(...);
30+
}
31+
32+
#[\Override]
33+
public function locateFile(TokenizedFile $file, callable $next): ?bool
34+
{
35+
return $file->getClasses() !== [] || $file->getFunctions() !== [] ? true : $next($file);
36+
}
37+
38+
#[\Override]
39+
public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions
40+
{
41+
// Define cases for classes
42+
foreach ($file->classes as $class) {
43+
if ($class->isAbstract()) {
44+
continue;
45+
}
46+
47+
$case = null;
48+
foreach ($class->getMethods() as $method) {
49+
if (Reflection::fetchFunctionAttributes($method, attributeClass: BenchWith::class)) {
50+
if ($case === null) {
51+
$case = $file->cases->define($class, $file);
52+
$case->invoker = $this->invoker;
53+
}
54+
55+
$case->tests->define($method);
56+
}
57+
}
58+
}
59+
60+
if ($file->functions === []) {
61+
return $next($file);
62+
}
63+
64+
// Define a case for functions
65+
// Implement a lazy case definition
66+
$case = null;
67+
foreach ($file->functions as $function) {
68+
if (Reflection::fetchFunctionAttributes($function, attributeClass: BenchWith::class)) {
69+
if ($case === null) {
70+
$case = $file->cases->define(null, $file);
71+
$case->invoker = $this->invoker;
72+
}
73+
74+
$case->tests->define($function);
75+
}
76+
}
77+
78+
return $next($file);
79+
}
80+
}

src/Pipeline/InterceptorProvider.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
use Testo\Application\Middleware\Locator\TestoAttributesLocatorInterceptor;
1111
use Testo\Assert\Interceptor\AssertCollectorInterceptor;
1212
use Testo\Assert\Interceptor\ExpectationsInterceptor;
13+
use Testo\Bench\Middleware\BenchFinder;
1314
use Testo\Common\Container;
1415
use Testo\Common\Reflection;
1516
use Testo\Inline\Middleware\TestInlineFinder;
@@ -56,6 +57,7 @@ public function fromConfig(string $class): array
5657
return $this->fromClasses($class, ...[
5758
FilterInterceptor::class,
5859
TestInlineFinder::class,
60+
BenchFinder::class,
5961
new FilePostfixTestLocatorInterceptor(),
6062
new TestoAttributesLocatorInterceptor(),
6163
new AssertCollectorInterceptor(),

tests/Bench/Self/BenchWithAttr.php

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace Tests\Bench\Self;
6+
7+
use Testo\Bench\BenchWith;
8+
9+
final class BenchWithAttr
10+
{
11+
#[BenchWith(
12+
[
13+
[self::class, 'sumFast'],
14+
[self::class, 'sumSlow'],
15+
],
16+
arguments: [1, 2],
17+
iterations: 10_000,
18+
)]
19+
public static function sumFast(int $a, int $b): int
20+
{
21+
return $a + $b;
22+
}
23+
24+
public static function sumSlow(int $a, int $b): int
25+
{
26+
return (int) \array_sum([$a, $b]);
27+
}
28+
}

tests/Bench/suites.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
use Testo\Application\Config\FinderConfig;
6+
use Testo\Application\Config\SuiteConfig;
7+
8+
return [
9+
new SuiteConfig(
10+
name: 'Bench/Self',
11+
location: new FinderConfig(
12+
include: [__DIR__ . '/Self'],
13+
),
14+
),
15+
];

0 commit comments

Comments
 (0)