সরাসরি পর্বে যান
পর্ব ৪৭ · ৬২-এর মধ্যে · মডিউল ৮

Cache-এর পারফরম্যান্স — write policy, AMAT আর multi-level cache

Cache Performance — Write-Through and Write-Back, Memory Stall Cycles and CPI, AMAT, Multi-Level Caches and Cache Optimizations

Taught in Bangla, this lesson covers cache performance: write-through with write buffers, write-back with dirty bits, write-allocate versus no-write-allocate, memory stall cycles and CPI, classic worked numericals, AMAT, multi-level caches with local and global miss rates, inclusive, exclusive and NINE policies, a catalogue of cache optimisations, prefetching, non-blocking caches, memory-level parallelism, and measuring misses with performance counters.

এই পর্বে যা শিখবেন

  • Write hit-এ write-through, write buffer আর write-back (dirty bit) কীভাবে কাজ করে, আর কোনটি কত মেমরি-লেখা বাঁচায়
  • Write miss-এ write-allocate বনাম no-write-allocate, প্রচলিত জুটি, আর dirty block বের করার খরচ miss penalty-তে
  • Memory stall cycle দিয়ে CPU time আর CPI-এর সূত্র, ক্লাসিক numerical-গুলো ধাপে ধাপে, আর AMAT
  • Multi-level cache: local বনাম global miss rate, দুই ও তিন স্তরের AMAT, প্রতিটি স্তরের নকশার লক্ষ্য, inclusive-exclusive-NINE
  • Cache optimization-এর তালিকা, prefetching, non-blocking cache, memory-level parallelism আর performance counter দিয়ে মাপা

১ · Store এলে কী হবে — write-through, write buffer আর write-back

পর্ব ৪৫-এ direct-mapped cache আর পর্ব ৪৬-এ set-associative cache ও replacement দেখেছেন। সেখানে মূলত পড়ার (load) গল্প ছিল: address-কে | tag | index | block offset | ভাগে ভেঙে খোঁজা, hit হলে ডেটা, miss হলে নিচের স্তর থেকে পুরো block আনা। এই পর্বে দুটি নতুন প্রশ্ন। এক, লেখার (store) সময় কী হবে? দুই, এই সব hit আর miss মিলে প্রোগ্রাম আসলে কতটা ধীর হয় — সংখ্যায়?

পড়ার সময় cache আর মেমরিতে একই মান থাকে, তাই সমস্যা নেই। কিন্তু cache-এ থাকা কোনো block-এ store লিখলে cache-এর কপি নতুন, আর মেমরির কপি পুরোনো হয়ে যায়। দুই কপি কখন আর কীভাবে আবার মেলানো হবে — এই সিদ্ধান্তই write policy.

একটি দোকানের হিসাব ভাবুন। ক্যাশ কাউন্টারে একটি ছোট খাতা (cache), আর পেছনের অফিসে মূল খতিয়ান বই (মেমরি)। প্রতিটি বিক্রির পর হিসাব লিখতে হয়। এক নিয়ম: প্রতিটি বিক্রি সাথে সাথে ছোট খাতায় আর মূল খতিয়ানেও লেখা — সবসময় মিল থাকে, কিন্তু প্রতিবার পেছনের অফিসে দৌড়াতে হয়। দ্বিতীয় নিয়ম: সারাদিন শুধু ছোট খাতায় লেখা, পাতায় একটা দাগ দিয়ে রাখা “এই পাতা বদলেছে”, আর পাতাটি সরানোর সময় একবারে মূল খতিয়ানে তোলা। প্রথমটি write-through, দ্বিতীয়টি write-back, আর সেই দাগটিই dirty bit.

Write-through — প্রতিটি store নিচেও যায়

Write-through নীতিতে store hit হলে cache-এর word বদলায়, আর একই সাথে লেখাটি নিচের স্তরেও (L2 বা মেমরি) পাঠানো হয়। সুবিধা: cache আর নিচের স্তর সবসময় মেলে, তাই কোনো block বের করে দিলে কিছু লিখতে হয় না, আর নকশা সরল। অসুবিধা: প্রতিটি store নিচে যায়। প্রোগ্রামে প্রায় ১০% instruction store হলে আর মেমরিতে একটি লেখা ১০০ cycle নিলে, প্রতিটি store-এ অপেক্ষা করলে CPI-তে যোগ হতো ০.১ × ১০০ = ১০ cycle — অসম্ভব রকম ধীর।

তাই write-through প্রায় সবসময় একটি write buffer-এর সাথে আসে: কয়েকটি entry-র ছোট একটি সারি, যেখানে store-এর ঠিকানা আর ডেটা রেখে দিয়ে CPU সাথে সাথে এগিয়ে যায়। Buffer নিজে ধীরে ধীরে নিচের স্তরে লিখতে থাকে। CPU থামে কেবল তখন, যখন buffer পুরো ভরা। এখানে একটি সহজ হিসাব খুব কাজের:

Write buffer কখন ভরে যায়

ধরুন CPI ১, প্রতি ১০টি instruction-এ ১টি store — তাহলে প্রতি cycle-এ গড়ে ০.১টি store আসে। নিচের স্তর প্রতি ১২ cycle-এ একটি লেখা নিতে পারে, অর্থাৎ প্রতি cycle-এ ১/১২ ≈ ০.০৮৩টি। আসার হার (০.১) চলে যাওয়ার হারের (০.০৮৩) চেয়ে বেশি, তাই buffer যত বড়ই হোক, একসময় ভরবেই, আর তারপর CPU নিচের স্তরের গতিতে আটকে যাবে। Buffer শুধু ক্ষণিকের চাপ (burst) সামলায়; গড় হার নিচের স্তরের ক্ষমতার নিচে থাকতে হয়। এখানে সীমা হলো store-এর অনুপাত প্রায় ৮.৩%-এর কম।

অনেক write buffer আরেকটি কৌশল নেয় — write merging: একই block-এর পরপর কয়েকটি store একটি entry-তে জুড়ে দেয়, যাতে নিচে একটি বড় লেখা যায়, অনেক ছোট লেখার বদলে।

Write-back আর dirty bit — শুধু cache-এ লেখা, পরে একবারে

Write-back নীতিতে store hit হলে শুধু cache-এর word বদলায়; নিচের স্তর ছোঁয়া হয় না। প্রতিটি line-এ valid bit-এর পাশে একটি বাড়তি bit থাকে — dirty bit. Store এলে dirty = 1. পরে কোনো miss-এর কারণে এই line সরাতে (evict) হলে দেখা হয়: dirty = 0 হলে line-টি নিচের স্তরের সাথে হুবহু মেলে, শুধু ফেলে দিলেই চলে; dirty = 1 হলে পুরো block নিচে লিখে (write back) তারপর নতুন block আনা হয়।

লাভটা আসে বারবার লেখা থেকে। একটি লুপে একটি যোগফলের চলক ৬৪ বার লেখা হলে write-through নিচে ৬৪টি লেখা পাঠায়, write-back পাঠায় শেষে একটি block. অংশ ২-এর simulator ঠিক এই তুলনাটি গুনে দেখাবে। দাম: প্রতি line-এ একটি বাড়তি bit, miss-এর সময় বাড়তি একটি block-লেখার সম্ভাবনা (অংশ ২-এর শেষে হিসাব), আর এক মুহূর্তে মেমরির কপি পুরোনো থাকা — যা একাধিক core বা DMA থাকলে coherence-এর প্রশ্ন তোলে (পর্ব ৫২ আর ৫৩).

বিষয়Write-throughWrite-back
Store hit-এcache আর নিচের স্তর — দুটোতেই লেখাশুধু cache; dirty = 1
Line সরানোর সময়কিছু লিখতে হয় নাdirty হলে পুরো block নিচে লেখা
নিচের স্তরে লেখার পরিমাণপ্রতিটি store-এ একটি wordপ্রতি dirty block-এ একবার, যতবারই লেখা হোক
CPU থামে কখনwrite buffer ভরে গেলেdirty line সরানোর সময় (buffer না থাকলে)
নিচের স্তরের কপিসবসময় হালনাগাদকিছু সময় পুরোনো থাকতে পারে
Bit-এর ভুল সামলানোparity-তে ভুল ধরা পড়লে line ফেলে দিয়ে নিচ থেকে আবার আনা যায়dirty ডেটার একমাত্র কপি cache-এ, তাই ভুল সংশোধনের (ECC) কোড দরকার

২ · Write miss — block আনবেন, নাকি না? আর dirty eviction-এর দাম

এবার store এমন ঠিকানায়, যার block cache-এ নেই — write miss. দুটি পথ:

Write-allocate

Write-allocate (fetch-on-write): read miss-এর মতোই পুরো block নিচ থেকে এনে cache-এ বসানো, তারপর সেই block-এ store লেখা। যুক্তি: এক জায়গায় লিখলে পাশের জায়গাতেও শিগগিরই লেখা বা পড়া হবে (spatial locality).

No-write-allocate

No-write-allocate (write-around): cache-কে পাশ কাটিয়ে store সরাসরি নিচের স্তরে পাঠানো; cache-এ কিছুই বসে না। যুক্তি: অনেক ডেটা একবার লিখে আর ছোঁয়া হয় না (যেমন বড় ফাইলে আউটপুট লেখা) — তাদের জন্য cache-এর জায়গা নষ্ট করা কেন?

যেকোনো জুটি সম্ভব, কিন্তু দুটি জুটি স্বাভাবিকভাবে মেলে, আর পাঠ্যবইও এই দুটিই ধরে (এই কোর্সের Module 8-এর সব পর্বেও তাই):

  • Write-back + write-allocate: block একবার এনে রাখলে পরের সব store cache-এই জমে, dirty bit শেষে একবার লেখায়। লেখা বারবার একই block-এ হলে এটাই সবচেয়ে সাশ্রয়ী। আজকের বেশিরভাগ L1 data cache, L2 আর L3 এই ধাঁচের বলে সাধারণভাবে বর্ণিত।
  • Write-through + no-write-allocate: store তো এমনিই নিচে যাবে; block এনে লাভ কেবল যদি পরে পড়া হয়। তাই block না এনে শুধু নিচে লেখা।

Write-back + write-allocate-এ একটি write miss-এর পুরো ধাপগুলো হলো: tag মেলানো, মিলল না; সরানোর জন্য বাছা line (victim) dirty হলে তাকে নিচে লেখা; নতুন block নিচ থেকে আনা, tag আর valid বসানো; তারপর store-এর word মিশিয়ে dirty = 1. নিচের stepper-এ একটি ছোট direct-mapped cache-এ এটি ধাপে ধাপে দেখুন।

Cache-এর গঠন৪টি line × ১৬ byte, direct-mapped
32-bit address:  │ tag: 26 bit │ index: 2 bit │ offset: 4 bit │

0x200 = ...0010 0000 0000  →  tag 0x8, index 0, offset 0
0x204                      →  tag 0x8, index 0, offset 4
0x300 = ...0011 0000 0000  →  tag 0xC, index 0, offset 0

line  V  D  tag
 0    0  0  —
 1    0  0  —
 2    0  0  —
 3    0  0  —        মেমরি-লেখা: 0   block-পড়া: 0
Access ১: load 0x200read miss
index 0 → V = 0  →  MISS
victim dirty? না (খালি line)
মেমরি থেকে block 0x200–0x20F আনা হলো

line  V  D  tag
 0    1  0  0x8     ← block 0x200
 1    0  0  —
 2    0  0  —
 3    0  0  —        মেমরি-লেখা: 0   block-পড়া: 1
Access ২–৩: store 0x204, store 0x208write hit × ২
store 0x204: index 0, tag 0x8 = 0x8  →  HIT
   word বদলাল শুধু cache-এ, D = 1
store 0x208: index 0, tag 0x8        →  HIT
   আবার শুধু cache-এ, D আগেই 1

line  V  D  tag
 0    1  1  0x8     ← dirty: মেমরির কপি পুরোনো
 1    0  0  —
 2    0  0  —
 3    0  0  —        মেমরি-লেখা: 0   block-পড়া: 1
Access ৪: store 0x300write miss + dirty eviction
index 0, tag 0xC ≠ 0x8  →  MISS
victim (block 0x200) dirty = 1
   ① block 0x200–0x20F মেমরিতে লেখা   (write back)
   ② block 0x300–0x30F মেমরি থেকে আনা (write-allocate)
   ③ store-এর word মেশানো, D = 1

line  V  D  tag
 0    1  1  0xC     ← block 0x300
 1    0  0  —
 2    0  0  —
 3    0  0  —        মেমরি-লেখা: 1 block   block-পড়া: 2
Access ৫: load 0x204read miss + আবার dirty eviction
index 0, tag 0x8 ≠ 0xC  →  MISS
victim (block 0x300) dirty = 1
   ① block 0x300–0x30F মেমরিতে লেখা
   ② block 0x200–0x20F আনা — access ২–৩-এর নতুন মানসহ,
      কারণ access ৪-এ সেগুলো মেমরিতে লেখা হয়ে গেছে

line  V  D  tag
 0    1  0  0x8     ← clean
 1    0  0  —
 2    0  0  —
 3    0  0  —        মেমরি-লেখা: 2 block   block-পড়া: 3
হিসাব মেলানোএকই ৫টি access, দুই নীতি
                     WB + WA        WT + NWA
hit                  2              3
block-পড়া            3              1
মেমরি-লেখা           2 block        3 word
                     (32 byte)      (12 byte)

WT + NWA-তে: store 0x204, 0x208 hit (cache আর মেমরি
দুটোতেই লেখা), store 0x300 miss → শুধু মেমরিতে, cache-এ
কিছু বসে না; তাই শেষের load 0x204 HIT.
  1. গঠন. ৪টি line, প্রতিটি ১৬ byte, তাই offset ৪ bit, index ২ bit, বাকি ২৬ bit tag. 0x200 আর 0x300 দুটিরই index 0 — একই line-এর জন্য লড়বে (conflict, পর্ব ৪৬-এর ভাষায়)। প্রতিটি line-এ valid (V) আর dirty (D) bit; শুরুতে সব খালি।
  2. Load 0x200 — read miss. Line 0 খালি, তাই miss. সরানোর মতো কিছু নেই, শুধু মেমরি থেকে block 0x200–0x20F আনা হলো: V = 1, D = 0, tag 0x8. মোট block-পড়া ১।
  3. দুটি write hit. Store 0x204 আর store 0x208 — দুটোই একই block-এ, tag মেলে, hit. Write-back-এ শুধু cache-এর word বদলায় আর D = 1 হয়। মেমরিতে একটিও লেখা যায়নি; মেমরির কপি এখন পুরোনো, আর এই কথাটাই D bit মনে রাখছে।
  4. Store 0x300 — write miss আর dirty eviction. Index 0-এ tag 0x8, চাই 0xC — miss. Victim dirty, তাই প্রথমে পুরো block 0x200 মেমরিতে লেখা (১টি block-লেখা), তারপর write-allocate নিয়মে block 0x300 আনা (block-পড়া ২), শেষে store-এর word মিশিয়ে D = 1. একটি miss-এ দুটি মেমরি-কাজ — এটাই dirty eviction-এর দাম।
  5. Load 0x204 — আবার miss. এবার victim block 0x300, সেটিও dirty — আবার একটি block-লেখা (মোট ২), তারপর block 0x200 আনা (মোট পড়া ৩)। আনা block-এ access ২–৩-এর মান আছে, কারণ access ৪-এ সেগুলো মেমরিতে পৌঁছে গিয়েছিল। এই line এখন clean (D = 0).
  6. তুলনা. একই ৫টি access-এ WB + WA: ২টি hit, ৩টি block-পড়া, ২টি block-লেখা (৩২ byte). WT + NWA: ৩টি hit, ১টি block-পড়া, ৩টি word-লেখা (১২ byte). এই ছোট ট্রেসে conflict-এর টানাটানির জন্য write-back হেরে গেল! Write-back জেতে তখন, যখন একই block-এ বারবার লেখা হয় — পরের simulator ঠিক সেটি দেখায়।

Simulator — দুই নীতি, দুই ধরনের লুপ

এবার একটি ছোট 2-way set-associative cache (২টি set × ২টি way, ১৬ byte block, LRU) নিয়ে দুটি লুপের মেমরি-ট্রাফিক গুনে দেখি। প্রথম লুপ sum += a[i]: array পড়া হয়, আর sum চলকটি মেমরিতে (ঠিকানা 0x130) প্রতিবার লেখা হয় — optimization ছাড়া compile করা কোডে এমনটাই হয়। দ্বিতীয় লুপ b[i] = a[i]: একটি array থেকে আরেকটিতে কপি। দুটিতেই ৬৪টি iteration. শেষে cache-এ যে dirty line থেকে যায়, সেগুলোও একসময় লিখতেই হবে, তাই তাদের “flush” হিসেবে লেখার byte-এ যোগ করা হয়েছে।

// Write policies on two traces: 2-way set-associative, 2 sets, 16-byte blocks, LRU, 4-byte words
const SETS = 2, WAYS = 2, BLOCK = 16, WORD = 4, N = 64;
const sumLoop = [], copyLoop = [];               // sum += a[i]  and  b[i] = a[i]
for (let i = 0; i < N; i++) {
  sumLoop.push(["R", 0x200 + 4 * i], ["W", 0x130]);
  copyLoop.push(["R", 0x200 + 4 * i], ["W", 0x800 + 4 * i]);
}
function run(trace, wb) {                       // wb=true: WB+WA, wb=false: WT+NWA
  const sets = Array.from({ length: SETS }, () => []);   // each set: [{blk, dirty}], MRU last
  const c = { hit: 0, rd: 0, ww: 0, wbk: 0 };
  for (const [op, a] of trace) {
    const blk = Math.floor(a / BLOCK), s = sets[blk % SETS];
    const k = s.findIndex(l => l.blk === blk);
    let line = k >= 0 ? s.splice(k, 1)[0] : null; // remove, re-insert as MRU
    if (line) c.hit++;
    if (op === "W" && !wb) { c.ww++; if (line) s.push(line); continue; } // WT: store goes to memory
    if (!line) {                                   // miss: allocate (read miss, or WB write miss)
      if (s.length === WAYS && s.shift().dirty) c.wbk++;  // evict LRU, write back if dirty
      line = { blk, dirty: false }; c.rd++;
    }
    if (op === "W") line.dirty = true;             // WB: write stays in cache
    s.push(line);
  }
  const left = sets.flat().filter(l => l.dirty).length;    // must be written back later
  return { ...c, left, readB: c.rd * BLOCK, writeB: c.ww * WORD + (c.wbk + left) * BLOCK };
}
console.log(`N = ${N} iterations; bytes-written includes the final flush of dirty lines`);
console.log("trace  policy   hits  block-reads  word-writes  writebacks+flush  bytes-read  bytes-written");
for (const [name, t] of [["sum", sumLoop], ["copy", copyLoop]]) for (const wb of [false, true]) {
  const s = run(t, wb);
  console.log(`${name.padEnd(6)} ${wb ? "WB+WA " : "WT+NWA"} ${String(s.hit).padStart(5)} ${String(s.rd).padStart(12)}` +
    ` ${String(s.ww).padStart(12)} ${(s.wbk + "+" + s.left).padStart(17)} ${String(s.readB).padStart(11)} ${String(s.writeB).padStart(14)}`);
}

আসল আউটপুট:

N = 64 iterations; bytes-written includes the final flush of dirty lines
trace  policy   hits  block-reads  word-writes  writebacks+flush  bytes-read  bytes-written
sum    WT+NWA    48           16           64               0+0         256            256
sum    WB+WA    111           17            0               0+1         272             16
copy   WT+NWA    48           16           64               0+0         256            256
copy   WB+WA     96           32            0              14+2         512            256

যোগফলের লুপ: write-through ৬৪টি store-এর প্রতিটি নিচে পাঠায় — ৬৪ × ৪ = ২৫৬ byte. Write-back-এ sum-এর block একবার আনা হয় (তাই block-পড়া ১৬-এর বদলে ১৭), তারপর ৬৩টি store hit হয়ে cache-এই জমে, আর শেষে একটি block (১৬ byte) লেখা হয়। লেখার ট্রাফিক ২৫৬ থেকে ১৬ byte — ১৬ ভাগের ১ ভাগ। Hit-এর সংখ্যাও ৪৮ থেকে ১১১: no-write-allocate-এ sum কখনো cache-এ ঢোকেনি, তাই তার প্রতিটি store “miss”.

কপির লুপ: এখানে প্রতিটি লেখা নতুন জায়গায় — কোনো word দুবার লেখা হয় না (প্রতিটি block-এর ৪টি word-এ একবার করে, মোট ৪টি store). Write-back + write-allocate গন্তব্যের প্রতিটি block আগে পড়ে আনে (block-পড়া ৩২, পড়ার ট্রাফিক দ্বিগুণ ৫১২ byte), অথচ পুরোনো মানটি কখনো দরকারই হয়নি — পুরোটাই তো ওপর থেকে লেখা হবে। লেখার ট্রাফিক দুই নীতিতেই ২৫৬ byte. অর্থাৎ শুধু-লেখা stream-এ write-allocate অপচয়। এজন্য x86-এর মতো ISA-তে “non-temporal” বা streaming store আছে, যা cache-কে পাশ কাটিয়ে লেখে, আর অনেক আধুনিক core পুরো line ওপর থেকে লেখা হচ্ছে বুঝলে আগের মান পড়া এড়ায় বলে বর্ণিত। (Simulator-এ N, SETS বা WAYS বদলে নিজে পরীক্ষা করুন।)

Dirty eviction-এর দাম miss penalty-তে

Write-back-এ একটি miss যদি dirty line সরায়, তাহলে নতুন block আনার আগে পুরোনোটি লিখতে হয়। ধরুন একটি clean miss-এর miss penalty ১০০ cycle, আর পুরো block লেখাও ১০০ cycle. যদি সরানো line-গুলোর ৩০% dirty হয়, আর লেখা আর পড়া একটির পর একটি হয়, তাহলে গড় miss penalty = ১০০ + ০.৩ × ১০০ = ১৩০ cycle. ১ cycle-এর hit আর ৫% miss rate ধরলে AMAT (অংশ ৩-এ সংজ্ঞা) ১ + ০.০৫ × ১০০ = ৬ cycle থেকে বেড়ে ১ + ০.০৫ × ১৩০ = ৭.৫ cycle.

সমাধান: write-back buffer (victim buffer) — dirty victim-কে প্রথমে একটি ছোট buffer-এ সরিয়ে রেখে নতুন block-এর পড়া আগে শুরু করা, আর buffer-টি পরে ফাঁকা সময়ে লেখা। তখন miss penalty আবার প্রায় ১০০ cycle-এ নামে — যতক্ষণ buffer ভরে না যায়। Write-through-এর write buffer-এর মতোই নিয়ম: buffer ক্ষণিকের চাপ লুকায়, গড় হার লুকাতে পারে না।

সাবধান: Read miss-কে write-এর আগে জায়গা দিলে একটি সূক্ষ্ম বিপদ আছে। Write buffer-এ ঠিকানা X-এর একটি নতুন মান অপেক্ষায়, আর এখন X-এর block-এর read miss হলো। মেমরি থেকে পড়লে পুরোনো মান আসবে! তাই read miss-এর সময় write buffer-এর ঠিকানাগুলো মিলিয়ে দেখা হয় — মিললে buffer থেকে মান নেওয়া, বা আগে buffer খালি করা। পর্ব ৪০-এর store-to-load forwarding-এর মতোই এক ধারণা।

৩ · পারফরম্যান্সের সমীকরণ — memory stall cycle, CPI আর AMAT

পর্ব ০৩-এ CPU time-এর মূল সূত্র দেখেছেন: CPU time = instruction count × CPI × clock cycle time. সেখানে CPI ধরা হয়েছিল একটি সংখ্যা। কিন্তু সেই CPI-এর ভেতরে দুটি অংশ লুকিয়ে আছে — CPU-র নিজের কাজের cycle, আর মেমরির জন্য দাঁড়িয়ে থাকার cycle. প্রথমটিকে বলি CPIbase (সব access cache-এ hit হলে যা হতো; hit time এর ভেতরেই ধরা), দ্বিতীয়টি memory stall cycle.

সূত্রটি কোথা থেকে আসে — একটি ছোট প্রমাণ

একটি প্রোগ্রামে IC সংখ্যক instruction. প্রতিটি instruction গড়ে A বার মেমরিতে যায় (instruction fetch আর load/store মিলিয়ে)। তাহলে মোট access = IC × A. এদের মধ্যে miss rate অনুপাতে miss — মোট miss = IC × A × miss rate. ধরে নিই (সরল in-order মডেলে) প্রতিটি miss-এ CPU পুরো miss penalty সময় দাঁড়িয়ে থাকে, আর miss-গুলো একটির ওপর আরেকটি পড়ে না। তাহলে:

Memory stall cycles = IC × A × miss rate × miss penalty

দুই দিককে IC দিয়ে ভাগ করলে পাই প্রতি instruction-এ stall, আর CPU-র cycle-এর সাথে যোগ করলে:

CPI = CPIbase + (মেমরি access / instruction) × miss rate × miss penalty

Instruction cache আর data cache আলাদা হলে (split cache — পর্ব ৩৬-এ structural hazard এড়ানোর প্রসঙ্গে দেখেছেন) দুটি পদ আলাদা লেখা হয়: প্রতিটি instruction ঠিক একবার fetch হয়, তাই I-cache-এর A = ১; আর D-cache-এ A = load/store-এর অনুপাত। দুটি অনুমান মনে রাখবেন: miss penalty সব miss-এ সমান, আর miss চলাকালীন CPU কিছুই করে না। দ্বিতীয় অনুমানটি অংশ ৬-এ ভাঙব।

ক্লাসিক numerical (ক) — perfect cache কত দ্রুত?

প্রশ্ন: I-cache miss rate ২%, D-cache miss rate ৪%, miss penalty ১০০ cycle, CPIbase ২, আর ৩৬% instruction load বা store. Stall-সহ CPI কত, আর সব hit হলে (perfect cache) প্রসেসর কত গুণ দ্রুত হতো? নিচের stepper-এ হিসাবটি ধাপে ধাপে।

ধাপ ১দেওয়া তথ্য সাজানো
CPI_base          = 2
I-cache miss rate = 2%   = 0.02
D-cache miss rate = 4%   = 0.04
miss penalty      = 100 cycle
load/store        = 36%  = 0.36 per instruction
I-fetch           = 1    per instruction (সবসময়)
ধাপ ২Instruction miss-এর stall
I-stall / instr = (I-access / instr) × miss rate × penalty
                = 1 × 0.02 × 100
                = 2.00 cycle
ধাপ ৩Data miss-এর stall
D-stall / instr = (D-access / instr) × miss rate × penalty
                = 0.36 × 0.04 × 100
                = 1.44 cycle
ধাপ ৪মোট CPI
CPI = CPI_base + I-stall + D-stall
    = 2 + 2.00 + 1.44
    = 5.44

মোট সময়ের 3.44 / 5.44 ≈ 63% শুধু মেমরির অপেক্ষা
ধাপ ৫Perfect cache-এর সাথে তুলনা
একই IC, একই clock — তাই সময়ের অনুপাত = CPI-এর অনুপাত

speedup = CPI_stall / CPI_perfect
        = 5.44 / 2
        = 2.72 গুণ
  1. তথ্য সাজানো. শতাংশকে ভগ্নাংশে লিখুন। লক্ষ করুন দুটি “প্রতি instruction-এ access”: I-fetch সবসময় ১, কারণ প্রতিটি instruction নিজেকে একবার আনতে হয়; data access ০.৩৬, কারণ শুধু load আর store D-cache-এ যায়।
  2. I-stall. ১ × ০.০২ × ১০০ = ২ cycle. প্রতি ১০০টি instruction-এ গড়ে ২টি I-cache miss, প্রতিটিতে ১০০ cycle — মোট ২০০ cycle, অর্থাৎ instruction প্রতি ২।
  3. D-stall. ০.৩৬ × ০.০৪ × ১০০ = ১.৪৪ cycle. Miss rate I-cache-এর দ্বিগুণ হলেও D-stall কম, কারণ সব instruction data access করে না।
  4. মোট CPI. ২ + ২ + ১.৪৪ = ৫.৪৪. CPU-র নিজের কাজ মাত্র ২ cycle; বাকি ৩.৪৪ cycle — মোট সময়ের প্রায় ৬৩% — মেমরির জন্য অপেক্ষা।
  5. Speedup. Instruction count আর clock একই, তাই CPU time-এর অনুপাত = CPI-এর অনুপাত = ৫.৪৪ / ২ = ২.৭২. সব access hit হলে প্রসেসর ২.৭২ গুণ দ্রুত হতো। এটাই বলে cache-এর উন্নতি কতটা মূল্যবান।

ক্লাসিক numerical (খ) — clock দ্বিগুণ করলে কী হয়?

এবার একই প্রসেসরের clock rate দ্বিগুণ করা হলো, কিন্তু মেমরি একই। মেমরির latency ন্যানোসেকেন্ডে অপরিবর্তিত, কিন্তু cycle এখন অর্ধেক লম্বা, তাই একই সময়ে দ্বিগুণ cycle — miss penalty ২০০ cycle. নতুন CPI = ২ + ১ × ০.০২ × ২০০ + ০.৩৬ × ০.০৪ × ২০০ = ২ + ৪ + ২.৮৮ = ৮.৮৮. CPU time-এর অনুপাত:

ধীর / দ্রুত = (IC × ৫.৪৪ × T) / (IC × ৮.৮৮ × T/২) = ৫.৪৪ × ২ / ৮.৮৮ ≈ ১.২৩

Clock দ্বিগুণ, অথচ CPU মাত্র প্রায় ১.২৩ গুণ দ্রুত। কারণ সময়ের যে অংশ মেমরির অপেক্ষা, সেটি clock-এর সাথে একটুও কমেনি — এটা পর্ব ০৩-এর Amdahl-এর সূত্র, শুধু মেমরির রূপে। এই প্রবণতারই নাম memory wall (পর্ব ৪৪).

AMAT — গড়ে একটি মেমরি access কত সময় নেয়

শুধু cache-এর নকশা তুলনা করতে চাইলে পুরো CPI লাগে না; একটি সরল মাপকাঠি যথেষ্ট — AMAT (average memory access time):

AMAT = hit time + miss rate × miss penalty

যুক্তি: প্রতিটি access-এ আগে cache দেখতেই হয় (hit time, hit হোক বা miss); তারপর miss rate অনুপাতে access-কে বাড়তি miss penalty দিতে হয়। উদাহরণ: hit time ১ cycle, miss rate ৫%, miss penalty ২০ cycle → AMAT = ১ + ০.০৫ × ২০ = ২ cycle. AMAT cycle-এ বা ন্যানোসেকেন্ডে — যেকোনো এককে লেখা যায়, শুধু তিনটি পদ একই এককে থাকতে হয়। আর মনে রাখবেন: একটি cache-এর miss rate কম কিন্তু hit time বেশি হলে AMAT খারাপও হতে পারে — তুলনা সবসময় পুরো সূত্রে।

নিচের calculator ঠিক এই হিসাবগুলো — (ক), (খ), AMAT আর পরের অংশের L2-এর উদাহরণ — প্রতিটি মধ্যবর্তী সংখ্যাসহ ছাপায়:

// CPI with memory stalls + AMAT: the classic textbook numericals, every step printed
const r = (x, d = 4) => +x.toFixed(d);
function cpiWithStalls(name, base, pen, iMiss, dMiss, ldst) {
  const iSt = 1 * iMiss * pen;            // every instruction is fetched: 1 I-access / instr
  const dSt = ldst * dMiss * pen;         // only loads/stores access the D-cache
  const cpi = base + iSt + dSt;
  console.log(`${name}: I-stall = 1 x ${iMiss} x ${pen} = ${r(iSt)}, D-stall = ${ldst} x ${dMiss} x ${pen} = ${r(dSt)}`);
  console.log(`   CPI = ${base} + ${r(iSt)} + ${r(dSt)} = ${r(cpi)};  perfect cache is ${r(cpi / base)}x faster`);
  return cpi;
}
const A = cpiWithStalls("(a) penalty 100", 2, 100, 0.02, 0.04, 0.36);
const B = cpiWithStalls("(b) penalty 200", 2, 200, 0.02, 0.04, 0.36);
// time = IC x CPI x cycle time; (b) has half the cycle time
console.log(`(b) vs (a): time ratio = (${r(A)} x 1) / (${r(B)} x 0.5) = ${r(A / (B * 0.5), 3)}x  (not 2x)`);

const amat = (ht, mr, mp) => ht + mr * mp;
console.log(`AMAT = 1 + 0.05 x 20 = ${amat(1, 0.05, 20)} cycles`);

// Two levels: base CPI 1, 4 GHz, L1 misses/instr 2%, memory 100 ns, L2 5 ns, global L2 miss 0.5%
const ghz = 4, cyc = ns => ns * ghz;     // ns -> cycles
const mem = cyc(100), l2 = cyc(5);
const cpi1 = 1 + 0.02 * mem;
const cpi2 = 1 + 0.02 * l2 + 0.005 * mem;
console.log(`memory = 100 ns x ${ghz} GHz = ${mem} cycles, L2 = 5 ns = ${l2} cycles`);
console.log(`L1 only : CPI = 1 + 0.02 x ${mem} = ${r(cpi1)}`);
console.log(`L1 + L2 : CPI = 1 + 0.02 x ${l2} + 0.005 x ${mem} = ${r(cpi2)}`);
const local = 0.005 / 0.02;
console.log(`L2 local miss rate = 0.005 / 0.02 = ${local}; check: 0.02 x (${l2} + ${local} x ${mem}) = ${r(0.02 * (l2 + local * mem))} stall cycles`);
console.log(`speedup from L2 = ${r(cpi1)} / ${r(cpi2)} = ${r(cpi1 / cpi2, 3)}x`);

আসল আউটপুট:

(a) penalty 100: I-stall = 1 x 0.02 x 100 = 2, D-stall = 0.36 x 0.04 x 100 = 1.44
   CPI = 2 + 2 + 1.44 = 5.44;  perfect cache is 2.72x faster
(b) penalty 200: I-stall = 1 x 0.02 x 200 = 4, D-stall = 0.36 x 0.04 x 200 = 2.88
   CPI = 2 + 4 + 2.88 = 8.88;  perfect cache is 4.44x faster
(b) vs (a): time ratio = (5.44 x 1) / (8.88 x 0.5) = 1.225x  (not 2x)
AMAT = 1 + 0.05 x 20 = 2 cycles
memory = 100 ns x 4 GHz = 400 cycles, L2 = 5 ns = 20 cycles
L1 only : CPI = 1 + 0.02 x 400 = 9
L1 + L2 : CPI = 1 + 0.02 x 20 + 0.005 x 400 = 3.4
L2 local miss rate = 0.005 / 0.02 = 0.25; check: 0.02 x (20 + 0.25 x 400) = 2.4 stall cycles
speedup from L2 = 9 / 3.4 = 2.647x

প্রথম দুই লাইন stepper-এর ৫.৪৪ আর ২.৭২-এর সাথে মেলে। তৃতীয়-চতুর্থ লাইন একটি চমকপ্রদ কথা বলে: দ্রুত clock-এ perfect cache-এর মূল্য আরও বেশি (৪.৪৪ গুণ) — CPU যত দ্রুত, মেমরি তত বড় বাধা। পঞ্চম লাইনের ১.২২৫ ≈ ১.২৩ হলো clock দ্বিগুণ করার আসল লাভ। শেষের পাঁচ লাইন পরের অংশের L2-এর উদাহরণ; সেখানে ব্যাখ্যা করছি। সংখ্যা বদলে (যেমন ldst বা pen) নিজের প্রশ্ন বানিয়ে চালাতে পারেন।

৪ · Multi-level cache — L2, L3, local আর global miss rate

(খ)-এর শিক্ষা: CPU আর মেমরির ফারাক যত বাড়ে, miss penalty তত বড়। L1-কে আরও বড় করলে miss কমে, কিন্তু বড় cache ধীর — L1-এর hit time প্রতিটি load-এর গতি ঠিক করে, তাই সেটি বাড়ানো যায় না। সমাধান: L1 ছোট আর দ্রুত রেখে তার পেছনে আরেকটি বড়, একটু ধীর cache বসানো — L2 cache. L1-এর miss এখন মেমরিতে না গিয়ে প্রথমে L2-তে যায়; অর্থাৎ L2-র কাজ হলো L1-এর miss penalty কমানো. তার পেছনে প্রায়ই সব core-এর ভাগাভাগি করা L3 (last-level cache) — এটাই multi-level cache.

লাইব্রেরিতে পড়ার টেবিলের কথা ভাবুন। টেবিলে চার-পাঁচটি বই (L1) — হাত বাড়ালেই পাওয়া যায়। পাশের তাকে কয়েকশো বই (L2) — উঠে দুই পা হাঁটতে হয়। তলার স্টোররুমে লাখো বই (মেমরি) — লিফটে নামতে কয়েক মিনিট। টেবিল বড় করলে হাত বাড়ানোও ধীর হতো, তাই টেবিল ছোটই থাকে; পাশের তাকটিই স্টোররুমে যাওয়ার বেশিরভাগ যাত্রা বাঁচায়।

দুই স্তরের AMAT আর local বনাম global miss rate

L1-এর miss penalty এখন নিজেই একটি ছোট AMAT — L2-এর hit time আর L2-এর miss-এর দাম মিলিয়ে:

AMAT = HTL1 + MRL1 × (HTL2 + MRL2 × MPL2)

এখানে MRL2 একটি local miss rate: L2-তে যত access পৌঁছায়, তার কত অংশ miss. আর global miss rate হলো CPU-র মোট access-এর কত অংশ L2-তেও miss করে মেমরিতে যায় = MRL1 × MRL2(local). L2-এর local miss rate প্রায়ই চোখে “খারাপ” দেখায় (২৫–৫০% হতেই পারে), কারণ সহজ access-গুলো L1-ই ছেঁকে নিয়েছে, L2-এর ভাগে শুধু কঠিনগুলো। তাই L2-এর মান বিচার করতে global miss rate-ই বেশি অর্থবহ।

পাঠ্যবইয়ের উদাহরণ — L2 যোগ করলে কত লাভ

একটি প্রসেসর: CPIbase ১, clock ৪ GHz, প্রতি instruction-এ L1 miss ২% (miss rate এখানে instruction প্রতি দেওয়া), main memory-র access time ১০০ ns (সব miss-এ সমান)। এবার একটি L2 যোগ: hit time ৫ ns, আর L2 যোগের পর মেমরিতে যাওয়ার global miss rate ০.৫%. প্রথমে ns-কে cycle-এ: ৪ GHz মানে এক cycle ০.২৫ ns, তাই ১০০ ns = ৪০০ cycle, ৫ ns = ২০ cycle.

  • শুধু L1: CPI = ১ + ০.০২ × ৪০০ = ৯.
  • L1 + L2: প্রতিটি L1 miss ২০ cycle-এ L2 দেখে; global ০.৫% access আরও ৪০০ cycle নেয়। CPI = ১ + ০.০২ × ২০ + ০.০০৫ × ৪০০ = ১ + ০.৪ + ২.০ = ৩.৪.
  • Local miss rate দিয়ে যাচাই: L2-এর local miss rate = ০.০০৫ / ০.০২ = ২৫%. প্রতি L1 miss-এর গড় দাম = ২০ + ০.২৫ × ৪০০ = ১২০ cycle; প্রতি instruction-এ ০.০২ × ১২০ = ২.৪ — একই ফল, কারণ ১ + ২.৪ = ৩.৪.
  • Speedup: ৯ / ৩.৪ ≈ ২.৬ গুণ (calculator-এ ২.৬৪৭)।

নিচের stepper-এ এই উদাহরণের একটি load দেখুন যেটি L1-এ আর L2-তেও miss করে।

  1. L1 lookupmisshit time CPI_base-এ ধরা
  2. L2 lookup+20 cycle5 ns, local miss 25%
  3. Main memory+400 cycle100 ns
  4. FillL2 → L1 → regblock দুই স্তরেই বসে
  1. L1 miss. Load-এর address ভেঙে L1-এর tag মেলানো হলো — মিলল না। L1-এর hit time CPIbase = ১-এর ভেতরেই ধরা আছে, তাই এখান থেকে যা দেরি হবে, সবই stall. Miss-টির হিসাব রাখতে একটি entry খোলা হয় (অংশ ৫-এর MSHR).
  2. L2-তে খোঁজা — ২০ cycle. L2-এর hit time ৫ ns = ২০ cycle. প্রতি চারটি L1 miss-এর তিনটি এখানেই শেষ হতো (local miss rate ২৫%), মাত্র ২০ cycle-এর stall-এ। এই load-টি দুর্ভাগা — L2-তেও miss.
  3. মেমরি — আরও ৪০০ cycle. ১০০ ns মানে ৪ GHz-এ ৪০০ cycle. এই load মোট ২০ + ৪০০ = ৪২০ cycle stall দেয়। L2 না থাকলে প্রতিটি L1 miss-ই ৪০০ cycle নিত; L2 থাকায় গড়ে ২০ + ০.২৫ × ৪০০ = ১২০ cycle.
  4. ফেরা আর গড়ের হিসাব. Block আগে L2-তে, তারপর L1-এ বসে (victim dirty হলে তার write-back আলাদা), আর চাওয়া word register-এ যায়। প্রতি instruction-এ stall = ০.০২ × ১২০ = ২.৪, তাই CPI ১ + ২.৪ = ৩.৪ — L2 ছাড়া ছিল ৯।

তিন স্তর আর একটি explorer

তিন স্তরে সূত্রটি আরেক ধাপ ভেতরে যায়: AMAT = HTL1 + MRL1 × (HTL2 + MRL2 × (HTL3 + MRL3 × MPmem)), প্রতিটি MR local. নিচের explorer-এ L2-এর আকার বদলালে কী হয় তা দেখুন। সব সংখ্যা শেখানোর জন্য ধরে নেওয়া — কোনো নির্দিষ্ট chip-এর নয়; ধারা বোঝানোই উদ্দেশ্য: বড় L2-এর miss কম কিন্তু hit একটু ধীর।

// Multi-level AMAT explorer. ALL numbers below are ASSUMED teaching values, not a real chip.
const L1 = { ht: 4, mr: 0.05 };             // L1: hit time 4 cycles, miss rate 5%
const MEM = 200;                            // main memory: 200 cycles
const L2 = [                                // assumed: bigger L2 -> lower local miss rate, slower hit
  { size: "256 KiB", ht: 10, mr: 0.50 }, { size: "512 KiB", ht: 11, mr: 0.40 },
  { size: "1 MiB", ht: 12, mr: 0.30 }, { size: "2 MiB", ht: 14, mr: 0.25 }, { size: "4 MiB", ht: 16, mr: 0.20 }];
const f = x => x.toFixed(2).padStart(6);
const noL2 = L1.ht + L1.mr * MEM;
console.log(`L1 only: AMAT = ${L1.ht} + ${L1.mr} x ${MEM} = ${noL2.toFixed(2)} cycles`);
console.log("L2 size   HT  local MR  global MR  L1 miss penalty   AMAT  speedup vs L1-only");
for (const c of L2) {
  const pen = c.ht + c.mr * MEM;             // what an L1 miss now costs
  const amat = L1.ht + L1.mr * pen;
  console.log(`${c.size.padEnd(8)} ${String(c.ht).padStart(3)} ${f(c.mr * 100)}%   ${f(L1.mr * c.mr * 100)}%` +
    `   ${f(pen).padStart(14)}   ${f(amat)}   ${(noL2 / amat).toFixed(2)}x`);
}
// add an L3 behind the 1 MiB L2 (assumed: 40-cycle hit, 20% local miss rate)
const L3 = { ht: 40, mr: 0.20 }, m = L2[2];
const l3pen = L3.ht + L3.mr * MEM, l2pen = m.ht + m.mr * l3pen, amat3 = L1.ht + L1.mr * l2pen;
console.log(`3 levels: AMAT = ${L1.ht} + ${L1.mr} x (${m.ht} + ${m.mr} x (${L3.ht} + ${L3.mr} x ${MEM}))`);
console.log(`        = ${L1.ht} + ${L1.mr} x (${m.ht} + ${m.mr} x ${l3pen}) = ${L1.ht} + ${L1.mr} x ${l2pen.toFixed(1)} = ${amat3.toFixed(2)} cycles`);
console.log(`global miss rates: L1 ${(L1.mr * 100).toFixed(1)}%, L2 ${(L1.mr * m.mr * 100).toFixed(1)}%, L3 ${(L1.mr * m.mr * L3.mr * 100).toFixed(2)}%`);

আসল আউটপুট:

L1 only: AMAT = 4 + 0.05 x 200 = 14.00 cycles
L2 size   HT  local MR  global MR  L1 miss penalty   AMAT  speedup vs L1-only
256 KiB   10  50.00%     2.50%           110.00     9.50   1.47x
512 KiB   11  40.00%     2.00%            91.00     8.55   1.64x
1 MiB     12  30.00%     1.50%            72.00     7.60   1.84x
2 MiB     14  25.00%     1.25%            64.00     7.20   1.94x
4 MiB     16  20.00%     1.00%            56.00     6.80   2.06x
3 levels: AMAT = 4 + 0.05 x (12 + 0.3 x (40 + 0.2 x 200))
        = 4 + 0.05 x (12 + 0.3 x 80) = 4 + 0.05 x 36.0 = 5.80 cycles
global miss rates: L1 5.0%, L2 1.5%, L3 0.30%

দেখুন তিনটি জিনিস। এক, ২৫৬ KiB-এর L2-এর local miss rate ৫০% — শুনতে ভয়ংকর — তবু AMAT ১৪ থেকে ৯.৫, প্রায় ১.৪৭ গুণ ভালো; local miss rate দিয়ে L2-কে বিচার করা ভুল। দুই, আকার দ্বিগুণ করার লাভ শেষের দিকে কমে আসে (৯.৫০ → ৮.৫৫ → ৭.৬০, তারপর মাত্র ৭.২০ → ৬.৮০), কারণ hit time বাড়ছে আর miss rate-এর পতন ধীর হচ্ছে। তিন, ১ MiB L2-এর পেছনে একটি L3 বসালে AMAT ৭.৬০ থেকে ৫.৮০: L2-এর miss এখন ২০০ নয়, গড়ে ৮০ cycle নেয় (৪০ + ০.২ × ২০০)। মেমরিতে যায় মোট access-এর মাত্র ০.৩%.

প্রতিটি স্তরের নকশার লক্ষ্য আলাদা

স্তরমূল লক্ষ্যসাধারণ নকশা-পছন্দ (মোটামুটি, chip-ভেদে আলাদা)
L1 (I আর D আলাদা)hit time কম — প্রতিটি load আর fetch এর গতিতে চলেছোট (কয়েক ডজন KiB), কম associativity, প্রায়ই core-এর clock-এ কয়েক cycle-এ hit; প্রতি core-এ নিজস্ব
L2miss rate কম, যাতে L1-এর miss penalty ছোট হয়বড় (কয়েকশো KiB থেকে কয়েক MiB), বেশি associativity, বড় block হতে পারে; প্রায়ই প্রতি core-এ নিজস্ব
L3 / LLCমেমরিতে যাওয়া কমানো, core-দের মধ্যে ভাগাভাগিঅনেক MiB, সব core-এর shared, কয়েক টুকরোয় (slice) ভাগ করা; hit time কয়েক ডজন cycle হতে পারে

এ কারণেই L1-এ hit time বাঁচাতে কখনো কম associativity মেনে নেওয়া হয়, অথচ L2-তে miss কমাতে বেশি associativity আর বড় আকার — কারণ L2-এর hit time প্রতিটি access-এ নয়, শুধু L1 miss-এ লাগে।

Inclusive, exclusive আর NINE

বাইরের স্তরে কী থাকবে, সেটিও একটি নকশা-সিদ্ধান্ত:

  • Inclusive: ভেতরের স্তরের (L1, L2) সব block বাইরের স্তরেও (L3) থাকবেই। সুবিধা: অন্য core-এর coherence-প্রশ্নের উত্তর L3 দেখেই দেওয়া যায় — L3-এ না থাকলে কোনো L1-এও নেই (পর্ব ৫৩). দাম: একই ডেটা দুই জায়গায়, তাই কার্যকর ধারণক্ষমতা কম; আর L3 থেকে কোনো block সরালে ভেতরের কপিও বাতিল করতে হয় (back-invalidation).
  • Exclusive: একটি block একসাথে L2 বা L3 — যেকোনো একটিতে। L2 থেকে সরানো block L3-এ যায় (L3 যেন একটি victim cache). মোট ধারণক্ষমতা L2 + L3-এর যোগফল, কিন্তু block দুই স্তরে আদান-প্রদানের ট্রাফিক বেশি।
  • NINE (non-inclusive non-exclusive): কোনো নিয়মই জোর করা হয় না — block থাকতেও পারে, না-ও পারে।

বাস্তব chip-এ কোনটি, তা প্রজন্মভেদে বদলেছে। Intel-এর অনেক পুরোনো desktop প্রজন্মের L3 inclusive বলে ব্যাপকভাবে বর্ণিত, আর পরের কিছু server প্রজন্মে non-inclusive-এ সরে যাওয়ার কথা জানা যায়; AMD-এর Zen-ধাঁচের core-এ L3 মূলত L2-এর victim cache হিসেবে কাজ করে বলে বর্ণিত। নির্দিষ্ট প্রজন্মের জন্য নির্মাতার optimization manual দেখে নেবেন — এগুলো সময়ের সাথে বদলায়।

৫ · Cache optimization-এর তালিকা — কোন কৌশল কোন পদ কমায়

AMAT-এর তিনটি পদ — hit time, miss rate, miss penalty — প্রতিটি কমানোর আলাদা কৌশল আছে; সাথে আছে bandwidth বাড়ানো আর miss-এর সময় অন্য কাজ চালিয়ে যাওয়া। Hennessy আর Patterson-এর বইয়ে যে ধাঁচে সাজানো থাকে, তার কাছাকাছি একটি তালিকা নিচে। প্রতিটি কৌশলের কোনো না কোনো দাম আছে; কোন chip কোনটি কতটা নেয়, তা নকশাভেদে আলাদা।

কৌশলকী কমায়/বাড়ায়কীভাবেদাম
বড় blockmiss rate ↓ (compulsory)এক miss-এ বেশি প্রতিবেশী ডেটাmiss penalty ↑; খুব বড় হলে block-সংখ্যা কমে, conflict ↑
বড় cachemiss rate ↓ (capacity)বেশি ডেটা ধরেhit time, power, area ↑
বেশি associativitymiss rate ↓ (conflict)এক set-এ বেশি way (পর্ব ৪৬)hit time আর power ↑ (বেশি tag তুলনা)
Multi-level cachemiss penalty ↓L1 miss মেমরির বদলে L2/L3-তেarea, জটিলতা
Read miss-কে write-এর আগেmiss penalty ↓write buffer-এর অপেক্ষার পেছনে read দাঁড়ায় নাbuffer-এর ঠিকানা মেলানোর সার্কিট
Critical word first / early restartmiss penalty ↓চাওয়া word আগে আনা / আসামাত্র CPU-কে দেওয়া, বাকি block পরেবড় block-এ বেশি লাভ; নিয়ন্ত্রণ জটিল
Non-blocking cacheকার্যকর miss penalty ↓miss চলাকালীন অন্য hit (hit under miss) ও অন্য miss চালানো; প্রতিটি miss একটি MSHR-এMSHR-এর সংখ্যা সীমিত; out-of-order core না হলে লাভ কম
Hardware prefetchingmiss rate বা penalty ↓next-line: পরের block আগেভাগে; stride prefetcher: নিয়মিত দূরত্বের ধারা চিনে সামনে আনাভুল অনুমানে bandwidth নষ্ট, দরকারি block সরিয়ে দেওয়া (pollution)
Compiler/software prefetchmiss rate বা penalty ↓আগেভাগে prefetch instruction বসানো (পর্ব ৪৮)বাড়তি instruction; সময়মতো না হলে লাভ নেই
Way predictionhit time আর power ↓set-এর কোন way-তে hit হবে অনুমান করে আগে সেটাই দেখাঅনুমান ভুল হলে বাড়তি cycle
Pipelined cache accessclock rate আর bandwidth ↑একটি hit কয়েক cycle-এ, কিন্তু প্রতি cycle-এ নতুন access শুরুload-use latency বাড়ে (পর্ব ৩৬-এর stall বাড়ে)

কয়েকটি কৌশল একটু খুলে বলি। Critical word first: ৬৪ byte-এর block আনতে কয়েকটি ধাপে ডেটা আসে; CPU চেয়েছে হয়তো মাঝের একটি ৮ byte. মেমরিকে বলা হয় সেই word দিয়েই শুরু করতে, আর আসামাত্র register-এ পাঠানো হয় — বাকি block পেছনে ভরে। Non-blocking cache: সাধারণ (blocking) cache একটি miss চলাকালীন সব access আটকে রাখে। Non-blocking cache প্রতিটি অসমাপ্ত miss-এর ঠিকানা আর কে অপেক্ষা করছে, তা একটি Miss Status Holding Register (MSHR)-এ লিখে রাখে, আর এর মধ্যে অন্য hit, এমনকি আরও miss, চালিয়ে যায়। একই block-এ দ্বিতীয় miss এলে নতুন অনুরোধ না পাঠিয়ে আগের MSHR-এর সাথে জুড়ে দেওয়া হয়। MSHR-এর সংখ্যা (আধুনিক core-এ কয়েকটি থেকে কয়েক ডজনের মতো বলে বর্ণিত) ঠিক করে একসাথে কতগুলো miss উড়তে পারে।

Prefetcher: সবচেয়ে সরলটি next-line — block B-তে miss হলে B + 1-ও এনে রাখা। Stride prefetcher একটি নির্দিষ্ট load instruction-এর (PC ধরে) পরপর ঠিকানার পার্থক্য দেখে; পার্থক্য স্থির হলে (যেমন প্রতিবার +২৫৬ byte) সামনের কয়েকটি ঠিকানা আগেভাগে আনে। আধুনিক chip-এ এর চেয়ে অনেক জটিল prefetcher থাকে বলে জানা যায়, কিন্তু তাদের খুঁটিনাটি সাধারণত প্রকাশিত নয়। একটি prefetch-এর মান তিনটি জিনিসে: accuracy (আনা block কি কাজে লাগল), coverage (কত miss এড়ানো গেল), আর timeliness (দরকারের আগে পৌঁছাল কি না)।

৬ · Memory-level parallelism — কেন stall-এর সূত্র একটি ঊর্ধ্বসীমা

অংশ ৩-এর প্রমাণে একটি অনুমান ছিল: প্রতিটি miss-এ CPU পুরো penalty দাঁড়িয়ে থাকে, আর miss-গুলো একটির পর একটি। সরল in-order core-এ এটি প্রায় সত্য। কিন্তু পর্ব ৪০-এর out-of-order core একটি load-এর miss চলাকালীন instruction window-এর পরের স্বাধীন কাজ চালিয়ে যায় — আর সেই কাজে যদি আরও load থাকে, তাদের miss-ও একই সময়ে শুরু হয়। Non-blocking cache আর MSHR এটি সম্ভব করে। একসাথে একাধিক miss উড়ন্ত রাখার এই ক্ষমতাই memory-level parallelism (MLP).

বাজারের ফর্দ হাতে একজন। ফর্দে চাল, ডাল, তেল — তিনটি আলাদা দোকান। একটি করে কিনে ফিরে এসে পরেরটির জন্য বের হলে তিনবারের হাঁটা। কিন্তু তিনজন সহকারীকে একসাথে তিন দোকানে পাঠালে মোটামুটি একবারের সময়ে সব আসে। কিন্তু ফর্দ যদি হয় “প্রথম দোকানদার যে ঠিকানা বলবেন, সেই দোকানে যেতে হবে, সেখান থেকে মিলবে পরের ঠিকানা” — তাহলে সহকারী যতজনই থাকুক, যেতে হবে একে একে। প্রথমটি MLP, দ্বিতীয়টি pointer chase.

ধরুন ৪টি miss একসাথে উড়ছে, প্রতিটি ১০০ cycle. Stall-এর সূত্র বলবে ৪০০ cycle; বাস্তবে প্রায় ১০০-র কিছু বেশি। তাই আধুনিক core-এ সূত্রটি একটি ঊর্ধ্বসীমা (worst case) — পরীক্ষার numerical-এ এটাই ব্যবহার করবেন, কিন্তু মাপা সময় এর চেয়ে কম হলে অবাক হবেন না। উল্টো দিকে, যে কোডে পরের ঠিকানা আগের load-এর ফল থেকে আসে (pointer chasing: linked list, tree), সেখানে MLP শূন্য, আর prefetcher-ও পরের ঠিকানা অনুমান করতে পারে না — সূত্রটি সেখানে প্রায় হুবহু খাটে।

নিচের প্রোগ্রাম একই ১৬ MiB-এর array (cache-এর চেয়ে বড়) দুইভাবে পড়ে। প্রথমবার ক্রমানুসারে — প্রতিটি load স্বাধীন, ঠিকানা আগে থেকে জানা, prefetcher-এর জন্য আদর্শ। দ্বিতীয়বার pointer chase: array-টিতে আগে থেকে একটি এলোমেলো চক্র (Sattolo-র পদ্ধতিতে, একটি নির্দিষ্ট seed দিয়ে — তাই প্রতিবার একই array) বসানো, আর প্রতিটি load-এর ফলই পরের ঠিকানা। দুটিতেই ঠিক ৪১,৯৪,৩০৪টি load.

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <time.h>
#define N (1u << 22)                     /* 4M entries x 4 bytes = 16 MiB, bigger than the caches */

static double now_ns(void) {
    struct timespec t;
    clock_gettime(CLOCK_MONOTONIC, &t);
    return (double)t.tv_sec * 1e9 + (double)t.tv_nsec;
}

int main(void) {
    uint32_t *nx = malloc(N * sizeof *nx);
    if (!nx) return 1;
    uint64_t r = 88172645463325252u, sum = 0;      /* fixed xorshift seed: same array every run */
    for (uint32_t i = 0; i < N; i++) nx[i] = i;
    for (uint32_t i = N - 1; i > 0; i--) {         /* Sattolo: ONE random cycle through all entries */
        r ^= r << 13; r ^= r >> 7; r ^= r << 17;
        uint32_t j = (uint32_t)(r % i), t = nx[i]; nx[i] = nx[j]; nx[j] = t;
    }
    double t0 = now_ns();
    for (uint32_t i = 0; i < N; i++) sum += nx[i];   /* independent loads, addresses in order */
    double t1 = now_ns();
    uint32_t p = 0;
    for (uint32_t i = 0; i < N; i++) p = nx[p];      /* next address = result of this load */
    double t2 = now_ns();
    double seq = (t1 - t0) / N, chase = (t2 - t1) / N;
    printf("sequential scan : %6.2f ns per access (sum %llu)\n", seq, (unsigned long long)sum);
    printf("pointer chase   : %6.2f ns per access (end at %u)\n", chase, p);
    printf("chase / scan    : about %.0fx slower\n", chase / seq);
    free(nx);
    return 0;
}

একটি রানের আসল আউটপুট (একটি shared virtual machine-এ; প্রতিবার আর প্রতিটি মেশিনে সংখ্যা আলাদা হবে):

sequential scan :   1.61 ns per access (sum 8796090925056)
pointer chase   : 179.60 ns per access (end at 0)
chase / scan    : about 112x slower

প্রথমে দুটি যাচাই: sum 8796090925056 হলো ০ থেকে N − ১ পর্যন্ত সব সংখ্যার যোগফল (N(N − ১)/২), অর্থাৎ array-তে প্রতিটি index ঠিক একবার আছে; আর end at 0 বলে ঠিক N ধাপের chase শেষে আমরা শুরুর জায়গায় ফিরেছি — Sattolo-র পদ্ধতি সত্যিই সব entry নিয়ে একটিই চক্র বানিয়েছে, কোনো ছোট চক্রে আটকে cache-এ বসে থাকার সুযোগ নেই।

এবার সময়। একই সংখ্যক load, একই array, অথচ pointer chase এই রানে প্রায় ১১২ গুণ ধীর — প্রতি access-এ প্রায় ১৮০ ns, যা মোটামুটি পুরো একটি মেমরি-যাত্রার latency (এখানে TLB miss-ও যোগ হয়, কারণ ১৬ MiB-এ হাজার হাজার 4 KiB page — পর্ব ৫০)। প্রতিটি load-এর ঠিকানা আগের load-এর ফল, তাই একটির পর একটি: MLP শূন্য, prefetcher-ও অসহায়। এখানে অংশ ৩-এর stall-সূত্র প্রায় হুবহু খাটে।

Sequential scan-এ প্রতিটি ৬৪ byte-এর line-এ ১৬টি entry, তাই বড়জোর প্রতি ১৬টি load-এ একটি miss. তবু সেই হিসাবে প্রতি line-এ খরচ মাত্র প্রায় ১৬ × ১.৬১ ≈ ২৬ ns — একটি পূর্ণ মেমরি latency-র অনেক কম। কারণ ঠিকানা আগে থেকে জানা: prefetcher পরের line-গুলো আগেভাগে আনে, আর out-of-order core আর non-blocking cache কয়েকটি miss একসাথে চালায়। তার ওপর optimization ছাড়া compile করা লুপে প্রতিটি iteration-এর নিজের instruction-ও সময় নেয়, তাই ১.৬১ ns-এর বড় অংশ সম্ভবত লুপের কাজ, মেমরি নয়। সৎ সতর্কতা: সংখ্যাগুলো প্রতিটি রানে আর মেশিনে আলাদা হবে, shared virtual machine-এ অন্য কাজের প্রভাবও পড়ে; শুধু অনুপাতের ধারা (দশকের বা শতকের ঘরে) নির্ভরযোগ্য।

মেপে দেখা — performance counter

অনুমানের বদলে মাপতে চাইলে আধুনিক CPU-র performance counter আছে — hardware-এর ভেতরের গণক, যারা cache miss, cycle, instruction ইত্যাদি গোনে। Linux-এ perf টুল দিয়ে এগুলো পড়া যায়। নিচের কমান্ডগুলো শুধু উদাহরণ — এখানে চালানো হয়নি, কারণ অনেক virtual machine আর container-এ counter পাওয়া যায় না বা অনুমতি (perf_event_paranoid) বন্ধ থাকে:

$ gcc -O2 scan_vs_chase.c -o scan
$ perf stat -e cycles,instructions,cache-references,cache-misses ./scan
$ perf stat -e L1-dcache-loads,L1-dcache-load-misses,LLC-load-misses ./scan

ফলাফলে প্রতিটি event-এর গণনা আর কিছু অনুপাত (যেমন instruction per cycle, cache-references-এর কত শতাংশ miss) আসে। সতর্কতা: cache-misses-এর মতো “generic” event কোন hardware event-এ যায়, তা CPU-ভেদে আলাদা — অনেক x86 মেশিনে এটি মূলত last-level cache-এর miss বোঝায় বলে বর্ণিত, L1-এর নয়। কোনো event কোনো মেশিনে না-ও থাকতে পারে। তাই সংখ্যা পড়ার আগে perf list দেখে নিন, আর একই মেশিনে দুটি সংস্করণের তুলনাই সবচেয়ে নির্ভরযোগ্য। OS-এর দিক থেকে মাপার আরও কৌশল Operating Systems কোর্সের পারফরম্যান্স পর্বে, আর C-এর দিক থেকে C প্রোগ্রামিং কোর্সের cache পর্বে.

আরও গভীরে: আধুনিক মাল্টিকোর chip-এ shared L3-এর জায়গা নিয়ে core-দের টানাটানি একটি বাস্তব সমস্যা; কিছু server chip-এ OS বা hypervisor-কে L3-এর কিছু way নির্দিষ্ট কাজের জন্য সংরক্ষণ করতে দেওয়া হয় (Intel-এর ক্ষেত্রে “Cache Allocation Technology” নামে পরিচিত)। গবেষণায় replacement-এর জন্য re-reference অনুমানভিত্তিক নীতি (যেমন RRIP পরিবার) আর block-কে ছোট করে সংকুচিত রাখা (cache compression) নিয়ে প্রচুর কাজ হয়েছে। আর DRAM-এর latency কয়েক দশকে খুব সামান্য কমেছে, অথচ bandwidth অনেক বেড়েছে — এজন্যই আজকের নকশায় MLP আর prefetching এত গুরুত্বপূর্ণ (পর্ব ৪৯-এ DRAM-এর ভেতর)।

৭ · AI-এর যুগে — cache-এর হিসাবে AI, আর যাচাইয়ের অভ্যাস

AMAT আর CPI-এর প্রশ্নে AI-এর উত্তর সাধারণত গুছানো দেখায়, কিন্তু সংখ্যায় আর সংজ্ঞায় ভুল প্রায়ই চোখে পড়ে:

AI-এর সাধারণ ভুলকীভাবে ধরবেন
D-cache-এর stall-এ load/store-এর অনুপাত গুণ করতে ভুলে যাওয়া, বা I-cache-এ ০.৩৬ গুণ করাপ্রতিটি পদের “access / instruction” আলাদা করে লিখুন
দুই স্তরের AMAT-এ global miss rate বসিয়ে আবার MRL1 দিয়ে গুণ করা (দুবার গোনা)সূত্রে কোন MR local আর কোনটি global, লিখে নিন; দুই পথে হিসাব করে মেলান
Clock বাড়ালে miss penalty-র cycle একই রাখামেমরির latency ns-এ স্থির; cycle = ns × GHz
Write-back-এ dirty eviction-এর লেখা বাদ দেওয়া, বা শেষে থেকে যাওয়া dirty line না গোনাছোট trace হাতে বা simulator-এ চালিয়ে মেলান
কোনো chip-এর L2/L3-এর আকার, latency বা inclusive কি না — বানিয়ে বলানির্মাতার manual বা নিজের মেশিনে /sys/devices/system/cpu/cpu0/cache/ দেখুন

AI-কে cache-এর numerical করাতে চাইলে সব অনুমান স্পষ্ট দিন আর প্রতিটি ধাপ চান:

আমি cache performance-এর numerical অনুশীলন করছি (Patterson–Hennessy-এর ধাঁচে)। এই নিয়মগুলো মেনে চলবে: - CPI = CPI_base + (access/instruction) × miss rate × miss penalty; I-cache-এর access/instruction = 1, D-cache-এর = load/store-এর অনুপাত। - AMAT = HT_L1 + MR_L1 × (HT_L2 + MR_L2 × MP), যেখানে MR_L2 local miss rate. - মেমরির latency ns-এ স্থির; cycle-এ বদলাতে ns × GHz. প্রশ্ন: CPI_base 1.5, I-cache miss 1%, D-cache miss 5%, 30% load/store, L1 miss penalty 50 cycle. ১. প্রতিটি পদ আলাদা লাইনে হিসাব করে stall-সহ CPI দাও। ২. এবার 10 cycle hit time আর 20% local miss rate-এর একটি L2 যোগ করো (মেমরি 50 cycle). নতুন CPI আর global miss rate দাও। ৩. কোথাও তথ্য অস্পষ্ট লাগলে অনুমান না করে আমাকে জিজ্ঞেস করো।

AI-এর দেওয়া cache-এর হিসাব হাতে পেলে — চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • Write-through আর write-back-এর পার্থক্য? — Write-through প্রতিটি store নিচেও লেখে (write buffer দিয়ে); write-back শুধু cache-এ লেখে, dirty bit রাখে, আর block সরানোর সময় একবার লেখে।
  • Dirty bit কেন লাগে? — Block সরানোর সময় সেটি নিচের স্তরে লিখতে হবে কি না জানতে; clean block শুধু ফেলে দেওয়া যায়।
  • AMAT-এর সূত্র? — Hit time + miss rate × miss penalty; দুই স্তরে miss penalty = HTL2 + MRL2(local) × MPL2.
  • Local আর global miss rate-এর পার্থক্য? — Local: ওই cache-এ পৌঁছানো access-এর কত অংশ miss; global: CPU-র মোট access-এর কত অংশ ওই স্তরেও miss.
  • Clock দ্বিগুণ করলে কেন CPU দ্বিগুণ দ্রুত হয় না? — মেমরির latency ns-এ স্থির, তাই miss penalty-র cycle দ্বিগুণ হয়; stall-এর অংশ কমে না।

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১Write-back বেশিরভাগ ক্ষেত্রে কম ট্রাফিক দেয়, তবু কিছু প্রসেসর L1-এ write-through আর পেছনের স্তরে write-back রেখেছিল। কেন?

প্রথম দিকের cache-গুলোতে write-through ছিল স্বাভাবিক পছন্দ। IBM System/360 Model 85 (১৯৬৮ সালের দিকে) প্রথম বাণিজ্যিক cache-এর একটি হিসেবে পরিচিত, আর সেই যুগের অনেক নকশায় store সরাসরি মেমরিতে যেত (store-through) বলে বর্ণিত। কারণ সহজ: মেমরি সবসময় হালনাগাদ থাকলে I/O বা অন্য প্রসেসর মেমরি পড়লে ভুল মান পায় না, আর block সরানোর জটিলতাও নেই।

পরে দুই স্তরের cache এলে একটি মাঝামাঝি নকশা জনপ্রিয় হয়: ছোট L1 data cache write-through, আর তার ঠিক পেছনে on-chip L2 write-back. এতে L1-এর store-গুলো মেমরিতে নয়, কাছের L2-তে যায় — L2 যথেষ্ট দ্রুত আর চওড়া হলে write-through-এর ট্রাফিক সামলানো যায়, আর L2 নিজে write-back বলে মেমরির ট্রাফিক কম থাকে। DEC-এর Alpha 21164-এর মতো কিছু প্রসেসর এই ধাঁচের বলে বর্ণিত।

লাভ কী? এক, নির্ভরযোগ্যতা: write-through L1-এর প্রতিটি মানের একটি কপি L2-তেও আছে, তাই L1-এ শুধু parity রাখলেই চলে — bit ভুল ধরা পড়লে line ফেলে দিয়ে L2 থেকে আবার আনা যায়। Write-back L1-এ dirty ডেটার একমাত্র কপি L1-এ, তাই ভুল সংশোধনের (ECC) কোড লাগে, যা দ্রুততম স্তরে সময় আর জায়গা খায়। দুই, coherence সহজ: অন্য core বা DMA কোনো ঠিকানা চাইলে L2 দেখলেই চলে, L1-এ dirty কিছু লুকিয়ে নেই। তিন, L1 line সরানো সবসময় বিনা খরচে।

দাম হলো L1 আর L2-এর মাঝে প্রচুর ট্রাফিক আর power. Core-এর সংখ্যা আর store-এর হার যত বেড়েছে, এই দাম তত চোখে পড়েছে; তাই আজকের বেশিরভাগ বড় core-এ L1 data cache-ও write-back বলে সাধারণভাবে বর্ণিত, আর ECC-এর খরচ মেনে নেওয়া হয়। তবে GPU-র মতো কিছু ভিন্ন ধরনের প্রসেসরে কোনো কোনো স্তরে write-through বা ভিন্ন নীতি এখনো দেখা যায় বলে জানা যায়।

মূল উপলব্ধি: Write policy শুধু ট্রাফিকের প্রশ্ন নয় — নির্ভরযোগ্যতা, coherence-এর সরলতা আর কোন স্তর কতটা চাপ সইতে পারে, সবই মিলিয়ে সিদ্ধান্ত হয়।

প্র ০২Clock দ্বিগুণ করলে CPU মাত্র প্রায় ১.২৩ গুণ দ্রুত হলো। এই হিসাব chip-শিল্পের ইতিহাসের কোন মোড়ের সাথে যুক্ত?

১৯৮০ থেকে ২০০০-এর দশকের শুরু পর্যন্ত প্রসেসরের গতি প্রতি বছর অনেক বেড়েছে, কিন্তু DRAM-এর latency কমেছে অনেক ধীরে। ১৯৯৫ সালে William Wulf আর Sally McKee “Hitting the Memory Wall” নামে একটি ছোট কিন্তু বহুল উদ্ধৃত প্রবন্ধে এই হিসাবটিই দেখান: cache-এর hit rate যত ভালোই হোক, গতির ফারাক এভাবে বাড়তে থাকলে একসময় প্রসেসরের গড় access time প্রায় পুরোটাই মেমরির latency দিয়ে ঠিক হবে, আর দ্রুত প্রসেসরের লাভ উবে যাবে। উপরের numerical (খ) সেই যুক্তির ছোট সংস্করণ: penalty ১০০ থেকে ২০০ cycle হতেই CPI ৫.৪৪ থেকে ৮.৮৮.

২০০০-এর দশকের শুরুতে chip-নির্মাতারা খুব গভীর pipeline আর উঁচু clock-এর দিকে গিয়েছিল — Intel-এর Pentium 4 এর পরিচিত উদাহরণ। কিন্তু দুটি দেয়াল একসাথে এল: power wall (পর্ব ০২ — উঁচু clock-এ তাপ সামলানো যায় না) আর এই memory wall. ২০০৪-২০০৫ সালের দিকে শিল্প মূলত clock-এর দৌড় ছেড়ে একাধিক core-এর দিকে ঘুরে যায় বলে ব্যাপকভাবে বর্ণিত।

Memory wall-এর জবাবে যা যা এসেছে, তা এই পর্বেরই বিষয়: on-chip L2 আর পরে বড় shared L3, যাতে মেমরিতে যাওয়ার সংখ্যা কমে; non-blocking cache আর out-of-order core, যাতে একসাথে অনেক miss চলে (MLP); hardware prefetcher, যাতে miss আগেভাগে শুরু হয়; আর DRAM-এর bandwidth বাড়ানো (DDR প্রজন্ম, একাধিক channel). Latency কমানো কঠিন, কিন্তু একসাথে বেশি অনুরোধ চালিয়ে latency ঢাকা সম্ভব — এই দর্শনের চরম রূপ GPU (পর্ব ৫৫), যা হাজার thread দিয়ে মেমরির অপেক্ষা ঢাকে।

একটি সূক্ষ্ম কথা: দ্রুত clock-এ perfect cache-এর মূল্য ২.৭২ থেকে বেড়ে ৪.৪৪ গুণ হয়েছে (calculator-এর আউটপুটে)। অর্থাৎ CPU যত দ্রুত, cache-এর উন্নতি তত বেশি মূল্যবান — এই কারণেই chip-এর area-র একটি বড় অংশ এখন cache.

মূল উপলব্ধি: যে অংশটি দ্রুত হয় না, সেটিই শেষে পুরো গতি ঠিক করে; clock-এর দৌড়ের সমাপ্তি আর বড় cache-এর যুগ একই Amdahl-ধাঁচের হিসাবের ফল।

প্র ০৩Inclusive L3 coherence সহজ করে, exclusive L3 বেশি জায়গা দেয়। একজন chip-নকশাকারী কীভাবে বেছে নেবেন?

Inclusive নকশার সবচেয়ে বড় সুবিধা snoop filtering. মাল্টিকোর chip-এ একটি core কোনো ঠিকানা লিখতে চাইলে জানতে হয় অন্য কোনো core-এর private cache-এ সেই block আছে কি না (পর্ব ৫৩-এর MESI). L3 inclusive হলে উত্তর সহজ: L3-এ না থাকলে কোনো L1/L2-তেও নেই, তাই অন্য core-দের বিরক্ত করার দরকার নেই। থাকলে L3-এর সাথে রাখা কিছু bit বলে দেয় কোন core-এর কাছে কপি থাকতে পারে।

দাম দুটি। এক, জায়গার অপচয়: ভেতরের সব ডেটার একটি কপি বাইরে। যখন L3 ভেতরের সব cache-এর যোগফলের চেয়ে অনেক বড় ছিল, এই অপচয় সামান্য ছিল। কিন্তু core-এর সংখ্যা আর প্রতি core-এর L2 বড় হতে থাকলে অনুপাত খারাপ হয় — যেমন বড় private L2 আর core-প্রতি ছোট L3-এর অংশ থাকলে inclusive L3-এর বড় অংশই শুধু কপি রাখে। দুই, back-invalidation: L3 থেকে কোনো block সরালে ভেতরের কপিও বাতিল করতে হয় — এমনকি যদি একটি core সেটি খুব ঘনঘন ব্যবহার করে; L3-এর replacement সেই ব্যবহার দেখে না, তাই গরম ডেটাও বেরিয়ে যেতে পারে।

Exclusive নকশায় মোট ধারণক্ষমতা পুরোটা কাজে লাগে, কিন্তু L2-এর প্রতিটি eviction-এ block L3-এ লিখতে হয় (clean হলেও), আর L3 hit-এ block L2-তে সরে যায় — স্তরগুলোর মধ্যে ট্রাফিক বেশি। Coherence-এর জন্য আলাদা directory বা snoop filter লাগে। NINE মাঝামাঝি: কোনো নিয়ম জোর না করে প্রয়োজনে আলাদা snoop filter রাখা।

বাস্তবে এই সিদ্ধান্ত প্রজন্মভেদে বদলেছে। Intel-এর অনেক পুরোনো প্রজন্মের L3 inclusive বলে ব্যাপকভাবে বর্ণিত, আর পরে কিছু server প্রজন্মে বড় L2-এর সাথে non-inclusive L3-এ সরে যাওয়ার কথা জানা যায়; AMD-এর বেশ কিছু নকশায় L3 মূলত victim cache. AMAT-এর ভাষায়: exclusive বা NINE বাইরের স্তরের miss rate কমায় (বেশি অনন্য ডেটা), inclusive coherence-এর ট্রাফিক আর latency কমায়।

মূল উপলব্ধি: Inclusion-এর সিদ্ধান্ত নির্ভর করে স্তরগুলোর আকারের অনুপাত আর core-এর সংখ্যার ওপর — ভেতরের cache বড় হলে inclusion-এর দাম বাড়ে।

প্র ০৪Hardware prefetcher প্রোগ্রামারকে কিছু না বলেই miss কমায়। তাহলে কেন এটি সব সমস্যার সমাধান নয়, আর কখন ক্ষতিও করে?

Prefetching-এর ধারণা পুরোনো, কিন্তু একটি বহুল উদ্ধৃত মাইলফলক ১৯৯০ সালে Norman Jouppi-র গবেষণা, যেখানে তিনি direct-mapped cache-এর পাশে ছোট একটি fully associative “victim cache” আর ক্রমানুসারী block আগেভাগে আনার “stream buffer” প্রস্তাব করেন। পরের দশকে stride prefetcher নিয়ে কাজ হয় — load instruction-এর PC ধরে ঠিকানার নিয়মিত পার্থক্য খোঁজা। আজকের chip-এ L1 আর L2-তে একাধিক prefetcher একসাথে চলে বলে জানা যায়, যদিও তাদের নকশা সাধারণত গোপন।

সীমা প্রথমত অনুমানযোগ্যতায়। Prefetcher শুধু সেই ধারা চিনতে পারে, যা অতীতের ঠিকানা দেখে বোঝা যায়: ক্রমানুসারী, স্থির stride, কিছু ক্ষেত্রে পুনরাবৃত্ত ধাঁচ। Linked list, tree বা hash table-এর পরের ঠিকানা আসে ডেটা থেকে — অংশ ৬-এর pointer chase-এ যেমন দেখলেন — সেখানে সাধারণ prefetcher অসহায়।

দ্বিতীয়ত, prefetch বিনামূল্যে নয়। ভুল block আনলে মেমরির bandwidth নষ্ট হয়, যা মাল্টিকোর chip-এ অন্য core-এর ভাগ থেকে কাটা যায়। আর আনা block cache-এ জায়গা নেয় — দরকারি কোনো block সরিয়ে দিলে (cache pollution) miss উল্টো বাড়ে। খুব দেরিতে prefetch করলে লাভ নেই, খুব আগে করলে ব্যবহারের আগেই সরে যায়। তাই prefetcher-এর মান মাপা হয় accuracy, coverage আর timeliness — তিনটি দিয়ে, আর অনেক chip ভুল বেশি হলে নিজেই prefetch কমিয়ে দেয় বলে বর্ণিত।

তৃতীয়ত, ঠিকানা-সীমা। অনেক hardware prefetcher একটি 4 KiB page-এর সীমা পার হয় না, কারণ পরের virtual page কোন physical page-এ, তা জানতে TLB লাগে (পর্ব ৫০) — তাই খুব লম্বা stream-এ page-এর সীমায় একটু ধাক্কা লাগতে পারে। এজন্য software prefetch (পর্ব ৪৮) আর ডেটা-বিন্যাস বদলানো — যেমন linked list-এর বদলে array — এখনো প্রোগ্রামারের হাতিয়ার।

মূল উপলব্ধি: Prefetcher ভবিষ্যৎ অনুমান করে অতীত দেখে; যে কোডের ঠিকানা অনুমানযোগ্য, সেখানে সে জাদুকর, যেখানে নয়, সেখানে সে নীরব বা এমনকি ক্ষতিকর।

প্র ০৫একটি out-of-order core-এ মেপে পাওয়া stall সূত্রের হিসাবের চেয়ে অনেক কম এলো। সূত্রটি কি ভুল? কত miss একসাথে উড়তে পারে, তা কে ঠিক করে?

সূত্রটি ভুল নয়, তার অনুমানগুলো এই মেশিনে খাটে না। Stall = access × miss rate × penalty ধরে নেয় প্রতিটি miss আলাদা আর CPU পুরো সময় দাঁড়িয়ে। Out-of-order core-এ একটি load miss করলে ROB-এর মাথা আটকে যায় ঠিকই, কিন্তু পেছনের স্বাধীন instruction চলতে থাকে — আর তাদের load-ও miss করলে সেই miss-গুলো প্রথমটির সাথে একই সময়ে চলে। চারটি miss একসাথে চললে চারটির penalty প্রায় একটির সময়েই শেষ। তাই কিছু বইয়ে “miss penalty”-র বদলে “non-overlapped miss latency” ব্যবহারের পরামর্শ দেওয়া হয়।

কতগুলো miss একসাথে চলতে পারে, তা কয়েকটি সীমার মধ্যে সবচেয়ে ছোটটি। এক, instruction window বা ROB-এর আকার: miss-এর load-টি মাথায় আটকে থাকলে window ভরে গেলে নতুন instruction ঢুকতে পারে না (পর্ব ৪০-এর আলোচনা)। দুই, MSHR-এর সংখ্যা: প্রতিটি অসমাপ্ত miss একটি MSHR দখল করে; সব ভরা থাকলে নতুন miss অপেক্ষা করে। তিন, load queue-র আকার আর মেমরি-ব্যবস্থার নিজের সীমা (bank, channel). চার, প্রোগ্রামের নিজের ধরন — dependent load-এর শৃঙ্খলে MLP থাকেই না।

এখানে Little-এর সূত্রের একটি রূপ কাজে লাগে: একসাথে উড়ন্ত অনুরোধ = bandwidth × latency. ধরুন মেমরির latency ১০০ ns, আর একটি core সেকেন্ডে ১০ GB-র মতো bandwidth চায়, ৬৪ byte-এর block-এ — তাহলে সেকেন্ডে প্রায় ১৫.৬ কোটি block, অর্থাৎ যেকোনো মুহূর্তে প্রায় ১৫-১৬টি miss উড়ন্ত থাকতে হবে। MSHR তার চেয়ে কম হলে সেই bandwidth কখনো পাওয়া যাবে না — একক core-এর মেমরি-bandwidth প্রায়ই এই কারণেই সীমিত বলে বর্ণিত।

পরীক্ষার জন্য তাই দুটি কথা মনে রাখবেন: প্রশ্নে বলা না থাকলে সরল সূত্রটি ব্যবহার করবেন (in-order, blocking ধরে), আর বাস্তব মেশিন নিয়ে কথা হলে সেটি একটি ঊর্ধ্বসীমা। Pointer chase-এর মতো কোডে বাস্তবতা ঊর্ধ্বসীমার কাছাকাছি, ক্রমানুসারী scan-এ অনেক নিচে।

মূল উপলব্ধি: আধুনিক মেমরি-ব্যবস্থায় latency-র চেয়ে বড় প্রশ্ন হলো কত অনুরোধ একসাথে চালানো যায় — MLP-ই latency আর bandwidth-এর সেতু।

অনুশীলন

পরীক্ষার ধাঁচে: stall-সহ CPI, দুই স্তরের AMAT, local ও global miss rate, write-ট্রাফিক গোনা আর calculator-এর আউটপুট অনুমান। আগে নিজে চেষ্টা করুন; প্রতিটি উত্তর এই পর্বের calculator বা simulator বদলে চালিয়ে মেলানো।

  1. I-cache আর D-cache-এর miss দিয়ে CPI: একটি প্রসেসরের CPIbase ১.২, I-cache miss rate ১.৫%, D-cache miss rate ৬%, ৪০% instruction load/store, miss penalty ৮০ cycle. (ক) Stall-সহ CPI কত? (খ) Perfect cache কত গুণ দ্রুত হতো? (গ) একটি দ্রুত মেমরি দিয়ে penalty ৪০ cycle করলে CPI কত হবে?

    (ক) I-stall = ১ × ০.০১৫ × ৮০ = ১.২; D-stall = ০.৪ × ০.০৬ × ৮০ = ১.৯২. CPI = ১.২ + ১.২ + ১.৯২ = ৪.৩২.

    (খ) Speedup = ৪.৩২ / ১.২ = ৩.৬ গুণ — সময়ের প্রায় ৭২% (৩.১২ / ৪.৩২) মেমরির অপেক্ষা।

    (গ) Penalty অর্ধেক হলে দুটি stall-ও অর্ধেক: ০.৬ + ০.৯৬. CPI = ১.২ + ০.৬ + ০.৯৬ = ২.৭৬. (Calculator-এ cpiWithStalls("x", 1.2, 40, 0.015, 0.06, 0.4) লিখে মেলাতে পারেন।)

  2. দুই স্তরের AMAT আর local/global miss rate: একটি প্রোগ্রামে ১০,০০০টি মেমরি access; L1-এ ৫০০টি miss, আর তাদের মধ্যে L2-তেও miss ১০০টি। L1 hit time ২ cycle, L2 hit time ১৫ cycle, মেমরি ১৫০ cycle. প্রতি instruction-এ গড়ে ১.৫টি মেমরি access. (ক) L1-এর miss rate, L2-এর local ও global miss rate কত? (খ) AMAT কত, আর L2 না থাকলে কত হতো? (গ) প্রতি instruction-এ hit time-এর বাইরে কত stall cycle?

    (ক) L1 miss rate = ৫০০ / ১০,০০০ = ৫%. L2 local = ১০০ / ৫০০ = ২০% (L2-তে পৌঁছেছে ৫০০টি)। L2 global = ১০০ / ১০,০০০ = ১% = ০.০৫ × ০.২০.

    (খ) AMAT = ২ + ০.০৫ × (১৫ + ০.২ × ১৫০) = ২ + ০.০৫ × ৪৫ = ৪.২৫ cycle. L2 ছাড়া: ২ + ০.০৫ × ১৫০ = ৯.৫ cycle, অর্থাৎ L2 access-কে প্রায় ২.২৪ গুণ দ্রুত করে।

    (গ) প্রতি access-এ hit time-এর বাইরের অংশ ০.০৫ × ৪৫ = ২.২৫ cycle; প্রতি instruction-এ ১.৫ × ২.২৫ = ৩.৩৭৫ cycle. সাধারণ ভুল: এখানে global miss rate (১%) সরাসরি ভেতরের বন্ধনীতে বসানো — তাহলে মেমরির অংশ দুবার কমে যায়।

  3. Write-ট্রাফিক হাতে গোনা: Direct-mapped cache, ২টি line, ১৬ byte block, ৪ byte word (index = ঠিকানার bit ৪). শুরুতে cache খালি। Trace: W 0x00, W 0x04, R 0x10, W 0x08, W 0x20, R 0x04, W 0x24. (ক) WT + NWA-তে hit, block-পড়া আর word-লেখা কত? (খ) WB + WA-তে hit, block-পড়া, block-write-back, আর শেষে কয়টি dirty line? (গ) কোন নীতি এই trace-এ কম byte লিখল, আর কেন?

    Block: 0x00–0x0F (index 0), 0x10–0x1F (index 1), 0x20–0x2F (index 0) — প্রথম আর তৃতীয় block একই line-এর জন্য লড়ে।

    (ক) WT + NWA: W 0x00, W 0x04 miss (কিছু বসে না) → ২টি word-লেখা; R 0x10 miss → block-পড়া ১; W 0x08 miss (line 0 খালি) → লেখা; W 0x20 miss → লেখা; R 0x04 miss → block-পড়া ২ (line 0 = block 0x00); W 0x24 miss (line 0-এ 0x00) → লেখা। মোট: hit ০, block-পড়া ২, word-লেখা ৫ (২০ byte).

    (খ) WB + WA: W 0x00 miss → পড়া ১, dirty; W 0x04 hit; R 0x10 miss → পড়া ২; W 0x08 hit; W 0x20 miss, victim 0x00 dirty → write-back ১, পড়া ৩, dirty; R 0x04 miss, victim 0x20 dirty → write-back ২, পড়া ৪, clean; W 0x24 miss, victim clean → পড়া ৫, dirty. মোট: hit ২, block-পড়া ৫, write-back ২, শেষে dirty ১টি — লেখা ২ × ১৬ = ৩২ byte, flush-সহ ৪৮ byte.

    (গ) এখানে WT + NWA কম লিখেছে (২০ বনাম ৪৮ byte), আর কম পড়েছেও। কারণ index 0-এ দুটি block-এর টানাটানি (conflict): প্রতিটি dirty block একবার-দুবার লেখার পরই সরে যায়, তাই write-back-এর “অনেক লেখা, একবার পাঠানো”-র সুবিধা পায় না। অংশ ২-এর simulator-এর ট্রেস বদলে মেলাতে পারেন। শিক্ষা: কোনো নীতি সব trace-এ জেতে না; write-back জেতে যখন block cache-এ থাকা অবস্থায় বহুবার লেখা হয়।

  4. আউটপুট অনুমান: অংশ ৩-এর cpi_amat.js-এ L2-এর উদাহরণে global miss rate 0.005-এর বদলে 0.0025 করা হলো (আরও বড় L2; কোডের চারটি জায়গাতেই, যেখানে 0.005 লেখা), বাকি সব একই। চালানোর আগে হাতে বলুন: (ক) “L1 + L2” লাইনে CPI কত আসবে? (খ) L2-এর local miss rate কত দেখাবে? (গ) শেষ লাইনের speedup কত? (ঘ) Global miss rate শূন্য করলেও CPI কত-র নিচে নামবে না, আর কেন?

    (ক) CPI = ১ + ০.০২ × ২০ + ০.০০২৫ × ৪০০ = ১ + ০.৪ + ১ = ২.৪; লাইনটি ছাপাবে L1 + L2 : CPI = 1 + 0.02 x 20 + 0.0025 x 400 = 2.4.

    (খ) Local = ০.০০২৫ / ০.০২ = ০.১২৫ (১২.৫%); যাচাই: ০.০২ × (২০ + ০.১২৫ × ৪০০) = ০.০২ × ৭০ = ১.৪, আর ১ + ১.৪ = ২.৪.

    (গ) Speedup = ৯ / ২.৪ = ৩.৭৫ গুণ (ছাপা হবে 3.75x).

    (ঘ) ১.৪-এর নিচে নয়: প্রতিটি L1 miss অন্তত L2-এর ২০ cycle নেয়, তাই ০.০২ × ২০ = ০.৪ stall থেকেই যায়। এরপর আরও উন্নতি চাইলে L1-এর miss rate বা L2-এর hit time কমাতে হবে — বড় L2 আর সাহায্য করবে না। এটাই AMAT-এর সূত্রের গঠন থেকে আসা সীমা।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমWrite-back + write-allocate cache-এ একটি store miss হলো, আর victim line dirty. ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 🔍 Index দিয়ে line বাছা আর tag মেলানো — মিলল না, miss
  2. 🏷️ Victim line-এর dirty bit দেখা — 1
  3. 📤 Victim block নিচের স্তরে লেখা (বা write-back buffer-এ রাখা)
  4. 📥 নতুন block নিচের স্তর থেকে আনা, tag আর valid বসানো
  5. ✍️ Store-এর word block-এ মেশানো আর dirty = 1

একটি প্রসেসরের CPIbase ১.৫, I-cache miss rate ১%, D-cache miss rate ৫%, ৩০% instruction load/store, miss penalty ৫০ cycle. Stall-সহ CPI কত?

  • ২.০
  • ৪.৫
  • ২.৭৫
  • ২.৪

L1: hit time ১ cycle, miss rate ১০%. L2: hit time ১০ cycle, local miss rate ২০%. Main memory ১০০ cycle. AMAT কত?

  • ১১ cycle
  • ৪ cycle
  • ২ cycle
  • ৩১ cycle

১০০০টি মেমরি access-এ L1-এ ৪০টি miss হলো, আর সেই ৪০টির মধ্যে L2-তেও miss ১০টি। L2-এর local আর global miss rate কত?

  • local ১%, global ৪%
  • local ১%, global ২৫%
  • local ৪%, global ১%
  • local ২৫%, global ১%

Write-back cache-এ dirty bit-এর কাজ কী?

  • Line সরানোর সময় block নিচের স্তরে লিখতে হবে কি না, তা জানানো
  • Line-এ বৈধ ডেটা আছে কি না, তা জানানো
  • কোন line সবচেয়ে কম সাম্প্রতিক ব্যবহৃত, তা জানানো
  • Write-through cache-এ প্রতিটি store-এর পর নিচের স্তর হালনাগাদ করা

একটি প্রসেসরে memory stall-এর কারণে CPI ৫.৪৪ (CPIbase ২, miss penalty ১০০ cycle). মেমরি একই রেখে clock rate দ্বিগুণ করলে miss penalty ২০০ cycle হয় আর CPI ৮.৮৮. নতুন প্রসেসর আগেরটির চেয়ে কত গুণ দ্রুত?

  • ২ গুণ
  • প্রায় ১.২৩ গুণ
  • প্রায় ০.৬১ গুণ (ধীর)
  • প্রায় ১.৬৩ গুণ

একটি non-blocking cache-এ MSHR (miss status holding register)-এর কাজ কী?

  • সরানো dirty block জমিয়ে রাখা, যাতে পরে মেমরিতে লেখা যায়
  • কোন way-তে hit হবে তা অনুমান করা
  • অসমাপ্ত প্রতিটি miss-এর ঠিকানা আর অপেক্ষারত instruction-এর হিসাব রাখা, যাতে miss চলাকালীন অন্য access চলে
  • ঠিকানার stride চিনে পরের block আগেভাগে আনা

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. Write hit: write-through সব store নিচেও পাঠায় (write buffer দিয়ে; আসার হার বেশি হলে buffer ভরে CPU থামে); write-back শুধু cache-এ লেখে, dirty bit রাখে, সরানোর সময় একবার লেখে।
  2. Write miss: write-allocate block আনে, no-write-allocate সরাসরি নিচে লেখে; জুটি WB + WA আর WT + NWA. Dirty eviction miss penalty বাড়ায় (৩০% dirty-তে ১০০ → ১৩০ cycle), write-back buffer তা লুকায়।
  3. CPI = CPIbase + Σ (access/instr) × miss rate × penalty. ক্লাসিক উদাহরণ: ২ → ৫.৪৪, perfect cache ২.৭২ গুণ; clock দ্বিগুণে মাত্র প্রায় ১.২৩ গুণ।
  4. AMAT = HTL1 + MRL1 × (HTL2 + MRL2 × MP). MRL2 local; global = MRL1 × local. L2 যোগে CPI ৯ → ৩.৪ (প্রায় ২.৬ গুণ)। L1 লক্ষ্য দ্রুত hit, L2/L3 লক্ষ্য কম miss.
  5. আধুনিক core: non-blocking cache, MSHR, prefetcher আর out-of-order মিলে একাধিক miss একসাথে (MLP) — stall-এর সূত্র ঊর্ধ্বসীমা; pointer chase সেই সুবিধা পায় না। মাপতে performance counter.

পরের পর্বে: Hardware যা পারে, তা দেখা হলো — এবার প্রোগ্রামারের পালা। Loop-এর ক্রম বদলে, ডেটা টুকরো টুকরো (blocking) করে আর false sharing এড়িয়ে কীভাবে miss rate নিজে কমাবেন — Cache-বান্ধব প্রোগ্রাম।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান