Хабрахабр

[Перевод] Оптимизация работы с прототипами в JavaScript-движках

Материал, перевод которого мы сегодня публикуем, подготовили Матиас Байненс и Бенедикт Мейрер. Они занимаются работой над JS-движком V8 в Google. Эта статья посвящена некоторым базовым механизмам, которые характерны не только для V8, но и для других движков. Знакомство с внутренним устройством подобных механизмов позволяет тем, кто занимается JavaScript-разработкой, лучше ориентироваться в вопросах производительности кода. В частности, здесь речь пойдёт об особенностях работы конвейеров оптимизации движков, и о том, как осуществляется ускорение доступа к свойствам прототипов объектов.

Уровни оптимизации кода и компромиссные решения

Процесс превращения текстов программ, написанных на JavaScript, в пригодный для выполнения код, в разных движках выглядит примерно одинаково.

Процесс преобразования исходного JS-кода в исполняемый код

Кроме того, надо отметить, что хотя, на высоком уровне, конвейеры преобразования исходного кода в исполняемый у различных движков очень похожи, их системы оптимизации кода часто различаются. Подробности об этом можно посмотреть здесь. Почему у одних движков больше уровней оптимизации, чем у других? Почему это так? Оказывается, движкам приходится, так или иначе, идти на компромисс, который заключается в том, что они могут либо быстро сформировать код, не самый эффективный, но пригодный для выполнения, либо потратить на создание такого кода больше времени, но, за счёт этого, добиться оптимальной производительности.

Быстрая подготовка кода к выполнению и оптимизированный код, который готовится дольше, но быстрее выполняется

Оптимизирующему компилятору, с другой стороны, требуется больше времени на формирование кода, но в итоге у него получается оптимизированный, более быстрый машинный код. Интерпретатор способен быстро сформировать байт-код, но такой код обычно не отличается особенной эффективностью.

Интерпретатор V8 называется Ignition, он является самым быстрым из существующих интерпретаторов (в плане выполнения исходного байт-кода). Именно такая модель подготовки кода к выполнению используется в V8. Оптимизирующий компилятор V8 называется TurboFan, он отвечает за создание высокооптимизированного машинного кода.

Интерпретатор Ignition и оптимизирующий компилятор TurboFan

Например, в SpiderMonkey, между интерпретатором и оптимизирующим компилятором IonMonkey, имеется промежуточный уровень, представленный базовым компилятором (он, в документации Mozilla, называется «The Baseline Compiler», но «baseline» — это не имя собственное). Компромисс между задержкой запуска программы и скоростью выполнения является причиной наличия у некоторых JS-движков дополнительных уровней оптимизации.

Уровни оптимизации кода в SpiderMonkey

Базовому компилятору, для того, чтобы сгенерировать код, требуется больше времени, но этот код работает уже быстрее. Интерпретатор быстро генерирует байт-код, но выполняется такой код сравнительно медленно. И, наконец, оптимизирующему компилятору IonMonkey требуется больше всего времени на создание машинного кода, но этот код может выполняться очень эффективно.

В представленном здесь примере имеется «горячий» цикл, содержащий код, который повторяется очень много раз. Взглянем на конкретный пример и посмотрим на то, как обращаются с кодом конвейеры различных движков.

let result = 0;
for (let i = 0; i < 4242424242; ++i) { result += i;
}
console.log(result);

V8 начинает выполнять байт-код в интерпретаторе Ignition. В некий момент времени движок выясняет, что код это «горячий», и запускает фронтенд TurboFan, который является частью TurboFan, работающей с данными профилирования и создающей базовое машинное представление кода. Затем данные передаются оптимизатору TurboFan, работающему в отдельном потоке, для дальнейших улучшений.

Оптимизация «горячего» кода в V8

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

Но у него имеется дополнительный уровень, представленный базовым компилятором, что приводит к тому, что «горячий» код сначала попадает к этому компилятору. Движок SpiderMonkey тоже начинает выполнять байт-код в интерпретаторе. Он генерирует базовый код в главном потоке, переход на выполнение этого кода производится тогда, когда он будет готов.

Оптимизация «горячего» кода в SpiderMonkey

Базовый код продолжает выполняться в процессе оптимизации кода, выполняемой IonMonkey. Если базовый код выполняется достаточно долго, SpiderMonkey, в итоге, запускает фронтенд IonMonkey и оптимизатор, что очень похоже на то, что происходит в V8. В итоге, когда оптимизация оказывается завершённой, вместо базового кода выполняется оптимизированный код.

Вместо того чтобы решать какие-либо задачи компиляции в главном потоке, Chakra копирует и отправляет байт-код и данные профилирования, которые, весьма вероятно, понадобятся компилятору, в отдельный процесс компиляции.
Архитектура движка Chakra очень похожа на архитектуру SpiderMonkey, но Chakra стремится к более высокому уровню параллелизма для того, чтобы избежать блокировки главного потока.

Оптимизация «горячего» кода в Chakra

Этот процесс повторяется для перехода к выполнению кода, подготовленного средствами FullJIT. Когда сгенерированный код, подготовленный средствами SimpleJIT, будет готов, движок будет выполнять его вместо байт-кода. Однако минусом такого подхода является тот факт, что эвристические алгоритмы копирования могут пропустить какую-то информацию, которая может оказаться полезной для выполнения некоей оптимизации. Преимущество такого подхода заключается в том, что паузы, связанные с копированием данных, обычно гораздо короче, чем те, которые вызваны работой полноценного компилятора (фронтенда). Здесь мы видим пример компромисса между качеством получаемого кода и задержками.

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

Оптимизация «горячего» кода в JavaScriptCore

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

Однако, это далеко не все проблемы, которые встают перед движками. Только что мы обсудили компромиссы, на которые вынуждены идти движки, выбирая между быстрым генерированием кода с помощью интерпретаторов и созданием быстрого кода с помощью оптимизирующих компиляторов. Для того чтобы это продемонстрировать, рассмотрим простую JS-программу, которая складывает числа. Память — это ещё один системный ресурс, при использовании которого приходится прибегать к компромиссным решениям.

function add(x, y) { return x + y;
} add(1, 2);

Вот байт-код функции add, сгенерированный интерпретатором Ignition в V8:

StackCheck
Ldar a1
Add a0, [0]
Return

В смысл этого байт-кода можно не вдаваться, на самом деле, его содержимое нас не особенно интересует. Главное здесь то, что в нём всего четыре инструкции.

Когда подобный фрагмент кода оказывается «горячим», за дело принимается TurboFan, который генерирует следующий высокооптимизированный машинный код:

leaq rcx,[rip+0x0]
movq rcx,[rcx-0x37]
testb [rcx+0xf],0x1
jnz CompileLazyDeoptimizedCode
push rbp
movq rbp,rsp
push rsi
push rdi
cmpq rsp,[r13+0xe88]
jna StackOverflow
movq rax,[rbp+0x18]
test al,0x1
jnz Deoptimize
movq rbx,[rbp+0x10]
testb rbx,0x1
jnz Deoptimize
movq rdx,rbx
shrq rdx, 32
movq rcx,rax
shrq rcx, 32
addl rdx,rcx
jo Deoptimize
shlq rdx, 32
movq rax,rdx
movq rsp,rbp
pop rbp
ret 0x18

Как видите, объём кода, в сравнении с вышеприведённым примером из четырёх инструкций, очень велик. Как правило, байт-код оказывается гораздо компактнее, чем машинный код, а в особенности — оптимизированный машинный код. С другой стороны, для выполнения байт-кода нужен интерпретатор, а оптимизированный код можно выполнять прямо на процессоре.
Это — одна из основных причин того, почему JavaScript-движки не занимаются оптимизацией абсолютно всего кода. Как мы видели ранее, создание оптимизированного машинного кода занимает много времени, и, более того, как мы только что выяснили, для хранения оптимизированного машинного кода требуется больше памяти.

Использование памяти и уровень оптимизации

Если говорить об уровнях оптимизации кода, применяемых в движках, то, чем их больше, тем более тонким оптимизациям может быть подвергнут код, но достигается это за счёт усложнения движков и за счёт дополнительной нагрузки на систему. В итоге можно сказать, что причина, по которой JS-движки имеют различные уровни оптимизации, заключается в фундаментальной проблеме выбора между быстрым генерированием кода, например, с помощью интерпретатора, и генерированием быстрого кода, выполняемым средствами оптимизирующего компилятора. Именно поэтому JS-движки стараются оптимизировать лишь «горячие» функции. Кроме того, тут нельзя забывать и о том, что уровень оптимизации кода влияет на объём памяти, который занимает этот код.

Оптимизация доступа к свойствам прототипов объектов

JavaScript-движки занимаются оптимизацией доступа к свойствам объектов благодаря использованию так называемых форм объектов (Shape) и инлайн-кэшей (Inline Cache, IC). Подробности об этом можно почитать в данном материале, если же выразить это в двух словах, то можно сказать, что движок хранит форму объекта отдельно от значений объекта.

Объекты, имеющие одну и ту же форму

Совместное использование форм объектов и инлайн-кэшей позволяет ускорить повторяющиеся операции обращения к свойствам объектов, выполняемые из одного и того же места кода.
Использование форм объектов даёт возможность выполнить оптимизацию, называемую инлайн-кэшированием.

Ускорение доступа к свойству объекта

Классы и прототипы

Теперь, когда мы знаем о том, как ускорить доступ к свойствам объектов в JavaScript, взглянем на одно из недавних новшеств JavaScript — на классы. Вот как выглядит объявление класса:

class Bar getX() { return this.x; }
}

Хотя выглядеть это может как появление в JS совершенно новой концепции, классы, на самом деле — лишь синтаксический сахар для прототипной системы конструирования объектов, которая присутствовала в JavaScript всегда:

function Bar(x) { this.x = x;
} Bar.prototype.getX = function getX() { return this.x;
};

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

Приглядимся к тому, что происходит, так сказать, за кулисами, когда мы создаём новый экземпляр объекта Bar, назначая его константе foo.

const foo = new Bar(true);

После выполнения подобного кода у экземпляра созданного здесь объекта будет форма, содержащая единственное свойство x. Прототипом объекта foo будет Bar.prototype, который принадлежит классу Bar.

Объект и его прототип

Прототип прототипа Bar.prototype — это Object.prototype, являющийся частью языка. У Bar.prototype имеется собственная форма, содержащая единственное свойство getX, значением которого является функция, которая, при её вызове, возвращает значение this.x. Object.prototype является корневым элементом дерева прототипов, поэтому его прототип — это значение null.

Посмотрим теперь, что произойдёт, если создать ещё один объект типа Bar.

Несколько объектов одного типа

Оба они используют и один и тот же прототип — объект Bar.prototype. Как видно, и объект foo, и объект qux, являющиеся экземплярами класса Bar, как мы уже говорили, используют одну и ту же форму объекта.

Доступ к свойствам прототипа

Итак, теперь мы знаем о том, что происходит, когда мы объявляем новый класс и создаём его экземпляры. А что можно сказать о вызове метода объекта? Рассмотрим следующий фрагмент кода:

class Bar { constructor(x) { this.x = x; } getX() { return this.x; }
} const foo = new Bar(true);
const x = foo.getX();
// ^^^^^^^^^^

Вызов метода можно воспринимать как операцию, состоящую из двух шагов:

const x = foo.getX(); // на самом деле эта операция состоит из двух шагов: const $getX = foo.getX;
const x = $getX.call(foo);

На первом шаге осуществляется загрузка метода, который представляет собой всего лишь свойство прототипа (значением которого оказывается функция). На втором шаге производится вызов функции с установкой this. Рассмотрим первый шаг, на котором выполняется загрузка метода getX из объекта foo:

Загрузка метода getX из объекта foo

Это значит, что движку надо просмотреть цепочку прототипов объекта для того, чтобы найти этот метод. Движок анализирует объект foo и выясняет, что в форме объекта foo нет свойства getX. Там он находит нужное свойство по смещению 0. Движок обращается к прототипу Bar.prototype и смотрит на форму объекта этого прототипа. На этом поиск метода завершается. Далее, осуществляется обращение к значению, хранящемуся по этому смещению в Bar.prototype, там обнаруживается JSFunction getX — а это как раз то, что мы ищем.

Например, так: Гибкость JavaScript даёт возможность менять цепочки прототипов.

const foo = new Bar(true);
foo.getX();
// true Object.setPrototypeOf(foo, null);
foo.getX();
// Uncaught TypeError: foo.getX is not a function

В этом примере мы вызываем метод foo.getX() дважды, но каждый из этих вызовов имеет совершенно разный смысл и результат. Именно поэтому, хотя прототипы в JavaScript — это всего лишь объекты, ускорение доступа к свойствам прототипов — это задача даже более сложная для JS-движков, чем ускорение доступа к собственным свойствам обычных объектов.

Она выполняется каждый раз, когда производится вызов метода. Если рассмотреть реально существующие программы, то окажется, что загрузка свойств прототипов — это операция, которая встречается очень часто.

class Bar { constructor(x) { this.x = x; } getX() { return this.x; }
} const foo = new Bar(true);
const x = foo.getX();
// ^^^^^^^^^^

Ранее мы говорили о том, как движки оптимизируют загрузку обычных, собственных свойств объектов благодаря использованию форм объектов и инлайн-кэшей. Как оптимизировать повторяющиеся загрузки свойств прототипа для объектов с одной и той же формой? Выше мы видели как производится загрузка свойств.

Загрузка метода getX из объекта foo

Для того чтобы ускорить доступ к методу при повторяющихся обращениях к нему, в нашем случае, нужно знать следующее:

  1. Форма объекта foo не содержит метода getX и не меняется. Это означает, что объект foo не модифицируют, добавляя в него свойства или удаляя их, или меняя атрибуты свойств.
  2. Прототипом foo всё ещё является исходный Bar.prototype. Это означает, что прототип foo не меняется с использованием метода Object.setPrototypeOf() или путём назначения нового прототипа специальному свойству _proto_.
  3. Форма Bar.prototype содержит getX и не меняется. То есть, Bar.prototype не изменяют, удаляя свойства, добавляя их, или меняя их атрибуты.

В общем случае это означает, что нам нужно произвести 1 проверку самого объекта, и 2 проверки для каждого прототипа вплоть до прототипа, хранящего свойство, которое мы ищем. То есть, нужно провести 1+2N проверок (где N — количество проверяемых прототипов), что в данном случае выглядит не так уж и плохо, так как цепочка прототипов у нас довольно короткая. Однако движкам часто приходится работать с гораздо более длинными цепочками прототипов. Это, например, характерно для обычных DOM-элементов. Вот пример:

const anchor = document.createElement('a');
// HTMLAnchorElement const title = anchor.getAttribute('title');

Тут у нас имеется HTMLAnchorElement и мы вызываем его метод getAttribute(). В цепочку прототипов этого простого элемента, представляющего HTML-ссылку, входит 6 прототипов! Большинство же интересных DOM-методов не находятся в собственном прототипе HTMLAnchorElement. Они находятся в прототипах, расположенных дальше в цепочке.

Цепочка прототипов

Это означает, что каждый раз, при вызове метода anchor.getAttribute(), движок вынужден выполнять следующие действия: Метод getAttribute() удаётся обнаружить в Element.prototype.

  1. Проверка самого объекта anchor на наличие getAttribute.
  2. Проверка того, что прямым прототипом объекта является HTMLAnchorElement.prototype.
  3. Выяснение, что в HTMLAnchorElement.prototype нет метода getAttribute.
  4. Проверка того, что следующим прототипом является HTMLElement.prototype.
  5. Выяснение того, что и здесь нет нужного метода.
  6. Наконец, выяснение того, что следующим прототипом является Element.prototype.
  7. Выяснение того, что тут имеется метод getAttribute.

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

Если вернуться к одному из предыдущих примеров, можно вспомнить, что при обращении к методу getX объекта foo, мы выполняем 3 проверки:

class Bar { constructor(x) { this.x = x; } getX() { return this.x; }
} const foo = new Bar(true);
const $getX = foo.getX;

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

Хранение ссылок на прототипы

Это ещё означает, что всякий раз, когда прототип foo меняется, движок переходит и к новой форме объекта. Каждая форма имеет ссылку на прототип. Теперь нам лишь надо проверить форму объекта на наличие в ней свойства и позаботиться о защите ссылки на прототип.

Однако такие операции всё ещё являются достаточно ресурсозатратными, так как между их количеством и длиной цепочки прототипов имеется линейная зависимость. Благодаря этому подходу мы можем уменьшить число проверок с 1+2N до 1+N, что позволит ускорить доступ к свойствам прототипов. Это особенно актуально в ситуациях, когда загрузка одного и того же свойства выполняется несколько раз. В движках реализованы различные механизмы, направленные на то, чтобы число проверок не зависело от длины цепочки прототипов, выражаясь константой.

Свойство ValidityCell

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

Свойство ValidityCell

Рассмотрим этот механизм подробнее. Это свойство объявляется недействительным при изменении прототипа, связанного с формой, или любого вышележащего прототипа.

Для того чтобы ускорить последовательные операции загрузки свойств из прототипов, V8 использует инлайн-кэш, содержащий четыре поля: ValidityCell, Prototype, Shape, Offset.

Поля инлайн-кэша

В ходе «разогрева» инлайн-кэша при первом запуске кода, V8 запоминает смещение, по которому свойство было найдено в прототипе, прототип, в котором было найдено свойство (в данном примере — Bar.prototype), форму объекта (foo в данном случае), и, кроме того, ссылку на текущий параметр ValidityCell непосредственного прототипа, ссылка на который имеется в форме объекта (в данном случае это тоже Bar.prototype).

Если показатель ValidityCell всё ещё является действительным, движок может напрямую воспользоваться сохранённым ранее смещением в прототипе, не выполняя дополнительные операции поиска. В следующий раз, когда произойдёт обращение к инлайн-кэшу, движку нужно будет проверить форму объекта и ValidityCell.

Как результат, при следующей попытке обращения к инлайн-кэшу пользы это не приносит, что приводит к ухудшению производительности.
Когда прототип меняется, создаётся новая форма, а предыдущее свойство ValidityCell объявляется недействительным.

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

Если вернуться к примеру с DOM-элементом, это означает, что любое изменение, например, в прототипе Object.prototype, приведёт не только к инвалидации инлайн-кэша для самого Object.prototype, но и для любых прототипов, расположенных ниже его в цепочке прототипов, включая EventTarget.prototype, Node.prototype, Element.prototype, и так далее, вплоть до HTMLAnchorElement.prototype.

Последствия изменения Object.prototype

Не делайте этого. Фактически, модификация Object.prototype в процессе выполнения кода означает нанесение серьёзного вреда производительности.

Предположим, у нас имеется класс Bar, и функция loadX, которая вызывает метод объектов, созданных на основе класса Bar. Исследуем вышесказанное на примере. Мы вызываем функцию loadX несколько раз, передавая ей экземпляры одного и того же класса.

function loadX(bar) { return bar.getX(); // IC для 'getX' в экземплярах `Bar`.
} loadX(new Bar(true));
loadX(new Bar(false));
// IC в `loadX` теперь указывает на `ValidityCell` для
// `Bar.prototype`. Object.prototype.newMethod = y => y;
// `ValidityCell` в IC `loadX` объявлено недействительным
// так как в `Object.prototype` внесены изменения.

Инлайн-кэш в loadX теперь указывает на ValidityCell для Bar.prototype. Если затем, скажем, изменить Object.prototype — корневой прототип в JavaScript, тогда значение ValidityCell окажется недействительным, и существующий инлайн-кэш не поможет ускорить работу при следующей попытке обращения к нему, что приведёт к ухудшению производительности.

Вот ещё один пример того, как поступать не следует: Менять Object.prototype — это всегда плохо, так как это приводит к инвалидации всех инлайн-кэшей для операций загрузки свойств из прототипов, созданных движком до момента изменения корневого прототипа.

Object.prototype.foo = function() { /* … */ }; // Особо важный фрагмент кода:
someObject.foo();
// Конец особо важного фрагмента кода. delete Object.prototype.foo;

Мы расширили Object.prototype, что привело к инвалидации инлайн-кэшей прототипов, ранее созданных движком. Затем мы запускаем некий код, использующий новый метод прототипа. Движку приходится начинать работу с инлайн-кэшами с чистого листа, создавать новые кэши для выполнения операций доступа к свойствам прототипов. Затем мы возвращаем всё к прежнему виду, «убираем за собой», удаляя метод прототипа, добавленный ранее.

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

Поэтому прототипы существующих объектов лучше всего не трогать. В результате можно сделать выводы о том, что хотя прототипы — это обычные объекты, JS-движки относятся к ним по-особому для того, чтобы оптимизировать производительность поиска в них методов. Так вы, по крайней мере, не разрушите результаты усилий по оптимизации кода, достигнутые движком во время его выполнения. А если вам действительно нужно их менять, то делайте это до того, как будет выполняться любой другой код.

Итоги

Из этого материала вы узнали о том, как JS-движки хранят объекты и классы, о том, как формы объектов, инлайн-кэши, свойства ValidityCell помогают оптимизировать операции, в которых участвуют прототипы объектов. На основе этих знаний мы вывели практическую рекомендацию по программированию на JavaScript, которая заключается в том, что прототипы лучше всего не менять (а если вам без этого совершенно невозможно обойтись, то, как минимум, делать это надо до выполнения остального кода программ).

Уважаемые читатели! Сталкивались ли вы на практике со случаями, когда низкую производительность какой-нибудь программы, написанной на JS, можно объяснить вмешательством в прототипы объектов во время выполнения кода?

Теги
Показать больше

Похожие статьи

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Кнопка «Наверх»
Закрыть