GCC versus clang

Vejam porque prefiro o GCC ao famoso clang: Performance e código menos inchado! Para mostrar isso eis a mais simples das rotinas: strlcpy() e usarei o GCC 4.9.2 e o clang 3.5:

$ gcc --version
gcc (Debian 4.9.2-10) 4.9.2
Copyright (C) 2014 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.

$ clang --version
Debian clang version 3.5.0-10 (tags/RELEASE_350/final) (based on LLVM 3.5.0)
Target: x86_64-pc-linux-gnu
Thread model: posix

Eis o código que será compilado pelos dois:

char *strlcpy(char *dest, char *src, size_t size)
{
  char *p = dest;
  while (*src && size--)
    *p++ = *src++;
  *p = '\0';
  return dest;
}

Eis os códigos equivalentes gerados pelos dois compiladores, ambos compilados com a chave “-O3” apenas:

;------ rotina gerada pelo gcc -------
  align 16
strlcpy:
  movzx ecx,byte [rsi]   ; ECX = *dest;

  mov   rax,rdi          ; RAX = dest
  mov   r8,rdi           ; R8 = dest

  test  cl,cl
  je    .strlcpy_exit

  test  rdx,rdx          ; size != 0? loop
  jne   .L2

  jmp   .strlcpy_exit    ;... senão sai.

  align 4
.loop:
  sub   rdx,1            ; size--;
  je    .strlcpy_exit    ; size == 0? sai!
.L2:  
  add   r8,1             ; dest++
  add   rsi,1            ; src++
  mov   [r8-1],cl        ; *(dest - 1) = cl
  movzx ecx,byte [rsi]   ; cl = *src;
  test  cl,cl            ; cl != 0? loop.
  jne   .loop
  
.strlcpy_exit:
  mov   byte [r8],0     ; *dest = '\0';
  ret

;------ rotina gerada pelo clang -------
  align 16
strlcpy:
  mov  r8b,[rsi]        ; R8B = *src;
  test r8b,r8b          
  mov  rcx,rdi          ; RCX = dest
  je   .strlcpy_exit    ; R8B == 0? sai
  test  rdx,rdx         ; size == 0? sai
  je   .strlcpy_exit

  mov   eax,1
  sub   rax,rdx         ; RAX = 1 - size;
  inc   rsi             ; src++;
  mov   rcx,rdi         ; RCX = dest (de novo?!)

  align 16
.loop:
  mov   [rcx],r8b       ; *dest = R8B
  inc   rcx             ; dest++;
  mov   r8b,[rsi]       ; R8B = *src;
  test  r8b,r8b         ; R8B == 0? sai.
  je    .strlcpy_exit
  inc   rsi             ; src++;
  test  eax,eax         
  lea   rax,[rax+1]     ; RAX = RAX+1
  jne   .loop           ; RAX != 0, loop

.strlcpy_exit:
  mov  byte [rcx],0     ; *dest = '\0';
  mov  rax,rdi          ; RAX = dest
  ret

Ok. Devo conceder que o clang é espertinho, especialmente com o tratamento do tamanho da string destino, mas note que o loop principal da rotina é mais lenta em comparação com a criada pelo GCC!

Ainda, o uso de ADD reg,1 ao invés de INC reg é uma dica da superioridade do GCC. A Intel recomenda isso porque INC, ao contrário de ADD, é uma daquelas instruções que leêm e depois escrevem e, com isso, a mudança parcial dos flags pode gastar um ciclo extra (INC não afeta CF! Se, por exemplo, houver overflow do valor sem sinal, então CF será 1, mas INC não o altera e, para isso, precisa manter uma cópia do antigo CF e alterá-lo de volta depois da operação aritmética).

Outra dica é que o GCC manteve o tamanho do loop em RDX intocado até a entrada no loop. Ele usa o valor passado para a função como contador. O clang, por outro lado, fez um esquema interessante: Ele faz RAX=-RDX+1 e vai incrementando RAX (que é negativo para RDX > 1) até que esse seja zero. Mas, isso quer dizer que RAX precisará ser recarregado com o ponteiro dest, no final do loop.

O compilador clang ainda deixou passar o fato dele já ter carregado RCX antes de entrar no loop e o recarrega uma segunda vez!!! Isso, em minha opinião, é uma séria indicação da má qualidade das rotinas de otimização do compilador ou, pelo menos, que clang não é tão “melhor” que o GCC quanto alguns querem crer… Dito isso, o GCC deixou passar uma ou duas coisinhas também:

  • Já que a compilação foi genérica, sem a especificação de arquitetura, então, por que diabos o GCC escolheu carregar CL, através da função MOVZX, alterando todos os 64 bits de RCX? Isso só acrescentará o prefixo 0x0F numa instruçãoq que é semelhante a mov cl,[rsi]. A performance de ambas as instruções seria a mesma (de mov cl,[rsi] e movzx ecx,byte [rsi]), mas a primeira tem 1 byte a menos, colocando ainda menos pressão no cache L1i;
  • Ao invés de saltar para o final da rotina caso RDX seja zero, o compilador resolveu fazer um salto condicional para o loop. Isso pode ser explicado graças ao alinhamento, mas um salto condicional para frente, deste jeito, faz com que a regra do algoritmo estático do branch prediction seja quebrada, causando penalidade na primeira iteração do loop. Isso é facilmente corrigível, mas o ponto é que o GCC perdeu essa oportunidade!

Outro ponto para o GCC é que ele criou rotina que não sofrerá com efeitos de cache miss, relação cache L1i. Dando uma olhada na imagem binária de ambas as rotinas:

strlcpy (GCC):
 0000 0fb60e48 89f84989 f884c974 244885d2  ...H..I....t$H..
 0010 750ceb1d 0f1f4000 4883ea01 74134983  u.....@.H...t.I.
 0020 c0014883 c6014188 48ff0fb6 0e84c975  ..H...A.H......u
 0030 e741c600 00c3                        .A....

strlcpy (clang):
 0000 448a0645 84c04889 f9743f48 85d24889  D..E..H..t?H..H.
 0010 f97437b8 01000000 4829d048 ffc64889  .t7.....H).H..H.
 0020 f9666666 6666662e 0f1f8400 00000000  .ffffff.........
 0030 44880148 ffc1448a 064584c0 740c48ff  D..H..D..E..t.H.
 0040 c64885c0 488d4001 75e6c601 004889f8  .H..H.@.u....H..
 0050 c3                                   .

Considerando o tamanho de uma linha de cache com 64 bytes, o bloco em vermelho (e roxo) na rotina gerada pelo clang encontra-se numa segunda linha do cache L1i. Se, por acaso, essa linha já não estiver presente, um cache miss ocorrerá. Obviamente isso não ocorrerá com o código gerado pelo GCC (a rotina inteira está numa única linha!).

Os bytes em verde (e roxo) correspondem ao loop de ambas rotinas e os bytes em preto a preparação para o loop. Os bytes em cinza correspondem ao espaço desperdiçados no alinhamento do loop… O GCC conseguiu fazer com que o alinhamento do início do loop não gastasse tantos bytes assim (apenas 4). Já o clang “gastou” 15 bytes (poderia ter gastado só 3!). Isso é estranho, porque mesmo que o compilador assumisse uma linha de cache de 32 bytes (arquiteturas “genéricas”, antigas) o início do loop estaria no final da segunda linha e haveria uma terceira. De qualquer maneira, se o processador tivesse uma linha de 32 bytes, o código do GCC poderia sofrer 1 cache miss na primeira iteração do loop, já o do clang, 2.

Pintei os bytes de roxo na listagem gerada pelo clang para mostrar que esse pedaço do loop está em outra linha de cache, de novo, considerando a linha com 64 bytes. As linhas em vermelho indicam essa segunda linha (roxo seria “verde” + “vermelho”).

Resumindo: O GCC tende a gerar código menor e mais rápido que o clang. Isso não significa que clang não possa gerar código melhor em algum caso (embora eu não conheça um!).

Anúncios