Главная » Хабрахабр » Портирование JS на Эльбрус

Портирование JS на Эльбрус

Это рассказ про портирование JavaScript на отечественную платформу Эльбрус, выполненное ребятами из компании UniPro. В статье — краткий сравнительный анализ платформ, детали процесса и подводные камни.

Под катом вы найдете видео и текстовую расшифровку доклада.
В основе статьи — доклад Дмитрия (dbezheckov) Бежецкова и Владимира (volodyabo) Ануфриенко с HolyJS 2018 Piter.

Часть 1. Эльбрус, родом из России

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

VLIW архитектура

Совершенно другое архитектурное решение, нежели суперскалярная архитектура, которая более распространена на рынке сейчас. VLIW позволяет более тонко выражать намерения в коде за счет явного управления всеми независимыми арифметико-логическими устройствами (АЛУ), которых у Эльбруса, к слову, 4. Это не исключает возможности простоя каких-то АЛУ, но все же увеличивает теоретическую производительность на один такт процессора.

Объединение команд в бандлы

Готовые процессорные команды объединяются в бандлы (Bundles). Один bundle — это одна большая инструкция, которая выполняется за условный такт. В ней есть много атомарных инструкций, которые в архитектуре Эльбрус исполняются независимо и сразу.

Если с инструкциями ldd, fmuld, faddd, fsqrts все примерно понятно, то с инструкция return в самом начале первого бандла вызывает удивление у людей, не знакомых с ассемблером Эльбруса. На изображении справа серыми прямоугольниками обозначены бандлы, получившиеся при обработке JS-кода слева. Затем в последнем бандле мы уже совершаем переход по заранее загруженному адресу в ctpr3. Эта инструкция загружает адрес возврата из текущей функции floatMath в регистр 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 на Эльбрусе?

  1. Импортозамещение.
  2. Вывод Эльбруса на рынок домашних компьютеров, где для того же браузера уже необходим Javascript.
  3. В промышленности уже нужен Эльбрус, например с Node.js. Следовательно, нужно портировать Node под данную архитектуру.
  4. Развитие архитектуры Эльбрус, а также специалистов в этой области.

Если нет интерпретатора, приходят два компилятора

За основу была взята предыдущая реализация v8 от Гугл. Работает она так: из исходного кода создается абстрактное синтаксическое дерево, далее в зависимости от того, исполнялся ли код или нет, с помощью одного из двух компиляторов (Crankshaft или FullCodegen) создается, соответственно, оптимизированный или неоптимизированный бинарный код. При этом интерпретатора нет.

Как работает FullCodegen?

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

0 для Эльбрус. Тем не менее, все это было сделано, и получилась версия компилятора FullCodegen 1. Делалось все через 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.

Видно, что на первом этапе все похоже, исходный код парсится в абстрактное синтаксическое дерево, потом в байт-код, а далее начинаются отличия.

Далее интерпретированный код попадает в неотимизирующий компилятор Baseline. В SpiderMonkey байт-код интерпретируется C++ интерпретатором, который в сущности напоминает большой switch, внутри которого совершаются прыжки по байт-коду. В движке V8 байт-код обрабатывается интерпретатором Ingnition, а затем компилятором TurboFan. Затем на финальной стадии в дело включается оптимизирующий компилятор Ion.

Baseline, выбираю тебя!

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

Что такое framе?

Грубо говоря, это Javascript-контекст на железе, то есть набор данных в стеке, описывающий какую-либо вашу функцию. На изображениях выше вы можете видеть слово frame. На изображении ниже функция foo, а справа от нее то, как она выглядит в стеке: аргументы, описание функции, адрес возврата, указание на предыдущий фрейм, ведь функция откуда-то была вызвана и чтобы корректно вернуться в место вызова эта информация должна хранится в стеке, а далее уже сами локальные переменные функции и операнды для вычислений.

Таким образом, достоинства Baseline:

  • Похож на FullCodegen, следовательно, пригодился его опыт портирования;
  • Портируем ассемблер, получаем рабочий компилятор;
  • Удобно отлаживать;
  • Любой стаб можно переписать.

Но есть и минусы:

  • Линейный код, пока один байт код не выполнишь, не получится выполнить следующий, что не очень хорошо для архитектуры с параллельными вычислениями;
  • Так как работает с байт-кодом, особо не оптимизируешь.

Оставалось только реализовать макроассемблер и получить готовый компилятор. Отладка тоже не предвещала особых проблем, достаточно было посмотреть стек на архитектуре x86, а потом на тот, что получался при портировании, чтобы найти проблему.

В итоге по тестам с новым компилятором производительность выросла в три раза:

А их реализация очень важна. Тем не менее, Octane не поддерживает работу с исключениями.

Исключительная работа

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

Потребуется по системным вызовам подсчитать, сколько нужно отмотать назад в Chain-стеке. Проблема в том, что из-за другого устройства стека на архитектуре Эльбрус так сделать не получится. Далее в адресе в Chain-стеке делаем замену на адрес, делающий возврат. Далее делаем системный вызов, чтобы получить стек вызовов.

Ниже иллюстрация об очередности данных шагов.

Но все-таки на Intel это выглядит как-то попроще: Не самый быстрый способ, тем не менее, исключение обрабатывается.

С Эльбрусом прыжков до самого обработчика будет больше:


Вот почему не стоит основывать логику программы на исключениях, тем более на Эльбрусе.

Оптимизируй это!

Итак, обработка исключений реализована. Теперь расскажем, как мы сделали все это чуть более быстрым:

  • Переписали инлайн кэши;

  • Сделали ручную (а потом и автоматическую) расстановку задержек;
  • Вынесли подготовки переходов (выше по коду): чем раньше подготавливается переход тем лучше;
  • Поддержали инкрементальный сборщик мусора

На втором пункте остановимся чуть более подробно. Мы уже рассматривали небольшой пример работы с bundles, к нему и перейдем.

Таким образом, если мы хотим перемножить два числа, пришли в операцию умножения, но сами операнды еще не загрузились, процессору остается только ждать их загрузки. Любая операция, например, загрузки не делается в один такт, в данном случае она делается в три такта. Но если вручную выставить задержки, время ожидания можно сократить, тем самым повысив быстродействие. И он будет ждать некоторое количество тактов, кратное четырем. Далее процесс расстановки задержек был автоматизирован.

0 vs Baseline v1. Итоги оптимизации 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)
} 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. Но их недостаточно, поэтому в runtime нужно получить информацию, где находятся эти операнды. В них сохраняются все операнды, нужные на данный момент для восстановления baseline фреймов. На основе этой информации восстанавливается baseline фрейм. Проходит фаза lowering, regAlloc, после чего получается снимок (snapshot), в котором известно, где находятся нужные операнды.

Процедура изображена на картинке:

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

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

  Схема для Эльбруса более сложная: есть промежуточный деоптимизационный стаб, который воссоздает всю эту информацию на chain-стеке, рекурсивно вызывая себя N раз, затем происходит системный вызов, который подменяет адреса возвратов на соответствующие адреса фреймов baseline, и затем переходит на точку сохранения и возобновляет выполнение.

Это стоит учесть при разработке, если у вас в коде часто встречается деоптимизация.

Переработанная схема для архитектуры Эльбрус:

Гистограмму вы можете увидеть ниже: Результатом всей работы портирования Ion стал 4-х кратный прирост производительности на некоторых бенчмарках по сравнению с предыдущей реализацией baseline.

Итоги

Итак, теперь вы знаете, что на Эльбрусе есть SpiderMonkey, V8 и Node. В целом портирование — несложный процесс. Его можно выполнять небольшой командой.

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

Уже известная информация о программе — на сайте, и билеты можно приобрести там же. Если доклад понравился, обратите внимание: 24-25 ноября в Москве состоится новая HolyJS, и там тоже будет много интересного.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

Как распознавание лиц помогает находить тестовые телефоны

Привет, хабровчане! В EastBanc Technologies ведётся большое количество проектов, связанных с мобильной разработкой. В связи с чем необходим целый зоопарк устройств для тестирования на всех этапах. И, что характерно, каждый отдельный девайс постоянно оказывается нужен самым разным людям, а найти ...

Security Week 39: на смерть Google+

На прошлой неделе Google объявил (новость) о закрытии социальной сети Google+, но сделано это было достаточно необычно. Компания Google вообще не стесняется закрывать проекты, которые по разным причинам не взлетели. Многие до сих пор не могут простить компании отказа от ...