From df877813a568abc72e8b17f4b9a94561c0f423d9 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:27:48 +0200 Subject: [PATCH 01/22] Add dev tooling and update dependencies for PHP 8.4+ Upgrade to PHP-DI 7, Symfony 7, adbario 3. Add PHPStan (level 8), PHP-CS-Fixer (PER-CS2.0), and GitHub Actions CI workflow. --- .github/workflows/ci.yml | 37 +++++++++++++++++++++++++++++++++++++ .gitignore | 6 +++++- .php-cs-fixer.php | 13 +++++++++++++ composer.json | 21 +++++++++++++++------ phpstan.neon | 4 ++++ 5 files changed, 74 insertions(+), 7 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .php-cs-fixer.php create mode 100644 phpstan.neon diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..aa85069 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,37 @@ +name: CI + +on: + push: + branches: [master] + pull_request: + branches: [master] + +jobs: + analyse: + name: Static Analysis & Code Style + runs-on: ubuntu-latest + + strategy: + matrix: + php-version: + - 8.4 + - 8.5 + + steps: + - uses: actions/checkout@v6 + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: ${{ matrix.php-version }} + extensions: mysqli + tools: composer + + - name: Install dependencies + run: composer install --no-interaction --prefer-dist + + - name: PHPStan + run: composer analyse + + - name: PHP-CS-Fixer + run: composer cs-check diff --git a/.gitignore b/.gitignore index 043f315..4d9ef1e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ /vendor/ /.idea/ /config/*.yml -composer.lock \ No newline at end of file +composer.lock +.php-cs-fixer.cache +reports/*.json +CLAUDE.md +/.claude diff --git a/.php-cs-fixer.php b/.php-cs-fixer.php new file mode 100644 index 0000000..de81fad --- /dev/null +++ b/.php-cs-fixer.php @@ -0,0 +1,13 @@ +setRules([ + '@PER-CS2.0' => true, + 'strict_param' => true, + 'declare_strict_types' => true, + ]) + ->setFinder( + PhpCsFixer\Finder::create()->in(__DIR__ . '/src') + ); diff --git a/composer.json b/composer.json index ee94eca..3a72b61 100644 --- a/composer.json +++ b/composer.json @@ -16,17 +16,26 @@ } ], "require": { + "php": ">=8.4", "roave/security-advisories": "dev-master", - "php": ">=7.1", - "symfony/config": "^4.1", - "symfony/yaml": "^4.1", - "php-di/php-di": "^6.0", - "adbario/php-dot-notation": "2.2.0", - "ext-mysqli": "^7.1" + "symfony/config": "^7.0", + "symfony/yaml": "^7.0", + "php-di/php-di": "^7.0", + "adbario/php-dot-notation": "^3.0", + "ext-mysqli": "*" + }, + "require-dev": { + "phpstan/phpstan": "^2.0", + "friendsofphp/php-cs-fixer": "^3.0" }, "autoload": { "psr-4": { "Hyperized\\Benchmark\\": "src/" } + }, + "scripts": { + "analyse": "phpstan analyse", + "cs-fix": "php-cs-fixer fix --allow-risky=yes", + "cs-check": "php-cs-fixer fix --dry-run --diff --allow-risky=yes" } } diff --git a/phpstan.neon b/phpstan.neon new file mode 100644 index 0000000..1ff472c --- /dev/null +++ b/phpstan.neon @@ -0,0 +1,4 @@ +parameters: + level: 8 + paths: + - src From a819e762ad82cd079c103d80605c3425821607db Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:28:02 +0200 Subject: [PATCH 02/22] Modernize core utilities and config for PHP 8.4+ Typed properties, constructor promotion, strict_types, readonly. Replace opendir/readdir with RecursiveDirectoryIterator. Update Symfony 7 FileLoader signatures. Remove Table and Visual classes. --- autoload.php | 4 +- info.php | 4 +- src/Config/Config.php | 52 +++----- src/Config/YamlConfigLoader.php | 36 ++---- src/Generic/Directory.php | 65 ++++------ src/Generic/Size.php | 54 ++------- src/Generic/Table.php | 208 -------------------------------- src/Generic/Utility.php | 21 ++-- src/Generic/Visual.php | 21 ---- 9 files changed, 76 insertions(+), 389 deletions(-) delete mode 100644 src/Generic/Table.php delete mode 100644 src/Generic/Visual.php diff --git a/autoload.php b/autoload.php index c76c8bf..1bb7803 100644 --- a/autoload.php +++ b/autoload.php @@ -1,3 +1,5 @@ */ + private array $config = []; - /** - * Config constructor. - */ public function __construct() { $locator = new FileLocator([ - __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . $this->directory + __DIR__ . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . '..' . DIRECTORY_SEPARATOR . $this->directory, ]); try { $loader = new YamlConfigLoader($locator); $this->config = $loader->load( - $locator->locate($this->file) + $locator->locate($this->file), ); - } catch (\Exception $e) { - Visual::print($this->file . ' could not be loaded, please copy ' . $this->directory . '/config.yml.example to ' . $this->directory . '/' . $this->file); + } catch (\Exception) { + echo $this->file . ' could not be loaded, please copy ' . $this->directory . '/config.yml.example to ' . $this->directory . '/' . $this->file . "\n"; } } - /** - * @param string $file - */ public function setFile(string $file): void { $this->file = $file; } - /** - * @param string $directory - */ public function setDirectory(string $directory): void { $this->directory = $directory; } - /** - * @param string $field - * - * @return mixed - */ - public function get(string $field) + public function get(string $field): mixed { $dot = new Dot($this->config); @@ -74,10 +50,10 @@ public function get(string $field) } /** - * @return array + * @return array */ public function getAll(): array { return $this->config; } -} \ No newline at end of file +} diff --git a/src/Config/YamlConfigLoader.php b/src/Config/YamlConfigLoader.php index dca8a66..9a7279c 100644 --- a/src/Config/YamlConfigLoader.php +++ b/src/Config/YamlConfigLoader.php @@ -1,38 +1,28 @@ isDir()) { + \rmdir($file->getPathname()); + } else { + \unlink($file->getPathname()); } - \rmdir($path); } + + \rmdir($path); } /** - * @param $path - * @param $permissions - * - * @return bool - * @throws \Exception + * @throws \RuntimeException */ - public static function create($path, $permissions = 0755): bool + public static function create(string $path, int $permissions = 0755): bool { - if (!\file_exists($path) && !mkdir($path, $permissions) && !is_dir($path)) { + if (!\file_exists($path) && !\mkdir($path, $permissions) && !\is_dir($path)) { throw new \RuntimeException('Could not create directory: ' . $path); } return true; } -} \ No newline at end of file +} diff --git a/src/Generic/Size.php b/src/Generic/Size.php index b46cc97..7556e3f 100644 --- a/src/Generic/Size.php +++ b/src/Generic/Size.php @@ -1,18 +1,13 @@ */ + private static array $units = [ 'B', 'KB', 'MB', @@ -21,46 +16,23 @@ class Size 'PB', 'EB', 'ZB', - 'YB' + 'YB', ]; - /** - * @var string - */ - private static $unitsPattern = 'bkmgtpezy'; - /** - * @var string - */ - private static $unitsRegexPattern = '/[^bkmgtpezy]/i'; - /** - * @var string - */ - private static $numberRegex = '/[^0-9\.]/'; + private static string $unitsPattern = 'bkmgtpezy'; + private static string $unitsRegexPattern = '/[^bkmgtpezy]/i'; + private static string $numberRegex = '/[^0-9\.]/'; - /** - * @param int $bytes - * - * @return string - * - * https://stackoverflow.com/a/11860664/1757763 - */ public static function bytesToFormat(int $bytes): string { - $power = $bytes > 0 ? \floor(\log($bytes, 1024)) : 0; + $power = $bytes > 0 ? (int) \floor(\log($bytes, 1024)) : 0; return \number_format($bytes / (1024 ** $power), 2, '.', ',') . ' ' . self::$units[$power]; } - /** - * @param string $format - * - * @return float - * - * https://stackoverflow.com/a/25370978/1757763 - */ public static function formatToBytes(string $format): float { - $unit = \preg_replace(self::$unitsRegexPattern, '', $format); - $format = \preg_replace(self::$numberRegex, '', $format); - return $unit ? \round($format * (1024 ** \stripos(self::$unitsPattern, $unit[0]))) : \round($format); + $unit = (string) \preg_replace(self::$unitsRegexPattern, '', $format); + $number = (string) \preg_replace(self::$numberRegex, '', $format); + return $unit !== '' ? \round((float) $number * (1024 ** (int) \stripos(self::$unitsPattern, $unit[0]))) : \round((float) $number); } -} \ No newline at end of file +} diff --git a/src/Generic/Table.php b/src/Generic/Table.php deleted file mode 100644 index 7ca1dde..0000000 --- a/src/Generic/Table.php +++ /dev/null @@ -1,208 +0,0 @@ -tableArray = $tableArray; - $this->columnHeaders = $this->columnHeaders($this->tableArray); - $this->columnLength = $this->columnLengths($this->tableArray, $this->columnHeaders); - $this->rowSeparator = $this->rowSeparator($this->columnLength); - $this->rowSpacer = $this->rowSpacer($this->columnLength); - $this->rowHeaders = $this->rowHeaders($this->columnHeaders, $this->columnLength); - - $this->render(); - } - - /** - * @param $table - * - * @return array - */ - private function columnHeaders($table): array - { - return \array_keys(\reset($table)); - } - - /** - * @param $table - * @param $columnHeaders - * - * @return array - */ - private function columnLengths($table, $columnHeaders): array - { - $lengths = []; - foreach ($columnHeaders as $header) { - $header_length = \strlen($header); - $max = $header_length; - foreach ($table as $row) { - $length = \strlen($row[$header]); - if ($length > $max) { - $max = $length; - } - } - - if (($max % 2) !== ($header_length % 2)) { - ++$max; - } - - $lengths[$header] = $max; - } - - return $lengths; - } - - /** - * @param $columnLengths - * - * @return string - */ - private function rowSeparator($columnLengths): string - { - $row = ''; - foreach ($columnLengths as $columnLength) { - $row .= self::$jointCharacter . \str_repeat(self::$lineXCharacter, - (self::$spacingX * 2) + $columnLength); - } - $row .= self::$jointCharacter; - - return $row; - } - - /** - * @param $columnLengths - * - * @return string - */ - private function rowSpacer($columnLengths): string - { - $row = ''; - foreach ($columnLengths as $columnLength) { - $row .= self::$lineYCharacter . \str_repeat(' ', (self::$spacingX * 2) + $columnLength); - } - $row .= self::$lineYCharacter; - - return $row; - } - - /** - * @param $columnHeaders - * @param $columnLengths - * - * @return string - */ - private function rowHeaders($columnHeaders, $columnLengths): string - { - $row = ''; - foreach ($columnHeaders as $header) { - $row .= self::$lineYCharacter . \str_pad($header, (self::$spacingX * 2) + $columnLengths[$header], ' ', - STR_PAD_BOTH); - } - $row .= self::$lineYCharacter; - - return $row; - } - - /** - * - */ - private function render(): void - { - echo $this->rowSeparator . self::$newLine; - echo \str_repeat($this->rowSpacer . self::$newLine, self::$spacingY); - echo $this->rowHeaders . self::$newLine; - echo \str_repeat($this->rowSpacer . self::$newLine, self::$spacingY); - echo $this->rowSeparator . self::$newLine; - echo \str_repeat($this->rowSpacer . self::$newLine, self::$spacingY); - foreach ($this->tableArray as $rowCells) { - $rowCells = $this->rowCells($rowCells, $this->columnHeaders, $this->columnLength); - echo $rowCells . self::$newLine; - echo \str_repeat($this->rowSpacer . self::$newLine, self::$spacingY); - } - echo $this->rowSeparator . self::$newLine; - } - - /** - * @param $rowCells - * @param $columnHeaders - * @param $columnLengths - * - * @return string - */ - private function rowCells($rowCells, $columnHeaders, $columnLengths): string - { - $row = ''; - foreach ($columnHeaders as $header) { - $row .= self::$lineYCharacter . \str_repeat(' ', self::$spacingX) . \str_pad($rowCells[$header], - self::$spacingX + $columnLengths[$header], ' ', STR_PAD_RIGHT); - } - $row .= self::$lineYCharacter; - - return $row; - } - -} \ No newline at end of file diff --git a/src/Generic/Utility.php b/src/Generic/Utility.php index 8d657da..5f7ff9d 100644 --- a/src/Generic/Utility.php +++ b/src/Generic/Utility.php @@ -1,30 +1,25 @@ $variables */ - public static function format(string $string, array $variables) + public static function format(string $string, array $variables): string { - $string = \preg_replace_callback('#\{\}#', function () { + $string = (string) \preg_replace_callback('#\{\}#', function () { static $i = 0; return '{' . ($i++) . '}'; }, $string); return \str_replace( - \array_map(function ($k) { - return '{' . $k . '}'; - }, \array_keys($variables)), \array_values($variables), $string + \array_map(fn(int|string $k): string => '{' . $k . '}', \array_keys($variables)), + \array_map(fn(string|int|float $v): string => (string) $v, \array_values($variables)), + $string, ); } -} \ No newline at end of file +} diff --git a/src/Generic/Visual.php b/src/Generic/Visual.php deleted file mode 100644 index 53f6648..0000000 --- a/src/Generic/Visual.php +++ /dev/null @@ -1,21 +0,0 @@ - Date: Sat, 11 Apr 2026 23:28:08 +0200 Subject: [PATCH 03/22] Add Result DTOs for benchmark data Readonly DTOs implementing JsonSerializable for PHP, CPU, Disk, MySQL, and aggregate BenchmarkResult. --- src/Result/BenchmarkResult.php | 43 ++++++++++++++++++++++++++++++++++ src/Result/CpuResult.php | 36 ++++++++++++++++++++++++++++ src/Result/DiskResult.php | 27 +++++++++++++++++++++ src/Result/MySqlResult.php | 26 ++++++++++++++++++++ src/Result/PhpResult.php | 35 +++++++++++++++++++++++++++ 5 files changed, 167 insertions(+) create mode 100644 src/Result/BenchmarkResult.php create mode 100644 src/Result/CpuResult.php create mode 100644 src/Result/DiskResult.php create mode 100644 src/Result/MySqlResult.php create mode 100644 src/Result/PhpResult.php diff --git a/src/Result/BenchmarkResult.php b/src/Result/BenchmarkResult.php new file mode 100644 index 0000000..3ce2d0b --- /dev/null +++ b/src/Result/BenchmarkResult.php @@ -0,0 +1,43 @@ + + */ + public function jsonSerialize(): array + { + $data = [ + 'timestamp' => $this->timestamp->format('c'), + 'total_duration' => $this->totalDuration, + ]; + + if ($this->php !== null) { + $data['php'] = $this->php; + } + if ($this->cpu !== null) { + $data['cpu'] = $this->cpu; + } + if ($this->disk !== null) { + $data['disk'] = $this->disk; + } + if ($this->mysql !== null) { + $data['mysql'] = $this->mysql; + } + + return $data; + } +} diff --git a/src/Result/CpuResult.php b/src/Result/CpuResult.php new file mode 100644 index 0000000..1fa6b14 --- /dev/null +++ b/src/Result/CpuResult.php @@ -0,0 +1,36 @@ + $mathResults + * @param array $stringResults + */ + public function __construct( + public array $mathResults, + public int $mathCount, + public array $stringResults, + public int $stringCount, + public float $loopsResult, + public int $loopsCount, + public float $ifElseResult, + public int $ifElseCount, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'math' => ['count' => $this->mathCount, 'results' => $this->mathResults], + 'strings' => ['count' => $this->stringCount, 'results' => $this->stringResults], + 'loops' => ['count' => $this->loopsCount, 'result' => $this->loopsResult], + 'if_else' => ['count' => $this->ifElseCount, 'result' => $this->ifElseResult], + ]; + } +} diff --git a/src/Result/DiskResult.php b/src/Result/DiskResult.php new file mode 100644 index 0000000..c89946e --- /dev/null +++ b/src/Result/DiskResult.php @@ -0,0 +1,27 @@ + $fileCreationTimes block size in bytes => total seconds + */ + public function __construct( + public array $fileCreationTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'file_creation_times' => $this->fileCreationTimes, + ]; + } +} diff --git a/src/Result/MySqlResult.php b/src/Result/MySqlResult.php new file mode 100644 index 0000000..5abd5a8 --- /dev/null +++ b/src/Result/MySqlResult.php @@ -0,0 +1,26 @@ + + */ + public function jsonSerialize(): array + { + return [ + 'version' => $this->version, + 'query_time' => $this->queryTime, + 'query_count' => $this->queryCount, + ]; + } +} diff --git a/src/Result/PhpResult.php b/src/Result/PhpResult.php new file mode 100644 index 0000000..53a8d7c --- /dev/null +++ b/src/Result/PhpResult.php @@ -0,0 +1,35 @@ + $extensions + */ + public function __construct( + public string $phpVersion, + public string $server, + public int $maxExecutionTime, + public int $maxMemoryBytes, + public int $maxUploadBytes, + public array $extensions, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'php_version' => $this->phpVersion, + 'server' => $this->server, + 'max_execution_time' => $this->maxExecutionTime, + 'max_memory_bytes' => $this->maxMemoryBytes, + 'max_upload_bytes' => $this->maxUploadBytes, + 'extensions' => $this->extensions, + ]; + } +} From b7c939ce63f69c04240c9c703c272f2ebb11d9eb Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:28:13 +0200 Subject: [PATCH 04/22] Refactor modules to return typed Result objects Modules no longer render output. Each implements BenchmarkModuleInterface with isEnabled() and a typed run() method. Add OutputMode enum for CLI/HTML detection. --- src/Modules/BenchmarkModuleInterface.php | 10 + src/Modules/CPU.php | 240 +++++++++-------------- src/Modules/Disk.php | 138 ++++--------- src/Modules/MySQL.php | 137 ++++--------- src/Modules/PHP.php | 155 +++------------ src/OutputMode.php | 11 ++ 6 files changed, 225 insertions(+), 466 deletions(-) create mode 100644 src/Modules/BenchmarkModuleInterface.php create mode 100644 src/OutputMode.php diff --git a/src/Modules/BenchmarkModuleInterface.php b/src/Modules/BenchmarkModuleInterface.php new file mode 100644 index 0000000..7e85d7f --- /dev/null +++ b/src/Modules/BenchmarkModuleInterface.php @@ -0,0 +1,10 @@ + */ + private static array $mathFunctions = [ 'abs', 'acos', 'asin', 'atan', - 'bindec', 'floor', 'exp', 'sin', 'tan', - 'pi', 'is_finite', 'is_nan', - 'sqrt' + 'sqrt', ]; - /** - * @var array - */ - private static $stringFunctions = [ + + /** @var list Functions that take a string argument */ + private static array $mathStringFunctions = [ + 'bindec', + ]; + + /** @var list Functions that take no arguments */ + private static array $mathNoArgFunctions = [ + 'pi', + ]; + + /** @var list */ + private static array $stringFunctions = [ 'addslashes', 'chunk_split', 'metaphone', @@ -51,172 +51,122 @@ class CPU 'strrev', 'strlen', 'soundex', - 'ord' + 'ord', ]; - /** - * @var string - */ - private static $string = 'the quick brown fox jumps over the lazy dog'; - /** - * @var \Hyperized\Benchmark\Config\Config - */ - private $config; + private static string $string = 'the quick brown fox jumps over the lazy dog'; - /** - * @var array - */ - private $mathResults = []; - /** - * @var array - */ - private $stringsResults = []; - /** - * @var integer - */ - private $loopsResults; - /** - * @var integer - */ - private $ifElseResults; - - /** - * @var - */ - private $mathCount; - /** - * @var - */ - private $stringsCount; - /** - * @var - */ - private $loopsCount; - /** - * @var - */ - private $ifElseCount; + public function __construct(private readonly Config $config) {} - /** - * CPU constructor. - * - * @param \Hyperized\Benchmark\Config\Config $config - */ - public function __construct(Config $config) + public function isEnabled(): bool { - $this->config = $config; - $this->configure(); - - if ($config->get('benchmark.cpu.enabled')) { - $this->run(); - $this->render(); - } + return (bool) $this->config->get('benchmark.cpu.enabled'); } - /** - * Configure - */ - private function configure(): void + public function run(): CpuResult { - $this->mathCount = $this->config->get('benchmark.cpu.math.count') ?? self::$defaultCount; - $this->stringsCount = $this->config->get('benchmark.cpu.strings.count') ?? self::$defaultCount; - $this->loopsCount = $this->config->get('benchmark.cpu.loops.count') ?? self::$defaultCount; - $this->ifElseCount = $this->config->get('benchmark.cpu.ifElse.count') ?? self::$defaultCount; + $mathCount = (int) ($this->config->get('benchmark.cpu.math.count') ?? self::$defaultCount); + $stringsCount = (int) ($this->config->get('benchmark.cpu.strings.count') ?? self::$defaultCount); + $loopsCount = (int) ($this->config->get('benchmark.cpu.loops.count') ?? self::$defaultCount); + $ifElseCount = (int) ($this->config->get('benchmark.cpu.ifElse.count') ?? self::$defaultCount); + + return new CpuResult( + mathResults: $this->math($mathCount), + mathCount: $mathCount, + stringResults: $this->strings($stringsCount), + stringCount: $stringsCount, + loopsResult: $this->loops($loopsCount), + loopsCount: $loopsCount, + ifElseResult: $this->ifElse($ifElseCount), + ifElseCount: $ifElseCount, + ); } /** - * Run! + * @return array */ - private function run(): void + private function math(int $count): array { - $this->math(); - $this->strings(); - $this->loops(); - $this->ifElse(); - } + $results = []; - /** - * Do Maths! - */ - private function math(): void - { foreach (self::$mathFunctions as $function) { - $this->mathResults['x'][$function] = 0; + $callable = $function(...); $start = \microtime(true); - for ($i = 0; $i < $this->mathCount; $i++) { - \call_user_func_array($function, array($i)); + for ($i = 0; $i < $count; $i++) { + $callable($i); } - $this->mathResults['x'][$function] += (\microtime(true) - $start); + $results[$function] = \microtime(true) - $start; } + + foreach (self::$mathStringFunctions as $function) { + $callable = $function(...); + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $callable((string) $i); + } + $results[$function] = \microtime(true) - $start; + } + + foreach (self::$mathNoArgFunctions as $function) { + $callable = $function(...); + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $callable(); + } + $results[$function] = \microtime(true) - $start; + } + + return $results; } /** - * Do string operations + * @return array */ - private function strings(): void + private function strings(int $count): array { + $results = []; + foreach (self::$stringFunctions as $function) { - $this->stringsResults['x'][$function] = 0; + $callable = $function(...); $start = \microtime(true); - for ($i = 0; $i < $this->stringsCount; $i++) { - \call_user_func_array($function, array(self::$string)); + for ($i = 0; $i < $count; $i++) { + $callable(self::$string); } - $this->stringsResults['x'][$function] += (\microtime(true) - $start); + $results[$function] = \microtime(true) - $start; } + + return $results; } - /** - * Loopy loop - */ - private function loops(): void + private function loops(int $count): float { $start = \microtime(true); - for ($i = 0; $i < $this->loopsCount; ++$i) { + for ($i = 0; $i < $count; ++$i) { ; } $i = 0; - while ($i < $this->loopsCount) { + while ($i < $count) { ++$i; } - $this->loopsResults = (\microtime(true) - $start); + return \microtime(true) - $start; } - /** - * ifElseIf really .. - */ - private function ifElse(): void + private function ifElse(int $count): float { $start = \microtime(true); - for ($i = 0; $i < $this->ifElseCount; $i++) { - if ($i === -1) { + for ($i = 0; $i < $count; $i++) { + if ($i === -1) { /** @phpstan-ignore identical.alwaysFalse */ ; - } elseif ($i === -2) { + } elseif ($i === -2) { /** @phpstan-ignore identical.alwaysFalse */ ; - } else if ($i === -3) { + } elseif ($i === -3) { /** @phpstan-ignore identical.alwaysFalse */ ; } } - $this->ifElseResults = (\microtime(true) - $start); - } - - /** - * Render - */ - private function render(): void - { - Visual::print('== CPU performance information', "\n"); - Visual::print('Math operation results by function in milliseconds (less is better), for a total of ' . $this->mathCount . ' cycles:'); - new Table($this->mathResults); - Visual::print(' ', "\n"); - Visual::print('String operation results by function in milliseconds (less is better), for a total of ' . $this->stringsCount . ' cycles:'); - new Table($this->stringsResults); - Visual::print(' ', "\n"); - Visual::print('Loop operation results in milliseconds (less is better), for a total of ' . $this->loopsCount . ' cycles: ' . $this->loopsResults); - Visual::print('If/Else operation results in milliseconds (less is better), for a total of ' . $this->ifElseCount . ' cycles: ' . $this->ifElseResults); - Visual::print(' ', "\n"); + return \microtime(true) - $start; } -} \ No newline at end of file +} diff --git a/src/Modules/Disk.php b/src/Modules/Disk.php index fedfb2b..11d53db 100644 --- a/src/Modules/Disk.php +++ b/src/Modules/Disk.php @@ -1,32 +1,20 @@ */ + private static array $commonBlockSizesBytes = [ 512, 1024, 2048, @@ -36,96 +24,50 @@ class Disk 32678, 65536, ]; - /** - * @var int - */ - private $initial; - /** - * @var - */ - private $tmpDirectoryPath; - /** - * @var array - */ - private $counterFileCreation = []; - /** - * @var int - */ - private $cycles = 1; - - /** - * Disk constructor. - * - * @param \Hyperized\Benchmark\Config\Config $config - */ - public function __construct(Config $config) + + public function __construct(private readonly Config $config) {} + + public function isEnabled(): bool { - if ($config->get('benchmark.disk.enabled')) { - $this->cycles = $config->get('benchmark.disk.cycles'); - $this->run(); - $this->render(); - } + return (bool) $this->config->get('benchmark.disk.enabled'); } - /** - * Run! - */ - private function run(): void + public function run(): DiskResult { - $this->initial = \time(); - $this->tmpDirectoryPath = \realpath(self::$path) . self::$tmpDirectory; + $cycles = (int) $this->config->get('benchmark.disk.cycles'); + $initial = \time(); + $tmpDirectoryPath = \realpath(self::$path) . self::$tmpDirectory; - try { - // Create subdirectory - Directory::create($this->tmpDirectoryPath); + /** @var array $fileCreationTimes */ + $fileCreationTimes = []; - foreach (self::$commonBlockSizesBytes as $bytes) { - $this->counterFileCreation['Run'][$bytes] = 0; - } + Directory::create($tmpDirectoryPath); + + foreach (self::$commonBlockSizesBytes as $bytes) { + $fileCreationTimes[$bytes] = 0.0; + } - for ($c = $this->cycles; $c >= 0; $c--) { - // Generate files with different block sizes - foreach (self::$commonBlockSizesBytes as $bytes) { - $prefix = $this->initial . '_' . $bytes; - $content = $this->getRandomBytes($bytes); + for ($c = $cycles; $c >= 0; $c--) { + foreach (self::$commonBlockSizesBytes as $bytes) { + $prefix = $initial . '_' . $bytes; + $content = \random_bytes($bytes); - // Start the timer (measure only disk interaction, not string generation etc) - $start = \microtime(true); + $start = \microtime(true); - $file = \tempnam($this->tmpDirectoryPath, $prefix); + $file = \tempnam($tmpDirectoryPath, $prefix); + if ($file !== false) { \file_put_contents($file, $content); - - // Stop timer & append time to timer array with this block size - $this->counterFileCreation['Run'][$bytes] += (\microtime(true) - $start); } - } - // Clean up - Directory::removeRecursively($this->tmpDirectoryPath); - } catch (\Exception $e) { - Visual::print($e); + $fileCreationTimes[$bytes] += (\microtime(true) - $start); + } } - } - /** - * @param $bytes - * - * @return string - * @throws \Exception - */ - private function getRandomBytes($bytes): string - { - return random_bytes($bytes); - } + Directory::removeRecursively($tmpDirectoryPath); - /** - * Render - */ - private function render(): void - { - Visual::print('== Disk performance information', "\n"); - Visual::print('Results sorted by file size (in bytes) in milliseconds (less is better), for a total of ' . $this->cycles . ' cycles:', "\n"); - new Table($this->counterFileCreation); - Visual::print(' ', "\n"); + return new DiskResult( + fileCreationTimes: $fileCreationTimes, + cycles: $cycles, + ); } -} \ No newline at end of file +} diff --git a/src/Modules/MySQL.php b/src/Modules/MySQL.php index 451c41e..52434b6 100644 --- a/src/Modules/MySQL.php +++ b/src/Modules/MySQL.php @@ -1,113 +1,58 @@ config = $config; - - if ($config->get('benchmark.mysql.enabled')) { - $this->configure(); - $this->run(); - $this->render(); - } - } - - /** - * Configure - */ - private function configure(): void - { - $this->connection = \mysqli_connect( - $this->config->get('benchmark.mysql.hostname'), - $this->config->get('benchmark.mysql.username'), - $this->config->get('benchmark.mysql.password'), - $this->config->get('benchmark.mysql.database') - ); - } + private static string $benchmarkQuery = 'SELECT BENCHMARK({},SHA2(\'{}\',256));'; + private static string $benchmarkText = 'hello'; - /** - * Run - */ - private function run(): void - { - $this->getVersion(); - $this->encodeRand(); - } + public function __construct(private readonly Config $config) {} - /** - * Obtain MySQL version - */ - private function getVersion(): void + public function isEnabled(): bool { - $this->version = \mysqli_get_server_version($this->connection); + return (bool) $this->config->get('benchmark.mysql.enabled'); } - /** - * Run encode with Random query - */ - private function encodeRand(): void + public function run(): MySqlResult { - $query = Utility::format($this->benchmarkQuery, [ - $this->config->get('benchmark.mysql.count'), - $this->benchmarkText - ]); - - $start = \microtime(true); - - \mysqli_query($this->connection, $query); - - $this->queryResults = (\microtime(true) - $start); + $connection = new \mysqli( + (string) $this->config->get('benchmark.mysql.hostname'), + (string) $this->config->get('benchmark.mysql.username'), + (string) $this->config->get('benchmark.mysql.password'), + (string) $this->config->get('benchmark.mysql.database'), + ); - } + if ($connection->connect_error) { + throw new \RuntimeException('MySQL connection failed: ' . $connection->connect_error); + } - /** - * Render - */ - private function render(): void - { - Visual::print('== MySQL performance information', "\n"); - Visual::print('MySQL version: ' . $this->version, "\n"); - Visual::print('Query (Encode + random) operation results in milliseconds (less is better), for a total of ' . $this->config->get('benchmark.mysql.count') . ' cycles: ' . $this->queryResults); + try { + $version = $connection->server_version; + $queryCount = (int) $this->config->get('benchmark.mysql.count'); + + $query = Utility::format(self::$benchmarkQuery, [ + (string) $queryCount, + self::$benchmarkText, + ]); + + $start = \microtime(true); + $connection->query($query); + $queryTime = \microtime(true) - $start; + + return new MySqlResult( + version: $version, + queryTime: $queryTime, + queryCount: $queryCount, + ); + } finally { + $connection->close(); + } } -} \ No newline at end of file +} diff --git a/src/Modules/PHP.php b/src/Modules/PHP.php index 0c46d57..7bc144a 100644 --- a/src/Modules/PHP.php +++ b/src/Modules/PHP.php @@ -1,152 +1,53 @@ get('benchmark.php.enabled')) { - $this->run(); - $this->render(); - } - } - - /** - * Run! - */ - private function run(): void - { - $this->extensions = $this->getExtensions(); - $this->maxUploadBytes = $this->getMaxUploadBytes(); - $this->maxMemoryBytes = $this->getMaxMemoryBytes(); - $this->server = $this->getServer(); - } + public function __construct(private readonly Config $config) {} - /** - * @return array - */ - private function getExtensions(): array + public function isEnabled(): bool { - return \get_loaded_extensions(); + return (bool) $this->config->get('benchmark.php.enabled'); } - /** - * @return int - * - * https://stackoverflow.com/a/25370978/1757763 - */ - private function getMaxUploadBytes(): int + public function run(): PhpResult { - static $max_size = -1; - - if ($max_size < 0) { - // Start with post_max_size. - $post_max_size = Size::formatToBytes(\ini_get('post_max_size')); - if ($post_max_size > 0) { - $max_size = $post_max_size; - } - - // If upload_max_size is less, then reduce. Except if upload_max_size is - // zero, which indicates no limit. - $upload_max = Size::formatToBytes(\ini_get('upload_max_filesize')); - if ($upload_max > 0 && $upload_max < $max_size) { - $max_size = $upload_max; - } + $extensions = []; + foreach (\get_loaded_extensions() as $ext) { + $extensions[] = ['name' => $ext, 'version' => \phpversion($ext) ?: '']; } - return $max_size; - } - /** - * @return int - */ - private function getMaxMemoryBytes(): int - { - return (int)Size::formatToBytes(\ini_get('memory_limit')); + return new PhpResult( + phpVersion: PHP_VERSION, + server: $_SERVER['SERVER_SOFTWARE'] ?? 'CLI', + maxExecutionTime: (int) \ini_get('max_execution_time'), + maxMemoryBytes: (int) Size::formatToBytes((string) \ini_get('memory_limit')), + maxUploadBytes: $this->getMaxUploadBytes(), + extensions: $extensions, + ); } - /** - * @return string - */ - private function getServer(): string + private function getMaxUploadBytes(): int { - if (isset($_SERVER['SERVER_SOFTWARE'])) { - return $_SERVER['SERVER_SOFTWARE']; - } - return 'Probably CLI'; - } + $max_size = -1; - /** - * Gives output - */ - private function render(): void - { - Visual::print('== Generic PHP information'); - Visual::print('PHP version: ' . $this->getVersion(), "\n"); - Visual::print('Server: ' . $this->server, "\n"); - Visual::print('Maximum execution time: ' . $this->getMaximumExecutionTime() . ' seconds'); - Visual::print('Maximum memory size: ' . Size::bytesToFormat($this->maxMemoryBytes) . ' (' . $this->maxMemoryBytes . ' bytes)', "\n"); - Visual::print('Maximum upload size: ' . Size::bytesToFormat($this->maxUploadBytes) . ' (' . $this->maxUploadBytes . ' bytes)'); - Visual::print('Modules loaded:', "\n"); - foreach ($this->extensions as $extension) { - Visual::print(' ' . $extension . ' (' . $this->getVersion($extension) . ')', "\n"); + $post_max_size = Size::formatToBytes((string) \ini_get('post_max_size')); + if ($post_max_size > 0) { + $max_size = $post_max_size; } - Visual::print(' ', "\n"); - } - /** - * @param null $extension - * - * @return string - */ - private function getVersion($extension = null): string - { - if ($extension !== null) { - return \phpversion($extension); + $upload_max = Size::formatToBytes((string) \ini_get('upload_max_filesize')); + if ($upload_max > 0 && $upload_max < $max_size) { + $max_size = $upload_max; } - return PHP_VERSION; - } - /** - * @return string - */ - private function getMaximumExecutionTime(): string - { - return \ini_get('max_execution_time'); + return (int) $max_size; } -} \ No newline at end of file +} diff --git a/src/OutputMode.php b/src/OutputMode.php new file mode 100644 index 0000000..70c64dd --- /dev/null +++ b/src/OutputMode.php @@ -0,0 +1,11 @@ + Date: Sat, 11 Apr 2026 23:28:21 +0200 Subject: [PATCH 05/22] Add CLI and HTML renderers CliRenderer with ANSI colors and Unicode box-drawing tables. HtmlRenderer with standalone styled HTML dashboard and responsive CSS grid layout. --- src/Renderer/CliRenderer.php | 229 +++++++++++++++++++++++ src/Renderer/HtmlRenderer.php | 291 +++++++++++++++++++++++++++++ src/Renderer/RendererInterface.php | 12 ++ 3 files changed, 532 insertions(+) create mode 100644 src/Renderer/CliRenderer.php create mode 100644 src/Renderer/HtmlRenderer.php create mode 100644 src/Renderer/RendererInterface.php diff --git a/src/Renderer/CliRenderer.php b/src/Renderer/CliRenderer.php new file mode 100644 index 0000000..11f4e51 --- /dev/null +++ b/src/Renderer/CliRenderer.php @@ -0,0 +1,229 @@ +renderHeader($result); + + if ($result->php !== null) { + $this->renderPhp($result->php); + } + if ($result->disk !== null) { + $this->renderDisk($result->disk); + } + if ($result->cpu !== null) { + $this->renderCpu($result->cpu); + } + if ($result->mysql !== null) { + $this->renderMySql($result->mysql); + } + + $this->renderFooter($result, $reportPath); + } + + private function renderHeader(BenchmarkResult $result): void + { + $title = 'PHP Benchmark Report'; + $date = $result->timestamp->format('Y-m-d H:i:s'); + $innerWidth = self::WIDTH - 2; + + echo "\n"; + echo self::BOLD_CYAN . '╔' . \str_repeat('═', $innerWidth) . '╗' . self::RESET . "\n"; + echo self::BOLD_CYAN . '║' . self::BOLD_WHITE . \str_pad(' ' . $title, $innerWidth) . self::BOLD_CYAN . '║' . self::RESET . "\n"; + echo self::BOLD_CYAN . '║' . self::DIM . \str_pad(' ' . $date, $innerWidth) . self::BOLD_CYAN . '║' . self::RESET . "\n"; + echo self::BOLD_CYAN . '╚' . \str_repeat('═', $innerWidth) . '╝' . self::RESET . "\n"; + } + + private function renderPhp(PhpResult $php): void + { + $this->sectionTitle('PHP'); + + $this->keyValue('PHP Version', $php->phpVersion); + $this->keyValue('Server', $php->server); + $this->keyValue('Max Memory', Size::bytesToFormat($php->maxMemoryBytes)); + $this->keyValue('Max Upload', Size::bytesToFormat($php->maxUploadBytes)); + $this->keyValue('Max Exec Time', $php->maxExecutionTime . ' seconds'); + $this->keyValue('Extensions', \count($php->extensions) . ' loaded'); + echo "\n"; + } + + private function renderDisk(DiskResult $disk): void + { + $this->sectionTitle('Disk I/O'); + + echo ' ' . self::DIM . 'Cycles: ' . self::RESET . self::GREEN . $disk->cycles . self::RESET . "\n"; + echo ' ' . self::DIM . 'Time in seconds (lower is better)' . self::RESET . "\n\n"; + + /** @var array $headers */ + $headers = []; + /** @var array $values */ + $values = []; + foreach ($disk->fileCreationTimes as $blockSize => $time) { + $label = Size::bytesToFormat($blockSize); + $headers[$label] = $label; + $values[$label] = \sprintf('%.4fs', $time); + } + + echo $this->buildTable($headers, [$values]); + } + + private function renderCpu(CpuResult $cpu): void + { + $this->sectionTitle('CPU'); + + echo ' ' . self::DIM . 'Time in seconds (lower is better)' . self::RESET . "\n\n"; + + echo ' ' . self::BOLD . 'Math' . self::RESET . self::DIM . ' (' . $cpu->mathCount . ' cycles)' . self::RESET . "\n\n"; + echo $this->buildResultTable($cpu->mathResults); + + echo ' ' . self::BOLD . 'Strings' . self::RESET . self::DIM . ' (' . $cpu->stringCount . ' cycles)' . self::RESET . "\n\n"; + echo $this->buildResultTable($cpu->stringResults); + + $this->keyValue('Loops', \sprintf('%.6fs', $cpu->loopsResult) . self::DIM . ' (' . $cpu->loopsCount . ' cycles)'); + $this->keyValue('If/Else', \sprintf('%.6fs', $cpu->ifElseResult) . self::DIM . ' (' . $cpu->ifElseCount . ' cycles)'); + echo "\n"; + } + + private function renderMySql(MySqlResult $mysql): void + { + $this->sectionTitle('MySQL'); + + $this->keyValue('Version', (string) $mysql->version); + $this->keyValue('Query Time', \sprintf('%.4fs', $mysql->queryTime) . self::DIM . ' (' . $mysql->queryCount . ' cycles, lower is better)'); + echo "\n"; + } + + private function renderFooter(BenchmarkResult $result, ?string $reportPath): void + { + echo self::DIM . \str_repeat('─', self::WIDTH) . self::RESET . "\n"; + echo ' ' . self::DIM . 'Total Duration ' . self::RESET . self::GREEN . \sprintf('%.3fs', $result->totalDuration) . self::RESET . "\n"; + if ($reportPath !== null) { + echo ' ' . self::DIM . 'Report saved ' . self::RESET . self::YELLOW . $reportPath . self::RESET . "\n"; + } + echo self::DIM . \str_repeat('─', self::WIDTH) . self::RESET . "\n\n"; + } + + private function sectionTitle(string $title): void + { + $line = \str_repeat('─', self::WIDTH - \mb_strlen($title) - 4); + echo "\n" . self::BOLD_CYAN . '── ' . $title . ' ' . $line . self::RESET . "\n\n"; + } + + private function keyValue(string $key, string $value): void + { + echo ' ' . self::DIM . \str_pad($key, 16) . self::RESET . self::GREEN . $value . self::RESET . "\n"; + } + + /** + * @param array $results + */ + private function buildResultTable(array $results): string + { + /** @var array $headers */ + $headers = []; + /** @var array $values */ + $values = []; + foreach ($results as $name => $time) { + $headers[$name] = $name; + $values[$name] = \sprintf('%.4fs', $time); + } + + return $this->buildTable($headers, [$values]); + } + + /** + * @param array $headers + * @param list> $rows + */ + private function buildTable(array $headers, array $rows): string + { + // Calculate column widths + /** @var array $widths */ + $widths = []; + foreach ($headers as $key => $header) { + $widths[$key] = \max(\mb_strlen($header), 8); + foreach ($rows as $row) { + $widths[$key] = \max($widths[$key], \mb_strlen($row[$key] ?? '')); + } + } + + $output = ''; + $pad = 1; + + // Top border + $output .= ' ' . self::DIM . '┌'; + $first = true; + foreach ($widths as $w) { + if (!$first) { + $output .= '┬'; + } + $output .= \str_repeat('─', $w + $pad * 2); + $first = false; + } + $output .= '┐' . self::RESET . "\n"; + + // Header row + $output .= ' ' . self::DIM . '│' . self::RESET; + foreach ($headers as $key => $header) { + $output .= \str_repeat(' ', $pad) . self::BOLD . \str_pad($header, $widths[$key]) . self::RESET . \str_repeat(' ', $pad) . self::DIM . '│' . self::RESET; + } + $output .= "\n"; + + // Separator + $output .= ' ' . self::DIM . '├'; + $first = true; + foreach ($widths as $w) { + if (!$first) { + $output .= '┼'; + } + $output .= \str_repeat('─', $w + $pad * 2); + $first = false; + } + $output .= '┤' . self::RESET . "\n"; + + // Data rows + foreach ($rows as $row) { + $output .= ' ' . self::DIM . '│' . self::RESET; + foreach ($headers as $key => $_) { + $output .= \str_repeat(' ', $pad) . self::GREEN . \str_pad($row[$key] ?? '', $widths[$key]) . self::RESET . \str_repeat(' ', $pad) . self::DIM . '│' . self::RESET; + } + $output .= "\n"; + } + + // Bottom border + $output .= ' ' . self::DIM . '└'; + $first = true; + foreach ($widths as $w) { + if (!$first) { + $output .= '┴'; + } + $output .= \str_repeat('─', $w + $pad * 2); + $first = false; + } + $output .= '┘' . self::RESET . "\n\n"; + + return $output; + } +} diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php new file mode 100644 index 0000000..10181c7 --- /dev/null +++ b/src/Renderer/HtmlRenderer.php @@ -0,0 +1,291 @@ +'; + echo ''; + echo ''; + echo ''; + echo 'PHP Benchmark Report'; + echo ''; + echo ''; + + $this->renderHeader($result); + + echo '
'; + + if ($result->php !== null) { + $this->renderPhp($result->php); + } + if ($result->disk !== null) { + $this->renderDisk($result->disk); + } + if ($result->cpu !== null) { + $this->renderCpu($result->cpu); + } + if ($result->mysql !== null) { + $this->renderMySql($result->mysql); + } + + echo '
'; + + $this->renderFooter($result, $reportPath); + + echo ''; + } + + private function renderHeader(BenchmarkResult $result): void + { + $date = $this->esc($result->timestamp->format('Y-m-d H:i:s')); + $duration = \sprintf('%.3fs', $result->totalDuration); + + echo ''; + } + + private function renderPhp(PhpResult $php): void + { + echo '
'; + echo '
PHP
'; + echo '
'; + + echo '
'; + $this->dt('PHP Version', $php->phpVersion); + $this->dt('Server', $php->server); + $this->dt('Max Memory', Size::bytesToFormat($php->maxMemoryBytes)); + $this->dt('Max Upload', Size::bytesToFormat($php->maxUploadBytes)); + $this->dt('Max Exec Time', $php->maxExecutionTime . 's'); + echo '
'; + + echo '
' . \count($php->extensions) . ' extensions loaded'; + echo '
'; + foreach ($php->extensions as $ext) { + echo '' . $this->esc($ext['name']) . ' ' . $this->esc($ext['version']) . ''; + } + echo '
'; + + echo '
'; + } + + private function renderDisk(DiskResult $disk): void + { + echo '
'; + echo '
Disk I/O
'; + echo '
'; + echo '

' . $disk->cycles . ' cycles · seconds (lower is better)

'; + + echo ''; + foreach ($disk->fileCreationTimes as $blockSize => $_) { + echo ''; + } + echo ''; + foreach ($disk->fileCreationTimes as $time) { + echo ''; + } + echo '
' . $this->esc(Size::bytesToFormat($blockSize)) . '
' . \sprintf('%.4fs', $time) . '
'; + + echo '
'; + } + + private function renderCpu(CpuResult $cpu): void + { + echo '
'; + echo '
CPU
'; + echo '
'; + + echo '

seconds (lower is better)

'; + + echo '

Math (' . $cpu->mathCount . ' cycles)

'; + $this->renderResultTable($cpu->mathResults); + + echo '

Strings (' . $cpu->stringCount . ' cycles)

'; + $this->renderResultTable($cpu->stringResults); + + echo '
'; + $this->dt('Loops', \sprintf('%.6fs', $cpu->loopsResult) . ' (' . $cpu->loopsCount . ' cycles)'); + $this->dt('If/Else', \sprintf('%.6fs', $cpu->ifElseResult) . ' (' . $cpu->ifElseCount . ' cycles)'); + echo '
'; + + echo '
'; + } + + private function renderMySql(MySqlResult $mysql): void + { + echo '
'; + echo '
MySQL
'; + echo '
'; + echo '
'; + $this->dt('Version', (string) $mysql->version); + $this->dt('Query Time', \sprintf('%.4fs', $mysql->queryTime) . ' (' . $mysql->queryCount . ' cycles, lower is better)'); + echo '
'; + echo '
'; + } + + private function renderFooter(BenchmarkResult $result, ?string $reportPath): void + { + echo '
'; + echo 'Total duration: ' . \sprintf('%.3fs', $result->totalDuration) . ''; + if ($reportPath !== null) { + echo 'Report: ' . $this->esc($reportPath) . ''; + } + echo '
'; + } + + /** + * @param array $results + */ + private function renderResultTable(array $results): void + { + echo ''; + foreach (\array_keys($results) as $name) { + echo ''; + } + echo ''; + foreach ($results as $time) { + echo ''; + } + echo '
' . $this->esc($name) . '
' . \sprintf('%.4fs', $time) . '
'; + } + + private function dt(string $key, string $value): void + { + echo '
' . $this->esc($key) . '
' . $this->esc($value) . '
'; + } + + private function esc(string $value): string + { + return \htmlspecialchars($value, ENT_QUOTES | ENT_HTML5); + } + + private function css(): string + { + return <<<'CSS' + * { margin: 0; padding: 0; box-sizing: border-box; } + body { + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; + background: #f0f2f5; + color: #1a1a2e; + line-height: 1.6; + } + .banner { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + color: #fff; + padding: 2rem; + text-align: center; + } + .banner h1 { font-size: 1.8rem; font-weight: 600; } + .banner .meta { margin-top: 0.5rem; opacity: 0.8; display: flex; justify-content: center; gap: 2rem; } + .banner .duration { font-family: "SF Mono", "Cascadia Code", monospace; color: #4ecca3; } + .grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); + gap: 1.5rem; + padding: 2rem; + max-width: 1400px; + margin: 0 auto; + } + .card { + background: #fff; + border-radius: 12px; + box-shadow: 0 2px 8px rgba(0,0,0,0.08); + overflow: hidden; + } + .card-wide { grid-column: 1 / -1; } + .card-title { + background: #1a1a2e; + color: #4ecca3; + padding: 0.75rem 1.25rem; + font-weight: 600; + font-size: 0.95rem; + text-transform: uppercase; + letter-spacing: 0.05em; + } + .card-body { padding: 1.25rem; } + .card-body h3 { margin: 1rem 0 0.5rem; color: #16213e; font-size: 0.95rem; } + .card-body h3 small { color: #888; font-weight: 400; } + .card-body h3:first-child { margin-top: 0; } + dl.kv { display: grid; grid-template-columns: auto 1fr; gap: 0.3rem 1rem; } + dl.kv dt { color: #666; font-size: 0.9rem; } + dl.kv dd { font-family: "SF Mono", "Cascadia Code", monospace; font-size: 0.9rem; color: #1a1a2e; } + table { + width: 100%; + border-collapse: collapse; + margin: 0.5rem 0 1rem; + font-size: 0.85rem; + } + table th { + background: #f0f2f5; + padding: 0.5rem 0.75rem; + text-align: left; + font-weight: 600; + color: #16213e; + border-bottom: 2px solid #ddd; + } + table td { + padding: 0.5rem 0.75rem; + border-bottom: 1px solid #eee; + font-family: "SF Mono", "Cascadia Code", monospace; + color: #2d6a4f; + } + table tr:last-child td { border-bottom: none; } + .subtitle { color: #888; font-size: 0.9rem; margin-bottom: 0.75rem; } + details { margin-top: 1rem; } + summary { + cursor: pointer; + color: #16213e; + font-weight: 500; + font-size: 0.9rem; + padding: 0.3rem 0; + } + .ext-list { + display: flex; + flex-wrap: wrap; + gap: 0.4rem; + margin-top: 0.5rem; + } + .ext { + background: #f0f2f5; + padding: 0.2rem 0.6rem; + border-radius: 4px; + font-size: 0.8rem; + font-family: "SF Mono", "Cascadia Code", monospace; + } + .ext small { color: #888; } + footer { + text-align: center; + padding: 1.5rem 2rem; + color: #888; + font-size: 0.85rem; + display: flex; + justify-content: center; + gap: 2rem; + } + footer strong { color: #1a1a2e; } + footer code { background: #f0f2f5; padding: 0.1rem 0.4rem; border-radius: 3px; } + @media (max-width: 600px) { + .grid { grid-template-columns: 1fr; padding: 1rem; gap: 1rem; } + .banner { padding: 1.5rem 1rem; } + .banner h1 { font-size: 1.4rem; } + footer { flex-direction: column; gap: 0.5rem; } + } + CSS; + } +} diff --git a/src/Renderer/RendererInterface.php b/src/Renderer/RendererInterface.php new file mode 100644 index 0000000..3aafe23 --- /dev/null +++ b/src/Renderer/RendererInterface.php @@ -0,0 +1,12 @@ + Date: Sat, 11 Apr 2026 23:28:25 +0200 Subject: [PATCH 06/22] Add JSON report system Save timestamped JSON reports to reports/ on each benchmark run. --- reports/.gitkeep | 0 src/Report/Report.php | 35 +++++++++++++++++++++++++++++++++++ src/Report/ReportWriter.php | 29 +++++++++++++++++++++++++++++ 3 files changed, 64 insertions(+) create mode 100644 reports/.gitkeep create mode 100644 src/Report/Report.php create mode 100644 src/Report/ReportWriter.php diff --git a/reports/.gitkeep b/reports/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/Report/Report.php b/src/Report/Report.php new file mode 100644 index 0000000..a1302c9 --- /dev/null +++ b/src/Report/Report.php @@ -0,0 +1,35 @@ + + */ + public function jsonSerialize(): array + { + return [ + 'generated_at' => $this->result->timestamp->format('c'), + 'hostname' => $this->hostname, + 'sapi' => $this->phpSapi, + 'duration_seconds' => $this->result->totalDuration, + 'modules' => [ + 'php' => $this->result->php, + 'cpu' => $this->result->cpu, + 'disk' => $this->result->disk, + 'mysql' => $this->result->mysql, + ], + ]; + } +} diff --git a/src/Report/ReportWriter.php b/src/Report/ReportWriter.php new file mode 100644 index 0000000..749e23d --- /dev/null +++ b/src/Report/ReportWriter.php @@ -0,0 +1,29 @@ +reportsDirectory)) { + \mkdir($this->reportsDirectory, 0755, true); + } + + $filename = $report->result->timestamp->format('Y-m-d_His') . '.json'; + $filePath = $this->reportsDirectory . DIRECTORY_SEPARATOR . $filename; + + \file_put_contents( + $filePath, + \json_encode($report, JSON_PRETTY_PRINT | JSON_THROW_ON_ERROR), + ); + + return $filePath; + } +} From bdc362c795ac319b19ce73e50228e8c11f3bc55c Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:28:30 +0200 Subject: [PATCH 07/22] Rewrite orchestrator and entry point Benchmark.php orchestrates modules, builds results, saves reports, and dispatches to the appropriate renderer. Auto-detects output mode from SAPI with config override support. --- benchmark.php | 23 ++++++---- config/config.yml.example | 5 ++- src/Benchmark.php | 90 +++++++++++++++++++++++++++------------ 3 files changed, 81 insertions(+), 37 deletions(-) diff --git a/benchmark.php b/benchmark.php index bd7ea0a..e76339b 100644 --- a/benchmark.php +++ b/benchmark.php @@ -1,22 +1,27 @@ addDefinitions([ + ReportWriter::class => \DI\create(ReportWriter::class) + ->constructor(__DIR__ . DIRECTORY_SEPARATOR . 'reports'), +]); $container = $builder->build(); -echo '
';
 try {
     $benchmark = $container->get(Benchmark::class);
-} catch (DependencyException $e) {
-    \print_r($e);
-} catch (NotFoundException $e) {
-    \print_r($e);
+    $benchmark->execute();
+} catch (\Throwable $e) {
+    if (\php_sapi_name() === 'cli') {
+        \fwrite(STDERR, $e->getMessage() . "\n");
+        exit(1);
+    }
+    echo '
' . \htmlspecialchars($e->getMessage(), ENT_QUOTES | ENT_HTML5) . '
'; } -echo '
'; - diff --git a/config/config.yml.example b/config/config.yml.example index c9072a9..752ddd0 100644 --- a/config/config.yml.example +++ b/config/config.yml.example @@ -1,5 +1,8 @@ --- benchmark: + output: + format: ~ # null = auto-detect, 'cli', or 'html' + report: true # save JSON report to reports/ php: enabled: true disk: @@ -22,4 +25,4 @@ benchmark: port: 3306 username: myuser password: mypassword - database: mydatabase \ No newline at end of file + database: mydatabase diff --git a/src/Benchmark.php b/src/Benchmark.php index 52a877e..6ff06ea 100644 --- a/src/Benchmark.php +++ b/src/Benchmark.php @@ -1,5 +1,7 @@ php->isEnabled() ? $this->php->run() : null; + $diskResult = $this->disk->isEnabled() ? $this->disk->run() : null; + $cpuResult = $this->cpu->isEnabled() ? $this->cpu->run() : null; + $mysqlResult = $this->mysql->isEnabled() ? $this->mysql->run() : null; + + $result = new BenchmarkResult( + timestamp: $timestamp, + totalDuration: \microtime(true) - $start, + php: $phpResult, + cpu: $cpuResult, + disk: $diskResult, + mysql: $mysqlResult, + ); + + $reportEnabled = (bool) ($this->config->get('benchmark.output.report') ?? true); + $reportPath = null; + + if ($reportEnabled) { + $report = new Report( + result: $result, + hostname: \gethostname() ?: 'unknown', + phpSapi: \php_sapi_name() ?: 'unknown', + ); + $reportPath = $this->reportWriter->write($report); + } + + $renderer = match ($this->detectOutputMode()) { + OutputMode::Cli => new CliRenderer(), + OutputMode::Html => new HtmlRenderer(), + }; + + $renderer->render($result, $reportPath); + } + + private function detectOutputMode(): OutputMode { - // Autowired via PHP DI + $override = $this->config->get('benchmark.output.format'); + if ($override !== null) { + return OutputMode::from((string) $override); + } + + return \php_sapi_name() === 'cli' ? OutputMode::Cli : OutputMode::Html; } -} \ No newline at end of file +} From ef5efdcb32939339a8e3463fe0f59b4163a43abc Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:28:36 +0200 Subject: [PATCH 08/22] Update README for new architecture Document output modes, report system, and configuration options. --- README.md | 100 ++++++++++++++++++++++++++++++++++-------------------- 1 file changed, 63 insertions(+), 37 deletions(-) diff --git a/README.md b/README.md index bee6371..00ae55b 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,81 @@ # PHP benchmark -[![Build Status](https://scrutinizer-ci.com/g/hyperized/benchmark/badges/build.png?b=master)](https://scrutinizer-ci.com/g/hyperized/benchmark/build-status/master) [![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/hyperized/benchmark/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/hyperized/benchmark/?branch=master) -[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fhyperized%2Fbenchmark.svg?type=shield)](https://app.fossa.io/projects/git%2Bgithub.com%2Fhyperized%2Fbenchmark?ref=badge_shield) -Simple PHP server benchmarking. +[![CI](https://github.com/hyperized/benchmark/actions/workflows/ci.yml/badge.svg)](https://github.com/hyperized/benchmark/actions/workflows/ci.yml) -This tool can help you determine if a hosting environment is suited for your projects in terms of: -- PHP.ini settings that affect uploads; -- CPU speed available to your PHP instance; -- Disk IOPS available to your PHP instance; -- MySQL query speed; +Simple PHP server benchmarking utility that tests hosting environment capabilities: -## How to install: - composer create-project hyperized/benchmark:dev-master - -Copy the `/config/config.yml.example` to `/config/config.yml` and adjust to your preferences. - -## How to run: +- **PHP** — version, ini settings (memory, uploads, execution time), loaded extensions +- **CPU** — math functions, string operations, loops, conditionals +- **Disk I/O** — file creation across block sizes +- **MySQL** — query performance -### Locally with CLI - php benchmark.php +Auto-detects CLI vs web and renders with the appropriate output mode: +- **CLI** — ANSI colors with Unicode box-drawing tables +- **Web** — styled HTML dashboard with responsive CSS grid layout -### Locally with development server +Each run saves a JSON report with a timestamp to `reports/`. - php -S localhost:8000 benchmark.php - -### Remotely -Install on the server by running composer and visiting the `/benchmark.php` page of the directory the project is installed at. +## Requirements + +- PHP >= 8.4 +- ext-mysqli (for MySQL benchmarks) + +## Installation + +```bash +composer create-project hyperized/benchmark:dev-master +cp config/config.yml.example config/config.yml +``` + +Adjust `config/config.yml` to your preferences. Set module toggles, cycle counts, MySQL credentials, and optionally override the output format. + +## Usage + +### CLI + +```bash +php benchmark.php +``` + +### Development server + +```bash +php -S localhost:8000 benchmark.php +``` + +### Remote + +Install on the server via Composer and visit `/benchmark.php` in the browser. + +### Reports + +JSON reports are saved to `reports/` with timestamped filenames (e.g. `2026-04-11_143022.json`). Disable via `benchmark.output.report: false` in config. ### Security -Note that you might want to add additional security to your server to not expose the config.yml file to your webtraffic. -For Apache with `mod_rewrite` you can use something like this in your `.htaccess` file: +If deploying to a web server, ensure `config.yml` is not publicly accessible. For Apache with `mod_rewrite`: - - deny from all - +```apache + + deny from all + +``` -## Contribution -I'm open to improvements and new benchmarks via [pull requests](https://github.com/hyperized/benchmark/pulls) +## Development -Issues can be reported through [Issues](https://github.com/hyperized/benchmark/issues). -Please include the full output of the script and your config file without the password. +```bash +composer analyse # Run PHPStan (level 8) +composer cs-fix # Auto-fix code style (PER-CS2.0) +composer cs-check # Check code style (dry run) +``` -## Credit -Credit where credit is due: +## Contribution -- https://github.com/odan/benchmark-php -- https://gist.github.com/RamadhanAmizudin/ca87f7be83c6237bb070 -- https://stackoverflow.com/a/25370978/1757763 -- http://php.net/manual/en/function.rmdir.php#119949 +I'm open to improvements and new benchmarks via [pull requests](https://github.com/hyperized/benchmark/pulls). +Issues can be reported through [Issues](https://github.com/hyperized/benchmark/issues). +Please include the full output of the script and your config file without the password. ## License -[![FOSSA Status](https://app.fossa.io/api/projects/git%2Bgithub.com%2Fhyperized%2Fbenchmark.svg?type=large)](https://app.fossa.io/projects/git%2Bgithub.com%2Fhyperized%2Fbenchmark?ref=badge_large) \ No newline at end of file + +MIT From 87645b32ff8b01eb08e2df1a4987c8d8fac1a784 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:29:36 +0200 Subject: [PATCH 09/22] Fix Disk I/O card not spanning full width in HTML output --- src/Renderer/HtmlRenderer.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 10181c7..690df4d 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -87,7 +87,7 @@ private function renderPhp(PhpResult $php): void private function renderDisk(DiskResult $disk): void { - echo '
'; + echo '
'; echo '
Disk I/O
'; echo '
'; echo '

' . $disk->cycles . ' cycles · seconds (lower is better)

'; From 6a1d691635d81c6fc12cda6b07cf0fa01c382b7a Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:34:01 +0200 Subject: [PATCH 10/22] Add Memory benchmark module Tests allocation speed for array fill, array sort, string concat, object creation, array map, and array merge. Reports both execution time and peak memory usage per operation. --- config/config.yml.example | 3 ++ src/Benchmark.php | 4 ++ src/Modules/Memory.php | 90 ++++++++++++++++++++++++++++++++++ src/Renderer/CliRenderer.php | 29 +++++++++++ src/Renderer/HtmlRenderer.php | 36 ++++++++++++++ src/Result/BenchmarkResult.php | 4 ++ src/Result/MemoryResult.php | 30 ++++++++++++ 7 files changed, 196 insertions(+) create mode 100644 src/Modules/Memory.php create mode 100644 src/Result/MemoryResult.php diff --git a/config/config.yml.example b/config/config.yml.example index 752ddd0..dfaed1f 100644 --- a/config/config.yml.example +++ b/config/config.yml.example @@ -8,6 +8,9 @@ benchmark: disk: enabled: true cycles: 100 + memory: + enabled: true + count: 9999 cpu: enabled: true math: diff --git a/src/Benchmark.php b/src/Benchmark.php index 6ff06ea..bceeb10 100644 --- a/src/Benchmark.php +++ b/src/Benchmark.php @@ -7,6 +7,7 @@ use Hyperized\Benchmark\Config\Config; use Hyperized\Benchmark\Modules\CPU; use Hyperized\Benchmark\Modules\Disk; +use Hyperized\Benchmark\Modules\Memory; use Hyperized\Benchmark\Modules\MySQL; use Hyperized\Benchmark\Modules\PHP; use Hyperized\Benchmark\Renderer\CliRenderer; @@ -22,6 +23,7 @@ public function __construct( private readonly PHP $php, private readonly CPU $cpu, private readonly Disk $disk, + private readonly Memory $memory, private readonly MySQL $mysql, private readonly ReportWriter $reportWriter, ) {} @@ -33,6 +35,7 @@ public function execute(): void $phpResult = $this->php->isEnabled() ? $this->php->run() : null; $diskResult = $this->disk->isEnabled() ? $this->disk->run() : null; + $memoryResult = $this->memory->isEnabled() ? $this->memory->run() : null; $cpuResult = $this->cpu->isEnabled() ? $this->cpu->run() : null; $mysqlResult = $this->mysql->isEnabled() ? $this->mysql->run() : null; @@ -42,6 +45,7 @@ public function execute(): void php: $phpResult, cpu: $cpuResult, disk: $diskResult, + memory: $memoryResult, mysql: $mysqlResult, ); diff --git a/src/Modules/Memory.php b/src/Modules/Memory.php new file mode 100644 index 0000000..e0dd099 --- /dev/null +++ b/src/Modules/Memory.php @@ -0,0 +1,90 @@ +config->get('benchmark.memory.enabled'); + } + + public function run(): MemoryResult + { + $count = (int) ($this->config->get('benchmark.memory.count') ?? self::$defaultCount); + + /** @var array $times */ + $times = []; + /** @var array $peaks */ + $peaks = []; + + $this->benchmark('Array Fill', $count, $times, $peaks, function (int $count): void { + $arr = []; + for ($i = 0; $i < $count; $i++) { + $arr[] = $i; + } + }); + + $this->benchmark('Array Sort', $count, $times, $peaks, function (int $count): void { + $arr = \range($count, 0, -1); + \sort($arr); + }); + + $this->benchmark('String Concat', $count, $times, $peaks, function (int $count): void { + $str = ''; + for ($i = 0; $i < $count; $i++) { + $str .= 'x'; + } + }); + + $this->benchmark('Object Creation', $count, $times, $peaks, function (int $count): void { + $objects = []; + for ($i = 0; $i < $count; $i++) { + $objects[] = new \stdClass(); + } + }); + + $this->benchmark('Array Map', $count, $times, $peaks, function (int $count): void { + $arr = \range(0, $count - 1); + \array_map(static fn(int $v): int => $v * 2, $arr); + }); + + $this->benchmark('Array Merge', $count, $times, $peaks, function (int $count): void { + $a = \range(0, (int) ($count / 2) - 1); + $b = \range((int) ($count / 2), $count - 1); + $_ = \array_merge($a, $b); + }); + + return new MemoryResult( + allocationTimes: $times, + peakMemoryUsages: $peaks, + cycles: $count, + ); + } + + /** + * @param array $times + * @param array $peaks + * @param callable(int): void $fn + */ + private function benchmark(string $name, int $count, array &$times, array &$peaks, callable $fn): void + { + \gc_collect_cycles(); + $memBefore = \memory_get_usage(true); + + $start = \microtime(true); + $fn($count); + $times[$name] = \microtime(true) - $start; + + $peaks[$name] = \memory_get_usage(true) - $memBefore; + } +} diff --git a/src/Renderer/CliRenderer.php b/src/Renderer/CliRenderer.php index 11f4e51..4503a40 100644 --- a/src/Renderer/CliRenderer.php +++ b/src/Renderer/CliRenderer.php @@ -8,6 +8,7 @@ use Hyperized\Benchmark\Result\BenchmarkResult; use Hyperized\Benchmark\Result\CpuResult; use Hyperized\Benchmark\Result\DiskResult; +use Hyperized\Benchmark\Result\MemoryResult; use Hyperized\Benchmark\Result\MySqlResult; use Hyperized\Benchmark\Result\PhpResult; @@ -33,6 +34,9 @@ public function render(BenchmarkResult $result, ?string $reportPath): void if ($result->disk !== null) { $this->renderDisk($result->disk); } + if ($result->memory !== null) { + $this->renderMemory($result->memory); + } if ($result->cpu !== null) { $this->renderCpu($result->cpu); } @@ -89,6 +93,31 @@ private function renderDisk(DiskResult $disk): void echo $this->buildTable($headers, [$values]); } + private function renderMemory(MemoryResult $memory): void + { + $this->sectionTitle('Memory'); + + echo ' ' . self::DIM . $memory->cycles . ' cycles · seconds (lower is better)' . self::RESET . "\n\n"; + + /** @var array $timeHeaders */ + $timeHeaders = []; + /** @var array $timeValues */ + $timeValues = []; + /** @var array $peakValues */ + $peakValues = []; + foreach ($memory->allocationTimes as $name => $time) { + $timeHeaders[$name] = $name; + $timeValues[$name] = \sprintf('%.4fs', $time); + $peakValues[$name] = Size::bytesToFormat($memory->peakMemoryUsages[$name]); + } + + echo ' ' . self::BOLD . 'Time' . self::RESET . "\n\n"; + echo $this->buildTable($timeHeaders, [$timeValues]); + + echo ' ' . self::BOLD . 'Peak Memory' . self::RESET . "\n\n"; + echo $this->buildTable($timeHeaders, [$peakValues]); + } + private function renderCpu(CpuResult $cpu): void { $this->sectionTitle('CPU'); diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index 690df4d..ffc21cb 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -8,6 +8,7 @@ use Hyperized\Benchmark\Result\BenchmarkResult; use Hyperized\Benchmark\Result\CpuResult; use Hyperized\Benchmark\Result\DiskResult; +use Hyperized\Benchmark\Result\MemoryResult; use Hyperized\Benchmark\Result\MySqlResult; use Hyperized\Benchmark\Result\PhpResult; @@ -33,6 +34,9 @@ public function render(BenchmarkResult $result, ?string $reportPath): void if ($result->disk !== null) { $this->renderDisk($result->disk); } + if ($result->memory !== null) { + $this->renderMemory($result->memory); + } if ($result->cpu !== null) { $this->renderCpu($result->cpu); } @@ -105,6 +109,38 @@ private function renderDisk(DiskResult $disk): void echo '
'; } + private function renderMemory(MemoryResult $memory): void + { + echo '
'; + echo '
Memory
'; + echo '
'; + echo '

' . $memory->cycles . ' cycles · seconds (lower is better)

'; + + echo '

Time

'; + echo ''; + foreach (\array_keys($memory->allocationTimes) as $name) { + echo ''; + } + echo ''; + foreach ($memory->allocationTimes as $time) { + echo ''; + } + echo '
' . $this->esc($name) . '
' . \sprintf('%.4fs', $time) . '
'; + + echo '

Peak Memory

'; + echo ''; + foreach (\array_keys($memory->peakMemoryUsages) as $name) { + echo ''; + } + echo ''; + foreach ($memory->peakMemoryUsages as $bytes) { + echo ''; + } + echo '
' . $this->esc($name) . '
' . $this->esc(Size::bytesToFormat($bytes)) . '
'; + + echo '
'; + } + private function renderCpu(CpuResult $cpu): void { echo '
'; diff --git a/src/Result/BenchmarkResult.php b/src/Result/BenchmarkResult.php index 3ce2d0b..f7f836a 100644 --- a/src/Result/BenchmarkResult.php +++ b/src/Result/BenchmarkResult.php @@ -12,6 +12,7 @@ public function __construct( public ?PhpResult $php, public ?CpuResult $cpu, public ?DiskResult $disk, + public ?MemoryResult $memory, public ?MySqlResult $mysql, ) {} @@ -34,6 +35,9 @@ public function jsonSerialize(): array if ($this->disk !== null) { $data['disk'] = $this->disk; } + if ($this->memory !== null) { + $data['memory'] = $this->memory; + } if ($this->mysql !== null) { $data['mysql'] = $this->mysql; } diff --git a/src/Result/MemoryResult.php b/src/Result/MemoryResult.php new file mode 100644 index 0000000..104171e --- /dev/null +++ b/src/Result/MemoryResult.php @@ -0,0 +1,30 @@ + $allocationTimes operation name => seconds + * @param array $peakMemoryUsages operation name => bytes + */ + public function __construct( + public array $allocationTimes, + public array $peakMemoryUsages, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'allocation_times' => $this->allocationTimes, + 'peak_memory_usages' => $this->peakMemoryUsages, + ]; + } +} From b7e50d3414ccf9535d58fdc8934260204048f299 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:37:16 +0200 Subject: [PATCH 11/22] Fix memory benchmark reporting 0 bytes for most operations Use memory_reset_peak_usage() and memory_get_peak_usage() instead of memory_get_usage(true) which reports OS-level allocations in large chunks and misses reused memory. --- src/Modules/Memory.php | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/Modules/Memory.php b/src/Modules/Memory.php index e0dd099..20578f0 100644 --- a/src/Modules/Memory.php +++ b/src/Modules/Memory.php @@ -79,12 +79,13 @@ public function run(): MemoryResult private function benchmark(string $name, int $count, array &$times, array &$peaks, callable $fn): void { \gc_collect_cycles(); - $memBefore = \memory_get_usage(true); + \memory_reset_peak_usage(); + $memBefore = \memory_get_peak_usage(); $start = \microtime(true); $fn($count); $times[$name] = \microtime(true) - $start; - $peaks[$name] = \memory_get_usage(true) - $memBefore; + $peaks[$name] = \memory_get_peak_usage() - $memBefore; } } From 0c280d09fa993afc33d008b06d9ea6cc8b883e87 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:44:18 +0200 Subject: [PATCH 12/22] Add Result DTOs for Network, JSON, Regex, and File Read modules --- src/Result/BenchmarkResult.php | 16 ++++++++++++++++ src/Result/FileReadResult.php | 27 +++++++++++++++++++++++++++ src/Result/JsonResult.php | 30 ++++++++++++++++++++++++++++++ src/Result/NetworkResult.php | 30 ++++++++++++++++++++++++++++++ src/Result/RegexResult.php | 30 ++++++++++++++++++++++++++++++ 5 files changed, 133 insertions(+) create mode 100644 src/Result/FileReadResult.php create mode 100644 src/Result/JsonResult.php create mode 100644 src/Result/NetworkResult.php create mode 100644 src/Result/RegexResult.php diff --git a/src/Result/BenchmarkResult.php b/src/Result/BenchmarkResult.php index f7f836a..b5a165c 100644 --- a/src/Result/BenchmarkResult.php +++ b/src/Result/BenchmarkResult.php @@ -13,6 +13,10 @@ public function __construct( public ?CpuResult $cpu, public ?DiskResult $disk, public ?MemoryResult $memory, + public ?NetworkResult $network, + public ?JsonResult $json, + public ?RegexResult $regex, + public ?FileReadResult $fileRead, public ?MySqlResult $mysql, ) {} @@ -38,6 +42,18 @@ public function jsonSerialize(): array if ($this->memory !== null) { $data['memory'] = $this->memory; } + if ($this->network !== null) { + $data['network'] = $this->network; + } + if ($this->json !== null) { + $data['json'] = $this->json; + } + if ($this->regex !== null) { + $data['regex'] = $this->regex; + } + if ($this->fileRead !== null) { + $data['file_read'] = $this->fileRead; + } if ($this->mysql !== null) { $data['mysql'] = $this->mysql; } diff --git a/src/Result/FileReadResult.php b/src/Result/FileReadResult.php new file mode 100644 index 0000000..a51dda1 --- /dev/null +++ b/src/Result/FileReadResult.php @@ -0,0 +1,27 @@ + $readTimes label => seconds + */ + public function __construct( + public array $readTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'read_times' => $this->readTimes, + ]; + } +} diff --git a/src/Result/JsonResult.php b/src/Result/JsonResult.php new file mode 100644 index 0000000..29e0c07 --- /dev/null +++ b/src/Result/JsonResult.php @@ -0,0 +1,30 @@ + $encodeTimes label => seconds + * @param array $decodeTimes label => seconds + */ + public function __construct( + public array $encodeTimes, + public array $decodeTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'encode_times' => $this->encodeTimes, + 'decode_times' => $this->decodeTimes, + ]; + } +} diff --git a/src/Result/NetworkResult.php b/src/Result/NetworkResult.php new file mode 100644 index 0000000..7dc2acd --- /dev/null +++ b/src/Result/NetworkResult.php @@ -0,0 +1,30 @@ + $dnsResolutionTimes hostname => seconds + * @param array $connectionTimes host:port => seconds + */ + public function __construct( + public array $dnsResolutionTimes, + public array $connectionTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'dns_resolution_times' => $this->dnsResolutionTimes, + 'connection_times' => $this->connectionTimes, + ]; + } +} diff --git a/src/Result/RegexResult.php b/src/Result/RegexResult.php new file mode 100644 index 0000000..9aa4fbe --- /dev/null +++ b/src/Result/RegexResult.php @@ -0,0 +1,30 @@ + $matchTimes pattern label => seconds + * @param array $replaceTimes pattern label => seconds + */ + public function __construct( + public array $matchTimes, + public array $replaceTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'match_times' => $this->matchTimes, + 'replace_times' => $this->replaceTimes, + ]; + } +} From 8e604c4d3e5da9213a4cc7ed20612ab8f3d165d5 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:44:23 +0200 Subject: [PATCH 13/22] Add Network, JSON, Regex, and File Read benchmark modules Network: DNS resolution and TCP connection latency to configurable hosts. JSON: encode/decode across small, medium, and large payloads. Regex: match and replace across 10 pattern types. File Read: sequential reads across 1KB to 4MB file sizes. --- src/Modules/FileRead.php | 67 +++++++++++++++++++++++++ src/Modules/Json.php | 106 +++++++++++++++++++++++++++++++++++++++ src/Modules/Network.php | 80 +++++++++++++++++++++++++++++ src/Modules/Regex.php | 66 ++++++++++++++++++++++++ 4 files changed, 319 insertions(+) create mode 100644 src/Modules/FileRead.php create mode 100644 src/Modules/Json.php create mode 100644 src/Modules/Network.php create mode 100644 src/Modules/Regex.php diff --git a/src/Modules/FileRead.php b/src/Modules/FileRead.php new file mode 100644 index 0000000..2e52520 --- /dev/null +++ b/src/Modules/FileRead.php @@ -0,0 +1,67 @@ + */ + private static array $fileSizes = [ + 1024, + 16384, + 262144, + 1048576, + 4194304, + ]; + + public function __construct(private readonly Config $config) {} + + public function isEnabled(): bool + { + return (bool) $this->config->get('benchmark.fileRead.enabled'); + } + + public function run(): FileReadResult + { + $cycles = (int) ($this->config->get('benchmark.fileRead.cycles') ?? 100); + $tmpDirectoryPath = \realpath(self::$path) . self::$tmpDirectory; + + Directory::create($tmpDirectoryPath); + + /** @var array $readTimes */ + $readTimes = []; + + foreach (self::$fileSizes as $size) { + $label = Size::bytesToFormat($size); + $filePath = $tmpDirectoryPath . DIRECTORY_SEPARATOR . 'read_' . $size; + + \file_put_contents($filePath, \random_bytes($size)); + + $total = 0.0; + for ($i = 0; $i < $cycles; $i++) { + \clearstatcache(true, $filePath); + $start = \microtime(true); + \file_get_contents($filePath); + $total += \microtime(true) - $start; + } + + $readTimes[$label] = $total; + } + + Directory::removeRecursively($tmpDirectoryPath); + + return new FileReadResult( + readTimes: $readTimes, + cycles: $cycles, + ); + } +} diff --git a/src/Modules/Json.php b/src/Modules/Json.php new file mode 100644 index 0000000..f3cf2f1 --- /dev/null +++ b/src/Modules/Json.php @@ -0,0 +1,106 @@ +config->get('benchmark.json.enabled'); + } + + public function run(): JsonResult + { + $count = (int) ($this->config->get('benchmark.json.count') ?? self::$defaultCount); + + $small = ['id' => 1, 'name' => 'test', 'active' => true]; + $medium = $this->buildMediumPayload(); + $large = $this->buildLargePayload(); + + $payloads = [ + 'Small' => $small, + 'Medium' => $medium, + 'Large' => $large, + ]; + + /** @var array $encodeTimes */ + $encodeTimes = []; + /** @var array $decodeTimes */ + $decodeTimes = []; + + foreach ($payloads as $label => $payload) { + $encoded = \json_encode($payload, JSON_THROW_ON_ERROR); + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \json_encode($payload, JSON_THROW_ON_ERROR); + } + $encodeTimes[$label] = \microtime(true) - $start; + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \json_decode($encoded, true, 512, JSON_THROW_ON_ERROR); + } + $decodeTimes[$label] = \microtime(true) - $start; + } + + return new JsonResult( + encodeTimes: $encodeTimes, + decodeTimes: $decodeTimes, + cycles: $count, + ); + } + + /** + * @return array + */ + private function buildMediumPayload(): array + { + $items = []; + for ($i = 0; $i < 50; $i++) { + $items[] = [ + 'id' => $i, + 'name' => 'item_' . $i, + 'value' => $i * 1.5, + 'tags' => ['alpha', 'beta', 'gamma'], + ]; + } + + return ['items' => $items, 'total' => 50, 'page' => 1]; + } + + /** + * @return array + */ + private function buildLargePayload(): array + { + $items = []; + for ($i = 0; $i < 500; $i++) { + $items[] = [ + 'id' => $i, + 'uuid' => \bin2hex(\random_bytes(16)), + 'name' => 'item_' . $i, + 'description' => \str_repeat('Lorem ipsum dolor sit amet. ', 5), + 'value' => $i * 1.5, + 'nested' => [ + 'level1' => [ + 'level2' => ['data' => \range(0, 9)], + ], + ], + 'tags' => ['alpha', 'beta', 'gamma', 'delta', 'epsilon'], + 'active' => $i % 2 === 0, + ]; + } + + return ['items' => $items, 'total' => 500, 'page' => 1, 'pages' => 10]; + } +} diff --git a/src/Modules/Network.php b/src/Modules/Network.php new file mode 100644 index 0000000..3d2ac6f --- /dev/null +++ b/src/Modules/Network.php @@ -0,0 +1,80 @@ + */ + private static array $defaultHosts = [ + 'google.com', + 'cloudflare.com', + 'github.com', + ]; + + public function __construct(private readonly Config $config) {} + + public function isEnabled(): bool + { + return (bool) $this->config->get('benchmark.network.enabled'); + } + + public function run(): NetworkResult + { + $cycles = (int) ($this->config->get('benchmark.network.cycles') ?? self::$defaultCycles); + + /** @var list|null $configHosts */ + $configHosts = $this->config->get('benchmark.network.hosts'); + $hosts = $configHosts ?? self::$defaultHosts; + + /** @var array $dnsTimes */ + $dnsTimes = []; + /** @var array $connectionTimes */ + $connectionTimes = []; + + foreach ($hosts as $host) { + $dnsTimes[$host] = $this->benchmarkDns($host, $cycles); + $connectionTimes[$host] = $this->benchmarkConnection($host, 80, $cycles); + } + + return new NetworkResult( + dnsResolutionTimes: $dnsTimes, + connectionTimes: $connectionTimes, + cycles: $cycles, + ); + } + + private function benchmarkDns(string $host, int $cycles): float + { + $total = 0.0; + for ($i = 0; $i < $cycles; $i++) { + $start = \microtime(true); + $_ = \gethostbyname($host); + $total += \microtime(true) - $start; + } + + return $total / $cycles; + } + + private function benchmarkConnection(string $host, int $port, int $cycles): float + { + $total = 0.0; + for ($i = 0; $i < $cycles; $i++) { + $start = \microtime(true); + $socket = @\fsockopen($host, $port, $errno, $errstr, 5); + $total += \microtime(true) - $start; + + if ($socket !== false) { + \fclose($socket); + } + } + + return $total / $cycles; + } +} diff --git a/src/Modules/Regex.php b/src/Modules/Regex.php new file mode 100644 index 0000000..42565ec --- /dev/null +++ b/src/Modules/Regex.php @@ -0,0 +1,66 @@ + label => pattern */ + private static array $patterns = [ + 'Literal' => '/quick/', + 'Case-insensitive' => '/QUICK/i', + 'Character class' => '/[a-z]+/', + 'Quantifier' => '/\w{3,8}/', + 'Alternation' => '/fox|dog|cat/', + 'Anchor' => '/^The.*dogs/', + 'Capture group' => '/(\w+)@(\w+)\.(\w+)/', + 'Lookahead' => '/\w+(?=@)/', + 'Date pattern' => '/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}Z/', + 'Email' => '/[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}/', + ]; + + public function __construct(private readonly Config $config) {} + + public function isEnabled(): bool + { + return (bool) $this->config->get('benchmark.regex.enabled'); + } + + public function run(): RegexResult + { + $count = (int) ($this->config->get('benchmark.regex.count') ?? self::$defaultCount); + + /** @var array $matchTimes */ + $matchTimes = []; + /** @var array $replaceTimes */ + $replaceTimes = []; + + foreach (self::$patterns as $label => $pattern) { + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \preg_match($pattern, self::$subject); + } + $matchTimes[$label] = \microtime(true) - $start; + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \preg_replace($pattern, '', self::$subject); + } + $replaceTimes[$label] = \microtime(true) - $start; + } + + return new RegexResult( + matchTimes: $matchTimes, + replaceTimes: $replaceTimes, + cycles: $count, + ); + } +} From 002cec40d38a5a63d3f759bd40009cfa0e413568 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:44:27 +0200 Subject: [PATCH 14/22] Add rendering for new modules and switch all timings to milliseconds --- src/Renderer/CliRenderer.php | 84 +++++++++++++++++++++++++++---- src/Renderer/HtmlRenderer.php | 94 +++++++++++++++++++++++++++++++---- 2 files changed, 159 insertions(+), 19 deletions(-) diff --git a/src/Renderer/CliRenderer.php b/src/Renderer/CliRenderer.php index 4503a40..9e10556 100644 --- a/src/Renderer/CliRenderer.php +++ b/src/Renderer/CliRenderer.php @@ -8,8 +8,12 @@ use Hyperized\Benchmark\Result\BenchmarkResult; use Hyperized\Benchmark\Result\CpuResult; use Hyperized\Benchmark\Result\DiskResult; +use Hyperized\Benchmark\Result\FileReadResult; +use Hyperized\Benchmark\Result\JsonResult; use Hyperized\Benchmark\Result\MemoryResult; use Hyperized\Benchmark\Result\MySqlResult; +use Hyperized\Benchmark\Result\NetworkResult; +use Hyperized\Benchmark\Result\RegexResult; use Hyperized\Benchmark\Result\PhpResult; final class CliRenderer implements RendererInterface @@ -37,6 +41,18 @@ public function render(BenchmarkResult $result, ?string $reportPath): void if ($result->memory !== null) { $this->renderMemory($result->memory); } + if ($result->network !== null) { + $this->renderNetwork($result->network); + } + if ($result->json !== null) { + $this->renderJson($result->json); + } + if ($result->regex !== null) { + $this->renderRegex($result->regex); + } + if ($result->fileRead !== null) { + $this->renderFileRead($result->fileRead); + } if ($result->cpu !== null) { $this->renderCpu($result->cpu); } @@ -78,7 +94,7 @@ private function renderDisk(DiskResult $disk): void $this->sectionTitle('Disk I/O'); echo ' ' . self::DIM . 'Cycles: ' . self::RESET . self::GREEN . $disk->cycles . self::RESET . "\n"; - echo ' ' . self::DIM . 'Time in seconds (lower is better)' . self::RESET . "\n\n"; + echo ' ' . self::DIM . 'ms (lower is better)' . self::RESET . "\n\n"; /** @var array $headers */ $headers = []; @@ -87,7 +103,7 @@ private function renderDisk(DiskResult $disk): void foreach ($disk->fileCreationTimes as $blockSize => $time) { $label = Size::bytesToFormat($blockSize); $headers[$label] = $label; - $values[$label] = \sprintf('%.4fs', $time); + $values[$label] = \sprintf('%.2fms', $time * 1000); } echo $this->buildTable($headers, [$values]); @@ -97,7 +113,7 @@ private function renderMemory(MemoryResult $memory): void { $this->sectionTitle('Memory'); - echo ' ' . self::DIM . $memory->cycles . ' cycles · seconds (lower is better)' . self::RESET . "\n\n"; + echo ' ' . self::DIM . $memory->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; /** @var array $timeHeaders */ $timeHeaders = []; @@ -107,7 +123,7 @@ private function renderMemory(MemoryResult $memory): void $peakValues = []; foreach ($memory->allocationTimes as $name => $time) { $timeHeaders[$name] = $name; - $timeValues[$name] = \sprintf('%.4fs', $time); + $timeValues[$name] = \sprintf('%.2fms', $time * 1000); $peakValues[$name] = Size::bytesToFormat($memory->peakMemoryUsages[$name]); } @@ -118,11 +134,59 @@ private function renderMemory(MemoryResult $memory): void echo $this->buildTable($timeHeaders, [$peakValues]); } + private function renderNetwork(NetworkResult $network): void + { + $this->sectionTitle('Network'); + + echo ' ' . self::DIM . $network->cycles . ' cycles · average ms per cycle (lower is better)' . self::RESET . "\n\n"; + + echo ' ' . self::BOLD . 'DNS Resolution' . self::RESET . "\n\n"; + echo $this->buildResultTable($network->dnsResolutionTimes); + + echo ' ' . self::BOLD . 'TCP Connection' . self::RESET . "\n\n"; + echo $this->buildResultTable($network->connectionTimes); + } + + private function renderJson(JsonResult $json): void + { + $this->sectionTitle('JSON'); + + echo ' ' . self::DIM . $json->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + + echo ' ' . self::BOLD . 'Encode' . self::RESET . "\n\n"; + echo $this->buildResultTable($json->encodeTimes); + + echo ' ' . self::BOLD . 'Decode' . self::RESET . "\n\n"; + echo $this->buildResultTable($json->decodeTimes); + } + + private function renderRegex(RegexResult $regex): void + { + $this->sectionTitle('Regex'); + + echo ' ' . self::DIM . $regex->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + + echo ' ' . self::BOLD . 'Match' . self::RESET . "\n\n"; + echo $this->buildResultTable($regex->matchTimes); + + echo ' ' . self::BOLD . 'Replace' . self::RESET . "\n\n"; + echo $this->buildResultTable($regex->replaceTimes); + } + + private function renderFileRead(FileReadResult $fileRead): void + { + $this->sectionTitle('File Read'); + + echo ' ' . self::DIM . $fileRead->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + + echo $this->buildResultTable($fileRead->readTimes); + } + private function renderCpu(CpuResult $cpu): void { $this->sectionTitle('CPU'); - echo ' ' . self::DIM . 'Time in seconds (lower is better)' . self::RESET . "\n\n"; + echo ' ' . self::DIM . 'ms (lower is better)' . self::RESET . "\n\n"; echo ' ' . self::BOLD . 'Math' . self::RESET . self::DIM . ' (' . $cpu->mathCount . ' cycles)' . self::RESET . "\n\n"; echo $this->buildResultTable($cpu->mathResults); @@ -130,8 +194,8 @@ private function renderCpu(CpuResult $cpu): void echo ' ' . self::BOLD . 'Strings' . self::RESET . self::DIM . ' (' . $cpu->stringCount . ' cycles)' . self::RESET . "\n\n"; echo $this->buildResultTable($cpu->stringResults); - $this->keyValue('Loops', \sprintf('%.6fs', $cpu->loopsResult) . self::DIM . ' (' . $cpu->loopsCount . ' cycles)'); - $this->keyValue('If/Else', \sprintf('%.6fs', $cpu->ifElseResult) . self::DIM . ' (' . $cpu->ifElseCount . ' cycles)'); + $this->keyValue('Loops', \sprintf('%.2fms', $cpu->loopsResult * 1000) . self::DIM . ' (' . $cpu->loopsCount . ' cycles)'); + $this->keyValue('If/Else', \sprintf('%.2fms', $cpu->ifElseResult * 1000) . self::DIM . ' (' . $cpu->ifElseCount . ' cycles)'); echo "\n"; } @@ -140,7 +204,7 @@ private function renderMySql(MySqlResult $mysql): void $this->sectionTitle('MySQL'); $this->keyValue('Version', (string) $mysql->version); - $this->keyValue('Query Time', \sprintf('%.4fs', $mysql->queryTime) . self::DIM . ' (' . $mysql->queryCount . ' cycles, lower is better)'); + $this->keyValue('Query Time', \sprintf('%.2fms', $mysql->queryTime * 1000) . self::DIM . ' (' . $mysql->queryCount . ' cycles, lower is better)'); echo "\n"; } @@ -166,7 +230,7 @@ private function keyValue(string $key, string $value): void } /** - * @param array $results + * @param array $results seconds */ private function buildResultTable(array $results): string { @@ -176,7 +240,7 @@ private function buildResultTable(array $results): string $values = []; foreach ($results as $name => $time) { $headers[$name] = $name; - $values[$name] = \sprintf('%.4fs', $time); + $values[$name] = \sprintf('%.2fms', $time * 1000); } return $this->buildTable($headers, [$values]); diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index ffc21cb..fcbc993 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -8,9 +8,13 @@ use Hyperized\Benchmark\Result\BenchmarkResult; use Hyperized\Benchmark\Result\CpuResult; use Hyperized\Benchmark\Result\DiskResult; +use Hyperized\Benchmark\Result\FileReadResult; +use Hyperized\Benchmark\Result\JsonResult; use Hyperized\Benchmark\Result\MemoryResult; use Hyperized\Benchmark\Result\MySqlResult; +use Hyperized\Benchmark\Result\NetworkResult; use Hyperized\Benchmark\Result\PhpResult; +use Hyperized\Benchmark\Result\RegexResult; final class HtmlRenderer implements RendererInterface { @@ -37,6 +41,18 @@ public function render(BenchmarkResult $result, ?string $reportPath): void if ($result->memory !== null) { $this->renderMemory($result->memory); } + if ($result->network !== null) { + $this->renderNetwork($result->network); + } + if ($result->json !== null) { + $this->renderJson($result->json); + } + if ($result->regex !== null) { + $this->renderRegex($result->regex); + } + if ($result->fileRead !== null) { + $this->renderFileRead($result->fileRead); + } if ($result->cpu !== null) { $this->renderCpu($result->cpu); } @@ -94,7 +110,7 @@ private function renderDisk(DiskResult $disk): void echo '
'; echo '
Disk I/O
'; echo '
'; - echo '

' . $disk->cycles . ' cycles · seconds (lower is better)

'; + echo '

' . $disk->cycles . ' cycles · ms (lower is better)

'; echo ''; foreach ($disk->fileCreationTimes as $blockSize => $_) { @@ -102,7 +118,7 @@ private function renderDisk(DiskResult $disk): void } echo ''; foreach ($disk->fileCreationTimes as $time) { - echo ''; + echo ''; } echo '
' . \sprintf('%.4fs', $time) . '' . \sprintf('%.2fms', $time * 1000) . '
'; @@ -114,7 +130,7 @@ private function renderMemory(MemoryResult $memory): void echo '
'; echo '
Memory
'; echo '
'; - echo '

' . $memory->cycles . ' cycles · seconds (lower is better)

'; + echo '

' . $memory->cycles . ' cycles · ms (lower is better)

'; echo '

Time

'; echo ''; @@ -123,7 +139,7 @@ private function renderMemory(MemoryResult $memory): void } echo ''; foreach ($memory->allocationTimes as $time) { - echo ''; + echo ''; } echo '
' . \sprintf('%.4fs', $time) . '' . \sprintf('%.2fms', $time * 1000) . '
'; @@ -147,7 +163,7 @@ private function renderCpu(CpuResult $cpu): void echo '
CPU
'; echo '
'; - echo '

seconds (lower is better)

'; + echo '

ms (lower is better)

'; echo '

Math (' . $cpu->mathCount . ' cycles)

'; $this->renderResultTable($cpu->mathResults); @@ -156,8 +172,8 @@ private function renderCpu(CpuResult $cpu): void $this->renderResultTable($cpu->stringResults); echo '
'; - $this->dt('Loops', \sprintf('%.6fs', $cpu->loopsResult) . ' (' . $cpu->loopsCount . ' cycles)'); - $this->dt('If/Else', \sprintf('%.6fs', $cpu->ifElseResult) . ' (' . $cpu->ifElseCount . ' cycles)'); + $this->dt('Loops', \sprintf('%.2fms', $cpu->loopsResult * 1000) . ' (' . $cpu->loopsCount . ' cycles)'); + $this->dt('If/Else', \sprintf('%.2fms', $cpu->ifElseResult * 1000) . ' (' . $cpu->ifElseCount . ' cycles)'); echo '
'; echo '
'; @@ -170,7 +186,7 @@ private function renderMySql(MySqlResult $mysql): void echo '
'; echo '
'; $this->dt('Version', (string) $mysql->version); - $this->dt('Query Time', \sprintf('%.4fs', $mysql->queryTime) . ' (' . $mysql->queryCount . ' cycles, lower is better)'); + $this->dt('Query Time', \sprintf('%.2fms', $mysql->queryTime * 1000) . ' (' . $mysql->queryCount . ' cycles, lower is better)'); echo '
'; echo '
'; } @@ -185,6 +201,66 @@ private function renderFooter(BenchmarkResult $result, ?string $reportPath): voi echo ''; } + private function renderNetwork(NetworkResult $network): void + { + echo '
'; + echo '
Network
'; + echo '
'; + echo '

' . $network->cycles . ' cycles · average ms per cycle (lower is better)

'; + + echo '

DNS Resolution

'; + $this->renderResultTable($network->dnsResolutionTimes); + + echo '

TCP Connection

'; + $this->renderResultTable($network->connectionTimes); + + echo '
'; + } + + private function renderJson(JsonResult $json): void + { + echo '
'; + echo '
JSON
'; + echo '
'; + echo '

' . $json->cycles . ' cycles · ms (lower is better)

'; + + echo '

Encode

'; + $this->renderResultTable($json->encodeTimes); + + echo '

Decode

'; + $this->renderResultTable($json->decodeTimes); + + echo '
'; + } + + private function renderRegex(RegexResult $regex): void + { + echo '
'; + echo '
Regex
'; + echo '
'; + echo '

' . $regex->cycles . ' cycles · ms (lower is better)

'; + + echo '

Match

'; + $this->renderResultTable($regex->matchTimes); + + echo '

Replace

'; + $this->renderResultTable($regex->replaceTimes); + + echo '
'; + } + + private function renderFileRead(FileReadResult $fileRead): void + { + echo '
'; + echo '
File Read
'; + echo '
'; + echo '

' . $fileRead->cycles . ' cycles · ms (lower is better)

'; + + $this->renderResultTable($fileRead->readTimes); + + echo '
'; + } + /** * @param array $results */ @@ -196,7 +272,7 @@ private function renderResultTable(array $results): void } echo ''; foreach ($results as $time) { - echo '' . \sprintf('%.4fs', $time) . ''; + echo '' . \sprintf('%.2fms', $time * 1000) . ''; } echo ''; } From 831eb0506f7952dc8aa1ed132c139fd156113502 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:44:30 +0200 Subject: [PATCH 15/22] Wire new modules into orchestrator and config --- config/config.yml.example | 16 ++++++++++++++++ src/Benchmark.php | 16 ++++++++++++++++ 2 files changed, 32 insertions(+) diff --git a/config/config.yml.example b/config/config.yml.example index dfaed1f..46a7b1c 100644 --- a/config/config.yml.example +++ b/config/config.yml.example @@ -11,6 +11,22 @@ benchmark: memory: enabled: true count: 9999 + network: + enabled: true + cycles: 3 + hosts: + - google.com + - cloudflare.com + - github.com + json: + enabled: true + count: 9999 + regex: + enabled: true + count: 99999 + fileRead: + enabled: true + cycles: 100 cpu: enabled: true math: diff --git a/src/Benchmark.php b/src/Benchmark.php index bceeb10..348e6c6 100644 --- a/src/Benchmark.php +++ b/src/Benchmark.php @@ -7,9 +7,13 @@ use Hyperized\Benchmark\Config\Config; use Hyperized\Benchmark\Modules\CPU; use Hyperized\Benchmark\Modules\Disk; +use Hyperized\Benchmark\Modules\FileRead; +use Hyperized\Benchmark\Modules\Json; use Hyperized\Benchmark\Modules\Memory; use Hyperized\Benchmark\Modules\MySQL; +use Hyperized\Benchmark\Modules\Network; use Hyperized\Benchmark\Modules\PHP; +use Hyperized\Benchmark\Modules\Regex; use Hyperized\Benchmark\Renderer\CliRenderer; use Hyperized\Benchmark\Renderer\HtmlRenderer; use Hyperized\Benchmark\Report\Report; @@ -24,6 +28,10 @@ public function __construct( private readonly CPU $cpu, private readonly Disk $disk, private readonly Memory $memory, + private readonly Network $network, + private readonly Json $json, + private readonly Regex $regex, + private readonly FileRead $fileRead, private readonly MySQL $mysql, private readonly ReportWriter $reportWriter, ) {} @@ -36,6 +44,10 @@ public function execute(): void $phpResult = $this->php->isEnabled() ? $this->php->run() : null; $diskResult = $this->disk->isEnabled() ? $this->disk->run() : null; $memoryResult = $this->memory->isEnabled() ? $this->memory->run() : null; + $networkResult = $this->network->isEnabled() ? $this->network->run() : null; + $jsonResult = $this->json->isEnabled() ? $this->json->run() : null; + $regexResult = $this->regex->isEnabled() ? $this->regex->run() : null; + $fileReadResult = $this->fileRead->isEnabled() ? $this->fileRead->run() : null; $cpuResult = $this->cpu->isEnabled() ? $this->cpu->run() : null; $mysqlResult = $this->mysql->isEnabled() ? $this->mysql->run() : null; @@ -46,6 +58,10 @@ public function execute(): void cpu: $cpuResult, disk: $diskResult, memory: $memoryResult, + network: $networkResult, + json: $jsonResult, + regex: $regexResult, + fileRead: $fileReadResult, mysql: $mysqlResult, ); From b97747b9e4fdc34b8744aab2f13f9fead0928f04 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:51:14 +0200 Subject: [PATCH 16/22] Add Result DTOs for Serialization, Hashing, Encryption, Function Overhead, Type Casting, Autoloading, and Compression --- src/Result/AutoloadingResult.php | 27 ++++++++++++++++++++++ src/Result/BenchmarkResult.php | 28 +++++++++++++++++++++++ src/Result/CompressionResult.php | 33 +++++++++++++++++++++++++++ src/Result/EncryptionResult.php | 30 ++++++++++++++++++++++++ src/Result/FunctionOverheadResult.php | 27 ++++++++++++++++++++++ src/Result/HashingResult.php | 32 ++++++++++++++++++++++++++ src/Result/SerializationResult.php | 30 ++++++++++++++++++++++++ src/Result/TypeCastingResult.php | 27 ++++++++++++++++++++++ 8 files changed, 234 insertions(+) create mode 100644 src/Result/AutoloadingResult.php create mode 100644 src/Result/CompressionResult.php create mode 100644 src/Result/EncryptionResult.php create mode 100644 src/Result/FunctionOverheadResult.php create mode 100644 src/Result/HashingResult.php create mode 100644 src/Result/SerializationResult.php create mode 100644 src/Result/TypeCastingResult.php diff --git a/src/Result/AutoloadingResult.php b/src/Result/AutoloadingResult.php new file mode 100644 index 0000000..13dedda --- /dev/null +++ b/src/Result/AutoloadingResult.php @@ -0,0 +1,27 @@ + $instantiationTimes class => seconds + */ + public function __construct( + public array $instantiationTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'instantiation_times' => $this->instantiationTimes, + ]; + } +} diff --git a/src/Result/BenchmarkResult.php b/src/Result/BenchmarkResult.php index b5a165c..82005e0 100644 --- a/src/Result/BenchmarkResult.php +++ b/src/Result/BenchmarkResult.php @@ -17,6 +17,13 @@ public function __construct( public ?JsonResult $json, public ?RegexResult $regex, public ?FileReadResult $fileRead, + public ?SerializationResult $serialization, + public ?HashingResult $hashing, + public ?EncryptionResult $encryption, + public ?FunctionOverheadResult $functionOverhead, + public ?TypeCastingResult $typeCasting, + public ?AutoloadingResult $autoloading, + public ?CompressionResult $compression, public ?MySqlResult $mysql, ) {} @@ -54,6 +61,27 @@ public function jsonSerialize(): array if ($this->fileRead !== null) { $data['file_read'] = $this->fileRead; } + if ($this->serialization !== null) { + $data['serialization'] = $this->serialization; + } + if ($this->hashing !== null) { + $data['hashing'] = $this->hashing; + } + if ($this->encryption !== null) { + $data['encryption'] = $this->encryption; + } + if ($this->functionOverhead !== null) { + $data['function_overhead'] = $this->functionOverhead; + } + if ($this->typeCasting !== null) { + $data['type_casting'] = $this->typeCasting; + } + if ($this->autoloading !== null) { + $data['autoloading'] = $this->autoloading; + } + if ($this->compression !== null) { + $data['compression'] = $this->compression; + } if ($this->mysql !== null) { $data['mysql'] = $this->mysql; } diff --git a/src/Result/CompressionResult.php b/src/Result/CompressionResult.php new file mode 100644 index 0000000..f0492f2 --- /dev/null +++ b/src/Result/CompressionResult.php @@ -0,0 +1,33 @@ + $compressTimes algorithm => seconds + * @param array $decompressTimes algorithm => seconds + * @param array $ratios algorithm => compression ratio + */ + public function __construct( + public array $compressTimes, + public array $decompressTimes, + public array $ratios, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'compress_times' => $this->compressTimes, + 'decompress_times' => $this->decompressTimes, + 'ratios' => $this->ratios, + ]; + } +} diff --git a/src/Result/EncryptionResult.php b/src/Result/EncryptionResult.php new file mode 100644 index 0000000..86d448c --- /dev/null +++ b/src/Result/EncryptionResult.php @@ -0,0 +1,30 @@ + $encryptTimes cipher => seconds + * @param array $decryptTimes cipher => seconds + */ + public function __construct( + public array $encryptTimes, + public array $decryptTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'encrypt_times' => $this->encryptTimes, + 'decrypt_times' => $this->decryptTimes, + ]; + } +} diff --git a/src/Result/FunctionOverheadResult.php b/src/Result/FunctionOverheadResult.php new file mode 100644 index 0000000..614e05b --- /dev/null +++ b/src/Result/FunctionOverheadResult.php @@ -0,0 +1,27 @@ + $callTimes call type => seconds + */ + public function __construct( + public array $callTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'call_times' => $this->callTimes, + ]; + } +} diff --git a/src/Result/HashingResult.php b/src/Result/HashingResult.php new file mode 100644 index 0000000..726e79b --- /dev/null +++ b/src/Result/HashingResult.php @@ -0,0 +1,32 @@ + $hashTimes algorithm => seconds + * @param array $passwordTimes algorithm => seconds + */ + public function __construct( + public array $hashTimes, + public array $passwordTimes, + public int $hashCycles, + public int $passwordCycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'hash_cycles' => $this->hashCycles, + 'password_cycles' => $this->passwordCycles, + 'hash_times' => $this->hashTimes, + 'password_times' => $this->passwordTimes, + ]; + } +} diff --git a/src/Result/SerializationResult.php b/src/Result/SerializationResult.php new file mode 100644 index 0000000..3836e56 --- /dev/null +++ b/src/Result/SerializationResult.php @@ -0,0 +1,30 @@ + $serializeTimes format => seconds + * @param array $unserializeTimes format => seconds + */ + public function __construct( + public array $serializeTimes, + public array $unserializeTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'serialize_times' => $this->serializeTimes, + 'unserialize_times' => $this->unserializeTimes, + ]; + } +} diff --git a/src/Result/TypeCastingResult.php b/src/Result/TypeCastingResult.php new file mode 100644 index 0000000..7d343a1 --- /dev/null +++ b/src/Result/TypeCastingResult.php @@ -0,0 +1,27 @@ + $castTimes cast type => seconds + */ + public function __construct( + public array $castTimes, + public int $cycles, + ) {} + + /** + * @return array + */ + public function jsonSerialize(): array + { + return [ + 'cycles' => $this->cycles, + 'cast_times' => $this->castTimes, + ]; + } +} From 74a04e863a5a17ea1e1729e4ec06cb66af351689 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:51:23 +0200 Subject: [PATCH 17/22] Add Serialization, Hashing, Encryption, Function Overhead, Type Casting, Object Instantiation, and Compression modules Serialization: serialize vs json_encode vs var_export, with igbinary/msgpack if available. Hashing: md5, sha1, sha256, sha512, xxh3, crc32, hmac-sha256, bcrypt, argon2id. Encryption: AES-128/256-CBC/GCM, ChaCha20-Poly1305 encrypt and decrypt. Function Overhead: named function, closure, first-class callable, static/instance method, arrow function. Type Casting: int/string/float/bool conversions and intval(). Object Instantiation: stdClass, DateTime, DateTimeImmutable, SplStack, SplPriorityQueue, ArrayObject. Compression: gzip, zlib, deflate with brotli/lzf if available. Reports time and compression ratio. Also reduces network connection timeout from 5s to 2s. --- src/Modules/Autoloading.php | 75 ++++++++++++++++ src/Modules/Compression.php | 147 +++++++++++++++++++++++++++++++ src/Modules/Encryption.php | 95 ++++++++++++++++++++ src/Modules/FunctionOverhead.php | 90 +++++++++++++++++++ src/Modules/Hashing.php | 81 +++++++++++++++++ src/Modules/Network.php | 2 +- src/Modules/Serialization.php | 125 ++++++++++++++++++++++++++ src/Modules/TypeCasting.php | 85 ++++++++++++++++++ 8 files changed, 699 insertions(+), 1 deletion(-) create mode 100644 src/Modules/Autoloading.php create mode 100644 src/Modules/Compression.php create mode 100644 src/Modules/Encryption.php create mode 100644 src/Modules/FunctionOverhead.php create mode 100644 src/Modules/Hashing.php create mode 100644 src/Modules/Serialization.php create mode 100644 src/Modules/TypeCasting.php diff --git a/src/Modules/Autoloading.php b/src/Modules/Autoloading.php new file mode 100644 index 0000000..334e5bc --- /dev/null +++ b/src/Modules/Autoloading.php @@ -0,0 +1,75 @@ +config->get('benchmark.autoloading.enabled'); + } + + public function run(): AutoloadingResult + { + $count = (int) ($this->config->get('benchmark.autoloading.count') ?? self::$defaultCount); + + /** @var array $times */ + $times = []; + + // stdClass + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = new \stdClass(); + } + $times['stdClass'] = \microtime(true) - $start; + + // DateTime + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + new \DateTime(); + } + $times['DateTime'] = \microtime(true) - $start; + + // DateTimeImmutable + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + new \DateTimeImmutable(); + } + $times['DateTimeImmutable'] = \microtime(true) - $start; + + // SplStack + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = new \SplStack(); + } + $times['SplStack'] = \microtime(true) - $start; + + // SplPriorityQueue + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = new \SplPriorityQueue(); + } + $times['SplPriorityQueue'] = \microtime(true) - $start; + + // ArrayObject + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + new \ArrayObject(); + } + $times['ArrayObject'] = \microtime(true) - $start; + + return new AutoloadingResult( + instantiationTimes: $times, + cycles: $count, + ); + } +} diff --git a/src/Modules/Compression.php b/src/Modules/Compression.php new file mode 100644 index 0000000..656ffa3 --- /dev/null +++ b/src/Modules/Compression.php @@ -0,0 +1,147 @@ +config->get('benchmark.compression.enabled'); + } + + public function run(): CompressionResult + { + $count = (int) ($this->config->get('benchmark.compression.count') ?? self::$defaultCount); + + $data = \str_repeat('The quick brown fox jumps over the lazy dog. Lorem ipsum dolor sit amet. ', 500); + + /** @var array $compressTimes */ + $compressTimes = []; + /** @var array $decompressTimes */ + $decompressTimes = []; + /** @var array $ratios */ + $ratios = []; + + // gzencode / gzdecode + if (\function_exists('gzencode')) { + $this->benchmarkCompression( + 'gzip', + $data, + $count, + static fn(string $d): string => \gzencode($d, 6) ?: '', + static fn(string $d): string => \gzdecode($d) ?: '', + $compressTimes, + $decompressTimes, + $ratios, + ); + } + + // gzcompress / gzuncompress (zlib) + if (\function_exists('gzcompress')) { + $this->benchmarkCompression( + 'zlib', + $data, + $count, + static fn(string $d): string => \gzcompress($d, 6) ?: '', + static fn(string $d): string => \gzuncompress($d) ?: '', + $compressTimes, + $decompressTimes, + $ratios, + ); + } + + // gzdeflate / gzinflate (deflate) + if (\function_exists('gzdeflate')) { + $this->benchmarkCompression( + 'deflate', + $data, + $count, + static fn(string $d): string => \gzdeflate($d, 6) ?: '', + static fn(string $d): string => \gzinflate($d) ?: '', + $compressTimes, + $decompressTimes, + $ratios, + ); + } + + // brotli (if available) + if (\function_exists('brotli_compress')) { + $this->benchmarkCompression( + 'brotli', + $data, + $count, + static fn(string $d): string => \brotli_compress($d) ?: '', + static fn(string $d): string => \brotli_uncompress($d) ?: '', + $compressTimes, + $decompressTimes, + $ratios, + ); + } + + // LZF (if available) + if (\function_exists('lzf_compress')) { + $this->benchmarkCompression( + 'lzf', + $data, + $count, + static fn(string $d): string => \lzf_compress($d) ?: '', + static fn(string $d): string => \lzf_decompress($d) ?: '', + $compressTimes, + $decompressTimes, + $ratios, + ); + } + + return new CompressionResult( + compressTimes: $compressTimes, + decompressTimes: $decompressTimes, + ratios: $ratios, + cycles: $count, + ); + } + + /** + * @param callable(string): string $compress + * @param callable(string): string $decompress + * @param array $compressTimes + * @param array $decompressTimes + * @param array $ratios + */ + private function benchmarkCompression( + string $name, + string $data, + int $count, + callable $compress, + callable $decompress, + array &$compressTimes, + array &$decompressTimes, + array &$ratios, + ): void { + $compressed = $compress($data); + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $compress($data); + } + $compressTimes[$name] = \microtime(true) - $start; + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $decompress($compressed); + } + $decompressTimes[$name] = \microtime(true) - $start; + + $originalLen = \strlen($data); + $compressedLen = \strlen($compressed); + $ratios[$name] = $originalLen > 0 ? \round(1 - ($compressedLen / $originalLen), 4) : 0.0; + } +} diff --git a/src/Modules/Encryption.php b/src/Modules/Encryption.php new file mode 100644 index 0000000..1057ef0 --- /dev/null +++ b/src/Modules/Encryption.php @@ -0,0 +1,95 @@ + */ + private static array $ciphers = [ + 'aes-128-cbc', + 'aes-256-cbc', + 'aes-128-gcm', + 'aes-256-gcm', + 'chacha20-poly1305', + ]; + + public function __construct(private readonly Config $config) {} + + public function isEnabled(): bool + { + return (bool) $this->config->get('benchmark.encryption.enabled'); + } + + public function run(): EncryptionResult + { + $count = (int) ($this->config->get('benchmark.encryption.count') ?? self::$defaultCount); + + $data = \str_repeat('The quick brown fox jumps over the lazy dog. ', 20); + + /** @var array $encryptTimes */ + $encryptTimes = []; + /** @var array $decryptTimes */ + $decryptTimes = []; + + $availableCiphers = \openssl_get_cipher_methods(); + + foreach (self::$ciphers as $cipher) { + if (!\in_array($cipher, $availableCiphers, true)) { + continue; + } + + $ivLen = \openssl_cipher_iv_length($cipher); + $iv = $ivLen > 0 ? \random_bytes($ivLen) : ''; + $key = \random_bytes(32); + $tag = ''; + + $isAead = \str_contains($cipher, 'gcm') || \str_contains($cipher, 'poly1305'); + + // Encrypt + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + if ($isAead) { + \openssl_encrypt($data, $cipher, $key, 0, $iv, $tag); + } else { + \openssl_encrypt($data, $cipher, $key, 0, $iv); + } + } + $encryptTimes[$cipher] = \microtime(true) - $start; + + // Get encrypted data for decryption benchmark + if ($isAead) { + $encrypted = \openssl_encrypt($data, $cipher, $key, 0, $iv, $tag); + } else { + $encrypted = \openssl_encrypt($data, $cipher, $key, 0, $iv); + } + + if ($encrypted === false) { + continue; + } + + // Decrypt + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + if ($isAead) { + \openssl_decrypt($encrypted, $cipher, $key, 0, $iv, $tag); + } else { + \openssl_decrypt($encrypted, $cipher, $key, 0, $iv); + } + } + $decryptTimes[$cipher] = \microtime(true) - $start; + } + + return new EncryptionResult( + encryptTimes: $encryptTimes, + decryptTimes: $decryptTimes, + cycles: $count, + ); + } +} diff --git a/src/Modules/FunctionOverhead.php b/src/Modules/FunctionOverhead.php new file mode 100644 index 0000000..783e46b --- /dev/null +++ b/src/Modules/FunctionOverhead.php @@ -0,0 +1,90 @@ +config->get('benchmark.functionOverhead.enabled'); + } + + public function run(): FunctionOverheadResult + { + $count = (int) ($this->config->get('benchmark.functionOverhead.count') ?? self::$defaultCount); + + /** @var array $times */ + $times = []; + + // Named function + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = \abs($i); + } + $times['Named Function'] = \microtime(true) - $start; + + // Closure + $closure = static function (int $v): int { + return $v >= 0 ? $v : -$v; + }; + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = $closure($i); + } + $times['Closure'] = \microtime(true) - $start; + + // First-class callable + $callable = \abs(...); + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = $callable($i); + } + $times['First-class Callable'] = \microtime(true) - $start; + + // Static method + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = self::staticMethod($i); + } + $times['Static Method'] = \microtime(true) - $start; + + // Instance method + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = $this->instanceMethod($i); + } + $times['Instance Method'] = \microtime(true) - $start; + + // Arrow function + $arrow = static fn(int $v): int => $v >= 0 ? $v : -$v; + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = $arrow($i); + } + $times['Arrow Function'] = \microtime(true) - $start; + + return new FunctionOverheadResult( + callTimes: $times, + cycles: $count, + ); + } + + private static function staticMethod(int $v): int + { + return $v >= 0 ? $v : -$v; + } + + private function instanceMethod(int $v): int + { + return $v >= 0 ? $v : -$v; + } +} diff --git a/src/Modules/Hashing.php b/src/Modules/Hashing.php new file mode 100644 index 0000000..b04e340 --- /dev/null +++ b/src/Modules/Hashing.php @@ -0,0 +1,81 @@ + */ + private static array $hashAlgorithms = [ + 'md5', + 'sha1', + 'sha256', + 'sha512', + 'xxh3', + 'crc32', + ]; + + public function __construct(private readonly Config $config) {} + + public function isEnabled(): bool + { + return (bool) $this->config->get('benchmark.hashing.enabled'); + } + + public function run(): HashingResult + { + $hashCount = (int) ($this->config->get('benchmark.hashing.hashCount') ?? self::$defaultHashCount); + $passwordCount = (int) ($this->config->get('benchmark.hashing.passwordCount') ?? self::$defaultPasswordCount); + + $data = \str_repeat('The quick brown fox jumps over the lazy dog. ', 20); + + /** @var array $hashTimes */ + $hashTimes = []; + foreach (self::$hashAlgorithms as $algo) { + $start = \microtime(true); + for ($i = 0; $i < $hashCount; $i++) { + $_ = \hash($algo, $data); + } + $hashTimes[$algo] = \microtime(true) - $start; + } + + // hash_hmac + $key = 'benchmark-secret-key'; + $start = \microtime(true); + for ($i = 0; $i < $hashCount; $i++) { + $_ = \hash_hmac('sha256', $data, $key); + } + $hashTimes['hmac-sha256'] = \microtime(true) - $start; + + /** @var array $passwordTimes */ + $passwordTimes = []; + + // bcrypt + $start = \microtime(true); + for ($i = 0; $i < $passwordCount; $i++) { + \password_hash('benchmark', PASSWORD_BCRYPT, ['cost' => 10]); + } + $passwordTimes['bcrypt'] = \microtime(true) - $start; + + // argon2id + $start = \microtime(true); + for ($i = 0; $i < $passwordCount; $i++) { + \password_hash('benchmark', PASSWORD_ARGON2ID); + } + $passwordTimes['argon2id'] = \microtime(true) - $start; + + return new HashingResult( + hashTimes: $hashTimes, + passwordTimes: $passwordTimes, + hashCycles: $hashCount, + passwordCycles: $passwordCount, + ); + } +} diff --git a/src/Modules/Network.php b/src/Modules/Network.php index 3d2ac6f..37bae84 100644 --- a/src/Modules/Network.php +++ b/src/Modules/Network.php @@ -67,7 +67,7 @@ private function benchmarkConnection(string $host, int $port, int $cycles): floa $total = 0.0; for ($i = 0; $i < $cycles; $i++) { $start = \microtime(true); - $socket = @\fsockopen($host, $port, $errno, $errstr, 5); + $socket = @\fsockopen($host, $port, $errno, $errstr, 2); $total += \microtime(true) - $start; if ($socket !== false) { diff --git a/src/Modules/Serialization.php b/src/Modules/Serialization.php new file mode 100644 index 0000000..2be66fd --- /dev/null +++ b/src/Modules/Serialization.php @@ -0,0 +1,125 @@ +config->get('benchmark.serialization.enabled'); + } + + public function run(): SerializationResult + { + $count = (int) ($this->config->get('benchmark.serialization.count') ?? self::$defaultCount); + + $payload = $this->buildPayload(); + $phpSerialized = \serialize($payload); + $jsonEncoded = \json_encode($payload, JSON_THROW_ON_ERROR); + $igbinarySerialized = \function_exists('igbinary_serialize') ? \igbinary_serialize($payload) : null; + $msgpackPacked = \function_exists('msgpack_pack') ? \msgpack_pack($payload) : null; + + /** @var array $serializeTimes */ + $serializeTimes = []; + /** @var array $unserializeTimes */ + $unserializeTimes = []; + + // PHP serialize + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \serialize($payload); + } + $serializeTimes['serialize'] = \microtime(true) - $start; + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \unserialize($phpSerialized); + } + $unserializeTimes['unserialize'] = \microtime(true) - $start; + + // JSON + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \json_encode($payload, JSON_THROW_ON_ERROR); + } + $serializeTimes['json_encode'] = \microtime(true) - $start; + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \json_decode($jsonEncoded, true, 512, JSON_THROW_ON_ERROR); + } + $unserializeTimes['json_decode'] = \microtime(true) - $start; + + // var_export / eval alternative: just var_export + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = \var_export($payload, true); + } + $serializeTimes['var_export'] = \microtime(true) - $start; + $unserializeTimes['var_export'] = 0.0; + + // igbinary (if available) + if ($igbinarySerialized !== null) { + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \igbinary_serialize($payload); + } + $serializeTimes['igbinary'] = \microtime(true) - $start; + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \igbinary_unserialize($igbinarySerialized); + } + $unserializeTimes['igbinary'] = \microtime(true) - $start; + } + + // msgpack (if available) + if ($msgpackPacked !== null) { + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \msgpack_pack($payload); + } + $serializeTimes['msgpack'] = \microtime(true) - $start; + + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + \msgpack_unpack($msgpackPacked); + } + $unserializeTimes['msgpack'] = \microtime(true) - $start; + } + + return new SerializationResult( + serializeTimes: $serializeTimes, + unserializeTimes: $unserializeTimes, + cycles: $count, + ); + } + + /** + * @return array + */ + private function buildPayload(): array + { + $items = []; + for ($i = 0; $i < 100; $i++) { + $items[] = [ + 'id' => $i, + 'name' => 'item_' . $i, + 'value' => $i * 1.5, + 'tags' => ['alpha', 'beta', 'gamma'], + 'active' => $i % 2 === 0, + ]; + } + + return ['items' => $items, 'total' => 100]; + } +} diff --git a/src/Modules/TypeCasting.php b/src/Modules/TypeCasting.php new file mode 100644 index 0000000..379321d --- /dev/null +++ b/src/Modules/TypeCasting.php @@ -0,0 +1,85 @@ +config->get('benchmark.typeCasting.enabled'); + } + + public function run(): TypeCastingResult + { + $count = (int) ($this->config->get('benchmark.typeCasting.count') ?? self::$defaultCount); + + /** @var array $times */ + $times = []; + + // int to string + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = (string) $i; + } + $times['int → string'] = \microtime(true) - $start; + + // string to int + $str = '12345'; + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = (int) $str; + } + $times['string → int'] = \microtime(true) - $start; + + // int to float + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = (float) $i; + } + $times['int → float'] = \microtime(true) - $start; + + // float to int + $float = 3.14159; + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = (int) $float; + } + $times['float → int'] = \microtime(true) - $start; + + // string to float + $floatStr = '3.14159'; + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = (float) $floatStr; + } + $times['string → float'] = \microtime(true) - $start; + + // intval() + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = \intval($str); + } + $times['intval()'] = \microtime(true) - $start; + + // settype equivalent: bool cast + $start = \microtime(true); + for ($i = 0; $i < $count; $i++) { + $_ = (bool) $i; + } + $times['int → bool'] = \microtime(true) - $start; + + return new TypeCastingResult( + castTimes: $times, + cycles: $count, + ); + } +} From 93cfe7bf6e86166478b6c43e0c47af118fdeade5 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:51:27 +0200 Subject: [PATCH 18/22] Add rendering for new benchmark modules in CLI and HTML --- src/Renderer/CliRenderer.php | 115 +++++++++++++++++++++++++++- src/Renderer/HtmlRenderer.php | 138 ++++++++++++++++++++++++++++++++++ 2 files changed, 252 insertions(+), 1 deletion(-) diff --git a/src/Renderer/CliRenderer.php b/src/Renderer/CliRenderer.php index 9e10556..e07fdd6 100644 --- a/src/Renderer/CliRenderer.php +++ b/src/Renderer/CliRenderer.php @@ -8,13 +8,20 @@ use Hyperized\Benchmark\Result\BenchmarkResult; use Hyperized\Benchmark\Result\CpuResult; use Hyperized\Benchmark\Result\DiskResult; +use Hyperized\Benchmark\Result\AutoloadingResult; +use Hyperized\Benchmark\Result\CompressionResult; +use Hyperized\Benchmark\Result\EncryptionResult; use Hyperized\Benchmark\Result\FileReadResult; +use Hyperized\Benchmark\Result\FunctionOverheadResult; +use Hyperized\Benchmark\Result\HashingResult; use Hyperized\Benchmark\Result\JsonResult; use Hyperized\Benchmark\Result\MemoryResult; use Hyperized\Benchmark\Result\MySqlResult; use Hyperized\Benchmark\Result\NetworkResult; -use Hyperized\Benchmark\Result\RegexResult; use Hyperized\Benchmark\Result\PhpResult; +use Hyperized\Benchmark\Result\RegexResult; +use Hyperized\Benchmark\Result\SerializationResult; +use Hyperized\Benchmark\Result\TypeCastingResult; final class CliRenderer implements RendererInterface { @@ -53,6 +60,27 @@ public function render(BenchmarkResult $result, ?string $reportPath): void if ($result->fileRead !== null) { $this->renderFileRead($result->fileRead); } + if ($result->serialization !== null) { + $this->renderSerialization($result->serialization); + } + if ($result->hashing !== null) { + $this->renderHashing($result->hashing); + } + if ($result->encryption !== null) { + $this->renderEncryption($result->encryption); + } + if ($result->functionOverhead !== null) { + $this->renderFunctionOverhead($result->functionOverhead); + } + if ($result->typeCasting !== null) { + $this->renderTypeCasting($result->typeCasting); + } + if ($result->autoloading !== null) { + $this->renderAutoloading($result->autoloading); + } + if ($result->compression !== null) { + $this->renderCompression($result->compression); + } if ($result->cpu !== null) { $this->renderCpu($result->cpu); } @@ -182,6 +210,91 @@ private function renderFileRead(FileReadResult $fileRead): void echo $this->buildResultTable($fileRead->readTimes); } + private function renderSerialization(SerializationResult $serialization): void + { + $this->sectionTitle('Serialization'); + + echo ' ' . self::DIM . $serialization->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + + echo ' ' . self::BOLD . 'Serialize' . self::RESET . "\n\n"; + echo $this->buildResultTable($serialization->serializeTimes); + + echo ' ' . self::BOLD . 'Unserialize' . self::RESET . "\n\n"; + echo $this->buildResultTable($serialization->unserializeTimes); + } + + private function renderHashing(HashingResult $hashing): void + { + $this->sectionTitle('Hashing'); + + echo ' ' . self::BOLD . 'Hash Functions' . self::RESET . self::DIM . ' (' . $hashing->hashCycles . ' cycles, ms, lower is better)' . self::RESET . "\n\n"; + echo $this->buildResultTable($hashing->hashTimes); + + echo ' ' . self::BOLD . 'Password Hashing' . self::RESET . self::DIM . ' (' . $hashing->passwordCycles . ' cycles, ms, lower is better)' . self::RESET . "\n\n"; + echo $this->buildResultTable($hashing->passwordTimes); + } + + private function renderEncryption(EncryptionResult $encryption): void + { + $this->sectionTitle('Encryption'); + + echo ' ' . self::DIM . $encryption->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + + echo ' ' . self::BOLD . 'Encrypt' . self::RESET . "\n\n"; + echo $this->buildResultTable($encryption->encryptTimes); + + echo ' ' . self::BOLD . 'Decrypt' . self::RESET . "\n\n"; + echo $this->buildResultTable($encryption->decryptTimes); + } + + private function renderFunctionOverhead(FunctionOverheadResult $functionOverhead): void + { + $this->sectionTitle('Function Call Overhead'); + + echo ' ' . self::DIM . $functionOverhead->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + echo $this->buildResultTable($functionOverhead->callTimes); + } + + private function renderTypeCasting(TypeCastingResult $typeCasting): void + { + $this->sectionTitle('Type Casting'); + + echo ' ' . self::DIM . $typeCasting->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + echo $this->buildResultTable($typeCasting->castTimes); + } + + private function renderAutoloading(AutoloadingResult $autoloading): void + { + $this->sectionTitle('Object Instantiation'); + + echo ' ' . self::DIM . $autoloading->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + echo $this->buildResultTable($autoloading->instantiationTimes); + } + + private function renderCompression(CompressionResult $compression): void + { + $this->sectionTitle('Compression'); + + echo ' ' . self::DIM . $compression->cycles . ' cycles · ms (lower is better)' . self::RESET . "\n\n"; + + echo ' ' . self::BOLD . 'Compress' . self::RESET . "\n\n"; + echo $this->buildResultTable($compression->compressTimes); + + echo ' ' . self::BOLD . 'Decompress' . self::RESET . "\n\n"; + echo $this->buildResultTable($compression->decompressTimes); + + echo ' ' . self::BOLD . 'Ratio' . self::RESET . "\n\n"; + /** @var array $headers */ + $headers = []; + /** @var array $values */ + $values = []; + foreach ($compression->ratios as $name => $ratio) { + $headers[$name] = $name; + $values[$name] = \sprintf('%.1f%%', $ratio * 100); + } + echo $this->buildTable($headers, [$values]); + } + private function renderCpu(CpuResult $cpu): void { $this->sectionTitle('CPU'); diff --git a/src/Renderer/HtmlRenderer.php b/src/Renderer/HtmlRenderer.php index fcbc993..aeaaa52 100644 --- a/src/Renderer/HtmlRenderer.php +++ b/src/Renderer/HtmlRenderer.php @@ -8,13 +8,20 @@ use Hyperized\Benchmark\Result\BenchmarkResult; use Hyperized\Benchmark\Result\CpuResult; use Hyperized\Benchmark\Result\DiskResult; +use Hyperized\Benchmark\Result\AutoloadingResult; +use Hyperized\Benchmark\Result\CompressionResult; +use Hyperized\Benchmark\Result\EncryptionResult; use Hyperized\Benchmark\Result\FileReadResult; +use Hyperized\Benchmark\Result\FunctionOverheadResult; +use Hyperized\Benchmark\Result\HashingResult; use Hyperized\Benchmark\Result\JsonResult; use Hyperized\Benchmark\Result\MemoryResult; use Hyperized\Benchmark\Result\MySqlResult; use Hyperized\Benchmark\Result\NetworkResult; use Hyperized\Benchmark\Result\PhpResult; use Hyperized\Benchmark\Result\RegexResult; +use Hyperized\Benchmark\Result\SerializationResult; +use Hyperized\Benchmark\Result\TypeCastingResult; final class HtmlRenderer implements RendererInterface { @@ -53,6 +60,27 @@ public function render(BenchmarkResult $result, ?string $reportPath): void if ($result->fileRead !== null) { $this->renderFileRead($result->fileRead); } + if ($result->serialization !== null) { + $this->renderSerialization($result->serialization); + } + if ($result->hashing !== null) { + $this->renderHashing($result->hashing); + } + if ($result->encryption !== null) { + $this->renderEncryption($result->encryption); + } + if ($result->functionOverhead !== null) { + $this->renderFunctionOverhead($result->functionOverhead); + } + if ($result->typeCasting !== null) { + $this->renderTypeCasting($result->typeCasting); + } + if ($result->autoloading !== null) { + $this->renderAutoloading($result->autoloading); + } + if ($result->compression !== null) { + $this->renderCompression($result->compression); + } if ($result->cpu !== null) { $this->renderCpu($result->cpu); } @@ -157,6 +185,116 @@ private function renderMemory(MemoryResult $memory): void echo '
'; } + private function renderSerialization(SerializationResult $serialization): void + { + echo '
'; + echo '
Serialization
'; + echo '
'; + echo '

' . $serialization->cycles . ' cycles · ms (lower is better)

'; + + echo '

Serialize

'; + $this->renderResultTable($serialization->serializeTimes); + + echo '

Unserialize

'; + $this->renderResultTable($serialization->unserializeTimes); + + echo '
'; + } + + private function renderHashing(HashingResult $hashing): void + { + echo '
'; + echo '
Hashing
'; + echo '
'; + + echo '

Hash Functions (' . $hashing->hashCycles . ' cycles)

'; + $this->renderResultTable($hashing->hashTimes); + + echo '

Password Hashing (' . $hashing->passwordCycles . ' cycles)

'; + $this->renderResultTable($hashing->passwordTimes); + + echo '
'; + } + + private function renderEncryption(EncryptionResult $encryption): void + { + echo '
'; + echo '
Encryption
'; + echo '
'; + echo '

' . $encryption->cycles . ' cycles · ms (lower is better)

'; + + echo '

Encrypt

'; + $this->renderResultTable($encryption->encryptTimes); + + echo '

Decrypt

'; + $this->renderResultTable($encryption->decryptTimes); + + echo '
'; + } + + private function renderFunctionOverhead(FunctionOverheadResult $functionOverhead): void + { + echo '
'; + echo '
Function Call Overhead
'; + echo '
'; + echo '

' . $functionOverhead->cycles . ' cycles · ms (lower is better)

'; + + $this->renderResultTable($functionOverhead->callTimes); + + echo '
'; + } + + private function renderTypeCasting(TypeCastingResult $typeCasting): void + { + echo '
'; + echo '
Type Casting
'; + echo '
'; + echo '

' . $typeCasting->cycles . ' cycles · ms (lower is better)

'; + + $this->renderResultTable($typeCasting->castTimes); + + echo '
'; + } + + private function renderAutoloading(AutoloadingResult $autoloading): void + { + echo '
'; + echo '
Object Instantiation
'; + echo '
'; + echo '

' . $autoloading->cycles . ' cycles · ms (lower is better)

'; + + $this->renderResultTable($autoloading->instantiationTimes); + + echo '
'; + } + + private function renderCompression(CompressionResult $compression): void + { + echo '
'; + echo '
Compression
'; + echo '
'; + echo '

' . $compression->cycles . ' cycles · ms (lower is better)

'; + + echo '

Compress

'; + $this->renderResultTable($compression->compressTimes); + + echo '

Decompress

'; + $this->renderResultTable($compression->decompressTimes); + + echo '

Ratio

'; + echo ''; + foreach (\array_keys($compression->ratios) as $name) { + echo ''; + } + echo ''; + foreach ($compression->ratios as $ratio) { + echo ''; + } + echo '
' . $this->esc($name) . '
' . \sprintf('%.1f%%', $ratio * 100) . '
'; + + echo '
'; + } + private function renderCpu(CpuResult $cpu): void { echo '
'; From b0f79c3f633a6fdf900ced7ebb029fff1bac03dd Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:51:30 +0200 Subject: [PATCH 19/22] Wire new modules into orchestrator and config --- config/config.yml.example | 22 ++++++++++++++++++++++ src/Benchmark.php | 28 ++++++++++++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/config/config.yml.example b/config/config.yml.example index 46a7b1c..ac516c9 100644 --- a/config/config.yml.example +++ b/config/config.yml.example @@ -27,6 +27,28 @@ benchmark: fileRead: enabled: true cycles: 100 + serialization: + enabled: true + count: 9999 + hashing: + enabled: true + hashCount: 99999 + passwordCount: 10 + encryption: + enabled: true + count: 9999 + functionOverhead: + enabled: true + count: 999999 + typeCasting: + enabled: true + count: 999999 + autoloading: + enabled: true + count: 99999 + compression: + enabled: true + count: 999 cpu: enabled: true math: diff --git a/src/Benchmark.php b/src/Benchmark.php index 348e6c6..c34235f 100644 --- a/src/Benchmark.php +++ b/src/Benchmark.php @@ -5,15 +5,22 @@ namespace Hyperized\Benchmark; use Hyperized\Benchmark\Config\Config; +use Hyperized\Benchmark\Modules\Autoloading; +use Hyperized\Benchmark\Modules\Compression; use Hyperized\Benchmark\Modules\CPU; use Hyperized\Benchmark\Modules\Disk; +use Hyperized\Benchmark\Modules\Encryption; use Hyperized\Benchmark\Modules\FileRead; +use Hyperized\Benchmark\Modules\FunctionOverhead; +use Hyperized\Benchmark\Modules\Hashing; use Hyperized\Benchmark\Modules\Json; use Hyperized\Benchmark\Modules\Memory; use Hyperized\Benchmark\Modules\MySQL; use Hyperized\Benchmark\Modules\Network; use Hyperized\Benchmark\Modules\PHP; use Hyperized\Benchmark\Modules\Regex; +use Hyperized\Benchmark\Modules\Serialization; +use Hyperized\Benchmark\Modules\TypeCasting; use Hyperized\Benchmark\Renderer\CliRenderer; use Hyperized\Benchmark\Renderer\HtmlRenderer; use Hyperized\Benchmark\Report\Report; @@ -32,6 +39,13 @@ public function __construct( private readonly Json $json, private readonly Regex $regex, private readonly FileRead $fileRead, + private readonly Serialization $serialization, + private readonly Hashing $hashing, + private readonly Encryption $encryption, + private readonly FunctionOverhead $functionOverhead, + private readonly TypeCasting $typeCasting, + private readonly Autoloading $autoloading, + private readonly Compression $compression, private readonly MySQL $mysql, private readonly ReportWriter $reportWriter, ) {} @@ -48,6 +62,13 @@ public function execute(): void $jsonResult = $this->json->isEnabled() ? $this->json->run() : null; $regexResult = $this->regex->isEnabled() ? $this->regex->run() : null; $fileReadResult = $this->fileRead->isEnabled() ? $this->fileRead->run() : null; + $serializationResult = $this->serialization->isEnabled() ? $this->serialization->run() : null; + $hashingResult = $this->hashing->isEnabled() ? $this->hashing->run() : null; + $encryptionResult = $this->encryption->isEnabled() ? $this->encryption->run() : null; + $functionOverheadResult = $this->functionOverhead->isEnabled() ? $this->functionOverhead->run() : null; + $typeCastingResult = $this->typeCasting->isEnabled() ? $this->typeCasting->run() : null; + $autoloadingResult = $this->autoloading->isEnabled() ? $this->autoloading->run() : null; + $compressionResult = $this->compression->isEnabled() ? $this->compression->run() : null; $cpuResult = $this->cpu->isEnabled() ? $this->cpu->run() : null; $mysqlResult = $this->mysql->isEnabled() ? $this->mysql->run() : null; @@ -62,6 +83,13 @@ public function execute(): void json: $jsonResult, regex: $regexResult, fileRead: $fileReadResult, + serialization: $serializationResult, + hashing: $hashingResult, + encryption: $encryptionResult, + functionOverhead: $functionOverheadResult, + typeCasting: $typeCastingResult, + autoloading: $autoloadingResult, + compression: $compressionResult, mysql: $mysqlResult, ); From 07990e0f80f4ef47be8d611ceefa61011e6dd20d Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:53:24 +0200 Subject: [PATCH 20/22] Update README with full module list, example output, and config reference --- README.md | 113 ++++++++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 105 insertions(+), 8 deletions(-) diff --git a/README.md b/README.md index 00ae55b..f227419 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,7 @@ [![CI](https://github.com/hyperized/benchmark/actions/workflows/ci.yml/badge.svg)](https://github.com/hyperized/benchmark/actions/workflows/ci.yml) -Simple PHP server benchmarking utility that tests hosting environment capabilities: - -- **PHP** — version, ini settings (memory, uploads, execution time), loaded extensions -- **CPU** — math functions, string operations, loops, conditionals -- **Disk I/O** — file creation across block sizes -- **MySQL** — query performance +Comprehensive PHP server benchmarking utility. Tests your hosting environment across 15 benchmark modules and reports results in milliseconds. Auto-detects CLI vs web and renders with the appropriate output mode: - **CLI** — ANSI colors with Unicode box-drawing tables @@ -15,10 +10,33 @@ Auto-detects CLI vs web and renders with the appropriate output mode: Each run saves a JSON report with a timestamp to `reports/`. +## Benchmark Modules + +| Category | Module | What it measures | +|----------|--------|-----------------| +| **Environment** | PHP | Version, ini settings, memory limits, loaded extensions | +| **I/O** | Disk I/O | File creation across 8 block sizes (512B–64KB) | +| | File Read | Sequential reads across file sizes (1KB–4MB) | +| | Network | DNS resolution and TCP connection latency | +| **Compute** | CPU | Math functions, string operations, loops, conditionals | +| | Memory | Allocation speed and peak memory per operation | +| | Regex | `preg_match` and `preg_replace` across 10 pattern types | +| | JSON | Encode/decode with small, medium, and large payloads | +| | Serialization | `serialize` vs `json_encode` vs `var_export` (+ igbinary/msgpack if available) | +| | Hashing | md5, sha1, sha256, sha512, xxh3, crc32, hmac-sha256, bcrypt, argon2id | +| | Encryption | AES-128/256-CBC/GCM, ChaCha20-Poly1305 encrypt and decrypt | +| | Compression | gzip, zlib, deflate (+ brotli/lzf if available) with compression ratios | +| **Runtime** | Function Overhead | Named function, closure, first-class callable, static/instance method, arrow function | +| | Type Casting | int/string/float/bool conversions | +| | Object Instantiation | stdClass, DateTime, SplStack, SplPriorityQueue, ArrayObject | +| **Database** | MySQL | Query performance (requires ext-mysqli) | + +All modules can be individually enabled/disabled and configured in `config/config.yml`. + ## Requirements - PHP >= 8.4 -- ext-mysqli (for MySQL benchmarks) +- ext-mysqli (for MySQL benchmarks, optional) ## Installation @@ -37,12 +55,60 @@ Adjust `config/config.yml` to your preferences. Set module toggles, cycle counts php benchmark.php ``` -### Development server +Example output: + +``` +╔════════════════════════════════════════════════╗ +║ PHP Benchmark Report ║ +║ 2026-04-11 14:30:22 ║ +╚════════════════════════════════════════════════╝ + +── PHP ─────────────────────────────────────────── + + PHP Version 8.5.4 + Server CLI + Max Memory 128.00 MB + Max Upload 2.00 MB + Max Exec Time 0 seconds + Extensions 67 loaded + +── Disk I/O ────────────────────────────────────── + + Cycles: 100 + ms (lower is better) + + ┌──────────┬──────────┬──────────┬──────────┐ + │ 512.00 B │ 1.00 KB │ 2.00 KB │ 4.00 KB │ ... + ├──────────┼──────────┼──────────┼──────────┤ + │ 9.55ms │ 9.78ms │ 9.46ms │ 10.97ms │ ... + └──────────┴──────────┴──────────┴──────────┘ + +── Hashing ─────────────────────────────────────── + + Hash Functions (99999 cycles, ms, lower is better) + + ┌──────────┬──────────┬──────────┬──────────┬──────────┐ + │ md5 │ sha1 │ sha256 │ sha512 │ xxh3 │ ... + ├──────────┼──────────┼──────────┼──────────┼──────────┤ + │ 143.00ms │ 155.71ms │ 387.45ms │ 288.95ms │ 16.35ms │ ... + └──────────┴──────────┴──────────┴──────────┴──────────┘ + + ... + +────────────────────────────────────────────────── + Total Duration 18.657s + Report saved reports/2026-04-11_143022.json +────────────────────────────────────────────────── +``` + +### Web ```bash php -S localhost:8000 benchmark.php ``` +Open `http://localhost:8000` for a styled HTML dashboard with cards for each module. + ### Remote Install on the server via Composer and visit `/benchmark.php` in the browser. @@ -51,6 +117,37 @@ Install on the server via Composer and visit `/benchmark.php` in the browser. JSON reports are saved to `reports/` with timestamped filenames (e.g. `2026-04-11_143022.json`). Disable via `benchmark.output.report: false` in config. +### Configuration + +All modules support `enabled: true/false` and configurable cycle counts: + +```yaml +benchmark: + output: + format: ~ # null = auto-detect, 'cli', or 'html' + report: true # save JSON report to reports/ + php: + enabled: true + disk: + enabled: true + cycles: 100 + memory: + enabled: true + count: 9999 + network: + enabled: true + cycles: 3 + hosts: + - google.com + - cloudflare.com + - github.com + hashing: + enabled: true + hashCount: 99999 + passwordCount: 10 + # ... see config/config.yml.example for all options +``` + ### Security If deploying to a web server, ensure `config.yml` is not publicly accessible. For Apache with `mod_rewrite`: From aa700a93d1ee13365c639a11a68334bde546d87d Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:55:14 +0200 Subject: [PATCH 21/22] Add CLI progress indicator during benchmark execution Shows [1/15] Running Module... on a single updating line so the user knows work is in progress. Clears the line before rendering results. Only displayed in CLI mode. --- src/Benchmark.php | 70 ++++++++++++++++++++++++++++++++++++----------- 1 file changed, 54 insertions(+), 16 deletions(-) diff --git a/src/Benchmark.php b/src/Benchmark.php index c34235f..514897e 100644 --- a/src/Benchmark.php +++ b/src/Benchmark.php @@ -54,23 +54,61 @@ public function execute(): void { $start = \microtime(true); $timestamp = new \DateTimeImmutable(); + $isCli = $this->detectOutputMode() === OutputMode::Cli; - $phpResult = $this->php->isEnabled() ? $this->php->run() : null; - $diskResult = $this->disk->isEnabled() ? $this->disk->run() : null; - $memoryResult = $this->memory->isEnabled() ? $this->memory->run() : null; - $networkResult = $this->network->isEnabled() ? $this->network->run() : null; - $jsonResult = $this->json->isEnabled() ? $this->json->run() : null; - $regexResult = $this->regex->isEnabled() ? $this->regex->run() : null; - $fileReadResult = $this->fileRead->isEnabled() ? $this->fileRead->run() : null; - $serializationResult = $this->serialization->isEnabled() ? $this->serialization->run() : null; - $hashingResult = $this->hashing->isEnabled() ? $this->hashing->run() : null; - $encryptionResult = $this->encryption->isEnabled() ? $this->encryption->run() : null; - $functionOverheadResult = $this->functionOverhead->isEnabled() ? $this->functionOverhead->run() : null; - $typeCastingResult = $this->typeCasting->isEnabled() ? $this->typeCasting->run() : null; - $autoloadingResult = $this->autoloading->isEnabled() ? $this->autoloading->run() : null; - $compressionResult = $this->compression->isEnabled() ? $this->compression->run() : null; - $cpuResult = $this->cpu->isEnabled() ? $this->cpu->run() : null; - $mysqlResult = $this->mysql->isEnabled() ? $this->mysql->run() : null; + $modules = [ + 'PHP' => $this->php, + 'Disk I/O' => $this->disk, + 'Memory' => $this->memory, + 'Network' => $this->network, + 'JSON' => $this->json, + 'Regex' => $this->regex, + 'File Read' => $this->fileRead, + 'Serialization' => $this->serialization, + 'Hashing' => $this->hashing, + 'Encryption' => $this->encryption, + 'Function Overhead' => $this->functionOverhead, + 'Type Casting' => $this->typeCasting, + 'Object Instantiation' => $this->autoloading, + 'Compression' => $this->compression, + 'CPU' => $this->cpu, + 'MySQL' => $this->mysql, + ]; + + $enabledModules = \array_filter($modules, static fn($m) => $m->isEnabled()); + $total = \count($enabledModules); + $current = 0; + + /** @var array $results */ + $results = []; + foreach ($enabledModules as $name => $module) { + $current++; + if ($isCli) { + echo "\r\033[2m [{$current}/{$total}] Running {$name}...\033[0m\033[K"; + } + $results[$name] = $module->run(); + } + + if ($isCli) { + echo "\r\033[K"; + } + + $phpResult = $results['PHP'] ?? null; + $diskResult = $results['Disk I/O'] ?? null; + $memoryResult = $results['Memory'] ?? null; + $networkResult = $results['Network'] ?? null; + $jsonResult = $results['JSON'] ?? null; + $regexResult = $results['Regex'] ?? null; + $fileReadResult = $results['File Read'] ?? null; + $serializationResult = $results['Serialization'] ?? null; + $hashingResult = $results['Hashing'] ?? null; + $encryptionResult = $results['Encryption'] ?? null; + $functionOverheadResult = $results['Function Overhead'] ?? null; + $typeCastingResult = $results['Type Casting'] ?? null; + $autoloadingResult = $results['Object Instantiation'] ?? null; + $compressionResult = $results['Compression'] ?? null; + $cpuResult = $results['CPU'] ?? null; + $mysqlResult = $results['MySQL'] ?? null; $result = new BenchmarkResult( timestamp: $timestamp, From 73252f73ecd12621dee56a1feb6e5855ec8ab398 Mon Sep 17 00:00:00 2001 From: Gerben Geijteman Date: Sat, 11 Apr 2026 23:57:01 +0200 Subject: [PATCH 22/22] Add color to CLI progress indicator --- src/Benchmark.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Benchmark.php b/src/Benchmark.php index 514897e..e18b1ec 100644 --- a/src/Benchmark.php +++ b/src/Benchmark.php @@ -84,7 +84,7 @@ public function execute(): void foreach ($enabledModules as $name => $module) { $current++; if ($isCli) { - echo "\r\033[2m [{$current}/{$total}] Running {$name}...\033[0m\033[K"; + echo "\r \033[1;36m[\033[0m\033[32m{$current}\033[2m/\033[0m{$total}\033[1;36m]\033[0m \033[2mRunning\033[0m \033[1m{$name}\033[0m\033[2m...\033[0m\033[K"; } $results[$name] = $module->run(); }