The Quake3’s Fast InvSqrt()

1.Introdução

O inverso da raiz quadrada é uma função presente em diversos problemas matemáticos, físicos e estatísticos. Aplicações algébricas de normalização, desvio padrão e até na indústria de videogames possuem freqüente utilização da simples expressão:

fig11__1

E foi na programação de um jogo que surgiu uma função que acabou por se tornar famosa entre os programadores por conseguir rodar até 4 vezes mais rápido que a implementação derivada da função sqrt(x), nativa da biblioteca do C.

A função que realizava o cálculo tão mais rapidamente com um erro associado de 1,75x10E-4 se apresentou muito eficiente na época e significou uma melhora considerável na execução do Quake – jogo que utilizava esta operação frenquentemente e para qual foi desenvolvida. O seu código é:

float InvSqrt(float x){
	float xhalf = 0.5f*x;
	int i = *(int*)&x;

	i = 0x5F3759DF – (i>>1);
	x = *(float*)&i;
	x = x*(1.5f – xhalf*x*x);

	return x;
}

Sua verdadeira autoria é desconhecida. Contudo, tudo indica que foi programada por John Carmack, na época programador do Quake 3, onde surgiu.

Para entender esta a genial “sacada” é necessário conhecer o algoritmo matemático usado e como ele foi optimizado graças ao conhecimento do padrão de ponto flutuante pelo autor.

2.O Método de Newton-Rapson

O algoritmo utilizado foi um método de aproximações numéricas já bastante conhecido em muitos projetos computacionais, conhecido como “Aproximação de Newton”, ou “Método de Newton-Rapson”.

Este é um método linear iterativo utilizado para encontrar raízes x de uma função f(x) a partir de uma estimativa inicial baseado na idéia de usar retas tangentes para substituir o gráfico y = f(x) próximo de onde f é zero.

Para tal, cria-se uma função G, chamada “função iteração” xnew = G( xold ) , a partir da função f(x) para se chegar à raiz de f(x). Sendo assim, a convergência deste método será tão rápida quanto for |G'(xold)|.

O método de Newton-Rapson modifica o método iterativo linear simples (Método do Ponto Fixo Modificado) fazendo com que |G'( xold )|->0. Assim, a fórmula iterativa fica:

fig11__2

Uma propriedade importante para a função InvSqrt é que o Método de Newton-Rapson converge quadraticamente. Uma interpretação gráfica do método pode ser vista na figura abaixo:

fig11__3

2.1.Newton-Rapson na InvSqrt

Queremos o retorno da função y=y(x) tal que:


fig11__4

contudo, queremos o valor de y, que é a inversa da raíz quadrada. Sendo assim, procuramos a função inversa tal que:


fig11__5

fig11__6

fig11__7

fig11__8

fig11__9

como sabemos que o Método de Newton-Rapson para encontrar raízes f(x) = 0, então poderíamos substituir a variável x pela y, que é a que queremos encontrar.


fig11__10

como


fig11__11

fig11__12

fig11__13

então


fig11__14

aplicando o Método de Newton, teremos que:

fig11__15

onde sabemos f(y),

fig11__14

logo

fig11__16

finalmente, a expressão para encontrar o valor de y é:

fig11__17

que pode ser reduzida para:

fig11__18

ou passando 0.5 para expressão dentro dos parentes,

fig11__19

lembrando que quanto maior o número de iterações, mais próximo da raiz será o valor do resultado.

3.I3E 754 de 1985

Para entender a maior “mágica” deste código, é necessário saber como os números de ponto flutuante são armazenados na memória e interpretados pelo processador.
Em ciências exatas é comum se ver a notação científica de base 10 para expressar valores muito grandes ou muito pequenos. Esta notação consiste em usar apenas um único dígito à esquerda do valor decimal. Por exemplo, um valor em notação científica na base 10 é:

0,8 x 10^-8

Da mesma forma, podemos expressar números em notação científica em base 2. Por exemplo:

1,0 x 2^7 (em base 2)

Contudo, como não se está trabalhando com a base 10, é conveniente renomear os valores após a vírgula de decimais para ponto binário.
A expressão “ponto flutuante” vêm porque a representação dos números em que o ponto binário não é fixo – por isso flutua. A linguagem em que o InvSqrt foi implementada, C, utiliza esta o nome “float” para estes números de ponto flutuantes.
Desta forma, internamente o computador armazena todos expoentes em base 2, algo como:

1,0 x 10^111 ( binário equivalente à 1,0 x 2^7 )

Observe que a representação de ponto flutuante implica em encontrar uma relação entre o tamanho da fração e o tamanho do expoente, pois o número de bits da palavra do processador é fixo. Assim, quando se aumenta o tamanho da fração, reduz-se o valor do expoente.
Logo, os números em ponto flutuante seguem o formato:

(-1)^s x F x 2^E

onde s é o sinal do número de ponto flutuante de maior relevância e que também é o bit que indicará o sinal do número (s=0 implica em número positivo, s=1, negativo).

F envolve o campo de fração e E são os bits de expoente. Com base nessa lógica, foi elaborado um padrão para pontos flutuantes chamado “padrão IEEE 754”, que é implementado em quase todos processadores fabricados desde 1985. A adoção deste padrão permitiu uma portabilidade muito maior entre plataformas e melhorou muito a qualidade numérica de operações aritméticas com valores reais.
Para entender o padrão, veja na tabela abaixo como se relacionam os bits na palavra de uma arquitetura de 32 bits:

fig11_20ieee754_1_float

Note que reservando 8 bits para o expoente temos que 7 bits são utilizados para o valor do expoente e 1 sinal é reservado para o sinal do expoente. Isso equivale em decimal à um range de 2×10^-38 a 2×10^38.
Seguindo a mesma idéia da notação de ponto flutuante de precisão simples (float), é implementada a precisão dupla (tipo “double”). Esta porém, utiliza 2 registradores de 32 bits em arquiteturas com esta palavra de processador, ou 1 registrador de 64 bits para a representação de ponto flutuante com precisão maior.
No caso da precisão dupla em processadores 32 bits, 1 registrador armazena 32 bits para a fração e outro registrador possui a seguinte divisão:

fig11_21ieee754_2_double

Ou seja, tanto em arquiteturas de 64 quanto de 32 bits são reservados 52 bits para fração (20 mesmo registrador + 32 de um segundo registrador em processadores 32 bits), 11 bits para expoente (1 de sinal + 10 de valor) e 1 bit para o sinal do número.
Logo, a precisão dupla permite um range de valores de 2×10^-308 a 2×10^308.
Outro ponto importante para compreensão do InvSqrt é saber que os projetistas do IEEE 754 construíram esta representação para que se pudesse ser facilmente processada utilizando os mesmos circuitos de comparação de inteiros – provavelmente já implementados nas arquiteturas da época. O motivo disso está relacionado com o melhor aproveitamento dos registradores (que não precisam ser dedicados à valores de ponto flutuante ou valores inteiros) e para permitir um teste rápido de comparação (instruções bne e beq aproveitadas para qualquer tipo de dado).

Assim, a representação do IEEE 754 pode ser processada com as mesmas comparações de inteiros para acelerar a ordenação dos números de ponto flutuante.
O IEEE 754 é sem dúvida um padrão muito consistente e eficiente e vêm sendo mantido por sua confiabilidade. Além das propriedades descritas aqui, ainda há algumas outras que vale a pena conhecer. Por exemplo, o padrão já prevê um símbolo padrão para operações que geram erros, utilizado por “baixo dos panos” no envio de sinal para o programa tratar uma exceção. Uma leitura altamente recomendada deste padrão está no livro “Computer Organization and Design”, do Patterson.

4.InvSqrt

Começando pela primeira linha do algoritmo, pode-se adiantar que xhalf equivale ao produto (0.5*x) da expressão obtida no ítem 2.1:

fig11__19

float xhalf = 0.5f*x;// equivalente a expressao acima

este valor é salvo em uma nova variável (xhalf) porque a variável x será reaproveitada para guardar o valor de raiz (chamada de y nas demonstrações). Como visto anteriormente, a variável x (das expressões) só aparece na última expressão (0.5*x).
Na linha seguinte:

int i = *(int*)&x;

temos um casting de ponteiros que é a alma deste código. Internamente, o que ocorre é uma mudança na significância dos bits.
Enquanto x é uma variável de ponto flutuante de 32 bits, com a significância para os bits do padrão 754 (1 bit de sinal, 8 para o expoente e 23 para a base), a variável i é uma variável inteira, onde todos os bits possuem a mesma significância. Todavia, como tanto o tipo de x quanto de i possuem o mesmo tamanho em palavra – 32 bits – é possível armazenar ambas em um mesmo espaço. Isso é o que faz esse código ser possível.
As operações com ponteiros utilizada aqui deve ser lida de trás pra frente. A ordem diz que:

– &x: passe o endereço do espaço de memória onde x ocupa (4 palavras de memoria, ou 32 bits), que suporta tipos de ponto flutuante.

– (int *): converta este espaço de memória para suportar tipos de dados inteiros simples (também 32 bits)

– *: retorne o valor armazenado lá como inteiro.

fig11_22

Na próxima linha,

i = 0x5f3759df – (i>>1);

têm-se a estimativa inicial para y[n] = y[0]. Note que a operação (i >> 1), deslocamento de bits à direita, equivale à divisão por 2. O uso desta operação é a justificativa para a conversão da representação de float para int.
Veja que realizando o deslocamento de bits em todo o vetor de bits, também se tira a raíz do expoente. Por exemplo, o deslocamento da representação por inteiro de um valor qualquer:

BIN(01100110101111100010110101010101) = DEC(3447478954)

apenas deslocando os bits à direita 1 vez, ( 01100110101111100010110101010101 >> 1) têm-se que:

BIN(00110011010111110001011010101010) = DEC(1723739477)

onde se vê claramente que 1723739477 = 3447478954/2.
Mas isso não tem nada de excepcional. Contudo, analisando levando em conta a reserva de bits para sinal, base e expoente, da norma IEEE 754 para essa mesma seqüência, vê-se no exemplo:

fig11_23

quando é aplicada o deslocamento de bits à direita, têm-se:

fig11_24

observe que o expoente é exatamente a metade inteira do valor antes do deslocamento dos bits. Da segunda linha das tabelas, no valor decimal, \((int) 102.5 == 102 == (int)205/2 \). Portanto, o motivo do autor do código de converter os bits de uma variável float para uma inteira e deslocar o bits é justamente para dividir o expoente por 2, que obviamente equivale à tirar a raiz. Em seguida, quando o autor reconverte o valor para ponto flutuante,

x = *(float*)&i;

ele simplesmente recoloca os bits na forma interpretada pelo processador como sendo um float do IEEE 754.

Sendo assim, após já tirar a raíz do valor de entrada, o método de Newton é aplicado na última linha antes do retorno da função, para convergir ao valor da raiz quadrada:

x = x * (1.5f – xhalf * x * x);
// xhalf = 0.5x e x = y, nas demonstracoes

conforme demonstrado, o Método de Newton para raiz quadrada inversa:

fig11__19

5.Conclusões

O código da InvSqrt quando foi desenvolvido, rodava supostamente 4 vezes mais rápido que utilizando (float) 1/sqrt(x), da biblioteca padrão e o erro relativo está por volta de 10^-4. O que tornava o código muito útil.
Com o avanço das otimizações dos compiladores e dos circuitos de multiplicação dos processadores, a função nativa consegue ser mais eficiente. Contudo, conhecer esta função, seu funcionamento e o algoritmo utilizado é engrandecedor para qualquer programador.
A importância desta função foi tamanha que muita discussão foi gerada em torno dela. Inclusive modificações, melhorias e até alguns artigos sobre ela.
Um exemplo disso foi a pesquisa de Chris Lomont (www.math.purdue.edu/~clomont) sobre o melhor valor a ser usado como y[0] no número mágico para que a primeira aproximação tenha convergência mais rápida. Segundo ele, a melhor constante para melhoria da performance seria 0x5f375a86, uma pequena diferença de 167 em relação ao original.

6.Referencias

  • Finney, Ross L. CÁLCULO DE GEORGE B. THOMAS JR. , volume 1. Tradução de Paulo Buschcov. Addison Wesley, 2002. Páginas 301-305. “O MÉTODO DE NEWTON”.
  • Chris Lomont, www.math.purdue.edu/~clomont, “FAST INVERT SQUARE ROOT”.
  • IEEE 754, grouper.ieee.org/groups/754
  • Patterson, David A. COMPUTER ORGANIZATION AND DESIGN. 3rd ed. Elsevier, 2005.