Производительность и оптимизация в V8

V8 оптимизирует через JIT и hidden classes. Поведение нелогичное: лишний try ломает оптимизацию, мономорфная функция в 100 раз быстрее полиморфной. Без замеров «оптимизация» делает только хуже.

Принципы V8

  • JIT-компиляция: код сначала интерпретируется (Ignition), горячий — компилируется в машинный (TurboFan)
  • Hidden classes: V8 присваивает форму объекту на основе порядка добавления полей. {a: 1, b: 2} и {b: 2, a: 1} — разные hidden classes
  • Inline caches: V8 кеширует тип аргументов функции. Если функция получала только числа — компилируется под числа. Передача строки → деоптимизация
  • Monomorphic/polymorphic/megamorphic: 1 тип → быстро, 2–4 → медленнее, 5+ → совсем плохо

Как правильно мерить

// ❌ console.time — грубый замер, не годится для микробенчмарков
console.time('x'); for (...) {}; console.timeEnd('x');

// ✅ process.hrtime.bigint — наносекунды
const t = process.hrtime.bigint;
for (let i = 0; i < 1_000_000; i++) work;
console.log(Number(process.hrtime.bigint - t) / 1e6, 'ms');

// ✅ perf_hooks
const { performance } = require('perf_hooks');
const t = performance.now();
// ...
const dt = performance.now() - t;

Правила V8-дружелюбного кода

// 1. Не меняй форму объекта в hot path
function bad() {
  const o = { a: 1 };
  o.b = 2;     // hidden class сменился
  o.c = 3;     // ещё раз
}
function good() {
  return { a: 1, b: 2, c: 3 }; // одна форма
}

// 2. Мономорфные функции — один тип аргументов
function fast(n) { return n * 2; }  // только числа
fast(1); fast(2); fast(3);          // ✅ компилируется в умножение int

fast('x');                          // ❌ полиморфно, деоптимизация

// 3. Не try/catch вокруг hot loop — раньше ломал оптимизацию, в новых V8 норм, но всё равно overhead

// 4. Возвращай результат — без return компилятор может вырезать
function loop() {
  let sum = 0;
  for (let i = 0; i < 1e6; i++) sum += i;
  return sum;  // ✅
  // если не вернуть — V8 видит мёртвый код и удаляет
}

// 5. Прогрев функции перед замером — даёт V8 шанс оптимизировать
for (let i = 0; i < 1000; i++) target(input);  // warmup
const t = performance.now();
// измерение

Подводные камни

  • «Кэшировать length» — бессмысленно, V8 это делает сам
  • Замыкания то быстрые, то медленные — зависит от inlining; не оптимизируй без замера
  • Object.create(null) — объект без прототипа, быстрее {} для map-use, нет коллизий с __proto__
  • Бенчмарки в naive cycle обманывают: V8 видит что результат не используется → вырезает
  • Прогрев влияет: первый запуск всегда медленнее в 5–10 раз
  • Inline caches очищаются при изменении hidden class — массовая деопт

--allow-natives-syntax (debug)

node --allow-natives-syntax script.js
// Доступны спецфункции
%OptimizeFunctionOnNextCall(fn);   // принудительная оптимизация
%GetOptimizationStatus(fn);        // битовая маска статуса
%NeverOptimizeFunction(fn);

🎓 Источники

  • 🎓 [Измерение производительности кода и оптимизация] · 2018-10-24 · YouTube · [Marp](../../../Documents/TimurShemsedinov/2018-10-24 — Измерение производительности кода и оптимизация в JavaScript и Node.js (sanq2X7Re8o).md)
    • Тезисы: непредсказуемая производительность V8, console.time грубый, hrtime/perf_hooks точно, прогрев функции, --allow-natives-syntax, TurboFan, OptimizeFunctionOnNextCall, инстанцирование через замыкание vs class, инлайнинг, миллион итераций — нужно стабилизировать тип; обмануть компилятор для замера через возврат результата
    • Цитата: «Без замеров оптимизация делает только хуже — половина общеизвестных советов мифы»
  • 🎓 [Мономорфный и полиморфный код, скрытые классы] · 2019-10-29 · YouTube
    • Тезисы: монoморфные функции компилируются в специализированный код, добавление 2-го типа = деопт, формы объектов и inline cache

См. также