Архитектура и особенности процессора Эльбрус 2000
В чем принципиальные особенности процессора российской разработки Эльбрус? О ней в последнее время много говорят: как хвалят, так и ругают. Но давайте углубимся в архитектуру процессора, чтобы все-таки понять в чем его плюсы и минусы.
Расскажу, что такое скрытый и явный параллелизм, как используются предикаты и осуществляется подготовка переходов. Почему Эльбрусу не нужны push и pop команды и в чем особенности его регистрового окна. Какая защита от атак есть у российского процессора и какие возможности дает защищенный режим.
Данная статья — транскрипт моего выступления на конференции HighLoad++.
Что такое Эльбрус
Эльбрус не является клоном какого-либо процессора. Это абсолютно российская, даже еще советская разработка. Эльбрус умеет исполнять код x86, но делает это не аппаратно, а путем бинарной трансляции. Современные версии довольно производительны. Например, Эльбрус-16С — это 16 ядер, 2 ГГц, 750 Гфлоп/с, 16 нм. И 2 ГГц Эльбруса — это не 2 ГГц того же самого Интела, потому что Эльбрус умеет запускать на один такт до 50 инструкций. Конечно, компилятор должен суметь сгенерировать такой код, но технически это возможно.
Эльбрус серийно производится с 2014 года. Имеет поддержку МСВС, ALT Linux, Astra Linux. Есть версия QNX, российские ОСРВ и Postgres. На него вообще перенесено довольно много кода.
Эльбрус — довольно специфичный вид процессоров. Он непохож на то, к чему все привыкли. В нем есть все те же базовые инструкции: сложение, вычитание, умножение, условные и безусловные переходы, но у него теговая архитектура. Процессор тегирует данные в памяти таким образом, что знает тип объектов. Когда обычный процессор обращается в памяти к какому-то значению, он просто считывает битовую строку и трактует ее как float, или int, или pointer. Эльбрус же точно знает, что лежит в данной ячейке памяти.
Закон Мура
Есть закон Мура: каждые два года количество транзисторов на одном кристалле в среднем удваивается. Появился он давно, но работает до сих пор.
До 1995 года это было абсолютной синекурой для программистов: удвоение давало прямой прирост производительности единственного ядра процессора, и программы без приложения усилий со стороны программиста работали быстрее. Заменили 8086 на 286 — все ускорилось, 386 — еще быстрее, 486 — еще быстрее, Пентиум — программы просто летали. Но дальше начались проблемы. Прирост производительности отдельного ядра замедлился, и развитие процессоров пошло в сторону параллелизма. Ускорение начали делать за счет увеличения числа ядер, и программисты больше не могли просто запускать программы на более быстром процессоре, их пришлось распараллеливать. Тогда и появились процессоры со встроенным распараллеливанием исполнения кода.
Скрытый параллелизм
В 1995 году у Интел возникла проблема с тем, что программы завязаны на набор команд, но в архитектуре x86 мало регистров. Например, мы написали часть кода, который использует все 8 регистров, но следующая часть кода тоже их использует. Если за счет мощности процессора попытаться исполнить две части кода параллельно, они все равно упрутся в одни и те же регистры.
Чтобы решить проблему Интел начал разбивать сложные инструкции на простые, которым назначалось больше регистров, чем видно с точки зрения программиста. Когда два таких набора инструкций обращались к одному и тому же регистру, использовали его — загружали, считали и записали в память — они распараллеливались. Им выдавались две отдельные копии виртуального регистра AX.
На схеме слева показано, как устроена инструкция Интел, справа — как это сделано в RISC процессоре, который такую инструкцию исполняет. В Интел инструкция сама может сходить в память, считать значения, сделать вычисления и сохранить данные в память. В процессорах RISC инструкции фиксированного размера: либо инструкция читает из памяти, либо она выполняет регистровую операцию. Поэтому одна инструкция Интел на RISC-машинах разбивалась на серию простых инструкций.
Это до сих пор позволяет ускорять программы не сильно нагружая программистов, за счет выявления процессором в коде скрытого параллелизма и реализации его в виде реального параллельного запуска участков кода. Но такой подход сильно усложняет процессор. Ему приходится много анализировать и внутри него возникает много дополнительных действий, связанных с разбором инструкций, исполнением микроинструкций и выяснением где они заканчиваются.
Явный параллелизм
Конечно, полностью снять с программиста задачу распараллеливания, не получилось. Под многоядерные процессоры все равно нужно писать мультритредные программы и искать способы прогрузки всех аппаратных средств. Поэтому компиляторы внутри процессора и код прикладной программы сложны и требуют от программиста ручного распараллеливания алгоритмов. Хотя в пределах ядра модель CISC/RISC преобразования на x86/x64 процессорах до сих пор работает.
VLIW: ручной параллелизм
При разработке Э2К был принят кардинально другой подход. Чтобы процессор не распараллеливал изначально линейный код, эту работу полностью перенесли на компилятор. Он и так знает о программе больше, чем процессор. Может анализировать крупные участки кода, вплоть до всей программы, и принимать решения об оптимизации на более высоком уровне. Поэтому Эльбрус не пытается разбираться, где и что можно параллельно исполнять в коде, а оставляет это компилятору. Компилятор генерирует большую сложную инструкцию и подробно объясняет процессору, какие именно исполняющие устройства в данной инструкции должны делать.
Обычному интеловскому компилятору надо знать, что происходит в процессоре и подавать код так, чтобы процессору было легче его распараллеливать. А в процессорах с длинным командным словом (таких как Эльбрус и, кстати, Интел Itanium) — это полностью происходит на стороне компилятора. Процессор упрощается и существенное количество его аппаратуры можно потратить на исполнение, исполняющие устройства и кэш.
На борту Эльбруса
У каждого ядра процессора Эльбрус шесть АЛУ (арифметико-логических устройств), которые работают параллельно и могут исполнять шесть полноценных параллельных инструкций: вычисления, доступ к памяти, и все, что считается инструкцией. В очень широкой команде Эльбруса у каждого АЛУ свой фрагмент — слог, где точно определено, что надо делать. Например: складывать, вычитать, делить, записывать в память.
Кроме слогов для шести АЛУ широкая команда может содержать:
одну субинструкцию для юнита, которая управляет переходами
3 вычисления на предикатах и 6 квалифицирующих предикатов
4 инструкции для асинхронного чтения данных в цикле
4 литерала в 32 бита, которые идут в исполнение как константы
Интересное решение заложено в предикатах.
Предикаты
У процессора Эльбрус есть отдельный регистр, который называется регистром предикатов. Один бит в этом регистре (на самом деле там два бита) — это булево значение, которое можно использовать для условного исполнения инструкций. Когда вы запускаете на исполнение огромную инструкцию, то 6 субинструкций для каждого АЛУ можно завязать на предикаты. Это позволяет экономить на jmp потому, что некоторые простые условные операции можно закодировать в одной инструкции таким образом, что первая половина IF выполняется, если предикат 1, а вторая, если он 0. Мы сразу кладем в команду оба варианта кода для IF и для ELSE, и проверяем по определенному биту, какую из них исполнять.
Еще есть субинструкции для самих предикатов, которые позволяют посчитать их перед исполнением команды. В регистре предикатов можно связать биты по И, ИЛИ, получить ответ, и по этому ответу условно использовать команду или оставить ответ в регистре предикатов. Это как бы большой флаговый регистр, в котором можно сохранять большое количество булевых значений.
Еще большое количество булевых вычислений, которые относятся, как правило, к control-flow, можно исполнять параллельно с основным кодом программы.
Все современные процессоры это пайплайновые устройства. Каждая команда исполняется в процессоре много тактов — 5, 7 до 19. Из-за того, что в каждом такте команды исполняются по очереди: от первой инструкции первый шаг, от второй второй, от третьей третий и т.д., они расположены ступенью.
Команды перехода (jmp) — ад для любого процессора. Они сбивают работу конвейера и обходятся процессору как добрый десяток других команд.
В первой инструкции мы только считываем инструкцию из памяти, на втором такте для следующей инструкции считываем ее из памяти, а для предыдущей декодируем ее, и т.д. Так мы движемся по цепочке и процессор каждую инструкцию исполняет, скажем, 10 тактов. Но за счет того, что в каждом такте исполняется очередной этап десяти инструкций, в сумме процессор выполняет одну инструкцию за такт.
Так устроены все современные процессоры. Команда перехода (jmp) сбивает работу конвейера и обходятся процессору как десяток других команд. Она вынуждает процессор доработать все висящие в пайплайне инструкции: полностью закончить исполнение, сделать переход и потом снова начать наполнять пайплайн с нуля в новом месте. Из-за этого теряется огромное количество процессорного времени. Один jmp по стоимости может достигать 10–20 инструкций. Это огромная проблема для компиляторов, поэтому и компиляторы, и процессоры максимально оптимизируют код, избегая команд перехода.
Эльбрус может избежать этой проблемы не только с помощью предикатов, но и за счёт по другому устроенной команды переходов.
Подготовка переходов
В обычном процессоре есть две традиционные команды. Jmp, по которой процессор переходит на новый адрес инструкции и исполняет код с нового места. И условный jmp, который проверяет условие (в Эльбрусе это, как правило, предикат) и делает переход, если условие исполнилось.
В Эльбрусе условный и безусловный переход, вызов подпрограммы и возврат из подпрограммы можно разделить на две части. Сначала мы готовим переход и специальной инструкцией сообщаем процессору, что планируем перейти по такому-то адресу и сообщаем, как вычислить адрес перехода. Аппаратура Эльбруса параллельно с исполнением других конструкций выполняет это вычисление. Она может сходить в память, прочитать этот адрес и подготовиться к исполнению перехода.
Затем исполняется инструкция собственно перехода.
Если между подготовкой и исполнением перехода процессор успел выполнить другие задачи, переход оказывается «бесплатным». Потому что процессор параллельно подготовился, выяснил адрес перехода и подготовил к этому свою аппаратуру, и никакого сбоя пайплайна не происходит.
Процессор позволяет подготовить до 3 переходов одновременно. У него есть три специальных регистра, которые используются для подготовки jmp. Например, мы не знаем по какому адресу будем переходить, у нас типичный код IF (может быть, перейдем по нему, может, нет), но заранее знаем куда будем переходить потому, что адрес константный. Два из трех регистров обычно используется для подготовки переходов внутри кода функции, а третий умеет вызывать функции и возвращаться из них.
Еще в Эльбрусе необычно сделано регистровое окно.
Регистровое окно
В обычном процессоре есть 10–20–40 регистров. Он пишет в регистры промежуточные значения и читает из них. Обычно при вызове функций в регистрах сохраняются аргументы и возвратные значения. На Интеле и АРМе обычная функция устроена так. Перед ее вызовом команда push сохраняет в память регистров значения, которые понадобятся после возврата. Вызывается функция, что-то делает с этими регистрами, возможно затирает. Потом важные регистры поднимаются командой pop из стека и работа продолжается.
В Эльбрусе команды push и pop не используются. Процессор содержит пул из 256 84-разрядных регистров. Не все они видны программе: каждая функция «заказывает» у процессора нужное количество и процессор выделяет ей часть пула, в которой она будет жить.
Видимая подпрограмме группа регистров (окно в регистровый файл) делится на три последовательные части:
Общие регистры для нас и для вызвавшей нас функции, в которых находятся переданные нам параметры.
Регистры, в которых мы просто работаем, они видны только нашей функции.
Регистры, в которых мы будем передавать параметры вызываемой функции. Они готовятся перед вызовом.
Когда мы вызываем функцию, окно сдвигается вверх так, что параметры для следующей функции становятся нижней частью окна регистров. Дальше образуется следующая ее личная часть и те регистры, которые функция будет использовать для вызова уже своих функций:
В момент вызова функции регистровый файл виртуально сдвигается вниз — первая и вторая группы уходят ниже и не видны вызванной функции. Третья группа становится первой, а новые вторую и третью группы вызванная функция заказывает себе у процессора специальной командой.
Стек регистров
Для большей части кода все происходит без обращений к памяти. На глубину 10–20 вызовов хватает пула из 256 регистров. Если процессору хватило физических регистров для обеспечения вызывающей и вызываемой функции, то обращений к памяти не происходит. Если регистровый файл кончается, процессор прозрачно для прикладного и системного кода сохраняет в стек части регистрового файла. Если функция запрашивает регистры, а свободных в файле нет, то процессор прозрачно для программы сохраняет «нижнюю» часть регистрового файла в стеке одной крупной операции записи. Что куда более эффективно, чем обслуживание мелких push/pop инструкций в традиционных архитектурах. Естественно, что по мере возвратов из функций процессор так же прозрачно для кода возвращает регистры из стека.
Регистровое окно содержит отдельную часть, которая не передвигается, а является глобальной. Она видна всем функциям и может использоваться как обычные регистры в обычном процессоре. Либо как временный регистр, и мы понимаем, что в момент вызова функции он будет затираться вызванной функцией, поэтому длительно ничего в нем не храним. Либо, наоборот, как глобальный регистр, например, регистр для хранения ссылок на глобальные данные или position-independent code регистр, который используется компиляторами для глобальных таблиц с нужными данными.
Подкачка данных для цикла
Быстродействие современных процессоров в огромной мере упирается в обмен с памятью. Доступ в ОЗУ в сотни раз медленнее, чем операции внутри самого процессора, особенно с ожиданием результатов. Запись выполняется отдельными записывающими юнитами. Неважно когда они произведут запись, работу это не останавливает. А вот считывание останавливает. Например, вы обсчитываете видео или звук: считываете значения из буфера, посэмплово их обрабатываете и записываете. Если вы закодируете эту операцию обычным циклом (часть кода, jmp на начало, снова та же часть кода, который исполняется в цикле), то на каждой итерации будет обращение к памяти, на котором процессор зависнет, пока не получит данные из памяти. Поэтому разработчики архитектуры стараются сделать так, чтобы мы узнали о потребности считывания из памяти как можно раньше, желательно до того, как оно стало критичным. Практически во всех современных процессорах есть инструкция, которая готовит данные для использования, и старается заранее сохранить их в кэш, чтобы мгновенно получить при считывании.
У Эльбруса есть отдельно параллельно работающий юнит аппаратной подкачки данных. Внутри цикла вы пишете не инструкцию считывания из памяти, а инструкцию выборки данных из FIFO. До начала цикла запускаете аппаратную подкачку, которой сообщаете, из какого места в памяти идти и как считывать значения. Она опережает цикл, выдергивает из памяти значения, которые нужны циклу и кладет в FIFO, а цикл параллельно забирает считанные значения оттуда. Если все сделано правильно, то считывание из памяти идет параллельно с работой процессора со скоростью, которая практически не задерживает исполнение цикла.
Эльбрус поддерживает схему исполнения циклов, которая запускает следующие итерации цикла на исполнение в процессоре до того, как закончились предыдущие. В традиционном процессоре, jmp не позволит исполнять следующую итерацию до окончания предыдущей, поэтому код будет исполняться строго до последней инструкции. В Эльбрусе внутри регистрового окна может быть выделено подокно, и коду цикла указывается не конкретный регистр, а подокно. Процессор аппаратно для каждой следующей итерации цикла выдает следующий регистр из этого подокна. Это позволяет логически написать обращение к одному и тому же регистру для 10 идущих подряд итераций цикла в коде, а физически каждая следующая итерация получает следующий отдельный регистр. Таким образом итерации работают с той степенью параллельности, которую допускает аппаратура процессора.
Эти подходы позволяют ускорить выполнение циклов и снизить зависимость от обращений к оперативной памяти.
Несколько стеков
Эльбрус поддерживает несколько параллельных стеков. В частности, есть стек вызовов, который полностью поддерживается аппаратно. Из-за того, что на него не сохраняются данные из регистрового файла и пользовательские данные, он может быть организован в отдельной части памяти. Инструкция вызова функции сохраняет на этом стеке информацию о текущем состоянии и все, что нужно для возврата в предыдущую функцию. Поэтому пересечение в стеке между адресами возврата и данными пользователя невозможно. Они находятся в двух разных стеках. Это защищает от типичного для Интела метода атаки на код на C, когда в переменную (обычно строковую, которая лежит на стеке) записывается больше, чем размер этой переменной. Старые функции типа strcpy такое позволяли. Если строка длиннее, чем буфер, в который ее копируют и буфер находится на стеке, то буфер перезаписывается. Данные в нем затираются, а вместо адресов возврата вписывается специальный адрес, и при выполнении возврата из функции, функция возвращается не туда, куда должна, а к вирусному коду. Перехват управления через переполнение буфера — типовая проблема, которая встречается до сих пор.
В Эльбрус это невозможно из-за того, что стеки находятся в разных местах. Но помимо этой особенности, у российской разработки есть и другие способы защиты кода.
Защищенный режим
Эльбрус устроен сложнее традиционных процессоров, поэтому большинство вирусов в нем не работают. Сохранение регистра происходит аппаратно и не доступно для прикладного кода, но для максимальной защиты, можно использовать защищенный режим, который заключается в тегировании данных в памяти процессора. С каждым значением в памяти лежат биты тегов, которые говорят — это число, а это указатель. Процессор знает, что именно он считывает из памяти. В защищенном режиме запрещено преобразование целых чисел и вообще чего бы то ни было в указатели. Поэтому получить указатель можно только из другого указателя. При этом первый указатель порождается в специальном режиме. Мало того, в защищённом режиме указатель не просто адрес в памяти, а 128 битная структура с тремя полями: базовый адрес (64 бита), размер окна и текущее положение в этом окне.
Чтобы получить обычный указатель, который смотрит на одно число в памяти, окно уменьшается до 4–8 байт. Раздвинутый указатель может смотреть уже на массив или структуру. Когда у указателя размер больше 8 байт, к нему можно применять адресную арифметику внутри поля. Если нам передали пойнтер на массив, то он указывает на начало массива, в размере указан размер массива, и он смотрит в опорную точку массива. От нее можно двигаться адресной арифметикой вверх и вниз, делать внутри этого массива все, что угодно, но выйти за его пределы нельзя. Адресная арифметика допустима в пределах [база, база+размер]. Этим же диапазоном ограничены указатели, которые можно породить из данного указателя.
Эффект от такого ограничения проще всего описать, как защищенность уровня Java или C#, то есть среды с managed указателями. Нельзя просканировать память! Можно обратиться только в ту часть памяти, к которой выдан указатель.
К сожалению, этим свойством Эльбруса мало пользуются из-за низкой совместимости существующего кода на Си с механизмами защиты. К примеру, недопустимо использование одного и того же поля структуры для хранения целого числа и указателя попеременно. На сегодня сделать ядро Linux для защищенного режима пока не удалось.
На данный момент, защищенный режим можно использовать, например, как инструмент тестирования кода. Защищенный режим — это такой аппаратный valgrind с абсолютной скоростью исполнения на уровне скорости процессора, который всегда проверяет все ваши указатели. Можно не использовать его в продакшен-коде, но во время тестирования это помогает.
Но давайте вернемся к вопросу об исполнении кода x86.
Исполнение кода x86
Процессор Эльбрус не умеет исполнять Интеловский код аппаратно, но для него реализована программная бинарная трансляция кода x86 в код Э2К. Трансляция выполняется прозрачно для исполняемого кода и позволяет запускать на Э2К операционные системы для Интеловских процессоров.
Бинарная трансляция частично поддержана аппаратно — процессор содержит элементы аппаратуры архитектуры x86, которые слишком дорого эмулировать программно: например, сегментная адресация и набор сегментных регистров. Адресные вычисления при работе с сегментными регистрами процессор делает аппаратно, а все остальное программно.
Бинарный транслятор
Бинарный транслятор сделан в виде набора из трёх трансляторов и состоит из трех уровней кодогенерации:
Шаблонный, который реализует пошаговую трансляцию: берет одну инструкцию Интела, синтезирует для нее соответствующий набор инструкций Эльбруса и исполняет. Это эффективно только для кода, который исполняется один раз.
Быстрый регионный, который берет уже не одну инструкцию, а линейный фрагмент и компилирует его, учитывая взаимосвязи между инструкциями. В скомпилированный код вставляются точки замера, которые показывают часто или редко исполняется код. Для часто исполняемого кода запускается третий уровень кодогенерации.
Оптимизирующий уровень работает медленно и применяется для перекомпиляции горячих участков кода. Это тяжелый оптимизирующий компилятор, который выжимает из бинарной трансляции все, что возможно, и синтезирует эффективный код. По быстродействию он мало уступает прямому исполнению на родном Интеле.
Исполнение кода x86 было необходимо 10 лет назад, когда Эльбрус только появился и для него не было родных операционных систем. Сейчас запуск на Э2К операционных систем, скомпилированных под x86 не такактуален, в отличие от режима, который запускает скомпилированные под x86 Linux приложения на ядре Linux под Эльбрус.
В штатной скомпилированной под Эл/ьбрус ОС Linux возможен запуск бинарной трансляции для прикладного кода. Это позволяет запускать под Эльбрусом приложения или библиотеки, скомпилированные под x86.
Итоги
Я описал только верхний уровень. У Эльбруса ещё три этажа тонкостей и деталей, не все из которых надо знать прикладному программисту. С моей точки зрения, а я знаю довольно много процессоров и то как они устроены, Эльбрус — это инженерное чудо. Он в некотором смысле проще, чем Интеловские процессоры, но он не простой. С ним надо уметь жить. Практика показывает, что для оптимизации вычислительного кода надо приложить усилия, чтобы выкачать из Эльбруса всю его производительность. Но по этому поводу есть много статей и руководство от разработчиков для программиста на С, есть готовые библиотеки, которые уже оптимизированы под Эльбрус и большой опыт перенесения кода.
HighLoad++ 2021 пройдет 17 и 18 марта 2022 года в Крокус-Экспо. Доклады уже сформированы, но из-за короновируса придется немного подождать. Билеты купить можно здесь.