Векторизация в RISC-V. Основы
Многие современные вычислительные задачи, в частности повсеместная обработка изображений и звука или работа с матрицами для ИИ, хорошо поддаются параллелизации на уровне данных. Входные данные таких задач представлены в виде большого вектора данных, элементы которого можно обрабатывать независимо. Чтобы ускорить вычисления с векторами, производители процессоров добавили в архитектуры специальные Single Instruction, Multiple Data инструкции, которые позволяют работать за одну инструкцию сразу с несколькими элементами.
Каждую инструкцию процессору необходимо вычитать из памяти и обработать. При большом объеме данных и простых скалярных инструкциях вычисления упираются в ботлнек фон Неймана — обращение к памяти за инструкцией занимает гораздо больше времени, чем само исполнение этой инструкции в процессоре. Параллелизация SIMD помогает бороться с этим ботлнеком и чем больше степень параллелизации, тем производительнее вычисления.
В самой распространенной архитектуре процессоров для персональных компьютеров x86–64 и в самой распространенной архитектуре процессоров для мобильных устройств ARM SIMD инструкции добавлялись по мере необходимости. У них есть ряд проблем из-за легаси и сложности расширения на вектора большей длины.
Архитектура RISC-V относительно молодая, и при её разработке можно было учесть прошлый опыт и не заботиться о легаси и обратной совместимости. Архитектуру сделали модульной, поэтому SIMD инструкции вынесены в отдельное расширение RISC-V «V» Extension, спецификацию на которую можно найти в git проекте https://github.com/riscvarchive/riscv-v-spec. В основе подхода к SIMD в RISC-V лежит идея чистого векторного процессора, которая использовалась в суперкомпьютерах. Cray-1, построенный в 1975 году, был первым реализовавшим векторный процессор.
Векторный процессор
В основе архитектуры векторного процессора лежит использование функциональных блоков и векторных регистров. Каждый блок выполняет над одним элементом вектора одну операцию в один шаг времени. Например, блок LOAD загружает элемент вектора из памяти данных в векторный регистр, блок STORE сохраняет элемент вектора из векторного регистра в память данных, блок ADD производит сложение элемента одного вектора с элементом другого вектора находящихся в векторных регистрах.

Функциональные блоки работают параллельно, т.е. блок LOAD загружает нулевой элемент в первый такт работы, в следующий такт работы блок LOAD загружает первый элемент, а блок ADD может сложить нулевой элемент с каким-то значением. Так вектор обрабатывается по цепочке и обработка всего вектора распараллеливается. Это схоже с pipelining-ом инструкций в суперскалярных процессорах, но путать эти два подхода не стоит, т.к. один основан на параллелизме данных, а другой на параллелизме инструкций. Группа блоков, обрабатывающих элементы вектора от начала до конца называется лейном (lane). Использование нескольких лейнов позволяет ещё сильнее параллелизовать вычисления.

Главное преимущество и ключевая особенность векторной архитектуры в том, что один набор функциональных блоков может обрабатывать вектора произвольной длины. Процессор получается относительно простым, но при этом способен достичь высокой степени параллелизации. Проблемой такого подхода являются начало и конец обработки вектора, когда часть функциональных блоков простаивает. Векторный процессор лучше себя проявляет при обработке очень длинных векторов, т.к. за одно обращение к памяти инструкций обрабатывается большое количество данных и начало с концом обработки занимают немного времени относительно части с полной параллелизацией.
Общей проблемой SIMD подходов является работа с условиями, т.к. суть SIMD в однородной обработке большого числа однородных элементов. Чтобы работать с условиями в SIMD архитектуры добавляют маскирование — какой-то элемент может быть пропущен при обработке по какому-то условию. Как правило, маска — это отдельный вектор или массив булевых элементов, который заполняется по условию и затем используется как дополнительный аргумент для SIMD инструкций. Элемент будет обработан инструкцией, только если соответствующий элемент в векторе масок выставлен в true
. В этой статье рассматриваются только основы, и работы с масками в векторном процессоре не будет.
Реализация векторного процессора в RISC-V
Спецификация на расширение RVV начинается с определения констант имплементации: длины векторного регистра в битах VLEN и максимальной длины элемента вектора в битах ELEN. Расширение добавляет 32 векторных регистра v0
-v31
.
Динамические параметры конфигурации определяют размер элемента вектора и сколько элементов будет обрабатывать каждая векторная инструкция. Динамический размер элемента в битах SEW — selected element width — задается в поле vsew[2:0]
параметра vtype
, в котором задается тип элемента. В одном векторном регистре содержится VLEN/SEW
элементов.
Один вектор может занимать только часть векторного регистра или быть большей длины и занимать несколько векторных регистров. Как вектор будет занимать векторные регистры определяется параметром кратности длины LMUL — length multiplier, который задается в поле vlmul[2:0]
в параметре vtype
. Максимальная возможная длина вектора в элементах при заданной конфигурации определяется как VLMAX = LMUL*VLEN/SEW
. LMUL может быть дробным, тогда в одном векторном регистре располагается только VLMAX элементов, остальные элементы будут неактивным хвостом — tail.
Текущее значение длины вектора в элементах задается в параметром vl
. Векторная инструкция обработает vl
элементов, а оставшиеся VLMAX-vl
элементов в векторном регистре неактивны и не будут обработаны. Также неактивными элементами являются замаскированные элементы.
Разрешённые значения для параметров конфигурации SEW, LMUL, политики обработки хвостов и маскированных элементов указаны в спецификации.
Для динамической конфигурации используется инструкция vsetvl
, которая выставляет значения параметров vtype
и vl
. Пользователь в инструкции передает желаемое значение количества обрабатываемых элементов — Application Vector Length (AVL) и тип элемента вектора vtypei
. Процессор вернет значение vl
которое оно смогло поставить в регистр, указанный как rd
. AVL и тип элемента могут передаваться в регистре, т.е. можно написать код с полностью динамически выставляемой длиной векторов.
vsetvli rd, rs1, vtypei # rd = new vl, rs1 = AVL, vtypei = new vtype setting
vsetivli rd, uimm, vtypei # rd = new vl, uimm = AVL, vtypei = new vtype setting
vsetvl rd, rs1, rs2 # rd = new vl, rs1 = AVL, rs2 = new vtype value
На рисунке 3 представлены две схемы как изменяется конфигурация векторных регистров инструкцией vsetvl
.
Слева инструкция запрашивает длину вектора в 7 элементов и это значение зашито в инструкцию — immediate значение. Тип элемента задается через третий параметр vtypei
и для простоты чтения в листинге его разделили на несколько значений. Длина элемента вектора SEW выставляется в 8 бит — параметр e8
. Значение кратности длины LMUL в 1 — параметр m1
. В заданной конфигурации каждый векторный регистр в себе хранит 1 вектор длиной VLMAX=512
элементов. При этом активными будут только первые 7 элементов каждого вектора и только они будут обработаны векторными инструкциями. Остальные элементы с индексами от 7 до 511 это неактивный хвост. То, как векторные инструкции будут обрабатывать хвост, задаётся параметром tail policy. Tail undisturbed — tu
— хвост остаётся неизменным. Tail agnostic — ta
— хвост может не измениться или быть заполненным единицами. Аналогично задается политика обработки замаскированных элементов mask agnostic — ma
и mask undisturbed — mu
.
Справа показана схема конфигурации векторного процессора для элементов длиной 32 бита и активной длиной вектора 33 элемента. Кратность длины задана 2, т.е. каждый вектора занимает 2 векторных регистра и может хранить 256 элементов. В инструкции AVL задаётся через регистр a0
, поэтому значение 33 перед этим кладется в этот регистр инструкцией load immediate.

vsetvl
.Параметры конфигурации записываются в специальные регистры контроля и статуса — Control and Status Registers (CSRs). Параметры SEW и LMUL задаются как поля регистра ctype. Список CSR в RISC-V можно посмотреть в спецификации на ISA. Для чтения CSR регистров в архитектуре RISC-V введены специальные инструкции CSRR. Размер регистра контроля и статуса в битах определяется константой имплементации XLEN.
Простые векторные инструкции
Рассмотрим простой пример прибавления константы к 21 элементу вектора целочисленных значений и умножения его на константу написанный на C. Чтобы не полагаться на компилятор для получения наглядных векторных инструкций воспользуемся ассемблерными вставками через интринсики. В C компиляторах gcc и clang есть заголовочный файл
с интринсиками для векторных инструкций RISC-V. Писать на интринсиках чуть проще чем прямыми ассемблерными вставками. Спецификацию на интринзики можно найти здесь.
Исходный код функции таков:
typedef struct {
int32_t data[128];
} my_array_t;
void bar(const my_array_t *a, my_array_t *b) {
size_t vl = vsetvl_e32m1(21);
const int32_t* a_ = a->data;
int32_t* b_ = b->data;
vint32m1_t buf_a = vle32_v_i32m1(a_, vl);
vint32m1_t buf_b = vadd(buf_a, 1, vl);
vint32m1_t buf_c = vmul(buf_b, 2, vl);
vse32_v_i32m1(b_, buf_c, vl);
}
Первая функция-интринсик vsetvl_e32m1(21)
производит векторную конфигурацию. Размер элемента вектора SEW, его тип и кратность LMUL зашиты в имя функции. Поскольку некоторые инструкции могут работать с элементами разного размера для аргументов и результата, например расширяемые арифметические инструкции vwadd
, для инструкций вводится понятие эффективных размеров элемента effective element width — EEW и кратности effective LMUL — EMUL. Для простых инструкций в нашем примере SEW=EEW и LMUL=EMUL. В функции e32m1
означает что EEW=32 бита и EMUL=1. Количество элементов AVL передается как аргумент этой функции. Функция возвращает реально установленное значение количества активных элементов vl
.
Сами вектора представлены объектами типа vint32m1_t
, для целочисленных 32-х битных элементов. Загрузка из реальной памяти в векторный регистр выполняется инструкцией векторной загрузки vle
в виде интринсика vle32_v_i32m1(a_, vl)
. Тип векторных элементов также зашит в имя. Аргументами функции выступают указатель int32_t* a_
на реальный участок памяти со значениями и реальная длина вектора. Возвращает функция объект с этим вектором. Загружаются значения из последовательных адресов в памяти.
Векторное сложение vadd(buf_a, 1, vl)
прибавляет скаляр 1 к каждому активному элементу вектора buf_a
и возвращает результат в вектор buf_b
.
Векторное умножение vmul(buf_b, 2, vl)
умножает каждый активный элемент вектора buf_b
на скаляр 2 и возвращает результат в вектор buf_с
.
Функция векторного сохранения vse32_v_i32m1(b_, buf_c, vl)
сохраняет вектор buf_c
в реальную память по указателю int32_t* b_
. Сохранение происходит в последовательные адреса в памяти.
В тестовой программе функция bar
применяется к массиву a.data
. Для наглядности на временных диаграммах элементы массива заполнены 4-х байтными значениями, в которых каждый байт это счётчик.
a.data[i] = (i << 24) | (i << 16) | (i << 8) | (i << 0);
// a.data[3] = 0x03030303
В ассемблерном листинге функция bar выглядит так:
000000008000026c :
8000026c: 57 f0 0a c5 vsetivli zero, 21, e32, m1, ta, mu
80000270: 07 64 05 02 vle32.v v8, (a0)
80000274: 57 b4 80 02 vadd.vi v8, v8, 1
80000278: 13 05 20 00 li a0, 2
8000027c: 57 64 85 96 vmul.vx v8, v8, a0
80000280: 27 e4 05 02 vse32.v v8, (a1)
80000284: 67 80 00 00 ret
Ассемблерный листинг практически один к одному соответствует коду на интринсиках. По соглашению вызовов RISC-V в регистрах a0
и a1
хранится аргументы функции — в данном случае адреса массивов a.data
и b.data
. Больше информации об ассемблере RISC-V можно найти в мануале: https://github.com/riscv-non-isa/riscv-asm-manual.
Проверить исполнение этого кода можно с помощью симулятора Spike https://github.com/riscv-software-src/riscv-isa-sim. Но мы пойдем глубже и посмотрим, что происходит внутри процессора при исполнении векторных инструкций.
Векторный процессор Ara
Хоть спецификация на RISC-V открытая, сама реализация процессора по этой спецификации не обязана быть открытой. Но, к счастью, проект PULP опубликовал на гитхабе https://github.com/pulp-platform/ara исходный RTL код на SystemVerilog своего сопроцессора Ara для RISС-V ядер CVA6 (также известных как Ariane), и мы можем посмотреть на их реализацию RVV расширения и просимулировать процессор при исполнении векторных инструкций.
Сопроцессор Ara — это отдельное от основного процессора устройство, которое обрабатывает только векторные инструкции. Процессор, к которому подключен сопроцессор Ara, заметив векторную инструкцию при декодировании, не исполняет её сам, а отправляет через специальный интерфейс в Ara.
Репозиторий проекта Ara включает в себя все необходимое: исходные SystemVerilog RTL коды основного процессора и сопроцессора, ПО Verilator, необходимое для симуляции RTL, clang для кросс-компиляции программ включенных примеров на C под RISC-V архитектуру и Spike для запуска скомпилированного кода без симуляции самого ядра. В своем форке я опубликовал рассматриваемый в данной статье пример программы https://github.com/DuzaBF/ara/tree/my_tests.
Спецификация на расширение RVV не описывает детали реализации векторных инструкций в процессоре. Разработчики процессоров могут по разному сделать модули внутри процессора, которые выполняют векторные инструкции. На рисунке 4 представлена структурная схема внутренностей сопроцессора Ara — одного из многих реализаций RVV расширения.

Процессор CVA6 общается с сопроцессором Ara через интерфейс запросов acc_req
и ответов acc_resp
. В запросе отправляются необходимые данные для исполнения инструкции, например векторная конфигурация и операнды инструкций. Тип сигнала запросов accelerator_req_t
, а инструкция расположена в поле insn
. В зависимости от типа инструкции по-разному разбивается на поля, и значения в полях несут разный смысл. Формат инструкций определен в спецификации на векторное расширение RVV.

Запросы от основного процессора сначала попадают в модуль диспетчера — ara_dispatcher.sv
. Диспетчер декодирует инструкцию в запросе и выставляет значения сигналов в зависимости от типа инструкции. Непосредственно работа над векторами выполняется бекэндом сопроцессора Ara. Модули в бекенде Ara включают в себя:
Несколько лейнов — модулей, которые непосредственно производят арифметические операции над элементами вектора с помощью ALU. По умолчанию в Ara 4 лейна.
Векторные регистры в файле векторных регистров — Vector Register File (VRF).
Модуль векторных загрузки/сохранения (Vector store/load unit — VLSU), который занимается загрузкой значений из памяти в векторные регистр и сохранением значений из векторных регистров в память.
Модули сдвига SLDU отвечает за инструкции векторного сдвига.
Модуль маски MASKU накладывает маски на операнды векторных инструкций.
Секвенсер
ara_sequencer.sv
который распределяет инструкции по зависимостям.
Операции в ALU и VLSU выполняют обрабатывающие элементы — processing elements (PEs). Диспетчер общается с бекендом через секвенсер.
Секвенсер получает от диспечера запросы ara_req
и формирует запросы для обрабатывающих элементов pe_req
. Модули бекенда висят на шине запросов от секвенсера. Модуль понимает, что запрос предназначен ему по значению поля vfu[4:0]
и по сигналу валидного запроса pe_req_valid_i
. После получения запроса модуль кладет его в очередь и начнет выполнять запрос когда будет готов. Секвенсер в это время может начать отправлять запросы на другие модули. На рисунке 6 представлена временная диаграмма запросов от секвенсера при выполнении функции bar
.

Если диспетчер получил инструкцию vsetvl
, он выставляет значения сигналов csr_vl_d
, csr_vtype_d
, которые соответсвуют параметрам конфигурации и векторным CSR. На рисунке 7 представлена временная диаграмма и сигналы при обработке диспетчером инструкции vsetivli
.
8000026c: 57 f0 0a c5 vsetivli zero, 21, e32, m1, ta, mu

Диспетчер понял, что принятое 32-х битное число в поле instr
— это векторная инструкция, по значению его первых 7 битов, где всегда находится opcode инструкции. Значение опкода 0x57
соответствует векторной инструкции. В исходном коде на процессор Ariane используется enum OpcodeVec = 7'b10_101_11
. Поле funct3
(биты [12:14] в инструкции) содержит значение 0b111
(в кодировке векторных инструкций OPCFG) указывающее, что это инструкция изменения конфигурации. По значению последних двух бит в инструкции — поле func2
— декодер определил, что это vsetivli
вариант инструкции. Значение AVL в 21 элемент записано в инструкцию как константное значение в поле uimm
, а тип в поле zimm10
.
При заданной конфигурации сопроцессора Ara и заданной векторной конфигурации максимальная длина вектора 128 элементов.

vsetvl
.Простые векторные load/store
Посмотрим, что происходит внутри Ara при загружении значений из памяти в векторный регистр и сохранении значений в память после арифметических операций. То, как выполнение арифметических операций происходит внутри лейнов, останется на будущие публикации.
Инструкция vle32.v v8, (a0)
— это загрузка из памяти данных в векторный регистр v8
. Размер элемента — 32 бита. Данные расположены в последовательных адресах начиная с адреса, записанного в регистре a0
.
80000270: 07 64 05 02 vle32.v v8, (a0)
Для инструкций векторных загрузок opcode 0x07
, enum OpcodeLoadFp= 7'b00_001_11
.

Модуль VLSU разбит на 2 подмодуля Vector Load Unit — VLDU — отвечает за загрузку данных в векторные регистры и Vector Store Unit — VSTU — отвечает за сохранение данных из векторных регистров в память. Связь с памятью организована через обычный AXI интерфейс.
![Рисунок 10 - Временные диаграммы вычитывания массива из памяти по интерфейсу AXI. Шина 128-битная (значение поля size[2:0] 0x100 кодирует 16 байт за передачу. Спецификация на AXI интерфейс), поэтому 32-битные значения вычитываются по 4 штуки. Для вычитывания 21-го 32-битного значения необходимо сделать 6 чтений, что закодировано в поле len[7:0] = 5 (количество чтений len+1).](https://habrastorage.org/r/w1560/getpro/habr/upload_files/5eb/393/afe/5eb393afe072b0bbfb37f2914351c477.png)
size[2:0]
0x100
кодирует 16 байт за передачу. Спецификация на AXI интерфейс), поэтому 32-битные значения вычитываются по 4 штуки. Для вычитывания 21-го 32-битного значения необходимо сделать 6 чтений, что закодировано в поле len[7:0] = 5
(количество чтений len+1). Вычитанные из памяти значения VLDU распределяет по 4 лейнам в файлы векторных регистров, которые хранятся внутри лейнов. Значения распределяются по лейнам как представлено на рисунке 11. В нулевой лейн попадают элементы массива с нулевым индексом и далее каждый четвертый. Значения в лейны передаются через сигналы с префиксом ldu_result_*
.

Лейны отдают значения после арифметических операций в VSTU через сигналы с префиксом stu_*
. Все лейны заканчивают обработку одновременно и VSTU записывает принятые значения по 64 бита сразу подряд от всех лейнов.

Можно отметить, что для обработки 21-го 32-битного элемента понадобилось всего 4 инструкции, без каких-либо циклов. При работе с x86 SIMD расширения AVX-512, у которого 512-битные регистры, за одну инструкцию можно обработать только 16 элементов. Поэтому понадобится отдельно обработать хвост из 5 элементов, либо циклом со скалярными инструкциями, либо с SIMD инструкциями с масками. А для обработки 128 элементов, которые Ara также сможет сделать за раз, придется в x86 и SIMD обработку основного тела данных помещать в цикл.
Заключение
В этой статье рассмотрены самые базовые принципы работы векторного процессора по спецификации RVV. В будущем планирую рассказать о важных возможностях маскирования для обработки с условием, загрузки/сохранения с шагом или по индексу (scatter/gather). Все вместе они делают RVV весьма мощным инструментом. Интересно будущее развитие процессоров на архитектуре RISC-V и как распространено будет их использование, смогут ли они превзойти процессоры x86 для задач обработки данных.
Отдельными темами выступает оптимизация программ для эффективного использования векторного процессора и автовекторизации в компиляторе. Программистам приходится писать низкоуровневый код на интринсики, т.к. компилятору самому сложно понять, где будет выгода от векторизации, несмотря на все средства выражения идеи в высокоуровневых языках. Это тоже заслуживает своей статьи.
Источники
J.L. Hennessy, D.A. Patterson, and K. Asanović, Computer architecture: a quantitative approach, 5th ed. Waltham, MA: Morgan Kaufmann/Elsevier, 2012.
O. Mutlu, «Digital Design & Computer Architecture. Lecture 20: SIMD Processors,» ETH Zürich, May 14, 2021.
RISC-V Instruction Set Manual: https://github.com/riscv/riscv-isa-manual.
RISC-V «V» Extension Specification: https://github.com/riscvarchive/riscv-v-spec.
PULP platform Ara: https://github.com/pulp-platform/ara.
К. Владимиров «Масштабируемая векторизация в RISCV»: https://www.youtube.com/watch? v=lwIBp6cc-HY.
RISC-V Vector in a Nutshell: https://fprox.substack.com/p/risc-v-vector-in-a-nutshell