Eu estive traçando um perfil de alguns de nossos cálculos matemáticos básicos em um Intel Core Duo e, ao examinar várias abordagens para a raiz quadrada, notei algo estranho: usando as operações escalares SSE, é mais rápido obter uma raiz quadrada recíproca e multiplicá-la para obter o sqrt, do que usar o opcode nativo sqrt!
Estou testando com um loop parecido com:
inline float TestSqrtFunction( float in );
void TestFunc()
{
#define ARRAYSIZE 4096
#define NUMITERS 16386
float flIn[ ARRAYSIZE ]; // filled with random numbers ( 0 .. 2^22 )
float flOut [ ARRAYSIZE ]; // filled with 0 to force fetch into L1 cache
cyclecounter.Start();
for ( int i = 0 ; i < NUMITERS ; ++i )
for ( int j = 0 ; j < ARRAYSIZE ; ++j )
{
flOut[j] = TestSqrtFunction( flIn[j] );
// unrolling this loop makes no difference -- I tested it.
}
cyclecounter.Stop();
printf( "%d loops over %d floats took %.3f milliseconds",
NUMITERS, ARRAYSIZE, cyclecounter.Milliseconds() );
}
Eu tentei isso com alguns corpos diferentes para TestSqrtFunction, e eu tenho alguns tempos que estão realmente coçando minha cabeça. O pior de tudo, de longe, foi usar a função nativa sqrt () e deixar o compilador "inteligente" "otimizar". A 24 ns / flutuante, usar o FPU x87 era pateticamente ruim:
inline float TestSqrtFunction( float in )
{ return sqrt(in); }
A próxima coisa que tentei foi usar um intrínseco para forçar o compilador a usar o opcode sqrt escalar do SSE:
inline void SSESqrt( float * restrict pOut, float * restrict pIn )
{
_mm_store_ss( pOut, _mm_sqrt_ss( _mm_load_ss( pIn ) ) );
// compiles to movss, sqrtss, movss
}
Isso era melhor, a 11,9 ns / float. Eu também tentei a técnica de aproximação Newton-Raphson maluca do Carmack , que funcionou ainda melhor do que o hardware, a 4,3 ns / float, embora com um erro de 1 em 2 10 (o que é demais para meus propósitos).
A surpresa foi quando tentei a operação de SSE para raiz quadrada recíproca e, em seguida, usei uma multiplicação para obter a raiz quadrada (x * 1 / √x = √x). Mesmo que isso leve duas operações dependentes, foi a solução mais rápida de longe, a 1,24 ns / flutuante e com precisão de 2 -14 :
inline void SSESqrt_Recip_Times_X( float * restrict pOut, float * restrict pIn )
{
__m128 in = _mm_load_ss( pIn );
_mm_store_ss( pOut, _mm_mul_ss( in, _mm_rsqrt_ss( in ) ) );
// compiles to movss, movaps, rsqrtss, mulss, movss
}
Minha pergunta é basicamente o que dá ? Por que o opcode de raiz quadrada embutido no hardware do SSE é mais lento do que sintetizá-lo a partir de duas outras operações matemáticas?
Tenho certeza de que este é realmente o custo da operação em si, porque verifiquei:
- Todos os dados cabem no cache e os acessos são sequenciais
- as funções são embutidas
- desenrolar o loop não faz diferença
- os sinalizadores do compilador estão definidos para otimização total (e a montagem está boa, eu verifiquei)
( editar : stephentyrone aponta corretamente que as operações em longas sequências de números devem usar as operações empacotadas SIMD de vetorização, como rsqrtps
- mas a estrutura de dados da matriz aqui é apenas para fins de teste: o que estou realmente tentando medir é o desempenho escalar para uso no código que não pode ser vetorizado.)
fonte
inline float SSESqrt( float restrict fIn ) { float fOut; _mm_store_ss( &fOut, _mm_sqrt_ss( _mm_load_ss( &fIn ) ) ); return fOut; }
,. Mas esta é uma má ideia porque pode facilmente induzir um bloqueio de carga-acerto-armazenamento se a CPU grava os flutuadores na pilha e os lê de volta imediatamente - fazendo malabarismo do registrador vetorial para um registrador flutuante para o valor de retorno em particular são más notícias. Além disso, os opcodes subjacentes da máquina que os intrínsecos SSE representam levam operandos de endereço de qualquer maneira.eax
) é muito ruim, enquanto uma viagem de ida e volta entre xmm0 e pilha e de volta não, por causa do encaminhamento da Intel para as lojas. Você mesmo pode cronometrar para ver com certeza. Geralmente, a maneira mais fácil de ver o potencial LHS é olhar o conjunto emitido e ver onde os dados são manipulados entre os conjuntos de registros; seu compilador pode fazer a coisa certa ou não. Quanto aos vetores normalizando, escrevi os meus resultados aqui: bit.ly/9W5zoURespostas:
sqrtss
dá um resultado arredondado corretamente.rsqrtss
dá uma aproximação ao recíproco, com precisão de cerca de 11 bits.sqrtss
está gerando um resultado muito mais preciso, para quando a precisão é necessária.rsqrtss
existe para os casos em que uma aproximação é suficiente, mas a velocidade é necessária. Se você ler a documentação da Intel, também encontrará uma sequência de instruções (aproximação de raiz quadrada recíproca seguida por uma única etapa de Newton-Raphson) que fornece precisão quase total (~ 23 bits de precisão, se bem me lembro), e ainda é um pouco mais rápido do quesqrtss
.editar: Se a velocidade é crítica, e você está realmente chamando isso em um loop para muitos valores, você deve usar as versões vetorizadas dessas instruções,
rsqrtps
ousqrtps
ambas processam quatro flutuadores por instrução.fonte
sqrtss
está corretamente arredondado , o que requer ~ 50 bits antes do arredondamento e não pode ser obtido usando uma iteração N / R simples com precisão única.Isso também se aplica à divisão. MULSS (a, RCPSS (b)) é muito mais rápido do que DIVSS (a, b). Na verdade, é ainda mais rápido, mesmo quando você aumenta sua precisão com uma iteração de Newton-Raphson.
A Intel e a AMD recomendam essa técnica em seus manuais de otimização. Em aplicativos que não exigem conformidade com IEEE-754, o único motivo para usar div / sqrt é a legibilidade do código.
fonte
div
não é a única operação, então a taxa de transferência total do uop costuma ser o gargalo, mesmo quando há umdivps
oudivss
. Consulte Divisão de ponto flutuante vs multiplicação de ponto flutuante , onde minha resposta contém uma seção sobre por quercpps
não é mais uma vitória de rendimento. (Ou um ganho de latência) e números na divisão de taxa de transferência / latência.a * rcpss(b)
pode ser mais rápido, mas ainda é mais uops do quea/b
!Em vez de fornecer uma resposta, isso pode estar incorreto (também não vou verificar ou discutir sobre cache e outras coisas, digamos que sejam idênticos), tentarei apontar a fonte que pode responder à sua pergunta.
A diferença pode estar em como sqrt e rsqrt são calculados. Você pode ler mais aqui http://www.intel.com/products/processor/manuals/ . Eu sugiro começar lendo sobre as funções do processador que você está usando, há algumas informações, especialmente sobre rsqrt (cpu está usando a tabela de pesquisa interna com grande aproximação, o que torna muito mais simples obter o resultado). Pode parecer que rsqrt é muito mais rápido que sqrt, que 1 operação mul adicional (que não é tão cara) pode não mudar a situação aqui.
Edit: Alguns fatos que podem valer a pena mencionar:
1. Uma vez eu estava fazendo algumas microotimizações para minha biblioteca de gráficos e usei rsqrt para calcular o comprimento dos vetores. (em vez de sqrt, multipliquei minha soma do quadrado pelo rsqrt dele, que é exatamente o que você fez em seus testes), e o desempenho foi melhor.
2. Calcular rsqrt usando a tabela de pesquisa simples pode ser mais fácil, pois para rsqrt, quando x vai para o infinito, 1 / sqrt (x) vai para 0, então para x pequenos os valores da função não mudam (muito), enquanto para sqrt - vai ao infinito, então é um caso simples;).
Além disso, esclarecimento: não tenho certeza de onde encontrei nos livros que vinculei, mas tenho quase certeza de que li que rsqrt está usando alguma tabela de pesquisa e deve ser usada apenas quando o resultado não precisa ser exato, embora - eu também possa estar errado, como estava há algum tempo :).
fonte
Newton-Raphson converge para o zero de
f(x)
usar incrementos iguais a-f/f'
ondef'
está a derivada.Pois
x=sqrt(y)
, você pode tentar resolverf(x) = 0
porx
usarf(x) = x^2 - y
;Então o incremento é:
dx = -f/f' = 1/2 (x - y/x) = 1/2 (x^2 - y) / x
que tem uma divisão lenta nele.Você pode tentar outras funções (como
f(x) = 1/y - 1/x^2
), mas elas serão igualmente complicadas.Vamos dar uma olhada
1/sqrt(y)
agora. Você pode tentarf(x) = x^2 - 1/y
, mas será igualmente complicado:dx = 2xy / (y*x^2 - 1)
por exemplo. Uma escolha alternativa não óbvia paraf(x)
é:f(x) = y - 1/x^2
Então:
dx = -f/f' = (y - 1/x^2) / (2/x^3) = 1/2 * x * (1 - y * x^2)
Ah! Não é uma expressão trivial, mas você só tem multiplicadores nela, sem divisão. => Mais rápido!
E: a etapa de atualização completa
new_x = x + dx
então diz:x *= 3/2 - y/2 * x * x
o que também é fácil.fonte
Existem várias outras respostas para isso já de alguns anos atrás. Aqui está o que o consenso acertou:
Aqui está o que o consenso errou:
O algoritmo NR para calcular a raiz quadrada recíproca tem esta etapa de atualização, como outros observaram:
São muitas multiplicações dependentes de dados e uma subtração.
O que se segue é o algoritmo que as FPUs modernas realmente usam.
Dado
b[0] = n
, suponha que possamos encontrar uma série de númerosY[i]
que seb[n] = b[0] * Y[0]^2 * Y[1]^2 * ... * Y[n]^2
aproxime de 1. Em seguida, considere:Claramente
x[n]
se aproximasqrt(n)
ey[n]
se aproxima1/sqrt(n)
.Podemos usar a etapa de atualização Newton-Raphson para raiz quadrada recíproca para obter um bom
Y[i]
:Então:
e:
A próxima observação chave é esta
b[i] = x[i-1] * y[i-1]
. Assim:Então:
Ou seja, dados xey iniciais, podemos usar a seguinte etapa de atualização:
Ou, ainda mais sofisticado, podemos definir
h = 0.5 * y
. Esta é a inicialização:E esta é a etapa de atualização:
Este é o algoritmo de Goldschmidt, e tem uma grande vantagem se você o estiver implementando em hardware: o "loop interno" é três multiplicação-adições e nada mais, e dois deles são independentes e podem ser pipeline.
Em 1999, as FPUs já precisavam de um circuito de adição / substrato em pipeline e um circuito de multiplicação em pipeline, caso contrário, o SSE não seria muito "streaming". Apenas um de cada circuito foi necessário em 1999 para implementar este loop interno de uma forma totalmente pipeline, sem desperdiçar muito hardware apenas na raiz quadrada.
Hoje, é claro, fundimos multiplicação-adição exposta ao programador. Novamente, o loop interno são três FMAs em pipeline, que são (novamente) geralmente úteis mesmo se você não estiver computando raízes quadradas.
fonte
_mm256_rsqrt_ps
, com análise de desempenho Haswell. Normalmente, apenas uma boa ideia se você não tiver outro trabalho no loop e isso prejudicaria fortemente a taxa de transferência do divisor. HW sqrt é único uop, então está ok misturado com outro trabalho.É mais rápido porque essas instruções ignoram os modos de arredondamento e não lidam com exceções de ponto flutuante ou números desnormalizados. Por essas razões, é muito mais fácil pipeline, especular e executar outra instrução fp fora de ordem.
fonte
rsqrt
's muito menor precisão, o que significa muito menos trabalho a fazer (ou nenhum?) Depois de uma tabela de lookup para obter uma estimativa inicial.