Победы в реальном мире
Пять кейсов, связывающих весь трек воедино: путь приёма JSON, ставший megamorphic; числовой конвейер, упакованный в HeapNumber; churn GC от аллокаций на запрос; цикл деопта от полиморфных аргументов; и голодание микрозадач, стопорящее рендер — каждый как симптом
Всё в этом треке сходится здесь. Каждый из этих пяти инцидентов реален по форме: маленькое изменение, последствие на уровне 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 Профилировать, чтобы найти единственную доминирующую стоимость
- 2 Подтвердить механизм флагом трассировки или %DebugPrint
- 3 Исправить данные или поток управления, его вызвавшие
- 4 Проверить страженым бенчмарком и держать страж регрессии в CI
▸Почему это работает
Почему фикс всегда выше движка: движок — это уже оптимизирующий компилятор мирового класса. Когда он производит медленный код, он добросовестно компилирует формы и типы, которые вы ему вручили — разные формы, упакованные числа, полиморфную диспетчеризацию, churn аллокаций. Сделать движок умнее нельзя, но сделать его вход проще — можно. Каждый кейс выше чинит вход: стабильную схему, типизированный массив, меньше аллокаций, одну форму аргумента, точку уступки. Вот почему те же пять механизмов покрывают подавляющее большинство реальной работы над производительностью JS.
- 01Сопоставьте каждый из пяти канонических симптомов с его механизмом V8 и фиксом.
- 02Сформулируйте фреймворк решений и почему каждый шаг именно в этом порядке.
- 03Почему фикс почти любой проблемы производительности V8 выше движка, в модели данных?
Этот синтез-урок прогоняет пять реальных по форме инцидентов через один цикл. Путь приёма JSON стал megamorphic, потому что объекты строились в порядке ключей входа, увиден через —trace-ic, исправлен построением по фиксированной схеме. Числовой конвейер был в 5x медленнее, потому что значения упаковались в HeapNumber (число, аллоцированное в куче, в отличие от Smi — малого целого, хранящегося прямо в указателе), увиден через %DebugPrint и время GC, исправлен через Float64Array. Паузы GC пришли от churn аллокаций на запрос, увиден на таймлайне кучи, исправлен переиспользованием и меньшим числом аллокаций. Цикл деопта пришёл от полиморфных аргументов, увиден через повторяющийся eager-деопт —trace-deopt, исправлен мономорфизацией входа. А заморозка UI пришла от голодания микрозадач, увиден как гигантская очередь микрозадач, опустошающаяся до рендера, исправлен уступкой макрозадаче. Каждый фикс жил в данных или потоке управления, никогда в движке. Фреймворк решений обобщает их: сначала профиль, чтобы найти доминирующую стоимость, назвать механизм трассировкой, исправить вход, проверить страженым бенчмарком и держать страж регрессии — и чинить только доминирующую стоимость, потому что движок уже настолько умён, насколько бывает. Теперь, когда инцидент производительности окажется на вашем столе, вы сопоставите симптом с одним из пяти механизмов ещё до того, как откроете редактор — и будете знать: фикс в данных, не в движке.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
встречается в6
- Собираем воедино: бэкенд — это система, а не стопкаjunior
- Проследи один запрос: каждый юнит на одном пути кодаmiddle
- Когда сбои складываются: каскад, что ни один юнит не мог показатьsenior
- Видеть систему: RED-метрики, хвост p99 и состояние breakersenior
- Сервис под перегрузкой: сброс нагрузки и грациозная деградацияsenior
- Готовность к продакшену: чеклист запуска, что и есть весь трекsenior
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.
Примени это
Примени этот урок в реальном проекте.