सामग्री पर जाएं

मेमोरी लेआउट, अलाइनमेंट और ऑब्जेक्ट रिप्रेज़ेंटेशन

struct पैडिंग और अलाइनमेंट की आवश्यकताओं को समझें, साइज़ घटाने के लिए मेंबर का क्रम ऑप्टिमाइज़ करें, कैश लाइन और फ़ॉल्स शेयरिंग के बारे में जानें, और कॉम्पैक्ट लेआउट के लिए [[no_unique_address]] जैसे C++20 फ़ीचर इस्तेमाल करें।

अलाइनमेंट की आवश्यकताएँ

C++ के हर टाइप की एक अलाइनमेंट आवश्यकता (alignment requirement) होती है — उस टाइप के ऑब्जेक्ट को जिस एड्रेस पर रखा जाए, वह उसके अलाइनमेंट का गुणज (multiple) होना चाहिए। यह एक हार्डवेयर आवश्यकता है: ज़्यादातर CPU मिसअलाइन्ड डेटा को कुशलता से एक्सेस नहीं कर सकते, और कुछ आर्किटेक्चर (ARM, पुराने SPARC) मिसअलाइन्ड एक्सेस पर फ़ॉल्ट देते हैं।

x86-64 पर आम अलाइनमेंट:
- char: 1 बाइट
- short: 2 बाइट
- int, float: 4 बाइट
- double, long long, पॉइंटर: 8 बाइट
- long double: 16 बाइट (कुछ प्लेटफ़ॉर्म पर)

किसी struct का अलाइनमेंट उसके किसी भी मेंबर का सबसे बड़ा अलाइनमेंट होता है। अलाइनमेंट आवश्यकताओं को पूरा करने के लिए कंपाइलर मेंबर के बीच और struct के अंत में पैडिंग बाइट (padding bytes) डालता है।

व्यवहार में Struct पैडिंग

कंपाइलर हर मेंबर को अलाइन करने के लिए अदृश्य पैडिंग डालता है। मेंबर का क्रम struct के साइज़ को नाटकीय रूप से प्रभावित कर सकता है।

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

कैश लाइन और फ़ॉल्स शेयरिंग

आधुनिक CPU मेमोरी को बाइट-दर-बाइट एक्सेस नहीं करते — वे पूरी कैश लाइन (cache line) लोड करते हैं (x86 पर आमतौर पर 64 बाइट)। जब दो थ्रेड ऐसे वेरिएबल बदलते हैं जो संयोग से एक ही कैश लाइन पर हों, तो हार्डवेयर को उस लाइन को कोर के बीच लगातार इनवैलिडेट और रीलोड करना पड़ता है। इसे फ़ॉल्स शेयरिंग कहते हैं, और यह मल्टी-थ्रेडेड परफ़ॉर्मेंस को 10-100 गुना तक घटा सकता है।

फ़ॉल्स शेयरिंग तब होता है जब:
- दो थ्रेड अलग-अलग वेरिएबल में लिखते हैं
- वे वेरिएबल एक ही 64-बाइट कैश लाइन पर हैं
- CPU का कैश कोहेरेंस प्रोटोकॉल (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) खाली मेंबर (स्टेटलेस डिलीटर, एलोकेटर) को दूसरे मेंबर के साथ ओवरलैप करके शून्य बाइट घेरने देता है, और पुराने 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
}
सामान्य गलती

1. struct को प्लेटफ़ॉर्म के पार सीरियलाइज़ करने के लिए memcpy का कभी उपयोग न करें — पैडिंग और मेंबर अलाइनमेंट कंपाइलर और आर्किटेक्चर के बीच अलग होते हैं।

2. बिट फ़ील्ड की पोर्टेबिलिटी: एक बाइट के भीतर बिट का क्रम और बिट फ़ील्ड के बीच की पैडिंग इम्प्लीमेंटेशन-डिफ़ाइंड होती है। क्रॉस-प्लेटफ़ॉर्म वायर फ़ॉर्मेट के लिए बिट फ़ील्ड का उपयोग न करें — स्पष्ट बिट मैनिपुलेशन का उपयोग करें।

3. #pragma pack गैर-मानक है: पैडिंग हटाने के लिए struct को पैक करने से मिसअलाइन्ड एक्सेस हो सकता है (ARM जैसे सख़्त-अलाइनमेंट वाले आर्किटेक्चर पर परफ़ॉर्मेंस में कमी या क्रैश)।

4. मेंबर को सबसे बड़े से सबसे छोटे क्रम में रखें: double, पॉइंटर (8-बाइट), फिर int (4-बाइट), फिर short (2-बाइट), फिर char (1-बाइट)। इससे आंतरिक पैडिंग न्यूनतम होती है। हॉट struct के लिए, कैश-लाइन के अनुकूल साइज़ लागू करने हेतु static_assert(sizeof(MyStruct) <= 64) का उपयोग करें।

मुख्य बातें
  • हर टाइप की एक अलाइनमेंट आवश्यकता होती है — उसे पूरा करने के लिए कंपाइलर पैडिंग डालता है
  • पैडिंग घटाने के लिए मेंबर को सबसे बड़े से सबसे छोटे अलाइनमेंट के क्रम में रखें
  • x86 पर कैश लाइन 64 बाइट की होती है — थ्रेड के बीच फ़ॉल्स शेयरिंग परफ़ॉर्मेंस का एक बड़ा जाल है
  • प्रति-थ्रेड डेटा को अलग कैश लाइन पर रखने के लिए alignas(64) का उपयोग करें
  • [[no_unique_address]] (C++20) खाली मेंबर का ओवरहेड ख़त्म कर देता है
  • बिट फ़ील्ड डेटा को कसकर पैक करते हैं लेकिन उनका लेआउट इम्प्लीमेंटेशन-डिफ़ाइंड होता है — वायर फ़ॉर्मेट के लिए इस्तेमाल न करें

क्विज़ — अपना ज्ञान परखें

(15 XP)

1. `struct S { char a; double b; char c; };` दिया गया है। 64-बिट प्लेटफ़ॉर्म पर sizeof(S) संभवतः क्या होगा?

2. फ़ॉल्स शेयरिंग क्या है?

3. `[[no_unique_address]]` (C++20) क्या करता है?