Разверните изменение, загрузите страницу, загрузите её снова. Второй ответ возвращается быстрее при идентичном коде и идентичных входных данных. В самом приложении это никак не решалось — разрыв кроется глубже, в работе, которую движок проделал один раз и которую ему не пришлось повторять.

Вопрос здесь носит структурный характер: что именно пропустил второй запрос?

Граница SAPI

PHP не запускает ваш файл. Его запускает SAPI — бинарный файл командной строки или, в случае веб-сервера, PHP-FPM. Воркер FPM работает в цикле: принимает запрос по протоколу FastCGI, передает движку путь к скрипту и окружение запроса, выполняет его, отправляет ответ и ждет следующий.

Вокруг этого вызова располагаются запуск и завершение запроса. Запуск формирует мир в рамках запроса: изолированную область памяти (memory arena), хук для каждого загруженного расширения на время запроса, а также суперглобальные переменные, из которых  $_SERVER ,  $_REQUEST  и  $_ENV  создаются при первом обращении в соответствии с настройкой по умолчанию  auto_globals_jit , а не при старте. Завершение уничтожает эту область памяти.

Следствием этого является концепция «Shared nothing» (без разделения общего состояния). Ваши переменные, статические свойства и определения классов, скомпилированные во время запроса, не переносятся на следующий, что бы воркер ни сохранял внутри себя. Процесс все же кое-что сохраняет — он продолжает жить, его расширения остаются загруженными, и он выделяет область разделяемой памяти, которая здесь важнее всего остального.

От исходного кода к опкодам

Получив файл, который он еще не видел, движок считывает байты и проводит их через три этапа. Лексер преобразует символы в токены, парсер превращает токены в абстрактное синтаксическое дерево (AST), а компилятор обходит это дерево и генерирует опкоды (opcodes).

Говорить о трех этапах — это упрощение, которое стоит отметить как таковое: реальный компилятор делает больше, чем просто перевод, и часть этой работы отражается в его выводе. Арифметика над литеральными операндами сворачивается прямо во время компиляции, поэтому  2 ** 16  оставляет после себя только число и никаких арифметических инструкций.

Единицей измерения для всего этого является файл. Компиляция одного файла создает массив опкодов (op array) для его кода верхнего уровня, а также по одному массиву для каждой определенной в нем функции и метода. Здесь нет ничего уровня класса или вызова: сошлитесь на одну функцию из файла размером в две тысячи строк — и весь файл отправится на компиляцию.

Что OPcache сохраняет, а что — нет

OPcache располагается между моментами «у движка запрашивают файл» и «лексер начинает работу», используя в качестве ключа путь к файлу в том виде, в каком его разрешил движок. При промахе файл компилируется, а результат записывается в сегмент разделяемой памяти, который может читать любой воркер в пуле. При попадании сохраненные массивы опкодов привязываются к запросу, а лексер, парсер и компилятор пропускаются.

Это единственные вещи, которые пропускаются. Попадание в кэш не отменяет выполнение. Каждый опкод в каждом массиве опкодов, к которому обращается запрос, выполняется в рамках этого запроса (независимо от того, был ли он закэширован), и OPcache никак не оценивает значения, которые эти опкоды производят. Второй запрос избежал затрат на превращение текста в инструкции — ровно один раз. Стоимость самих этих инструкций все равно взимается при каждом запросе, и JIT-компилятор — это как раз та функция, которая нацелена на оставшуюся половину: куда дотягивается JIT и куда нет, оказывается, более узкой темой, чем предполагает название.

Проверяется ли устаревание файлов, зависит от параметра  opcache.validate_timestamps . Если он включен, движок сравнивает время модификации (mtime) на диске с временем в записи кэша не чаще чем раз в  opcache.revalidate_freq  секунд и считает более свежий файл промахом кэша. Если он отключен, записям доверяют до тех пор, пока какая-либо из них не будет явно аннулирована.

Виртуальная машина представляет собой цикл по опкодам

Массив опкодов — это плоская последовательность инструкций, а не дерево. Каждая инструкция содержит обработчик, до двух операндов и слот для своего результата, а виртуальная машина циклически обходит эту последовательность: берет следующую инструкцию, вызывает ее обработчик, переходит дальше. Вызов создает кадр выполнения (execution frame), содержащий переменные вызываемой функции, а возврат — удаляет его.

Операнды бывают в основном двух видов. CV (compiled variable) — это скомпилированная переменная, пронумерованный слот, заменяющий именованную пользовательскую переменную; он разрешается на этапе компиляции, поэтому во время выполнения поиск по имени не требуется. T (temporary) — временное значение, содержащее промежуточный результат. И то, и другое является слотами, а не значениями: шестнадцать байт в 64-битной сборке, содержащие тег типа и либо небольшой полезный нагрузочный блок, либо указатель на что-то более крупное, поэтому опкод, считывающий элемент массива, стоит ровно столько, сколько структура за этим указателем, а не сколько подсказывает количество инструкций. Эту структуру иллюстрируют два выражения:

 <?php

// total.php

declare(strict_types=1);

$total = (int) $argv[1] + 5;
echo $total, PHP_EOL;  php -d opcache.enable_cli=1 -d opcache.opt_debug_level=0x10000 total.php 7 

Дамп отправляется в стандартный поток ошибок (stderr), опережая собственный вывод скрипта. В обоих приведенных ниже листингах опущена первая строка комментария с указанием абсолютного пути к файлу. На PHP 8.5.9 первая выдает:

 $_main:
     ; (lines=7, args=0, vars=2, tmps=4)
     ; (before optimizer)
     ; return  [] RANGE[0..0]
0000 T2 = FETCH_DIM_R CV1($argv) int(1)
0001 T3 = CAST (long) T2
0002 T4 = ADD T3 int(5)
0003 ASSIGN CV0($total) T4
0004 ECHO CV0($total)
0005 ECHO string("\n")
0006 RETURN int(1) 

Единая строка арифметических действий — это четыре инструкции: извлечь элемент 1 из  $argv  во временную переменную, привести тип, прибавить литерал, сохранить сумму в слот, представляющий  $total .

Это результат работы компилятора, а не кэша, как свидетельствуют название флага и комментарий в заголовке. OPcache запускает оптимизатор над массивом опкодов перед его сохранением, поэтому форма, по которой обходит виртуальная машина, выглядит так, как ее печатает  0x20000 :

 $_main:
     ; (lines=7, args=0, vars=2, tmps=2)
     ; (after optimizer)
0000 T2 = FETCH_DIM_R CV1($argv) int(1)
0001 T3 = CAST (long) T2
0002 T2 = ADD T3 int(5)
0003 ASSIGN CV0($total) T2
0004 ECHO CV0($total)
0005 ECHO string("\n")
0006 RETURN int(1) 

Те же семь инструкций, но вместо четырех временных слотов используется два. Первый листинг ссылается на три временные переменные, но объявляет четыре, так что одна из них вообще не использовалась: оптимизатор отбрасывает ее и сворачивает результат сложения обратно в  T2 , значение которого приведение типов уже потребило.

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

Уборка — это лишь меньшая часть. Запустите те же два флага для функции, содержащей локальную переменную  $base = 5 , мертвую ветку  if (false)  и объявленный тип возвращаемого значения, и десять инструкций превратятся в четыре: константа распространяется в сложение, недостижимая ветка и завершающий неявный return отбрасываются, а сумма записывается напрямую в скомпилированную переменную вообще без временных слотов.

Автозагрузка определяет, сколько именно доходит до компилятора

Чтобы выразить в цифрах затраты на компиляцию, я сгенерировал 300 небольших файлов классов (по двенадцать методов в каждом, около 414 КБ исходного кода) и замерил время выполнения цикла, требующего их все, при отключенном OPcache, чтобы каждый запуск компилировал каждый файл. Apple M4 Pro, macOS, PHP 8.5.9 CLI, NTS, JIT отключен, замеры производились с помощью  hrtime()  вокруг цикла require, поэтому операции чтения с файловой системы учтены. Медиана по 15 запускам составила 7,69 мс в диапазоне от 7,61 до 7,89 мс (разброс 3,6% от медианы): примерно 26 мкс на файл. Воспринимайте эти цифры как относительные, а не абсолютные — один ноутбук, без стабилизации окружения. Форма зависимости здесь показательна: стоимость пропорциональна количеству файлов, по нескольку десятков микросекунд на каждый.

Автозагрузка решает, какие именно файлы дойдут до компилятора. При использовании автозагрузчика Composer класс, на который не ссылается ни один путь выполнения, никогда не подключается через require, не компилируется и не кэшируется, в то время как задействованный класс проводит свой файл через описанную выше цепочку. Стоимость «холодного старта» зависит от того, скольких различных файлов касается запрос, и граф зависимостей влияет на эту цифру гораздо сильнее, чем что-либо внутри тела функции. Сокращение инструкций в горячем цикле не вернет по 26 мкс на файлы, которые никогда не требовалось загружать.

Оптимизированный classmap — это более узкий рычаг: он заменяет поклассовый опрос файловой системы автозагрузчиком на одно обращение к массиву, устраняя системные вызовы stat, но он не уменьшает количество компилируемых файлов. На момент написания статьи я не измерял эту разницу; более поздние измерения на скелетоне Symfony показали, что это убрало 508 проверок файловой системы и никак не изменило показатели холодного запроса, которые этот метод мог бы зафиксировать.

Куда более мощный рычаг, чем оба предыдущих, — это перенос работы за пределы запроса вообще. Фреймворк, который компилирует свой контейнер сервисов, производит разрешение зависимостей единожды во время сборки и записывает результат в виде сгенерированного кода, поэтому запрос загружает один файл и вызывает конструкторы напрямую, вместо того чтобы загружать механизм определений и разрешать зависимости через него при каждом вызове. Этот файл компилируется один раз и впоследствии считывается из кэша, что делает все преимущество полностью зависящим от включенного OPcache — статья что на самом деле дают скомпилированные контейнеры внедрения зависимостей оценивает обе стороны экономии и определяет размер контейнера, начиная с которого компиляция сгенерированного файла обходится дороже, чем заменяемые ею определения.

Какие выводы это дает для деплоя

Отключите  opcache.validate_timestamps  на продакшене и делайте это осознанно. Движок полностью перестает вызывать stat для ваших файлов, и взамен этого правка на диске не возымеет никакого эффекта до тех пор, пока запись в кэше не исчезнет — а это значит, что деплой должен осуществляться в новый каталог релиза, чтобы ключи кэша были новыми, либо путем перезагрузки пула. Исправление проблемы путем правки файла прямо на продакшн-сервере перестает работать. К этому стоит прибегать, когда деплой автоматизирован, и отказываться, когда это не так.

Настраивайте размер  opcache.memory_consumption  и  opcache.max_accelerated_files с учетом количества PHP-файлов, которые приложение действительно загружает. OPcache не вытесняет старые записи: когда один из лимитов исчерпывается, новые скрипты перестают кэшироваться, в то время как все уже сохраненное продолжает обслуживаться. Какой именно из двух лимитов исчерпался, говорит ли об этом флаг  cache_full  и почему перезапуск, который должен высвобождать потраченный впустую сегмент, часто не срабатывает — это второй вопрос, требующий собственных измерений; раньше здесь было пять абзацев, а теперь это отдельная статья.

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