Sparkplug — неоптимизирующий компилятор JavaScript в подробностях

Kate

Administrator
Команда форума
a6fdc5b006280f94f576535613fed10d.jpg

Создать компилятор JS с высокой производительностью означает сделать больше, чем разработать сильно оптимизированный компилятор, например TurboFan, особенно это касается коротких сессий, к примеру, загрузки сайта или инструментов командной строки, когда большая часть работы выполняется до того, как оптимизирующий компилятор получит хотя бы шанс на оптимизацию, не говоря уже о том, чтобы располагать временем на оптимизацию. Как решить эту проблему? К старту курса о Frontend-разработке делимся переводом статьи о Sparkplug — свече зажигания под капотом Chrome 91.


Вот почему с 2016 года мы ушли от синтетических бенчмарков, таких как Octane, к измерению реальной производительности и почему старательно работали над производительностью JS вне оптимизирующих компиляторов. Для нас это означало работу над парсером, стримингом [этой поясняющей ссылки в оригинале нет], объектной моделью, конкурентностью, кешированием скомпилированного кода...

Впрочем, повернувшись лицом к улучшению производительности фактического, начального выполнения JS, мы столкнулись с ограничениями процесса оптимизации интерпретатора. Интерпретатор V8 сам по себе быстрый и сильно оптимизированный, но интерпретаторам как таковым свойственны накладные расходы, избавиться от которых мы не можем: например на декодирование байт-кода или диспетчеризацию — неотъемлемые части функциональности интерпретатора.

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

Выход из положения — Sparkplug: новый неоптимизирующий компилятор JavaScript, который мы выпустили вместе с V8 9.1, он работает между интерпретатором Ignition и компилятором TurboFan.

Новый процесс компиляции
Новый процесс компиляции

Быстрый компилятор​

Sparkplug создан компилировать быстро. Очень быстро. Настолько, что мы всегда можем компилировать, когда захотим, повышая уровень кода SparkPlug намного агрессивнее кода TurboFan, [подробнее здесь, этой ссылки в оригинале нет].

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

Второй трюк — в отличие от большинства компиляторов Sparkplug не генерирует промежуточное представление (IR). Вместо этого Sparkplug компилирует прямо в машинный код за один линейный проход по байт-коду и выдаёт соответствующий выполнению байт-кода машинный код. На самом деле компилятор — это switch внутри цикла for, который занят диспетчеризацией, чтобы за каждой инструкцией закрепить генерирующие машинный код функции.

// The Sparkplug compiler (abridged).
for (; !iterator.done(); iterator.Advance()) {
VisitSingleBytecode();
}
Недочёт промежуточного представления (IR) заключается в том, что компилятор ограничен возможностями оптимизации. Поскольку промежуточной архитектурно независимой стадии нет, этот факт также означает, что всю реализацию нужно переносить на каждую поддерживаемую архитектуру отдельно. Но, оказывается, ни то, ни другое не является проблемой: портировать код довольно легко, вместе с тем Sparkplug не нужно выполнять тяжёлую оптимизацию.

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

Совместимые с интерпретатором фреймы​

Добавление нового компилятора в зрелую виртуальную машину JS — пугающая задача. Кроме стандартного выполнения в V8 мы должны поддерживать отладчик, профилирование центрального процессора с обходом стека, а значит, трассировки стека для исключений, интеграцию в стратегию динамического повышения уровня функции, замену на стеке, чтобы оптимизировать код горячих циклов. Работы много.

Sparkplug — это рука мастера, которая ловкими, красивыми движениями упрощает большинство этих задач, то есть он поддерживает совместимые с интерпретатором стековые фреймы. Давайте вернёмся немного назад. При выполнении кода во стековых фреймах хранится состояние функции. Это касается каждого вызова какой-то новой функции: для её локальных переменных создаётся стековый фрейм. Этот стек определяется указателем на фрейм в начале и указателем на стек в конце.

Стековый фрейм с указателями стека и фрейма
Стековый фрейм с указателями стека и фрейма


Сейчас около половины читателей закричит: "Диаграмма не имеет смысла, стек направлен в другую сторону!" Ничего страшного, я сделал кнопку: думаю, стек направлен вниз [переворачивающую стек кнопку вы найдёте в оригинале].
Когда функция вызвана, адрес возврата кладётся на стек и удаляется со стека функцией, когда она возвращается, чтобы знать, куда вернуться. Затем, когда функция создаёт новый фрейм, она сохраняет указатель на старый фрейм на стеке и устанавливает новый указатель в начало собственного стекового фрейма. Таким образом, на стеке образуется цепочка указателей на фреймы, каждый из которых отмечает начало фрейма, указывающего на предыдущий:

Стековые фреймы для нескольких вызовов
Стековые фреймы для нескольких вызовов
Строго говоря, это только соглашение, согласно которому создаётся код, но не требование. Хотя оно довольно универсально; не работает оно только в двух случаях: когда стековые фреймы полностью игнорируются или когда вместо него можно использовать таблицы со стороны отладчика.
Это основной макет стека для всех типов функций; затем идёт соглашение о передаче аргументов и о том, как в своём фрейме функция хранит значения. В V8 мы имеем соглашение для фреймов JS, что аргументы до вызова функции (включая приёмник) добавляются на стек в обратном порядке, а также о том, что первые несколько слотов стека — это вызываемая функция, контекст, с которым она вызывается, и количество передаваемых аргументов. Вот наш стандартный фрейм JS.

c9bcd0ac68a88600cfe0e1c2945038f5.png

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

В случае Ignition соглашение становится более явным. Ignition — интерпретатор на основе регистров, это означает, что есть виртуальные регистры (не путайте их с машинными!), которые хранят текущее состояние интерпретатора. включая локальные переменные (объявления var, let, const) и временные значения. Эти регистры содержатся в стековом фрейме интерпретатора, вместе с указателем на выполняемый массив байт-кода и смещением текущего байт-кода в массиве.

e85f13bd52146c94dafc9fa93e1451a5.png

Sparkplug намеренно создаёт и поддерживает соответствующий фрейму интерпретатора макет фрейма. Всякий раз, когда интерпретатор сохраняет значение регистра, SparkPlug также сохраняет его. Делает он это по нескольким причинам:

  1. Это упрощает компиляцию Sparkplug; новый компилятор может просто отражать поведение интерпретатора без необходимости сохранять какое-либо отображение из регистров интерпретатора в состояние Sparkplug.
  2. Поскольку компилятор байт-кода выполнил тяжёлую работу по распределению регистров, такой подход ускоряет компиляцию.
  3. Это делает интеграцию с остальной частью системы почти тривиальной; отладчик, профайлер, раскручивание стека исключений, вывод трассировки — все эти операции идут по стеку, чтобы узнать, каков текущий стек выполняемых функций, и все эти операции продолжают работать со Sparkplug почти без изменений, потому всё, что касается их, они получают из фрейма интерпретатора.
  4. Тривиальной становится и замена на стеке (OSR). Замена на стеке — это когда выполняемая функция заменяется в процессе выполнения; сейчас это происходит, когда интерпретированная функция находится в горячем цикле (в это время она поднимается до оптимизированного кода этого цикла) и где оптимизированный код деоптимизируется (когда он опускается и продолжает выполнение функции в интерпретаторе), любая работающая в интерпретаторе логика замены на стеке будет работать и для Sparkplug. Даже лучше: мы можем взаимозаменять код интерпретатора и SparkPlug почти без накладных расходов на переход фреймов.
Мы немного изменили стековый фрейм интерпретатора: во время выполнения кода Sparkplug не поддерживается актуальная позиция смещения. Вместо этого мы храним двустороннее отображение из диапазона адресов кода Sparkplug к соответствующему смещению. Для декодирования такое сопоставление относительно просто, поскольку код Sparklpug получается линейным проходом через байт-код. Всякий раз, когда стековый фрейм хочет узнать "смещение байт-кода" для фрейма Sparkplug, мы смотрим на текущую выполняемую инструкцию в отображении и возвращаем связанное смещение байт-кода. Аналогично, когда Sparkplug нужно узнать OSR из интерпретатора, мы смотрим на байт-код в смещении и перемещаемся к соответствующей инструкции Sparkplug.

Вы можете заметить, что теперь у нас есть неиспользуемый слот фрейма, где должно быть смещение байт-кода; избавиться от него мы не можем, поскольку хотим сохранить оставшуюся часть стека неизменной. Мы перепрофилируем этот слот стека, чтобы вместо него кешировать "вектор обратной связи" для текущей выполняющейся функции; это вектор, хранящий данные о форме объекта, и он должен быть загружен для большинства операций. Всё, что нам нужно делать, — соблюдать осторожность с OSR, чтобы гарантировать, что мы подставляем либо правильное смещение байт-кода, либо правильный вектор обратной связи для этого слота. В итоге стековый фрейм Sparkplug выглядит так:

bde42457d038c88d41375147c4d44e1a.png

Полагаемся на встроенный код​

На самом деле Sparkplug генерирует очень мало собственного кода. У JS сложная семантика, так что для выполнения даже самых простых операций требуется много кода. Принудительная повторная генерация такого кода при каждой компиляции оказалась бы плохим решениям по нескольким причинам:

  1. Из-за огромного количества кода, который необходимо сгенерировать, она значительно увеличила бы время компиляции.
  2. Этот подход увеличил бы потребление памяти кодом Sparkplug.
  3. Пришлось бы переписывать кодогенерацию для большого количества функциональности JS, что, вероятно, означало бы и больше ошибок, и большую поверхность атаки.
Поэтому, чтобы сделать грязную работу, вместо повторной генерации кода Sparkplug вызывает встроенные функции, небольшие сниппеты машинного кода, встроенного в двоичный файл. Они либо совпадают с теми, что использует интерпретатор, либо по крайней мере большая часть кода Sparkplug — общая с кодом обработчиков интерпретатора. В действительности код Sparkplug — это вызовы встроенных двоичных сниппетов и поток управления.

Вы можете подумать: "Ну и какой тогда смысл во всём этом? Разве Sparkplug не выполняет ту же работу, что и интерпретатор?" — и во многом будете правы. Во многих отношениях Sparkplug является "просто" сериализацией выполнения интерпретатора, вызывая те же встроенные двоичные сниппеты и поддерживая тот же стековый фрейм. Тем не менее оно того стоит, потому что Sparkplug удаляет (или, точнее, предварительно компилирует) те самые неустранимые накладные расходы интерпретатора, такие как декодирование операндов и диспетчеризация следующего байт-кода.

Оказалось, что интерпретация эффективнее множества оптимизаций уровня центрального процессора: статические операнды динамически читаются из памяти интерпретатором, вынуждая процессор делать предположения о том, какими могут быть значения. Диспетчеризация к следующей инструкции байт-кода для сохранения производительности требует успешного прогнозирования ветви выполнения, и, даже если предположения и прогнозы верны, по-прежнему нужно выполнять декодирование и диспетчеризацию кода, а также занимать драгоценное пространство различных буферов и кешей. ЦП сам по себе — эффективный интерпретатор, хотя он применяется к машинному коду. С этой точки зрения Sparkplug — транспилятор из байт-кода Ignition в байт-код центрального процессора, перемещающий выполнение в "эмуляторе" к "нативному" выполнению.

Производительность​

Так как же Sparkplug работает на практике? Мы выполнили несколько бенчмарков Chrome на наших ботах для замера производительности со Sparkplug и без него. Спойлер: мы очень довольны.

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

Speedometer​

Speedometer — это тест, который пытается эмулировать реально работающий фреймворк, создавая веб-приложение для отслеживания списка задач с использованием нескольких популярных фреймворков и проводя стресс-тестирование производительности этого приложения добавлением и удалением задач. Мы обнаружили, что это отличное отражение поведения загрузки и взаимодействия в реальном мире, и мы неоднократно обнаруживали, что улучшения в спидометре отражаются на наших реальных показателях. Со Sparkplug оценка Speedometer улучшается на 5–10 %, в зависимости от бота.

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

Обзор бенчмарка​

Speedometer — отличный ориентир, но он не показывает всей картины. Также у нас есть набор «бенчмарков просмотра веб-страниц» — записи набора реальных веб-сайтов, их мы можем воспроизводить, а также скрипт небольших взаимодействий, с их помощью мы можем получить более реалистичное представление о том, как различные метрики ведут себя в реальном мире.

На этих тестах мы решили посмотреть метрику «V8 Main-Thread Thread», измеряющую общее проведённое в V8 время (включая компиляцию и выполнение), в основном потоке (то есть исключая стриминговый парсинг или фоновую оптимизацию). Это лучший способ увидеть, насколько оправдан Sparkplug, без учёта других источников шума бенчмарка.

Результаты различны и сильно зависят от машины и веб-сайта, но в целом выглядят прекрасно: видно улучшение около 5–15 %.

Медианное улучшение времени работы V8 в основном потоке на наших бенчмарках для просмотра с 10 повторениями. Полосы на диаграмме указывают на диапазон между квартилями
Медианное улучшение времени работы V8 в основном потоке на наших бенчмарках для просмотра с 10 повторениями. Полосы на диаграмме указывают на диапазон между квартилями
Таким образом, V8 имеет новый сверхбыстрый неоптимизирующий компилятор, повышающий производительность V8 в реальных бенчмарках на 5–15 %. Он уже доступен в V8 v9.1 (укажите опцию --sparkplug), и мы выпустим его вместе с Chrome 91.


Источник статьи: https://habr.com/ru/company/skillfactory/blog/561034/
 
Сверху