رفتن به محتوا

چیدمان حافظه، هم‌ترازی و نمایش شیء

الزامات لایه‌گذاری و هم‌ترازی ساختار را درک کنید، ترتیب اعضا را برای اندازه بهینه‌سازی کنید، درباره خطوط کش و اشتراک‌گذاری کاذب بیاموزید و از ویژگی‌های ‎C++‎20 مانند [[no_unique_address]] برای چیدمان‌های فشرده استفاده کنید.

الزامات هم‌ترازی

هر نوع در ‎C++‎ یک الزام هم‌ترازی (alignment requirement) دارد — آدرسی که شیء از آن نوع باید در آن قرار گیرد باید مضربی از هم‌ترازی آن باشد. این یک الزام سخت‌افزاری است: بیشتر پردازنده‌ها نمی‌توانند به‌صورت کارآمد به داده‌های نامرتب دسترسی پیدا کنند و برخی معماری‌ها (ARM، SPARC قدیمی) روی دسترسی نامرتب خطا می‌دهند.

هم‌ترازی‌های رایج در x86-64:
- char: ۱ بایت
- short: ۲ بایت
- int، float: ۴ بایت
- double، long long، اشاره‌گرها: ۸ بایت
- long double: ۱۶ بایت (در برخی پلتفرم‌ها)

هم‌ترازی ساختار بزرگ‌ترین هم‌ترازی هر یک از اعضایش است. کامپایلر بایت‌های لایه‌گذاری (padding) بین اعضا و در انتهای ساختار برای رعایت الزامات هم‌ترازی درج می‌کند.

لایه‌گذاری ساختار در عمل

کامپایلر لایه‌گذاری نامرئی برای هم‌تراز کردن هر عضو درج می‌کند. ترتیب اعضا می‌تواند به‌طور چشمگیری بر اندازه ساختار تأثیر بگذارد.

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
}

alignas و alignof

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;
}

خطوط کش و اشتراک‌گذاری کاذب

پردازنده‌های مدرن به حافظه بایت‌به‌بایت دسترسی پیدا نمی‌کنند — آن‌ها کل خطوط کش (معمولاً ۶۴ بایت در x86) را بارگذاری می‌کنند. وقتی دو نخ (thread) متغیرهایی را تغییر می‌دهند که اتفاقاً روی یک خط کش هستند، سخت‌افزار باید دائماً آن خط را بین هسته‌ها بی‌اعتبار و بازبارگذاری کند. این اشتراک‌گذاری کاذب (false sharing) نامیده می‌شود و می‌تواند عملکرد چندنخی را ۱۰ تا ۱۰۰ برابر کاهش دهد.

اشتراک‌گذاری کاذب رخ می‌دهد وقتی:
- دو نخ به متغیرهای مختلف می‌نویسند
- آن متغیرها روی یک خط کش ۶۴ بایتی هستند
- پروتکل انسجام کش CPU (MESI) جهش مداوم خط کش را اجبار می‌کند

راه‌حل: متغیرهای داغ را لایه‌گذاری یا هم‌تراز کنید تا روی خطوط کش جداگانه قرار گیرند. از alignas(64) یا std::hardware_destructive_interference_size (‎C++‎17، اما در همه کامپایلرها پشتیبانی نمی‌شود) استفاده کنید.

برعکس، اشتراک‌گذاری واقعی (true sharing) زمانی است که چندین متغیر مرتبط با هم دسترسی پیدا می‌شوند. قرار دادن آن‌ها روی یک خط کش عملکرد را بهبود می‌بخشد — این 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");
}

فیلدهای بیتی و [[no_unique_address]] (‎C++‎20)

فیلدهای بیتی (bit fields) تعداد دقیق بیت‌هایی که یک عضو اشغال می‌کند را مشخص می‌کنند — مفید برای رجیسترهای سخت‌افزاری و سرآیندهای پروتکل، اما چیدمانشان وابسته به پیاده‌سازی (implementation-defined) است. [[no_unique_address]] (‎C++‎20) اجازه می‌دهد اعضای خالی (حذف‌کننده‌ها، تخصیص‌دهنده‌های بدون حالت) صفر بایت اشغال کنند با هم‌پوشانی با اعضای دیگر، و جایگزین الگوی قدیمی بهینه‌سازی پایه خالی (Empty Base Optimization) می‌شود.

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
}
دام

۱. هرگز از memcpy برای سریالی‌سازی ساختارها بین پلتفرم‌ها استفاده نکنید — لایه‌گذاری و هم‌ترازی اعضا بین کامپایلرها و معماری‌ها متفاوت است.

۲. قابلیت حمل فیلد بیتی: ترتیب بیت‌ها درون بایت و لایه‌گذاری بین فیلدهای بیتی وابسته به پیاده‌سازی است. از فیلدهای بیتی برای فرمت‌های سیمی بین‌پلتفرمی استفاده نکنید — از دستکاری صریح بیت استفاده کنید.

۳. #pragma pack غیراستاندارد است: فشرده‌سازی ساختارها برای حذف لایه‌گذاری می‌تواند باعث دسترسی نامرتب شود (افت عملکرد یا خرابی در معماری‌های هم‌ترازی سختگیرانه مانند ARM).

۴. اعضا را از بزرگ‌ترین به کوچک‌ترین ترتیب دهید: double، اشاره‌گرها (۸ بایت)، سپس int (۴ بایت)، سپس short (۲ بایت)، سپس char (۱ بایت). این لایه‌گذاری داخلی را به حداقل می‌رساند. برای ساختارهای داغ، از static_assert(sizeof(MyStruct) <= 64) برای اعمال اندازه‌های سازگار با خط کش استفاده کنید.

نکات کلیدی
  • هر نوع الزام هم‌ترازی دارد — کامپایلر برای رعایت آن لایه‌گذاری درج می‌کند
  • اعضا را از بزرگ‌ترین تا کوچک‌ترین هم‌ترازی ترتیب دهید تا لایه‌گذاری به حداقل برسد
  • خطوط کش در x86 شصت و چهار بایت هستند — اشتراک‌گذاری کاذب بین نخ‌ها دام عملکردی بزرگی است
  • از alignas(64) استفاده کنید تا داده‌های هر نخ روی خطوط کش جداگانه قرار گیرند
  • [[no_unique_address]] (‎C++‎20) سربار اعضای خالی را حذف می‌کند
  • فیلدهای بیتی داده‌ها را فشرده بسته‌بندی می‌کنند اما چیدمان وابسته به پیاده‌سازی دارند — برای فرمت‌های سیمی استفاده نکنید

آزمون — دانش خود را بسنجید

(15 امتیاز)

1. با فرض `struct S { char a; double b; char c; };`، sizeof(S) احتمالاً روی پلتفرم ۶۴ بیتی چقدر است؟

2. اشتراک‌گذاری کاذب چیست؟

3. `[[no_unique_address]]` (‎C++‎20) چه کار می‌کند؟