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

Автор оригинала: Benedikt Meyrer, Mathias Bynens
  • Перевод
Сегодня мы публикуем вторую часть перевода материала, посвящённого внутренним механизмам V8 и расследованию проблемы с производительностью React.



Первая часть

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


Что если поле изначально содержало 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 необходимо найти так называемую форму разделения (split shape). Это — последняя форма в цепочке, находящаяся до формы, в которой появляется соответствующее свойство. Здесь мы меняем y, то есть — нам надо найти последнюю форму, в которой не было y. В нашем примере это — форма, в которой появляется x.


Поиск последней формы, в которой не было изменённого значения

Здесь мы, начиная с этой формы, создаём новую цепочку переходов для y, которая воспроизводит все предыдущие переходы. Только теперь свойство 'y' будет представлено в виде Double. Теперь мы используем эту новую цепочку переходов для y, помечая как устаревшее старое поддерево. На последнем шаге мы осуществляем миграцию экземпляра объекта o на новую форму, используя теперь для хранения значения y сущность 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 — это приводит к выполнению особого перехода к новой форме, которая отмечена как нерасширяемая. Этот особый переход не приводит к появлению некоего нового свойства. Это, на самом деле, просто маркер.


Результат обработки объекта с помощью метода Object.preventExtensions()

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

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


Теперь давайте соберём всё то, о чём мы говорили, и воспользуемся полученными знаниями для понимания сущности недавней проблемы с производительностью React. Когда команда 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(). Это приводило к тому, что эти значения уже нельзя было представить в виде Smi. Для представления этих полей теперь требовались сущности Double. Вдобавок ко всему этому в React ещё и предотвращалось расширение экземпляров класса FiberNode.

Изначально наш упрощённый пример можно было бы представить в следующем виде.


Начальное состояние системы

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


V8 оказывается в замешательстве

V8 назначает новую «осиротевшую» форму объекту node1. То же самое немного позже происходит и с объектом node2. В результате у нас теперь имеются две «осиротевшие» формы, каждая из которых используется только одним объектом. Во множестве реальных React-приложений количество подобных объектов куда больше, чем два. Это могут быть десятки или даже тысячи объектов класса FiberNode. Несложно понять, что подобная ситуация не особенно хорошо сказывается на производительности V8.

К счастью мы исправили эту проблему в V8 v7.4, и мы исследуем возможность того, чтобы сделать операцию изменения представления полей объектов менее ресурсозатратной. Это позволит нам решить оставшиеся проблемы с производительностью, возникающие в подобных ситуациях. V8, благодаря исправлению, теперь правильно ведёт себя в вышеописанной проблемной ситуации.


Начальное состояние системы

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


Результаты присвоения нового значения свойству node1.actualStartTime

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


Результаты присвоения нового значения свойству node2.actualStartTime

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

Обратите внимание на то, что операции по пометке форм объектов в виде устаревших и их миграция может выглядеть как нечто сложное. На самом деле — так оно и есть. Мы подозреваем, что на реальных веб-сайтах это приносит больше вреда (в плане производительности, использования памяти, сложности), чем пользы. Особенно — после того, как, в случае со сжатием указателей, мы больше не можем использовать этот подход для хранения Double-полей в виде значений, встроенных в объекты. В результате мы надеемся полностью отказаться от механизма устаревания форм объектов V8 и сделать сам этот механизм устаревшим.

Надо отметить, что команда React решила рассматриваемую проблему своими силами, сделав так, чтобы поля в объектах класса FiberNodes изначально были бы представлены значениями 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-движка. Однако полезно иметь возможность что-то исправить, оптимизируя код, в том случае, если причины неких ошибок коренятся в особенностях движка.

Стоит помнить о том, что в недрах JS-движков происходит много всяких удивительных вещей. JS-разработчик может помочь всем этим механизмам, по возможности не присваивая одним и тем же переменным значения разных типов. Например, не стоит инициализировать числовые поля значением null, так как это сведёт на нет все преимущества от наблюдения за представлением поля и улучшит читаемость кода:

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

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

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

Итоги


В этом материале мы рассмотрели следующие важные вопросы:

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

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

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

Уважаемые читатели! Оптимизировали ли вы когда-нибудь свой код в расчёте на внутренние особенности неких JavaScript-движков?

  • +33
  • 6,5k
  • 1
RUVDS.com
1 057,11
RUVDS – хостинг VDS/VPS серверов
Поделиться публикацией

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

    +1
    Спасибо, отличный перевод (и сам разбор, разумеется)!
    Интересный пример leaky abstraction.

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

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