Перейти к основному содержимому

Виды элементов в V8

· 13 мин. чтения
Матиас Байненс ([@mathias](https://twitter.com/mathias))
примечание

Примечание: Если вы предпочитаете смотреть презентации вместо чтения статей, наслаждайтесь видео ниже!

Объекты JavaScript могут иметь произвольные свойства, связанные с ними. Имена свойств объектов могут содержать любые символы. Одним из интересных случаев, которые может выбрать для оптимизации JavaScript-движок, являются свойства, имена которых исключительно числовые, наиболее конкретно индексы массива.

В V8 свойства с целыми именами — самая распространенная форма которых — это объекты, созданные с помощью конструктора Array, обрабатываются особым образом. Хотя во многих случаях эти свойства с числовыми индексами ведут себя так же, как и другие свойства, V8 выбирает хранить их отдельно от нечисловых свойств для целей оптимизации. Внутри V8 такие свойства даже получают особое название: элементы. Объекты имеют свойства, которые отображаются в значения, тогда как массивы имеют индексы, которые отображаются в элементы.

Хотя эти внутренние механизмы никогда не бывают напрямую доступны разработчикам JavaScript, они объясняют, почему определенные шаблоны кода работают быстрее, чем другие.

Основные виды элементов

Во время выполнения JavaScript-кода V8 отслеживает, какой тип элементов содержит каждый массив. Эта информация позволяет V8 оптимизировать любые операции с массивом специально для этого типа элемента. Например, когда вы вызываете reduce, map или forEach для массива, V8 может оптимизировать эти операции на основе типа элементов, содержащихся в массиве.

Возьмем, например, этот массив:

const array = [1, 2, 3];

Какие типы элементов он содержит? Если вы спросите оператор typeof, он скажет вам, что массив содержит number. На уровне языка это все, что вы получите: JavaScript не различает целые числа, числа с плавающей точкой и двойки — они все просто числа. Однако на уровне движка мы можем делать более точные различия. Тип элементов для этого массива — PACKED_SMI_ELEMENTS. В V8 термин Smi относится к особому формату, используемому для хранения маленьких целых чисел. (К части PACKED мы вернемся позже.)

Позже добавление числа с плавающей точкой в тот же массив переводит его на более общий тип элементов:

const array = [1, 2, 3];
// тип элементов: PACKED_SMI_ELEMENTS
array.push(4.56);
// тип элементов: PACKED_DOUBLE_ELEMENTS

Добавление строкового литерала в массив снова изменяет тип его элементов.

const array = [1, 2, 3];
// тип элементов: PACKED_SMI_ELEMENTS
array.push(4.56);
// тип элементов: PACKED_DOUBLE_ELEMENTS
array.push('x');
// тип элементов: PACKED_ELEMENTS

Мы рассмотрели три разных типа элементов, имеющих следующие базовые типы:

  • Маленькие целые числа, также известные как Smi.
  • Двойки, для чисел с плавающей точкой и целых чисел, которые не могут быть представлены как Smi.
  • Обычные элементы, для значений, которые не могут быть представлены как Smi или двойки.

Обратите внимание, что двойки являются более общей вариацией Smi, а обычные элементы — это еще одна общая версия поверх двойок. Набор чисел, которые могут быть представлены как Smi, является подмножеством чисел, которые могут быть представлены как двойки.

Важно отметить, что переходы тип элементов происходят только в одном направлении: от специфических (например, PACKED_SMI_ELEMENTS) к более общим (например, PACKED_ELEMENTS). Как только массив отмечен как PACKED_ELEMENTS, он не может вернуться к PACKED_DOUBLE_ELEMENTS, например.

Итак, мы узнали следующее:

  • V8 назначает тип элементов для каждого массива.
  • Тип элементов массива не зафиксирован — он может изменяться во время выполнения. В предыдущем примере мы перешли от PACKED_SMI_ELEMENTS к PACKED_ELEMENTS.
  • Переходы типа элементов могут происходить только от специфичных типов к более общим типам.

PACKED против типов HOLEY

До сих пор мы имели дело только с плотными или упакованными массивами. Создание дырок в массиве (то есть снижение плотности массива) переводит тип элементов в его вариант с «дырками»:

const array = [1, 2, 3, 4.56, 'x'];
// тип элементов: PACKED_ELEMENTS
array.length; // 5
array[9] = 1; // array[5] до array[8] теперь являются дырками
// тип элементов: HOLEY_ELEMENTS

V8 делает это различие, потому что операции с плотными массивами могут быть оптимизированы более агрессивно, чем операции с дырявыми массивами. Для плотных массивов большинство операций могут выполняться эффективно. В сравнении, операции с дырявыми массивами требуют дополнительных проверок и затратных обращений к цепочке прототипов.

Каждый из основных типов элементов, которые мы рассмотрели до сих пор (т.е. Smis, doubles и обычные элементы), имеет два варианта: плотный (packed) и дырявый (holey). Мы можем перейти, например, от PACKED_SMI_ELEMENTS к PACKED_DOUBLE_ELEMENTS, а также от любого вида PACKED к его аналогу HOLEY.

Краткий итог:

  • Наиболее распространенные типы элементов бывают в вариантах PACKED и HOLEY.
  • Операции с плотными массивами более эффективны, чем с дырявыми массивами.
  • Типы элементов могут переходить из варианта PACKED в HOLEY.

Решетка типов элементов

V8 реализует эту систему перехода тегов в виде решетки. Вот упрощенная визуализация с использованием только наиболее распространенных типов элементов:

Переход возможен только вниз по решетке. Как только одно число с плавающей точкой добавляется в массив Smis, он помечается как DOUBLE, даже если вы позже замените это число Smi-значением. Так же, как только в массиве создается дырка, он помечается как дырявый навсегда, даже если вы заполняете эту дырку позже.

примечание

Обновление от 2025-02-28: Теперь есть исключение для Array.prototype.fill.

V8 в настоящее время различает 21 тип элементов, каждый из которых имеет набор возможных оптимизаций.

В общем, более специфические типы элементов позволяют выполнять более точные оптимизации. Чем ниже тип элемента находится в решетке, тем медленнее могут быть манипуляции с этим объектом. Для достижения оптимальной производительности избегайте ненужного перехода к менее специфическим типам — оставайтесь на наиболее подходящем для вашей ситуации.

Советы по производительности

В большинстве случаев отслеживание типов элементов работает незаметно для вас, и вам не нужно об этом задумываться. Но вот несколько вещей, которые вы можете сделать, чтобы получить максимальную пользу от этой системы.

Избегайте чтения за пределами длины массива

Несколько неожиданно (учитывая тему этого текста), наш совет №1 по производительности не связан напрямую с отслеживанием типов элементов (хотя то, что происходит «под капотом», похоже). Чтение за пределами длины массива может неожиданно повлиять на производительность, например, чтение array[42], когда array.length === 5. В этом случае индекс массива 42 выходит за пределы, и поэтому свойство отсутствует в самом массиве, из-за чего движок JavaScript должен выполнять затратные обращения к цепочке прототипов. После того как одна загрузка сталкивается с этой ситуацией, V8 запоминает, что «эта загрузка должна обрабатывать особые случаи», и она никогда не будет работать так же быстро, как до выхода за пределы массива.

Не пишите циклы таким образом:

// Не делайте так!
for (let i = 0, item; (item = items[i]) != null; i++) {
doSomething(item);
}

Этот код читает все элементы массива, а затем ещё один. Он заканчивается только тогда, когда находит элемент undefined или null. (Такой шаблон используется JQuery в нескольких местах.)

Вместо этого пишите циклы старомодным способом, и продолжайте итерацию до последнего элемента.

for (let index = 0; index < items.length; index++) {
const item = items[index];
doSomething(item);
}

Когда коллекция, по которой вы итерируете, является итерируемой (как в случае массивов и NodeList), это еще лучше: просто используйте for-of.

for (const item of items) {
doSomething(item);
}

Для массивов в частности вы могли бы использовать встроенный метод forEach:

items.forEach((item) => {
doSomething(item);
});

Сегодня производительность как for-of, так и forEach сравнима с производительностью старомодного цикла for.

Избегайте чтения за пределами длины массива! В этом случае проверка границ в V8 завершается неудачей, проверка наличия свойства завершается неудачей, и затем V8 нужно обращаться к цепочке прототипов. Влияние еще хуже, если вы случайно используете это значение в вычислениях, например:

function Maximum(array) {
let max = 0;
for (let i = 0; i <= array.length; i++) { // ПЛОХОЕ СРАВНЕНИЕ!
if (array[i] > max) max = array[i];
}
return max;
}

Здесь последняя итерация считывает данные за пределами длины массива, что возвращает undefined, что загрязняет не только загрузку, но и сравнение: вместо сравнения только чисел теперь нужно учитывать особые случаи. Исправление условия завершения на правильное i < array.length дает 6-кратное улучшение производительности для этого примера (измеренного на массивах с 10,000 элементов, так что количество итераций уменьшается лишь на 0,01%).

Избегайте переходов между типами элементов

В общем, если вам нужно выполнить множество операций с массивом, постарайтесь использовать тип элементов, который настолько специфичен, насколько это возможно, чтобы V8 могла оптимизировать эти операции максимально эффективно.

Это сложнее, чем кажется. Например, простое добавление -0 в массив из небольших целых чисел достаточно для перехода к PACKED_DOUBLE_ELEMENTS.

const array = [3, 2, 1, +0];
// PACKED_SMI_ELEMENTS
array.push(-0);
// PACKED_DOUBLE_ELEMENTS

В результате любые последующие операции с этим массивом оптимизируются совершенно иначе, чем для Smis.

Избегайте использования -0, если только вам явно не нужно различать -0 и +0 в вашем коде. (Скорее всего, не нужно.)

То же самое относится к NaN и Infinity. Они представлены в виде чисел с плавающей точкой, поэтому добавление одного NaN или Infinity в массив типа SMI_ELEMENTS переводит его в DOUBLE_ELEMENTS.

const array = [3, 2, 1];
// PACKED_SMI_ELEMENTS
array.push(NaN, Infinity);
// PACKED_DOUBLE_ELEMENTS

Если вы планируете выполнять множество операций с массивом целых чисел, рассмотрите возможность нормализации -0 и блокировки NaN и Infinity при инициализации значений. Таким образом, массив останется типом PACKED_SMI_ELEMENTS. Эти разовые затраты на нормализацию могут окупиться благодаря последующим оптимизациям.

Фактически, если вы выполняете математические операции с массивом чисел, рассмотрите использование TypedArray. У нас есть специальные типы элементов и для них.

Предпочитайте массивы объектам, похожим на массивы

Некоторые объекты в JavaScript — особенно в DOM — выглядят как массивы, хотя на самом деле таковыми не являются. Возможно создать объекты, похожие на массивы, самостоятельно:

const arrayLike = {};
arrayLike[0] = 'a';
arrayLike[1] = 'b';
arrayLike[2] = 'c';
arrayLike.length = 3;

Этот объект имеет length и поддерживает доступ к элементам по индексу (как массив!), но у него отсутствуют методы массива, такие как forEach, в его прототипе. Однако к нему можно применять методы массива через явный вызов:

Array.prototype.forEach.call(arrayLike, (value, index) => {
console.log(`${ index }: ${ value }`);
});
// Это выведет: '0: a', затем '1: b' и, наконец, '2: c'.

Этот код вызывает встроенный метод Array.prototype.forEach для объекта, похожего на массив, и он работает, как ожидалось. Однако это медленнее, чем вызов forEach для настоящего массива, который сильно оптимизирован в V8. Если вы планируете использовать методы массивов с этим объектом более одного раза, рассмотрите возможность преобразования его в настоящий массив заранее:

const actualArray = Array.prototype.slice.call(arrayLike, 0);
actualArray.forEach((value, index) => {
console.log(`${ index }: ${ value }`);
});
// Это выведет: '0: a', затем '1: b' и, наконец, '2: c'.

Разовые затраты на преобразование могут окупиться благодаря последующим оптимизациям, особенно если вы планируете выполнять множество операций с массивом.

Объект arguments, например, является объектом, похожим на массив. К нему можно применять методы массивов, но такие операции не будут полностью оптимизированы, как это было бы с настоящим массивом.

const logArgs = function() {
Array.prototype.forEach.call(arguments, (value, index) => {
console.log(`${ index }: ${ value }`);
});
};
logArgs('a', 'b', 'c');
// Это выведет: '0: a', затем '1: b', и, наконец, '2: c'.

ES2015 rest параметры могут помочь здесь. Они создают настоящие массивы, которые можно использовать вместо объектов, похожих на массивы arguments, элегантным способом.

const logArgs = (...args) => {
args.forEach((value, index) => {
console.log(`${ index }: ${ value }`);
});
};
logArgs('a', 'b', 'c');
// Это выведет: '0: a', затем '1: b', и, наконец, '2: c'.

Сегодня нет оснований использовать напрямую объект arguments.

В общем, избегайте объектов, похожих на массивы, где это возможно, и вместо этого используйте настоящие массивы.

Избегайте полиморфизма

Если у вас есть код, который работает с массивами разных типов элементов, это может привести к полиморфным операциям, которые медленнее, чем версия кода, работающая только с одним типом элементов.

Рассмотрим следующий пример, где вызов функции библиотеки происходит с разными типами элементов. (Обратите внимание, что это не встроенный метод Array.prototype.forEach, который имеет свои собственные оптимизации поверх тех, что обсуждаются в этой статье.)

const each = (array, callback) => {
for (let index = 0; index < array.length; ++index) {
const item = array[index];
callback(item);
}
};
const doSomething = (item) => console.log(item);

each([], () => {});

each(['a', 'b', 'c'], doSomething);
// `each` вызывается с `PACKED_ELEMENTS`. V8 использует встроенный кэш
// (или “IC”), чтобы помнить, что `each` вызывается с этим конкретным
// типом элементов. V8 оптимистичен и предполагает, что
// доступы к `array.length` и `array[index]` внутри функции `each`
// являются мономорфными (т.е. всегда будут получать только один
// тип элементов), пока не будет доказано обратное. При каждом
// последующем вызове `each` V8 проверяет, является ли тип
// элементов `PACKED_ELEMENTS`. Если да, ранее сгенерированный код
// может быть использован повторно. Если нет, потребуется
// больше работы.
each([1.1, 2.2, 3.3], doSomething);
// `each` вызывается с `PACKED_DOUBLE_ELEMENTS`. Поскольку V8
// теперь видит разные типы элементов, передаваемые в `each` через IC,
// доступы к `array.length` и `array[index]` внутри функции `each`
// помечаются как полиморфные. Теперь V8 требуется дополнительная
// проверка каждый раз при вызове `each`: одна для `PACKED_ELEMENTS`
// (как и прежде), новая для `PACKED_DOUBLE_ELEMENTS` и одна для
// любых других типов элементов (как и прежде). Это приводит к
// снижению производительности.

each([1, 2, 3], doSomething);
// `each` вызывается с `PACKED_SMI_ELEMENTS`. Это вызывает еще
// один уровень полиморфизма. Теперь в IC для `each` имеются три
// разных типа элементов. Для каждого вызова `each` теперь требуется
// еще одна проверка типа элементов для повторного использования сгенерированного кода
// для `PACKED_SMI_ELEMENTS`. Это обходится дороже с точки зрения производительности.

Встроенные методы (такие как Array.prototype.forEach) могут справляться с таким полиморфизмом намного эффективнее, поэтому в ситуациях, чувствительных к производительности, лучше использовать их вместо функций пользователя.

Еще один пример мономорфизма vs. полиморфизма в V8 связан с формами объектов, также известными как скрытые классы объектов. Чтобы узнать об этом случае, ознакомьтесь со статьей Вячеслава.

Избегайте создания пробелов

В реальных сценариях разница в производительности между доступом к массивам с пробелами или упакованными массивами обычно слишком мала, чтобы иметь значение или быть измеримой. Если (и это большое “если”!) ваши измерения производительности указывают на то, что стоит экономить каждую последнюю машинную инструкцию в оптимизированном коде, то вы можете попытаться сохранить ваши массивы в режиме упакованных элементов. Допустим, мы пытаемся создать массив, например:

const array = new Array(3);
// На данный момент массив является разреженным, поэтому он становится
// `HOLEY_SMI_ELEMENTS`, то есть наиболее специфичной возможностью,
// учитывая текущую информацию.
array[0] = 'a';
// Постойте, это строка вместо небольшого целого числа… Таким образом, тип
// переходит к `HOLEY_ELEMENTS`.
array[1] = 'b';
array[2] = 'c';
// На данный момент все три позиции в массиве заполнены, поэтому
// массив становится упакованным (т.е. больше не является разреженным). Тем не менее, мы
// не можем перейти к более специфичному типу, такому как `PACKED_ELEMENTS`. Тип
// элементов остается `HOLEY_ELEMENTS`.

Как только массив помечается как разреженный, он остается разреженным навсегда — даже если все его элементы присутствуют позже!

Лучший способ создания массива — использовать литеральную запись:

const array = ['a', 'b', 'c'];
// тип элементов: PACKED_ELEMENTS

Если вы не знаете всех значений заранее, создайте пустой массив и позже добавляйте в него значения методом push.

const array = [];
// …
array.push(someValue);
// …
array.push(someOtherValue);

Такой подход гарантирует, что массив никогда не перейдет к типу разреженных элементов. В результате V8 потенциально может генерировать чуть более быстрый оптимизированный код для некоторых операций с этим массивом.

Отладка типов элементов

Чтобы определить «тип элементов» данного объекта, получите отладочную сборку d8 (либо путем сборки из исходного кода в режиме отладки, либо загрузив предварительно скомпилированный бинарный код с помощью jsvu) и выполните:

out/x64.debug/d8 --allow-natives-syntax

Это запустит REPL d8, в котором доступны специальные функции такие как %DebugPrint(object).

d8> const array = [1, 2, 3]; %DebugPrint(array);
DebugPrint: 0x1fbbad30fd71: [JSArray]
- map = 0x10a6f8a038b1 [FastProperties]
- prototype = 0x1212bb687ec1
- elements = 0x1fbbad30fd19 <FixedArray[3]> [PACKED_SMI_ELEMENTS (COW)]
- length = 3
- properties = 0x219eb0702241 <FixedArray[0]> {
#length: 0x219eb0764ac9 <AccessorInfo> (const accessor descriptor)
}
- elements= 0x1fbbad30fd19 <FixedArray[3]> {
0: 1
1: 2
2: 3
}
[]

Обратите внимание, что “COW” означает copy-on-write, что является еще одной внутренней оптимизацией. Не беспокойтесь об этом сейчас — это тема для другой статьи!

Еще один полезный флаг, доступный в отладочных сборках, это --trace-elements-transitions. Включите его, чтобы V8 информировал вас всякий раз, когда происходит переход типа элементов.

$ cat my-script.js
const array = [1, 2, 3];
array[3] = 4.56;

$ out/x64.debug/d8 --trace-elements-transitions my-script.js
elements transition [PACKED_SMI_ELEMENTS -> PACKED_DOUBLE_ELEMENTS] in ~+34 at x.js:2 for 0x1df87228c911 <JSArray[3]> from 0x1df87228c889 <FixedArray[3]> to 0x1df87228c941 <FixedDoubleArray[22]>