Speicherlayout, Alignment & Objektdarstellung
Verstehe Padding und Alignment-Anforderungen in Structs, optimiere die Reihenfolge der Member für kompakte Größen, lerne Cache-Lines und False Sharing kennen und nutze C++20-Features wie [[no_unique_address]] für kompakte Layouts.
Alignment-Anforderungen
Jeder Typ in C++ hat eine Alignment-Anforderung — die Adresse, an der ein Objekt dieses Typs liegt, muss ein Vielfaches seines Alignments sein. Das ist eine Hardware-Anforderung: Die meisten CPUs können auf nicht ausgerichtete Daten nicht effizient zugreifen, und manche Architekturen (ARM, ältere SPARC) lösen bei nicht ausgerichteten Zugriffen sogar einen Fehler aus.
Übliche Alignments auf x86-64:
- char: 1 Byte
- short: 2 Bytes
- int, float: 4 Bytes
- double, long long, Zeiger: 8 Bytes
- long double: 16 Bytes (auf manchen Plattformen)
Das Alignment eines Structs entspricht dem größten Alignment all seiner Member. Der Compiler fügt zwischen den Membern und am Ende des Structs Padding-Bytes ein, um die Alignment-Anforderungen zu erfüllen.
Struct-Padding in der Praxis
Der Compiler fügt unsichtbares Padding ein, um jeden Member auszurichten. Die Reihenfolge der Member kann die Größe eines Structs drastisch verändern.
#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 und alignof
alignof(T) fragt das Alignment eines Typs ab. alignas(N) fordert ein bestimmtes Alignment für eine Variable oder einen Typ an. Beides ist unverzichtbar für SIMD, DMA-Puffer und die Ausrichtung an Cache-Lines.
#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;
}Cache-Lines & False Sharing
Moderne CPUs greifen nicht Byte für Byte auf den Speicher zu — sie laden ganze Cache-Lines (auf x86 typischerweise 64 Bytes). Verändern zwei Threads Variablen, die zufällig in derselben Cache-Line liegen, muss die Hardware diese Line ständig zwischen den Kernen invalidieren und neu laden. Das nennt man False Sharing, und es kann die Performance bei mehreren Threads um den Faktor 10 bis 100 verschlechtern.
False Sharing tritt auf, wenn:
- zwei Threads in unterschiedliche Variablen schreiben,
- diese Variablen in derselben 64-Byte-Cache-Line liegen,
- das Cache-Kohärenzprotokoll der CPU (MESI) die Cache-Line ständig hin- und herschieben muss.
Die Lösung: Fülle oder richte heiß umkämpfte Variablen so aus, dass sie in getrennten Cache-Lines landen. Verwende alignas(64) oder std::hardware_destructive_interference_size (C++17, aber nicht von allen Compilern unterstützt).
Umgekehrt spricht man von True Sharing, wenn mehrere zusammengehörige Variablen gemeinsam verwendet werden. Sie in dieselbe Cache-Line zu legen verbessert die Performance — dafür gibt es std::hardware_constructive_interference_size.
False Sharing: Beispiel & Lösung
Dieses Beispiel führt False Sharing vor: Zwei Zähler in derselben Cache-Line bremsen das Programm massiv aus, wenn sie von verschiedenen Threads hochgezählt werden.
#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");
}Bitfelder & [[no_unique_address]] (C++20)
Bitfelder legen genau fest, wie viele Bits ein Member belegt — praktisch für Hardware-Register und Protokoll-Header, allerdings ist ihr Layout implementierungsabhängig. [[no_unique_address]] (C++20) erlaubt es, dass leere Member (zustandslose Deleter, Allokatoren) null Bytes belegen, indem sie sich mit anderen Membern überlappen — ein Ersatz für das ältere Muster der 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. Serialisiere Structs niemals plattformübergreifend per memcpy — Padding und Alignment der Member unterscheiden sich zwischen Compilern und Architekturen.
2. Portabilität von Bitfeldern: Die Reihenfolge der Bits innerhalb eines Bytes und das Padding zwischen Bitfeldern sind implementierungsabhängig. Verwende Bitfelder nicht für plattformübergreifende Datenformate auf der Leitung — nimm stattdessen explizite Bitmanipulation.
3. #pragma pack ist nicht standardisiert: Structs zu packen, um Padding zu entfernen, kann zu nicht ausgerichteten Zugriffen führen (Performanceverlust oder Abstürze auf Architekturen mit strengem Alignment wie ARM).
4. Ordne Member von groß nach klein: double und Zeiger (8 Byte), dann int (4 Byte), dann short (2 Byte), dann char (1 Byte). Das minimiert das interne Padding. Für heiß umkämpfte Structs erzwingst du mit static_assert(sizeof(MyStruct) <= 64) cache-line-freundliche Größen.
- Jeder Typ hat eine Alignment-Anforderung — der Compiler fügt Padding ein, um sie zu erfüllen
- Ordne Member vom größten zum kleinsten Alignment, um Padding zu minimieren
- Cache-Lines sind auf x86 64 Bytes groß — False Sharing zwischen Threads ist eine große Performance-Falle
- Verwende
alignas(64), um Daten je Thread in getrennte Cache-Lines zu legen [[no_unique_address]](C++20) beseitigt den Overhead leerer Member- Bitfelder packen Daten dicht, haben aber ein implementierungsabhängiges Layout — nicht für Datenformate auf der Leitung verwenden
Quiz — Teste dein Wissen
(15 XP)1. Wie groß ist sizeof(S) bei `struct S { char a; double b; char c; };` auf einer 64-Bit-Plattform vermutlich?
2. Was ist False Sharing?
3. Was bewirkt `[[no_unique_address]]` (C++20)?