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

в 9:53, , рубрики: C, embox, fpu, neon, simd, STM32F4Discovery, stm32f7discovery, vfp, Блог компании Embox, плавающая точка, системное программирование

Почти все, что вы хотели знать про плавающую точку в ARM, но боялись спросить - 1Привет! В этой статье я хочу рассказать про работу с плавающей точкой для процессоров с архитектурой 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) числами с плавающей точкой, Соответствие между этими регистрами показано на картинке ниже.

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

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

Источник

* - обязательные к заполнению поля