Prestazioni inaspettatamente povere e stranamente bimodali per il ciclo di acquisto su Intel Skylake

Vedo prestazioni inaspettatamente scadenti per un semplice ciclo del negozio che ha due negozi: uno con un passo in avanti di 16 byte e uno che è sempre nella stessa posizione 1 , come questo:

volatile uint32_t value; void weirdo_cpp(size_t iters, uint32_t* output) { uint32_t x = value; uint32_t *rdx = output; volatile uint32_t *rsi = output; do { *rdx = x; *rsi = x; rdx += 4; // 16 byte stride } while (--iters > 0); } 

In assemblaggio questo loop probabilmente 3 assomiglia a:

 weirdo_cpp: ... align 16 .top: mov [rdx], eax ; stride 16 mov [rsi], eax ; never changes add rdx, 16 dec rdi jne .top ret 

Quando l’area di memoria a cui si accede si trova in L2, mi aspetto che venga eseguita a meno di 3 cicli per iterazione. Il secondo negozio continua a colpire la stessa posizione e dovrebbe aggiungere circa un ciclo. Il primo negozio implica l’inserimento di una riga da L2 e quindi anche lo sfratto di una riga ogni 4 iterazioni . Non sono sicuro di come si valuti il ​​costo L2, ma anche se si stima prudentemente che L1 possa eseguire solo uno dei seguenti cicli ad ogni: (a) commit a store o (b) ricevere una riga da L2 o (c) sfrattare una linea a L2, otterresti qualcosa come 1 + 0,25 + 0,25 = 1,5 cicli per lo stream store di 16 passi.

Effettivamente, si commenta un negozio che si ottiene ~ 1,25 cicli per iterazione solo per il primo negozio e ~ 1,01 cicli per iterazione per il secondo negozio, quindi 2,5 cicli per iterazione sembrano una stima prudente.

La performance effettiva è molto strana, comunque. Ecco una corsa tipica del cablaggio di prova:

 Estimated CPU speed: 2.60 GHz output size : 64 KiB output alignment: 32 3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0 3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0 3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0 3.89 cycles/iter, 1.49 ns/iter, cpu before: 0, cpu after: 0 3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0 4.73 cycles/iter, 1.81 ns/iter, cpu before: 0, cpu after: 0 7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0 7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0 7.34 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0 7.26 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0 7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0 7.31 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0 7.29 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0 7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0 7.29 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0 7.27 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0 7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0 7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0 7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0 7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0 

Due cose sono strane qui.

Innanzitutto sono i tempi bimodali: c’è una modalità veloce e una modalità lenta . Iniziamo in modalità lenta prendendo circa 7,3 cicli per iterazione e ad un certo punto passando a circa 3,9 cicli per iterazione. Questo comportamento è coerente e riproducibile e le due tempistiche sono sempre abbastanza coerenti raggruppate attorno ai due valori. La transizione si presenta in entrambe le direzioni, dalla modalità lenta alla modalità veloce e viceversa (e talvolta transizioni multiple in una sola esecuzione).

L’altra cosa strana è la prestazione davvero brutta. Anche in modalità rapida , a circa 3,9 cicli, le prestazioni sono molto peggiori rispetto ai cast di 1.0 + 1.3 = 2.3 peggiori che ci si aspetterebbe dall’aggiunta di ciascuno dei casi con un singolo archivio (e supponendo che sia ansible eseguire lo zero assoluto di lavoro quando entrambi i negozi sono nel ciclo). In modalità lenta , le prestazioni sono terribili rispetto a quanto ci si aspetterebbe in base ai primi principi: ci sono 7,3 cicli per fare 2 negozi, e se lo si inserisce in termini di larghezza di banda del negozio L2, si tratta di circa 29 cicli per negozio L2 (dal momento che memorizza solo una riga di cache completa ogni 4 iterazioni).

Skylake è registrato come avente un throughput del ciclo 64B / L1 tra L1 e L2, che è molto più alto del throughput osservato qui (circa 2 byte / ciclo in modalità lenta ).

Cosa spiega la scarsa produttività e le prestazioni bimodali e posso evitarlo?

Sono anche curioso di sapere se questo si riproduce su altre architetture e persino su altre scatole Skylake. Sentiti libero di includere risultati locali nei commenti.

Puoi trovare il codice di test e il cablaggio su github . Esiste un Makefile per piattaforms Linux o Unix, ma dovrebbe essere relativamente facile da build anche su Windows. Se vuoi eseguire la variante asm avrai bisogno di yasm o yasm per l’assembly 4 – se non lo hai, puoi semplicemente provare la versione C ++.

Possibilità eliminate

Ecco alcune possibilità che ho considerato e in gran parte eliminato. Molte delle possibilità sono eliminate dal semplice fatto che si vede la transizione delle prestazioni casualmente nel mezzo del ciclo di benchmarking , quando molte cose semplicemente non sono cambiate (ad esempio, se era correlata all’allineamento dell’array di output, non poteva cambia nel mezzo di una corsa poiché lo stesso buffer viene utilizzato per tutto il tempo). Mi riferirò a questo come l’ eliminazione predefinita di seguito (anche per le cose che sono l’eliminazione di default c’è spesso un altro argomento da fare).

  • Fattori di allineamento: l’array di output è allineato a 16 byte e ho provato fino a 2MB di allineamento senza modifiche. Eliminato anche dall’eliminazione predefinita .
  • Contesa con altri processi sulla macchina: l’effetto è osservato più o meno identicamente su una macchina intriggers e anche su uno molto carico (es. Usando stress -vm 4 ). Lo stesso benchmark dovrebbe essere completamente core-local perché si adatta a L2, e perf conferma che ci sono pochissimi errori L2 per iterazione (circa 1 errore ogni 300-400 iterazioni, probabilmente correlato al codice printf ).
  • TurboBoost: TurboBoost è completamente disabilitato, confermato da tre diverse letture MHz.
  • Risparmio energetico: il regolatore delle prestazioni è intel_pstate in modalità performance . Durante il test non si osservano variazioni di frequenza (la CPU rimane essenzialmente bloccata a 2,59 GHz).
  • Effetti TLB: l’effetto è presente anche quando il buffer di output si trova in una pagina enorme di 2 MB. In ogni caso, le voci TLB 64 4k coprono più del buffer di output 128K. perf non riporta alcun comportamento TLB particolarmente strano.
  • Alias ​​4k: versioni più vecchie e più complesse di questo benchmark mostravano alcuni alias 4k, ma questo è stato eliminato poiché non ci sono carichi nel benchmark (sono carichi che potrebbero erroneamente indicare alias precedenti negozi). Eliminato anche dall’eliminazione predefinita .
  • Conflitti di associatività L2: eliminati dall’eliminazione predefinita e dal fatto che questo non va via anche con pagine da 2MB, dove possiamo essere sicuri che il buffer di output è disposto linearmente nella memoria fisica.
  • Effetti di hyperthreading: HT è disabilitato.
  • Prefetch: solo due dei prefetcher potrebbero essere coinvolti qui (il “DCU”, noto anche come L1 L2 prefetcher), poiché tutti i dati risiedono in L1 o L2, ma le prestazioni sono le stesse con tutti i prefetcher abilitati o tutti disabilitati.
  • Interrupt: nessuna correlazione tra il conteggio degli interrupt e la modalità lenta. Esiste un numero limitato di interrupt totali, principalmente tick dell’orologio.

toplev.py

Ho usato toplev.py che implementa il metodo di analisi Top Down di Intel, e non sorprende che identifica il benchmark come bound del negozio:

 BE Backend_Bound: 82.11 % Slots [ 4.83%] BE/Mem Backend_Bound.Memory_Bound: 59.64 % Slots [ 4.83%] BE/Core Backend_Bound.Core_Bound: 22.47 % Slots [ 4.83%] BE/Mem Backend_Bound.Memory_Bound.L1_Bound: 0.03 % Stalls [ 4.92%] This metric estimates how often the CPU was stalled without loads missing the L1 data cache... Sampling events: mem_load_retired.l1_hit:pp mem_load_retired.fb_hit:pp BE/Mem Backend_Bound.Memory_Bound.Store_Bound: 74.91 % Stalls [ 4.96%] <== This metric estimates how often CPU was stalled due to store memory accesses... Sampling events: mem_inst_retired.all_stores:pp BE/Core Backend_Bound.Core_Bound.Ports_Utilization: 28.20 % Clocks [ 4.93%] BE/Core Backend_Bound.Core_Bound.Ports_Utilization.1_Port_Utilized: 26.28 % CoreClocks [ 4.83%] This metric represents Core cycles fraction where the CPU executed total of 1 uop per cycle on all execution ports... MUX: 4.65 % PerfMon Event Multiplexing accuracy indicator 

Questo non fa davvero molta luce: sapevamo già che i negozi dovevano rovinare le cose, ma perché? La descrizione di Intel della condizione non dice molto.

Ecco un riepilogo ragionevole di alcuni dei problemi coinvolti nell’interazione L1-L2.


1 Questo è un MCVE molto semplificato del mio loop originale, che era almeno 3 volte la dimensione e che ha fatto un sacco di lavoro aggiuntivo, ma esibito esattamente le stesse prestazioni di questa versione semplice, con collo di bottiglia sullo stesso misterioso problema.

3 In particolare, appare esattamente come questo se si scrive l’assembly a mano, o se lo si compila con gcc -O1 (versione 5.4.1), e probabilmente i compilatori più ragionevoli (si usa volatile per evitare di affondare il secondo per lo più morto conservare fuori dal giro).

4 Senza dubbio è ansible convertire questo in syntax MASM con alcune modifiche minori poiché l’assemblaggio è così banale. Pull richieste accettate.

Quello che ho trovato finora. Sfortunatamente non offre una spiegazione per le scarse prestazioni, e non per la distribuzione bimodale, ma è più un insieme di regole per quando potresti vedere le prestazioni e le note per attenuarlo:

  • Il throughput del negozio in L2 sembra essere al massimo una cache-line a 64 byte per tre cicli 0 , ponendo un limite superiore di ~ 21 byte per ciclo sul throughput del negozio. Detto in un altro modo, le serie di negozi che mancano in L1 e colpiscono in L2 impiegheranno almeno tre cicli per ogni linea della cache toccata.
  • Al di sopra di tale linea di base vi è una penalità significativa quando i negozi che colpiscono in L2 sono intercalati con negozi in una linea cache diversa (indipendentemente dal fatto che quei negozi colpiscano in L1 o L2).
  • La penalità è apparentemente un po ‘più grande per i negozi che si trovano nelle vicinanze (ma non sono ancora nella stessa linea di cache).
  • La prestazione bimodale è almeno superficialmente correlata all’effetto sopra visto poiché nel caso non interleaving non sembra verificarsi, sebbene non abbia un’ulteriore spiegazione per questo.
  • Se si assicura che la linea di cache sia già in L1 prima dello store, mediante prefetch o un caricamento fittizio, le prestazioni lente scompaiono e le prestazioni non sono più bimodali.

Dettagli e immagini

64-byte Stride

La domanda originale usava arbitrariamente un passo di 16, ma iniziamo con il caso più semplice: una falcata di 64, cioè una linea di cache completa. A mano a mano che i vari effetti sono visibili con qualsiasi falcata, 64 garantisce una perdita della cache L2 ad ogni passo e quindi rimuove alcune variabili.

Rimuoviamo anche il secondo store per ora – quindi testiamo solo un singolo store a 64 byte con oltre 64 KB di memoria:

 top: mov BYTE PTR [rdx],al add rdx,0x40 sub rdi,0x1 jne top 

Eseguendo questo nella stessa bardatura di cui sopra, ottengo circa 3,05 cicli / negozio 2 , anche se c’è un po ‘di varianza rispetto a quello che sono abituato a vedere (- si può anche trovare un 3.0 in là).

Quindi sappiamo già che probabilmente non faremo di meglio per i negozi sostenibili esclusivamente per L2 1 . Mentre Skylake ha apparentemente un throughput di 64 byte tra L1 e L2, nel caso di un stream di negozi, quella larghezza di banda deve essere condivisa per entrambi gli sfratti da L1 e per caricare la nuova linea in L1. 3 cicli sembrano ragionevoli se richiede 1 ciclo per (a) sfrattare la linea della vittima sporca da L1 a L2 (b) aggiornare L1 con la nuova linea da L2 e (c) commettere il negozio in L1.

Cosa succede quando si aggiunge una seconda scrittura sulla stessa riga della cache (al byte successivo, sebbene risulti non avere importanza) nel ciclo? Come questo:

 top: mov BYTE PTR [rdx],al mov BYTE PTR [rdx+0x1],al add rdx,0x40 sub rdi,0x1 jne top 

Ecco un istogramma della temporizzazione per 1000 esecuzioni del cablaggio di test per il ciclo precedente:

  count cycles/itr 1 3.0 51 3.1 5 3.2 5 3.3 12 3.4 733 3.5 139 3.6 22 3.7 2 3.8 11 4.0 16 4.1 1 4.3 2 4.4 

Quindi la maggior parte delle volte sono raggruppate attorno a 3,5 cicli. Ciò significa che questo negozio aggiuntivo ha aggiunto solo 0,5 cicli alla tempistica. Potrebbe essere qualcosa di simile al buffer del negozio che è in grado di drenare due negozi sul L1 se sono nella stessa linea, ma questo accade solo circa la metà delle volte.

Considera che il buffer del negozio contiene una serie di negozi come 1, 1, 2, 2, 3, 3 dove 1 indica la linea della cache: metà delle posizioni ha due valori consecutivi dalla stessa riga della cache e metà non lo fa. Poiché il buffer del negozio è in attesa di drenare i negozi, e L1 sta sfrattando e accettando le linee da L2, la L1 sarà disponibile per un negozio in un punto “arbitrario”, e se è nella posizione 1, 1 forse il i depositi si scaricano in un ciclo, ma se è a 1, 2 occorrono due cicli.

Nota c’è un altro picco di circa il 6% dei risultati attorno a 3.1 anziché a 3.5. Quello potrebbe essere uno stato stabile in cui otteniamo sempre il risultato fortunato. C’è un altro picco di circa il 3% a ~ 4.0-4.1 – l’accordo “sempre sfortunato”.

Proviamo questa teoria osservando i vari offset tra il primo e il secondo negozio:

 top: mov BYTE PTR [rdx + FIRST],al mov BYTE PTR [rdx + SECOND],al add rdx,0x40 sub rdi,0x1 jne top 

Proviamo tutti i valori di FIRST e SECOND da 0 a 256 in step di 8. I risultati, con valori FIRST variabili sull’asse verticale e SECOND sull’orizzontale:

cicli / iter per variazioni di negozio diverse

Vediamo uno schema specifico: i valori bianchi sono “veloci” (attorno ai valori 3.0-4.1 discussi sopra per l’offset di 1). I valori gialli sono più alti, fino a 8 cicli, e rossi fino a 10. I valori anomali viola sono i più alti e di solito sono i casi in cui la “modalità lenta” descritta nell’OP entra (di solito con un ciclo / iter di 18.0). Notiamo quanto segue:

  • Dal modello di celle bianche, vediamo che otteniamo il veloce risultato del ciclo ~ 3,5 finché il secondo negozio si trova nella stessa riga della cache o il prossimo relativo al primo negozio. Ciò è coerente con l’idea precedente che i negozi alla stessa linea di cache sono gestiti in modo più efficiente. Il motivo per cui avere il secondo store nella prossima linea di cache funziona è che il pattern finisce per essere lo stesso, tranne che per il primo primo accesso: 0, 0, 1, 1, 2, 2, ... vs 0, 1, 1, 2, 2, ... – dove nel secondo caso è il secondo negozio che tocca per primo ciascuna riga della cache. Il buffer del negozio non interessa però. Non appena entri in diverse linee della cache, ottieni uno schema come 0, 2, 1, 3, 2, ... e apparentemente questo fa schifo?

  • I “valori anomali” viola non compaiono mai nelle aree bianche, quindi sono apparentemente limitati allo scenario che è già lento (e il più lento qui rende circa 2,5 volte più lento: da ~ 8 a 18 cicli).

Possiamo ridurre un po ‘lo zoom e osservare offset ancora più grandi:

compensa fino al 2048

Lo stesso schema di base, anche se vediamo che le prestazioni migliorano (area verde) mentre il secondo negozio si allontana (avanti o indietro) dal primo, fino a quando non peggiora di nuovo con un offset di circa ~ 1700 byte. Anche nell’area migliorata otteniamo al massimo 5.8 cicli / iterazione ancora molto peggiori rispetto alle prestazioni della stessa linea di 3.5.

Se aggiungi qualche tipo di istruzione di caricamento o di prefetch che esegue in anticipo 3 dei negozi, scompaiono sia i valori anomali complessivi sia quelli a “modalità lenta”:

tutto bene

Puoi riportare questo problema all’originale passo 16: qualsiasi tipo di prefetch o carico nel core loop, praticamente insensibile alla distanza (anche se è dietro di fatto), corregge il problema e ottieni 2.3 cicli / iterazione, vicino al miglior ideale ansible di 2.0 e uguale alla sum dei due negozi con loop separati.

Pertanto, la regola di base è che i negozi su L2 senza carichi corrispondenti sono molto più lenti di quelli con il software che li precarica, a meno che l’intero stream del negozio non acceda alle linee della cache in un singolo pattern sequenziale. Ciò è contrario all’idea che un modello lineare come questo non tragga mai vantaggio da SW prefetch.

Non ho una spiegazione approfondita, ma potrebbe includere questi fattori:

  • Avere altri negozi nei buffer del negozio può ridurre la concorrenza delle richieste andando a L2. Non è chiaro esattamente quando gli archivi che mancheranno in L1 allocano un buffer del negozio, ma forse si verifica vicino quando il negozio sta per andare in pensione e c’è una certa quantità di “lookhead” nel buffer del negozio per portare le posizioni in L1, quindi avere ulteriori negozi che non mancheranno in L1 ferisce la concorrenza dal momento che il lookahead non può vedere tante richieste che mancheranno.
  • Forse ci sono conflitti per le risorse L1 e L2 come le porte di lettura e scrittura, la larghezza di banda inter-cache, che sono peggiori con questo modello di negozi. Ad esempio, quando i negozi su linee diverse si alternano, forse non possono scaricarsi più rapidamente dalla coda del negozio (vedi sopra dove sembra che in alcuni scenari più di un negozio possa esaurire per ciclo).

Anche questi commenti di Dr. McCalpin sui forum di Intel sono piuttosto interessanti.


0 La maggior parte è realizzabile solo con lo streamer L2 disabilitato poiché altrimenti la contesa aggiuntiva su L2 rallenta di circa 1 riga per 3,5 cicli.

1 Contrasto con negozi, dove ottengo quasi esattamente 1,5 cicli per carico, per una larghezza di banda implicita di ~ 43 byte per ciclo. Questo ha perfettamente senso: la banda L1 <-> L2 è 64 byte, ma supponendo che L1 stia accettando una linea da L2 o assecondando richieste di carico dal core ad ogni ciclo (ma non entrambi in parallelo) allora hai 3 cicli per due carichi su linee L2 diverse: 2 cicli per accettare le linee da L2 e 1 ciclo per soddisfare due istruzioni di caricamento.

2 Con il prefetch distriggersto . A quanto pare, il prefetcher L2 compete per l’accesso alla cache L2 quando rileva l’accesso allo streaming: anche se trova sempre le linee candidate e non passa a L3, questo rallenta il codice e aumenta la variabilità. Le conclusioni generalmente valgono con il precaricamento attivo, ma tutto è solo un po ‘più lento (ecco un grosso blob di risultati con il precaricamento attivo – si vedono circa 3,3 cicli per carico, ma con molta variabilità).

3 Non ha nemmeno bisogno di essere in anticipo – anche il precaricamento di più righe dietro funziona: immagino che il prefetch / load si affretti a correre velocemente davanti ai negozi che sono colli di bottiglia, in modo che vadano avanti comunque. In questo modo, il prefetching è un tipo di auto-guarigione e sembra funzionare con quasi tutti i valori che hai inserito.

Sandy Bridge ha “pre-fetchers hardware di dati L1”. Ciò significa che inizialmente quando si esegue il proprio archivio, la CPU deve recuperare i dati da L2 a L1; ma dopo che questo è accaduto diverse volte, il pre-fetcher hardware nota il piacevole pattern sequenziale e inizia a prelampare i dati da L2 a L1 per te, in modo che i dati siano in L1 o “a metà strada a L1” prima che il codice faccia il suo negozio.