Desenvolvendo no modo i386 e usando ponto flutuante? Cuidado!

Eis um aviso e uma dica importante para você que pretende fazer rotinas que usem ponto flutuante… Não use o 387, use SSE, sempre!

O coprocessador matemático contido dentro do seu processador, além de ser uma peça de museu, tem alguns problemas interessantes… Um deles é o fato de que ele sempre realiza seus cálculos usando a máxima precisão possível, ou seja, o tipo estendido definido no padrão IEEE 754. Ou, no caso de linguagens como C, o tipo long double.

Não interessa se você está usando o tipo float (32 bits) ou double (64 bits). O tipo long double (80 bits) sempre é usado. O que uma instrução de carga da pilha do 387 faz é arredondar para que ele caiba na pilha, de float/double para long double. Depois disso, cada operação aritmética incorre no próprio arredondamento e, por fim, você tem que armazenar o valor contido na pilha para algum lugar da memória, ocorrendo ainda outro arredondamento, agora da conversão de long double para float/double.

O arredondamento que ocorre nas operações são normais e devem mesmo acontecer, mas os arredondamentos de carga e “descarga” não deveriam ocorrer, já que você não tem como saber quais são as otimizações que o compilador fará (ele pode decidir, em algum ponto, carregar e descarregar uma mesma variável várias vezes, por mais estranho que pareça!).

O programinha abaixo ilustra o problema:

/* Se estiver usando x86_64, compile com:
   $ gcc -O0 -mfpmath=387 -o dround387 dround.c
   $ gcc -O0 -o droundSSE dround.c

Se estiver usando i386, compile com:
   $ gcc -O0 -o dround387 dround.c
   $ gcc -O0 -mfpmath=sse -msse2 -o droundSSE dround.c

Eis os resultados:

   $ ./dround387
   c = 3.6893488147419103232e+19
   $ ./droundSSE
   c = 3.6893488147419111424e+19

   OBS: O resultado "correto" é o usando SSE!
 */
#include <stdio.h>

void main(void)
{
  double a = 1848874847.0;
  double b = 19954562207.0;
  double c;

  c = a * b;

  printf("c = %20.19e\n", c);
}

Notou que foi feita apenas uma multiplicação simples de dois valores grandes que não causam overflow no resultado? Reparou também que desabilitei as otimizações com a opção ‘-O0’? Fiz isso para garantir que o compilador não otimizaria o código e fizesse a conta, sem gerar código algum em ponto flutuante a não ser o que já está contido no interior da chamada a printf().

SSE não sofre deste problema porque as operações são feitas de acordo com a precisão desejadas. No caso, o compilador vai gerar código para uso de instruções MOVSD, para carregar XMM0 e XMM1, e uma MULSD. Se tivéssemos usado float ele criaria código que usaria MOVSS e MULSS… Não há “arredondamentos” nas cargas e descargas e, portanto, SSE é mais “preciso”.

Você pode estar pensando: “Ahhhh, então eu só vou usar long double, ora bolas!”… Má idéia. Você poderá não mais sofrer com os arredondamentos extras, mas agora sua rotina vai ficar bem mais lenta. Como eu disse, o 387 lida com “registradores” de 80 bits, ou seja, 10 bytes de tamanho. E os registradores do modo i386 só têm 32 bits. Para copiar um valor long double de uma variável para outra você precisará de 3 instruções MOV para isso! Além do mais, 10 bytes em apenas uma variável coloca mais pressão no cache L1D do que 4 ou 8 bytes, dos tipos float ou double, respectivamente.

O modo x86_64 usa, automaticamente, SSE2. Isso é sempre garantido que funcione já que SSE existe desde o Pentium 3 e quando a Intel resolveu adotar o modo x86_64 em seus processadores (que ela chamdou de EMT64), foi na época do Pentium 4.

Anúncios