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

Победы в реальном мире

Пять кейсов, связывающих весь трек воедино: путь приёма JSON, ставший megamorphic; числовой конвейер, упакованный в HeapNumber; churn GC от аллокаций на запрос; цикл деопта от полиморфных аргументов; и голодание микрозадач, стопорящее рендер — каждый как симптом

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

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

Кейс 1 — приём JSON, ставший megamorphic

Симптом: p99 по CPU у эндпоинта приёма JSON подскочил в ~30 раз после рефакторинга, без изменения объёма запросов. Как вы это увидели: node --cpu-prof показал время, занятое общим стабом загрузки свойства, а не GC или I/O; --trace-ic сообщил нижестоящую точку доступа строителя результата как MEGAMORPHIC с десятками различных map. Механизм: рефакторинг строил каждый объект результата, итерируя ключи входа (for (const k of Object.keys(input)) out[k] = ...). Входной JSON приходит с ключами в произвольном порядке, поэтому каждый запрос шёл по другому пути переходов hidden class, давая новую форму почти каждый раз. (Это ровно то расхождение hidden class, о котором предупреждал трек.) Фикс: строить по фиксированной схеме, а не по порядку входа — out = { id: input.id, name: input.name ?? null, ... } со всеми ключами в постоянном порядке; для действительно динамических наборов ключей хранить в Map. Результат: точка доступа вернулась к monomorphic, p99 упал к базовому уровню.

Кейс 2 — числовой конвейер, упакованный в HeapNumber

Симптом: проход агрегации метрик был в 5 раз медленнее прикидки на салфетке, с высоким временем GC. Как вы это увидели: отчёт --prof показал тяжёлую аллокацию и GC; %DebugPrint рабочего массива показал PACKED_DOUBLE_ELEMENTS, а аккумуляторы были упакованными HeapNumber. Механизм: значения пересекли диапазон Smi, и конвейер хранил промежуточные числа в обычных массивах/объектах, поэтому каждое значение стало аллоцированным в куче HeapNumber — одна аллокация и одно разыменование на число, плюс давление GC. Фикс: перенести горячие числовые буферы в Float64Array (и держать целые id в диапазоне Smi). Типизированные массивы обходят отслеживание element kind и хранят сырые double inline — без боксинга, без аллокации на элемент. Результат: большое ускорение и время GC близко к нулю в замеряемой зоне.

Кейс 3 — паузы GC от churn аллокаций на запрос

Симптом: периодические всплески задержки, коррелирующие с событиями GC; пропускная способность в среднем нормальна, p99 уродлив. Как вы это увидели: панель Performance DevTools (или --prof) приписала всплески GC, а таймлайн кучи показал пилообразный рост — новый всплеск короткоживущих объектов, аллоцированных на запрос. Механизм: обработчик аллоцировал много временных объектов на запрос, заполняя молодое поколение и вызывая частые minor GC (и иногда major). (См. сборку мусора о поколенческой модели стоимости.) Фикс: сократить аллокации на горячем пути — переиспользовать буферы/объекты (небольшой пул), вынести инвариантные аллокации из цикла на запрос и избегать создания замыканий на вызов. Результат: меньше и короче паузы GC; всплески p99 разгладились.

Кейс 4 — цикл деопта от полиморфных аргументов

Симптом: горячая функция была быстрой, затем медленной по расписанию — подпись «быстро-потом-медленно». Как вы это увидели: --trace-opt --trace-deopt, отфильтрованные по функции, показали её оптимизацию, затем eager deoptimizing ... reason: wrong map на том же смещении, затем переоптимизацию — повторяющиеся. Механизм: функция получала объекты нескольких форм (полиморфный аргумент), поэтому спекуляция TurboFan ломалась каждый раз, когда приходила неожиданная форма, вызывая цикл деопта. Фикс: мономорфизировать вход — нормализовать всех вызывающих к одной форме до горячей функции или разбить на варианты под конкретные формы. Результат: цикл деопта прекратился; функция осталась в оптимизированном коде.

Кейс 5 — голодание микрозадач, стопорящее рендер

Симптом: UI замирал на сотни миллисекунд, хотя ни одна отдельная функция не была медленной. Как вы это увидели: панель Performance показала одну длинную задачу с огромной очередью микрозадач, опустошающейся до следующего рендера; отрисовка не происходила, пока очередь не опустеет. Механизм: рекурсивная цепочка промисов продолжала ставить микрозадачи в очередь, а поскольку очередь микрозадач опустошается исчерпывающе до того, как цикл уступит, рендеринг и ввод голодали. (См. микрозадачи против макрозадач.) Фикс: разбить работу на макрозадачи — уступать через setTimeout(…, 0) / scheduler.postTask (или await макрозадачи) между чанками, чтобы цикл событий мог отрисовать и обработать ввод. Результат: UI остался отзывчивым; работа завершилась чанками между кадрами.

Симптом → механизм, с одного взгляда
p99 CPU вырос в 30x, горяч общий стаб загрузки
megamorphic IC (форма)
В 5x медленно, высокая аллокация/GC на числах
боксинг HeapNumber
Периодические всплески, пилообразная куча
GC churn (аллокация)
Быстро-потом-медленно по расписанию
цикл деопта (полиморфизм)
Заморозка UI, нет одной медленной функции
голодание микрозадач
Место фикса, в каждом кейсе
данные / поток, не движок

Фреймворк решений

Заметьте дисциплину за всеми пятью: никогда не начинай с догадки. Сначала профиль, чтобы найти единственную доминирующую стоимость — чинить вторую по величине стоимость, пока доминирует первая, это потраченная впустую работа. Назови механизм флагом трассировки или %DebugPrint, чтобы фикс целил в реальную причину, а не в симптом. Исправь данные или поток управления — стабильную форму, типизированный массив, меньше аллокаций, мономорфные входы, точку уступки — потому что причина почти никогда не в движке. Затем проверь страженым бенчмарком (честным стендом из прошлого урока) и держи страж регрессии в CI, чтобы победа тихо не размылась в следующем рефакторинге.

Викторина

Горячая функция быстра какое-то время, затем медленна, по регулярному расписанию; --trace-deopt показывает повторяющийся eager-деопт на том же смещении. Что это и каков фикс?

Викторина

Прежде чем чинить любую проблему производительности, какой первый шаг во фреймворке решений?

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

Расставьте фреймворк решений в применении к реальному инциденту производительности.

  1. 1 Профилировать, чтобы найти единственную доминирующую стоимость
  2. 2 Подтвердить механизм флагом трассировки или %DebugPrint
  3. 3 Исправить данные или поток управления, его вызвавшие
  4. 4 Проверить страженым бенчмарком и держать страж регрессии в CI
Почему это работает

Почему фикс всегда выше движка: движок — это уже оптимизирующий компилятор мирового класса. Когда он производит медленный код, он добросовестно компилирует формы и типы, которые вы ему вручили — разные формы, упакованные числа, полиморфную диспетчеризацию, churn аллокаций. Сделать движок умнее нельзя, но сделать его вход проще — можно. Каждый кейс выше чинит вход: стабильную схему, типизированный массив, меньше аллокаций, одну форму аргумента, точку уступки. Вот почему те же пять механизмов покрывают подавляющее большинство реальной работы над производительностью JS.

Вспомните перед уходом
  1. 01
    Сопоставьте каждый из пяти канонических симптомов с его механизмом V8 и фиксом.
  2. 02
    Сформулируйте фреймворк решений и почему каждый шаг именно в этом порядке.
  3. 03
    Почему фикс почти любой проблемы производительности V8 выше движка, в модели данных?
Итог

Этот синтез-урок прогоняет пять реальных по форме инцидентов через один цикл. Путь приёма JSON стал megamorphic, потому что объекты строились в порядке ключей входа, увиден через —trace-ic, исправлен построением по фиксированной схеме. Числовой конвейер был в 5x медленнее, потому что значения упаковались в HeapNumber (число, аллоцированное в куче, в отличие от Smi — малого целого, хранящегося прямо в указателе), увиден через %DebugPrint и время GC, исправлен через Float64Array. Паузы GC пришли от churn аллокаций на запрос, увиден на таймлайне кучи, исправлен переиспользованием и меньшим числом аллокаций. Цикл деопта пришёл от полиморфных аргументов, увиден через повторяющийся eager-деопт —trace-deopt, исправлен мономорфизацией входа. А заморозка UI пришла от голодания микрозадач, увиден как гигантская очередь микрозадач, опустошающаяся до рендера, исправлен уступкой макрозадаче. Каждый фикс жил в данных или потоке управления, никогда в движке. Фреймворк решений обобщает их: сначала профиль, чтобы найти доминирующую стоимость, назвать механизм трассировкой, исправить вход, проверить страженым бенчмарком и держать страж регрессии — и чинить только доминирующую стоимость, потому что движок уже настолько умён, насколько бывает. Теперь, когда инцидент производительности окажется на вашем столе, вы сопоставите симптом с одним из пяти механизмов ещё до того, как откроете редактор — и будете знать: фикс в данных, не в движке.

Практика

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

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

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

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

Примени это

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

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

Trademarks belong to their respective owners. Editorial reference only.