Cache Coherence ও MESI Protocol — সবার cache একমত থাকার নিয়ম
Cache Coherence and the MESI Protocol
একাধিক core-এর প্রতিটার নিজস্ব private cache থাকায় একই memory address-এর ভিন্ন কপি ভিন্ন core-এ একসাথে থাকতে পারে — cache coherence protocol (MESI: Modified/Exclusive/Shared/Invalid) সেই কপিগুলোকে bus/interconnect message দিয়ে সমন্বিত রাখে, প্রতিটা লেখাকে অন্য সব কপি invalidate করতে বাধ্য করে — আর এই সমন্বয়ের real, non-zero খরচই পরের লেসনের false sharing সমস্যার মূল কারণ।
আগে এটা বুঝি
আগের লেসনে (cache-hierarchy-inclusive-exclusive) আমরা একটা গুরুত্বপূর্ণ বাস্তবতা প্রতিষ্ঠা করেছি: একটা আধুনিক multi-core চিপে L1 আর L2 সাধারণত private — প্রতিটা core-এর নিজস্ব, শুধু তারই ব্যবহারের জন্য। এই একটা তথ্যই একটা গভীর সমস্যার জন্ম দেয়, যা single-core CPU-তে কখনো ছিলই না।
কল্পনা করুন দুইটা core, A আর B, একই memory address X শেয়ার করছে (হয়তো একটা shared variable, দুইটা thread-এর মধ্যে)। Core A ঠিকানা X পড়ে, তার নিজের private L1-এ একটা কপি রেখে দেয়। এখন Core B-ও X পড়ে, তারও নিজের private L1-এ আরেকটা, আলাদা কপি তৈরি হয়। এই মুহূর্তে memory-তে X-এর মান, আর দুইটা cache-এ X-এর কপি — তিনটাই আলাদা জায়গায়, আলাদা হার্ডওয়্যারে বসে আছে, একই মান নিয়ে (আপাতত)।
এখন সমস্যাটা কল্পনা করুন: Core A যদি X-এ লেখে, তার নিজের L1-এর কপি বদলে যায় — কিন্তু Core B-র L1-এর কপি? সেটা তো A-র write সম্পর্কে কিছুই জানে না! B যদি এরপর X পড়ে, সে তার নিজের পুরনো (stale) কপি থেকে পড়বে — একটা ভুল, বাসি মান, অথচ B-র hardware-এর দৃষ্টিতে এটা একটা সম্পূর্ণ স্বাভাবিক cache hit।
এটাই cache coherence problem — single-core-এ caching ছিল সম্পূর্ণ অদৃশ্য একটা optimization (memory-hierarchy লেসনের মূল প্রতিশ্রুতি — cache থাকুক বা না থাকুক, প্রোগ্রামের ফলাফল একই, শুধু গতি ভিন্ন)। Multi-core-এ, private cache-এর উপস্থিতি হঠাৎ একটা দৃশ্যমান, সঠিকতা-সম্পর্কিত সমস্যা হয়ে ওঠে যদি hardware কিছু না করে। Cache coherence protocol হলো সেই hardware সমাধান — একটা নিয়মের সেট, যা নিশ্চিত করে সব core একই memory address সম্পর্কে সবসময় একটা সামঞ্জস্যপূর্ণ ছবি দেখে, private cache থাকা সত্ত্বেও। আজকের লেসনে আমরা সবচেয়ে বহুল-ব্যবহৃত এমন protocol — MESI — সম্পূর্ণভাবে দেখব।
মূল ধারণা
Cache Coherence-এর সংজ্ঞা — দুইটা গ্যারান্টি
একটা memory system-কে “coherent” বলা হয় যদি এটা দুইটা গ্যারান্টি দেয়:
১. Write propagation — একটা core-এর লেখা পরিবর্তন শেষ পর্যন্ত অন্য সব core-এর কাছে দৃশ্যমান হবে (চিরকাল লুকানো থাকবে না)।
২. Write serialization (write serialization/ single writer-multiple reader) — একই address-এ একাধিক লেখা সব core একই ক্রমে দেখবে — কোনো দুইটা core ভিন্ন ক্রমে একই দুইটা লেখা “দেখবে” না।
MESI — চারটা State
MESI নামটাই তার চারটা state-এর আদ্যক্ষর:
| State | পূর্ণ নাম | অর্থ |
|---|---|---|
| M | Modified | এই cache-এই একমাত্র কপি, dirty (memory-র মানের চেয়ে ভিন্ন — write হয়েছে, memory-তে এখনো পৌঁছায়নি) |
| E | Exclusive | এই cache-এই একমাত্র কপি, কিন্তু clean (memory-র মানের সাথে অভিন্ন) |
| S | Shared | একাধিক cache-এ একই সাথে থাকতে পারে এমন কপি, সবগুলোই clean, memory-র সাথে অভিন্ন |
| I | Invalid | এই cache-এর কপি stale/অকেজো — hardware এটাকে ব্যবহারযোগ্য বলে গণ্য করে না, hit হিসেবে ধরবে না |
এই cache-ই একমাত্র? একাধিক cache-এ থাকতে পারে?
┌───────────────────────┐ ┌───────────────────────┐
dirty (memory │ │ │ │
থেকে ভিন্ন) │ M (Modified) │ │ (এই কম্বিনেশন │
│ │ │ MESI-তে নেই) │
├───────────────────────┤ ├───────────────────────┤
clean (memory │ │ │ │
সাথে অভিন্ন) │ E (Exclusive) │ │ S (Shared) │
└───────────────────────┘ └───────────────────────┘
I (Invalid) — কপি অকেজো, উপরের কোনো বিভাগেই নেই ┌─────────────────────────────────────┐
│ │
▼ │
┌──────┐ L-read, কেউ সাড়া দেয়নি ┌──────┐ │
│ I │ ─────────────────────────────► │ E │ │
└──┬───┘ └──┬───┘ │
│ L-read, কেউ সাড়া দিয়েছে │ L-write (bus message ছাড়াই)
│ ▼ │
│ ┌──────┐ │
└───────────────────────────────────► │ M │◄────────┘
└──┬───┘ R-write (data+writeback), M→I
┌──────┐ L-write (invalidate broadcast) │
│ S │ ──────────────────────────────────┘
└──┬───┘
│ R-write (invalidate নিজেকে)
▼
┌──────┐
│ I │
└──────┘
R-read একটা M বা E কপিকে S-এ নামায় (data সরবরাহসহ); S কপি R-read-এ S-ই থাকে (নতুন sharer যোগ হয়)।ভেতরে কী ঘটছে
কীভাবে state বদলায় — bus snooping ও inter-core message
MESI বাস্তবায়ন করতে প্রতিটা core-এর cache controller-কে অন্য সব core-এর memory transaction “শুনতে” হয় — একে বলে bus snooping। যখন কোনো core একটা address-এ read বা write করতে চায়, সে একটা বার্তা bus/interconnect-এ পাঠায়, আর প্রতিটা অন্য cache controller সেই বার্তা দেখে নিজের copy-র state যাচাই ও প্রয়োজনে আপডেট করে।
সাধারণ (textbook-এ প্রচলিত নামে) বার্তার ধরন:
- BusRd — “আমি এই address পড়তে চাই” (একটা read miss-এর ফলে পাঠানো হয়)
- BusRdX (বা Read-For-Ownership/RFO) — “আমি এই address-এ লিখতে চাই, এবং এই মুহূর্তে আমার কাছে বৈধ ডেটা নেই” — ডেটা আনা এবং অন্য সবাইকে invalidate করা, একসাথে
- BusUpgr (বা Invalidate) — “আমার কাছে ইতিমধ্যে বৈধ (S state) ডেটা আছে, আমি শুধু লেখার অনুমতি (exclusive ownership) চাই — ডেটা পাঠানোর দরকার নেই, শুধু অন্য সবাইকে invalidate করো”
Broadcast bus বনাম directory — স্কেলের সমস্যা
উপরের সবকিছু ধরে নিচ্ছে একটা shared bus আছে, যেখানে সব core-এর সব snoop message broadcast হয় — প্রতিটা core প্রতিটা অন্য core-এর প্রতিটা memory transaction দেখে। এটা ছোট core-সংখ্যায় (৪-৮টা) ব্যবহারিক, কিন্তু বড় সংখ্যায় (৩০+) broadcast ট্র্যাফিক নিজেই একটা বাধা হয়ে ওঠে।
আগের লেসনের (cache-hierarchy-inclusive-exclusive) hood অংশে ঠিক এই সমস্যার একটা সমাধান দেখানো হয়েছিল — একটা inclusive shared L3, presence bits-সহ, broadcast এড়িয়ে শুধু প্রাসঙ্গিক core-দের targeted invalidate message পাঠাতে পারে। এটাকে আরও সাধারণীকরণ করলে পাওয়া যায় directory-based coherence — L3 (বা একটা আলাদা structure) প্রতিটা line-এর জন্য একটা “directory entry” রাখে (কোন core-দের কাছে কপি আছে), আর coherence action সরাসরি সেই directory দেখে ঠিক কাকে বার্তা পাঠাতে হবে তা জানে, বাকিদের বিরক্ত না করেই।
এই কারণেই বড় multi-core/multi-socket সিস্টেম (৩০+ core, একাধিক socket) প্রায় সবসময় directory-based coherence ব্যবহার করে, ছোট desktop/laptop-grade চিপ প্রায়ই সরল broadcast snooping-এই যথেষ্ট পায়।
একটা সম্পূর্ণ state-transition রেফারেন্স টেবিল
উপরের বার্তাগুলো এবার একটা সম্পূর্ণ রেফারেন্স টেবিলে সংগঠিত করি — একটা cache-এর বর্তমান state, আর তার উপর ঘটা প্রতিটা সম্ভাব্য ঘটনার জন্য পরবর্তী state। “Local” মানে এই core নিজে সেই অপারেশন করছে; “Remote” মানে অন্য কোনো core-এর অনুরোধে snoop করে প্রতিক্রিয়া জানানো হচ্ছে।
| বর্তমান state | Local read | Local write | Remote read (অন্য core পড়তে চায়) | Remote write (অন্য core লিখতে চায়) |
|---|---|---|---|---|
| I | miss → E বা S (নিচে দেখুন) | miss → M (সবাইকে invalidate) | কোনো প্রভাব নেই (এমনিতেই invalid) | কোনো প্রভাব নেই |
| E | local hit, state অপরিবর্তিত | কোনো bus message ছাড়াই E→M | data সরবরাহ (বা memory থেকে, দুইটাই বৈধ কারণ clean), E→S | নিজের কপি invalidate (data clean, memory-ই যথেষ্ট), E→I |
| S | local hit, state অপরিবর্তিত | invalidate broadcast, S→M | data সরবরাহ (ঐচ্ছিক, অন্য S কপিও পারে), S→S | নিজের কপি invalidate, S→I |
| M | local hit, state অপরিবর্তিত | local hit, state অপরিবর্তিত | data সরবরাহ + writeback (MESI) বা O-তে যাওয়া (MOESI), M→S বা M→O | data সরবরাহ + writeback, M→I |
আর read miss-এ E বনাম S-এর সিদ্ধান্ত: snoop-এ কেউ সাড়া না দিলে (কেউ cache করেনি) → E। কেউ সাড়া দিলে (অন্তত একজনের কাছে ইতিমধ্যে বৈধ কপি আছে) → উভয় পক্ষই (বা সবপক্ষ) S।
উদাহরণ
সম্পূর্ণ Worked Example — Read, Read, Write, আবার Read
এবার এই লেসনের কেন্দ্রীয় দাবিটা — একটা সম্পূর্ণ, ধাপে-ধাপে state-transition sequence, দুইটা (পরে তিনটা) core নিয়ে, একটা একক memory address X-এর জন্য। শুরুতে কেউই X cache করেনি, memory-তে X-এর মান ।
| ধাপ | ঘটনা | Bus-এ বার্তা | Core A state | Core B state | Core C state | Memory-তে মান |
|---|---|---|---|---|---|---|
| ০ | (শুরু, কেউ cache করেনি) | — | I | I | I | |
| ১ | A পড়ে X | read request; কেউ সাড়া দেয়নি (কেউ cache করেনি) | E | I | I | |
| ২ | B পড়ে X | read request; A snoop করে, নিজের E কপি আছে দেখে সাড়া দেয় | S | S | I | |
| ৩ | A লেখে X-এ | invalidate request (A-র ইতিমধ্যে S আছে, শুধু ownership দরকার); B snoop করে নিজের কপি invalidate করে | M | I | I | (এখন stale — cache-এ dirty ডেটা) |
| ৪ | C পড়ে X | read request; A snoop করে, নিজের M কপি আছে দেখে data সরবরাহ করে + memory-তে writeback করে | S | I | S | (writeback-এর ফলে আপডেট) |
ধাপে ধাপে যুক্তি:
ধাপ ১ (A পড়ে): X কোথাও cache নেই। A একটা read request পাঠায়। কেউ সাড়া দেয় না (কারো কাছে কপি নেই), তাই memory থেকে সরাসরি ডেটা আসে। যেহেতু hardware নিশ্চিত কেউ এটা শেয়ার করছে না (কোনো snoop hit হয়নি), A সরাসরি E state-এ যায় — একমাত্র কপি, clean।
ধাপ ২ (B পড়ে): B-রও X দরকার, miss হয়, read request পাঠায়। এবার A snoop করে দেখে তার কাছে E state-এ কপি আছে — যেহেতু এখন দুইটা cache-এ কপি থাকবে, দুইটাকেই S-এ নামতে হয় (E আর “একমাত্র কপি” থাকতে পারে না)। A নিজের state E→S-এ নামায় (আর data B-কে সরবরাহ করে, অথবা memory থেকেও আসতে পারে যেহেতু clean — বাস্তবায়ন-নির্ভর, উভয়ই logically সঠিক)। B নতুন কপি S state-এ পায়।
ধাপ ৩ (A লেখে): A-র কাছে ইতিমধ্যে বৈধ (S) ডেটা আছে, শুধু লেখার অনুমতি দরকার — exclusive ownership। A একটা invalidate request পাঠায় (ডেটা আনার দরকার নেই, শুধু অন্য সবাইকে সরিয়ে দেওয়া)। B snoop করে দেখে তার কপি invalidate করতে হবে — B: S→I। A নিজে এখন একমাত্র বৈধ কপি, লেখে, state S→M (dirty, memory এখনো পুরনো )।
ধাপ ৪ (C পড়ে): C-র miss হয়, read request পাঠায়। A snoop করে দেখে তার কাছে M state-এ (dirty) কপি আছে — A-কেই এখন ডেটা সরবরাহ করতে হবে (cache-to-cache transfer), এবং যেহেতু plain MESI-তে dirty ডেটা শেয়ার করার সাথে সাথে সেটা “clean” বলে গণ্য করতে হয় (আর কোনো একক owner থাকছে না), A লেখা মান memory-তে writeback করে দেয়। A নিজের state M→S-এ নামে, C নতুন কপি S state-এ পায়, memory এখন আপডেটেড ()।
MOESI — Owned State, লেখা writeback ছাড়াই শেয়ার করা
ধাপ ৪-এ লক্ষ্য করুন — C পড়তে চাওয়ায় A-কে বাধ্যতামূলকভাবে memory-তে writeback করতে হলো, শুধু C-কে ডেটা দেওয়ার জন্য। যদি এরপর D-ও পড়তে চায়, memory থেকে (এখন সঠিক) ডেটা দেওয়া যাবে, কোনো সমস্যা নেই — কিন্তু সেই writeback-টা একটা বাস্তব memory-bandwidth খরচ ছিল, যেটা এড়ানো যেত যদি শুধু cache-to-cache transfer-এই যথেষ্ট হতো।
MOESI protocol (MESI + একটা পঞ্চম state, O = Owned) ঠিক এই অপচয় এড়ায়:
| ধাপ (MOESI বিকল্প) | ঘটনা | Core A state | Core C state | Memory |
|---|---|---|---|---|
| ৪′ | C পড়ে X; A snoop করে data সরবরাহ করে, কিন্তু writeback করে না | O (dirty, কিন্তু এখন শেয়ারিং দায়িত্বপ্রাপ্ত) | S | (এখনো stale!) |
O (Owned)-এর অর্থ: “আমার কপি dirty (memory-র চেয়ে ভিন্ন), এবং অন্য cache-ও (S state-এ) এই একই মান শেয়ার করছে — কিন্তু memory-তে writeback করার দায়িত্ব আমারই, যখনই সেটা প্রয়োজন হবে (যেমন আমি নিজে evict হলে, বা কেউ লিখতে চাইলে)।” C-র কপি এখন S — clean বলেই গণ্য, যদিও memory আসলে stale — কারণ C জানে (protocol অনুযায়ী) যদি তার সঠিক মান দরকার হয়, A (Owner)-ই সত্যের উৎস, memory না।
এই একটা state যোগ করে MOESI প্রতিটা “dirty ডেটা একাধিক reader-এর মধ্যে বারবার পাস হওয়া” পরিস্থিতিতে বাস্তব memory-bandwidth সাশ্রয় করে — বিশেষত producer-consumer প্যাটার্নের multi-threaded কোডে, যেখানে একটা core বারবার লেখে আর একাধিক core বারবার পড়ে।
নিজে চালিয়ে দেখুন
Coherence traffic-এর real latency cost মাপুন — 'ping-pong' বেঞ্চমার্ক
ধারণা: দুইটা thread, দুইটা ভিন্ন core-এ pin করা। একটাতে (A) দুইটাই একটা একক, shared counter বারবার increment করে (প্রতিবার cache line-টা A থেকে B-তে, B থেকে A-তে বারবার “পিং-পং” করবে — বারবার M→I, I→M)। আরেকটাতে (B) প্রতিটা thread তার নিজস্ব, আলাদা cache line-এ (অন্তত ৬৪ বাইট দূরত্বে) counter increment করে — কোনো coherence traffic লাগবে না।
#include \<pthread.h\>
#include \<stdio.h\>
#include \<time.h\>
#define ITERATIONS 50000000
volatile long shared_counter = 0; /* দুই thread-ই একই line-এ লিখবে */
struct padded_counter {
volatile long value;
char padding[56]; /* বাকি cache line ভরাট — অন্য variable-এর সাথে line শেয়ার এড়াতে */
};
struct padded_counter counters[2]; /* প্রতিটা thread-এর নিজস্ব line */
void *shared_worker(void *arg) {
for (long i = 0; i \< ITERATIONS; i++) shared_counter++;
return NULL;
}
void *private_worker(void *arg) {
int id = *(int *)arg;
for (long i = 0; i \< ITERATIONS; i++) counters[id].value++;
return NULL;
}
double run(void *(*fn)(void *), void *args[2]) {
struct timespec t0, t1;
pthread_t th[2];
clock_gettime(CLOCK_MONOTONIC, &t0);
pthread_create(&th[0], NULL, fn, args[0]);
pthread_create(&th[1], NULL, fn, args[1]);
pthread_join(th[0], NULL);
pthread_join(th[1], NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
return (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
}
int main(void) {
int ids[2] = {0, 1};
void *no_args[2] = {NULL, NULL};
void *id_args[2] = {&ids[0], &ids[1]};
double t_shared = run(shared_worker, no_args);
double t_private = run(private_worker, id_args);
printf("shared line (real coherence traffic): %.3f সেকেন্ড\n", t_shared);
printf("private line (কোনো coherence traffic না): %.3f সেকেন্ড\n", t_private);
printf("অনুপাত: %.2fx\n", t_shared / t_private);
return 0;
}প্রত্যাশিত ফলাফলের ধরন (মেশিনভেদে বদলাবে):
shared line (real coherence traffic): 4.820 সেকেন্ড
private line (কোনো coherence traffic না): 0.410 সেকেন্ড
অনুপাত: 11.76xএই পার্থক্য প্রায় সম্পূর্ণভাবে coherence traffic-এর দাম — shared_counter++ প্রতিবার আগে read করে, তারপর লেখে; দুই thread পালাক্রমে লিখলে প্রতিটা লেখাই আগের thread-এর কপি invalidate করে, নিজের জন্য নতুন M state আনতে বাধ্য করে একটা bus/interconnect round-trip। private_worker-এ প্রতিটা thread তার নিজস্ব, প্যাডেড (padded) cache line-এ কাজ করে — কখনো invalidate হয় না, পুরোটাই local L1 hit-এর গতিতে চলে।
একটা cache line-এ দুইটা core বারবার পালাক্রমে লিখলে (সত্যিকারের, ইচ্ছাকৃত sharing — false sharing না, পরের লেসনের বিষয়), প্রতিটা লেখাই M→I এবং I→M state-transition আর একটা real inter-core message ঘটায় — এই coherence traffic স্বাধীন, cache-local লেখার চেয়ে বহুগুণ ধীর।
নিজে বানান
একটা MESI State Machine Simulator
- প্রতিটা core-এর জন্য একটা dict বানান address → state (ডিফল্ট Invalid)
- একটা shared "bus log" লিস্ট বানান, প্রতিটা coherence message (read request/invalidate/data supply) এখানে যোগ হবে
- একটা read(core, addr) ফাংশন লিখুন যা এই লেসনের ধাপ ১-২-এর যুক্তি বাস্তবায়ন করে — অন্য কারো কাছে কপি না থাকলে E, থাকলে সবাইকে S বানায়
- একটা write(core, addr) ফাংশন লিখুন যা ধাপ ৩-এর যুক্তি বাস্তবায়ন করে — অন্য সবার কপি invalidate করে, লেখক core-কে M বানায়
- এই লেসনের সম্পূর্ণ worked example (A পড়ে, B পড়ে, A লেখে, C পড়ে) চালিয়ে output-এর state-table এই লেসনের টেবিলের সাথে হুবহু মেলে কিনা যাচাই করুন
- মোট bus message সংখ্যা গণনা করুন আর তুলনা করুন — যদি A বারবার লিখতে থাকে (কোনো অন্য core না পড়েই), কত কম message লাগে (E→M কোনো bus transaction ছাড়াই) তার বিপরীতে A-B পালাক্রমে লিখলে
class MESICache:
def __init__(self, name):
self.name = name
self.states = {} # addr -> 'M' | 'E' | 'S' | 'I'
def state(self, addr):
return self.states.get(addr, 'I')
def set_state(self, addr, s):
self.states[addr] = s
class System:
def __init__(self, core_names):
self.caches = {n: MESICache(n) for n in core_names}
self.memory = {}
self.bus_log = []
def read(self, core, addr):
c = self.caches[core]
if c.state(addr) != 'I':
self.bus_log.append(f"{core}: local hit ({c.state(addr)}), কোনো bus message লাগেনি")
return
sharers = [n for n, other in self.caches.items()
if n != core and other.state(addr) in ('M', 'E', 'S')]
self.bus_log.append(f"{core}: BusRd({addr}) broadcast")
if not sharers:
c.set_state(addr, 'E')
self.bus_log.append(f" → কেউ সাড়া দেয়নি, memory থেকে ডেটা, {core}: E")
return
for s in sharers:
other = self.caches[s]
if other.state(addr) == 'M':
self.memory[addr] = "writeback হয়েছে (M থেকে)"
self.bus_log.append(f" → {s} snoop করল (M), data supply + writeback, {s}: M→S")
other.set_state(addr, 'S')
elif other.state(addr) == 'E':
self.bus_log.append(f" → {s} snoop করল (E), data supply, {s}: E→S")
other.set_state(addr, 'S')
else:
self.bus_log.append(f" → {s} snoop করল (S), ইতিমধ্যে shared")
c.set_state(addr, 'S')
self.bus_log.append(f" → {core}: S")
def write(self, core, addr):
c = self.caches[core]
state = c.state(addr)
if state == 'M':
self.bus_log.append(f"{core}: local write, ইতিমধ্যে M, কোনো bus message লাগেনি")
return
if state == 'E':
c.set_state(addr, 'M')
self.bus_log.append(f"{core}: E→M, কোনো bus message লাগেনি (ইতিমধ্যে একমাত্র কপি)")
return
others = [n for n, other in self.caches.items() if n != core and other.state(addr) != 'I']
self.bus_log.append(f"{core}: Invalidate({addr}) broadcast")
for o in others:
self.bus_log.append(f" → {o} snoop করল, invalidate: {self.caches[o].state(addr)}→I")
self.caches[o].set_state(addr, 'I')
c.set_state(addr, 'M')
self.bus_log.append(f" → {core}: {state}→M")
sys_ = System(['A', 'B', 'C'])
sys_.read('A', 'X') # প্রত্যাশিত: A অ্যাডভান্স E
sys_.read('B', 'X') # প্রত্যাশিত: A,B উভয়ই S
sys_.write('A', 'X') # প্রত্যাশিত: A: M, B: I
sys_.read('C', 'X') # প্রত্যাশিত: A: S (writeback-সহ), C: S
for line in sys_.bus_log:
print(line)
print("\nচূড়ান্ত states:", {n: c.state('X') for n, c in sys_.caches.items()})প্রত্যাশিত final state: {'A': 'S', 'B': 'I', 'C': 'S'} — ঠিক এই লেসনের টেবিলের ধাপ ৪-এর সাথে মিলবে। এই simulator চালিয়ে নিজে যাচাই করুন misconception অংশের “সাধারণ ভুল” গুলো (A সরাসরি I-তে যাওয়া, ইত্যাদি) simulator-এ ঘটছে না।
বাস্তব সিস্টেমে
Cache coherence বাস্তব সিস্টেমে
-
Intel/AMD x86-64 — আধুনিক সব multi-core x86 CPU হার্ডওয়্যার cache coherence প্রয়োগ করে (একে বলা হয় “cache coherent”, সংক্ষেপে ccNUMA বড় সিস্টেমে)। Intel প্রায়ই MESIF ব্যবহার করে — MESI + একটা “Forward” state, যা ঠিক করে দেয় একাধিক S-state কপির মধ্যে কোনটা নতুন reader-কে ডেটা সরবরাহ করবে (শুধু memory-ই না, কোনো একটা নির্দিষ্ট cache-ও দায়িত্ব নেয়, বাড়তি সমন্বয়-দায়িত্ব একটা কপির উপর কেন্দ্রীভূত করে)।
-
AMD (Opteron থেকে Zen পর্যন্ত) — MOESI ব্যবহার করে, এই লেসনের O state ঠিক এই vendor-এর বাস্তব প্রয়োগ। বিশেষত multi-socket সার্ভার ডিজাইনে (একাধিক চিপ, Infinity Fabric দিয়ে যুক্ত), dirty ডেটা cache-to-cache শেয়ার করে বারবার memory writeback এড়ানো একটা বাস্তব bandwidth-সাশ্রয়ী কৌশল।
-
ARM ইন্টারকানেক্ট (CCI, CMN, AMBA ACE) — ARM-এর multi-core ডিজাইনে coherence প্রায়ই MOESI-ধাঁচের একটা প্রোটোকল অনুসরণ করে, ARM-এর নিজস্ব coherent interconnect fabric (CoreLink CMN সিরিজ) দিয়ে বাস্তবায়িত — phone/tablet SoC-তে CPU cluster-দের মধ্যে coherence বজায় রাখতে ব্যবহৃত।
-
Multi-socket সার্ভার (QPI/UPI, Infinity Fabric) — একাধিক physical চিপ (socket) যখন একসাথে coherent memory শেয়ার করে, broadcast snooping আর ব্যবহারিক থাকে না (latency আর bandwidth-এর কারণে) — এখানে directory-based coherence বাধ্যতামূলক। এটাই এই module-এর পরবর্তী একটা topic (NUMA topology) সরাসরি প্রাসঙ্গিক করে তোলে — coherence থাকলেও, দূরত্বভেদে latency ভিন্ন হতে পারে।
-
perf c2c (cache-to-cache) — Linux-এর বাস্তব coherence-debugging টুল — Linux
perf c2cকমান্ড সরাসরি hardware performance counter ব্যবহার করে detect করে কোন cache line-এ “HITM” (Hit Modified — অন্য core-এর M-state কপি থেকে data সরবরাহ) ঘটছে বেশি, অর্থাৎ ঠিক কোথায় এই লেসনের coherence traffic বাস্তব প্রোগ্রামে ঘটছে সবচেয়ে বেশি — এই লেসনের ধারণাগুলোর সরাসরি, দৈনন্দিন প্রকৌশলিক প্রয়োগ। -
False sharing — coherence-এর দাম যখন বাগে পরিণত হয় — পরের লেসনের (
false-sharing) মূল বিষয়: যখন দুইটা সম্পূর্ণ অসম্পর্কিত variable কাকতালীয়ভাবে একই ৬৪-বাইট cache line-এ পড়ে, দুইটা ভিন্ন core সেগুলোতে স্বাধীনভাবে লিখলেও, hardware-এর দৃষ্টিতে তারা একই line-এ লিখছে — এই লেসনের experiment-এরshared_counterউদাহরণের মতোই ping-pong ঘটে, যদিও প্রোগ্রামার কোনো logical sharing চায়নি। -
GPU-CPU কোহেরেন্স — সবসময় ছিল না, এখন আসছে — পুরনো GPU ডিজাইনে CPU আর GPU memory সম্পূর্ণ আলাদা ছিল, explicit copy (যেমন CUDA-র
cudaMemcpy) ছাড়া কোনো coherence গ্যারান্টিই ছিল না। আধুনিক ডিজাইন (Apple-এর unified memory architecture, NVIDIA-র NVLink-C2C, AMD-র Infinity Cache-ভিত্তিক APU) ধীরে ধীরে CPU-GPU-এর মধ্যেও hardware coherence আনছে — প্রমাণ করে coherence কোনো “সবসময়-থাকা” প্রাকৃতিক বৈশিষ্ট্য না, এটা একটা স্পষ্ট, দামি প্রকৌশল সিদ্ধান্ত যেখানে প্রয়োজন সেখানেই যোগ করা হয়।
যে ভুলগুলো সবাই করে
“Cache coherence মানে সব core-এর cache সবসময় বাস্তব-সময়ে (real-time) সিঙ্ক্রোনাইজড, একটা ক্রমাগত broadcast চলতে থাকে।”
না — coherence একটা on-demand গ্যারান্টি, ক্রমাগত ব্যাকগ্রাউন্ড সিঙ্ক্রোনাইজেশন না। যতক্ষণ কোনো core একটা address-এ access করছে না, তার cache সেই address সম্পর্কে যা খুশি (এমনকি stale) অবস্থায় “চুপচাপ” বসে থাকতে পারে — protocol শুধু তখনই সক্রিয় হয় যখন একটা conflicting access ঘটে (একটা core পড়তে/লিখতে চায়, আর অন্য কারো কাছে সংশ্লিষ্ট কপি থাকে)। এই লেসনের worked example-এর প্রতিটা ধাপ একটা নির্দিষ্ট read/write ঘটনার প্রতিক্রিয়া, কোনো ঘড়ি-চালিত polling না।
“Invalid (I) state মানে সেই cache-এ সেই address-এর জন্য কোনো ডেটা physically নেই।”
cache-organization লেসনের valid bit আলোচনার সরাসরি প্রতিধ্বনি — I state মানে শুধু এটাই যে হার্ডওয়্যার সেই line-এর content-কে ব্যবহারযোগ্য বলে গণ্য করবে না, hit-check-এ সবসময় miss হিসেবে ধরবে। Physically, সেই SRAM cell-গুলোতে পুরনো bit pattern এখনও বসে থাকতে পারে, নতুন কিছু দিয়ে ওভাররাইট না হওয়া পর্যন্ত — শুধু state metadata (tag-এর পাশের ছোট state field) বলে দেয় এই content বিশ্বাসযোগ্য না। এটা ঠিক direct-mapped cache-এর valid bit-এর ধারণার সম্প্রসারণ, এখন চার-মানের একটা state field হিসেবে।
“Modified (M) state-এ থাকা একটা লেখা সরাসরি memory-তে যায় — তাই M state-এর লেখা 'ধীর'।”
উল্টো — M state-এর পুরো পয়েন্টই হলো লেখাটা শুধু local cache-এ ঘটে, কোনো memory access ছাড়াই (এই কারণেই write-back নীতি, cache-policies লেসনের বিষয়, দ্রুত)। যা ধীর, তা হলো আগের ধাপ — E বা S থেকে M-এ পৌঁছাতে অন্য cache-দের invalidate করার bus transaction (যদি লাগে)। একবার M state-এ পৌঁছালে, পরবর্তী লেখাগুলো (যতক্ষণ অন্য কেউ interfere না করে) সম্পূর্ণ local-gতির, cache-organization-এর সাধারণ cache hit-এর মতোই দ্রুত।
“Cache coherence protocol নিশ্চিত করে multi-threaded প্রোগ্রামে data race বা logical bug হবে না।”
এটা এই লেসনের সবচেয়ে গুরুত্বপূর্ণ, সূক্ষ্ম সংশোধন — coherence আর program correctness সম্পূর্ণ ভিন্ন স্তরের বিষয়। MESI নিশ্চিত করে একটা মাত্র address-এর সব cache কপি শেষ পর্যন্ত সামঞ্জস্যপূর্ণ থাকবে (write propagation + serialization, concept অংশের সংজ্ঞা)। কিন্তু এটা কিছুই বলে না ভিন্ন ভিন্ন address-এর মধ্যে অপারেশনের আপেক্ষিক ক্রম নিয়ে — একটা thread যদি data = 42 লিখে তারপর ready = true লিখে, coherence একাই গ্যারান্টি দেয় না যে অন্য thread ready-কে true দেখার পরে data-কেও আপডেটেড দেখবে (compiler/hardware reordering-এর কারণে)। এটা memory consistency model-এর প্রশ্ন, আর সঠিক multi-threaded synchronization-এর জন্য প্রয়োজন হয় explicit atomics/memory barrier (এই module-এর পরের একটা লেসনের বিষয়), শুধু coherence-ই যথেষ্ট না।
বুঝেছেন কি না দেখুন
1MESI-এর চারটা state-এর নাম আর প্রতিটার এক-লাইনের অর্থ বলুন।
স্মরণ
M (Modified) — একমাত্র কপি, dirty (memory-র থেকে ভিন্ন)। E (Exclusive) — একমাত্র কপি, clean (memory-র সাথে অভিন্ন)। S (Shared) — একাধিক cache-এ থাকতে পারা কপি, সব clean, memory-র সাথে অভিন্ন। I (Invalid) — কপি stale/অকেজো, hit হিসেবে গণ্য হয় না।
2তিনটা core — P, Q, R। শুরুতে কেউই address Y cache করেনি। ক্রমানুসারে ঘটে: (১) Q পড়ে Y, (২) R পড়ে Y, (৩) Q লেখে Y-এ। প্রতিটা ধাপের পরে P, Q, R-এর state কী?
প্রয়োগ
ধাপ ১ (Q পড়ে): কেউ cache করেনি, তাই কোনো snoop hit নেই। Q: E। P, R: I (অপরিবর্তিত)।
ধাপ ২ (R পড়ে): R miss করে, snoop করে দেখে Q-র কাছে E কপি আছে। যেহেতু এখন দুইটা কপি হবে, দুইটাই S-এ নামে। Q: E→S। R: S। P: I (কখনো জড়ায়নি)।
ধাপ ৩ (Q লেখে): Q-র কাছে ইতিমধ্যে S আছে, শুধু ownership দরকার — invalidate broadcast পাঠায়। R snoop করে নিজের কপি invalidate করে: R: S→I। Q: S→M। P: I (অপরিবর্তিত, কখনো জড়ায়নি বলে কোনো প্রভাব নেই)।
চূড়ান্ত অবস্থা: P: I, Q: M, R: I।
3MESI-তে E state না থাকলে (অর্থাৎ শুধু MSI হলে) কী পার্থক্য হতো একটা single-threaded প্রোগ্রামে যেখানে একটা core একটা variable প্রথমে read করে (initialize করার জন্য) তারপর সাথে সাথেই write করে, আর অন্য কোনো core কখনোই সেই variable ছুঁয়ে দেখে না?
যুক্তি
MESI-তে: read miss-এ কোনো snoop hit হয় না (কেউ cache করেনি), তাই সরাসরি E state পাওয়া যায়। পরের write-এ E→M transition-এ কোনো bus transaction লাগে না — hardware ইতিমধ্যে জানে এটাই একমাত্র কপি।
শুধু MSI (E ছাড়া) হলে: read miss-এ সরাসরি S state-এ যেতে হতো (কারণ MSI-তে “একমাত্র কপি” বলার কোনো state নেই, সবসময় সতর্কতাবশত S ধরে নেওয়া হয়)। পরের write-এ S→M transition-এর জন্য একটা invalidate broadcast পাঠাতে হতো, যদিও বাস্তবে কোনো অন্য core-এর কাছে কপিই ছিল না — একটা সম্পূর্ণ অপ্রয়োজনীয় bus transaction, শুধু protocol-এর সীমাবদ্ধতার কারণে।
তাই এই সাধারণ, খুবই প্রচলিত প্যাটার্নে (read-then-write, single-threaded বা একা ব্যবহৃত ডেটা) MESI-এর E state একটা বাস্তব, পরিমাপযোগ্য coherence-ট্র্যাফিক সাশ্রয় দেয় MSI-এর তুলনায়।
4দুইটা core, A আর B, একটা shared cache line-এ পালাক্রমে বারবার লিখতে থাকে (A লেখে, B লেখে, A লেখে, …)। প্রতিটা লেখার আগে ঠিক কী coherence ঘটনা ঘটবে, আর এই প্যাটার্নটা কেন এই module-এর পরের লেসনের (false sharing) সাথে সরাসরি সম্পর্কিত?
যুক্তি
প্রতিটা লেখার আগে: লেখক core-এর কাছে যদি কপি না থাকে (আগের লেখক-এর invalidate-এর কারণে থাকবে না), তাকে একটা read-for-ownership-ধরনের request পাঠাতে হয় — অন্য core (যার কাছে M state-এ কপি আছে) snoop করে data সরবরাহ করে এবং নিজের কপি invalidate করে (M→I), নতুন লেখক M state পায়। পরের লেখার আগে ঠিক উল্টোটা ঘটে। অর্থাৎ প্রতিটা লেখা একটা সম্পূর্ণ inter-core round-trip ঘটায় — কখনোই স্থিতিশীল M state-এ থেকে দ্রুত local লেখা পাওয়া যায় না, কারণ line-টা প্রতিবার “পিং-পং” করছে।
এটা ঠিক এই লেসনের experiment-এর shared_counter পরিস্থিতি — এখানে sharing ইচ্ছাকৃত (সত্যিকারের shared variable)। পরের লেসনের (false-sharing) মূল বিন্দু: এই ঠিক একই ব্যয়বহুল ping-pong ঘটতে পারে এমনকি যখন A আর B সম্পূর্ণ আলাদা, অসম্পর্কিত variable-এ লিখছে — শুধু কাকতালীয়ভাবে সেই দুইটা variable একই ৬৪-বাইট cache line-এ memory-তে পাশাপাশি বসে যাওয়ায়। Coherence protocol line-এর ভেতরের কোন বাইট বদলেছে তা জানে না/পরোয়া করে না — পুরো line-কে একক এককভাবে দেখে, তাই “ভিন্ন ডেটা” হলেও protocol একই আচরণ করে।
5একটা producer-consumer প্যাটার্নে, core P বারবার একটা shared buffer-এ লেখে, আর core Q১, Q২, Q৩ — তিনটা core — বারবার সেই একই buffer পড়ে (কিন্তু কখনো লেখে না)। এই পরিস্থিতিতে MESI বনাম MOESI-এর মধ্যে বাস্তব পারফরম্যান্স পার্থক্য কী হবে, আর কেন?
ডিজাইন
প্রতিবার P লেখে, তার কপি M state-এ যায় (অন্য সবাইকে invalidate করে)। এরপর Q১, Q২, Q৩ পালাক্রমে পড়তে চাইলে:
MESI-তে: প্রথম reader (ধরুন Q১) পড়তে চাইলে, P-কে (M state-এ) data সরবরাহ এবং memory-তে writeback করতে হয় (ধাপ ৪-এর যুক্তি), P: M→S, Q১: S। এরপর Q২ পড়তে চাইলে, এবার P বা Q১ (উভয়ই S, clean) থেকে বা memory থেকে সহজেই ডেটা পাওয়া যায় — কোনো নতুন writeback লাগে না, কারণ ইতিমধ্যেই clean।
MOESI-তে: প্রথম reader Q১ পড়তে চাইলে, P data সরবরাহ করে কিন্তু writeback করে না — P: M→O, Q১: S। মূল পার্থক্য: MOESI-তে এই memory writeback-টা এড়ানো গেল। যেহেতু P বারবার নতুন করে লিখতে থাকবে (producer প্যাটার্ন), প্রতিটা লেখার চক্রে MESI একটা করে বাধ্যতামূলক memory writeback করবে (প্রথম reader আসার সময়), যেখানে MOESI সেই writeback সম্পূর্ণ এড়িয়ে শুধু cache-to-cache transfer-এই কাজ চালাবে — যতক্ষণ না P নিজে evict হয় বা memory-access সত্যিই আবশ্যক হয়।
ফলাফল: ঘন ঘন producer-write, বহু-reader প্যাটার্নে MOESI বাস্তবে কম memory bandwidth ব্যবহার করে MESI-এর তুলনায় — ঠিক এই কারণেই AMD-এর মতো vendor যাদের workload-এ এই প্যাটার্ন সাধারণ (multi-threaded সার্ভার/HPC কোড), MOESI বেছে নিয়েছে।
এরপর কী
আমরা এখন সম্পূর্ণভাবে দেখেছি কীভাবে multi-core CPU নিশ্চিত করে সব core একই memory-র একটা সামঞ্জস্যপূর্ণ ছবি দেখে — MESI-এর চার state, bus snooping, আর MOESI-এর memory-bandwidth সাশ্রয়। কিন্তু এই লেসনের experiment আর check-এর প্রশ্ন ৪ ইচ্ছাকৃতভাবে একটা ভয়ংকর বাস্তবতার দিকে ইঙ্গিত করেছে: coherence protocol পুরো cache line-কে একক এককভাবে দেখে, ভেতরের আলাদা variable-কে না।
এর মানে দুইটা প্রোগ্রামার-দৃষ্টিতে সম্পূর্ণ স্বাধীন variable, যাদের মধ্যে কোনো logical sharing নেই, শুধু memory layout-এর কাকতালে একই ৬৪-বাইট line-এ বসে গেলে ঠিক এই লেসনের ব্যয়বহুল ping-pong ঘটাতে পারে — সম্পূর্ণ অদৃশ্যভাবে, প্রোগ্রামের logic-এ কোনো bug ছাড়াই, শুধু পারফরম্যান্সে। পরের লেসনে (false-sharing) আমরা ঠিক এই সমস্যাটা — কীভাবে চিনতে হয়, কীভাবে মাপতে হয়, আর কীভাবে (padding, alignment দিয়ে) এড়াতে হয় — বিস্তারিত দেখব।
আরও পড়ুন
- Computer Architecture: A Quantitative Approach, Chapter 5 — Hennessy & Patterson · MESI/MOESI ও snooping/directory coherence-এর প্রামাণ্য আলোচনা
- A Primer on Memory Consistency and Cache Coherence — Sorin, Hill, Wood · Coherence ও consistency-র পার্থক্যসহ সম্পূর্ণ প্রযুক্তিগত ভিত্তি
- AMD64 Architecture Programmer's Manual, Volume 2 — System Programming — AMD · MOESI protocol-এর বাস্তব বাস্তবায়নের বিবরণ