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

Maglev - самый быстрый оптимизирующий JIT V8

· 13 мин. чтения
[Тун Верваст](https://twitter.com/tverwaes), [Лешек Свирски](https://twitter.com/leszekswirski), [Виктор Гомес](https://twitter.com/VictorBFG), Оливье Флюкигер, Дариус Меркадье и Камилло Бруни — поваров не так много, чтобы испортить бульон

В Chrome M117 мы представили новый оптимизирующий компилятор: Maglev. Maglev расположен между нашими существующими компиляторами Sparkplug и TurboFan, выполняя роль быстрого оптимизирующего компилятора, который создает достаточно хороший код достаточно быстро.

До 2021 года V8 имел два основных уровня исполнения: Ignition, интерпретатор; и TurboFan, оптимизирующий компилятор V8, ориентированный на максимальную производительность. Весь код JavaScript сначала компилируется в байт-код Ignition и исполняется путем интерпретации. Во время выполнения V8 отслеживает поведение программы, включая формы объектов и типы. Метаданные выполнения и байт-код поступают в оптимизирующий компилятор для генерации высокопроизводительного, часто спекулятивного машинного кода, который работает значительно быстрее, чем интерпретатор.

Эти улучшения хорошо видны в тестах, таких как JetStream, набор традиционных тестов на чистом JavaScript, которые оценивают запуск, задержку и максимальную производительность. TurboFan помогает V8 пройти тесты в 4.35 раз быстрее! JetStream меньше акцентируется на производительности в стационарном состоянии по сравнению с прошлыми тестами (например, устаревший тест Octane), но из-за простоты многих пунктов, именно оптимизированный код занимает большую часть времени.

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

TurboFan все еще оказывает значительное влияние на Speedometer: выполнение происходит в 1.5 раза быстрее! Однако влияние заметно более сдержанное, чем в JetStream. Часть этой разницы обусловлена тем, что полные страницы проводят меньше времени в чистом JavaScript. Но отчасти это связано с тем, что в тесте затрачивается много времени на функции, которые недостаточно часто вызываются, чтобы быть оптимизированными TurboFan.

Сравнение тестов веб-производительности с не оптимизированным и оптимизированным исполнением

::: note Все результаты тестов в этом посте были измерены с Chrome 117.0.5897.3 на 13-дюймовом Macbook Air с процессором M2. :::

Поскольку разница в скорости исполнения и времени компиляции между Ignition и TurboFan очень велика, в 2021 году мы представили новый базовый JIT под названием Sparkplug. Он разработан для почти мгновенной компиляции байт-кода в эквивалентный машинный код.

На JetStream Sparkplug значительно улучшает производительность по сравнению с Ignition (+45%). Даже когда TurboFan также используется, мы видим значительное улучшение производительности (+8%). В Speedometer мы видим 41% улучшение по сравнению с Ignition, достигая показателей, близких к производительности TurboFan, и 22% улучшение по сравнению с Ignition + TurboFan! Поскольку Sparkplug работает очень быстро, мы можем легко развернуть его очень широко и получить стабильный прирост скорости. Если выполнение кода не зависит исключительно от легкой оптимизации, долгосрочных, плотных циклов JavaScript, это отличное дополнение.

Тесты веб-производительности с добавленным Sparkplug

Простота Sparkplug накладывает относительно низкий верхний предел ускорения, которое она может обеспечить. Это ясно демонстрируется большим разрывом между Ignition + Sparkplug и Ignition + TurboFan.

И здесь появляется Maglev, наш новый оптимизирующий JIT, который создает код, значительно быстрее, чем код Sparkplug, но генерируется гораздо быстрее, чем TurboFan.

Maglev: простой JIT-компилятор на основе SSA

Когда мы начали этот проект, то увидели два пути для преодоления разрыва между Sparkplug и TurboFan: либо попытаться генерировать более качественный код, используя подход Sparkplug с одним проходом, либо создать JIT-компилятор с промежуточным представлением (IR). Поскольку мы считали, что полный отказ от использования IR во время компиляции может серьёзно ограничить возможности компилятора, мы решили выбрать относительно традиционный подход, основанный на статическом одноназначении (SSA), используя граф управления потоком (CFG), а не более гибкое, но неподходящее для кэширования «море узлов» TurboFan.

Сам компилятор спроектирован быть быстрым и удобным для работы. Он имеет минимальный набор проходов и простой одноуровневый IR, который кодирует специализированную семантику JavaScript.

Предварительный проход

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

SSA

Вывод графа SSA Maglev на командной строке

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

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

Циклы могут объединять значения переменных «назад во времени», при этом данные перетекают назад от конца цикла к началу цикла, в случаях, когда переменные присваиваются внутри тела цикла. Здесь информация из предварительного прохода оказывается полезной: поскольку мы уже знаем, какие переменные присваиваются внутри циклов, мы можем предварительно создать фи-узлы для цикла, даже не начиная обработки тела цикла. В конце цикла мы можем заполнить входные значения фи узлами соответствующими узлами SSA. Это позволяет генерации графа SSA быть единым прямым проходом, без необходимости «исправлять» переменные циклов, а также минимизируя количество фи-узлов, которые нужно выделить.

Известная информация об узлах

Чтобы быть максимально быстрым, Maglev делает всё возможное сразу. Вместо построения общего графа JavaScript и его преобразования на поздних стадиях оптимизации, что является теоретически чистым, но вычислительно затратным подходом, Maglev делает всё возможное немедленно во время построения графа.

Во время построения графа Maglev смотрит на метаданные обратной связи времени выполнения, собранные во время неоптимизированного выполнения, и генерирует специализированные узлы SSA для наблюдаемых типов. Если Maglev видит o.x и знает из обратной связи времени выполнения, что o всегда имеет одну конкретную форму, он генерирует узел SSA для проверки того, что во время выполнения o все еще имеет ожидаемую форму, а затем дешёвый узел LoadField, который выполняет простой доступ по смещению.

Кроме того, Maglev создаст боковой узел, который теперь знает форму o, что делает ненужной проверку формы снова позже. Если Maglev позже сталкивается с операцией над o, для которой по какой-то причине отсутствует обратная связь, такая информация, полученная во время компиляции, может использоваться как второй источник обратной связи.

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

Некоторые формы информации являются «нестабильными». Такая информация может быть использована только в той степени, в которой компилятор точно знает, что она не может измениться. Например, если мы только что создали объект, мы знаем, что это новый объект, и можем полностью пропустить дорогостоящие барьеры записи. Как только произошла другая потенциальная аллокация, сборщик мусора мог переместить объект, и теперь нам нужно генерировать такие проверки. Другие формы информации являются «стабильными»: если мы никогда не видели, чтобы какой-либо объект переходил от определённой формы, мы можем зарегистрировать зависимость от этого события (любое изменение объекта от данной формы) и не нужно перепроверять форму объекта, даже после вызова неизвестной функции с неизвестными побочными эффектами.

Деоптимизация

Учитывая, что Maglev может использовать спекулятивную информацию, которую он проверяет во время выполнения, коду Maglev необходимо иметь возможность деоптимизировать. Чтобы сделать это возможным, Maglev прикрепляет абстрактное состояние интерпретатора к узлам, которые могут деоптимизировать. Это состояние сопоставляет регистры интерпретатора с значениями SSA. Это состояние превращается в метаданные во время генерации кода, предоставляя соответствие между оптимизированным и неоптимизированным состоянием. Деоптимизатор интерпретирует эти данные, считывая значения из кадра интерпретатора и машинных регистров и помещая их в необходимые места для интерпретации. Это основывается на том же механизме деоптимизации, который использует TurboFan, что позволяет нам разделять большую часть логики и пользоваться преимуществами тестирования существующей системы.

Выбор представления

Числа JavaScript представляют собой, согласно спецификации, 64-битное значение с плавающей точкой. Однако это не означает, что движок должен всегда хранить их как 64-битные числа с плавающей точкой, особенно учитывая, что на практике многие числа являются маленькими целыми числами (например, индексы массива). V8 старается кодировать числа как 31-битные помеченные целые числа (внутренне называемые "маленькими целыми числами" или "Smi"), чтобы уменьшить расход памяти (32 бит благодаря сжатию указателей) и повысить производительность (целочисленные операции быстрее операций с плавающей точкой).

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

Maglev узнает о представлении узлов SSA главным образом через просмотр данных выполнения, например, бинарных операций, и распространяет эту информацию через механизм Known Node Info. Когда значения SSA с конкретными представлениями перетекают в узлы Phi, необходимо выбрать корректное представление, поддерживающее все входы. Узлы Phi в циклах снова оказываются сложными, так как входы изнутри цикла видны после того, как представление должно быть выбрано для узла Phi — та же проблема "возврата во времени", что и при построении графа. Именно поэтому у Maglev есть отдельный этап после построения графа для выбора представлений для узлов Phi в циклах.

Распределение регистров

После построения графа и выбора представлений Maglev в основном знает, какой код он хочет генерировать, и "завершён" с точки зрения классической оптимизации. Однако для генерации кода нам нужно выбрать, где фактически живут значения SSA при выполнении машинного кода: когда они находятся в машинных регистрах и когда они сохранены в стеке. Это осуществляется через распределение регистров.

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

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

После предварительного прохода начинается распределение регистров. Назначение регистров следует простым локальным правилам: Если значение уже находится в регистре, то используется этот регистр, если возможно. Узлы отслеживают, какие регистры они занимают во время обхода графа. Если узел ещё не имеет регистра, но регистр свободен, он выбирается. Узел обновляется, чтобы указать, что он находится в этом регистре, и абстрактное состояние регистра обновляется, чтобы знать, что он содержит узел. Если свободного регистра нет, но регистр необходим, другое значение выталкивается из регистра. В идеале у нас есть узел, который уже находится в другом регистре, и мы можем исключить его "бесплатно"; если это невозможно, мы выбираем значение, которое не понадобится в течение длительного времени, и помещаем его в стек.

При слиянии ветвей абстрактные состояния регистров из входящих ветвей объединяются. Мы стараемся сохранить как можно больше значений в регистрах. Это может означать, что нам нужно вводить перемещения между регистрами или извлекать значения из стека, используя перемещения, называемые "gap moves". Если слияние ветвей имеет узел Phi, распределение регистров назначит выходные регистры для этих узлов Phi. Maglev предпочитает выводить узлы Phi в те же регистры, что и их входы, чтобы минимизировать перемещения.

Если значений SSA больше, чем имеется регистров, нам придется сбрасывать некоторые значения в стек и восстанавливать их позже. В духе Maglev мы делаем это просто: если значение нужно сбросить, его ретроспективно указывают немедленно сбросить при определении (сразу после его создания), а генерация кода позаботится о сбросе. Гарантируется, что определение «доминирует» все использования значения (для достижения использования мы должны были пройти через определение и, следовательно, код сброса). Это также означает, что сброшенное значение будет иметь ровно один слот сброса на все время выполнения кода; значения с пересекающимися сроками жизни будут иметь непересекающиеся назначенные слоты сброса.

Из-за выбора представления некоторые значения в рамках Maglev будут метками указателями, которые понимает сборщик мусора V8 и которые нужно учитывать, а некоторые будут неметками, значения, которые сборщик мусора не должен рассматривать. TurboFan справляется с этим, точно отслеживая, какие слоты стека содержат метки значения, а какие содержат неметки, что меняется во время выполнения, поскольку слоты перераспределяются для разных значений. Для Maglev мы решили упростить эту задачу, чтобы уменьшить память, требуемую для отслеживания: мы разделяем стековый фрейм на метки и неметки области и храним только эту точку разделения.

Генерация кода

Как только мы знаем, для каких выражений мы хотим сгенерировать код и где хотим разместить их выводы и входы, Maglev готов генерировать код.

Узлы Maglev напрямую знают, как генерировать машинный код с помощью «макроассемблера». Например, узел CheckMap знает, как выпускать инструкции ассемблерa, которые сравнивают форму (внутреннее название - «map») объекта ввода с известным значением и оптимизировать код, если объект имеет неправильную форму.

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

Результаты

Таким образом, представленный нами компилятор как явно намного сложнее Sparkplug, так и намного проще TurboFan. Как он справляется?

С точки зрения скорости компиляции нам удалось создать JIT, который примерно в 10 раз медленнее Sparkplug, но в 10 раз быстрее TurboFan.

Сравнение времени компиляции разных уровней компиляции, для всех функций, скомпилированных в JetStream

Это позволяет нам использовать Maglev гораздо раньше, чем мы хотели бы использовать TurboFan. Если использованная обратная связь оказалась не очень стабильной, нет большого ущерба в деоптимизации и перекомпиляции позже. Это также позволяет нам использовать TurboFan чуть позже: мы работаем гораздо быстрее, чем с Sparkplug.

Вставка Maglev между Sparkplug и TurboFan приводит к заметным улучшениям в тестах производительности:

Тесты производительности веба с Maglev

Мы также проверили Maglev на реальных данных и наблюдаем хорошие улучшения на Core Web Vitals.

Поскольку Maglev компилирует намного быстрее, и поскольку мы теперь можем позволить себе ожидать дольше, прежде чем компилировать функции с TurboFan, это приводит к вторичному преимуществу, которое не так заметно на поверхности. Тесты фокусируются на задержке главного потока, но Maglev также значительно снижает общую ресурсопотребление V8 за счет использования меньшего количества времени на потоке. Энергопотребление процесса можно легко измерить на MacBook с процессорами M1 или M2 с помощью taskinfo.

ТестЭнергопотребление
JetStream-3.5%
Speedometer-10%

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

Maglev теперь доступен для настольного Chrome и скоро будет развернут на мобильные устройства.