Storia
Le CPU moderne utilizzano protocolli di coerenza della cache come MESI per sincronizzare i dati tra le cache L1 private di diversi core. Quando thread indipendenti scrivono in posizioni di memoria distinte che accidentalmente risiedono sulla stessa linea di cache (tipicamente 64 o 128 byte), l'hardware serializza queste operazioni invalidando continuamente e trasferendo la proprietà di quella linea, un fenomeno denominato false sharing. C++17 ha introdotto std::hardware_destructive_interference_size per esporre la larghezza della linea di cache dell'architettura, consentendo agli sviluppatori di separare i dati mutabili in modo che le variabili calde di ciascun thread occupino linee distinte e evitando questo sovraccarico di sincronizzazione.
Problema
Applicando alignas(std::hardware_destructive_interference_size) a una variabile con durata di archiviazione automatica si garantisce che l'indirizzo di partenza dell'oggetto sia un multiplo della dimensione della linea di cache all'interno del frame di stack specifico del thread. Tuttavia, questo allineamento è locale alla vista della memoria del thread e non garantisce l'occupazione esclusiva della linea di cache fisica. Se l'oggetto è più piccolo della linea di cache, variabili adiacenti nello stesso stack—o variabili negli stack di thread diversi che per caso sono allocate a indirizzi fisici che differiscono per multipli della dimensione della linea—possono mappare alla stessa linea di cache fisica. Di conseguenza, l'hardware continua a sperimentare traffico di coerenza quando un altro thread scrive a un'altra variabile su quella stessa linea, rendendo la specifica alignas insufficiente per l'isolamento.
Soluzione
Per garantire l'evitare il false sharing, i dati devono essere riempiti per consumare l'intera linea di cache, assicurando che nessun altro dato condivida la memoria fisica indipendentemente dal layout degli indirizzi a runtime. Ciò si ottiene definendo una struct che sia sia allineata che dimensionata secondo std::hardware_destructive_interference_size.
#include <new> #include <cstddef> #include <atomic> struct alignas(std::hardware_destructive_interference_size) PaddedCounter { std::atomic<int> value; // Il padding riempie il resto della linea di cache per prevenire la condivisione char padding[std::hardware_destructive_interference_size - sizeof(std::atomic<int>)]; }; // L'array garantisce che ogni elemento risieda su una linea di cache distinta PaddedCounter thread_counters[8];
Descrizione del problema
Un processore di dati di mercato a bassa latenza utilizzava otto thread di lavoro, ognuno dei quali manteneva un contatore tick per thread in un array globale di std::atomic<int> stats[8]. Ogni thread incrementava esclusivamente il proprio indice senza blocchi, eppure il profiling rivelava che il throughput si era stabilizzato a una frazione del massimo teorico, con i contatori della CPU che mostravano cicli eccessivi di coerenza della cache piuttosto che calcolo in modalità utente. Le indagini confermarono che gli interi atomici, nonostante fossero logicamente indipendenti, erano impacchettati contiguamente in una singola linea di cache di 64 byte, causando interferenze distruttive tra i core.
Soluzione 1: Variabili locali allineate
Il team inizialmente tentò di dichiarare alignas(64) std::atomic<int> local_stat all'interno della funzione di esecuzione di ciascun thread, passando puntatori a un thread di monitoraggio. Questo approccio richiedeva una minima rifattorizzazione e evitava stato globale. Tuttavia, si rivelò inaffidabile perché il compilatore poteva posizionare altre variabili automatiche adiacenti a local_stat all'interno della stessa linea di cache, e le allocazioni dello stack di thread diversi potevano essere separate da multipli esatti di 64 byte, causando le variabili allineate ad alias a quella stessa linea fisica e perpetuando il false sharing.
Soluzione 2: Allocazione nel heap con puntatori raw
Un altro approccio considerato allocava ciascun contatore attraverso new std::atomic<int> nella speranza che l'allocatore dell'heap disperdesse le allocazioni attraverso indirizzi di memoria distanti. Anche se questo a volte riduceva la contesa, introduceva prestazioni non deterministiche poiché piccole allocazioni sono spesso servite da lastre contigue, e i metadati dell'allocatore possono posizionare oggetti distinti sulla stessa linea di cache. Inoltre, questo richiedeva gestione manuale della memoria e non forniva garanzie di allineamento o padding a tempo di compilazione.
Soluzione scelta e risultato
L'implementazione finale adottò la struct PaddedCounter definita sopra, memorizzando istanze in un array statico. Questa soluzione è stata selezionata perché imponeva in modo deterministico la separazione delle linee di cache tramite padding e allineamento a tempo di compilazione, eliminando la contesa a livello hardware indipendentemente dal layout della memoria a runtime. Il consumo di memoria è aumentato da 32 byte a 512 byte, il che era accettabile per il guadagno in prestazioni. Il risultato è stato un aumento di dodici volte del throughput e una riduzione della varianza di latenza, soddisfacendo i requisiti di elaborazione sub-microsecondo.
Perché applicare alignas(std::hardware_destructive_interference_size) a un piccolo oggetto non riesce a prevenire il false sharing con altri dati nello stesso thread?
alignas controlla solo l'allineamento dell'indirizzo di partenza dell'oggetto, non la sua estensione. Se l'oggetto è più piccolo della linea di cache (ad esempio, un intero da 4 byte su una linea di 64 byte), i byte rimanenti di quella linea di cache possono contenere altre variabili. Quando il compilatore posiziona un'altra variabile su quella stessa linea, o quando una variabile di un thread diverso mappa a quella linea fisica, si verifica il false sharing. La vera separazione richiede che l'oggetto occupi l'intera linea tramite padding, non semplicemente che sia allineato all'inizio.
Qual è la distinzione tra std::hardware_destructive_interference_size e std::hardware_constructive_interference_size, e quando raggruppare i dati per adattarsi a quest'ultimo migliorerà le prestazioni?
std::hardware_destructive_interference_size è la minima separazione necessaria per evitare il false sharing, mentre std::hardware_constructive_interference_size è la dimensione massima dei dati che beneficia della località spaziale su una singola linea di cache. Raggruppare campi frequentemente acceduti corrrelati (ad esempio, le coordinate x, y, z di un punto) in una struct che si adatta alla dimensione costruttiva garantisce che risiedano sulla stessa linea, massimizzando i tassi di hit della cache e l'efficienza del prefetching, mentre la dimensione distruttiva è utilizzata per separare dati mutabili non correlati.
Come influisce il false sharing sulle operazioni std::atomic utilizzando memory_order_relaxed, e perché l'ordinamento di memoria rilassato non risolve il degrado delle prestazioni?
Anche con memory_order_relaxed, che non impone vincoli di ordinamento sulle operazioni di memoria circostanti, una scrittura atomica richiede comunque che il core CPU acquisisca la proprietà esclusiva della linea di cache (un ciclo di Letto-Per-Proprietà). Se un altro thread ha recentemente modificato un'altra variabile su quella stessa linea, il protocollo di coerenza della cache costringe la linea a rimbalzare tra i core. Questa sincronizzazione a livello hardware si verifica indipendentemente dalle garanzie logiche del modello di memoria C++, il che significa che il false sharing comporta l'intera latenza da cache miss, indipendentemente dall'ordinamento di memoria specificato.