Disposizione in memoria, allineamento e rappresentazione degli oggetti
Comprendi il padding delle struct e i requisiti di allineamento, ottimizza l'ordine dei membri per ridurre la dimensione, impara che cosa sono le linee di cache e il false sharing e usa le funzionalità del C++20 come [[no_unique_address]] per layout compatti.
Requisiti di allineamento
Ogni tipo in C++ ha un requisito di allineamento: l'indirizzo in cui va collocato un oggetto di quel tipo deve essere un multiplo del suo allineamento. È un requisito dell'hardware: la maggior parte delle CPU non riesce ad accedere in modo efficiente a dati non allineati, e alcune architetture (ARM, le vecchie SPARC) generano un fault in caso di accesso non allineato.
Allineamenti comuni su x86-64:
- char: 1 byte
- short: 2 byte
- int, float: 4 byte
- double, long long, puntatori: 8 byte
- long double: 16 byte (su alcune piattaforme)
L'allineamento di una struct è il più grande allineamento tra quelli dei suoi membri. Il compilatore inserisce byte di padding tra i membri e alla fine della struct per soddisfare i requisiti di allineamento.
Il padding delle struct in pratica
Il compilatore inserisce padding invisibile per allineare ogni membro. L'ordine dei membri può influire drasticamente sulla dimensione della struct.
#include <iostream>
#include <cstddef> // offsetof
// BAD: wasteful ordering — 24 bytes
struct BadLayout {
char a; // offset 0, size 1
// 7 bytes padding (align double to 8)
double b; // offset 8, size 8
char c; // offset 16, size 1
// 3 bytes padding (align int to 4)
int d; // offset 20, size 4
// 0 bytes tail padding
}; // total: 24 bytes
// GOOD: optimized ordering — 16 bytes
struct GoodLayout {
double b; // offset 0, size 8
int d; // offset 8, size 4
char a; // offset 12, size 1
char c; // offset 13, size 1
// 2 bytes tail padding (struct alignment = 8)
}; // total: 16 bytes
int main() {
std::cout << "sizeof(BadLayout): " << sizeof(BadLayout) << '\n'; // 24
std::cout << "sizeof(GoodLayout): " << sizeof(GoodLayout) << '\n'; // 16
// Verify offsets
std::cout << "BadLayout::a offset: " << offsetof(BadLayout, a) << '\n'; // 0
std::cout << "BadLayout::b offset: " << offsetof(BadLayout, b) << '\n'; // 8
std::cout << "BadLayout::c offset: " << offsetof(BadLayout, c) << '\n'; // 16
std::cout << "BadLayout::d offset: " << offsetof(BadLayout, d) << '\n'; // 20
// Savings: 33% smaller per object
// With 1 million objects: 8 MB saved
}alignas e alignof
alignof(T) restituisce l'allineamento di un tipo. alignas(N) richiede un allineamento specifico per una variabile o per un tipo. Sono essenziali per il SIMD, per i buffer DMA e per l'allineamento alla linea di cache.
#include <iostream>
#include <new> // std::hardware_destructive_interference_size
// Query alignment
static_assert(alignof(int) == 4);
static_assert(alignof(double) == 8);
// Force alignment
struct alignas(64) CacheAligned {
int data[16]; // 64 bytes of data on a 64-byte boundary
};
static_assert(alignof(CacheAligned) == 64);
static_assert(sizeof(CacheAligned) == 64);
// Align a variable
alignas(256) char simd_buffer[256];
// Over-aligned allocation (C++17)
struct alignas(128) BigAlign {
double values[8];
};
int main() {
// C++17: hardware_destructive_interference_size
// Minimum offset to avoid false sharing (typically 64 on x86)
// NOTE: not available on all compilers
// std::cout << std::hardware_destructive_interference_size << '\n';
auto* p = new CacheAligned; // C++17 guarantees aligned allocation
std::cout << "alignof(CacheAligned): " << alignof(CacheAligned) << '\n';
std::cout << "address: " << p << '\n';
std::cout << "aligned? " << (reinterpret_cast<std::uintptr_t>(p) % 64 == 0
? "yes" : "no") << '\n';
delete p;
}Linee di cache e false sharing
Le CPU moderne non accedono alla memoria byte per byte — caricano intere linee di cache (tipicamente 64 byte su x86). Quando due thread modificano variabili che si trovano sulla stessa linea di cache, l'hardware deve invalidare e ricaricare continuamente quella linea tra i core. Questo fenomeno si chiama false sharing (condivisione fittizia) e può ridurre le prestazioni multi-thread di 10-100 volte.
Il false sharing si verifica quando:
- Due thread scrivono su variabili diverse
- Quelle variabili si trovano sulla stessa linea di cache da 64 byte
- Il protocollo di coerenza della cache della CPU (MESI) costringe la linea di cache a rimbalzare di continuo tra i core
La soluzione: aggiungi padding o allinea le variabili più sollecitate su linee di cache separate. Usa alignas(64) oppure std::hardware_destructive_interference_size (C++17, ma non supportato da tutti i compilatori).
Al contrario, si parla di true sharing quando più variabili correlate vengono usate insieme. Collocarle sulla stessa linea di cache migliora le prestazioni — a questo serve std::hardware_constructive_interference_size.
Esempio di false sharing e relativa soluzione
Questo esempio mostra il false sharing: due contatori sulla stessa linea di cache causano un enorme rallentamento quando vengono incrementati da thread diversi.
#include <atomic>
#include <thread>
#include <chrono>
#include <iostream>
constexpr int ITERATIONS = 100'000'000;
// BAD: counters on the same cache line — false sharing
struct BadCounters {
std::atomic<int> counter1{0};
std::atomic<int> counter2{0}; // likely on same 64-byte cache line
};
// GOOD: each counter on its own cache line
struct GoodCounters {
alignas(64) std::atomic<int> counter1{0};
alignas(64) std::atomic<int> counter2{0}; // guaranteed separate cache lines
};
template <typename Counters>
void benchmark(const char* label) {
Counters c;
auto start = std::chrono::high_resolution_clock::now();
std::thread t1([&] { for (int i = 0; i < ITERATIONS; ++i) ++c.counter1; });
std::thread t2([&] { for (int i = 0; i < ITERATIONS; ++i) ++c.counter2; });
t1.join();
t2.join();
auto elapsed = std::chrono::high_resolution_clock::now() - start;
std::cout << label << ": "
<< std::chrono::duration_cast<std::chrono::milliseconds>(elapsed).count()
<< " ms\n";
}
int main() {
std::cout << "sizeof(BadCounters): " << sizeof(BadCounters) << '\n'; // 8
std::cout << "sizeof(GoodCounters): " << sizeof(GoodCounters) << '\n'; // 128
benchmark<BadCounters>("False sharing ");
benchmark<GoodCounters>("No false sharing");
}Campi di bit e [[no_unique_address]] (C++20)
I campi di bit specificano il numero esatto di bit occupati da un membro — utili per i registri hardware e per le intestazioni dei protocolli, ma la loro disposizione è definita dall'implementazione. [[no_unique_address]] (C++20) permette ai membri vuoti (deleter e allocatori senza stato) di occupare zero byte sovrapponendosi ad altri membri, sostituendo il vecchio pattern della Empty Base Optimization.
#include <iostream>
#include <cstdint>
// Bit fields: pack flags tightly
struct TCPFlags {
uint8_t fin : 1;
uint8_t syn : 1;
uint8_t rst : 1;
uint8_t psh : 1;
uint8_t ack : 1;
uint8_t urg : 1;
uint8_t ece : 1;
uint8_t cwr : 1;
};
static_assert(sizeof(TCPFlags) == 1); // 8 flags in 1 byte
// [[no_unique_address]]: eliminate empty member overhead
struct Empty {};
struct Before { int x; Empty e; }; // sizeof == 8 (padding)
struct After { int x; [[no_unique_address]] Empty e; }; // sizeof == 4
static_assert(sizeof(Before) == 8);
static_assert(sizeof(After) == 4);
int main() {
TCPFlags tcp{};
tcp.syn = 1;
tcp.ack = 1;
std::cout << "SYN: " << tcp.syn << ", ACK: " << tcp.ack << '\n';
// Cannot take address of bit field: &tcp.syn is a compiler error
std::cout << "Before: " << sizeof(Before) << '\n'; // 8
std::cout << "After: " << sizeof(After) << '\n'; // 4
}1. Non usare mai memcpy per serializzare le struct tra piattaforme diverse — il padding e l'allineamento dei membri variano tra compilatori e architetture.
2. Portabilità dei campi di bit: l'ordine dei bit all'interno di un byte e il padding tra campi di bit sono definiti dall'implementazione. Non usare i campi di bit per i formati di trasmissione multipiattaforma — ricorri a manipolazioni di bit esplicite.
3. #pragma pack non è standard: compattare le struct per eliminare il padding può causare accessi non allineati (perdita di prestazioni o crash su architetture con allineamento rigido come ARM).
4. Ordina i membri dal più grande al più piccolo: double e puntatori (8 byte), poi int (4 byte), poi short (2 byte), poi char (1 byte). Così minimizzi il padding interno. Per le struct più sollecitate, usa static_assert(sizeof(MyStruct) <= 64) per garantire dimensioni compatibili con la linea di cache.
- Ogni tipo ha un requisito di allineamento — il compilatore inserisce padding per soddisfarlo
- Ordina i membri dall'allineamento più grande al più piccolo per minimizzare il padding
- Su x86 le linee di cache sono di 64 byte — il false sharing tra thread è una grave trappola per le prestazioni
- Usa
alignas(64)per mettere i dati di ciascun thread su linee di cache separate [[no_unique_address]](C++20) elimina il costo dei membri vuoti- I campi di bit compattano i dati ma hanno una disposizione definita dall'implementazione — non usarli per i formati di trasmissione
Quiz — Metti alla prova le tue conoscenze
(15 XP)1. Data `struct S { char a; double b; char c; };`, quanto vale probabilmente sizeof(S) su una piattaforma a 64 bit?
2. Che cos'è il false sharing?
3. Che cosa fa `[[no_unique_address]]` (C++20)?