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

История о производительности V8 в React

· 17 мин. чтения
Бенедикт Меурер ([@bmeurer](https://twitter.com/bmeurer)) и Матиас Биненс ([@mathias](https://twitter.com/mathias))

Ранее мы обсуждали, как движки JavaScript оптимизируют доступ к объектам и массивам с помощью Shapes и Inline Caches, а также исследовали как движки ускоряют доступ к свойствам прототипа. В этой статье описано, как V8 выбирает оптимальные внутренние представления в памяти для различных значений JavaScript и как это влияет на механизм форм — что помогает объяснить недавнее падение производительности V8 в ядре React.

примечание

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

«Основы JavaScript-движков: хорошее, плохое и ужасное», представлено Матиасом Биненсом и Бенедиктом Меурером на AgentConf 2019.

Типы JavaScript

Каждое значение JavaScript имеет ровно один из (в настоящее время) восьми различных типов: Number, String, Symbol, BigInt, Boolean, Undefined, Null и Object.

За исключением одного примечательного случая, эти типы могут быть определены в JavaScript с помощью оператора typeof:

typeof 42;
// → 'number'
typeof 'foo';
// → 'string'
typeof Symbol('bar');
// → 'symbol'
typeof 42n;
// → 'bigint'
typeof true;
// → 'boolean'
typeof undefined;
// → 'undefined'
typeof null;
// → 'object' 🤔
typeof { x: 42 };
// → 'object'

typeof null возвращает 'object', а не 'null', несмотря на то, что Null является собственным типом. Чтобы понять почему, стоит учитывать, что множество всех типов JavaScript делится на две группы:

  • объекты (например, тип Object)
  • примитивы (например, любое значение, не являющееся объектом)

Таким образом, null означает «нет объектного значения», тогда как undefined означает «нет значения».

Следуя этой логике, Брендан Эйх спроектировал JavaScript таким образом, чтобы typeof возвращал 'object' для всех значений справа, т.е. для всех объектов и значений null, в духе Java. Вот почему typeof null === 'object', несмотря на то, что спецификация имеет отдельный тип Null.

Представление значения

Движки JavaScript должны уметь представлять произвольные значения JavaScript в памяти. Однако важно понимать, что тип значения в JavaScript и то, как движки JavaScript представляют это значение в памяти, — это разные вещи.

Например, значение 42 имеет тип number в JavaScript.

typeof 42;
// → 'number'

Существует несколько способов представить целое число, такое как 42, в памяти:

представлениебиты
двоичное дополнение 8 бит0010 1010
двоичное дополнение 32 бит0000 0000 0000 0000 0000 0000 0010 1010
упакованный двоично-кодированный (BCD)0100 0010
32-битное числовое значение IEEE-7540100 0010 0010 1000 0000 0000 0000 0000
64-битное числовое значение IEEE-7540100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

Стандарт ECMAScript определяет числа как 64-битные числовые значения с плавающей точкой, также известные как плавающая точка двойной точности или Float64. Однако это не означает, что движки JavaScript хранят числа в представлении Float64 все время — это было бы крайне неэффективно! Движки могут выбирать другие внутренние представления, при условии, что наблюдаемое поведение полностью соответствует Float64.

Большинство чисел в реальных JavaScript приложениях являются допустимыми индексами массива ECMAScript, то есть целыми числами в диапазоне от 0 до 2³²−2.

array[0]; // Наименьший возможный индекс массива.
array[42];
array[2**32-2]; // Наибольший возможный индекс массива.

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

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

for (let i = 0; i < 1000; ++i) {
// быстро 🚀
}

for (let i = 0.1; i < 1000.1; ++i) {
// медленно 🐌
}

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

const remainder = value % divisor;
// Быстро 🚀, если `value` и `divisor` представлены как целые числа,
// медленно 🐌 в противном случае.

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

Поскольку операции с целыми числами обычно выполняются значительно быстрее, чем с числами с плавающей точкой, кажется, что движки могли бы всегда использовать дополнение до двух для всех целых чисел и результатов операций с ними. К сожалению, это было бы нарушением спецификации ECMAScript! ECMAScript стандартизирует формат Float64, и поэтому определённые операции с целыми числами на самом деле возвращают числа с плавающей точкой. Очень важно, чтобы движки JavaScript возвращали правильные результаты в таких случаях.

// Float64 имеет безопасный диапазон целых чисел в 53 бита. За пределами этого диапазона
// теряется точность.
2**53 === 2**53+1;
// → true

// Float64 поддерживает отрицательные нули, поэтому -1 * 0 должно быть -0, но
// невозможно представить отрицательный ноль в дополнении до двух.
-1*0 === -0;
// → true

// Float64 имеет бесконечности, которые можно получить при делении
// на ноль.
1/0 === Infinity;
// → true
-1/0 === -Infinity;
// → true

// Float64 также имеет NaN.
0/0 === NaN;

Хотя значения слева являются целыми числами, все значения справа представлены в виде чисел с плавающей точкой. Именно поэтому ни одна из вышеуказанных операций не может быть выполнена корректно с использованием 32-битного дополнения до двух. Движки JavaScript должны уделять особое внимание тому, чтобы операции с целыми числами при необходимости переходили на использование более сложного представления Float64.

Для небольших целых чисел в диапазоне 31-битного знакового целого числа V8 использует специальное представление Smi. Всё, что не является Smi, представляется как HeapObject, который является адресом некоторой сущности в памяти. Для чисел используется специальный вид HeapObject — так называемый HeapNumber, чтобы представить числа, которые не входят в диапазон Smi.

 -Infinity // HeapNumber
-(2**30)-1 // HeapNumber
-(2**30) // Smi
-42 // Smi
-0 // HeapNumber
0 // Smi
4.2 // HeapNumber
42 // Smi
2**30-1 // Smi
2**30 // HeapNumber
Infinity // HeapNumber
NaN // HeapNumber

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

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

Smi vs. HeapNumber vs. MutableHeapNumber

Вот как это работает под капотом. Допустим, у вас есть следующий объект:

const o = {
x: 42, // Smi
y: 4.2, // HeapNumber
};

Значение 42 для x может быть закодировано как Smi, поэтому оно может быть сохранено внутри самого объекта. Значение 4.2, с другой стороны, требует отдельной сущности для хранения значения, и объект ссылается на эту сущность.

Теперь предположим, что мы запускаем следующий фрагмент JavaScript кода:

o.x += 10;
// → o.x теперь 52
o.y += 1;
// → o.y теперь 5.2

В этом случае значение x может быть обновлено на месте, так как новое значение 52 также помещается в диапазон Smi.

Однако новое значение y=5.2 не помещается в Smi и также отличается от предыдущего значения 4.2, поэтому V8 должен выделить новую сущность HeapNumber для присваивания y.

HeapNumber не являются изменяемыми, что обеспечивает определенные оптимизации. Например, если мы присвоим значение y переменной x:

o.x = o.y;
// → теперь o.x равно 5.2

…мы можем просто ссылаться на один и тот же HeapNumber, вместо того чтобы выделять новый для того же значения.

Один недостаток HeapNumber в том, что их неизменяемость делает медленным обновление полей со значениями вне диапазона Smi. Например:

// Создаем экземпляр `HeapNumber`.
const o = { x: 0.1 };

for (let i = 0; i < 5; ++i) {
// Создаем дополнительный экземпляр `HeapNumber`.
o.x += 1;
}

Первая строка создает экземпляр HeapNumber с начальным значением 0.1. Тело цикла изменяет это значение на 1.1, 2.1, 3.1, 4.1 и, наконец, 5.1, создавая в общей сложности шесть экземпляров HeapNumber, пять из которых становятся мусором после завершения цикла.

Чтобы избежать этой проблемы, V8 предоставляет способ обновлять числовые поля вне диапазона Smi на месте, как оптимизацию. Когда числовое поле содержит значения вне диапазона Smi, V8 помечает это поле как Double на форме и выделяет так называемый MutableHeapNumber, который содержит фактическое значение, закодированное как Float64.

Когда значение поля изменяется, V8 больше не нужно выделять новый HeapNumber, вместо этого он может просто обновить MutableHeapNumber на месте.

Однако у этого подхода есть ловушка. Поскольку значение MutableHeapNumber может изменяться, важно, чтобы они не передавались между объектами.

Например, если вы присваиваете o.x другой переменной y, вы не захотите, чтобы значение y изменялось при следующем изменении o.x — это было бы нарушением спецификации JavaScript! Поэтому, когда происходит доступ к o.x, число должно быть перепаковано в обычный HeapNumber перед присваиванием его y.

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

const object = { x: 1 };
// → нет “упаковки” для `x` в объекте

object.x += 1;
// → обновление значения `x` внутри объекта

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

Устаревание и миграция форм

Что если поле изначально содержит Smi, но позже хранит число вне диапазона небольших целых чисел? Как в данном случае, с двумя объектами, которые используют одну и ту же форму, где x изначально представляется как Smi:

const a = { x: 1 };
const b = { x: 2 };
// → объекты имеют `x` как поле `Smi`

b.x = 0.2;
// → теперь `b.x` представляется как `Double`

y = a.x;

Это начинается с двух объектов, указывающих на одну и ту же форму, где x помечен как представление Smi:

Когда b.x изменяется на представление Double, V8 выделяет новую форму, где x назначено представление Double, и которая указывает назад на пустую форму. V8 также выделяет MutableHeapNumber, чтобы хранить новое значение 0.2 для свойства x. Затем обновляется объект b, чтобы указывать на эту новую форму, а слот в объекте изменяется, чтобы указывать на ранее выделенный MutableHeapNumber со смещением 0. И, наконец, старая форма помечается как устаревшая и отвязывается от дерева переходов. Это достигается созданием нового перехода для 'x' от пустой формы к недавно созданной форме.

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

Более сложный случай возникает, если поле, которое изменяет представление, не является последним в цепочке:

const o = {
x: 1,
y: 2,
z: 3,
};

o.y = 0.1;

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

Начиная с разделённой формы, мы создаём новую цепочку переходов для y, которая воспроизводит все предыдущие переходы, но с 'y', отмеченным как представление Double. Мы используем эту новую цепочку переходов для y, помечая старую поддеревьевую структуру как устаревшую. На последнем шаге мы мигрируем экземпляр o в новую форму, используя MutableHeapNumber, чтобы хранить значение y теперь. Таким образом, новые объекты больше не идут по старому пути, и как только все ссылки на старую форму пропадают, устаревшая часть поддерева исчезает.

Расширяемость и переходы уровня целостности

Object.preventExtensions() предотвращает добавление новых свойств к объекту. Если вы попробуете, это вызовет исключение. (Если вы не в строгом режиме, это не вызывает исключение, но тихо ничего не делает.)

const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Невозможно добавить свойство y;
// объект нерасширяем

Object.seal делает то же самое, что и Object.preventExtensions, но также отмечает все свойства как неконфигурируемые, что означает, что вы не можете их удалить, или изменить их перечисляемость, конфигурируемость или возможность записи.

const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Невозможно добавить свойство y;
// объект нерасширяем
delete object.x;
// TypeError: Невозможно удалить свойство x

Object.freeze делает то же самое, что и Object.seal, но также предотвращает изменения значений существующих свойств, отмечая их как неизменяемые.

const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Невозможно добавить свойство y;
// объект нерасширяем
delete object.x;
// TypeError: Невозможно удалить свойство x
object.x = 3;
// TypeError: Невозможно присвоить значение только для чтения свойству x

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

const a = { x: 1 };
const b = { x: 2 };

Object.preventExtensions(b);

Начинается так, как мы уже знаем, переход от пустой формы к новой форме, которая содержит свойство 'x' (представленное как Smi). Когда мы запрещаем расширения для b, мы выполняем специальный переход к новой форме, которая отмечена как нерасширяемая. Этот специальный переход не вводит никакое новое свойство — это просто маркер.

Обратите внимание, что мы не можем просто обновить форму с x на месте, так как она нужна для другого объекта a, который всё ещё расширяем.

Проблема производительности React

Давайте соберём всё вместе и используем то, что мы узнали, чтобы понять недавнюю проблему React #14365. Когда команда React профилировала приложение реального мира, они обнаружили странный спад производительности V8, который затронул ядро React. Вот упрощённая воспроизводимая ошибка:

const o = { x: 1, y: 2 };
Object.preventExtensions(o);
o.y = 0.2;

У нас есть объект с двумя полями, которые имеют представление Smi. Мы запрещаем дальнейшие расширения объекта и, в конечном итоге, вынуждаем второе поле перейти к представлению Double.

Как мы узнали ранее, это создаёт примерно следующую настройку:

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

Теперь нам нужно изменить y на представление Double, что означает, что нам нужно снова начать с поиска разделённой формы. В данном случае это форма, которая ввела x. Но теперь V8 запутался, поскольку разделённая форма была расширяемой, а текущая форма была отмечена как нерасширяемая. И V8 действительно не знала, как повторить переходы должным образом в этом случае. Таким образом, V8 фактически просто отказалась пытаться осмыслить это и вместо этого создала отдельную форму, которая не связана с существующим деревом форм и не разделяется с другими объектами. Можно подумать о ней как о осиротевшей форме:

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

В случае React произошло следующее: каждый FiberNode имеет несколько полей, которые должны содержать временные метки, когда включено профилирование.

class FiberNode {
constructor() {
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}

const node1 = new FiberNode();
const node2 = new FiberNode();

Эти поля (такие как actualStartTime) инициализируются значениями 0 или -1, и, таким образом, начинают с представления Smi. Но позже в эти поля сохраняются реальные числа с плавающей точкой из performance.now(), что приводит к их переходу к представлению Double, так как они не помещаются в Smi. На добавок к этому, React также предотвращает расширения экземпляров FiberNode.

Первоначально упрощённый пример выше выглядел так:

Есть два экземпляра, разделяющие дерево форм, всё работает как задумано. Но затем, когда вы сохраняете реальную временную метку, V8 запутывается, пытаясь найти разделённую форму:

V8 назначает новую изолированную форму для node1, и то же самое происходит с node2 некоторое время спустя, что приводит к двум изолированным островам, каждый из которых имеет свои собственные несвязанные формы. Многие реальные приложения на React имеют не два, а десятки тысяч таких FiberNode. Как вы можете себе представить, такая ситуация была не особенно хороша для производительности V8.

К счастью, мы исправили эту проблему производительности в V8 v7.4, и мы рассматриваем возможность удешевления изменений представления полей, чтобы устранить оставшиеся проблемы производительности. После исправления V8 теперь делает правильно:

Два экземпляра FiberNode указывают на неизменяемую форму, где 'actualStartTime' является полем типа Smi. Когда первое присваивание node1.actualStartTime происходит, создается новая цепочка переходов, а предыдущая цепочка помечается как устаревшая:

Обратите внимание, как переход к изменяемости теперь правильно воспроизводится в новой цепочке.

После присваивания node2.actualStartTime оба узла ссылаются на новую форму, и устаревшая часть дерева переходов может быть очищена сборщиком мусора.

примечание

Примечание: Вы можете подумать, что вся эта устаревшая форма/миграция сложна, и вы будете правы. На самом деле, мы подозреваем, что на реальных веб-сайтах это вызывает больше проблем (в плане производительности, использования памяти и сложности), чем помогает, особенно учитывая, что с сжатием указателей мы больше не сможем использовать это для хранения значений типа double внутри объекта. Поэтому мы надеемся полностью удалить механизм устаревания форм V8. Можно сказать, что это *ставит очки* устарело. ДАААА…

Команда React снизила проблему на своей стороне, убедившись, что все временные и длительные поля на FiberNode с самого начала имеют представление Double:

class FiberNode {
constructor() {
// Принудительное представление `Double` с самого начала.
this.actualStartTime = Number.NaN;
// Позже вы все еще можете инициализировать значение, которое вам нужно:
this.actualStartTime = 0;
Object.preventExtensions(this);
}
}

const node1 = new FiberNode();
const node2 = new FiberNode();

Вместо Number.NaN может быть использовано любое значение с плавающей точкой, которое не попадает в диапазон Smi. Примеры включают 0.000001, Number.MIN_VALUE, -0, и Infinity.

Стоит отметить, что конкретная ошибка React была специфичной для V8, и, в целом, разработчики не должны оптимизировать для конкретной версии JavaScript-движка. Тем не менее, приятно иметь способ решения проблемы, когда что-то не работает.

Имейте в виду, что JavaScript-движок выполняет определенное волшебство «за кулисами», и вы можете помочь ему, если возможно, не смешивая типы. Например, не инициализируйте свои числовые поля с null, так как это отключает все преимущества отслеживания представления полей и делает ваш код более читаемым:

// Не делайте так!
class Point {
x = null;
y = null;
}

const p = new Point();
p.x = 0.1;
p.y = 402;

Другими словами, пишите читаемый код, и производительность последует!

Резюме

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

  • JavaScript различает «примитивы» и «объекты», и typeof вводит в заблуждение.
  • Даже значения одного и того же типа JavaScript могут иметь разные представления «за кулисами».
  • V8 пытается найти оптимальное представление для каждого свойства в ваших JavaScript программах.
  • Мы обсудили, как V8 справляется с устареванием и миграцией форм, включая переходы к изменяемости.

Основываясь на этих знаниях, мы определили несколько практических советов по программированию на JavaScript, которые могут помочь повысить производительность:

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