Векторизация в 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 производит сложение элемента одного вектора с элементом другого вектора находящихся в векторных регистрах.

Рисунок 1 - Схема последовательности работы функциональных блоков векторного процессора для операций загрузки элементов вектора из памяти, сложения с константой, умножения на константу и сохранения элементов вектора в память.
Рисунок 1 — Схема последовательности работы функциональных блоков векторного процессора для операций загрузки элементов вектора из памяти, сложения с константой, умножения на константу и сохранения элементов вектора в память.

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

Рисунок 2 - Схема обработки векторов в векторном процессоре с 4 лейнами. Источник (Hennessy, 2012).
Рисунок 2 — Схема обработки векторов в векторном процессоре с 4 лейнами. Источник (Hennessy, 2012).

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

Общей проблемой 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.

Рисунок 3 - схемы изменения конфигурации векторных регистров инструкциями vsetvl.
Рисунок 3 — схемы изменения конфигурации векторных регистров инструкциями 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 расширения.

Рисунок 4 - Блок-схема векторного процессора Ara. Источник (Perotti, 2022) https://arxiv.org/abs/2210.08882.
Рисунок 4 — Блок-схема векторного процессора Ara. Источник (Perotti, 2022) https://arxiv.org/abs/2210.08882.

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

Рисунок 5 - Форматы векторных инструкций.
Рисунок 5 — Форматы векторных инструкций.

Запросы от основного процессора сначала попадают в модуль диспетчера — 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.

Рисунок 6 - Временные диаграммы запросов от секвенсера. Последовательно формируются запросы на загрузку - VLE, сложение - VADD, умножение - VMUL и сохранение - VSE.
Рисунок 6 — Временные диаграммы запросов от секвенсера. Последовательно формируются запросы на загрузку — VLE, сложение — VADD, умножение — VMUL и сохранение — VSE.

Если диспетчер получил инструкцию vsetvl, он выставляет значения сигналов csr_vl_d, csr_vtype_d, которые соответсвуют параметрам конфигурации и векторным CSR. На рисунке 7 представлена временная диаграмма и сигналы при обработке диспетчером инструкции vsetivli.

8000026c: 57 f0 0a c5   vsetivli zero, 21, e32, m1, ta, mu
Рисунок 7 - Временные диаграммы сигналов векторной конфигурации в сопроцессоре Ara. Внизу поля инструкции после декодирования.
Рисунок 7 — Временные диаграммы сигналов векторной конфигурации в сопроцессоре Ara. Внизу поля инструкции после декодирования.

Диспетчер понял, что принятое 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 элементов.

Рисунок 8 - Форматы инструкций векторной конфигурации vsetvl.
Рисунок 8 — Форматы инструкций векторной конфигурации 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.

Рисунок 9 - Форматы инструкций загрузки и сохранения с последовательным доступом к памяти.
Рисунок 9 — Форматы инструкций загрузки и сохранения с последовательным доступом к памяти.

Модуль 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).
Рисунок 10 — Временные диаграммы вычитывания массива из памяти по интерфейсу AXI. Шина 128-битная (значение поля size[2:0] 0x100 кодирует 16 байт за передачу. Спецификация на AXI интерфейс), поэтому 32-битные значения вычитываются по 4 штуки. Для вычитывания 21-го 32-битного значения необходимо сделать 6 чтений, что закодировано в поле len[7:0] = 5 (количество чтений len+1).

Вычитанные из памяти значения VLDU распределяет по 4 лейнам в файлы векторных регистров, которые хранятся внутри лейнов. Значения распределяются по лейнам как представлено на рисунке 11. В нулевой лейн попадают элементы массива с нулевым индексом и далее каждый четвертый. Значения в лейны передаются через сигналы с префиксом ldu_result_*.

Рисунок 11 - Временные диаграммы сигналов передачи данных из VLSU в лейны.
Рисунок 11 — Временные диаграммы сигналов передачи данных из VLSU в лейны.

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

Рисунок 12 - Временные диаграммы сохранения данных из векторных регистров в память.
Рисунок 12 — Временные диаграммы сохранения данных из векторных регистров в память.

Можно отметить, что для обработки 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

© Habrahabr.ru