2013-05-16 14 views
9

Sto cercando di ottimizzare del codice che dovrebbe leggere i float di precisione singola dalla memoria ed eseguire aritmetici su di essi in doppia precisione. Ciò sta diventando un collo di bottiglia significativo per le prestazioni, poiché il codice che memorizza i dati in memoria come precisione singola è sostanzialmente più lento rispetto al codice equivalente che memorizza i dati in memoria come precisione doppia. Qui di seguito è un programma giocattolo C++ che cattura l'essenza del mio problema:Perché GCC e Clang non usano cvtss2sd [memoria]?

#include <cstdio> 

// noinline to force main() to actually read the value from memory. 
__attributes__ ((noinline)) float* GetFloat() { 
    float* f = new float; 
    *f = 3.14; 
    return f; 
} 

int main() { 
    float* f = GetFloat(); 
    double d = *f; 
    printf("%f\n", d); // Use the value so it isn't optimized out of existence. 
} 

Sia GCC e Clang eseguire il caricamento di *f e la conversione a doppia precisione come due istruzioni separate, anche se l'istruzione cvtss2sd supporta la memoria come argomento fonte . Secondo Agner Fog, cvtss2sd r, m viene eseguito con la stessa velocità di movss r, m sulla maggior parte delle architetture ed evita di dover eseguire afterwords cvtss2sd r, r. Ciò nonostante, Clang genera il seguente codice per main():

main PROC 
     push rbp          ; 
     mov  rbp, rsp        ; 
     call _Z8GetFloatv       ; 
     movss xmm0, dword ptr [rax]     ; 
     cvtss2sd xmm0, xmm0        ; 
     mov  edi, offset ?_001      ; 
     mov  al, 1         ; 
     call printf         ; 
     xor  eax, eax        ; 
     pop  rbp          ; 
     ret            ; 
main ENDP 

GCC genera codice simile inefficiente. Perché nessuno di questi compilatori genera semplicemente qualcosa come cvtss2sd xmm0, dword ptr [rax]?

MODIFICA: Ottima risposta, Stephen Canon! Ho preso l'output in linguaggio assembly di Clang per il mio caso d'uso reale, l'ho incollato in un file sorgente come inline ASM, l'ho confrontato, poi ho apportato le modifiche qui discusse e l'ho confrontato di nuovo. Non potevo credere che cvtss2sd [memory] sia effettivamente più lento.

risposta

13

Questa è in realtà un'ottimizzazione. CVTSS2SD dalla memoria lascia invariati gli alti 64 bit del registro di destinazione. Ciò significa che si verifica un aggiornamento del registro parziale, che può comportare uno stallo significativo e ridurre notevolmente ILP in molte circostanze. MOVSS, d'altra parte, azzera i bit inutilizzati del registro, che è la rottura della dipendenza ed evita il rischio di stallo.

Si potrebbe avere un collo di bottiglia sulla conversione da raddoppiare, ma non è questo.


Mi dilungherò un po 'sul motivo esatto per cui l'aggiornamento del registro parziale è un rischio di prestazioni.

Non ho idea di quello che il calcolo viene effettivamente eseguita, ma supponiamo che assomiglia a questo esempio molto semplice:

double accumulator, x; 
float y[n]; 
for (size_t i=0; i<n; ++i) { 
    accumulator += x*(double)y[i]; 
} 

Il codegen "ovvio" per il ciclo sembra qualcosa di simile:

loop_begin: 
    cvtss2sd xmm0, [y + 4*i] 
    mulsd xmm0, x 
    addsd accumulator, xmm0 
    // some loop arithmetic that I'll ignore; it isn't important. 

Ingenuamente, l'unica dipendenza dal ciclo è nell'aggiornamento dell'accumulatore, quindi in modo asintotico il ciclo deve essere eseguito ad una velocità di 1/(latenza addsd), ovvero 3 cicli per iterazione di ciclo su core x86 "tipici" correnti (vedi i tavoli di Agner Fog o In manuale di ottimizzazione del telefono per maggiori dettagli).

Tuttavia, se effettivamente osserviamo il funzionamento di queste istruzioni, vediamo che le alte 64 bit di xmm0, anche se non hanno alcun effetto sul risultato che ci interessa, formare una seconda dipendenza loop-portato catena.Ogni istruzione cvtss2sd non può iniziare finché non è disponibile il risultato dell'iterazione del ciclo precedente mulsd; questo limita la velocità effettiva del loop a 1/(latenza cvtss2sd + mulsd) o 7 cicli per iterazione di ciclo su core x86 tipici (la buona notizia è che si paga solo la latenza di conversione reg-reg, perché l'operazione di conversione è incrinato in due μops, e il carico μop fa non ha una dipendenza su xmm0, quindi può essere issato).

Possiamo scrivere l'operazione di questo ciclo come segue per renderlo un po 'più chiaro (sto ignorando il carico metà dello cvtss2sd, poiché questi μops sono quasi non vincolati e possono succedere più o meno ogni volta):

cycle iteration 1 iteration 2 iteration 3 
------------------------------------------------ 
0  cvtss2sd 
1  . 
2  mulsd 
3  . 
4  . 
5  . 
6  . --- xmm0[64:127]--> 
7  addsd   cvtss2sd(*) 
8  .    . 
9  .-- accum -+ mulsd 
10    | . 
11    | . 
12    | . 
13    | . --- xmm0[64:127]--> 
14    +-> addsd   cvtss2sd 
15     .    . 

(*) In realtà sto semplificando un po 'le cose; dobbiamo considerare non solo la latenza ma anche l'utilizzo della porta per renderlo accurato. Considerando che solo la latenza è sufficiente per illustrare lo stallo in questione, tuttavia, lo sto mantenendo semplice. Fai finta di essere in esecuzione su una macchina con infinite risorse ILP.

Supponiamo ora che si scrive il ciclo come questo, invece:

loop_begin: 
    movss xmm0, [y + 4*i] 
    cvtss2sd xmm0, xmm0 
    mulsd xmm0, x 
    addsd accumulator, xmm0 
    // some loop arithmetic that I'll ignore; it isn't important. 

Poiché movss dalla memoria azzera bit [32: 127] di xmm0, non v'è una dipendenza loop-esercitata xmm0, quindi abbiamo sono legati dalla latenza di accumulo, come previsto; esecuzione a regime simile a questa:

cycle iteration i iteration i+1 iteration i+2 
------------------------------------------------ 
0  cvtss2sd  . 
1  .    . 
2  mulsd   .    movss 
3  .    cvtss2sd  . 
4  .    .    . 
5  .    mulsd   . 
6  .    .    cvtss2sd 
7  addsd   .    . 
8  .    .    mulsd 
9  .    .    . 
10  . -- accum --> addsd   . 
11     .    . 
12     .    . 
13     . -- accum --> addsd 

Si noti che nel mio esempio giocattolo, c'è ancora molto di più da fare per ottimizzare il codice in questione dopo aver eliminato lo stallo parziale registrarsi-aggiornamento. Può essere vettorizzato e possono essere utilizzati più accumulatori (al costo di cambiare l'arrotondamento specifico che si verifica) per ridurre al minimo l'effetto della latenza accumulata-accumulata da ciclo trasportato.

+0

Interessante, ma porta a due domande: 1. Perché i bit alti non vengono azzerati? Presumibilmente se stai usando questa istruzione il tuo intento è quello di scrivere codice non-vettorizzato. 2. GCC e Clang sembrano continuare a farlo anche quando i bit alti del registro xmm non vengono utilizzati, cioè quando si usano solo istruzioni non vettoriali successivamente. Perchè è questo? – dsimcha

+0

1. Intel ha scelto di fare così; il perché non è terribilmente importante. È occasionalmente utile, ma probabilmente causa più problemi del suo valore. 2. Il pericolo di aggiornamento del registro parziale è presente anche se la parte alta dei registri XMM non viene mai utilizzata. Questo è ciò che lo rende così insidioso. –

+0

La spiegazione più dettagliata nella tua modifica è fantastica! La mia unica domanda è, perché la logica della CPU non dipende dalla bassa quadricipia delle dipendenze della quadword superiore e capisce che le istruzioni xxxsd leggono solo da/write alla quadword bassa del registro? – dsimcha

Problemi correlati