open atlas
↑ К треку
Внутренности движка JavaScript JSE · 04 · 05

Деоптимизация: падение с обрыва

Деопт отбрасывает оптимизированный машинный код и возобновляет исполнение в Ignition на точном смещении байт-кода. Eager против lazy deopt, механизм трансляции фрейма, реконструирующий интерпретаторный фрейм

JSE Senior ◷ 16 min
Уровень
ОсновыJuniorMiddleSenior

Функция, работавшая на 40 нс за вызов в вашем бенчмарке, вдруг стоит 4 мкс за вызов в продакшене — в сто раз медленнее, чем была бы нескомпилированной. Нет бесконечного цикла, нет блокирующего ввода-вывода, нет шторма GC. То, что вы наблюдаете, — это функция, которую оптимизируют и выбрасывают, оптимизируют и выбрасывают, десятки раз в секунду, потому что один из guards TurboFan продолжает падать на значении, которое он не может перестать видеть. Это цикл деоптимизации, и это самый дорогой способ ошибиться в типах в JavaScript.

Что такое деопт на самом деле

Провал guard (урок 04) не падает с ошибкой и не выдаёт тихо неверный ответ. Он запускает деоптимизацию: V8 отбрасывает оптимизированный машинный код этой функции и возобновляет исполнение в интерпретаторе Ignition на точном смещении байт-кода, соответствующем месту, где оптимизированный код сдался. Исполнение продолжается корректно, просто медленнее. Оптимизированный код отбрасывается (или помечается невалидным), а функция откатывается на нижний ярус; если она остаётся горячей и её обратная связь стабилизируется, она может быть переоптимизирована позже.

Есть две разновидности, различающиеся тем, что инвалидировало предположение.

Eager deopt (немедленный) происходит синхронно, когда guard падает в рантайме — функция исполняет оптимизированный код, доходит до CheckMaps / CheckSmi / CheckBounds, проверка падает, и управление откатывается прямо там. Триггер локальный: этот вызов увидел значение, которого спекуляция не допускала. В --trace-deopt появляется как DEOPT eager с причиной вроде wrong map или not a Smi.

Lazy deopt (ленивый) происходит, когда предположение инвалидируется извне, а не собственным исполнением функции. Вы переопределяете функцию, которую оптимизированный код встроил, изменяете прототип, от которого он зависел, добавляете свойство к разделяемому объекту или меняете глобал. V8 не может залезть во фрейм, который, возможно, исполняется, поэтому вместо этого он помечает весь зависимый оптимизированный код на деопт при следующем входе — код «лениво» деоптится в следующий раз, когда был бы вызван. В --trace-deopt появляется как DEOPT lazy. Классическая причина — monkey-patching: Array.prototype.map = ... после того, как горячий код был оптимизирован под оригинал.

Трансляция фрейма: как он возвращается чисто

Когда guard падает, V8 должен возобновиться в интерпретаторе — но два фрейма говорят на совершенно разных языках. Сложная часть деопта в том, что оптимизированный фрейм и интерпретаторный фрейм — это совершенно разные раскладки. Оптимизированный код держит значения в машинных регистрах и плотном стек-фрейме, часто в распакованных (unboxed) представлениях (сырой int32, сырой float64). Интерпретатор ожидает значения в собственном регистровом файле («регистры» интерпретатора — это слоты стека) в их тегированной, забоксенной форме. Чтобы возобновиться в интерпретаторе, V8 должен перестроить фрейм интерпретатора из оптимизированного.

Он делает это с помощью метаданных деопта (deopt metadata), записанных на компиляции: для каждой возможной точки деопта TurboFan хранит описание, отображающее каждую живую локацию оптимизированного фрейма (этот машинный регистр держит локальную i, этот слот стека держит total, это распакованный double, который надо перебоксить) на интерпретаторный регистр, которому она принадлежит. В момент деопта deoptimizer читает эти метаданные и выполняет трансляцию фрейма (frame translation) — он выделяет и заполняет свежий фрейм интерпретатора, помещая каждое живое значение туда, где его ожидает Ignition, перебоксивая распакованные значения, затем прыгает в интерпретатор на сохранённое смещение байт-кода. Трансляция одного деопта дёшева, порядка микросекунд.

Цикл деоптимизации: настоящая катастрофа

Один деопт — это нормально: микросекунды учётной работы, затем функция заново разогревается с обратной связью, теперь включающей неожиданный случай, переоптимизируется с более широким предположением и остаётся оптимизированной. Катастрофа — это цикл деоптимизации (deopt-loop): функцию раз за разом кормят формой или типом, который она не может удержать стабильным, так что она оптимизируется, деоптится, переоптимизируется (функция всё ещё горячая, так что V8 продвигает её снова), деоптится опять — бесконечно. Каждый цикл платит трансляцию деопта плюс свежую компиляцию TurboFan (десятки-сотни мс) плюс время, проведённое в медленном ярусе между ними. Функция в цикле деоптимизации на порядки медленнее, чем если бы вы просто отключили оптимизацию через --no-opt.

Когда вы видите в —trace-deopt функцию, мечущуюся между оптимизированным и деоптимизированным состоянием, ищите один из этих паттернов — каждый способ заставить guard падать на повторяющемся значении:

  • Переполнение Smi (Smi overflow) — арифметический сайт, оптимизированный на SignedSmall, производит значение за пределами диапазона Smi (больше ~2^30), проваливая CheckSmi каждый раз, когда большое значение повторяется.
  • Смена формы / расхождение hidden class — место вызова видит объекты с расходящимися картами; CheckMaps падает. Добавление свойств в разном порядке, условные поля, delete.
  • Смена типа поля — поле, оптимизированное как Smi, позже держит строку или объект, так что загрузки, охраняемые представлением поля, деоптятся.
  • Чтение arguments в оптимизированных функциях — исторически форсировало деопт или блокировало оптимизацию; современный V8 обрабатывает многие случаи, но rest-параметры всё ещё безопасный выбор.
  • Выход за границы или дырявый доступ — провал CheckBounds или переход упакованного массива в дырявый elements kind.

Диагностируйте через --trace-deopt (каждый деопт с его функцией, смещением байт-кода и причиной) и, в d8 под --allow-natives-syntax, %GetOptimizationStatus(fn), чтобы прочитать биты яруса функции. Урок браузерного трека спекулятивный движок TurboFan и ловушка цикла деоптимизации проходит полную трассировку-и-починку; здесь упор на механизм и паттерн починки: отвести патологическое значение на отдельный медленный путь до горячего блока, чтобы быстрый путь оставался стабильным по типам.

Цены и триггеры деопта
Трансляция одного деопта
~микросекунды
Переоптимизация (TurboFan)
десятки-сотни мс
Замедление цикла деопта
на порядки
Триггер eager deopt
guard падает в рантайме
Триггер lazy deopt
внешняя инвалидация
Диапазон Smi
~31-битный знаковый
Флаг диагностики
--trace-deopt
Интринсик статуса
%GetOptimizationStatus(fn)
Викторина

Вы делаете monkey-patch `Array.prototype.includes` в рантайме, после того как горячая функция, использовавшая его, была оптимизирована TurboFan. Какой вид деопта это вызывает?

Викторина

Почему цикл деоптимизации медленнее, чем исполнение той же функции с `--no-opt` (оптимизация целиком отключена)?

Расставь шаги по порядку

Расставьте, что происходит во время одного eager deopt.

  1. 1 Оптимизированный код исполняется и доходит до guard (например, CheckSmi)
  2. 2 Guard падает: значение нарушает спекулируемый тип
  3. 3 Deoptimizer читает метаданные деопта времени компиляции
  4. 4 Он перестраивает интерпретаторный фрейм, перебоксивая живые значения в регистры интерпретатора
  5. 5 Исполнение возобновляется в Ignition на сохранённом смещении байт-кода
Частая ошибка

Тонкий цикл деоптимизации прячется в «защитном» коде, возвращающем смешанные типы: парсер, возвращающий число для валидного входа, но null для невалидного, питающий горячий арифметический сайт. Для 99% входов сайт Smi-стабилен и оптимизируется; редкий null проваливает CheckSmi и деоптит; сайт переоптимизируется снова на Smi, ведь null редок; следующий null деоптит опять. Починка — не «обрабатывать null быстрее», а разделить поток: валидировать и отбрасывать null до горячего цикла, чтобы арифметический сайт видел только числа.

Вспомните перед уходом
  1. 01
    Различите eager и lazy deopt с триггером для каждого.
  2. 02
    Объясните трансляцию фрейма и зачем она нужна.
  3. 03
    Что такое цикл деоптимизации, почему он катастрофичен и как его починить?
Итог

Деоптимизация — это предохранительный клапан, удерживающий спекулятивную оптимизацию корректной. Когда предположение ломается, V8 отбрасывает оптимизированный машинный код и возобновляет исполнение в интерпретаторе Ignition на точном смещении байт-кода, где оптимизированный код сдался, — исполнение остаётся корректным, просто медленнее. Eager deopt (немедленный деопт) синхронен: собственный оптимизированный код функции доходит до падающего guard (CheckMaps ‘wrong map’, CheckSmi ‘not a Smi’, CheckBounds ‘out of bounds’). Lazy deopt (ленивый деопт) внешний: переопределение встроенной функции, изменение прототипа или смена глобала инвалидируют зависимый оптимизированный код, который V8 помечает на деопт при следующем входе, ведь не может пропатчить исполняющийся фрейм. Чистый возврат возможен благодаря трансляции фрейма (frame translation): TurboFan записывает метаданные деопта, отображающие каждое живое значение оптимизированного фрейма (в регистрах, часто распакованное) на интерпретаторный регистр, которому оно принадлежит, и в момент деопта deoptimizer перестраивает тегированный интерпретаторный фрейм и прыгает на сохранённое смещение. Один деопт стоит микросекунды и является системой, работающей по замыслу. Катастрофа — цикл деоптимизации: повторяющееся значение ломает guard практически на каждом вызове, так что V8 оптимизирует и деоптит без конца, платя свежую компиляцию каждый цикл и оказываясь на порядки медленнее —no-opt. Триггеры включают переполнение Smi, расхождение формы/hidden class, смену типа поля, чтение arguments и дырявый/выходящий за границы доступ. Диагностируйте через —trace-deopt и %GetOptimizationStatus; чините, отводя патологическое значение на медленный путь до горячего блока, чтобы быстрый путь оставался стабильным по типам. Теперь, когда вы видите в продакшене регрессию в 100 раз без очевидной причины — откройте —trace-deopt прежде профайлера: цикл деоптимизации объявит себя сам.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.

вспомнитьприменитьуглубить0 из 7 завершено
Связанные уроки
встречается в184

Что-то непонятно?

Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.

Примени это

Примени этот урок в реальном проекте.

хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.