Ho profilato alcuni dei nostri calcoli matematici di base su un Intel Core Duo e, esaminando vari approcci alla radice quadrata, ho notato qualcosa di strano: utilizzando le operazioni scalari SSE, è più veloce prendere una radice quadrata reciproca e moltiplicarla per ottenere sqrt, piuttosto che utilizzare il codice operativo sqrt nativo!
Lo sto provando con un ciclo simile a:
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() );
}
L'ho provato con alcuni corpi diversi per TestSqrtFunction e ho alcuni tempi che mi stanno davvero graffiando la testa. La cosa peggiore in assoluto era usare la funzione nativa sqrt () e lasciare che il compilatore "intelligente" si "ottimizzasse". A 24ns / float, usando l'x87 FPU questo era pateticamente negativo:
inline float TestSqrtFunction( float in )
{ return sqrt(in); }
La prossima cosa che ho provato è stato utilizzare un intrinseco per forzare il compilatore a utilizzare il codice operativo sqrt scalare di 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
}
Era meglio, a 11,9 ns / float. Ho anche provato la stravagante tecnica di approssimazione Newton-Raphson di Carmack , che funzionava anche meglio dell'hardware, a 4,3 ns / float, anche se con un errore di 1 su 2 10 (che è troppo per i miei scopi).
Il problema è stato quando ho provato l'operazione SSE per la radice quadrata reciproca , e poi ho usato un moltiplicatore per ottenere la radice quadrata (x * 1 / √x = √x). Anche se questo richiede due operazioni dipendenti, che era la soluzione estremamente veloce, in 1.24ns / galleggiante e preciso per 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
}
La mia domanda è fondamentalmente cosa dà ? Perché il codice operativo a radice quadrata integrato nell'hardware di SSE è più lento della sua sintesi da altre due operazioni matematiche?
Sono sicuro che questo sia davvero il costo dell'operazione stessa, perché ho verificato:
- Tutti i dati entrano nella cache e gli accessi sono sequenziali
- le funzioni sono inline
- srotolare il ciclo non fa differenza
- i flag del compilatore sono impostati per l'ottimizzazione completa (e l'assembly è buono, ho controllato)
( modifica : stephentyrone sottolinea correttamente che le operazioni su lunghe stringhe di numeri dovrebbero usare le operazioni di vettorizzazione SIMD imballate, come rsqrtps
- ma la struttura dei dati dell'array qui è solo a scopo di test: quello che sto davvero cercando di misurare sono le prestazioni scalari per l'uso nel codice che non può essere vettorializzato.)
inline float SSESqrt( float restrict fIn ) { float fOut; _mm_store_ss( &fOut, _mm_sqrt_ss( _mm_load_ss( &fIn ) ) ); return fOut; }
. Ma questa è una cattiva idea perché può facilmente indurre uno stallo del load-hit-store se la CPU scrive i float nello stack e poi li legge immediatamente indietro - destreggiandosi dal registro vettoriale a un registro float per il valore di ritorno in particolare è una cattiva notizia. Inoltre, i codici operativi della macchina sottostante rappresentati dagli intrinseci SSE accettano comunque operandi di indirizzo.
eax
) è pessimo, mentre un viaggio di andata e ritorno tra xmm0 e stack e indietro no, a causa dell'inoltro del negozio di Intel. Puoi cronometrare te stesso per vedere con certezza. Generalmente il modo più semplice per vedere il potenziale LHS è guardare l'assieme emesso e vedere dove i dati vengono manipolati tra i set di registri; il tuo compilatore potrebbe fare la cosa intelligente, oppure no. Per quanto riguarda la normalizzazione dei vettori, ho scritto i miei risultati qui: bit.ly/9W5zoU