В материале, первую часть перевода которого мы публикуем сегодня, речь пойдёт о том, как JavaScript-движок V8 выбирает оптимальные способы представления различных JS-значений в памяти, и о том, как это влияет на внутренние механизмы V8, касающиеся работы с так называемыми формами объектов (Shape). Всё это поможет нам разобраться с сутью недавней проблемы, касающейся производительности React.
Каждое JavaScript-значение может иметь только один из ныне существующих восьми типов данных:
Типы данных в JavaScript
Тип значения можно выяснить с помощью оператора
Как видите, команда
В свете этого знания оказывается, что
Примитивные значения, объекты, null и undefined
Следуя этим размышлениям в духе Java, Брендан Эйх спроектировал JavaScript так, чтобы оператор
Выражение typeof v === 'object' истинно
JavaScript-движки должны иметь возможность представления любых JavaScript-значений в памяти. Однако важно отметить то, что типы значений в JavaScript отделены от того, как JS-движки представляют их в памяти.
Например, значение 42 в JavaScript имеет тип
Существует несколько способов представления в памяти целых чисел наподобие 42:
В соответствии со стандартом ECMAScript числа — это 64-битные значения с плавающей точкой, известные как числа с плавающей точкой двойной точности (Float64). Однако это не означает того, что JavaScript-движки всегда хранят числа в представлении Float64. Это было бы очень и очень неэффективно! Движки могут использовать другие внутренние представления чисел — до тех пор, пока поведение значений в точности соответствует тому, как ведут себя Float64-числа.
Большинство чисел в реальных JS-приложениях, как оказалось, являются действительными индексами ECMAScript-массивов. То есть — целыми числами в диапазоне от 0 до 232-2.
JavaScript-движки могут выбирать оптимальный формат для представления подобных значений в памяти. Делается это для того чтобы оптимизировать код, который работает с элементами массивов, используя индексы. Процессору, выполняющему операции обращения к памяти, нужно, чтобы индексы массива были бы доступны в виде чисел, хранящихся в представлении с дополнением до двух. Если вместо этого представлять индексы массивов в виде Float64-значений — это будет означать пустую трату системных ресурсов, так как движку тогда понадобилось бы преобразовывать Float64-числа в формат с дополнением до двух и обратно всякий раз, когда кто-то обращается к элементу массива.
Представление 32-битных чисел с дополнением до двух полезно не только для оптимизации работы с массивами. В целом можно отметить, что процессор выполняет целочисленные операции гораздо быстрее, чем операции, в которых используются значения с плавающей точкой. Именно поэтому в следующем примере первый цикл без проблем оказывается в два раза быстрее в сравнении со вторым циклом.
То же самое применимо и к вычислениям с использованием математических операторов.
Например, производительность оператора взятия остатка от деления из следующего фрагмента кода зависит от того, какие числа участвуют в вычислениях.
Если оба операнда представлены целыми числами, то процессор может вычислить результат весьма эффективно. В V8 есть дополнительная оптимизация для тех случаев, когда операнд
Так как целочисленные операции обычно выполняются гораздо быстрее операций над значениями с плавающей точкой, может показаться, что движки могут просто всегда хранить все целые числа и все результаты целочисленных операций в формате с дополнением до двух. К несчастью, такой подход означал бы нарушение спецификации ECMAScript. Как уже было сказано, в стандарте предусмотрено представление чисел в формате Float64, а некоторые операции с целыми числами могут приводить к появлению результатов в виде чисел с плавающей точкой. Важно, чтобы в подобных ситуациях JS-движки выдавали бы корректные результаты.
Даже хотя в предыдущем примере все числа, находящиеся в левой части выражений, являются целыми, все числа в правой части выражения являются значениями с плавающей точкой. Именно поэтому ни одна из предыдущих операций не может быть выполнена корректно с использованием 32-битного формата с дополнением до двух. JavaScript-движкам приходится уделять особое внимание тому, чтобы при выполнении целочисленных операций получались бы правильные (хотя и способные выглядеть необычно — как в предыдущем примере) Float64-результаты.
В случае с маленькими целыми числами, укладывающимися в диапазон 31-битного представления целых чисел со знаком, V8 использует особое представление, называемое
Как видно из предыдущего примера, некоторые JS-числа представляются в виде
Поговорим о том, как выглядит внутреннее устройство этих механизмов. Предположим, у нас имеется следующий объект:
Значение 42 свойства объекта
Хранение различных значений
Предположим, что мы выполняем следующий фрагмент JavaScript-кода:
В данном случае значение свойства
Новое значение свойства x сохраняется там же, где хранилось предыдущее значение
Однако новое значение
Новая сущность HeapNumber для хранения нового значения y
Сущности
При выполнении данной операции мы можем просто сослаться на ту же сущность
Один из недостатков иммутабельности сущностей
При обработке первой строки создаётся экземпляр
Сущности HeapNumber
Для того чтобы избежать этой проблемы в V8 имеется оптимизация, представляющая собой механизм обновления числовых полей, значения которых не укладываются в диапазон
Использование сущностей MutableHeapNumber
В результате после того, как значение поля меняется, V8 больше не нужно выделять память под новую сущность
Запись нового значения в MutableHeapNumber
Однако и у этого подхода есть свои недостатки. А именно, так как значения
Недостатки MutableHeapNumber
Например, если присвоить значение
В случае с числами с плавающей точкой V8 выполняет вышеописанные операции упаковки с использованием своих внутренних механизмов. Но в случае с маленькими целыми числами использование
Для того чтобы избежать неэффективного использования системных ресурсов всё, что нам нужно сделать для работы с маленькими целыми числами, заключается в том, чтобы отмечать соответствующие им поля в формах объектов как
Работа с целыми числами, значения которых укладываются в диапазон Smi
Продолжение следует…
Уважаемые читатели! Сталкивались ли вы с проблемами производительности JavaScript-кода, вызванными особенностями JS-движков?
Типы данных в 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
имеет собственный тип — Null
. Для того чтобы понять причину подобного поведения typeof
, примем во внимание то, что множество всех JavaScript-типов может быть разделено на две группы:- Объекты (то есть — тип
Object
). - Примитивные значения (то есть — любые необъектные значения).
В свете этого знания оказывается, что
null
означает «отсутствие объектного значения», в то время как undefined
— это «отсутствие значения».Примитивные значения, объекты, null и undefined
Следуя этим размышлениям в духе Java, Брендан Эйх спроектировал JavaScript так, чтобы оператор
typeof
возвращал бы 'object'
для значений тех типов, которые расположены на предыдущем рисунке справа. Сюда попадают все объектные значения и null
. Именно поэтому истинным является выражение typeof null === 'object'
несмотря на то, что в спецификации языка имеется отдельный тип Null
.Выражение typeof v === 'object' истинно
Представление значений
JavaScript-движки должны иметь возможность представления любых JavaScript-значений в памяти. Однако важно отметить то, что типы значений в JavaScript отделены от того, как JS-движки представляют их в памяти.
Например, значение 42 в JavaScript имеет тип
number
.typeof 42;
// 'number'
Существует несколько способов представления в памяти целых чисел наподобие 42:
Представление |
Биты |
8 бит, с дополнением до двух |
0010 1010 |
32 бита, с дополнением до двух |
0000 0000 0000 0000 0000 0000 0010 1010 |
Упакованное двоично-десятичное число (binary-coded decimal, BCD) |
0100 0010 |
32 бита, число с плавающей точкой IEEE-754 |
0100 0010 0010 1000 0000 0000 0000 0000 |
64 бита, число с плавающей точкой IEEE-754 |
0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 |
В соответствии со стандартом ECMAScript числа — это 64-битные значения с плавающей точкой, известные как числа с плавающей точкой двойной точности (Float64). Однако это не означает того, что JavaScript-движки всегда хранят числа в представлении Float64. Это было бы очень и очень неэффективно! Движки могут использовать другие внутренние представления чисел — до тех пор, пока поведение значений в точности соответствует тому, как ведут себя Float64-числа.
Большинство чисел в реальных JS-приложениях, как оказалось, являются действительными индексами ECMAScript-массивов. То есть — целыми числами в диапазоне от 0 до 232-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. Как уже было сказано, в стандарте предусмотрено представление чисел в формате Float64, а некоторые операции с целыми числами могут приводить к появлению результатов в виде чисел с плавающей точкой. Важно, чтобы в подобных ситуациях JS-движки выдавали бы корректные результаты.
// Во Float64 имеется безопасный 53-битный целочисленный диапазон.
// Выход за пределы этого диапазона ведёт к потере точности.
2**53 === 2**53+1;
// true
// Float64 поддерживает отрицательные нули, в результате -1 * 0 должно дать -0, но
// в формате с дополнением до двух нет способа представления отрицательного нуля.
-1*0 === -0;
// true
// Float64 поддерживает значение Infinity, получить которое можно,
// разделив некое число на ноль.
1/0 === Infinity;
// true
-1/0 === -Infinity;
// true
// Float64 поддерживает и значения NaN.
0/0 === NaN;
Даже хотя в предыдущем примере все числа, находящиеся в левой части выражений, являются целыми, все числа в правой части выражения являются значениями с плавающей точкой. Именно поэтому ни одна из предыдущих операций не может быть выполнена корректно с использованием 32-битного формата с дополнением до двух. JavaScript-движкам приходится уделять особое внимание тому, чтобы при выполнении целочисленных операций получались бы правильные (хотя и способные выглядеть необычно — как в предыдущем примере) Float64-результаты.
В случае с маленькими целыми числами, укладывающимися в диапазон 31-битного представления целых чисел со знаком, V8 использует особое представление, называемое
Smi
. Всё, что не является значением Smi
, представляется в виде значения 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
Как видно из предыдущего примера, некоторые JS-числа представляются в виде
Smi
, а некоторые — в виде HeapNumber
. Движок V8 оптимизирован в плане обработки Smi
-чисел. Дело в том, что маленькие целые числа весьма часто встречаются в реальных JS-программах. При работе со Smi
-значениями не нужно выделять память под отдельные сущности. Их использование, кроме того, позволяет выполнять быстрые операции с целыми числами.Сравнение Smi, HeapNumber и 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
может быть обновлено в месте его хранения. Дело в том, что новое значение x
равно 52, а это число укладывается в диапазон Smi
.Новое значение свойства x сохраняется там же, где хранилось предыдущее значение
Однако новое значение
y
, 5.2, не укладывается в диапазон Smi
, и оно, кроме того, отличается от предыдущего значения y — 4.2. В результате V8 приходится выделять память под новую сущность HeapNumber
и ссылаться из объекта уже на неё.Новая сущность HeapNumber для хранения нового значения y
Сущности
HeapNumber
являются иммутабельными. Это позволяет реализовать некоторые оптимизации. Предположим, мы хотим присвоить свойству объекта x
значение свойства y
:o.x = o.y;
// o.x теперь равно 5.2
При выполнении данной операции мы можем просто сослаться на ту же сущность
HeapNumber
, а не выделять дополнительную память для хранения одного и того же значения.Один из недостатков иммутабельности сущностей
HeapNuber
заключается в том, что частое обновление полей, значения в которых выходят за пределы диапазона 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. В результате в процессе выполнения этого кода создаётся 6 экземпляров HeapNumber
, пять из которых будут подвергнуты операции сборки мусора после завершения работы цикла.Сущности HeapNumber
Для того чтобы избежать этой проблемы в V8 имеется оптимизация, представляющая собой механизм обновления числовых полей, значения которых не укладываются в диапазон
Smi
, в тех же местах, где они уже хранятся. Если числовое поле хранит значения, для хранения которых сущность Smi
не подходит, то V8, в форме объекта, помечает это поле как Double
и выделяет память под сущность MutableHeapNumber
, которая хранит реальное значение, представленное в формате Float64.Использование сущностей MutableHeapNumber
В результате после того, как значение поля меняется, V8 больше не нужно выделять память под новую сущность
HeapNumber
. Вместо этого достаточно записать новое значение в уже имеющуюся сущность MutableHeapNumber
.Запись нового значения в MutableHeapNumber
Однако и у этого подхода есть свои недостатки. А именно, так как значения
MutableHeapNumber
могут меняться — важно обеспечивать такую работу системы, при которой эти значения будут вести себя так, как это предусмотрено в спецификации языка.Недостатки MutableHeapNumber
Например, если присвоить значение
o.x
некоей другой переменной y
, то нужно, чтобы значение y
не изменялось бы при последующем изменении o.x
. Это было бы нарушением спецификации JavaScript! В результате, когда осуществляется доступ к o.x
, число, прежде чем оно будет присвоено y
, должно быть переупаковано в обычное значение HeapNumber
.В случае с числами с плавающей точкой V8 выполняет вышеописанные операции упаковки с использованием своих внутренних механизмов. Но в случае с маленькими целыми числами использование
MutableHeapNumber
было бы пустой тратой времени из-за того, что Smi
— это более эффективный способ представления таких чисел.const object = { x: 1 };
// "Упаковка" свойства `x` объекта не выполняется
object.x += 1;
// обновление значения `x` внутри объекта
Для того чтобы избежать неэффективного использования системных ресурсов всё, что нам нужно сделать для работы с маленькими целыми числами, заключается в том, чтобы отмечать соответствующие им поля в формах объектов как
Smi
. В результате значения этих полей, до тех пор, пока они соответствуют диапазону Smi
, можно обновлять прямо внутри объектов.Работа с целыми числами, значения которых укладываются в диапазон Smi
Продолжение следует…
Уважаемые читатели! Сталкивались ли вы с проблемами производительности JavaScript-кода, вызванными особенностями JS-движков?