Это рассказ про портирование JavaScript на отечественную платформу Эльбрус, выполненное ребятами из компании UniPro. В статье — краткий сравнительный анализ платформ, детали процесса и подводные камни.
В основе статьи — доклад Дмитрия (dbezheckov) Бежецкова и Владимира (volodyabo) Ануфриенко с HolyJS 2018 Piter. Под катом вы найдете видео и текстовую расшифровку доклада.
Часть 1. Эльбрус, родом из России
Сначала разберемся с тем, что такое Эльбрус. Вот несколько ключевых особенностей данной платформы в сравнении с x86.
VLIW архитектура
Совершенно другое архитектурное решение, нежели суперскалярная архитектура, которая более распространена на рынке сейчас. VLIW позволяет более тонко выражать намерения в коде за счет явного управления всеми независимыми арифметико-логическими устройствами (АЛУ), которых у Эльбруса, к слову, 4. Это не исключает возможности простоя каких-то АЛУ, но все же увеличивает теоретическую производительность на один такт процессора.
Объединение команд в бандлы
Готовые процессорные команды объединяются в бандлы (Bundles). Один bundle — это одна большая инструкция, которая выполняется за условный такт. В ней есть много атомарных инструкций, которые в архитектуре Эльбрус исполняются независимо и сразу.
На изображении справа серыми прямоугольниками обозначены бандлы, получившиеся при обработке JS-кода слева. Если с инструкциями ldd, fmuld, faddd, fsqrts все примерно понятно, то с инструкция return в самом начале первого бандла вызывает удивление у людей, не знакомых с ассемблером Эльбруса. Эта инструкция загружает адрес возврата из текущей функции floatMath в регистр ctpr3 заранее, чтобы процессор смог успеть подкачать нужные инструкции. Затем в последнем бандле мы уже совершаем переход по заранее загруженному адресу в ctpr3.
Также стоит отметить что у Эльбруса намного больше регистров 192 + 32 + 32 против 16 + 16 +8 у x86.
Явная спекулятивность против неявной
Эльбрус поддерживает явную спекулятивность на уровне команд. Следовательно, мы можем сделать вызов и загрузку a.bar из памяти еще до проверки того, что она не является null, как это видно в коде справа. Если чтение логически в конце окажется невалидным, то значение в b просто будет помечено аппаратно как неправильное и обратиться к нему будет нельзя.
Поддержка условного исполнения
Эльбрус также поддерживает условное исполнение. Рассмотрим это на следующем примере.
Как мы видим, код из предыдущего примера про спекулятивность сокращается еще и за счет использования свертки условного выражение в зависимость не по управлению, а по данным. Эльбрус аппаратно поддерживает предикатные регистры, в которых можно хранить только два значение true или false. Основная их фишка в том, что можно помечать инструкции таким предикатом и зависимости от его значения в момент исполнения инструкция исполнится или нет. В данном примере инструкция cmpeq выполняет сравнение и его логический результат помещает в предикат P1, который затем используется как маркер для загрузки в результат значения из b. Соответственно если предикат был равен true, то и в result осталось значение 0.
Данный подход позволяет трансформировать довольно сложный граф управления программы в предикатное исполнение и соответственно повышает наполненность бандла. Теперь мы можем генерировать больше независимых команд под разными предикатами и наполнять ими бандлы. Эльбрус поддерживает 32 предикатных регистра, что позволяет кодировать 65 потоков управления (плюс один на отсутствие предиката на команде).
Три аппаратных стека по сравнению с одним в Intel
Два из них защищенные от модификации программистом. Один — chain-стек — отвечает за хранение адресов для возвратов из функций, другой — стек регистров — содержит параметры, через которые они передаются. В третьем — пользовательском стеке — хранятся переменные и данные пользователя. В intel все хранится в одном стеке, что порождает уязвимости, так как все адреса переходов, параметров находятся в одном незащищенном от модификаций пользователем месте.
Нет динамического предсказателя переходов
Вместо него используется схема с if-конверсией и подготовками переходов, чтобы конвейер выполнения не останавливался.
Так зачем нам JS на Эльбрусе?
- Импортозамещение.
- Вывод Эльбруса на рынок домашних компьютеров, где для того же браузера уже необходим Javascript.
- В промышленности уже нужен Эльбрус, например с Node.js. Следовательно, нужно портировать Node под данную архитектуру.
- Развитие архитектуры Эльбрус, а также специалистов в этой области.
Если нет интерпретатора, приходят два компилятора
За основу была взята предыдущая реализация v8 от Гугл. Работает она так: из исходного кода создается абстрактное синтаксическое дерево, далее в зависимости от того, исполнялся ли код или нет, с помощью одного из двух компиляторов (Crankshaft или FullCodegen) создается, соответственно, оптимизированный или неоптимизированный бинарный код. При этом интерпретатора нет.
Как работает FullCodegen?
Узлы синтаксического дерева переводятся в бинарный код, после чего все «склеивается» вместе. Один узел представляет собой около 300 строк кода на макроассемблере. Это, во-первых, дает широкий горизонт оптимизаций, а, во-вторых, отсутствуют переходы по байткодам, как в интерпретаторе. Это просто, но в то же время существует проблема — во время портирования придется переписать много кода на макроассемблере.
Тем не менее, все это было сделано, и получилась версия компилятора FullCodegen 1.0 для Эльбрус. Делалось все через C++ runtime v8, ничего не оптимизировали, ассемблерный код был просто переписан с x86 под архитектуру Эльбрус.
Codegen 1.1
В итоге результат получился не совсем таким, как ожидали, и было решено выпустить версию FullCodegen 1.1:
- Сделали меньше runtime, писали на макроассемблере;
- Добавили ручные if-конверсии (на рисунке, в качестве примера, показана проверка переменной js на true или false);
Обратите внимание, что проверка на NaN, undefined, null делается за один раз, без использования if, которое бы потребовалось в Intel-архитектуре.
- Код не просто переписали с Intel, а реализовали спекулятивность в стабах и реализовали fast-path тоже через MAsm (макроассемблер).
Тесты проводились в Google Octane. Тестовые машины:
- Эльбрус: E2S 750 МГц, 24 ГБ
- Intel: core i7 3,4 ГГц, 16 ГБ
Далее результаты:
На гистограмме — соотношение результатов, т.е. насколько Эльбрус хуже «Интела». На двух тестах Crypto и zlib результаты заметно хуже из-за того, что в Эльбрусе еще нет аппаратных инструкций для работы с шифрованием. В целом, учитывая разницу в частотах, получилось неплохо.
Далее тест в сравнении с интерпретатором js из firefox, входящего в стандартную поставку Эльбруса. Больше — лучше.
Вердикт — компилятор снова справился неплохо.
Результаты разработки
- Новый JS-движок прошел test262 тесты. Это дает ему право называться полноценной средой исполнения ECMAScript 262.
- Производительность увеличилась в среднем в пять раз по сравнению с предыдущим движком — интерпретатором.
- Node.js 6.10 тоже портировали в качестве примера использования V8, благо это было не сложно.
- Тем не менее, все еще хуже, чем Core i7 на FullCodegen, в семь раз.
Казалось, ничего не предвещало
Все было бы хорошо, но тут Google объявил, что больше не поддерживает FullCodegen и Crankshaft и они будут удалены. После чего команда получила заказ на разработку для браузера Firefox, и об этом — дальше.
Часть 2. Firefox и ее обезьяна-паук
Речь пойдет о движке браузера Firefox — SpiderMonkey. На рисунке отличия между этим движком и более новым V8.
Видно, что на первом этапе все похоже, исходный код парсится в абстрактное синтаксическое дерево, потом в байт-код, а далее начинаются отличия.
В SpiderMonkey байт-код интерпретируется C++ интерпретатором, который в сущности напоминает большой switch, внутри которого совершаются прыжки по байт-коду. Далее интерпретированный код попадает в неотимизирующий компилятор Baseline. Затем на финальной стадии в дело включается оптимизирующий компилятор Ion. В движке V8 байт-код обрабатывается интерпретатором Ingnition, а затем компилятором TurboFan.
Baseline, выбираю тебя!
Портирование начали с компилятора Baseline. Он в сущности представляет собой стековую машину. То есть есть некий стек, из ячеек которого он берет переменные, запоминает их, производит с ними какие-то действия, после чего возвращает как переменные, так и результаты действий обратно в ячейки стека. Ниже на нескольких картинках пошагово отображен этот механизм в отношении простой функции foo:
Что такое framе?
На изображениях выше вы можете видеть слово frame. Грубо говоря, это Javascript-контекст на железе, то есть набор данных в стеке, описывающий какую-либо вашу функцию. На изображении ниже функция foo, а справа от нее то, как она выглядит в стеке: аргументы, описание функции, адрес возврата, указание на предыдущий фрейм, ведь функция откуда-то была вызвана и чтобы корректно вернуться в место вызова эта информация должна хранится в стеке, а далее уже сами локальные переменные функции и операнды для вычислений.
Таким образом, достоинства Baseline:
- Похож на FullCodegen, следовательно, пригодился его опыт портирования;
- Портируем ассемблер, получаем рабочий компилятор;
- Удобно отлаживать;
- Любой стаб можно переписать.
Но есть и минусы:
- Линейный код, пока один байт код не выполнишь, не получится выполнить следующий, что не очень хорошо для архитектуры с параллельными вычислениями;
- Так как работает с байт-кодом, особо не оптимизируешь.
Оставалось только реализовать макроассемблер и получить готовый компилятор. Отладка тоже не предвещала особых проблем, достаточно было посмотреть стек на архитектуре x86, а потом на тот, что получался при портировании, чтобы найти проблему.
В итоге по тестам с новым компилятором производительность выросла в три раза:
Тем не менее, Octane не поддерживает работу с исключениями. А их реализация очень важна.
Исключительная работа
Для начала разберемся, как работают исключения на x86. Пока выполняется программа, в стек записываются адреса возврата из функций. В какой-то момент происходит исключение. Переходим в runtime обработчик исключений, в котором используются фреймы, о которых мы говорили выше. Находим, где конкретно возникло исключение, после чего нам нужно отмотать стек до нужного состояния, а далее меняется адрес возврата на тот, где будет обработано исключение.
Проблема в том, что из-за другого устройства стека на архитектуре Эльбрус так сделать не получится. Потребуется по системным вызовам подсчитать, сколько нужно отмотать назад в Chain-стеке. Далее делаем системный вызов, чтобы получить стек вызовов. Далее в адресе в Chain-стеке делаем замену на адрес, делающий возврат.
Ниже иллюстрация об очередности данных шагов.
Не самый быстрый способ, тем не менее, исключение обрабатывается. Но все-таки на Intel это выглядит как-то попроще:
С Эльбрусом прыжков до самого обработчика будет больше:
Вот почему не стоит основывать логику программы на исключениях, тем более на Эльбрусе.
Оптимизируй это!
Итак, обработка исключений реализована. Теперь расскажем, как мы сделали все это чуть более быстрым:
- Переписали инлайн кэши;
- Сделали ручную (а потом и автоматическую) расстановку задержек;
- Вынесли подготовки переходов (выше по коду): чем раньше подготавливается переход тем лучше;
- Поддержали инкрементальный сборщик мусора
На втором пункте остановимся чуть более подробно. Мы уже рассматривали небольшой пример работы с bundles, к нему и перейдем.
Любая операция, например, загрузки не делается в один такт, в данном случае она делается в три такта. Таким образом, если мы хотим перемножить два числа, пришли в операцию умножения, но сами операнды еще не загрузились, процессору остается только ждать их загрузки. И он будет ждать некоторое количество тактов, кратное четырем. Но если вручную выставить задержки, время ожидания можно сократить, тем самым повысив быстродействие. Далее процесс расстановки задержек был автоматизирован.
Итоги оптимизации BaseLine v1.0 vs Baseline v1.1. Несомненно, движок стал быстрее.
Как же это программистам и не сделать Ion’ную пушку?
На волне успеха от реализации Baseline v1.1 было решено портировать и оптимизирующий компилятор Ion.
Как работает оптимизирующий компилятор? Исходный код интерпретируется, запускается компиляция. В процессе выполнения байт-кода Ion собирает данные о типах, использующихся в программе, происходит анализ «горячих функций» — тех, которые выполняются чаще других. После этого принимается решение скомпилировать их получше, оптимизировать. Далее строится высокоуровневое представление компилятора, граф операций. На графе происходит оптимизация (opt 1, opt 2, opt …), создается низкоуровневое представление, состоящее из машинных команд, резервируются регистры, генерируется непосредственно оптимизированный бинарный код.
На Эльбрусе регистров больше и сами команды большие, поэтому нужны:
- Планировщик команд;
- Свой регистр аллокатор;
- Свой LIR (Low-level Intermediate Representation);
- Свой Code-generator.
У команды уже имелся опыт портирования Java на Эльбрус, эту же библиотеку для генерации кода решили использовать и для портирования Ion. Она называется TANGO. В ней есть:
- Планировщик команд;
- Свой регистр аллокатор;
- Низкоуровневые оптимизации.
Осталось привнести высокоуровневое представление в TANGO, сделать селектор. Проблема в том, что низкоуровневое представление в TANGO похоже на ассемблер, который сложен в поддержке и отладке. Как должен выглядеть компилятор внутри? В Mozilla для большей понятности сделали свой компилятор HolyJit, также есть еще вариант написать свой мини-язык для перевода между высокоуровневым и низкоуровневым представлением.
Разработка еще ведется. Ну а далее о том, как не перестараться с оптимизацией.
Часть 3. Лучшее — враг хорошего
Компиляция, как она есть
Процесс оптимизации в Ion, когда код нагревается, а потом компилируется и оптимизируется — жадный, это можно увидеть на следующем примере.
function foo(a, b) {
return a + b;
}
function doSomeStuff(obj) {
for (let i = 0; i < 1100; ++i) {
print(foo(obj,obj));
}
}
doSomeStuff("HollyJS");
doSomeStuff({n:10});
При запуске в JS Shell с отключенной многопоточностью (для чистоты эксперимента), который поставляется с Mozilla, видим следующую ситуацию:
Функция исполняется. Мы видим, что счетчик количества исполнений большой, но в какой-то момент появляется сообщение о bailout (катапультировании). Это означает, что произошла деоптимизация. В данном случае в функцию foo сначала передаются объекты типа object, но после прогона функции в цикле со строками в качестве аргументов, компилятор решает так оптимизировать код, как будто данная функция работает только со строками. Если декомпилировать оптимизированный код, получится следующее:
function doSomeStuff(obj) {
for (let i=0; i < 1100; ++i) {
if (!(obj instanceof String))
// bailout
print(foo_only_str(obj, obj));
}
}
Как только в функцию будет передан первый аргумент не строка, оптимизированный код будет выброшен и мы снова перейдем на неоптимизированную версию кода.
На изображении мы видим пример низкоуровневой работы компилятора. Серым отмечены команды, которые были им оптимизированы при создании новой версии кода, например при DCE.
Пониженная передача
При катапультировании мы переходим в менее оптимизированный код, который ожидает, что на стеке будут все нужные ему объекты, как будто все это время исполнялась именно неоптимизированная версия кода.
Для того чтобы данные, необходимые для выполнения, при этом не терялись, в SpiderMonkey есть Resume Point. Что-то вроде точек сохранения, к которым всегда можно вернуться. В них сохраняются все операнды, нужные на данный момент для восстановления baseline фреймов. Но их недостаточно, поэтому в runtime нужно получить информацию, где находятся эти операнды. Проходит фаза lowering, regAlloc, после чего получается снимок (snapshot), в котором известно, где находятся нужные операнды. На основе этой информации восстанавливается baseline фрейм.
Процедура изображена на картинке:
В runtime на x86 это выглядит следующим образом: допустим, проверка в оптимизированном коде сработала и вас катапультировало. Собирается дамп регистров и нужная из них информация. Вызывается С, в куче размещается информация о полученных фреймах, вызывается еще один стаб, копирующий операнды на стек, а затем делается переход на байткод, который соответствует точке сохранения. Или, если вы были посреди инлайн кэша, переключается на Type монитор. Схема изображена ниже:
На Эльбрусе так не получится, потому что одна функция соответствует нескольким фреймам, а фреймы в Эльбрусе управляются железом и хранятся в защищенном chain стеке. Следовательно, нужно воссоздать не один фрейм, а несколько.
Схема для Эльбруса более сложная: есть промежуточный деоптимизационный стаб, который воссоздает всю эту информацию на chain-стеке, рекурсивно вызывая себя N раз, затем происходит системный вызов, который подменяет адреса возвратов на соответствующие адреса фреймов baseline, и затем переходит на точку сохранения и возобновляет выполнение.
Это стоит учесть при разработке, если у вас в коде часто встречается деоптимизация.
Переработанная схема для архитектуры Эльбрус:
Результатом всей работы портирования Ion стал 4-х кратный прирост производительности на некоторых бенчмарках по сравнению с предыдущей реализацией baseline. Гистограмму вы можете увидеть ниже:
Итоги
Итак, теперь вы знаете, что на Эльбрусе есть SpiderMonkey, V8 и Node. В целом портирование — несложный процесс. Его можно выполнять небольшой командой.
Но всегда важно помнить про подводные камни. В случае с Эльбрусом ими были деоптимизация, медленная работа стабов, работа с задержками конвейера и chain-стек.
Если доклад понравился, обратите внимание: 24-25 ноября в Москве состоится новая HolyJS, и там тоже будет много интересного. Уже известная информация о программе — на сайте, и билеты можно приобрести там же.
Автор: Евгений Трифонов