DEV Community

собачья будка
собачья будка

Posted on • Edited on

история обвала производительности v8 в react [перевод]

Вольный перевод статьи 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 имеет ровно один (в настоящий момент) из восьми типов: NumberStringSymbolBigIntBooleanUndefinedNull и Object.

https://v8.dev/_img/react-cliff/01-javascript-types.svg

С одним заметным исключением, 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'
Enter fullscreen mode Exit fullscreen mode

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

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

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

https://v8.dev/_img/react-cliff/02-primitives-objects.svg

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

https://v8.dev/_img/react-cliff/03-primitives-objects-typeof.svg

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

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

Значение 42, например, имее тип number.

typeof 42;
// → 'number'
Enter fullscreen mode Exit fullscreen mode

Есть несколько способов представления в памяти целого числа вроде 42:

Untitled

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

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

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

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

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

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

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

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

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

Если оба операнда представлены целыми числами, 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;
Enter fullscreen mode Exit fullscreen mode

Не смотря на то, что значения слева от знака целочисленны, все значения справа являются числами с плавающей запятой. Потому ни одна из операций, используя 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
Enter fullscreen mode Exit fullscreen mode

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

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

Smi против HeapNumber против MutableHeapNumber

Вот как это работает под капотом. Представим, что у вас есть объект такого вида:

const o = {
  x: 42,  // Smi
  y: 4.2, // HeapNumber
};
Enter fullscreen mode Exit fullscreen mode

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

https://v8.dev/_img/react-cliff/04-smi-vs-heapnumber.svg

А теперь представим, что у нас есть код такого вида:

o.x += 10;
// → o.x теперь имеет значение 52
o.y += 1;
// → o.y теперь имеет значение 5.2
Enter fullscreen mode Exit fullscreen mode

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

https://v8.dev/_img/react-cliff/05-update-smi.svg

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

https://v8.dev/_img/react-cliff/06-update-heapnumber.svg

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

o.x = o.y;
// → o.x теперь имеет значение 5.2
Enter fullscreen mode Exit fullscreen mode

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

https://v8.dev/_img/react-cliff/07-heapnumbers.svg

Один минус в иммутабельность HeapNumber в том, что процесс обновления полей со значениями, не попадающими в диапазон  Smi, будет замедляться. Например:

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

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

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

https://v8.dev/_img/react-cliff/08-garbage-heapnumbers.svg

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

https://v8.dev/_img/react-cliff/09-mutableheapnumber.svg

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

https://v8.dev/_img/react-cliff/10-update-mutableheapnumber.svg

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

https://v8.dev/_img/react-cliff/11-mutableheapnumber-to-heapnumber.svg

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

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

const object = { x: 1 };
// → для `x` в объекте конвертация не производится

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

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

https://v8.dev/_img/react-cliff/12-smi-no-boxing.svg

Устаревшие формы и миграции

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

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

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

y = a.x;
Enter fullscreen mode Exit fullscreen mode

Начинается все с того, что объекты одного вида, где x представлен в виде Smi:

https://v8.dev/_img/react-cliff/13-shape.svg

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

https://v8.dev/_img/react-cliff/14-shape-transition.svg

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

https://v8.dev/_img/react-cliff/15-shape-deprecation.svg

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

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

o.y = 0.1;
Enter fullscreen mode Exit fullscreen mode

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

https://v8.dev/_img/react-cliff/16-split-shape.svg

Начиная с формы разделения, мы создаем новую цепочку переходов для  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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

Давайте рассмотрим пример, когда у нас есть два объекта со свойством x, и ограничим добавление новых свойств у второго.

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

Object.preventExtensions(b);
Enter fullscreen mode Exit fullscreen mode

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

https://v8.dev/_img/react-cliff/17-shape-nonextensible.svg

Заметьте, мы не можем просто обновить форму x на месте, поскольку она все еще используется все еще расширяемым объектом a.

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

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

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

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

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

https://v8.dev/_img/react-cliff/18-repro-shape-setup.svg

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

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

https://v8.dev/_img/react-cliff/19-orphaned-shape.svg

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

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

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

const node1 = new FiberNode();
const node2 = new FiberNode();
Enter fullscreen mode Exit fullscreen mode

Поля, вроде actualStartTime, инициализируются со значениями 0 или -1, таким образом начиная со Smi представления. Но позже, временные метки из [performance.now()](https://w3c.github.io/hr-time/#dom-performance-now), хранящиеся в этих полях и являющиеся числами с плавающей запятой, заставляют их менять представление на Double, поскольку больше не помещаются в диапазонSmi. Более того, React также ограничивает перенастраиваемость для экземпляров FiberNode.

Первоначально упрощенный пример выглядел следующим образом:

https://v8.dev/_img/react-cliff/20-fibernode-shape.svg

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

https://v8.dev/_img/react-cliff/21-orphan-islands.svg

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

К счастью, мы починили эту утечку в V8 версии 7.4 и ищем способ облегчить операцию по смене представления, чтобы исправить оставшиеся утечки. Вместе с этим фиксом V8 начал делать всё правильно:

https://v8.dev/_img/react-cliff/22-fix.svg

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

https://v8.dev/_img/react-cliff/23-fix-fibernode-shape-1.svg

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

https://v8.dev/_img/react-cliff/24-fix-fibernode-shape-2.svg

После присвоения 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();
Enter fullscreen mode Exit fullscreen mode

Кроме Number.NaN может быть использовано любое значение с плавающей запятой, не входящее в диапазон Smi. Например, 0.000001Number.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;
Enter fullscreen mode Exit fullscreen mode

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

Заключение

Мы глубже рассмотрели следующие темы:

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

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

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

Top comments (0)