Недавно здесь, на Хабре, появилось несколько статей о достоинствах/недостатках VLIW архитектуры по сравнению с CISC и RISC. Но ведь и те и другие далеко не идеальны! Суперскалярные процессоры вынуждены тратить ресурсы на попытки распараллеливания последовательных команд и предугадывание возможных переходов, что не только ведет к перерасходу вычислительных ресурсов, но и просто небезопасно (вспоминаем Spectre и Meltdown).
VLIW предполагает выполнение длинных командных слов внутри которых может предполагаться выполнение до 23 (Эльбрус) параллельных инструкций. Однако и эта структура не лишена недостатков : длительность выполнения командного слова определяется самой медленной инструкцией (например деления или обращения к памяти); приходится для каждого ядра держать большой резерв вычислительной мощности в расчете на необходимость параллельного выполнения максимального числа инструкций; очень ограниченные возможности по распараллеливанию инструкций обращения к памяти; ограниченность параллелизма только шагом в одну инструкцию; необходимость предсказания перехода (как и в суперскалярных процессорах); и невозможность динамически разносить инструкции на параллельные потоки (количество одновременно исполняемых инструкций жестко прописано на этапе компиляции программы и не может быть изменено для процессоров с разными возможностями).
В этой статье я представлю свои мысли по альтернативной архитектуре процессора, которая должна объединить в себе достоинства RISC, CISC и VLIW архитектур.
Итак, представляю вашему вниманию процеонную архитектуру процессора :
Пул процеонов (от Processor и Nucleon (протон или нейтрон)) – это набор SuperRISC процессоров, находящихся в ожидании или в выполнении линейки команд. Ядро – это управляющий процессор для выполнения потоков команд и управления обращениями к внешним данным. Многоканальная кэш-память обеспечивает быстрый доступ к наиболее часто необходимым данным. Блок внешних обменов транслирует запросы к внешней (относительно микропроцессора) памяти, внешним устройствам и обеспечивает приём внешних прерываний.
Ниже приводятся принципы для новой архитектуры (процеонная структура).
Процессор многоядерный.
Ядро не выполняет команды, а только управляет потоком команд и обращениями к памяти.
Команды выполняют процеоны – это SuperRISC процессорные ядра с ограниченным набором команд (ограничение прежде всего управлением исполнения программы).
Ядро обрабатывает целый блок команд – параграф (в дальнейшем под этим понимается замкнутый набор команд, после выполнения которых изменяется состояние программы. Можно сказать что параграф для ядра процессора – это команда), который содержит отдельно данные и отдельно одну или более линеек команд.
Параграф начинается префиксом и заканчивается окончанием. Префикс и окончание обрабатываются ядром.
Префикс определяет сколько линеек в параграфе, какой они длины и типа. Самая первая линейка – это данные для команд находящихся в рабочих линейках. Благодаря этому можно отделить команды от данных и сделать фиксированную длину каждой команды.
Окончание определяет какие результаты помещаются в регистры по окончанию работы всех команд параграфа. А так же какой параграф выполняется следующим.
Линейки команд выполняются параллельно (по возможности, если есть свободные процеоны). А команды в линейке – последовательно.
Процеоны (а значит и линейки команд !) могут быть разными (например процеон для целочисленных 32-битных операций, для работы с числами с плавающей запятой, для работы со строковыми данными, для работы с двойными словами и т.д.). Однако формат команд для всех процеонов един.
Система команд процеонов не содержит команд перехода, за исключением команды выполнить/пропустить следующую команду по условию и команд завершить линейку, завершить параграф. Флаги выполнения в процеонах не используются (за исключением переноса в целочисленных операциях для учета в следующей команде).
Процеоны не жестко закреплены за ядром, а выделяются для выполнения конкретного параграфа команд по мере необходимости. Если процеонов оказывается меньше, чем линеек в параграфе, то часть линеек выполняются последовательно.
Прерывание параграфа возможно только при неустранимой ошибке вызывающей аварийное завершение. В остальных случаях прерывание откладывается до окончания параграфа. Так например деление на 0 не вызывает немедленного прерывания параграфа, а только завершение одной линейки, в которой произошла ошибка.
Регистры процессора делятся на 4 группы : Регистры Общего Назначения (РОН (R0-R15) – 16 регистров по 64 бит, копии передаются в процеоны при старте линеек, по окончании линеек их комбинация копируется в ядро), локальные (временные / temporary) регистры процеонов (RT – 16 регистров разрядностью в зависимости от типа процеона, по окончании линейки значения теряются, при начале линейки инициализируются значениями от 0 до 14, последний получает значение -1), регистры данных (RD – доступны только для чтения, инициализируются данными 0-ой линейки или командами отложенной загрузки. Регистры не получившие данные – обнуляются), управляющие регистры (RF – 16 регистров: регистр состояния программы, счетчик команд, регистр следующего параграфа, управления кэш-памятью, регистры копирования, регистр блока коротких обращений к памяти и проч.).
Процеон может обращаться к памяти напрямую только в границах сегмента указанного в регистре блока коротких обращений к памяти (не более 4 ГБ – пространство адресуемое 32 битами, конкретный размер определяется в регистре флагов). Обращения за пределами этого сегмента возможны только в виде команд отложенного обращения к памяти (их выполнение откладывается до момента окончания параграфа).
Для уменьшения обращений в память есть 3 регистра копирования (в блоке управляющих регистров): стартовый адрес, конечный адрес и количество байт для копирования.
Все обращения к памяти и трансляцию адресов выполняет ядро. Процеон не имеет кэша и не преобразует виртуальные адреса в реальные физические.
Рассмотрим структуру параграфа :
Наименование | Структура |
Префикс | <Код префикса><Длина Данных (K) ><Количество линеек (N)> <Тип линейки 1> <Длина линейки 1 (L1)>…. <Тип линейки N> <Длина линейки N(Ln)> |
Данные | <Слово 1> ….. <Слово K> |
Линейка 1 | <Команда 1>…..<Команда L1> |
....... | ........ |
Линейка N | <Команда 1>…..<Команда Ln> |
Окончание | <Код окончания> |
Код префикса (4 бита + 3 бита резерв ) – код определяющий начало параграфа и его тип
Длина Данных (5 бит) – количество 64-разрядных данных для регистров данных (K – от 0 до 16, часть регистров данных может быть инициализирован через отложенную команду чтения или остаться 0)
Количество линеек (4 бита) – количество линеек в блоке команд (N – от 1 до 16)
Тип линейки (4 бита) – какой нужен тип процеона для выполнения данной линейки
Длина линейки (4 бита) – количество команд в соответствующей линейке команд (L от 1 до 16)
Код окончания (8 бит) – код определяющий как будет завершен параграф и как будут объединены регистры из процеонов в ядре.
Как происходит отработка ядром параграфа по типу 1 (базовому) при достаточном количестве процеонов:
Ядро выбирает префикс содержащий Код префикса, Длину Данных, Количество линеек (итого 16 бит)
Выбирает байты с длиной и типом линеек (в соответствии с Количеством линеек)
Считывает Данные в блок регистров данных (или заполняет данными из команд отложенного чтения)
Запрашивает нужные процеоны из общего пула процеонов
Инициализирует блок общих регистров каждого процеона копией РОН ядра, а Регистр Следующего Параграфа адресом следующим за Окончанием. Блок временных регистров каждого процеона инициализируется от 0 до 14 (RT0 - RT14) и -1 (RT15).
Передает каждому процеону его линейку команд
Ждет окончания всех линеек (в это время ядро может переключиться на выполнение другого потока – по аналогии с hyper-threading technology в процессорах x86)
Собирает данные со всех линеек по условию (код окончания определяет процедуру сбора РОН, управляющие регистры заполняются строго последними поступившими данными)
Передает управление по адресу из Регистра Следующего Параграфа
Как происходит отработка ядром параграфа по типу 2 (параллельный цикл). В этом случае в параграфе содержится только одна линейка команд, но которая будет выполняться параллельно на нескольких процеонах, причем количество процеонов может быть меньше количества итераций выполнения линейки. Рассмотрим как это должно происходить.
Ядро выбирает префикс параграфа типа 2, в котором вместо количества линеек указан РОН в котором содержится количество итераций (из-за ограничения на выполнение прерывания внутри параграфа число итераций ограничено 256).
Считывает Данные в блок регистров данных
Запрашивает процеоны из пула в количестве равным количеству итераций.
В зависимости от реально полученного числа процеонов, выделяет каждому процеону выполнение своего количества итераций (например, количество итераций 16, а выделено 4 процеона, значит 1-й выполняет итерации с 16 по 13, 2-й - с 12 по 9, 3-й - с 8 по 5, 4-й - с 4 по 1)
При каждом входе каждый процеон инициализирует свои регистры, регистр содержащий количество итераций, инициализируется номером итерации
Каждый процеон выполняет отведенные ему итерации
Ядро дожидается окончания выполнения всех итераций и решает какие данные поместить в РОН (в зависимости от кода окончания происходит либо выбор регистров определенного процеона, либо арифметическое/логическое действие над регистрами, либо их комбинация. Подробнее механизм заполнения РОН результатами рассматривается ниже)
По окончании передается управление по адресу из Регистра следующего Параграфа (данные в него могут быть занесены командой процеона)
Все команды (инструкции) всех процеонов имеют одинаковую длину и формат. Формат инструкций следующий : одна команда - 24 бита, 7 бит КОП, 5 бит - приемник, 6+6 бит - источник1 и источник2. Исключение – команда сравнения. В ней вместо приемника – условие выполнения следующей команды. В случае операций записи в память вместо регистра-приемника указывается регистр-источник данных, а 2 регистра-источника суммируются в адрес памяти.
Структура памяти и обращений к ней.
Все операции к памяти процеоны выполняют через обращение к ядру. Операции записи ядро выполняет через кэш с обратной записью (т.е. программа не ждет выполнения записи). Результат операций чтения так же является отложенным – данные попадают в регистры к моменту начала следующего параграфа, благодаря чему процеоны не простаивают в ожидании данных. Исключение – обращение к памяти в ограниченном сегменте, адрес которого указан в регистре быстрых обращений к памяти (РБОП), а в регистре флагов находится длина маски изменяемых бит адресов (в пределах которых процеоны могут обращаться напрямую к памяти и дожидаться данных). Это позволяет заранее загрузить в кэш блок данных на который указывает РБОП и размером определяемым в регистре флагов. Если в кэше такой блок не помещается, то он должен быть размещен в основной памяти, но ни в коем случае не в виртуальной, чтобы при обращении к нему не произошло прерывания из-за отсутствия данных в ОЗУ.
Так же для разгрузки вычислительных мощностей процеонов специально для копирования данных в ядро добавлена логика копирования – 3 управляющих регистра: Регистр Начального Адреса (РНА), Регистр Конечного Адреса (РКА) и Регистр Длины Копирования (РДК). При инициализации РДК, ядро начинает операцию копирования из адреса РНА+РДК на адрес РКА+РДК, затем уменьшает РДК на количество скопированных байт и повторяет операцию копирования, пока РДК не станет равен 0. Такая операция копирования может выполняться параллельно с основной программой, не привязываясь к началу или окончанию параграфа. Признаком окончания такой операции является 0 в РДК.
Важное замечание : знаковый бит в адресе определяет идет ли обращение к ОЗУ или к внешним устройствам. Таким образом все адресное пространство разделено пополам на ОЗУ (адреса 0-0x7FFFFFFFFFFFFFFF) и порты ввода-вывода (адреса 0x8000000000000000-0xFFFFFFFFFFFFFFFF). Это сделано для облегчения перехода на 128-битные процессоры: 128-битный адрес получается расширением знакового бита из 64-разрядного адреса.
Механизм заполнения РОН результатами выполнения параграфа
По окончании выполнения параграфа в ядре оказываются копии РОН каждой выполненной линейки (или итерации) команд. Ядро должно выбрать те данные которые следует поместить в РОН (замечание по Управляющим Регистрам – в них попадают данные строго по тому, в какой очередности были выполнены команды, поэтому ответственность за заполнение Управляющих Регистров лежит на программисте ! Исключение – сумматор, о нем речь ниже), для этого используется код окончания блока команд.
Рассмотрим подробнее как происходит заполнение РОН при окончании параграфа. Если данный регистр был изменен только из одной линейки, то он будет заполнен результатом переданным из этой линейки. Если для одного регистра приходят данные из двух или более линеек, то выбор результата для заполнения осуществляется в зависимости от кода окончания: а) данные берутся из линейки с самым большим номером (принцип последние данные замещают более ранние); б) самый младший регистр (R0) считается селектором – то есть «побеждают» данные из той линейки, которая записала в селектор самое большое (или самое маленькое) число (примечание – все РОН можно разделить на 2 группы по 8 регистров, в каждой из которых будет свой селектор); в) данные комбинируются через битовые операции (AND, OR, XOR). Наиболее интересным представляется вариант Б – выбор данных на основании селектора. В этом случае программа может параллельно решить несколько вариантов и по окончании параграфа выбрать правильный вариант, отбросив ненужные. Для удобства, можно разделить все РОН на 2 группы, в каждой из которых можно применить свои правила заполнения при окончании параграфа (например среди первых 8 регистров выбор результата происходит по варианту В, а для регистров R8-R15 – по варианту Б (селектором в этом случае выступает R8)).
Особое место среди Управляющих Регистров занимает Сумматор. При окончании параграфа данные занесенные в регистр младшего слова сумматора, суммируются и результат заносится в регистры старшего и младшего слов сумматора.
Преимущества данной схемы:
Распараллеливанием команд занимается компилятор, а не процессор, что позволяет делать это более оптимально
Распараллеливание происходит на уровне элементарных RISC команд (аналог микрокоманд в x86 процессорах), причем не на уровне отдельных команд, а в виде линеек последовательно выполняющихся команд, что позволяет параллельно выполнять команды с разным временем выполнения (например в одной линейке выполняется команда деления, а в другой за это время может быть выполнено несколько простых арифметических команд)
Есть возможность динамически изменять количество одновременно выполняемых линеек команд (в зависимости от свободных ресурсов выполнять линейки либо последовательно, либо параллельно)
Вычислительные мощности (процеоны) могут быть использованы любым ядром (динамическое перераспределение вычислительной мощности между задачами)
Экономия энергии (возможность временно отключать неиспользуемые процеоны)
Благодаря отсутствию команды условного перехода (на уровне ядра) отпадает потребность в блоке предсказания переходов. Адрес следующего параграфа может быть получен заранее, до окончания выполнения текущего параграфа
Удобное масштабирование и специализация процессоров – достаточно увеличить или уменьшить число процеонов соответствующего типа
Упрощается одновременное выполнение ядром нескольких задач (на время выполнения процеонами одной задачи ядро может запускать на выполнение другую задачу)
Оптимизируется доступ к памяти – процеонам требуется для чтения блок в памяти ограниченного размера. Операции чтения из других адресов (оказавшихся в виртуальной памяти) могут быть отложены и не приводят к немедленному прерыванию задачи
Благодаря разделению процессора на сравнительно одинаковые блоки, упрощается проектирование микропроцессоров.
Заключение
В этой краткой статье сделана попытка описания альтернативной структуры (в рамках классического «фон-Неймановского» процессора) центрального процессора общего назначения с упором на внедрение параллельного исполнения команд на уровне приближенном к микрокомандам. Конечно данный подход не является единственно возможным, но принципы изложенные выше (вынесение из ядра процессора вычислительных модулей, возможность динамически перераспределять ресурсы между ядрами, параллельное выполнение целых линеек команд (а не отдельных команд), динамическое выделение ресурсов для параллельных вычислений, отсутствие потребности в блоке прогнозирования ветвлений и введение команд отложенного чтения из памяти) заслуживают рассмотрения.