PHPBench - это, кажется, крайне не популярный фреймворк для тестирования производительности кода на PHP. По крайней мере за 18 лет он мне ни разу нигде не встретился, а услышал я об нём примерно год назад. Фреймворк PHPUnit-подобный, где бенчмарки, как и тесты в PHPUnit объединяются в классы, группы и т.д. и т.п. Чтобы много не болтать, давайте напишем чуть кода и отбенчмаркаем его.
Я пошагово опишу всё, чтобы вы могли быстро повторить всё это у себя. Вначале создаём директорию в которой будем химичить и переходим в неё:
mkdir phpbench && cd phpbench
Закинем в корень проекта вот такой composer.json
:
{
"name": "my/app",
"require-dev": {
"phpbench/phpbench": "*"
},
"autoload": {
"psr-4": {
"My\\App\\": "src/",
"My\\App\\Tests\\": "tests/"
}
},
}
Затем вот такой phpbench.json
:
{
"runner.bootstrap": "vendor/autoload.php",
"runner.php_disable_ini": true
}
В папку src/
вот такой файл User.php
:
<?php
namespace My\App;
class User {
public static function getFullName(string $firstName, string $middleName, string $lastName) {
return "$firstName $middleName $lastName";
}
}
Создадим папку tests/Benchmark
:
mkdir tests/Benchmark
И в неё закинем вот такой UserBench.php
:
<?php
namespace My\App\Tests;
use My\App\User;
class UserBench {
function benchGetFullName() {
User::getFullName('Иван', 'Иваныч', 'Иванов');
}
}
Получится вот такая структура проекта:
$ ls -R
composer.json phpbench.json src/ tests/
./src:
User.php
./tests:
Benchmark/
./tests/Benchmark:
UserBench.php
Устанавливаем зависимости:
composer install
И наконец-то запускаем наш бенчмарк вот так:
./vendor/bin/phpbench run ./tests/Benchmark/UserBench.php
PHPBench (1.4.1) running benchmarks... #standwithukraine
with configuration file: /Users/zeleniy/Projects/phpbench/phpbench.json
with PHP version 8.4.8, xdebug ❌, opcache ❌
\My\App\Tests\UserBench
benchGetFullName........................I0 - Mo114.000μs (±0.00%)
Subjects: 1, Assertions: 0, Failures: 0, Errors: 0
Выглядит суховато, но давайте разберёмся, что тут написано, потом сделаем вывод более информативным. А написано тут, что обнаружился один бенчмарк benchGetFullName
в классе My\App\Tests\UserBench
и среднее время его выполнения - 114.000μs
т.е. 0.000114 секунды. В скобках (±0.00%)
указан разброс.
Есть возможность менять репорты, писать их файлы в том числе и в виде JSON. Давайте пока попробуем разнообразить этот отчёт опцией --report
со значением default
, которое по умолчанию дефолтным не являтся (разве не шикарное поведение? ?):
$ ./vendor/bin/phpbench run ./tests/Benchmark/UserBench.php --report=default
PHPBench (1.4.1) running benchmarks... #standwithukraine
with configuration file: /Users/zeleniy/Projects/phpbench/phpbench.json
with PHP version 8.4.8, xdebug ❌, opcache ❌
\My\App\Tests\UserBench
benchGetFullName........................I0 - Mo115.000μs (±0.00%)
Subjects: 1, Assertions: 0, Failures: 0, Errors: 0
+------+-----------+------------------+-----+------+----------+-----------+--------------+----------------+
| iter | benchmark | subject | set | revs | mem_peak | time_avg | comp_z_value | comp_deviation |
+------+-----------+------------------+-----+------+----------+-----------+--------------+----------------+
| 0 | UserBench | benchGetFullName | | 1 | 716,520b | 115.000μs | +0.00σ | +0.00% |
+------+-----------+------------------+-----+------+----------+-----------+--------------+----------------+
Тут у нас уже есть табличка. Я не буду пока на ней останавливаться. Видно, что Mo114.000μs
изменлилось на Mo115.000μs
и эта же цифра фигурирует в колонке time_avg
. Что такое Mo
перед 115.000μs
выяснить не удалось. Эти цифры будут постоянно меняться т.к. на вашем компьютере запущены сотни и тысячи фоновых процессов из-за которых и происходят эти флуктуации. Проблема этого теста в том, что он запускается только 1 раз и никакой достоверной статистики собрать с этого никак нельзя. Исправить это можно с помощью опции --revs
:
./v$ ./vendor/bin/phpbench run ./tests/Benchmark/UserBench.php --report=default --revs=1000
PHPBench (1.4.1) running benchmarks... #standwithukraine
with configuration file: /Users/zeleniy/Projects/phpbench/phpbench.json
with PHP version 8.4.8, xdebug ❌, opcache ❌
\My\App\Tests\UserBench
benchGetFullName........................I0 - Mo0.269μs (±0.00%)
Subjects: 1, Assertions: 0, Failures: 0, Errors: 0
+------+-----------+------------------+-----+------+----------+----------+--------------+----------------+
| iter | benchmark | subject | set | revs | mem_peak | time_avg | comp_z_value | comp_deviation |
+------+-----------+------------------+-----+------+----------+----------+--------------+----------------+
| 0 | UserBench | benchGetFullName | | 1000 | 716,520b | 0.269μs | +0.00σ | +0.00% |
+------+-----------+------------------+-----+------+----------+----------+--------------+----------------+000
--revs=1000
означает, что код метода User::getFullName
будет запущен не 1 раз, а 1000. Интересно, что время увеличилось до 0.269μs
. Я вам честно скажу, что в статистике я почти что не разбираюсь. И почему-то при указании --revs=1000
и без него скорость выполения отличается в 2-3 раза, хотя казлось бы она должна быть примерно одинаковой. На этом странности не кончаются. Есть ещё одна опция --iterations
, которая регламентирует сколько раз тест будет запущен... я даже не знаю как это сказать простым языком. Короче в итоге тест будет запущен --revs умножить на --iterations
раз. Вот так это выглядит на экране:
$ ./vendor/bin/phpbench run ./tests/Benchmark/UserBench.php --report=default --revs=1000 --iterations=5
PHPBench (1.4.1) running benchmarks... #standwithukraine
with configuration file: /Users/zeleniy/Projects/phpbench/phpbench.json
with PHP version 8.4.8, xdebug ❌, opcache ❌
\My\App\Tests\UserBench
benchGetFullName........................I4 - Mo0.270μs (±7.62%)
Subjects: 1, Assertions: 0, Failures: 0, Errors: 0
+------+-----------+------------------+-----+------+----------+----------+--------------+----------------+
| iter | benchmark | subject | set | revs | mem_peak | time_avg | comp_z_value | comp_deviation |
+------+-----------+------------------+-----+------+----------+----------+--------------+----------------+
| 0 | UserBench | benchGetFullName | | 1000 | 716,520b | 0.322μs | +1.95σ | +14.84% |
| 1 | UserBench | benchGetFullName | | 1000 | 716,520b | 0.261μs | -0.91σ | -6.92% |
| 2 | UserBench | benchGetFullName | | 1000 | 716,520b | 0.276μs | -0.21σ | -1.57% |
| 3 | UserBench | benchGetFullName | | 1000 | 716,520b | 0.272μs | -0.39σ | -3.00% |
| 4 | UserBench | benchGetFullName | | 1000 | 716,520b | 0.271μs | -0.44σ | -3.35% |
+------+-----------+------------------+-----+------+----------+----------+--------------+----------------+
Наконец-то в колонках comp_z_value
и comp_deviation
что-то появилось. И в скобках тоже что-то появилось: Mo0.270μs (±7.62%)
. Надо думать это среднее значение по колонке comp_deviation
, которая представляет собой коэффициент вариации, а comp_z_value
- это z-оценка т.е. разброс времени выполнения между каждой итерацией (--iterations
) относительно среднего значения. Я честно не вижу разницы между тем, чтобы запустить тест 5000 раз или 5 раз по тысяче. Но возможно это из-за скудоумия. Если вдруг вы разбираетесь в статистике, то поясните пожалуйста в комментариях что всё это и зачем, в целях ликвидации безграмотности среди населения, так сказать ?
При выполнении оптимизаций кода, мы будем ориентироваться на среднее значение (0.270μs
). А собственно, давайте и попробуем улучшить этот код. Но перед этим сохраним текущие результаты с пометкой interpolation
. Если вы вдруг помните, то когда переменные запихиваются непосредственно в строку с двойными кавычками, то это называется variable interpolation
. Тэгнуть версию бенчмарка можно при помощи опции --tag
:
./vendor/bin/phpbench run ./tests/Benchmark/UserBench.php --report=default --revs=1000 --iterations=5 --tag=interpolation
Кстати, PHPBench поддерживает аннотации и опции --revs=1000
и --iterations=5
можно прописать непосредственно перед тестируемым методом (или классом), поэтому я буду их опускать далее:
class UserBench {
/**
* @Revs(1000)
* @Iterations(5)
*/
function benchGetFullName() {
User::getFullName('Иван', 'Иваныч', 'Иванов');
}
}
Теперь давайте заменим наш способ сборки полного имени с "$firstName $middleName $lastName"
на явную конктенацию $firstName . ' ' . $middleName . ' ' . $lastName
и запустим бенчмарк ещё раз указав при помощи опции --ref
референс т.е. предыдущий запуск, который мы тэгнули как --tag=interpolation
:
$ ./vendor/bin/phpbench run ./tests/Benchmark/UserBench.php --report=default --ref=interpolation
PHPBench (1.4.1) running benchmarks... #standwithukraine
with configuration file: /Users/zeleniy/Projects/phpbench/phpbench.json
with PHP version 8.4.8, xdebug ❌, opcache ❌
comparing [actual vs. interpolation]
\My\App\Tests\UserBench
benchGetFullName........................I4 - [Mo0.345μs vs. Mo0.279μs] +23.87% (±5.20%)
Subjects: 1, Assertions: 0, Failures: 0, Errors: 0
+------+-----------+------------------+-----+------+-----------------+----------+--------------+----------------+
| iter | benchmark | subject | set | revs | mem_peak | time_avg | comp_z_value | comp_deviation |
+------+-----------+------------------+-----+------+-----------------+----------+--------------+----------------+
| 0 | UserBench | benchGetFullName | | 1000 | 716.520kb 0.00% | 0.344μs | +0.41σ | +2.14% |
| 1 | UserBench | benchGetFullName | | 1000 | 716.520kb 0.00% | 0.349μs | +0.70σ | +3.62% |
| 2 | UserBench | benchGetFullName | | 1000 | 716.520kb 0.00% | 0.302μs | -1.99σ | -10.33% |
| 3 | UserBench | benchGetFullName | | 1000 | 716.520kb 0.00% | 0.343μs | +0.35σ | +1.84% |
| 4 | UserBench | benchGetFullName | | 1000 | 716.520kb 0.00% | 0.346μs | +0.53σ | +2.73% |
+------+-----------+------------------+-----+------+-----------------+----------+--------------+----------------+
Что мы тут видим? Главное тут вот это: [Mo0.345μs vs. Mo0.279μs] +23.87%
. Новая версия кода на 23% процента медленее, чем предыдущая. Разработчики PHPBench рассказ про опции --tag
и --ref
помещают в раздел про регрессионное тестирование. Теоретически вы можете прикрутить это в ваш CI-пайплайн (и спать спокойно). Да, и теперь вы знаете как правильно сраться с коллегами на тему, что быстрее, например join или implode. Кстати, про CI. PHPBench поддерживает exit-коды:
0
: Everything was fine.1
: Errors encountered in benchmarks.2
: Assertion failures.255
: Internal error
Хочу акцентировать ваше внимание на коде 2 Assertion failures
. Да, у PHPBench есть ассерты т.е. проверки на соответствия каким-либо измерениям и список их не так уж мал. Тут суть в том, что при помощи аннотации вы можете, например, сказать, что я не хочу, чтобы метод X потреблял памяти больше, чем N вот так:
class UserBench {
/**
* @Revs(1000)
* @Iterations(5)
* @Assert("mode(variant.time.avg) < 200 microseconds +/- 10%")
* @Assert("mode(variant.mem.final) < 10")
*/
function benchGetFullName() {
User::getFullName('Иван', 'Иваныч', 'Иванов');
}
}
Кстати, дополнительную инфу по памяти вы можете получить при помощи значения memory
для опции --report
:

Видно, что мы заказывали 10 байт памяти, а скрипт съел 500120. И exit-код равен 2. Можно указывать допустимые отклонения: @Assert("mode(variant.time.avg) < 200 microseconds +/- 10%")
. Однако история про проверку времени выполнения кажется не жилой т.к. на разных компьютерах будут разные показатели.
Хочу ещё обратить ваше внимание на красные крестики напротив xdebug
и opcache
. Они хоть и красные, но в руководстве к PHPBench говорится, что xdebug
можно было бы и отключить, что я и сделал прописав в phpbench.json
"runner.php_disable_ini": true
. В принципе, большой разницы нет, включён xdebug
или нет, главное чтобы в конфиге было прописано что-то одно, иначе тесты будут выдавать несопостовимые показатели. Но всё же, скажу, что с включённым XDebug код $firstName . ' ' . $middleName . ' ' . $lastName;
выполняется за 0.979μs
, а с выключенным за 0.328μs
. Смысла просто так коптить землю не вижу.
Обсуждая включён или выключен XDebug мы плавно переходим к вопросу воспроизводимости. Понятно, что все эти показатели имеют смысл только в контексте одного компьютера, будь то домашний/рабочий комп или CI-сервер. Даже в рамках примеров, которые я приводил выше есть выбросы в пределах одного запуска: 0.344μs, 0.349μs, 0.302μs, 0.343μs, 0.346μs. Очевидно, что 0.302 выпадает из этого ряда чисел. Полагаться на такие плавающие показатели не хотелось бы. И разработчики предусмотрели флаг --retry-threshold
, который отслеживает выбросы и не включает их в финальный отчёт:

Видно, что показатели в колонке comp_deviation
теперь не выходят за границы 3, а среднее время в обоих запусках укладывается в интервал 0.321 ± 1.18% и 0.311 ± 1.91% т. е. теперь показатели запусков сопоставимы, где бы они не запускались. Чем меньшее значение вы передаёте в --retry-threshold
, тем дольше выполняется тест т.к. придётся отбросить большее количество проб. Но тем точнее результаты.
Выше я писал, что для сравнения производительности можно использовать опции --tag
и --ref
. Однако для каких-то микробенчмарков, если вдруг это надо (вот тут даже миллион+ советов и мы ими ща воспользуемся), проще использовать один класс с несколькими методами, чем именованные версии.
Ну что, давайте затестим способы конкатенации строки, что ли? Вот класс с гипотезами, скажем так:
<?php
namespace My\App;
class User {
public static function getFullNameInterpolate(string $firstName, string $middleName, string $lastName) {
return "$firstName $middleName $lastName";
}
public static function getFullNameConcatenate(string $firstName, string $middleName, string $lastName) {
return $firstName . ' ' . $middleName . ' ' . $lastName;
}
public static function getFullNameSprintfSingleQuote(string $firstName, string $middleName, string $lastName) {
return sprintf('%s %s %s', $firstName, $middleName, $lastName);
}
public static function getFullNameSprintfDoubleQuote(string $firstName, string $middleName, string $lastName) {
return sprintf('%s %s %s', $firstName, $middleName, $lastName);
}
}
Вот класс с бенчмарками:
<?php
namespace My\App\Tests;
use My\App\User;
/**
* @Revs(1000)
* @Iterations(5)
*/
class UserBench {
function benchFullNameInterpolate() {
User::getFullNameInterpolate('Иван', 'Иваныч', 'Иванов');
}
function benchGetFullNameConcatenate() {
User::getFullNameConcatenate('Иван', 'Иваныч', 'Иванов');
}
function benchGetFullNameSprintfSingleQuote() {
User::getFullNameSprintfSingleQuote('Иван', 'Иваныч', 'Иванов');
}
function benchFetFullNameSprintfDoubleQuote() {
User::getFullNameSprintfDoubleQuote('Иван', 'Иваныч', 'Иванов');
}
}
А вот правда-матка (на моём компьютере):
$ ./vendor/bin/phpbench run ./tests/Benchmark/UserBench.php --report=aggregate --retry-threshold=3
PHPBench (1.4.1) running benchmarks... #standwithukraine
with configuration file: /Users/zeleniy/Projects/phpbench/phpbench.json
with PHP version 8.4.8, xdebug ❌, opcache ❌
\My\App\Tests\UserBench
benchGetFullNameInterpolate.............R2 I3 - Mo0.299μs (±0.84%)
benchGetFullNameConcatenate.............R2 I1 - Mo0.371μs (±1.83%)
benchGetFullNameSprintfSingleQuote......R1 I0 - Mo0.399μs (±1.29%)
benchGetFullNameSprintfDoubleQuote......R1 I3 - Mo0.398μs (±1.44%)
Subjects: 4, Assertions: 0, Failures: 0, Errors: 0
+-----------+------------------------------------+-----+------+-----+-----------+---------+--------+
| benchmark | subject | set | revs | its | mem_peak | mode | rstdev |
+-----------+------------------------------------+-----+------+-----+-----------+---------+--------+
| UserBench | benchGetFullNameInterpolate | | 1000 | 5 | 717.048kb | 0.299μs | ±0.84% |
| UserBench | benchGetFullNameConcatenate | | 1000 | 5 | 717.048kb | 0.371μs | ±1.83% |
| UserBench | benchGetFullNameSprintfSingleQuote | | 1000 | 5 | 717.064kb | 0.399μs | ±1.29% |
| UserBench | benchGetFullNameSprintfDoubleQuote | | 1000 | 5 | 717.064kb | 0.398μs | ±1.44% |
+-----------+------------------------------------+-----+------+-----+-----------+---------+--------+
Победила жвачка. Кстати, опция --report
поддерживает множество значений, в данном случае использован aggregate
, который понятно что.
Так же хочется добавить, что не стоит принебрегать как минимум первыми четырьмя советами из уже ранее упомянутой статьи Серия 50+ советов по оптимизации PHP кода. «За» и «против» такой оптимизации. Первые 10 советов.
PHPBench - это инструмент для измерений. Для поиска же узких мест в коде вам понадобится профилировщик, например тот же XDebug. Удачи.
zorn-v100500
https://www.php-fig.org/psr/psr-2/
А то аж глаза потекли