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

Деревья переходов, deprecation и миграция

Добавление свойства следует по ребру перехода или создаёт его к новому Map; дерево ветвится по порядку добавления, по смене elements kind и по обобщению representation поля. Когда representation должен расшириться по всем экземплярам, старый Map помечается deprecated

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

Вы уже знаете из обзора, что «порядок свойств важен». Но вот что удивляет даже сеньоров: Map может стать deprecated — помеченным мёртвым, — пока объекты всё ещё указывают на него, и V8 тихо переписывает эти объекты на более новую раскладку при следующем обращении к ним. Присваивание 2.5 полю, которое раньше держало целые, может незаметно перестроить форму каждого объекта, разделяющего её. Этот урок — о том, как граф форм растёт, мутирует и заживает.

Переходы — это рёбра в общем дереве

Вспомним однострочную версию из browser/03-v8-internals/03-hidden-classes: добавление x затем y приходит в другой лист, чем добавление y затем x, так что порядок важен. Теперь механизм. Каждый Map хранит forward-TransitionArray с ключом {имя свойства, attributes}. Добавление свойства:

  1. Ищет ребро {имя, attributes} в TransitionArray текущего Map.
  2. Если ребро есть, следует по нему — никакой аллокации, вы переиспользуете существующий дочерний Map и его (общий) DescriptorArray.
  3. Если ребра нет, создаёт новый дочерний Map (расширяя DescriptorArray родителя на один дескриптор), записывает ребро и направляет объект на новый Map.

Поскольку дерево разделяется всем изолятом, второй объект, идущий по пути, бесплатен — он следует по рёбрам, созданным первым объектом. Поэтому строить объекты одинаково дёшево, а строить их в перемешанных порядках — нет: перемешанные порядки никогда не находят ребро заново, так что каждый объект вынуждает свежий Map.

const a = {}; a.x = 1; a.y = 2;   // empty -> +x -> +x,y   (создаёт 2 ребра)
const b = {}; b.x = 3; b.y = 4;   // empty -> +x -> +x,y   (следует по ним; 0 новых Map)
const c = {}; c.y = 5; c.x = 6;   // empty -> +y -> +y,x   (новая ветка; 2 новых Map)

Ещё три способа перейти (не только добавление свойств)

Обзор намекал, что переходы приходят лишь от добавления свойств. Это не так. Есть три других триггера, и они кусаются в проде:

  • Смена elements kind. Запись float’а в массив PACKED_SMI_ELEMENTS или создание дырки переводит Map массива в более широкий elements kind (PACKED_SMI → PACKED_DOUBLE → PACKED_ELEMENTS и HOLEY-варианты). Односторонне, как и дерево свойств.
  • Обобщение representation поля. Это главное. Поле стартует узким — скажем, Smi. Если позже какой-то экземпляр сохранит значение, которое не помещается (2.5 или строку), representation поля должен расшириться до Double или Tagged. Но representation — это свойство Map, общее для всех экземпляров. Так что V8 не может изменить один объект; он должен обобщить Map.
  • Потеря constness. Поле const, пока каждый экземпляр присваивал ему одно и то же значение. Первый экземпляр, присвоивший другое значение, переводит дескриптор в mutable, что само по себе переход (скомпилированный код, заинлайнивший константу, должен быть инвалидирован).

Все три «не-добавляющих» триггера объединяет одно: изменение того, что поле может держать, вынуждает менять общий Map, а не отдельные объекты. Без этого понимания можно часами грешить на GC или сеть, когда настоящая причина — одно присваивание float’а двумя фреймами выше.

Deprecation и миграция: как V8 расширяет общее поле

Вот тонкая часть. Пусть 10000 объектов разделяют Map M1, где поле temp имеет representation Smi. Теперь один объект делает obj.temp = 98.6. Поле должно стать Double — но для всей формы, потому что Map общий. V8 не может переписать 10000 объектов в этот миг (у него даже нет их списка). Вместо этого:

  1. Он создаёт новый Map M2, идентичный M1, кроме того, что tempDouble (более общий representation). Разделение происходит в нужном месте дерева, потомки перенаправляются.
  2. Он помечает M1 как deprecated — мёртвый Map, который больше не следует использовать. Существующие объекты пока всё ещё указывают на M1.
  3. Миграция ленива. В следующий раз, когда V8 трогает один из этих объектов по медленному пути (доступ к свойству с промахом inline cache, %DebugPrint и т. п.), он замечает, что Map объекта deprecated, идёт к соответствующему не-deprecated Map (M2) по back-pointer’ам из урока 01, переписывает раскладку объекта на месте (упаковывая целое в double-слот) и обновляет указатель map объекта. Это путь «MigrationMarker» / миграции.

Так что единственное obj.temp = 98.6 может начать deprecation формы, используемой тысячами объектов, каждый из которых платит небольшую стоимость миграции при следующем доступе. Если это поле колеблется между целым и float’ом, можно повторно churn’ить deprecation — настоящий, трудно замечаемый источник деоптов.

Механика переходов (V8)
Ключ поиска перехода
{имя свойства, attributes}
Повторный проход существующего пути
0 новых Map — рёбра следуются
Порядок расширения representation
Smi → Double → Tagged (односторонне)
Старый Map после обобщения
помечен deprecated
Миграция экземпляра
лениво, при следующем медленном доступе
Поиск живого Map
идти по back-pointer'ам к не-deprecated потомку
Трассировка переходов
--trace-maps в d8 / node

Почему перемешанные порядки ключей патологичны

Сложите кусочки. Функция, строящая объекты итерацией ключей входа в произвольном порядке (ре-сериализатор JSON, обобщённый mapper, генерированный код), создаёт новую ветку дерева переходов почти на каждый различный порядок, который видит. Формы никогда не повторяются, поэтому:

  • Каждый объект аллоцирует свежие Map и DescriptorArray (раздувание памяти из урока 01).
  • Нижестоящий inline cache, читающий эти объекты, видит свежий Map почти каждый раз → он становится polymorphic, затем megamorphic, затем сдаётся (урок 05).
  • TurboFan не может специализироваться на стабильной форме, так что горячая функция либо не достигает верхнего яруса, либо повторно деоптится.

Фикс всегда один: строй по фиксированной схеме в постоянном порядке (отсутствующие поля по умолчанию null) или храни действительно динамические наборы ключей в коллекции Map, созданной для произвольных ключей.

Викторина

10000 объектов разделяют Map с полем `score`, представленным как `Smi`. Один объект делает `o.score = 1.5`. Что происходит с общим Map?

Викторина

Объект `a` создан `{}; a.p=1; a.q=2`. Объект `b` затем создаётся так же. Сколько новых объектов Map аллоцирует создание `b`?

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

Расставьте по порядку, что делает V8, когда один экземпляр присваивает float поле, которое общий Map записывает как Smi.

  1. 1 Обнаружить, что новое значение не помещается в текущий representation поля (Smi)
  2. 2 Создать новый Map, идентичный, кроме того, что поле обобщено до Double
  3. 3 Пометить старый Map deprecated, чтобы он перестал использоваться для новых объектов
  4. 4 Оставить существующие экземпляры пока указывающими на deprecated Map
  5. 5 При следующем медленном доступе каждого экземпляра идти по back-pointer'ам к живому Map и мигрировать его на месте
Граничные случаи

Числа — не единственное обобщение. Присваивание объекта кучи туда, где поле раньше держало лишь Smi, обобщает representation до Tagged (самого общего), что навсегда отключает часть оптимизаций распаковки TurboFan для этого поля. Поле, держащее null для «отсутствует» и объект для «присутствует», уже Tagged с первой не-Smi-записи — обычно это нормально, но знайте, что это закрывает быстрый путь распакованного целого. Если поле горячее и числовое, держите его числовым.

Вспомните перед уходом
  1. 01
    Назовите все виды событий, создающих переход к новому Map, помимо добавления свойства.
  2. 02
    Объясните deprecation и ленивую миграцию шаг за шагом.
  3. 03
    Почему построение объектов итерацией ключей входа в произвольном порядке разрушает производительность, в терминах дерева переходов?
Итог

Переход — это ребро в дереве форм, разделяемом всем изолятом. Добавление свойства ищет ребро {имя, attributes} в TransitionArray текущего Map и следует по нему (бесплатно) или создаёт его (один новый дочерний Map, расширяющий DescriptorArray родителя). Но три других события тоже дают переход: расширение elements kind массива, обобщение representation поля (Smi → Double → Tagged) и потеря полем constness. Обобщение representation особое, потому что representation принадлежит общему Map: V8 создаёт обобщённый Map, помечает старый deprecated и мигрирует каждый экземпляр лениво — при следующем медленном доступе он идёт по back-pointer’ам к живому Map и переписывает хранилище на месте. Построение объектов в перемешанных порядках ключей бесконечно ветвит дерево, раздувает память Map, гонит нижестоящий inline cache в megamorphic и лишает TurboFan стабильной формы; фикс — порядок создания по фиксированной схеме или коллекция Map для действительно динамических ключей. Трассировать всё это можно через --trace-maps. Теперь, когда увидишь внезапный провал пропускной способности после безобидного с виду присваивания существующему полю, загляни в --trace-maps на события deprecation — возможно, ты только что расширил representation, разделяемый тысячами объектов.

Практика

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