Deottimizzare un programma per la pipeline nelle CPU della famiglia Intel Sandybridge


322

Ho rotto il cervello per una settimana cercando di completare questo incarico e spero che qualcuno qui possa condurmi verso la strada giusta. Vorrei iniziare con le istruzioni dell'istruttore:

Il tuo incarico è l'opposto del nostro primo incarico di laboratorio, che è stato quello di ottimizzare un programma di numeri primi. Il tuo scopo in questo compito è pessimizzare il programma, cioè farlo funzionare più lentamente. Entrambi sono programmi ad alta intensità di CPU. Sono necessari alcuni secondi per l'esecuzione sui nostri PC da laboratorio. Non è possibile modificare l'algoritmo.

Per ottimizzare il programma, utilizzare le proprie conoscenze sul funzionamento della pipeline Intel i7. Immagina i modi per riordinare i percorsi delle istruzioni per introdurre WAR, RAW e altri pericoli. Pensa ai modi per ridurre al minimo l'efficacia della cache. Sii diabolicamente incompetente.

L'incarico ha dato una scelta di programmi Whetstone o Monte-Carlo. I commenti sull'efficacia della cache sono applicabili principalmente solo a Whetstone, ma ho scelto il programma di simulazione Monte-Carlo:

// Un-modified baseline for pessimization, as given in the assignment
#include <algorithm>    // Needed for the "max" function
#include <cmath>
#include <iostream>

// A simple implementation of the Box-Muller algorithm, used to generate
// gaussian random numbers - necessary for the Monte Carlo method below
// Note that C++11 actually provides std::normal_distribution<> in 
// the <random> library, which can be used instead of this function
double gaussian_box_muller() {
  double x = 0.0;
  double y = 0.0;
  double euclid_sq = 0.0;

  // Continue generating two uniform random variables
  // until the square of their "euclidean distance" 
  // is less than unity
  do {
    x = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    y = 2.0 * rand() / static_cast<double>(RAND_MAX)-1;
    euclid_sq = x*x + y*y;
  } while (euclid_sq >= 1.0);

  return x*sqrt(-2*log(euclid_sq)/euclid_sq);
}

// Pricing a European vanilla call option with a Monte Carlo method
double monte_carlo_call_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(S_cur - K, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

// Pricing a European vanilla put option with a Monte Carlo method
double monte_carlo_put_price(const int& num_sims, const double& S, const double& K, const double& r, const double& v, const double& T) {
  double S_adjust = S * exp(T*(r-0.5*v*v));
  double S_cur = 0.0;
  double payoff_sum = 0.0;

  for (int i=0; i<num_sims; i++) {
    double gauss_bm = gaussian_box_muller();
    S_cur = S_adjust * exp(sqrt(v*v*T)*gauss_bm);
    payoff_sum += std::max(K - S_cur, 0.0);
  }

  return (payoff_sum / static_cast<double>(num_sims)) * exp(-r*T);
}

int main(int argc, char **argv) {
  // First we create the parameter list                                                                               
  int num_sims = 10000000;   // Number of simulated asset paths                                                       
  double S = 100.0;  // Option price                                                                                  
  double K = 100.0;  // Strike price                                                                                  
  double r = 0.05;   // Risk-free rate (5%)                                                                           
  double v = 0.2;    // Volatility of the underlying (20%)                                                            
  double T = 1.0;    // One year until expiry                                                                         

  // Then we calculate the call/put values via Monte Carlo                                                                          
  double call = monte_carlo_call_price(num_sims, S, K, r, v, T);
  double put = monte_carlo_put_price(num_sims, S, K, r, v, T);

  // Finally we output the parameters and prices                                                                      
  std::cout << "Number of Paths: " << num_sims << std::endl;
  std::cout << "Underlying:      " << S << std::endl;
  std::cout << "Strike:          " << K << std::endl;
  std::cout << "Risk-Free Rate:  " << r << std::endl;
  std::cout << "Volatility:      " << v << std::endl;
  std::cout << "Maturity:        " << T << std::endl;

  std::cout << "Call Price:      " << call << std::endl;
  std::cout << "Put Price:       " << put << std::endl;

  return 0;
}

Le modifiche che ho apportato sembrano aumentare il tempo di esecuzione del codice di un secondo, ma non sono del tutto sicuro di cosa posso cambiare per bloccare la pipeline senza aggiungere codice. Un punto nella giusta direzione sarebbe fantastico, apprezzo qualsiasi risposta.


Aggiornamento: il professore che ha dato questo incarico ha pubblicato alcuni dettagli

I punti salienti sono:

  • È una lezione di architettura del secondo semestre in un college della comunità (usando il libro di testo di Hennessy e Patterson).
  • i computer di laboratorio hanno CPU Haswell
  • Gli studenti sono stati esposti alle CPUIDistruzioni e a come determinare la dimensione della cache, nonché i valori intrinseci e le CLFLUSHistruzioni.
  • sono consentite tutte le opzioni del compilatore, e così è inline asm.
  • Scrivere il tuo algoritmo radice quadrata è stato annunciato come fuori dal comune

I commenti di Cowmoogun sul meta thread indicano che non era chiaro che l'ottimizzazione del compilatore potesse essere parte di questo, e ipotizzato-O0 , e che un aumento del 17% del tempo di esecuzione era ragionevole.

Quindi sembra che l'obiettivo del compito sia di convincere gli studenti a riordinare il lavoro esistente per ridurre il parallelismo a livello di istruzione o cose del genere, ma non è un male che le persone abbiano approfondito e imparato di più.


Tieni presente che questa è una domanda di architettura informatica, non una domanda su come rallentare il C ++ in generale.


97
Ho sentito che l'i7 funziona molto male conwhile(true){}
Cliff AB,


5
Con openmp se lo fai male dovresti riuscire a far sì che N thread impieghi più tempo di 1.
Flexo

9
Questa domanda è ora in discussione nel meta
Madara's Ghost

3
@bluefeet: l'ho aggiunto perché aveva già attirato un voto di chiusura in meno di un'ora dalla riapertura. Ci vogliono solo 5 persone per venire avanti e VTC senza rendersi conto di leggere i commenti per vedere che è in discussione su meta. C'è un altro voto vicino adesso. Penso che almeno una frase aiuterà ad evitare cicli di chiusura / riapertura.
Peter Cordes,

Risposte:


405

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 neltag 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/ loglibreria. 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/ RDTSCo 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 vzeroupperprima 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 parallelsu quel ciclo sarebbe probabilmente un'ottimizzazione, non una pessimizzazione.

Multi-thread ma forza entrambi i thread a condividere lo stesso contatore di loop (con atomicincrementi, quindi il numero totale di iterazioni è corretto). Questo sembra diabolicamente logico. Ciò significa utilizzare una staticvariabile come contatore di loop. Ciò giustifica l'uso di atomicper 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 cmpxchg8bper incrementare atomicamente un contendente uint64_tsu 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, lockun'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 lockistruzioni 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 expaccetta 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-mathper 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-mathnon è 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 movntiper 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 vzerouppercause 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 expsenza fare vzeroupperprima, allora ti fermi. Dopo il ritorno, si arresterà anche un'istruzione AVX-128 come vmovsdimpostare 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à movzxdopo un'operazione a 8 o 16 bit, ma ecco un caso in cui gcc modifica ahe 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 ALIGNusa molti nops a byte singolo invece di un paio di nops 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=intele 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/ RDTSCper assicurarsi che il RDTSCnon è 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 doubleXOR 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 volatilese si sta compilando -O3e 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 doublebisogno 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_to uint16_tper 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 #defines 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/ newche 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 lockistruzioni MFENCE e ed sono abbastanza lente anche senza contese da parte di un altro thread.

-m32renderà 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 -m32richiede un lock cmpxchg8Bloop (i586). (Quindi usalo per i contatori di loop! [Evil ride]).

-march=i386sarà anche pessimizzato (grazie @Jesper). I confronti di FP fcomsono più lenti di 686 fcomi. Pre-586 non fornisce un archivio atomico a 64 bit (per non parlare di un cmpxchg), quindi tutte le atomicoperazioni 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/ explper 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 doubleequivalenti a double. (Ad ogni modo, il carico / archivio di operandi FP a 10 byte (80 bit) è 4/7 uops, rispetto floato doubleprendendo solo 1 uop ciascuno per fld m64/m32/ fst). Forzare x87 con long doublesconfitte auto-vettorizzazione anche per gcc -m64 -march=haswell -O3.

Se non si utilizzano i atomic<uint64_t>contatori di loop, utilizzare long doubleper 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 volatileo 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 chara 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_tcontatori di loop, per forzare il troncamento a 16 bit, probabilmente utilizzando dimensioni dell'operando a 16 bit (potenziali stalle) e / o movzxistruzioni aggiuntive (sicure). L'overflow con segno è un comportamento indefinito , quindi, a meno che non si utilizzi -fwrapvo 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 floate viceversa. E / o double<=> floatconversioni. 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 pxorper 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 gettimeofdaypossano 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.


10
@JesperJuhl: sì, comprerò quella giustificazione. "Diabolicamente incompetente" è una frase così meravigliosa :)
Peter Cordes,

2
Cambiare i moltiplicatori da costante a divisione dall'inverso della costante potrebbe ridurre modestamente le prestazioni (almeno se non si sta cercando di superare in astuzia -O3 -fastmath). Allo stesso modo usare l'associatività per aumentare il lavoro ( exp(T*(r-0.5*v*v))diventare exp(T*r - T*v*v/2.0); exp(sqrt(v*v*T)*gauss_bm)diventare exp(sqrt(v)*sqrt(v)*sqrt(T)*gauss_bm)). L'associatività (e la generalizzazione) potrebbe anche trasformarsi exp(T*r - T*v*v/2.0)in `pow ((pow (e_value, T), r) / pow (pow (pow ((pow (e_value, T), v), v)), - 2.0) [o qualcosa del genere così]. Questi trucchi matematici non contano davvero come deoptimizzazioni microarchitetturali.
Paul A. Clayton,

2
Apprezzo molto questa risposta e la nebbia di Agner è stata di grande aiuto. Lascerò questo digest e inizierò a lavorarci questo pomeriggio. Questo è stato probabilmente il compito più utile in termini di apprendimento reale di ciò che sta accadendo.
Cowmoogun,

19
Alcuni di questi suggerimenti sono così diabolicamente incompetenti che devo parlare con il professore per vedere se il tempo di esecuzione, ora 7 minuti, è troppo per lui perché voglia sedersi per verificare l'output. Continuando a lavorare con questo, questo è stato probabilmente il più divertente che abbia avuto con un progetto.
Cowmoogun,

4
Che cosa? Nessun mutex? Avere due milioni di thread in esecuzione contemporaneamente con un mutex che protegge ogni singolo calcolo (per ogni evenienza!) Metterebbe in ginocchio il supercomputer più veloce del pianeta. Detto questo, adoro questa risposta diabolicamente incompetente.
David Hammen,

35

Alcune cose che puoi fare per far funzionare le cose nel modo peggiore possibile:

  • compilare il codice per l'architettura i386. Ciò impedirà l'uso di SSE e le istruzioni più recenti e forzerà l'uso della FPU x87.

  • usa le std::atomicvariabili ovunque. Ciò li renderà molto costosi a causa del fatto che il compilatore è costretto a inserire barriere di memoria ovunque. E questo è qualcosa che una persona incompetente potrebbe plausibilmente fare per "garantire la sicurezza del thread".

  • assicurati di accedere alla memoria nel peggior modo possibile per il prefetcher (colonna maggiore vs riga maggiore).

  • per rendere le tue variabili più costose puoi assicurarti che abbiano tutte una "durata della memoria dinamica" (heap allocato) allocandole newinvece che lasciare loro una "durata della memoria automatica" (stack allocato).

  • assicurati che tutta la memoria che assegni sia allineata in modo molto strano e evita in ogni caso l'allocazione di pagine enormi, poiché farlo sarebbe troppo efficiente per TLB.

  • qualunque cosa tu faccia, non creare il tuo codice con l'ottimizzatore dei compilatori abilitato. E assicurati di abilitare i simboli di debug più espressivi che puoi (non rallenterà l' esecuzione del codice , ma sprecherà spazio su disco aggiuntivo).

Nota: questa risposta sostanzialmente riassume i miei commenti che @Peter Cordes ha già incorporato nella sua ottima risposta. Suggeriscigli di ottenere il tuo voto se ne hai solo uno da vendere :)


9
La mia principale obiezione ad alcuni di questi è la formulazione della domanda: per de-ottimizzare il programma, usa la tua conoscenza di come funziona la pipeline Intel i7 . Non mi sembra che ci sia qualcosa di specifico di uarch in x87 o std::atomic, o, un ulteriore livello di indiretta dall'allocazione dinamica. Saranno lenti anche su un Atom o K8. Ancora votando, ma è per questo che stavo resistendo ad alcuni dei tuoi suggerimenti.
Peter Cordes,

Questi sono punti giusti. Indipendentemente da ciò, queste cose continuano a funzionare in qualche modo verso l'obiettivo del richiedente. Apprezzo il voto :)
Jesper Juhl,

L'unità SSE utilizza le porte 0, 1 e 5. L'unità x87 utilizza solo le porte 0 e 1.
Michas,

@Michas: ti sbagli. Haswell non esegue alcuna istruzione matematica SSE FP sulla porta 5. Principalmente SSE FP mescola e booleani (xorps / andps / orps). x87 è più lento, ma la tua spiegazione del perché è leggermente sbagliata. (E questo punto è completamente sbagliato.)
Peter Cordes,

1
@Michas: di movapd xmm, xmmsolito non è necessaria una porta di esecuzione (viene gestita in fase di ridenominazione del registro su IVB e successive). Inoltre non è quasi mai necessario nel codice AVX, perché tutto tranne FMA non è distruttivo. Ma abbastanza onesto, Haswell lo esegue su port5 se non viene eliminato. Non avevo guardato x87 register-copy ( fld st(i)), ma hai ragione per Haswell / Broadwell: funziona su p01. Skylake lo esegue su p05, SnB lo esegue su p0, IvB lo esegue su p5. Quindi IVB / SKL fanno alcune cose x87 (incluso il confronto) su p5, ma SNB / HSW / BDW non usano affatto p5 per x87.
Peter Cordes,

11

È possibile utilizzare long doubleper il calcolo. Su x86 dovrebbe essere il formato a 80 bit. Solo l'eredità, FPU x87 ha il supporto per questo.

Poche carenze di FPU x87:

  1. Mancanza di SIMD, potrebbe richiedere ulteriori istruzioni.
  2. Basato su stack, problematico per architetture super scalari e pipeline.
  3. Insieme separato e piuttosto piccolo di registri, potrebbe essere necessaria una maggiore conversione da altri registri e più operazioni di memoria.
  4. Sul Core i7 ci sono 3 porte per SSE e solo 2 per x87, il processore può eseguire meno istruzioni parallele.

3
Per la matematica scalare, le stesse istruzioni matematiche x87 sono solo leggermente più lente. La memorizzazione / caricamento di operandi da 10 byte è notevolmente più lenta, tuttavia, e il design basato su stack di x87 tende a richiedere istruzioni aggiuntive (come fxch). Con -ffast-math, un buon compilatore potrebbe vectorize i loop Monte-Carlo, però, e x87 glielo impedisce.
Peter Cordes,

Ho esteso un po 'la mia risposta.
Michas,

1
ri: 4: Di quale i7 uarch stai parlando e quali istruzioni? Haswell può essere eseguito mulsssu p01, ma fmulsolo su p0. addssfunziona solo p1come fadd. Esistono solo due porte di esecuzione che gestiscono operazioni matematiche FP. (L'unica eccezione a ciò è che Skylake ha lasciato cadere l'unità di aggiunta dedicata ed esegue addssle unità FMA su p01, ma faddsu p5. Quindi, mescolando alcune faddistruzioni insieme fma...ps, in teoria è possibile fare leggermente più FLOP / s totali.)
Peter Cordes,

2
Si noti inoltre che l'ABI di Windows x86-64 ha 64 bit long double, ovvero è ancora giusto double. L'ABI SysV utilizza però 80 bit long double. Inoltre, re: 2: la ridenominazione dei registri espone il parallelismo nei registri dello stack. L'architettura basata su stack richiede alcune istruzioni aggiuntive, ad esempio fxchgesp. durante l'interlacciamento di calcoli paralleli. Quindi è più difficile esprimere il parallelismo senza i round trip della memoria, piuttosto che è difficile per l'Uarch sfruttare quello che c'è. Non hai bisogno di più conversioni da altri reg, però. Non sono sicuro di cosa intendi.
Peter Cordes,

6

Risposta tardiva ma non credo che abbiamo abusato abbastanza degli elenchi collegati e del TLB.

Usa mmap per allocare i tuoi nodi, in modo tale che usi principalmente l'MSB dell'indirizzo. Ciò dovrebbe comportare lunghe catene di ricerca TLB, una pagina di 12 bit, lasciando 52 bit per la traduzione o circa 5 livelli che deve attraversare ogni volta. Con un po 'di fortuna devono andare in memoria ogni volta per una ricerca di 5 livelli più 1 accesso alla memoria per raggiungere il tuo nodo, il livello più alto sarà probabilmente nella cache da qualche parte, quindi possiamo sperare in un accesso alla memoria 5 *. Posiziona il nodo in modo che sia il margine peggiore in modo che la lettura del puntatore successivo provocherebbe altre 3-4 ricerche di traduzione. Ciò potrebbe anche rovinare totalmente la cache a causa dell'enorme quantità di ricerche di traduzione. Inoltre, la dimensione delle tabelle virtuali potrebbe causare il paging della maggior parte dei dati utente sul disco per tempi supplementari.

Quando si legge da un singolo elenco collegato, assicurarsi di leggere ogni volta dall'inizio dell'elenco per causare il massimo ritardo nella lettura di un singolo numero.


Le tabelle delle pagine x86-64 hanno una profondità di 4 livelli per indirizzi virtuali a 48 bit. (Un PTE ha 52 bit di indirizzo fisico). Le CPU future supporteranno una funzione di tabella delle pagine a 5 livelli, per altri 9 bit di spazio di indirizzi virtuali (57). Perché a 64 bit l'indirizzo virtuale è corto di 4 bit (lungo 48 bit) rispetto all'indirizzo fisico (lungo 52 bit)? . I sistemi operativi non lo abiliteranno per impostazione predefinita perché sarebbe più lento e non porta alcun vantaggio a meno che non sia necessario lo spazio di indirizzi virt.
Peter Cordes,

Ma sì, idea divertente. È possibile utilizzare mmapun file o un'area di memoria condivisa per ottenere più indirizzi virtuali per la stessa pagina fisica (con gli stessi contenuti), consentendo più errori TLB sulla stessa quantità di RAM fisica. Se la tua lista collegata nextera solo un offset relativo , potresti avere una serie di mappature della stessa pagina con un +4096 * 1024fino a quando finalmente arrivi a una diversa pagina fisica. O ovviamente si estende su più pagine per evitare hit della cache L1d. Esiste la memorizzazione nella cache di PDE di livello superiore all'interno dell'hardware di page-walk, quindi sì, spargeteli nello spazio virt addr!
Peter Cordes,

L'aggiunta di un offset al vecchio indirizzo peggiora anche la latenza di utilizzo del carico sconfiggendo [il caso speciale per una [reg+small_offset]modalità di indirizzamento] ( c'è una penalità quando base + offset si trova in una pagina diversa rispetto alla base? ); otterresti una sorgente adddi memoria di un offset a 64 bit, oppure otterrai un carico e una modalità di indirizzamento indicizzata come [reg+reg]. Vedi anche Cosa succede dopo un mancato TLB L2? - il walk della pagina recupera attraverso la cache L1d sulla famiglia SnB.
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.