Главная » Хабрахабр » Почти все, что вы хотели знать про плавающую точку в ARM, но боялись спросить

Почти все, что вы хотели знать про плавающую точку в ARM, но боялись спросить

Привет, Хабр! В этой статье я хочу рассказать про работу с плавающей точкой для процессоров с архитектурой ARM. Думаю, эта статья будет полезна прежде всего тем, кто портирует свою ОС на ARM-архитектуру и при этом им нужна поддержка аппаратной плавающей точки (что мы и делали для Embox, в котором до этого использовалась программная реализация операций с плавающей точкой).

Итак, приступим.

Флаги компилятора

Для поддержки плавающей точки необходимо передавать правильные флаги компилятору. Беглое гугление нас приводит к мысли, что особенно важны две опции: -mfloat-abi и -mfpu. Опция -mfloat-abi задает ABI для работы с плавающей точкой и может иметь одно из трех значений: ‘soft’, ‘softfp’ и ‘hard’. Вариант ‘soft’, как и следует из названия, говорит компилятору использовать встроенные вызовы функций, для программной работы с плавающей точкой (этот вариант и использовался раньше). Остальные два ‘softfp’ и ‘hard’ рассмотрим немного позже, после рассмотрения опции -mfpu.

Флаг -mfpu и версия VFP

Опция -mfpu, как написано в онлайн-документации gcc, позволяет задать тип аппаратуры и может принимать следующие варианты:

Причем ‘neon’ это тоже самое что и ‘neon-vfpv3’, а ‘vfp’ это ‘vfpv2’. ‘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’.

Мой компилятор (arm-none-eabi-gcc (15:5.4.1+svn241155-1) 5.4.1 20160919) выдаёт немного другой список, но сути дела это не меняет. Нам в любом случае нужно понять, как влияет тот или иной флаг на работу компилятора, ну и конечно, какой флаг когда следует использовать.

В qemu платформа Interator/cp основана на процессоре ARM926EJ-S, который в свою очередь поддерживает сопроцессор VFP9-S. Я начинал разбираться с платформы на основе процессора imx6, но отложим и ее ненадолго, поскольку сопроцессор neon имеет особенности о которых я расскажу позже, а начнем с более простого случая — с платформы integrator/cp,
Самой платы у меня нет, поэтому отладка производилась на эмуляторе qemu. Соответственно, нужно поставить -mfpu=vfpv2, но в списке опций моего компилятора, такого варианта не оказалось. Данный сопроцессор соответствует стандарту Vector Floating-point Architecture version 2 (VFPv2). При запуске я получил исключение undefined instruction, что было предсказуемо, ведь сопроцессор был выключен. На просторах интернета я встретил вариант компиляции с флагами -mcpu=arm926ej-s -mfpu=vfpv3-d16, поставил, и у меня все скомпилилось.

Делается это с помощью команды VMSR Для того чтобы, разрешить работу сопроцессора нужно установить бит EN [30] в регистре FPEXC.

/* Enable FPU extensions */ asm volatile ("VMSR FPEXC, %0" : : "r" (1 << 30);

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

Но когда я включил оптимизацию (-O2), стало возникать уже упомянутое ранее исключение undefined instruction. После разрешения работы сопроцессора стали проходить наши тесты на математические функции. Наконец, я обнаружил в конце приведенной страницы фразу “The instructions that copy immediate constants are available in VFPv3” (т.е. Причем возникало оно на инструкции vmov которая вызывалась в коде раньше, но исполнялась успешно (без возникновения исключения). И решил проверить, какая же версия релизована в моем эмуляторе. операции с константами поддерживаются начиная с VFPv3). Из документации следует, что значение регистра должно быть 0x41011090. Версия записана в регистре FPSID. 16] то есть VFPv2. Это соответствует 1 в поле architecture [19.. Собственно, сделав распечатку при старте, я это и получил

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!,
vldm r0!, {d0-d15}

И так далее. Полный список команд можно посмотреть на сайте ARM.

Ну и конечно не стоит забывать о версии VFP, чтобы не возникло ситуаций вроде той, что описана выше.

Флаг -mfloat-abi ‘softfp’ и ‘hard’

Вернемся к -mfloat-abi. Если почитать документацию, то увидим:

‘hard’ allows generation of floating-point instructions and uses FPU-specific calling conventions. ‘softfp’ allows the generation of code using hardware floating-point instructions, but still uses the soft-float 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’. На вопрос, какой же флаг использовать ‘softfp’ или ‘hard’, я ответил для себя следующим образом: везде где нет уже скомпилированных с флагом ‘softfp’ частей, следует использовать ‘hard’.

Переключение контекста

Поскольку Embox поддерживает вытесняющую многозадачность, для корректной работы в рантайме, естественно, нужна была реализация переключения контекста. Для этого необходимо сохранять регистры сопроцессора. Тут есть пара нюансов. Первый: оказалось что команды операций со стеком для плавающих точек (vstm/vldm) поддерживают не все режимы. Второй: эти операции не поддерживают работу более, чем с шестнадцатью 64-битных регистров. Если за раз нужно загрузить/сохранить больше регистров, нужно использовать две инструкции.

На самом деле, каждый раз сохранять и восстанавливать по 256 байт VFP-регистров совсем не обязательно (регистры общего назначения занимают всего 64 байта, так что разница существенная). Еще приведу одну небольшую оптимизацию. Очевидной оптимизацией будет совершать эти операции только если процесс этими регистрами в принципе пользуется.

В обработчике этого исключения нужно проверить, чем исключение вызвано, и если дело в использовании VPF-сопроцессора, то процесс помечается как использующий VFP-сопроцессор. Как я уже упоминал, при выключенном сопроцессоре VFP попытка исполнить соответствующую инструкцию будет приводить к исключению “Undefined Instruction”.

В итоге уже написанное сохранение/восстановление контекста дополнилось макросами

#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.

Конечно, лучше указывать для -mfpu варианты vfpv3 или vfpv3-d32 для лучшей оптимизации, поскольку он имеет 32 64-битных регистра. Режим vfp для NEON почти ничем не отличается от разобранного сопроцессора vfp9-s. это делается с помощью команд И для включения сопроцессора необходимо дать доступ к сопроцессорам 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] = %d\n", 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. Отмечу, что современные микроконтроллеры очень мощные и подходят не только для управления, но и для обработки сигналов или различного рода мультимедиа-информации. Для того, чтобы эффективно использовать всю эту мощь, нужно понимать как она устроена. Надеюсь данная статья помогла в этом чуть лучше разобраться.


Оставить комментарий

Ваш email нигде не будет показан
Обязательные для заполнения поля помечены *

*

x

Ещё Hi-Tech Интересное!

[Из песочницы] C++: сеанс спонтанной археологии и почему не стоит использовать вариативные функции в стиле C

Началось все, как водится, с ошибки. Я первый раз работал с Java Native Interface и делал в C++ части обертку над функцией, создающей Java объект. Эта функция — CallVoidMethod — вариативна, т.е. помимо указателя на среду JNI, указателя на тип ...

Почему разработчикам железа важно проводить качественный cusdev

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