Мне захотелось узнать, сколько стоит возведение в степень в PHP, поэтому я написал цикл, который написал бы каждый: десять миллионов итераций $result = 2 ** 16; , замеряя время с помощью
hrtime() . Результат составил 18.92 мс, примерно 1.9 нс на итерацию. Получается, возведение в степень ничего не стоит.
Число было реальным. Вывод — неверным, потому что в этой программе нет никакого
возведения в степень. Оба операнда являются литералами, поэтому компилятор
сворачивает выражение во время компиляции, и тело цикла, которое он генерирует, представляет собой просто присваивание
целого числа 65536. Я измерил десять миллионов присваиваний, а результат интерпретировал как
факт о ** .
Полезно знать, где именно это происходит, потому что это не то место, о котором думает большинство: сворачивание выполняет компилятор, а не OPcache, и phpdbg показывает это при вообще не запускавшемся оптимизаторе.
Уберите базу из зоны досягаемости компилятора, и операция вернется:
<?php
declare(strict_types=1);
$iterations = 10_000_000;
$base = (int) ($argv[1] ?? '2');
$start = hrtime(true);
for ($i = 0; $i < $iterations; $i++) {
$result = $base ** 16;
}
printf("%.3f ms\n", (hrtime(true) - $start) / 1_000_000);
Тот же цикл, то же количество итераций, 55.63 мс вместо 18.92 мс. Дамп опкодов
объясняет почему: тело цикла в первой версии — это один ASSIGN константы, а
во второй — инструкция POW , результат которой передается в присваивание. Бенчмарк честно показал то, что он измерял. Это я неверно истолковал его.
Что изменилось вопреки моему намерению
Микробенчмарк — это контролируемый эксперимент с удручающим количеством неконтролируемых переменных.
Масштабирование частоты процессора и температурные лимиты означают, что ноутбук не работает на одной скорости. Это проявляется в виде дрейфа: более поздние запуски проходят медленнее, чем ранние, в серии, которая в остальном выглядит чистой. Чередование вариантов вместо запуска сначала всех тестов A, а затем всех тестов B превращает этот дрейф в шум, влияющий на обе стороны, а не в разницу, которая дает преимущество тому варианту, который запускался первым.
Другие процессы на машине проявляют себя иначе — в виде выбросов, когда несколько запусков оказываются намного медленнее остальных, в то время как медиана почти не меняется. Это аргумент в пользу использования медианы и диапазона значений вместо среднего арифметического.
Состояние OPcache определяет, тратит ли запуск ресурсы на компиляцию исходного кода в опкоды, а это совсем другой порядок затрат по сравнению с тем, что происходит внутри цикла; в статье путь, который запрос проходит от исходного кода до опкодов приведены цифры. Тестирование с выключенным кэшем измеряет конфигурацию, которую вы не используете в продакшене. JIT — это переменная того же рода, но более коварная, поскольку она не меняет затраты равномерно: то, до чего JIT дотягивается, и то, до чего нет, показывает, что цикл работал в шестнадцать раз медленнее с включенным JIT при ста итерациях и в шесть раз быстрее при двух миллионах.
Размер набора данных определяет, какую именно память вы измеряете. Десять элементов помещаются в L1-кэш и показывают скорость работы L1. В реальном массиве пятьдесят тысяч элементов, и с ним все будет иначе.
Контекст, который необходим числу
Вот результаты первого измерения, представленные в том виде, в каком я сам хотел бы их читать.
PHP 8.5.9 CLI, NTS, arm64, сборка Homebrew. opcache.enable_cli=1 и
opcache.jit=disable , который является дефолтным для этой сборки — машинный код не
генерировался ни для одного из вариантов. Ноутбук Apple M4 Pro под управлением macOS, не в режиме покоя;
load average на протяжении всего теста держался около 1.7. Десять миллионов итераций на запуск,
hrtime(true) считывается один раз до и один раз после цикла в том же процессе, 15
запусков на вариант, варианты чередуются, одна пара прогревочных запусков отброшена. Постоянные
величины: количество итераций, структура цикла, настройки ini. Переменная величина: была ли база степени литералом или передавалась через $argv .
Медиана свернутого варианта составила 18.92 мс в диапазоне от 18.52 до 19.68 мс, что составляет 6.1% от медианы. Медиана варианта с базой во время выполнения составила 55.63 мс в диапазоне от 55.39 до 56.15 мс, то есть 1.4%. Отношение медиан равно 2.94.
Строку с дисперсией обычно опускают, а ведь именно она определяет, имеет ли это отношение хоть какой-то смысл. В данном случае два диапазона — от 18.52 до 19.68 и от 55.39 до 56.15 — даже не приближаются друг к другу, так что разница в 2.94 раза — это реальное разделение, а не артефакт порядка запусков. Если бы они пересекались, честный отчет заключался бы в том, что этот метод не позволяет отличить один вариант от другого, что бы там ни говорили медианы. Приведение соотношения, диапазоны которого пересекаются, превращает бенчмарк в субъективное мнение, прикрытое цифрами.
Относительные числа переносимы, абсолютные — нет
Цифра 18.92 мс для вас практически бесполезна. У вас другой процессор, в вашей сборке PHP загружены другие расширения, и ваша машина занята чем-то, чем моя занята не была. Приведите ее в pull request, и кто-то сравнит ее с числом на своем ноутбуке, и это сравнение будет бессмысленным, причем так, что это трудно заметить.
Отношение переносит этот путь. Как и разброс, который говорит читателю, насколько можно доверять этому отношению. Приводите и абсолютные цифры, но с привязкой к машине, которая их выдала, и будьте готовы к тому, что в другом месте они окажутся неверными.
Есть и вторая причина не доверять абсолютным значениям. Разница в 3.7 нс на итерацию, разделяющая два моих варианта, реальна, но она не имеет значения для запроса, который большую часть времени проводит в ожидании базы данных. Перенос микробенчмарка на реальный запрос требует понимания того, сколько раз операция выполняется в этом запросе, чего микробенчмарк сказать не может.
Бенчмарк и профайлер отвечают на разные вопросы
Профайлер отвечает на вопрос «куда ушло время» для одного выполнения реального пути. Он находит функцию, о которой стоит беспокоиться, и при этом искажает абсолютные затраты, потому что инструментирование само по себе тратит время — для инструментирующего профайлера гораздо сильнее, чем для сэмплирующего, и эту разницу я измерил уже после написания этой статьи: 13.1% против величины, неотличимой от шума между запусками.
Бенчмарк отвечает на вопрос «помогло ли это изменение» для одной операции в выбранных вами условиях. Он точен в узком вопросе и умалчивает о том, имеет ли этот вопрос значение.
Оба инструмента работают внутри процесса, что ограничивает их область видимости. Время, которое запрос проводит в очереди на сокете в ожидании свободного воркера, тратится до запуска вашего кода, поэтому оно не отображается ни там, ни там, и его нужно измерять снаружи воркера, как показывает статья о настройке размера пула PHP-FPM.
Выбрать не тот инструмент — легко, а расплачиваться за это придется долго. День, потраченный на микрооптимизацию двух реализаций функции, которую профайлер показал бы как погрешность округления в запросе, — это день, проведенный с пользой по любым меркам, кроме тех, что действительно важны.
Результат, который противоречил ожиданиям
Я ожидал, что прогретый кэш скомпилированного кода выиграет у компиляции из исходников, поэтому я подготовил 300 файлов классов и сравнил подключение их всех через require с отключенным OPcache против подключения их с использованием
opcache.file_cache_only=1 , когда в директории кэша уже находилась 301 запись. Та же машина и метод, что и выше, по 15
чередующихся запусков для каждого варианта. Это другая серия тестов, отличная от той, что использовалась для получения цифр компиляции в статье что происходит, когда PHP выполняет ваш код, она проводилась в другое время,
поэтому и медиана, и диапазон немного отличаются от приведенных там 7.69 мс (от 7.61 до 7.89). Эта разница и есть то самое колебание от запуска к запуску, о котором написана эта статья, проявившееся в ее собственных цифрах.
Компиляция: медиана 7.678 мс, диапазон от 7.567 до 7.938. Прогретый файловый кэш: медиана 8.024 мс, диапазон от 7.962 до 8.202. Диапазоны не пересекаются, так что кэш здесь действительно оказался медленнее — на 4.5%, разница, которой я бы не поверил по результатам трех запусков.
Я не стал докапываться до причин и просто привожу этот результат как есть, а не выбрасываю его. Что я могу сказать точно, так это то, чем этот эксперимент не являлся: CLI-процесс не может унаследовать кэш в разделяемой памяти другого процесса, поэтому здесь сравнивалась десериализация опкодов с диска с компиляцией небольших файлов, а это не то сравнение, которое происходит в пуле FPM. Результат реален, но вопрос, который, как мне казалось, я задавал, был не тем вопросом, который я задал на самом деле.
Метод, который выдерживает столкновение с продакшеном
Сначала измерьте реальный путь с помощью профайлера под нагрузкой, похожей на реальные входные данные. Это даст ранжированный список того, куда уходит время, и отсеет большую часть того, что вы собирались оптимизировать.
Сформулируйте гипотезу, в которой фигурирует конкретная операция. «Контейнер разрешает этот сервис при каждом запросе, и это стоит дороже, чем кажется» — это гипотеза. «Массивы работают медленно» — нет. На первый вопрос есть ответ — что дает компиляция контейнера, а что нет — именно потому, что в нем называется то, что можно изолировать при измерении.
Проведите микробенчмарк этой операции, и только ее, с чередованием вариантов и выводом дисперсии. Проверьте опкоды, если результат выглядит слишком хорошо — столь большая разница обычно означает, что два варианта выполняют разную работу.
Затем повторно измерьте реальный путь и убедитесь, что эффект сохранился. Часто он не сохраняется, и выяснение этого стоит одного запуска профайлера, что гораздо дешевле, чем поддержка оптимизации, которая никогда не помогала.
Комментарии (0)
Пока нет комментариев — будьте первым.