Нельзя просто так взять и обойтись без переноса


Если нельзя, но очень хочется, то можно все равно нельзя


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

Те, кому данная тема интересна, могут нажать на кнопку.

Итак, мы начинаем.

Часть 1. Необходимое вступление

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

Значение имеет состав данных, в слове состояний хранящийся и ключевым для дальнейшего рассмотрения будет набор признаков результата, в любимой мною архитектуре DEC это NZVC. Примечание на полях (Пнп): любые совпадения обозначений данных признаков с надписями на футболках отдельных одиозных личностей, считающих себя телеведущими, а также иных арт-объектов, являются случайными и обусловлены исторически. Все эти признаки описывают результаты некоей ранее выполненной операции, не обязательно непосредственно предшествующей команде, в которой они анализируются (используются).
N – признак отрицательности результата этой операции (старший разряд равен 1), Z – признак нулевого результата (все биты, включая знаковый, равны нулю), V – признак знакового переполнения (комбинация значений остальных признаков), C – признак переноса (результат операции не умещается в разрядной сетке). Кроме этих признаков, обычно представленных в виде отдельных битов СС, последнее может содержать и дополнительную информацию о режиме выполнения текущей команды — бит трассировки T, поле режима исполнения (Thumb), уровень привилегий(usr|core), состояние разрешения обработки прерываний и текущий уровень обработки, а также другие специфичные для архитектуры данные, которые все равно надо где-то хранить, так почему бы и не в СС. Далее мы сосредоточимся только на признаках, поскольку на все остальные поля никто не посягает и проблема не в них.

Как правило, значения признаков N и Z используются для организации управления последовательностью выполнения команд при помощи команд условного перехода или условного исполнения команд. Существуют два различных набора команд ветвления для различной интерпретации типов данных — знаковых и без-знаковых. Для первого типа это BGE, BGT, BLT, BLE, для второго BHIS, BHI, BLO, BLOS, ну а ветвление BEQ и BNE одинаковы для любых типов. Признак V фиксирует факт переполнения в результате выполнения арифметических операций и позволяет организовать переход на программу обработки исключений командами типа BVS/BVC. В некоторых архитектурах данные признаки формируются автоматически при выполнении арифметических команд, в некоторых существует отдельная команда TST, позволяющая сформировать эти признаки для содержания конкретного регистра, хранящего результат предыдущей операции.

А вот с признаком С несколько сложнее. Во-первых, этот признак фиксирует наличие переноса при выполнении арифметических операций и в таком качестве может быть использован как для формирования переходов BCS/BCC, так и в качестве неявного (дополнительного) операнда в арифметических операциях с длинными числами (ADDC вместо ADD). Во-вторых, он также может использоваться, как дополнительный бит-приемник/источник в операциях «длинного» и циклического сдвигов. А в-третьих, далеко не все команды изменения данных влияют на бит С, и для этого есть веские основания, связанные с организацией обработки длинных данных.

Часть 2

Формулируем задачу и решаем

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

Пнп: на самом деле мы пробуем исключить из СС только признаки результата, на прочие поля мы на посягаем. Связано данное желание с тем, что формирование признаков в процессе выполнения команд обработки сильно затрудняет нам возможность изменение порядка выполнения команд в процессе исполнения кода (re-ordering).

Признаки N и Z – первоочередные кандидаты на исключение, поскольку они просто дублируют состояние некоторых битов данных. Первый — это просто алиас для старшего битового разряда, да и второй не сложен в реализации (сумма по И всех битов слова). Можно просто объединить команду тестирования содержимого регистра и формирования перехода, но в рассматриваемой архитектуре пошли чуть дальше — объединили команду сравнения двух данных (часто в качестве одного из них используют ноль) и команду перехода с разными условиями в одно целое. Получается вполне себе элегантное решение:

if (Data<=0) … превратится в
BLE r0,r1,L1
(что эквивалентно последовательности)
TST r1 (CMP r1,#0)
BLE L1

которое позволяет даже уменьшить код программы на 1 слово. Да, Вам потребуется длинное слово для кодирования команды, но оно у нас уже есть (32 бита или 16 для режима сжатия).
Ситуация с признаком V точно такая же, так что здесь проблем так же не предвидится.

А вот с битом C проблемы точно есть и прежде всего рассмотрим сдвиг длинного слова, здесь нам надо переносить старший (младший) бит из одного слова в другое и где-то его сохранять между операциями. Конечно, есть простое и естественное решение — вначале спрятать куда-то (наверное, в младший бит промежуточного регистра) переносимый бит слова, затем сдвинуть собственно слово, потом сдвинуть второе слово и вернуть спрятанный бит в старший (младший бит) слова результата.

Но, когда нам потребуется сдвиг длинного данного более, чем на 1 разряд, многократное повторение вышеуказанной процедуры становится утомительным. И здесь разработчики архитектуры нашли «воистину замечательное решение», которое лучше всего проиллюстрировать примером реализации сдвига влево на 12 разрядов двойного слова данных в паре (r2.r1):

rsr r3,r1,#32-12 ; младшие 12 разрядов старшего слова результата
rsl r1,r1,#12 ; младшее слово результата
rsl r2,r2,#12 ; старшие 20 разрядов старшего слова результата
or r2,r3,r2 ; собственно старшее слово результата.


Если учесть возможность использования специального регистра в качестве счетчика для длинного сдвига слова (в предыдущих командах сдвиг выполнялся на константное количество бит) и особенности проведения операций сдвигов в разные стороны — сдвиг влево идет на значение счетчика, а сдвиг вправо — на (32-значение счетчика), то операция сдвига длинного слова на произвольное значение разрядов становится совсем простой и осуществляется без перезагрузки значения счетчика сдвига за счет разной интерпретации содержимого регистра.
Так что со сдвигами длинных слов мы справились, причем бит переноса вообще не потребовался.

Есть еще одно интересное применение бита С — выдвижение в него очередного бита (старшего либо младшего) для передачи одновременно со сдвигом собственно слова данных для передачи. Данный трюк постоянно использовался в программах на ассемблере, но на языке С не имеет естественной реализации. Его можно проимитировать при помощи очевидной последовательности команд

int tmp = data & 0x01; 
data = data >>1; 
if (tmp) …. 

но никаких проблем с трансляцией данного фрагмента у компилятора для RISC-V не будет, поскольку здесь бита С нет, а есть лишь его имитация. Да, имитация не очень удачная, но тут уже претензии не к архитектуре без переноса, а к конкретному языку программирования.

Со сдвигами вроде разобрались, но со сложением так просто не получится, поскольку перенос из младшего слова реально надо учитывать при операции со старшим словом данных и обойтись без него нельзя, от слова «совсем». Пнп: в интересной книге «Хаки для программистов» есть раздел, посвященный длинной арифметике, в начале которого нам обещают показать, как проводить операции без переноса, а потом дают псевдокод такого метода. В первой строке складывают младшие слова, а в следующей при помощи набора логических операций формируют объект, называемый С, который и суммируют со старшими словами. Раз уж «хакеры» с задачей избавится от переноса не справились, что же говорить о нормальных программистах.

Естественным решением выглядит использование некоторого регистра (его младшего бита) для хранения значения переноса от его создания до использования. Но если мы будем использовать заранее определенный регистр, исходная проблема сохранится, и мы не сможем изменить порядок команд, формирующих этот бит, не рискуя потерять значения в нем до их использования. Значит, нам потребуется много места для битов переноса, и обычные регистры предоставят нам это место. Тогда номер регистра хранения переноса мы можем просто указать в команде, производящей операцию, как дополнительный параметр — вполне приемлемое решение. При таком подходе фрагмент кода
register uint64_t lg, lr; lr=lg+1; // r4.r3 < — r2.r1+1
который в случае «нормальной» архитектуры с переносом превратился бы в 2 команды ассемблера:

addi r3,r1,#1;
addc r4,r2,#0


обзаведется дополнительной командой и потребует дополнительного регистра:

addi r3,r1,#1; инкрементируем младшую часть
sltu r4,r3,r1; формируем бит переноса, анализируя операнд и результат
add r4,r3,r4; прибавляем перенос к старшей части


Прибавилась 1 команда для формирования переноса. Не уверен, что такой overhead (50%) компенсируется возможностью переставить команды (тем более, что в данном случае ничего и не переставляем), но уже для переменной из памяти ситуация не столь катастрофична -для реализации следующей операции uint64_t lg; lg++; вместо 6 команд (чтение длинной переменной в регистры — 2 команды, собственно операция — 2 команды, запись в память — 2 команды) потребуется лишь 7 команд (чтение 2 команды, операция (2+1)=3 команды, запись 2 команды), что приводит к лишней одной команде, или увеличению на 16%. Для сложения двух длинных слов вместо 4+2+2=8 потребуется 4+3+2=9, что тоже не слишком много.
В любом случае, не думаю, чтобы разработчики архитектуры RISCV (и MIPS заодно и, вроде как, SPARC) не взвесили все аспекты подобного решения. Пнп: мое личное мнение, ни в коей мере никому не навязываемое и не подкрепленное расчетами или практикой разработки АЛУ, состоит в том, что отсутствие слова признаков все-таки более фича, нежели реальная необходимость, что подтверждается практикой ARM (и x86 заодно), читатели могут высказать свое мнение в опросе.

Осталось понять, что делает команда SLTU – понятно, что формируется перенос, но как, Холмс? Сначала я предположил, что анализируются знаки результата и операнда, но не сходилось. Тогда посмотрел описание системы команд и обнаружил, что это просто акроним от Set Less Then (Unsigned) и это команда сравнения двух операндов, которая формирует значение 1 в регистре результата, если первый операнд меньше второго, ничего экстраординарного. Сразу ясно применение этой команды в операции сложения и вот пример сложения двух длинных чисел (пары (r1.r0) и пары (r3.r2), результат в паре (r5.r4)), чтобы увидеть обработку переноса:

add r4,r2,r0; сложили младшие части
sltu r5,r4,r2; установили r5 в 1, если результат меньше слагаемого
add r5,r5,r1; прибавляем перенос к старшей части слагаемого
add r5,r5,r3; прибавляем старшую часть второго слагаемого.


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

Со сложением все очевидно и понятно, но для вычитания такой подход не сработает. Пнп: перенос/заём формируется несколько по-разному для сложения и вычитания. Приведу следующий пример: исходное число 2 — 0х02, прибавляем к нему 254 — 0хFE, получаем 256 — 0х00 и бит С должен быть сформирован. Теперь от числа 2 отнимем 2 (прибавим 0xFE — дополнение двух), результат по-прежнему 0, но заёма быть не должно.
Рассматриваем код вычитания длинных чисел (пары (r1.r0) из пары (r3.r2) с результатом в (r5.r4)), и ожидаем увидеть команду, противоположную к SLTU (SGTU ?), чтобы получить правильное значение заёма, но видим по-прежнему именно ее:

sub r4,r2,r0; вычитаем младшие части
sltu r5,r2,r4; формируем заем
sub r5,r3,r5; вычитаем из старшей части заем
sub r5,r5,r1; вычитаем из старшей части вычитаемое.


Ответ кроется в деталях применения — в команде sltu изменен порядок операндов и заём формируется, если результат больше, чем уменьшаемое, что совершенно логично.

Теперь рассмотрим еще один интересный случай, привлекший мое внимание, а именно декремент длинного слова:

addi r2,r0,#-1 ; декремент младшего слова
sltu r4,r2,r0 ; формирование переноса как при сложении
addi r3,r1,#-1 ; хитрый трюк
add r3,a3,r4 ; прибавление переноса к старшей части.


Секрет спрятан в команде 3, которая, по сути, инвертирует значение переноса, выполнив предварительную операцию вычитания 1 из старшего слова.

Почему данный фрагмент меня заинтересовал — потому что фрагмент ниже, придуманный мною и аналогичный варианту с вычитанием, выполняет ту же операцию и занимает на одну команду меньше:

addi r2,r0,#-1 ; декремент младшего слова
sltu r4,r0,r2 ; формирование заема
sub r3,a1,r4 ; вычитание заема из старшей части.


Хотя, может быть, вычитание более дорогая операция по времени, но вряд ли она займет место двух сложений, так что почему компилятор создает первый вариант, я ответить не смогу. Если среди моих читателей есть разработчики компилятора GCC, буду благодарен за разъяснения.

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

Комментарии (6)


  1. unreal_undead2
    06.12.2024 13:01

    Понятно как это появилось - последовательное "академическое" применение идеологии RISC с выкидыванием инструкций и концепций, которые можно реализовать другими имеющимися средствами. Насколько влияет на производительность и количество транзисторов на кристалле - без симуляций и сравнения RTL сказать невозможно (особенно если рассматривать не микроконтроллеры, а полноценные процессоры с несколькими исполнительными устройствами и нетривиальными преобразованиями инструкций в микрооперации).


  1. mpa4b
    06.12.2024 13:01

    Здесь ответ очевиден — переход не самая удачная идея, поскольку не слишком хорошо сочетается с конвейером. Но есть еще и вариант с условным исполнением команд, и он вполне приемлем

    Есть мнение, что ситуация ровно обратная. Условные переходы предсказываются задолго до попадания операций в вычислительный конвеер и как правило с очень хорошей точностью. А вот команды с предикатами, как в том же 32-битном арме -- это ад с т.з. структуры конвеера. Начиная с необходимости +1 порта чтения у регистрового файла (регистр-приёмник может или записаться или нет только в простом несуперскалярном единственном конвеере как у arm7tdmi, а во "взрослой" суперскалярной архитектуре это будет дополнительный операнд у команды) и кончая всеми теми милыми приколами, когда предикатная операция решит записать в pc. Ну и конечно же, дополнительные зависимости по флагам, что отнюдь не облегчает шедулинг.


    1. arteast
      06.12.2024 13:01

      Поскольку в статье идет речь о RISC-V, то наверное стоит посмотреть на RISC-V и в этом вопросе. Ратифицированное расширение Zicond вводит ровно два опкода:

      • czero.eqz rd, rs1, rs2 -- Moves zero to a register rd, if the condition rs2 is equal to zero, otherwise moves rs1 to rd

      • czero.nez rd, rs1, rs2 -- Moves zero to a register rd, if the condition rs2 is nonzero, otherwise moves rs1 to rd

      Все остальное делается набором команд (к примеру, тернарный оператор `cond ? a : b` делается тремя операциями).

      PC нет в gpr (имхо возможность писать - да и даже читать - PC обычными операциями - это красиво в чистой ISA, но "ад с т.з. конвеера"), флагов нет, никаких лишних портов чтения, никакой неявной зависимости от rd (как у cmove) и вообще никаких дополнительных зависимостей нет помимо стандартных двух. Фактически это обычные операции АЛУ, ничем архитектурно не отличающиеся от, например, add - и исполняться они будут так же быстро, не затыкая конвейер.


  1. nerudo
    06.12.2024 13:01

    Язык С долго ругали за то, что в нем нет встроенного способа контролировать флаги переполнения. Чтобы было не так обидно, стали продвигать архитектуру, где их нет. Как-то так?


  1. Sly_tom_cat
    06.12.2024 13:01

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


  1. checkpoint
    06.12.2024 13:01

    Удаление флагов на архитектурном уровне позволяет существенно упростить конвейер на микроархитектурном уровне при реализации OoO, так как устраняет лишние зависимости. Также читал где-то, что флаги создают проблемы для виртуализации. В этом смысле одна дополнительная инструкция sltu это минимальная плата за такую роскошь.

    А еще при поддержке macro-op fusion две последовательные команды add/sltu могут быть слиты и исполнены за один такт как команда "сложения и взятия флага". Фактически мы получем команду addc. Но это отдается на откуп разработчикам микроархитектуры.