Mais alguns detalhes sobre ponto flutuante

Tenho escrito sobre ponto flutuante aqui para alertar aos desavisados de que realizar cálculos com esses tipos não é a mesma coisa que lidar com números “reais”, na matemática tradicional. Realmente, espero que esses textos criem a consciência de que nem tudo é tão simples quanto parece.

Neste texto vou tentar esmiuçar alguns detalhes adicionais sobre a estrutura do tipo float, usado na linguagem C ou, mais precisamente, do tipo single precision, como especificado no padrão IEEE 754. A discussão, claro, aplica-se aos outros tipos (double precision e extended double precision, bem como às “novos” tipos descritos na revisão de 2008 da referida especificação).

Lembrando sobre a estrutura de um float:

O tipo float, ou single precision, tem 32 bits de tamanho e a seguinte estrutura:

Estrutura de um float

O valor decimal, codificado nessa estrutura, é obtido através da equação abaixo:

\displaystyle v=(-1)^s\cdot\left(1+f\right)\cdot2^e

Onde s corresponde ao bit de sinal (0 significa + e 1, -); o valor f é uma “fração”, ou seja, um valor entre 0 e 1 (0\leqslant f<1). Repare que ao valor de f adicionamos o valor 1 que não está presente na estrutura acima. Esse valor é implícito e segue a regra daquilo que é conhecido como notação científica, onde o primeiro algarismo significativo deve sempre maior que zero e ser o único algarismo “inteiro”. No caso de valores binários não temos alternativa a não ser usarmos o algarismo 1.

O componente e é o expoente do “fator de escala” que indica para que lado o “ponto” flutuará. Valores positivos fazem o ponto flutuar para a direita e negativos, para a esquerda. O expoente e merece alguma atenção, já que ele é codificado de forma “polarizada” (biased), onde o valor central, zero, com os 8 bits disponíveis num float, corresponde a 127 (ou 0b01111111, em binário). Daqui por diante usarei nomenclatura maiúscula para expressar o expoente “real” da escala de acordo com a equação E=e-127 e quando vir o expoente e, minúsculo, trata-se do valor armazenado na estrutura acima.

Porque chamamos os bits significativos de “fração”?

Era comum vermos, na literatura especializada, o fragmento (1+f) ser chamado de “mantissa”, mas essa nomenclatura está errada. Não porque o uso do termo seja mais comum com logaritmos, mas porque ela significa “a parte quebrada ou excedente”, ou seja, justamente a parte fracionária, excluindo o componente inteiro. Isso poderia ser aplicado apenas ao componente f, mas é preferível chamar (1+f) de bits significativos e o componente f de “fração”.

O termo “fração” é apropriado. Como o valor expresso na estrutura é binário, podemos simplesmente “deslocar a vírgula” 23 bits para a direita e obtermos um valor inteiro. Isso quer dizer que o valor expresso nos bits significativos é, de facto, um número inteiro que compõe uma fração somada a uma unidade:

v=(-1)^s\cdot\left(1 + \frac{n}{2^{23}}\right)\cdot2^E

O valor 2^{23} no denominador vem do fato de que f sempre será menor que 1 e essa é quantidade máxima de arranjos binários possíveis para esse campo, ou seja, a parte fracionária tem 23 bits de tamanho, cabendo valores entre 0 e 2^{23}-1.

Isso merece uma analogia: Quando escrevemos, em decimal, o valor 0,62 (por exemplo), automaticamente usamos a fração \frac{62}{100} já que temos 2 “casas após a vírgula”. Ou seja, com 2 “casas” as combinações dos 10 algarismos da base decimal possíveis variam de 00 até 99. Assim, com duas casas decimais, a fração \frac{n}{10^2} sempre será manor que 1.

Como a fração é obtida?

Graças ao MSB (Most significant Bit, ou “bit mais significativo”) implícito nos bits significativos, o valor inteiro 1.0 é expresso simplesmente como (1+0)\cdot2^0. Se queremos converter um valor n, decimal, para a base binária (num valor b) que contenha o MSB implícito, podemos fazer:

\displaystyle n=\frac{b}{2^{24}}\,\therefore\,b=n\cdot2^{24}

Note que eu disse que b contém o bit implícito e, por isso, queremos obter o valor correspontende a 24 bits, não apenas os 23 da fração!

Vou usar o programinha abaixo para mostrar a estrutura de um float. Isso nos auxiliará a corroborar a alegação acima.

/* shwflt.c */
#include <stdio.h>
#include <stdlib.h>
#include <errno.h>

/* Usada para decompor um float
   em seus componentes. */
union float_u {
  float v;
  struct {
    unsigned int f:23;
    unsigned int e:8;
    unsigned int s:1;
  };
};

/* Retorna string com os 23 
   bits em binário. */
static char *binfrac(unsigned int f)
{
  static char bits[24];
  char *p;
  int i;

  p = bits;
  i = 23;
  while (i--)
  {
    /* Isola o bit 22 */
    *p++ = (f & (1U << 22)) ? '1' : '0';
    f <<= 1;
  }

  /* fim da string. */
  *p = 0;

  return bits;
}

int main(int argc, char *argv[])
{
  union float_u flt;
  char sign;

  /* Precisamos de 1 argumento */
  if (!*++argv)
  {
    puts("Usage:\n"
         "  shwflt <value>");
    return EXIT_FAILURE;
  }

  errno = 0;
  flt.v = strtof(*argv, NULL);
  if (errno == ERANGE)
  {
    fprintf(stderr, 
            "ERROR: Invalid value: '%s'\n",
            *argv);
    return 1;
  }

  sign = flt.s ? '-' : '+';

  /* NaN ou Infinito? */
  if (flt.e == 255)
  {
    if (!flt.f)
      printf("%c\xe2\x88\x9e", sign); /* utf-8 */
    else
      printf("%cNaN\n", sign);

    return 0;
  }

  /* Valor normalizado? */
  if (flt.e)
  {
    printf("[Normal] %.8g -> "
           "s=%c, E=%d, f=%#x (0b1.%s)\n",
      flt.v,
      sign,
      flt.e-127,
      flt.f,
      binfrac(flt.f));
  }
  else
  {
    printf("[SubNormal] %.8g -> "
           "s=%c, E=-126, f=%#x (0b0.%s)\n",
      flt.v,
      sign,
      flt.f,
      binfrac(flt.f));
  }

  return EXIT_SUCCESS;
}

Tomemos o valor 1.0 como exemplo: Temos b=1.0\cdot2^{24} ou, em binário, 0b100000000000000000000000. Esse número binário tem exatamente 24 bits de tamanho e, então, não há necessidade de escalonar a fração (deslocar o “ponto”, lembra?):

./shwflt 1
[Normal] 1 -> s=+, E=0, f=0 (0b1.00000000000000000000000)

Note que O 24º bit é, como comentei antes, implícito e, portanto, não aparece no componente f. Ele é indicado aqui como “0b1.”.

Consideremos, agora, o valor 0.1: Temos 0.1\cdot2^{24} ou, em binário, 0b000110011001100110011001.10011001…, que tem apenas 21 bits na parte inteira (os 3 bits zero, superiores, estão à esquerda!). Esse valor, escalonado (normalizado) e limitado a 24 bits, acabará sendo 0b1.10011001100110011001100_1. O bit extra, mais à direita (depois do separador ‘_’), é chamado de bit de guarda, usado no arredondamento. Ele deve ser somado ao LSB (Less Significant Bit, ou “bit menos significativo”) do valor binário normalizado (é o equivalente a somar 0,5 a um valor com “uma casa decimal” para obter o valor inteiro mais próximo). Assim, o valor de f será 0b10011001100110011001101 ou, em hexadecimal, 0x4ccccd (note que descartei o MSB ‘1’).

$ ./shwflt 0.1
[Normal] 0.1 -> s=+, E=-4, f=0x4ccccd (0b1.10011001100110011001101)

Quanto ao fator de escala, é interessante notar que o valor real de 0.1 (decimal) em binário é 0b0.00011001…. Assim, temos que deslocar a vírgula em 4 posições para a esquerda fazendo E=-4, aplicado ao valor normalizado. Então, a estrutura do valor 0.1 (decimal) num float nos dá: v=(-1)^0\cdot\left(1+\frac{5033165}{8388608}\right)\cdot2^{-4}\approx0.1. O valor 533165 é 0x4ccccd, 8388608 é 2^23.

Outro exemplo: Vamos ver como fica o valor aproximado de π (aproximado para, 3.141593):

./shwflt 3.141593
[Normal] 3.141593 -> s=+, E=1, f=0x490fdc (0b1.10010010000111111011100)

De acordo com nosso esquema, n=3.141593\cdot2^{24}=52707184, ou seja:

0b11001001000011111101110000.01011

A parte inteira tem 26 bits e somente os últimos 24 podem ser normalizados (e arredondados), mas note, também que precisamos deslocar o ponto 24 bits para a esquerda para obter 0b11 (3) na parte inteira. Como a parte fracionária tem que ter 23 bits, isso significa que E=1 e obteremos 0b1.10010010000111111011100_00 antes do escalonamento! Retirando a parte inteira obtemos 23 bits, onde f=0x490fdc, exatamente como esperávamos.

Se usarmos esses valores de f e E na equação que nos dá o valor decimal expresso na estrutura do float, obteremos algo como 3,1415929794.

Minha trapaça e o que falta…

Como consegui obter a representação fracionária dos valores decimais em binário? Eu trapaceei… Fiz uso do Big number Calculator (bc):

$ bc
obase=2

0.1*(2^24)
110011001100110011001.1001

10*(2^24)
1010000000000000000000000000

3.141593*(2^24)
11001001000011111101110000.101100001010111101

Isso é uma “trapaça” no sentido de que estou usando um software que converte o valor fracionário corretamente para nós. Na prática, não deveria usar esse tipo de auxílio e te mostrar como os valores fracionários são obtidos através de operações elementares usando valores inteiros apenas (suponha que o processador não tenha uma unidade de ponto flutuante, como é o caso dos antigos 386).

Sinto dizer, mas existe outra trapaça ai… O valor binário obtido tem tamanho arbitrário e, na prática, temos apenas uma quantidade de bits limitada para trabalhar. Nos processadores Intel modernos temos registradores de até 64 bits de tamanho e, se usarmos AVX, podemos chegar até 256 bits. Lembre-se que o valor binário final pode ser escalonado até 2^{126}, ou seja, além dos 24 bits significativos, podemos ter até 127 bits adicionais, totalizando 151 bits (para valores normalizados)… E olha que estou falando apenas do tipo float… Com o tipo double, que tem 53 bits significativos e o expoente E pode chegar até 1023, o que nos dá um total de 1080 bits. Muito além até mesmo do AVX-512, que não está disponível em todos os processadores modernos.

Isso significa que aritmética de múltipla precisão não é usada nem mesmo pelo co-processador. As rotinas têm que levar em conta o tamanho dos registradores da arquitetura em questão e, ainda assim, serem o mais rápidas possíveis… Não discuti neste artigo como essas rotinas funcionam, mas o farei, em breve…

Anúncios