Pull to refresh

Как процессоры x86 декодировали инструкции в RISC-форму: история легенды

Reading time8 min
Views9.6K
Original author: Fanael Linithien

Распространено мнение, будто современные высокопроизводительные процессоры x86 работают так: декодируют «сложные» инструкции x86 в «простые» RISC-подобные инструкции, которые затем обрабатываются в оставшейся части конвейера. Но насколько эта идея на самом деле отражает, как именно устроен внутри процессор?

Чтобы ответить на этот вопрос, давайте проанализируем, как следующий простой цикл обрабатывают различные процессоры x86, от P6 (первой микроархитектуры Intel «современного» типа до современных конфигураций). Код сделан 32-разрядным лишь для того, чтобы можно было затронуть и очень старые процессоры с архитектурой x86:

x86 assembly
.loop:
    add [edx], eax
    add edx, 4
    sub eax, 1
    jnz .loop

Начнём с азов: как же этот код будет выглядеть в архитектуре RISC? Необходимо выработать эталон, с которым будем его сравнивать. В природе существует множество разных RISC-архитектур, так что давайте возьмём для примера RISC-V, поскольку она открытая и свободно распространяемая:

RISC-V assembly
.loop:
    lw a2, 0(a0)
    add a2, a2, a1
    sw a2, 0(a0)
    addi a1, a1, -1
    addi a0, a0, 4
    bnez a1, .loop

Примерно этого и следовало бы ожидать от любой архитектуры, в которой строго соблюдаются принципы RISC: «сложная» операция добавления регистра в некоторую область памяти разделяется на три этапа, а именно: 1) загрузка памяти во временный регистр, 2) сложение целых чисел, это операция исключительно над регистрами, 3) наконец, сохранение в памяти. Ещё три инструкции  x86: приплюсовать константу в регистр, вычитание константы, а также условное ветвление. Они достаточно просты и, следовательно, почти идентичны аналогичным инструкциям из RISC-V.

Существует разница между рассматриваемыми здесь ветками RISC-V и x86: в x86 условные ветвления предоставляются на основе флаговых битов, устанавливаемых при арифметических операциях, как sub в нашем примере. В свою очередь, ветки из RISC-V действуют так: напрямую сравниваются значения двух регистров, в данном случае a1 и x0, которая всегда равна нулю. Разница здесь не слишком важна, поскольку мы сравниваем результат арифметической операции с нулём. Итак, разница сводится к тому, что нужно сообщить RISC-V, какой именно регистр сравнивать с нулём, а в случае с x86 в качестве величины, сравниваемой с нулём, подразумевается результат предыдущей арифметической операции.

P6: что было давным-давно

Архитектура P6, появившаяся в 1995 году вместе с Pentium Pro, была первой из P6 микроархитектур x86, в которой применялось внеочередное исполнение. Позже с минимальными изменениями она же использовалась в  Pentium II и III, и именно она прямой предок современных высокопроизводительных микроархитектур Intel. Как здесь обрабатывается наш цикл?

Первая инструкция, add [edx], eax, декодируется в следующие четыре микрооперации:

  1. Загрузим в безымянный регистр 32-разрядное значение из адреса, содержащегося в edx.

  2. Сложим значение, содержащееся в eax, со значением, загружаемым первой микрооперацией, затрагивающей флаги.

  3. Отправим результат сложения в блок записи в ОЗУ (memory store unit). Обратите внимание, что адрес хранилища здесь не используется!

  4. Отправим в блок записи в ОЗУ адрес, содержащийся в edx.

Следующие три инструкции просты, каждая из них декодируется непосредственно в одну микрооперацию.

В итоге у нас получается всего семь микроопераций. На самом деле, инструкций RISC-V у нас предусмотрено на одну меньше, так как хранилище памяти здесь делится на две части! Причина, по которой требуется разделить работу с областью памяти на две микрооперации — в нюансе проектирования: каждая микрооперация в P6 может иметь до двух входных точек. Архитектура x86 поддерживает режимы адресации в форме register + register * scale + constant и получает на вход две порции информации о регистрах. Ещё одна такая порция на вход — это данные, которые должны быть сохранены в памяти, всего — три… то есть, на одну больше, чем мы в состоянии закодировать. Следовательно, операция сохранения делится на две части: «сохранение данных» — это микрооперация с одним вводом, а микрооперация «сохранение адреса» — с другим.

Вердикт: для P6 всё абсолютно подтверждается, здесь микрооперации устроены очень схоже с RISC, а в силу нюансов реализации в чём-то, пожалуй, даже превосходят саму архитектуру RISC.

Pentium M: знакомимся с микрослияниями

Вслед за P6 появилась версия Pentium M, где «M», вероятно, означала «мобильная». Она использовалась преимущественно на ноутбуках, где современный вариант NetBurst поставить было нельзя из-за чрезмерных требований к питанию и охлаждению. Существовало несколько серверных процессоров на основе Pentium M, оснащённые адаптерами, позволявшими установить такой процессор в гнездо стандартной материнской платы 478 для ПК. Но такие модели были не слишком распространены.

В Pentium M впервые появилась возможность слияния микроопераций, для краткости — микрослияния. При микрослиянии можно было объединять некоторые пары микроопераций, декодированные из одних и тех же инструкций. Такие сплавленные пары удерживались вместе на протяжении большей части работы конвейера. Декодеры инструкций генерировали такую пару как единое целое, и механизм переименования регистров также трактовал пару как одну микрооперацию. Соответственно, в буфере переупорядочивания под такую пару отводилась одна запись, и в областях удаления (retirement station) пара также воспринималась как одно целое. Пожалуй, единственным исключением из этого правила (где пара считалась парой) были сами блоки выполнения. Ведь если бы блок загрузки памяти не знал, что делать при сложении целых чисел, то никогда бы и не получил один из элементов пары, полученной микрослиянием. Именно по этим причинам пары, подвергнутые микрослиянию, во всех практических смыслах являлись одинарными микрооперациями, а разделение такой единицы на две части было лишь деталью реализации в блоках выполнения.

Единственная инструкция на P6, которая декодировалась в множество микроопераций — это «приплюсовать к указанному местоположению в памяти». Пошло бы ей на пользу микрослияние, принятое в Pentium M? Действительно, пошло. Теперь она декодируется в три микрооперации, которые вполне точно совпадают с тем, что в таких случаях делает наш код RISC-V:

  1. Загрузить 32-разрядное значение с содержащегося в edx адреса во временный безымянный регистр.

  2. Сложить значение, содержащееся в eax, со значением, загруженным первой микрооперацией (при этом затрагиваются флаги).

  3. Отправить результат сложения и адрес в указанный блок записи в ОЗУ.

Сложение, вычитание и условное ветвление в Pentium M остаются одиночными микрооперациями.

Вердикт: в данном конкретном случае мы добились идеального совпадения между нашим кодом RISC-V и микрооперациями Pentium M.

Core: улучшенное микрослияние

Чрезвычайно успешная архитектура Core была впервые выпущена в 2006 году, когда в Intel, наконец-то, осознали тупиковость NetBurst. Тогда они взяли заточенный на мобильные устройства вариант Pentium M, производный от P6, и доработали его, положив начало легендарной линейке процессоров Core 2 Solo/Duo/Quad.

Доработок относительно Pentium M было действительно много, в частности, поддержка AMD64, дополнительный декодер инструкций и полноценные блоки для обработки 128-разрядных векторных блоков выполнения. Но нас в данном случае интересует улучшенное микрослияние.

В Pentium M микрослияние применялось в двух случаях: можно было объединять либо два блока хранения информации, либо две операции загрузки в память. При этом для слияния использовались обычные арифметические операции, производимые над инструкциями вида add eax, [edx]. К сожалению, когда местоположение в памяти указывал операнд destination (место назначения), Pentium M обеспечивал слияние всего двух частей. Но в Core это ограничение было снято, в результате чего было позволено одновременно выполнять оба варианта микрослияния. Следовательно, в Core первая инструкция нашего цикла декодируется всего в две микрооперации:

  1. Загрузить 32-разрядное значение из адреса, содержащегося в edx, во временный регистр  и приплюсовать к нему значение, содержащееся в eax, повлияв при этом на флаги.

  2. Отправить результат сложения и содержащийся в edx адрес в блок записи в ОЗУ.

Как и ранее, три оставшихся инструкции просто декодировались в одну микрооперацию каждая.

Вердикт: здесь мы начинаем углубляться в дебри. Операция «взять значение по адресу в памяти и добавить его в регистр» совсем не относится к RISC-подобным операциям.

Sandy Bridge: (улучшенное) слияние веток

Выпущенный в 2011 году Sandy Bridge был первой производной от P6 конфигурацией, в основе которой лежал физический файл регистра. Наконец-то была решена проблема с перманентным ограничением на число считывания регистров, которая была настоящим бичом P6 и всех её более ранних потомков. Во многих других отношениях это было значительное, но всего лишь эволюционное улучшение по сравнению с предшествующими микроархитектурами Core и Nehalem.

Но по-настоящему важны здесь те улучшения, что были внесены в слияние веток. Именно они появились только в Sandy Bridge и отличали её от предшественников.

Слияние веток, часто именуемое более общим термином макрослияние, во всех ныне существующих процессорах x86 ограничивается только слиянием веток. Поэтому я ограничусь первым термином как более точным и буду обозначать им акт слияния ветки и предшествующей арифметической инструкции (обычно — сравнения). В x86 такая возможность впервые появилась в Core, но применялась только для слияния инструкций cmp и test. В некоторых случаях сразу после такого слияния следовало условное ветвление. Мы имеем дело с обычным вычитанием, и на слияние такой операции процессор Core был неспособен.

Но в Sandy Bridge при слиянии веток выбор доступных паттернов более широк. Наш паттерн, вычитание следующего значения из регистра с незамедлительным переходом к разности, если она не равна нулю — один из этих новых паттернов. Соответственно, на Sandy Bridge и более новых процессорах весь наш цикл в декодированном виде раскладывается на четыре микрооперации:

  1. Загрузка во временный регистр 32-разрядного значения с адреса, содержащегося в edx. Далее к результату прибавляется значение eax, и это отражается на флагах.

  2. Отправка суммы и адреса, содержащегося в edx, в блок записи в ОЗУ.

  3. Сложение 4 и edx, с затрагиванием флагов.

  4. Вычитание 1 из eax, с затрагиванием флагов и переходом к началу цикла в случае, если результат ненулевой.

Фактически, на Sandy Bridge и более новых процессорах операция sub, для которой указан регистр назначения, поддаётся слиянию, даже если вторым операндом является местоположение в памяти. Получается последовательность sub eax, [rdx] и далее jz .foo поддаётся слиянию всего в одну микрооперацию!

Вердикт: на Sandy Bridge и более новых высокопроизводительных процессорах Intel соответствие между микрооперациями и инструкциями RISC полностью теряется. Фактически, микроопераций у нас столько же, сколько и «сложных» инструкций x86, с которых мы начали.

Что насчёт процессоров AMD и Intel Atom? Или NetBurst (брррр)?

Выше речь в этой статье шла только о тех микроархитектурах, которые являются производными от P6. Но есть ещё несколько семейств микроархитектур для x86 с внеочередным исполнением, которые всё ещё активно используются (или использовались ранее): NetBurst (это жесть), семейство Intel Atom, AMD K7/K8/K10, AMD Bobcat/Jaguar/Puma, семейство Bulldozer от AMD (было таким же ужасным, как и NetBurst, но по-своему) и последнее — но очень заметное — AMD Zen (которое мне кажется забавным).

Давайте затронем все архитектуры из этого списка и начнём с NetBurst. Мы объединили все эти микроархитектуры в одну компанию по простой причине: ни в одной из них инструкции вроде add [edx], eax никогда не разделялись на несколько микроопераций. Ни в одной из них также не предусмотрена возможность слияния ветки с результатом предшествующего вычитания. В семействах Bulldozer и Zen слияние веток поддерживается, но для выбора доступны только инструкции cmp и test. Всем этим процессорам мы скармливаем наш цикл из четырёх инструкций, и они декодируют эти инструкции в четыре микрооперации. Каждая из микроопераций строго соответствует одной из исходных инструкций.

Что касается NetBurst, в этом отношении она вела себя во многом подобно P6, за одним исключением: блоки хранения, в режимах адресации которых учитывался только один входной регистр, трактовались как одна микрооперация. Только при сложных режимах адресации требовалось отделять данные о хранилище от адреса хранилища. Следовательно, рассмотренный нами цикл декодировался в шесть микроопераций, непосредственно соответствующих коду RISC-V.

Заключение

Не лишена истины история о том, что процессоры с архитектурами x86 внутрисистемно декодируют инструкции в RISC-подобную форму. На самом деле, во многом так и работали процессоры P6, но после дальнейших усовершенствований это соответствие стало в лучшем случае натянутым. С другой стороны, в некоторых семействах микроархитектур ничего подобного вообще не делалось.

Tags:
Hubs:
Total votes 39: ↑35 and ↓4+45
Comments6

Articles