open atlas
↑ К треку
Node.js с нуля до senior NODE · 14 · 01

Оптимизация V8: hidden classes, inline caches и ярусы JIT

V8 делает типостабильный код быстрым: одинаково устроенные объекты делят один hidden class, чтение свойств кэшируется мономорфно, горячие функции дорастают до TurboFan. Churn формы, delete и смешивание типов вызывают деоптимизацию и медленный dictionary mode.

NODE Senior ◷ 18 min
Уровень
ОсновыJuniorMiddleSenior

Горячий обработчик запросов, который парсил JSON и нормализовал его, бежал на 40k оп/с в бенчмарке и 9k оп/с в проде. Тот же код, та же версия Node. Бенчмарк кормил его одним литералом объекта; прод кормил пятью вариантами payload, и один путь делал delete obj.temp перед возвратом. Этот единственный delete перевёл объект в dictionary mode, inline cache чтения свойства стал megamorphic, а TurboFan отказался держать функцию оптимизированной — он сваливался обратно в интерпретатор на каждом горячем цикле. Фикс был в три строки: строить свежий объект со стабильной формой вместо мутации-и-удаления. Пропускная способность вернулась к 38k.

Hidden classes: объекты типизированы, хотя JavaScript — нет

У JavaScript нет статических типов, но V8 изобретает их в рантайме. Каждый объект указывает на hidden class (V8 зовёт его Map, документирован также как Shape), который фиксирует раскладку объекта: какие свойства есть, в каком порядке и по какому смещению в памяти лежит каждое. Два объекта, созданные одинаково — те же свойства, добавленные в том же порядке, — делят один и тот же hidden class, поэтому V8 может скомпилировать доступ к свойству в «прочитай слот по смещению 16» вместо поиска по хэш-карте.

Hidden classes строятся переходами (transitions). Начни с {} (пустой hidden class C0); добавь x — перейдёшь к C1; добавь y — перейдёшь к C2. V8 кэширует эти переходы в дереве, поэтому следующий объект, добавляющий x, затем y, идёт тем же путём и попадает в тот же C2 — новый класс не аллоцируется. Вот почему порядок конструирования объекта несущий.

function makePoint(a, b) {
  const p = {};
  p.x = a;   // C0 -> C1
  p.y = b;   // C1 -> C2
  return p;
}
// Оба делят hidden class C2 — V8 трактует их как один «тип»:
const p1 = makePoint(1, 2);
const p2 = makePoint(3, 4);

// Но добавь ТЕ ЖЕ свойства в другом ПОРЯДКЕ — и получишь другой путь
// перехода, ВТОРОЙ hidden class для тех же полей:
const q = {};
q.y = 2;   // C0 -> C1'
q.x = 1;   // C1' -> C2'   (C2' != C2)

Три вещи ломают разделение формы, и ты обязан знать все три:

  • Добавление свойств в другом порядке даёт расходящийся путь перехода и отдельный hidden class, даже если набор полей идентичен.
  • delete obj.prop — худшее: V8 не может представить «дыру» в быстрой packed-раскладке, поэтому переводит объект в dictionary mode (slow properties) — хэш-карту на объект, без общего hidden class и без выгоды inline cache. Используй obj.prop = undefined или пересобери объект, если ключ действительно надо убрать.
  • Смешивание типов значений в одном слоте (целое там, где обычно живёт double или объект) меняет представление поля и форсирует смену hidden class; для массивов это запускает переход elements-kind (PACKED_SMI → PACKED_DOUBLE → PACKED_ELEMENTS, каждый строго более общий и менее оптимизируемый).

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

Inline caches: monomorphic, polymorphic, megamorphic

Hidden class — лишь половина механики. Вторая половина — inline cache (IC): на каждом месте доступа к свойству — obj.x в какой-то функции — V8 запоминает, какой hidden class он видел и в какое смещение разрешил его. В следующий раз, если у объекта тот же hidden class, он читает кэшированное смещение напрямую. Состояние IC — лучший единичный предиктор того, как быстро бежит это место.

function dist(p) {
  return Math.sqrt(p.x * p.x + p.y * p.y); // IC живёт на p.x / p.y
}
  • Monomorphic — место видело только один hidden class. IC — это одно сравнение-и-чтение; это быстрый путь, и тот, который TurboFan может встроить и спекулировать на нём жёстче всего.
  • Polymorphic — место видело 2–4 hidden class. V8 держит маленький список и линейно проверяет каждый; медленнее, но всё ещё оптимизируемо.
  • Megamorphic — больше ~4 форм. V8 сдаётся на покоместный кэш и откатывается к обобщённому megamorphic-стабу (глобальный хэш-поиск). Это обрыв: он драматически медленнее и блокирует хорошую оптимизацию функции.

Практическое правило: корми каждое горячее место вызова объектами одной формы. Функция, нормализующая три разные формы payload, должна быть тремя мономорфными функциями (или нормализуй к одной форме до горячего пути), а не одной megamorphic.

Почему это работает

Состояние IC/оптимизации можно подсмотреть в разработке через natives syntax: запусти node --allow-natives-syntax и вызови %GetOptimizationStatus(fn) или печатай состояния IC через --trace-ic. Это только диагностический подгляд — никогда не отгружай его, никогда не ветви прод-логику по нему. %-функции — внутренний контракт V8, который меняется между версиями, а --allow-natives-syntax отключает гарантии безопасности. Используй, чтобы подтвердить гипотезу («эта функция реально оптимизирована?»), затем удали флаг.

Ярусы JIT (Just-In-Time): Ignition, Sparkplug, Maglev, TurboFan

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

ЯрусРольТрейдофф скорости
IgnitionИнтерпретатор. Компилирует JS в байткод, исполняет его и собирает type feedback (hidden classes, виденные на каждом IC).Мгновенный старт, медленнейшее исполнение.
SparkplugBaseline JIT. Байткод → машинный код почти мгновенно, без оптимизации; ~45% быстрее интерпретатора.Очень быстрая компиляция, скромное ускорение.
MaglevСредний оптимизатор (SSA + feedback). Код намного быстрее Sparkplug, компилируется куда быстрее TurboFan.Сбалансированный.
TurboFanПиковый оптимизатор. Спекулятивный, типоспециализированный машинный код на стабильном feedback; агрессивно встраивает.Медленная компиляция, быстрейшее исполнение.

Скорость TurboFan идёт от спекуляции: он предполагает, что p всегда формы C2, и впекает это допущение в код, отбрасывая все проверки типов. Когда допущение держится — он летит. Когда оно ломается — появляется объект C3, тип слота меняется — спекуляция недействительна, и V8 деоптимизирует: отбрасывает оптимизированный код и роняет функцию обратно в интерпретатор, чтобы заново собрать feedback. Пара деоптов — нормальный разогрев; функция, которая деоптится многократно (деопт-цикл), застряла платя интерпретаторную цену на горячем пути.

Триггеры, которыми ты управляешь:

// 1. Churn hidden class: это место видит C2, потом C2', потом объекты в
//    dictionary mode -> идёт в polymorphic, потом megamorphic, потом деопт.
function sum(o) { return o.x + o.y; }

// 2. Утечка `arguments` из функции раньше убивала оптимизацию;
//    даже сегодня предпочитай rest-параметры — V8 рассуждает о них чисто:
function bad() { return Array.prototype.slice.call(arguments); } // избегай
function good(...args) { return args; }                          // оптимизируемо

Исторически try/catch и злоупотребление arguments блокировали оптимизацию напрочь; современный TurboFan нормально справляется с try/catch, но churn формы и нестабильность типов остаются надёжными триггерами деопта. Держи формы и типы, которые видит горячая функция, постоянными — и она дорастёт до TurboFan и останется там.

Сборка мусора: почему короткоживущие аллокации дёшевы

Куча V8 поколенческая, построена на эмпирическом факте, что большинство объектов умирают молодыми. Новые объекты идут в молодое поколение (маленькое пространство, разбитое на два semi-space, from и to). Сборка там — Scavenge — дешёвый копирующий сборщик: он копирует ещё живые объекты из from в to, затем объявляет всё пространство from свободным одним движением. Поскольку он трогает только выживших, а большинство объектов уже мертвы, Scavenge быстрый и частый.

Объекты, пережившие пару Scavenge, продвигаются (promote) в старое поколение, собираемое Mark-Sweep-Compact: пометь всё достижимое, выметь мёртвое, иногда уплотни против фрагментации. Это дорогой, менее частый сборщик. Вывод для горячего кода: короткоживущая аллокация внутри запроса — временный массив, промежуточный объект — реально дешева, потому что умирает в молодом пространстве и реклаймится быстрым Scavenge. Что бьёт — это продвижение мусора: удержание ссылок, которые держат объекты живыми ровно настолько, чтобы они дошли до старого поколения, где собирать их дорого. Пуль или переиспользуй, только когда профилирование докажет реальную проблему Scavenge/promotion — преждевременный object pooling часто добавляет давления на старое поколение.

Викторина

Горячая функция читает obj.id, и место вызова видело 7 разных hidden class. Каково состояние IC и последствие?

Викторина

Почему `delete obj.temp` на горячем объекте бьёт куда сильнее, чем установка `obj.temp = undefined`?

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

Расставь ярусы исполнения V8 от первого запуска до пиковой оптимизации:

  1. 1 Ignition — интерпретирует байткод, собирает type feedback на каждом IC
  2. 2 Sparkplug — baseline JIT, байткод в машинный код почти мгновенно
  3. 3 Maglev — средний оптимизатор, SSA + feedback, быстро компилирует
  4. 4 TurboFan — пиковый спекулятивный оптимизатор на стабильных формах/типах
Вспомните перед уходом
  1. 01
    Пройди по шагам, почему функция, обрабатывающая пять по-разному устроенных payload, бежит куда медленнее той же логики над одной формой — назови механизмы hidden class, IC и ярусов.
  2. 02
    Почему короткоживущие аллокации в обработчике запросов дёшевы и какой паттерн аллокаций реально бьёт по GC?
Итог

V8 изобретает типы для бестипового JavaScript: каждый объект указывает на hidden class, фиксирующий его точную раскладку, и одинаково построенные объекты — те же свойства, тот же порядок — делят один hidden class, поэтому доступ к свойству становится прямым чтением по смещению. На каждом месте доступа inline cache кэширует виденную форму: monomorphic (одна форма) — быстрый путь, polymorphic (2–4) — терпимо, а megamorphic (больше ~4) — обрыв, где V8 откатывается к обобщённому хэш-поиску. Горячие функции повышают ярус — Ignition интерпретирует и собирает feedback, Sparkplug выдаёт baseline машинный код, Maglev средне оптимизирует, а TurboFan производит пиковый спекулятивный код, предполагающий стабильные формы и типы; когда допущение ломается, он деоптимизирует обратно в интерпретатор. Надёжные триггеры деопта, которыми ты управляешь — churn формы: добавление свойств не по порядку, delete (роняющий объект в медленный dictionary mode) и смешивание типов значений в слоте или массиве. Используй obj.prop = undefined вместо delete, нормализуй payload к одной форме до горячего пути и предпочитай rest-параметры утечке arguments. По памяти: куча поколенческая — короткоживущие аллокации умирают в молодом пространстве и реклаймятся дешёвым частым Scavenge, поэтому мелкие объекты на запрос почти бесплатны; бьёт продвижение мусора в старое поколение, собираемое дорогим Mark-Sweep-Compact. Подсматривай состояние оптимизации через node --allow-natives-syntax и %GetOptimizationStatus только как диагностику, никогда в проде. Типостабильный, одинаково устроенный, мономорфный код — быстрый код. Теперь, когда встретишь горячую функцию, которая проседает в проде, но летит в бенчмарке, первый вопрос: сколько форм видит каждое место вызова и не использует ли какой-то путь delete?

Практика

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

вспомнитьприменитьуглубить0 из 5 завершено

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

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

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

Trademarks belong to their respective owners. Editorial reference only.