diff --git a/composer.json b/composer.json index 2474fc8..e3002a4 100644 --- a/composer.json +++ b/composer.json @@ -23,6 +23,7 @@ ], "require": { "php": ">=8.1", + "dragon-code/benchmark": "^2.6 || ^3.0", "internal/destroy": "^1.0", "internal/path": "^1.2", "psr/container": "1 - 2", diff --git a/src/Bench/BenchWith.php b/src/Bench/BenchWith.php new file mode 100644 index 0000000..d836a4e --- /dev/null +++ b/src/Bench/BenchWith.php @@ -0,0 +1,47 @@ + $callables Functions to benchmark with. + * It might be a callable or an array with class name and non-public method name. + */ + public readonly array $callables, + + /** + * @var array Arguments to pass to the benchmarked functions. + */ + public readonly array $arguments = [], + + /** + * @var int<1, max> Number of iterations to run for each benchmark. + */ + public readonly int $iterations = 1000, + + /** + * @var int<1, max> Number of revolutions to run for each benchmark. + * A revolution is a single execution of the benchmarked function. + */ + public readonly int $revolutions = 5, + ) { + \count($callables) < 1 or throw new \InvalidArgumentException('At least one callable must be provided.'); + $iterations > 0 or throw new \InvalidArgumentException('Iterations must be greater than 0.'); + $revolutions > 0 or throw new \InvalidArgumentException('Revolutions must be greater than 0.'); + } +} diff --git a/src/Bench/Exception/BenchWithAttributeMissingException.php b/src/Bench/Exception/BenchWithAttributeMissingException.php new file mode 100644 index 0000000..324617e --- /dev/null +++ b/src/Bench/Exception/BenchWithAttributeMissingException.php @@ -0,0 +1,34 @@ +caseInfo->definition->reflection === null + ? '' + : $info->caseInfo->definition->reflection->getName() . '::', + $info->testDefinition->reflection->getName(), + ), + ); + } +} diff --git a/src/Bench/Internal/BenchInvoker.php b/src/Bench/Internal/BenchInvoker.php new file mode 100644 index 0000000..89e58a2 --- /dev/null +++ b/src/Bench/Internal/BenchInvoker.php @@ -0,0 +1,49 @@ + $fn(...$info->arguments); + } + + public function __invoke(TestInfo $info): mixed + { + $attr = $info->getAttribute(BenchWith::class); + $attr instanceof BenchWith or throw BenchWithAttributeMissingException::fromTestInfo($info); + + // Current function callable + $fn = $info->caseInfo->instance === null || $info->testDefinition->reflection->isStatic() + ? $info->testDefinition->reflection->getClosure(null) + : $info->testDefinition->reflection->getClosure($info->caseInfo->instance->getInstance()); + + + $functions = [static fn (): mixed => $fn(...$info->arguments)]; + + # Collect callables + foreach ($attr->callables as $callable) { + $f = self::normalizeCallable($info, $callable); + $functions[] = $f; + } + + + Benchmark::start() + ->withoutData() + ->iterations($attr->iterations) + ->compare( + ...$functions, + ); + + return null; + } +} diff --git a/src/Bench/Internal/BenchWithInterceptor.php b/src/Bench/Internal/BenchWithInterceptor.php new file mode 100644 index 0000000..7d291c2 --- /dev/null +++ b/src/Bench/Internal/BenchWithInterceptor.php @@ -0,0 +1,89 @@ +getAttribute(BenchWith::class); + if ($attributes === []) { + return $next($info); + } + + if (\count($attributes) === 1) { + $attr = \reset($attributes); + return $next($info->with(arguments: $attr->arguments)->withAttribute(BenchWith::class, $attr)); + } + + # Dispatch batch starting event + $this->eventDispatcher->dispatch(new TestBatchStarting($info)); + + # Run the test for each data set + $results = []; + $status = Status::Passed; + foreach ($attributes as $index => $attr) { + $newInfo = $info->with(arguments: $attr->arguments)->withAttribute(BenchWith::class, $attr); + $label = "$index"; + + # Dispatch dataset starting event + $this->eventDispatcher->dispatch( + new TestDataSetStarting($newInfo, $label, null, $index), + ); + + try { + $result = $next($newInfo); + } catch (\Throwable $throwable) { + $result = new TestResult(info: $newInfo, status: Status::Error, failure: $throwable); + } + + # Dispatch dataset finished event + $this->eventDispatcher->dispatch( + new TestDataSetFinished($newInfo, $result, $label, null, $index), + ); + + unset($attr, $newInfo); + $result->status->isFailure() and ($status = Status::Failed); + $results[] = $result; + } + + $results = new MultipleResult($results); + + $finalResult = new TestResult(info: $info, status: $status, result: $results, attributes: [ + MultipleResult::class => $results, + ]); + + # Dispatch batch finished event + $this->eventDispatcher->dispatch(new TestBatchFinished($info, $finalResult)); + + return $finalResult; + } +} diff --git a/src/Bench/Middleware/BenchFinder.php b/src/Bench/Middleware/BenchFinder.php new file mode 100644 index 0000000..0e876b4 --- /dev/null +++ b/src/Bench/Middleware/BenchFinder.php @@ -0,0 +1,80 @@ +invoker = $invoker(...); + } + + #[\Override] + public function locateFile(TokenizedFile $file, callable $next): ?bool + { + return $file->getClasses() !== [] || $file->getFunctions() !== [] ? true : $next($file); + } + + #[\Override] + public function locateTestCases(FileDefinitions $file, callable $next): CaseDefinitions + { + // Define cases for classes + foreach ($file->classes as $class) { + if ($class->isAbstract()) { + continue; + } + + $case = null; + foreach ($class->getMethods() as $method) { + if (Reflection::fetchFunctionAttributes($method, attributeClass: BenchWith::class)) { + if ($case === null) { + $case = $file->cases->define($class, $file); + $case->invoker = $this->invoker; + } + + $case->tests->define($method); + } + } + } + + if ($file->functions === []) { + return $next($file); + } + + // Define a case for functions + // Implement a lazy case definition + $case = null; + foreach ($file->functions as $function) { + if (Reflection::fetchFunctionAttributes($function, attributeClass: BenchWith::class)) { + if ($case === null) { + $case = $file->cases->define(null, $file); + $case->invoker = $this->invoker; + } + + $case->tests->define($function); + } + } + + return $next($file); + } +} diff --git a/src/Pipeline/InterceptorProvider.php b/src/Pipeline/InterceptorProvider.php index 2db3408..09d01ac 100644 --- a/src/Pipeline/InterceptorProvider.php +++ b/src/Pipeline/InterceptorProvider.php @@ -10,6 +10,7 @@ use Testo\Application\Middleware\Locator\TestoAttributesLocatorInterceptor; use Testo\Assert\Interceptor\AssertCollectorInterceptor; use Testo\Assert\Interceptor\ExpectationsInterceptor; +use Testo\Bench\Middleware\BenchFinder; use Testo\Common\Container; use Testo\Common\Reflection; use Testo\Inline\Middleware\TestInlineFinder; @@ -56,6 +57,7 @@ public function fromConfig(string $class): array return $this->fromClasses($class, ...[ FilterInterceptor::class, TestInlineFinder::class, + BenchFinder::class, new FilePostfixTestLocatorInterceptor(), new TestoAttributesLocatorInterceptor(), new AssertCollectorInterceptor(), diff --git a/tests/Bench/Self/BenchWithAttr.php b/tests/Bench/Self/BenchWithAttr.php new file mode 100644 index 0000000..64617cc --- /dev/null +++ b/tests/Bench/Self/BenchWithAttr.php @@ -0,0 +1,28 @@ +