Disposition mémoire, alignement et représentation des objets
Comprenez le remplissage (padding) des structs et les exigences d'alignement, optimisez l'ordre des membres pour réduire la taille, découvrez les lignes de cache et le faux partage, et utilisez des fonctionnalités C++20 comme [[no_unique_address]] pour des dispositions compactes.
Exigences d'alignement
Chaque type en C++ a une exigence d'alignement — l'adresse à laquelle un objet de ce type est placé doit être un multiple de son alignement. C'est une contrainte matérielle : la plupart des processeurs ne peuvent pas accéder efficacement à des données mal alignées, et certaines architectures (ARM, anciens SPARC) déclenchent une faute en cas d'accès mal aligné.
Alignements courants sur x86-64 :
- char : 1 octet
- short : 2 octets
- int, float : 4 octets
- double, long long, pointeurs : 8 octets
- long double : 16 octets (sur certaines plateformes)
L'alignement d'une struct est le plus grand alignement parmi ses membres. Le compilateur insère des octets de remplissage (padding) entre les membres et à la fin de la struct pour satisfaire les exigences d'alignement.
Le remplissage des structs en pratique
Le compilateur insère un remplissage invisible pour aligner chaque membre. L'ordre des membres peut modifier considérablement la taille d'une 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 et alignof
alignof(T) interroge l'alignement d'un type. alignas(N) demande un alignement précis pour une variable ou un type. Ils sont indispensables pour le SIMD, les tampons DMA et l'alignement sur les lignes de 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;
}Lignes de cache et faux partage
Les processeurs modernes n'accèdent pas à la mémoire octet par octet — ils chargent des lignes de cache entières (généralement 64 octets sur x86). Lorsque deux threads modifient des variables qui se trouvent sur la même ligne de cache, le matériel doit sans cesse invalider et recharger cette ligne entre les cœurs. C'est ce qu'on appelle le faux partage (false sharing), et cela peut réduire les performances multithread d'un facteur 10 à 100.
Le faux partage se produit lorsque :
- Deux threads écrivent dans des variables différentes
- Ces variables se trouvent sur la même ligne de cache de 64 octets
- Le protocole de cohérence de cache du processeur (MESI) provoque un va-et-vient incessant de la ligne de cache
La solution : ajoutez du remplissage ou alignez les variables fréquemment modifiées sur des lignes de cache distinctes. Utilisez alignas(64) ou std::hardware_destructive_interference_size (C++17, mais pas pris en charge par tous les compilateurs).
À l'inverse, le vrai partage (true sharing) désigne le cas où plusieurs variables liées sont accédées ensemble. Les placer sur la même ligne de cache améliore les performances — c'est std::hardware_constructive_interference_size.
Exemple de faux partage et correction
Cet exemple illustre le faux partage : deux compteurs situés sur la même ligne de cache provoquent un ralentissement massif lorsqu'ils sont incrémentés par des threads différents.
#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");
}Champs de bits et [[no_unique_address]] (C++20)
Les champs de bits précisent le nombre exact de bits qu'occupe un membre — utile pour les registres matériels et les en-têtes de protocole, mais leur disposition est définie par l'implémentation. [[no_unique_address]] (C++20) permet aux membres vides (suppresseurs sans état, allocateurs) d'occuper zéro octet en se superposant à d'autres membres, ce qui remplace l'ancien idiome de l'optimisation de la base vide (EBO).
#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. N'utilisez jamais memcpy pour sérialiser des structs entre plateformes — le remplissage et l'alignement des membres diffèrent selon les compilateurs et les architectures.
2. Portabilité des champs de bits : l'ordre des bits au sein d'un octet et le remplissage entre les champs de bits sont définis par l'implémentation. N'utilisez pas de champs de bits pour des formats d'échange multiplateformes — utilisez une manipulation de bits explicite.
3. #pragma pack n'est pas standard : compacter des structs pour supprimer le remplissage peut entraîner des accès mal alignés (perte de performances ou plantages sur les architectures à alignement strict comme ARM).
4. Ordonnez les membres du plus grand au plus petit : double, pointeurs (8 octets), puis int (4 octets), puis short (2 octets), puis char (1 octet). Cela minimise le remplissage interne. Pour les structs très sollicitées, utilisez static_assert(sizeof(MyStruct) <= 64) pour imposer des tailles compatibles avec une ligne de cache.
- Chaque type a une exigence d'alignement — le compilateur insère du remplissage pour la satisfaire
- Ordonnez les membres du plus grand au plus petit alignement pour minimiser le remplissage
- Les lignes de cache font 64 octets sur x86 — le faux partage entre threads est un piège de performance majeur
- Utilisez
alignas(64)pour placer les données propres à chaque thread sur des lignes de cache distinctes [[no_unique_address]](C++20) élimine le surcoût des membres vides- Les champs de bits compactent les données, mais leur disposition est définie par l'implémentation — ne les utilisez pas pour des formats d'échange
Quiz — Testez vos connaissances
(15 XP)1. Soit `struct S { char a; double b; char c; };` : quelle est la valeur probable de sizeof(S) sur une plateforme 64 bits ?
2. Qu'est-ce que le faux partage ?
3. Que fait `[[no_unique_address]]` (C++20) ?