Importante lettura di fondo: il microarca pdf di Agner Fog , e probabilmente anche quello che ogni programmatore dovrebbe sapere sulla memoria . Vedi anche gli altri link nelX 86tag wiki, in particolare i manuali di ottimizzazione di Intel e l' analisi di David Kanter della microarchitettura Haswell, con diagrammi .
Incarico molto interessante; molto meglio di quelli che ho visto per cui agli studenti è stato chiesto di ottimizzare alcuni codicigcc -O0
, imparando un sacco di trucchi che non contano nel codice reale. In questo caso, ti viene chiesto di conoscere la pipeline della CPU e utilizzarla per guidare i tuoi sforzi di de-ottimizzazione, non solo per indovinare. La parte più divertente di questa è giustificare ogni pessimizzazione con "diabolica incompetenza", non malizia intenzionale.
Problemi con la formulazione e il codice dell'assegnazione :
Le opzioni specifiche di UARAR per questo codice sono limitate. Non utilizza alcun array e gran parte del costo è rappresentato dalle chiamate alle funzioni exp
/ log
libreria. Non esiste un modo ovvio per avere un parallelismo più o meno a livello di istruzione e la catena di dipendenze trasportata da loop è molto breve.
Mi piacerebbe vedere una risposta che ha cercato di rallentare il riordino delle espressioni per cambiare le dipendenze, per ridurre l' ILP solo dalle dipendenze (pericoli). Non ci ho provato.
Le CPU della famiglia Intel Sandybridge sono progettazioni aggressive fuori uso che impiegano molti transistor e potenza per trovare il parallelismo ed evitare i pericoli (dipendenze) che potrebbero disturbare una classica pipeline RISC in ordine . Di solito gli unici pericoli tradizionali che lo rallentano sono le dipendenze "vere" RAW che causano la velocità effettiva limitata dalla latenza.
I pericoli di WAR e WAW per i registri non sono praticamente un problema, grazie alla ridenominazione dei registri . (ad eccezione dipopcnt
/lzcnt
/tzcnt
, che hanno una falsa dipendenza la loro destinazione su CPU Intel , anche se è di sola scrittura. Vale a dire che WAW viene gestito come un pericolo RAW + una scrittura). Per l'ordinamento della memoria, le moderne CPU utilizzano le code dello store per ritardare il commit nella cache fino al ritiro, evitando anche i pericoli di WAR e WAW .
Perché mulss richiede solo 3 cicli su Haswell, diverso dalle tabelle di istruzioni di Agner? contiene ulteriori informazioni sulla ridenominazione dei registri e sull'occultamento della latenza FMA in un loop di prodotti FP dot.
Il marchio "i7" è stato introdotto con Nehalem (successore di Core2) e alcuni manuali Intel dicono addirittura "Core i7" quando sembrano significare Nehalem, ma hanno mantenuto il marchio "i7" per Sandybridge e le successive microarchitettura. SnB è quando la famiglia P6 si è evoluta in una nuova specie, la famiglia SnB . In molti modi, Nehalem ha più cose in comune con Pentium III che con Sandybridge (ad es. Le bancarelle di lettura dei registri e le bancarelle di lettura ROB non si verificano su SnB, perché è cambiata usando un file di registro fisico. Anche una cache uop e un interno diverso formato superiore). Il termine "architettura i7" non è utile, perché ha poco senso raggruppare la famiglia SnB con Nehalem ma non Core2. (Tuttavia, Nehalem ha introdotto l'architettura cache L3 inclusiva condivisa per connettere più core insieme. E anche GPU integrate. Quindi, a livello di chip, la denominazione ha più senso.)
Sintesi delle buone idee che l'incompetenza diabolica può giustificare
È improbabile che anche i diabolicamente incompetenti aggiungano un lavoro ovviamente inutile o un ciclo infinito, e fare un pasticcio con le classi C ++ / Boost va oltre lo scopo del compito.
- Multi-thread con un singolo contatore di loop condiviso
std::atomic<uint64_t>
, in modo che avvenga il numero totale corretto di iterazioni. Atomic uint64_t è particolarmente male con -m32 -march=i586
. Per i punti bonus, fai in modo che non sia allineato e attraversi un confine di pagina con una divisione irregolare (non 4: 4).
- La falsa condivisione per qualche altra variabile non atomica -> la pipeline della speculazione errata dell'ordine di memoria cancella, così come i mancati errori della cache.
- Invece di utilizzare le
-
variabili FP, XOR il byte alto con 0x80 per capovolgere il bit di segno, causando blocchi di inoltro del negozio .
- Tempo ogni iterazione in modo indipendente, con qualcosa di ancora più pesante di
RDTSC
. ad es. CPUID
/ RDTSC
o una funzione temporale che effettua una chiamata di sistema. Le istruzioni di serializzazione sono intrinsecamente ostili alla pipeline.
- Il cambiamento si moltiplica per le costanti in divisioni per il reciproco ("per facilità di lettura"). div è lento e non completamente pipeline.
- Vettorializzare il moltiplicare / sqrt con AVX (SIMD), ma non utilizzare
vzeroupper
prima delle chiamate a libreria matematica exp()
e log()
funzioni scalari , causando blocchi di transizione SSE <-> AVX .
- Memorizza l'output RNG in un elenco collegato o in array che attraversi fuori servizio. Lo stesso per il risultato di ogni iterazione e somma alla fine.
Anche coperto in questa risposta ma escluso dal riassunto: suggerimenti che sarebbero altrettanto lenti su una CPU senza pipeline, o che non sembrano essere giustificabili anche con un'incompetenza diabolica. ad esempio molte idee di gimp-the-compilator che producono ovviamente diverse / peggiori asm.
Multi-thread male
Forse usi OpenMP per loop multi-thread con pochissime iterazioni, con un overhead maggiore rispetto al guadagno di velocità. Il tuo codice monte-carlo ha abbastanza parallelismo per ottenere effettivamente uno speedup, però, esp. se riusciamo a rallentare ogni iterazione. (Ogni thread calcola un parziale payoff_sum
, aggiunto alla fine). #omp parallel
su quel ciclo sarebbe probabilmente un'ottimizzazione, non una pessimizzazione.
Multi-thread ma forza entrambi i thread a condividere lo stesso contatore di loop (con atomic
incrementi, quindi il numero totale di iterazioni è corretto). Questo sembra diabolicamente logico. Ciò significa utilizzare una static
variabile come contatore di loop. Ciò giustifica l'uso di atomic
per i contatori di loop e crea un vero ping-pong di cache-line (purché i thread non vengano eseguiti sullo stesso core fisico con hyperthreading; potrebbe non essere così lento). Ad ogni modo, questo è molto più lento del caso senza contese lock inc
. E lock cmpxchg8b
per incrementare atomicamente un contendente uint64_t
su un sistema a 32 bit dovrà riprovare in un ciclo invece di far arbitrare l'hardware in un atomico inc
.
Crea anche una condivisione falsa , in cui più thread mantengono i loro dati privati (ad es. Stato RNG) in byte diversi della stessa linea di cache. (Tutorial Intel su di esso, inclusi i contatori di perf da guardare) . C'è un aspetto specifico della microarchitettura in questo : le CPU Intel speculano sul fatto che non si verificano errori di ordinamento della memoria e c'è un evento perf di cancellazione della macchina dell'ordine di memoria per rilevare questo, almeno su P4 . La penalità potrebbe non essere così grande su Haswell. Come sottolinea quel collegamento, lock
un'istruzione ed presuppone che ciò accada, evitando errate speculazioni. Un carico normale specifica che altri core non invalideranno una riga della cache tra quando il carico viene eseguito e quando si ritira nell'ordine del programma (a meno che tu non usipause
). La vera condivisione senza lock
istruzioni ed è di solito un bug. Sarebbe interessante confrontare un contatore di loop condiviso non atomico con il caso atomico. Per pessimizzare davvero, mantieni il contatore del loop atomico condiviso e causa una falsa condivisione nella stessa o in un'altra riga della cache per qualche altra variabile.
Idee casuali specifiche di uarch:
Se riesci a introdurre rami imprevedibili , questo pessimizza sostanzialmente il codice. Le moderne CPU x86 hanno condutture piuttosto lunghe, quindi un errore costa circa 15 cicli (quando si esegue dalla cache uop).
Catene di dipendenza:
Penso che questa sia stata una delle parti previste dell'incarico.
Sconfiggi la capacità della CPU di sfruttare il parallelismo a livello di istruzione scegliendo un ordine di operazioni che ha una catena di dipendenze lunga anziché più catene di dipendenze corte. I compilatori non sono autorizzati a modificare l'ordine delle operazioni per i calcoli FP a meno che non vengano utilizzati -ffast-math
, poiché ciò può modificare i risultati (come discusso di seguito).
Per renderlo davvero efficace, aumenta la lunghezza di una catena di dipendenze trasportata da loop. Nulla salta fuori come ovvio, però: i loop come scritto hanno catene di dipendenza portate da un ciclo molto breve: solo un FP aggiunto. (3 cicli). Le iterazioni multiple possono avere i loro calcoli in volo contemporaneamente, perché possono iniziare molto prima payoff_sum +=
della fine dell'iterazione precedente. ( log()
e exp
accetta molte istruzioni, ma non molto di più della finestra fuori servizio di Haswell per trovare il parallelismo: dimensione ROB = 192 uops di dominio fuso e dimensione dello scheduler = 60 uops di dominio non utilizzato. Non appena l'esecuzione dell'attuale iterazione procede abbastanza lontano da lasciare spazio alle istruzioni della successiva iterazione da emettere, tutte le parti di essa che hanno i loro input pronti (cioè dep chain indipendente / separata) possono iniziare l'esecuzione quando le istruzioni più vecchie lasciano le unità di esecuzione gratuito (ad es. perché hanno un collo di bottiglia in termini di latenza, non di velocità effettiva).
Lo stato di RNG sarà quasi certamente una catena di dipendenze trasportata in loop più lunga rispetto a addps
.
Usa operazioni FP più lente / più (specialmente più divisione):
Dividi per 2,0 invece di moltiplicare per 0,5 e così via. La moltiplicazione FP è fortemente potenziata nei progetti Intel e ha una velocità effettiva di 0,5 c su Haswell e versioni successive. FP divsd
/ divpd
è solo parzialmente pipeline . (Sebbene Skylake abbia un rendimento impressionante per 4c per divpd xmm
, con latenza 13-14c, rispetto a non pipeline su Nehalem (7-22c)).
La do { ...; euclid_sq = x*x + y*y; } while (euclid_sq >= 1.0);
è chiaramente testando per una distanza, così chiaramente sarebbe proprio sqrt()
essa. : P ( sqrt
è persino più lento di div
).
Come suggerisce @Paul Clayton, riscrivere le espressioni con equivalenti associativi / distributivi può introdurre più lavoro (purché non si utilizzi -ffast-math
per consentire al compilatore di ottimizzare nuovamente). (exp(T*(r-0.5*v*v))
potrebbe diventare exp(T*r - T*v*v/2.0)
. Nota che mentre la matematica sui numeri reali è associativa, la matematica in virgola mobile non lo è , anche senza considerare overflow / NaN (motivo per cui -ffast-math
non è attiva per impostazione predefinita). Vedi il commento di Paul per un pow()
suggerimento annidato molto peloso .
Se è possibile ridimensionare i calcoli fino a numeri molto piccoli, le operazioni matematiche FP richiedono circa 120 cicli extra per passare al microcodice quando un'operazione su due numeri normali produce un denormale . Vedi il pdf del microarca di Agner Fog per i numeri e i dettagli esatti. Questo è improbabile poiché hai molti moltiplicatori, quindi il fattore di scala sarebbe quadrato e underflow fino a 0,0. Non vedo alcun modo per giustificare il necessario ridimensionamento con incompetenza (anche diabolica), solo malizia intenzionale.
Se puoi usare intrinsics ( <immintrin.h>
)
Utilizzare movnti
per eliminare i dati dalla cache . Diabolico: è nuovo e debolmente ordinato, quindi dovrebbe consentire alla CPU di eseguirlo più velocemente, giusto? O vedi quella domanda collegata per un caso in cui qualcuno era in pericolo di fare esattamente questo (per le scritture sparse in cui solo alcune delle posizioni erano calde). clflush
è probabilmente impossibile senza malizia.
Utilizzare i riquadri interi tra le operazioni matematiche FP per causare ritardi di bypass.
La miscelazione delle istruzioni SSE e AVX senza un uso corretto delle vzeroupper
cause provoca grandi stalle nel pre-Skylake (e una penalità diversa in Skylake ). Anche senza di ciò, vettorizzare male può essere peggio che scalare (più cicli trascorsi mescolando i dati dentro / fuori dai vettori di quelli salvati eseguendo le operazioni add / sub / mul / div / sqrt per 4 iterazioni Monte-Carlo contemporaneamente, con 256 vettori) . Le unità di esecuzione add / sub / mul sono completamente pipeline e full-width, ma div e sqrt su vettori 256b non sono veloci come su vettori (o scalari) 128b, quindi la velocità non è drammatica perdouble
.
exp()
e log()
non hanno il supporto hardware, quindi quella parte richiederebbe l'estrazione di elementi vettoriali su scalare e la chiamata della funzione di libreria separatamente, quindi rimescolare i risultati in un vettore. libm è in genere compilato per utilizzare solo SSE2, quindi utilizzerà le codifiche legacy-SSE delle istruzioni matematiche scalari. Se il tuo codice utilizza 256b vettori e chiama exp
senza fare vzeroupper
prima, allora ti fermi. Dopo il ritorno, si arresterà anche un'istruzione AVX-128 come vmovsd
impostare il prossimo elemento vettoriale come arg per exp
. E poi si exp()
fermerà di nuovo quando esegue un'istruzione SSE. Questo è esattamente ciò che è accaduto in questa domanda , causando un rallentamento di 10 volte. (Grazie @ZBoson).
Vedi anche gli esperimenti di Nathan Kurz con la lib matematica di Intel contro glibc per questo codice . Glibc futuri arriveranno con implementazioni vettoriali exp()
e così via.
Se hai come target pre-IvB o esp. Nehalem, cerca di ottenere gcc per causare stalli a registro parziale con operazioni a 16 bit o 8 bit seguite da operazioni a 32 bit o 64 bit. Nella maggior parte dei casi, gcc utilizzerà movzx
dopo un'operazione a 8 o 16 bit, ma ecco un caso in cui gcc modifica ah
e quindi leggeax
Con (inline) asm:
Con (inline) asm, è possibile interrompere la cache uop: un blocco di codice da 32 B che non si adatta a tre righe della cache 6uop impone il passaggio dalla cache uop ai decodificatori. Un incompetente che ALIGN
usa molti nop
s a byte singolo invece di un paio di nop
s lunghi su una destinazione del ramo all'interno del loop interno potrebbe fare il trucco. Oppure metti l'imbottitura di allineamento dopo l'etichetta, anziché prima. : P Questo importa solo se il frontend è un collo di bottiglia, cosa che non succederebbe se riuscissimo a pessimizzare il resto del codice.
Utilizzare il codice automodificante per attivare le cancellazioni della pipeline (alias macchina-bombe).
È improbabile che le stalle LCP da istruzioni a 16 bit con valori immediatamente troppo grandi per adattarsi a 8 bit. La cache uop su SnB e successivamente significa che paghi la penalità di decodifica una sola volta. Su Nehalem (il primo i7), potrebbe funzionare per un loop che non rientra nel buffer del ciclo 28 uop. gcc a volte genererà tali istruzioni, anche con -mtune=intel
e quando avrebbe potuto usare un'istruzione a 32 bit.
Un linguaggio comune per i tempi è CPUID
(serializzare) alloraRDTSC
. Ora ogni iterazione separatamente con un CPUID
/ RDTSC
per assicurarsi che il RDTSC
non è riordinate con le istruzioni precedenti, che rallentare le cose un sacco . (Nella vita reale, il modo intelligente di cronometrare è di cronometrare tutte le iterazioni insieme, invece di cronometrare ciascuna separatamente e sommarle).
Causa molti errori di cache e altri rallentamenti della memoria
Usa a union { double d; char a[8]; }
per alcune delle tue variabili. Provocare uno stallo di inoltro del negozio eseguendo un archivio ristretto (o Leggi-Modifica-Scrittura) su uno solo dei byte. (Quell'articolo wiki copre anche molte altre cose microarchitetturali per le code di caricamento / archiviazione). ad es. capovolgere il segno di un double
XOR 0x80 usando solo il byte alto , anziché un -
operatore. Lo sviluppatore diabolicamente incompetente potrebbe aver sentito che FP è più lento dell'intero, e quindi provare a fare il più possibile usando operazioni intere. (Un ottimo compilatore indirizzato alla matematica FP nei registri SSE può eventualmente compilare questo in un filexorps
con una costante in un altro registro xmm, ma l'unico modo non è terribile per x87 è se il compilatore si rende conto che sta negando il valore e sostituisce l'aggiunta successiva con una sottrazione.)
Utilizzare volatile
se si sta compilando -O3
e non si sta utilizzando std::atomic
, per forzare il compilatore a archiviare / ricaricare effettivamente ovunque. Anche le variabili globali (anziché locali) imporranno alcuni negozi / ricariche, ma l'ordinamento debole del modello di memoria C ++ non richiede che il compilatore si riversi / ricarichi continuamente in memoria.
Sostituisci i var locali con membri di una grande struttura, in modo da poter controllare il layout della memoria.
Utilizzare gli array nella struttura per il riempimento (e la memorizzazione di numeri casuali, per giustificare la loro esistenza).
Scegli il layout di memoria in modo che tutto vada su una riga diversa nello stesso "set" nella cache L1 . È solo associativo a 8 vie, ovvero ogni set ha 8 "vie". Le linee della cache sono 64B.
Ancora meglio, metti le cose esattamente a 4096B, poiché i carichi hanno una falsa dipendenza dai negozi su pagine diverse ma con lo stesso offset all'interno di una pagina . Le CPU aggressive fuori servizio utilizzano la disambiguazione della memoria per capire quando è possibile riordinare carichi e archivi senza modificare i risultati e l'implementazione di Intel ha falsi positivi che impediscono l'avvio anticipato dei carichi. Probabilmente controllano solo i bit al di sotto dell'offset della pagina, quindi il controllo può iniziare prima che il TLB abbia tradotto i bit più alti da una pagina virtuale a una pagina fisica. Oltre alla guida di Agner, vedi una risposta di Stephen Canon e anche una sezione alla fine della risposta di @Krazy Glew sulla stessa domanda. (Andy Glew è stato uno degli architetti della microarchitettura P6 originale di Intel.)
Utilizzare __attribute__((packed))
per consentire l'allineamento errato delle variabili in modo che si estendano sulla linea di cache o addirittura sui limiti della pagina. (Quindi un carico di uno ha double
bisogno di dati da due linee di cache). I carichi disallineati non comportano penalità in alcun Intel i7 uarch, tranne quando si incrociano linee di cache e linee di pagina. Le suddivisioni della linea di cache richiedono ancora cicli extra . Skylake riduce drasticamente la penalità per i carichi di divisione della pagina, da 100 a 5 cicli. (Sezione 2.1.3) . Forse correlato alla possibilità di fare due pagine in parallelo.
Una divisione di pagina su an atomic<uint64_t>
dovrebbe essere solo il caso peggiore , esp. se si tratta di 5 byte in una pagina e 3 byte nell'altra pagina o qualsiasi cosa diversa da 4: 4. Anche le divisioni al centro sono più efficienti per le divisioni cache-line con vettori 16B su alcuni Uarches, IIRC. Metti tutto in un alignas(4096) struct __attribute((packed))
(per risparmiare spazio, ovviamente), incluso un array per l'archiviazione dei risultati RNG. Ottieni il disallineamento usando uint8_t
o uint16_t
per qualcosa prima del bancone.
Se riesci a fare in modo che il compilatore utilizzi le modalità di indirizzamento indicizzato, questo eliminerà la micro-fusione . Forse usando #define
s per sostituire semplici variabili scalari con my_data[constant]
.
Se è possibile introdurre un ulteriore livello di riferimento indiretto, quindi gli indirizzi di caricamento / archivio non sono noti in anticipo, ciò può pessimizzare ulteriormente.
Attraversare le matrici in ordine non contiguo
Penso che possiamo trovare una giustificazione incompetente per l'introduzione di un array in primo luogo: ci consente di separare la generazione di numeri casuali dall'uso di numeri casuali. I risultati di ogni iterazione potrebbero anche essere archiviati in un array, per essere riassunti in seguito (con più incompetenza diabolica).
Per "massima casualità", potremmo avere un thread in loop sull'array casuale che scrive nuovi numeri casuali in esso. Il thread che utilizza i numeri casuali potrebbe generare un indice casuale da cui caricare un numero casuale. (C'è un po 'di lavoro qui, ma microarchitetturalmente aiuta a conoscere in anticipo gli indirizzi di carico in modo che ogni possibile latenza di carico possa essere risolta prima che i dati caricati siano necessari.) Avere un lettore e uno scrittore su core diversi causerà errori nell'ordinamento della memoria -specifica la pipeline cancella (come discusso in precedenza per il caso di falsa condivisione).
Per la massima pessimizzazione, eseguire il loop sull'array con un passo di 4096 byte (ovvero 512 doppi). per esempio
for (int i=0 ; i<512; i++)
for (int j=i ; j<UPPER_BOUND ; j+=512)
monte_carlo_step(rng_array[j]);
Quindi il modello di accesso è 0, 4096, 8192, ...,
8, 4104, 8200, ...
16, 4112, 8208, ...
Questo è ciò che otterresti accedendo a un array 2D come double rng_array[MAX_ROWS][512]
nell'ordine sbagliato (passando sopra le righe invece delle colonne all'interno di una fila nel ciclo interno, come suggerito da @JesperJuhl). Se l'incompetenza diabolica può giustificare un array 2D con dimensioni del genere, l'incompetenza nel mondo reale della varietà del giardino giustifica facilmente il looping con un modello di accesso errato. Questo accade nel vero codice nella vita reale.
Se necessario, regola i limiti del ciclo per utilizzare molte pagine diverse invece di riutilizzare le stesse poche pagine, se l'array non è così grande. Il prefetching dell'hardware non funziona (o lo è affatto) tra le pagine. Il prefetcher può tracciare uno stream avanti e uno indietro all'interno di ciascuna pagina (che è ciò che accade qui), ma agirà su di esso solo se la larghezza di banda della memoria non è già satura di non prefetch.
Ciò genererà anche molti errori TLB, a meno che le pagine non vengano unite in un hugepage ( Linux lo fa opportunisticamente per allocazioni anonime (non supportate da file) come malloc
/ new
che usanommap(MAP_ANONYMOUS)
).
Invece di un array per memorizzare l'elenco dei risultati, è possibile utilizzare un elenco collegato . Quindi ogni iterazione richiederebbe un carico di inseguimento del puntatore (un vero pericolo di dipendenza RAW per l'indirizzo di carico del carico successivo). Con un cattivo allocatore, potresti riuscire a spargere i nodi della lista in memoria, sconfiggendo la cache. Con un allocatore diabolicamente incompetente, potrebbe mettere ogni nodo all'inizio della propria pagina. (ad es. allocare mmap(MAP_ANONYMOUS)
direttamente, senza rompere le pagine o tenere traccia delle dimensioni degli oggetti per supportare correttamente free
).
Questi non sono specifici della microarchitettura e hanno poco a che fare con la pipeline (la maggior parte di questi sarebbe anche un rallentamento su una CPU non pipeline).
Un po 'fuori tema: fai in modo che il compilatore generi codice peggiore / faccia più lavoro:
Utilizzare C ++ 11 std::atomic<int>
e std::atomic<double>
per il codice più pessimale. Le lock
istruzioni MFENCE e ed sono abbastanza lente anche senza contese da parte di un altro thread.
-m32
renderà il codice più lento, perché il codice x87 sarà peggiore del codice SSE2. La convenzione di chiamata a 32 bit basata su stack richiede più istruzioni e passa persino gli argomenti FP sullo stack a funzioni simili exp()
. atomic<uint64_t>::operator++
on -m32
richiede un lock cmpxchg8B
loop (i586). (Quindi usalo per i contatori di loop! [Evil ride]).
-march=i386
sarà anche pessimizzato (grazie @Jesper). I confronti di FP fcom
sono più lenti di 686 fcomi
. Pre-586 non fornisce un archivio atomico a 64 bit (per non parlare di un cmpxchg), quindi tutte le atomic
operazioni a 64 bit vengono compilate in chiamate di funzione libgcc (che è probabilmente compilato per i686, piuttosto che utilizzare effettivamente un blocco). Provalo sul link Godbolt Compiler Explorer nell'ultimo paragrafo.
Utilizzare long double
/ sqrtl
/ expl
per maggiore precisione e lentezza in più nelle ABI in cui sizeof ( long double
) è 10 o 16 (con riempimento per l'allineamento). (IIRC, Windows a 64 bit utilizza 8 byte long double
equivalenti a double
. (Ad ogni modo, il carico / archivio di operandi FP a 10 byte (80 bit) è 4/7 uops, rispetto float
o double
prendendo solo 1 uop ciascuno per fld m64/m32
/ fst
). Forzare x87 con long double
sconfitte auto-vettorizzazione anche per gcc -m64 -march=haswell -O3
.
Se non si utilizzano i atomic<uint64_t>
contatori di loop, utilizzare long double
per tutto, inclusi i contatori di loop.
atomic<double>
compila, ma operazioni di lettura-modifica-scrittura come +=
non sono supportate (anche a 64 bit). atomic<long double>
deve chiamare una funzione di libreria solo per carichi / negozi atomici. Probabilmente è davvero inefficiente, perché l'ISA x86 non supporta naturalmente carichi / negozi atomici a 10 byte e l'unico modo a cui riesco a pensare senza bloccare ( cmpxchg16b
) richiede la modalità 64 bit.
A -O0
, la rottura di una grossa espressione assegnando parti a variabili temporanee causerà più memorizzazione / ricariche. Senza volatile
o qualcosa del genere, questo non importa con le impostazioni di ottimizzazione che una vera build di codice reale userebbe.
Le regole di aliasing consentono a char
a di alias qualsiasi cosa, quindi l'archiviazione attraverso una char*
forza impone al compilatore di archiviare / ricaricare tutto prima / dopo l'archivio byte, anche a -O3
. (Questo è un problema per il codice diuint8_t
vettorializzazione automatica che opera su un array di , per esempio.)
Prova i uint16_t
contatori di loop, per forzare il troncamento a 16 bit, probabilmente utilizzando dimensioni dell'operando a 16 bit (potenziali stalle) e / o movzx
istruzioni aggiuntive (sicure). L'overflow con segno è un comportamento indefinito , quindi, a meno che non si utilizzi -fwrapv
o almeno -fno-strict-overflow
, i contatori di loop con segno non debbano essere rinnovati nuovamente ogni iterazione , anche se utilizzati come offset a puntatori a 64 bit.
Forza la conversione da intero a float
e viceversa. E / o double
<=> float
conversioni. Le istruzioni hanno una latenza maggiore di uno e int-> float ( cvtsi2ss
) scalare è mal progettato per non azzerare il resto del registro xmm. (gcc inserisce un extra pxor
per interrompere le dipendenze, per questo motivo.)
Imposta spesso l'affinità della tua CPU su un'altra CPU (suggerita da @Egwor). ragionamento diabolico: non vuoi che un core si surriscaldi eseguendo il tuo thread per molto tempo, vero? Forse lo scambio con un altro core permetterà a quel core turbo di raggiungere una velocità di clock superiore. (In realtà: sono così vicini termicamente l'uno all'altro che è altamente improbabile se non in un sistema multi-socket). Ora sbaglia la messa a punto e fallo troppo spesso. Oltre al tempo impiegato nello stato del thread di salvataggio / ripristino del sistema operativo, il nuovo core ha cache L2 / L1 fredde, cache uop e predittori di diramazione.
L'introduzione di frequenti e inutili chiamate di sistema può rallentare, indipendentemente da cosa si tratti. Sebbene alcuni importanti ma semplici come quelli gettimeofday
possano essere implementati nello spazio utente con, senza transizione alla modalità kernel. (glibc su Linux lo fa con l'aiuto del kernel, dal momento che il kernel esporta il codice in vdso
).
Per ulteriori informazioni sull'overhead delle chiamate di sistema (inclusi mancati errori cache / TLB dopo il ritorno nello spazio utente, non solo il cambio di contesto stesso), il documento FlexSC offre alcune ottime analisi del contatore perf della situazione attuale, nonché una proposta per il sistema di batch chiamate da processi server multi-thread di massa.
while(true){}