Sparkplug — не оптимизирующий компилятор JavaScript
Создание высокопроизводительного движка JavaScript требует не только наличия высоко оптимизирующего компилятора, такого как TurboFan. Особенно для короткоживущих сессий, таких как загрузка веб-сайтов или инструменты командной строки, до того, как оптимизирующий компилятор начнет свою работу, выполняется множество задач, и зачастую нет времени на генерацию оптимизированного кода.
Именно поэтому, с 2016 года мы отказались от отслеживания синтетических тестов (например, Octane) и сосредоточились на измерении производительности в реальных условиях. С тех пор мы много работали над производительностью JavaScript вне рамок оптимизирующего компилятора, включая работу над парсером, потоковой передачей, нашей объектной моделью, асинхронностью в сборщике мусора, кэшированием скомпилированного кода… в общем, скучать нам было некогда.
Однако, когда дело доходит до улучшения производительности начального выполнения JavaScript, оптимизация интерпретатора начинает сталкиваться с ограничениями. Интерпретатор V8 — это высоко оптимизированное и очень быстрое решение, но у интерпретаторов есть внутренние оверхеды, от которых невозможно избавиться, такие как накладные расходы на декодирование байт-кода или диспетчеризацию, которые являются неотъемлемой частью функциональности интерпретатора.
При существующей модели двух компиляторов мы не можем значительно ускорить переход к оптимизированному коду; мы можем (и делаем) ускорить процесс оптимизации, но в какой-то момент единственный способ сделать работу быстрее — это сократить этапы оптимизации, что снижает максимальную производительность. И что еще хуже, мы не можем начать оптимизацию раньше, потому что у нас еще нет стабильной обратной связи о формах объекта.
Здесь на сцену выходит Sparkplug: наш новый не оптимизирующий компилятор JavaScript, который мы выпускаем вместе с V8 v9.1 и который находится между интерпретатором Ignition и оптимизирующим компилятором TurboFan.
Быстрый компилятор
Sparkplug разработан для быстрой компиляции. Очень быстрой. Настолько быстрой, что мы можем компилировать практически когда захотим, позволяя нам гораздо более агрессивно переходить к коду Sparkplug, чем к коду TurboFan.
Есть несколько приемов, которые делают компилятор Sparkplug быстрым. Во-первых, он «шустрит»: функции, которые он компилирует, уже были скомпилированы в байт-код, и компилятор байт-кода уже выполнил большинство сложной работы, такой как разрешение переменных, определение того, являются ли круглые скобки на самом деле стрелочными функциями, десахаризацию операторов деструктуризации и т.д. Sparkplug компилирует из байт-кода, а не из исходного кода JavaScript, и поэтому ему не нужно беспокоиться обо всех этих нюансах.
Вторая хитрость заключается в том, что Sparkplug не генерирует промежуточные представления (IR), как это делают большинство компиляторов. Вместо этого Sparkplug компилирует непосредственно в машинный код за один линейный проход по байт-коду, выдавая код, соответствующий выполнению этого байт-кода. На самом деле, весь компилятор представляет собой оператор switch
внутри цикла for
, который выполняет машинный код для каждого байт-кода.
// Компилятор Sparkplug (сокращенная версия).
for (; !iterator.done(); iterator.Advance()) {
VisitSingleBytecode();
}
Отсутствие промежуточного представления (IR) означает, что у компилятора ограниченные возможности для оптимизации, за исключением очень локальных оптимизаций типа подсматривания. Это также означает, что нам приходится портировать всю реализацию отдельно для каждой архитектуры, которую мы поддерживаем, так как нет промежуточного этапа, независимого от архитектуры. Однако оказывается, что ни одно из этих ограничений не является проблемой: быстрый компилятор — это простой компилятор, следовательно, код достаточно легко переносить; и Sparkplug не нуждается в интенсивной оптимизации, поскольку у нас в любом случае есть отличный оптимизирующий компилятор на более позднем этапе в конвейере.
::: note Технически, на данный момент мы выполняем два прохода по байткоду — один для обнаружения циклов, а второй для генерации реального кода. Мы планируем отказаться от первого прохода в будущем. :::
Кадры, совместимые с интерпретатором
Добавление нового компилятора к существующей зрелой виртуальной машине JavaScript — это сложная задача. Существует множество аспектов, которые необходимо поддерживать помимо стандартного выполнения: у V8 есть отладчик, профилировщик ЦП для обхода стека, трассировка стека для исключений, интеграция в процесс повышения уровня, замена кода в горячих циклах... всего много.
Sparkplug использует изящный трюк, который упрощает большинство этих задач. Он поддерживает «кадры стека, совместимые с интерпретатором».
Давайте немного отмотаем назад. Кадры стека — это способ хранения состояния функции при выполнении кода. Каждый раз, когда вызывается новая функция, создается новый кадр стека для локальных переменных этой функции. Кадр стека определяется указателем кадра (отмечающим начало) и указателем стека (отмечающим конец):
::: note
На этом этапе примерно половина из вас начнет кричать: «эта диаграмма не имеет смысла, стек явно растет в противоположном направлении!». Не бойтесь, я сделал кнопку для вас:
:::
Когда вызывается функция, адрес возврата помещается в стек; этот адрес извлекается функцией при возврате, чтобы знать, куда возвращаться. Затем, когда эта функция создает новый кадр, она сохраняет старый указатель кадра в стеке и устанавливает новый указатель кадра в начале своего кадра стека. Таким образом, стек образует цепочку указателей кадра, каждый из которых отмечает начало кадра, который указывает на предыдущий:
::: note Строго говоря, это лишь соглашение, следуемое сгенерированным кодом, а не обязательное требование. Однако оно довольно универсально; единственные случаи, когда оно действительно нарушается, — это когда кадры стека полностью опускаются или когда для обхода стеков используются вспомогательные таблицы отладки. :::
Это общая структура стека для всех типов функций; затем следуют соглашения о том, как передаются аргументы и как функция сохраняет значения в своем кадре. В V8 у нас есть соглашение для JavaScript-кадров, согласно которому аргументы (включая принимающий объект) помещаются в обратном порядке в стек перед вызовом функции, и что первые несколько слотов в стеке содержат: текущую вызываемую функцию, контекст, с которым она вызывается, и количество переданных аргументов. Это наша «стандартная» структура JS-кадра:
Этот стандарт вызовов JS используется как в оптимизированных, так и в интерпретированных кадрах, что позволяет, например, обходить стек с минимальными накладными расходами при профилировании кода в панели производительности отладчика.
В случае интерпретатора Ignition это соглашение становится более явным. Ignition — это интерпретатор, основанный на регистрах, что означает, что существуют виртуальные регистры (не путайте с регистрами машины!), которые хранят текущее состояние интерпретатора — это включает в себя локальные переменные JavaScript (объявления var/let/const) и временные значения. Эти регистры хранятся в кадре стека интерпретатора, вместе с указателем на массив байткода, который выполняется, и смещением текущего байткода в этом массиве:
Sparkplug преднамеренно создает и поддерживает структуру кадра, которая соответствует кадру интерпретатора; всякий раз, когда интерпретатор сохраняет значение регистра, Sparkplug делает то же самое. Это делается по нескольким причинам:
- Это упрощает компиляцию Sparkplug; Sparkplug может просто зеркально повторять поведение интерпретатора без необходимости поддерживать какую-либо сопоставление между регистрами интерпретатора и состоянием Sparkplug.
- Это также ускоряет компиляцию, поскольку компилятор байткода уже выполнил тяжелую работу по распределению регистров.
- Это делает интеграцию с остальной системой почти тривиальной: отладчик, профилировщик, расшифровка стека исключений, вывод трассировок стека — все эти операции обходят стек для обнаружения текущего стека выполняемых функций, и все эти операции продолжают работать с Sparkplug почти без изменений, потому что для них все выглядит как кадр интерпретатора.
- Это делает замену на стеке (OSR) тривиальной. OSR — это процесс, когда исполняемая функция заменяется во время её выполнения; в настоящее время это происходит, когда интерпретируемая функция находится внутри горячего цикла (где она переходит к оптимизированному коду для этого цикла), и когда оптимизированный код деоптимизируется (где он переходит на уровень ниже и продолжает выполнение функции в интерпретаторе). Благодаря тому, что кадры Sparkplug зеркально отображают кадры интерпретатора, любая логика OSR, которая работает для интерпретатора, будет работать и для Sparkplug; что еще лучше, мы можем перейти от кода интерпретатора к коду Sparkplug практически без накладных расходов на перевод кадра.
Мы вносим одно небольшое изменение в стековый кадр интерпретатора: мы не поддерживаем актуальность смещения байт-кода во время выполнения кода Sparkplug. Вместо этого мы сохраняем двустороннюю таблицу соответствия диапазона адресов кода Sparkplug соответствующему смещению байт-кода; относительно простое кодирование, поскольку код Sparkplug генерируется напрямую из линейного обхода байт-кода. Каждый раз, когда доступ к стековому кадру хочет узнать «смещение байт-кода» для кадра Sparkplug, мы ищем выполняемую инструкцию в этой таблице и возвращаем соответствующее смещение байт-кода. Таким же образом, когда мы хотим выполнить OSR из интерпретатора в Sparkplug, мы можем найти текущее смещение байт-кода в таблице и перейти к соответствующей инструкции Sparkplug.
Вы можете заметить, что теперь на стековом кадре есть неиспользуемый слот, где должно быть смещение байт-кода; слот, который мы не можем удалить, так как хотим сохранить остальную часть стека неизменной. Мы переназначаем этот слот для кеширования «вектора обратной связи» для выполняемой функции; это вектор, который хранит данные о форме объекта и должен загружаться для большинства операций. Все, что нам нужно сделать, — быть немного осторожными с OSR, чтобы убедиться, что мы заменяем либо правильное смещение байт-кода, либо правильный вектор обратной связи для этого слота.
Таким образом, стековый кадр Sparkplug выглядит так:
Делегирование встроенным функциям
На самом деле Sparkplug генерирует очень мало собственного кода. Семантика JavaScript сложна, и для выполнения даже самых простых операций требуется много кода. Заставить Sparkplug регенерировать этот код в процессе каждой компиляции было бы плохим решением по нескольким причинам:
- Это значительно увеличило бы время компиляции из-за большого количества кода, который нужно генерировать.
- Это увеличило бы потребление памяти для кода Sparkplug.
- Нам пришлось бы переосмысливать генерацию кода для множества функций JavaScript в Sparkplug, что, вероятно, привело бы к большему числу ошибок и увеличению зоны безопасности.
Вместо этого большинство кода Sparkplug просто вызывает «встроенные функции» — небольшие фрагменты машинного кода, встроенные в бинарный файл, — для выполнения основной работы. Эти встроенные функции либо те же самые, что используются интерпретатором, либо, по крайней мере, они имеют общий код с обработчиками байт-кода интерпретатора.
Фактически, код Sparkplug — это, в основном, вызовы встроенных функций и управление потоком:
Теперь вы можете подумать: «Ну, какой смысл во всем этом? Разве Sparkplug не делает то же самое, что и интерпретатор?» — и вы будете не совсем неправы. Во многих отношениях Sparkplug — это просто сериализация выполнения интерпретатора, вызывающая те же встроенные функции и поддерживающая тот же стековый кадр. Тем не менее, даже это стоит того, потому что оно устраняет (или точнее, предварительно компилирует) те неизбежные накладные расходы интерпретатора, такие как декодирование операндов и переход на следующий байт-код.
Оказывается, интерпретаторы препятствуют многим оптимизациям процессора: статические операнды динамически считываются из памяти интерпретатором, заставляя процессор либо задерживаться, либо делать предположения о значениях; переход к следующему байт-коду требует успешного предсказания ветвлений для сохранения производительности, и даже если предположения и предсказания оказались верными, все равно пришлось выполнить весь код для декодирования и перехода, и все равно были использованы ценные ресурсы из различных буферов и кешей. Процессор фактически сам является интерпретатором, тем не менее, для машинного кода; глядя на это, Sparkplug — это своего рода «транспайлер» от байт-кода Ignition к машинному коду процессора, переводя ваши функции из выполнения в «эмуляторе» на выполнение «нативно».
Производительность
Итак, насколько хорошо Sparkplug работает на практике? Мы запустили Chrome 91 с несколькими тестами на нескольких наших ботах производительности с включенным и отключенным Sparkplug, чтобы увидеть его влияние.
Спойлер: мы довольны результатами.
::: note Ниже приведены тесты на различных ботах, работающих под управлением различных операционных систем. Хотя операционная система явно указана в названии ботов, мы не думаем, что она действительно оказывает значительное влияние на результаты. Скорее, разные машины имеют разные конфигурации процессора и памяти, которые, по нашему мнению, являются основным источником различий. :::
Speedometer
Speedometer — это тест, который пытается имитировать реальное использование веб-фреймворков, создавая веб-приложение для отслеживания списка задач с использованием нескольких популярных фреймворков и тестируя производительность приложения при добавлении и удалении задач. Мы нашли этот тест отличным отражением реального поведения загрузки и взаимодействия, и неоднократно замечали, что улучшения Speedometer отражаются в наших реальных метриках.
С Sparkplug показатель Speedometer улучшается на 5-10% в зависимости от того, какой бот мы рассматриваем.
Тесты производительности при просмотре
Speedometer — отличный тест, но он показывает только часть картины. У нас также есть набор тестов «просмотра», которые представляют собой записи набора реальных веб-сайтов. Мы можем воспроизвести эти записи, немного автоматизировать взаимодействие и получить более реалистичное представление о том, как наши различные метрики работают в реальном мире.
В этих тестах мы решили обратить внимание на нашу метрику «время работы основного потока V8», которая измеряет общее время, проведенное в V8 (включая компиляцию и исполнение) в основном потоке (т.е. исключая потоковую разборку или оптимизированную фоновую компиляцию). Это наш лучший способ увидеть, насколько хорошо Sparkplug себя оправдывает, исключая другие источники шума в тестах.
Результаты разнятся и сильно зависят от машины и веб-сайта, но в целом они выглядят здорово: мы наблюдаем улучшения порядка 5–15%.
::: figure Среднее улучшение времени работы основного потока V8 в наших тестах просмотра с 10 повторами. Ошибки показывают межквартильный размах.
:::
В заключение: у V8 появился новый сверхбыстрый некоптимизирующий компилятор, который улучшает производительность V8 на реальных тестах на 5–15%. Он уже доступен в V8 v9.1 с флагом --sparkplug
, и мы скоро выпустим его в Chrome 91.