История о V8, React и падении производительности. Часть 1

Автор оригинала: Benedikt Meyrer, Mathias Bynens
  • Перевод
В материале, первую часть перевода которого мы публикуем сегодня, речь пойдёт о том, как JavaScript-движок V8 выбирает оптимальные способы представления различных JS-значений в памяти, и о том, как это влияет на внутренние механизмы V8, касающиеся работы с так называемыми формами объектов (Shape). Всё это поможет нам разобраться с сутью недавней проблемы, касающейся производительности React.



Типы данных в 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-движков?

  • +35
  • 10,4k
  • 5
RUVDS.com
1 047,56
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией

Комментарии 5

    0
    При работе с многомерным массивом занимающим всего 3Gb в памяти, хром с его V8 ведет себя неадекватно, периодически считая, что раз выделяется столько памяти то это утечка и крашит страницу без возможности перехватить в исключениях (и эти люди делают ChromeOS, и говорят что то о смещении от сайтов к веб-приложениям..). Firefox тоже не особо хорош при работе с большим количеством данных. Vivaldi, Opera и Edge (чакра) справлялись лучше всего.
      +1
      Не совсем понял, Вивальди это по сути собранный хромиум, как он мог вести себя лучше?
        0
        Это да, но вероятно правило которое говорит странице закрашится при быстром выделении памяти есть именно в хроме
        +1

        А вы пользовались обычными массивами или типизированными? Со вторыми такой проблемы, как рассказывается в статье, быть не должно, потому что там типы известны.

        +2

        Потеря точности, которую упомянули в комментарии к дабл числу не совсем верно.
        Даже если говорить о потери точности в целой части, то 53 степень это уже много. Так как мантисса имеет всего 52 бита.
        Потеря точности в флоат поинт числах совсем о другом. Могу расписать подробнее, когда сяду с компа.

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

        Самое читаемое