Вольный перевод статьи Benedikt Meurer и Mathias Bynens "The story of a V8 performance cliff in React"
История обвала производительности V8 в React
Примечание: если тексту вы предпочитаете презентацию, ниже приложено видео.
video: https://www.youtube.com/watch?v=0I0d8LkDqyc&feature=emb_logo
Типы в 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 движки представляют это занчение в памяти.
Значение 42
, например, имее тип number
.
typeof 42;
// → 'number'
Есть несколько способов представления в памяти целого числа вроде 42
:
ECMAScript стандартизирует числа как 64-битные значения с плавающей запятой, также известные как числа двойной точности (double precision floating-point) или 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` представлены целыми числами,
// медленный 🐌 в противном случае.
Если оба операнда представлены целыми числами, CPU сможет вычислить результат очень эффективно, а V8 имеет дополнительные ускорялки для случаев, когда divisor
имеет степень двойки. Для чисел с плавающей запятой вычисление намного сложнее и занимает гораздо больше времени.
Поскольку целочисленные операции производятся гораздо быстрее, может показаться, что движку стоит использовать представление в виде дополнительного кода для всех целых чисел и операций над ними. К сожалению, такой подход будет являться нарушением спецификации ECMAScript. ECMAScript стандартизирует Float64, потому некоторые целочисленные операции на самом деле создают числа с плавающей запятой. В таких случаях важно, чтобы JS движки выдавали правильные результаты.
// 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, операции над целыми числами выполнялись должным образом.
V8 использует специальное представление Smi
для небольших целочисленных значений в диапазоне 31-битных целых чисел со знаком, для всех остальных - HeapObject
который является адресом для некоего объекта в памяти. Для представления чисел, не попадающих в диапазон Smi
, используется специальный тип HeapObject
, который называется HeapNumber
.
-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
против HeapNumber
против MutableHeapNumber
Вот как это работает под капотом. Представим, что у вас есть объект такого вида:
const o = {
x: 42, // Smi
y: 4.2, // HeapNumber
};
Значение 42
для x
может кодироваться как Smi
, потому может храниться внутри самого объекта. Значению 4.2
же для хранения значения необходим отдельный участок, на который будет ссылаться наш объект.
А теперь представим, что у нас есть код такого вида:
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
иммутабелен, что позволяет проводить некоторые оптимизации. Например, если мы x
у присвоим значение
y`:
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
без смещения. И, наконец, мы помечаем старый вид как как устаревший и отвязываем его от дерева перехода. Это получается сделать с помощью нового перехода для 'x'
от пустой формы к только что созданной.
На жтом этапе мы не можем польностью избавиться от старой формы, поскольку она все еще используется a
, и проход по памяти для нахождения и обновления всех объектов старой формы будет слишком дорогой операцией. Вместо того, V8 делает это лениво: доступ или присвоение любого свойства к a
сначала мигрирует его к новой форме. Идея в том, чтобы по итогу сделать устаревшую форму недоступной и удалить ее с помощью сборщика мусора.
Более сложный случай случается, если свойство, меняющее представление, не последнее в цепи:
const o = {
x: 1,
y: 2,
z: 3,
};
o.y = 0.1;
В таком случае V8 придется найти так называемую форму разделения, которая является предыдущей в цепочке до определения нужного свойства. Мы меняем y
, потому нам нужно найти ее предпоследнюю форму, которой, в нашем примере, будет являться x
.
Начиная с формы разделения, мы создаем новую цепочку переходов для y
, который повторяет все прошлые переходы с пометкой 'y'
в Double
представлении, и используем ее, помечая все старые поддеревья как устаревшие. Последним шагом мы мигрируем экземпляр o
к новой форме, с помощью MutableHeapNumber
. Таким образом, новые объекты не идут старым путем, и, как только все ссылки на старые значения будут удалены, часть дерева со старой формой исчезнет.
Расширяемость и переходы целостного уровня
Object.preventExtensions()
предотвращает добавление объекту новых свойств, выбрасывая исключение. (в нестрогом режиме работает аналогично, но не выбрасывает исключение)
const object = { x: 1 };
Object.preventExtensions(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
Object.seal
делает то же, что и Object.preventExtensions
, но также помечает все свойства, как неперенастраиваемые. Это значит, что их нельзя удалять и менять их перечислимость, конфигурируемость и перезаписываемость.
const object = { x: 1 };
Object.seal(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
Object.freeze
делает то же, что и Object.seal
, но также предотвращает изменения значений существующих свойств, помечая их как неперезаписываемые.
const object = { x: 1 };
Object.freeze(object);
object.y = 2;
// TypeError: Cannot add property y;
// object is not extensible
delete object.x;
// TypeError: Cannot delete property x
object.x = 3;
// TypeError: Cannot assign to read-only property 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, по сути, не знает как правильно повторить переходы в этом случае, потому перестаёт пытаться разобраться, и, вместо этого создает раздельную форму, которая не связана с уже существующим деревом и не используется другими объектами. Ее можно назвать сиротской формой:
Можете представить, насколько плохи дела, когда такое поведение применяется к большому количеству объектов. Вся система форм становится бесполезной.
В случае с 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()](https://w3c.github.io/hr-time/#dom-performance-now)
, хранящиеся в этих полях и являющиеся числами с плавающей запятой, заставляют их менять представление на Double
, поскольку больше не помещаются в диапазонSmi
. Более того, React также ограничивает перенастраиваемость для экземпляров FiberNode
.
Первоначально упрощенный пример выглядел следующим образом:
Есть два экземпляра, которые используют дерево форм. Все работает, как задумано. Но как только вы начинаете хранить настоящие временные метки, V8 начинает путаться и не может найти разделительную форму:
V8 присваивает новую сиротскую форму к node1
, и та же вещь произойдет с node2
позже. В результате мы получаем два сиротских острова с непересекающимися формами. А большинство реальных React приложений имеют не два, а десятки тысяч этих FiberNode
Как вы можете представить, ситуация для производительности V8 складывается не лучшим образом.
К счастью, мы починили эту утечку в V8 версии 7.4 и ищем способ облегчить операцию по смене представления, чтобы исправить оставшиеся утечки. Вместе с этим фиксом V8 начал делать всё правильно:
Два экземпляра FiberNode
указывают на неперенастраиваемую форму, где 'actualStartTime'
является свойством вSmi
. Когда происходит первое присвоение node1.actualStartTime
, новая цепочка переходов создается, а старая помечается устаревшей:
Обратите внимание, что теперь переход расширяемости повторяется в новой цепочке правильным образом.
После присвоения node2.actualStartTime
, оба узла ссылаются на новую форму, а помеченные устаревшими части дерева переходов могут быть удалены сборщиком мусора.
Примечание: Вы можете подумать, что все эти формы и миграции - сложная штука, и будете правы. На деле, у нас есть подозрения, что на реальных вебсайтах это вызывает больше проблем (с точки зрения производительности, использования памяти и комплексности), чем помогает. В особенности потому вместе с pointer compression мы больше не сможем хранить двойные значения в свойствах объекта. Потому мы надеемся полностью удалить механизм устаревания форм 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 движок под капотом выполняет некоторую магию, которой вы можете помочь не смешивая типы. Например, не инициализируйте числовые свойства с помощью 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 движку с выбором представления.
Top comments (0)