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

Cache-বান্ধব প্রোগ্রাম — loop-এর ক্রম, blocking আর false sharing

Cache-Aware Programming — Row-major Layout, Loop Interchange, the Six Matrix-Multiply Orders, Blocking (Tiling), Data Layout, Prefetching, False Sharing and Honest Measurement

Taught in Bangla, this lesson teaches cache-aware programming: row-major addresses and counting misses per element, loop interchange, the six loop orders of matrix multiplication, blocking or tiling with tile-size selection, transposes, AoS versus SoA, hot/cold splitting, padding and alignment, pointer chasing, prefetching, loop fusion and fission, false versus true sharing, and honest benchmarking, with live C programs and a miss-model simulator.

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

  • Row-major বিন্যাসে a[i][j]-এর ঠিকানা আর line নম্বর বের করা, আর একটি loop nest-এর প্রতি উপাদানে miss হাতে গোনা
  • Matrix গুণের ছয়টি loop-ক্রমের miss-বিশ্লেষণ — একটি simulator দিয়ে মিলিয়ে দেখা
  • Cache-এর মাপ থেকে tile-এর মাপ বাছাই, blocking-এর miss-সূত্রের প্রমাণ, আর transpose-এর উদাহরণ
  • AoS বনাম SoA, hot/cold field ভাগ, padding, cache-line alignment, pointer chasing, prefetch আর loop fusion/fission
  • False sharing বনাম true sharing, আর পারফরম্যান্স মাপার সময় যেসব ফাঁদে লোকে সবচেয়ে বেশি পড়ে

১ · প্রোগ্রামারের চোখে cache — line, locality আর row-major ঠিকানা

পর্ব ৪৪ থেকে ৪৭-এ আপনি cache-কে hardware-এর দিক থেকে দেখেছেন: কেন একটি ছোট দ্রুত মেমরি এত কাজ করে (পর্ব ৪৪), ঠিকানা কীভাবে tag, index আর block offset-এ ভাগ হয় (পর্ব ৪৫), set-associative cache আর LRU (পর্ব ৪৬), আর AMAT ও multi-level cache-এর হিসাব (পর্ব ৪৭). এই পর্বে প্রশ্নটা উল্টো দিক থেকে: hardware যেমন আছে তেমনই রেখে, আমি কোড কীভাবে লিখলে কম miss হবে?

প্রোগ্রামারের জন্য পুরো cache-তত্ত্ব তিনটি কথায় নামিয়ে আনা যায়। প্রথম, মেমরি আর cache-এর মধ্যে আনা-নেওয়ার একক একটি cache line — আজকের প্রায় সব x86-64 আর ARM chip-এ ৬৪ byte. একটি double পড়তে চাইলেও পুরো ৬৪ byte আসে, অর্থাৎ পাশের আরও ৭টি double. দ্বিতীয়, spatial locality: একটি byte ছুঁলে তার পাশেরগুলোও শিগগির ছোঁয়া হবে — তাহলে আনা line-এর পুরোটা কাজে লাগে। তৃতীয়, temporal locality: একটি ডেটা আবার ব্যবহার হবে শিগগিরই — তাহলে সেটি তখনো cache-এ থাকে।

পুরান ঢাকার একটি পাইকারি দোকানের গুদাম ভাবুন। কর্মচারী একটি একটি সাবান আনেন না — আনেন ১২টি সাবানের পুরো কার্টন (cache line); কাউন্টারের তাকে (cache) জায়গা মাত্র কয়েকটি কার্টনের। ক্রেতারা যদি একই কার্টনের সাবান পরপর চান, প্রতি ১২টি বিক্রিতে গুদামে মাত্র একবার যেতে হয় (spatial locality). আর তাক থেকে একটি কার্টন সরিয়ে দেওয়ার পরই যদি আবার সেটি লাগে, আবার গুদামে দৌড় (temporal locality-র অভাব). কর্মচারী (hardware) নিয়ম বদলাতে পারেন না; কিন্তু অর্ডারের ক্রম (আপনার loop) বদলে দৌড় অনেক কমানো যায়।

Miss-এর তিন ধরন (পর্ব ৪৫–৪৬) প্রোগ্রামারের ভাষায়: compulsory miss — প্রথমবার ছোঁয়ার miss, এড়ানো যায় না, তবে প্রতিটি line-এর সব byte ব্যবহার করে সংখ্যায় কমানো যায়; capacity miss — যে ডেটা একসাথে লাগছে (working set) তা cache-এর চেয়ে বড়; সমাধান working set ছোট করা, যেমন blocking; conflict miss — জায়গা আছে, কিন্তু কয়েকটি line একই set-এ পড়ছে; সমাধান প্রায়ই array-র মাপ ২-এর ঘাত থেকে একটু সরানো। (চতুর্থ ধরন coherence miss আসবে পর্ব ৫৩-এ; এই পর্বের false sharing তারই একটি রূপ।)

Row-major বিন্যাস — a[i][j] মেমরির কোথায়

C-তে double a[R][M] মেমরিতে একটি লম্বা সারি: প্রথমে row 0-র M-টি উপাদান, তারপর row 1-এর, এভাবে। একে বলে row-major ক্রম। Fortran, MATLAB আর Julia উল্টো — column-major: একটি column-এর উপাদান পাশাপাশি। NumPy দুটোই পারে (ডিফল্ট row-major). তাই “কোন loop ভেতরে রাখব” প্রশ্নের উত্তর ভাষাভেদে উল্টে যায়।

সূত্র

T a[R][M] (প্রতিটি উপাদান S byte, শুরুর ঠিকানা base) হলে: &a[i][j] = base + (i·M + j)·S. Line নম্বর = ঠিকানা ÷ ৬৪ (ভাগফলের পূর্ণসংখ্যা অংশ), line-এর ভেতরের offset = ঠিকানা mod ৬৪. একই row-তে j এক বাড়লে ঠিকানা S byte সরে; i এক বাড়লে সরে M·S byte — এটাই column ধরে হাঁটার stride.

উদাহরণ। double a[100][300], base = 0x1000. তাহলে a[2][5]-এর ঠিকানা = 0x1000 + (২ × ৩০০ + ৫) × ৮ = 0x1000 + ৪৮৪০ = 0x1000 + 0x12E8 = 0x22E8. 0x22E8 মানে দশমিকে ৮৯৩৬; line নম্বর = ৮৯৩৬ ÷ ৬৪ → ১৩৯ (ভাগশেষ ৪০), অর্থাৎ line ১৩৯-এর ৪০ নম্বর byte থেকে শুরু। পাশের a[2][6] একই line-এ (offset ৪৮), কিন্তু a[3][5] ঠিক ২৪০০ byte দূরে — ৩৭টিরও বেশি line পরে।

২ · সারি ধরে না কলাম ধরে — loop interchange আর miss গোনা

সবচেয়ে সরল cache-বান্ধব কৌশল loop interchange: nested loop-এর ক্রম অদলবদল, যাতে ভেতরের loop মেমরিতে পাশাপাশি উপাদান ধরে হাঁটে। দুটি loop দেখুন, দুটিই একই যোগফল দেয়:

for (i = 0; i < N; i++) for (j = 0; j < N; j++) s += a[i][j];   /* ij: সারি ধরে  */
for (j = 0; j < N; j++) for (i = 0; i < N; i++) s += a[i][j];   /* ji: কলাম ধরে */

হিসাব। ধরুন এক line-এ B-টি উপাদান (৬৪-byte line-এ double হলে B = ৮, int হলে ১৬)। ij ক্রমে প্রতিটি line-এর প্রথম উপাদানে একটি compulsory miss, পরের B − ১টি hit: প্রতি উপাদানে ১/B miss — double-এ ০.১২৫. ji ক্রমে পরপর দুটি access M·S byte দূরে, তাই প্রতিটি access নতুন line ছোঁয়। একটি column শেষ করতে N-টি আলাদা line লাগে। পরের column-এর উপাদান ওই একই N-টি line-এ আছে — যদি cache সেগুলো ধরে রাখতে পারে, তাহলে ji-ও ১/B-তে নামে; না পারলে প্রতিটি access miss: প্রতি উপাদানে ১.

নিচের stepper-এ একটি খুদে উদাহরণ: int a[4][8], line ১৬ byte (৪টি int), তাই প্রতিটি row দুটি line-এ — মোট ৮টি line L0–L7. Cache fully associative, LRU (পর্ব ৪৬)। ছকে M = miss, H = hit, . = এখনো ছোঁয়া হয়নি।

অবস্থা (int a[4][8], line = ৪টি int, LRU)
ক্রম
cache-এর মাপ
cache-এ এখন (পুরোনো → নতুন)
miss / access
a[i][j]-এর ফলসারি = i, কলাম = j

            
  1. বিন্যাস। Row-major-এ row 0-র ৮টি int পাশাপাশি — দুটি line, L0 আর L1. তারপর row 1 (L2, L3), এভাবে L7 পর্যন্ত। Cache শুরুতে খালি।
  2. ij, row 0 (cache ২ line). a[0][0] পড়তে L0 এল — miss. a[0][1..3] একই line-এ — তিনটি hit. a[0][4]-এ L1 — miss, তারপর তিনটি hit. ৮টি access-এ ২টি miss.
  3. ij, পুরো array. প্রতি row-এ একই ছবি: ৩২টি access-এ ৮টি miss, প্রতি উপাদানে ১/৪ = ১/B. প্রতিটি line ঠিক একবার আনা হয়েছে — এর কম সম্ভব নয়। Cache মাত্র ২ line হলেও ক্ষতি নেই, কারণ একটি line শেষ না হতেই পরেরটি লাগে না।
  4. ji, column 0 (cache ২ line). a[0][0] → L0, a[1][0] → L2, a[2][0] → L4, a[3][0] → L6: চারটি আলাদা line, চারটিই miss. ২টি জায়গা, তাই L4 এসে L0-কে আর L6 এসে L2-কে বের করে দিল। এখন cache-এ L4 আর L6.
  5. ji, পুরো array (cache ২ line). Column 1-এ a[0][1]-এর জন্য আবার L0 লাগবে — কিন্তু সেটি একটু আগে বের হয়ে গেছে। প্রতিটি access miss: ৩২ / ৩২. প্রতিটি line চারবার আনা হলো, প্রতিবার মাত্র একটি int কাজে লাগল। (একটি simulator-এ দেখা গেছে, cache ৩ line হলেও ফল ৩২ / ৩২ — LRU-তে column-এর line-সংখ্যা cache-এর চেয়ে একটিও বেশি হলে প্রতিটি access miss.)
  6. ji, cache ৪ line. এবার column 0-র চারটি line (L0, L2, L4, L6) একসাথে ধরে। Column ১, ২, ৩ একই line-এ — সব hit. Column 4-এ নতুন চারটি line (L1, L3, L5, L7), তারপর আবার hit. মোট ৮ / ৩২ — ij-এর সমান! নিয়ম: column ধরে হাঁটা তখনই ক্ষতিকর, যখন row-সংখ্যা N cache-এর line-সংখ্যার চেয়ে বেশি. বাস্তবের বড় array-তে তা প্রায় সবসময়ই সত্য।

বাস্তব cache-এ: ২-এর ঘাতের ফাঁদ

বাস্তব L1 fully associative নয়। এই পর্বের C কোড যে runner-এ চলে, সেখানে /sys/devices/system/cpu/cpu0/cache বলে L1 data cache ৩২ KiB, ৮-way, line ৬৪ byte — অর্থাৎ ৬৪টি set. ৩২ KiB = ৫১২টি line, তাই মনে হতে পারে ২৫৬ row-এর একটি double array-র column (২৫৬টি line) আরামে ধরবে। কিন্তু row-এর মাপ ২৫৬ × ৮ = ২০৪৮ byte = ৩২টি line, তাই column-এর পরপর line-এর set index লাফায় ৩২ করে — ৬৪টি set-এর মধ্যে মাত্র ২টি set-এ সব line জড়ো হয়, জায়গা মোট ১৬টি line. একটি set-associative LRU simulator-এ প্রথম ৬৪টি column হাঁটালে ফল:

double array, row-সংখ্যা Nrow-এর দৈর্ঘ্য (উপাদান)প্রতি উপাদানে L1 miss (৩২ KiB, ৮-way)কারণ
২৫৬২৫৬১.০conflict: সব line ২টি set-এ
২৫৬২৬৪ (৮টি বাড়তি ঘর)০.১২৫line সব set-এ ছড়ায়, ধরে যায়
৫১২৫১২১.০conflict: সব line ১টি set-এ
৫১২৫২০০.১২৫৫১২টি line, ঠিক cache-এর মাপ
৪০৯৬৪১০৪১.০capacity: ৪০৯৬টি line ধরার জায়গা নেই

এজন্য বড় সংখ্যাতাত্ত্বিক কোডে অনেক সময় array-র row একটু লম্বা করে বরাদ্দ করা হয় (leading dimension-এ padding). তবে সবচেয়ে নিশ্চিত সমাধান loop interchange — তাহলে column ধরে হাঁটাই লাগে না। এবার আসল যন্ত্রে মেপে দেখা যাক:

// Row-wise vs column-wise sum of the same 2D array (times vary per run and machine)
#include <stdio.h>
#include <time.h>
#define N 2048
static int a[N][N];                          // 2048 x 2048 int = 16 MiB, row-major

static double now_ms(void) {
    struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
    return t.tv_sec * 1e3 + t.tv_nsec / 1e6;
}
static long sum(int by_row) {
    long s = 0;
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++)
            s += by_row ? a[i][j] : a[j][i];   // a[j][i]: next access is 8 KiB away
    return s;
}
int main(void) {
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) a[i][j] = (i + j) % 10;
    double best[2] = {1e9, 1e9};
    long s[2] = {0, 0};
    for (int rep = 0; rep < 3; rep++)        // repeat, keep the fastest run
        for (int k = 0; k < 2; k++) {
            double t0 = now_ms();
            s[k] = sum(k == 0);
            double t = now_ms() - t0;
            if (t < best[k]) best[k] = t;
        }
    printf("sums: row %ld, column %ld\n", s[0], s[1]);
    printf("row-wise   : %7.1f ms\n", best[0]);
    printf("column-wise: %7.1f ms\n", best[1]);
    printf("column / row = %.1f\n", best[1] / best[0]);
    return 0;
}

যাচাইয়ের সময় আউটপুট (সময় প্রতিবার বদলায়):

sums: row 18874378, column 18874378
row-wise   :    11.2 ms
column-wise:    35.6 ms
column / row = 3.2

দুই ক্রমে যোগফল হুবহু এক — শুধু ছোঁয়ার ক্রম আলাদা। Array ১৬ MiB, row ৮ KiB; column ধরে হাঁটায় প্রতিটি access নতুন line আর নতুন 4 KiB page-এ পড়ে, আর একটি column ছোঁয় ২০৪৮টি line, যা L1-এর ৫১২টি line-এ ধরে না। গণনা বলছে line-আনার পরিমাণ ১৬ গুণ (int-এ B = ১৬), কিন্তু মাপা অনুপাত ৩.২ — এই পর্ব লেখার সময় অন্য কয়েকটি চালনায় ২.৯ থেকে ৪.৪. কেন ১৬ নয়? Runner ডিফল্টে -O0-তে compile করে, তাই loop-এর নিজের খরচ দুই ক্রমেই সমানভাবে যোগ হয়; hardware prefetcher (অংশ ৫) কিছু line আগেভাগে আনে; আর অনেক L1 miss L2 বা L3-তেই মেটে। মূল কথা বদলায় না: একই কাজ, শুধু loop-এর ক্রমে কয়েক গুণ পার্থক্য।

৩ · Matrix গুণের ছয় loop-ক্রম — ভেতরের loop-এ কয়টি miss

C = A × B, তিনটিই N × N double, row-major. মূল অঙ্ক C[i][j] += A[i][k] * B[k][j], আর তিনটি loop (i, j, k) যেকোনো ক্রমে সাজানো যায় — ৩! = ৬টি ক্রম, সবগুলো একই ফল দেয় (পূর্ণসংখ্যা-মানের ডেটায় হুবহু; ভগ্নাংশে যোগের ক্রম বদলালে শেষ bit-এ সামান্য পার্থক্য হতে পারে, পর্ব ২৪)। প্রশ্ন: কোনটি cache-বান্ধব?

বিশ্লেষণের কৌশল। ধরে নিন N বড় — এতটাই যে cache একটি column-এর সব line ধরে রাখতে পারে না, এমনকি একটি পুরো row-ও এক loop-চক্র থেকে পরের চক্র পর্যন্ত টেকে না (এটিই পাঠ্যবইয়ের প্রচলিত অনুমান, যেমন Bryant ও O'Hallaron-এর CS:APP বইয়ে)। তখন শুধু ভেতরের loop-এর চলক দেখলেই চলে। একটি array-র সূচকে ভেতরের চলক যদি:

  • দ্বিতীয় (ডানের) সূচক হয় → সারি ধরে হাঁটা, stride ১ → প্রতি iteration-এ ১/B miss (double-এ ০.১২৫);
  • প্রথম (বাঁয়ের) সূচক হয় → column ধরে হাঁটা, stride N → প্রতি iteration-এ ১ miss;
  • কোনো সূচকেই না থাকে → ভেতরের loop জুড়ে একই উপাদান, register-এ রাখা যায় → ০ miss.

এখন ছয়টি ক্রমকে ভেতরের চলক অনুযায়ী তিন জোড়ায় ভাগ করুন। ভেতরে k (ijk, jik): A[i][k] সারি ধরে (০.১২৫), B[k][j] column ধরে (১), C[i][j] স্থির (০) — মোট ১.১২৫. ভেতরে i (jki, kji): A[i][k] column ধরে (১), B[k][j] স্থির, C[i][j] column ধরে (১) — মোট ২. ভেতরে j (kij, ikj): A[i][k] স্থির, B[k][j] আর C[i][j] দুটিই সারি ধরে — মোট ০.২৫.

ক্রম (বাইরে → ভেতরে)ভেতরের loop-এ load / storeABCমোট miss
ijk, jik২ load, ০ store (যোগফল register-এ)০.১২৫১০১.১২৫
jki, kji২ load, ১ store১০১২
kij, ikj২ load, ১ store০০.১২৫০.১২৫০.২৫

লক্ষ করুন, সবচেয়ে কম miss-এর ক্রম (ikj) প্রতি iteration-এ একটি store বেশি করে, তবু সাধারণত জেতে — কারণ miss-এর দাম কয়েক দশ থেকে কয়েকশো cycle, আর একটি hit-করা store প্রায় বিনামূল্যে। এখন অনুমানগুলো সত্যি কি না, তা একটি simulator দিয়ে মিলিয়ে দেখা যাক। নিচের মডেল প্রতিটি access-কে একটি fully associative LRU cache-এ চালায়, আর প্রতিটি matrix-এর miss আলাদা গোনে। দুটি মাপ: ৪ line (একটি row-ও ধরে না — পাঠ্যবইয়ের অনুমান) আর ৩২ line (৪টি row ধরে)।

// Cache-miss model for C = A x B (double, 64 x 64, row-major), fully associative LRU cache
const N = 64, LINE = 64, PER = LINE / 8;          // 64 B line = 8 doubles; one row = 8 lines
function run(order, LINES, T) {
  const lru = new Map(), miss = { A: 0, B: 0, C: 0 };
  const touch = (m, base, r, c) => {              // each matrix starts on its own line
    const line = base + Math.floor((r * N + c) / PER);
    if (lru.has(line)) lru.delete(line);
    else { miss[m]++; if (lru.size === LINES) lru.delete(lru.keys().next().value); }
    lru.set(line, 1);                             // Map order = least to most recently used
  };
  const body = (i, j, k) => { touch("A", 0, i, k); touch("B", 1e6, k, j); touch("C", 2e6, i, j); };
  if (T) {                                        // blocked: T x T tiles, ikj inside a tile
    for (let ii = 0; ii < N; ii += T) for (let jj = 0; jj < N; jj += T)
      for (let kk = 0; kk < N; kk += T) for (let i = ii; i < ii + T; i++)
        for (let k = kk; k < kk + T; k++) for (let j = jj; j < jj + T; j++) body(i, j, k);
  } else {
    const v = [0, 0, 0], p = [..."ijk"].map((c) => order.indexOf(c));  // where i, j, k sit
    for (v[0] = 0; v[0] < N; v[0]++) for (v[1] = 0; v[1] < N; v[1]++)     // outer, middle
      for (v[2] = 0; v[2] < N; v[2]++) body(v[p[0]], v[p[1]], v[p[2]]);   // inner loop
  }
  const f = (n) => (n / N ** 3).toFixed(3);
  return `  ${(T ? "tiled T=" + T : order).padEnd(10)} A ${f(miss.A)}  B ${f(miss.B)}` +
         `  C ${f(miss.C)}  total ${f(miss.A + miss.B + miss.C)}`;
}
for (const L of [4, 32]) {
  console.log(`cache = ${L} lines (${L * LINE} B): misses per inner iteration`);
  for (const o of ["ijk", "jik", "jki", "kji", "kij", "ikj"]) console.log(run(o, L, 0));
}
console.log("cache = 32 lines, tiled (tile ii, jj, kk; then i, k, j):");
for (const T of [8, 16]) console.log(run("", 32, T));

আসল আউটপুট:

cache = 4 lines (256 B): misses per inner iteration
  ijk        A 0.125  B 1.000  C 0.002  total 1.127
  jik        A 0.125  B 1.000  C 0.016  total 1.141
  jki        A 1.000  B 0.016  C 1.000  total 2.016
  kji        A 1.000  B 0.002  C 1.000  total 2.002
  kij        A 0.016  B 0.125  C 0.125  total 0.266
  ikj        A 0.002  B 0.125  C 0.125  total 0.252
cache = 32 lines (2048 B): misses per inner iteration
  ijk        A 0.125  B 1.000  C 0.002  total 1.127
  jik        A 0.125  B 1.000  C 0.016  total 1.141
  jki        A 1.000  B 0.016  C 1.000  total 2.016
  kji        A 1.000  B 0.002  C 1.000  total 2.002
  kij        A 0.016  B 0.002  C 0.125  total 0.143
  ikj        A 0.002  B 0.125  C 0.002  total 0.129
cache = 32 lines, tiled (tile ii, jj, kk; then i, k, j):
  tiled T=8  A 0.016  B 0.016  C 0.002  total 0.033
  tiled T=16 A 0.008  B 0.125  C 0.008  total 0.141

৪ line-এর cache-এ তিন জোড়া প্রায় হুবহু টেবিলের মতো: ১.১২৭–১.১৪১, ২.০০২–২.০১৬ আর ০.২৫২–০.২৬৬. ছোট বাড়তি সংখ্যাগুলো বাইরের loop-এর খরচ: ০.০০২ = ১ ÷ ৫১২ = ১/(B·N) — যেমন ijk-এ C[i][j]-এর row প্রতি j-চক্রে একবার এক উপাদান এগোয়; আর ০.০১৬ = ১ ÷ ৬৪ = ১/N — যেমন jik-এ C[i][j] মাঝের loop-এ column ধরে হাঁটে। N বড় হলে দুটিই শূন্যের দিকে যায়।

৩২ line-এর cache-এ একটি আকর্ষণীয় পরিবর্তন: kij-তে B-এর miss ০.১২৫ থেকে ০.০০২-এ নেমেছে, আর ikj-তে C-এর miss — মোট ০.১৪৩ আর ০.১২৯. কারণ এখন একটি row (৮টি line) মাঝের loop জুড়ে cache-এ টিকে যায়: kij-তে B-এর row k পুরো i-loop-এ একই, ikj-তে C-এর row i পুরো k-loop-এ একই। কিন্তু column-নির্ভর চারটি ক্রম একটুও বদলায়নি — ৩২টি line-এর cache একটি column-এর ৬৪টি line ধরতে পারে না। শিক্ষা: পাঠ্যবইয়ের টেবিল একটি অনুমানের ফল; cache আর N বদলালে সংখ্যা বদলায়, কিন্তু ভালো-মন্দের ক্রম একই থাকে। Tiled সারি দুটির অর্থ পরের অংশে।

৪ · Blocking বা tiling — working set-কে cache-এর মাপে কাটা

ikj ক্রমেও একটি সমস্যা থেকে যায়। C-এর প্রতিটি row বানাতে পুরো B matrix একবার পড়তে হয়, আর N বড় হলে B (N² × ৮ byte) cache-এ ধরে না — তাই B প্রতিবার মেমরি থেকে নতুন করে আসে, মোট N বার। Temporal locality আছে (B-এর প্রতিটি উপাদান N বার লাগে), কিন্তু পুনর্ব্যবহারের মাঝে এত বেশি ডেটা ছোঁয়া হয় যে সেটি ততক্ষণে cache থেকে সরে গেছে।

Loop tiling (blocking) এর সমাধান: matrix-গুলোকে T × T মাপের ছোট টুকরোয় (tile) ভাগ করে কাজের ক্রম এমনভাবে সাজানো, যাতে একসাথে শুধু কয়েকটি tile লাগে — আর সেগুলো cache-এ ধরে। গণিত বদলায় না: block matrix গুণের নিয়মে CIJ = ΣK AIK · BKJ, যেখানে প্রতিটি পদ নিজেই একটি ছোট matrix গুণ।

রেস্তোরাঁর রান্নাঘরে একশো প্লেট বিরিয়ানি সাজাতে হবে, আর প্রতিটি প্লেটে দশ রকম উপকরণ। বাবুর্চির হাতের কাছে টেবিলে জায়গা মাত্র তিনটি পাত্রের। একটি একটি প্লেট ধরে সাজালে প্রতিটি প্লেটের জন্য দশটি পাত্রই ভাঁড়ার থেকে আনা-নেওয়া করতে হয়। বুদ্ধিমান বাবুর্চি দশটি প্লেটের একটি দল নেন, দুটি পাত্র টেবিলে এনে দলের সব প্লেটে দেন, তারপর পরের দুটি — যাতায়াত দশ ভাগের এক ভাগ। Tile ঠিক এই “দল”.

নিচের stepper-এ ৪ × ৪ matrix, T = ২. Tile-এর loop ক্রম ii → jj → kk, আর প্রতিটি tile-এর ভেতরে ikj — অর্থাৎ একটি C-tile পুরো শেষ করে তবে পরেরটিতে যাওয়া।

অবস্থা (N = ৪, T = ২)
tile ধাপ (ii, jj, kk)
এখন যা হচ্ছে
cache-এ যা দরকার
tile-গুণ শেষ
tile-এর ছবিপ্রতিটি tile ২ × ২

            
  1. সমস্যা। ikj ক্রমে C-এর row 0 বানাতে A-র row 0 আর B-এর চারটি row-ই লাগে; row 1 বানাতে আবার পুরো B. B-এর প্রতিটি উপাদান N বার লাগে, কিন্তু দুবারের মাঝে পুরো B ছোঁয়া হয়। B cache-এর চেয়ে বড় হলে প্রতিবার নতুন করে আনতে হয়।
  2. Tile-এ ভাগ। প্রতিটি matrix ৪টি ২ × ২ tile-এ ভাগ হলো। Block গুণের নিয়মে C00 = A00·B00 + A01·B10 — সাধারণ গুণের মতোই, শুধু প্রতিটি “উপাদান” এখন একটি ছোট matrix.
  3. প্রথম tile-গুণ। (ii, jj, kk) = (০, ০, ০): C00 += A00 × B00. এখানে T³ = ৮টি গুণ-যোগ হয়, আর ছোঁয়া হয় মাত্র ৩টি tile — ৩T² = ১২টি উপাদান। এই ১২টি cache-এ ধরলে প্রতিটি উপাদান tile-এর ভেতরে T বার কাজে লাগে।
  4. দ্বিতীয় tile-গুণ — C00 শেষ। kk = ২: C00 += A01 × B10. C00 tile আগের ধাপ থেকেই cache-এ — নতুন এল শুধু A01 আর B10. kk-loop সবচেয়ে ভেতরের tile-loop বলে C-এর প্রতিটি tile মাত্র একবার আনতে হয়।
  5. পরের C-tile. jj = ২: C01 = A00·B01 + A01·B11. A00 আর A01 একটু আগেই লেগেছিল; cache বড় হলে আবার আনতেও হয় না। এভাবে প্রতিটি tile অনেকবার ব্যবহার হয়, দুই ব্যবহারের মাঝে অল্প ডেটা ছোঁয়া হয়।
  6. হিসাব। মোট (N/T)³ = ৮টি tile-গুণ; প্রতিটিতে একটি A-tile আর একটি B-tile আনতে হয় (প্রতিটি T²/B line), আর প্রতিটি C-tile একবার। মোট miss ≈ ২N³/(B·T) + N²/B. Naive ikj-এ যেখানে প্রায় ২N³/B, এখানে T গুণ কম — T যত বড়, তত ভালো, যতক্ষণ ৩টি tile cache-এ ধরে।
সূত্র আর প্রমাণ

অনুমান: ৩টি T × T tile একসাথে cache-এ ধরে (৩T²·S ≤ cache-এর মাপ), N ভাগ যায় T দিয়ে, আর tile-loop-এর ক্রম ii → jj → kk. তাহলে মোট miss ≈ ২N³ ÷ (B·T) + N² ÷ B, আর ভেতরের প্রতি iteration-এ (মোট N³টি) miss ≈ ২ ÷ (B·T) + ১ ÷ (B·N).

যুক্তি। Tile-গুণের সংখ্যা (N/T)³. প্রতিটিতে একটি A-tile আর একটি B-tile লাগে; ৩টি tile ধরে বলে tile-গুণের ভেতরে প্রতিটি line একবারই আসে — compulsory, T²/B line করে। তাই A আর B মিলে (N/T)³ × ২T²/B = ২N³/(B·T). একটি নির্দিষ্ট (ii, jj)-এর সব kk-তে C-tile একই, আর সেটি সবসময় cache-এ থাকে, তাই C-এর প্রতিটি line মোট একবার: N²/B. মোট N³ দিয়ে ভাগ করলে প্রতি iteration-এর সূত্র। ■

Simulator-এর সাথে মেলানো যাক। ওপরের মডেলে cache ৩২ line = ২০৪৮ byte = ২৫৬টি double. ৩T² ≤ ২৫৬ ⇒ T ≤ ৯.২, আর ৬৪-কে ভাগ করে এমন T = ৮. সূত্র: ২ ÷ (৮ × ৮) + ১ ÷ (৮ × ৬৪) = ০.০৩১২৫ + ০.০০১৯৫ ≈ ০.০৩৩ — মডেলের “tiled T=8” সারি হুবহু তাই বলছে (A ০.০১৬, B ০.০১৬, C ০.০০২)। এটি ikj-এর ০.১২৯-এর প্রায় চার ভাগের এক ভাগ। আর T = ১৬ নিলে ৩ × ২৫৬ = ৭৬৮টি double লাগে, যা ২৫৬-এর তিন গুণ: tile আর cache-এ ধরে না, B-এর miss ফিরে যায় ০.১২৫-এ, মোট ০.১৪১ — tile বেশি বড় হলে লাভ পুরো উধাও।

আসল cache থেকে tile-এর মাপ — একটি পূর্ণ উদাহরণ

Runner-এর L1 data cache ৩২ KiB = ৩২৭৬৮ byte, double ৮ byte. শর্ত ৩T² × ৮ ≤ ৩২৭৬৮ ⇒ T² ≤ ১৩৬৫.৩ ⇒ T ≤ ৩৬.৯. N = ৫১২-কে ভাগ করে এমন সবচেয়ে বড় ২-এর ঘাত T = ৩২: তিনটি tile = ৩ × ৩২ × ৩২ × ৮ = ২৪৫৭৬ byte = ২৪ KiB, cache-এর তিন-চতুর্থাংশ। পুরো ৩২ KiB না ভরানোর কারণ: cache-এ অন্য ডেটাও থাকে, আর ২-এর ঘাতের stride-এ কিছু conflict miss হয়ই। সূত্রমতে প্রতি iteration-এ miss ≈ ২ ÷ (৮ × ৩২) ≈ ০.০০৭৮ — ikj-এর ০.২৫-এর ৩২ ভাগের এক ভাগ। বাস্তবে সেরা T খুঁজতে কয়েকটি মান (১৬, ৩২, ৪৮, ৬৪) মেপে দেখা হয়; সূত্র শুধু শুরু করার জায়গা দেয়।

// Naive (ijk) vs blocked matrix multiply: same result, different cache behaviour (times vary)
#pragma GCC optimize("O2")               // compile this file at -O2 so memory, not overhead, dominates
#include <stdio.h>
#include <string.h>
#include <time.h>
#define N 512
#define T 32                             // tile: 3 tiles x 32 x 32 x 8 B = 24 KiB < 32 KiB L1d
static double A[N][N], B[N][N], C1[N][N], C2[N][N];
static double now_ms(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
                             return t.tv_sec * 1e3 + t.tv_nsec / 1e6; }
int main(void) {
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) { A[i][j] = (i + j) % 7; B[i][j] = (i * j) % 5; }
    double t0 = now_ms();
    for (int i = 0; i < N; i++)          // naive ijk: inner loop walks DOWN a column of B
        for (int j = 0; j < N; j++) {
            double s = 0;
            for (int k = 0; k < N; k++) s += A[i][k] * B[k][j];
            C1[i][j] = s;
        }
    double t1 = now_ms();
    for (int ii = 0; ii < N; ii += T)     // blocked: finish a T x T tile of C before moving on
        for (int jj = 0; jj < N; jj += T)
            for (int kk = 0; kk < N; kk += T)
                for (int i = ii; i < ii + T; i++)
                    for (int k = kk; k < kk + T; k++) {
                        double aik = A[i][k];
                        for (int j = jj; j < jj + T; j++) C2[i][j] += aik * B[k][j];
                    }
    double t2 = now_ms();
    printf("identical results: %s\n", memcmp(C1, C2, sizeof C1) == 0 ? "yes" : "NO");
    printf("naive ijk : %6.0f ms\nblocked   : %6.0f ms\n", t1 - t0, t2 - t1);
    printf("naive / blocked = %.1f\n", (t1 - t0) / (t2 - t1));
    return 0;
}

যাচাইয়ের সময় আউটপুট (সময় প্রতিবার বদলায়):

identical results: yes
naive ijk :    279 ms
blocked   :     69 ms
naive / blocked = 4.1

প্রথম লাইন সবচেয়ে জরুরি: দুটি ফল byte ধরে মিলেছে (memcmp). ডেটা ছোট পূর্ণসংখ্যা, আর প্রতিটি C[i][j]-তে k বাড়ার একই ক্রমে যোগ হয়েছে, তাই এখানে হুবহু মেলা প্রত্যাশিত। Blocked সংস্করণ প্রায় ৪.১ গুণ দ্রুত (এই পর্ব লেখার সময় অন্য চালনায় ৩.৫ থেকে ৪.৪)। দুটি সতর্কতা। প্রথম, এই লাভ দুটি কৌশলের যোগফল: tile-এর ভেতরে ক্রমটিও ikj, তাই “naive ijk থেকে blocked” তুলনায় loop interchange-এর ভাগও আছে। দ্বিতীয়, N = ৫১২ মানে B-এর column-এর stride ৪০৯৬ byte — অংশ ২-এর ২-এর ঘাতের ফাঁদ naive সংস্করণকে আরও ধীর করেছে।

#pragma GCC optimize("O2") কেন? Runner ডিফল্টে -O0-তে compile করে, যেখানে প্রতিটি ভ্যারিয়েবল প্রতিবার মেমরিতে লেখা-পড়া হয়। তাতে loop-এর নিজের খরচ এত বেশি যে cache-এর প্রভাব ঢাকা পড়ে। এই pragma GCC-কে বলে এই ফাইলের function-গুলো -O2-তে optimize করতে। একই কোড pragma ছাড়া (-O0) চালিয়ে এই পর্ব লেখার সময় পাওয়া গেছে naive ৭৩৫ ms, blocked ৫৫৭ ms — অনুপাত মাত্র ১.৩. অর্থাৎ optimization ছাড়া মাপলে cache-কৌশলের লাভ ভুলভাবে ছোট দেখায়। এই pragma সব ক্ষেত্রে command line-এর flag-এর মতো আচরণ করে না; আসল প্রজেক্টে gcc -O2 command line-এ দিন।

Transpose — যেখানে loop interchange কাজ করে না

Matrix transpose dst[j][i] = src[i][j]-এ একটি বিপদ আছে: loop যেভাবেই সাজান, একটি array সারি ধরে আর অন্যটি column ধরে হাঁটবে। ij ক্রমে src-এর miss ১/B, কিন্তু dst column ধরে — প্রতি উপাদানে ১; ji করলে উল্টো। মোট প্রায় ১ + ১/B, interchange কিছুই বদলায় না। সমাধান আবার tiling: একটি T × T tile-এর ভেতরে src-এর T-টি row আর dst-এর T-টি row একসাথে cache-এ থাকে, তাই dst-এর প্রতিটি line-এর B-টি উপাদানই ব্যবহার হয় তার আগে, যতক্ষণ না line সরে যায়।

উদাহরণ (চালানো যায় না, শুধু কাঠামো):

for (int ii = 0; ii < N; ii += T)
    for (int jj = 0; jj < N; jj += T)
        for (int i = ii; i < ii + T; i++)          /* src: T row, সারি ধরে       */
            for (int j = jj; j < jj + T; j++)      /* dst: T row, প্রতিটির B ঘর  */
                dst[j][i] = src[i][j];

একই fully associative LRU মডেলে (N = ৬৪ double, cache ৩২ line) গুনে পাওয়া গেছে: naive transpose-এ প্রতি উপাদানে src ০.১২৫ আর dst ১.০ miss; T = ৮-এর tiled transpose-এ দুটিই ০.১২৫ — মোট ১.১২৫ থেকে ০.২৫, সাড়ে চার গুণ কম।

৫ · ডেটার বিন্যাস — AoS বনাম SoA, padding, alignment আর pointer chasing

Loop-এর ক্রম ঠিক করে দেয় কোন ক্রমে ঠিকানা ছোঁয়া হবে; ডেটার বিন্যাস ঠিক করে দেয় এক line-এ কী কী থাকবে. দ্বিতীয়টি প্রায়ই বেশি জরুরি, আর পরে বদলানো কঠিন — তাই নকশার শুরুতেই ভাবার মতো।

AoS বনাম SoA আর hot/cold ভাগ

একটি সিমুলেশনে ১০ লাখ কণা (particle):

struct particle { double x, y, z, vx, vy, vz; int id; char name[20]; };   /* sizeof = 72 */
struct particle p[1000000];                       /* AoS: array of structs  */
for (i = 0; i < n; i++) p[i].x += p[i].vx * dt;   /* লাগে শুধু x আর vx      */

x86-64-এর সাধারণ ABI-তে struct-টির মাপ ৭২ byte (৬টি double = ৪৮, int ৪, name ২০, মোট ৭২; ৮-এর গুণিতক বলে শেষে padding লাগে না)। Loop প্রতি কণায় লাগে মাত্র ১৬ byte (x আর vx), কিন্তু line আসে পুরো ৭২ byte-এর এলাকা ঢেকে — ১০ লাখ × ৭২ ÷ ৬৪ = ১১ লাখ ২৫ হাজার line. আনা byte-এর মাত্র ১৬ ÷ ৭২ ≈ ২২% কাজে লাগে। এই বিন্যাসের নাম array of structs (AoS).

Struct of arrays (SoA)-এ প্রতিটি field নিজের array-তে: double x[N], vx[N], …; তখন loop ছোঁয় শুধু x আর vx — ২ × ৮ × ১০ লাখ ÷ ৬৪ = আড়াই লাখ line, AoS-এর ৪.৫ ভাগের এক ভাগ। বাড়তি লাভ: পাশাপাশি x-গুলো SIMD register-এ একসাথে তোলা যায় (পর্ব ৪২)। মাঝামাঝি পথ hot/cold splitting: যে field প্রতিটি loop-এ লাগে (hot — অবস্থান, বেগ) সেগুলো এক struct-এ, আর কালেভদ্রে লাগা field (cold — নাম, id, debug তথ্য) আলাদা array-তে, একই index দিয়ে।

SoA সবসময় ভালো নয়। যে কাজ একটি কণার সব field একসাথে চায় (যেমন একটি কণা ফাইলে লেখা, বা এলোমেলো index-এ একটি একটি কণা খোঁজা), সেখানে AoS-এ এক-দুটি line-এই সব পাওয়া যায়, আর SoA-তে প্রতিটি field-এর জন্য আলাদা line — আটটি field মানে আটটি miss. কোন loop সবচেয়ে বেশি সময় নেয়, তা দেখে বিন্যাস বাছুন।

Padding, field-এর ক্রম আর cache-line alignment

প্রতিটি field তার alignment-এর গুণিতক ঠিকানায় বসে (পর্ব ০৬), তাই compiler ফাঁকে padding বসায়। struct { char c; double d; int i; }-এর মাপ ২৪ byte (c-এর পরে ৭ byte ফাঁকা, শেষে ৪ byte); বড় থেকে ছোট ক্রমে { double d; int i; char c; } লিখলে ১৬ byte. এক line-এ প্রথমটির ২.৬৭টি, দ্বিতীয়টির ঠিক ৪টি — array-তে ৩৩% কম line. C কোর্সের পারফরম্যান্স আর cache পর্বে padding গোনার একটি চালানো যায় এমন উদাহরণ আছে।

উল্টো দিকে, কখনো ইচ্ছা করে ফাঁকা রাখা হয়। ৬৪ byte বা তার ছোট একটি গুরুত্বপূর্ণ object যদি দুটি line-এর সীমানায় পড়ে, একবার ছুঁতে দুটি miss. C11-এর alignas(64) (C23-এ সরাসরি keyword; C11-এ <stdalign.h>) একটি ভ্যারিয়েবল বা struct-কে ৬৪-এর গুণিতক ঠিকানায় বসায়; heap-এ একই কাজ করে aligned_alloc(64, size), যেখানে size-কেও ৬৪-এর গুণিতক রাখাই নিরাপদ। এর সবচেয়ে বড় ব্যবহার অংশ ৬-এর false sharing.

Linked list বনাম array — pointer chasing

Array-তে পরের উপাদানের ঠিকানা আগেই জানা (base + i·S), তাই out-of-order core (পর্ব ৪০) একসাথে কয়েকটি miss চালু রাখতে পারে, আর prefetcher পরের line আগেভাগে আনে। Linked list-এ পরের node-এর ঠিকানা থাকে এই node-এর ভেতরে: p = p->next. আগের load শেষ না হলে পরের load-এর ঠিকানাই জানা নেই — একে বলে pointer chasing. প্রতিটি miss তখন একা, পরপর অপেক্ষা করে; node-গুলো heap-এ ছড়ানো থাকলে প্রায় প্রতিটি ধাপ একটি পূর্ণ মেমরি latency. Big-O-তে list-এ মাঝখানে ঢোকানো O(1), কিন্তু বাস্তবে প্রায়ই vector জেতে — DSA কোর্সের Linked list পর্বে এই তুলনা আর Array পর্বে row-major বিন্যাস আছে। প্রতিকার: node-গুলো একটি pool বা array থেকে পরপর বরাদ্দ করা, প্রতিটি node-এ কয়েকটি উপাদান রাখা (unrolled list), বা tree-র বদলে B-tree-র মতো চওড়া node — যেখানে একটি node কয়েকটি line জুড়ে অনেক key রাখে।

Prefetch-বান্ধব access আর software prefetch

আধুনিক CPU-তে hardware prefetcher থাকে: পরপর line বা সমান stride-এর access দেখলে সে নিজে থেকেই পরের line-গুলো আনতে শুরু করে। তাই সবচেয়ে prefetch-বান্ধব কোড হলো সরল ক্রমিক বা সমান-stride access. সাধারণভাবে বলা হয়, অনেক prefetcher একটি 4 KiB page-এর সীমা পেরোয় না, তাই খুব বড় stride-এ (প্রতি access-এ নতুন page) সাহায্য কম — নকশা chip-ভেদে আলাদা, আর নির্মাতারা সব বিস্তারিত প্রকাশ করেন না।

ঠিকানা যখন অনিয়মিত কিন্তু আগেভাগে জানা — যেমন sum += a[idx[i]], যেখানে idx আগে থেকেই আছে — তখন GCC আর Clang-এর software prefetch নির্দেশ কাজে লাগতে পারে:

উদাহরণ (চালানো যায় না):

for (i = 0; i < n; i++) {
    if (i + 16 < n) __builtin_prefetch(&a[idx[i + 16]], 0, 1);   /* ১৬ ধাপ আগে চাওয়া */
    sum += a[idx[i]];
}

__builtin_prefetch(ঠিকানা, rw, locality) — rw ০ মানে পড়ার জন্য, ১ মানে লেখার জন্য; locality ০ থেকে ৩, কতদিন cache-এ রাখার ইঙ্গিত। এটি একটি ইঙ্গিত, ভুল ঠিকানায় fault হয় না। কিন্তু লাভ নিশ্চিত নয়: ক্রমিক access-এ hardware আগেই কাজটি করছে, তাই বাড়তি instruction শুধু খরচ; দূরত্ব (এখানে ১৬) কম হলে ডেটা সময়মতো আসে না, বেশি হলে ব্যবহারের আগেই সরে যায়; আর বাড়তি prefetch bandwidth খায়। মেপে লাভ দেখলে তবেই রাখুন, আর অন্য যন্ত্রে আবার মাপুন।

Loop fusion আর fission

Loop fusion: একই array-র ওপর পরপর দুটি loop থাকলে একটিতে মেশানো — for i: b[i] = a[i] * 2; আর for i: c[i] = b[i] + 1; একসাথে করলে b[i] লেখার সাথে সাথে register বা L1 থেকেই পড়া হয়; বড় array-তে না মেশালে দ্বিতীয় loop-এর শুরুতে b-এর প্রথম অংশ cache থেকে সরে গেছে। Loop fission উল্টো: একটি loop যদি একসাথে দশটি array ছোঁয়, তাহলে প্রতি iteration-এ দশটি line জায়গা চায় — conflict আর capacity miss বাড়ে, vectorize করাও কঠিন হয়; দুই-তিনটি ছোট loop-এ ভাগ করলে প্রতিটির working set ছোট। শেষ কথা বলে মাপা।

৬ · Thread-এর সময় — false sharing বনাম true sharing

Multicore chip-এ প্রতিটি core-এর নিজের L1 (আর সাধারণত L2). একই ঠিকানার কপি কয়েকটি core-এর cache-এ থাকলে সবার ছবি এক রাখতে hardware একটি cache coherence protocol চালায় — কীভাবে, MESI-র অবস্থাসহ, তা পর্ব ৫৩-এর বিষয়। এখানে প্রোগ্রামারের জন্য একটি নিয়মই যথেষ্ট: একটি line-এ লিখতে হলে core-কে line-টির একমাত্র মালিক হতে হয়; অন্য সব core-এর কপি বাতিল হয়ে যায়। আর হিসাব হয় পুরো line ধরে, ভ্যারিয়েবল ধরে নয়।

এখান থেকেই false sharing: দুটি thread দুটি আলাদা ভ্যারিয়েবলে লেখে — যুক্তির দিক থেকে কোনো ভাগাভাগি নেই, lock-ও লাগে না — কিন্তু ভ্যারিয়েবল দুটি একই line-এ। ফলে line-টি দুই core-এর মধ্যে ping-pong খেলে।

অবস্থা (struct { long a, b; } — a আর b একই ৬৪-byte line L-এ)
ঘটনা
core 0-এর L1
core 1-এর L1
core থেকে core-এ line যাতায়াত
line-এর ছবি৬৪ byte

            
  1. শুরু। struct { long a, b; }-এ a আর b পাশাপাশি, দুটিই একটি ৬৪-byte line L-এ। Thread 0 (core 0-এ) শুধু a বাড়ায়, thread 1 (core 1-এ) শুধু b. কোনো data race নেই, ফল সঠিক হবে।
  2. Core 0 লিখল। a++ করতে core 0 line L আনল আর মালিক হলো। এখনো core 1-এর কাছে L নেই, তাই কোনো বাতিলও নেই।
  3. Core 1 লিখল। b++ করতে core 1-কে L-এর মালিক হতে হবে। সে মালিকানা চায়; core 0-এর কপি বাতিল হয়, আর হালনাগাদ line (a = ১ সমেত) core 1-এ যায় — একটি core-থেকে-core যাতায়াত। অথচ core 1-এর a নিয়ে কোনো আগ্রহই নেই।
  4. Core 0 আবার লিখল। a++-এর জন্য L আবার core 0-এ ফেরত — দ্বিতীয় যাতায়াত, core 1-এর কপি বাতিল।
  5. লাখো বার। দুজনে একসাথে লিখতে থাকলে প্রায় প্রতিটি ++-এ line এদিক-ওদিক যায়। L1-এ কয়েক cycle-এর একটি লেখা এখন core-থেকে-core যাতায়াতের অপেক্ষা — chip-ভেদে কয়েক দশ থেকে শতাধিক cycle. ফল সঠিক, কিন্তু thread বাড়িয়ে গতি বাড়ে না, উল্টো কমতে পারে।
  6. সমাধান — padding. a আর b-কে alignas(64) দিয়ে আলাদা line-এ রাখলে প্রত্যেক core নিজের line-এর স্থায়ী মালিক; প্রথম আনার পরে আর কোনো যাতায়াত নেই। দাম: ১৬ byte-এর জায়গায় ১২৮ byte — counter-এর জন্য তুচ্ছ।

এবার আসল দুটি thread দিয়ে মাপা যাক। প্রোগ্রামটি POSIX thread ব্যবহার করে; runner-এ -pthread flag ছাড়াই চলে (glibc-র নতুন version-এ pthread মূল library-র অংশ, পর্ব ৪৩-এও একই runner). volatile রাখা হয়েছে যাতে প্রতিটি ++ সত্যিই মেমরিতে লেখা হয় — না হলে -O2-তে compiler পুরো loop-কে একটি যোগে বদলে দিতে পারত।

// False sharing: two threads, two counters -- same cache line vs padded to 64 B (times vary)
#include <pthread.h>
#include <stdalign.h>
#include <stdio.h>
#include <time.h>
#define ITERS 100000000L

struct packed { volatile long a, b; };                  // a and b: 8 bytes apart, one line
struct padded { alignas(64) volatile long a; alignas(64) volatile long b; };  // own lines
static struct packed P;
static struct padded Q;

static void *bump(void *p) {                           // each thread touches only its own counter
    volatile long *c = p;
    for (long i = 0; i < ITERS; i++) (*c)++;
    return NULL;
}
static double run(volatile long *x, volatile long *y) {
    struct timespec t0, t1; pthread_t ta, tb;
    clock_gettime(CLOCK_MONOTONIC, &t0);
    pthread_create(&ta, NULL, bump, (void *)x); pthread_create(&tb, NULL, bump, (void *)y);
    pthread_join(ta, NULL); pthread_join(tb, NULL);
    clock_gettime(CLOCK_MONOTONIC, &t1);
    return (t1.tv_sec - t0.tv_sec) * 1e3 + (t1.tv_nsec - t0.tv_nsec) / 1e6;
}
int main(void) {
    printf("distance a->b: packed %zu B, padded %zu B\n",
           (size_t)((char *)&P.b - (char *)&P.a), (size_t)((char *)&Q.b - (char *)&Q.a));
    double same = run(&P.a, &P.b), apart = run(&Q.a, &Q.b);
    printf("counts: %ld %ld %ld %ld\n", P.a, P.b, Q.a, Q.b);
    printf("same line  : %6.0f ms\n", same);
    printf("padded     : %6.0f ms\n", apart);
    printf("same / padded = %.2f\n", same / apart);
    return 0;
}

যাচাইয়ের সময় আউটপুট (সময় প্রতিবার অনেক বদলায়):

distance a->b: packed 8 B, padded 64 B
counts: 100000000 100000000 100000000 100000000
same line  :    423 ms
padded     :    210 ms
same / padded = 2.01

দূরত্বের লাইন নিশ্চিত করে: packed-এ a আর b ৮ byte দূরে (একই line), padded-এ ৬৪ byte (আলাদা line). চারটি গণনাই ১০ কোটি — দুই সংস্করণেই ফল সঠিক। সময়ের অনুপাত এই চালনায় ২.০১; কিন্তু এই পর্ব লেখার সময় একই runner-এ কয়েকবার চালিয়ে অনুপাত পাওয়া গেছে ৪.৩৫, ৩.৬৮, ২.১১ আর ১.০৮ — খুবই অস্থির। কারণটা গুরুত্বপূর্ণ। এই runner একটি ভাগ করা virtual machine, যেখানে দেখা যায় মাত্র ২টি logical CPU, আর পর্ব ৪৩-এর মতো thread_siblings_list দুটির জন্যই 0-1 বলে — অর্থাৎ kernel-এর চোখে এরা একই core-এর দুটি hardware thread. একই core-এর দুই sibling একই L1 ভাগ করে, তাই সেখানে আলাদা core-এর মধ্যে line-এর ping-pong হওয়ার কথাই নয়। তবু মাঝে মাঝে বড় পার্থক্য দেখা গেল — হয়তো hypervisor কখনো দুটি vCPU-কে আলাদা physical core-এ বসায়, বা একই core-এও একই line-এ দুই thread-এর লেখার কিছু খরচ আছে; এখান থেকে নিশ্চিত বলা যায় না। নিজের কয়েক core-এর Linux যন্ত্রে, দুই thread-কে আলাদা core-এ বেঁধে (taskset বা pthread_setaffinity_np) চালালে পার্থক্য সাধারণত অনেক বেশি স্থির ও স্পষ্ট হয়।

True sharing আর false sharing — কীভাবে চিনবেন, কীভাবে সারাবেন

True sharingFalse sharing
কী ঘটেকয়েকটি thread সত্যিই একই ভ্যারিয়েবল পড়ে-লেখে (একটি shared counter, একটি lock)প্রতিটি thread নিজের ভ্যারিয়েবলে লেখে, কিন্তু ভ্যারিয়েবলগুলো একই line-এ
সঠিকতার জন্য কী লাগেAtomic operation বা lock (Operating Systems কোর্সের atomics আর spinlock পর্ব)কিছুই না — ফল এমনিতেই সঠিক
Line-এর যাতায়াতঅনিবার্য, কারণ ডেটাটাই ভাগ করাঅপ্রয়োজনীয়, শুধু বিন্যাসের দোষ
প্রতিকারভাগাভাগি কমানো: প্রতিটি thread নিজের local ভ্যারিয়েবলে জমায়, শেষে একবার যোগPadding / alignas(64), বা প্রতিটি thread-এর ডেটা আলাদা জায়গায়

False sharing-এর সাধারণ জায়গা: প্রতি thread-এর counter-এর array (long hits[NTHREADS]), একটি queue-র head আর tail একই struct-এ পাশাপাশি, আর একটি lock তার পাহারা দেওয়া ডেটার ঠিক পাশে। লক্ষণ: thread বাড়ালে গতি বাড়ে না, অথচ কোনো lock-এর প্রতিযোগিতা নেই। Linux-এর perf c2c টুল এমন line খুঁজে বের করতে বানানো। C++17-এ std::hardware_destructive_interference_size নামে একটি ধ্রুবক আছে, যা padding-এর দূরত্ব বলে; কিছু Intel chip-এ পাশের line-কে জোড়ায় আনা prefetcher থাকায় অনেক লাইব্রেরি ৬৪-এর বদলে ১২৮ byte padding দেয়।

৭ · ঠিকভাবে মাপা — আর কখন মাথা ঘামানোর দরকার নেই

Cache-কৌশলের প্রতিটি দাবি শেষ পর্যন্ত একটি মাপ। আর পারফরম্যান্স মাপা আশ্চর্য রকম সহজে ভুল হয়। এই পর্বের তিনটি C প্রোগ্রাম যে নিয়মগুলো মেনে লেখা, সেগুলো একসাথে:

  1. সঠিক ঘড়ি। clock_gettime(CLOCK_MONOTONIC, …) — ন্যানোসেকেন্ডের ঘরে wall-clock time, আর সিস্টেমের সময় বদলালেও পেছনে যায় না। clock() মাপে প্রসেসের CPU time — একাধিক thread-এ সব thread-এর সময় যোগ হয়ে যায়।
  2. কয়েকবার চালিয়ে সবচেয়ে কম সময়। শব্দ (অন্য প্রসেস, interrupt, ভাগ করা যন্ত্রে প্রতিবেশীর কাজ) সময় শুধু বাড়াতে পারে, কমাতে নয়। তাই কয়েকবারের সর্বনিম্নটি কোডের আসল খরচের সবচেয়ে কাছের অনুমান।
  3. প্রথম ছোঁয়া আলাদা রাখা। একটি বড় array প্রথমবার লিখলে OS প্রতিটি page-এর জন্য একটি page fault সামলায় (পর্ব ৩৮, ৫০)। মাপার আগে array ভরে নিন — ওপরের প্রোগ্রামগুলোতে input array timing-এর আগেই ভরা হয়েছে।
  4. ফল ব্যবহার করা। -O2-তে compiler যদি দেখে loop-এর ফল কোথাও লাগে না, পুরো loop মুছে দিতে পারে (dead-code elimination) — সময় আসে প্রায় শূন্য, আর মনে হয় “বিশাল উন্নতি”. তাই ফল print করুন, তুলনা করুন, বা একটি volatile ভ্যারিয়েবলে রাখুন।
  5. -O0 দিয়ে বিচার নয়। Optimization level -O0-তে loop-এর নিজের খরচ বিশাল; অংশ ৪-এ একই blocked কোডের লাভ -O0-তে ১.৩ গুণ, -O2-তে ৩.৫ থেকে ৪.৪ গুণ। যে কোড আসলে -O2-তে চলবে, তাকে -O2-তেই মাপুন।
  6. সঠিক মাপের ডেটা। সব ডেটা L1-এ ধরলে কোনো loop-ক্রমেই পার্থক্য দেখবেন না। Runner-এর L2 ৫১২ KiB আর L3 ১৬ MiB — মেমরির প্রভাব দেখতে ডেটা এর চেয়ে বড় করুন, আর বলুন কোন মাপে মাপলেন।

Hardware counter — perf stat

সময় বলে “কতটা ধীর”, কিন্তু “কেন” বলে না। CPU-র performance counter সরাসরি cache miss গোনে, আর Linux-এর perf টুল সেগুলো পড়ে। এই runner-এ perf নেই (container-এ সাধারণত counter পড়ার অনুমতিও থাকে না), তাই নিচেরটি নিজের Linux যন্ত্রের জন্য একটি উদাহরণ — সংখ্যার জায়গা ইচ্ছা করে খালি রাখা হলো:

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

 Performance counter stats for './matmul':
        <সংখ্যা>      cycles
        <সংখ্যা>      instructions              #  <IPC> insn per cycle
        <সংখ্যা>      L1-dcache-loads
        <সংখ্যা>      L1-dcache-load-misses     #  <শতাংশ> of all L1-dcache accesses
        <সংখ্যা>      LLC-load-misses

দুই সংস্করণে L1-dcache-load-misses ÷ (ভেতরের iteration-এর সংখ্যা) হিসাব করে অংশ ৩-এর টেবিলের সাথে মেলানো যায়। Event-এর নাম CPU আর kernel-ভেদে আলাদা (perf list দেখায়), তাই আপেক্ষিক তুলনাই বেশি নির্ভরযোগ্য।

কখন মাথা ঘামাবেন না

Donald Knuth-এর বিখ্যাত সতর্কবাণী — ছোটখাটো দক্ষতার পেছনে আগেভাগে ছোটা (premature optimization) বহু সমস্যার মূল — এখানেও খাটে। একটি loop যদি মোট সময়ের ২% নেয়, তাকে দশ গুণ দ্রুত করলেও পুরো প্রোগ্রাম ২%-এর কম দ্রুত হবে (Amdahl-এর সূত্র, পর্ব ০৩)। ডেটা যদি ছোট আর cache-এ ধরে, loop-ক্রম নিয়ে ভাবনা বৃথা। Matrix গুণ, FFT, sort-এর মতো কাজের জন্য প্রায়ই একটি ভালো library (BLAS, FFTW) আপনার হাতে-লেখা tiling-এর চেয়ে অনেক দ্রুত। আর পড়তে কঠিন কোডের নিজস্ব দাম আছে। নিয়ম: আগে profiler দিয়ে দেখুন সময় কোথায় যায়, তারপর সেখানেই miss গুনুন, পরিবর্তন করুন, ফল মেলান, আবার মাপুন। ব্যতিক্রম একটি: ডেটার বিন্যাস (AoS না SoA, কোন field কোথায়) পরে বদলানো খুব কঠিন, তাই বড় সিস্টেমের নকশার শুরুতেই এটুকু ভেবে রাখা “আগেভাগে” নয়, বুদ্ধিমানের কাজ।

আরও গভীরে: উচ্চ-পারফরম্যান্স BLAS লাইব্রেরি (GotoBLAS-এর উত্তরসূরি OpenBLAS, BLIS, নির্মাতাদের নিজস্ব লাইব্রেরি) matrix গুণে এক স্তরে নয়, কয়েক স্তরে blocking করে — L3, L2 আর L1-এর মাপ অনুযায়ী আলাদা tile, তারপর register-এর মাপের একটি ছোট “micro-kernel”, যা SIMD-এ লেখা। তার ওপর tile-গুলো আগে একটি ধারাবাহিক buffer-এ কপি (packing) করা হয়, যাতে stride আর conflict miss না থাকে আর TLB-ও কম লাগে। অন্য দিকে, ১৯৯৯ সালে Frigo, Leiserson, Prokop আর Ramachandran-এর প্রস্তাবিত cache-oblivious algorithm cache-এর মাপ না জেনেই recursion দিয়ে সব স্তরে কাছাকাছি সেরা miss-সংখ্যা পায়।

৮ · AI-এর যুগে — cache-কৌশলে AI, আর যাচাইয়ের অভ্যাস

“এই কোড দ্রুত করে দাও” — AI খুব আত্মবিশ্বাসের সাথে tiling, prefetch আর padding যোগ করে দেয়। কিন্তু এই বিষয়ে কয়েকটি ভুল বারবার দেখা যায়:

AI-এর সাধারণ ভুলকীভাবে ধরবেন
Row-major আর column-major গুলিয়ে ফেলা; false sharing-কে data race ভেবে lock বসানোঠিকানার সূত্র (i·M + j)·S দিয়ে stride আর কোন ঠিকানা কোন line-এ, নিজে বের করুন
ছয় loop-ক্রমের miss-সংখ্যা ভুল বলা, বা অনুমান (N বড়, line-এর মাপ) না বলে সংখ্যা দেওয়াভেতরের চলকের নিয়মে নিজে গুনুন, আর ছোট একটি LRU মডেলে চালিয়ে মেলান
Tile-এর মাপ cache-এর পুরো মাপ ধরে বাছা, বা ৩টি tile-এর বদলে একটির হিসাব৩T²·S ≤ cache শর্তে বসান; কয়েকটি T মেপে দেখুন
Blocked সংস্করণে ফল বদলে যাওয়া (সীমানায় N ভাগ না গেলে বাকি অংশ বাদ)পুরোনো আর নতুন ফল memcmp বা সহনশীলতা দিয়ে তুলনা করুন, N = T·k + ১-এর মতো মাপেও
“__builtin_prefetch দিলে সবসময় দ্রুত” বা “L1 miss মানে ১০০ cycle” ধরনের নির্দিষ্ট সংখ্যানিজের যন্ত্রে -O2-তে মাপুন; latency chip-ভেদে আলাদা, উৎস চান
-O0-তে বা ফল ব্যবহার না করে benchmark লেখাফল print করা হচ্ছে কি না, আর কোন flag-এ compile হচ্ছে, দেখুন

AI-কে cache-বান্ধব কোডের জন্য বলার সময় যন্ত্রের মাপ, অনুমান আর যাচাইয়ের দাবি স্পষ্ট দিন:

আমি C-তে double-এর একটি N × N matrix গুণ (C = A × B, row-major) দ্রুত করতে চাই। N = 1024. আমার যন্ত্রে L1 data cache 32 KiB, 8-way, line 64 byte; L2 512 KiB. gcc -O2 দিয়ে compile করব। ১. ijk, ikj আর jki — তিনটি ক্রমের ভেতরের loop-এ প্রতি iteration-এ A, B, C-এর miss কত, তোমার অনুমানসহ (N বড়, একটি row cache-এ টেকে না) একটি টেবিলে দেখাও। ২. তিনটি tile একসাথে L1-এ ধরবে এমন tile-এর মাপ T হিসাব করে দেখাও, আর কেন পুরো 32 KiB ভরাবে না তা বলো। ৩. Blocked কোড লেখো, যেখানে N যদি T দিয়ে ভাগ না যায় তাও ঠিক কাজ করে। ৪. একটি পরীক্ষা লেখো যা naive আর blocked-এর ফল তুলনা করে, আর clock_gettime দিয়ে কয়েকবার চালিয়ে সবচেয়ে কম সময় দেখায়; ফল যেন print হয়, যাতে compiler loop মুছে না ফেলে। ৫. কোনো latency বা speedup-এর সংখ্যা নিশ্চিত না হলে "নিশ্চিত নই, মেপে দেখো" লেখো; নিজে সংখ্যা বানিয়ো না।

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

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

  • 2D array-তে সারি ধরে হাঁটা কেন দ্রুত? — C row-major; সারি ধরে stride ১, এক line-এর B-টি উপাদানের জন্য একটি miss; column ধরে বড় N-এ প্রতিটি access-এ miss.
  • Matrix গুণের কোন loop-ক্রম সবচেয়ে ভালো, কেন? — ikj বা kij: ভেতরের loop j দুটি array-কেই সারি ধরে হাঁটায়, প্রতি iteration-এ প্রায় ২/B miss (double-এ ০.২৫)।
  • Blocking-এ tile-এর মাপ কীভাবে বাছবেন? — ৩টি tile cache-এ ধরবে: ৩T²·S ≤ cache; ৩২ KiB আর double-এ T ≤ ৩৬, তাই সাধারণত ৩২; তারপর মেপে ঠিক করা।
  • False sharing কী, কীভাবে সারাবেন? — দুই thread একই line-এর আলাদা ভ্যারিয়েবলে লেখে, line core-এর মধ্যে ping-pong করে; padding বা alignas(64), বা thread-local জমা।
  • Linked list কেন array-র চেয়ে ধীর হতে পারে, যদিও Big-O এক? — Pointer chasing: পরের ঠিকানা আগের load-এর ফল, miss একসাথে চলে না, prefetch কাজ করে না।

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

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

প্র ০১তিনটি loop-এর matrix গুণ সবাই লিখতে পারে — তাহলে BLAS লাইব্রেরির matrix গুণ কেন অনেক গুণ দ্রুত, আর ATLAS থেকে GotoBLAS ও BLIS পর্যন্ত গল্পটা কী শেখায়?

Matrix গুণ বৈজ্ঞানিক হিসাব, মেশিন লার্নিং আর গ্রাফিক্সের ভিত্তি, তাই ১৯৭০-এর দশক থেকেই এর জন্য একটি মানক interface আছে — BLAS (Basic Linear Algebra Subprograms); তার সবচেয়ে ভারী অংশ, matrix-matrix গুণ (GEMM, “Level 3 BLAS”), ১৯৮০-এর দশকের শেষ থেকে ১৯৯০-এর শুরুর মধ্যে মানক হয়। Interface একটাই, কিন্তু ভেতরের কোড প্রতিটি যন্ত্রের জন্য আলাদা করে টিউন করতে হয় — কারণ সেরা tile-এর মাপ নির্ভর করে L1, L2, L3, TLB আর register-সংখ্যার ওপর।

১৯৯০-এর দশকের শেষে R. Clint Whaley আর Jack Dongarra-র ATLAS প্রকল্প একটি সাহসী পথ নেয়: install-এর সময় লাইব্রেরি নিজেই অনেকগুলো tile-মাপ আর loop-রূপ চালিয়ে মাপে, আর সবচেয়ে দ্রুতটি রেখে দেয় — auto-tuning. প্রায় একই সময়ে জাপানের Kazushige Goto হাতে লেখা কোড দিয়ে GotoBLAS বানান, যা অনেক যন্ত্রে সবার চেয়ে দ্রুত ছিল বলে খ্যাতি পায়। ২০০৮ সালের দিকে Goto আর Robert van de Geijn-এর একটি প্রবন্ধ এর রহস্য ব্যাখ্যা করে: tile শুধু L1-এর জন্য নয় — একটি বড় টুকরো L2-তে (আর TLB-তে) রাখার জন্য packing, তারপর register-এর মাপের একটি ছোট micro-kernel, যেখানে সব হিসাব register-এই হয়। পরে GotoBLAS থেকে OpenBLAS জন্মায়, আর van de Geijn-এর দল BLIS বানায়, যেখানে শুধু micro-kernel-টি যন্ত্রভেদে লিখতে হয়।

শিক্ষা দুটি। প্রথম, এই পর্বের এক-স্তরের tiling সঠিক দিক, কিন্তু শেষ কথা নয় — পেশাদার কোড register থেকে L3 পর্যন্ত প্রতিটি স্তরের জন্য আলাদা block করে, আর SIMD (পর্ব ৪২) ব্যবহার করে। দ্বিতীয়, বাস্তব প্রজেক্টে matrix গুণ নিজে না লিখে একটি ভালো লাইব্রেরি ডাকাই প্রায় সবসময় সঠিক সিদ্ধান্ত। NumPy বা PyTorch-এর matrix গুণ ভেতরে এমন লাইব্রেরিই ডাকে।

মূল উপলব্ধি: cache-বান্ধব কোডের সর্বোচ্চ রূপ হলো স্তরে স্তরে blocking আর যন্ত্রের মাপ অনুযায়ী টিউন করা — আর সেই কাজ অন্যরা আগেই করে রাখলে সেটিই ব্যবহার করা।

প্র ০২Tile-এর মাপ বাছতে cache-এর মাপ জানতে হয়। এমন algorithm কি সম্ভব, যা cache-এর মাপ না জেনেই সব স্তরে ভালো চলে — cache-oblivious ধারণাটি কী, আর বাস্তবে এর জায়গা কোথায়?

Cache-aware tiling-এর একটি অস্বস্তি আছে: T বাছতে হয় একটি নির্দিষ্ট cache-এর জন্য। কিন্তু যন্ত্রে cache-এর কয়েকটি স্তর, আর প্রোগ্রাম এক যন্ত্র থেকে আরেক যন্ত্রে যায়। ১৯৯৯ সালে MIT-র Matteo Frigo, Charles Leiserson, Harald Prokop আর Sridhar Ramachandran একটি প্রবন্ধে প্রস্তাব করেন cache-oblivious algorithm: এমন algorithm, যার কোডে cache-এর মাপ কোথাও নেই, অথচ বিশ্লেষণে দেখা যায় যেকোনো মাপের cache-এ miss-সংখ্যা প্রায় সেরা।

কৌশলটি recursion. Matrix গুণে বড় matrix-কে চার ভাগে ভাগ করে আটটি ছোট গুণ, প্রতিটি আবার চার ভাগে — যতক্ষণ না টুকরো খুব ছোট হয়। কোথাও না কোথাও টুকরোর মাপ আপনা থেকেই L1-এর মাপের কাছাকাছি হয়, আরেক স্তরে L2-র, আরেক স্তরে L3-র — অর্থাৎ recursion নিজেই সব মাপের tile বানিয়ে দেয়। Transpose, FFT, sort আর search tree-র “van Emde Boas layout”-এও একই চিন্তা। প্রমাণের জন্য একটি আদর্শ cache-মডেল ধরা হয় (fully associative, সর্বোত্তম replacement), আর দেখানো হয় LRU-ও তার কাছাকাছি।

বাস্তবে? সুন্দর তত্ত্ব, মিশ্র ফল। Recursion-এর নিজস্ব খরচ আছে, তাই নিচের স্তরে একটি যথেষ্ট বড় base case লাগে — যেটি আবার যন্ত্রভেদে টিউন করতে হয়। ঘন matrix গুণে হাতে-টিউন করা BLAS সাধারণত এখনো জেতে। কিন্তু যেখানে যন্ত্র অজানা বা ডেটা-কাঠামো বহু স্তরের মেমরিতে (RAM আর disk) ছড়ানো, সেখানে cache-oblivious নকশা খুব কাজের। Frigo নিজে Steven Johnson-এর সাথে FFTW লাইব্রেরি বানিয়েছিলেন, যার পরিকল্পনায় recursive ভাগ আর যন্ত্রে মেপে বেছে নেওয়া — দুটোরই মিশ্রণ আছে।

মূল উপলব্ধি: recursion দিয়ে সমস্যা ভাঙলে সব মাপের tile আপনা থেকে তৈরি হয়; তবু সেরা গতির জন্য শেষ স্তরে যন্ত্র মেপে টিউন করার দরকার প্রায়ই থেকে যায়।

প্র ০৩Knuth বলেছিলেন premature optimization বহু অনিষ্টের মূল — তাহলে game engine-এর “data-oriented design” কেন শুরু থেকেই cache নিয়ে ভাবতে বলে? দুটি পরামর্শ কি পরস্পরবিরোধী?

উক্তিটি Donald Knuth-এর ১৯৭৪ সালের প্রবন্ধ “Structured Programming with go to Statements” থেকে, আর পুরো বাক্যটি প্রায়ই কেটে উদ্ধৃত হয়। তিনি বলেছিলেন, ছোটখাটো দক্ষতা নিয়ে প্রায় ৯৭% সময় ভুলে থাকা উচিত — আগেভাগে optimization বহু অনিষ্টের মূল; কিন্তু সেই গুরুত্বপূর্ণ ৩%-এর সুযোগ হাতছাড়া করা উচিত নয়। আর সেই ৩% চিনতে তিনি মাপার যন্ত্র ব্যবহারের ওপর জোর দিয়েছিলেন। অর্থাৎ আসল বার্তা “মাপুন, তারপর গুরুত্বপূর্ণ জায়গায় optimize করুন” — “কখনো ভাববেন না” নয়।

অন্য দিকে game আর উচ্চ-পারফরম্যান্স সিস্টেমের প্রকৌশলীরা — যেমন ২০১৪ সালের CppCon-এ Mike Acton-এর বহুল আলোচিত বক্তৃতা “Data-Oriented Design and C++” — বলেন, ডেটা কীভাবে মেমরিতে সাজানো, সেটিই সিস্টেমের আসল নকশা। একটি game-এ প্রতি frame-এ হাজারো object-এর অবস্থান হালনাগাদ হয়। প্রতিটি object যদি একটি বড় class হয়, যার মধ্যে নাম, texture-এর pointer, AI-এর অবস্থা সব মিশে আছে, তাহলে অবস্থান হালনাগাদের loop প্রতি line-এর বেশিরভাগ byte অকারণে আনে — এই পর্বের AoS সমস্যা।

দুটি পরামর্শ আসলে আলাদা স্তরের। Loop-এর ভেতরের ছোটখাটো কৌশল — prefetch, loop unrolling, একটি branch সরানো — পরে যোগ করা সহজ, তাই সেগুলো মাপার আগে করা অপচয়। কিন্তু ডেটার বিন্যাস পুরো codebase-এ ছড়িয়ে থাকে; হাজার জায়গায় obj->pos লেখার পরে AoS থেকে SoA-তে যাওয়া প্রায় নতুন করে লেখা। যে সিদ্ধান্ত পরে বদলানো দামি, তা নিয়ে শুরুতে ভাবা “আগেভাগে” নয়, স্থাপত্যের কাজ। আর সেখানেও মূল প্রশ্ন একই: কোন loop বারবার চলবে, আর সেটি কোন field চায়?

মূল উপলব্ধি: ছোট কৌশল মাপার পরে, কিন্তু বদলাতে কঠিন ডেটা-বিন্যাস নকশার শুরুতেই — দুই পরামর্শ একসাথে মানা যায়।

প্র ০৪Java-র @Contended, LMAX Disruptor বা Linux kernel-এর cache-line alignment — বাস্তব সফটওয়্যার false sharing কীভাবে ঠেকায়, আর compiler নিজে থেকে কেন এটা ঠিক করে দেয় না?

False sharing একাডেমিক কৌতূহল নয়; উচ্চ-গতির সার্ভার সফটওয়্যারে এটি বারবার ধরা পড়েছে। ২০১১ সালের দিকে লন্ডনের LMAX এক্সচেঞ্জের প্রকৌশলীরা Disruptor নামে একটি খুব দ্রুত inter-thread queue প্রকাশ করেন। তাঁদের লেখায় একটি বড় শিক্ষা ছিল: queue-র producer আর consumer-এর sequence counter পাশাপাশি থাকলে দুই thread একই line নিয়ে টানাটানি করে, তাই তাঁরা counter-গুলোর চারপাশে ইচ্ছা করে অব্যবহৃত long ভ্যারিয়েবল বসিয়ে আলাদা line-এ রাখেন। Martin Thompson-এর “mechanical sympathy” — hardware কীভাবে চলে তা বুঝে কোড লেখা — ধারণাটি এই আলোচনা থেকে জনপ্রিয় হয়।

Java-তে হাতে padding ভঙ্গুর, কারণ JVM field-এর ক্রম বদলে দিতে পারে। তাই Java 8-এ (JEP 142) @Contended annotation আসে, যা JVM-কে field-এর চারপাশে padding দিতে বলে; JDK-র নিজের কিছু concurrent class-এ এটি ব্যবহৃত, আর সাধারণ কোডে ব্যবহারে একটি বিশেষ JVM flag লাগে। Linux kernel-এ ____cacheline_aligned_in_smp ধরনের macro আছে, যা multiprocessor build-এ struct বা field-কে line-এর শুরুতে বসায়; আর প্রতি-CPU ভ্যারিয়েবল (per-CPU data) তো ভাগাভাগিই এড়ায় — এই পর্বের “local-এ জমানো, শেষে একবার যোগ” নীতি।

Compiler কেন নিজে করে না? প্রথমত, C-তে struct-এর বিন্যাস ABI-র অংশ — অন্য ফাইল, অন্য library, এমনকি disk-এ লেখা ডেটা একই বিন্যাস ধরে নেয়; compiler ইচ্ছামতো padding বসাতে পারে না। দ্বিতীয়ত, compile-এর সময় compiler জানে না কোন field কোন thread লিখবে — সেটি চলার সময়ের আচরণ। তৃতীয়ত, সব জায়গায় padding দিলে মেমরি আর cache-এর জায়গা নষ্ট হতো, যা নিজেই ধীর করত। তাই এটি প্রোগ্রামারের সিদ্ধান্ত, যা মাপা আর perf c2c-এর মতো টুল দিয়ে নিতে হয়।

মূল উপলব্ধি: false sharing-এর প্রতিকার সরল — আলাদা line বা আলাদা জায়গা — কিন্তু কোথায় লাগবে, তা চলার সময়ের আচরণ জানা মানুষকেই ঠিক করতে হয়।

প্র ০৫একটি micro-benchmark দেখাল নতুন কোড ২০% দ্রুত। এই সংখ্যা কতটা বিশ্বাসযোগ্য — কোন কোন লুকানো কারণে benchmark “মিথ্যা” বলতে পারে?

২০০৯ সালে Todd Mytkowicz, Amer Diwan, Matthias Hauswirth আর Peter Sweeney-র একটি প্রবন্ধের শিরোনামই ছিল সতর্কবার্তা — “Producing Wrong Data Without Doing Anything Obviously Wrong!” তাঁরা দেখান, শুধু environment variable-এর মোট আকার বা link করার সময় object file-এর ক্রম বদলালেই একই প্রোগ্রামের মাপা সময় লক্ষণীয়ভাবে বদলায় — কারণ stack আর code-এর ঠিকানা সরে গিয়ে alignment আর cache-এর আচরণ বদলে যায়। অর্থাৎ আপনার “২০% উন্নতি”-র একটা অংশ হয়তো কোডের নয়, ঠিকানার ভাগ্যের।

আরও কয়েকটি পরিচিত ফাঁদ: dead-code elimination — ফল ব্যবহার না হলে compiler কাজটাই মুছে দেয়; constant folding — input compile-এর সময় জানা থাকলে হিসাব আগেই হয়ে যায়; warm-up — প্রথম চালনায় page fault, cold cache আর branch predictor-এর শেখা; CPU-র frequency — turbo, তাপে গতি কমা, বা শক্তি-সাশ্রয়ী মোড; প্রতিবেশী — ভাগ করা virtual machine-এ অন্যের কাজ (এই পর্বের false-sharing মাপে অনুপাত ১.০৮ থেকে ৪.৩৫ পর্যন্ত ছড়িয়েছে); আর ডেটার মাপ — ছোট ডেটায় সব L1-এ ধরে, বাস্তবের বড় ডেটায় ছবি উল্টে যেতে পারে।

প্রতিরক্ষা: কয়েকবার চালিয়ে বিতরণ দেখা (সর্বনিম্ন, median, ছড়ানো), ফল ব্যবহার করা (Google Benchmark-এর DoNotOptimize-এর মতো সাহায্যকারী), আসল flag আর আসল মাপের ডেটা, আর সময়ের সাথে hardware counter দিয়ে কারণ মেলানো — miss যদি সত্যিই কমে থাকে, perf stat তা দেখাবে। সবচেয়ে ভালো যাচাই: পুরো application-এ, আসল কাজের ধরনে উন্নতিটি টেকে কি না। আরেকটি অভ্যাস কাজে লাগে — উন্নতির একটি পূর্বাভাস আগেই লিখে রাখা (যেমন “miss অর্ধেক হবে, তাই সময় প্রায় এত কমবে”), তারপর মাপা। মাপের ফল পূর্বাভাসের চেয়ে অনেক ভালো বা অনেক খারাপ হলে, ধরে নিন কোথাও একটি ফাঁদ আছে, আর আগে সেটি খুঁজুন।

মূল উপলব্ধি: একটি সংখ্যা প্রমাণ নয়; কারণ (miss কমেছে কি না) আর ধারাবাহিকতা (কয়েকবার, কয়েক যন্ত্রে) মিললে তবেই benchmark-এর দাবি বিশ্বাসযোগ্য।

অনুশীলন

পরীক্ষার ধাঁচে: loop nest-এ miss গোনা, tile-এর মাপ বাছাই, false sharing খুঁজে বের করা আর AoS থেকে SoA. আগে নিজে চেষ্টা করুন।

  1. Loop nest-এ miss গোনা (হাতে হিসাব): int a[128][128] (int ৪ byte, row-major, শুরু line-এর সীমানায়)। Cache fully associative, LRU, line ৬৪ byte, শুরুতে খালি। (ক) ij ক্রমে (ভেতরে j) পুরো array একবার পড়লে কয়টি miss? (খ) ji ক্রমে, cache ৪ KiB হলে? (গ) ji ক্রমে, cache ১৬ KiB হলে? প্রতিটির জন্য প্রতি উপাদানে miss-ও বলুন।

    এক line-এ ৬৪ ÷ ৪ = ১৬টি int; প্রতিটি row ১২৮ × ৪ = ৫১২ byte = ৮টি line; পুরো array ১২৮ × ৮ = ১০২৪টি line, উপাদান ১৬৩৮৪টি।

    (ক) সারি ধরে প্রতিটি line একবারই আসে: ১০২৪টি miss, প্রতি উপাদানে ১/১৬ = ০.০৬২৫. Cache-এর মাপে কিছু যায় আসে না।

    (খ) ৪ KiB = ৬৪টি line. একটি column ছোঁয় ১২৮টি আলাদা line (প্রতি row-এ একটি) — ৬৪-এর বেশি। LRU-তে প্রতিটি line পরের column-এ লাগার আগেই সরে যায়, তাই প্রতিটি access miss: ১৬৩৮৪টি miss, প্রতি উপাদানে ১।

    (গ) ১৬ KiB = ২৫৬টি line ≥ ১২৮. Column 0-তে ১২৮টি miss, তারপর column ১–১৫ একই line-এ — সব hit; column ১৬-তে আবার ১২৮টি নতুন line, এভাবে ৮ বার: ৮ × ১২৮ = ১০২৪টি miss, (ক)-এর সমান। একটি LRU simulator-এ তিনটি উত্তরই মিলেছে। শিক্ষা: column ধরে হাঁটা খারাপ তখনই, যখন row-সংখ্যা cache-এর line-সংখ্যার চেয়ে বেশি।

  2. Tile-এর মাপ বাছাই: একটি যন্ত্রে L1 data cache ৪৮ KiB, line ৬৪ byte. N = ১০২৪-এর double matrix গুণ blocking দিয়ে করবেন, tile-loop ক্রম ii → jj → kk, ভেতরে ikj. (ক) তিনটি tile ধরার শর্তে সবচেয়ে বড় T কত, আর N-কে ভাগ করে এমন কোন ২-এর ঘাত নেবেন? (খ) সূত্রমতে ভেতরের loop-এর প্রতি iteration-এ miss কত, ikj-এর ০.২৫-এর তুলনায় কত গুণ কম? (গ) double-এর বদলে float হলে?

    (ক) ৩T² × ৮ ≤ ৪৯১৫২ ⇒ T² ≤ ২০৪৮ ⇒ T ≤ ৪৫.২৫. ১০২৪-কে ভাগ করে এমন ২-এর ঘাত: ৩২ (৬৪ হলে ৩ × ৬৪ × ৬৪ × ৮ = ৯৬ KiB, ধরে না)। T = ৩২: তিনটি tile = ২৪ KiB, cache-এর অর্ধেক — অন্য ডেটার জন্য জায়গা থাকে।

    (খ) B = ৬৪ ÷ ৮ = ৮. Miss ≈ ২ ÷ (B·T) + ১ ÷ (B·N) = ২ ÷ ২৫৬ + ১ ÷ ৮১৯২ ≈ ০.০০৭৮১ + ০.০০০১২ ≈ ০.০০৭৯. ০.২৫ ÷ ০.০০৭৯ ≈ ৩১.৫ গুণ কম।

    (গ) float ৪ byte: ৩T² × ৪ ≤ ৪৯১৫২ ⇒ T ≤ ৬৪. কিন্তু T = ৬৪-এ তিনটি tile পুরো ৪৮ KiB ভরায় — অন্য কিছুর জায়গা নেই, conflict-ও হবে; নিরাপদ পছন্দ T = ৩২ (১২ KiB). তখন B = ১৬, miss ≈ ২ ÷ (১৬ × ৩২) ≈ ০.০০৩৯. শেষ সিদ্ধান্ত: ৩২ আর ৬৪ দুটিই মেপে দেখা।

  3. False sharing খুঁজুন (আউটপুট অনুমান): নিচের কোডে দুটি thread চলে, প্রতিটি নিজের id দিয়ে (০ বা ১)। দুটি শেষ হলে printf("%ld %ld\n", hits[0], hits[1]); চলে। কোড -O0-তে compile হয়, যন্ত্রে দুটি আলাদা core.
    long hits[2];
    void *work(void *p) {
        long id = (long)p;
        for (long i = 0; i < 100000000; i++) hits[id]++;
        return NULL;
    }
    (ক) কী print হবে, আর ফল কি সঠিক? (খ) একটি thread-এর চেয়ে দুটি thread দ্বিগুণ দ্রুত শেষ করবে বলে আশা করা যায় কি? (গ) দুটি সংশোধন লিখুন। (ঘ) যদি দুটি thread-ই একটি ভ্যারিয়েবল total++ করত, সমস্যাটি কোন ধরনের হতো?

    (ক) 100000000 100000000 — সঠিক। প্রতিটি thread শুধু নিজের ঘর লেখে, কোনো data race নেই।

    (খ) না। hits[0] আর hits[1] ৮ byte দূরে — একই ৬৪-byte line-এ (১৬ byte-এর array-টি সাধারণত ১৬-এর গুণিতক ঠিকানায় বসে, তাই line-এর সীমানায় ভাগ হয় না)। প্রতিটি ++-এ line-এর মালিকানা core থেকে core-এ যায়: false sharing. দুই thread মিলেও একটি thread-এর একার সময়ের কাছাকাছি বা তার চেয়ে বেশি লাগতে পারে।

    (গ) সংশোধন ১ — padding: struct slot { alignas(64) long v; }; struct slot hits[2]; — প্রতিটি ঘর নিজের line-এ (sizeof = ৬৪)। সংশোধন ২ — ভাগাভাগিই নয়: loop-এ একটি local long n = 0; বাড়িয়ে শেষে একবার hits[id] = n; — line ছোঁয়া হয় মাত্র একবার। দ্বিতীয়টি সাধারণত আরও ভালো, -O2-তে n register-এই থাকে।

    (ঘ) তখন true sharing: দুজন সত্যিই একই ঠিকানায় লেখে। শুধু ধীর নয়, ভুলও — সাধারণ ++ atomic নয়, তাই কিছু যোগ হারিয়ে যাবে (data race). Atomic বা lock দিলে ফল সঠিক হয়, কিন্তু line-এর টানাটানি থেকেই যায়; আসল প্রতিকার আবার local-এ জমিয়ে শেষে একবার যোগ।

  4. AoS থেকে SoA (কোড লিখুন): struct particle { double x, y, z, vx, vy, vz; int id; char name[20]; };-এর ১০ লাখ কণার একটি array. প্রতি time-step-এ চলে p[i].x += p[i].vx * dt; (y আর z-এর জন্যও একই)। (ক) x86-64-এ sizeof(struct particle) কত, আর শুধু x-এর loop-টি কয়টি line ছোঁয়? (খ) SoA আর hot/cold ভাগ করে নতুন বিন্যাস লিখুন। (গ) নতুন বিন্যাসে x-এর loop কয়টি line ছোঁয়?

    (ক) ৬ × ৮ = ৪৮, int ৪ (offset ৪৮), name ২০ (offset ৫২) → ৭২ byte; সবচেয়ে বড় alignment ৮, ৭২ তার গুণিতক, তাই শেষে padding নেই: ৭২. Loop পরপর সব struct ছোঁয়, তাই প্রায় পুরো array-র line আসে: ১০ লাখ × ৭২ ÷ ৬৪ = ১১ লাখ ২৫ হাজার line, যার প্রতিটির মাত্র ১৬ ÷ ৭২ ভাগ কাজে লাগে।

    (খ) একটি সম্ভাব্য বিন্যাস:

    struct particles_hot {               /* SoA: প্রতি field একটি array */
        double *x, *y, *z, *vx, *vy, *vz;
    };
    struct particle_cold { int id; char name[20]; };   /* cold: আলাদা array */
    
    void step(struct particles_hot *p, size_t n, double dt) {
        for (size_t i = 0; i < n; i++) p->x[i] += p->vx[i] * dt;
        for (size_t i = 0; i < n; i++) p->y[i] += p->vy[i] * dt;
        for (size_t i = 0; i < n; i++) p->z[i] += p->vz[i] * dt;
    }

    Cold অংশ struct particle_cold cold[N], একই index i দিয়ে — নাম লাগলে cold[i].name. প্রতিটি array aligned_alloc(64, …) দিয়ে বরাদ্দ করলে শুরু line-এর সীমানায় পড়ে।

    (গ) x আর vx: ২ × ১০ লাখ × ৮ ÷ ৬৪ = আড়াই লাখ line — ৪.৫ গুণ কম, আর আনা প্রতিটি byte কাজে লাগে। বাড়তি লাভ: compiler সহজে SIMD-এ vectorize করতে পারে।

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

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

ক্রমএকটি ধীর প্রোগ্রামকে cache-বান্ধব করার কাজের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. 📏 আসল flag (-O2) আর আসল মাপের ডেটা দিয়ে একটি পুনরাবৃত্তিযোগ্য মাপ ঠিক করা
  2. 🔍 Profiler দিয়ে খুঁজে বের করা কোন loop সবচেয়ে বেশি সময় নেয়
  3. 🧮 সেই loop-এর access pattern থেকে প্রতি iteration-এ miss হিসাব করা
  4. 🔧 একটি পরিবর্তন করা — loop interchange, tiling বা ডেটার বিন্যাস
  5. ✅ নতুন ফল পুরোনো ফলের সাথে মিলছে কি না যাচাই করা
  6. ⏱️ কয়েকবার চালিয়ে সবচেয়ে কম সময় তুলনা করা, আর counter দিয়ে কারণ মেলানো

C-তে double a[100][300] (double ৮ byte, row-major) ঠিকানা 0x1000 থেকে শুরু। a[2][5]-এর ঠিকানা কত?

  • 0x22E8
  • 0x1FB0
  • 0x1974
  • 0x125D

C = A × B, তিনটিই N × N double, row-major, N খুব বড় (একটি row-ও এক loop-চক্র থেকে পরেরটি পর্যন্ত cache-এ টেকে না), line ৬৪ byte. ভেতরের loop-এর প্রতি iteration-এ মোট miss কোন ক্রমে সবচেয়ে কম?

  • ijk
  • jki
  • ikj
  • jik

L1 data cache ৩২ KiB. Double-এর tiled matrix গুণে তিনটি T × T tile একসাথে cache-এ ধরতে হবে, আর T একটি ২-এর ঘাত। সবচেয়ে বড় কোন T শর্ত মানে?

  • ১৬
  • ৩২
  • ৬৪
  • ১২৮

দুটি thread আলাদা core-এ চলে; একটি শুধু c[0]++, অন্যটি শুধু c[1]++ করে কোটিবার, যেখানে long c[2]. কোন বক্তব্যটি সঠিক?

  • ফল ভুল হবে, কারণ এটি data race; একটি mutex লাগবে
  • কোনো প্রভাব নেই, কারণ দুটি আলাদা ঠিকানা
  • c-কে volatile করলে সমস্যা মিটবে
  • ফল সঠিক, কিন্তু false sharing-এ ধীর; alignas(64) দিয়ে আলাদা line-এ রাখলে দ্রুত হয়

একটি benchmark-এ একটি বড় array-র যোগফল বের করার loop -O2-তে compile করে মাপা হলো, আর সময় এলো প্রায় ০ ms. যোগফলটি কোথাও print বা ব্যবহার করা হয়নি। সবচেয়ে সম্ভাব্য ব্যাখ্যা কী?

  • পুরো array L1 cache-এ ছিল, তাই সময় লাগেনি
  • Compiler loop-টি মুছে দিয়েছে (dead-code elimination)
  • Hardware prefetcher সব ডেটা আগেই এনে রেখেছিল
  • clock_gettime-এর resolution খুব মোটা

int a[1024][1024] (int ৪ byte, row-major) column ধরে পড়া হচ্ছে: for j { for i { s += a[i][j]; } }. Cache fully associative, LRU, ৫১২টি line, line ৬৪ byte. বড় সময় ধরে গড়ে প্রতি উপাদানে কয়টি miss?

  • ০
  • ১/১৬
  • ১/৪
  • ১

শব্দকোষ

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

মনে রাখুন

  1. একক হলো line: &a[i][j] = base + (i·M + j)·S; সারি ধরে প্রতি উপাদানে ১/B miss, column ধরে — row-সংখ্যা cache-এর line-সংখ্যার চেয়ে বেশি হলে — ১; ২-এর ঘাতের stride-এ conflict-ও যোগ হয়।
  2. ছয় loop-ক্রম: ভেতরের চলক দ্বিতীয় সূচকে = ১/B, প্রথম সূচকে = ১, অনুপস্থিত = ০; তাই ijk/jik ১.১২৫, jki/kji ২, ikj/kij ০.২৫ (double, ৬৪-byte line, N বড়)।
  3. Tiling: ৩T²·S ≤ cache থেকে T; miss ≈ ২/(B·T) + ১/(B·N) প্রতি iteration; transpose-এর মতো যেখানে interchange কাজ করে না, সেখানেও tiling.
  4. বিন্যাস: একসাথে লাগা field পাশাপাশি (SoA, hot/cold), field বড় থেকে ছোট ক্রমে, দরকারে alignas(64); linked list-এর pointer chasing miss-কে পরপর অপেক্ষা করায়; prefetch শুধু মেপে লাভ দেখলে।
  5. Thread আর মাপা: false sharing = আলাদা ভ্যারিয়েবল, একই line — padding বা local জমা; মাপুন -O2-তে, ফল ব্যবহার করে, কয়েকবারের সর্বনিম্ন নিয়ে — আর আগে profiler দেখুন, সব জায়গায় optimize নয়।

পরের পর্বে: Cache-এর নিচের স্তরে নামা — main memory. SRAM আর DRAM-এর cell কীভাবে কাজ করে, DDR-এর গতি কোথা থেকে আসে, interleaving কেন, আর ROM ও flash কোথায় বসে। এই পর্বে যে “miss-এর দাম” বারবার এসেছে, তার উৎস সেখানেই।

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

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