Я долго думал, как мне расписать вкратце историю процессоров Эльбрус, и решил, что нет смысла это делать, когда за меня уже давным-давно всё сделано.
Видео 1. Дмитрий Бачило – «Кремниевые Титаны #31: Эльбрус».
Настоятельно рекомендую вам ознакомиться с видео Стаса «Эльбрус — российский Intel и наша последняя надежда...» и с видео Дмитрия Бачило «Кремниевые Титаны #31: Эльбрус». В принципе со всей историей Эльбруса можно вкратце ознакомиться, посмотрев эти 2 видеоролика. Повторять слово в слово тот материал, что был в этих роликах, я смысла особо не вижу.
Сейчас же мы вкратце рассмотрим то, как в целом реализована архитектура Эльбруса.
Эльбрус – далеко не калька с какого-либо иного процессора, будь то Intel или AMD (x86). Современные микропроцессоры Эльбрус базируются на архитектуре E2K, в основе которой лежит подход VLIW.
Скриншот 1. Различие архитектур CISC (x86), RISC (ARM), и VLIW (E2K).
Как вы знаете, если смотрели наш обзор на Macbook Pro с Apple M1, CISC (x86) архитектура от RISC (ARM) отличается шириной командного слова: в RISC (ARM) команды короткие и имеют фиксированный размер, а в CISC (x86) ширина команды уже не фиксирована, но может быть длиннее.
VLIW больше похоже на RISC, нежели на CISC, т.к., хоть команды и шире намного, чем даже в CISC, но у VLIW ширина команд фиксирована.
К слову, Эльбрус – не единственный в мире, кто имел VLIW подход. Был ещё Intel Itanium, который сами же Intel похоронили в 17-м году.
И тут интересный вопрос: а почему Эльбрусы используют VLIW? Intel ведь сами с выходом Pentium Pro в 1995-м году перешли на RISC внутри ядер, и также AMD с выходом K5 в 1996-м году перешли на RISC backend, оставив лишь CISC (x86) frontend. Что ещё за бэкэнд и фронтэнд? С 1995 года x86 программы подают процессору x86 команды, но затем процессоры разбивают эти команды на RISC-подобные микрооперации. Снаружи у Intel и AMD – CISC, а внутри – RISC. Но зачем было так делать в 95-м? Причина в том, что с годами количество транзисторов и вычислительных блоков росло, но было неясно, а как их задействовать то. Intel и AMD нужно было как-то распараллелить операции для того, чтобы вычисления задействовали больше вычислительных устройств. Вот и стали одну x86 (CISC) команду делить на (максимум) 4 RISC-подобные микрооперации. Одна команда теперь стала грузить не один большой вычислительный блок, а вплоть до 4 мелких.
Но почему Эльбрус тогда использует VLIW? Ведь Intel и AMD оказалось проще засунуть RISC блоки внутрь своих x86 процессоров, и заставить CISC команды разбиваться на RISC команды, чтобы добиться повышения производительности. Разве это не значит, что если уж CISC не проканал, то и с VLIW будет то же самое? Разве он сможет в параллелизм?
Тут, как выяснилось, дело не в том, что широкое (CISC) и очень широкое (VLIW) команды не эффективны, а в том, что оптимизировать код для задействования всех возможностей процессора крайне сложно. Если у вас одна команда дробится, условно, на 4 микрооперации, вы уже можете занять до 4 раз больше ресурсов для выполнения этой одной команды, если правильно свой код организуете.
Что значит организовать правильно? Я сейчас постараюсь максимально просто пояснить и, возможно, даже где-то ошибусь. Например, вы просите своего приятеля перед приходом к вам домой распечатать один документ, зайти в магазин и прихватить молоко и хлеб. Т.е. человеку, чтобы выполнить эту задачу, надо написать текст, распечатать его, зайти в магазин, собрать по очереди эти 2 товара, оплатить их на кассе и двинуться к вам домой. Какую из этих частей можно оптимизировать? Вместо того, чтобы использовать только один палец для набора текста, можно использовать пять пальцев, да и не только на одной руке, а сразу на обеих руках. Как насчёт десятипальцевой слепой печати? Каждому пальцу на обеих руках мы направим мини-команды для набора нужных букв в нужной нам последовательности. Далее, зачем покупать каждый из этих товаров по отдельности и нести к вам домой их по одному? Ваш друг ведь может купить их разом, верно? Да и нести продукты на кассу можно, используя обе руки, верно? Зачем носить их по одному?
Подход с оптимизацией кода самим процессором имеет свои тонкости. Ну, взять тот же пример выше с десятипальцевой слепой печатью: да, вы быстрее вводите текст, если используете для печати те пальцы, что ближе всего расположены к нужным вам буквам. Но вы, всё равно, не начнёте вводить следующий текст, пока не допишете предыдущий. Перед вводом каждой следующей буквы у вас уже должна быть введена предыдущая. Вы же не напишете слово «очередь» вот так «оечрдееь», просто натыкав на клавиатуре все буквы из этого слова одновременно, верно? Но часть работы можно выполнять полностью параллельно. Зачем набирать продукты из списка строго по одному в том порядке, в котором они обозначены в списке? Можно же свободно гулять по гипермаркету и брать тот товар, что первым заметите. И, тем более, зачем по одному приносить эти товары вам домой?
У вас просто одна часть мозга хранит информацию о том, какие товары вам нужны и вы, гуляя по магазину, хватаете первым тот товар, что можно. Да и десятипальцевая печать запрягает больше ваших маленьких пальцев.
Задача по оптимизации кода у x86 процессора лежит на самом процессоре: насколько эффективно процессор декодирует эти инструкции, и насколько эффективно он распределит задачу по ядрам – большой вопрос.
x86 (CISC) процессоры Intel и AMD разбивают относительно небольшое количество CISC команд на огромное количество RISC команд, которые могут исполняться параллельно. Т.е. вместо того, чтобы по одному последовательно выполнять большие входящие команды, процессоры Intel и AMD разбивают эти команды на RISC-подобные микрооперации и выполняют сразу несколько команд параллельно маленькими арифметико-логическими устройствами (ALU). То, что я сейчас описал, зовётся superscalar (суперскалярным процессором). И этот вид параллелизма зовётся неявным параллелизмом. Почему неявным? Дело в том, что, вместо того, чтобы изначально давать процессору уже распараллеленный код, мы даём ядрам процессора простые команды, а дальше они уже сам решает, сколько своих ALU им задействовать (в рамках каждого из ядер).
Но даже с таким подходом наращивать производительность бесконечно не выходит ни у Intel, ни у AMD. Для начала, разбивать большие CISC (x86) команды можно лишь не более чем на 4 маленькие RISC команды. Почему максимум 4? Как я понял (опять же, я не эксперт), дело в том, что иначе обратную совместимость с предыдущими процессорами x86 не обеспечить. Да, с каждым годом всё больше и больше транзисторов задействуется в процессорах Intel и AMD, но им постоянно приходится идти на ухищрения для роста производительности, т.к. больше параллелизма им со старым подходом не выжать. Поэтому в 2002-м году свет увидели процессоры Intel Pentium 4, которые первыми на потребительском рынке получили поддержку виртуальной многоядерности или многопоточности, которую назвали Intel Hyper-Threading (ранее эта технология появилась в процессорах серии Intel Xeon). Суть этой технологии в том, что ядро физически у вас одно, но система его видит как 2 разных ядра, и ваши программы работают с одним ядром так, словно их 2. И, если ранее программы не могли задействовать все вычислительные возможности каждого из ядер процессора, то теперь они на каждом ядре старались задействовать больше АЛУ (до 2 раз больше).
Скриншот 2. Разница между физическими ядрами и виртуальными.
Поймите, в чём логика между подходом с виртуальными ядрами и реальными: 2 виртуальных ядра делят между собой один и тот же набор арифметико-логических устройств (АЛУ), один и тот же кэш, но разные регистры, тогда как в случае с двумя обычными ядрами у вас разделены для каждого из ядер и АЛУ, и кэш, и все регистры. За счёт того, что на АЛУ приходится больше параллельных команд (8 вместо 4), у вас удаётся загрузить одно ядро процессора в большей мере, и таким образом вы добиваетесь роста производительности в расчёте на каждое из ядер. Но по итогу, да, логические ядро, как правило, медленнее, чем физическое.
Для тех, кто не читал мой предыдущий крупный обзор, отвечу вкратце на вопрос «что ещё за регистры такие?». Регистр — это устройство хранения данных внутри самого процессора, которое обычно имеет объём в 32 бита или 64 бита. Для некоторых расширений наборов инструкций (вроде SSE и AVX, которые используются для ускорения обработки больших массивов) выделяются регистры объёмом 128 бит, 256 бит или 512 бит (в случае AVX512). Понятное дело, что обычными инструкциями вы эти регистры огромного размера не задействуете, потому для них и существуют отдельные инструкции вроде SSE и AVX в случае с x86, и SVE в случае ARM. Вкратце про регистры можете вычитать в статье со шпаргалкой по Ассемблеру x86.
У подходов Intel и AMD, безусловно, есть минусы. Самый главный минус заключается в необходимости разграничивать память между потоками. Если оба потока будут работать с одними и теми же регистрами, у вас одна из программ будет иметь доступ к данным другой, и наоборот. И потому для того, чтобы ядра процессора эффективно работали с многопоточностью, им необходимо выделять большой объём регистровой памяти, да и размер кэш-памяти должен быть отнюдь не маленьким.
К слову, в этом же и причины того, почему процессоры Apple M1 на базе Apple Silicon способны тягаться с x86 аналогами с более высоким энергопотреблением: у них и команды могут декодироваться на 8 микроопераций (т.е. Apple могут эффективно параллельно грузить процессор даже без наличия Hyper-Threading или какого-либо его аналога), так и объём кэша у Apple просто монструозный по меркам ARM-процессоров (в общем-то, у них и от ARM осталась лишь система команд, дизайн ядер у них свой).
С Эльбрусом история иная: внутри процессора нет никаких блоков, которые бы разбивали команды и переупорядочивали бы выполнение команд. Задача по задействованию всех ALU процессора ложится на программиста и компилятор. Т.е. машинный код, который подаётся на исполнение процессору уже распараллелен настолько, насколько это позволяет сделать компилятор. Параллелизм у Эльбруса зовётся явным, т.к. процессор уже работает с кодом, нацеленным на параллельное исполнение. Программисты должны писать хороший код, который компилятору будет проще распараллелить, тогда и компилятор уже сделает своё дело как надо.
Про параллелизм здесь рассказывается не в контексте распараллеливания задачи на X число ядер процессора, а в контексте её распараллеливания на все возможные АЛУ (арифметико-логические устройства) внутри каждого из ядер процессора. Как пример, представим, что каждое ядро процессора – это отдельный человек. У нас стоит задача отнести 4 пакета с продуктами домой. Представим, что нам эту задачу нужно решить на двухъядерном процессоре (т.е. с помощью двух людей). Логично каждому из этих людей дать по 2 пакета, и пускай они их носят синхронно. Обычно программисты распараллеливают код так, чтобы его выполняло много таких людей. Но они при этом не определяют заранее, а сколько рук будут задействовать эти самые люди. В случае с Intel и AMD эти самые 2 человека, которым дают пакеты, сами за счёт декодера команд определяют, что можно взять сразу оба пакета, по одному в каждую руку (АЛУ). У Эльбруса же ядра не имеют такого декодера, они сами не решают, как именно ресурсы свои распределять между АЛУ. Вместо этого компилятор, программа (не часть процессора) для генерации машинного кода из языков программирования (C, C++ и Fortran), определяет в машинном коде в самих командах отправляемых двум этим людям, что каждый из пакетов нужно брать по одному в руку, и так они вдвоём и справятся, взяв каждый по 2 пакета.
Такая разность подходов. В одном случае (Intel и AMD), разъяснением по тому, какие АЛУ выделять для каких задач в рамках ядра, занимается декодер команд внутри каждого из ядер процессора, а в случае с Эльбрусом эти все разъяснения заранее закладываются в машинный код, который даётся процессору. В случае с Эльбрусом программисту нужно учитывать особенности архитектуры и грамотно оптимизировать под неё код так, чтобы компилятор на выходе давал машинный код, задействующий как можно больше АЛУ внутри каждого из ядер процессора.
Каждое ядро процессора Эльбрус содержит 6 АЛУ (арифметико-логических устройств) разного назначения. Задача программиста – писать хороший код, а задача компилятора – оптимизировать выходной (машинный) код так, чтобы задействовать как можно больше арифметико-логических устройств, которые занимаются считыванием данных из памяти, обработкой данных и записью сохранением уже обработанных данных.
Вынос всей оптимизации кода вовне из процессора (а именно – в компилятор), позволяет на той же площади кристалла разместить больше вычислительных блоков. Иначе оптимизатор кода внутри самого процессора занимал бы немаловажное место. Но есть и свои тонкости у такого подхода.
Во-первых, у Эльбруса 2 пары по 3 АЛУ разного назначения. Разным операциям нужны разные АЛУ, так что не везде будут все 6 использоваться.
Во-вторых, компилятор LCC и его оптимизация кода работают только с языками C, C++ и Fortran. В пролёте оказываются Java, C# и многие другие языки. Я уже и не говорю об интерпретируемых языках вроде Python и JavaScript. И, в общем-то, в этом заключается главная проблема Эльбруса: не весь код на нём можно оптимизировать и заставить быстро работать. Если в 90-х и 00-х языки C и C++ были в топе по популярности, то сейчас в Enterprise сегменте всем рулит Java, в веб-разработке устоялись Python и PHP, на горизонте маячит ещё Go и куча других языков. И непонятно, что со всем этим делать, т.к. на Эльбрусе Python-то есть, JavaScript есть, языки в целом подтягиваются, но скорость исполнения кода на этих языках далека от идеала. В таких языках распараллеливание операций происходит на X число ядер/потоков, но нет инструментов для распараллеливания операций на множество АЛУ в каждом ядре. У Эльбруса нет многопоточности в рамках каждого из ядер процессора, и это усложняет распараллеливание Python на нём. В E2K v7 (Эльбрус 32С) планируют добавить предсказатель переходов, который позволит повысить скорость кода на интерпретируемых языках. Только Эльбрус-32С должен был быть запущен в серийное производство на заводах TSMC по 6 нм техпроцессу в 2025-м году, а детальной информации пока нет по планам переноса производства на заводы SMIC.
На данный момент актуальной версией архитектуры Эльбрус является E2Kv5, реализованная в Эльбрус 8СВ. У нас же на тесте был Эльбрус 8С на базе архитектуры E2Kv4. Основными отличиями E2Kv5 от E2Kv4 является поддержка SIMD 128 бит инструкций, которые позволяют за 1 раз обрабатывать куда больший объём данных. Мы это рассмотрим чуть позже.
Для начала зададимся вопросом: а как исполняется код на Эльбрусе?