Perché SSE scalare sqrt (x) è più lento di rsqrt (x) * x?


106

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.)


13
x / sqrt (x) = sqrt (x). Oppure, in altre parole: x ^ 1 * x ^ (- 1/2) = x ^ (1 - 1/2) = x ^ (1/2) = sqrt (x)
Crashworks

6
ovviamente 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.
Crashworks

4
Quanto conta LHS dipende dalla particolare generazione e stepping di un dato x86: la mia esperienza è che su qualsiasi cosa fino a i7, spostare i dati tra i set di registri (ad esempio da FPU a SSE a 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
Crashworks

2
Per il PowerPC, sì: IBM dispone di un simulatore di CPU in grado di prevedere LHS e molte altre bolle di pipeline attraverso l'analisi statica. Alcuni PPC hanno anche un contatore hardware per LHS che puoi interrogare. È più difficile per x86; buoni strumenti di profiling sono più scarsi (VTune è un po 'rotto in questi giorni) e le pipeline riordinate sono meno deterministiche. Puoi provare a misurarlo empiricamente misurando le istruzioni per ciclo, cosa che può essere eseguita precisamente con i contatori delle prestazioni dell'hardware. I registri "istruzioni ritirate" e "cicli totali" possono essere letti, ad esempio, con PAPI o PerfSuite ( bit.ly/an6cMt ).
Crashworks

2
Puoi anche scrivere semplicemente alcune permutazioni su una funzione e temporizzarle per vedere se qualcuno soffre particolarmente di stallo. Intel non pubblica molti dettagli sul modo in cui funzionano le loro pipeline (che LHS è affatto uno sporco segreto), quindi molto di quello che ho imparato è stato guardando uno scenario che causa uno stallo su altri archi (ad esempio PPC ), e quindi costruire un esperimento controllato per vedere se ce l'ha anche x86.
Crashworks

Risposte:


216

sqrtssdà un risultato correttamente arrotondato. rsqrtssfornisce un'approssimazione al reciproco, accurata a circa 11 bit.

sqrtsssta generando un risultato molto più accurato, per quando è richiesta la precisione. rsqrtssesiste per i casi in cui è sufficiente un'approssimazione, ma è richiesta la velocità. Se leggi la documentazione di Intel, troverai anche una sequenza di istruzioni (approssimazione della radice quadrata reciproca seguita da un singolo passaggio di Newton-Raphson) che fornisce una precisione quasi completa (~ 23 bit di accuratezza, se ricordo bene), ed è ancora un po ' più veloce di sqrtss.

modifica: se la velocità è critica, e lo chiami davvero in un ciclo per molti valori, dovresti usare le versioni vettorializzate di queste istruzioni, rsqrtpso sqrtpsentrambe elaborano quattro float per istruzione.


3
Il passo n / r ti dà 22 bit di precisione (lo raddoppia); 23 bit sarebbero esattamente la massima precisione.
Jasper Bekkers

7
@ Jasper Bekkers: No, non lo sarebbe. Innanzitutto, float ha 24 bit di precisione. In secondo luogo, sqrtssè arrotondato correttamente , il che richiede ~ 50 bit prima dell'arrotondamento e non può essere ottenuto utilizzando una semplice iterazione N / R con precisione singola.
Stephen Canon

1
Questo è sicuramente il motivo. Per estendere questo risultato: il progetto Embree di Intel ( software.intel.com/en-us/articles/… ), utilizza la vettorizzazione per la sua matematica. Puoi scaricare la fonte a quel link e guardare come fanno i loro vettori 3/4 D. La loro normalizzazione vettoriale utilizza rsqrt seguito da un'iterazione di newton-raphson, che è quindi molto accurata e ancora più veloce di 1 / ssqrt!
Brandon Pelfrey

7
Un piccolo avvertimento: x rsqrt (x) restituisce NaN se x è zero o infinito. 0 * rsqrt (0) = 0 * INF = NaN. INF rsqrt (INF) = INF * 0 = NaN. Per questo motivo, CUDA sulle GPU NVIDIA calcola radici quadrate approssimative a precisione singola come recip (rsqrt (x)), con l'hardware che fornisce sia una rapida approssimazione alla radice quadrata reciproca che a quella reciproca. Ovviamente sono possibili anche controlli espliciti che gestiscono i due casi speciali (ma sarebbero più lenti sulla GPU).
njuffa

@BrandonPelfrey In quale file hai trovato il passaggio di Newton Rhapson?
fredoverflow

7

Questo vale anche per la divisione. MULSS (a, RCPSS (b)) è molto più veloce di DIVSS (a, b). In effetti è ancora più veloce anche quando aumenti la sua precisione con un'iterazione Newton-Raphson.

Sia Intel che AMD raccomandano questa tecnica nei loro manuali di ottimizzazione. Nelle applicazioni che non richiedono la conformità IEEE-754, l'unico motivo per utilizzare div / sqrt è la leggibilità del codice.


1
Broadwell e versioni successive hanno prestazioni di divisione FP migliori, quindi compilatori come clang scelgono di non utilizzare reciproco + Newton per scalare sulle CPU recenti, perché di solito non è più veloce. Nella maggior parte dei cicli, divnon è l'unica operazione, quindi il throughput uop totale è spesso il collo di bottiglia anche quando c'è un divpso divss. Vedi Divisione in virgola mobile vs moltiplicazione in virgola mobile , dove la mia risposta ha una sezione sul motivo per cui rcppsnon è più una vincita del throughput. (O una vittoria di latenza) e numeri sulla divisione throughput / latenza.
Peter Cordes

Se i tuoi requisiti di precisione sono così bassi da poter saltare un'iterazione di Newton, allora sì a * rcpss(b)può essere più veloce, ma è comunque più vantaggioso di a/b!
Peter Cordes

5

Invece di fornire una risposta, che in realtà potrebbe essere errata (non controllerò o discuterò sulla cache e altre cose, diciamo che sono identiche) cercherò di indicarti la fonte che può rispondere alla tua domanda.
La differenza potrebbe risiedere nel modo in cui vengono calcolati sqrt e rsqrt. Puoi leggere di più qui http://www.intel.com/products/processor/manuals/ . Suggerirei di iniziare dalla lettura delle funzioni del processore che stai utilizzando, ci sono alcune informazioni, in particolare su rsqrt (la cpu utilizza una tabella di ricerca interna con grande approssimazione, il che rende molto più semplice ottenere il risultato). Può sembrare che rsqrt sia molto più veloce di sqrt, che 1 operazione mul aggiuntiva (che non è troppo costosa) potrebbe non cambiare la situazione qui.

Modifica: alcuni fatti che potrebbero valere la pena menzionare:
1. Una volta stavo facendo alcune micro ottimizzazioni per la mia libreria grafica e ho usato rsqrt per calcolare la lunghezza dei vettori. (invece di sqrt, ho moltiplicato la mia somma di quadrato per rsqrt, che è esattamente quello che hai fatto nei tuoi test), e ha funzionato meglio.
2. Il calcolo di rsqrt utilizzando una semplice tabella di ricerca potrebbe essere più facile, poiché per rsqrt, quando x va all'infinito, 1 / sqrt (x) va a 0, quindi per le x piccole i valori della funzione non cambiano (molto), mentre per sqrt: va all'infinito, quindi è così semplice;).

Inoltre, chiarimento: non sono sicuro di dove l'ho trovato nei libri che ho collegato, ma sono abbastanza sicuro di aver letto che rsqrt sta usando una tabella di ricerca, e dovrebbe essere usato solo quando il risultato non ha bisogno di essere esatto, anche se - potrei anche sbagliarmi, come è stato qualche tempo fa :).


4

Newton-Raphson converge allo zero f(x)utilizzando incrementi uguali a -f/f' dove f'è la derivata.

Per x=sqrt(y), si può cercare di risolvere f(x) = 0per xutilizzare f(x) = x^2 - y;

Quindi l'incremento è: dx = -f/f' = 1/2 (x - y/x) = 1/2 (x^2 - y) / x che ha una lenta divisione.

Puoi provare altre funzioni (come f(x) = 1/y - 1/x^2) ma saranno altrettanto complicate.

Diamo un'occhiata 1/sqrt(y)adesso. Puoi provare f(x) = x^2 - 1/y, ma sarà altrettanto complicato: dx = 2xy / (y*x^2 - 1)per esempio. Una scelta alternativa non ovvia per f(x)è:f(x) = y - 1/x^2

Poi: dx = -f/f' = (y - 1/x^2) / (2/x^3) = 1/2 * x * (1 - y * x^2)

Ah! Non è un'espressione banale, ma ci sono solo moltiplicazioni, nessuna divisione. => Più veloce!

E: il passaggio di aggiornamento completo new_x = x + dxquindi legge:

x *= 3/2 - y/2 * x * x anche questo è facile.


2

Ci sono una serie di altre risposte a questo già da alcuni anni fa. Ecco cosa ha ottenuto il consenso:

  • Le istruzioni rsqrt * calcolano un'approssimazione della radice quadrata reciproca, buona fino a circa 11-12 bit.
  • È implementato con una tabella di ricerca (cioè una ROM) indicizzata dalla mantissa. (In effetti, è una tabella di ricerca compressa, simile alle vecchie tabelle matematiche, che utilizza regolazioni ai bit di ordine inferiore per risparmiare sui transistor.)
  • Il motivo per cui è disponibile è che è la stima iniziale utilizzata dalla FPU per l'algoritmo della radice quadrata "reale".
  • C'è anche un'istruzione reciproca approssimativa, rcp. Entrambe queste istruzioni sono un indizio su come la FPU implementa la radice quadrata e la divisione.

Ecco cosa ha sbagliato il consenso:

  • Le FPU dell'era SSE non utilizzano Newton-Raphson per calcolare le radici quadrate. È un ottimo metodo nel software, ma sarebbe un errore implementarlo in questo modo nell'hardware.

L'algoritmo NR per calcolare la radice quadrata reciproca ha questo passaggio di aggiornamento, come altri hanno notato:

x' = 0.5 * x * (3 - n*x*x);

Sono molte moltiplicazioni dipendenti dai dati e una sottrazione.

Quello che segue è l'algoritmo che le moderne FPU utilizzano effettivamente.

Dato b[0] = n, supponiamo di poter trovare una serie di numeri Y[i]tale che si b[n] = b[0] * Y[0]^2 * Y[1]^2 * ... * Y[n]^2avvicini a 1. Quindi considera:

x[n] = b[0] * Y[0] * Y[1] * ... * Y[n]
y[n] = Y[0] * Y[1] * ... * Y[n]

Chiaramente x[n]approcci sqrt(n)e y[n]approcci 1/sqrt(n).

Possiamo usare il passaggio di aggiornamento di Newton-Raphson per la radice quadrata reciproca per ottenere un buon Y[i]:

b[i] = b[i-1] * Y[i-1]^2
Y[i] = 0.5 * (3 - b[i])

Poi:

x[0] = n Y[0]
x[i] = x[i-1] * Y[i]

e:

y[0] = Y[0]
y[i] = y[i-1] * Y[i]

La prossima osservazione chiave è questa b[i] = x[i-1] * y[i-1]. Così:

Y[i] = 0.5 * (3 - x[i-1] * y[i-1])
     = 1 + 0.5 * (1 - x[i-1] * y[i-1])

Poi:

x[i] = x[i-1] * (1 + 0.5 * (1 - x[i-1] * y[i-1]))
     = x[i-1] + x[i-1] * 0.5 * (1 - x[i-1] * y[i-1]))
y[i] = y[i-1] * (1 + 0.5 * (1 - x[i-1] * y[i-1]))
     = y[i-1] + y[i-1] * 0.5 * (1 - x[i-1] * y[i-1]))

Cioè, date le iniziali x e y, possiamo usare il seguente passaggio di aggiornamento:

r = 0.5 * (1 - x * y)
x' = x + x * r
y' = y + y * r

O, anche più elaborato, possiamo impostare h = 0.5 * y. Questa è l'inizializzazione:

Y = approx_rsqrt(n)
x = Y * n
h = Y * 0.5

E questo è il passaggio dell'aggiornamento:

r = 0.5 - x * h
x' = x + x * r
h' = h + h * r

Questo è l'algoritmo di Goldschmidt e ha un enorme vantaggio se lo stai implementando nell'hardware: il "ciclo interno" è composto da tre moltiplicazioni e nient'altro, e due di loro sono indipendenti e possono essere pipeline.

Nel 1999, le FPU avevano già bisogno di un circuito add / sottratto pipeline e un circuito multiplo pipeline, altrimenti SSE non sarebbe stato molto "streaming". Nel 1999 era necessario solo uno di ciascun circuito per implementare questo loop interno in modo completamente pipeline senza sprecare molto hardware solo in radice quadrata.

Oggi, ovviamente, abbiamo fuso moltiplica-add esposto al programmatore. Ancora una volta, il ciclo interno è costituito da tre FMA pipeline, che sono (di nuovo) generalmente utili anche se non stai calcolando le radici quadrate.


1
Correlati: Come funziona sqrt () di GCC dopo la compilazione? Quale metodo di root viene utilizzato? Newton-Raphson? ha alcuni collegamenti a progetti di unità di esecuzione div / sqrt hardware. Rapido rsqrt vettorializzato e reciproco con SSE / AVX a seconda della precisione : un'iterazione di Newton nel software, con o senza FMA, da utilizzare con _mm256_rsqrt_ps, con analisi Haswell perf. Di solito è una buona idea solo se non hai altro lavoro nel ciclo e potresti creare un collo di bottiglia sul throughput del divisore. HW sqrt è single uop quindi va bene mescolato con altri lavori.
Peter Cordes

-2

È più veloce perché queste istruzioni ignorano le modalità di arrotondamento e non gestiscono eccezioni in virgola mobile o numeri dernormalizzati. Per questi motivi è molto più facile pipeline, speculare ed eseguire altre istruzioni FP Fuori servizio.


Ovviamente sbagliato. FMA dipende dalla modalità di arrotondamento corrente, ma ha una velocità effettiva di due per clock su Haswell e versioni successive. Con due unità FMA completamente pipeline, Haswell può avere fino a 10 FMA in volo contemporaneamente. La risposta giusta èrsqrt 's molto minore precisione, il che significa meno lavoro da fare (o nessuno?) Dopo una tabella-lookup per ottenere una congettura di partenza.
Peter Cordes
Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.