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. Удачи.

Комментарии (1)


  1. zorn-v100500
    24.06.2025 06:35

    https://www.php-fig.org/psr/psr-2/

    • Opening braces for classes MUST go on the next line, and closing braces MUST go on the next line after the body.

    • Opening braces for methods MUST go on the next line, and closing braces MUST go on the next line after the body.

    А то аж глаза потекли