Очень часто приходится сравнивать время выполнения кода с целью выбора наиболее оптимального решения, и каждый раз для этого приходилось писать обработчик с расчётом времени обработки, после чего все результаты заносились в какую-нибудь таблицу Excel и вручную рассчитывалось минимальное, максимальное и среднее время выполнения...
Для решения этой тривиальной задачи был выпущен пакет "Benchmark", позволяющий сравнивать время выполнения кода без лишних затрат.
Установка
Проще всего установить пакет при помощи пакетного менеджера Composer:
composer require dragon-code/benchmark --dev
И всё, пакет готов к работе.
Использование
Так как вся нужная информация выводится в консоль, то лучше вызывать данный код через консоль хоть через команды используемого Вами фреймворка, хоть напрямую через вызов файла, например, php handler.php
.
use DragonCode\Benchmark\Benchmark;
(new Benchmark())->compare(
fn () => /* some code */,
fn () => /* some code */,
);
(new Benchmark())->compare([
fn () => /* some code */,
fn () => /* some code */,
]);
(new Benchmark())->compare([
'foo' => fn () => /* some code */,
'bar' => fn () => /* some code */,
]);
Передавать можно неограниченное количество аргументов.
Все переданные колбэки будут вызываться строго в указанном порядке. Если Вы хотите явно определить имена, то можете в метод compare
передать ассоциативный массив, где в качестве ключа будет указано название проверяемого функционала.
Пример результата выполнения
------- ------------- -------------
# 0 1
------- ------------- -------------
1 12.5914 ms 15.5279 ms
2 15.2767 ms 15.5117 ms
3 15.5172 ms 15.3314 ms
4 14.6393 ms 15.3283 ms
5 15.278 ms 15.5241 ms
6 16.1444 ms 15.1441 ms
7 15.3792 ms 14.2409 ms
8 15.2188 ms 14.6961 ms
9 15.1009 ms 15.71 ms
10 14.9955 ms 15.522 ms
------- ------------- -------------
min 12.5914 ms 14.2409 ms
max 16.1444 ms 15.71 ms
avg 15.01414 ms 15.25365 ms
------- ------------- -------------
Order - 1 - - 2 -
------- ------------- -------------
Количество итераций
По-умолчанию каждый колбэк проходит 10 итераций, но Вы можете задать своё количество вызывав метод iterations
:
use DragonCode\Benchmark\Benchmark;
(new Benchmark())
->iterations(5)
->compare(
fn () => /* some code */,
fn () => /* some code */,
);
В случае если переданное значение будет меньше единицы, то скрипт будет использовать значение "1".
Пример результата выполнения
------- ------------- -------------
# 0 1
------- ------------- -------------
1 11.6046 ms 15.89 ms
2 14.8923 ms 15.6117 ms
3 15.5306 ms 15.3212 ms
4 14.8029 ms 14.7584 ms
5 14.7727 ms 14.9909 ms
------- ------------- -------------
min 11.6046 ms 14.7584 ms
max 15.5306 ms 15.89 ms
avg 14.32062 ms 15.31444 ms
------- ------------- -------------
Order - 1 - - 2 -
------- ------------- -------------
Округление значений
По-умолчанию скрипт не округляет результаты замеров, но Вы можете указать количество символов после запятой, до которых может производиться округление:
use DragonCode\Benchmark\Benchmark;
(new Benchmark())
->roundPrecision(2)
->compare(
fn () => /* some code */,
fn () => /* some code */,
);
Пример результата выполнения
------- ---------- ----------
# 0 1
------- ---------- ----------
1 11.1 ms 15.19 ms
2 14.65 ms 15.22 ms
3 15.3 ms 15.3 ms
4 15.1 ms 15.55 ms
5 14.91 ms 15.75 ms
------- ---------- ----------
min 11.1 ms 15.19 ms
max 15.3 ms 15.75 ms
avg 14.21 ms 15.4 ms
------- ---------- ----------
Order - 1 - - 2 -
------- ---------- ----------
Вывод только итоговой информации
В случае если Вы проверяете скорость выполнения при большом количестве итераций или просто не хотите смотреть на подробную статистику времени выполнения по каждой итерации, Вы можете вызвать метод withoutData
, который позволит скрыть её, отобразив в конечном итоге только суммарную информацию:
use DragonCode\Benchmark\Benchmark;
(new Benchmark())
->withoutData()
->compare([
'foo' => fn () => /* some code */,
'bar' => fn () => /* some code */,
]);
Пример результата выполнения
------- ------------- -------------
# foo bar
------- ------------- -------------
min 11.5861 ms 14.6096 ms
max 15.9592 ms 15.8362 ms
avg 14.65038 ms 15.08028 ms
------- ------------- -------------
Order - 1 - - 2 -
------- ------------- -------------
Расчёт победителей
Порядок определяется по среднему арифметическому значению и обозначается числом от 1 и выше, где "1" - это наименьшее затраченное время.
Заключение
Теперь сравнивать время выполнения кода стало значительно проще и быстрее, и можно не тратить время на реализацию данного функционала ????
Пример кода и результат выполнения частого вопроса среди начинающих
Так как для тестирования выбрал простой массив, то все изменения происходят настолько быстро, что код всегда показывает на выходе время выполнения 0.000 мс. Поэтому перед началом тестирования немного преобразуем данные в многомерный массив.
В качестве обработчика решил проверить следующую цепочку:
очищаем от пробелов по обеим сторонам от букв;
собираем в значении подряд 20 символов из буквы;
преобразовываем полученное значение в верхний регистр;
возвращаем результат выполнения.
В итоге, получаем такой код:
<?php
declare(strict_types=1);
use DragonCode\Benchmark\Benchmark;
require 'vendor/autoload.php';
class Test
{
protected array $values = [
' a ',
' b ',
' c ',
' d ',
' e ',
' f ',
' g ',
' h ',
' i ',
' j ',
' k ',
' l ',
' m ',
' n ',
' o ',
' p ',
' q ',
' r ',
' s ',
' t ',
' u ',
' v ',
' w ',
' x ',
' y ',
' z ',
];
public function __construct(
protected Benchmark $benchmark = new Benchmark()
) {
}
public function compare(int $iterations): void
{
$data = $this->prepareData();
$this->$benchmark
->withoutData()
->iterations($iterations)
->compare([
'foreach' => fn () => $this->each($data),
'array_map' => fn () => $this->map($data),
'array_walk' => fn () => $this->walk($data),
]);
}
protected function prepareData(): array
{
$result = [];
foreach ($this->values as $value) {
$result[$value] = $this->values;
}
return $result;
}
protected function each(array $values): array
{
foreach ($values as &$value) {
if (is_array($value)) {
$value = $this->each($value);
continue;
}
$value = $this->change($value);
}
return $values;
}
protected function map(array $values): array
{
return array_map(
fn ($value) => is_array($value)
? $this->map($value)
: $this->change($value),
$values
);
}
protected function walk(array $values): array
{
array_walk($values, fn ($value) => is_array($value)
? $this->walk($value)
: $this->change($value)
);
return $values;
}
protected function change(string $value): string
{
$value = trim($value);
$value = str_pad('', 20, $value);
return mb_strtoupper($value);
}
}
(new Test())->compare(1000);
И результат его выполнения при двух запусках:
------- -------------- -------------- --------------
# foreach array_map array_walk
------- -------------- -------------- --------------
min 0.8363 ms 1.0899 ms 1.143 ms
max 3.2254 ms 5.1938 ms 2.1181 ms
avg 0.8964671 ms 1.1901751 ms 1.1914344 ms
------- -------------- -------------- --------------
Order - 1 - - 2 - - 3 -
------- -------------- -------------- --------------
------- -------------- -------------- --------------
# foreach array_map array_walk
------- -------------- -------------- --------------
min 0.8358 ms 1.09 ms 1.1336 ms
max 4.2263 ms 5.2699 ms 5.1957 ms
avg 0.8951935 ms 1.1673948 ms 1.2270636 ms
------- -------------- -------------- --------------
Order - 1 - - 2 - - 3 -
------- -------------- -------------- --------------
PS: И всё же, это функционал бенчмарка, поэтому переименовал проект.
Комментарии (32)
a-tk
04.02.2023 09:13А как там с мультимодальными распределениями дела?
Helldar Автор
04.02.2023 12:46Пакету не важно что отправляется в колбэк. По сути, весь код это обёртка над:
$startAt = microtime(true); /* user function */ $callback(); return microtime(true) - $startAt;
a-tk
04.02.2023 13:42+1То есть бесполезно.
Вообще прежде чем делать нечто с закосом на бенчмаркинг, попробуйте сначала ознакомиться в общих чертах с проблемами в процессе бенчмаркинга.
FanatPHP
04.02.2023 10:42Долго корпели, считали, и получили "виннера" с разницей меньше погрешности измерения.
А потом и рождаются все эти адские байки про "одинарные ковычки быстрее"и можно не тратить время на реализацию данного функционала
Да-да, и потратить его на бессмысленные измерения.
Helldar Автор
04.02.2023 12:49+1Разница одинарных и двойных кавычек в том, что одинарные принимаются интерпретатором "как есть", а двойные парсятся внутренним компилятором с целью обнаружения в них переменных для подстановки значений.
Так что, зная это, можно и без всяких проверок уверенно сказать, что если текст не содержит обращений к переменным внутри себя, то нужно использовать одинарные кавычки.
FanatPHP
04.02.2023 13:54+2Странно, что вы подхватили этот, в сущности, второстепенный вопрос с кавычками. Речь не о нем. Но, с другой стороны, это как раз очень характерно для таких увлеченных оптимизаторов, которым надо "здесь и сейчас", без всякой связи с реальностью и без попытки подумать хотя бы на один ход вперёд. И в этом смысле кавычки очень показательны, да.
Логично предположить, что если нас интересует производительность РНР кода, то первым шагом, который будет сделан — это включение опкод кэша. В котором, как можно увидеть, уже и близко не остается никакой разницы между кавычками. То есть, если нас интересует реальная реальная производительность, а не воображаемая, то она достигается не разницей между кавычками.
И то же самое относится и ко всем другим случаям оптимизации. Если код работает медленно, надо не кавычки переставлять, а подход менять. Сокращать объем обрабатываемых данных, распараллеливать задачу, оптимизировать алгоритм.
А все это крохоборство на синтаксисе — это самообман, и пустая трата ресурсов.
Helldar Автор
04.02.2023 14:32+1...без всякой связи с реальностью и без попытки подумать хотя бы на один ход вперёд. И в этом смысле кавычки очень показательны, да.
Согласен, показательны. Показательны в том, что, во-первых, не нужно использовать "расширенную" механику там где она не оправдана, а также в том, что предварительная оптимизация является плохим решением.
(new Comparator()) ->withoutData() ->iterations(100000) ->compare([ 'single' => fn () => 'foo' . $value, 'double' => fn () => "foo$value", ]);
Результаты выполнения
----- -------------------- --------------------- # single double ----- -------------------- --------------------- min 0 0 max 5.3167343139648E-5 0.00011587142944336 avg 8.6287260055542E-7 8.7460517883301E-7 ----- -------------------- --------------------- winner loser ----- -------------------- ---------------------
----- -------------------- -------------------- # single double ----- -------------------- -------------------- min 0 0 max 2.1934509277344E-5 2.598762512207E-5 avg 8.3098649978638E-7 8.3781242370605E-7 ----- -------------------- -------------------- winner loser ----- -------------------- --------------------
----- -------------------- -------------------- # single double ----- -------------------- -------------------- min 0 0 max 2.7894973754883E-5 4.1961669921875E-5 avg 8.3756923675537E-7 8.5575819015503E-7 ----- -------------------- -------------------- winner loser ----- -------------------- --------------------
Если код работает медленно, надо не кавычки переставлять, а подход менять.
Именно для этого и нужен этот пакет - понять какой подход будет работать быстрее.
А все это крохоборство на синтаксисе — это самообман, и пустая трата ресурсов.
Понял, Ваш тимлид не даёт пачкать код и Вы таким образом пытаетесь вылить негатив на меня. Ок, учту.
FanatPHP
04.02.2023 14:53+3Но вы-то в ваших тестах меняете не подход, а синтаксис. При том что для радикальных изменений какие-то особые измерения не нужны — при нормальной оптимизации все будет видно невооруженным взглядом. Скажем, вместо того, чтобы читать значение из гигабайтного джейсон файла, использовать хранилище с произвольным доступом. А ради разницы в 0.2 миллисекунды и затеваться не стоило.
Понял, Ваш тимлид не даёт пачкать код
На будущее, делать предположения о событиях, не относящихся к обсуждаемому вопросу — это не очень хорошая практика, и не очень одобряется сообществом.
Helldar Автор
04.02.2023 15:06Ради 0.2 мс разумеется не стоит. Это экономия на спичках получится. В этом случае лучше выбрать путь, позволяющий легче реализовывать задачу.
На днях у меня был кейс где нужно было проверить общую скорость работы: найти в базе запись, получить значение из одной колонки, рассчитать процент по входящему числу и положить в другую колонку той же записи.
Сам кейс заключался в проверки двух подходов: на 1000 записей выполнить 1 джобу, которая одним запросом получит из базы все значения, произведёт вычисления и вторым запросом массово положит обратно, или же выполнить 1000 отдельных джоб, выполняющих одну операцию.
Сам тест показал что 1 джоба технически сработает быстрее, но был и второй момент - скорость обновления записей. Пока одна джоба перебирает цикл, в базе уже могли измениться значения и это означает, что 1 джоба, по сути, сломает её.
Тем не менее, вопрос был не в целостности и актуальности данных, а именно в скорости работы двух подходов.
...не относящихся к обсуждаемому вопросу...
Обратите внимание, что данный вопрос как раз-таки обсуждался и начали это именно Вы.
FanatPHP
04.02.2023 15:16+1Ну то есть вам понадобился тест, чтобы выяснить, что один сделать запрос будет быстрее, чем последовательно выполнить 1000. Понятно.
Helldar Автор
04.02.2023 15:37+1На практике далеко не всегда 1 запрос будет работать быстрее 1000 отдельных. Так что да, для проверки потребовалось тестирование.
ysoft
04.02.2023 16:28да, но что мешало сделать shared lock в транзакции? также можно применить и атомарные операции, да много чего можно придумать не используя 1000 апдейтов
FanatPHP
04.02.2023 18:22+2О, да вы обновили комментарий, добавив в него пример, на котором я как раз хотел остановиться, но не было подходящего кейса. Спасибо, это ровно то, чего здесь не хватало!
Я не буду даже к вам приставать с той гигантской разницей в цифрах, которую вы получили на этих тестах. И рассказывать, что получил сходные результаты… оставив в обеих ветках идентичный код с одинарными кавычками.
На самом деле главной проблемой всех этих тестов является даже не их бессмысленность, а то, что тестирующий зачастую не понимает — а что он, собственно, тестирует-то. Но ничтоже сумняшеся делает далеко идущие выводы. Продумать эксперимент и правильно интерпретировать его результаты — это целая наука. Про это даже хороший анекдот есть:
Один исследователь решил узнать, где у таракана уши.
Поймал таракана, посадил на стол, постучал по столешнице — таракан убежал.
Поймал снова, оторвал ноги, постучал — таракан никуда не бежит. Значит, не слышит.
Вывод — у таракана уши в ногах!Вот и сейчас.
Вы зачем-то добавили в свой комментарий тест "конкатенация vs. интерполяция". Но ведь хотели-то — "single vs. double", верно?Здесь у вас получилось как минимум две методологические ошибки:
- во-первых, вы вообще никак не тестируете парсинг строк. Парсится этот ваш код "внутренним компилятором" ровно один раз, при старте. А когда запускается цикл на офигиллиард итераций, то оба строковых литерала — это уже просто адреса в памяти, без всяких кавычек. И внутри цикла никакого парсинга не происходит. Чтобы протестировать разницу в парсинге, вам надо запускать в цикле именно парсинг. Инклюдом например (убедившись в том, что опкод кэш отключен). Но в этом случае у вас погрешность будет вносить дисковый ввод-вывод.
- во-вторых, как я написал выше, вся разница в вашем коде заключается в способе объединения двух строк, конкатенация vs. интерполяция. Совершенно лишняя операция, которую вы зачем-то добавили. Если бы кавычки в этом тесте парсились, то добавление лишней операции исказило бы результаты. Но поскольку единственным отличием двух тестов является способ соединения строк, то в итоге вы тестируете вообще не то, что хотели
И это мы еще даже не касались размеров или количества операндов. А на результаты теста может влиять что угодно — и состав операндов стоит одним из первых в очереди и смотрит на вас грустными глазами Шлёмы-маляра. И стоит нам поменять тестовую строку с
"Hallo $world";
на"Hi! My name is $name and I am $age years old! I love doing $hobby!";
как виннеры и лузеры внезапно меняются местами! И что теперь делать с этими результатами? Продолжать рассказывать всем, что одинарные быстрее?Вот это-то и является главной проблемой. Берется какой-нибудь вырожденный случай, который в лучшем случае имеет весьма отдаленное отношение к исследуемой проблеме, а обычно — так и вообще никакого. И потом на основании полученных результатов переписываются гайдлайны, рефакторится значительное количество кода, распространяются нелепые слухи. А всего этого вполне можно было бы избежать, если не упарываться в микротесты, а заниматься оптимизацией профессионально — отталкиваясь от результатов профайлинга...
tzlom
04.02.2023 12:04+1Идея лучше реализации. Нужно считать стандартное отклонение и сравнивать уже учитывая его. Так же имеет смысл указывать место на пьедестале а не победитель и остальные.
ознакомьтесь с google benchmark - идея та же но реализацию они отточили чтобы этим цифрам можно было верить
Helldar Автор
04.02.2023 13:00Ну как сказать лучше... Так или иначе самый наипростейший способ отслеживать время выполнения кода - использовать функцию microtime сравнивая значения до и после. Пример писал в комментарии, поэтому дублировать не стоит. И именно "стандартное отклонение", читай "среднее арифметическое" и берётся как основное значение для сравнения, т.к. минимальная и максимальная пиковая нагрузка не всегда может показывать реальные показатели по каким-либо причинам.
Что касается цифрового обозначения порядка, согласен. Возможно на досуге реализую.
По поводу Google Benchmark, он под C++ и у него свои особенности реализации. В случае с PHP "бенчмарк" совсем по-другому работает, но общий принцип тот же - замер времени между началом и завершением выполнения. И эта цель достигнута в этом программном продукте.
a-tk
04.02.2023 13:45Бенчмарк - это не про прогнать несколько раз и посчитать, это ещё и про оценку того, насколько статистике можно верить, на основе статистики.
Вы ведь не станете утверждать, что распределение времени всегда строго нормальное?Dekmabot
04.02.2023 14:08+1Это решение не про бенчмарки, на сколько я понял автора, а про "сравнить мои решения между собой" по времени на глазок и выявить явного лидера. Даже "winner/loser" как бы намекают на несерьёзность :)
Если бы мне нужно было сравнение в изолированной среде с нормальным распределением, замерами памяти, сети и io, я бы выбрал другое решение, и потратил бы на него больше времени.
Helldar Автор
04.02.2023 14:36Аналогично. Если нужно что-то более мощное, то явно воспользовался другими инструментами тестирования. Да хоть тот же Яндекс.Танк для нагрузочного тестирования.
И да, Вы правы, текущий пакет не претендует на место среди проверочных инструментов. Он, скорее, "здесь и сейчас на глаз сравнить решения между собой", как Вы и сказали.
Helldar Автор
04.02.2023 14:35Нет, не стану, и именно поэтому основной единицей сравнения является среднее арифметическое из всех значений, т.к. оно ближе к правде, но не всегда соответствует. И это понимает любой разработчик.
И бенчмарк определяет производительность, а это не про данное программное решение. Его задача определить скорость выполнения участка кода с ограниченной областью видимости. Бенчмарком здесь и не пахнет. Мало того, он и не претендует на него. Вы видите слово "benchmark" в описании? Вот именно.
tzlom
04.02.2023 15:10Бенчмаркинг это методика сравнения показателей между собой с целью установления отличия от эталонного. Нет никакой принципиальной разницы измеряете ли вы одну функцию, десяток вложенных или целую программу, важно только что вы даёте результаты которые можно сравнивать между собой. Выдавать усреднённые значения это разумный подход который позволяет сократить влияние внешних флуктуаций на измеряемое значение, но как я показал в другом комментарии этого не достаточно для заключения быстрее ли А чем Б или нет.
tzlom
04.02.2023 14:59Наипростейший != правильный
Начнём с microtime - какая у него точность? Идём в документацию и видим "For performance measurements, using hrtime() is recommended.", упс...
Дальше лучше, смотрим на данные из параграфа "Округление значений"
Казалось бы всё однозначно - минимум, максимум и среднее варианта А меньше варианта Б, однако проведём Т-тест> a = c(0.0112, 0.0147, 0.0153, .0157, .0154) > b = c(.015, .0155, .0153,.015, .0158) > summary(a) Min. 1st Qu. Median Mean 3rd Qu. Max. 0.01120 0.01470 0.01530 0.01446 0.01540 0.01570 > summary(b) Min. 1st Qu. Median Mean 3rd Qu. Max. 0.01500 0.01500 0.01530 0.01532 0.01550 0.01580 > sd(a) [1] 0.001858225 > sd(b) [1] 0.0003420526 > t.test(a, b) Welch Two Sample t-test data: a and b t = -1.0178, df = 4.2708, p-value = 0.3629 alternative hypothesis: true difference in means is not equal to 0 95 percent confidence interval: -0.003148545 0.001428545 sample estimates: mean of x mean of y 0.01446 0.01532
P-value говорит что основная гипотеза (распределения равны) верна, т.е. на самом деле там одинаковые распределения.
google benchmark я привёл как инструмент который делает бенчмарки правильно - там много нюансов которые увеличивают точность измерений:возможность задать код до и после измерений, чтобы подготовить данные и почистить за собой
автоматический подбор количества итераций — проще для пользователя и позволяет реагировать на случайные выбросы в статистике
перемешивание вариантов, т.к. последовательная долбёжка одного варианта показывает слишком оптимистичную картину при наличии кешей
авто подсчёт нужных статистик
исключение выбросов, к примеру если ОС тупанула во время измерений
Helldar Автор
04.02.2023 15:10-2Runtime Comparison не претендует на звание бенчмарка, а для проверки скорости участков кода microtime хватает за глаза.
ysoft
04.02.2023 16:32имеет смысл сделать, откидывание крайних значений, или брать например, 30%(задаваемо) лучших значений
Helldar Автор
04.02.2023 16:54Задаваемые значения точно не подойдут, т.к. пакет заранее не знает что именно передаст ему разработчик и в каком виде. Что касается крайних значений, именно поэтому и используется среднее арифметическое, что позволяет получать приблизительное время выполнения без плясок с бубном.
ysoft
04.02.2023 17:14+1имелось ввиду другое, у тебя например есть 100 прогонов, и ты можешь сказать - учесть только 30 из них лучших, т.к. у тебя также какие то процессы на компе могут испортить все показатели, а лучшие значение не изменяться
derwin
04.02.2023 20:15+1У Laravel есть из коробки что то похожее https://laravel.com/docs/9.x/helpers#benchmarking
Helldar Автор
04.02.2023 20:23Да, верно. Этот же пакет фреймворко-независимый.
Кстати, бенчмарк в Laravel выводит лишь время.
Например:
$range = [0, 1000000]; Benchmark::dd([ 'rand' => fn () => rand(...$range), 'mt_rand' => fn () => mt_rand(...$range), 'random_int' => fn () => random_int(...$range), ], 100);
array:3 [ "rand" => "0.001ms" "mt_rand" => "0.002ms" "random_int" => "0.001ms" ]
Если этого достаточно, то вполне годный инструмент.
Под капотом та же самая функция hrtime с последующим расчётом среднего значения из списка результатов.
Dekmabot
Простой и полезный скрипт, спасибо, часто провожу подобные тесты на коленке, взял на вооружение.
Столкнулся с округлением на маленьких значениях, попробую предложить pr, возможно с
show($precision)
, округляя уже на этапе вывода статистики.Helldar Автор
Да, я тоже понял это и добавил возможность вручную задавать округление. Статью обновил. Спасибо за отзыв :)