Заметки KPHP: тестирование и бенчмарки
Перед вами первая статья из серии «Как использовать KPHP в open source?».
В этих статьях мы будем разбирать разные аспекты работы с KPHP, расширяя информацию, которую вы можете найти в официальной документации.
В сегодняшнем выпуске обсудим:
- базовое использование composer с KPHP;
- как писать и запускать unit-тесты для KPHP;
- бенчмаркинг KPHP-кода (профилирование затронем в другой раз);
- как правильно сравнивать результаты бенчмарков.
Предисловие
Кратко о том, в чём преимущества 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:
- Вы можете раньше обнаружить некоторые неожиданные различия в поведении PHP и KPHP.
- Получаете простой способ проверять на CI то, что ваш код всё ещё успешно компилируется.
- Сам код тестов будет проверяться компилятором. А как знаем, он наш лучший друг.
Встречайте — ktest
ktest — это утилита командной строки для тестирования корректности и производительности KPHP-программ.
Проще всего её установить, скачав релизный бинарник последней версии.
Если же в вашей системе установлен 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)
Как это работает:
- Каждый файл с тестами анализируется утилитой ktest.
- Для каждого из классов генерируется его модифицированная версия (*).
- Затем для каждого такого класса создаётся отдельный скрипт — точка входа.
- Результат работы скрипта анализируется утилитой 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
между собой, но придётся для этого немного поработать ручками.
- Все результаты для
V4nosprintf
кладём в отдельный файлnew.txt
. - Все результаты для
V4
тоже помещаем в отдельныйold.txt
. - В
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 — это практически бесплатно, примерно как и в C++. Классы ускоряют код, а не замедляют. Для возврата нескольких результатов есть tuple
, который эффективнее, чем просто массив из множества элементов.
На простых бенчмарках KPHP может быть примерно идентичен по производительности.
Бывают случаи, когда бóльшая часть бенчмарка состоит из вызова встроенных функций (или функций из расширений). Тогда мы тестируем не производительность PHP или KPHP, а качество реализации этих функций на C/C++.
Но всё это не значит, что сравнивать эти инструменты подобными бенчмарками нет смысла. Благодаря им мы можем проверить, где есть простор для оптимизаций с обеих сторон.
Если вы вдруг придёте к нам в KPHP-репозиторий и принесёте суперкрутую оптимизацию, это будет шикарно. ^_^
Настраиваем GitHub Actions
Общие рекомендации:
- Максимально покрываем код тестами через
phpunit
. - То, что можно, дополнительно запускаем через
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 у нас будут запускаться тесты.
Не забудьте добавить build status badge в свой README:
![Build Status](https://github.com/$user/$repo/workflows/PHP/badge.svg)
В моём случае $user/$repo
будет quasilyte/kphp-uuid
.
Заключение
Сегодня мы научились создавать проекты на современном KPHP: composer, unit-тесты, бенчмарки. Напишите в комментариях, что вы обо всём этом думаете: было ли полезно, что ещё хочется обсудить по теме?
Весь исходный код kphp-uuid
вместе с Makefile и прочими интеграциями можно найти здесь: github.com/quasilyte/kphp-uuid.