انتقل إلى المحتوى

تخطيط الذاكرة والمحاذاة وتمثيل الكائنات

فهم متطلبات حشو البنية والمحاذاة، وتحسين ترتيب الأعضاء من حيث الحجم، والتعرف على خطوط ذاكرة التخزين المؤقت والمشاركة الخاطئة، واستخدام ميزات C++20 مثل [[no_unique_address]] للتخطيطات المضغوطة.

متطلبات المحاذاة

كل نوع في C++ له متطلبات محاذاة — يجب أن يكون العنوان الذي يجب وضع كائن من هذا النوع فيه مضاعفًا لمحاذاته. يعد هذا أحد متطلبات الأجهزة: لا تستطيع معظم وحدات المعالجة المركزية (CPUs) الوصول بكفاءة إلى البيانات غير المحاذاة، وبعض البنيات (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;
}

خطوط ذاكرة التخزين المؤقت والمشاركة الزائفة

لا تصل وحدات المعالجة المركزية (CPUs) الحديثة إلى الذاكرة بايتة بايت، بل تقوم بتحميل خطوط ذاكرة التخزين المؤقت بالكامل (عادةً 64 بايت على x86). عندما يقوم خيطان بتعديل المتغيرات الموجودة على نفس سطر ذاكرة التخزين المؤقت، يجب على الجهاز باستمرار إبطال هذا الخط وإعادة تحميله بين النوى. وهذا ما يسمى المشاركة الزائفة، ويمكن أن يقلل الأداء متعدد الخيوط بمقدار 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");
}

حقول البت و[[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 نقطة خبرة)

1. بالنظر إلى`struct S { char a; double b; char c; };`، ما هو الحجم المحتمل لـ (S) على نظام أساسي 64 بت؟

2. ما هي المشاركة الزائفة؟

3. ماذا يفعل`[[no_unique_address]]`(C++20)؟