Перейти к основному содержимому

Более быстрая инициализация экземпляров с новыми функциями классов

· 11 мин. чтения
[Joyee Cheung](https://twitter.com/JoyeeCheung), инициализатор экземпляров

Классовые поля были внедрены в V8 начиная с версии v7.2, а приватные методы классов — с версии v8.4. После того как предложения достигли этапа 4 в 2021 году, началась работа по улучшению поддержки новых функций классов в V8. До этого существовало две основные проблемы, влияющие на их принятие:

  1. Инициализация классовых полей и приватных методов была значительно медленнее, чем присвоение обычных свойств.
  2. Инициализаторы классовых полей были неработоспособны в стартовых снимках, используемых встроенными приложениями, такими как Node.js и Deno, для ускорения их загрузки или загрузки пользовательских приложений.

Первая проблема была устранена в V8 v9.7, а исправление второй проблемы было выпущено в V8 v10.0. В этом посте рассматривается, как была устранена первая проблема. Чтобы прочитать об исправлении проблемы с снимками, ознакомьтесь с этим постом.

Оптимизация классовых полей

Чтобы устранить разрыв в производительности между присвоением обычных свойств и инициализацией классовых полей, мы обновили существующую систему встроенного кеширования (IC), чтобы она работала с последними. До версии v9.7 V8 всегда использовала дорогостоящий вызов режима выполнения для инициализации классовых полей. С версии v9.7, если V8 считает шаблон инициализации достаточно предсказуемым, он использует новый IC для ускорения операции, так же как это делается для присвоения обычных свойств.

Производительность инициализаций, оптимизированная

Производительность инициализаций, интерпретируемая

Оригинальная реализация классовых полей

Для реализации приватных полей V8 использует внутренние приватные символы — это внутренняя структура данных V8, похожая на стандартные Symbol, за исключением того, что они не являются перечисляемыми при использовании в качестве ключа свойства. Возьмем, к примеру, этот класс:

class A {
#a = 0;
b = this.#a;
}

V8 собирает инициализаторы классовых полей (#a = 0 и b = this.#a) и генерирует синтетическую функцию члена экземпляра с инициализаторами в качестве тела функции. Сгенерированный байт-код для этой синтетической функции раньше выглядел примерно так:

// Загружаем приватный символ имени для `#a` в r1
LdaImmutableCurrentContextSlot [2]
Star r1

// Загружаем 0 в r2
LdaZero
Star r2

// Перемещаем целевой объект в r0
Mov <this>, r0

// Используем функцию %AddPrivateField() для сохранения значения 0 как
// значения свойства, обозначенного приватным символом имени `#a` в экземпляре,
// то есть `#a = 0`.
CallRuntime [AddPrivateField], r0-r2

// Загружаем имя свойства `b` в r1
LdaConstant [0]
Star r1

// Загружаем приватный символ имени для `#a`
LdaImmutableCurrentContextSlot [2]

// Загружаем значение свойства, обозначенного `#a`, из экземпляра в r2
LdaKeyedProperty <this>, [0]
Star r2

// Перемещаем целевой объект в r0
Mov <this>, r0

// Используем функцию %CreateDataProperty() для сохранения свойства `b`,
// обозначенного `#a`, в качестве значения свойства `b`, то есть `b = this.#a`
CallRuntime [CreateDataProperty], r0-r2

Сравните класс в предыдущем фрагменте с таким классом:

class A {
constructor() {
this._a = 0;
this.b = this._a;
}
}

Технически эти два класса не эквивалентны, даже если игнорировать разницу в видимости между this.#a и this._a. Спецификация требует семантики "define" вместо семантики "set". То есть инициализация классовых полей не вызывает установки или ловушек set Proxy. Поэтому приближение первого класса должно использовать Object.defineProperty() вместо простых присвоений для инициализации свойств. Кроме того, оно должно вызывать исключение, если приватное поле уже существует в экземпляре (в случае, если целевой объект, который инициализируется, переопределен в базовом конструкторе чтобы быть другим экземпляром):

class A {
constructor() {
// Примерная трансляция функции %AddPrivateField():
const _a = %PrivateSymbol('#a')
if (_a in this) {
throw TypeError('Невозможно дважды инициализировать #a у одного и того же объекта');
}
Object.defineProperty(this, _a, {
writable: true,
configurable: false,
enumerable: false,
value: 0
});
// Примерная трансляция функции %CreateDataProperty():
Object.defineProperty(this, 'b', {
writable: true,
configurable: true,
enumerable: true,
value: this[_a]
});
}
}

Для реализации указанной семантики до завершения предложения V8 использовал вызовы функций во время выполнения, поскольку они более гибкие. Как показано в приведенном выше байткоде, инициализация публичных полей осуществлялась с помощью вызовов функции времени исполнения %CreateDataProperty(), тогда как инициализация приватных полей осуществлялась с помощью %AddPrivateField(). Поскольку вызов функций времени исполнения представляет собой значительные накладные расходы, инициализация полей класса была гораздо медленнее по сравнению с присваиванием обычных свойств объекта.

Тем не менее, в большинстве случаев различия в семантике незначительны. Было бы здорово достичь производительности оптимизированного присваивания свойств в этих случаях — поэтому была создана более оптимальная реализация после завершения предложения.

Оптимизация приватных полей класса и вычисляемых публичных полей класса

Чтобы ускорить инициализацию приватных полей класса и вычисляемых публичных полей класса, была введена новая механизм для интеграции в систему кеширования inline-кэш (IC) при обработке этих операций. Этот механизм состоит из трех взаимодействующих компонентов:

  • В генераторе байткода введен новый байткод DefineKeyedOwnProperty. Этот байткод используется при создании кода для узлов AST ClassLiteral::Property, представляющих инициализаторы полей класса.
  • В JIT-компиляторе TurboFan, соответствующая IR-операция JSDefineKeyedOwnProperty, которая может быть скомпилирована из нового байткода.
  • В системе IC, новый DefineKeyedOwnIC, который используется в интерпретаторе нового байткода, а также в коде, скомпилированном из нового IR-оператора. Для упрощения реализации новый IC переиспользует часть кода KeyedStoreIC, предназначенного для обычного хранения свойств.

Теперь, когда V8 обнаруживает следующий класс:

class A {
#a = 0;
}

Он генерирует следующий байткод для инициализатора #a = 0:

// Загрузить символ приватного имени для `#a` в r1
LdaImmutableCurrentContextSlot [2]
Star0

// Использовать байткод DefineKeyedOwnProperty для сохранения 0 в качестве значения
// свойства, ключ которого - символ приватного имени `#a` в экземпляре,
// то есть, `#a = 0`.
LdaZero
DefineKeyedOwnProperty <this>, r0, [0]

Когда инициализатор выполняется достаточное количество раз, V8 выделяет один слот вектора обратной связи для каждого инициализируемого поля. Слот содержит ключ добавляемого поля (в случае приватного поля это символ приватного имени) и пару скрытых классов, между которыми экземпляр переходил в результате инициализации поля. В последующих инициализациях IC использует обратную связь, чтобы убедиться, что поля инициализируются в том же порядке на экземплярах с одними и теми же скрытыми классами. Если инициализация соответствует шаблону, который ранее видел V8 (что обычно так и бывает), V8 использует быстрый путь и выполняет инициализацию с предварительно сгенерированным кодом вместо вызова функций времени исполнения, тем самым ускоряя операцию. Если инициализация не соответствует шаблону, который ранее видел V8, она возвращается к вызову функций времени исполнения для обработки медленных случаев.

Оптимизация именованных публичных полей класса

Чтобы ускорить инициализацию именованных публичных полей класса, мы переиспользовали существующий байткод DefineNamedOwnProperty, который вызывает DefineNamedOwnIC либо в интерпретаторе, либо через код, скомпилированный из IR-оператора JSDefineNamedOwnProperty.

Теперь, когда V8 обнаруживает следующий класс:

class A {
#a = 0;
b = this.#a;
}

Он генерирует следующий байткод для инициализатора b = this.#a:

// Загрузить символ приватного имени для `#a`
LdaImmutableCurrentContextSlot [2]

// Загрузить значение свойства, ключ которого `#a`, из экземпляра в r2
// Примечание: LdaKeyedProperty переименован в GetKeyedProperty в процессе рефакторинга
GetKeyedProperty <this>, [2]

// Использовать байткод DefineKeyedOwnProperty для хранения свойства, ключ которого
// `#a`, в качестве значения свойства, ключ которого `b`, то есть, `b = this.#a;`
DefineNamedOwnProperty <this>, [0], [4]

Оригинальный механизм DefineNamedOwnIC нельзя было просто подключить к обработке именованных публичных полей класса, поскольку изначально он был предназначен только для инициализации литералов объектов. Ранее он ожидал, что целевой объект, подвергающийся инициализации, еще не был изменен пользователем с момента своего создания, что всегда было верным для инициализации литералов объектов, но поля класса могут быть инициализированы на объектах, определенных пользователем, когда класс расширяет базовый класс, конструктор которого переопределяет объект:

class A {
constructor() {
return new Proxy(
{ a: 1 },
{
defineProperty(object, key, desc) {
console.log(‘object:, object);
console.log(‘key:, key);
console.log(‘desc:, desc);
return true;
}
});
}
}

class B extends A {
a = 2;
#b = 3; // Не наблюдаемо.
}

// object: { a: 1 },
// key: ‘a’,
// desc: {value: 2, writable: true, enumerable: true, configurable: true}
new B();

Для работы с этими целями мы изменили механизм IC, чтобы он переходил к выполнению в режиме runtime, если обнаруживается, что объект при инициализации является прокси, если поле, которое определяется, уже существует на объекте, или если объект просто имеет скрытый класс, который ранее не был замечен IC. Всё ещё возможно оптимизировать крайние случаи, если они станут достаточно распространёнными, но пока что кажется лучшим решением пожертвовать их производительностью ради простоты реализации.

Оптимизация приватных методов

Реализация приватных методов

В спецификации приватные методы описаны так, как будто они устанавливаются в экземплярах, но не в классе. Однако для экономии памяти реализация V8 хранит приватные методы вместе с символом приватного бренда в контексте, связанном с классом. Когда вызывается конструктор, V8 хранит только ссылку на этот контекст в экземпляре, с символом приватного бренда в качестве ключа.

Оценка и создание классов с приватными методами

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

Доступ к приватным методам

Рассмотрим следующий пример:

class A {
#a() {}
}

V8 раньше генерировал следующий байт-код для конструктора A:

// Загрузить символ приватного бренда для класса A из контекста
// и сохранить его в r1.
LdaImmutableCurrentContextSlot [3]
Star r1

// Загрузить целевой объект в r0.
Mov <this>, r0
// Загрузить текущий контекст в r2.
Mov <context>, r2
// Вызвать функцию %AddPrivateBrand() в режиме runtime, чтобы сохранить контекст в
// экземпляре с символом приватного бренда в качестве ключа.
CallRuntime [AddPrivateBrand], r0-r2

Поскольку присутствовал вызов функции runtime %AddPrivateBrand(), это увеличивало накладные расходы и замедляло конструктор по сравнению с конструкторами классов, содержащих только публичные методы.

Оптимизация инициализации приватных брендов

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

// Загрузить символ приватного бренда для класса A из контекста
// и сохранить его в r1
LdaImmutableCurrentContextSlot [3]
Star0

// Использовать байт-код DefineKeyedOwnProperty, чтобы сохранить
// контекст в экземпляре с символом приватного бренда в качестве ключа
Ldar <context>
DefineKeyedOwnProperty <this>, r0, [0]

Производительность инициализации экземпляров классов с различными методами

Однако есть один нюанс: если класс является производным, конструктор которого вызывает super(), инициализацию приватных методов - а в нашем случае установку символа приватного бренда - необходимо выполнить после того, как super() вернёт управление:

class A {
constructor() {
// Это вызовет ошибку при вызове new B(), потому что super() ещё не вернул управление.
this.callMethod();
}
}

class B extends A {
#method() {}
callMethod() { return this.#method(); }
constructor(o) {
super();
}
};

Как описано ранее, при инициализации бренда V8 также сохраняет ссылку на контекст класса в экземпляре. Эта ссылка не используется при проверках бренда, но предназначена для отладки, чтобы получить список приватных методов из экземпляра, не зная, из какого класса он создан. Когда super() вызывается непосредственно в конструкторе, V8 может просто загрузить контекст из регистра контекста (что и делают операции Mov <context>, r2 или Ldar <context> в приведённых выше байт-кодах) для выполнения инициализации, но super() также может быть вызвано из вложенной стрелочной функции, которая, в свою очередь, может быть вызвана из другого контекста. В этом случае V8 переходит к функции runtime (по-прежнему называемой %AddPrivateBrand()), чтобы найти контекст класса в цепочке контекстов, вместо того чтобы полагаться на регистр контекста. Например, для функции callSuper, приведённой ниже:

class A extends class {} {
#method() {}
constructor(run) {
const callSuper = () => super();
// ...что-то делаем
run(callSuper)
}
};

new A((fn) => fn());

Теперь V8 генерирует следующий байт-код:

// Вызвать суперконструктор для создания экземпляра
// и сохранить его в r3.
...

// Загрузить символ приватного бренда из контекста класса на
// глубине 1 от текущего контекста и сохранить его в r4
LdaImmutableContextSlot <context>, [3], [1]
Star4

// Загрузить глубину 1 как Smi в r6
LdaSmi [1]
Star6

// Загрузить текущий контекст в r5
Mov <context>, r5

// Использовать %AddPrivateBrand(), чтобы найти контекст класса на
// глубине 1 от текущего контекста и сохранить его в экземпляре
// с символом приватного бренда в качестве ключа
CallRuntime [AddPrivateBrand], r3-r6

В этом случае стоимость вызова времени выполнения возвращается, поэтому инициализация экземпляров этого класса все равно будет медленнее по сравнению с инициализацией экземпляров классов, содержащих только публичные методы. Можно использовать специальный байт-код для реализации того, что делает %AddPrivateBrand(), но поскольку вызов super() во вложенной стрелочной функции встречается довольно редко, мы снова выбрали простоту реализации вместо производительности.

Заключительные замечания

Работы, упомянутые в этом блоге, также включены в Node.js 18.0.0 release. Ранее в Node.js произошел переход к символическим свойствам в некоторых встроенных классах, которые использовали приватные поля, с целью включения их в снимок начальной загрузки, а также для повышения производительности конструкторов (см. этот блог для получения дополнительного контекста). С улучшенной поддержкой функций классов в V8, Node.js перешел обратно к приватным полям классов в этих классах, и бенчмарки Node.js показали, что эти изменения не привели к уменьшению производительности.

Благодарим Igalia и Bloomberg за внесенный вклад в эту реализацию!