Atomic double floating point o SSE / AVX vector load / store su x86_64

Qui (e in alcune domande SO) vedo che C ++ non supporta qualcosa come lock-free std::atomic e non può ancora supportare qualcosa come il vettore atomico AVX / SSE perché è dipendente dalla CPU (anche se oggigiorno di CPU che conosco, ARM, AArch64 e x86_64 hanno i vettori).

Ma c’è il supporto a livello di assembly per le operazioni atomiche su double s o vettori in x86_64? In tal caso, quali operazioni sono supportate (come caricare, memorizzare, aggiungere, sottrarre, moltiplicare forse)? Quali operazioni MSVC ++ 2017 implementano lock-free in atomic ?

C ++ non supporta qualcosa come lock-free std::atomic

In realtà, C ++ 11 std::atomic è privo di blocco su implementazioni C ++ tipiche e espone quasi tutto ciò che è ansible fare in asm per la programmazione lock-free con float / double su x86 (ad esempio carico, archivio e I CAS sono sufficienti per implementare qualsiasi cosa: perché non è completamente implementato il doppio atomico ). Tuttavia, i compilatori attuali non compilano sempre atomic efficiente.

C ++ 11 std :: atomic non ha un’API per le estensioni della memoria transazionale di Intel (TSX) (per FP o integer). TSX potrebbe essere un game-changer in particolare per FP / SIMD, poiché rimuoverà tutto il sovraccarico di dati rimbalzanti tra registri xmm e interi. Se la transazione non si interrompe, qualsiasi cosa tu abbia appena fatto con i carichi / negozi double o vector avviene atomicamente.

Alcuni hardware non x86 supportano atomic add per float / double e C ++ p0020 è una proposta per aggiungere specializzazioni template fetch_add e operator+= / -= a std::atomic / di C ++.

L’hardware con atomica LL / SC invece dell’istruzione di destinazione della memoria in stile x86, come ARM e la maggior parte delle altre CPU RISC, può eseguire operazioni RMW atomiche su double e float senza CAS, ma è necessario recuperare i dati da FP a interi registri perché LL / SC è solitamente disponibile solo per i cmpxchg integer, come cmpxchg di x86. Tuttavia, se l’hardware arbitrasse le coppie LL / SC per evitare / ridurre il livelock, sarebbe significativamente più efficiente rispetto a un ciclo CAS in situazioni di contesa molto alta. Se hai progettato i tuoi algoritmi in modo che la contesa sia rara, forse c’è solo una piccola differenza in termini di dimensioni del codice tra un ciclo di ripetizione LL / add / SC per fetch_add rispetto a un carico + aggiungi + LL / SC.


x86 carichi e archivi con allineamento nativo sono atomici fino a 8 byte, anche x87 o SSE . (Ad esempio movsd xmm0, [some_variable] è atomico, anche in modalità a 32 bit). Infatti gcc usa x87 fild / fistp o SSE 8B carichi / negozi per implementare std::atomic caricare e archiviare in codice a 32 bit.

Ironia della sorte, i compilatori (gcc7.1, clang4.0, ICC17, MSVC CL19) fanno un brutto lavoro nel codice a 64 bit (o 32 bit con SSE2 disponibile) e rimbalzano i dati attraverso i registri di interi invece di fare solo carichi / negozi movsd direttamente da / per regs xmm ( guardalo su Godbolt ):

 #include  std::atomic ad; void store(double x){ ad.store(x, std::memory_order_release); } // gcc7.1 -O3 -mtune=intel: // movq rax, xmm0 # ALU xmm->integer // mov QWORD PTR ad[rip], rax // ret double load(){ return ad.load(std::memory_order_acquire); } // mov rax, QWORD PTR ad[rip] // movq xmm0, rax // ret 

Senza -mtune=intel , gcc ama memorizzare / ricaricare per intero-> xmm. Vedi https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80820 e bug correlati che ho segnalato. Questa è una scelta sbagliata anche per -mtune=generic . AMD ha una latenza elevata per movq tra interi e movq vettori, ma ha anche una latenza elevata per un negozio / ricarica. Con il valore predefinito -mtune=generic , load() compilato per:

 // mov rax, QWORD PTR ad[rip] // mov QWORD PTR [rsp-8], rax # store/reload integer->xmm // movsd xmm0, QWORD PTR [rsp-8] // ret 

Lo spostamento dei dati tra xmm e il registro intero ci porta al prossimo argomento:


Atomic read-modify-write (come fetch_add ) è un’altra storia : c’è un supporto diretto per interi con cose come lock xadd [mem], eax (vedi Can num ++ essere atomic per ‘int num’? Per maggiori dettagli). Per altre cose, come atomic o atomic , l’unica opzione su x86 è un ciclo retry con cmpxchg (o TSX) .

Atomic compare-and-swap (CAS) è utilizzabile come blocco di costruzione privo di blocco per qualsiasi operazione RMW atomica, fino alla massima larghezza CAS supportata dall’hardware. Su x86-64, questo è 16 byte con cmpxchg16b (non disponibile su alcuni AMD K8 di prima generazione, quindi per gcc devi usare -mcx16 o -march=whatever per abilitarlo).

gcc rende il meglio asm ansible per lo exchange() :

 double exchange(double x) { return ad.exchange(x); // seq_cst } movq rax, xmm0 xchg rax, QWORD PTR ad[rip] movq xmm0, rax ret // in 32-bit code, compiles to a cmpxchg8b retry loop void atomic_add1() { // ad += 1.0; // not supported // ad.fetch_or(-0.0); // not supported // have to implement the CAS loop ourselves: double desired, expected = ad.load(std::memory_order_relaxed); do { desired = expected + 1.0; } while( !ad.compare_exchange_weak(expected, desired) ); // seq_cst } mov rax, QWORD PTR ad[rip] movsd xmm1, QWORD PTR .LC0[rip] mov QWORD PTR [rsp-8], rax # useless store movq xmm0, rax mov rax, QWORD PTR [rsp-8] # and reload .L8: addsd xmm0, xmm1 movq rdx, xmm0 lock cmpxchg QWORD PTR ad[rip], rdx je .L5 mov QWORD PTR [rsp-8], rax movsd xmm0, QWORD PTR [rsp-8] jmp .L8 .L5: ret 

compare_exchange esegue sempre un confronto bit a bit, quindi non è necessario preoccuparsi del fatto che lo zero negativo ( -0.0 ) è paragonabile a +0.0 nella semantica IEEE o che NaN non è ordinato. Questo potrebbe essere un problema se si tenta di verificare che desired == expected e saltare l’operazione CAS, però. Per i nuovi compilatori sufficienti, memcmp(&expected, &desired, sizeof(double)) == 0 potrebbe essere un buon modo per esprimere un confronto bit a bit dei valori FP in C ++. Assicurati di evitare i falsi positivi; i falsi negativi porteranno solo a un CAS non necessario.


Bloccato da hardware lock or [mem], 1 è decisamente meglio che avere più thread che lock cmpxchg cicli di lock cmpxchg . Ogni volta che un core accede alla linea della cache ma fallisce il suo cmpxchg è un throughput sprecato rispetto alle operazioni di destinazione della memoria intera che riescono sempre una volta che mettono le mani su una linea della cache.

Alcuni casi speciali per i galleggianti IEEE possono essere implementati con operazioni su interi . ad esempio il valore assoluto di un atomic può essere eseguito con lock and [mem], rax (dove RAX ha tutti i bit tranne il bit del segno impostato). O forza un float / double per essere negativo ORing un 1 nel bit del segno. Oppure cambia il suo segno con XOR. Potresti anche aumentare atomicamente la sua magnitudine di 1 ulp con lock add [mem], 1 . (Ma solo se si può essere sicuri che non fosse infinito iniziare con … nextafter() è una funzione interessante, grazie al design molto interessante di IEEE754 con esponenti distorti che fanno sì che il trasferimento da mantissa ad esponente funzioni effettivamente).

Probabilmente non c’è modo di esprimere questo in C ++ che permetterà ai compilatori di farlo per voi su obiettivi che usano IEEE FP. Quindi, se lo vuoi, potresti atomic fare da solo con type-punning a atomic o qualcosa del genere, e verificare che FP endianness corrisponda all’intero endianness, ecc. Ecc. (Oppure fallo solo per x86. avere LL / SC invece delle operazioni bloccate di destinazione della memoria comunque.)


non è ancora in grado di supportare qualcosa come il vettore atomico AVX / SSE perché dipende dalla CPU

Corretta. Non c’è modo di rilevare quando un archivio o carico di 128b o 256b è atomico completamente attraverso il sistema di coerenza della cache. ( https://gcc.gnu.org/bugzilla/show_bug.cgi?id=70490 ). Persino un sistema con trasferimenti atomici tra L1D e unità di esecuzione può ottenere lo strappo tra blocchi da 8B durante il trasferimento di linee cache tra cache e un protocollo ristretto. Esempio reale: Opteron K10 a più socket con interconnessioni HyperTransport sembra disporre di carichi / archivi atomici da 16B in un singolo socket, ma i thread su socket differenti possono osservare lo strappo.

Ma se hai un array condiviso di double s allineati, dovresti essere in grado di utilizzare carichi / negozi vettoriali su di essi senza il rischio di “strappare” all’interno di un dato double .

Atomicità per elemento del vettore load / store e gather / scatter?

Penso che sia lecito ritenere che un carico / archivio 32B allineato sia fatto con 8B non sovrapposti o carichi / negozi più ampi, sebbene Intel non lo garantisca. Per le operazioni non allineate, probabilmente non è sicuro assumere nulla.

Se hai bisogno di un carico atomico di 16B, l’unica opzione è lock cmpxchg16b , con desired=expected . Se ha successo, sostituisce il valore esistente con se stesso. Se fallisce, ottieni i vecchi contenuti. (Caso d’angolo: questi errori “caricano” sulla memoria di sola lettura, quindi fai attenzione a quali puntatori passi ad una funzione che fa ciò.) Inoltre, le prestazioni sono ovviamente orribili rispetto ai carichi di sola lettura che possono lasciare il linea cache in stato Condiviso e che non sono tutte le barriere di memoria.

L’atomic store 16B e RMW possono entrambi utilizzare lock cmpxchg16b modo ovvio. Ciò rende i negozi puri molto più costosi rispetto ai normali negozi di vettori, specialmente se il cmpxchg16b deve riprovare più volte, ma l’RMW atomico è già costoso.

Le istruzioni aggiuntive per spostare i dati vettoriali da / per i reg di interi non sono gratuite, ma anche non costose rispetto al lock cmpxchg16b .

 # xmm0 -> rdx:rax, using SSE4 movq rax, xmm0 pextrq rdx, xmm0, 1 # rdx:rax -> xmm0, again using SSE4 movq xmm0, rax pinsrq xmm0, rdx, 1 

In termini C ++ 11:

atomic<__m128d> sarebbe lento anche per operazioni di sola lettura o di sola scrittura (usando cmpxchg16b ), anche se implementate in modo ottimale. atomic<__m256d> non può nemmeno essere bloccato.

alignas(64) atomic shared_buffer[1024]; in teoria permetterebbe comunque l’auto-vettorizzazione per il codice che la legge o la scrive, avendo solo bisogno di movq rax, xmm0 e poi xchg o cmpxchg per RMW atomico su un double . (Nella modalità a 32 bit, cmpxchg8b funzionerebbe.) cmpxchg8b non cmpxchg8b quasi certamente un buon asm da un compilatore per questo!


È ansible aggiornare atomicamente un object 16B, ma leggere a livello atomico le metà di 8B separatamente . (Penso che questo sia sicuro rispetto all’ordinamento della memoria su x86: vedi il mio ragionamento su https://gcc.gnu.org/bugzilla/show_bug.cgi?id=80835 ).

Tuttavia, i compilatori non forniscono alcun modo pulito per esprimere questo. Ho hackerato un sindacato tipo puning-thing che funziona per gcc / clang: come posso implementare il contatore ABA con c ++ 11 CAS? . Ma gcc7 e versioni successive non cmpxchg16b inline cmpxchg16b , perché stanno considerando nuovamente se gli oggetti 16B debbano davvero presentarsi come “senza blocco”. ( https://gcc.gnu.org/ml/gcc-patches/2017-01/msg02344.html ).

Su x86-64 le operazioni atomiche sono implementate tramite il prefisso LOCK. Il Manuale dello sviluppatore del software Intel (Volume 2, Riferimento Set di istruzioni) afferma

Il prefisso LOCK può essere anteposto solo alle seguenti istruzioni e solo a quelle forms delle istruzioni in cui l’operando di destinazione è un operando di memoria: ADD, ADC, AND, BTC, BTR, BTS, CMPXCHG, CMPXCH8B, CMPXCHG16B, DEC, INC, NEG, NOT, OR, SBB, SUB, XOR, XADD e XCHG.

Nessuna di queste istruzioni funziona su registri in virgola mobile (come i registri XMM, YMM o FPU).

Ciò significa che non esiste un modo naturale per implementare le operazioni float / double atomiche su x86-64. Sebbene la maggior parte di queste operazioni possa essere implementata caricando la rappresentazione bit del valore in virgola mobile in un registro generale (ad esempio intero), ciò farebbe un grave deterioramento delle prestazioni, quindi gli autori del compilatore non optarono per implementarlo.

Come sottolineato da Peter Cordes nei commenti, il prefisso LOCK non è richiesto per carichi e negozi, poiché sono sempre atomici su x86-64. Tuttavia Intel SDM (Volume 3, System Programming Guide) garantisce solo che i seguenti carichi / negozi siano atomici:

  • Istruzioni che leggono o scrivono un singolo byte.
  • Istruzioni che leggono o scrivono una parola (2 byte) il cui indirizzo è allineato su un limite di 2 byte.
  • Istruzioni che leggono o scrivono una doppia parola (4 byte) il cui indirizzo è allineato su un limite di 4 byte.
  • Istruzioni che leggono o scrivono una quadrupla (8 byte) il cui indirizzo è allineato su un limite di 8 byte.

In particolare, l’atomicità dei carichi / negozi da / verso i registri vettoriali XMM e YMM più grandi non è garantita.