Perché l'introduzione di inutili istruzioni MOV accelererebbe un ciclo stretto nell'assemblaggio x86_64?


222

Sfondo:

Durante l'ottimizzazione del codice Pascal con linguaggio assembly incorporato, ho notato un'istruzione non necessaria MOVe l'ho rimosso.

Con mia sorpresa, la rimozione delle istruzioni non necessarie ha fatto rallentare il mio programma .

Ho scoperto che l' aggiunta di MOVistruzioni arbitrarie e inutili ha aumentato ulteriormente le prestazioni .

L'effetto è irregolare e cambia in base all'ordine di esecuzione: le stesse istruzioni spazzatura trasposte su o giù da una singola riga producono un rallentamento .

Capisco che la CPU fa tutti i tipi di ottimizzazioni e ottimizzazione, ma sembra più una magia nera.

I dati:

Una versione del mio codice compila in modo condizionale tre operazioni spazzatura nel mezzo di un ciclo che esegue i 2**20==1048576tempi. (Il programma circostante calcola solo gli hash SHA-256 ).

I risultati sulla mia macchina piuttosto vecchia (Intel (R) Core (TM) 2 CPU 6400 a 2,13 GHz):

avg time (ms) with -dJUNKOPS: 1822.84 ms
avg time (ms) without:        1836.44 ms

I programmi sono stati eseguiti 25 volte in un ciclo, con l'ordine di esecuzione che cambia ogni volta in modo casuale.

Estratto:

{$asmmode intel}
procedure example_junkop_in_sha256;
  var s1, t2 : uint32;
  begin
    // Here are parts of the SHA-256 algorithm, in Pascal:
    // s0 {r10d} := ror(a, 2) xor ror(a, 13) xor ror(a, 22)
    // s1 {r11d} := ror(e, 6) xor ror(e, 11) xor ror(e, 25)
    // Here is how I translated them (side by side to show symmetry):
  asm
    MOV r8d, a                 ; MOV r9d, e
    ROR r8d, 2                 ; ROR r9d, 6
    MOV r10d, r8d              ; MOV r11d, r9d
    ROR r8d, 11    {13 total}  ; ROR r9d, 5     {11 total}
    XOR r10d, r8d              ; XOR r11d, r9d
    ROR r8d, 9     {22 total}  ; ROR r9d, 14    {25 total}
    XOR r10d, r8d              ; XOR r11d, r9d

    // Here is the extraneous operation that I removed, causing a speedup
    // s1 is the uint32 variable declared at the start of the Pascal code.
    //
    // I had cleaned up the code, so I no longer needed this variable, and 
    // could just leave the value sitting in the r11d register until I needed
    // it again later.
    //
    // Since copying to RAM seemed like a waste, I removed the instruction, 
    // only to discover that the code ran slower without it.
    {$IFDEF JUNKOPS}
    MOV s1,  r11d
    {$ENDIF}

    // The next part of the code just moves on to another part of SHA-256,
    // maj { r12d } := (a and b) xor (a and c) xor (b and c)
    mov r8d,  a
    mov r9d,  b
    mov r13d, r9d // Set aside a copy of b
    and r9d,  r8d

    mov r12d, c
    and r8d, r12d  { a and c }
    xor r9d, r8d

    and r12d, r13d { c and b }
    xor r12d, r9d

    // Copying the calculated value to the same s1 variable is another speedup.
    // As far as I can tell, it doesn't actually matter what register is copied,
    // but moving this line up or down makes a huge difference.
    {$IFDEF JUNKOPS}
    MOV s1,  r9d // after mov r12d, c
    {$ENDIF}

    // And here is where the two calculated values above are actually used:
    // T2 {r12d} := S0 {r10d} + Maj {r12d};
    ADD r12d, r10d
    MOV T2, r12d

  end
end;

Provate voi stessi:

Il codice è online su GitHub se vuoi provarlo tu stesso.

Le mie domande:

  • Perché copiare inutilmente i contenuti di un registro nella RAM aumenterebbe mai le prestazioni?
  • Perché la stessa inutile istruzione fornirebbe un aumento di velocità su alcune linee e un rallentamento su altre?
  • Questo comportamento è qualcosa che potrebbe essere sfruttato in modo prevedibile da un compilatore?

7
Esistono ogni sorta di istruzioni "inutili" che possono effettivamente servire a spezzare le catene di dipendenza, contrassegnare i registri fisici come ritirati, ecc. Lo sfruttamento di queste operazioni richiede una certa conoscenza della microarchitettura . La tua domanda dovrebbe fornire una breve sequenza di istruzioni come esempio minimo, piuttosto che indirizzare le persone a github.
Brett Hale,

1
@BrettHale buon punto, grazie. Ho aggiunto un estratto di codice con alcuni commenti. Copiando il valore di un registro per ram contrassegnare il registro come ritirato, anche se il valore in esso viene utilizzato in seguito?
tangentstorm,

9
Puoi mettere la deviazione standard su quelle medie? Non ci sono indicazioni reali in questo post che ci sia una vera differenza.
inamidato il

2
Puoi provare a cronometrare le istruzioni usando l'istruzione rdtscp e controllare i cicli di clock per entrambe le versioni?
Jakakbbotsch,

2
Può anche essere dovuto all'allineamento della memoria? Non ho fatto la matematica da solo (pigro: P) ma l'aggiunta di alcune istruzioni fittizie può far sì che il tuo codice sia allineato alla memoria ...
Lorenzo Dematté

Risposte:


144

La causa più probabile del miglioramento della velocità è che:

  • l'inserimento di un MOV sposta le istruzioni successive su diversi indirizzi di memoria
  • una di quelle istruzioni spostate era un ramo condizionale importante
  • quel ramo era stato erroneamente previsto a causa dell'aliasing nella tabella di previsione del ramo
  • lo spostamento del ramo ha eliminato l'alias e ha consentito di prevedere correttamente il ramo

Il tuo Core2 non tiene un registro storico separato per ogni salto condizionale. Invece mantiene una cronologia condivisa di tutti i salti condizionati. Uno svantaggio della previsione del ramo globale è che la storia è diluita da informazioni irrilevanti se i diversi salti condizionali non sono correlati.

Questo piccolo tutorial di previsione dei rami mostra come funzionano i buffer di previsione dei rami. Il buffer della cache è indicizzato dalla parte inferiore dell'indirizzo dell'istruzione di diramazione. Funziona bene a meno che due importanti rami non correlati condividano gli stessi bit inferiori. In tal caso, si finisce con l'aliasing che causa molti rami non previsti (che blocca la pipeline di istruzioni e rallenta il programma).

Se vuoi capire come le cattive previsioni del ramo influenzano le prestazioni, dai un'occhiata a questa eccellente risposta: https://stackoverflow.com/a/11227902/1001643

I compilatori in genere non dispongono di informazioni sufficienti per sapere quali rami saranno alias e se tali alias saranno significativi. Tuttavia, tali informazioni possono essere determinate in fase di esecuzione con strumenti come Cachegrind e VTune .


2
Hmm. Sembra promettente. Gli unici rami condizionali in questa implementazione di sha256 sono i controlli per la fine dei loop FOR. A quel tempo, avevo etichettato questa revisione come una stranezza e ho continuato a ottimizzare. Uno dei miei prossimi passi è stato quello di riscrivere il ciclo FOR Pascal da solo in assemblea, a quel punto queste istruzioni extra non hanno più avuto un effetto positivo. Forse il codice generato da Free Pascal era più difficile da prevedere per il processore rispetto al semplice contatore con cui l'ho sostituito.
tangentstorm,

1
@tangentstorm Sembra un buon riassunto. La tabella di previsione del ramo non è molto grande, quindi una voce della tabella potrebbe fare riferimento a più di un ramo. Questo può rendere inutili alcune previsioni. Il problema si risolve facilmente se uno dei rami in conflitto si sposta su un'altra parte della tabella. Quasi ogni piccolo cambiamento può far sì che ciò accada :-)
Raymond Hettinger,

1
Penso che questa sia la spiegazione più ragionevole del comportamento specifico che ho osservato, quindi lo segnerò come risposta. Grazie. :)
tangentstorm,

3
C'è una discussione assolutamente eccellente su un problema simile in cui si è imbattuto uno dei contributori di Bochs, potresti voler aggiungere questo alla tua risposta: emulators.com/docs/nx25_nostradamus.htm
leander

3
L'allineamento è importante per molto più dei semplici obiettivi di filiale. I colli di bottiglia della decodifica sono un grosso problema per Core2 e Nehalem: spesso fa fatica a tenere occupate le sue unità di esecuzione. L'introduzione da parte di Sandybridge della cache uop ha aumentato enormemente il rendimento del frontend. L'allineamento dei target delle filiali viene eseguito a causa di questo problema, ma influisce su tutto il codice.
Peter Cordes,

80

Potresti voler leggere http://research.google.com/pubs/pub37077.html

TL; DR: l'inserimento casuale delle istruzioni nop nei programmi può facilmente aumentare le prestazioni del 5% o più, e no, i compilatori non possono sfruttarlo facilmente. Di solito è una combinazione di predittore di diramazione e comportamento della cache, ma può anche essere, ad esempio, uno stallo della stazione di prenotazione (anche nel caso in cui non vi siano catene di dipendenze rotte o evidenti sovra-abbonamenti di sorta).


1
Interessante. Ma il processore (o FPC) è abbastanza intelligente da vedere che scrivere su ram è un NOP in questo caso?
tangentstorm,

8
L'assemblatore non è ottimizzato.
Marco van de Voort,

5
I compilatori potrebbero sfruttarlo facendo ottimizzazioni incredibilmente costose come costruire e profilare ripetutamente e quindi variare l'output del compilatore con una ricottura simulata o un algoritmo genetico. Ho letto di alcuni lavori in quell'area. Ma stiamo parlando di un minimo di 5-10 minuti di CPU al 100% da compilare e le ottimizzazioni risultanti probabilmente sarebbero il modello core della CPU e persino la revisione del core o del microcodice.
AdamIerymenko,

Non lo chiamerei NOP casuale, spiegano perché i NOP possono avere un effetto positivo sulle prestazioni (tl; dr: stackoverflow.com/a/5901856/357198 ) e l'inserimento casuale di NOP ha provocato un peggioramento delle prestazioni. Ciò che è interessante dell'articolo è che la rimozione del NOP "strategico" da parte di GCC non ha avuto alcun effetto sulle prestazioni complessive!
PuercoPop,

15

Credo nelle CPU moderne le istruzioni di assemblaggio, pur essendo l'ultimo strato visibile a un programmatore per fornire istruzioni di esecuzione a una CPU, in realtà sono diversi livelli dall'esecuzione effettiva da parte della CPU.

Le moderne CPU sono ibridi RISC / CISC che traducono le istruzioni CISC x86 in istruzioni interne che presentano un comportamento più RISC. Inoltre ci sono analizzatori di esecuzione fuori servizio, predittori di diramazioni, "micro-ops fusion" di Intel che cercano di raggruppare le istruzioni in grandi lotti di lavoro simultaneo (un po 'come il titanio VLIW / Itanium ). Esistono persino limiti della cache che potrebbero rendere il codice più veloce per god-know-why se è più grande (forse il controller della cache lo inserisce in modo più intelligente o lo mantiene più a lungo).

Il CISC ha sempre avuto un livello di traduzione da assembly a microcodice, ma il punto è che con le CPU moderne le cose sono molto più complicate. Con tutte le proprietà extra di transistor nei moderni impianti di fabbricazione di semiconduttori, le CPU possono probabilmente applicare diversi approcci di ottimizzazione in parallelo e quindi selezionare quello alla fine che fornisce la migliore velocità. Le istruzioni aggiuntive potrebbero influenzare la CPU a utilizzare un percorso di ottimizzazione migliore di altri.

L'effetto delle istruzioni aggiuntive dipende probabilmente dal modello / generazione / produttore della CPU e non è probabile che sia prevedibile. L'ottimizzazione del linguaggio assembly in questo modo richiederebbe l'esecuzione contro molte generazioni di architetture di CPU, magari utilizzando percorsi di esecuzione specifici della CPU, e sarebbe auspicabile solo per sezioni di codice veramente importanti, anche se probabilmente si sta già eseguendo un assembly.


6
La tua risposta è un po 'confusa. In molti posti sembra che tu stia indovinando, anche se la maggior parte di quello che dici è corretto.
alcuadrado,

2
Forse dovrei chiarire. Ciò che trovo confuso è la mancanza di certezza
alcuadrado,

3
indovinare che ha un senso e con una buona argomentazione è completamente valido.
jturolla,

7
Nessuno può sapere con certezza perché l'OP stia osservando questo strano comportamento, a meno che non fosse un ingegnere di Intel che aveva accesso a speciali apparecchiature diagnostiche. Quindi tutto ciò che gli altri possono fare è indovinare. Non è colpa di @ cowarldlydragon.
Alex D,

2
downvote; nulla di ciò che dici spiega il comportamento che sta osservando OP. La tua risposta è inutile
fuz,

0

Preparare la cache

Le operazioni di spostamento in memoria possono preparare la cache e rendere più veloci le successive operazioni di spostamento. Una CPU di solito ha due unità di carico e una unità di memoria. Un'unità di carico può leggere dalla memoria in un registro (una lettura per ciclo), un'unità di memoria memorizza dal registro alla memoria. Esistono anche altre unità che eseguono operazioni tra i registri. Tutte le unità lavorano in parallelo. Pertanto, su ogni ciclo, possiamo eseguire più operazioni contemporaneamente, ma non più di due carichi, un magazzino e diverse operazioni di registro. Di solito sono fino a 4 semplici operazioni con registri semplici, fino a 3 semplici operazioni con registri XMM / YMM e 1-2 operazioni complesse con qualsiasi tipo di registri. Il tuo codice ha molte operazioni con i registri, quindi un'operazione di memorizzazione di memoria fittizia è gratuita (poiché ci sono comunque più di 4 operazioni di registro), ma prepara la cache di memoria per l'operazione di archiviazione successiva. Per scoprire come funzionano gli archivi di memoria, fare riferimento aManuale di riferimento per l'ottimizzazione delle architetture Intel 64 e IA-32 .

Rompere le false dipendenze

Anche se questo non si riferisce esattamente al tuo caso, ma a volte usando operazioni di movimentazione a 32 bit sotto il processore a 64 bit (come nel tuo caso) vengono utilizzati per cancellare i bit più alti (32-63) e interrompere le catene di dipendenza.

È noto che in x86-64, l'utilizzo di operandi a 32 bit cancella i bit più alti del registro a 64 bit. Si prega di leggere la sezione pertinente - 3.4.1.1 - del Manuale per gli sviluppatori del software per le architetture Intel® 64 e IA-32 Volume 1 :

Gli operandi a 32 bit generano un risultato a 32 bit, con estensione zero fino a un risultato a 64 bit nel registro di destinazione generale

Quindi, le istruzioni mov, che possono sembrare inutili a prima vista, cancellano i bit più alti dei registri appropriati. Cosa ci dà? Rompe le catene di dipendenze e consente l'esecuzione delle istruzioni in parallelo, in ordine casuale, dall'algoritmo Out-of-Order implementato internamente dalle CPU dal Pentium Pro nel 1995.

Un preventivo dal Manuale di riferimento per l'ottimizzazione delle architetture Intel® 64 e IA-32 , Sezione 3.5.1.8:

Le sequenze di codici che modificano il registro parziale possono subire alcuni ritardi nella sua catena di dipendenze, ma possono essere evitate usando i modi di dire di rottura delle dipendenze. Nei processori basati sulla microarchitettura Intel Core, una serie di istruzioni può aiutare a cancellare la dipendenza dall'esecuzione quando il software utilizza queste istruzioni per azzerare il contenuto del registro. Rompere le dipendenze da porzioni di registri tra le istruzioni operando su registri a 32 bit anziché su registri parziali. Per le mosse, ciò può essere realizzato con mosse a 32 bit o utilizzando MOVZX.

Regola di codifica assembly / compilatore 37. (Impatto M, generalità MH) : Rompere le dipendenze su porzioni di registri tra le istruzioni operando su registri a 32 bit anziché su registri parziali. Per le mosse, ciò può essere realizzato con mosse a 32 bit o utilizzando MOVZX.

MOVZX e MOV con operandi a 32 bit per x64 sono equivalenti: interrompono tutti le catene di dipendenza.

Ecco perché il tuo codice viene eseguito più velocemente. Se non ci sono dipendenze, la CPU può rinominare internamente i registri, anche se a prima vista può sembrare che la seconda istruzione modifichi un registro usato dalla prima istruzione e che i due non possano essere eseguiti in parallelo. Ma a causa della ridenominazione del registro possono farlo.

La ridenominazione dei registri è una tecnica utilizzata internamente da una CPU che elimina le false dipendenze dei dati derivanti dal riutilizzo dei registri mediante istruzioni successive che non hanno alcuna reale dipendenza tra i dati.

Penso che ora vedi che è troppo ovvio.


Questo è tutto vero, ma non ha nulla a che fare con il codice presentato nella domanda.
Cody Grey

@CodyGray - grazie per il tuo feedback. Ho modificato la risposta e aggiunto un capitolo sul caso: lo spostamento in memoria circondato da operazioni di registro prepara la cache ed è gratuito poiché l'unità di archiviazione è inattiva comunque. Quindi l'operazione di memorizzazione successiva sarà più veloce.
Maxim Masiutin,

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.