O objectivo deste exercício foi criar um algoritmo para encontrar raízes quadradas inteiras usando apenas operações com números inteiros. Desenvolvi inicialmente este método em 1990 para o ZX Spectrum +3 que vinha equipado com um processador Zilog Z80 (compatível com o Intel APX 8080), processador esse que não tem sequer instruções assembly de multiplicação ou divisão!
O desenvolvimento deste documento iniciou-se em 1994, antes de ter fundado a Cynergi, mas não foi terminado até Julho de 2000. Enquanto terminava o documento, encontrei uma melhoria ao algoritmo (a pesquisa binária) que parece ser inovadora e se implementada em hardware, poderia provavelmente gerar uma raiz quadrada de virgula flutuante de 32 bits em 25 ciclos de relógio ou menos, o que é mais rápido do que a ultima geração de processadores Intel (o Itanium, em Agosto de 2000)! Simuladores de FPU (unidade de virgula flutuante, ou floating-point unit, em Inglês) incluídos em sistemas operativos também podem usar este algoritmo para implementar uma raiz quadrada rápida que seja apenas aproximadamente duas vezes mais lenta do que a versão em hardware (num i486).
Dada a sua simplicidade e rapidez, ambos os algoritmos podem também ser usados em
e quaisquer outros sistemas onde a quantidade de memória é pequena, uma FPU não esteja disponível e/ou onde gráficos 3D (que geralmente necessitam de cálculos de raiz quadrada) sejam necessários. No entanto, e uma vez que o algoritmo da pesquisa binária é mais rápido e barato do que as implementações actuais da raiz quadrada, poderá substitui-las em todas as suas implementações.
Dividi o documento em duas partes, mas deixei-o quase totalmente na mesma ordem em que as ideias foram desenvolvidas. Isto permite que se conheça a sequência de descobertas. Esta Parte I lida com a descoberta empírica de um algoritmo sequencial para encontrar a raiz quadrada, e a sua posterior prova matemática. A Parte II irá lidar com a variante "pesquisa binária" que melhora de forma drástica a velocidade de cálculo da raiz (apesar deste algoritmo sequencial ser o mais pequeno que irá encontrar, se tamanho é o que lhe interessa). Ambos os algoritmos têm a mesma precisão.
O documento completo consiste em:
Parte I
NOTAS: O endereço "oficial" para este documento é http://www.pedrofreire.com/sqrt. Por favor não crie um atalho directo para o endereço que surge no seu browser — este poderá ser alterado sem aviso prévio. Note ainda que a esta data (Agosto de 2000) não estou ciente da existência ou uso destes algoritmos (pelo menos da vertente "pesquisa binária"), embora não o possa confirmar. Por fim, uso neste documento o ponto para representar a virgula decimal de forma a evitar confusões já que sou obrigado a usá-lo nos trechos de código por definição das linguagens.
Comecemos pela raiz mais comum (a raiz quadrada ou sqrt(x), do Inglês "square root"). Podemos montar uma tabela de raízes quadradas (onde r é o resultado inteiro):
x | sqrt(x) | r | x | sqrt(x) | r | ||
---|---|---|---|---|---|---|---|
0 | 0.00 | 0 | 20 | 4.47 | 4 | ||
1 | 1.00 | 1 | 21 | 4.58 | 5 | ||
2 | 1.41 | 1 | … | ||||
3 | 1.73 | 2 | 30 | 5.48 | 5 | ||
4 | 2.00 | 2 | 31 | 5.57 | 6 | ||
5 | 2.24 | 2 | … | ||||
6 | 2.45 | 2 | 42 | 6.48 | 6 | ||
7 | 2.65 | 3 | 43 | 6.56 | 7 | ||
… | … | ||||||
12 | 3.46 | 3 | 56 | 7.48 | 7 | ||
13 | 3.61 | 4 | 57 | 7.55 | 8 | ||
… | … |
A partir desta tabela poderá reparar que para números sucessivos, os resultados repetem-se com um padrão, nomeadamente
r | no. de resultados iguais | r | no. de resultados iguais | ||
---|---|---|---|---|---|
0 | 1 | 5 | 30-21+1 = 10 | ||
1 | 2 | 6 | 42-31+1 = 12 | ||
2 | 4 | 7 | 56-43+1 = 14 | ||
3 | 12-7+1 = 6 | 8 | 16 | ||
4 | 20-13+1 = 8 | … | 2r + (1 if r=0) |
Com estes valores em mente, podemos agora facilmente criar o seguinte código em C
register unsigned int x, r; /* … */ x = (x+1) >> 1; /* init x */ for( r=0; x>r; x-=r++ );
que assume x sem sinal (unsigned). Ao terminar, r
tem a raiz inteira, e x
tem algum tipo de resto (este e outros
restos não serão explorados neste documento).
Experimentemos alguns exemplos para ver se funciona:
x | init x | Ciclo for |
r | ||||
---|---|---|---|---|---|---|---|
0 | 0 | 0 | 0 | ||||
1 | 1 | 1-0=1 | 1 | ||||
2 | 1 | 1-0=1 | 1 | ||||
3 | 2 | 2-0=2, 2-1=1 | 2 | ||||
4 | 2 | 2-0=2, 2-1=1 | 2 | ||||
5 | 3 | 3-0=3, 3-1=2 | 2 | ||||
6 | 3 | 3-0=3, 3-1=2 | 2 | ||||
7 | 4 | 4-0=4, 4-1=3, 3-2=1 | 3 | ||||
… | |||||||
12 | 6 | 6-0=6, 6-1=5, 5-2=3 | 3 | ||||
13 | 7 | 7-0=7, 7-1=6, 6-2=4, 4-3=1 | 4 | ||||
… | |||||||
15 | 8 | 8-0=8, 8-1=7, 7-2=5, 5-3=2 | 4 | ||||
… | |||||||
144 | 72 |
72-0, 72-1, 71-2, 69-3, 66-4, 62-5, 57-6, 51-7, 44-8, 36-9, 27-10, 17-11=6 |
12 |
O número máximo de iterações para
o ciclo for
para palavras de n bits é a
raiz do maior número que pode ser codificado nesse
número de bits, ou seja
`sqrt(2^n - 1) ~~ sqrt(2^n) = 2^(n/2)`
o que, para alguns valores de n, poderá provar que o algoritmo é mais eficiente do que uma conversão inteiro-para-real, seguida da raiz quadrada de virgula flutuante, seguida da conversão real-para-inteiro. Vamos descobrir se tal acontece.
Uma optimização em assembly Intel APX/IA do
ciclo poderia ser (assumindo AX
=x e BX
=r,
ambos inteiros sem sinal):
; … inc AX ; ciclos i486: 1 shr AX, 1 ; 2 mov BX, -1 ; 1 loop1: inc BX ; 1 sub AX, BX ; 1 ja loop1 ; 3/1
Ao terminar, BX
tem o resultado r, e AX
algum
tipo de resto. Os ponto-e-virgulas foram simplesmente usados
para validar os comentários.
O código correspondente usando aritmética de virgula flutuante seria algo como
ctrlword DW ? ; Ajustar arredondamento ao inteiro mais proximo fstcw ctrlword and ctrlword, 1111001111111111b fldcw ctrlword ; … fild WORD PTR [BX] ; ciclos i486: 13-16 fsqrt ; 83-87 fistp WORD PTR [BX] ; 29-34
assumindo que WORD PTR [BX]
tem x. O resultado é
devolvido em WORD PTR [BX]
(não temos restos em virgula
flutuante). Registos de 32 bits podem ser usados em vez dos seus pares
de 16 bits em ambos os trechos de código, sem os tornar mais
lentos.
Poderá haver uma discrepância entre o resultado devolvido por este código e o devolvido pelo algoritmo. Se um resultado tem uma parte decimal de precisamente .5, o algoritmo irá sempre arredondar o resultado para cima, enquanto a FPU (unidade de virgula flutuante, ou floating-point unit, em Inglês) do i486 irá arrendondá-lo para o número inteiro par mais próximo. Ambos os resultados são correctos, no entanto, e nenhum número inteiro tem uma raiz quadrada com uma parte decimal de .5.
Para comparar a velocidade dos dois métodos, primeiro mostramos que para o algoritmo (chamemo-lhe soft-sqrt), o número de ciclos é de
1+2+1+(1+1+3)(r+1)-2 = 7+5r
onde r é de novo a raiz calculada. Para o método usando a FPU (chamemo-lhe hard-sqrt), temos
Logo, a maior gama de raízes que pode ser calculada mais rapidamente pelo soft-sqrt é de
`7+5r_min = 125` `r_min = 23.6` `x_min = r_min^2 = 556.96` |
a |
`7+5r_max = 137` `r_max = 26` `x_max = r_max^2 = 676` |
Isto quer dizer que o soft-sqrt é mais rápido do que o hard-sqrt para valores de x entre 0 e 557~676. Cálculos similares para outros processadores APX/IA (x86) geram resultados semelhantes (a melhor gama sendo para o processador i286).
Se precisa de calcular com frequência raízes inteiras na gama acima (que tem n>9, i.e. aproximadamente números de 9 bits), então este algoritmo é-lhe extremamente útil — lembre-se, apenas os valores altos da gama são tão lentos quanto a solução por hardware! Melhor ainda, processadores anteriores ao i486 não têm uma FPU incorporada pelo que a raiz estará provavelmente a ser calculada por um algoritmo lento em software (lento porque tem de levar em conta a virgula flutuante).
A principal desvantagem deste algoritmo é que não tem a precisão da virgula flutuante (devolve o resultado arredondado ao inteiro mais próximo, mas as operações seguintes com este valor podem aumentar o erro).
O quadrado de um número r é, naturalmente,
`r^2`
e o quadrado do seu sucessor (número inteiro seguinte) é
`(r+1)^2 = r^2 + 2r + 1`
isto é, está deslocado 2r+1 do r² original. Isto indica-nos a que distância (em termos de x, que é = r²) estará um novo resultado r+1.
Contudo, os valores de r que se alimenta a esta fórmula devem ser escolhidos com cuidado. A fórmula opera com números reais e o meu algoritmo com números inteiros. O meu algoritmo foi desenhado para devolver o resultado da raiz quadrada arredondado para o inteiro mais próximo, o que significa que a alteração de r = 0 para r = 1 por exemplo, irá ocorrer em números reais quando r = 0.5. Isto significa que devemos aplicar 0.5 à fórmula para descobrir quantos resultados de r = 1 existem, já que todos os r = 0.5 a r = 1.49999… serão arredondados para o inteiro 1, e 0.5 é o valor mais baixo neste intervalo. Você quer descobrir a que distância é que r = 0.5 se torna em r = 0.5+1.
Portanto em vez de aplicar
r = 0, 1, 2, 3, 4, 5, …
deveria na realidade aplicar
r = 0, 0.5, 1.5, 2.5, 3.5, 4.5, …
uma vez que 0 é o menor número (maior ou igual a 0) que pode ser arredondado a 0, 0.5 a 1, 1.5 a 2 e assim por diante. A fórmula acima gera assim
r | 2r+1 | x | sqrt(x) | ||||
---|---|---|---|---|---|---|---|
0 | 1 | 0 | 0 | ||||
0.5 | 2 | 1..2 | 1 | ||||
1.5 | 4 | 3..6 | 2 | ||||
2.5 | 6 | 7..12 | 3 | ||||
3.5 | 8 | 13..20 | 4 | ||||
4.5 | 10 | 21..30 | 5 | ||||
… |
o que é obviamente a mesma sucessão de inteiros que foi exibida nos exemplos iniciais. No entanto, se ainda tem dúvidas, então poderá reparar que
2×0+1 = 1 |
para a excepção, 0 (o que nos dá quantos valores de x devolvem r = 0) |
|
2×0.5+1 = 2 |
para a primeira distância regular (o que nos dá quantos valores de x devolvem r = 1) |
|
(2(r+1)+1)-(2r+1) = 2r+2+1-2r-1 = 2 |
para a variação da distância (o que nos dá quantos valores de x devolvem r+1, relativamente a quantos valores de x devolvem r) |
como tínhamos assumido.
Q.E.D.
Seguindo a mesma linha de raciocínio de acima, podemos ainda resolver raízes de qualquer índice positivo e inteiro (chamemo-lhe i). Para tal, vamos fazer uso do binómio de Newton
`(r+1)^i = sum_(k=0)^i (i!r^(i-k)) / (k!(i-k)!)`
que se reduz a
`(r+1)^2 = (2!r^2)/(0!*2!) + (2!r^1)/(1!*1!) + (2!r^0)/(2!*0!) = r^2 + 2r + 1`
para o caso particular da raiz quadrada (i=2) (e é igual à fórmula a que chegámos acima, verificando ambas). Uma vez que quando o índice do somatório k=0 é
`(i!r^(i-0)) / (0!(i-0)!) = r^i`
o deslocamento (ou distância) pode ser descrito genericamente como
`(r+1)^i - r^i = sum_(k=1)^i (i!r^(i-k)) / (k!(i-k)!)`
e a variação da distância, como
`(((r+1)+1)^i - (r+1)^i) - ((r+1)^i - r^i) = (r+2)^i - 2(r+1)^i + r^i`
ou
`sum_(k=1)^i (i!(r+1)^(i-k)) / (k!(i-k)!) - sum_(k=1)^i (i!r^(i-k)) / (k!(i-k)!) = sum_(k=1)^(i-1) (i!((r+1)^(i-k)-r^(i-k))) / (k!(i-k)!)`
onde o índice superior do somatório é agora i-1 porque quando k=i,
`(i!((r+1)^(i-i)-r^(i-i))) / (i!(i-i)!) = 0`
Se aplicar i=2 a qualquer uma destas fórmulas, elas irão devolver a constante 2, verificando-as e aos resultados já calculados.
Agora deve compreender porque é que usei o binómio de Newton: apesar da primeira destas fórmulas não o mostrar, a segunda demonstra claramente que a variação da distância nunca é de um grau superior a i-2. Porquê i-2 e não i-1? Bom, porque se tomar
`(r+1)^(i-k) - r^(i-k)`
da fórmula com o somatório, verá que esta se assemelha à fórmula do deslocamento ou distância, e a sua resolução mostra que se baixa o resultado por mais um grau.
Como o algoritmo explicado acima necessita da variação da distância para funcionar, altos expoentes (ou graus) implicam um algoritmo lento em altos valores de i. Mas podemos criar código genérico em C para a raiz de qualquer índice:
register int x, s; /* use double se necessario */ register int r; /* … */ r = 0; if( x > 0 ) { s = FD(0.5); for( r=1; (x-=s)>0; r++ ) s += FVD(R-0.5); }
Este código só funciona com números positivos: se
quiser que funcione com números negativos simplesmente inverta
o sinal de r
quando x
for negativo à entrada, e
use abs(x)
no interior do código. A operação
x-=s
anterior à comparação com 0
é a razão de x
não ser unsigned
.
s
e r
não são unsigned
para evitar
conversões desnecessárias.
Deve substituir FD(0.5)
com o resultado da fórmula do
deslocamento ou distância quando r=0.5, e FVD(R-0.5)
pela fórmula da variação da distância,
substituindo r por r-0.5. Deveria também tornar as
variáveis x
e s
double
(reais) em vez de
int
(inteiras) se quaisquer destas substituições
devolve números não-inteiros. Isto irá desacelerar
o código, mas vai ter de o optimizar à mão, de
qualquer forma. x
deverá ter o valor para o qual se quer
calcular a raiz quadrada, ao entrar no código, e r
irá ter a raiz calculada à saída.
Por exemplo, para a raiz quadrada, deveria fazer FD(0.5)
= 2 e
FVD(R-0.5)
= 2 uma vez que é constante. x
e
s
mantêm-se int
.
Qual é a rapidez deste código, em comparação com a correspondente operação de hardware? Bem, isso depende de quatro factores:
double
s em vez de int
s;
O primeiro factor diz-lhe se todo este trabalho vale de facto a pena.
Na maioria dos CPUs, cálculos de virgula flutuante levam dezenas
(se não centenas) de ciclos de relógio a completar, e a
única raiz que fazem é a quadrada (i=2). Outras
raízes necessitam de ser calculadas usando logaritmos o que torna essas
operações ainda mais lentas e este algoritmo ainda mais
rápido (comparativamente). Por outro lado, para valores altos
de i, o código do ciclo interno deste algoritmo abrange
mais números (em termos de x
— isto quer dizer que s
cresce mais rapidamente), o que quer dizer que o código chega
mais rapidamente a uma solução. Mas se o seu CPU consegue
executar uma raiz de forma muito rápida, todo o trabalho aqui
descrito é inútil — use-a!
O segundo factor (usar double
em vez de int
) pode ser
evitado se souber suficiente matemática e souber como optimizar
código. Por exemplo, um valor de 3.25 para FD(0.5)
pode
ser transformado em 3, e um novo contador inicializado para subtrair
1 de x
a cada 4 iterações. Dependendo da rapidez
de execução de adições (e
subtracções) em virgula flutuante (reais) no seu CPU,
isto poderá ou não valer a pena.
O terceiro factor determina até que ponto podemos subir em
termos de i antes do algoritmo deixar de ser eficiente. Alguns
CPUs executam uma multiplicação inteira em um ciclo de
relógio. Para esses, de facto não vai importar muito
o grau de FVD(R-0.5)
já que apenas precisará
de fazer uma sequência de multiplicações, e essas
são baratas o suficiente (em termos de velocidade) nesses CPUs.
Finalmente, o ultimo factor está inteiramente dependente de si.
Para i=2, por exemplo, o código acima torna-se no
código que exibi ao início deste documento — sem
if
s e com um ciclo muito apertado. Só a
experiência o pode ajudar a este ponto. — não é o
objectivo deste documento dar-lhe uma série de algoritmos
optimizados para vários valores de
i, especialmente uma vez que tal optimização
seria feita em assembly e variantes dessa linguagem abundam.
Irei no entanto focar-nos na raiz cúbica para o preparar para tal
análise.
Como exercício e exemplo, vamos criar um algoritmo para calcular a raiz cúbica (i=3). Vamos aplicar o valor de i à fórmula do deslocamento ou distância:
`sum_(k=1)^3 (3!r^(3-k)) / (k!(3-k)!) = (3!r^2)/(1!*2!) + (3!r^1)/(2!*1!) + (3!r^0)/(3!*0!) = 3r^2 + 3r + 1`
Quando faz r=0.5, obtém o valor 3.25 desta fórmula.
Isto quer dizer que FD(0.5)
= 3.25. Agora fazemos o mesmo
à fórmula da variação da
distância:
`sum_(k=1)^2 (3!((r+1)^(3-k)-r^(3-k))) / (k!(3-k)!) = (3!((r+1)^2-r^2))/(1!*2!) + (3!((r+1)^1-r^1))/(2!*1!) = 6r + 6`
Aqui, quando faz r=r-0.5, obtém 6(r-0.5)+6 = 6r+3.
Isto quer dizer que FVD(R-0.5)
= 6r+3. Isto dá-nos o
algoritmo genérico
register double x, s; /* double *e'* necessario */ register int r; /* … */ r = 0; if( x > 0 ) { s = 3.25; for( r=1; (x-=s)>0; r++ ) s += 6*r+3; }
Mais uma vez, este código só funciona com números
positivos: se quiser que funcione com números negativos simplesmente
inverta o sinal de r
quando x
for negativo à entrada,
e use abs(x)
no interior do código. Tivemos de usar x
e s
como double
s porque apesar de FVD(R-0.5)
nunca
devolver um número não-inteiro, o valor inicial FD(0.5)
não é inteiro. Portanto, o nosso primeiro passo em optimizar
este código é livramo-nos dos valores em double
,
tornando todo o ciclo inteiro.
Note que os passos seguintes só se aplicam a esta raiz em particular,
e podem não se aplicar a outras. Para aqueles não
familiarizados com a linguagem C, note-se ainda que x-=s
significa x=x-s, o que é uma expressão
válida devolvendo o resultado da subtracção.
A parte decimal de .25 de s
nunca se altera: s
é
incrementado por 6r+3, o que sempre devolve inteiros se alimentado
com valores inteiros de r
(o que é). A parte decimal
presente no valor inicial de s
tem no entanto um impacto em cada
iteração de x
, no entanto.
Uma vez que .25 = ¼, torna-se 1 em cada 4 iterações
do ciclo. Isto significa que se deveria subtrair 1 de x
quando
a sua parte decimal for .00, antes da subtracção de
s
. Porquê antes da subtracção? Bem, porque
precisaríamos de verificar se este decremento faz x
<0, e
isto nunca acontece nesse ponto, poupando-nos outra
comparação.
A parte decimal de x
é .00 ao início da
primeira iteração do ciclo e ao início de
cada 4 iterações após essa. Vamos ver um
exemplo que começa em x
=20 e s
=3.25.
iteração | após x-=s |
int(x-=s ) |
int(x )-int(x-=s ) |
||
---|---|---|---|---|---|
1 | 16.75 | 16 | 4 | ||
2 | 13.50 | 13 | 3 | ||
3 | 10.25 | 10 | 3 | ||
4 | 7.00 | 7 | 3 | ||
5 | 3.75 | 3 | 4 | ||
6 | 0.5 | 0 | 3 | ||
… |
Portanto você decrementa x
antes da primeira
iteração do ciclo, e volta a fazê-lo antes
de x-=s
quando os dois bits menos significativos
de r
são iguais a um certo valor. Isto elimina a
necessidade de um contador — afinal de contas, r
comporta-se como um contador, e os dois bits menos significativos
de um contador repetem-se a cada 4 contagens, automaticamente.
Mas que valor escolher?
Bem, o valor na coluna iteração na tabela
acima é o mesmo que r
dentro do ciclo (r
é
incrementado ao final do ciclo, após tudo o resto). Aqui
poderá ver que precisa de decrementar x
antes da
iteração 1 e antes da iteração 5.
Isto significa iterações 0 e 4, ou quando os 2 bits
menos significativos de r
=00b.
Isto ajuda a manter sincronizados os valores de s
e x
.
Comparar ambos (na subtracção x-=s
)
é agora trivial excepto quando são iguais. Ao
contrário do código com double
, não
podemos agora assumir que x-=s
igual a 0 seja
um sinal para terminar o ciclo, porque mesmo que as suas partes
inteiras sejam iguais, as suas partes decimais podem não o
ser, pelo que precisamos de uma forma de descobrir qual era a
parte decimal de x
logo após a subtracção.
Para tal, vamos analisar melhor a iteração do ciclo
nesse ponto:
2 bits menos significativos de r |
x -int(x ) |
>0? | ||
---|---|---|---|---|
01b | .75 | sim | ||
10b | .50 | sim | ||
11b | .25 | sim | ||
00b | .00 | não |
Isto é válido para as primeiras 4 iterações
do ciclo, e para cada 4 após essas. Tal como esta tabela mostra,
só quando os 2 bits menos significativos de r
são
00b é que o ciclo terminaria na realidade, caso contrário
ocorreria mais uma iteração. Portanto tudo o que o
código tem de fazer nestas situações (mais uma
iteração) é incrementar r
antes de terminar.
Vamos ver uma nova versão do código que leva tudo isto em atenção:
register int x, s; register int r; /* … */ r = 0; if( x > 0 ) { x--; s = 3; for( r=1; (x-=s)>=0; r++ ) { if( x==0 ) { if( (r & 3) != 0 ) r++; break; } s += 6*r + 3; if( (r & 3) == 0 ) /* 3 == 11b */ x--; } }
Agora todas as operações são do tipo int
,
mas o código ainda só funciona com números
positivos. Existe uma comparação dupla de x
com 0,
mas isso pode ser facilmente corrigido em assembly. O que podemos
ainda melhorar é a multiplicação: esta pode ser
removida, tornando o código mais rápido. Isto pode ser
feito com um contador que se inicializa a 6 e é incrementado por
6 a cada iteração. Irá então conter 6r
já que r
é um contador que se inicializa a 1 e
é incrementado por 1 a cada iteração. Isto substitui
uma multiplicação por uma adição, uma
mudança muito bem-vinda.
Poderia também argumentar-se que o contador para r
já
não seria necessário uma vez que podemos determinar r
dividindo o contador da multiplicação por 6 ao final do
ciclo. Isto é verdade e ainda poderia testar os bits 1 e 2 deste
contador por 00b em vez de testar os bits 0 e 1 r
pelo mesmo valor
(não irei demonstrar isto). Mas a operação de
divisão é muito cara (24 a 40 ciclos de relógio)
versus aquilo que poupa (1 ciclo de relógio por
iteração), se quiser usar esta "soft-raiz-cubica" em vez de
uma "hard-raiz-cubica". Em alguns CPUs (e de forma mais geral, se
não tiver uma FPU em hardware), isto poderá compensar, no
entanto.
Uma vez que o código C para a ultima alteração
é trivial, vamos passar directamente a uma versão deste
algoritmo em assembly (assumindo AX
=x
e
BX
=r
, ambos inteiros sem sinal):
; … mov BX, 0 ; BX = r ciclos i486: 1 sub AX, 1 ; 1 jb done ; 3/1 mov CX, 3 ; CX = s 1 mov DX, 0 ; DX = contador da multiplicacao 1 loop1: inc BX ; 1 sub AX, CX ; 1 jb done ; 3/1 je equal ; 3/1 add DX, 6 ; 1 add CX, DX ; 1 add CX, 3 ; 1 test BX, 11b ; 1 jnz loop1 ; 3/1 dec AX ; 1 jmp loop1 ; 3 equal: test BX, 11b ; 1 jz done ; 3/1 inc BX ; 1 done:
À saída, BX
tem o resultado, r. Este
código está optimizado para velocidade num i486, e
não para tamanho. Por outro lado, as operações
test
não estão disponíveis num processador
anterior ao i386, o que quereria dizer que teria de encontrar uma
alternativa às mesmas usando uma cópia temporária
de BX
e um and
.
Agora vamos escrever o código de virgula flutuante para o mesmo processador. A família de processadores Intel APX/IA-16/32 (i.e., o x86 até ao Pentium-III) não tem uma operação hardware para a raiz de qualquer índice. Poderíamos então usar
`root(i) x = x^(1/i)`
mas eles não têm sequer uma operação para elevar qualquer base a qualquer expoente! Eles têm no entanto logaritmos selectos e bases selectas elevadas a expoentes pelo que pode usar a regra
`x = b^(log_(b) x)`
para construir a fórmula
`x^(1/i) = b^(log_(b) x^(1/i))`
`x^(1/i) = b^(1/i log_(b) x)`
que devolve a raiz de índice i de x, dada qualquer base real, b.
Computadores trabalham na base 2, pelo que faz sentido que logaritmos e expoentes que eles calculam sejam na base 2. Isto é verdade para o i486 (pelo que fazemos b=2). O i486 tem as seguintes operações relevantes:
`x*2^y,` | `|y| in NN_(0)` |
`2^x - 1,` | `-1 <= x <= 1` |
`y * log_(2) x,` | `x in RR_(0)^(+)` |
`y * log_(2) (x+1),` | `0 <= |x| < (2-sqrt(2))/2` |
Cada linha é uma operação do CPU: esta operação executa mais do que uma operação matemática de cada vez, o que pode ou não ajudar-nos. Algumas são limitadas na gama de números aceites (as variáveis que não são exibidas nas limitações, não são limitadas). Mas algo é óbvio — não existe operação para elevar 2 (ou outra base qualquer) a um expoente arbitrário, e nós precisamos disso! Felizmente você pode usar
`b^m * b^n = b^(m+n)`
para resolver esse problema usando as duas primeiras operações exibidas acima. Para tal faça m=int(z) (sendo z o expoente a que quer elevar 2), e n=z-int(z). Você atribui estes valores aos expoentes de cada uma das primeiras duas operações exibidas acima, respectivamente, e é tudo! Uma vez que int(z) é sempre um inteiro e z-int(z) está sempre entre -1 e 1 (exclusivamente), independentemente do método de arredondamento, este é o passo final para traduzir a nossa raiz para:
`z = 1/i * log_(2) x`
`r = ((2^(z-"int"(z))-1)+1) * 2^("int"(z))`
Vamos usar o arredondamento para o inteiro mais próximo no código, já que precisamos desse tipo de arredondamento para o resultado final, e não queremos perder tempo a trocar de arredondamento a meio de um cálculo. Isto quer dizer que z-int(z) estará na realidade entre -0.5 e 0.5 (inclusivamente). O código final é então:
ctrlword DW ? inv_i REAL8 1/i ; substitua i ao "assemblar"/montar one REAL8 1 ; Ajustar arredondamento ao inteiro mais proximo fstcw ctrlword and ctrlword, 1111001111111111b fldcw ctrlword ; … fld inv_i ; ciclos i486: 3 fild WORD PTR [BX] ; 13- 16 fyl2x ; ST(1) = (1/i) * log2 [BX] 196-329 fld ST ; dup ST: ST(1) now = ST 4 frndint ; ST = int(ST) 21- 30 fxch ; ST <-> ST(1) 4 fsub ST, ST(1) ; ST = ST - ST(1) 8- 20 (*) f2xm1 ; ST = 2^ST - 1 140-279 fadd one ; ST++ 8- 20 fscale ; ST = ST * 2^ST(1) 30- 32 fistp WORD PTR [BX] ; 29- 34 ffree ST ; (veja a proxima linha) 3 fincstp ; "pop" do velho ST(1) 3 ; ; (*) neste ponto (apos o fsub): ; se z = (1/i) * log2 [BX], entao ; ST(1) = int(z) ; ST = z - int(z) ; ST esta' entre [-0.5 .. 0.5]
A operação f2xm1
só opera no intervalo
[0 .. 0.5] numa FPU 8087. Neste processador você precisa
de verificar se ST
era negativo. Se sim, então
precisa de alimentar o seu valor absoluto a f2xm1
e
encontrar o inverso do resultado. As ultimas duas
operações são necessárias para
remover da pilha um valor temporário que não
é removido pela ultima operação, fscale
.
Se ignora os avisos que a FPU lhe pode dar sobre a pilha estar
cheia, pode ignorar as operações e poupar algum
tempo.
A parte "divertida" deste código é que na realidade
eleva o valor inteiro guardado em WORD PTR [BX]
ao expoente
real guardado em inv_i
. Se guardasse nesta ultima
localização o valor 8.25, por exemplo, este ainda
seria o código optimizado para elevar um inteiro em
WORD PTR [BX]
a esse expoente. Para calcular uma raiz de
índice i, terá de guardar o inverso de i
(tal como exibido) nessa localização, antes da
primeira execução do código. Isto pode ser
feito pelo próprio assembler. Isto também
significa que este código se aplica a toda as outras
raízes que não a raiz quadrada.
Vamos agora comparar a velocidade do algoritmo versus este código baseado em hardware.
Começamos por mostrar que para a soft-raiz-cubica, o número de ciclos de relógio é de 5 se r=0, ou
5×1+(8×1+3)(r-1)+(-2+1+3)(¼)(r-1)+5 = 11.5r - 1.5
se r>0, onde r é de novo a raiz calculada. O número de ciclos de relógio das ultimas 3 operações (entre as etiquetas — labels — "igual" e "feito"), que ocorre a cada ¼ (25%) dos possíveis valores finais de r, ou cada
`(1/4 * sqrt(2^n))/(2^n) = 2^(-n/2-2)`
cálculos aleatórios da raiz cúbica para números com n bits, é negligenciável e portanto não adicionado a esta fórmula (e.g.: para n=16 você tem essas operações a serem executadas menos de 0.1% vezes, e o valor a ser adicionado a esta fórmula seria inferior a 0.004). Para a hard-raiz-cubica, você tem
Assim, a maior gama de raízes que pode ser calculada mais rapidamente pela soft-raiz-cubica é de
`11.5*r_min - 1.5 = 462` `r_min ~~ 40.3` `x_min = r_min^2 = 1624.09` |
a |
`11.5*r_max - 1.5 = 777` `r_max ~~ 67.7` `x_max = r_max^2 = 4583.29` |
Isto significa que a soft-raiz-cubica é mais rápida do que a hard-raiz-cubica para valores de x entre 0 e 1624~4583. Cálculos similares para outros processadores APX/IA (x86) geram resultados semelhantes (a melhor gama sendo para o processador i286).
Se precisa de calcular com frequência raízes inteiras na gama acima (que tem n>10, i.e. aproximadamente números de 10 a 13 bits), então este algoritmo interessa-lhe. Ele é muito mais apelativo do que o soft-sqrt era para raízes quadradas!
Este trabalho ainda pode ser estendido em pelo menos duas direcções: precisão e velocidade.
Em termos de precisão, você pode fazer os cálculos necessários para de alguma forma fazer uso dos valores devolvidos (para além de r) como restos, de forma a fazer cálculos mais precisos. A aplicação disto é limitada, no entanto.
Também pode "afinar" o algoritmo de forma a que quando a parte decimal do resultado real r for .5, ele arredonde o resultado ao número inteiro par mais próximo e não sempre para cima como faz agora. Tal como já foi dito, raízes quadradas de números inteiros nunca têm partes decimais de precisamente .5, pelo que isto não seria mais do que um exercício para soft-sqrt. Para outras raízes, tal precisão é na minha opinião inútil já que estamos a falar de números inteiros afinal de contas, e a precisão já foi pela janela fora, mas tornaria o algoritmo talvez um pouco mais preciso.
Em termos de velocidade, poderia sempre tentar usar uma pesquisa binária (o tipo de pesquisa que usa quando está à procura de uma palavra num dicionário), se esta se provar rápida o suficiente para encontrar raízes, ou simplesmente inverter a direcção de pesquisa pela raiz (começando pelos valores mais altos de x e r e pesquisando para trás). Isto significaria que as raízes maiores são pesquisadas primeiro, pelo que uma maior gama de valores de x são cobertos nas primeiras iterações, e em média uma raiz é encontrada mais cedo. O pior caso ainda é uma pesquisa total, no entanto.
Por exemplo, uma optimização em assembly
Intel APX/IA desta variante poderia ser (assumindo AX
=x
e BX
=r, ambos inteiros sem sinal, e n=16, onde
n significa o número de bits a serem calculados):
; … not AX ; AX = (2^n)-1-AX ciclos i486: 1 mov BX, 256 ; BX = 2^(n/2) 1 sub AX, 254 ; AX = AX - ( (2^(n/2))-2 ) 1 jbe done ; 3/1 inc AX ; 1 shr AX, 1 ; 2 loop1: dec BX ; 1 sub AX, BX ; 1 ja loop1 ; 3/1 done:
Para n=16, este algoritmo tem um número de ciclos de relógio de 6 se r=256, ou
7+5×(256-r)-2 = 1285-5r
se r<256, o que significa que consegue calcular raízes quadradas cujos resultados sejam de 230~232 a 256 durante o mesmo tempo que leva a calcular hard-sqrt. Isto é uma gama de 12636 valores de x (19.3%), comparado com apenas 677 (1%) para o algoritmo já exibido. O pior caso é idêntico em ambos, no entanto (ou algo pior neste algoritmo, mas a diferença é fixa e não proporcional a r).
Iremos ainda prestar mais atenção à variante de pesquisa binária na Parte II deste documento, mas outro trabalho é deixado para o leitor.
Fotografia pela NASA no Unsplash