• XSS.stack #1 – первый литературный журнал от юзеров форума

Статья Warp: Улучшенная производительность JS в Firefox

sploitem

HDD-drive
Пользователь
Регистрация
18.05.2020
Сообщения
23
Реакции
26
13 Ноября 2020


Введение

Мы включили Warp, значительное обновление в SpiderMonkey, по умолчанию начиная с Firefox 83. SpiderMonkey это javascript движок в составе веб браузера Firefox.

С Warp (или WarpBuilder) мы внесли значительные изменения в JIT компиляторы, что улучшило отзывчивость, скорость загрузки страниц и оптимальное использование памяти. Новую архитектуру проще обслуживать и она открывает дополнительные улучшения SpiderMonkey.

В этой статье мы объясним как работает Warp и как он делает SpiderMonkey (SM) быстрее.


Как работает Warp


Несколько JIT компиляторов


Первым этапом выполнения js кода, является парсинг исходника в байткод. Байткод может быть тут же выполнен интерпретатором или скомпилирован в нативный код JIT компилятором. Современные js движки содержат многоуровневую архитектуру.

Js функции могут переключаться между уровнями, если это переключение выиграет в производительности:

Интерпретаторы и базовые JIT компиляторы умеют быстро компилировать, используя незначительную оптимизацию кода (в основном Inline Caches) и собирают данные профилирования.
Оптимизирующие JIT компиляторы применяют продвинутые оптимизации кода, но компилируют код намного дольше и используют больше памяти, поэтому используются для теплых\горячих js функций (вызывающихся много раз).

Оптимизирующие JIT делают предположения на основе данных профилирования, собранных другими уровнями. Елси предположения оказываются неверными, оптимизированный код отвергается. Выполнение функции возвращается на базовые уровни и должна снова стать теплой (этот процесс называется bailout).

Для SM упрощенно это выглядит так:

JIT-model1.png




Данные профилирования

Ion, предыдущий оптимизирующий JIT, использовал две разные системы для сбора информации о профилировании, которую использовал, как руководство для выполнения оптимизаций. Первая система Type Inference (TI), которая собирает глобальную информацию о типах объектов используемых в js коде. Вторая система CacheIR, простой формат байткода, использующийся базовым интерпретатором и базовым JIT компилятором, как фундаментальный примитив оптимизации. Ion в основном полагается на TI, но иногда использует информацию CacheIR, когда данные TI недоступны.

В Warp мы изменили наш оптимизирующий JIT, чтобы полагаться исключительно на данные CacheIR, собранные на базовых уровнях.

Вот как это выглядит:
warp-diagram-reduced2-500x383.png


Здесь много информации, но следует отметить, что мы заменили интерфейс IonBuilder (обведен красным) на более простой интерфейс WarpBuilder (обведен зеленым). IonBuilder и WarpBuilder создают Ion MIR, промежуточное представление, используемое оптимизирующим JIT бэкэндом.

Там, где IonBuilder использовал данные TI, собранные со всего движка, для генерации MIR, WarpBuilder генерирует MIR, используя тот же CacheIR, который базовый интерпретатор и базовый JIT используют для генерации встроенных кэшей (IC). Как мы увидим ниже, более тесная интеграция между Warp и нижними уровнями имеет несколько преимуществ.


Как работает CacheIR

Рассмотрим следующую JS-функцию:

JavaScript:
function f(o) {
    return o.x - 1;
}

Baseline Interpreter и Baseline JIT используют для этой функции два встроенных кэша: один для доступа к свойству (o.x), а другой для вычитания. Это потому, что мы не можем оптимизировать эту функцию, не зная типов o и o.x.

IC для доступа к свойству, o.x, будет вызываться со значением o. Затем он может присоединить заглушку IC (небольшой фрагмент машинного кода) для оптимизации этой операции. В SpiderMonkey это работает так что, сначала генерируется CacheIR (простой линейный формат байт-кода, вы можете рассматривать его как рецепт оптимизации). Например, если o - объект, а x - свойство с простыми данными, мы генерируем это:

Код:
GuardToObject        inputId 0
GuardShape           objId 0, shapeOffset 0
LoadFixedSlotResult  objId 0, offsetOffset 8
ReturnFromIC

Здесь мы сначала убеждаемся, что вход (o) - это объект, затем мы проверяем форму объекта (которая определяет свойства и макет объекта), а затем мы загружаем значение o.x из слотов объекта.

Обратите внимание, что форма и индекс свойства в массиве слотов хранятся в отдельном разделе данных, а не встроены в сам код CacheIR или IC. CacheIR обращается к смещениям этих полей с помощью shapeOffset и offsetOffset. Это позволяет множеству разных заглушек IC совместно использовать один и тот же сгенерированный код, уменьшая накладные расходы на компиляцию.

Затем IC компилирует этот фрагмент CacheIR в машинный код. Теперь Baseline Interpreter и Baseline JIT могут быстро выполнить эту операцию, не вызывая код C ++.

IC вычитания работает точно так же. Если o.x является значением int32, IC вычитания будет вызвана с двумя значениями int32, и IC сгенерирует следующий CacheIR для оптимизации этого случая:

Код:
GuardToInt32     inputId 0
GuardToInt32     inputId 1
Int32SubResult   lhsId 0, rhsId 1
ReturnFromIC

Это означает, что сначала мы проверяем, что левая часть это значения int32, затем правую часть, а затем мы можем выполнить вычитание int32 и вернуть результат из заглушки IC в функцию.

Инструкции CacheIR фиксируют все, что нам нужно, чтобы сделать оптимизацию операции. У нас есть несколько сотен инструкций CacheIR, определенных в файле YAML. Это строительные блоки для нашего конвейера JIT-оптимизации.


Warp: Транспиляция CacheIR в MIR

Если функция JS вызывается много раз, мы хотим скомпилировать ее с помощью оптимизирующего компилятора. В Warp есть три шага:

WarpOracle: запускается в основном потоке, создает моментальный снимок, содержащий данные Baseline CacheIR.

WarpBuilder: запускается вне потока, строит MIR из снимка.

Оптимизирующий JIT бэкэнд: также работает вне потока, оптимизирует MIR и генерирует машинный код.

Фаза WarpOracle выполняется в основном потоке и выполняется очень быстро. Фактическое построение MIR может быть выполнено в фоновом потоке. Это улучшение по сравнению с IonBuilder, где нам приходилось строить MIR на основном потоке, потому что он полагался на множество глобальных структур данных для Type Inference.

WarpBuilder имеет транспилятор для переноса CacheIR в MIR. Это механический процесс: для каждой инструкции CacheIR он просто генерирует соответствующие инструкции MIR.

Собирая все это вместе, мы получаем следующую картину:
JIT-pipeline.png


Нам очень понравился этот дизайн: когда мы вносим изменения в инструкции CacheIR, это автоматически влияет на все наши уровни JIT (см. Синие стрелки на рисунке выше). Warp просто объединяет байт-код функции и инструкции CacheIR в единый граф MIR.

У нашего старого конструктора MIR (IonBuilder) было много сложного кода, который нам не нужен в WarpBuilder, потому что вся семантика JS захватывается данными CacheIR, которые нам также нужны для IC.



Пробное встраивание: специализирующиеся на типах встроенные функции

Оптимизирующие JavaScript JIT могут встраивать функции JavaScript в вызывающие функции. С Warp мы делаем еще один шаг вперед: Warp также может специализировать встроенные функции на стороне вызывающего.

Снова рассмотрим наш пример функции:

JavaScript:
function f(o) {
    return o.x - 1;
}

Эта функция может быть вызвана из нескольких мест, каждое из которых передает различную форму объекта или разные типы для o.x. В этом случае встроенные кэши будут иметь полиморфные заглушки CacheIR IC, даже если каждый из вызывающих абонентов передает только один тип. Если мы встроим функцию в Warp, мы не сможем оптимизировать ее так, как нам хотелось бы.

Чтобы решить эту проблему, мы ввели новую оптимизацию под названием Trial Inlining. Каждая функция имеет ICScript, в котором хранятся данные CacheIR и IC для этой функции. Прежде чем мы Warp-компилируем функцию, мы сканируем Baseline IC в этой функции для поиска вызовов встроенных функций. Для каждого места вызова мы создаем новый ICScript для вызываемой функции. Каждый раз, когда мы вызываем встраиваемого кандидата, вместо использования ICScript по умолчанию для вызываемого мы передаем новый специализированный ICScript. Это означает, что Baseline Interpreter, Baseline JIT и Warp теперь будут собирать и использовать информацию, специально предназначенную для этого места вызова.


Пробное встраивание очень мощная техника, поскольку работает рекурсивно. Например, рассмотрим следующий код JS:

JavaScript:
function callWithArg(fun, x) {
    return fun(x);
}
function test(a) {
    var b = callWithArg(x => x + 1, a);
    var c = callWithArg(x => x - 1, a);
    return b + c;
}

Когда мы выполняем пробное встраивание для функции test, мы сгенерируем специализированный ICScript для каждого из вызовов callWithArg. Позже мы пробуем рекурсивное пробное встраивание в эти функции callWithArg, специализированные для вызывающего, и затем можем специализировать вызов fun на основе вызывающего. В IonBuilder это было невозможно.

Когда приходит время Warp-компиляции тестовой функции, у нас есть данные CacheIR, специализированные для вызывающего, и мы можем сгенерировать оптимальный код.

Это означает, что мы создаем встраиваемый граф до того, как функции будут скомпилированы Warp, путем (рекурсивной) специализации данных Baseline IC на местах вызовов. Затем Warp просто встраивает на основе этого, не нуждаясь в собственной эвристике встраивания.


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

IonBuilder мог напрямую встраивать определенные встроенные функции. Это особенно полезно для таких вещей, как Math.abs и Array.prototype.push, потому что мы можем реализовать их с помощью нескольких машинных инструкций, и это намного быстрее, чем вызов функции.

Поскольку Warp управляется CacheIR, мы решили создать оптимизированный CacheIR для вызовов этих функций.

Это означает, что эти встроенные модули теперь также должным образом оптимизированы с помощью заглушек IC в нашем Baseline Interpreter и JIT. Новый дизайн ведет нас к генерированию правильных инструкций CacheIR, которые затем приносят пользу не только Warp, но и всем нашим уровням JIT.

Например, давайте посмотрим на вызов Math.pow с двумя аргументами int32. Мы генерируем следующий CacheIR:

Код:
LoadArgumentFixedSlot      resultId 1, slotIndex 3
GuardToObject              inputId 1
GuardSpecificFunction      funId 1, expectedOffset 0, nargsAndFlagsOffset 8
LoadArgumentFixedSlot      resultId 2, slotIndex 1
LoadArgumentFixedSlot      resultId 3, slotIndex 0
GuardToInt32               inputId 2
GuardToInt32               inputId 3
Int32PowResult             lhsId 2, rhsId 3
ReturnFromIC

Во-первых, мы следим за тем, чтобы вызываемый объект был встроенной функцией pow. Затем мы загружаем два аргумента и мы следим за тем, что они типа int32. Затем мы выполняем операцию pow, специализированную для двух аргументов int32, и возвращаем результат из заглушки IC.

Кроме того, инструкция Int32PowResult CacheIR также используется для оптимизации оператора возведения в степень JS x ** y. Для этого оператора мы можем сгенерировать:

Код:
GuardToInt32               inputId 0
GuardToInt32               inputId 1
Int32PowResult             lhsId 0, rhsId 1
ReturnFromIC

Когда мы добавили поддержку транспилятора Warp для Int32PowResult, Warp смог оптимизировать как оператор возведения в степень, так и Math.pow без дополнительных изменений. Это хороший пример того, как CacheIR предоставляет строительные блоки, которые можно использовать для оптимизации различных операций.


Окончание статьи относится к описанию результатов тестирования на бенчмарках, и не относится к устройству Warp. Желающие могут посмотреть в оригинале.





Перевод - sploitem
Источник - https://web.archive.org/web/2020112...1/warp-improved-js-performance-in-firefox-83/
 


Напишите ответ...
  • Вставить:
Прикрепить файлы
Верх