Одно и то же приложение, одинаковый граф зависимостей, те же двадцать сервисов, разрешаемых за
запрос. Напрямую через ContainerBuilder от Symfony это заняло 0,157 мс;
через контейнер, сдампленный тем же билдером в сгенерированный PHP-код, — 0,027
мс. В самом приложении между этими вариантами не изменилось ничего — только момент,
когда именно выстраивались связи.
Методика
PHP 8.5.9, NTS, arm64, сборка из Homebrew, ноутбук Apple M4 Pro на macOS,
без перевода системы в режим покоя. symfony/dependency-injection 8.1.5. Данные получены со
встроенного сервера PHP, отдающего один скрипт; замеры времени проводились с помощью hrtime() внутри процесса
отдельно для двух фаз: получение контейнера и резолв сервисов
из него. opcache.enable=1 , opcache.jit=disable ,
opcache.file_update_protection=0 . Пятнадцать чередующихся прогонов для каждого варианта, первые три
«прогревочных» раунда отбрасывались. В таблицах приведены медианы; разброс от прогона к
прогону указан под каждой таблицей, а там, где значения колонок близки, дан в виде диапазона для каждой ячейки. Каждый
ответ выводил свою фактическую конфигурацию и счетчики OPcache — благодаря этому
я знаю, что варианты действительно отличались, а не сбросились все к значениям по умолчанию.
Граф состоит из одного сервиса на каждый класс репозитория: каждый сервис зависит от своего репозитория и общего логгера, а каждый репозиторий — от общего подключения к БД. Сервисы и репозитории не являются синглтонами (non-shared), поэтому резолв 20 сервисов создает 40 объектов плюс три общих. Классы, инстанцируемые при прогоне, подключаются через require до запуска таймера, так что цифры отражают работу самого контейнера, а не автозагрузку, издержки на которую у обоих вариантов одинаковы.
Что делает runtime-контейнер при каждом резолве
Получая запрос на сервис, runtime-контейнер считывает его определение (definition), резолвит каждый
аргумент конструктора — рекурсивно, поскольку аргументы чаще всего являются ссылками на
другие сервисы, — и создает объект. Symfony ContainerBuilder для этого обходит
объекты Definition . Контейнер Laravel использует рефлексию над
конструктором и резолвит каждый типизированный параметр. В обоих случаях эта работа ложится на
запрос, и повторяется снова на следующем, потому что PHP-запрос ничего не
наследует от предыдущего: модель shared-nothing, описанная в статье
путь запроса от исходного кода до
опкодов, уничтожает граф
объектов вместе со всем остальным.
Что переносит компиляция
Компиляция контейнера
выполняет этот резолв один раз и генерирует PHP-класс с отдельным методом под каждый
сервис. PhpDumper из Symfony сгенерировал следующий код для сервиса,
зависящего от non-shared репозитория и общего логгера (строки разбиты для читаемости, так как
генератор выводит вызов конструктора в одну строку):
protected static function getSvc0Service($container)
{
$container->factories['svc0'] = function ($container) {
return new \Gen50\Svc0(
new \Gen50\Repo0(($container->services['connection'] ?? self::getConnectionService($container))),
($container->services['logger'] ?? self::getLoggerService($container)),
);
};
return $container->factories['svc0']($container);
}
Здесь нет определений, которые нужно читать, и аргументов, которые нужно резолвить. Репозиторий был встроен прямо в вызов конструктора (inlined), поскольку больше никем не разделяется, а две общие зависимости превратились в проверку по массиву с фолбэком. Работа во время запроса сводится к вызову метода над кодом, структуру которого компилятор уже просчитал заранее.
Экономия состоит из двух частей, и масштабируются они по-разному
Если зафиксировать число резолвов на 20 и менять количество сервисов, объявленных в контейнере, эти составляющие можно разделить. Медианы в миллисекундах:
| Определения | Runtime, получение | Runtime, резолв | Компилируемый, получение | Компилируемый, резолв |
|---|---|---|---|---|
| 54 | 0.0448 | 0.0645 | 0.0154 | 0.0114 |
| 204 | 0.0942 | 0.0633 | 0.0152 | 0.0113 |
| 1,004 | 0.3654 | 0.0630 | 0.0152 | 0.0114 |
| 4,004 | 1.5690 | 0.0663 | 0.0158 | 0.0123 |
Разброс в этой таблице составил от 2,0% от медианы до 44%; сильнее всего он заметен на строке с 54 определениями и в двух колонках компилируемого контейнера на 4 004 определениях, где измеряемое время составляет считанные микросекунды, и один медленный прогон сильно растягивает диапазон. Строки с 204 и 1 004 определениями, на которые опирается аргументация, уложились в диапазон от 2,0% до 8,1%. Это показатели для данной серии замеров: разброс — свойство конкретного прогона, а не величина, которую повторный тест воспроизводит так же стабильно, как медиану.
Стоимость резолва остается неизменной при 70-кратном увеличении размера контейнера у обоих вариантов. Построение определений — нет: колонка runtime растет примерно пропорционально количеству сервисов, достигая 1,57 мс на четырех тысячах, где она многократно перекрывает время самого резолва, ради которого и существует. Колонки компилируемого контейнера не меняются.
Варьирование по другой оси — фиксированные 1 004 определения и изменение только числа сервисов, резолвимых за запрос, — изолирует разницу на уровне отдельного резолва:
| Резолвы | Runtime | Компилируемый | Соотношение |
|---|---|---|---|
| 1 | 0.0098 (0.0092–0.0145) | 0.0015 (0.0014–0.0018) | 6.5x |
| 5 | 0.0212 (0.0209–0.0214) | 0.0036 (0.0035–0.0037) | 5.9x |
| 20 | 0.0640 (0.0630–0.0701) | 0.0114 (0.0113–0.0140) | 5.6x |
| 100 | 0.2833 (0.2814–0.3045) | 0.0500 (0.0490–0.0553) | 5.7x |
| 300 | 0.8387 (0.8324–0.8550) | 0.1420 (0.1408–0.1468) | 5.9x |
Разброс не снижается строго пропорционально размеру. Строка с одним резолвом оказалась самой нестабильной — 54% от медианы в колонке runtime, 27% в компилируемой, — поскольку пара микросекунд находится на пределе разрешающей способности этого метода. Однако ячейка runtime для пяти резолвов показала минимальный разброс во всей таблице (2,4%), а компилируемая ячейка для 20 резолвов — 24%. Что сохраняется на каждой строке, так это четкое разделение: ни один диапазон runtime даже близко не подходит к компилируемому аналогу (минимальный разрыв — в 4,5 раза), так что колонка соотношения остается показательной даже при высоком уровне шума.
Оба варианта масштабируются линейно по числу резолвов, а соотношение держится в пределах от 5,6x до 6,5x, что дает около 2,3 мкс экономии на каждый отрезолвленный сервис. Фаза получения runtime-контейнера оставалась в пределах от 0,3696 до 0,3798 мс на протяжении всей этой колонки, подтверждая, что она зависит от количества определений, а не от количества резолвов.
Таким образом, утверждение «экономия масштабируется в зависимости от того, что собирает запрос, а не от размера контейнера» верно для части, отвечающей за резолв, и неверно для второй половины. Фреймворк, пересобирающий определения на каждый запрос, платит за весь контейнер целиком независимо от того, обращается к нему запрос или нет.
Один результат разошелся с моими ожиданиями. Контейнер на сорок строк кода с автовайрингом через рефлексию конструкторов в момент резолва (по схеме Laravel) отрезолвил те же 20 сервисов за 0,0162 мс против 0,0113 мс у компилируемого контейнера, но вообще без фазы получения, так что его суммарные 0,0163 мс обошли результат компилируемого контейнера в 0,0265 мс. Сравнение не совсем честное: мой вариант обрабатывает параметры конструктора с тайп-хинтингом и ничего больше — никаких привязок интерфейсов, фабрик или ленивых сервисов. Считайте это доказательством того, что рефлексия обходится дешевле, чем принято считать, а не свидетельством проигрыша компиляции.
Скомпилированный контейнер — это файл, и OPcache — причина, почему он обходится бесплатно
Стабильные показатели в колонке получения зависят от одного важного фактора. Если отключить OPcache, не меняя ничего другого:
| Определения | OPcache включен | OPcache выключен |
|---|---|---|
| 204 | 0.0154 (0.0148–0.0213) | 1.0150 (0.9819–1.0769) |
| 1,004 | 0.0154 (0.0148–0.0211) | 3.4235 (3.4144–3.5583) |
| 4,004 | 0.0155 (0.0148–0.0299) | 12.9366 (12.7245–13.1540) |
Сгенерированный код — это всё еще код. Сдампленный контейнер на четыре тысячи определений представляет собой PHP-файл размером 2,1 МБ, и без кэша каждый запрос расходует время на его компиляцию. Первый запрос в новом процессе в любом случае платит эту цену единожды: 2,58 мс при 204 определениях, 8,65 мс при 1 004, 35,42 мс при 4 004. Компиляция меняет работу во время запроса на большой файл, и то, что именно OPcache убирает, а что оставляет, как раз и делает этот размен выгодным.
Издержки, одна из которых оказывается плюсом
Шаг сборки действительно требует ресурсов. Определение, компиляция и дамп 4 004 сервисов заняли 265 мс, из которых 107 мс ушло на compiler passes, а 153 мс — на сам дамп. Это происходит во время деплоя, а не в процессе запроса, но все же выполняется, и сгенерированный файл нужно инвалидировать при изменении графа зависимостей — для чего и нужен сброс кэша (cache clear). Если отредактировать определение сервиса без перегенерации, приложение продолжит использовать старые связи, молча и корректно, пока что-то не пересоберет контейнер.
Класс ошибок тоже смещается. Я зарегистрировал сервис, указав mailerr вместо
нужного mailer . Нескомпилированный контейнер обработал запрос с резолвом
mailer и даже не заметил этого, потому что сломанный сервис никто не запрашивал.
Компиляция же завершилась ошибкой:
Symfony\Component\DependencyInjection\Exception\ServiceNotFoundException:
The service "invoice_sender" has a dependency on a non-existent service
"mailerr". Did you mean this: "mailer"?
Ошибка в связывании зависимостей на пути, не покрытом тестами, обернется инцидентом в проде при runtime-контейнере и упавшим билдом — при компилируемом.
Где это не станет точкой роста
При 20 сервисах на запрос компиляция сэкономила около 46 мкс на резолве. Один loopback-запрос (round trip) на этой машине стоил примерно 86 мкс согласно замерам из статьи про OPcache — это другая серия тестов, поэтому сравнивайте скорее порядки величин, чем точные цифры. Один лишний запрос к базе данных полностью перевешивает всё время работы контейнера. Если профайлер показывает, что запрос простаивает в ожидании I/O, оптимизация контейнера ничего не решит; о том, как это выяснить, читайте в статье бенчмаркинг PHP без самообмана.
Что со всем этим делать
Компилируйте контейнер, если ваш фреймворк это поддерживает, и считайте главной причиной надежность, а не скорость. Обнаружение ошибок конфигурирования на этапе сборки дает гораздо больше, чем 46 мкс, а аргумент о производительности становится значимым только тогда, когда запрос резолвит сотни сервисов или контейнер содержит тысячи определений.
Держите OPcache включенным, поскольку преимущество компилируемого контейнера напрямую зависит от него, и степень этой зависимости растет вместе с размером. При замере обеих фаз вместе и выключенном кэше (в отдельной серии из одиннадцати прогонов) компилируемый контейнер всё еще выигрывал при 204 определениях — 1,07 мс против 1,47 мс — и проигрывал при 4 004: 13,04 мс против 2,83 мс. Начиная с определенного размера контейнера компиляция многомегабайтного сгенерированного файла на каждый запрос обходится дороже, чем создание тех определений, которые он заменил.
Сначала измерьте число резолвов, прежде чем винить контейнер во всех грехах. Двадцать сервисов с экономией по 2,3 мкс на каждом — совсем не то место, где теряется время медленного запроса, а контейнер — это одна из тех вещей во фреймворке, на которую проще всего свалить вину и которую сложнее всего честно спрофилировать.
Комментарии (0)
Пока нет комментариев — будьте первым.