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

Замыкания, feedback-векторы и полиморфизм точки вызова

У каждого замыкания свой feedback vector, но байт-код общий через SharedFunctionInfo. Вызов через переменную образует call IC: monomorphic для одной callee, megamorphic для многих. Горячий диспетчер с многими идентичностями функций нельзя заинлайнить.

JSE Middle ◷ 15 min
Уровень
ОсновыJuniorMiddleSenior

Вы построили чистый диспетчер: один горячий цикл, вызывающий handler(event), где handler — это какой угодно из сорока колбэков, который вернул реестр. Это элегантно — и медленно, медленнее, чем сорок отдельных циклов. Причина не в вашей логике; причина в том, что V8 следит, какая функция проходит через эту единственную точку вызова, и, увидев сорок разных идентичностей функций, сдаётся в попытке предсказать и заинлайнить, откатываясь к общему косвенному вызову на самой горячей строке вашей программы.

Общий код, приватная обратная связь

Почему одно и то же тело функции иногда оптимизируется хорошо в одном месте и остаётся медленным в другом? Ответ в том, что каждый экземпляр замыкания ведёт свою приватную запись того, что видел во время работы — и именно по ней TurboFan компилирует. Когда вы создаёте много экземпляров одной функции — каждую стрелку, возвращённую из фабрики, каждое замыкание, запушенное в цикле — они не несут каждый свою копию байт-кода. Они делят один SharedFunctionInfo (SFI): байт-код, ScopeInfo (информацию об областях видимости), метаданные исходника. Что каждый экземпляр замыкания несёт отдельно — это его feedback vector (вектор обратной связи): массив на экземпляр, куда движок записывает наблюдения за типами во время работы для точек вызова и доступов к свойствам этого экземпляра.

Это разделение важно для производительности. Общий SFI держит память низкой и позволяет оптимизатору скомпилировать тело один раз. Feedback vector на экземпляр означает, что два замыкания из одной фабрики могут специализироваться по-разному, если им скармливают разные типы — и означает, что замыкание начинает собирать полезную обратную связь лишь после того, как этот экземпляр выполнился достаточно раз.

Точки вызова тоже образуют inline cache

Вы уже знаете, что чтения свойств образуют inline cache, ключуемые по hidden class (см. урок про mono/poly/mega в юните о hidden-классах). Вызовы делают то же. Выражение вызова вида fn(x) — где fn это переменная или свойство, держащее функцию — это точка вызова со своим IC-слотом в feedback vector. Этот слот фиксирует, какая функция (какая идентичность callee) здесь вызывалась:

  • Monomorphic — точка вызывала только одну идентичность функции. V8 может спекулировать жёстко: он знает точную цель и может заинлайнить её тело.
  • Polymorphic — небольшое число (до ~4) различных callee. V8 держит маленькую таблицу и ветвится между ними.
  • Megamorphic — слишком много различных callee. V8 перестаёт отслеживать идентичности и выпускает общий косвенный вызов через то, что fn держит сейчас.

Почему инлайнинг — это вся игра

Инлайнинг — это оптимизация, которая открывает остальные. Когда TurboFan инлайнит monomorphic callee в вызывающего, он затем может свернуть константы через границу, устранить накладные расходы на передачу аргументов и специализировать слитый код по наблюдаемым типам. Megamorphic точка вызова блокирует всё это: TurboFan не знает, какая функция выполнится, поэтому обязан выпустить косвенный вызов — положить аргументы, прыгнуть через указатель на функцию, без инлайнинга, без оптимизации через границу. На горячем цикле диспетчеризации эти расходы ложатся на каждую итерацию.

// Megamorphic: одна точка, много идентичностей callee.
const registry = { click: onClick, hover: onHover, /* …ещё 38 */ };
function dispatch(type, ev) {
  return registry[type](ev);   // эта точка видит десятки идентичностей -> megamorphic
}

// Дружелюбно к monomorphic: маршрутизируй на несколько стабильных, отдельно скомпилированных путей.
function dispatchFast(type, ev) {
  switch (type) {
    case 'click': return onClick(ev);  // у каждой точки вызова ОДНА идентичность
    case 'hover': return onHover(ev);  // monomorphic, инлайнится
    default:      return onOther(ev);
  }
}

Версия со switch имеет много точек вызова, каждая monomorphic, вместо одной megamorphic точки. Каждая становится инлайнимой по отдельности. «Элегантная» версия с единой диспетчеризацией концентрирует всё разнообразие идентичностей на одной строке и отравляет её.

Функции высшего порядка: держите колбэки единообразными

Тот же эффект бьёт по map/filter/reduce и любому хелперу высшего порядка. Общий хелпер, вызываемый каждый раз с другим колбэком, имеет внутри одну точку вызова (cb(item)), которая видит много идентичностей — megamorphic. Он всё ещё работает, но не может заинлайнить колбэк. Паттерны, которые держат его быстрым:

  • Передавайте ту же идентичность колбэка, когда можете, а не свежую инлайн-стрелку на каждый вызов. Литерал arr.map(x => f(x)) создаёт новую идентичность функции каждый вызов; arr.map(f) переиспользует одну.
  • Специализируйте горячие хелперы. По-настоящему горячая свёртка над числами может быть рукописным циклом с заинлайненной операцией, а не общим reduce(fn), чья точка fn(acc, x) megamorphic.
  • Держите формы аргументов единообразными тоже. Даже точки с monomorphic callee деоптятся, если аргументы дико меняют hidden class; важны и единообразные callee, и единообразные формы аргументов.
Состояния IC точки вызова и инлайнинг
Различных callee: monomorphic
1
Различных callee: polymorphic
2–4
Различных callee: megamorphic
~5+
Monomorphic вызов
инлайнится
Megamorphic вызов
косвенный, не инлайн.
Общее на семейство функций
SharedFunctionInfo
Приватное на экземпляр замыкания
feedback vector
Викторина

Одна горячая строка `registry[type](ev)` диспетчеризует на ~40 разных функций. В какое состояние IC придёт эта точка вызова и что сделает TurboFan?

Викторина

Два замыкания пришли из одной фабрики. Что они делят, а что приватно для каждого?

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

Расставьте по порядку, что происходит с одной точкой вызова, когда через неё со временем проходит всё больше различных идентичностей функций.

  1. 1 Первый вызов: IC-слот неинициализирован, фиксирует первую идентичность callee
  2. 2 Та же идентичность повторяется: точка monomorphic, TurboFan может заинлайнить callee
  3. 3 Появляется ещё несколько идентичностей: точка становится polymorphic с маленькой таблицей диспетчеризации
  4. 4 Через неё проходит много идентичностей: точка становится megamorphic — общий косвенный вызов, без инлайнинга
Почему это работает

Это можно наблюдать напрямую. Запустите с --trace-ic и отфильтруйте CallIC; деградирующий диспетчер показывает переходы 0 -> 1 -> P -> N (неинициализирован → monomorphic → polymorphic → megamorphic). Подтвердите потерю инлайнинга через --trace-turbo-inlining: monomorphic callee появляется как заинлайненный, megamorphic точка — нет. Для быстрого локального эксперимента %GetOptimizationStatus(fn) под --allow-natives-syntax в d8 скажет, оптимизировался ли сам диспетчер.

Вспомните перед уходом
  1. 01
    Что делят соседние замыкания, а что приватно для каждого, и почему это важно?
  2. 02
    Каковы состояния IC точки вызова и что каждое значит для инлайнинга?
  3. 03
    Как не дать горячему диспетчеру или хелперу высшего порядка стать megamorphic?
Итог

Много экземпляров одной функции делят единственный SharedFunctionInfo — байт-код, ScopeInfo, метаданные — но каждый экземпляр замыкания несёт свой feedback vector, фиксирующий наблюдения во время работы на его точках вызова и доступах к свойствам, поэтому соседние замыкания специализируются независимо и не объединяют обратную связь. Выражение вызова, чья callee живёт в переменной или свойстве, само является точкой вызова с IC-слотом, ключуемым по идентичности callee: monomorphic, когда видна лишь одна идентичность (TurboFan может заинлайнить callee), polymorphic для нескольких (маленькая таблица диспетчеризации) и megamorphic для многих (общий косвенный вызов, который нельзя заинлайнить). Поскольку инлайнинг — это то, что открывает сворачивание констант и специализацию через границу, megamorphic вызов на горячей строке диспетчеризации — настоящий обрыв производительности: накладные расходы косвенного вызова ложатся на каждую итерацию. Лекарства концентрируют разнообразие идентичностей прочь от горячей строки — заменить одну многоцелевую диспетчеризацию несколькими одноцелевыми точками через switch, передавать стабильные идентичности колбэков в хелперы высшего порядка, а не свежие инлайн-стрелки, специализировать по-настоящему горячие свёртки как явные циклы и держать формы аргументов единообразными. Это взгляд со стороны замыканий и областей на ту же машинерию mono/poly/mega, которую юнит о hidden-классах ввёл для доступа к свойствам, теперь применённую к тому, какая функция проходит через точку вызова — наблюдаемо через —trace-ic и —trace-turbo-inlining. Теперь, когда горячий цикл диспетчеризации работает медленнее ожидаемого, проверьте, сколько различных идентичностей функций проходит через его единственную точку вызова — если больше четырёх, у вас megamorphic-бутылочное горлышко и switch, который нужно написать.

Практика

Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем 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.