Горячий метод после JIT-компиляции стал медленнее из-за чрезмерного встраивания вызовов. Какой механизм объясняет такой парадокс?
Инлайнинг может ускорить отдельный вызов, но чрезмерное встраивание раздувает машинный код вызывающего метода. Из-за этого ухудшается использование кэша инструкций процессора, растут стоимость компиляции и давление на Code Cache, поэтому итоговая производительность иногда снижается.
Интерпретация и вызов методов через обычную виртуальную диспетчеризацию удобны для запуска Java-программ, но добавляют накладные расходы в горячих участках. JIT-компилятор появился как адаптивный механизм: он наблюдает за выполнением и оптимизирует именно часто исполняемый код.
Инлайнинг стал одной из базовых оптимизаций, потому что после встраивания исчезает отдельный вызов метода, а компилятор получает возможность применять дополнительные оптимизации к объединённому коду. Однако размер машинного кода ограничен практическими свойствами процессора и самой JVM, поэтому встраивание выполняется по эвристикам, а не безусловно.
Предположим, горячий метод вызывает длинную цепочку небольших методов-обёрток. Каждый отдельный вызов недорог, и встраивание сначала уменьшает накладные расходы.
Если JIT встроит слишком много таких методов, итоговый машинный код вызывающего метода может стать существенно больше. Тогда процессору приходится чаще загружать инструкции из более медленных уровней памяти, а компилятору — тратить больше времени и памяти на генерацию и хранение этого кода.
Неверный вывод состоит в том, что «горячий» или «маленький» метод всегда нужно встраивать. Горячесть повышает целесообразность оптимизации, но не отменяет ограничений на размер итогового кода и его реальную исполняемость.
При инлайнинге тело вызываемого метода вставляется в машинный код вызывающего метода. Это позволяет убрать часть накладных расходов вызова, распространить константы, устранить недостижимые ветви и иногда ускорить последующие оптимизации.
Проблема возникает, когда рост кода превышает пользу от устранённых вызовов. Большой метод хуже помещается в L1-кэш инструкций и другие кэши процессора; увеличивается вероятность промахов, а выполнение может замедлиться даже при меньшем числе инструкций вызова.
Есть и JVM-издержки. Более крупный граф машинных инструкций дольше компилируется, занимает больше места в Code Cache и может вытеснять другой скомпилированный код. Снижается также плотность полезного кода в кэше, поэтому оптимизация одного участка способна ухудшить соседние горячие участки.
JIT принимает решение эвристически: учитывает горячесть вызова, размер метода, глубину цепочки инлайнинга, тип вызова и ожидаемую выгоду. Точные пороги зависят от JVM, версии и режима компиляции, поэтому нельзя надёжно рассуждать по одному размеру исходного метода.
Инлайнинг не означает обязательного устранения всей динамики вызова. Для виртуального или интерфейсного вызова JVM может встроить конкретную реализацию при наличии устойчивого профиля типов, добавив проверку предположения. Если предположение нарушится, возможна деоптимизация и возврат к менее специализированному варианту.
В сервисе маршрутизации запросов горячий обработчик проходил через множество маленьких адаптеров. После прогрева профиль показывал большой скомпилированный метод и рост промахов кэша инструкций; средняя загрузка процессора при этом не объясняла увеличение задержки.
Рассматривались три варианта:
Выбран третий вариант. После изменения сравнили профиль до и после прогрева: размер скомпилированного обработчика уменьшился, а задержка критического пути снизилась. Важно проверять это измерениями, потому что сам факт наличия инлайнинга ещё не доказывает его вред.
Нет. JVM может встроить вызов, если профиль показывает один устойчивый тип получателя или если доступны другие доказательства специализации. Для сохранения корректности она способна добавить проверку типа и использовать оптимизированную ветку только при выполнении предположения. При появлении нового типа возможны деоптимизация и повторная компиляция, поэтому инлайнинг не превращает динамический вызов в безусловно статический.
Не обязательно в смысле байткода класса: JIT обычно работает с промежуточным представлением и создаёт отдельный машинный код. Увеличивается прежде всего размер скомпилированного кода и графа оптимизации. Поэтому heap dump не покажет проблему как обычный рост Java-объектов; для анализа нужны сведения о компиляции, Code Cache и профиле процессора.
Частый вызов не гарантирует положительный итог. Полное встраивание может привести к раздуванию машинного кода, ухудшению кэша инструкций, увеличению времени JIT-компиляции и вытеснению полезного кода из Code Cache. Кроме того, чрезмерная специализация по текущему профилю повышает риск последующей деоптимизации. Поэтому JIT ищет компромисс между экономией на вызовах, возможностями последующих оптимизаций и размером результата.