Liftoff: новый базовый компилятор для WebAssembly в V8
V8 v6.9 включает Liftoff, новый базовый компилятор для WebAssembly. Liftoff теперь включён по умолчанию на настольных системах. В этой статье рассмотрены причины добавления ещё одного уровня компиляции, а также описаны реализация и производительность Liftoff.
С момента запуска WebAssembly более года назад его популярность в интернете постоянно растёт. Начали появляться крупные приложения, ориентированные на WebAssembly. Например, эталонный тест ZenGarden от Epic включает WebAssembly-бинарный файл размером 39,5 МБ, а AutoDesk распространяется в виде бинарного файла размером 36,8 МБ. Поскольку время компиляции фактически линейно связано с размером бинарного файла, запуск таких приложений занимает значительное время. На многих машинах это более 30 секунд, что не обеспечивает высокого уровня удобства для пользователей.
Но почему запуск приложения на WebAssembly занимает столько времени, если схожие приложения на JavaScript стартуют значительно быстрее? Причина в том, что WebAssembly обещает предсказуемую производительность, чтобы, как только приложение запущено, можно было быть уверенным в стабильном достижении заданных целей производительности (например, рендеринг 60 кадров в секунду, отсутствие задержек или артефактов в аудио…). Для этого код WebAssembly предварительно компилируется в V8, чтобы избежать пауз компиляции, вносимых JIT-компилятором, которые могли бы вызвать заметные задержки в приложении.
Существующий конвейер компиляции (TurboFan)
Подход V8 к компиляции WebAssembly основывается на TurboFan, оптимизирующем компиляторе, который мы разработали для JavaScript и asm.js. TurboFan — это мощный компилятор с графовым промежуточным представлением (IR), подходящим для продвинутых оптимизаций, таких как редукция мощности, инлайн-шаблоны, перемещение кода, комбинирование инструкций и сложное распределение регистров. TurboFan спроектирован так, чтобы можно было подключаться к конвейеру на очень позднем этапе, ближе к машинному коду, минуя множество стадий, необходимых для поддержки компиляции JavaScript. Благодаря структурированному контролю потоков WebAssembly, преобразование кода WebAssembly в IR TurboFan (включая конструкцию SSA) в одном быстром проходе весьма эффективно. Однако, даже при таком подходе задний план процесса компиляции всё ещё потребляет значительное количество времени и памяти.
Новый конвейер компиляции (Liftoff)
Цель Liftoff — сократить время запуска приложений на основе WebAssembly, генерируя код максимально быстро. Качество кода вторично, так как горячий код всё равно будет перекомпилирован с использованием TurboFan. Liftoff избегает времени и памяти, затрачиваемых на построение IR, и генерирует машинный код в одном проходе по байт-коду функции WebAssembly.
На диаграмме выше видно, что Liftoff должен быть способен генерировать код гораздо быстрее TurboFan, так как конвейер состоит всего из двух стадий. Фактически, декодер тела функции выполняет один проход по необработанным байтам WebAssembly и взаимодействует с последующим этапом через обратные вызовы, поэтому генерация кода выполняется одновременно с декодированием и валидацией тела функции. Вместе с потоковыми API WebAssembly это позволяет V8 компилировать код WebAssembly в машинный код во время загрузки из сети.
Генерация кода в Liftoff
Liftoff — это простой генератор кода, и он быстрый. Он выполняет только один проход по опкодам функции, генерируя код для каждого опкода, один за раз. Для простых опкодов, таких как арифметические операции, это часто одна машинная инструкция, а для других, таких как вызовы, может быть больше. Liftoff сохраняет метаданные об операндном стеке, чтобы знать, где в данный момент хранятся входные данные каждой операции. Этот виртуальный стек существует только во время компиляции. Структурированный поток управления WebAssembly и правила его проверки гарантируют, что местоположение этих входных данных можно определить статически. Поэтому реальный стек времени выполнения, на который операнды помещаются и извлекаются, не требуется. Во время выполнения каждое значение в виртуальном стеке будет либо храниться в регистре, либо выгружаться в физический стековый кадр этой функции. Для небольших целых констант (сгенерированных командой i32.const
) Liftoff просто записывает значение константы в виртуальный стек и не генерирует никакого кода. Только когда константа используется последующей операцией, она либо излучается, либо комбинируется с операцией, например, путем прямой генерации инструкции addl <reg>, <const>
для x64. Это позволяет избежать загрузки этой константы в регистр, что приводит к лучшему коду.
Рассмотрим очень простую функцию, чтобы увидеть, как Liftoff генерирует код для неё.
Эта примерная функция принимает два параметра и возвращает их сумму. Когда Liftoff декодирует байты этой функции, он сначала начинает с инициирования своего внутреннего состояния для локальных переменных согласно соглашению вызова для функций WebAssembly. Для x64 соглашение вызова V8 передает два параметра в регистрах rax и rdx.
Для инструкций get_local
Liftoff не генерирует никакого кода, а просто обновляет свое внутреннее состояние, чтобы отразить, что эти значения из регистров теперь помещены на виртуальный стек. Инструкция i32.add
затем извлекает два регистра и выбирает регистр для значения результата. Мы не можем использовать какой-либо из входных регистров для результата, так как оба регистра все еще находятся на стеке для хранения локальных переменных. Перезаписывание их изменило бы значение, возвращаемое последующей инструкцией get_local
. Поэтому Liftoff выбирает свободный регистр — в данном случае rcx, — и записывает сумму rax и rdx в этот регистр. rcx затем добавляется на виртуальный стек.
После инструкции i32.add
тело функции завершено, поэтому Liftoff должен сформировать возврат функции. Поскольку наша примерная функция имеет одно возвращаемое значение, проверка требует, чтобы в конце тела функции на виртуальном стеке было ровно одно значение. Поэтому Liftoff генерирует код, который перемещает возвращаемое значение, хранящееся в rcx, в правильный регистр возврата rax, а затем возвращает управление из функции.
Для простоты, приведенный выше пример не содержит никаких блоков (if
, loop
…) или ветвлений. Блоки в WebAssembly вводят слияние управления, поскольку код может переходить в любой родительский блок, а блоки if
могут пропускаться. Эти точки слияния могут быть достигнуты из разных состояний стека. Однако последующий код должен предполагать определенное состояние стека для генерации кода. Таким образом, Liftoff делает снимок текущего состояния виртуального стека как состояния, которое будет предполагаться для кода, следующего за новым блоком (т.е. при возврате к уровню управления, где мы сейчас находимся). Новый блок затем продолжает с текущего активного состояния, потенциально изменяя, где хранятся значения стека или локальные переменные: некоторые могут быть выгружены в стек или размещены в других регистрах. При переходе в другой блок или завершении блока (что то же самое, что и переход к родительскому блоку) Liftoff должен генерировать код, который адаптирует текущие состояние к ожидаемому состоянию в этой точке, чтобы код, излучаемый для целевого блока, находил правильные значения там, где он их ожидает. Проверка гарантирует, что высота текущего виртуального стека соответствует высоте ожидаемого состояния, поэтому Liftoff нужно только генерировать код для перемещения значений между регистрами и/или физическим стековым кадром, как показано ниже.
Посмотрим пример этого.
Пример выше предполагает виртуальный стек с двумя значениями в операндном стеке. Перед началом нового блока верхнее значение виртуального стека извлекается как аргумент для инструкции if
. Оставшееся значение стека нужно разместить в другом регистре, так как оно сейчас тенирует первый параметр, но при возврате к этому состоянию нам могут понадобиться два разных значения для значения стека и параметра. В данном случае Liftoff выбирает для этого регистр rcx. Это состояние затем сохраняется как снимок, а активное состояние модифицируется внутри блока. В конце блока мы неявно возвращаемся к родительскому блоку, поэтому объединяем текущее состояние со снимком, перемещая регистр rbx в rcx и загружая регистр rdx из стекового кадра.
Повышение уровня от Liftoff до TurboFan
С использованием Liftoff и TurboFan теперь у V8 есть два уровня компиляции для WebAssembly: Liftoff в качестве базового компилятора для быстрого запуска и TurboFan в качестве оптимизирующего компилятора для максимальной производительности. Возникает вопрос, как объединить два компилятора для обеспечения наилучшего общего пользовательского опыта.
Для JavaScript V8 использует интерпретатор Ignition и компилятор TurboFan, применяя стратегию динамического повышения уровня. Каждая функция сначала выполняется в Ignition, и если функция становится горячей, TurboFan компилирует её в высокооптимизированный машинный код. Подобный подход также может быть использован для Liftoff, но здесь компромиссы немного отличаются:
- WebAssembly не требует обратной связи по типам для генерации быстрого кода. В то время как JavaScript значительно выигрывает от сбора обратной связи по типам, WebAssembly является статически типизированным, поэтому движок может сразу генерировать оптимизированный код.
- Код WebAssembly должен выполняться предсказуемо быстро, без длительной фазы разогрева. Одной из причин, по которой приложения ориентированы на WebAssembly, является выполнение в вебе с предсказуемо высокой производительностью. Поэтому мы не можем терпеть слишком долго выполнение неоптимального кода, а паузы на компиляцию во время выполнения также неприемлемы.
- Важной целью проектирования интерпретатора Ignition для JavaScript является уменьшение использования памяти путем отказа от компиляции функций. Однако мы обнаружили, что интерпретатор для WebAssembly слишком медленный, чтобы обеспечивать предсказуемо высокую производительность. Фактически мы разработали такой интерпретатор, но, будучи в 20 раз или более медленнее, чем скомпилированный код, он полезен только для отладки, независимо от того, сколько памяти он экономит. Учитывая это, движок все равно должен сохранять скомпилированный код; в конечном итоге он должен хранить только наиболее компактный и эффективный код, который оптимизирован с помощью TurboFan.
Исходя из этих ограничений, мы пришли к выводу, что динамическое повышение уровня (tier-up) прямо сейчас является неудачным компромиссом для реализации WebAssembly в V8, так как это увеличило бы размер кода и снизило производительность на неопределенный период времени. Вместо этого мы выбрали стратегию раннего повышения уровня (eager tier-up). Сразу после завершения компиляции модуля с помощью Liftoff движок WebAssembly запускает фоновые потоки для генерации оптимизированного кода для этого модуля. Это позволяет V8 быстро начинать выполнение кода (после завершения Liftoff), но при этом обеспечивать доступность самого производительного кода TurboFan как можно раньше.
На изображении ниже показан процесс компиляции и выполнения бенчмарка EpicZenGarden. Видно, что сразу после компиляции с помощью Liftoff мы можем инстанцировать модуль WebAssembly и начать его выполнение. Компиляция с помощью TurboFan длится еще несколько секунд, поэтому в течение периода повышения уровня производительность выполнения постепенно увеличивается, поскольку отдельные функции TurboFan начинают использоваться сразу после их завершения.
Производительность
Два показателя интересны для оценки производительности нового компилятора Liftoff. Во-первых, мы хотим сравнить скорость компиляции (т.е. время генерации кода) с TurboFan. Во-вторых, мы хотим измерить производительность сгенерированного кода (т.е. скорость выполнения). Первый показатель представляет больший интерес, так как целью Liftoff является сокращение времени запуска путем быстрой генерации кода. С другой стороны, производительность сгенерированного кода все равно должна быть довольно хорошей, поскольку этот код может выполняться в течение нескольких секунд или даже минут на низкопроизводительном оборудовании.
Производительность генерации кода
Для измерения производительности компилятора мы запустили ряд бенчмарков и измерили чистое время компиляции, используя трассировку (см. изображение выше). Тесты проводились на компьютере HP Z840 (2 x Intel Xeon E5-2690 @2.6GHz, 24 ядра, 48 потоков) и на MacBook Pro (Intel Core i7-4980HQ @2.8GHz, 4 ядра, 8 потоков). Обратите внимание, что Chrome в настоящее время не использует более 10 фоновых потоков, поэтому большинство ядер машины Z840 остаются незадействованными.
Мы провели три теста:
- EpicZenGarden: демо ZenGarden, работающая на основе Epic framework
- Tanks!: демо движка Unity
- AutoDesk
- PSPDFKit
Для каждого теста мы измеряем чистое время компиляции, используя вывод трассировки, как показано выше. Это число более стабильно, чем любое время, сообщаемое самим бенчмарком, так как оно не зависит от задач, запланированных на основном потоке, и не включает несвязанные задачи, такие как создание фактического экземпляра WebAssembly.
Графики ниже показывают результаты этих тестов. Каждый тест был выполнен трижды, и мы приводим среднее время компиляции.
Как и ожидалось, компилятор Liftoff генерирует код значительно быстрее как на высокопроизводительном рабочем столе, так и на MacBook. Ускорение Liftoff относительно TurboFan еще больше на менее мощной аппаратной платформе MacBook.
Производительность сгенерированного кода
Несмотря на то, что производительность сгенерированного кода является вторичной целью, мы стремимся обеспечить пользователям высокую производительность на этапе запуска, так как код Liftoff может выполняться несколько секунд до завершения работы кода TurboFan.
Для измерения производительности кода Liftoff мы отключили повышение уровня (tier-up), чтобы измерить чистое выполнение Liftoff. В этой настройке мы выполняем два теста:
-
Бенчмарки Unity без интерфейса
Это серия тестов, выполняющихся в среде Unity. Они выполняются без интерфейса, поэтому могут запускаться напрямую в оболочке d8. Каждый тест выдаёт результат, который не обязательно пропорционален производительности выполнения, но достаточен для сравнения характеристик.
-
Этот тест показывает время, необходимое для выполнения различных действий с PDF-документом, и время, необходимое для создания модуля WebAssembly (включая компиляцию).
Как и прежде, мы запускаем каждый тест три раза и используем среднее значение этих запусков. Поскольку масштаб записанных чисел значительно различается между тестами, мы представляем относительную производительность Liftoff против TurboFan. Значение +30% означает, что код Liftoff выполняется на 30% медленнее, чем код TurboFan. Отрицательные числа указывают на то, что Liftoff выполняется быстрее. Вот результаты:
На Unity код Liftoff выполняется в среднем примерно на 50% медленнее, чем код TurboFan на настольном компьютере, и на 70% медленнее на MacBook. Интересно, что есть один случай (Mandelbrot Script), где код Liftoff превосходит код TurboFan. Вероятно, это исключение, где, например, распределение регистров TurboFan работает плохо в горячем цикле. Мы исследуем возможность улучшения TurboFan для лучшей обработки этого случая.
В тесте PSPDFKit код Liftoff выполняется на 18-54% медленнее, чем оптимизированный код, при этом инициализация значительно улучшена, как ожидалось. Эти числа показывают, что для кода реального мира, который также взаимодействует с браузером через вызовы JavaScript, потеря производительности неоптимизированного кода обычно ниже, чем на более вычислительно-интенсивных тестах.
И снова отметим, что для этих чисел мы полностью отключили tier-up, поэтому мы выполняли только код Liftoff. В производственных конфигурациях код Liftoff постепенно заменяется кодом TurboFan, так что пониженная производительность кода Liftoff длится только в течение короткого времени.
Будущая работа
После первоначального запуска Liftoff мы работаем над дальнейшим улучшением времени запуска, снижением использования памяти и предоставлением преимуществ Liftoff большему числу пользователей. В частности, мы работаем над улучшением следующих аспектов:
- Портировать Liftoff на архитектуры arm и arm64 для использования на мобильных устройствах. В настоящее время Liftoff реализован только для платформ Intel (32 и 64 бит), которые в основном охватывают сценарии использования на десктопе. Чтобы также охватить мобильных пользователей, мы перенесем Liftoff на другие архитектуры.
- Реализовать динамический tier-up для мобильных устройств. Поскольку мобильные устройства, как правило, имеют гораздо меньше доступной памяти, чем настольные системы, нам нужно адаптировать нашу стратегию многоуровневой компиляции для этих устройств. Просто рекомпиляция всех функций с TurboFan легко удваивает объем памяти, необходимой для хранения всего кода, по крайней мере временно (до тех пор, пока код Liftoff не будет удален). Вместо этого мы экспериментируем с комбинацией ленивой компиляции с использованием Liftoff и динамического tier-up горячих функций в TurboFan.
- Улучшить производительность генерации кода Liftoff. Первая итерация реализации редко бывает лучшей. Есть несколько вещей, которые можно настроить для ускорения скорости компиляции Liftoff. Это будет происходить постепенно в следующих выпусках.
- Улучшить производительность кода Liftoff. Помимо самого компилятора, размер и скорость сгенерированного кода также можно улучшить. Это также будет происходить постепенно в следующих выпусках.
Заключение
V8 теперь содержит Liftoff, новый базовый компилятор для WebAssembly. Liftoff значительно сокращает время запуска WebAssembly приложений с помощью простого и быстрого генератора кода. На настольных системах V8 все еще достигает максимальной пиковой производительности, рекомпилируя весь код в фоновом режиме с использованием TurboFan. Liftoff включен по умолчанию в V8 v6.9 (Chrome 69) и может быть явно управляем с помощью флагов --liftoff
/--no-liftoff
и chrome://flags/#enable-webassembly-baseline
соответственно.