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/README.md b/README.md index bee6371..f227419 100644 --- a/README.md +++ b/README.md @@ -1,55 +1,178 @@ # 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; +Comprehensive PHP server benchmarking utility. Tests your hosting environment across 15 benchmark modules and reports results in milliseconds. -## 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: +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 CLI - php benchmark.php +Each run saves a JSON report with a timestamp to `reports/`. -### Locally with development server +## Benchmark Modules - 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. +| 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, optional) + +## 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 +``` + +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. + +### 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. + +### 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 -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 diff --git a/autoload.php b/autoload.php index c76c8bf..1bb7803 100644 --- a/autoload.php +++ b/autoload.php @@ -1,3 +1,5 @@ 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/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/config/config.yml.example b/config/config.yml.example index c9072a9..ac516c9 100644 --- a/config/config.yml.example +++ b/config/config.yml.example @@ -1,10 +1,54 @@ --- 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 + json: + enabled: true + count: 9999 + regex: + enabled: true + count: 99999 + 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: @@ -22,4 +66,4 @@ benchmark: port: 3306 username: myuser password: mypassword - database: mydatabase \ No newline at end of file + database: mydatabase diff --git a/info.php b/info.php index ac35b2b..bf976f8 100644 --- a/info.php +++ b/info.php @@ -1,3 +1,5 @@ detectOutputMode() === OutputMode::Cli; + + $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[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(); + } + + 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, + totalDuration: \microtime(true) - $start, + php: $phpResult, + cpu: $cpuResult, + disk: $diskResult, + memory: $memoryResult, + network: $networkResult, + json: $jsonResult, + regex: $regexResult, + fileRead: $fileReadResult, + serialization: $serializationResult, + hashing: $hashingResult, + encryption: $encryptionResult, + functionOverhead: $functionOverheadResult, + typeCasting: $typeCastingResult, + autoloading: $autoloadingResult, + compression: $compressionResult, + 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 +} diff --git a/src/Config/Config.php b/src/Config/Config.php index a780cf2..dabbfde 100644 --- a/src/Config/Config.php +++ b/src/Config/Config.php @@ -1,72 +1,48 @@ */ + 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 @@ -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/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/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/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/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/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/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/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/Memory.php b/src/Modules/Memory.php new file mode 100644 index 0000000..20578f0 --- /dev/null +++ b/src/Modules/Memory.php @@ -0,0 +1,91 @@ +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(); + \memory_reset_peak_usage(); + $memBefore = \memory_get_peak_usage(); + + $start = \microtime(true); + $fn($count); + $times[$name] = \microtime(true) - $start; + + $peaks[$name] = \memory_get_peak_usage() - $memBefore; + } +} 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/Network.php b/src/Modules/Network.php new file mode 100644 index 0000000..37bae84 --- /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, 2); + $total += \microtime(true) - $start; + + if ($socket !== false) { + \fclose($socket); + } + } + + return $total / $cycles; + } +} 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/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, + ); + } +} 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, + ); + } +} 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 @@ +renderHeader($result); + + if ($result->php !== null) { + $this->renderPhp($result->php); + } + if ($result->disk !== null) { + $this->renderDisk($result->disk); + } + 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->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); + } + 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 . 'ms (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('%.2fms', $time * 1000); + } + + echo $this->buildTable($headers, [$values]); + } + + private function renderMemory(MemoryResult $memory): void + { + $this->sectionTitle('Memory'); + + echo ' ' . self::DIM . $memory->cycles . ' cycles · ms (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('%.2fms', $time * 1000); + $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 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 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'); + + 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); + + echo ' ' . self::BOLD . 'Strings' . self::RESET . self::DIM . ' (' . $cpu->stringCount . ' cycles)' . self::RESET . "\n\n"; + echo $this->buildResultTable($cpu->stringResults); + + $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"; + } + + private function renderMySql(MySqlResult $mysql): void + { + $this->sectionTitle('MySQL'); + + $this->keyValue('Version', (string) $mysql->version); + $this->keyValue('Query Time', \sprintf('%.2fms', $mysql->queryTime * 1000) . 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 seconds + */ + 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('%.2fms', $time * 1000); + } + + 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..aeaaa52 --- /dev/null +++ b/src/Renderer/HtmlRenderer.php @@ -0,0 +1,541 @@ +'; + 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->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->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); + } + 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 · ms (lower is better)

'; + + echo ''; + foreach ($disk->fileCreationTimes as $blockSize => $_) { + echo ''; + } + echo ''; + foreach ($disk->fileCreationTimes as $time) { + echo ''; + } + echo '
' . $this->esc(Size::bytesToFormat($blockSize)) . '
' . \sprintf('%.2fms', $time * 1000) . '
'; + + echo '
'; + } + + private function renderMemory(MemoryResult $memory): void + { + echo '
'; + echo '
Memory
'; + echo '
'; + echo '

' . $memory->cycles . ' cycles · ms (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('%.2fms', $time * 1000) . '
'; + + 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 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 '
'; + echo '
CPU
'; + echo '
'; + + echo '

ms (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('%.2fms', $cpu->loopsResult * 1000) . ' (' . $cpu->loopsCount . ' cycles)'); + $this->dt('If/Else', \sprintf('%.2fms', $cpu->ifElseResult * 1000) . ' (' . $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('%.2fms', $mysql->queryTime * 1000) . ' (' . $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 '
'; + } + + 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 + */ + 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('%.2fms', $time * 1000) . '
'; + } + + 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 @@ + + */ + 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; + } +} 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 new file mode 100644 index 0000000..82005e0 --- /dev/null +++ b/src/Result/BenchmarkResult.php @@ -0,0 +1,91 @@ + + */ + 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->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->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; + } + + return $data; + } +} 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/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/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/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/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/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/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, + ]; + } +} 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/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/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, + ]; + } +} 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, + ]; + } +} 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, + ]; + } +}