Заметки KPHP: тестирование и бенчмарки

Перед вами первая статья из серии «Как использовать KPHP в open source?».

В этих статьях мы будем разбирать разные аспекты работы с KPHP, расширяя информацию, которую вы можете найти в официальной документации.

В сегодняшнем выпуске обсудим:


  • базовое использование composer с KPHP;
  • как писать и запускать unit-тесты для KPHP;
  • бенчмаркинг KPHP-кода (профилирование затронем в другой раз);
  • как правильно сравнивать результаты бенчмарков.

image-loader.svg


Предисловие

Кратко о том, в чём преимущества KPHP и зачем он вообще нужен:


  • очень хорошо оптимизируются абстракции типа классов, интерфейсов и функций;
  • проверки типов выполняются на этапе компиляции;
  • все скрипты компилируются в единый исполняемый файл.

Но сейчас использовать KPHP для «боевых» задач за пределами ВКонтакте проблематично по нескольким причинам:


  • нет готовых решений для работы с популярными базами данных;
  • мало примеров и обучающего материала, осваивать особенности тулинга сложно;
  • очень немногие PHP-библиотеки будет работать с KPHP без модификаций.

Наша команда работает над тем, чтобы каждый из этих аспектов со временем становился менее выраженным. Мы хотим, чтобы разработчики могли эффективно и удобно использовать KPHP за пределами ВКонтакте. Для этого добавляем фичи в компилятор и рантайм, а статьями вроде этой дополняем сведения из документации.


Готовимся к работе

Сперва нужно установить KPHP.


Если у вас есть такая возможность, рекомендую ставить KPHP из deb-пакета. Работать через Docker-контейнер может быть менее удобно.
# Качаем Docker-образ:
$ docker pull vkcom/kphp

Мы будем разрабатывать свой код в директории ~/kphp-uuid. Её нужно будет пробросить в контейнер при его запуске.

# Запускаем контейнер, подключаемся к нему:
$ docker run -ti -v ~/kphp-uuid/:/tmp/kphp-uuid:rw -p 8080:8080 vkcom/kphp

Внутри контейнера вам будет доступен компилятор kphp.

$ kphp --version
kphp2cpp compiled at ...

Позже нам понадобится утилита ktest внутри контейнера. Я предлагаю положить бинарник ktest в директорию ~/kphp-uuid, которую мы уже договорились подключать к контейнеру.

Далее по ходу статьи я буду делать вид, что kphp у вас установлен локально.


Создаём проект

Подготовим директорию, в которой будем работать:

$ mkdir kphp-uuid
$ cd kphp-uuid

Любой уважающий себя [K]PHP-пакет хочет дружить с composer, поэтому нам нужен composer.json в самом корне:

{
    "name": "quasilyte/kphp-uuid",
    "description": "A simple UUID generation package for KPHP",
    "type": "library",
    "license": "MIT",
    "autoload": {
        "psr-4": {"Quasilyte\\KPHP\\Uuid\\": "src/"}
    },
    "require": {}
}


KPHP также поддерживает autoload.files, а в будущем добавятся autoload.psr-0 и autoload.classmap. Если эти возможности нужны вам прямо сейчас, дайте нам знать.

Структура типичного пакета выглядит примерно так:

kphp-uuid/
  src/    <- Здесь все наши исходники
  tests/  <- [K]PHPUnit тесты
  vendor/ <- Зависимости, установленные через composer
  composer.json

Как можно догадаться по описанию, наш пакет будет предоставлять функции создания UUID. Основной класс мы разместим в файле src/UUID.php:


Внимание: приведённая в примере выше генерация UUIDv4 не должна использоваться в реальных приложениях. Этот код стоит рассматривать только как компактный и удобный пример для демонстрации.

Библиотека выложена на Packagist, поэтому её можно установить в своих [K]PHP-программах.



Устанавливаем и используем kphp-uuid

Теперь создадим отдельный проект, в котором установим kphp-uuid и запустим наш hello world, печатающий сгенерированный UUIDv4.

# Покидаем наш проект (но мы к нему ещё вернёмся).
$ cd ~

$ mkdir kphp-helloworld
$ cd kphp-helloworld

# Либо можем запустить `composer init`:
$ echo '{}' > composer.json

$ composer require quasilyte/kphp-uuid:dev-master

В самом корне положим наш main.php:

Соберём исполняемый файл:

$ kphp --composer-root=$(pwd) --mode=cli main.php


  • --composer-root включает composer-режим. Аргументом передаём корень проекта.
  • --mode указывает, что мы собираем; cli подходит для простых скриптов.

По умолчанию результат компиляции размещается в ./kphp_out, бинарник получит название ./kphp_out/$mode.

Запустим собранную программу:

$ ./kphp_out/cli
string(36) "9f21785c-01a5-44aa-bc72-5fe262c10ca6"

Этот main-файл также можно запускать как обычный PHP-скрипт:

$ php -f main.php
string(36) "489ee573-5f1a-4b1f-8090-162029e0f428"


PHPUnit + PHP

# Возвращаемся к нашему проекту:
$ cd ~/kphp-uuid

Теперь, когда мы научились писать и запускать простой код на KPHP, можем приступить к главной теме этой статьи — тестированию.

Код на KPHP легко запускать через php, так что первой приходит идея тестировать этот код через PHPUnit. Это рабочая стратегия, поэтому начнём с неё.

Тесты будем размещать внутри директории tests. Следуя правилам хорошего тона, тестовый класс для UUID расположим вtests/UUIDTest.php:

assertSame(count($set), $n);
    }

    public function testLength() {
        $this->assertSame(strlen(UUID::v4()), 36);
    }

    // Проверяем, что генерируются валидные UUID
    public function testValid() {
        // Sanity test на то, что метод isValid может
        // определить заведомо валидные и плохие строки .
        $example = 'f37ac10b-58cc-4372-a567-0e02b2c3d170';
        $this->assertTrue(self::isValid($example));
        $this->assertFalse(self::isValid('foo'));

        // А теперь проверим сгенерированные значения.
        for ($i = 0; $i < 100; $i++) {
            $this->assertTrue(self::isValid(UUID::v4()));
        }
    }

    private static function isValid(string $uuid): bool {
        $w = '[0-9a-f]';
        $pattern = "/^$w{8}-$w{4}-4$w{3}-[89ab]$w{3}-$w{12}$/";
        return preg_match($pattern, $uuid) === 1;
    }
}

Запускать тесты можно привычным для нас образом:

$ phpunit --bootstrap vendor/autoload.php tests

OK (3 tests, 104 assertions)


Я разместил тестовый класс UUIDTest в глобальном пространстве имён. Есть и другие способы, но единственно правильного — нет.

Мы протестировали наш KPHP-код как обычный PHP-код. Чаще всего этого достаточно, ведь в большинстве случаев результаты будут идентичными.

Однако, есть несколько преимуществ при запуске тестов на KPHP:


  1. Вы можете раньше обнаружить некоторые неожиданные различия в поведении PHP и KPHP.
  2. Получаете простой способ проверять на CI то, что ваш код всё ещё успешно компилируется.
  3. Сам код тестов будет проверяться компилятором. А как знаем, он наш лучший друг.


Встречайте — ktest

ktest — это утилита командной строки для тестирования корректности и производительности KPHP-программ.

Проще всего её установить, скачав релизный бинарник последней версии.


Ставим ktest из исходников

Если же в вашей системе установлен Go, можно поставить ktest и из исходников:

# Традиционный способ (до Go 1.17):
$ go get github.com/VKCOM/ktest/cmd/ktest

# Новый способ (Go 1.17+):
$ go install github.com/VKCOM/ktest/cmd/ktest

Если ваш $(go env GOPATH)/bin добавлен в системный $PATH, то команда ktest сразу же станет доступной.


$ ktest --help
Usage:

  ./ktest COMMAND

Possible commands are:

  phpunit         run phpunit tests using KPHP
  benchstat       compute and compare statistics about benchmark results
  bench           run benchmarks using KPHP
  bench-php       run benchmarks using PHP
  bench-vs-php    run benchmarks using both KPHP and PHP, compare results
  env             print ktest-related env variables information

Run './ktest COMMAND -h' to see more information about a command.


ktest: тестируем KPHP вместе с PHP

В наш пакет kphp-uuid нужно будет установить вспомогательный пакет kphpunit:

$ composer require --dev vkcom/kphpunit:dev-master

Использовать его напрямую нам не нужно. Этот пакет требуется для работы тестирующего кода, который создаётся утилитой ktest.

Теперь мы готовы запустить те же тесты на KPHP:

$ ktest phpunit tests

OK (3 tests, 104 assertions)

Как это работает:


  1. Каждый файл с тестами анализируется утилитой ktest.
  2. Для каждого из классов генерируется его модифицированная версия (*).
  3. Затем для каждого такого класса создаётся отдельный скрипт — точка входа.
  4. Результат работы скрипта анализируется утилитой ktest.


(*) Заменяем PHPUnit\Framework\TestCase на KPHPUnit\Framework\TestCase
и выполняем некоторые другие преобразования.

Формат вывода у ktest phpunit очень близок к обычному phpunit.


Пример теста, где поведение будет отличаться

Напишем метод getFirst(), который будет возвращать первый элемент числового массива:

В PHP мы получим NULL, если массив будет пустым.

Протестируем это поведение:

assertSame(Integers::getFirst([]), null);
        $this->assertSame(Integers::getFirst([1]), 1);
    }
}

Запустим тесты на PHP:

.                                                                   1 / 1 (100%)

Time: 36 ms, Memory: 4.00 MB

OK (1 test, 2 assertions)

А теперь попробуем запустить тесты на KPHP:

F 1 / 1 (100%) FAIL

Time: 4.59874429s

There was 1 failure:

1) IntegersTest::testGetFirst
Failed asserting that null is identical to 0.

IntegersTest.php:8

FAILURES!
Tests: 1, Assertions: 1, Failures: 1.

В KPHP при извлечении несуществующего индекса из массива для типов, которые не могут быть NULL (типа int), будет возвращаться «нулевое значение» (для int это будет 0). Для массива из объектов или других nullable-типов разницы в поведении не будет.



ktest: бенчмарки для KPHP

Допустим, вы услышали от коллег, что функция sprintf ужасно медленная и использовать её недопустимо.

public static function v4nosprintf(): string {
    $hex_fmt = fn (int $val) => str_pad(dechex($val), 4, '0');
    return (
        $hex_fmt(mt_rand(0, 0xffff)) .
        $hex_fmt(mt_rand(0, 0xffff)) .
        '-' .
        $hex_fmt(mt_rand(0, 0xffff)) .
        '-' .
        $hex_fmt(mt_rand(0, 0x0fff) | 0x4000) .
        '-' .
        $hex_fmt(mt_rand(0, 0x3fff) | 0x8000) .
        '-' .
        $hex_fmt(mt_rand(0, 0xffff)) .
        $hex_fmt(mt_rand(0, 0xffff)) .
        $hex_fmt(mt_rand(0, 0xffff))
    );
}

Добавляем тесты для v4nosprintf и убеждаемся, что работает она корректно.

Теперь попробуем узнать, насколько мы ускорили код. Здесь поможет команда ktest bench.

Тесты производительности (бенчмарки) пишутся в похожем на другие тесты стиле: создаём особый класс, описываем там методы. В своём примере я размещу бенчмарки в директории benchmarks в корне проекта.

Содержимое файла benchmarks/BenchmarkUUID.php:

Обратите внимание:


  • названия файла и класса должны соответствовать друг другу;
  • у названия класса должен быть префикс Benchmark;
  • название каждого метода-бенчмарка должно иметь префикс benchmark.

Запустим бенчмарки:

$ ktest bench ./benchmarks
BenchmarkUUID::V4           41380   1462.0 ns/op
BenchmarkUUID::V4nosprintf  121220  1020.0 ns/op


Как видим, v4nosprintf() занимает меньше времени, чем v4(). Но это только предварительные результат — более точный анализ проведём немного позднее.

Вы можете использовать статическое (и не только) состояние класса внутри кода бенчмарков. Например, тестовые данные можно держать внутри static поля класса. Если вам нужно подготовить входные данные для бенчмарков, делайте это в конструкторе, а не внутри метода-бенчмарка.

Код в методе-бенчмарке должен выполнять именно ту операцию, которую вы хотите протестировать. Обычно не нужно самостоятельно добавлять циклы с повторным выполнением этой операции.

Бывают микробенчмарки, которые тестируют самые базовые и тривиальные вещи, занимающие менее 50 ns. В их метод можно добавить цикл с повторением. Но будьте осторожны: простые циклы без зависимостей между итерациями могут легко оптимизироваться, и тогда вы рискуете получить искажённые результаты.


Уменьшаем количество шума

Как настраивать свою машину для стабильной работы бенчмарков — тема для отдельного рассказа. Но поделюсь одним простым лайфхаком.

Для процессоров от Intel вы, скорее всего, захотите выключить Turbo Boost.

На некоторых линуксах это делается, например, вот так:

$ echo "1" | sudo tee /sys/devices/system/cpu/intel_pstate/no_turbo

Если разброс времени исполнения выше, чем 3–5%, то проблема либо в плохо настроенном окружении, либо в самом коде бенчмарка. Желательно достигать разброса в 1–2%.

Сравнивая результаты бенчмарков, стоит помнить: сопоставление будет корректным, только если все наборы результатов были получены на одной машине. Если машины разные, у них должны быть идентичные настройки и железо.

Корректно: запустить бенчмарк для PHP и KPHP на своей машине, сравнить числа.

Некорректно: найти в интернете результаты для PHP или KPHP и сравнить их с вашими.



Правильно интерпретируем результаты бенчмарков

ktest bench будет запускать тестируемый метод много раз, пытаясь подобрать правильное значение повторений N. Но даже это не даёт необходимой точности.

Нам нужно собрать ещё больше результатов, выявить статистическую значимость и оценить погрешности. После этого важно правильно соотнести результаты v4 и v4nosprintf.

Чтобы получить больше семплов, ktest bench стоит запускать с параметром count:

$ ktest bench -count 5 ./benchmarks | tee results.txt
BenchmarkUUID::V4   52500   1490.0 ns/op
BenchmarkUUID::V4   62900   1486.0 ns/op
BenchmarkUUID::V4   63820   1484.0 ns/op
BenchmarkUUID::V4   61780   1481.0 ns/op
BenchmarkUUID::V4   66580   1473.0 ns/op
BenchmarkUUID::V4nosprintf  87260   910.0 ns/op
BenchmarkUUID::V4nosprintf  93460   916.0 ns/op
BenchmarkUUID::V4nosprintf  99120   933.0 ns/op
BenchmarkUUID::V4nosprintf  96540   915.0 ns/op
BenchmarkUUID::V4nosprintf  100820  942.0 ns/op

Есть замечательная утилита benchstat, которая позволяет анализировать и сравнивать результаты бенчмарков.

benchstat встроен в ktest, поэтому мы можем легко оценить разброс результатов:

$ ktest benchstat results.txt
name               time/op
UUID::V4           1.48µs ± 1%
UUID::V4nosprintf   923ns ± 2%

Разброс в 1-2% — это хороший результат, с которым можно работать.

benchstat может помочь нам сравнить UUID::V4 и UUID::V4nosprintf между собой, но придётся для этого немного поработать ручками.


  1. Все результаты для V4nosprintf кладём в отдельный файл new.txt.
  2. Все результаты для V4 тоже помещаем в отдельный old.txt.
  3. В new.txt заменяем BenchmarkUUID::V4nosprintf на BenchmarkUUID::V4.

Как-то так:

$ grep 'V4nosprintf\b' results.txt > new.txt
$ grep 'V4\b' results.txt > old.txt
$ sed -i 's/V4nosprintf/V4/g' new.txt

Теперь у нас есть два файла похожей структуры: old.txt и new.txt. Их можно сравнить:

$ ktest benchstat old.txt new.txt 
name      old time/op  new time/op  delta
UUID::V4  1.48µs ± 1%  0.92µs ± 2%  -37.74%  (p=0.008 n=5+5)

Видим, что версия nosprintf (new) на 37.74% быстрее.

Низкий p=0.008 говорит о том, что результаты достаточно стабильны.

В целом я рекомендую использовать -count со значением выше 5. Например, неплохо работает значение 10. Чем более «шумным» будет ваш бенчмарк, тем больше семплов может потребоваться.

Я считаю хорошим тоном прикладывать вот такую информацию со сравнением производительности в формате benchstat в каждом коммите, который внедряет какую-то оптимизацию. Эту традицию я перенял, когда работал над golang/go.


Запускаем бенчмарки в режиме PHP vs KPHP

Утилита ktest умеет запускать бенчмарки через PHP:

$ ktest bench-php --php=php7.4 ./benchmarks
BenchmarkUUID::V4           45120  1233.0 ns/op
BenchmarkUUID::V4nosprintf  37780  1925.0 ns/op

$ ktest bench-php --php=php8.0 ./benchmarks
BenchmarkUUID::V4           42360  1193.0 ns/op
BenchmarkUUID::V4nosprintf  36220  1891.0 ns/op

Интересный вывод: для PHP версия без sprintf оказалась менее эффективной. Я почти уверен, что на практике подобные различия в производительности не имеют для ваших программ никакого значения. Но это хороший пример к выводу о том, что без бенчмарков и профилирования «оптимизировать» код может быть чревато пессимизацией. Также стоит учитывать, что ускорение на одной платформе и версии может вызвать замедление на другой комбинации.

Если мы соберём одни результаты в файлик php.txt, а другие в kphp.txt, то через ktest benchstat их можно будет сравнить.

Команда ktest bench-vs-php сделает всё за нас:

$ ktest bench-vs-kphp --php=php8.0 ./benchmarks/BenchmarkUUID.php
name               PHP time/op  KPHP time/op  delta
UUID::V4           1.19µs ± 0%  1.45µs ± 0%  +21.88%  (p=0.000 n=10+9)
UUID::V4nosprintf  1.84µs ± 1%  0.90µs ± 0%  -51.16%  (p=0.000 n=9+10)


V4 в KPHP отработал на 22.88% медленнее, а V4nosprintf — на 51.16% быстрее.

Подобные сравнения производительности помогают находить слабые места в обеих реализациях. Но они не позволяют однозначно сказать, что эффективнее в общем случае: KPHP или PHP.

При этом бенчмарки полезны ещё во многих ситуациях:


  • когда хотим сопоставить производительность разных алгоритмов или версий рантайма и компилятора;
  • чтобы предоставить доказательства того, что ваш патч действительно что-то оптимизирует;
  • чтобы собрать метрики для последующего избежания регрессий (сложная задача).


Подробнее о производительности KPHP

KPHP сияет там, где у вас используются абстракции. Вызывать функции и методы в KPHP — это практически бесплатно, примерно как и в C++. Классы ускоряют код, а не замедляют. Для возврата нескольких результатов есть tuple, который эффективнее, чем просто массив из множества элементов.

На простых бенчмарках KPHP может быть примерно идентичен по производительности.

Бывают случаи, когда бóльшая часть бенчмарка состоит из вызова встроенных функций (или функций из расширений). Тогда мы тестируем не производительность PHP или KPHP, а качество реализации этих функций на C/C++.

Но всё это не значит, что сравнивать эти инструменты подобными бенчмарками нет смысла. Благодаря им мы можем проверить, где есть простор для оптимизаций с обеих сторон.

Если вы вдруг придёте к нам в KPHP-репозиторий и принесёте суперкрутую оптимизацию, это будет шикарно. ^_^



Настраиваем GitHub Actions

Общие рекомендации:


  1. Максимально покрываем код тестами через phpunit.
  2. То, что можно, дополнительно запускаем через ktest phpunit.

Самый простой вариант тестов интеграции в GitHub actions — запуск в PHP-only режиме. Подразумевается, что KPHP-тесты будут запускаться через отдельную команду, локально, на машине разработчика (где KPHP точно должен быть установлен).

Чтобы упростить запуск тестов, создадим Makefile в корне проекта:

.PHONY: ci-tests test php-test kphp-test

test:
    @echo "Run PHP tests"
    @make php-test
    @echo "Run KPHP tests"
    @make kphp-test
    @echo "Everything is OK"

php-test:
    @phpunit --bootstrap vendor/autoload.php tests

kphp-test:
    @ktest phpunit tests

ci-tests:
    @curl -L -o phpunit.phar https://phar.phpunit.de/phpunit.phar
    @php phpunit.phar --bootstrap vendor/autoload.php tests

Работая с репозиторием локально, мы запускаем все тесты:

$ make test 

В GitHub Actions будем запускать make ci-tests.

Создадим файл .github/workflows/php.yml:

name: PHP
on: [push, pull_request]

jobs:
  build:
    name: Build
    runs-on: ubuntu-latest
    steps:
    - uses: actions/checkout@v2
    - uses: php-actions/composer@v5
    - name: Test
      run: |
        make ci-tests

Теперь при каждом пуше и создании Pull Request у нас будут запускаться тесты.

image-loader.svg

Не забудьте добавить build status badge в свой README:

![Build Status](https://github.com/$user/$repo/workflows/PHP/badge.svg)

В моём случае $user/$repo будет quasilyte/kphp-uuid.


Заключение

image-loader.svg

Сегодня мы научились создавать проекты на современном KPHP: composer, unit-тесты, бенчмарки. Напишите в комментариях, что вы обо всём этом думаете: было ли полезно, что ещё хочется обсудить по теме?

Весь исходный код kphp-uuid вместе с Makefile и прочими интеграциями можно найти здесь: github.com/quasilyte/kphp-uuid.


Полезные ресурсы


© Habrahabr.ru