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

Капстоун: оптимизируем горячий путь

Один медленный обработчик запросов — и в игре каждый слой движка. Профилируем, читаем трейсы деопта и IC, чиним форму, боксинг, давление GC и затор микрозадач — затем доказываем выигрыш. Весь трек, применённый к одной функции.

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

Эндпоинт скоринга, раньше тянувший 40k запросов в секунду, теперь еле держит 6k, и единственным «изменением» был рефакторинг, который «просто прибрал построение объектов». В коде ничего не выглядит неправильным. Всё, что вы изучили в этом треке, сейчас сойдётся на одной функции из 30 строк — потому что движок делает пять разных медленных вещей разом, и вы можете назвать и починить каждую.

Метод — раньше, чем функция

Оптимизация без измерения — это суеверие. Цикл всегда один: профилируй, чтобы найти доминирующую стоимость, назови механизм, сделай одно изменение, перезамерь. Этот трек дал вам словарь для шага «назови механизм» — именно здесь большинство инженеров застревают, гадая вместо чтения трейса. К концу этого капстоуна вы назовёте и почините каждую из пяти проблем — и будете точно знать, за каким инструментом тянуться в следующий раз.

Вот регрессировавший обработчик. Читайте его так, как читал бы движок.

function scoreBatch(rows) {
  const out = [];
  for (const row of rows) {
    const e = {};
    e.id = row.id;
    if (row.name) e.name = row.name;       // условное поле
    e.score = row.weight * 1e9 + row.bonus; // выходит за диапазон Smi
    if (row.flagged) e.flag = true;          // позднее условное поле
    out.push(e);
    log(`scored ${e.id}`);                    // синхронный console в горячем цикле
  }
  return out;
}

Слой 1 — форма (юниты 02–03)

--trace-ic показывает, что нижестоящая точка доступа, читающая .score, становится megamorphic (мегаморфной — IC, видящий более двух разных форм объекта и вынужденный откатиться к медленному поиску). Причина — условные поля: name и flag добавляются лишь иногда, поэтому объекты идут по разным веткам дерева переходов и приходят в разные hidden class (юнит 03). IC у потребителя видит пять и более map и откатывается к общему поиску — десятки циклов вместо одного. Когда видите megamorphic или GENERIC в трейсе, первый вопрос такой: какая точка вызова производит объекты с несогласованными формами?

Фикс: инициализируй каждое свойство безусловно, в фиксированном порядке, чтобы все объекты разделяли один Map.

const e = {
  id: row.id,
  name: row.name ?? null,
  score: 0,                 // задаётся ниже
  flag: row.flagged === true,
};

Слой 2 — боксинг и деопт (юниты 02, 04)

--trace-deopt показывает, что scoreBatch деоптимизируется на guard CheckSmi. row.weight * 1e9 выходит за диапазон Smi (Small Integer, целое число, умещающееся в указатель без выделения памяти, ±2³¹), поэтому результат становится HeapNumber (объект-обёртка в куче, требующий отдельной аллокации) — боксом в куче (юнит 02). TurboFan спекулировал Smi по ранней обратной связи; первое переполнение проваливает guard и вызывает деопт, а поскольку это повторяется каждый батч, получается деопт-цикл (юнит 04): оптимизировать → деопт → переоптимизировать, никогда не оставаясь быстрым.

Фикс: перестань притворяться, что значение целое. Считай в double с самого начала, чтобы оптимизатор специализировался на Float64 и никогда не ставил guard на Smi — или, если потребитель численно-тяжёлый, аккумулируй скоры в Float64Array вместо полей объекта, устраняя бокс целиком.

Стоимость каждого механизма на этом пути
Megamorphic-загрузка свойства против monomorphic
~10–50×
Деопт-цикл (оптимизация/деопт churn)
остаётся интерпрет.
Бокс HeapNumber против Smi
alloc + deref против 1 instr
Minor GC от аллокации на строку
доли мс, но часто
Синхронный log() в цикле на 100k
блокирует ход цикла

Слой 3 — churn у GC (юнит 06)

Даже с починенными формами аллокация одного объекта out на строку заливает new space; bump-pointer аллокатор заполняет semi-space и запускает Scavenge (юнит 06) куда чаще необходимого. Minor GC дёшев поодиночке, но при таком объёме — это смерть от тысячи порезов, а выжившие промоутятся в old space, в итоге форсируя major GC.

Фикс: уменьши аллокацию. Если out идёт в редьюсер, сворачивай строки напрямую, не материализуя массив объектов; если массив нужен, преаллоцируй (new Array(rows.length) здесь годится, потому что вы заполняете каждый слот, сохраняя массив PACKED). Меньше, дольше живущих аллокаций лучше, чем шторм короткоживущих.

Слой 4 — планирование (юнит 07)

Вызов log() — синхронный ввод-вывод внутри цикла. Помимо собственной стоимости, в контексте запроса он переплетается с циклом событий: поток синхронной работы на стеке откладывает microtask checkpoint и морит голодом другие обработчики (юнит 07). Фикс — не «сделать логирование асинхронным», а не логировать на строку на горячем пути. Агрегируй и эмить один раз либо сэмплируй.

Слой 5 — докажи, потом защити (юнит 08)

Замерь переписанную версию против оригинала на разогретом коде, потребляя результат, чтобы dead-code elimination не удалил твой бенчмарк (юнит 08), держа аллокацию вне замеряемой области и записав движок. Затем добавь в CI микробенчмарк, утверждающий нижнюю границу пропускной способности, плюс юнит-тест, считающий число различных сигнатур Object.keys из scoreBatch и падающий, если оно превысит один. Регрессия формы, с которой начался инцидент, теперь не сможет снова уехать тихо.

Викторина

Почему чинить нестабильность hidden class раньше, чем гоняться за деоптом или churn у GC?

Викторина

`row.weight * 1e9 + row.bonus` вызвал деопт-цикл. Каков точный механизм?

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

Расставьте метод оптимизации для этого инцидента.

  1. 1 Профилировать, чтобы найти доминирующую стоимость (--prof, --trace-deopt, --trace-ic, heap snapshot)
  2. 2 Назвать механизм движка за ней (форма / боксинг / GC / планирование)
  3. 3 Сделать минимальное изменение под этот механизм
  4. 4 Перезамерить на разогретом коде с потреблением результата
  5. 5 Добавить регресс-гейт, чтобы выигрыш не откатился тихо
Ещё практика

Примените это к своему коду: возьмите одну функцию, которую профайлер отмечает как горячую. Прежде чем её трогать, запишите, какой из пяти слоёв вы ожидаете увидеть стоимостью, затем запустите --trace-deopt и heap snapshot для проверки. Большинство инженеров ошибаются в том, какой слой доминирует — именно этот разрыв между интуицией и трейсом и закрывает этот трек.

Вспомните перед уходом
  1. 01
    Обработчик медленный. Пройдите полный метод диагностики от начала до конца.
  2. 02
    Почему условная инициализация свойств регрессирует горячего потребителя и каков фикс?
  3. 03
    Когда переписывание горячего пути на WASM/Rust действительно оправдано?
Итог

Этот капстоун положил весь трек на одну функцию. Регрессировавший обработчик скоринга был медленным по пяти перекрывающимся причинам, каждую из которых можно назвать из этого трека: условная инициализация свойств разнесла объекты по разным hidden class и сделала IC потребителя megamorphic (юниты 02–03); целое, вышедшее за диапазон Smi, стало HeapNumber и вызвало деопт-цикл TurboFan (юниты 02, 04); аллокация объекта на строку гоняла new space в частые Scavenge (юнит 06); синхронный log на итерацию стопорил microtask checkpoint (юнит 07). Метод неизменен: профилируй, чтобы найти доминирующую стоимость, назови точный механизм, измени одно, перезамерь на разогретом коде с потреблением результата и закрой выигрыш регресс-гейтом (юнит 08). Чини форму первой, ведь стабильная форма — предусловие для того, чтобы оптимизатор давал заслуживающий доверия код и заслуживающие доверия замеры. Теперь, встречая медленный обработчик, вы первым делом откроете трейс, точно назовёте механизм и будете чинить по одному слою — потому что именно это превращает гадание в инженерию.

Практика

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

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

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

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

Примени это

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

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

Trademarks belong to their respective owners. Editorial reference only.