Привет! В этой статье я хочу рассказать про работу с плавающей точкой для процессоров с архитектурой ARM. Думаю, эта статья будет полезна прежде всего тем, кто портирует свою ОС на ARM-архитектуру и при этом им нужна поддержка аппаратной плавающей точки (что мы и делали для Embox, в котором до этого использовалась программная реализация операций с плавающей точкой).
Итак, приступим.
Флаги компилятора
Для поддержки плавающей точки необходимо передавать правильные флаги компилятору. Беглое гугление нас приводит к мысли, что особенно важны две опции: -mfloat-abi и -mfpu. Опция -mfloat-abi задает ABI для работы с плавающей точкой и может иметь одно из трех значений: ‘soft’, ‘softfp’ и ‘hard’. Вариант ‘soft’, как и следует из названия, говорит компилятору использовать встроенные вызовы функций, для программной работы с плавающей точкой (этот вариант и использовался раньше). Остальные два ‘softfp’ и ‘hard’ рассмотрим немного позже, после рассмотрения опции -mfpu.
Флаг -mfpu и версия VFP
Опция -mfpu, как написано в онлайн-документации gcc, позволяет задать тип аппаратуры и может принимать следующие варианты:
‘auto’, ‘vfpv2’, ‘vfpv3’, ‘vfpv3-fp16’, ‘vfpv3-d16’, ‘vfpv3-d16-fp16’, ‘vfpv3xd’, ‘vfpv3xd-fp16’, ‘neon-vfpv3’, ‘neon-fp16’, ‘vfpv4’, ‘vfpv4-d16’, ‘fpv4-sp-d16’, ‘neon-vfpv4’, ‘fpv5-d16’, ‘fpv5-sp-d16’, ‘fp-armv8’, ‘neon-fp-armv8’ and ‘crypto-neon-fp-armv8’. Причем ‘neon’ это тоже самое что и ‘neon-vfpv3’, а ‘vfp’ это ‘vfpv2’.
Мой компилятор (arm-none-eabi-gcc (15:5.4.1+svn241155-1) 5.4.1 20160919) выдаёт немного другой список, но сути дела это не меняет. Нам в любом случае нужно понять, как влияет тот или иной флаг на работу компилятора, ну и конечно, какой флаг когда следует использовать.
Я начинал разбираться с платформы на основе процессора imx6, но отложим и ее ненадолго, поскольку сопроцессор neon имеет особенности о которых я расскажу позже, а начнем с более простого случая — с платформы integrator/cp,
Самой платы у меня нет, поэтому отладка производилась на эмуляторе qemu. В qemu платформа Interator/cp основана на процессоре ARM926EJ-S, который в свою очередь поддерживает сопроцессор VFP9-S. Данный сопроцессор соответствует стандарту Vector Floating-point Architecture version 2 (VFPv2). Соответственно, нужно поставить -mfpu=vfpv2, но в списке опций моего компилятора, такого варианта не оказалось. На просторах интернета я встретил вариант компиляции с флагами -mcpu=arm926ej-s -mfpu=vfpv3-d16, поставил, и у меня все скомпилилось. При запуске я получил исключение undefined instruction, что было предсказуемо, ведь сопроцессор был выключен.
Для того чтобы, разрешить работу сопроцессора нужно установить бит EN [30] в регистре FPEXC. Делается это с помощью команды VMSR
/* Enable FPU extensions */
asm volatile ("VMSR FPEXC, %0" : : "r" (1 << 30);
Вообще-то команда VMSR обрабатывается сопроцессором и вызывает исключение, если не включен сопроцессор, но обращение к этому регистру, его не вызывает. Правда, в отличие от остальных, обращение к этому регистру возможно только в привелирегированном режиме.
После разрешения работы сопроцессора стали проходить наши тесты на математические функции. Но когда я включил оптимизацию (-O2), стало возникать уже упомянутое ранее исключение undefined instruction. Причем возникало оно на инструкции vmov которая вызывалась в коде раньше, но исполнялась успешно (без возникновения исключения). Наконец, я обнаружил в конце приведенной страницы фразу “The instructions that copy immediate constants are available in VFPv3” (т.е. операции с константами поддерживаются начиная с VFPv3). И решил проверить, какая же версия релизована в моем эмуляторе. Версия записана в регистре FPSID. Из документации следует, что значение регистра должно быть 0x41011090. Это соответствует 1 в поле architecture [19..16] то есть VFPv2. Собственно, сделав распечатку при старте, я это и получил
unit: initializing embox.arch.arm.fpu.vfp9_s:
VPF info:
Hardware FP support
Implementer = 0x41 (ARM)
Subarch: VFPv2
Part number = 0x10
Variant = 0x09
Revision = 0x00
Прочитав внимательно, что ‘vfp’ это alias ‘vfpv2’, я выставил правильный флаг, все заработало. Возвращаясь к странице, где я увидел сочетания флагов -mcpu=arm926ej-s -mfpu=vfpv3-d16 отмечу, что был недостаточно внимателен, потому что в списке флагов фигурирует -mfloat-abi=soft. То есть, никакой аппаратной поддержки в этом случае нет. Точнее, -mfpu имеет значение только если установлено отличное от ‘soft’ значение для -mfloat-abi.
Ассемблер
Пришло время поговорить об ассемблере. Ведь мне нужно было сделать и поддержку рантайма, а, например, про переключение контекста компилятор, конечно, не знает.
Регистры
Начнем с описание регистров. VFP позволяет совершать операции с 32-битными (s0..s31) и 64-битными (d0..d15) числами с плавающей точкой, Соответствие между этими регистрами показано на картинке ниже.
Q0-Q15 — это 128-битные регистры из более старших версий для работы с SIMD, о них чуть позже.
Система команд
Конечно, чаще всего работу с VFP-регистрами стоит отдать компилятору, но как минимум переключение контекста придётся написать вручную. Если у вас уже есть примерное понимание синтаксиса команд ассемблера для работы с регистрами общего назначения, разобраться с новыми командами не должно составить большого труда. Чаще всего просто добавляется приставка “v”.
vmov d0, r0, r1 /* Указывается r0 и r1, т.к. в d0 64 бита, а в r0-1 только 32 */
vmov r0, r1, d0
vadd d0, d1, d2
vldr d0, r0
vstm r0!, {d0-d15}
vldm r0!, {d0-d15}
И так далее. Полный список команд можно посмотреть на сайте ARM.
Ну и конечно не стоит забывать о версии VFP, чтобы не возникло ситуаций вроде той, что описана выше.
Флаг -mfloat-abi ‘softfp’ и ‘hard’
Вернемся к -mfloat-abi. Если почитать документацию, то увидим:
‘softfp’ allows the generation of code using hardware floating-point instructions, but still uses the soft-float calling conventions. ‘hard’ allows generation of floating-point instructions and uses FPU-specific calling conventions.
То есть, речь идет о передаче аргументов в функцию. Но, по крайней мере, мне было не очень понятно, в чем разница между “soft-float” и “FPU-specific” calling conventions. Предположив, что в случае hard используются регистры с плавающей точкой, а случае softfp используются целочисленные регистры, я нашел подтверждение этому на вики debian. И хотя это для сопроцессоров NEON, но это не имеет принципиального значения. Еще один интересный момент, что при варианте softfp компилятор может, но не обязан использовать аппаратную поддержку:
“Compiler can make smart choices about when and if it generates emulated or real FPU instructions depending on chosen FPU type (-mfpu=) “
Для лучшей ясности я решил поэкспериментировать, и очень удивился, поскольку при выключенной оптимизации -O0 разница была очень незначительная и касалась не тех мест, где реально использовалась плавающая точка. Догадавшись, что компилятор просто все укладывает на стек, а не использует регистры, я включил оптимизацию -O2 и опять удивился, поскольку с оптимизацией компилятор начинал использовать аппаратные регистры с плавающей точкой, как для варианта hard, так и для sotffp, и разница, как и в случае с -O0 была очень незначительная. В итоге для себя я объяснил это тем что компилятор решает проблему, связанную с тем, что если копировать данные между регистрами с плавающей точкой и целочисленными, существенно падает производительность. И компилятор при оптимизации начинает использовать все имеющиеся в его распоряжении ресурсы.
На вопрос, какой же флаг использовать ‘softfp’ или ‘hard’, я ответил для себя следующим образом: везде где нет уже скомпилированных с флагом ‘softfp’ частей, следует использовать ‘hard’. Если же такие есть, то необходимо использовать ‘softfp’.
Переключение контекста
Поскольку Embox поддерживает вытесняющую многозадачность, для корректной работы в рантайме, естественно, нужна была реализация переключения контекста. Для этого необходимо сохранять регистры сопроцессора. Тут есть пара нюансов. Первый: оказалось что команды операций со стеком для плавающих точек (vstm/vldm) поддерживают не все режимы. Второй: эти операции не поддерживают работу более, чем с шестнадцатью 64-битных регистров. Если за раз нужно загрузить/сохранить больше регистров, нужно использовать две инструкции.
Еще приведу одну небольшую оптимизацию. На самом деле, каждый раз сохранять и восстанавливать по 256 байт VFP-регистров совсем не обязательно (регистры общего назначения занимают всего 64 байта, так что разница существенная). Очевидной оптимизацией будет совершать эти операции только если процесс этими регистрами в принципе пользуется.
Как я уже упоминал, при выключенном сопроцессоре VFP попытка исполнить соответствующую инструкцию будет приводить к исключению “Undefined Instruction”. В обработчике этого исключения нужно проверить, чем исключение вызвано, и если дело в использовании VPF-сопроцессора, то процесс помечается как использующий VFP-сопроцессор.
В итоге уже написанное сохранение/восстановление контекста дополнилось макросами
#define ARM_FPU_CONTEXT_SAVE_INC(tmp, stack)
vmrs tmp, FPEXC ;
stmia stack!, {tmp};
ands tmp, tmp, #1<<30;
beq fpu_out_save_inc;
vstmia stack!, {d0-d15};
fpu_out_save_inc:
#define ARM_FPU_CONTEXT_LOAD_INC(tmp, stack)
ldmia stack!, {tmp};
vmsr FPEXC, tmp;
ands tmp, tmp, #1<<30;
beq fpu_out_load_inc;
vldmia stack!, {d0-d15};
fpu_out_load_inc:
Для проверки корректности работы переключения контекста в условиях работы с плавающей точки мы написали тест, в котором в одном потоке в цикле мы делаем умножение, а в другом деление, потом сравниваем результаты.
EMBOX_TEST_SUITE("FPU context consistency test. Must be compiled with -02");
#define TICK_COUNT 10
static float res_out[2][TICK_COUNT];
static void *fpu_context_thr1_hnd(void *arg) {
float res = 1.0f;
int i;
for (i = 0; i < TICK_COUNT; ) {
res_out[0][i] = res;
if (i == 0 || res_out[1][i - 1] > 0) {
i++;
}
if (res > 0.000001f) {
res /= 1.01f;
}
sleep(0);
}
return NULL;
}
static void *fpu_context_thr2_hnd(void *arg) {
float res = 1.0f;
int i = 0;
for (i = 0; i < TICK_COUNT; ) {
res_out[1][i] = res;
if (res_out[0][i] != 0) {
i++;
}
if (res < 1000000.f) {
res *= 1.01f;
}
sleep(0);
}
return NULL;
}
TEST_CASE("Test FPU context consistency") {
pthread_t threads[2];
pthread_t tid = 0;
int status;
status = pthread_create(&threads[0], NULL, fpu_context_thr1_hnd, &tid);
if (status != 0) {
test_assert(0);
}
status = pthread_create(&threads[1], NULL, fpu_context_thr2_hnd, &tid);
if (status != 0) {
test_assert(0);
}
pthread_join(threads[0], (void**)&status);
pthread_join(threads[1], (void**)&status);
test_assert(res_out[0][0] != 0 && res_out[1][0] != 0);
for (int i = 1; i < TICK_COUNT; i++) {
test_assert(res_out[0][i] < res_out[0][i - 1]);
test_assert(res_out[1][i] > res_out[1][i - 1]);
}
}
Тест успешно проходил при выключенной оптимизации поэтому мы и указали в описании теста, что он должен быть скомпилирован с оптимизацией, EMBOX_TEST_SUITE(«FPU context consistency test. Must be compiled with -02»); хотя знаем, что тесты не должны на это полагаться.
Сопроцессор NEON и SIMD
Пришло время рассказать, почему я отложил рассказ про imx6. Дело в том что он основан на ядре Cortex-A9 и содержит более продвинутый сопроцессор NEON (https://developer.arm.com/technologies/neon ). NEON не только является VFPv3, но это еще и сопроцессор SIMD. VFP и NEON используют одни и те же регистры. VFP использует для работы 32- и 64-разрядные регистры, а NEON — 64- и 128-битные, последние как раз и были обозначены Q0-Q16. Помимо целых значений и чисел с плавающей точкой NEON также умеет работать с кольцом многочленов 16-й или 8-й степени по модулю 2.
Режим vfp для NEON почти ничем не отличается от разобранного сопроцессора vfp9-s. Конечно, лучше указывать для -mfpu варианты vfpv3 или vfpv3-d32 для лучшей оптимизации, поскольку он имеет 32 64-битных регистра. И для включения сопроцессора необходимо дать доступ к сопроцессорам c10 и c11. это делается с помощью команд
/* Allow access to c10 & c11 coprocessors */
asm volatile ("mrc p15, 0, %0, c1, c0, 2" : "=r" (val) :);
val |= 0xf << 20;
asm volatile ("mcr p15, 0, %0, c1, c0, 2" : : "r" (val));
но других принципиальных отличий нет.
Другое дело если указывать -mfpu=neon, в этом случае компилятор может использовать SIMD-инструкции.
Использование SIMD в C
Для того, чтобы <<рассовывать>> значения по регистрам вручную, можно заинклудить “arm_neon.h” и использовать соответствующие типы данных:
float32x4_t для четырёх 32-битных флоатов в одном регистре, uint8x8_t для восьми 8-битных целых чисел и так далее. Для обращения к одному значению обращаемся как к массиву, сложение, умножение, присвоение и т.д. как для обычных переменных, например:
uint32x4_t a = {1, 2, 3, 4}, b = {5, 6, 7, 8};
uint32x4_t c = a * b;
printf(“Result=[%d, %d, %d, %d]n”, c[0], c[1], c[2], c[3]);
Само собой, использовать автоматическую векторизацию проще. Для автоматической векторизации добавляем в GCC флаг -ftree-vectorize.
void simd_test() {
int a[LEN], b[LEN], c[LEN];
for (int i = 0; i < LEN; i++) {
a[i] = i;
b[i] = LEN - i;
}
for (int i = 0; i < LEN; i++) {
c[i] = a[i] + b[i];
}
for (int i = 0; i < LEN; i++) {
printf("c[i] = %dn", c[i]);
}
}
Цикл со сложениями генерирует следующий код:
600059a0: f4610adf vld1.64 {d16-d17}, [r1 :64]
600059a4: e2833010 add r3, r3, #16
600059a8: e28d0a03 add r0, sp, #12288 ; 0x3000
600059ac: e2811010 add r1, r1, #16
600059b0: f4622adf vld1.64 {d18-d19}, [r2 :64]
600059b4: e2822010 add r2, r2, #16
600059b8: f26008e2 vadd.i32 q8, q8, q9
600059bc: ed430b04 vstr d16, [r3, #-16]
600059c0: ed431b02 vstr d17, [r3, #-8]
600059c4: e1530000 cmp r3, r0
600059c8: 1afffff4 bne 600059a0 <foo+0x58>
600059cc: e28d5dbf add r5, sp, #12224 ; 0x2fc0
600059d0: e2444004 sub r4, r4, #4
600059d4: e285503c add r5, r5, #60 ; 0x3c
Проведя тесты на распараллеленный код, получили, что простое сложение в цикле, при условии независимости переменных дает ускорение аж в 7 раз. Кроме того, мы решили посмотреть, насколько влияет распараллеливание на реальных задачах, взяли MESA3d с его программной эмуляцией и померили количество fps с разными флагами, получился выигрыш в 2 кадра в секунду (15 против 13), то есть, ускорение около 15-20%.
Приведу еще один пример ускорения с помощью команд NEON, не нашего, а от ARM-а.
Копирование памяти ускоряется почти на 50 процентов по сравнению с обычным. Правда примеры там на ассемблере.
Обычный цикл копирования:
WordCopy
LDR r3, [r1], #4
STR r3, [r0], #4
SUBS r2, r2, #4
BGE WordCopy
цикл с командами и регистрами neon:
NEONCopyPLD
PLD [r1, #0xC0]
VLDM r1!,{d0-d7}
VSTM r0!,{d0-d7}
SUBS r2,r2,#0x40
BGE NEONCopyPLD
Понятно, что копировать по 64 байта быстрее чем по 4 и такое копирование даст прирост на 10%, но остальные 40% похоже дает работа сопроцессора.
Cortex-M
Работа с FPU в Cortex-M мало чем отличается от описанного выше. Например, вот так выглядит приведенный выше макрос для сохранения fpu-шного контекста
#define ARM_FPU_CONTEXT_SAVE_INC(tmp, stack)
ldr tmp, =CPACR;
ldr tmp, [tmp];
tst tmp, #0xF00000;
beq fpu_out_save_inc;
vstmia stack!, {s0-s31};
fpu_out_save_inc:
Также команда vstmia использует только регистры s0-s31 и по-другому происходит обращение к управляющим регистрам. Поэтому не буду сильно вдаваться в подробности, объясню только diff. Итак, мы сделали поддержку для STM32F7discovery c cortex-m7 для него, соответственно, нужно поставить флаг -mfpu=fpv5-sp-d16. Обратите внимание, что в мобильных версиях, нужно еще более внимательно смотреть версию сопроцессора, поскольку могут быть разные варианты у одного и того же cortex-m. Так, если у вас вариант не с двойной точностью, а с одинарной, то может не быть регистров D0-D16, как у нас в stm32f4discovery, именно поэтому и используются вариант с регистрами S0-S31. Для этого контроллера мы -mfpu=fpv4-sp-d16.
Основным же отличием является доступ к управляющим регистрам контроллера они расположены прямо в адресном пространстве основного ядра для причем для разных типов они разные cortex-m4 для cortex-m7.
Заключение
на этом я закончу свой краткий рассказ про плавающую точку для ARM. Отмечу, что современные микроконтроллеры очень мощные и подходят не только для управления, но и для обработки сигналов или различного рода мультимедиа-информации. Для того, чтобы эффективно использовать всю эту мощь, нужно понимать как она устроена. Надеюсь данная статья помогла в этом чуть лучше разобраться.
Автор: abondarev