Ir para conteúdo

Comparação "injusta" entre AArch32 e i386


fredericopissarra

Posts Recomendados

Ei-lo:
--------%<-----------%<-----------------------------
Usei as siglas “oficiais” aqui. AArch32 é o modo de 32 bits do ARMv8 e IA-32 é o modo de 32 bits dos processadores da família Intel (costumo chamar esse modo de i386). Para a comparação, compilei com o GCC, para as duas plataformas, o seguinte código:

#include <stddef.h>
#include <stdint.h>
 
int64_t sum( int *ptr, size_t size )
{
  int64_t s;
 
  s = 0;
  while ( size-- )
    s += *ptr++;
 
  return s;
}

Para poupar tempo, fiz uma cross compilation, na minha estação de trabalho (Intel), usando a versão 7.4 do GCC para as duas plataformas:

$ arm-linux-gnueabi-gcc --version | head -1
arm-linux-gnueabi-gcc (Ubuntu/Linaro 7.4.0-1ubuntu1~18.04.1) 7.4.0

$ gcc --version | head -1
gcc (Ubuntu 7.4.0-1ubuntu1~18.04.1) 7.4.0

Abaixo, temos os códigos gerados com otimizações ligadas:

#-- Intel i386              @-- ARM Cortex-A53 AArch32
  .text                       .cpu cortex-a53
  .globl  sum                 .text
                              .global sum
                              .arm
                             
sum:                        sum:
  push  ebp                   cmp r1, #0
  push  edi                   mov r2, #0
  push  esi                   mov r3, #0
  push  ebx                   sub r1, r1, #1
  mov edi, [esp+24]           beq .exit
  mov ebp, [esp+20]         .loop:
  test  edi, edi              ldr ip, [r0], #4
  je  .exit                   sub r1, r1, #1
  xor esi, esi                adds  r2, r2, ip
  xor eax, eax                adc r3, r3, ip, asr #31
  xor edx, edx                cmn r1, #1
.loop:                        bne .loop
  mov ecx, [ebp+esi*4]      .exit:
  mov ebx, ecx                mov r0, r2
  sar ebx, 31                 mov r1, r3
  add eax, ecx                bx  lr
  adc edx, ebx
  add esi, 1
  cmp esi, edi
  jne .loop
  pop ebx
  pop esi
  pop edi
  pop ebp
  ret
.exit:
  pop ebx
  xor eax, eax
  xor edx, edx
  pop esi
  pop edi
  pop ebp
  ret

Note que:

  1. Usei a calling convention default em ambos os casos;
  2. Não fiz quaisquer otimizações adicionais, em ambos os casos.

O código para i386 é sempre pior porque a convenção de chamada usa, necessariamente, a pilha e temos menos registradores de uso geral para manipular que no ARM. Com o i386 temos apenas EAX, EBX, ECX, EDX, ESI, EDI e EBP, ou seja, 7 registradores, onde 4 deles precisam ser preservados entre chamadas (EBX, ESI, EDI e EBP), daí os pushes/pops. No caso do ARM temos 13 registradores de uso geral disponíveis para brincar, de R0 até R12 (R13 [SP] é o Stack Pointer, R14 [LR] é Link Register e R15 [PC] é o Program Counter — eles não devem ser futucados levianamente!) e o código gerado pelo GCC não precisou usar nenhum dos registradores que precisassem ser preservados (R4 até R11). O registrador IP é um apelido para R12 usado pelo assembly, que funciona como um Intra Procedure scratch register. Dentre outras regras, a convenção de chamada padrão para o AArch32 é usar de R0 até R4 como argumentos da função, de R4 até R11 como “variáveis locais” (daí a preservação necessária).

Note que, no caso da família Intel, o processador não tem alternativa senão afetar os flags depois de uma instrução lógica ou aritmética. Não é o caso com o ARM. A instrução ADDS tem esse ‘S’ ai adicionado para dizer que essa adição afeta os flags (no caso, precisamos do carry), mas ADC não afeta! Outro detalhe é que os modos de endereçamento permitem pré-incremento (ou decremento) e pós-incremento (ou decremento). No caso da instrução ldr ip, [r0], #4 o que ela instrui é a carga de IP com o conteúdo da memória apontada por R0, mas depois R0 é incrementado em 4 (o tamanho de um int). Ou seja, a sub-expressão *ptr++ é executada numa única instrução. Já no modo i386 precisamos de duas: mov ecx,[ebp+esi*4]/add esi,1.

Outro detalhe é que todas as instruções do ARM têm sempre exatamente 32 bits de tamanho. Em teoria não existe penalidades para decodificação de instruções complexas. Isso não é verdade na família Intel… Prefixos, extensões, endereçamentos complexos, tudo isso adiciona ciclos de clock ao processamento, bem como podem gerar instruções grandes (do ponto de vista da decodificação). Para compensar isso os processadores da família Intel mais recentes usam um monte de “macetes” para que o código execute o mais rápido possível, como, por exemplo, manter um buffer de instruções (a família Haswell mantém um buffer de 192 instruções) e a reordenação das instruções para melhorar o paralelismo… É nesse ponto que minha comparaçẽo dos códigos acima é “injusta” porque, embora possa parecer que para a família Intel o código seja mais extenso do que deveria, em comparação com o código ARM, na verdade muitas das “redundâncias” são compensadas pelo processador.

Isso não quer dizer que o código Intel é mais eficiente, neste caso. Note que existem vários acessos à memória (os PUSH/POP e a cópia dos argumentos à partir da pilha) que, é claro, sáo feitos apenas uma vez na entrada e outra na saída. Só que nada disso é necessário no ARM. Assim, um código para 32 bits para a plataforma Intel é, quase que certamente, menos eficiente (se não fossem os “macetes” e o clock mais elevado) do que o mesmo código gerado para o ARM AArch32. Agora, comparemos o mesmo código entre o x86-64 e AArch64 (para o mesmo processador ARM: O Cortex-A53):

  .text                           .arch armv8-a+crc
  .globl  sum                     .text
                                  .global sum
                               
sum:                            sum:
  test  rsi, rsi                  mov x4, x0
  je  .exit                       cbz x1, .exit
  xor edx, edx                    mov x2, 0
  xor eax, eax                    mov x0, 0
.loop:                          .loop:
  movsx rcx, dword [rdi+rdx*4]    ldrsw x3, [x4, x2, lsl 2]
  add rdx, 1                      add x2, x2, 1
  add rax, rcx                    cmp x2, x1
  cmp rdx, rsi                    add x0, x0, x3
  jne .loop                       bne .loop
  ret                             ret
.exit:                          .exit:
  xor eax, eax                    mov x0, 0
  ret                             ret

Reparou que o código é, essencialemnte, o mesmo? Isso porque x86-64 tem 15 registradores de uso geral disponíveis e a convenção de chamada (SysV ABI para x86-64, neste caso) os usa, assim como no ARM. Mesmo assim, se os clocks fosse identicos, provavelmente o código ARM seria alguns poucos ciclos mais rápido: No código x86-64 uma instrução TEST RSI,RSI é necessária para afetar os flags e fazer o salto se ZF=1 em seguida (JE .exit), mas isso sofrerá uma fusão no reordenador de instruções e se comportará exatamente como o CBZ X1, .exit do ARM. O problema é no loop: As instruções MOVSX RCX,DWORD [RDI+RDX*4], ADD RDX,1, ADD RAX,RCX e CMP RDX,RSI usam o prefixo REX, o que adicionará 1 ciclo extra a cada uma delas. Isso não acontece com o ARM porque a instrução tem sempre 32 bits de tamanho (exceto no modo Thumb, onde têm 16 bits de tamanho), então, não há penalidade na decodificação.

PS: Modo Thumb (dedão) é claramente uma piadinha com o modo ARM (braço). As documentações do ARM estão cheias dessas piadinhas: O Manual de Referencia da Architetura ARM, por exemplo, é chamado de ARM ARM (ARM Architecture Reference Manual). Existem 3 tipos de “processadores”, os da série A (Application), os da série R (Realtime) e os da série M (MicroController), ou seja, A,R e M…. e por ai vai…

-------------%<-------------%<---------------

Link para o comentário
Compartilhar em outros sites

Arquivado

Este tópico foi arquivado e está fechado para novas respostas.

  • Quem Está Navegando   0 membros estão online

    • Nenhum usuário registrado visualizando esta página.
×
×
  • Criar Novo...