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
-[](https://scrutinizer-ci.com/g/hyperized/benchmark/build-status/master) [](https://scrutinizer-ci.com/g/hyperized/benchmark/?branch=master)
-[](https://app.fossa.io/projects/git%2Bgithub.com%2Fhyperized%2Fbenchmark?ref=badge_shield)
-Simple PHP server benchmarking.
+[](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
-[](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 '| ' . $this->esc(Size::bytesToFormat($blockSize)) . ' | ';
+ }
+ echo '
';
+ foreach ($disk->fileCreationTimes as $time) {
+ echo '| ' . \sprintf('%.2fms', $time * 1000) . ' | ';
+ }
+ echo '
';
+
+ 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 '| ' . $this->esc($name) . ' | ';
+ }
+ echo '
';
+ foreach ($memory->allocationTimes as $time) {
+ echo '| ' . \sprintf('%.2fms', $time * 1000) . ' | ';
+ }
+ echo '
';
+
+ echo '
Peak Memory
';
+ echo '
';
+ foreach (\array_keys($memory->peakMemoryUsages) as $name) {
+ echo '| ' . $this->esc($name) . ' | ';
+ }
+ echo '
';
+ foreach ($memory->peakMemoryUsages as $bytes) {
+ echo '| ' . $this->esc(Size::bytesToFormat($bytes)) . ' | ';
+ }
+ echo '
';
+
+ 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 '| ' . $this->esc($name) . ' | ';
+ }
+ echo '
';
+ foreach ($compression->ratios as $ratio) {
+ echo '| ' . \sprintf('%.1f%%', $ratio * 100) . ' | ';
+ }
+ echo '
';
+
+ 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 '';
+ }
+
+ 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 '| ' . $this->esc($name) . ' | ';
+ }
+ echo '
';
+ foreach ($results as $time) {
+ echo '| ' . \sprintf('%.2fms', $time * 1000) . ' | ';
+ }
+ echo '
';
+ }
+
+ 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,
+ ];
+ }
+}