Перейти к содержимому

Планировка памяти, выравнивание и представление объектов

Поймите требования к настройке структуры и выравниванию, оптимизируйте заказ членов для размера, узнайте о кэш-линиях и ложном совместном использовании и используйте функции C++20, такие как [[no unique address]] для компактных макетов.

Требования к выравниванию

Каждый тип в C++ имеет требование выравнивания - адрес, где должен быть размещен объект этого типа, должен быть кратным его выравниванию. Это аппаратное требование: большинство процессоров не могут эффективно получать доступ к несоответствующим данным, а некоторые архитектуры (ARM, более старый SPARC) будут винить в несоответствующем доступе.

Общие положения x86-64:
- char: 1 байт
- short: 2 байта
- int, float: 4 байта
- double, long long, указатели: 8 байт
long double: 16 байт (на некоторых платформах)

Выравнивание структуры является самым большим выравниванием любого из ее членов. Компилятор вставляет байты между участниками и в конце структуры для удовлетворения требований к выравниванию.

Конструктивный падинг на практике

Компилятор вставляет невидимую накладку для выравнивания каждого члена. Заказ членов может существенно повлиять на размер структуры.

struct_padding.cpp
#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
}

выравнивание и выравнивание

alignof(T) запрашивает выравнивание типа. alignas(N) запрашивает определенное выравнивание для переменной или типа. Они необходимы для SIMD, буферов DMA и выравнивания кэш-линии.

alignas_alignof.cpp
#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

Современные процессоры не получают доступ к памяти байт за байтом — они загружают целые строки кэша (обычно * * 64 байта на x86 ). Когда два потока модифицируют переменные, которые находятся на одной линии кэша , аппаратное обеспечение должно постоянно отключать и перезагружать эту линию между ядрами. Это называется false sharing, и это может снизить многопоточную производительность на 10-100x.

Подделка происходит, когда:
Две нити пишут на разные переменные
Эти переменные находятся на той же 64-байтовой линии кэша.
Протокол когерентности кэша процессора (MESI) заставляет постоянно подпрыгивать линию кэша

Фиксация: выравнивание горячих переменных для разделения линий кэша. Используйте alignas(64) или std::hardware_destructive_interference_size (C++17, но не поддерживается на всех компиляторах).

И наоборот, истинное совместное использование — это когда несколько связанных переменных доступны вместе. Размещение их на одной линии кэша повышает производительность — это std::hardware_constructive_interference_size.

Ложный пример и исправление

Этот пример демонстрирует ложное совместное использование: два счетчика на одной и той же линии кэша вызывают массовое замедление при увеличении различными потоками.

false_sharing.cpp
#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");
}

Bit Fields & [[no unique address]] (C++20)

Битовые поля указывают точное количество битов, занимаемых членом — полезно для аппаратных регистров и заголовков протоколов, но их расположение определяется реализацией. [[no_unique_address]] (C++20) позволяет пустым членам (удалителям без состояния, распределителям) занимать нулевые байты, перекрываясь с другими членами, заменяя старый шаблон оптимизации пустой базы.

bit_fields_no_unique_address.cpp
#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. Никогда не используйте memcpy для сериализации структур на разных платформах — накладка и выравнивание членов различаются между компиляторами и архитектурами.

2.Переносимость битового поля: Порядок битов в байте и набивки между битовыми полями определяется реализацией. Не используйте битовые поля для кроссплатформенных форматов проводов — используйте явные битовые манипуляции.

3. #pragma pack является нестандартным: Структуры упаковки для удаления прокладки могут привести к неправильному доступу (потеря производительности или сбои в архитектурах строгого выравнивания, таких как ARM).

**4. Члены заказов: double, указатели (8-байт), затем int (4-байт), затем short (2-байт), затем char (1-байт). Это минимизирует внутреннюю прокладку. Для горячих структур используйте static_assert(sizeof(MyStruct) <= 64) для обеспечения удобных размеров кэш-линии.

Ключевые выводы
  • Каждый тип имеет требование выравнивания — компилятор вставляет накладку, чтобы удовлетворить его.
  • Заказ членов от наибольшего до наименьшего выравнивания для минимизации прокладки
  • Линии кэша составляют 64 байта на x86 — ложное разделение между потоками является основной ловушкой производительности.
  • Используйте alignas(64) для размещения данных на одну нить на отдельных строках кэша
  • [[no_unique_address]] (C++20) устраняет накладные расходы на пустые члены
  • Битовые поля плотно упаковывают данные, но имеют определенную реализацию - не используйте для проводных форматов

Тест — проверьте свои знания

(15 XP)

1. Учитывая `struct S { char a; double b; char c; };`, каков вероятный размер S на 64-битной платформе?

2. Что такое ложный обмен?

3. Что делает `[[no_unique_address]]` (C++20)?