এই পর্বে যা শিখবেন
- Write-back cache-এ stale value কীভাবে তৈরি হয়, আর coherence-এর সংজ্ঞা — write propagation, write serialization আর single-writer/multiple-reader নিয়ম
- MSI-এর state-transition টেবিল আর diagram, MESI-র E অবস্থা, আর যেকোনো read/write trace-এ প্রতিটি ধাপের অবস্থা আর bus transaction হাতে বের করা
- Directory-ভিত্তিক coherence-এর মূল কথা, coherence miss আর false sharing-এর ভেতরের কারণ
- Sequential consistency, store buffer, x86-এর TSO বনাম ARM/RISC-V-এর দুর্বল model, fence আর acquire/release
- Test-and-set, CAS, LR/SC আর AMO দিয়ে spinlock, cache-line bouncing আর test-and-test-and-set
১ · কেন multicore — power wall আর ILP-এর দেয়াল
২০০০-এর দশকের শুরু পর্যন্ত প্রতি প্রজন্মে CPU দ্রুত হতো মূলত দুই উপায়ে: clock বাড়িয়ে, আর একটি core-কে আরও চতুর করে — গভীর pipeline, superscalar, out-of-order. দুটো পথই প্রায় একই সময়ে দেয়ালে ঠেকে।
প্রথম দেয়াল power wall (পর্ব ০২): CMOS-এ dynamic power মোটামুটি C·V²·f-এর সমানুপাতিক (পর্ব ০৮). Dennard scaling-এর যুগে transistor ছোট হলে voltage-ও কমত, তাই বাড়তি clock-এর খরচ পুষিয়ে যেত। ২০০৫-এর আশেপাশে voltage আর কমানো কঠিন হয়ে পড়ল (leakage বেড়ে যায়), আর chip-এর তাপ ঠান্ডা রাখার সীমা ছুঁয়ে ফেলল। ফলে desktop chip-এর clock মোটামুটি কয়েক GHz-এ থেমে আছে প্রায় দুই দশক।
দ্বিতীয় দেয়াল ILP-এর দেয়াল (পর্ব ৩৯): একটি সাধারণ প্রোগ্রামে একসাথে চালানো যায় এমন instruction সীমিত। Core-কে ৪-wide থেকে ৮-wide করলে register port, dependence-পরীক্ষা আর bypass-এর খরচ প্রায় বর্গের হারে বাড়ে, কিন্তু IPC বাড়ে সামান্য।
অথচ Moore-এর সূত্র মেনে transistor তখনো বাড়ছিল। সেই বাড়তি transistor দিয়ে কী করা যায়? উত্তর: একটি বিশাল core-এর বদলে কয়েকটি মাঝারি core — multicore. দুটি ধীর core একটি দ্রুত core-এর চেয়ে কম বিদ্যুতে বেশি কাজ করতে পারে — যদি সফটওয়্যার কাজ ভাগ করতে পারে; ভাগ না হওয়া অংশ লাভ সীমিত করে (Amdahl, পর্ব ০৩).
একটি বড় রেস্তোরাঁর রান্নাঘর ভাবুন। এক জন বাবুর্চিকে দ্বিগুণ দ্রুত হাত চালাতে বলা যায় না — ঘাম ঝরবে, ভুল হবে (power wall). তাঁকে একসাথে চারটি চুলায় নজর রাখতে বললেও লাভ কম, কারণ অনেক রান্না আগের ধাপ শেষ না হলে শুরুই হয় না (ILP-এর দেয়াল). সমাধান: চার জন বাবুর্চি, প্রত্যেকের নিজের ছোট তাক (L1/L2 cache), আর মাঝখানে একটি ভাগ করা বড় ভাঁড়ার (L3) আর গুদাম (মেমরি). কিন্তু এখনই একটি নতুন সমস্যা — একজন যদি নিজের তাকের লবণের কৌটায় লেখেন “শেষ”, অন্যরা কি তা জানবেন?
২ · Shared-memory multiprocessor — নিজের L1/L2, ভাগ করা L3
আজকের ফোন, ল্যাপটপ আর সার্ভারের প্রায় সব chip একটি symmetric multiprocessor (SMP): সব core সমান, সবাই একই physical address space দেখে, একই OS চালায়। Core-সংখ্যা কম হলে যেকোনো core থেকে যেকোনো ঠিকানায় যেতে মোটামুটি একই সময় লাগে — একে বলে UMA. বড় সার্ভারে, যেখানে কয়েকটি socket-এর প্রত্যেকটির নিজের মেমরি, সময় ঠিকানাভেদে আলাদা — NUMA, যা পর্ব ৫৪-এর বিষয়।
কেন সবাই একটি L1 ভাগ করে না? L1-কে কয়েক cycle-এ উত্তর দিতে হয়, তাই সেটি ছোট আর core-এর একদম পাশে (পর্ব ৪৪); চারটি core-এর ভিড়ে port আর তারের দৈর্ঘ্যে তা ধীর হয়ে যেত। তাই প্রতিটি core-এর private cache, আর তার নিচে একটি বড়, তুলনায় ধীর, ভাগ করা L3. আর এখানেই সমস্যার বীজ: একই ঠিকানার কপি একাধিক private cache-এ থাকতে পারে।
৩ · Coherence-এর সমস্যা — stale value, আর সমাধানের সংজ্ঞা
পর্ব ৪৭-এ (cache-এর পারফরম্যান্স) দেখেছেন, আধুনিক cache প্রায় সবসময় write-back: লেখা প্রথমে শুধু cache-এ হয়, line-টি dirty চিহ্নিত থাকে, আর মেমরিতে যায় অনেক পরে, line সরানোর সময়। একটি core-এর জন্য এটি দারুণ — মেমরির ট্রাফিক অনেক কমে। কিন্তু দুটি core হলে? নিচের stepper-এ প্রথমে কোনো coherence ছাড়া, তারপর MSI protocol দিয়ে একই ঘটনা দেখুন।
| ঘটনা | |
| P1-এর cache | |
| P2-এর cache | |
| মেমরিতে X | |
| bus-এ কী গেল |
- শুরু। মেমরিতে X = ৫. দুটি core, P1 আর P2, প্রত্যেকের নিজস্ব write-back cache, দুটিই খালি।
- P1 পড়ে। P1-এর read miss; X = ৫ মেমরি থেকে P1-এর cache-এ এল।
- P2 পড়ে। P2-এরও miss; সেও মেমরি থেকে ৫ আনল। এখন একই ঠিকানার দুটি কপি — এ পর্যন্ত কোনো সমস্যা নেই, কারণ দুটিই সঠিক।
- P1 লেখে (coherence ছাড়া)। X = ৭ লেখা হলো শুধু P1-এর cache-এ; write-back বলে মেমরি এখনো ৫. P2-কে কেউ কিছু জানাল না।
- P2 পড়ে — ভুল। P2-এর cache-এ X আছে, তাই hit, আর সে পড়ল ৫ — একটি stale মান। P1 আগেই ৭ লিখেছে, অথচ P2 কখনো তা দেখবে কি না, তার কোনো নিশ্চয়তা নেই। (Write-through-এও মেটে না: মেমরি ৭ হতো, P2-এর কপি তবু ৫.)
- এবার MSI দিয়ে, ধাপ ৪-এর জায়গায়। ধাপ ৩-এর পরে দুজনের কপিই S (Shared) অবস্থায়। P1 লিখতে চাইলে আগে bus-এ BusUpgr পাঠায় — “আমি লিখব, বাকিরা কপি বাতিল করুন।” P2 শুনে নিজের কপি I (Invalid) করে। তারপর P1 লেখে, তার কপি M (Modified).
- MSI-তে P2 পড়ে — ঠিক। P2-এর কপি বাতিল, তাই miss; bus-এ BusRd. P1 শোনে যে তার কাছে M কপি আছে, তাই সে Flush করে — নতুন মান ৭ bus-এ তুলে দেয়; মেমরিও হালনাগাদ হয়। P2 পায় ৭, দুজনেই এখন S. সমস্যা মিটল একটি নিয়মে: লেখার আগে অন্য সব কপি বাতিল।
Coherence-এর সংজ্ঞা
“সবাই সর্বশেষ মান দেখবে” — শুনতে সহজ, কিন্তু “সর্বশেষ” কথাটাই multicore-এ ঝাপসা, কারণ আলাদা core-এর ঘটনার একটি সার্বজনীন ঘড়ি নেই। তাই cache coherence-কে একটি ঠিকানা ধরে দুটি শর্তে সংজ্ঞায়িত করা হয় (Hennessy–Patterson-এর পাঠ্যবইয়ের ধাঁচে):
- Write propagation: কোনো core X-এ লিখলে, যথেষ্ট সময় পরে অন্য core X পড়লে সেই নতুন মান (বা তার পরের কোনো মান) পাবে। কেউ চিরকাল পুরোনো কপিতে আটকে থাকবে না। (আর একটি core নিজের লেখার পরে নিজে পড়লে নিজের মান পাবে, মাঝে অন্য কেউ না লিখলে।)
- Write serialization: একই ঠিকানায় দুটি লেখা — ধরুন P1 লেখে ১, P2 লেখে ২ — সব core একই ক্রমে দেখবে। কেউ “১ তারপর ২” আর কেউ “২ তারপর ১” দেখবে না।
বাস্তবে প্রায় সব protocol এই দুই শর্ত মানে একটি সরল নিয়ম দিয়ে — single-writer, multiple-reader (SWMR): যেকোনো মুহূর্তে একটি line-এর হয় একজন মাত্র core আছে যে পড়তে-লিখতে পারে, নয়তো এক বা একাধিক core আছে যারা শুধু পড়তে পারে। সঙ্গে ডেটার নিয়ম: একটি read-only সময়কালের শুরুতে line-এর মান হবে আগের read-write সময়কালের শেষ মান। যুক্তি: লেখা সবসময় একজন মালিকের হাতে হয়, তাই একই ঠিকানার সব লেখা মালিকানা হাতবদলের ক্রমে সাজানো — এটিই write serialization. আর প্রতিটি হাতবদলে নতুন মালিক বা পাঠক আগের মালিকের শেষ মান পায় — এটিই write propagation.
Coherence বনাম consistency — দুটি আলাদা প্রশ্ন
Coherence শুধু একটি ঠিকানার কথা বলে। কিন্তু প্রোগ্রাম সাধারণত কয়েকটি ঠিকানা একসাথে ব্যবহার করে: thread A লেখে data = 42, তারপর flag = 1; thread B flag ১ দেখলে data পড়ে। B কি নিশ্চিত ৪২ পাবে? এটি ভিন্ন ঠিকানার ক্রমের প্রশ্ন, যার উত্তর দেয় memory consistency model — অংশ ৭-এর বিষয়। মনে রাখার সূত্র: coherence = একটি ঠিকানার সব লেখা সবাই একভাবে দেখে; consistency = আলাদা ঠিকানার access-গুলো অন্যদের চোখে কোন ক্রমে দেখা দিতে পারে। একটি সিস্টেম পুরোপুরি coherent হয়েও দুর্বল consistency দিতে পারে — আজকের ARM আর RISC-V chip ঠিক তাই।
৪ · Snooping আর MSI protocol — state, event আর transition
কম core-এর chip-এ coherence-এর সবচেয়ে পুরোনো আর সরল উপায় snooping: সব cache একটি ভাগ করা bus-এ (বা এমন কোনো পথে, যেখানে প্রতিটি বার্তা সবাই একই ক্রমে শোনে) যুক্ত। প্রতিটি cache-এর controller দুই দিকে কান পাতে — নিজের core-এর অনুরোধ, আর bus-এ অন্যদের অনুরোধ। Bus-এর একটি গুরুত্বপূর্ণ গুণ: একবারে একজনই bus পায়, তাই সব অনুরোধ একটি নির্দিষ্ট ক্রমে ঘটে — write serialization বিনা খরচে।
মহল্লার মাইকে ঘোষণার মতো: একবারে একটিই ঘোষণা, সবাই একসাথে শোনে, আর প্রতিটি বাড়ি (cache) শুধু দেখে ঘোষণাটি তার সাথে সম্পর্কিত কি না।
দুই নীতি: write-invalidate আর write-update
Write-invalidate
লেখার আগে একবার bus-এ জানিয়ে অন্য সব কপি বাতিল; তারপর যত খুশি লেখা, সবই নিজের cache-এ। কেউ আবার পড়তে চাইলে তখন miss হয়ে নতুন মান আনে। MSI, MESI, MOESI — আজকের প্রায় সব CPU.
একবার জানানো, তারপর নিশ্চিন্তে লেখাWrite-update
প্রতিটি লেখার নতুন মান bus-এ broadcast, সব কপি হালনাগাদ। পাঠকের পরের read hit হয়। কিন্তু একই ঠিকানায় পরপর অনেক লেখা হলে প্রতিটিতেই bus খরচ। Xerox PARC-এর Dragon আর DEC Firefly-র মতো কিছু পুরোনো গবেষণা-যন্ত্রে ব্যবহৃত হয়েছিল।
প্রতিটি লেখায় সবাইকে জানানোছোট হিসাব। P1 একটি ঠিকানায় পরপর ১০ বার লেখে, P2-এর কাছে তার কপি আছে, আর P2 শেষে একবার পড়ে। Write-update: ১০টি broadcast. Write-invalidate: প্রথম লেখায় ১টি invalidation, শেষে P2-এর ১টি miss — মোট ২টি। উল্টো ধরন — producer-consumer, যেখানে প্রতিটি লেখার পরেই অন্যজন পড়ে: update-এ প্রতি লেখায় ১টি, invalidate-এ প্রতি লেখায় ২টি (বাতিল + miss). বাস্তবে প্রথম ধরন বেশি — তাই invalidate জিতেছে।
MSI-এর তিন অবস্থা
MSI-এ প্রতিটি cache-এর প্রতিটি line-এর সঙ্গে দুটি বাড়তি bit, যা তিনটি অবস্থার একটি বোঝায়:
| অবস্থা | মানে | পড়া যায়? | লেখা যায়? | মেমরির সাথে মিল? |
|---|---|---|---|---|
| M (Modified) | এই cache-ই একমাত্র মালিক, আর মান বদলেছে | হ্যাঁ | হ্যাঁ, bus ছাড়া | না (dirty) — সরানোর আগে লিখতে হবে |
| S (Shared) | পরিষ্কার কপি; অন্যদের কাছেও থাকতে পারে | হ্যাঁ | না — আগে অন্যদের বাতিল করাতে হবে | হ্যাঁ |
| I (Invalid) | কপি নেই, বা বাতিল | না (miss) | না (miss) | — |
এবার event দুই ধরনের। Processor event — নিজের core থেকে: PrRd (পড়া), PrWr (লেখা)। Bus event — অন্য cache থেকে শোনা bus transaction: BusRd (“পড়ার জন্য line চাই”), BusRdX (“লেখার জন্য line চাই — সবাই বাতিল করুন”; X = exclusive), BusUpgr (“line আমার কাছে S-এ আছে, শুধু অন্যরা বাতিল করুন” — ডেটা আনতে হয় না, তাই BusRdX-এর চেয়ে সস্তা; কিছু পাঠ্যবই এখানেও BusRdX লেখে)। আর একটি উত্তর: Flush — M কপির মালিক নিজের হালনাগাদ ডেটা bus-এ তুলে দেয়।
| বর্তমান | PrRd | PrWr | শোনা BusRd | শোনা BusRdX | শোনা BusUpgr |
|---|---|---|---|---|---|
| I | BusRd পাঠায় → S | BusRdX পাঠায় → M | — (I থাকে) | — (I থাকে) | — (I থাকে) |
| S | hit, S থাকে | BusUpgr পাঠায় → M | S থাকে | → I | → I |
| M | hit, M থাকে | hit, M থাকে | Flush → S | Flush → I | ঘটে না (M থাকলে অন্য কারও S থাকতে পারে না) |
শেষ ঘরটি SWMR-এর সরাসরি ফল: কেউ M-এ থাকলে আর কারও কাছে বৈধ কপি নেই, তাই কেউ S থেকে BusUpgr পাঠাতে পারে না। একই তথ্য ছবিতে:
পাঠ্যবইয়ের MSI “atomic bus” ধরে নেওয়া আদর্শ ছবি। বাস্তবে অনুরোধ আর উত্তরের মাঝে সময় লাগে, তাই “I থেকে S-এর পথে” ধরনের অনেক transient অবস্থা আর race সামলানোর নিয়ম লাগে — hardware bug-এর বড় উৎস, যা শিল্পে model checking-এর মতো formal পদ্ধতিতে যাচাই করা হয়।
৫ · MESI — Exclusive অবস্থা কী বাঁচায়, আর একটি পূর্ণ trace
MSI-তে একটি অপচয় খুব সাধারণ: একটি thread নিজের ব্যক্তিগত ডেটা (যেমন নিজের stack বা নিজের array) পড়ে, তারপর লেখে। পড়ায় BusRd → S; লেখায় BusUpgr → M. অথচ আর কারও কাছে কপি ছিলই না — BusUpgr-টি বৃথা, আর কম্পাইলারের তৈরি x = x + 1-এর মতো load-তারপর-store প্রোগ্রামে প্রতি মুহূর্তে ঘটে।
MESI একটি চতুর্থ অবস্থা যোগ করে: E (Exclusive) — পরিষ্কার কপি (মেমরির সাথে মিল), কিন্তু শুধু এই cache-এর কাছেই. BusRd-এর সময় bus-এ একটি “shared” সংকেত থাকে: অন্য কোনো cache-এর কাছে কপি থাকলে তারা সংকেতটি চালু করে। চালু হলে line S-এ আসে, না হলে E-তে। E থেকে লেখায় কোনো bus transaction লাগে না — নিঃশব্দে M. আর E-তে থাকা line অন্য কেউ BusRd করলে → S; সরিয়ে দেওয়ার সময় মেমরিতে লিখতে হয় না, কারণ ডেটা পরিষ্কার। (এই protocol প্রায়ই “Illinois protocol” নামে পরিচিত, ১৯৮৪ সালের দিকে University of Illinois-এর Papamarcos আর Patel-এর প্রস্তাব থেকে।)
একটি thread ১০০টি আলাদা line-এ ব্যক্তিগত ডেটা পড়ে-লেখে (a[i] = a[i] + 1, অন্য কেউ ছোঁয় না): MSI-তে প্রতি line-এ BusRd + BusUpgr = ২০০টি bus transaction; MESI-তে প্রতি line-এ শুধু BusRd = ১০০টি — অর্ধেক। কিন্তু যদি শুধু লেখা হয় (a[i] = 0), দুই protocol-এই প্রতি line-এ একটি BusRdX — ১০০ আর ১০০, কোনো পার্থক্য নেই। (দুটি হিসাবই নিচের simulator-এর মতো একটি মডেলে চালিয়ে মেলানো হয়েছে।)
এবার তিনটি core আর দুটি ঠিকানা (X ভাগ করা, Y শুধু P3-এর) নিয়ে একটি পূর্ণ trace. প্রতিটি ধাপে ভাবুন — কোন অবস্থা, কোন bus transaction, ডেটা কোথা থেকে — তারপর “পরে” চাপুন। ডান দিকের গণনায় একই trace MSI-তে চালালে কত bus transaction হতো, তাও দেখানো হয়েছে।
| ঘটনা | |
| P1 · P2 · P3 (ওই ঠিকানার) | |
| bus transaction | |
| ডেটা এল কোথা থেকে | |
| মোট transaction: MESI / MSI |
- শুরু। তিনটি core, P1, P2, P3. X আর Y কোনো cache-এ নেই — সব I. প্রতিটি ধাপে শুধু সেই ধাপের ঠিকানার অবস্থা দেখানো হয়েছে।
- P1 পড়ে X. I-তে PrRd → BusRd. আর কারও কপি নেই, তাই shared সংকেত বন্ধ; ডেটা মেমরি থেকে, P1 যায় E-তে। (MSI-তে S.)
- P1 লেখে X. E মানে “শুধু আমার কাছে, পরিষ্কার” — তাই কাউকে জানানোর দরকার নেই: bus ছাড়াই E → M. MSI-তে এখানে S থেকে BusUpgr লাগত — প্রথম সঞ্চয়।
- P2 পড়ে X. P2-এর miss, BusRd. P1 শোনে, তার কাছে M — সে Flush করে নতুন মান দেয় (মেমরিও হালনাগাদ) আর নিজে M → S. P2-ও S.
- P3 পড়ে X. BusRd; এখন কারও কাছে M নেই, সব কপি পরিষ্কার — তাই এই মডেলে ডেটা আসে মেমরি থেকে। (অনেক বাস্তব নকশায় একটি S কপিধারী cache সরাসরি পাঠায় — MESIF-এর F অবস্থা ঠিক এর জন্য।) তিনজনই S.
- P2 লেখে X. P2-এর কাছে S কপি আছে, ডেটা আনার দরকার নেই — শুধু BusUpgr. P1 আর P3 → I, P2 → M.
- P3 লেখে X. P3-এর কপি I, তাই লেখার জন্য পুরো line চাই: BusRdX. P2 শোনে, তার কাছে M — Flush করে line P3-কে দেয়, আর নিজে I. P3 → M. SWMR লক্ষ করুন: লেখক সবসময় একজন।
- P1 পড়ে X. BusRd; P3-এর কাছে M — Flush, P3 → S, P1 → S. এখানে P1-এর miss একটি coherence miss: line-টি তার কাছে ছিল, কিন্তু ধাপ ৫-এ অন্যের লেখায় বাতিল হয়েছিল।
- P3 পড়ে Y. Y শুধু P3-এর ব্যক্তিগত ডেটা। BusRd, কোনো shared সংকেত নেই → E.
- P3 লেখে Y. আবার নিঃশব্দ E → M. মোট: MESI-তে ৭টি bus transaction, একই trace MSI-তে ৯টি — দুটি BusUpgr বাঁচল, দুটিই “পড়ে তারপর লেখা, আর কারও কপি নেই” ধাপে (২ আর ৯)। Flush দুই protocol-এই ৩টি।
এবার একই কাজ একটি simulator দিয়ে। নিচের JS কোড N-টি core-এর জন্য লেখা (এখানে ৩), একটি trace (core, R/W, ঠিকানা) নেয়, আর MSI ও MESI — দুই নিয়মে চালিয়ে প্রতিটি ধাপে প্রতিটি core-এর অবস্থা, bus transaction আর ডেটার উৎস ছাপে। Trace বদলে নিজের প্রশ্ন বানিয়ে দেখতে পারেন।
// MSI vs MESI snooping simulator: per step each core's state, bus transaction, data source
const CORES = 3, ids = Array.from({ length: CORES }, (_, i) => i + 1);
const trace = [[1,"R","X"],[1,"W","X"],[2,"R","X"],[3,"R","X"],[2,"W","X"],
[3,"W","X"],[1,"R","X"],[3,"R","Y"],[3,"W","Y"]]; // (core, R/W, address)
function simulate(proto) {
const st = {}, get = (c, a) => (st[a] ??= Array(CORES + 1).fill("I"))[c];
let reqs = 0, flushes = 0;
console.log(`${proto}
step op P1 P2 P3 bus data from`);
trace.forEach(([c, op, a], n) => {
const s = get(c, a), row = st[a], others = ids.filter((o) => o !== c);
const owner = others.find((o) => row[o] === "M"), anyCopy = others.some((o) => row[o] !== "I");
let bus = "-", src = "own cache (hit)";
if (op === "R" && s === "I") {
bus = "BusRd"; src = owner ? `P${owner} cache (Flush)` : "memory";
if (owner) flushes++;
others.forEach((o) => { if (row[o] !== "I") row[o] = "S"; }); // M/E holders drop to S
row[c] = proto === "MESI" && !anyCopy ? "E" : "S";
} else if (op === "W" && s !== "M") {
if (s === "E") { src = "own cache (E->M, silent)"; }
else if (s === "S") { bus = "BusUpgr"; src = "own cache (others invalidated)"; }
else { bus = "BusRdX"; src = owner ? `P${owner} cache (Flush)` : "memory"; if (owner) flushes++; }
others.forEach((o) => { row[o] = "I"; });
row[c] = "M";
}
if (bus !== "-") reqs++;
const states = ids.map((o) => row[o].padEnd(2)).join(" ");
console.log(`${String(n + 1).padStart(4)} P${c} ${op} ${a} ${states} ${bus.padEnd(7)} ${src}`);
});
console.log(`${proto}: ${reqs} bus requests, ${flushes} flushes
`);
return reqs;
}
const msi = simulate("MSI"), mesi = simulate("MESI");
console.log(`MESI saves ${msi - mesi} of ${msi} bus requests on this trace`);আসল আউটপুট:
MSI
step op P1 P2 P3 bus data from
1 P1 R X S I I BusRd memory
2 P1 W X M I I BusUpgr own cache (others invalidated)
3 P2 R X S S I BusRd P1 cache (Flush)
4 P3 R X S S S BusRd memory
5 P2 W X I M I BusUpgr own cache (others invalidated)
6 P3 W X I I M BusRdX P2 cache (Flush)
7 P1 R X S I S BusRd P3 cache (Flush)
8 P3 R Y I I S BusRd memory
9 P3 W Y I I M BusUpgr own cache (others invalidated)
MSI: 9 bus requests, 3 flushes
MESI
step op P1 P2 P3 bus data from
1 P1 R X E I I BusRd memory
2 P1 W X M I I - own cache (E->M, silent)
3 P2 R X S S I BusRd P1 cache (Flush)
4 P3 R X S S S BusRd memory
5 P2 W X I M I BusUpgr own cache (others invalidated)
6 P3 W X I I M BusRdX P2 cache (Flush)
7 P1 R X S I S BusRd P3 cache (Flush)
8 P3 R Y I I E BusRd memory
9 P3 W Y I I M - own cache (E->M, silent)
MESI: 7 bus requests, 3 flushes
MESI saves 2 of 9 bus requests on this trace
প্রতিটি সারি stepper-এর সঙ্গে মেলে। পার্থক্য শুধু দুটি সারিতে: ধাপ ২ আর ৯-এ MSI-তে BusUpgr, MESI-তে - (E থেকে নিঃশব্দে M) — ফলে ৯ বনাম ৭. লক্ষ করুন, ধাপ ১ আর ৮-এ MSI-র অবস্থা S, MESI-র E; ঠিক এই একটি অক্ষরই পরের লেখার খরচ ঠিক করে দেয়। আর ধাপ ৩–৭ দুই protocol-এ হুবহু এক, কারণ সেখানে ডেটা সত্যিই ভাগ করা — E-এর জায়গা থাকে শুধু যখন line একজনের কাছেই আছে। (মডেলে bus atomic, আর line জায়গার অভাবে সরে না।)
MOESI আর MESIF — দুই বাস্তব রূপ
বাস্তব chip আরও একটি-দুটি অবস্থা যোগ করে। MOESI-তে O (Owned): dirty line অন্যের সাথে ভাগ করা যায় মেমরিতে না লিখেই — owner পরিবর্তিত ডেটা রাখে আর অনুরোধ এলে সেই-ই পাঠায়, অন্যরা S. MSI-তে ধাপ ৩-এর মতো জায়গায় মেমরিতে লেখা লাগত; O তা এড়ায়। এটি AMD-র অনেক processor-এর সঙ্গে পরিচিত। MESIF-এ F (Forward): কয়েকটি পরিষ্কার S কপির মধ্যে ঠিক একজন “উত্তরদাতা”, যাতে একটি অনুরোধে পাঁচটি cache একসাথে ডেটা না পাঠায়; Intel-এর point-to-point interconnect-এর (QuickPath) যুগ থেকে এটি তাদের সঙ্গে পরিচিত। ভেতরের আসল protocol এর চেয়ে জটিল আর সবটা প্রকাশ্য নয়।
৬ · Directory, coherence miss আর false sharing-এর ভেতরের কারণ
Directory-ভিত্তিক coherence — সংক্ষেপে
Snooping-এর দুর্বলতা: প্রতিটি miss সবাইকে শোনাতে হয়। ৪টি core-এ ঠিক আছে, কিন্তু ৬৪টি core-এ প্রতিটি miss ৬৩টি cache-এর tag খোঁজায় আর একটি ভাগ করা পথে ভিড় বাড়ায়। Directory-ভিত্তিক পদ্ধতিতে প্রতিটি line-এর জন্য একটি হিসাব থাকে তার home node-এ (প্রায়ই L3-এর যে slice বা যে socket-এর মেমরিতে ঠিকানাটি পড়ে): line-এর অবস্থা আর presence bits — প্রতি core-এ একটি bit, “এর কাছে কপি আছে কি না”। Miss হলে অনুরোধ যায় home-এ; home জানে ঠিক কাদের কাছে কপি, তাই শুধু তাদেরই invalidation বা forward পাঠায় — broadcast নেই।
খরচের হিসাব। ৬৪টি core-এর জন্য পূর্ণ presence vector = প্রতি line-এ ৬৪ bit. ৬৪-byte line = ৫১২ bit, তাই শুধু presence bit-এর বাড়তি জায়গা ৬৪ ÷ ৫১২ = ১২.৫% — core দ্বিগুণ হলে এটিও দ্বিগুণ। এজন্য বড় সিস্টেম নানা সংক্ষিপ্ত রূপ ব্যবহার করে (মোটা দাগের bit, সীমিত pointer, বা শুধু cache-এ থাকা line-এর হিসাব রাখা snoop filter). বিস্তারিত — NUMA, interconnect আর directory-র বড় পরিসর — পর্ব ৫৪-এ।
Coherence miss — চতুর্থ C
পর্ব ৪৫–৪৬-এ miss-এর তিনটি C দেখেছেন: compulsory, capacity, conflict. Multicore যোগ করে চতুর্থটি — coherence miss: line-টি cache-এ ছিল, জায়গাও ছিল, কিন্তু অন্য core লেখায় বাতিল হয়ে গেছে। Cache বড় করে এটি কমে না। এর দুই উৎস:
- True sharing miss: P2 সত্যিই সেই word পড়ছে, যা P1 লিখেছে। এটি অনিবার্য — তথ্য তো এক core থেকে আরেক core-এ যেতেই হবে।
- False sharing miss: P1 লিখেছে word A, P2 পড়ছে একই line-এর word B. B বদলায়ইনি, তবু P2-এর miss — কারণ coherence-এর হিসাব পুরো line ধরে, word ধরে নয়।
পর্ব ৪৮-এ আপনি প্রোগ্রামারের দিক থেকে false sharing মেপে দেখেছেন: দুই thread দুটি আলাদা counter বাড়ায়, counter দুটি একই ৬৪-byte line-এ, আর padding দিলেই গতি ফেরে। এখন তার যন্ত্রটা স্পষ্ট। প্রতিটি a++ একটি PrWr. Core 0 লিখতে চাইলে line-টি M-এ চাই — core 1-এর কাছে M থাকলে BusRdX, core 1-এর Flush আর I. তারপর core 1-এর b++ — উল্টো দিকে একই কাজ। MESI-র ভাষায় line-টি M → I → M → I করতে থাকে দুই cache-এ পালা করে, প্রতিটি লেখা একটি cache-থেকে-cache হস্তান্তর। প্রতিকার একটাই: দুটি ভ্যারিয়েবল আলাদা line-এ — তাহলে প্রতিটি line একজনের কাছে স্থায়ীভাবে M থাকে, আর আর কোনো bus transaction হয় না।
৭ · Memory consistency — store buffer, SC, TSO আর দুর্বল model
Coherence নিখুঁত হলেও একটি প্রশ্ন থেকে যায়। পরিচিত একটি mutual exclusion-এর কৌশল দেখুন — Dekker আর Peterson-এর algorithm-এর মূল ভাবনা (Operating Systems কোর্সের race condition আর Peterson-এর পর্ব):
/* শুরুতে x = 0, y = 0 (x = "thread 0 ঢুকতে চায়", y = "thread 1 ঢুকতে চায়") */
Thread 0: Thread 1:
x = 1; y = 1;
r1 = y; r2 = x;
if (r1 == 0) critical(); if (r2 == 0) critical();
স্বাভাবিক বুদ্ধি বলে, দুজনে একসাথে critical section-এ ঢুকতে পারে না: যে পরে পড়ছে, সে অন্যের লেখা ১ দেখবেই। অর্থাৎ (r1, r2) = (0, 0) অসম্ভব। এই “স্বাভাবিক বুদ্ধি”-র একটি নাম আছে।
১৯৭৯ সালে Leslie Lamport তাঁর ছোট একটি প্রবন্ধে (“How to Make a Multiprocessor Computer That Correctly Executes Multiprocess Programs”) sequential consistency (SC)-র সংজ্ঞা দেন: যেকোনো চালনার ফল এমন হবে, যেন সব processor-এর operation কোনো একটি একক ক্রমে পরপর চলেছে, আর সেই ক্রমে প্রতিটি processor-এর operation তার নিজের প্রোগ্রামের ক্রমে আছে। অর্থাৎ — একটি তাসের বান্ডিলের মতো সব thread-এর instruction এলোমেলোভাবে মেশানো যায়, কিন্তু প্রতিটি thread-এর নিজের তাসগুলোর ক্রম বদলায় না।
SC-তে উপরের দুই thread-এর ৪টি operation মোট ৬ ভাবে মেশানো যায়, আর প্রতিটিতে প্রথম operation-টি অবশ্যই কোনো একটি store — তাই অন্য thread-এর load, যা তার পরে চলে, সেই ১ দেখবে। (0, 0) অসম্ভব। কিন্তু আসল x86 যন্ত্রে এই প্রোগ্রাম চালালে (0, 0) দেখা যায়! কারণ store buffer.
Store buffer — কেন hardware SC ভাঙে
একটি store-কে cache-এ লিখতে হলে line-টি M-এ থাকতে হয়। Line না থাকলে BusRdX পাঠিয়ে অপেক্ষা — দশ থেকে শতাধিক cycle. সেই সময় পুরো core থামিয়ে রাখা অপচয়, কারণ store-এর ফল কেউ সঙ্গে সঙ্গে চায় না। তাই প্রতিটি core-এ একটি ছোট FIFO সারি — store retire হয়ে সেখানে বসে, আর core পরের instruction-এ চলে যায়। Store buffer পরে, সুযোগমতো, ক্রম মেনে cache-এ লেখে (পর্ব ৪০-এর load-store queue আর পর্ব ৪৭-এর write buffer-এর আত্মীয়)। আর নিজের core-এর পরের load একই ঠিকানা চাইলে store buffer থেকেই মান নেয় — store-to-load forwarding.
এখন উপরের প্রোগ্রাম: core 0-এর x = 1 store buffer-এ বসে আছে, তখনো cache-এ যায়নি। Core 0-এর r1 = y ভিন্ন ঠিকানা — buffer-এ মিল নেই, তাই cache বা মেমরি থেকে পড়ে: ০. একই সময়ে core 1-এ উল্টো ছবি: y = 1 buffer-এ, r2 = x পড়ে ০. তারপর দুই buffer খালি হয়। ফল (0, 0) — যেন প্রতিটি core-এ store আর তার পরের load-এর ক্রম উল্টে গেছে। একে বলে store-buffering বা SB litmus test.
রাফি আর তানিয়া ঠিক করল: ভাগ করা নোটিশ বোর্ডে নিজের নাম লিখে দেখবে অন্যজন নাম লিখেছে কি না; না লিখলে দোকানে যাবে। কিন্তু দুজনেই নাম আগে চিরকুটে লিখে পকেটে রাখল (store buffer), বোর্ডে টাঙাবে একটু পরে। বোর্ডে কারও নাম নেই দেখে দুজনেই রওনা দিল।
নিচের JS মডেল দুই ধরনের যন্ত্রে সব সম্ভাব্য interleaving গুনে দেখে। SC-তে store সঙ্গে সঙ্গে মেমরিতে যায়। TSO-তে প্রতিটি store আগে নিজের core-এর FIFO buffer-এ ঢোকে (S), পরে যেকোনো সময় মেমরিতে নামে (D, drain); load আগে নিজের buffer, তারপর মেমরি দেখে (L).
// Store-buffering (SB) litmus test: T0: x=1; r1=y T1: y=1; r2=x (x = y = 0 at start)
// SC : every store reaches memory at once. TSO: a store waits in its core's FIFO store
// buffer and drains to memory later; a load first checks its own buffer, then memory.
function explore(tso) {
const outcomes = new Map(); let runs = 0;
const prog = [[["st", "x"], ["ld", "y", "r1"]], [["st", "y"], ["ld", "x", "r2"]]];
function step(mem, pc, buf, regs, log) {
let moved = false;
for (let t = 0; t < 2; t++) {
if (pc[t] < 2) { // next instruction of thread t
const [op, v, r] = prog[t][pc[t]], p2 = [...pc]; p2[t]++; moved = true;
if (op === "st") {
if (tso) { const b = buf.map((q) => [...q]); b[t].push(v); step(mem, p2, b, regs, log + ` S${t}`); }
else step({ ...mem, [v]: 1 }, p2, buf, regs, log + ` S${t}`);
} else {
const val = buf[t].includes(v) ? 1 : mem[v]; // store-to-load forwarding
step(mem, p2, buf, { ...regs, [r]: val }, log + ` L${t}`);
}
}
if (tso && buf[t].length) { // drain oldest buffered store of t
const b = buf.map((q) => [...q]), v = b[t].shift(); moved = true;
step({ ...mem, [v]: 1 }, pc, b, regs, log + ` D${t}`);
}
}
if (!moved) {
runs++; const k = `(r1, r2) = (${regs.r1}, ${regs.r2})`;
if (!outcomes.has(k)) outcomes.set(k, { n: 0, ex: log.trim() });
outcomes.get(k).n++;
}
}
step({ x: 0, y: 0 }, [0, 0], [[], []], {}, "");
console.log(`${tso ? "TSO (store buffers)" : "SC"}: ${runs} interleavings`);
for (const [k, o] of [...outcomes].sort()) console.log(` ${k} x${String(o.n).padStart(2)} e.g. ${o.ex}`);
}
explore(false); explore(true);আসল আউটপুট:
SC: 6 interleavings
(r1, r2) = (0, 1) x 1 e.g. S0 L0 S1 L1
(r1, r2) = (1, 0) x 1 e.g. S1 L1 S0 L0
(r1, r2) = (1, 1) x 4 e.g. S0 S1 L0 L1
TSO (store buffers): 80 interleavings
(r1, r2) = (0, 0) x18 e.g. S0 L0 S1 L1 D0 D1
(r1, r2) = (0, 1) x22 e.g. S0 L0 D0 S1 L1 D1
(r1, r2) = (1, 0) x22 e.g. S0 S1 L1 D0 D1 L0
(r1, r2) = (1, 1) x18 e.g. S0 D0 S1 L1 D1 L0
SC-তে ৬টি interleaving (৪টি operation, প্রতিটি thread-এর ক্রম স্থির: ৪! ÷ (২! · ২!) = ৬), ফল মাত্র তিন রকম — (0, 0) একবারও নেই। TSO-তে প্রতিটি thread-এর তিনটি event (S, L, D), যেখানে S সবার আগে, কিন্তু L আর D যেকোনো ক্রমে — তাই প্রতি thread-এ ২ রকম, আর দুই thread-এর ৩টি করে event মেশানোর উপায় ৬! ÷ (৩! · ৩!) = ২০; মোট ২ × ২ × ২০ = ৮০. এর ১৮টিতে (0, 0). উদাহরণ S0 L0 S1 L1 D0 D1 ঠিক রাফি-তানিয়ার গল্প।
TSO, আর তার চেয়েও ঢিলা model
x86-এর memory model-কে সাধারণত TSO (Total Store Order) বলা হয় — SPARC-এর একটি model-এর নাম থেকে; x86-এর এই আচরণ Intel আর AMD-র manual-এর নিয়ম থেকে গবেষকেরা ২০০৯–২০১০ সালের দিকে “x86-TSO” নামে আনুষ্ঠানিক রূপ দেন। সারকথা: store buffer FIFO, তাই store-store ক্রম অটুট; load-load আর load-store ক্রমও অটুট; ভাঙে শুধু store → পরের ভিন্ন ঠিকানার load. তাই x86-এ message passing — data = 42; flag = 1; আর অন্য দিকে if (flag) use(data); — fence ছাড়াই কাজ করে (অনুশীলন ৩-এ এর হিসাব)।
ARM (ARMv8) আর RISC-V-এর ডিফল্ট model, RVWMO, অনেক ঢিলা: ভিন্ন ঠিকানার প্রায় যেকোনো দুটি access-এর ক্রম উল্টে দেখা দিতে পারে — store-store, load-load সবই। এতে hardware-এর নকশা সহজ আর দ্রুত হয়; দাম — যেখানে ক্রম দরকার, সেখানে software-কে স্পষ্টভাবে চাইতে হয়। (RISC-V-এ ঐচ্ছিক “Ztso” extension আছে, যা TSO-র নিশ্চয়তা দেয় — যেমন x86 কোড অনুবাদ করে চালানোর সুবিধার জন্য।)
| ভিন্ন ঠিকানার দুটি access, প্রোগ্রামের ক্রমে | SC | TSO (x86) | ARMv8, RISC-V RVWMO |
|---|---|---|---|
| store → load (SB litmus-এর (0, 0)) | উল্টায় না | উল্টাতে পারে | উল্টাতে পারে |
| store → store (message passing-এর ভয়) | উল্টায় না | উল্টায় না | উল্টাতে পারে |
| load → load | উল্টায় না | উল্টায় না | উল্টাতে পারে |
| load → store | উল্টায় না | উল্টায় না | উল্টাতে পারে |
Fence, acquire আর release
ক্রম ফিরিয়ে আনার যন্ত্র fence (বা barrier): fence-এর আগের নির্দিষ্ট ধরনের access শেষ না হলে পরেরগুলো দেখা দেয় না। RISC-V-এ fence rw, rw (আগের সব read/write, পরের সব read/write-এর আগে); x86-এ mfence, বা যেকোনো lock-যুক্ত instruction; ARM-এ dmb ish. SB-তে দুই thread-এ store আর load-এর মাঝে একটি পূর্ণ fence দিলে store buffer খালি না হওয়া পর্যন্ত load অপেক্ষা করে — (0, 0) আর হয় না। দাম: প্রতি fence-এ কয়েক দশ cycle পর্যন্ত, chip-ভেদে।
পূর্ণ fence প্রায়ই দরকারের চেয়ে বেশি। Lock-এর জন্য লাগে একমুখী নিয়ম — acquire আর release: acquire (lock নেওয়া, বা flag পড়া) — এর পরের কোনো access এর আগে চলে যেতে পারবে না; release (lock ছাড়া, বা flag লেখা) — এর আগের কোনো access এর পরে যেতে পারবে না। Critical section-এর কাজ এভাবে দুই দেয়ালের মাঝে আটকে থাকে, অথচ বাইরের access ভেতরে ঢুকতে পারে — hardware-এর স্বাধীনতা কম কাটে। ARMv8-এ ldar/stlr, RISC-V-এ atomic instruction-এর .aq/.rl bit, আর C11-এ memory_order_acquire/memory_order_release. C-র ডিফল্ট memory_order_seq_cst race-মুক্ত প্রোগ্রামকে SC-র মতো আচরণের নিশ্চয়তা দেয় — compiler দরকারি fence বসিয়ে দেয় (x86-এ seq_cst store সাধারণত xchg, বা mov-এর পরে mfence).
এবার আসল hardware-এ SB litmus. দুটি thread প্রতিটি round-এ একটি খুদে barrier-এ মিলিত হয়ে একসাথে নিজের store আর load চালায়; প্রতিটি round-এর জন্য আলাদা নতুন x[i], y[i] (শুরুতে ০), আর শেষে চার রকম ফল গোনা হয়। প্রথমে memory_order_relaxed (compiler সাধারণ mov বসায়, hardware TSO-র স্বাধীনতা পুরো পায়), তারপর seq_cst.
// SB litmus on real hardware: T0: x=1; r1=y T1: y=1; r2=x -- count (0,0) outcomes
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#define ROUNDS 200000
#define TEST(MO) if (id == 0) { atomic_store_explicit(&x[i], 1, MO); r1[i] = atomic_load_explicit(&y[i], MO); } \
else { atomic_store_explicit(&y[i], 1, MO); r2[i] = atomic_load_explicit(&x[i], MO); }
static atomic_int x[ROUNDS], y[ROUNDS], arrived; // fresh x[i] = y[i] = 0 for every round
static int r1[ROUNDS], r2[ROUNDS], relaxed;
static void *thr(void *arg) {
long id = (long)arg;
for (int i = 0; i < ROUNDS; i++) {
atomic_fetch_add(&arrived, 1); // tiny barrier: both start round i together
while (atomic_load(&arrived) < 2 * (i + 1)) { }
if (relaxed) { TEST(memory_order_relaxed) } else { TEST(memory_order_seq_cst) }
}
return NULL;
}
static void trial(int use_relaxed) {
pthread_t a, b; int h[4] = {0}; relaxed = use_relaxed; atomic_store(&arrived, 0);
for (int i = 0; i < ROUNDS; i++) { atomic_store(&x[i], 0); atomic_store(&y[i], 0); }
pthread_create(&a, NULL, thr, (void *)0L); pthread_create(&b, NULL, thr, (void *)1L);
pthread_join(a, NULL); pthread_join(b, NULL);
for (int i = 0; i < ROUNDS; i++) h[r1[i] * 2 + r2[i]]++; // index = r1 r2 in binary
printf("%-8s (0,0) %6d (0,1) %6d (1,0) %6d (1,1) %6d\n",
use_relaxed ? "relaxed" : "seq_cst", h[0], h[1], h[2], h[3]);
}
int main(void) {
printf("%d rounds each\n", ROUNDS);
trial(1); trial(0);
return 0;
}যাচাইয়ের সময় দুটি চালনার আউটপুট (সংখ্যা প্রতিবার অনেক বদলায়):
200000 rounds each
relaxed (0,0) 106 (0,1) 99978 (1,0) 99850 (1,1) 66
seq_cst (0,0) 0 (0,1) 99932 (1,0) 99986 (1,1) 82
200000 rounds each
relaxed (0,0) 22680 (0,1) 85474 (1,0) 87693 (1,1) 4153
seq_cst (0,0) 0 (0,1) 93927 (1,0) 96296 (1,1) 9777
relaxed-এ (0, 0) সত্যিই দেখা গেল — SC-তে যা অসম্ভব, তা আসল x86 CPU-তে ঘটে। কিন্তু কত বার, তা খুব অস্থির: একটি চালনায় দুই লাখে মাত্র ১০৬ বার, আরেকটিতে ২২ হাজারের বেশি, শেষ যাচাইয়ে ১১,৮৯৩ বার। (0, 0) হতে হলে দুই thread-কে কয়েক ন্যানোসেকেন্ডের মধ্যে store আর load করতে হয় — barrier থেকে দুজন কতটা একসাথে বের হয়, তার ওপর সব নির্ভর করে। এই runner একটি shared virtual machine, যেখানে মাত্র ২টি logical CPU দেখা যায়, আর পর্ব ৪৩ ও ৪৮-এর মতো kernel তাদের একই core-এর দুটি hardware thread বলে; প্রতিবেশীর কাজ আর virtual machine-এর scheduling সময়ের মিল বদলে দেয়। seq_cst-এ (0, 0) একবারও নয় — store-এর পরে fence-এর কাজ হওয়ায় load-কে store buffer খালি হওয়ার অপেক্ষা করতে হয়। (1, 1)-এর সংখ্যা বলে দুই thread কত বার সত্যিই একসাথে চলেছে।
একটি শিক্ষণীয় ফাঁদ, যা এই প্রোগ্রাম লেখার সময় ধরা পড়েছে: প্রথম খসড়ায় memory order একটি ভ্যারিয়েবলে রেখে atomic_store_explicit(&x, 1, mo) লেখা হয়েছিল, আর relaxed-এও (0, 0) একবারও আসেনি। কারণ memory order compile-এর সময় জানা না থাকলে GCC নিরাপদ পথে seq_cst ধরে নেয়। তাই এখানে TEST(MO) macro দিয়ে দুটি স্থির order আলাদা করে লেখা।
৮ · Hardware-এর atomic instruction আর spinlock
Lock বানাতে লাগে পড়া-বদলানো-লেখা এমনভাবে, যাতে মাঝখানে কেউ ঢুকতে না পারে — atomic read-modify-write. সাধারণ counter++ তা নয়: compiler এটিকে load, add, store — তিনটি instruction বানায়, আর দুই core-এর মধ্যে ছয়টি instruction মিশে গেলে একটি যোগ হারিয়ে যায়। নিচে দুটি thread একটি counter-কে ১ কোটি বার করে বাড়ায় — সাধারণ int আর C11-এর atomic_fetch_add দিয়ে।
// Shared counter, 2 threads: plain int (data race = undefined behaviour) vs C11 atomic
#include <pthread.h>
#include <stdatomic.h>
#include <stdio.h>
#include <time.h>
#define ITERS 10000000
static int plain; // NOT atomic: ++ is load, add, store
static atomic_int atom; // atomic_fetch_add: one indivisible RMW
static void *bump_plain(void *arg) { (void)arg; for (int i = 0; i < ITERS; i++) plain++; return NULL; }
static void *bump_atom(void *arg) { (void)arg; for (int i = 0; i < ITERS; i++) atomic_fetch_add(&atom, 1); return NULL; }
static double run(void *(*fn)(void *)) {
struct timespec t0, t1; pthread_t a, b;
clock_gettime(CLOCK_MONOTONIC, &t0);
pthread_create(&a, NULL, fn, NULL); pthread_create(&b, NULL, fn, NULL);
pthread_join(a, NULL); pthread_join(b, NULL);
clock_gettime(CLOCK_MONOTONIC, &t1);
return (t1.tv_sec - t0.tv_sec) * 1e3 + (t1.tv_nsec - t0.tv_nsec) / 1e6;
}
int main(void) {
double tp = run(bump_plain), ta = run(bump_atom);
printf("expected : %d\n", 2 * ITERS);
printf("plain int : %d (lost %d) %5.0f ms\n", plain, 2 * ITERS - plain, tp);
printf("atomic_int : %d (lost %d) %5.0f ms\n", atomic_load(&atom), 2 * ITERS - atomic_load(&atom), ta);
printf("atomic / plain time = %.1f\n", ta / tp);
return 0;
}যাচাইয়ের সময় আউটপুট (সংখ্যা আর সময় প্রতিবার বদলায়):
expected : 20000000
plain int : 12654418 (lost 7345582) 220 ms
atomic_int : 20000000 (lost 0) 324 ms
atomic / plain time = 1.5
Atomic counter প্রতিবারই ঠিক ২ কোটি। সাধারণ int-এ লাখ লাখ যোগ হারিয়েছে — আর হারানোর সংখ্যা চালনাভেদে বদলায় (অন্য একটি চালনায় এসেছিল ১ কোটি ৬৩ লাখ ১৪ হাজার ৪৯৭, অর্থাৎ প্রায় ৩৭ লাখ যোগ হারানো)। কিন্তু মনে রাখুন: C-র নিয়মে এটি একটি data race, অর্থাৎ undefined behavior — “কিছু যোগ হারাবে” পর্যন্তও নিশ্চয়তা নেই। -O2-তে compiler পুরো loop-কে হয়তো একটি যোগে বদলে দিত, আর ফল দেখাত অন্য রকম, এমনকি “সঠিক” — UB-র এটাই বিপদ। Atomic সংস্করণে x86-এ compiler একটি lock-যুক্ত instruction (lock add বা lock xadd) বসায়; lock চলাকালীন line-টি নিজের cache-এ M অবস্থায় আটকে রাখা হয়, তাই অন্য core মাঝখানে লিখতে পারে না — atomicity আসে coherence থেকেই। দাম সময়ে: এই চালনায় atomic সংস্করণে প্রায় ১.৫ গুণ সময় লেগেছে (অন্য চালনায় ১.৩ থেকে ২.২ পর্যন্ত) — এই shared virtual machine-এ সংখ্যাটি অস্থির, কিন্তু atomic যে বিনা খরচে নয়, তা স্পষ্ট।
চার ধরনের atomic instruction
| নাম | কী করে | কোথায় |
|---|---|---|
| Test-and-set / swap | পুরোনো মান ফেরত দেওয়া আর নতুন মান (যেমন ১) লেখা — একবারে | x86 xchg; RISC-V amoswap.w; ARM swp (ARMv8.1-এর পরে) |
| Fetch-and-add | পুরোনো মান ফেরত দেওয়া আর যোগফল লেখা | x86 lock xadd; RISC-V amoadd.w; ARM ldadd |
| Compare-and-swap | মান প্রত্যাশিত হলে নতুন মান লেখা, না হলে বর্তমান মান জানানো | x86 lock cmpxchg; ARM cas (ARMv8.1); RISC-V-এ নতুন Zacas extension, নইলে LR/SC দিয়ে বানানো |
| LR/SC | lr.w পড়ে আর ঠিকানা “সংরক্ষণ” করে; sc.w লেখে শুধু যদি মাঝে কেউ সেখানে না লিখে থাকে, নইলে ব্যর্থ | RISC-V lr.w/sc.w; ARM ldxr/stxr; আগে MIPS, Alpha, PowerPC |
RISC-V-এর A extension-এর AMO (যেমন amoadd.w rd, rs2, (rs1): rd ← মেমরি[rs1]; মেমরি[rs1] ← পুরোনো মান + rs2) এক instruction-এই কাজ সারে; বড় chip-এ এগুলো এমনকি L3 বা মেমরির কাছাকাছি চালানো যায়, line core-এ না এনেই। LR/SC আরও নমনীয়: lr আর sc-এর মাঝে যেকোনো ছোট হিসাব (দুই মানের সর্বোচ্চ, একটি bit বসানো) করা যায়। কীভাবে কাজ করে? lr.w line-টির ওপর একটি reservation বসায়; অন্য কোনো core সেখানে লিখতে গেলে তার BusRdX/BusUpgr শুনে reservation মুছে যায় — coherence protocol-ই খবর দেয়। sc.w rd, rs2, (rs1) সফল হলে rd = ০, ব্যর্থ হলে rd ≠ ০ — তখন software আবার চেষ্টা করে।
একটি RISC-V spinlock (উদাহরণ; RISC-V unprivileged spec-এর lock-এর নমুনার ধাঁচে লেখা):
# a0 = lock-এর ঠিকানা (0 = খালি, 1 = কারও হাতে)
acquire:
li t1, 1
retry: lr.w.aq t0, (a0) # পড়া + reservation; aq = acquire
bnez t0, retry # কারও হাতে: আবার পড়া (নিজের cache-এ)
sc.w t2, t1, (a0) # reservation টিকে থাকলে 1 লেখা
bnez t2, retry # t2 != 0: অন্য কেউ আগে লিখেছে, আবার
ret
release:
amoswap.w.rl x0, x0, (a0) # 0 লেখা; rl = release (আগের কাজ আগে)
ret
# একই lock শুধু AMO দিয়ে (test-and-set):
li t0, 1
again: amoswap.w.aq t0, t0, (a0) # t0 = পুরোনো মান, lock = 1
bnez t0, again
দুই core একসাথে lock নিতে চাইলে কী হয়, বাস্তব bus transaction সহ নিচে ধাপে ধাপে দেখুন (line-এর অবস্থা MESI-তে):
| ঘটনা | |
| lock-এর মান | |
| P1: অবস্থা · reservation | |
| P2: অবস্থা · reservation | |
| bus |
- P1 পড়ে।
lr.wএকটি read miss — BusRd; আর কারও কাছে নেই, তাই E. মান ০ (খালি), আর P1-এর reservation বসল lock-এর line-এ। - P2-ও পড়ে। প্রায় একই সময়ে P2-এর
lr.w— BusRd; P1-এর E কপি S হয়, P2-ও S. P2-ও দেখে ০, তারও reservation. দুজনেই ভাবছে lock খালি — সাধারণ load-তারপর-store হলে এখানেই দুজনে ঢুকে পড়ত। - P1 আগে sc.w করে। লেখার জন্য S থেকে BusUpgr; P2-এর কপি I হয় — আর সেই invalidation শুনে P2-এর reservation মুছে যায়. P1-এর reservation অক্ষত, তাই sc সফল (t2 = ০), lock = ১, P1 critical section-এ।
- P2-এর sc.w ব্যর্থ। Reservation নেই, তাই লেখা হয় না, t2 ≠ ০. (অনেক নকশায় ব্যর্থ sc bus-এ কিছুই পাঠায় না।) P2
retry-তে ফেরে। Atomicity নিশ্চিত হলো coherence-এর invalidation দিয়েই। - P2 ঘোরে — নিজের cache-এ।
lr.wmiss — BusRd, P1 M থেকে Flush করে S. P2 দেখে ১,bnez→ আবারlr.w. কিন্তু এবার line P2-এর cache-এ S অবস্থায় আছে: প্রতিটি পরেরlr.whit, bus-এ কোনো ট্রাফিক নেই। এই কোড আসলে test-and-test-and-set-এর মতো আচরণ করে। - P1 lock ছাড়ে।
amoswap.w.rl০ লেখে; .rl নিশ্চিত করে critical section-এর সব লেখা আগে দেখা দেবে। লিখতে S থেকে BusUpgr; P2-এর কপি I, reservation মুছে যায়। - P2 lock পায়। পরের
lr.wmiss — BusRd, P1 Flush; P2 দেখে ০. তারপরsc.w— S থেকে BusUpgr, কেউ মাঝে লেখেনি, তাই সফল। P2 এখন M-এ, lock = ১. পুরো প্রতিযোগিতায় bus-এ গেল মোট ৭টি transaction, আর ঘোরার সময় একটিও বাড়তি নয়।
Cache-line bouncing আর test-and-test-and-set
সবচেয়ে সরল spinlock — while (test_and_set(&lock)) ; — অপেক্ষার সময় প্রতিটি চেষ্টায় একটি লেখা করে (ব্যর্থ হলেও, কারণ TAS সবসময় ১ লেখে)। প্রতিটি লেখার জন্য line-টি M-এ চাই, তাই প্রতিটি চেষ্টা একটি BusRdX আর আগের মালিকের Flush. তিনটি core অপেক্ষায় থাকলে line-টি তাদের মধ্যে অবিরাম লাফায় — cache-line bouncing. ক্ষতি দুটি: bus বা interconnect ভরে যায়, ফলে অন্য সব core-এর miss-ও ধীর হয়; আর lock-এর মালিক যখন ছাড়তে চায়, তাকেও line ফেরত পেতে লাইনে দাঁড়াতে হয়।
প্রতিকার test-and-test-and-set (TTAS): আগে সাধারণ load দিয়ে দেখা, lock খালি মনে হলে তবেই TAS.
/* C11 দিয়ে TTAS spinlock (উদাহরণ) */
void lock(atomic_int *l) {
for (;;) {
while (atomic_load_explicit(l, memory_order_relaxed) != 0) { } /* নিজের S কপিতে ঘোরা */
if (atomic_exchange_explicit(l, 1, memory_order_acquire) == 0) return; /* তবেই TAS */
}
}
void unlock(atomic_int *l) { atomic_store_explicit(l, 0, memory_order_release); }
অপেক্ষার সময় প্রত্যেক অপেক্ষাকারী একবার BusRd করে S কপি নেয়, তারপর নিজের cache-এ hit-এর পর hit — bus শান্ত। মালিক ছাড়লে একটি invalidation, তারপর সবার একটি করে miss আর TAS-এর ছোট একটি ঝাঁক; একজন জেতে, বাকিরা আবার নিজের কপিতে ফেরে। হিসাবটি অনুশীলন ৪-এ। আরও বড় প্রতিযোগিতায় ব্যবহৃত হয় exponential backoff, ticket lock আর MCS lock-এর মতো queue-ভিত্তিক lock, যেখানে প্রত্যেকে নিজের আলাদা line-এ অপেক্ষা করে — software-এর দিক Operating Systems কোর্সের atomic instruction আর spinlock পর্বে।
৯ · কতদূর বাড়ানো যায় — bus-এর সীমা, ring আর mesh
Snooping bus-এর দুটি সীমা আছে। Bandwidth: সব core-এর সব miss একটি পথে। একটি খেলনা হিসাব: ধরুন প্রতিটি core গড়ে প্রতি ১০০ cycle-এ একটি bus transaction করে, আর bus প্রতি ৪ cycle-এ একটি সামলাতে পারে। তাহলে bus ১০০ ÷ ৪ = ২৫টি core-এর বেশি সামলাতে পারবে না — তার আগেই সারি লম্বা হয়ে প্রতিটি miss-এর অপেক্ষা বাড়তে থাকবে। (সংখ্যাগুলো কাল্পনিক; নীতিটি বাস্তব।) Snoop-এর চাপ: প্রতিটি অনুরোধ সব cache-এর tag খোঁজায়, তাই core দ্বিগুণ হলে মোট snoop-এর কাজ প্রায় চার গুণ (দ্বিগুণ অনুরোধ × দ্বিগুণ শ্রোতা)। আর chip বড় হলে লম্বা bus-এর তারের দেরিও বাড়ে (পর্ব ১৮).
তাই বড় chip-এ একক bus আর নেই। প্রচলিত ধারা (নির্দিষ্ট পণ্যের ভেতরের নকশা সবসময় প্রকাশ্য নয়, তাই মোটা দাগে): L3 কয়েকটি slice-এ ভাগ, প্রতিটি slice ঠিকানার একটি অংশের “home”; core আর slice জোড়া লাগে একটি on-chip interconnect-এ — কয়েক থেকে দশ-বারোটি core-এ একটি ring, আরও বেশি core-এ গ্রিডের মতো mesh (Intel-এর কিছু সার্ভার chip কয়েক প্রজন্ম আগে ring থেকে mesh-এ গেছে বলে প্রকাশিত)। Broadcast-এর বদলে L3-এর পাশে snoop filter বা directory রাখে কোন line কার কাছে, আর বার্তা যায় শুধু দরকারি core-এ। Chiplet দিয়ে বানানো processor-এ chiplet-এর মাঝেও একই হিসাব লাগে। পুরো ছবি — NUMA, topology আর directory-র স্কেল — পর্ব ৫৪-এ।
আরও গভীরে: আধুনিক chip-এ coherence আর consistency আলাদা স্তরে কাজ করে: cache-এর স্তরে MESI-র কোনো রূপ (অনেক transient অবস্থাসহ) SWMR রাখে, আর core-এর ভেতরে store buffer ও out-of-order load-এর নিয়ম ঠিক করে consistency. যেমন x86-এ load আগেভাগে চালানো হয়, আর ফল দেখা দেওয়ার আগে সেই line-এ invalidation এলে load আবার চালানো হয়, যাতে TSO-র load-load ক্রম রক্ষা পায়। ২০১০-এর দশক থেকে গবেষকেরা x86, ARM, POWER আর RISC-V-এর memory model আনুষ্ঠানিক গণিতে লিখে herd বা litmus7-এর মতো টুলে আসল chip-এ হাজারো litmus test চালিয়ে মিলিয়েছেন; RISC-V-এর RVWMO শুরু থেকেই formal সংজ্ঞাসহ লেখা।
১০ · AI-এর যুগে — coherence আর memory ordering-এ AI, আর যাচাইয়ের অভ্যাস
এই বিষয়ের প্রশ্নে AI-এর উত্তর শুনতে খুব আত্মবিশ্বাসী, কিন্তু কয়েকটি ভুল বারবার দেখা যায়:
| AI-এর সাধারণ ভুল | কীভাবে ধরবেন |
|---|---|
| MESI trace-এ E আর S গুলিয়ে ফেলা (অন্য কপি থাকলেও E বলা), বা E থেকে লেখায় bus transaction গোনা | প্রতিটি ধাপে “অন্য কারও কাছে বৈধ কপি আছে কি?” প্রশ্নটি নিজে করুন, আর একটি ছোট simulator-এ trace চালান |
| Coherence আর consistency এক জিনিস ভাবা — “cache coherent, তাই fence লাগবে না” | Coherence = একটি ঠিকানা; প্রশ্নে দুটি ঠিকানা থাকলে সেটি consistency-র প্রশ্ন |
| “x86-এ কোনো reordering হয় না” বা উল্টো, “x86-এ store-store উল্টাতে পারে” | SB litmus মনে রাখুন: x86-এ শুধু store → load উল্টায়; Intel/AMD manual-এর memory ordering অধ্যায় দেখুন |
| volatile দিয়ে thread-এর synchronization; relaxed atomic সবখানে “দ্রুত বলে” | C-তে volatile ক্রম বা atomicity দেয় না; _Atomic/stdatomic.h আর acquire/release ব্যবহার করুন, ThreadSanitizer চালান |
RISC-V-এ sc.w-এর ফল উল্টো পড়া (০ মানে ব্যর্থ ভাবা), বা MIPS-এর ll/sc-এর নিয়ম মিশিয়ে ফেলা | RISC-V spec: sc সফল হলে rd = ০; MIPS-এর sc সফল হলে ১ লেখে — ISA manual মিলিয়ে নিন |
| Core-থেকে-core হস্তান্তরের cycle-এর মতো নির্দিষ্ট সংখ্যা বানিয়ে বলা | উৎস চান, নিজের যন্ত্রে মাপুন |
AI-কে coherence বা memory ordering-এর প্রশ্ন করার সময় model, অনুমান আর যাচাইয়ের দাবি স্পষ্ট দিন:
AI-এর দেওয়া coherence-ব্যাখ্যা বা concurrent কোড হাতে পেলে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Cache coherence কী, আর কেন লাগে? — একই ঠিকানার কপি কয়েকটি private cache-এ থাকলে সবাই যেন একই মান আর একই লেখার ক্রম দেখে; না হলে write-back cache-এ stale value পড়া হয়।
- MESI-তে E অবস্থা কেন? — অন্য কারও কপি না থাকলে পড়া line E-তে আসে; পরে লিখলে bus ছাড়াই M — ব্যক্তিগত ডেটায় প্রতি line-এ একটি BusUpgr বাঁচে।
- Coherence আর consistency-র পার্থক্য? — Coherence একটি ঠিকানার লেখার ক্রম; consistency ভিন্ন ঠিকানার access-এর দৃশ্যমান ক্রম।
- x86-এ কোন reordering সম্ভব? — শুধু store-এর পরের ভিন্ন ঠিকানার load আগে চলে যেতে পারে (store buffer); SB litmus-এ (0, 0);
mfenceবা lock-যুক্ত instruction তা থামায়। - Test-and-test-and-set কেন TAS-এর চেয়ে ভালো? — অপেক্ষার সময় সাধারণ load দিয়ে নিজের S কপিতে ঘোরে, তাই প্রতিটি চেষ্টায় BusRdX আর cache-line bouncing হয় না।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Coherence-এর কাজ hardware-কে কেন দেওয়া হলো, software-কে নয়? আর GPU বা কিছু embedded chip কেন পুরো coherence রাখে না?
১৯৭০-এর দশকের শেষ থেকে ১৯৮০-এর দশকের শুরুতে কয়েকটি processor একটি bus-এ জুড়ে ছোট shared-memory যন্ত্র বানানো শুরু হয়। তখন দুটি পথ ছিল। এক, software দায়িত্ব নেবে: ভাগ করা ডেটা cache-এ রাখা হবে না, বা প্রোগ্রাম নিজে দরকারমতো cache খালি (flush) করবে। দুই, hardware নিজেই কপিগুলোর হিসাব রাখবে। ১৯৮৩ সালে James Goodman-এর “write-once” protocol, আর ১৯৮৪ সালের দিকে Papamarcos ও Patel-এর Illinois protocol (যা পরে MESI নামে পরিচিত) দেখাল, একটি bus-এ snooping করে hardware খুব কম খরচে এই কাজ করতে পারে। Directory-র ধারণাও তার আগেই, ১৯৭৮ সালে Censier আর Feautrier-এর প্রবন্ধে, এসে গিয়েছিল।
Hardware জিতল একটি সহজ কারণে: প্রোগ্রামাররা সাধারণ load আর store দিয়ে লিখতে চান, কোন ডেটা কখন কোন cache-এ আছে তা নিয়ে ভাবতে চান না। Software-নিয়ন্ত্রিত পদ্ধতিতে একটি ভুলে-যাওয়া flush মানে খুঁজে পাওয়া প্রায় অসম্ভব একটি bug. আর OS, library আর পুরোনো প্রোগ্রাম — সবাইকে নতুন নিয়মে লিখতে হতো।
কিন্তু যেখানে হাজার core বা খুব কম বিদ্যুতের লক্ষ্য, সেখানে হিসাব বদলায়। GPU-র হাজার হাজার thread বেশিরভাগ সময় আলাদা ডেটায় কাজ করে, তাই পূর্ণ coherence-এর খরচ লাভের চেয়ে বেশি; অনেক GPU-তে L1 core-গুলোর মধ্যে coherent নয়, আর synchronization-এর সময় নির্দিষ্ট নিয়মে হালনাগাদ হয় (GPU-র ভেতরের গঠন পর্ব ৫৫-এ). ২০০৬ সালের দিকে আসা Cell processor-এর SPE-গুলোতে cache-এর বদলে ছিল software-নিয়ন্ত্রিত local store, যা প্রোগ্রামারের জন্য কঠিন বলে সমালোচিত। Microcontroller-এ CPU আর DMA-র মধ্যে প্রায়ই coherence থাকে না, তাই driver-কে cache clean/invalidate করতে হয় (পর্ব ৫২).
মূল উপলব্ধি: coherence hardware-এ গেছে কারণ এটি প্রোগ্রামারের সবচেয়ে বড় মাথাব্যথা সরিয়ে দেয়; যেখানে স্কেল বা বিদ্যুতের খরচ বেশি, সেখানে আবার এর কিছু অংশ software-এর ঘাড়ে ফেরে।
প্র ০২Sequential consistency এত সহজবোধ্য — তাহলে কোনো বড় CPU নির্মাতা পুরো SC-তে hardware বানায় না কেন? আর প্রোগ্রামারের জন্য সমাধানটা কী দাঁড়াল?
Lamport-এর ১৯৭৯ সালের সংজ্ঞা মানুষের স্বাভাবিক ধারণার সঙ্গে মেলে, কিন্তু তা মানতে গেলে core-এর প্রায় সব গতি-কৌশল আটকে যায়। Store buffer-এ store রেখে load এগিয়ে নেওয়া যায় না; out-of-order core ভিন্ন ঠিকানার load আগেভাগে চালাতে পারে না; compiler-ও দুটি ভিন্ন ভ্যারিয়েবলের access-এর ক্রম বদলাতে পারে না। মাপা গবেষণায় দেখা গেছে store buffer একাই উল্লেখযোগ্য গতি দেয়, আর SC-কে আগেভাগে চালিয়ে ভুল হলে ফেরত যাওয়ার (speculation) কৌশল দিয়ে বাঁচানো যায়, কিন্তু তাতে hardware জটিল হয়। MIPS R10000-এর মতো কিছু নকশা এমন speculation দিয়ে কঠোর ক্রম দিত বলে জানা যায়, তবে শিল্পের বড় অংশ ঢিলা model বেছে নিয়েছে।
১৯৯০-এর দশকে Sarita Adve আর Mark Hill-এর কাজ থেকে একটি আপসের ধারণা আসে — “data-race-free হলে SC” (SC for DRF): hardware আর compiler নিজের মতো reorder করুক, কিন্তু প্রোগ্রাম যদি সব ভাগ করা ডেটা synchronization দিয়ে পাহারা দেয় (data race না থাকে), তাহলে ফল SC-র মতোই দেখাবে। Java-র memory model-এর সংশোধন (JSR-133, Java 5, ২০০৪ সালের দিকে) আর C11/C++11 (২০১১)-এর atomic ও memory_order এই নীতির ওপর দাঁড়িয়ে। Hans Boehm-এর ২০০৫ সালের প্রবন্ধ “Threads Cannot Be Implemented as a Library” দেখায়, ভাষার মানদণ্ডে memory model না থাকলে compiler-এর সাধারণ optimization-ও thread-এর কোড ভেঙে দিতে পারে।
প্রোগ্রামারের জন্য ফল: সাধারণ কোডে lock আর seq_cst atomic ব্যবহার করুন — তাহলে আপনি SC-র জগতে থাকেন, hardware যত ঢিলাই হোক। শুধু পারফরম্যান্সের জন্য খুব দরকার হলে acquire/release বা relaxed, আর তখন litmus test ও টুল দিয়ে যাচাই। এই পর্বের C পরীক্ষায় relaxed-এ (0, 0) দেখা দেওয়াটাই সেই সতর্কবার্তা।
মূল উপলব্ধি: hardware-এর গতি আর প্রোগ্রামারের সহজবোধ্যতার দ্বন্দ্ব মিটেছে একটি চুক্তিতে — data race না থাকলে SC, আর তার দায়িত্ব ভাষার atomic আর lock-এর।
প্র ০৩Apple-এর ARM-ভিত্তিক Mac x86-এর প্রোগ্রাম অনুবাদ করে চালায়। x86 আর ARM-এর memory model আলাদা হলে অনুবাদ করা multithreaded প্রোগ্রাম ভাঙে না কেন?
Binary translation-এ (Apple-এর Rosetta 2, Microsoft-এর Windows-on-ARM-এর x86 emulation, বা QEMU) x86-এর instruction ARM-এর instruction-এ বদলানো হয়। একটি thread-এর হিসাব মেলানো তুলনামূলক সহজ, কিন্তু কয়েকটি thread থাকলে সমস্যা: x86 প্রোগ্রাম TSO-র নিশ্চয়তা ধরে নিয়ে লেখা। x86-এ message passing — data লেখা, তারপর flag লেখা — fence ছাড়াই কাজ করে; ARM-এর ঢিলা model-এ সেই দুই store উল্টে দেখা দিতে পারে, আর অন্য thread flag দেখে পুরোনো data পড়বে।
সরল সমাধান: অনুবাদের সময় প্রতিটি load-কে acquire আর প্রতিটি store-কে release ধরনের instruction বানানো, বা দরকারি জায়গায় fence বসানো। কিন্তু প্রতিটি memory access-এ এমন খরচ যোগ হলে অনুবাদ করা প্রোগ্রাম অনেক ধীর হয়। প্রকাশিত বিশ্লেষণ আর প্রতিবেদন অনুযায়ী Apple-এর M-সিরিজ chip-এ একটি hardware-এর সুবিধা আছে: OS প্রতিটি প্রসেসের জন্য একটি বিশেষ মোড চালু করতে পারে, যেখানে core TSO-র নিয়ম মেনে চলে — তাই অনুবাদকের প্রতিটি access-এ fence বসানোর দরকার পড়ে না। এটি ARM-এর মানক অংশ নয়, Apple-এর নিজস্ব সংযোজন বলে জানা যায়।
RISC-V এই সমস্যাটি আগে থেকেই ভেবে ঐচ্ছিক Ztso extension রেখেছে: যে chip এটি দেয়, সে TSO-র নিশ্চয়তা দেয়, আর x86 থেকে অনুবাদ বা TSO ধরে লেখা পুরোনো কোড সহজে চলে। অন্য দিকে, যেখানে এমন hardware সুবিধা নেই, সেখানে অনুবাদক কৌশলে কাজ চালায় — যেমন কোন memory access thread-এর মধ্যে ভাগ হয় না (যেমন নিজের stack) তা অনুমান করে সেখানে fence বাদ দেওয়া, যদিও এতে ঝুঁকি থাকে।
মূল উপলব্ধি: memory model ISA-র চুক্তির অংশ; এক ISA-র প্রোগ্রাম আরেক ISA-তে চালাতে হলে শুধু instruction নয়, ক্রমের নিশ্চয়তাও অনুবাদ করতে হয় — আর তার দাম পড়ে গতিতে, যদি না hardware সাহায্য করে।
প্র ০৪Compare-and-swap আর LR/SC — দুটিই lock-free কোড বানায়। কিন্তু x86 বেছে নিল CAS, RISC-V আর পুরোনো ARM বেছে নিল LR/SC, পরে ARM আবার CAS যোগ করল। কেন এই দোলাচল?
১৯৯১ সালে Maurice Herlihy-র “wait-free synchronization”-এর তত্ত্ব দেখায়, সব atomic instruction সমান শক্তিশালী নয়: test-and-set বা fetch-and-add দিয়ে দুইয়ের বেশি thread-এর মধ্যে wait-free consensus সম্ভব নয়, কিন্তু CAS বা LL/SC দিয়ে যেকোনো সংখ্যক thread-এর জন্য সম্ভব। তাই আধুনিক lock-free data structure-এর ভিত্তি এই দুটিই।
CAS-এর সুবিধা: একটি instruction, সহজ অর্থ, আর line-টি একবার M-এ এনে কাজ শেষ। সমস্যা ABA: আপনি মান A পড়লেন, অন্য thread A থেকে B করে আবার A করল, আপনার CAS সফল হলো — অথচ এর মধ্যে কাঠামো বদলে গেছে (যেমন linked list-এর একটি node মুছে আবার ব্যবহার হয়েছে)। প্রতিকার হিসেবে pointer-এর সঙ্গে একটি সংস্করণ-সংখ্যা রাখা হয়, যার জন্য x86-এ দ্বিগুণ চওড়া cmpxchg16b-এর মতো instruction আছে।
LL/SC (MIPS, Alpha, PowerPC, ARMv7-এর ldrex/strex, RISC-V) RISC দর্শনের সঙ্গে মেলে: load আর store আলাদা রাখা, একটি instruction-এ পড়া-লেখা না মেশানো। আর sc “মাঝে কেউ লিখেছে কি না” দেখে, মান নয় — তাই ABA নেই। দুর্বলতা: খুব বেশি প্রতিযোগিতায় সবাই lr করে, একজনের sc অন্যদের reservation মুছে দেয়, আর কেউ এগোতে না পারার ঝুঁকি (livelock) থাকে; তাই RISC-V spec lr আর sc-এর মাঝে কয়েকটি সরল instruction-এর একটি “constrained” loop-এর জন্য অগ্রগতির নিশ্চয়তা দেয়। বড় সার্ভারে শত core একই counter বাড়ালে একক atomic instruction ভালো — তা line core-এ না এনে L3-র কাছেই (“far atomic”) চালানো যায়। এজন্য ARMv8.1 LSE extension-এ CAS, LDADD, SWP যোগ হয়, আর RISC-V-এর শুরু থেকেই AMO আছে, পরে Zacas extension-এ CAS-ও এসেছে।
মূল উপলব্ধি: LL/SC নমনীয় আর ABA-মুক্ত, একক atomic instruction প্রতিযোগিতায় আর বড় স্কেলে দ্রুত — তাই আধুনিক ISA দুটোই রাখে।
প্র ০৫Core-সংখ্যা বাড়ছে, কিন্তু coherence-এর খরচও বাড়ছে। ভবিষ্যতের শত-হাজার core-এর chip-এ কি সবখানে পূর্ণ hardware coherence টিকবে?
২০১২ সালে Milo Martin, Mark Hill আর Daniel Sorin-এর একটি প্রবন্ধের শিরোনামই ছিল একটি দাবি — “Why On-Chip Cache Coherence Is Here to Stay”. তাঁদের যুক্তি: directory আর inclusive cache-এর চতুর হিসাব রাখলে core বাড়ার সঙ্গে coherence-এর বাড়তি ট্রাফিক আর জায়গা খুব ধীরে বাড়ে — presence bit-এর বদলে সংক্ষিপ্ত রূপ, শুধু cache-এ থাকা line-এর হিসাব, আর কাজের বেশিরভাগ ডেটা আসলে ভাগই হয় না। তাই পূর্ণ coherence রেখে দেওয়ার খরচ সহনীয়, আর তা ফেলে দিলে software-এর জটিলতা অনেক বেশি।
বাস্তবে সার্ভার chip-এর core-সংখ্যা কয়েক ডজন থেকে শতের ঘরে পৌঁছেছে, আর এরা সবাই coherent shared memory দেয় — mesh interconnect, slice-এ ভাগ করা L3 আর snoop filter বা directory দিয়ে। কয়েকটি socket জুড়লেও coherence থাকে, তবে NUMA-র দূরত্বে খরচ বাড়ে। অন্য দিকে, মাঝে কিছু গবেষণা-chip coherence ইচ্ছা করে বাদ দিয়ে message passing পরীক্ষা করেছিল — যেমন Intel-এর ২০০৯–২০১০ সালের দিকের ৪৮-core “Single-chip Cloud Computer”; এর ধারা মূলধারায় আসেনি।
তবে ভবিষ্যতের ছবি মিশ্র হতে পারে। CPU-র ভেতরে পূর্ণ coherence থাকছে, কিন্তু CPU আর accelerator-এর (GPU, NIC, AI chip) মাঝে নতুন মানক — যেমন CXL — নির্বাচিত বা আংশিক coherence দেয়, যাতে device দরকার হলে CPU-র cache-এর সাথে মিল রাখে, আর বাকি সময় নিজের মতো চলে। Chiplet-ভিত্তিক নকশায় chiplet-এর মাঝের coherence ট্রাফিক বিদ্যুৎ আর latency-র বড় অংশ নেয়, তাই সেখানে নতুন কৌশলের গবেষণা চলছে — যেমন কোন ডেটা আসলে ভাগ হয় না তা চিনে তার হিসাব না রাখা।
মূল উপলব্ধি: core-এর ভেতরের জগতে hardware coherence টিকে আছে কারণ চতুর directory তার খরচ নিয়ন্ত্রণে রাখে; core আর accelerator-এর সীমানায় coherence হয়ে উঠছে বেছে নেওয়ার বিষয়।
অনুশীলন
পরীক্ষার ধাঁচে: MESI টেবিল পূরণ, MSI বনাম MESI-তে bus transaction গোনা, SC/TSO-তে কোন ফল সম্ভব, আর TTAS spinlock. আগে নিজে চেষ্টা করুন।
-
MESI টেবিল পূরণ (হাতে trace): তিনটি core, snooping bus, write-back cache, MESI; সব line শুরুতে I. M থেকে উত্তর দিলে Flush; অন্য কপি না থাকলে BusRd-এর পরে E. নিচের trace-এর প্রতিটি ধাপে P1, P2, P3-এর অবস্থা, bus transaction আর ডেটার উৎস লিখুন, শেষে মোট transaction আর Flush গুনুন: P1 R A, P2 R A, P2 W A, P1 W A, P3 R A, P1 R A, P3 W A, P2 R B, P2 W B.
ধাপ ঘটনা P1 P2 P3 bus ডেটা এল ১ P1 R A E I I BusRd মেমরি ২ P2 R A S S I BusRd মেমরি (P1-এর কপি পরিষ্কার) ৩ P2 W A I M I BusUpgr নিজের cache ৪ P1 W A M I I BusRdX P2-এর cache (Flush) ৫ P3 R A S I S BusRd P1-এর cache (Flush) ৬ P1 R A S I S — (hit) নিজের cache ৭ P3 W A I I M BusUpgr নিজের cache ৮ P2 R B I E I BusRd মেমরি ৯ P2 W B I M I — (E → M) নিজের cache মোট ৭টি bus transaction (BusRd ৪, BusUpgr ২, BusRdX ১) আর ২টি Flush (ধাপ ৪ আর ৫)। একই trace MSI-তে: ধাপ ১ আর ৮-এ S, আর ধাপ ৯-এ বাড়তি BusUpgr — মোট ৮টি। ধাপ ১-এর E ধাপ ২-এ S হয়ে যাওয়ায় সেখানে MESI-র কোনো লাভ হয়নি; লাভ শুধু B-তে। ধাপ ৫-এ P2-এর কাছে কিছু নেই, আর ধাপ ৬ একটি hit — P1-এর S কপি ধাপ ৫-এই এসেছে। সব কটি সংখ্যা এই পর্বের simulator-এ trace বদলে চালিয়ে মেলানো হয়েছে।
-
MSI বনাম MESI — transaction গোনা: একটি thread core P1-এ চলে, আর অন্য কোনো core তার ডেটা ছোঁয় না। প্রতিটি উপাদান আলাদা cache line-এ, সব শুরুতে I. (ক)
for (i = 0; i < 100; i++) a[i] = a[i] + 1;— MSI আর MESI-তে মোট bus transaction কত? (খ)for (i = 0; i < 100; i++) a[i] = 0;— কত? (গ) (ক)-এর loop চলার পরে আরেকটি core P2 সব a[i] পড়ে — MESI-তে P2-এর জন্য কতগুলো transaction আর কতগুলো Flush?(ক) প্রতিটি a[i]: আগে PrRd (I-তে miss → BusRd), তারপর PrWr. MSI-তে BusRd-এর পরে S, তাই লেখায় BusUpgr: প্রতি line-এ ২টি, মোট ২০০. MESI-তে আর কারও কপি নেই, তাই BusRd-এর পরে E, লেখা নিঃশব্দ: মোট ১০০.
(খ) প্রথম access-ই লেখা: I-তে PrWr → BusRdX → M — দুই protocol-এই প্রতি line-এ ১টি, মোট ১০০ আর ১০০. E-এর সুবিধা কেবল “পড়ে তারপর লেখা”-র ধরনে।
(গ) (ক)-এর পরে P1-এর কাছে ১০০টি line-ই M. P2-এর প্রতিটি read miss → BusRd, আর P1 প্রতিটিতে Flush করে S হয়: ১০০টি transaction আর ১০০টি Flush. (simulator-এ মেলানো।)
-
কোন ফল সম্ভব — SC আর TSO: সব ভ্যারিয়েবল শুরুতে ০, আর প্রতিটি thread আলাদা core-এ চলে। (ক) SB: T0:
x = 1; r1 = y;T1:y = 1; r2 = x;(খ) MP (message passing): T0:data = 1; flag = 1;T1:r1 = flag; r2 = data;প্রতিটির জন্য বলুন (r1, r2)-এর কোন মান SC-তে আর কোন মান TSO-তে (x86-এর মতো FIFO store buffer) সম্ভব। (গ) ARM বা RISC-V-এ (খ)-এর কোন ফল ভয়ের, আর কোথায় কী বসালে তা বন্ধ হয়?(ক) SC: (0, 1), (1, 0), (1, 1) — ৬টি interleaving-এর প্রতিটিতে প্রথম operation একটি store, তাই (0, 0) অসম্ভব। TSO: চারটিই, (0, 0)-সহ — দুই store buffer-এ থাকতেই দুই load পড়ে ফেলে। (এই পর্বের মডেলে ৮০টি interleaving-এর ১৮টিতে (0, 0).)
(খ) SC: (0, 0), (0, 1), (1, 1); (1, 0) — অর্থাৎ flag দেখল কিন্তু data পুরোনো — অসম্ভব, কারণ flag = 1 সবসময় data = 1-এর পরে। TSO-তেও একই তিনটি: store buffer FIFO, তাই data আগে drain হয়, তারপর flag; আর T1-এর দুই load-ও ক্রম মানে। একই ধরনের একটি মডেল চালিয়ে দেখা গেছে, SC-র ৬টি আর TSO-র ৩০টি interleaving-এর কোনোটিতেই (1, 0) নেই। তাই x86-এ message passing fence ছাড়াই নিরাপদ (hardware-এর দিক থেকে; compiler-কে আটকাতে C-তে তবু atomic লাগে)।
(গ) দুর্বল model-এ (1, 0) সম্ভব: দুই store উল্টে দেখা দিতে পারে, বা দুই load উল্টে চলতে পারে। প্রতিকার দুই দিকেই: T0-তে
flag-এর store-কে release বানানো (বা দুই store-এর মাঝে RISC-V-এfence w, w), আর T1-এflag-এর load-কে acquire বানানো (বা দুই load-এর মাঝেfence r, r). C11-এ:atomic_store_explicit(&flag, 1, memory_order_release)আরatomic_load_explicit(&flag, memory_order_acquire). -
কেন test-and-test-and-set (গুনুন আর কোড লিখুন): তিনটি core, MESI. P1 lock নিয়েছে (তার কাছে line M). P2 আর P3 অপেক্ষায়, পালা করে ৫ বার করে চেষ্টা করে (মোট ১০টি চেষ্টা), তারপর P1 lock ছাড়ে (একটি লেখা), আর P2 lock পায়। (ক) সাধারণ TAS spinlock-এ (প্রতিটি চেষ্টা একটি লেখা) অপেক্ষার ১০টি চেষ্টায় কতগুলো bus transaction? (খ) TTAS-এ (চেষ্টা = সাধারণ পড়া) অপেক্ষার সময় কত? (গ) C11 atomic দিয়ে একটি TTAS spinlock-এর
lock()আরunlock()লিখুন।(ক) প্রতিটি TAS একটি PrWr, আর আগের চেষ্টা অন্য core করেছে বলে line সবসময় অন্যের কাছে M: প্রতিটি চেষ্টা = ১টি BusRdX + ১টি Flush. ১০টি চেষ্টায় ১০টি BusRdX, ১০টি Flush — line P2 আর P3-এর মধ্যে লাফাচ্ছে। তারপর P1-এর release (BusRdX, কারণ তার কপি অনেক আগেই বাতিল) আর P2-এর সফল TAS (BusRdX): মোট ১২টি.
(খ) P2-এর প্রথম পড়া BusRd (P1 Flush করে S), P3-এর প্রথম পড়া BusRd; বাকি ৮টি পড়া নিজের S কপিতে hit: অপেক্ষায় মাত্র ২টি. তারপর P1-এর release (BusUpgr, দুজনই বাতিল), P2 আর P3-এর পড়া (২টি BusRd), আর ধরে নিন দুজনেই ০ দেখে TAS করে, P2 আগে (P2-এর BusUpgr সফল; P3-এর BusRdX ব্যর্থ, কিন্তু line কেড়ে নেয়) — মোট ৭টি. Simulator-এ দুটি trace চালিয়ে ১২ আর ৭ মেলানো হয়েছে (P1-এর প্রথম lock নেওয়া বাদে)। অপেক্ষা যত লম্বা, পার্থক্য তত বড় — TAS-এ চেষ্টা-প্রতি একটি, TTAS-এ প্রায় শূন্য।
#include <stdatomic.h> void lock(atomic_int *l) { for (;;) { while (atomic_load_explicit(l, memory_order_relaxed) != 0) { } /* local spin */ if (atomic_exchange_explicit(l, 1, memory_order_acquire) == 0) return; } } void unlock(atomic_int *l) { atomic_store_explicit(l, 0, memory_order_release); }(গ)-এর ব্যাখ্যা: ভেতরের loop সাধারণ load — line নিজের cache-এ S থাকলে bus চুপ। ০ দেখলে তবেই
atomic_exchange(TAS); পুরোনো মান ০ হলে lock পাওয়া, acquire নিশ্চিত করে critical section-এর access এর আগে যাবে না। অন্যথায় আবার ঘোরা।unlock-এ release: critical section-এর সব লেখা lock ছাড়ার আগে দেখা দেবে। x86-এatomic_exchangeহয়xchg, RISC-V-এamoswap.w.aq. (gcc-তে-Wall -Wextraদিয়ে warning ছাড়াই compile হয় — যাচাই করা।)
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমMSI-তে P1-এর কাছে line X অবস্থা M-এ আছে, আর P2 X পড়তে চায়। ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- 🔍 P2-এর cache-এ X অবস্থা I — read miss
- 📢 P2 bus-এ BusRd পাঠায়
- 👂 P1-এর controller BusRd শুনে দেখে, তার কাছে X অবস্থা M-এ
- 📤 P1 Flush করে — হালনাগাদ ডেটা bus-এ, মেমরিও হালনাগাদ
- 🔁 P1-এর X অবস্থা M থেকে S-এ নামে
- ✅ P2 ডেটা পেয়ে X-কে S অবস্থায় রাখে, আর load শেষ করে
MESI protocol-এ একটি core এমন একটি line পড়ল, যার কপি আর কোনো cache-এ নেই। Line-টি কোন অবস্থায় আসবে, আর পরে সেই core লিখলে কী হবে?
- S; লিখতে হলে BusUpgr লাগবে
- M; লেখায় কোনো bus লাগবে না
- E; পরে লিখলে bus transaction ছাড়াই M
- E; লিখতে হলে BusRdX লাগবে
MSI-তে P1, P2, P3 — তিনজনের কাছেই line X অবস্থা S-এ। এখন P2 X-এ লেখে। লেখার পরে তিনজনের অবস্থা কী, আর bus-এ কোন transaction গেল?
- S, M, S; BusUpgr
- I, M, I; BusUpgr
- M, I, M; BusRd
- I, M, I; BusRd
x = y = 0 থেকে শুরু। Thread 0: x = 1; r1 = y; Thread 1: y = 1; r2 = x; — দুটি আলাদা core-এ, কোনো fence নেই। (r1, r2) = (0, 0) কোথায় সম্ভব?
- শুধু sequential consistency মানা যন্ত্রে
- কোথাও নয়, কারণ cache coherence তা আটকায়
- শুধু ARM আর RISC-V-এর মতো দুর্বল model-এ
- x86 (TSO) আর ARM/RISC-V-এ সম্ভব, SC-তে নয়
চারটি core একটি spinlock-এর জন্য অপেক্ষা করছে। while (test_and_set(&lock)) ;-এর বদলে test-and-test-and-set ব্যবহার করলে প্রধান লাভ কী?
- Lock আরও সঠিক হয় — TAS একা দুজনকে ঢুকতে দিতে পারে
- অপেক্ষার সময় thread ঘুমিয়ে পড়ে, CPU বাঁচে
- অপেক্ষাকারীরা নিজের cache-এর S কপিতে ঘোরে, তাই প্রতিটি চেষ্টায় bus-এ লেখার অনুরোধ যায় না
- Lock ছাড়ার সময় কোনো bus transaction লাগে না
RISC-V-এ lr.w t0, (a0)-এর পরে sc.w t2, t1, (a0) চলল, কিন্তু মাঝখানে অন্য একটি core ঠিকানা a0-তে লিখেছে। কী ঘটবে?
- sc.w তবু লিখবে, কারণ t1-এর মান বৈধ
- sc.w লিখবে না, t2 ≠ 0 হবে, আর কোড আবার lr.w থেকে চেষ্টা করবে
- একটি exception হবে, OS সামলাবে
- sc.w লিখবে না, আর t2 = 0 হবে
৬৪টি core-এর একটি chip-এ পূর্ণ directory প্রতি cache line-এ প্রতিটি core-এর জন্য ১টি presence bit রাখে। Line ৬৪ byte হলে শুধু presence bit-এর জন্য বাড়তি জায়গা line-এর ডেটার কত শতাংশ?
- ১২.৫%
- ১০০%
- ৬.২৫%
- ১.৫৬%
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Multicore কেন, আর সমস্যা কোথায়: power wall আর ILP-এর দেয়ালের পরে বাড়তি transistor গেছে বেশি core-এ; প্রতিটির নিজের L1/L2 থাকায় একই ঠিকানার কপি কয়েক জায়গায় — write-back cache-এ stale value.
- Coherence-এর সংজ্ঞা: write propagation + write serialization, বাস্তবে SWMR — একজন লেখক অথবা অনেক পাঠক।
- MSI/MESI: I+PrRd → BusRd → S (MESI-তে কেউ না থাকলে E); I+PrWr → BusRdX → M; S+PrWr → BusUpgr → M; M শুনল BusRd → Flush, S; শুনল BusRdX → Flush, I. E → M নিঃশব্দ — ব্যক্তিগত ডেটায় প্রতি line-এ একটি transaction বাঁচে।
- Consistency: SC সহজবোধ্য কিন্তু ধীর; store buffer-এর কারণে x86 (TSO)-এ store → load উল্টায়, ARM/RISC-V-এ প্রায় সব; ক্রম চাই — fence, বা acquire/release, বা C-র seq_cst.
- Atomic আর lock: TAS, fetch-and-add, CAS, LR/SC আর AMO সবই coherence-এর মালিকানা দিয়ে atomic; TAS-এ ঘোরা line লাফায়, TTAS নিজের S কপিতে ঘোরে; বড় chip-এ bus-এর বদলে ring/mesh আর directory.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৫৪ · সমান্তরাল আর্কিটেকচার — Flynn-এর শ্রেণি, NUMA আর interconnectপরবর্তী পর্বDirectory, NUMA আর বড় স্কেলের coherence.
- পর্ব ৪৮ · Cache-বান্ধব প্রোগ্রাম — loop-এর ক্রম, blocking আর false sharingআগের ভিত্তিFalse sharing মেপে দেখা আর padding দিয়ে সারানো।
- কম্পিউটার আর্কিটেকচার ও ডিজিটাল লজিক · কোর্স হোম৬২টি পর্বপুরো কোর্সের রোডম্যাপ।
- Operating Systems · Hardware support — atomic instruction, spinlock আর memory orderingপাশের ট্র্যাকএকই instruction দিয়ে software-এ lock, ticket আর MCS lock.
- Operating Systems · Race condition আর critical section — Peterson-এর সমাধানপাশের ট্র্যাকযে algorithm store buffer-এ ভেঙে যায়।
- C প্রোগ্রামিং · মাল্টিথ্রেডিংহাতে-কলমেC-তে thread, mutex আর atomic লেখা।