Cache Organization — বিশাল ঠিকানাকে ছোট array-তে গুঁজে দেওয়া
Cache Organization
একটা cache বিশাল address space-কে ছোট array-তে map করে address-কে tag+index+offset-এ ভেঙে — direct-mapped (এক ঠিকানা = এক line, সস্তা কিন্তু conflict-prone), fully-associative (যেকোনো line, conflict নেই কিন্তু ব্যয়বহুল parallel search), আর মাঝামাঝি, বাস্তবে সবচেয়ে ব্যবহৃত set-associative।
আগে এটা বুঝি
গত লেসনে আমরা দেখেছি cache কেন আছে — locality of reference exploit করে গড় access time কমানোর জন্য। কিন্তু একটা প্রশ্ন এড়িয়ে গিয়েছিলাম: cache যখন একটা memory ঠিকানা পায়, সেটা কীভাবে জানে ডেটাটা তার কাছে আছে কি না — আর যদি থাকে, ঠিক কোথায়?
সংখ্যাটা একটু ভাবুন। একটা 64-বিট ঠিকানা মানে 2^64 সম্ভাব্য location — কার্যত অসীম। অথচ একটা L1 cache মাত্র 32 KB, যেটা 64-বাইট line হিসেবে গুনলে মাত্র 512-টা “slot”। এই বিশাল space-কে এই ক্ষুদ্র array-তে map করতে হবে, আর সেটা করতে হবে প্রতি cycle-এ — cache-এর পুরো সুবিধাটাই নষ্ট হয়ে যাবে যদি “এটা cache-এ আছে কি না” যাচাই করতেই কয়েকশ cycle লাগে।
সমাধানটা লেসন ৭-এর (digital-logic module-এর) দুইটা পুরনো পরিচিত hardware building block-এর মধ্যেই আছে — decoder আর multiplexer, ঠিক যেভাবে DRAM row/column addressing-এ ব্যবহৃত হয়েছিল। কিন্তু cache-এর সমস্যাটা DRAM addressing-এর চেয়ে একটু আলাদা: DRAM-এ প্রতিটা bit-এর একটা নির্দিষ্ট, স্থায়ী ঠিকানা আছে (row+column সরাসরি সেই bit-কে নির্দেশ করে)। Cache-এ উল্টো — cache-এর প্রতিটা slot সময়ের সাথে বদলে যেতে পারে কোন memory ঠিকানার ডেটা ধরে রাখছে। এই লেসনের মূল বিষয়: কীভাবে একটা বিশাল, পরিবর্তনশীল mapping-কে দ্রুত hardware দিয়ে বাস্তবায়ন করা যায়।
মূল ধারণা
Address-কে তিন ভাগে ভাঙা — tag, index, offset
প্রতিটা cache design একটা memory address-কে তিনটা অংশে ভাগ করে দেখে:
- Offset — cache line-এর ভেতরে কোন বাইট, সেটা নির্দেশ করে।
- Index — cache-এর কোন slot (বা slot-এর group) এই ঠিকানা ব্যবহার করবে, সেটা নির্দেশ করে।
- Tag — সেই slot-এ ঠিক কোন memory ঠিকানার ডেটা আছে, সেটা নিশ্চিত করার জন্য বাকি সব বিট।
একটা concrete উদাহরণ দিয়ে derive করা
ধরুন 32-বিট address, cache আকার 4 KB (4096 বাইট), line আকার 64 বাইট, direct-mapped (প্রতি address-এর জন্য ঠিক একটা সম্ভাব্য line — এই লেসনের পরের অংশে বিস্তারিত)।
ধাপ ১ — Offset বিট: একটা line-এ 64 বাইট আছে, তাই সেই 64টা বাইটের মধ্যে একটাকে চিহ্নিত করতে লাগবে:
ধাপ ২ — মোট line সংখ্যা, তাই index বিট: cache-এ মোট line সংখ্যা:
64-টা line-এর একটাকে চিহ্নিত করতে লাগবে:
ধাপ ৩ — বাকি সব বিট tag:
যাচাই: 20 + 6 + 6 = 32 — ঠিক address-এর প্রস্থের সমান। ✓
বিট: 31 12 11 6 5 0
┌──────────────────────────┬────────────┬────────────┐
│ tag (20 বিট) │ index (6) │ offset (6) │
└──────────────────────────┴────────────┴────────────┘
"কোন memory location" "কোন line" "line-এর কোন বাইট"Direct-mapped cache — প্রতি ঠিকানার একটাই সম্ভাব্য বাড়ি
Direct-mapped cache-এ প্রতিটা memory address-এর জন্য ঠিক একটা cache line সম্ভব — index বিট সরাসরি বলে দেয় কোনটা। Lookup প্রক্রিয়া:
- Address-কে tag/index/offset-এ ভাঙা হয়।
- Index দিয়ে সরাসরি (কোনো তুলনা ছাড়াই) সেই একটা line select করা হয় — এটাই decoder-এর কাজ, ঠিক DRAM row-select-এর মতো।
- সেই line-এ সংরক্ষিত tag-এর সাথে address-এর tag তুলনা করা হয় — একটা মাত্র comparator।
- যদি মেলে এবং সেই line “valid” (একটা valid bit থাকে, cold-start-এ সব line invalid) — তাহলে hit, offset দিয়ে সঠিক বাইট বের করা হয়।
- না মিললে — miss। নিচের memory স্তর থেকে পুরো line আনতে হয়, cache-এ বসাতে হয় (পুরনো যা ছিল তা মুছে), তারপর data দেওয়া হয়।
address (32 বিট)
┌──────────┬──────────┬──────────┐
│ tag │ index │ offset │
│ (20 বিট) │ (6 বিট) │ (6 বিট) │
└────┬─────┘└────┬─────┘└────┬────┘
│ │ │
│ ┌─────▼─────┐ │
│ │ index দিয়ে │ │
│ │ ঠিক ১টা │ │
│ │ line select│ │
│ └─────┬─────┘ │
│ │ │
│ ┌──────▼──────┐ │
│ │ line-এ সংরক্ষিত │ │
│ │ tag + valid বিট │ │
│ └──────┬──────┘ │
│ │ │
└───►[ = ? ]◄┘ │
│ comparator (১টা) │
┌─────▼─────┐ │
│ match+valid│ │
│ → HIT │ │
│ না মিললে │ │
│ → MISS │ │
└────────────┘ │
line-এর ভেতরে
offset দিয়ে বাইট বাছাইConflict-এর সমস্যা — concrete উদাহরণ
Direct-mapped-এর সমস্যা তার নামেই লুকানো — প্রতিটা address-এর জন্য একটাই সম্ভাব্য line। দুইটা ভিন্ন, ঘনঘন-ব্যবহৃত ঠিকানা যদি একই index-এ পড়ে (কিন্তু আলাদা tag), তারা একে অপরকে বারবার উৎখাত (evict) করতে থাকবে — এটাকে বলে thrashing।
মনে করুন উপরের 4 KB, 64-বাইট-line cache-এ দুইটা int array আছে, প্রতিটাতে 1024 element (4096 বাইট করে):
int a[1024]; /* ঠিকানা শুরু হয় 0x10000 থেকে ধরুন */
int b[1024]; /* ঠিকানা শুরু হয় 0x11000 থেকে — a-এর ঠিক পরে */0x11000 - 0x10000 = 0x1000 = 4096 — অর্থাৎ b-এর base ঠিকানা a-এর base ঠিকানা থেকে ঠিক cache আকারের সমান দূরত্বে। যেহেতু 4096 = 2^12 আর index+offset মিলিয়ে মাত্র 12 বিট (6+6), 4096 যোগ করলে সেই 12 বিটের কোনোটাই বদলায় না — শুধু tag বদলায়:
a[i]-এর ঠিকানা: ... tag=T1 ... index=I ... offset=O
b[i]-এর ঠিকানা: ... tag=T2 ... index=I ... offset=O ← একই index, শুধু tag ভিন্ন!এখন কল্পনা করুন একটা loop যা পালাক্রমে a[i] আর b[i] access করছে:
for (int i = 0; i \< 1024; i++) {
sum += a[i] + b[i];
}a[i] access করলে সেই index-এ a-এর line বসে (tag=T1)। পরের মুহূর্তেই b[i] access — একই index, কিন্তু tag=T2, মিলছে না — miss, a-এর line উৎখাত হয়ে b-এর line বসে। পরের iteration-এ a[i+1] — যদিও সেটা নতুন offset-এর জন্য হয়তো একই line-এই থাকত (spatial locality থাকলে), কিন্তু b ইতিমধ্যে সেই line দখল করে ফেলেছে — আবার miss। ফলাফল: প্রতিটা access-ই miss, যদিও প্রতিটা array আলাদা করে পুরোপুরি cache-এ আঁটত (4096 বাইট, cache-এর সমান আকার), আর দুটো মিলিয়েও (8192 বাইট) মাত্র 2× cache আকার — তুলনায় নগণ্য working set।
Fully-associative cache — যেকোনো জায়গায়, কিন্তু সবখানে খুঁজতে হয়
বিপরীত চরম ডিজাইন: fully-associative cache-এ কোনো index-ই নেই — একটা address যেকোনো line-এ থাকতে পারে। Address ভাগ হয় শুধু tag + offset-এ (উপরের উদাহরণে: tag 26 বিট, offset 6 বিট, 26+6=32 ✓ — index বিট শূন্য)।
সুবিধা: উপরের conflict সমস্যা সম্পূর্ণ অদৃশ্য — a আর b একই “index”-এর জন্য প্রতিদ্বন্দ্বিতা করছে না, কারণ কোনো নির্দিষ্ট index নেই যেখানে তাদের যেতেই হবে। যেকোনো দুটো ঠিকানা cache-এর যেকোনো দুইটা (বা একই সময়ে বেশি) ভিন্ন line-এ থাকতে পারে, যতক্ষণ পুরো cache না ভরে।
কিন্তু দাম: index না থাকায় hardware জানে না কোথায় খুঁজবে — তাই প্রতিটা line-এর tag-এর সাথে address-এর tag তুলনা করতে হয়, একযোগে (parallel), একটা cycle-এই। এর মানে N-টা line-এর cache-এ N-টা comparator লাগবে।
address → tag (26 বিট) + offset (6 বিট)
│
┌──────────┬──────────┬──────────┬───┬──────────┐
│ line 0 │ line 1 │ line 2 │...│ line 63 │
│ tag=? │ tag=? │ tag=? │ │ tag=? │
└────┬─────┴────┬─────┴────┬─────┴───┴────┬─────┘
│ │ │ │
[=?] [=?] [=?] ... [=?] ← ৬৪টা comparator, একসাথে
└───────────┴─────OR───┴──────────────┘
│
কোনো একটাতে match+valid → HITSet-associative cache — বাস্তবসম্মত মাঝামাঝি
এখানেই আসল ইঞ্জিনিয়ারিং সমাধান — N-way set-associative। ধারণাটা direct-mapped আর fully-associative-এর মধ্যে একটা spectrum তৈরি করে:
- Cache-এর line-গুলোকে ছোট ছোট group বা set-এ ভাগ করা হয়, প্রতিটা set-এ ঠিক
N-টা line (N-কে বলে “way”)। - একটা address-এর index বিট বলে দেয় সে কোন set-এ যাবে (direct-mapped-এর মতো — নির্দিষ্ট)।
- কিন্তু সেই set-এর ভেতরে address
N-টা line-এর যেকোনো একটাতে থাকতে পারে (fully-associative-এর মতো — নমনীয়, তবে শুধু সেই set-এর মধ্যে)। - Lookup-এ শুধু সেই একটা set-এর
N-টা line-এর tag তুলনা করতে হয় —N-টা comparator, পুরো cache-এর সবগুলো না।
একটা concrete 4-way উদাহরণ
একই 4 KB cache, 64-বাইট line, কিন্তু এবার 4-way set-associative।
ধাপ ১ — মোট line সংখ্যা অপরিবর্তিত:
ধাপ ২ — set সংখ্যা: প্রতিটা set-এ 4-টা line থাকলে:
ধাপ ৩ — index বিট: 16-টা set-এর একটা চিহ্নিত করতে:
ধাপ ৪ — offset অপরিবর্তিত: 6 বিট (line আকার একই)।
ধাপ ৫ — tag বাকি সব বিট:
যাচাই: 22 + 4 + 6 = 32 ✓। আর সঞ্চয় ক্ষমতাও মেলে: 16 set × 4 way × 64 বাইট = 4096 বাইট = 4 KB ✓।
Direct-mapped (1-way): 64 line, 64 set → tag=20 index=6 offset=6
4-way set-associative: 64 line, 16 set → tag=22 index=4 offset=6
Fully-associative (64-way): 64 line, 1 set → tag=26 index=0 offset=6
tag বিট বাড়ছে ──────────►
◄────────── index বিট কমছে (associativity বাড়ার সাথে)একই conflict উদাহরণ, এবার 4-way-তে
আগের a[i]/b[i] thrashing উদাহরণটা 4-way cache-এ ফিরিয়ে আনি। a-এর আর b-এর ঠিকানা এখনও 4096 বাইট দূরত্বে, তাই তারা এখনও একই set-এ পড়বে (কারণ 4096 = 2^12, আর index+offset মিলে এখনও 10 বিট (4+6), যা 2^10=1024-এর সীমার মধ্যে repeat করে — 4096-এর প্যাটার্ন এখনও index অপরিবর্তিত রাখে)। কিন্তু এবার সেই set-এ 4-টা line আছে — a[i] একটা way দখল করে, b[i] আরেকটা way দখল করে, দুটোই একই সাথে সেই set-এ থাকতে পারে, একে অপরকে উৎখাত না করেই। Thrashing সম্পূর্ণ চলে গেছে — শুধু associativity বাড়ানোর কারণে, কোনো code পরিবর্তন ছাড়াই।
Skew-associative — associativity-র সুবিধা, কম হার্ডওয়্যার খরচে
ভেতরে কী ঘটছে
Lookup hardware ভেতর থেকে — set-associative-এর পূর্ণ পথ
একটা 4-way set-associative cache-এ একটা address আসার পর ঠিক কী ঘটে, ধাপে ধাপে:
address (32 বিট)
┌──────────────┬──────────┬──────────┐
│ tag (22 বিট) │ index(4) │ offset(6)│
└──────┬───────┘└────┬─────┘└────┬────┘
│ │ │
│ ┌──────▼──────┐ │
│ │ index দিয়ে │ │
│ │ ১টা SET বাছাই │ │
│ │ (16 সেটের │ │
│ │ একটা) │ │
│ └──────┬──────┘ │
│ │ │
│ ┌─────────▼─────────┐ │
│ │ সেই set-এর ৪টা line │ │
│ │ way0 way1 way2 way3│ │
│ │ tag তুলনা (৪টা │ │
│ │ comparator, সমান্তরাল)│ │
│ └─────────┬─────────┘ │
└──────────────┤ │
┌──────▼──────┐ │
│ কোনো way-তে │ │
│ match+valid │ │
│ → HIT │ │
│ না হলে │ │
│ → MISS │ │
└──────────────┘ │
hit হলে offset
দিয়ে বাইট বাছাইধাপে ধাপে:
- Address আসে, তিন ভাগে বিভক্ত হয় (হার্ডওয়্যার শুধু বিট-পজিশন অনুযায়ী wire আলাদা করে — কোনো গণনা লাগে না, এটা প্রায় বিনামূল্যে)।
- Index বিট একটা ছোট decoder-এ যায় (
4-to-16) — ঠিক একটা set select হয়। এটা DRAM row decoder-এরই ছোট সংস্করণ। - সেই set-এর
4-টা line-এর সংরক্ষিত tag একসাথে পড়া হয়। 4-টা comparator সমান্তরালে address-এর tag-এর সাথে তুলনা করে — প্রতিটার সাথে সেই line-এর valid bit-ও AND করা হয় (invalid line কখনো match হতে পারবে না, এমনকি tag কাকতালীয়ভাবে মিললেও)।4-টা তুলনার ফলাফল একটা OR-এ যায় — যেকোনো একটা true হলে hit।- Hit হলে, যেই way-তে match হয়েছে তার ডেটা একটা mux দিয়ে বাছাই করা হয়, তারপর offset দিয়ে ভেতরের সঠিক বাইট।
এই পুরো প্রক্রিয়া — decode, parallel compare, OR, mux — একটা একক, পাইপলাইনড hardware ধাপে ঘটে, সাধারণত 1-4 cycle-এর মধ্যে (এই কারণেই L1 latency ~4 cycle)। কোনো “খোঁজা” (searching) নেই, লেসন ৭-এর কোনো sequential loop নেই — সবকিছু সমান্তরাল combinational logic।
Miss হলে কী হয় — replacement-এর প্রথম আভাস
যদি miss হয়, নতুন ডেটা নিচের memory স্তর থেকে আনতে হবে। কিন্তু cache (বা সেই set) যদি ইতিমধ্যে ভরা থাকে, একটা পুরনো line-কে জায়গা করে দিতে উৎখাত (evict) করতে হবে। Direct-mapped-এ এই সিদ্ধান্ত তুচ্ছ — শুধু একটাই সম্ভাব্য line, সেটাই যাবে। কিন্তু set-associative-এ (N > 1 way) — সেই set-এর N-টা line-এর মধ্যে কোনটা উৎখাত হবে, সেই সিদ্ধান্তটাই একটা স্বতন্ত্র প্রশ্ন — এটাই replacement policy, যেটা পরের লেসনের প্রধান বিষয়।
Way prediction — associativity-র latency খরচ আংশিক এড়ানো
Set-associative lookup-এ একটা সূক্ষ্ম দাম আছে — parallel tag compare-এর ফলাফল (কোন way-তে hit) জানার পরেই সঠিক way-র ডেটা mux দিয়ে বাছাই করা যায়। এই “compare তারপর mux” ক্রম একটা ছোট বাড়তি delay যোগ করে, direct-mapped-এর তুলনায় (যেখানে কোনো mux লাগে না, ডেটা সরাসরি একটা line থেকে)।
কিছু উচ্চ-performance CPU design way prediction ব্যবহার করে এই delay কমাতে — একটা ছোট predictor আগেভাগেই অনুমান করে কোন way-তে hit হতে পারে (আগের access pattern দেখে), আর সেই অনুমানের ডেটা speculatively আগেই পড়া শুরু করে, tag compare সম্পূর্ণ হওয়ার আগেই। যদি অনুমান সঠিক হয় (বেশিরভাগ সময়), latency প্রায় direct-mapped-এর কাছাকাছি নেমে আসে। ভুল হলে, একটা ছোট penalty দিয়ে সঠিক way থেকে আবার পড়তে হয়। এটা এই module-এর পরবর্তী একটা বড় থিমের (branch prediction — ভুল হতে পারে এমন একটা দ্রুত অনুমান, ভুল হলে সংশোধনের penalty) একটা ছোট, cache-স্তরের পূর্বাভাস।
Cache line কেন এত গুরুত্বপূর্ণ একটা একক
লক্ষ্য করুন cache কখনো একটা মাত্র বাইট বা word আনে না — সবসময় পুরো line (এই উদাহরণে 64 বাইট) আনে, ব্যবহার হোক বা না হোক বাকি বাইটগুলো। এটা ঠিক আগের লেসনের DRAM word-oriented organization-এর একই যুক্তি — যেহেতু row/line access করার “fixed cost” আছে (address decode, sense amplify), একসাথে বেশি ডেটা আনা প্রায় বিনামূল্যে, আর spatial locality নিশ্চিত করে সেই বাড়তি বাইটগুলো শীঘ্রই কাজে লাগবে। Cache line-এর আকার তাই একটা design trade-off: বড় line spatial locality বেশি কাজে লাগায় কিন্তু miss-এ বেশি বাইট আনতে হয় (বেশি bandwidth খরচ) আর কম useful ডেটার সাথে বেশি অপ্রয়োজনীয় বাইট আসার ঝুঁকি (false sharing-এর মূল, যেটা advanced-architecture module-এ multi-core প্রসঙ্গে ফিরে আসবে)।
উদাহরণ
সংখ্যায় সম্পূর্ণ ট্রেস — একটা L1 cache, বাস্তব স্পেসিফিকেশন
ধরুন একটা বাস্তবসম্মত L1 data cache: 32 KB, 8-way set-associative, 64-বাইট line, 48-বিট virtual address (আধুনিক x86-64-এ ব্যবহৃত প্রকৃত address প্রস্থ, যদিও পুরো 64-বিট register ব্যবহার হয়)।
হিসাব:
যাচাই: 36 + 6 + 6 = 48 ✓। সঞ্চয় ক্ষমতা: 64 \text{ set} \times 8 \text{ way} \times 64 \text{ বাইট} = 32{,}768 বাইট = 32 KB ✓।
| বিট পরিসর | অংশ | মান |
|---|---|---|
বিট 47–12 | tag | 36 বিট |
বিট 11–6 | index | 6 বিট (64 সম্ভাব্য set) |
বিট 5–0 | offset | 6 বিট (64 সম্ভাব্য বাইট) |
একটা নির্দিষ্ট ঠিকানার সম্পূর্ণ ট্রেস
ধরুন CPU ঠিকানা 0x00007FFE_2A48_1064 access করছে (একটা সাধারণ ৪৮-বিট virtual address, উদাহরণস্বরূপ)।
Hexadecimal-কে binary-তে দেখলে শেষ কয়েকটা byte grouped:
ঠিকানা (hex): ... 2A48 1064
│
নিচের ১২ বিট (৩ hex digit) বের করি: 0x064 = 0000 0110 0100 (বাইনারি)
offset (নিচের ৬ বিট): 100100 = 0x24 = 36 (দশমিক)
index (তার পরের ৬ বিট): 000001 = 0x01 = 1 (দশমিক)
tag (বাকি উপরের ৩৬ বিট): ঠিকানার বাকি অংশঅর্থ: এই access set নাম্বার 1-এ যাবে। সেই set-এর 8-টা way-র tag-এর সাথে ঠিকানার tag-অংশ তুলনা হবে। যদি কোনো way-তে match+valid পাওয়া যায়, hit — সেই line-এর অফসেট 36-তম বাইট থেকে ডেটা পড়া হবে (36-তম বাইট থেকে শুরু করে, যত বাইট instruction চায় — 1, 2, 4, বা 8 বাইট)।
- address আসে0x00007FFE2A481064, 48 বিট
- বিভাজনtag(36) | index(6)=1 | offset(6)=36
- set selectindex=1 → set #1, ৬৪টার মধ্যে একটা
- parallel tag compareset #1-এর ৮টা way, ৮টা comparator একসাথে
- hit/miss সিদ্ধান্তকোনো way-তে match+valid থাকলে hit, নাহলে miss → নিচের স্তরে যাও
- data বাছাইhit হলে: offset=36 দিয়ে সেই line-এর ভেতরের বাইট বাছাই
নিজে চালিয়ে দেখুন
Conflict miss হাতেকলমে তৈরি করুন — stride দিয়ে thrash
ধারণা: দুইটা array বানাই যাদের মধ্যে দূরত্ব ইচ্ছাকৃতভাবে L1 cache আকারের একটা গুণিতক করে রাখি, তারপর তাদের পালাক্রমে access করে conflict miss আদায় করি — বনাম দূরত্ব সামান্য বদলে (যাতে তারা ভিন্ন set-এ পড়ে) একই experiment আবার চালাই।
#include \<stdio.h\>
#include \<stdlib.h\>
#include \<time.h\>
#define L1_SIZE (32 * 1024) /* আপনার মেশিনের প্রকৃত L1 আকার lscpu দিয়ে যাচাই করুন */
#define N 100000
double time_pair(char *a, char *b, long stride_a, long stride_b) {
struct timespec t0, t1;
volatile long sum = 0;
clock_gettime(CLOCK_MONOTONIC, &t0);
for (int i = 0; i \< N; i++) {
sum += a[(i * stride_a) % L1_SIZE];
sum += b[(i * stride_b) % L1_SIZE];
}
clock_gettime(CLOCK_MONOTONIC, &t1);
return (t1.tv_sec - t0.tv_sec) + (t1.tv_nsec - t0.tv_nsec) / 1e9;
}
int main(void) {
/* conflicting: b ঠিক L1_SIZE দূরে বসানো, দুই array-ই একটা বড় buffer-এর অংশ */
char *buf_conflict = malloc(L1_SIZE * 2);
char *a1 = buf_conflict;
char *b1 = buf_conflict + L1_SIZE; /* ঠিক L1 আকারের দূরত্বে — একই set-এ পড়বে */
/* non-conflicting: b সামান্য offset করে বসানো (অর্ধেক cache line, ভিন্ন set) */
char *buf_ok = malloc(L1_SIZE * 2 + 32);
char *a2 = buf_ok;
char *b2 = buf_ok + L1_SIZE + 32; /* L1_SIZE + অর্ধেক line — ভিন্ন index */
double t_conflict = time_pair(a1, b1, 64, 64);
double t_ok = time_pair(a2, b2, 64, 64);
printf("conflicting stride: %.4f সেকেন্ড\\n", t_conflict);
printf("non-conflicting stride: %.4f সেকেন্ড\\n", t_ok);
printf("অনুপাত: %.2fx\\n", t_conflict / t_ok);
free(buf_conflict);
free(buf_ok);
return 0;
}প্রত্যাশিত ফলাফলের ধরন (মেশিনভেদে বদলাবে):
conflicting stride: 0.0850 সেকেন্ড
non-conflicting stride: 0.0210 সেকেন্ড
অনুপাত: 4.05xDirect-mapped-এর মতো L1 বাস্তব CPU-তে বিরল (বেশিরভাগ 8-way বা তার বেশি), তাই পার্থক্যটা তাত্ত্বিক direct-mapped উদাহরণের মতো নাটকীয় (কার্যত ০% hit) হবে না — associativity কিছুটা রক্ষা করবে। কিন্তু যদি একাধিক array একসাথে একই set-এ ভিড় করে (way সংখ্যার চেয়ে বেশি প্রতিযোগী), তখনও পরিমাপযোগ্য পার্থক্য দেখা যাবে। perf stat -e cache-misses ./a.out দিয়ে সরাসরি miss সংখ্যা মিলিয়ে দেখুন।
দুইটা array যাদের base ঠিকানা cache আকারের গুণিতক দূরত্বে, তাদের পালাক্রমে access করলে conflict miss তৈরি হয় — এমনকি মোট working set cache-এর চেয়ে ছোট হলেও।
Set distribution visualizer — address-এর index কীভাবে ছড়ায়
এই ছোট simulation address-এর একটা তালিকা নিয়ে (কোনো real hardware লাগবে না) দেখায় সেগুলো 64-টা set-এ কীভাবে বিতরণ (distribute) হয় — একটা সুস্থ ব্যবহারে হিস্টোগ্রাম প্রায় সমান হওয়া উচিত।
def index_of(addr, num_sets=64, offset_bits=6):
return (addr >> offset_bits) % num_sets
# ভালো case: সাধারণ sequential heap allocation, বিভিন্ন আকারের বস্তু
import random
random.seed(0)
good_addrs = []
cur = 0x10000
for _ in range(2000):
good_addrs.append(cur)
cur += random.choice([16, 24, 32, 48, 64]) # বাস্তবসম্মত allocation আকার
# খারাপ case: ইচ্ছাকৃতভাবে সব address ঠিক 4096 বাইট (= cache size) ব্যবধানে
bad_addrs = [0x10000 + i * 4096 for i in range(2000)]
def histogram(addrs, num_sets=64):
counts = [0] * num_sets
for a in addrs:
counts[index_of(a, num_sets)] += 1
return counts
good_hist = histogram(good_addrs)
bad_hist = histogram(bad_addrs)
print(f"ভালো case — সবচেয়ে বেশি ব্যবহৃত set: {max(good_hist)} বার, সবচেয়ে কম: {min(good_hist)} বার")
print(f"খারাপ case — সবচেয়ে বেশি ব্যবহৃত set: {max(bad_hist)} বার, সবচেয়ে কম: {min(bad_hist)} বার")প্রত্যাশিত ফলাফলের ধরন:
ভালো case — সবচেয়ে বেশি ব্যবহৃত set: 38 বার, সবচেয়ে কম: 24 বার
খারাপ case — সবচেয়ে বেশি ব্যবহৃত set: 2000 বার, সবচেয়ে কম: 0 বারখারাপ case-এ একটা মাত্র set সবগুলো 2000-টা address-ই পেয়েছে (বাকি 63-টা set সম্পূর্ণ অব্যবহৃত থেকেছে) — এটা এই লেসনের conflict miss উদাহরণেরই একটা সাধারণীকৃত (generalized), পরিসংখ্যানগত সংস্করণ। ভালো case-এ বিতরণ প্রায় সমান, কারণ allocation আকার বৈচিত্র্যময় হওয়ায় address-এর নিচের বিটগুলো (যেগুলো index নির্ধারণ করে) কার্যত এলোমেলো আচরণ করে। এটাই দেখায় কেন বাস্তব cache design-এ শুধু associativity যথেষ্ট না — address-থেকে-index mapping-টাও (এই ক্ষেত্রে সহজ modulo) access pattern-এর সাথে “ভালোভাবে মেশে” এটা নিশ্চিত করা জরুরি।
'ভালো' access pattern (স্বাভাবিক sequential বা random malloc address) set-গুলোর মধ্যে মোটামুটি সমানভাবে ছড়িয়ে পড়ে; কিন্তু ইচ্ছাকৃতভাবে power-of-2 stride ব্যবহার করলে সবকিছু মাত্র কয়েকটা set-এ জমা হয়ে যায়।
নিজে বানান
Address Splitter — যেকোনো cache config-এর জন্য tag/index/offset হিসাব
- Cache আকার, line আকার, associativity (way সংখ্যা), আর address প্রস্থ ইনপুট নিন
- Offset, index, tag বিট সংখ্যা গণনা করুন এবং assert দিয়ে যাচাই করুন যোগফল address প্রস্থের সমান কিনা
- একটা নির্দিষ্ট hex address দিয়ে তার tag/index/offset মান বের করুন
- একই cache আকারে associativity 1 থেকে পূর্ণ (fully-associative) পর্যন্ত বাড়িয়ে index/tag বিট কীভাবে বদলায় তার একটা টেবিল ছাপান
import math
def split_address(cache_size, line_size, ways, addr_width):
total_lines = cache_size // line_size
assert cache_size % line_size == 0, "cache size line size দিয়ে ভাগযোগ্য হতে হবে"
assert total_lines % ways == 0, "line সংখ্যা way সংখ্যা দিয়ে ভাগযোগ্য হতে হবে"
num_sets = total_lines // ways
offset_bits = int(math.log2(line_size))
index_bits = int(math.log2(num_sets)) if num_sets > 1 else 0
tag_bits = addr_width - offset_bits - index_bits
assert tag_bits + index_bits + offset_bits == addr_width, "বিট যোগফল মিলছে না!"
return {
"total_lines": total_lines,
"num_sets": num_sets,
"offset_bits": offset_bits,
"index_bits": index_bits,
"tag_bits": tag_bits,
}
def decode_address(addr_hex, cache_size, line_size, ways, addr_width):
info = split_address(cache_size, line_size, ways, addr_width)
addr = int(addr_hex, 16)
offset = addr & ((1 \<\< info["offset_bits"]) - 1)
index = (addr \>\> info["offset_bits"]) & ((1 \<\< info["index_bits"]) - 1)
tag = addr \>\> (info["offset_bits"] + info["index_bits"])
return tag, index, offset
# ── উদাহরণ ১: 32KB, 8-way, 64B line, 48-বিট address ──
info = split_address(32 * 1024, 64, 8, 48)
print(f"32KB/8-way/64B: {info}")
tag, index, offset = decode_address("7FFE2A481064", 32 * 1024, 64, 8, 48)
print(f"address 0x7FFE2A481064 → tag={hex(tag)} index={index} offset={offset}")
# ── উদাহরণ ২: একই cache আকারে associativity spectrum ──
print(f"\\n{'way সংখ্যা':\<12}{'set সংখ্যা':\<12}{'index বিট':\<12}{'tag বিট':\<10}")
for ways in [1, 2, 4, 8, 16, 32, 64]:
info = split_address(4096, 64, ways, 32)
print(f"{ways:\<12}{info['num_sets']:\<12}{info['index_bits']:\<12}{info['tag_bits']:\<10}")প্রত্যাশিত আউটপুট (উদাহরণ ২-এর টেবিল, 4 KB / 64B line / 32-বিট address):
way সংখ্যা set সংখ্যা index বিট tag বিট
1 64 6 20
2 32 5 21
4 16 4 22
8 8 3 23
16 4 2 24
32 2 1 25
64 1 0 26লক্ষ্য করুন — way × set = ৬৪ (মোট line সংখ্যা) প্রতিটা সারিতে অপরিবর্তিত, আর প্রতিবার way দ্বিগুণ হলে index বিট ঠিক ১ কমছে, tag বিট ১ বাড়ছে — এটাই এই লেসনের spectrum দাবির সংখ্যাগত প্রমাণ, নিজের কোডে যাচাই করা।
বাস্তব সিস্টেমে
বাস্তব সিস্টেমে cache organization
১. বাস্তব CPU-র associativity সংখ্যা
আধুনিক CPU-তে L1 সাধারণত ৪-৮-way, L2 ৮-১৬-way, L3 প্রায়ই ১৬-way বা তার বেশি। Associativity সাধারণত hierarchy-তে নিচের দিকে বাড়ে — কারণ নিচের স্তর বড়, বেশি set প্রতিযোগী সহ্য করতে হয়, আর latency budget-ও একটু শিথিল (L3-এর ~40 cycle বাজেটে বেশি comparator চালানো সাশ্রয়ী)।
২. TLB — fully-associative-এর একটা বাস্তব উদাহরণ
operating-systems module-এ (Level 4) TLB (Translation Lookaside Buffer) নিয়ে বিস্তারিত পড়বেন — এটা virtual-to-physical address translation cache করে। TLB সাধারণত ছোট (কয়েক ডজন থেকে কয়েকশ entry) এবং প্রায়ই fully-associative বা উচ্চ-associativity — কারণ ছোট entry সংখ্যায় fully-associative-এর comparator খরচ সহনীয়, আর TLB miss-এর দাম (একটা page table walk) এত বেশি যে conflict miss এড়ানো অগ্রাধিকার পায়।
৩. Victim cache — একটা সৃজনশীল হাইব্রিড
কিছু CPU design একটা ছোট, fully-associative “victim cache” ব্যবহার করে যা সদ্য L1 থেকে উৎখাত হওয়া line-গুলো সাময়িকভাবে ধরে রাখে। এটা direct-mapped বা low-associativity L1-এর conflict miss সমস্যা কমায় fully-associative-এর পূর্ণ খরচ ছাড়াই — শুধু সাম্প্রতিক উৎখাত হওয়া কয়েকটা line-এর জন্য।
৪. Cache line size-এর বাস্তব মান
প্রায় সব আধুনিক x86-64 এবং ARM64 CPU ৬৪-বাইট cache line ব্যবহার করে (কিছু পুরনো বা বিশেষায়িত ডিজাইনে ৩২ বা ১২৮ বাইট)। এই সংখ্যাটা sysconf বা /sys/devices/system/cpu/cpu0/cache/index0/coherency_line_size (Linux) দিয়ে সরাসরি দেখা যায় — এই লেসনের উদাহরণে ব্যবহৃত ৬৪ বাইট তাই একটা বাস্তব, প্রায়-সার্বজনীন মান, নির্বিচার সংখ্যা না।
৫. Direct-mapped-এর বাস্তব ব্যবহার — খুব ছোট, বিশেষায়িত cache
যদিও L1/L2/L3-এ direct-mapped বিরল, ছোট, বিশেষায়িত cache-এ (যেমন কিছু embedded system-এর instruction cache, বা branch prediction-এর branch target buffer) direct-mapped এখনও ব্যবহৃত হয় — কারণ hardware সরলতা আর গতি (index থেকে সরাসরি line, কোনো tag-comparison delay ছাড়াই প্রাথমিক prediction) কখনো কখনো conflict miss-এর ঝুঁকির চেয়ে বেশি গুরুত্বপূর্ণ।
৬. Cache size বাছাইয়ে associativity-র প্রভাব — hit rate গবেষণা
Hennessy-Patterson-এর classic গবেষণায় দেখানো হয়েছে associativity 1 থেকে 2-এ যাওয়া (direct-mapped থেকে 2-way) hit rate-এ প্রায়ই cache আকার দ্বিগুণ করার সমান উন্নতি আনে (একটা rule-of-thumb, workload-নির্ভর) — এই কারণেই বাস্তব design-এ প্রায় কখনোই বড় cache-এ direct-mapped দেখা যায় না, associativity-র হার্ডওয়্যার খরচ এই সুবিধার তুলনায় সাশ্রয়ী প্রমাণিত হয়েছে।
৭. GPU cache — ভিন্ন trade-off
GPU-র L1/L2 cache সাধারণত CPU-র তুলনায় ছোট associativity রাখে, কারণ GPU workload (massively parallel, প্রায়ই streaming-ধরনের access) conflict miss-এর প্রতি কম সংবেদনশীল আর bandwidth অগ্রাধিকার পায় latency-র চেয়ে — advanced-architecture module-এ (Level 11) GPU architecture নিয়ে বিস্তারিত আসবে।
৮. Cache simulator — এই লেসনের ধারণাগুলো নিজে যাচাই করার টুল
dinero, cachegrind (Valgrind-এর অংশ), বা এই curriculum-এর নিজস্ব “Cache Simulator” প্রজেক্ট (curriculum-এ তালিকাভুক্ত) একটা memory trace নিয়ে বিভিন্ন associativity-তে hit/miss rate সিমুলেট করে — এই লেসনের tag/index/offset arithmetic হুবহু সেই simulator-এর ভেতরের কোর যুক্তি।
যে ভুলগুলো সবাই করে
“Associativity বাড়ানো সবসময় বিনামূল্যে একটা win — তাই সব cache fully-associative হওয়া উচিত”
Associativity বাড়ানো মানে প্রতি lookup-এ বেশি সমান্তরাল comparator চালানো — hardware area, power খরচ, আর প্রায়ই সামান্য বাড়তি latency (বেশি comparator-এর ফলাফল একত্র করতে বেশি logic লাগে)। এই কারণেই L1 (যেখানে latency সবচেয়ে সংবেদনশীল, ~4 cycle বাজেট) associativity মাঝারি রাখে (4-8-way), যেখানে fully-associative শুধু ছোট, বিশেষায়িত cache-এ (TLB-এর মতো) ব্যবহারিক। এটা একটা trade-off, না কোনো একমুখী “যত বেশি তত ভালো” নিয়ম।
“Index বিট address-এর কোনো নির্দিষ্ট, বিশেষ বিট — সেগুলো আলাদাভাবে সংরক্ষিত বা চিহ্নিত থাকে”
Index (এবং offset, tag) কোনো বিশেষ, আলাদাভাবে চিহ্নিত বিট না — এরা শুধু address-এর সাধারণ বিট, যাদের hardware কীভাবে ব্যাখ্যা করে তার ভিত্তিতে বিভক্ত করা হয়। একই address, ভিন্ন cache configuration-এ (ভিন্ন আকার/associativity) সম্পূর্ণ ভিন্নভাবে বিভক্ত হবে — এই লেসনের “Address Splitter” build-এ ঠিক এটাই দেখা গেছে, একই address-এর index/tag বিট সংখ্যা associativity বদলানোর সাথে সাথে বদলে যাচ্ছে।
“Cache miss মানেই ভুল বা অদক্ষ প্রোগ্রাম”
প্রতিটা প্রোগ্রামের প্রথম access (cold start), যেকোনো নতুন ডেটা structure-এর প্রথম access — এগুলো অনিবার্যভাবে miss হবে, প্রোগ্রামিং কতই ভালো হোক না কেন (এই ধরনের miss-কে বলা হয় compulsory miss)। এই লেসনের conflict miss (associativity-জনিত) আরেকটা ভিন্ন প্রকার, যেটা এড়ানো সম্ভব design/access-pattern পরিবর্তন দিয়ে। বাস্তবে miss-এর তিনটা শ্রেণীবিভাগ প্রচলিত — compulsory, capacity (working set cache-এর চেয়ে বড়), আর conflict (এই লেসনে দেখা) — একে বলা হয় “3C” model। সব miss সমান “দোষ” না — কিছু অনিবার্য, কিছু design-নির্ভর।
বুঝেছেন কি না দেখুন
1একটা 16 KB direct-mapped cache, 32-বাইট line। Offset, index, আর tag বিট সংখ্যা কত (ধরুন 32-বিট address)?
স্মরণ
16 KB direct-mapped cache, 32-বাইট line। Offset, index, আর tag বিট সংখ্যা কত (ধরুন 32-বিট address)?Offset: log2(32) = 5 বিট। মোট line = 16384/32 = 512, তাই index: log2(512) = 9 বিট। Tag: 32 - 5 - 9 = 18 বিট। যাচাই: 18+9+5=32 ✓।
2একই 16 KB cache, 32-বাইট line, কিন্তু এখন 4-way set-associative। নতুন offset, index, tag বিট সংখ্যা কত? আগেরটার সাথে তুলনা করে কী প্যাটার্ন দেখছেন?
প্রয়োগ
16 KB cache, 32-বাইট line, কিন্তু এখন 4-way set-associative। নতুন offset, index, tag বিট সংখ্যা কত? আগেরটার সাথে তুলনা করে কী প্যাটার্ন দেখছেন?মোট line অপরিবর্তিত: 512। Set সংখ্যা = 512/4 = 128, index = log2(128) = 7 বিট (আগে ছিল 9)। Offset অপরিবর্তিত 5 বিট। Tag = 32-7-5=20 বিট (আগে ছিল 18)। প্যাটার্ন: associativity 4× বাড়ায় (1-way থেকে 4-way) index ঠিক 2 বিট কমেছে (log2(4)=2), আর tag ঠিক 2 বিট বেড়েছে — এটাই এই লেসনের spectrum নীতির সরাসরি সংখ্যাগত প্রকাশ।
3দুইটা ঠিকানা যাদের মধ্যে দূরত্ব ঠিক cache-এর মোট আকারের সমান (যেমন 4 KB cache-এ 4096 বাইট দূরত্ব), direct-mapped cache-এ কেন সবসময় একই line-এ সংঘর্ষ করবে?
যুক্তি
4 KB cache-এ 4096 বাইট দূরত্ব), direct-mapped cache-এ কেন সবসময় একই line-এ সংঘর্ষ করবে?কারণ index+offset বিট একসাথে ঠিক log2(\text{cache size}) বিট নির্ধারণ করে (এই উদাহরণে log2(4096)=12 বিট)। যদি দুইটা ঠিকানা ঠিক 4096 বাইট (= 2^12) দূরত্বে থাকে, তাদের বিয়োগফলের নিচের 12 বিট শূন্য — অর্থাৎ index+offset অংশ হুবহু অভিন্ন, শুধু tag (উপরের বিট) ভিন্ন হয়। যেহেতু direct-mapped-এ index-ই একমাত্র নির্ধারক কোন line ব্যবহার হবে, index অভিন্ন মানে line-ও অভিন্ন — অনিবার্য conflict।
4Fully-associative cache-এ কোনো “index” নেই, তবু hardware কীভাবে জানে ডেটা cache-এ আছে কি না? Direct-mapped-এর তুলনায় এই পদ্ধতির hardware খরচ কেমন?
যুক্তি
Fully-associative-এ প্রতিটা line-এর সংরক্ষিত tag একসাথে, সমান্তরালে address-এর tag-এর সাথে তুলনা করা হয় — কোনো index দিয়ে আগেই একটামাত্র line বেছে নেওয়া হয় না। এর মানে N-line cache-এ N-টা পূর্ণ-প্রস্থ comparator লাগে, প্রতিটা একসাথে সক্রিয়। Direct-mapped-এ মাত্র ১-টা comparator লাগে (index দিয়ে আগেই একটা line নির্ধারিত)। তাই fully-associative hardware খরচে (comparator সংখ্যা, area, power) N-এর সমানুপাতিক, direct-mapped-এ constant — এই পার্থক্যই বড় cache-এ fully-associative অব্যবহারিক করে তোলে।
5একটা 8 MB L3 cache, 64-বাইট line, 16-way set-associative। Set সংখ্যা আর (ধরে 48-বিট address) tag বিট সংখ্যা বের করুন।
প্রয়োগ
8 MB L3 cache, 64-বাইট line, 16-way set-associative। Set সংখ্যা আর (ধরে 48-বিট address) tag বিট সংখ্যা বের করুন।মোট line = 8{,}388{,}608 / 64 = 131{,}072। Set সংখ্যা = 131{,}072 / 16 = 8{,}192। Index বিট = log2(8192) = 13। Offset বিট = log2(64) = 6। Tag বিট = 48 - 13 - 6 = 29। যাচাই: 29+13+6=48 ✓, আর সঞ্চয় ক্ষমতা 8192 × 16 × 64 = 8{,}388{,}608 বাইট = 8 MB ✓।
6একটা চিপ ডিজাইনার প্রস্তাব দিলেন একটা নতুন L1 cache, একই আকার আর line size বজায় রেখে, কিন্তু direct-mapped-এর বদলে fully-associative করার — যুক্তি: “conflict miss পুরোপুরি বাদ যাবে, hit rate বাড়বে।” এই প্রস্তাবের সম্ভাব্য সমস্যা কী, আর একটা বিকল্প কী প্রস্তাব করবেন?
ডিজাইন
সমস্যা: L1-এর জন্য latency budget সবচেয়ে কড়া (~4 cycle) — fully-associative-এর জন্য প্রয়োজনীয় শত শত সমান্তরাল comparator L1-এর ছোট, দ্রুত আকারের সাথে বেমানান; এত বেশি comparator চালাতে গিয়ে হয় latency বেড়ে যাবে (আর L1 আর “L1-গতির” থাকবে না), অথবা area/power বাজেট বহুগুণ বেড়ে যাবে। বিকল্প: hit rate বাড়ানোর জন্য conflict miss কমাতে associativity মাঝারি মাত্রায় বাড়ানো (যেমন direct-mapped থেকে ৪- বা ৮-way set-associative) — এতে conflict miss উল্লেখযোগ্যভাবে কমবে, কিন্তু per-set comparator সংখ্যা মাত্র ৪ বা ৮ (পুরো cache-এর সব line না), যা L1-এর latency বাজেটের মধ্যেই সহনীয়। এটাই বাস্তবে প্রায় সব CPU-র L1 design-এর পছন্দ।
এরপর কী
আমরা এখন জানি একটা cache কীভাবে একটা address-কে দ্রুত hit/miss-এ পরিণত করে, আর কেন associativity একটা spectrum, কোনো তিনটা আলাদা প্রযুক্তি না। কিন্তু দুইটা প্রশ্ন এখনও বাকি: set-associative cache-এ যখন একটা set পূর্ণ থাকে আর নতুন line আনতে হয়, কোন line উৎখাত হবে সেই সিদ্ধান্ত কে নেয় আর কীভাবে? আর যখন CPU cache-এ লেখে (write করে), সেই পরিবর্তন কখন, কীভাবে নিচের memory স্তরে পৌঁছায়?
পরের লেসনে (Cache Policies) আমরা replacement policy (LRU, FIFO, random) আর write policy (write-through, write-back, write-allocate) নিয়ে বিস্তারিত আলোচনা করব, আর সবশেষে একটা একক সূত্রে (AMAT) দেখব কীভাবে hit rate-এর একটা ছোট পরিবর্তনও পুরো সিস্টেমের গড় গতিতে নাটকীয় প্রভাব ফেলতে পারে।
আরও পড়ুন
- Computer Organization and Design (RISC-V Edition), Chapter 5 — David A. Patterson, John L. Hennessy · Cache mapping, associativity, আর address split-এর প্রামাণ্য আলোচনা
- Computer Architecture: A Quantitative Approach, Chapter 2 (Appendix B) — John L. Hennessy, David A. Patterson · Cache design স্পেসের বিস্তারিত quantitative বিশ্লেষণ
- What Every Programmer Should Know About Memory, Section 3 — Ulrich Drepper · Associativity আর real cache geometry-র ব্যবহারিক আলোচনা