Включение JIT по идее должно ускорять приложение. Я включил его для запроса, который загружает 200 файлов классов и вызывает 400 методов, и запрос стал медленнее — первый примерно на 6 мс, а каждый последующий — на величину, которую я не смог отличить от межпрогонового шума.
JIT вел себя абсолютно нормально. Он работал над запросом, в котором для него почти не было работы — а это обычная ситуация для веб-кода и причина, почему эта настройка разочаровывает большинство людей, которые пытаются её использовать.
Методика
Всё описанное ниже запускалось на PHP 8.5.9 CLI, NTS, arm64, сборка Homebrew, на ноутбуке Apple M4 Pro под управлением macOS, без специальной изоляции системы. Данные по запросам получены с помощью встроенного сервера PHP, обрабатывающего один фиксированный скрипт, с замером времени через hrtime() внутри процесса отдельно для каждой фазы, так что затраты на HTTP и клиента исключены. Десять жизненных циклов сервера по восемь запросов в каждом, то есть первый запрос замерялся десять раз, а прогретые — семьдесят. Показатели циклов — это 15 чередующихся прогонов для каждого варианта, приведенные в виде медиан с диапазонами.
<?php
declare(strict_types=1);
// bench.php - the loop behind the Mandelbrot rows below.
function mandelbrot(int $iterations): float
{
$sum = 0.0;
for ($i = 0; $i < $iterations; $i++) {
$cr = ($i % 200) / 100.0 - 1.5;
$ci = ($i % 100) / 50.0 - 1.0;
$zr = 0.0;
$zi = 0.0;
$k = 0;
while ($k < 20 && $zr * $zr + $zi * $zi < 4.0) {
$t = $zr * $zr - $zi * $zi + $cr;
$zi = 2.0 * $zr * $zi + $ci;
$zr = $t;
$k++;
}
$sum += $k;
}
return $sum;
}
$iterations = (int) ($argv[1] ?? 2_000_000);
$warm = ($argv[2] ?? 'warm') === 'warm';
$jit = opcache_get_status(false)['jit'] ?? null;
if ($warm) {
mandelbrot(50_000);
}
$start = hrtime(true);
$result = mandelbrot($iterations);
$ms = (hrtime(true) - $start) / 1_000_000;
printf(
"jit=%s on=%s n=%d %.4f ms result=%.1f\n",
ini_get('opcache.jit'),
$jit === null ? 'no-status' : var_export($jit['on'], true),
$iterations,
$ms,
$result,
);
# loop figures: the plain CLI SAPI needs both flags before OPcache is active
php -d opcache.enable=1 -d opcache.enable_cli=1 -d opcache.file_update_protection=0 \
-d opcache.jit=disable bench.php 2000000
# per-request figures: the built-in server reads opcache.enable, not opcache.enable_cli.
# public/ holds the script under test; it is not bench.php.
php -d opcache.enable=1 -d opcache.file_update_protection=0 \
-d opcache.jit=tracing -d opcache.memory_consumption=256 \
-S 127.0.0.1:8080 -t public/
Эти два комментария стоили мне по одному прогону каждый. Со встроенным сервером opcache.enable_cli=0 оставляет кэш включенным, а в обычном бинарнике CLI один лишь opcache.enable_cli=1 оставляет его выключенным — любая из этих ошибок выдаёт красивую, уверенную таблицу для конфигурации, которую вы на самом деле не задавали.
Директива opcache.file_update_protection была установлена в 0 во всех прогонах с кэшированием: только что записанный файл не кэшируется в пределах этого окна, и сохранение значения по умолчанию привело бы к замеру пустого кэша при отчете о полном. Каждый прогон выводил свою фактическую конфигурацию вместе со счетчиками попаданий и промахов OPcache — именно так я убедился, что варианты действительно отличались, а не сбросились все к дефолту. Относитесь к абсолютным числам как к относительным величинам по отношению друг к другу и к этой машине; в статье «benchmarking PHP without lying to yourself» объясняется почему.
Что устраняет OPcache
Запрашивая файл, который он еще не видел, движок выполняет лексический и синтаксический анализ и компилирует его в опкоды. OPcache сохраняет этот результат в разделяемой памяти, чтобы следующий запрос пропустил все три этапа; подробнее они описаны в статье «the path a request takes from source to opcodes».
Эти три этапа — всё, на что способна эта функция. Чтобы оценить масштаб, я сгенерировал 200 файлов классов намеренно разного размера (от 830 байт до 41 КБ, медиана 1 926 байт, всего 1,24 МиБ) и написал скрипт, который подключает их все через require, а затем создаёт 200 объектов и вызывает 400 методов. Замеры для двух фаз проводились отдельно, менялась только настройка OPcache:
| Конфигурация | Фаза компиляции | Фаза исполнения |
|---|---|---|
| OPcache выкл., каждый запрос | 13.83 ms (13.47–14.83) | 0.051 ms (0.045–0.101) |
| OPcache вкл., первый запрос | 26.53 ms (26.18–26.78) | 0.060 ms (0.048–0.071) |
| OPcache вкл., последующие запросы | 0.336 ms (0.316–0.477) | 0.048 ms (0.044–0.065) |
Время фазы компиляции сократилось в 41 раз. Фаза исполнения не изменилась вовсе: 0,051 мс без кэша против 0,048 мс с кэшем, при этом диапазоны пересекаются, так что данный метод измерения не позволяет их различие увидеть. Виртуальная машина в обоих случаях проходит один и тот же массив опкодов, и каждый опкод в нем по-прежнему выполняется.
Первая строка с холодным кэшем — это обратная сторона компромисса. Компиляция и сохранение обошлись в 26,53 мс против 13,83 мс для одной лишь компиляции: первый запрос после деплоя платит почти вдвое больше, чем заплатил бы с выключенным кэшем. Preloading переносит эту работу на момент запуска сервера: когда opcache.preload скомпилировал те же 200 файлов, фаза компиляции первого запроса составила 0,457 мс, и только два файла дали промах. В реальном приложении фаза компиляции — это еще не весь запрос, и то, что preloading убирает на практике — и какую цену за это берет, является менее однозначной выгодой, чем кажется по этой цифре.
Что компилирует JIT
Tracing JIT наблюдает за выполнением программы, и когда цикл или функция преодолевают порог «горячности», он компилирует записанную трассировку в машинный код. По умолчанию opcache.jit_hot_loop равно 61, а opcache.jit_hot_func — 127. Пять типов нагрузки, по два миллиона итераций каждый, при этом opcache.jit была единственной различающейся настройкой:
| Нагрузка | jit=disable | jit=tracing | Ускорение |
|---|---|---|---|
| Арифметика с плавающей точкой, внутренний цикл Мандельброта | 271.6 ms | 40.6 ms | 6.69x |
| Целочисленная арифметика | 7.05 ms | 1.18 ms | 5.98x |
| Вызовы методов одного объекта | 26.0 ms | 9.26 ms | 2.81x |
| Сборка строк через встроенные функции | 52.4 ms | 38.1 ms | 1.37x |
| Чтение и запись в хеш-таблицы | 70.7 ms | 58.6 ms | 1.21x |
Арифметика выигрывает почти порядок величины. Работа со строками и хеш-таблицами — две вещи, на которые обработчик запросов тратит основное время, — ускоряются на 37% и 21%. Разброс между прогонами составлял менее 8% от медианы для всех строк, кроме скомпилированных прогонов целочисленной арифметики (20%) и нескомпилированных вызовов методов, которые варьировались от 23,9 до 45,1 мс (разброс 81% против 3,7% у скомпилированного аналога). Считайте показатель 2,81x наименее надежной цифрой в таблице.
Порог «горячности» определяет, увидите ли вы хоть какой-то эффект
Эти цифры были получены на циклах, прогретых внутри процесса до начала замеров. Запустим тот же цикл Мандельброта «на холодную» и будем менять только количество итераций:
| Итерации | jit=disable | jit=tracing | Соотношение |
|---|---|---|---|
| 100 | 0.0130 ms | 0.2050 ms | 0.06x |
| 1,000 | 0.136 ms | 0.225 ms | 0.60x |
| 2,000 | 0.261 ms | 0.246 ms | 1.06x |
| 5,000 | 0.679 ms | 0.299 ms | 2.27x |
| 100,000 | 13.58 ms | 2.22 ms | 6.11x |
| 2,000,000 | 270.9 ms | 40.7 ms | 6.66x |
При 100 итерациях прогон с включенным JIT был в шестнадцать раз медленнее, и диапазоны (от 0,012 до 0,016 против 0,199–0,250) вообще не пересекаются, так что это реальное замедление, а не шум. Примерно 0,19 мс этой потери — фиксированные затраты на компиляцию трассы, которые при таком объеме ничем не окупаются. Точка окупаемости находится в районе 2 000 итераций, где диапазоны пересекаются, и честный вывод заключается в том, что данный метод замеров не позволяет отличить один вариант от другого.
Эта цифра относится именно к телу данного цикла, а не к JIT вообще. Каждая итерация выше выполняет до двадцати операций арифметики комплексных чисел, поэтому быстро окупает фиксированные затраты на компиляцию. Более легкое тело дает меньший выигрыш на каждой итерации и требует пропорционально большего их количества: при тех же фиксированных затратах цикл, выполняющий вдесятеро меньше работы за проход, выйдет в плюс примерно на порядок позже. Измеряйте точку перехода для своего конкретного цикла, а не переносите цифру 2 000 вслепую.
Именно из-за этого порога запрос, о котором я упомянул в самом начале, стал работать медленнее. Его главный цикл выполнялся 200 раз, проходя по opcache.jit_hot_loop , поэтому компилятор трасс взял плату за попытку (9 176 байт машинного кода при первом запросе), тогда как каждый из 400 вызванных методов выполнился ровно по одному разу, практически не дав скомпилированному коду поработать. В долгоживущем процессе счетчики и скомпилированные трассы сохраняются между запросами (прогретые запросы в том же процессе сервера скомпилировали еще 992 байта трассы), поэтому расходы амортизируются за время жизни воркера, а не списываются при каждом запросе. Как это ведет себя в пуле воркеров PHP-FPM, я не измерял.
Куда на самом деле уходит время
Сначала проверьте ожидания. Я запустил сервис по схеме запрос/ответ на loopback-интерфейсе, без выполнения запросов к БД и сетевых скачков, и получил от него 1 500 байт двумя способами: за один round trip и за сто. Те же байты, тот же процесс, то же соединение.
| Сценарий | Медиана | Диапазон |
|---|---|---|
| Один round trip | 0.0935 ms | 0.0661–0.1080 |
| Сто round trip'ов | 8.56 ms | 7.03–8.62 |
В девяносто один раз дороже за абсолютно те же данные! Включение JIT в этом замере не изменило ничего, что можно было бы заметить по имеющимся диапазонам — именно так фича, компилирующая арифметику, влияет на программу, ожидающую ответа от сокета.
Во-вторых, проверьте количество операций. Две строки выше различаются только тем, сколько раз программа пересекала границу, и один round trip по loopback стоил около 86 мкс — в то время как лучший результат JIT сэкономил порядка 115 нс на итерацию самого плотного числового цикла, который я смог написать. Один лишний round trip стоит примерно 750 таких итераций, а реальная база данных на другом хосте стоит еще дороже, чем loopback.
Стоимость одной операции идет лишь на третьем месте, и это единственное из трех плеч, на которое влияет любая из этих функций.
Что делать с этой настройкой
Делайте замеры перед включением, причем измеряйте реальный путь выполнения, а не синтетический цикл. Если профилировщик показывает, что запрос ждет базу данных, кэш или HTTP-вызов, JIT нечего компилировать, и его включение просто обменивает штраф на первом запросе на нулевую выгоду.
Задавайте opcache.jit_buffer_size=0 , если замеры говорят сделать именно это. При opcache.jit=tracing и нулевом буфере opcache_get_status() сообщал, что JIT отключен, и тот же цикл Мандельброта (здесь на 500 000 итераций вместо двух миллионов в таблицах выше) выполнялся за 65,99 мс против 67,01 мс с opcache.jit=disable — с той же скоростью, то есть фактически выключенным. В этой сборке размер буфера по умолчанию составляет 64 МБ разделяемой памяти, так что ноль к тому же освобождает неиспользуемую память.
В любом случае оставляйте OPcache включенным. Он убирает накладные расходы, масштабирующиеся от количества затрагиваемых запросом файлов, убирает их для каждого запроса после первого и не зависит от того, что делает ваш код. JIT — более узкоспециализированный инструмент, и условие, при котором он оправдывает свое существование (наличие достаточно «горячей» арифметики, способной преодолеть порог внутри одного процесса), — это условие, которое стоит подтвердить замерами, а не принимать на веру.
Комментарии (0)
Пока нет комментариев — будьте первым.