Foundationপ্রথম নীতি থেকে
LEVEL 11লেসন ৪/১৭কঠিন৫৫ মিনিট

False Sharing — যখন সম্পূর্ণ ভিন্ন দুটো variable-ও একে অপরকে ধীর করে দেয়

False Sharing

দুটো সম্পূর্ণ আলাদা variable, দুটো আলাদা thread, কোনো true data dependency নেই — তবু ১০ গুণ ধীর। কারণ একটাই: তারা কাকতালীয়ভাবে একই ৬৪-বাইট cache line-এ পড়ে গেছে, আর গত লেসনের MESI protocol সেই line-টাকে প্রতিটা write-এ দুই core-এর মধ্যে ping-pong করাতে বাধ্য। এই লেসনের কেন্দ্রে আছে একটা measured সংখ্যা আর তার সমাধান — padding।

এই লেসন শেষে আপনি পারবেন

  • false sharing-কে সংজ্ঞায়িত করতে পারবেন, আর কেন এটা true sharing থেকে ভিন্ন — কোনো logical data dependency ছাড়াই শুধু physical co-location-এর কারণে ধীরগতি — তা ব্যাখ্যা করতে পারবেন
  • একটা struct-এর memory layout দেখে বলতে পারবেন দুটো field একই cache line-এ পড়বে কি না, আর সেখান থেকে false sharing-এর ঝুঁকি অনুমান করতে পারবেন
  • গত লেসনের MESI state machine ব্যবহার করে দেখাতে পারবেন কীভাবে false sharing আসলে M→I→M ট্রানজিশনের একটা ক্রমাগত চক্র তৈরি করে
  • padding, `alignas`, এবং `std::hardware_destructive_interference_size` দিয়ে false sharing সমাধান করতে পারবেন, আর trade-off (memory খরচ) ব্যাখ্যা করতে পারবেন
  • একটা বাস্তব benchmark চালিয়ে false sharing-এর ১০ গুণের বেশি ধীরগতি নিজের চোখে মাপতে পারবেন, আর `perf c2c` দিয়ে production কোডে এটা শনাক্ত করার পদ্ধতি বলতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত লেসনের শেষে একটা প্রশ্ন খোলা রাখা হয়েছিল: MESI protocol নিশ্চিত করে প্রতিটা core সবসময় সাম্প্রতিক তথ্য দেখে — কিন্তু সেই নিশ্চয়তার একটা দাম আছে, coherence traffic। একটা cache line যখন Modified অবস্থা থেকে Invalid-এ যায়, অন্য core-কে সেটা আবার নিয়ে আসতে হয় (Read-For-Ownership), আর এই পুরো ঘটনাটা একটা cross-core বার্তা-চালাচালি, কয়েক দশ থেকে কয়েকশো cycle-এর ব্যাপার।

স্বাভাবিক প্রশ্ন: এই খরচ কখন বাস্তবে ধরা পড়ে? উত্তর যদি হতো “শুধু যখন দুই thread সত্যিই একই variable-এ read-write করে”, তাহলে সমস্যাটা সহজ — সেটা তো race condition, আমরা lock দিয়ে বা atomic দিয়ে সেটা এমনিতেই সামলাই।

কিন্তু আসল ছবিটা অনেক বেশি বিরক্তিকর। নিচের কোডটা দেখুন:

struct counters {
    long thread0_count;   /* শুধু thread 0 লেখে */
    long thread1_count;   /* শুধু thread 1 লেখে */
};

কোনো race condition নেই — দুইটা variable, দুইজন লেখক, একে অপরের সাথে কোনো true data dependency নেই। কোনো lock দরকার নেই, কোনো atomic দরকার নেই, correctness নিয়ে চিন্তার কিছু নেই। তবু, এই দুইটা long যদি স্মৃতিতে পাশাপাশি বসে (যা এখানে হবেই — কোনো padding ছাড়া একটা struct-এ পরপর field-গুলো পাশাপাশিই বসে), আর দুইটা আলাদা core থেকে দুইটা thread একসাথে এগুলো বাড়াতে থাকে, তাহলে মাপা যায় এমন ফলাফল: একই algorithm, একই complexity, ১০ গুণের বেশি ধীর।

এটাই এই মডিউলের driving question-এর প্রথম সরাসরি, হাতে-গরম প্রমাণ। কোনো race নেই, কোনো bug নেই কোডে — শুধু দুইটা variable কাকতালীয়ভাবে একই ৬৪ বাইটের ভেতরে পড়ে গেছে। এই ঘটনার নাম false sharing।

মূল ধারণা

True sharing বনাম false sharing

গত লেসনে যা আলোচনা হয়েছিল সেটা ছিল true sharing — দুই বা তার বেশি core সত্যিই একই memory location পড়ছে-লিখছে, আর MESI protocol নিশ্চিত করছে তারা সবাই একটা সামঞ্জস্যপূর্ণ ছবি দেখে। এখানে coherence traffic প্রয়োজনীয় — এটা এড়ানোর উপায় নেই, কারণ প্রোগ্রামটাই দাবি করছে দুই core একই ডেটা share করুক।

False sharing সম্পূর্ণ ভিন্ন সমস্যা:

দুই বা তার বেশি thread ভিন্ন ভিন্ন, স্বাধীন memory location access করছে — কোনো logical data dependency নেই — কিন্তু সেই location-গুলো কাকতালীয়ভাবে একই cache line-এ বসে আছে। যেহেতু cache coherence protocol পুরো line-এর গ্রানুলারিটিতে কাজ করে (একটা byte-এর জন্য না), হার্ডওয়্যার এই দুইটা access-কে আলাদা করতেই পারে না — তার কাছে এটা একটাই line-এর উপর প্রতিযোগিতা মনে হয়, যদিও প্রোগ্রামের দৃষ্টিতে এখানে কোনো প্রতিযোগিতাই নেই।

True sharingFalse sharing
কী share হচ্ছেএকই variableভিন্ন variable, একই cache line
Logical dependencyআছেনেই
Correctness ঝুঁকিআছে (race condition)নেই
Performance ঝুঁকিআছে, প্রায়ই প্রয়োজনীয় খরচআছে, সম্পূর্ণ অপ্রয়োজনীয় খরচ
সমাধানlock / atomic / algorithm redesignশুধু layout বদলানো (padding)

শেষ সারিটাই সবচেয়ে গুরুত্বপূর্ণ পার্থক্য। True sharing-এর সমাধান জটিল হতে পারে — algorithm নিজেই বদলাতে হতে পারে। False sharing-এর সমাধান প্রায় সবসময় বিস্ময়করভাবে সহজ: কিছু বাইট memory নষ্ট করে দুইটা variable-কে আলাদা cache line-এ সরিয়ে দিন। কোনো algorithm বদলায় না, কোনো logic বদলায় না — শুধু memory layout।

কেন cache coherence protocol এটা এড়াতে পারে না

গত লেসনে দেখা হয়েছিল — MESI-র state (Modified, Exclusive, Shared, Invalid) একটা পুরো cache line-এর সাথে যুক্ত থাকে, কোনো একক byte বা variable-এর সাথে না। এটা কোনো ডিজাইন-ত্রুটি না — hardware-এর দৃষ্টিতে address space একটা flat বাইট-অ্যারে, আর memory bus/interconnect-এ ডেটা আনা-নেওয়া হয় fixed-size block-এ (সাধারণত ৬৪ বাইট) কারণ সেটাই DRAM row access আর bus transaction-এর জন্য দক্ষ (Level 3-এর cache-line লেসনের যুক্তি এখানে আক্ষরিকভাবে ফিরে আসছে)।

হার্ডওয়্যারের কাছে “এই line-এর ভেতরে দুইটা ভিন্ন logical variable আছে যাদের মধ্যে কোনো সম্পর্ক নেই” — এই তথ্যটা অস্তিত্বহীন। CPU শুধু ঠিকানা দেখে, byte offset দেখে না protocol-এর স্তরে। তাই protocol-এর দৃষ্টিতে “thread 0 এই line-এ লিখল” আর “thread 1 এই line-এ লিখল” — এই দুইটা ঘটনা সম্পূর্ণ একই রকম দেখায়, তারা একই variable-এ লিখুক বা না লিখুক।

ভেতরে কী ঘটছে

একটা concrete উদাহরণ, ধাপে ধাপে

উপরের struct counters-টা নিয়েই কাজ করি। ধরুন long ৮ বাইট (সাধারণ ৬৪-বিট সিস্টেমে), আর কোনো explicit padding নেই:

Offset   Field
------   -----------------
0        thread0_count   (৮ বাইট)
8        thread1_count   (৮ বাইট)

মোট ১৬ বাইট। একটা cache line ৬৪ বাইট (Level 3-এর আলোচনা অনুযায়ী, প্রায় সব আধুনিক x86-64/ARM64-এ)। তাই এই struct-টা সম্পূর্ণভাবে একটা single cache line-এর ভেতরে বসে যায় — বরং একটা line-এ এরকম চারটা struct counters ধরে যায়।

বাইট:   0        8        16   ...                    64
       |--------|--------|-----------------------------|
       | thread0| thread1|     (এই line-এর বাকি অংশ)     |
       | _count | _count |                              |
       |--------|--------|-----------------------------|
            ↑         ↑
       thread 0     thread 1
       এখানে লেখে   এখানে লেখে
একটা 64-বাইট cache line -- দুইটা counter একসাথে, কোনো ফাঁক নেই।

ট্রেস — লুপে কী ঘটে, cycle-বাই-cycle অর্থে

ধরুন thread 0 core 0-এ, thread 1 core 1-এ চলছে, দুজনেই একটা টাইট লুপে নিজ নিজ counter বাড়াচ্ছে:

void *worker0(void *arg) {
    for (long i = 0; i \< N; i++) shared.thread0_count++;
    return NULL;
}
void *worker1(void *arg) {
    for (long i = 0; i \< N; i++) shared.thread1_count++;
    return NULL;
}
একটা single iteration -- দুই core-এ কী ঘটে যখন cache line ping-pong করে
  1. শুরুর অবস্থাcache line M অবস্থায় core 1-এর cache-এ (গত iteration-এ thread1_count লিখেছিল)
  2. core 0: thread0_count++ চেষ্টাcore 0-এর cache-এ এই line নেই (বা Invalid) -- write miss
  3. RFO (Read For Ownership) পাঠানো হয়core 0 ইন্টারকানেক্টে অনুরোধ পাঠায় -- "এই line, exclusive access সহ"
  4. core 1: snoop hit, নিজের কপি Invalidate করেcore 1-এর M কপি ফ্লাশ হয়ে core 0-এ পাঠানো হয়, core 1-এ এখন Invalid
  5. core 0: line M অবস্থায় পায়, লেখেread-modify-write সম্পন্ন, thread0_count++ শেষ; কিন্তু thread1_count-এর মান বদলায়নি!
  6. core 1: পরের iteration-এ thread1_count++ চেষ্টাcore 1-এর cache-এ এখন এই line Invalid -- আবার একটা RFO, আবার একটা invalidate -- ঠিক উল্টো দিকে

লক্ষ করুন — thread0_count-এর মান কখনো পড়েনি thread 1, thread1_count-এর মান কখনো পড়েনি thread 0। তবু প্রতিটা ++ একটা সম্পূর্ণ cross-core cache-line transfer ঘটাচ্ছে, শুধু কারণ দুইটা variable একই line-এ। এটাই cache line ping-pong — line-টা M অবস্থায় বারবার এক core থেকে অন্য core-এ লাফাতে থাকে, যদিও প্রতিটা core আসলে তার নিজের variable-ই বদলাচ্ছে।

সমাধান — padding

সমাধানটা যান্ত্রিক: প্রতিটা counter-কে নিজের cache line-এ আলাদা করে দিন, যাতে হার্ডওয়্যার তাদের আলাদা করে দেখতে বাধ্য হয়।

struct counters_padded {
    long thread0_count;
    char pad0[64 - sizeof(long)];   /* বাকি line খালি রাখা */
    long thread1_count;
    char pad1[64 - sizeof(long)];
};

এখন thread0_count offset 0-এ, thread1_count offset 64-এ — দুইটা আলাদা cache line। core 0 আর core 1 এখন সত্যিকারের অর্থে independent, কোনো coherence traffic নেই তাদের মধ্যে।

আরো পরিষ্কার উপায় — alignas (C11/C++11):

#include <stdalign.h>

struct counters_aligned {
    alignas(64) long thread0_count;
    alignas(64) long thread1_count;
};

alignas(64) compiler-কে বলে দেয় প্রতিটা field-কে একটা ৬৪-বাইট সীমানায় align করতে — compiler নিজেই প্রয়োজনীয় padding বসিয়ে দেয়, hand-written char pad[] লেখার দরকার নেই।

C++17-এর portable constant — std::hardware_destructive_interference_size:

#include <new>

struct counters_cpp {
    alignas(std::hardware_destructive_interference_size) long thread0_count;
    alignas(std::hardware_destructive_interference_size) long thread1_count;
};

সংখ্যা 64 সরাসরি লেখার বদলে এই standard constant ব্যবহার করা ভালো অভ্যাস — কিছু ARM64 বা বিশেষ server CPU-তে cache line ১২৮ বাইট হতে পারে, আর এই constant compiler/platform অনুযায়ী সঠিক মান দেয়। (নাম নিজেই ইঙ্গিতপূর্ণ — “destructive interference” মানে “এর চেয়ে কাছাকাছি রাখলে একে অপরকে ধ্বংসাত্মকভাবে প্রভাবিত করবে”, অর্থাৎ false sharing-এর ঝুঁকি।)

Struct-of-arrays বনাম array-of-structs — একটা layout সিদ্ধান্ত যা false sharing বিনামূল্যে এড়ায়

Padding একটা প্রতিক্রিয়াশীল সমাধান — সমস্যা ধরা পড়ার পরে বাইট যোগ করা। একটা প্রতিরোধমূলক কৌশলও আছে, যা প্রায়ই false sharing-এর প্রশ্নটাই তোলে না — data layout-এর মৌলিক পছন্দ।

Array-of-structs (AoS) — সাধারণ, স্বাভাবিক পছন্দ:

struct worker { long items; long errors; };
struct worker workers[N];   /* workers[0].items, workers[0].errors পাশাপাশি */

এখানে প্রতিটা worker-এর সব field একসাথে, কিন্তু ভিন্ন worker-দের field একে অপরের কাছাকাছি — ঠিক আগের প্রশ্নের সমস্যা।

Struct-of-arrays (SoA) — field অনুযায়ী আলাদা array:

long items_all[N];    /* সব worker-এর items, একসাথে */
long errors_all[N];   /* সব worker-এর errors, একসাথে */

SoA-তে items_all[i]-তে লেখা আর items_all[i+1]-এ লেখা এখনো একই cache line-এ পড়তে পারে (তাই SoA নিজে থেকেই false sharing দূর করে না) — কিন্তু SoA-র আসল সুবিধা ভিন্ন জায়গায়: যদি monitoring thread শুধু items_all[] স্ক্যান করে (সব worker-এর items যোগ করতে), সেই স্ক্যান sequential আর compact — cache-friendly, prefetcher-friendly। AoS-এ একই কাজ করতে প্রতিটা worker[i].items পড়তে পুরো worker[i] struct cache-এ আনতে হয় (৮ বাইট দরকার, ২৪+ বাইট আনা হচ্ছে) — bandwidth অপচয়।

তাই SoA বনাম AoS একটা ভিন্ন trade-off সমাধান করে (locality of access pattern), কিন্তু প্রায়ই একইসাথে সাহায্য করে false-sharing এড়াতেও — কারণ SoA-তে “কে কোন field লেখে” আর “সেই field-গুলো memory-তে কোথায়” এই দুইটা প্রশ্ন আলাদা হয়ে যায়, আর প্রতিটা array আলাদাভাবে align/pad করা সহজ হয়। GPU প্রোগ্রামিং-এ (এই মডিউলের পরের অংশে, SIMD/SIMT লেসনে) SoA প্রায় সবসময় পছন্দের কারণ coalesced memory access — একটা সম্পর্কিত কিন্তু ভিন্ন hardware সুবিধা।

উদাহরণ

সংখ্যায় পার্থক্য — একটা বাস্তবসম্মত হিসাব

ধরুন প্রতিটা thread ৫০ কোটি (৫×১০⁸) বার নিজের counter বাড়ায়, দুইটা thread, দুইটা আলাদা core। নিচের সংখ্যাগুলো একটা typical আধুনিক ৪-কোর desktop-শ্রেণির CPU-তে (৩ GHz এর কাছাকাছি) পরিমাপের প্রতিনিধিত্বমূলক — ঠিক সংখ্যা মেশিনভেদে বদলাবে, কিন্তু অনুপাতটা ধারাবাহিকভাবে দেখা যায়:

সংস্করণমোট সময় (২ thread)প্রতি opমন্তব্য
Unpadded (false sharing)~১৪ সেকেন্ড~২৮ nsপ্রতিটা ++ একটা cross-core RFO ট্রিগার করছে
Padded (alignas(64))~১.২ সেকেন্ড~২.৪ nsদুই core সম্পূর্ণ স্বাধীন, প্রায় single-thread-এর গতি
স্লোডাউন~১১.৭×একই algorithm, একই instruction count, শুধু layout ভিন্ন

এই ১১.৭× সংখ্যাটা algorithm বা complexity-র কোনো পার্থক্য থেকে আসেনি — thread0_count++ উভয় সংস্করণেই ঠিক একই x86 instruction (inc বা add)। পার্থক্যটা সম্পূর্ণভাবে এসেছে memory layout থেকে, MESI protocol-এর মধ্য দিয়ে। এটাই এই মডিউলের driving question-এর সবচেয়ে খাঁটি উদাহরণ: একই algorithm, একই complexity — তবু ১০ গুণের বেশি ধীর, শুধু hardware-এর ৬৪-বাইট গ্রানুলারিটির কারণে।

নিজে চালিয়ে দেখুন

EXPERIMENT

False sharing নিজের চোখে মাপুন — padded বনাম unpadded

Linux/macOS/Windows, gcc বা clang, ≥ 2 core· ২০ মিনিট
/* false_sharing.c -- gcc -O2 -pthread -o false_sharing false_sharing.c */
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdint.h>
#include <time.h>
#include <sched.h>

#define ITERS 500000000UL

/* মোড ০ -- ভাঙা: একই cache line-এ */
struct unpadded {
    volatile long a;
    volatile long b;
};
static struct unpadded bad;

/* মোড ১ -- ঠিক: আলাদা cache line-এ */
struct padded {
    volatile long a;
    char pad[64 - sizeof(long)];
    volatile long b;
    char pad2[64 - sizeof(long)];
};
static struct padded good;

static int mode;

static void pin_to(int cpu) {
    cpu_set_t set;
    CPU_ZERO(&set);
    CPU_SET(cpu, &set);
    sched_setaffinity(0, sizeof(set), &set);
}

void *worker_a(void *arg) {
    pin_to(0);
    for (unsigned long i = 0; i \< ITERS; i++) {
        if (mode == 0) bad.a++; else good.a++;
    }
    return NULL;
}
void *worker_b(void *arg) {
    pin_to(1);
    for (unsigned long i = 0; i \< ITERS; i++) {
        if (mode == 0) bad.b++; else good.b++;
    }
    return NULL;
}

int main(int argc, char **argv) {
    mode = argc > 1 ? atoi(argv[1]) : 0;
    pthread_t t0, t1;
    struct timespec ts0, ts1;

    clock_gettime(CLOCK_MONOTONIC, &ts0);
    pthread_create(&t0, NULL, worker_a, NULL);
    pthread_create(&t1, NULL, worker_b, NULL);
    pthread_join(t0, NULL);
    pthread_join(t1, NULL);
    clock_gettime(CLOCK_MONOTONIC, &ts1);

    double sec = (ts1.tv_sec - ts0.tv_sec) + (ts1.tv_nsec - ts0.tv_nsec) / 1e9;
    printf("mode=%d (%s)  সময় = %.3f s  = %.2f ns/op\n",
           mode, mode == 0 ? "unpadded" : "padded",
           sec, sec * 1e9 / (2.0 * ITERS));
    return 0;
}
gcc -O2 -pthread -o false_sharing false_sharing.c
./false_sharing 0     # unpadded -- false sharing
./false_sharing 1     # padded -- ঠিক

প্রত্যাশিত ধরনের output:

mode=0 (unpadded)  সময় = 14.021 s  = 28.04 ns/op
mode=1 (padded)    সময় = 1.198 s  = 2.40 ns/op

~১২× পার্থক্য, একই কোড-লজিক, শুধু struct layout বদলেছে। নিজের মেশিনে চালিয়ে দেখুন — অনুপাতটা CPU architecture, core সংখ্যা আর interconnect অনুযায়ী বদলাবে, কিন্তু দিকটা (unpadded সবসময় উল্লেখযোগ্যভাবে ধীর) সব multi-core মেশিনে ধারাবাহিক।

এটা কী প্রমাণ করে

দুইটা সম্পূর্ণ independent variable একই cache line-এ থাকলে, শুধু layout বদলে ১০ গুণের বেশি throughput পার্থক্য পাওয়া যায় — কোনো algorithm বা logic বদল ছাড়াই।

EXPERIMENT

Production কোডে false sharing শনাক্ত করা -- perf c2c

Linux (perf, root বা perf_event_paranoid শিথিল করা)· ১৫ মিনিট

উপরের false_sharing প্রোগ্রামের mode=0 সংস্করণ চালিয়ে perf c2c (cache-to-cache) দিয়ে সরাসরি দেখুন:

perf c2c record -- ./false_sharing 0
perf c2c report

Output-এর গুরুত্বপূর্ণ অংশ একটা “Shared Data Cache Line Table” দেখাবে, যেখানে bad struct-এর ঠিকানাটা তালিকার শীর্ষে থাকবে — “HITM” (Hit Modified) কলামে বড় সংখ্যা, মানে বারবার অন্য core-এর Modified কপি থেকে data আনতে হয়েছে। এটাই ঠিক উপরের LayerTrace-এ বর্ণিত RFO/invalidate চক্রের সরাসরি hardware-level প্রমাণ — PMU (Performance Monitoring Unit) counter দিয়ে মাপা, অনুমান নয়।

perf c2c না থাকলে বিকল্প সূত্র: perf stat -e cache-misses,LLC-load-misses দিয়ে unpadded বনাম padded সংস্করণে cache-miss সংখ্যার বিশাল পার্থক্য দেখা যাবে, যদিও এটা perf c2c-র মতো সরাসরি “কোন line” নির্দেশ করে না।

এটা কী প্রমাণ করে

একটা বাস্তব profiling টুল সরাসরি cache-line-ping-pong পরিমাপ করতে পারে, শুধু অনুমান নয় -- production-এ এই একই কৌশলে false sharing খুঁজে বের করা হয়।

নিজে বানান

BUILD IT

একটা false-sharing-detector মাইক্রো-বেঞ্চমার্ক হার্নেস

C · ●●●○○
  1. N থ্রেড আর একটা array of counters নিন -- প্রথমে naive (adjacent longs)
  2. প্রতিটা counter-কে তার নিজের core-এ pin করা thread দিয়ে বাড়ান, উচ্চ iteration count
  3. একই বেঞ্চমার্ক দ্বিতীয়বার চালান, এবার প্রতিটা counter-কে alignas(64) দিয়ে আলাদা করে
  4. দুই সংস্করণের throughput তুলনা করে একটা স্লোডাউন-অনুপাত রিপোর্ট করুন
  5. N (thread সংখ্যা) ২, ৪, ৮ করে বাড়িয়ে দেখুন স্লোডাউন-অনুপাত কীভাবে বদলায়
/* fs_bench.c -- gcc -O2 -pthread -o fs_bench fs_bench.c */
#define _GNU_SOURCE
#include <pthread.h>
#include <stdio.h>
#include <stdlib.h>
#include <sched.h>
#include <time.h>

#define ITERS 200000000UL
#define MAXN  8

static int N;
static int padded_mode;

/* একই array, দুই ভিন্ন layout-এ ব্যাখ্যা করা হবে stride দিয়ে */
struct slot { volatile long v; char pad[64 - sizeof(long)]; };
static volatile long   tight[MAXN];          /* কোনো ফাঁক নেই -- false sharing */
static struct slot     spaced[MAXN];         /* ৬৪ বাইট আলাদা -- ঠিক */

static void pin_to(int cpu) {
    cpu_set_t set; CPU_ZERO(&set); CPU_SET(cpu, &set);
    sched_setaffinity(0, sizeof(set), &set);
}

void *worker(void *arg) {
    long id = (long)arg;
    pin_to((int)id);
    for (unsigned long i = 0; i \< ITERS; i++) {
        if (padded_mode) spaced[id].v++;
        else             tight[id]++;
    }
    return NULL;
}

double run(int n, int padded) {
    N = n; padded_mode = padded;
    pthread_t t[MAXN];
    struct timespec t0, t1;

    clock_gettime(CLOCK_MONOTONIC, &t0);
    for (long i = 0; i \< n; i++) pthread_create(&t[i], NULL, worker, (void *)i);
    for (int i = 0; i \< n; i++)  pthread_join(t[i], NULL);
    clock_gettime(CLOCK_MONOTONIC, &t1);

    return (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
}

int main(void) {
    printf("%6s %14s %14s %10s\n", "N", "tight (s)", "spaced (s)", "ratio");
    for (int n = 2; n \<= MAXN; n *= 2) {
        double tt = run(n, 0);
        double ts = run(n, 1);
        printf("%6d %14.3f %14.3f %10.2fx\n", n, tt, ts, tt / ts);
    }
    return 0;
}
gcc -O2 -pthread -o fs_bench fs_bench.c && ./fs_bench

প্রত্যাশিত ধরনের প্যাটার্ন:

     N      tight (s)     spaced (s)      ratio
     2          14.10           1.21      11.65x
     4          19.40           1.25      15.52x
     8          24.70           1.30      19.00x

থ্রেড সংখ্যা বাড়ার সাথে সাথে স্লোডাউন-অনুপাত বাড়ে — tight সংস্করণে সব counter একই এক বা দুইটা cache line-এ গাদাগাদি করে থাকে (৮টা long = ৬৪ বাইট = ঠিক এক line), তাই ৮টা thread-ই একই line-এর জন্য প্রতিযোগিতা করছে, অথচ spaced সংস্করণে প্রতিটা thread সম্পূর্ণ স্বাধীন — তাই তার সময় N বাড়লেও প্রায় স্থির থাকে।

নিজে বাড়ান:

  1. MAXN বাড়িয়ে দেখুন সিস্টেমের core সংখ্যা ছাড়িয়ে গেলে কী হয় (hyperthreading/SMT sibling-এর প্রভাব)
  2. Cache line size ৬৪ না ধরে নিয়ে sysconf বা /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size দিয়ে runtime-এ পড়ে নিন, আর padding সেই অনুযায়ী গণনা করুন
  3. একটা তৃতীয় মোড যোগ করুন — সব thread একই core-এ pin করা — যাতে false sharing-এর প্রভাব শুধু multi-core-এই দেখা যায় তা নিশ্চিত হয়

বাস্তব সিস্টেমে

False sharing যেখানে সত্যিই ধরা পড়েছে

LMAX Disruptor (Java)। যে blog post false sharing-কে mainstream করেছিল, সেটাই এই library-র ডিজাইন থেকে এসেছে — একটা lock-free ring buffer-এর sequence counter (producer cursor, consumer cursor) ইচ্ছাকৃতভাবে ৭টা long দিয়ে padded, যাতে তারা কখনো একে অপরের সাথে একই cache line ভাগ না করে। এই একটা পরিবর্তনই throughput কয়েকগুণ বাড়িয়েছিল বলে রিপোর্ট করা হয়েছে।

Java-র @Contended annotation (JEP 142, Java 8+)। java.util.concurrent প্যাকেজের ভেতরে (যেমন ConcurrentHashMap-এর counter cell, ForkJoinPool-এর work-queue) @sun.misc.Contended ব্যবহার করা হয় ঠিক এই সমস্যা এড়াতে — JVM নিজেই সেই field-কে আলাদা cache line-এ align করে দেয়, প্রোগ্রামারকে হাতে padding লিখতে হয় না।

C++17-এর std::hardware_destructive_interference_size। ভাষা-স্তরে এই সমস্যাটাকে “গুরুত্বপূর্ণ যথেষ্ট” মনে করা হয়েছে যে standard-এ একটা constant যোগ করা হয়েছে — কোনো আলাদা library ছাড়াই portable padding লেখার উপায়।

Linux kernel per-CPU variables। Kernel প্রতিটা CPU-র জন্য আলাদা copy রাখে অনেক frequently-updated counter-এর (যেমন vmstat counter) — এটা আসলে false sharing এড়ানোর একটা চরম রূপ: সমস্যাটাই দূর করে দেওয়া, শুধু padding না করে সম্পূর্ণ আলাদা physical storage দিয়ে (DEFINE_PER_CPU macro)।

Database engine-এর lock/latch statistics। PostgreSQL এবং অন্যান্য database engine-এর ভেতরে per-backend statistics counter প্রায়ই cache-line-aligned রাখা হয়, কারণ শত শত backend process একসাথে সেগুলো আপডেট করে — align না করলে পুরো statistics-collection subsystem নিজেই একটা bottleneck হয়ে যায়।

Thread pool-এর per-worker queue। Work-stealing thread pool-এ (যেমন Go-র scheduler, Java-র ForkJoinPool, Rust-এর Rayon) প্রতিটা worker-এর নিজস্ব local queue-র metadata (head/tail pointer) সাধারণত cache-line-padded রাখা হয় — নাহলে ভিন্ন worker-এর queue metadata একে অপরের সাথে false sharing করবে, যদিও প্রতিটা worker যুক্তিসঙ্গতভাবে নিজের queue-ই সামলাচ্ছে।

Intel VTune-এর “Memory Access” analysis। perf c2c-র মতোই, কিন্তু GUI-সহ আর PMU counter-এর গভীরতর ব্যাখ্যা সহ — VTune সরাসরি “False Sharing” নামের একটা category-তে hot cache line-গুলো র‍্যাঙ্ক করে দেখায়, প্রোডাকশন performance engineering টিমে এটাই প্রায়শই প্রথম টুল যা চালানো হয় যখন একটা multi-threaded কোড “কোর বাড়ালেও দ্রুত হচ্ছে না” বলে সন্দেহ হয়।

perf c2c real production incident। বড় সার্ভিসগুলোতে (অনেক প্রযুক্তি কোম্পানির internal performance engineering টিমে প্রকাশিত case study অনুযায়ী) perf c2c দিয়ে হঠাৎ ধীর হয়ে যাওয়া একটা hot function-এ false sharing ধরা পড়ার ঘটনা সাধারণ — সাধারণত দুটো ভিন্ন, সম্পর্কহীন কাউন্টার/flag যেগুলো কোনো এক refactor-এর পর কাকতালীয়ভাবে একই struct-এ পাশাপাশি এসে পড়েছিল।

যে ভুলগুলো সবাই করে

“False sharing শুধু তখনই ঘটে যখন দুইটা thread একই memory address-এ লেখে।”

সম্পূর্ণ উল্টো — যদি দুইটা thread সত্যিই একই address-এ লিখত, সেটা একটা race condition, false sharing না। False sharing সংজ্ঞা অনুযায়ীই ধরে নেয় থ্রেডগুলো ভিন্ন, সম্পূর্ণ স্বাধীন address-এ লিখছে — সমস্যাটা শুধু তারা একই ৬৪-বাইট line-এর ভেতরে পড়ে যাওয়া। যদি কোনো race condition থাকে, সেটা একটা আলাদা, correctness সমস্যা — lock/atomic দিয়ে সমাধান করতে হবে, padding দিয়ে না। False sharing-এর সমাধান (padding) কোনো race condition ঠিক করে না, কারণ সেখানে কোনো race আছেই না।

“Padding সবসময় করা উচিত, ক্ষতি তো নেই।”

ক্ষতি আছে — memory। প্রতিটা padded variable ৬৪ বাইট দখল করে, আসল ডেটা হয়তো ৪ বা ৮ বাইট মাত্র। যদি হাজার হাজার এমন struct থাকে (উদাহরণ: প্রতি-connection statistics একটা high-concurrency সার্ভারে), এই overhead cache-এ কার্যকরী জায়গা কমায় আর memory bandwidth বাড়ায় বড় array iterate করার সময়। শুধু সেই variable-গুলোর জন্য padding দিন যেগুলো সত্যিই আলাদা core থেকে ঘন ঘন লেখা হয় — profile করে নিশ্চিত হওয়ার পরে, অনুমান করে নয়। এটাই ঠিক সেই একই নীতি যা futex লেসনে দেখা গিয়েছিল atomic instruction সম্পর্কে: hardware-এর একটা feature সবসময় ব্যবহার করা উচিত না শুধু কারণ সেটা কোনো সমস্যা সমাধান করতে পারে — শুধু যেখানে সমস্যাটা আসলে আছে।

“False sharing শুধু counter/integer-এর মতো ছোট ডেটার সমস্যা।”

Data type-এর আকার আসলে অপ্রাসঙ্গিক — সমস্যাটা layout-এর, size-এর না। দুইটা বড় struct-ও যদি একটা array-তে পাশাপাশি থাকে আর তাদের প্রথম কয়েক বাইট (উদাহরণ: একটা “dirty” flag বা “version” field, প্রায়ই struct-এর শুরুতে রাখা হয়) একই cache-line-এ পড়ে যায়, সেই flag-দুটোতে ঘন ঘন লেখালেখি একই false-sharing প্যাটার্ন তৈরি করবে — বাকি struct-এর আকার যতই বড় হোক না কেন। গুরুত্বপূর্ণ প্রশ্ন সবসময় “কোন নির্দিষ্ট বাইট-রেঞ্জ ঘন ঘন লেখা হচ্ছে, আর সেগুলো কোন cache line-এ পড়ছে” — পুরো variable বা struct-এর আকার না।

“একটা array-তে false sharing এড়াতে শুধু elements-এর সংখ্যা কমালেই হবে।”

Array-এর দৈর্ঘ্য false sharing-এর সাথে সরাসরি সম্পর্কিত না — সম্পর্কিত হলো কোন index-গুলো কোন thread লেখে, আর সেগুলো কতটা কাছাকাছি। উদাহরণ: int counters[1000]-এ যদি thread 0 শুধু counters[0] লেখে আর thread 1 শুধু counters[500] লেখে, তাদের মধ্যে ২০০০ বাইট ব্যবধান — কোনো false sharing নেই, কারণ তারা ভিন্ন cache line-এ। কিন্তু যদি thread 0 counters[0] লেখে আর thread 1 counters[1] লেখে (adjacent index), সেটাই সবচেয়ে সাধারণ false-sharing প্যাটার্ন — per-thread বা per-worker index দিয়ে একটা shared array indexing করা একটা ক্লাসিক ভুল, যদি না প্রতিটা index-এর জন্য stride/padding যোগ করা হয়।

বুঝেছেন কি না দেখুন

1

একজন সহকর্মী বলছেন: “আমি volatile ব্যবহার করেছি, তাই আমার দুইটা counter-এর মধ্যে false sharing হওয়ার কথা না — volatile তো প্রতিবার memory থেকে সরাসরি পড়ে-লেখে।” এই যুক্তিতে কী ভুল আছে?

যুক্তি

এই যুক্তিতে দুইটা সম্পূর্ণ ভিন্ন বিষয় গুলিয়ে ফেলা হয়েছে — compiler-level optimization আর hardware-level cache coherence।

volatile compiler-কে বলে: এই variable-এর read/write-কে register-এ cache করে রেখো না বা reorder/eliminate কোরো না — প্রতিটা access সত্যিই memory-তে যেতে হবে (source code-এ যেভাবে লেখা, ঠিক সেভাবে)। এটা একটা compiler-স্তরের নিশ্চয়তা।

কিন্তু volatile hardware-কে কিছুই বলে না সেই memory access কীভাবে হার্ডওয়্যারে ঘটবে। CPU-র cache hierarchy আর MESI protocol volatile সম্পর্কে কিছুই জানে না — সে শুধু দেখে কোন cache line-এ read/write হচ্ছে। যদি volatile দিয়ে ঘোষিত দুইটা variable একই cache line-এ পড়ে, তাদের মধ্যে false sharing ঠিক একইভাবে ঘটবে, volatile ছাড়া যেভাবে ঘটত।

আসলে, volatile এই সমস্যাটাকে আরও worse করতে পারে — volatile ছাড়া কম্পাইলার হয়তো একটা লুপে বারবার একই variable-এ লেখাকে register-এ জমিয়ে শেষে একবারে memory-তে লিখত (dead-store elimination, loop-এ শেষ মান ছাড়া বাকিগুলো optimize হয়ে যাওয়া), যা memory access সংখ্যা এবং তাই coherence traffic কমিয়ে দিত। volatile সেই optimization-কে বন্ধ করে দেয় — প্রতিটা ++-এ সত্যিই একটা memory write ঘটে, মানে প্রতিটাতেই সত্যিকারের cache-line-transfer সুযোগ তৈরি হয়।

সঠিক সমাধান একই থাকে, volatile থাকুক বা না থাকুক: layout বদলান — padding/alignas দিয়ে variable-দুটোকে আলাদা cache line-এ পাঠান। এটাই একমাত্র সমাধান যা সরাসরি hardware-এর গ্রানুলারিটিতে কাজ করে।

2
struct worker_state {
    int   worker_id;        /* শুধু একবার, শুরুতে সেট হয় */
    long  items_processed;  /* প্রতি item-এ ++ হয়, নিজের thread থেকে */
    long  errors;           /* খুব কম দেখা যায়, নিজের thread থেকে */
};
struct worker_state workers[NUM_WORKERS];  /* প্রতিটা worker তার নিজের workers[i] ব্যবহার করে */

প্রতিটা worker thread শুধু তার নিজের workers[i] element পড়ে-লেখে, অন্য কারো না। তাও কি এখানে false sharing হতে পারে? যদি হয়, কেন?

প্রয়োগ

হ্যাঁ, হতে পারে — এবং এটাই একটা খুব সাধারণ, easy-to-miss প্যাটার্ন।

প্রতিটা worker সত্যিই তার নিজের logical element-এই কাজ করছে — এটা ঠিক। কিন্তু প্রশ্নটা হলো: workers[i] আর workers[i+1] কি একই cache line-এ পড়ে?

struct worker_state-এর আকার: int (৪ বাইট, সাধারণত ৪-বাইট align-এর জন্য ৪ বাইটেই থাকে বা alignment padding সহ ৮), তারপর দুইটা long (৮+৮ = ১৬ বাইট)। ধরুন compiler alignment-এর জন্য মোট struct আকার হয় ২৪ বাইট (৪ বাইটের worker_id-এর পরে ৪ বাইট padding, কারণ long-এর জন্য ৮-বাইট alignment লাগে, তারপর ১৬ বাইট দুই long)।

একটা ৬৪-বাইট cache line-এ 64 / 24 ≈ 2.67, অর্থাৎ প্রতিটা line-এ প্রায় ২.৬৭টা struct ধরে। এর মানে workers[0] আর workers[1] প্রায় নিশ্চিতভাবে একই line-এ পড়বে (এবং workers[2]-এর একটা অংশও), আর মাঝেমধ্যে workers[2] আর workers[3]-এর মাঝে line-এর সীমানা পড়বে — একটা অসামঞ্জস্যপূর্ণ, predict করা কঠিন প্যাটার্ন।

তাই worker 0 আর worker 1 — দুইজনেই তাদের নিজের element-এ লিখছে, কোনো logical sharing নেই — তবু তাদের items_processed++ কল দুটো একই cache line-এ আঘাত করবে, আর ঠিক আগের লেসনের struct-এর মতোই ping-pong শুরু হবে।

সমাধান: struct worker_state-কে cache-line-aligned করুন, যাতে প্রতিটা array element নিজের line নেয়:

struct worker_state {
    int   worker_id;
    long  items_processed;
    long  errors;
} __attribute__((aligned(64)));   /* অথবা C11/C++11: alignas(64) */

এখন প্রতিটা struct worker_state-এর আকার ৬৪ বাইটে round হয়ে যায় (compiler বাকি জায়গা automatically padding হিসেবে যোগ করে), তাই array-এর প্রতিটা element তার নিজের line-এ, কোনো দুইটা worker কখনো একই line share করে না।

সাধারণ শিক্ষা: “প্রতিটা thread তার নিজের ডেটা লেখে” বলাটা যথেষ্ট না — প্রশ্ন করতে হবে “সেই নিজের ডেটাগুলো memory-তে কতটা কাছাকাছি বসে”। Array of struct-এ per-thread/ per-worker element প্রায়ই এই ফাঁদে পড়ে, বিশেষত ছোট struct-এ (cache line-এর চেয়ে ছোট) যেখানে একাধিক element স্বাভাবিকভাবেই একই line-এ জমা হয়।

3

আপনি একটা HTTP সার্ভার লিখছেন যেখানে প্রতিটা request handler থ্রেড একটা shared, per-thread স্ট্যাট struct-এর array-তে নিজের index-এ আপডেট করে:

struct stats { long requests; long bytes_sent; long errors; };
struct stats per_thread_stats[NUM_THREADS];

আর একটা আলাদা monitoring থ্রেড প্রতি সেকেন্ডে সবগুলো যোগ করে dashboard-এ পাঠায়। এই ডিজাইনে false sharing-এর দুইটা সম্ভাব্য উৎস চিহ্নিত করুন, আর প্রতিটার সমাধান বলুন।

ডিজাইন

দুইটা আলাদা উৎস আছে, আর তারা ভিন্ন প্রকৃতির — একটা আগের প্রশ্নের মতোই “adjacent worker” সমস্যা, আরেকটা নতুন — “reader বনাম writer” সমস্যা।

উৎস ১ — adjacent request-handler thread-দের মধ্যে (আগের প্রশ্নের মতো)। যদি stats[] array-এর element-গুলো cache-line-aligned না হয়, thread i আর thread i+1-এর statistics একই line-এ পড়তে পারে — উচ্চ-throughput সার্ভারে (প্রতি সেকেন্ডে হাজার হাজার request) এটা সরাসরি request-handling latency-তে যোগ হবে, কারণ প্রতিটা requests++/bytes_sent += একটা coherence-এ জড়িয়ে যাচ্ছে।

সমাধান: উপরের মতোই — struct stats-কে alignas(64) বা __attribute__((aligned(64))) দিয়ে align করুন, যাতে প্রতিটা array element তার নিজের cache line নেয়।

উৎস ২ — monitoring thread বনাম request-handler thread (নতুন, সূক্ষ্ম)। এখানে একটা true sharing এবং একটা false sharing দুটোই ঘটতে পারে। Monitoring thread প্রতি সেকেন্ডে সবগুলো stats[i] পড়ে — এটা প্রযুক্তিগতভাবে true sharing (একই ডেটা পড়ছে যা handler লিখছে), কিন্তু এটা এত কম ঘন ঘন (প্রতি সেকেন্ডে একবার) যে এর coherence খরচ নগণ্য।

আসল সমস্যা: monitoring thread যখন stats[0] পড়ে, সেই read একটা RFO-জাতীয় ঘটনা ঘটায় না (read শুধু line-কে Shared বানায়, Modified না) — কিন্তু যদি monitoring thread একই সময়ে একটা নিজস্ব running-total variable আপডেট করে যেটা কাকতালীয়ভাবে কোনো handler thread-এর hot variable-এর কাছাকাছি বসে (উদাহরণ: monitoring thread-এর নিজস্ব accumulator যদি stats[] array-এর ঠিক পাশে declare করা হয়ে থাকে memory-তে), সেটা একটা নতুন, আলাদা false-sharing সম্পর্ক তৈরি করবে।

সমাধান: Monitoring thread-এর নিজস্ব কাজের ডেটা (accumulator variable ইত্যাদি) সম্পূর্ণ আলাদা, dedicated memory region-এ রাখুন — stats[] array-এর সাথে বা তার আশেপাশে declare করবেন না। আর read করার সময় প্রতিটা stats[i]-কে একবারে একটা local কপিতে snapshot নিন, বারবার একই array element বারবার access করার বদলে — এতে monitoring pass-এর সময়কাল কমে, যা coherence window ছোট রাখে।

সাধারণ নীতি এই প্রশ্ন থেকে: false sharing শুধু “একই ধরনের thread-দের মধ্যে” ঘটে না — producer আর consumer, worker আর monitor, বা সম্পূর্ণ ভিন্ন ভূমিকার thread-দের মধ্যেও ঘটতে পারে যদি তাদের ডেটা memory-তে কাছাকাছি বসে। Layout ডিজাইন করার সময় “কে কী পড়ে/লেখে” ম্যাপ করাটাই যথেষ্ট না — “কে কতবার, আর কোন সময়ে” এই প্রশ্নগুলোও গুরুত্বপূর্ণ।

4

std::hardware_destructive_interference_size আর std::hardware_constructive_interference_size — C++17-এর এই দুইটা constant-এর নাম প্রায় একই রকম শোনায়। তাদের মধ্যে পার্থক্য কী, আর প্রতিটা কখন ব্যবহার করা উচিত?

স্মরণ

দুইটাই cache-line-সম্পর্কিত size hint, কিন্তু সম্পূর্ণ বিপরীত উদ্দেশ্যে — নামের “destructive” বনাম “constructive” শব্দ দুটোই আক্ষরিক অর্থ বহন করে।

hardware_destructive_interference_size — এই লেসনের বিষয়। এই সংখ্যা (সাধারণত ৬৪) নির্দেশ করে কতটা দূরত্বে রাখলে দুইটা variable একে অপরকে “ধ্বংসাত্মকভাবে” প্রভাবিত করবে না — অর্থাৎ, দুইটা variable-এর মধ্যে এই দূরত্ব রাখলে (বা তার বেশি) তারা কখনো একই cache line share করবে না, তাই false sharing অসম্ভব। এটা ব্যবহার হয় আলাদা করতে চাওয়া variable-এর জন্য — যেমন এই লেসনের দুইটা independent counter।

প্রতিটা variable-কে আলাদাভাবে align করতে হয়, যাতে দুটোই নিজের line পায়:

struct {
    alignas(std::hardware_destructive_interference_size) long a;
    alignas(std::hardware_destructive_interference_size) long b;
};

hardware_constructive_interference_size — বিপরীত উদ্দেশ্য। এই সংখ্যা নির্দেশ করে কতটুকু ডেটা একসাথে রাখলে তারা “গঠনমূলকভাবে” উপকৃত হবে — অর্থাৎ, যদি একই thread থেকে বারবার একসাথে ব্যবহৃত হওয়া কয়েকটা ছোট variable এই সীমার মধ্যে রাখা হয়, তারা একই cache line-এ পড়বে, তাই একবার line আনলেই সবগুলো cache-এ চলে আসে — এটা spatial locality-র সুবিধা নেওয়া (Level 3-এর আলোচনার সরাসরি প্রয়োগ)। এটা ব্যবহার হয় এমন ডেটার জন্য যেগুলো প্রায়ই একসাথে পড়া হয়, একে অপরের সাথে প্রতিযোগিতা না করে (single-thread বা read-mostly প্রেক্ষাপটে)।

সংক্ষেপে:

Constantউদ্দেশ্যকখন
destructive_interference_sizeআলাদা করা — false sharing এড়ানোভিন্ন থ্রেড থেকে ঘন ঘন লেখা variable
constructive_interference_sizeকাছাকাছি রাখা — locality কাজে লাগানোএকসাথে পড়া হওয়া, low-contention variable

দুইটাই একই সংখ্যায় (সাধারণত ৬৪) resolve হতে পারে বেশিরভাগ প্ল্যাটফর্মে, কিন্তু ধারণাগতভাবে তারা সম্পূর্ণ ভিন্ন প্রশ্নের উত্তর দেয় — একটা “কত দূরে রাখব” (max বা তার বেশি), আরেকটা “কত কাছে রাখতে পারি” (max, এর মধ্যে)।

5

এই লেসনের শুরুতে বলা হয়েছিল false sharing-এর “কোনো correctness ঝুঁকি নেই” — শুধু performance সমস্যা। এটা কি সবসময় পুরোপুরি সত্যি, নাকি কোনো ব্যতিক্রম আছে যেখানে false sharing পরোক্ষভাবে একটা correctness সমস্যা প্রকাশ করে (তৈরি করে না, কিন্তু প্রকাশ করে)?

যুক্তি

নীতিগতভাবে সত্য থেকে যায় — false sharing নিজে কখনো একটা সঠিক প্রোগ্রামকে ভুল করে না, কারণ প্রতিটা variable-এ শুধু তার নিজস্ব owner thread-ই লেখে (সংজ্ঞা অনুযায়ী)। কিন্তু একটা গুরুত্বপূর্ণ পরোক্ষ সংযোগ আছে: সময়ের ব্যবহার বদলে যাওয়া একটা লুকানো race condition-কে প্রকাশ করতে পারে।

কল্পনা করুন একটা প্রোগ্রামে সত্যিই একটা race condition আছে — দুইটা thread মাঝেমধ্যে একই variable ছুঁয়ে ফেলে কোনো সঠিক synchronization ছাড়াই, কিন্তু বাস্তবে এই race window এতই সরু (কয়েক ন্যানোসেকেন্ড) যে এটা প্রায় কখনো ট্রিগার হয় না — প্রোগ্রামটা টেস্টে “কাজ করে” মনে হয়।

এখন যদি একটা অসম্পর্কিত পরিবর্তন (যেমন একটা নতুন field যোগ করা যা কাকতালীয়ভাবে false sharing তৈরি করে) সেই থ্রেডগুলোকে ধীর করে দেয়, race window-এর সময়কাল (duration) বদলে যেতে পারে। একটা ধীর হয়ে যাওয়া অপারেশন race window-কে চওড়া করতে পারে (বেশি cycle লাগছে বলে অন্য thread-এর মাঝখানে ঢোকার সম্ভাবনা বাড়ে), অথবা টাইমিং-নির্ভর একটা bug-কে আগে যা কখনো ট্রিগার হয়নি তা এখন মাঝে মাঝে ট্রিগার হতে শুরু করতে পারে।

গুরুত্বপূর্ণ: এখানে false sharing race condition-টা তৈরি করেনি — race condition আগে থেকেই কোডে ছিল, একটা আসল bug (হয়তো একটা missing lock বা atomic)। False sharing শুধু timing বদলে দিয়ে সেই আগে-থেকে-থাকা bug-টাকে দৃশ্যমান করেছে, অথবা তার সম্ভাবনা বদলে দিয়েছে।

ব্যবহারিক প্রয়োগ: এই কারণেই একটা “শুধু performance optimization” (padding যোগ করা বা সরানো) কখনো কখনো একটা অপ্রত্যাশিত bug আবির্ভূত বা অদৃশ্য হতে দেখা যায় — এবং এটা একটা সংকেত যে আসল সমস্যা performance-এ না, বরং একটা লুকিয়ে থাকা race condition-এ, যেটা সঠিক synchronization (lock, atomic, বা memory barrier — পরের কয়েকটা লেসনের বিষয়) দিয়ে সমাধান করা উচিত, timing-নির্ভর কাকতালীয় সুরক্ষার উপর নির্ভর না করে।

এরপর কী

এরপর — যখন cache line-ই যথেষ্ট নয়

এই লেসনে আমরা দেখেছি একটা single socket-এর ভেতরে, একটা single memory bank-এর ভেতরে, শুধু cache line গ্রানুলারিটির কারণে কীভাবে performance ধসে যেতে পারে। কিন্তু আধুনিক সার্ভার হার্ডওয়্যার প্রায়ই একের বেশি physical socket নিয়ে গঠিত — প্রতিটার নিজস্ব memory controller, নিজস্ব সরাসরি সংযুক্ত DRAM।

পরের লেসনে আমরা এক ধাপ উপরে উঠব: cache line-এর ভেতরের গল্প থেকে পুরো memory bank-এর গল্পে। প্রশ্নটা তখন আর “কোন cache line” থাকবে না, হবে “কোন socket-এর memory” — আর উত্তরটা প্রতিটা memory access-এর latency-কে সরাসরি প্রভাবিত করবে, তার সাইজ বা alignment যাই হোক না কেন। এটাই NUMA (Non-Uniform Memory Access)।

আরও পড়ুন

  • False Sharing — Martin Thompson (Mechanical Sympathy blog) · যে পোস্ট শব্দটাকে mainstream করেছে — LMAX Disruptor-এর প্রেক্ষাপটে, বাস্তব benchmark সংখ্যাসহ
  • std::hardware_destructive_interference_size — C++ Standard (P0154R1) — ISO/IEC JTC1/SC22/WG21 · false sharing এড়াতে ভাষা-স্তরের portable constant
  • Intel 64 and IA-32 Architectures Optimization Reference Manual, §3.6.10 — False Sharing — Intel Corporation · hardware vendor-এর নিজস্ব ভাষায় সমস্যা আর mitigation