Quase tudo o que você queria saber sobre ponto flutuante no ARM, mas teve medo de perguntar

Olá Habr! Neste artigo, quero falar sobre o trabalho de ponto flutuante para processadores com arquitetura ARM. Acho que este artigo será útil principalmente para aqueles que portam seu SO na arquitetura ARM e, ao mesmo tempo, precisam de suporte para ponto flutuante de hardware (o que fizemos para a Embox , que anteriormente usava uma implementação de software de operações de ponto flutuante).

Então, vamos começar.

Sinalizadores do compilador


Para dar suporte ao ponto flutuante, você deve passar os sinalizadores corretos para o compilador. Uma pesquisa rápida de nós leva à idéia de que duas opções são especialmente importantes: -mfloat-abi e -mfpu. A opção -mfloat-abi define a ABI para operações de ponto flutuante e pode ter um dos três valores: 'soft', 'softfp' e 'hard'. A opção 'soft', como o nome indica, diz ao compilador para usar as chamadas de função internas para programar o ponto flutuante (esta opção foi usada antes). Os dois restantes 'softfp' e 'hard' serão considerados um pouco mais tarde, depois de considerar a opção -mfpu.

-Mfpu flag e versão VFP


A opção -mfpu, conforme escrita na documentação on-line do gcc , permite especificar o tipo de hardware e pode executar as seguintes opções:
'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 'e' crypto-neon-fp-armv8 '. E 'neon' é o mesmo que 'neon-vfpv3' e 'vfp' é 'vfpv2'.
Meu compilador (arm-none-eabi-gcc (15: 5.4.1 + svn241155-1) 5.4.1 20160919) produz uma lista um pouco diferente, mas isso não altera a essência do problema. De qualquer forma, precisamos entender como esse ou aquele flag afeta o compilador e, é claro, qual flag deve ser usado quando.

Comecei a entender a plataforma com base no processador imx6, mas adiaremos por um tempo, já que o coprocessador neon possui recursos que discutirei mais adiante e começaremos com um caso mais simples - da plataforma integrator / cp ,
Eu não tenho o próprio quadro, então a depuração foi feita no emulador qemu. No qemu, a plataforma Interator / cp é baseada no processador ARM926EJ-S , que, por sua vez, suporta o coprocessador VFP9-S . Esse coprocessador está em conformidade com a Vector 2 Floating-point Architecture versão 2 (VFPv2). Portanto, você precisa definir -mfpu = vfpv2, mas essa opção não estava na lista de opções do meu compilador. Na Internet, encontrei uma opção de compilação com os sinalizadores -mcpu = arm926ej-s -mfpu = vfpv3-d16, instalei e tudo compilado para mim. Quando iniciei, recebi uma exceção de instrução indefinida , que era previsível, porque o coprocessador estava desligado .

Para permitir que o coprocessador funcione, é necessário definir o bit EN [30] no registro FPEXC . Isso é feito usando o comando VMSR.

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

De fato, o comando VMSR é processado pelo coprocessador e gera uma exceção se o coprocessador não estiver ativado, mas o acesso a esse registro não o causa . É verdade que, diferentemente dos outros, o acesso a esse registro é possível apenas no modo privilegiado .

Depois que o coprocessador foi autorizado a funcionar, nossos testes para funções matemáticas começaram a passar. Mas quando ativei a otimização (-O2), a exceção de instrução indefinida mencionada anteriormente foi levantada. E surgiu na instrução vmov que foi chamada no código anteriormente, mas foi executada com sucesso (sem uma exceção). Por fim, encontrei no final da página a frase “As instruções que copiam constantes imediatas estão disponíveis no VFPv3” (ou seja, operações com constantes são suportadas a partir do VFPv3). E eu decidi verificar qual versão é lançada no meu emulador. A versão é gravada no registro FPSID . Da documentação, segue-se que o valor do registro deve ser 0x41011090. Isso corresponde a 1 no campo de arquitetura [19..16], ou seja, VFPv2. Na verdade, depois de fazer uma impressão na inicialização, consegui

  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 

Depois de ler atentamente que 'vfp' é o apelido 'vfpv2', configurei o sinalizador correto, ele funcionou. Voltando à página em que vi a combinação de sinalizadores -mcpu = arm926ej-s -mfpu = vfpv3-d16, observei que não tomei cuidado o suficiente, porque -mfloat-abi = soft aparece na lista de sinalizadores. Ou seja, não há suporte de hardware neste caso. Mais precisamente, -mfpu importa apenas se um valor diferente de 'soft' estiver definido como -mfloat-abi.

Montador


É hora de falar sobre montador. Afinal, eu precisava dar suporte ao tempo de execução e, por exemplo, o compilador, é claro, não sabe sobre a alternância de contexto.

Registros


Vamos começar com a descrição dos registros. O VFP permite executar operações com números de ponto flutuante de 32 bits (s0..s31) e 64 bits (d0..d15) .A correspondência entre esses registradores é mostrada na figura abaixo.



Q0-Q15 são registros de 128 bits de versões mais antigas para trabalhar com o SIMD, mais sobre eles posteriormente.

Sistema de comando


Obviamente, na maioria das vezes o trabalho com registros VFP deve ser fornecido ao compilador, mas pelo menos você precisa escrever a alternância de contexto manualmente. Se você já possui uma compreensão aproximada da sintaxe das instruções do assembler para trabalhar com registros de uso geral, não deve ser difícil lidar com novas instruções. Na maioria das vezes, o prefixo "v" é simplesmente adicionado.

 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} 

E assim por diante Uma lista completa de comandos pode ser encontrada no site do ARM .

E, é claro, não se esqueça da versão VFP para que não haja situações como a descrita acima.

Sinalizar -mfloat-abi 'softfp' e 'hard'


Voltar para -mfloat-abi. Se você ler a documentação , veremos:
'softfp' permite a geração de código usando instruções de ponto flutuante de hardware, mas ainda usa as convenções de chamada de flutuação suave. 'hard' permite a geração de instruções de ponto flutuante e usa convenções de chamada específicas da FPU.
Ou seja, estamos falando sobre passar argumentos para uma função. Mas pelo menos não estava muito claro para mim qual é a diferença entre convenções de chamada "flutuação suave" e "específica para FPU". Assumindo que o caso difícil usa registradores de ponto flutuante e o caso softfp usa registros inteiros, eu encontrei confirmação no wiki debian . E embora isso seja para coprocessadores NEON, mas isso não importa. Outro ponto interessante é que, com a opção softfp, o compilador pode, mas não é necessário usar o suporte de hardware:
“O compilador pode fazer escolhas inteligentes sobre quando e se gera instruções de FPU reais ou emuladas, dependendo do tipo de FPU escolhido (-mfpu =)”
Para maior clareza, decidi experimentar e fiquei muito surpreso, porque com a otimização -O0 desativada, a diferença era muito pequena e não se aplicava a locais onde o ponto flutuante era realmente usado. Supondo que o compilador apenas empurre tudo na pilha, em vez de usar registradores, ativei a otimização -O2 e fiquei novamente surpreso, porque com a otimização o compilador começou a usar registradores de ponto flutuante de hardware para as opções hard e sotffp, e a diferença é como e no caso de -0, era muito insignificante. Como resultado, expliquei isso pelo fato de o compilador resolver o problema associado ao fato de que, se você copiar dados entre registradores de ponto flutuante e números inteiros, o desempenho diminuirá significativamente. E o compilador, ao otimizar, começa a usar todos os recursos à sua disposição.

Quando perguntado sobre qual sinalizador usar 'softfp' ou 'hard', respondi por mim da seguinte maneira: sempre que já houver peças compiladas com o sinalizador 'softfp', você deve usar 'hard'. Se houver, você precisará usar 'softfp'.

Mudança de contexto


Como a Embox suporta multitarefa preemptiva, para funcionar corretamente em tempo de execução, naturalmente, era necessária uma implementação de alternância de contexto. Para isso, é necessário salvar os registros do coprocessador. Existem algumas nuances. Primeiro: verificou-se que os comandos de operação de pilha para pontos flutuantes (vstm / vldm) não suportam todos os modos . Segundo: essas operações não oferecem suporte ao trabalho com mais de dezesseis registros de 64 bits. Se você precisar carregar / salvar mais registros por vez, precisará usar duas instruções.

Vou dar mais uma pequena otimização. De fato, salvar e restaurar 256 bytes de registros VFP a cada vez não é necessário (os registros de uso geral ocupam apenas 64 bytes, portanto a diferença é significativa). A otimização óbvia executará essas operações somente se o processo usar esses registros em princípio.

Como já mencionei, quando o coprocessador VFP é desligado, uma tentativa de executar a instrução correspondente resultará em uma exceção "Instrução indefinida". No manipulador dessa exceção, é necessário verificar por que a exceção é causada e, se for uma questão de usar um coprocessador VPF, o processo será marcado como usando o coprocessador VFP.

Como resultado, o salvar / restaurar o contexto já gravado foi complementado com macros

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

Para verificar a correção da operação de alternância de contexto em condições de ponto flutuante, escrevemos um teste no qual multiplicamos em um segmento em um loop e dividimos em outro e depois comparamos os resultados.

 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]); } } 

O teste foi aprovado com êxito quando a otimização foi desativada; portanto, indicamos na descrição do teste que ele deve ser compilado com otimização, EMBOX_TEST_SUITE ("Teste de consistência de contexto da FPU. Deve ser compilado com -02"); embora saibamos que os testes não devem depender disso.

Co-processador NEON e SIMD


É hora de dizer por que adiei a história sobre imx6. O fato é que ele se baseia no núcleo do Cortex-A9 e contém o coprocessador NEON mais avançado (https://developer.arm.com/technologies/neon). NEON não é apenas VFPv3, mas também é um coprocessador SIMD. VFP e NEON usam os mesmos registros. O VFP usa registradores de 32 e 64 bits para operação, e o NEON usa registradores de 64 e 128 bits, estes últimos foram designados apenas como Q0-Q16. Além de valores inteiros e números de ponto flutuante, o NEON também é capaz de trabalhar com um anel polinomial do módulo 2 do 16º ou 8º graus.

O modo vfp para NEON quase não é diferente do coprocessador desmontado do vfp9-s. Obviamente, é melhor especificar as opções vfpv3 ou vfpv3-d32 para -mfpu para melhor otimização, pois ele possui 32 registros de 64 bits. E para ativar o coprocessador, você deve dar acesso aos coprocessadores c10 e c11. isso é feito usando comandos

 /* 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)); 

mas não há outras diferenças fundamentais.

Outra coisa, se você especificar -mfpu = neon, nesse caso, o compilador poderá usar as instruções SIMD.

Usando SIMD em C


Para << registrar >> os valores manualmente pelo registro, você pode incluir "arm_neon.h" e usar os tipos de dados correspondentes:
float32x4_t para quatro flutuações de 32 bits em um registro, uint8x8_t para oito números inteiros de 8 bits e assim por diante. Para acessar um valor único, nos referimos a ele como uma matriz, adição, multiplicação, atribuição, etc. como para variáveis ​​comuns, por exemplo:

 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]); 

Obviamente, é mais fácil usar a vetorização automática. Para vetorização automática, adicione o sinalizador -ftree-vectorize ao GCC.

 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]); } } 

O loop de adição gera o seguinte código:

 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 

Após realizar testes para código paralelo, descobrimos que a simples adição em um loop, desde que as variáveis ​​sejam independentes, fornece aceleração em até 7 vezes. Além disso, decidimos ver o quanto a paralelização afeta tarefas reais, pegamos o MESA3d com sua emulação de software e medimos o número de fps com sinalizadores diferentes, obtivemos um ganho de 2 quadros por segundo (15 vs. 13), ou seja, a aceleração é de cerca de 15 a 20% .

Vou dar outro exemplo de aceleração usando os comandos NEON , não os nossos, mas do ARM.

A cópia de memória é 50% mais rápida que o normal. Exemplos verdadeiros existem no assembler.

Ciclo de cópia normal:

 WordCopy LDR r3, [r1], #4 STR r3, [r0], #4 SUBS r2, r2, #4 BGE WordCopy 

loop com comandos e registros neon:

 NEONCopyPLD PLD [r1, #0xC0] VLDM r1!,{d0-d7} VSTM r0!,{d0-d7} SUBS r2,r2,#0x40 BGE NEONCopyPLD 

É claro que a cópia de 64 bytes é mais rápida que 4 bytes e essa cópia aumentará 10%, mas os 40% restantes parecem ser fornecidos pelo trabalho do coprocessador.

Cortex-m


Trabalhar com FPUs no Cortex-M não é muito diferente do descrito acima. Por exemplo, é assim que a macro acima parece salvar o contexto fpu-shny

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

Além disso, o comando vstmia usa apenas os registradores s0-s31 e os registradores de controle são acessados ​​de maneira diferente. Portanto, não entrarei em muitos detalhes, explicarei apenas o diff. Portanto, apoiamos a descoberta STM32F7 com o córtex-m7 , respectivamente, precisamos definir o sinalizador -mfpu = fpv5-sp-d16. Observe que nas versões móveis, é necessário olhar mais de perto a versão do coprocessador, pois o mesmo córtex-m pode ter opções diferentes. Portanto, se sua opção não for com precisão dupla, mas com single, talvez não haja registros D0-D16, como temos em stm32f4discovery , e é por isso que a variante com registros S0-S31 é usada. Para este controlador, usamos -mfpu = fpv4-sp-d16.

A principal diferença é o acesso aos registros de controle do controlador, eles estão localizados diretamente no espaço de endereço do núcleo principal e, para tipos diferentes, são diferentes córtex-m4 para córtex-m7 .

Conclusão


Nisto terminarei minha pequena história sobre ponto flutuante para ARM. Observo que os microcontroladores modernos são muito poderosos e adequados não apenas para controle, mas também para processar sinais ou vários tipos de informações multimídia. Para usar efetivamente todo esse poder, você precisa entender como ele funciona. Espero que este artigo tenha ajudado a entender isso um pouco melhor.

Source: https://habr.com/ru/post/pt418295/


All Articles