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

পারফরম্যান্স আর CPU cache — মেপে দ্রুত করা

Performance & the CPU Cache — Measure First, Then Make C Fast

Taught in Bangla, this lesson explains where time really goes: the memory hierarchy, cache lines and locality, row versus column traversal, struct padding and AoS versus SoA, branch prediction, compiler optimizations and SIMD, false sharing, and honest benchmarking with perf, cachegrind and Amdahl's law.

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

  • Register থেকে SSD পর্যন্ত মেমরির সিঁড়ি, আর কোন স্তরে মোটামুটি কত সময় লাগে
  • Cache line, spatial ও temporal locality — একটি খেলনা cache simulator দিয়ে hit আর miss গুনে দেখা
  • Struct padding, field-এর ক্রম, AoS বনাম SoA — ডেটার বিন্যাস বদলে মেমরি আর সময় বাঁচানো
  • Branch prediction, branchless কোড, compiler optimization, restrict, loop tiling, SIMD আর false sharing
  • ঠিকভাবে benchmark করা, perf ও cachegrind পড়া, Amdahl's law দিয়ে লাভ আগেই হিসাব করা — আর AI-এর optimization যাচাই

১ · একই algorithm, কয়েক গুণ পার্থক্য — সময় আসলে কোথায় যায়

চট্টগ্রামের একটি অনলাইন শপে কাজ করেন নাদিয়া। প্রতি রাতে তাঁর একটি C প্রোগ্রাম বিক্রির হিসাব করে। ডেটা থাকে একটি বড় টেবিলে — sales[শাখা][দিন], অর্থাৎ প্রতিটি সারি একটি শাখা, প্রতিটি কলাম একটি দিন। নাদিয়া লুপ লিখেছেন “প্রতিটি দিনের জন্য, সব শাখা ঘুরে যোগ করো”।

টেবিল বড় হতেই রিপোর্ট ধীর হয়ে গেল। AI-এর পরামর্শ (register, হাতে loop unrolling, গুণের বদলে shift) কাজে এলো না। তারপর একজন সিনিয়র শুধু লুপ দুটির ক্রম উল্টে দিলেন: “প্রতিটি শাখার জন্য, সব দিন ঘুরে যার যার দিনের ঘরে যোগ করো”। ফল হুবহু এক, কিন্তু প্রোগ্রাম কয়েক গুণ দ্রুত।

পর্ব ৩৯-এ শিখেছেন, Big-O গোনে কাজের ধাপ। দুটি সংস্করণেই ধাপ সমান — প্রতিটি ঘর একবার পড়া। পার্থক্য লুকিয়ে আছে Big-O যা বাদ দেয় তাতে: প্রতিটি ধাপের দাম। আর সেই দাম ঠিক করে মূলত ডেটা মেমরির কোন স্তর থেকে আসছে।

চায়ের দোকানির হাতের কাছে কাউন্টারে চিনি থাকলে এক কাপ চা আধা মিনিটের কাজ। কিন্তু চিনি যদি রাস্তার ওপারের গুদামে থাকে আর প্রতি কাপে সেখানে হেঁটে যেতে হয়, তাহলে গতি আর তাঁর হাতের দক্ষতায় নয় — গুদামের দূরত্বে আটকে যায়। CPU-ও তাই: হিসাবে অসম্ভব দ্রুত, কিন্তু ডেটার অপেক্ষায় বসে থাকে।

গত কয়েক দশকে প্রসেসরের গতি যত দ্রুত বেড়েছে, RAM থেকে ডেটা আনার সময় তত কমেনি। এই ক্রমবর্ধমান ফাঁককে বলে memory wall (ভাবনার প্রশ্ন ০১)। ফলে আজকের CPU-র বেশিরভাগ সময় হিসাবে নয়, ডেটার অপেক্ষায় কাটতে পারে। এই পর্বের পুরো কাহিনি তিনটি সরল নিয়মে বাঁধা:

ক্রমানুসারে ছুঁয়ে যান

মেমরিতে পাশাপাশি থাকা ডেটা পরপর পড়ুন। CPU একবারে একটি বড় টুকরো আনে, পরেরটাও আগাম আনতে পারে।

সারি ধরে লুপ, array

ডেটা ঘন রাখুন

যত কম বাইটে কাজের ডেটা ধরে, তত বেশি ডেটা cache-এ থাকে। অকেজো ফাঁকা বাইট মানে অকেজো যাতায়াত।

padding কমানো, SoA

অনুমানযোগ্য থাকুন

CPU আগাম আন্দাজ করে এগোয়। নিয়মিত branch আর নিয়মিত ঠিকানায় আন্দাজ মেলে, এলোমেলোতে মেলে না।

সাজানো ডেটা, branchless

সবার আগে মাপুন

কোথায় সময় যাচ্ছে, অনুমানে বলা যায় না। মাপ ছাড়া optimization প্রায়ই ভুল জায়গায় পরিশ্রম।

perf, cachegrind, benchmark
মূল ধারণা

Big-O বলে ইনপুট বাড়লে কাজ কত বাড়ে; মেমরির আচরণ বলে প্রতিটি কাজের দাম কত। ভালো প্রোগ্রামার আগে সঠিক algorithm বেছে নেন, তারপর ডেটা এমনভাবে সাজান যাতে CPU অপেক্ষা কম করে — আর পুরো সময় মাপ দিয়ে যাচাই করেন, কোথায় আসলে সময় যাচ্ছে।

২ · মেমরির সিঁড়ি — register থেকে SSD

দ্রুত মেমরি দামি আর ছোট; বড় মেমরি সস্তা কিন্তু ধীর। তাই কম্পিউটার একটি সিঁড়ির মতো কয়েক স্তরে মেমরি সাজায় — CPU-র একদম পাশে ছোট আর দ্রুত, দূরে বড় আর ধীর। এই ব্যবস্থার নাম memory hierarchy.

স্তরসাধারণ আকারএকবার পড়তে আনুমানিক সময়L1 = ১ সেকেন্ড ধরলে
Registerকয়েক ডজন, প্রতিটি ৮ বাইটএক cycle বা তারও কমচোখের পলক
L1 cacheপ্রতি core-এ ৩২–৪৮ KB~১ ns (৪–৫ cycle)১ সেকেন্ড
L2 cacheপ্রতি core-এ ২৫৬ KB – ২ MB~৩–৫ nsকয়েক সেকেন্ড
L3 cacheসব core মিলে কয়েক MB থেকে কয়েক ডজন MB~১০–২০ ns১০–২০ সেকেন্ড
RAM (DRAM)কয়েক GB~৮০–১০০ nsদেড় মিনিটের মতো
NVMe SSD (এলোমেলো পড়া)কয়েকশো GB – কয়েক TBকয়েক দশ µsকয়েক ঘণ্টা
হার্ড ডিস্ক (seek)কয়েক TB~৫–১০ msদুই থেকে চার মাস

সংখ্যাগুলো আনুমানিক — CPU-র প্রজন্ম, নির্মাতা আর মেশিনভেদে বদলায়। মুখস্থ করার দরকার নেই; মনে রাখুন অনুপাত। L1 থেকে RAM প্রায় একশো গুণ দূরে। চাওয়া ডেটা cache-এ না পেলে হয় cache miss — RAM থেকে আনার সেই সময়ে core কয়েকশো সাধারণ হিসাব করে ফেলতে পারত। উল্টোদিকে, চাওয়া ডেটা cache-এ পেয়ে যাওয়াকে বলে cache hit.

পরীক্ষার আগের রাতে পড়ছেন: হাতের খাতা register, টেবিলে খোলা বই L1, পাশের তাক L2, ঘরের আলমারি L3. বই বাসায় না থাকলে নীলক্ষেত যেতে হবে (RAM), আর গ্রামের বাড়িতে ফেলে আসা নোট আনাতে কয়েক দিন (ডিস্ক)।

“Latency numbers every programmer should know” নামে একটি তালিকা প্রোগ্রামারদের মধ্যে খুব পরিচিত। এর উৎস Peter Norvig-এর ২০০১ সালের প্রবন্ধ “Teach Yourself Programming in Ten Years”, আর পরে Google-এর Jeff Dean-এর বক্তৃতায় এটি ব্যাপক ছড়ায়। সেখানে মোটামুটি এমন সংখ্যা ছিল: L1 থেকে পড়া ০.৫ ns, branch-এর ভুল আন্দাজ ৫ ns, RAM থেকে পড়া ১০০ ns, ডিস্কে seek প্রায় ১০ ms. আজ L1 আর RAM-এর সংখ্যা খুব বেশি বদলায়নি, ডিস্কের জায়গায় SSD এসেছে। তালিকার আসল শিক্ষা তাই আজও খাটে: দূরত্বই দাম।

একটি কাজ শুরু থেকে শেষ হতে যে সময় লাগে, তাকে বলে latency. RAM-এর latency বেশি হলেও একটানা পড়লে সে প্রতি সেকেন্ডে কয়েক দশ GB পাঠাতে পারে, কারণ একসাথে অনেক অনুরোধ চলে। তাই ক্রমানুসারে পড়া প্রায় সবসময় এলোমেলো পড়ার চেয়ে অনেক সস্তা।

৩ · Cache line আর locality — একবারে ৬৪ বাইট

CPU যখন RAM থেকে একটি int চায়, তখন শুধু সেই ৪ বাইট আসে না। আসে তার চারপাশের একটি নির্দিষ্ট মাপের টুকরো — cache line. x86-64 প্রসেসরে (Intel, AMD) এই মাপ সাধারণত ৬৪ বাইট; কিছু ARM চিপে, যেমন Apple-এর M-সিরিজে, ১২৮ বাইট। ৬৪ বাইটে ১৬টি int ধরে। অর্থাৎ a[0] পড়লে (array-টি line-এর শুরুতে থাকলে) a[0] থেকে a[15] পর্যন্ত সব একসাথে চলে আসে।

নিচের ডায়াগ্রামে একটি ৩২ ঘরের int array-এর যোগফল বের করা হচ্ছে। “পরে” চাপুন, আর দেখুন কোন পড়াগুলো সত্যিই RAM পর্যন্ত যায়:

1int a[32];        /* 32 x 4 = 128 bytes = 2 lines */2long s = 0;3for (int i = 0; i < 32; i++)4    s += a[i];
Cache-এর অবস্থা
i
ফল
cache-এ
miss
hit
  1. প্রথম পড়া — miss. a[0] cache-এ নেই, তাই CPU RAM থেকে পুরো ৬৪ বাইটের line আনে — a[0] থেকে a[15]. এই একটি পড়ায় প্রায় ১০০ ns লাগতে পারে।
  2. দ্বিতীয় পড়া — hit. a[1] একই line-এ, আগেই চলে এসেছে। L1 থেকে পড়তে লাগে মাত্র কয়েক cycle. এটাই spatial locality-র পুরস্কার: পাশের ঘর বিনা খরচে।
  3. পরের ১৪টি পড়াও hit. a[2] থেকে a[15] — সবই প্রথম line-এর ভেতরে। ১৬টি পড়ায় খরচ হলো মাত্র একবার RAM যাওয়া।
  4. a[16] — আবার miss. এটি দ্বিতীয় line-এর শুরু, তাই নতুন করে আনতে হয় a[16..31]. বাস্তবে এখানে একটি সাহায্যকারী কাজ করে: hardware prefetcher দেখে আপনি ঠিকানা ধরে সোজা এগোচ্ছেন, তাই দ্বিতীয় line আপনার চাওয়ার আগেই আনতে শুরু করে দেয়।
  5. হিসাব। ৩২টি পড়ায় ২টি miss, ৩০টি hit — hit rate প্রায় ৯৪%. ঘর যত বড় হোক, ক্রমানুসারে পড়লে প্রতি ১৬টি int-এ মাত্র একটি miss. আর prefetcher সেটুকুও অনেকটা লুকিয়ে ফেলে।

দুই রকম locality

  • Spatial locality (স্থানের নৈকট্য): একটি ঠিকানা পড়লে খুব শিগগিরই তার পাশের ঠিকানাও পড়বেন। Array ক্রমানুসারে পড়া এর সেরা উদাহরণ।
  • Temporal locality (সময়ের নৈকট্য): যে ডেটা এইমাত্র পড়েছেন, শিগগিরই আবার পড়বেন। যেমন একটি ছোট lookup table বারবার ব্যবহার করা, বা একই ডেটার ওপর কয়েকটি কাজ একসাথে সেরে ফেলা — ডেটা cache থেকে বেরিয়ে যাওয়ার আগেই।

একটি সাধারণ ভুল: দশ লাখ গ্রাহকের তালিকায় মোট ব্যালান্স, সর্বোচ্চ ব্যালান্স আর সক্রিয় গ্রাহক — তিনটি কাজের জন্য তিনটি আলাদা লুপ। তালিকা cache-এর চেয়ে বড়, তাই পুরোটা তিনবার RAM থেকে আসে। এক লুপে তিনটি কাজ করলে (loop fusion) প্রতিটি গ্রাহক একবার এসেই তিনটি কাজে লাগে।

Linked list বনাম array — ঠিকানার পেছনে দৌড়

পর্ব ৩৩-এ linked list বানিয়েছেন। Array আর linked list — দুটিতেই সব উপাদান একবার করে দেখা O(n). কিন্তু মেমরির কাছে দুটি সম্পূর্ণ আলাদা কাজ:

  • Array: সব উপাদান পাশাপাশি। প্রতি line-এ অনেকগুলো উপাদান, আর পরের ঠিকানা আগে থেকেই জানা — prefetcher খুশি।
  • Linked list: প্রতিটি node malloc থেকে আলাদাভাবে এসেছে, মেমরিতে ছড়ানো থাকতে পারে। আর পরের node কোথায়, তা জানতে আগে এই node-এর next পড়তেই হবে — একে বলে pointer chasing. তাই CPU একসাথে কয়েকটি পড়া চালাতে পারে না, prefetcher-ও পরের ঠিকানা আন্দাজ করতে পারে না।

ফলে বড় তালিকায় linked list ঘুরে আসা array-র চেয়ে অনেক গুণ ধীর হতে পারে — Big-O সমান হলেও। node-গুলো একটি বড় block থেকে পাশাপাশি দিলে (অংশ ৮-এর arena) ক্ষতি অনেক কমে।

Prefetching-এর অন্তর্দৃষ্টি: Hardware prefetcher নিয়মিত প্যাটার্ন চেনে — ক্রমানুসারে পড়া বা নির্দিষ্ট দূরত্বে লাফ (stride); এলোমেলো ঠিকানা বা pointer chasing চেনে না। GCC-র __builtin_prefetch দিয়ে হাতে অনুরোধ করা যায়, কিন্তু সাধারণত দরকার হয় না। আগে ডেটার বিন্যাস ঠিক করুন — prefetcher নিজেই কাজ করবে।

৪ · সারি ধরে না কলাম ধরে — cache গুনে দেখা

এবার নাদিয়ার রহস্যে ফিরি। পর্ব ২১-এ বহুমাত্রিক অ্যারে-তে দেখেছেন, C-তে 2D array মেমরিতে থাকে row-major ক্রমে: প্রথম সারির সব ঘর পাশাপাশি, তারপর দ্বিতীয় সারি, এভাবে। তাই sales[b][d]-এর ঠিক পরের ঘর হলো sales[b][d+1], আর sales[b+1][d] আছে পুরো একটি সারি দূরে।

Cache কীভাবে কাজ করে, তা বোঝাতে একটি খুব ছোট খেলনা ধরি: ৪ × ৪ grid, প্রতিটি line-এ ৪টি int (অর্থাৎ এক সারি = এক line), আর cache-এ জায়গা মাত্র ২টি line-এর। কোন line কোন জায়গায় বসবে, তার নিয়ম সরল — সারি নম্বর জোড় হলে জায়গা ০, বিজোড় হলে জায়গা ১। এমন cache-কে বলে direct-mapped cache: প্রতিটি line-এর জন্য cache-এ ঠিক একটিই নির্দিষ্ট জায়গা।

int g[4][4]খেলনা: ১ line = ৪ int = ১ সারি
সারি 0: [ 0  1  2  3]  -> জায়গা 0
সারি 1: [ 4  5  6  7]  -> জায়গা 1
সারি 2: [ 8  9 10 11]  -> জায়গা 0
সারি 3: [12 13 14 15]  -> জায়গা 1
cache: জায়গা 0 = খালি, জায়গা 1 = খালি
সারি ধরে: for r { for c }প্রথম সারি
g[0][0]  M   সারি 0 আনা -> জায়গা 0
g[0][1]  H
g[0][2]  H
g[0][3]  H
৪টি পড়ায় ১টি miss
সারি ধরে: পুরো gridM = miss, H = hit
সারি 0:  M H H H
সারি 1:  M H H H
সারি 2:  M H H H
সারি 3:  M H H H
মোট: miss 4, hit 12
কলাম ধরে: for c { for r }কলাম 0
g[0][0]  M  জায়গা 0 = সারি 0
g[1][0]  M  জায়গা 1 = সারি 1
g[2][0]  M  জায়গা 0 = সারি 2  (সারি 0 বের হলো)
g[3][0]  M  জায়গা 1 = সারি 3  (সারি 1 বের হলো)
কলাম ধরে: কলাম 1আগের line নেই
g[0][1]  M  সারি 0 দরকার, কিন্তু সেটা বের হয়ে গেছে!
g[1][1]  M
g[2][1]  M
g[3][1]  M
একই line বারবার আনা আর ফেলা
ফলাফলএকই ১৬টি ঘর, একই যোগফল
              miss   hit
সারি ধরে        4     12
কলাম ধরে       16      0
line-এ ১৬টি int হলে পার্থক্য ১৬ গুণ
  1. খেলনা সেটআপ। প্রতিটি সারি ঠিক একটি line. জোড় সারি যায় জায়গা ০-তে, বিজোড় সারি জায়গা ১-এ। cache শুরুতে খালি।
  2. সারি ধরে — প্রথম সারি। g[0][0] পড়তে সারি ০-এর পুরো line এল (miss). পরের তিনটি ঘর একই line-এ — তিনটিই hit.
  3. সারি ধরে — পুরো grid. প্রতিটি সারির প্রথম ঘরে একটি miss, বাকি তিনটি hit. ১৬টি পড়ায় মাত্র ৪টি miss — যত line আছে, ঠিক ততটি। এর চেয়ে কম হওয়া সম্ভব নয়।
  4. কলাম ধরে — কলাম ০। এবার প্রতিটি পরের ঘর আলাদা সারিতে, তাই প্রতিবার নতুন line. আর মাত্র দুটি জায়গা থাকায় সারি ২ এসে সারি ০-কে, সারি ৩ এসে সারি ১-কে বের করে দিল।
  5. কলাম ধরে — কলাম ১। g[0][1]-এর জন্য সারি ০ আবার লাগবে — কিন্তু সেটা একটু আগেই বের হয়ে গেছে! আবার miss. প্রতিটি line আনা হলো, তার একটি মাত্র ঘর কাজে লাগল, তারপর ফেলে দেওয়া হলো।
  6. ফল। একই grid, একই যোগফল — সারি ধরে ৪টি miss, কলাম ধরে ১৬টি। আসল cache-এ line-এ ১৬টি int ধরে, তাই বড় টেবিলে পার্থক্য দাঁড়ায় প্রায় ১৬ গুণ বেশি মেমরি-যাতায়াত। নিচের প্রোগ্রাম ঠিক এটাই গুনে দেখায়।

এবার একটু বড় করে সত্যিকারের মাপে গুনি। নিচের প্রোগ্রাম একটি direct-mapped খেলনা cache বানায় — ৬৪ বাইটের ৮টি line, মোট ৫১২ বাইট। তারপর ৬৪ শাখা × ৬৪ দিনের int বিক্রির টেবিলের (১৬ KB) প্রতিটি ঘরের ঠিকানা দুই ক্রমে “ছুঁয়ে” গোনে, কতবার নতুন line আনতে হলো। কোনো সময় মাপা হচ্ছে না, তাই ফল প্রতিবার হুবহু এক:

#include <stdio.h>
#define N 64     /* sales[64][64] of int = 16 KB */
#define LINE 64  /* bytes per cache line */
#define SETS 8   /* toy direct-mapped cache: 8 lines = 512 bytes */

static long count_misses(int by_row) {
    long tag[SETS], misses = 0;
    for (int s = 0; s < SETS; s++) tag[s] = -1;          /* empty cache */
    for (int i = 0; i < N; i++)
        for (int j = 0; j < N; j++) {
            long idx = by_row ? (long)i * N + j : (long)j * N + i;
            long line = idx * (long)sizeof(int) / LINE;   /* which line? */
            if (tag[line % SETS] != line) { tag[line % SETS] = line; misses++; }
        }
    return misses;
}

int main(void) {
    printf("accesses: %d\n", N * N);
    printf("misses by row: %ld, by column: %ld\n", count_misses(1), count_misses(0));
    return 0;
}

আউটপুট: accesses: 4096 আর misses by row: 256, by column: 4096. প্রতিটি ঘরের ক্রমিক নম্বর (idx) থেকে বাইট-ঠিকানা, তা থেকে line নম্বর; line % SETS বলে cache-এর কোন জায়গায় বসবে, আর tag মনে রাখে সেখানে এখন কোন line আছে। সারি ধরে প্রতি ১৬টি ঘরে একটি নতুন line — ৪,০৯৬ ÷ ১৬ = ২৫৬টি miss. কলাম ধরে প্রতিটি পরের ঘর ২৫৬ বাইট দূরে; সেই line-গুলো cache-এর মাত্র দুটি জায়গায় পড়ে আর একে অপরকে বের করে দেয় — প্রতিটি পড়াই miss. SETS বাড়িয়ে ১২৮ বা ২৫৬ করে দেখুন, কলাম-ক্রমের miss কখন কমে।

আসল cache অনেক বড়, আর সাধারণত set-associative — একটি জায়গায় ৮ বা ১২টি line থাকতে পারে। কিন্তু টেবিলও তখন লাখ লাখ ঘরের, তাই বড় টেবিলে অনেক মেশিনে সারি-ক্রম কয়েক গুণ দ্রুত; নির্দিষ্ট সংখ্যা হার্ডওয়্যার আর মাপের ওপর নির্ভর করে।

২-এর ঘাতের ফাঁদ: খেলনায় কলাম-ক্রমের line-গুলো মাত্র দুটি জায়গায় জড়ো হলো, কারণ সারির মাপ (২৫৬ বাইট) cache-এর মাপের সাথে সুন্দরভাবে মিলে যায়। আসল cache-এও এমন হয় — যেমন সারির মাপ ঠিক ৪০৯৬ বাইট হলে একই কলামের সব ঘর একই set-এ পড়তে পারে, আর জায়গা থাকা সত্ত্বেও একে অপরকে বের করে দেয়। একে বলে conflict miss. অনেক সময় সারির শেষে কয়েকটি বাড়তি ঘর যোগ করে (যেমন ১০২৪-এর বদলে ১০৪০টি কলাম) এই সমস্যা দূর করা হয়।

নাদিয়ার সমাধান: লুপের ক্রম উল্টে সারি ধরে হাঁটা, আর দিনের যোগফল একটি ছোট day_total[দিন] array-তে জমানো, যা পুরো সময় cache-এ থাকে। মূল কথা: যে ক্রমে বেশি পড়বেন, সেই ক্রমে মেমরিতে সাজান।

৫ · ডেটার বিন্যাস — padding, field-এর ক্রম, AoS বনাম SoA

পর্ব ২২-এ struct পড়ার সময় দেখেছেন, compiler প্রতিটি member-কে তার alignment মেনে বসায় — double সাধারণত ৮-এর গুণিতক ঠিকানায়, int ৪-এর গুণিতকে। মাঝের ফাঁকা বাইটগুলোকে বলে padding. এক রেকর্ডে কয়েক বাইট নষ্ট হওয়া তুচ্ছ মনে হয়। কিন্তু দশ লাখ গ্রাহকের রেকর্ডে সেটাই কয়েক MB — আর প্রতিটি অকেজো বাইট cache-এ জায়গা খায়, RAM থেকে আনতে সময় খায়।

struct cust_badযে ক্রমে মনে এসেছে, সেই ক্রমে
struct cust_bad {
    char   active;    /* 1 byte, align 1 */
    double balance;   /* 8 bytes, align 8 */
    char   tier;      /* 1 byte, align 1 */
    int    id;        /* 4 bytes, align 4 */
};                    /* আসল ডেটা: 14 bytes */
মেমরিতে cust_bad. = padding
বাইট     0 1 2 3 4 5 6 7
সারি  0: A . . . . . . .   active + 7 ফাঁকা
সারি  8: B B B B B B B B   balance
সারি 16: T . . . I I I I   tier + 3 ফাঁকা + id
sizeof = 24, এর মধ্যে 10 বাইট ফাঁকা
struct cust_goodবড় থেকে ছোট ক্রমে
বাইট     0 1 2 3 4 5 6 7
সারি  0: B B B B B B B B   balance
সারি  8: I I I I A T . .   id, active, tier
sizeof = 16, ফাঁকা মাত্র শেষের 2 বাইট
শেষের 2 বাইট কেন?array-তে পরের রেকর্ড
c[0]: B B B B B B B B | I I I I A T . .
c[1]: B B B B B B B B | ...
      ^ ঠিকানা 16 = 8-এর গুণিতক
ফাঁকা না রাখলে c[1].balance বসত 14-তে, alignment ভাঙত
দশ লাখ গ্রাহকx86-64 Linux
             প্রতি রেকর্ড   মোট           এক line-এ
cust_bad       24 বাইট    24,000,000    2টি রেকর্ড
cust_good      16 বাইট    16,000,000    4টি রেকর্ড
বাঁচল                      8,000,000 বাইট (প্রায় 7.6 MiB)
  1. যেমন খুশি ক্রম। একটি গ্রাহক-রেকর্ড: সক্রিয় কি না, ব্যালান্স, গ্রাহকের স্তর আর আইডি। আসল তথ্য মাত্র ১ + ৮ + ১ + ৪ = ১৪ বাইট।
  2. Compiler-এর বসানো। active বসল ০-তে। balance ৮-এর গুণিতকে বসতে চায়, তাই ১ থেকে ৭ ফাঁকা। tier বসল ১৬-তে, id ৪-এর গুণিতক চায়, তাই আরও ৩ বাইট ফাঁকা, id বসল ২০-তে। মোট ২৪ বাইট — প্রায় ৪২% অপচয়।
  3. বড় থেকে ছোট সাজানো। আগে ৮ বাইটের double, তারপর ৪ বাইটের int, শেষে দুটি char. প্রত্যেকে নিজের alignment-এ আপনিই বসে যায় — মাঝখানে কোনো ফাঁকা নেই। মোট ১৬ বাইট।
  4. শেষের ২ বাইট কেন রইল? Struct array-তে পরপর থাকে। পরের রেকর্ডের balance-কেও ৮-এর গুণিতকে থাকতে হবে, তাই struct-এর মাপ সবসময় তার সবচেয়ে বড় alignment-এর গুণিতক হয় — ১৪ থেকে বেড়ে ১৬। একে বলে tail padding.
  5. বড় পরিসরে লাভ। দশ লাখ রেকর্ডে ঠিক ৮০ লাখ বাইট কম। আর একটি ৬৪ বাইটের cache line-এ এখন ২টির বদলে ৪টি রেকর্ড ধরে — পুরো তালিকা ঘুরে আসতে অর্ধেক line আনলেই চলে। একটি লাইনও কোড না বদলে, শুধু field-এর ক্রম বদলে।

নিচের প্রোগ্রাম sizeof আর offsetof দিয়ে compiler-এর আসল সিদ্ধান্ত ছাপায়, তারপর দশ লাখ রেকর্ডের হিসাব করে:

#include <stdio.h>
#include <stddef.h>

struct cust_bad  { char active; double balance; char tier; int id; };
struct cust_good { double balance; int id; char active; char tier; };

int main(void) {
    printf("bad : size %zu, offsets %zu %zu %zu %zu\n", sizeof(struct cust_bad),
           offsetof(struct cust_bad, active), offsetof(struct cust_bad, balance),
           offsetof(struct cust_bad, tier), offsetof(struct cust_bad, id));
    printf("good: size %zu, offsets %zu %zu %zu %zu\n", sizeof(struct cust_good),
           offsetof(struct cust_good, balance), offsetof(struct cust_good, id),
           offsetof(struct cust_good, active), offsetof(struct cust_good, tier));
    size_t n = 1000000, a = sizeof(struct cust_bad), b = sizeof(struct cust_good);
    printf("1M records: %zu vs %zu bytes, saved %zu\n", n * a, n * b, n * (a - b));
    printf("records per 64-byte line: %zu vs %zu\n", 64 / a, 64 / b);
    return 0;
}

x86-64 Linux-এ আউটপুট: bad : size 24, offsets 0 8 16 20, good: size 16, offsets 0 8 12 13, 1M records: 24000000 vs 16000000 bytes, saved 8000000, records per 64-byte line: 2 vs 4. offsetof বলে struct-এর শুরু থেকে প্রতিটি member কত বাইট দূরে — ফাঁকাগুলো সেখানেই চোখে পড়ে (০ থেকে সরাসরি ৮-এ লাফ)। এই সংখ্যাগুলো প্ল্যাটফর্মভেদে বদলাতে পারে; যেমন পুরোনো ৩২-বিট x86 Linux-এ struct-এর ভেতরে double-এর alignment ৪, তাই সেখানে মাপ ভিন্ন আসে। তাই অনুমান নয়, sizeof ছাপিয়ে দেখুন।

__attribute__((packed)) দিয়ে সব ফাঁকা মুছবেন? মাপ ১৪ হবে, কিন্তু balance বসবে বেজোড় ঠিকানায় — x86-এ একটু ধীর হতে পারে, কিছু প্রসেসরে সরাসরি crash, আর packed member-এর ঠিকানা pointer-এ নেওয়া ঝুঁকিপূর্ণ। ফাইল বা নেটওয়ার্ক ফরম্যাটে packed-এর জায়গা আছে; গতির জন্য প্রথম পছন্দ field সাজানো।

AoS বনাম SoA — কোন ঘরগুলো পাশাপাশি রাখবেন

ধরুন দশ লাখ গ্রাহকের মোট ব্যালান্স দরকার। সাধারণ নকশায় একটি struct-এর array থাকে — Array of Structs (AoS). প্রতিটি রেকর্ডের ১৬ বাইটের মধ্যে কাজে লাগে শুধু ৮ বাইটের balance; বাকি অর্ধেক line অকারণে আসে। বিকল্প হলো Struct of Arrays (SoA) — প্রতিটি field-এর জন্য আলাদা array. এই দুই নকশার তুলনাকে একসাথে বলে AoS বনাম SoA:

/* AoS: one array of records - natural, good when you use whole records */
struct customer { double balance; int id; char active; char tier; };
struct customer cust[1000000];

/* SoA: one array per field - good when a loop uses only one or two fields */
struct customers {
    double balance[1000000];
    int    id[1000000];
    char   active[1000000];
    char   tier[1000000];
};

/* summing balances in SoA: every byte fetched is a balance */
double total = 0;
for (int i = 0; i < n; i++) total += all.balance[i];

SoA-তে ব্যালান্সের লুপ শুধু ব্যালান্সই আনে — প্রতিটি line-এ ৮টি double, অপচয় নেই, আর compiler সহজে SIMD করতে পারে (অংশ ৭)। কিন্তু একজন গ্রাহকের সব তথ্য একসাথে লাগলে SoA-তে চারটি line আনতে হয়, AoS-এ একটি। নিয়ম: সবচেয়ে ঘন ঘন চলা লুপ যে field-গুলো একসাথে ছোঁয়, সেগুলো পাশাপাশি রাখুন।

মাঝামাঝি একটি কৌশল হলো hot/cold splitting: রেকর্ডের ঘন ঘন ব্যবহৃত ছোট field (ব্যালান্স, স্ট্যাটাস) এক struct-এ, আর কালেভদ্রে লাগা বড় field (ঠিকানা, নোট, ছবির পথ) আরেকটি struct-এ রেখে pointer বা index দিয়ে জোড়া। গরম অংশ ছোট থাকে, বেশি সংখ্যায় cache-এ ধরে।

অন্য ভাষায় একই গল্প: Python-এর list আসলে object-এর pointer-এর array, তাই লুপ মানে pointer chasing; NumPy array-তে সংখ্যাগুলো C-এর মতো পাশাপাশি, row-major. Fortran, MATLAB আর Julia-র matrix column-major, তাই সেখানে কলাম ধরে হাঁটাই দ্রুত। C++ কোর্সের পারফরম্যান্স পর্বে এই একই নীতি std::vector আর object নকশার দিক থেকে দেখানো হয়েছে।

৬ · Branch prediction আর শাখাহীন কোড

আধুনিক CPU একটি নির্দেশ শেষ হওয়ার অপেক্ষা না করে পরের অনেকগুলো নির্দেশ একসাথে বিভিন্ন ধাপে এগিয়ে রাখে — গার্মেন্টসের সেলাই লাইনের মতো। কিন্তু একটি if-এ এসে সমস্যা: শর্তের ফল জানার আগে কোন পথের নির্দেশ লাইনে তুলবে? CPU অপেক্ষা করে না — আগের ইতিহাস দেখে আন্দাজ করে এগিয়ে যায়। একে বলে branch prediction. আন্দাজ ঠিক হলে প্রায় কোনো খরচ নেই। ভুল হলে এগিয়ে রাখা সব কাজ ফেলে দিয়ে আবার শুরু করতে হয় — মোটামুটি ১০–২০ cycle নষ্ট। একে বলে branch misprediction.

long sum = 0;
for (int i = 0; i < n; i++)
    if (data[i] >= 128)        /* data: values 0..255 */
        sum += data[i];

এখন দুটি পরিস্থিতি ভাবুন, আর আন্দাজ কতবার ভুল হয় তা মনে মনে গুনুন:

ডেটাif-এর ফলের ধারা১ লাখ উপাদানে ভুল আন্দাজ (মোটামুটি)
এলোমেলো (০–২৫৫)হ্যাঁ না না হ্যাঁ না হ্যাঁ হ্যাঁ না …প্রায় অর্ধেক — ৫০ হাজারের কাছাকাছি
আগে সাজানোনা না না … না হ্যাঁ হ্যাঁ … হ্যাঁহাতে গোনা কয়েকটি — শুধু পরিবর্তনের জায়গায়

এলোমেলো ডেটায় কোনো predictor-ই কয়েন টসের চেয়ে ভালো আন্দাজ করতে পারে না; সাজানো ডেটায় আন্দাজ প্রায় সবসময় মেলে। ২০১২ সালের বিখ্যাত Stack Overflow প্রশ্ন “Why is processing a sorted array faster than processing an unsorted array?” এই ঘটনা নিয়েই (ভাবনার প্রশ্ন ০৩)।

শাখাহীন (branchless) কোড

অনুমান করা কঠিন এমন if-কে হিসাবে বদলে ফেলা যায়, যাতে লাফ দেওয়ার প্রশ্নই না ওঠে। একে বলে branchless কোড:

/* branchless sum: mask is all 1 bits (-1) or all 0 bits */
for (int i = 0; i < n; i++) {
    int mask = -(data[i] >= 128);
    sum += data[i] & mask;
}

/* max and clamp: compilers usually turn ?: into a conditional move */
static inline int max_int(int a, int b) { return a > b ? a : b; }
static inline int clamp(int x, int lo, int hi) {
    x = x < lo ? lo : x;
    return x > hi ? hi : x;
}

প্রথম লুপে তুলনার ফল (০ বা ১) থেকে mask: -(1) মানে সব বিট ১, -(0) মানে সব বিট ০; & করলে মান হয় পুরোটা থাকে, নয় শূন্য — কোনো লাফ নেই। max আর clamp-এর ?:-কে -O2-এ GCC ও Clang সাধারণত conditional move (x86-এর cmov) নির্দেশে বদলায়। বাস্তব উদাহরণ: সেন্সরের মান সীমার ভেতরে আটকানো, বা পিক্সেলকে ০–২৫৫-এ রাখা।

Branchless সবসময় ভালো নয়। যে branch প্রায় সবসময় একদিকে যায় (যেমন error check), সেখানে predictor প্রায় নিখুঁত, আর branchless সংস্করণ দুই দিকের কাজ করে বলে উল্টো ধীর হতে পারে। আধুনিক compiler নিজেই অনেক if শাখাহীন বা SIMD করে ফেলে। হাতের কৌশলে লাভ আছে কি না, মাপ দিয়ে প্রমাণ করুন।

৭ · Compiler-কে কাজে লাগানো — -O2, inlining, restrict, loop রূপান্তর, SIMD

হাতে কৌশল লেখার আগে মনে রাখুন: সবচেয়ে দক্ষ সহকারী compiler নিজেই। Optimization ছাড়া (-O0, GCC-র ডিফল্ট) compiler প্রায় প্রতিটি লাইন আক্ষরিক অনুবাদ করে; পর্ব ৪৬-এ assembly-তে এই পার্থক্য দেখেছেন। তাই -O0-তে মাপা গতির কোনো অর্থ নেই।

Flagকী করেকখন
-O0কোনো optimization নেই; debugger-এ প্রতিটি লাইন মেলেউন্নয়ন ও debugging (-O0 -g)
-O2inlining, invariant সরানো, অপ্রয়োজনীয় কোড মোছা, ভালো register ব্যবহার; নতুন GCC-তে সীমিত vectorization-ওপ্রকাশের মানক পছন্দ
-O3আরও আক্রমণাত্মক inlining, loop রূপান্তর আর vectorization; কোড বড় হয়সংখ্যাভিত্তিক ভারী লুপে — মেপে দেখে
-Osমাপ ছোট রাখতে optimizationছোট চিপ, embedded (পর্ব ৫৪)
-Ofast-O3 + -ffast-math: দশমিকের কঠোর নিয়ম ভাঙার অনুমতিখুব সাবধানে — নিচের সতর্কতা দেখুন
-march=nativeএই মেশিনের CPU-র সব নির্দেশ (যেমন AVX2) ব্যবহারশুধু যে মেশিনে build, সেখানেই চালালে

-Ofast আর -march=native-এর দাম। -ffast-math compiler-কে ধরে নিতে দেয় যে NaN বা infinity কখনো আসবে না, আর দশমিক যোগের ক্রম ইচ্ছেমতো বদলানো যাবে। ফলে isnan() পরীক্ষা নিঃশব্দে মুছে যেতে পারে, আর বড় যোগফলের শেষ অঙ্ক বদলে যেতে পারে — হিসাব-নিকাশ বা বৈজ্ঞানিক কোডে বিপজ্জনক। আর -march=native দিয়ে নিজের নতুন ল্যাপটপে build করা প্রোগ্রাম পুরোনো সার্ভারে চালালে Illegal instruction দিয়ে crash করতে পারে, কারণ সেই CPU-তে নতুন নির্দেশগুলো নেই।

Inlining আর restrict

Inlining মানে ছোট ফাংশন ডাকার বদলে তার কোড সরাসরি ডাকার জায়গায় বসানো। লাভ শুধু ডাকার খরচ বাঁচানো নয় — ভেতরের কোড চোখের সামনে এলে compiler আরও অনেক optimization করতে পারে। একই ফাইলের static ছোট ফাংশন -O2-এ সাধারণত আপনিই inline হয়। ভিন্ন .c ফাইলের ফাংশন inline করতে লাগে link-time optimization (-flto).

পর্ব ৪৫-এ উন্নত পয়েন্টার-এ restrict দেখেছেন। এটি পারফরম্যান্সে কেন জরুরি, দেখুন:

void add_arrays(int n, float *restrict dst,
                const float *restrict x, const float *restrict y) {
    for (int i = 0; i < n; i++)
        dst[i] = x[i] + y[i];
}

restrict ছাড়া compiler-কে ধরে নিতে হয় যে dst হয়তো x-এর ভেতরেই কোথাও (aliasing), তাই একসাথে আটটি যোগ করা নিরাপদ নয়। restrict লিখে আপনি প্রতিশ্রুতি দেন যে এরা একই মেমরি ছোঁবে না; তখন compiler নিশ্চিন্তে vectorize করে। প্রতিশ্রুতি ভাঙলে undefined behavior.

Loop রূপান্তর — hoisting, interchange আর tiling

  • Hoisting: লুপের ভেতরের যে হিসাব প্রতিবার একই ফল দেয়, তা বাইরে এনে একবার করা। Compiler প্রায়ই নিজেই করে, কিন্তু ফাংশন-ডাকের পার্শ্বপ্রতিক্রিয়া নিয়ে নিশ্চিত না হলে পারে না — for (i = 0; i < strlen(s); i++) তাই O(n²) হয়ে যেতে পারে।
  • Loop interchange: nested loop-এর ক্রম বদলে ভেতরের লুপকে সারি ধরে হাঁটানো — নাদিয়ার সমাধান।
  • Loop tiling (blocking): বড় ডেটাকে ছোট ছোট টুকরোয় (tile) ভাগ করে প্রতিটি টুকরোর কাজ শেষ করা, যাতে টুকরোটি cache-এ থাকতে থাকতেই বারবার ব্যবহৃত হয়।

Matrix গুণ এর সেরা উদাহরণ। পর্ব ২১-এর সরল i-j-k ক্রমে ভেতরের লুপ b[k][j] পড়ে কলাম ধরে — প্রতিটি ধাপে নতুন line. ক্রম i-k-j করলে ভেতরের লুপে b আর c দুটিই সারি ধরে হাঁটে। আর tiling আরও এক ধাপ এগোয়:

/* 1) loop interchange: i-k-j, inner loop walks rows of b and c */
for (int i = 0; i < n; i++)
    for (int k = 0; k < n; k++) {
        double aik = a[i][k];                 /* hoisted out of the j loop */
        for (int j = 0; j < n; j++)
            c[i][j] += aik * b[k][j];
    }

/* 2) tiling: work on T x T blocks that fit in cache (n multiple of T) */
for (int ii = 0; ii < n; ii += T)
    for (int kk = 0; kk < n; kk += T)
        for (int jj = 0; jj < n; jj += 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++)
                        c[i][j] += aik * b[k][j];
                }

দুটি সংস্করণই একই গুণফল দেয়, দুটিই O(n³) — পার্থক্য শুধু ঘর ছোঁয়ার ক্রমে। tiling-এ T × T মাপের টুকরোগুলো (T = ৩২ হলে প্রতিটি ৮ KB) একসাথে cache-এ ধরে, তাই b-এর একটি টুকরো একবার এনে অনেকবার ব্যবহার হয়। বড় matrix-এ এতে অনেক মেশিনে কয়েক গুণ গতি আসে। তবে বাস্তবে বড় matrix গুণের জন্য OpenBLAS-এর মতো লাইব্রেরি ব্যবহার করুন — সেখানে প্রতিটি CPU-র জন্য আলাদা tuning করা।

Auto-vectorization — এক নির্দেশে অনেক সংখ্যা

আধুনিক CPU-র একটি নির্দেশ একসাথে ৪, ৮ বা ১৬টি সংখ্যার ওপর কাজ করতে পারে — SIMD. সাধারণ লুপকেও compiler নিজে SIMD-এ বদলাতে পারে (auto-vectorization), যদি লুপ সরল হয়: ডেটা পাশাপাশি, aliasing-এর ভয় নেই, ভেতরে জটিল branch বা অজানা ফাংশন-ডাক নেই। কোন লুপ vectorize হলো, জিজ্ঞেস করা যায়:

gcc -O3 -fopt-info-vec-optimized add.c -o add
# add.c:3:23: optimized: loop vectorized using 16 byte vectors   (sample)

Assembly নিজের চোখে দেখতে Compiler Explorer (godbolt.org) সবচেয়ে সহজ — বাঁ পাশে C, ডান পাশে assembly. ymm register বা addps-এর মতো নির্দেশ দেখলে বুঝবেন SIMD হয়েছে।

৮ · Thread আর allocation-এর লুকানো খরচ — false sharing, malloc

পর্ব ৫২-এ মাল্টিথ্রেডিং শিখেছেন। এখানে cache আরেকটি অদ্ভুত সমস্যা তৈরি করে। প্রতিটি core-এর নিজস্ব L1 আর L2 cache আছে। একটি core কোনো line-এ লিখলে অন্য সব core-এর কাছে থাকা সেই line-এর কপি বাতিল করতে হয়, নইলে তারা পুরোনো মান পড়বে। এই হিসাব রাখার ব্যবস্থাকে বলে cache coherence. সমস্যা হলো, এই হিসাব চলে পুরো line ধরে, আলাদা ভ্যারিয়েবল ধরে নয়।

struct { long a; long b; } cnt;দুটি counter, একটি line
cache line (64 বাইট): [ a | b | ........ ]
thread 1 (core 0):  cnt.a++  বারবার
thread 2 (core 1):  cnt.b++  বারবার
দুজন আলাদা ভ্যারিয়েবলে লেখে — কোনো data race নেই
core 0 লিখল aline এখন core 0-এর
core 0 L1: [ a=1 | b=0 ]  মালিক (লেখার অধিকার)
core 1 L1: [ বাতিল ]
লিখতে হলে line-এর একমাত্র মালিক হতে হয়
core 1 লিখল bline টেনে নিতে হলো
core 0 L1: [ বাতিল ]
core 1 L1: [ a=1 | b=1 ]  মালিক
b লিখতে পুরো line core 0 থেকে আনতে হলো
লাখো বারপিং-পং
core 0: a++ -> line টানো
core 1: b++ -> line টানো
core 0: a++ -> line টানো
 ...
প্রতিটি ++ এখন L1-এর কয়েক cycle নয়, core-থেকে-core যাতায়াত
সমাধানআলাদা line, বা local হিসাব
struct padded { alignas(64) long v; };   /* sizeof = 64 */
struct padded cnt[2];   /* cnt[0] আর cnt[1] আলাদা line-এ */

আরও ভালো: প্রতিটি thread নিজের local ভ্যারিয়েবলে
গোনে, শেষে একবার মোটে যোগ করে।
  1. দুটি counter, একটি line. দুটি thread দুটি আলাদা counter বাড়াচ্ছে। যুক্তির দিক থেকে কোনো সমস্যা নেই — কেউ অন্যেরটা ছোঁয় না, mutex-ও লাগে না। কিন্তু a আর b পাশাপাশি ৮ বাইট করে, তাই একই ৬৪ বাইটের line-এ।
  2. Core 0 লিখল। লিখতে হলে line-এর একমাত্র মালিক হতে হয়। তাই core 1-এর কাছে থাকা কপি বাতিল হলো।
  3. Core 1 লিখল। b লিখতে core 1-কে line-টি core 0-এর কাছ থেকে টেনে আনতে হলো, আর এবার core 0-এর কপি বাতিল। অথচ core 1-এর a নিয়ে কোনো আগ্রহই নেই!
  4. টানাটানি চলতেই থাকে। প্রতিটি ++-এ line এক core থেকে আরেক core-এ যায় — প্রতিবার কয়েক দশ ns. একে বলে false sharing: ভাগ করা হচ্ছে না, তবু ভাগাভাগির খরচ। প্রোগ্রাম সঠিক ফলই দেয়, শুধু thread বাড়ালে দ্রুত হওয়ার বদলে অনেক সময় এক thread-এর চেয়েও ধীর হয়ে যায়।
  5. সমাধান। প্রতিটি counter-কে নিজস্ব ৬৪ বাইটের line দিন — C11-এর alignas(64) দিয়ে (C23-এ alignas সরাসরি keyword; C11-এ <stdalign.h> লাগে)। আরও ভালো সমাধান হলো ভাগাভাগিই না করা: প্রতিটি thread নিজের local ভ্যারিয়েবলে গোনে, শেষে একবার যোগ। মনে রাখবেন, কিছু ARM চিপে line ১২৮ বাইট — তখন ১২৮ দিয়ে align করতে হয়।

False sharing ধরা কঠিন: কোডে ভুল নেই, ThreadSanitizer-ও কিছু বলে না। লক্ষণ একটাই — thread বাড়ালে গতি বাড়ে না। Linux-এর perf c2c টুল ঠিক এই টানাটানি খুঁজে বের করতে তৈরি।

malloc-এর দাম — arena আর pool

malloc বিনামূল্যে নয়: allocator-কে খালি জায়গা খুঁজতে হয়, হিসাব রাখতে হয়, মাঝে মাঝে OS-এর কাছে মেমরি চাইতে হয়। একটি ছোট allocation-এ সাধারণত কয়েক দশ ns — লুপে লাখো বার হলে মোট সময়ের বড় অংশ। তার ওপর আলাদা আলাদা টুকরো মেমরিতে ছড়িয়ে থাকে, locality নষ্ট হয়।

পর্ব ৪৭-এ নিজের allocator বানানোর সময় এর দুটি জনপ্রিয় সমাধান দেখেছেন:

  • Arena allocator: শুরুতে একটি বড় block নিন, তারপর প্রতিটি চাওয়ায় শুধু একটি pointer সামনে সরান (bump allocation) — কয়েকটি নির্দেশের কাজ। আলাদা free নেই; কাজ শেষে পুরো arena একবারে ছেড়ে দিন। একটি HTTP request বা একটি ফাইল parse করার সব অস্থায়ী ডেটার জন্য আদর্শ। বোনাস: পরপর নেওয়া জিনিস মেমরিতেও পাশাপাশি থাকে।
  • Memory pool: একই মাপের অনেক বস্তুর (যেমন linked list-এর node) জন্য আগে থেকে সারি সারি খোপ রাখা; খালি খোপগুলো একটি free list-এ। নেওয়া আর ফেরত দেওয়া দুটিই O(1), আর node-গুলো কাছাকাছি থাকে।

তবে প্রথম কাজ অপ্রয়োজনীয় allocation বাদ দেওয়া — লুপের ভেতরে প্রতিবার নতুন buffer না নিয়ে বাইরে একবার নেওয়া। profiler-এ malloc/free বড় অংশ নিলে তবেই allocator বদলানোর কথা ভাবুন।

৯ · মাপার বিজ্ঞান — benchmark, profiler আর Amdahl's law

সব কৌশলের আগে একটি শর্ত: আগে জানুন সময় কোথায় যাচ্ছে। প্রক্রিয়াটি একটি চক্র: সঠিক কোড আর টেস্ট → লক্ষ্য ঠিক করা ও বর্তমান অবস্থা মাপা → profiler দিয়ে সবচেয়ে গরম জায়গা (hot spot) খোঁজা → একটি পরিবর্তন → আবার মাপা ও টেস্ট চালানো। একবারে একটি পরিবর্তন, যাতে বোঝা যায় কোনটি কাজ করল।

নিজে মাপা — clock_gettime দিয়ে ছোট benchmark

Benchmark লিখতে clock() নয় (সেটি CPU সময় মাপে, পর্ব ৩৯), বরং POSIX-এর clock_gettime ব্যবহার করুন CLOCK_MONOTONIC ঘড়ি দিয়ে — এই ঘড়ি কখনো পেছনে যায় না, কেউ সিস্টেমের সময় বদলালেও না। নিচের কাঠামোটি শুধু দেখার জন্য (এখানে চালানোর বোতাম নেই, কারণ সময় প্রতিবার আলাদা আসে):

#define _POSIX_C_SOURCE 199309L
#include <stdio.h>
#include <time.h>

static double now_sec(void) {
    struct timespec ts;
    clock_gettime(CLOCK_MONOTONIC, &ts);
    return ts.tv_sec + ts.tv_nsec / 1e9;
}

/* inside main, after building the table t: */
long checksum = 0;
for (int w = 0; w < 3; w++) checksum += sum_table(t);   /* warm-up runs */
double best = 1e9;
for (int r = 0; r < 10; r++) {                           /* repeat */
    double t0 = now_sec();
    checksum += sum_table(t);
    double dt = now_sec() - t0;
    if (dt < best) best = dt;
}
printf("best %.3f ms, checksum %ld\n", best * 1e3, checksum); /* use the result */

এই ছোট কাঠামোয় benchmark-এর পাঁচটি নিয়ম লুকিয়ে আছে:

  1. Optimization চালু করে build করুন (-O2) — যেভাবে আসল প্রোগ্রাম চলবে।
  2. Warm-up: প্রথম দৌড়ে cache খালি, পেজ প্রথমবার আসে, CPU-র ঘড়ি গতি তোলে — হিসাবে ধরবেন না।
  3. বারবার চালান: একবারের মাপে অন্য প্রোগ্রাম আর interrupt মিশে থাকে; কয়েকবার চালিয়ে সবচেয়ে কম বা median নিন।
  4. ফলাফল ব্যবহার করুন: হিসাবের ফল কোথাও ব্যবহার না হলে -O2-এ compiler পুরো হিসাবটাই মুছে দিতে পারে (dead code elimination) — আর আপনি দেখবেন “০ ns”! এজন্যই checksum জমিয়ে শেষে ছাপানো।
  5. বাস্তবের মাপের ইনপুট: ১০০ ঘরের টেবিল পুরোটা L1-এ ধরে, সেখানে সারি-কলামের পার্থক্য দেখাই যাবে না। আসল ডেটার মাপে মাপুন।

Profiler — অনুমান নয়, গণনা

বড় প্রোগ্রামে কোন ফাংশন বা কোন লাইন সময় খাচ্ছে, তা বলে profiler. Linux-এ সবচেয়ে শক্তিশালী হাতিয়ার perf, যা CPU-র ভেতরের হার্ডওয়্যার counter পড়ে — কতগুলো cycle গেল, কতগুলো নির্দেশ চলল, কতবার cache miss আর branch-এর ভুল আন্দাজ হলো:

gcc -O2 -g sales.c -o sales
perf stat -e cycles,instructions,cache-misses,branch-misses ./sales
perf record -g ./sales      # sample where time goes
perf report                 # browse hottest functions and lines

নিচের আউটপুটটি উদাহরণ হিসেবে বানানো নমুনা — আপনার মেশিনে সংখ্যা আলাদা হবে, কিন্তু কোন লাইনে কী খুঁজবেন তা একই:

# sample output (illustrative) — column-order version
 Performance counter stats for './sales':

     3,912,455,120      cycles
     1,204,881,307      instructions        #  0.31  insn per cycle
        98,345,210      cache-misses
           412,906      branch-misses

সবচেয়ে কাজের সংখ্যা insn per cycle (IPC) — প্রতি cycle-এ কয়টি নির্দেশ শেষ হলো। IPC ১-এর অনেক নিচে আর cache-misses বিশাল মানে CPU বেশিরভাগ সময় মেমরির অপেক্ষায়। সারি-ক্রমে বদলানোর পর cache-misses কমবে, IPC বাড়বে — তখন জানবেন উন্নতি কেন হলো।

টুলকী বলেকীভাবে
perf statপুরো প্রোগ্রামের cycle, IPC, cache ও branch missperf stat ./prog
perf record / reportকোন ফাংশন ও লাইনে সময় যায় (sampling)-g দিয়ে build, তারপর record
gprofকোন ফাংশন কতবার ডাকা হলো, কত সময় নিলgcc -pg, চালান, তারপর gprof prog gmon.out
Cachegrindসিমুলেট করা cache-এ প্রতিটি লাইনের miss — নির্ধারিত, প্রতিবার একvalgrind --tool=cachegrind --cache-sim=yes ./prog, তারপর cg_annotate
Compiler Explorercompiler আসলে কী নির্দেশ বানালgodbolt.org-এ কোড আর flag

Cachegrind আমাদের খেলনা simulator-এরই বড় ভাই: সিমুলেট করা cache-এ প্রতিটি লাইনের miss গোনে। ধীর চলে, কিন্তু ফল প্রতিবার এক। (নতুন Valgrind-এ cache সিমুলেশন ডিফল্টভাবে বন্ধ, তাই --cache-sim=yes লাগে।) পর্ব ৩১-এ দেখা Valgrind-এরই আরেকটি টুল।

Amdahl's law — কতটা লাভ সম্ভব, আগেই হিসাব

ধরুন রিপোর্ট প্রোগ্রামের মোট সময়ের ২০% যায় একটি সর্টিং ফাংশনে। আপনি দুই দিন খেটে সেটিকে ১০ গুণ দ্রুত করলেন। পুরো প্রোগ্রাম কত দ্রুত হবে? Amdahl's law বলে:

মোট গতি-বৃদ্ধি = 1 ÷ ((1 − p) + p ÷ s)
p = যে অংশ দ্রুত করলেন, s = সেই অংশ কত গুণ দ্রুত হলো

এখানে p = ০.২, s = ১০: 1 ÷ (০.৮ + ০.০২) ≈ ১.২২ — দুই দিনের পরিশ্রমে মাত্র প্রায় ২২% লাভ; ফাংশনটি অসীম দ্রুত হলেও সর্বোচ্চ ১.২৫ গুণ। অথচ ৮০% সময় নেওয়া অংশকে মাত্র ২ গুণ দ্রুত করলেই 1 ÷ (০.২ + ০.৪) ≈ ১.৬৭ গুণ। শিক্ষা: বড় অংশে ছোট উন্নতি প্রায়ই ছোট অংশে বড় উন্নতিকে হারায় — আর কোন অংশ বড়, তা বলে profiler.

“Premature optimization is the root of all evil” — Donald Knuth-এর এই লাইনটি বহুল উদ্ধৃত, কিন্তু প্রায়ই অর্ধেক। ১৯৭৪ সালে “Structured Programming with go to Statements” প্রবন্ধে পুরো বাক্যটি ছিল: “We should forget about small efficiencies, say about 97% of the time: premature optimization is the root of all evil. Yet we should not pass up our opportunities in that critical 3%.” অর্থাৎ বেশিরভাগ কোডে ছোটখাটো কৌশল অপচয়, কিন্তু যে ছোট অংশ সত্যিই সংকটপূর্ণ — যা মেপে খুঁজে পাওয়া যায় — সেখানে সুযোগ ছাড়া যাবে না। পুরো প্রসঙ্গ ভাবনার প্রশ্ন ০২-এ।

১০ · AI-এর যুগে — মাপা ছাড়া “optimization”

AI-কে “কোড দ্রুত করে দাও” বললে সে কিছু বদলে আত্মবিশ্বাসে বলে “এখন অনেক দ্রুত” — অথচ আপনার মেশিনে কোড চালায়নি, মাপেনি, ডেটার মাপও জানে না।

যে উন্নতি মাপা হয়নি, তা উন্নতি নয় — অনুমান।
আগে profiler, তারপর পরিবর্তন, তারপর আবার মাপ।

AI সাধারণত পারফরম্যান্সে যেসব ভুল করে

AI-এর ভুলকেন সমস্যাআপনি যা করবেন
মাপ ছাড়াই “X গুণ দ্রুত” দাবিসংখ্যাটি বানানো; আপনার হার্ডওয়্যার আর ডেটায় উল্টোও হতে পারেনিজে আগে-পরে মাপুন, perf stat দিয়ে কারণ দেখুন
ঠান্ডা কোডে micro-optimizationযে ফাংশন ১% সময় নেয়, তাকে ১০ গুণ দ্রুত করলে লাভ প্রায় শূন্য, কোড শুধু জটিলProfiler-এ hot spot দেখে তবেই হাত দিন
register, হাতে loop unrolling, গুণের বদলে shift-O2-এ compiler এগুলো নিজেই করে; হাতের কৌশল পড়া কঠিন করেCompiler Explorer-এ দেখুন, আগে থেকেই হচ্ছে কি না
এমন benchmark, যার ফল কোথাও ব্যবহার হয় নাCompiler পুরো লুপ মুছে দেয়, “০ ns” দেখায়Checksum জমিয়ে ছাপান, -O2-এ মাপুন
নির্দ্বিধায় -Ofast বা -march=nativeNaN পরীক্ষা মুছে যায়, যোগফল বদলায়; অন্য মেশিনে crashপ্রয়োজন আর ঝুঁকি বুঝে, টেস্টসহ ব্যবহার
Cache-কে উপেক্ষাকলাম-ক্রমের লুপ, linked list, অগোছালো struct — Big-O ঠিক রেখেও ধীরডেটার বিন্যাস আর লুপের ক্রম আলাদা করে যাচাই করুন

AI-কে পারফরম্যান্স নিয়ে প্রশ্ন করার সময় মাপের তথ্য দিন, আর মাপের পরিকল্পনা চেয়ে নিন। নিচের কাঠামোটি ব্যবহার করুন:

আমার C প্রোগ্রামটি (gcc -O2, x86-64 Linux) ধীর চলছে। perf record অনুযায়ী মোট সময়ের ৭০% যায় নিচের ফাংশনে, আর perf stat-এ IPC 0.4, cache-misses অনেক বেশি। ইনপুট: 20000 x 5000 int-এর টেবিল। [ফাংশনের কোড] ১. সমস্যাটি কি algorithm, মেমরি-বিন্যাস (cache), branch, না অন্য কিছু — perf-এর সংখ্যা দেখে কারণসহ বলো। ২. একবারে একটি করে পরিবর্তন প্রস্তাব করো, প্রতিটির জন্য কোন counter কমা উচিত তা বলো। ৩. ফল হুবহু এক থাকবে কি না, আর কী টেস্ট চালালে নিশ্চিত হব, তা বলো। ৪. গতির কোনো সংখ্যা অনুমান করে বলো না — আমি নিজে মেপে দেখব।

AI-এর “optimized” কোড হাতে পেলে — যাচাইয়ের চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে — বিশ্ববিদ্যালয়, চাকরি বা কোডিং ইন্টারভিউর পরীক্ষায় এই বিষয়ে সাধারণত যে ধরনের প্রশ্ন আসে:

  • Cache hit, cache miss আর locality কী? — চাওয়া ডেটা cache-এ পাওয়া hit, না পাওয়া miss; spatial locality মানে পাশের ঠিকানা শিগগিরই লাগবে, temporal মানে একই ডেটা শিগগিরই আবার লাগবে।
  • C-তে 2D array-র কোন লুপ-ক্রম দ্রুত, কেন? — বাইরের লুপ সারি, ভেতরের লুপ কলাম; C row-major, তাই এতে ঠিকানা পাশাপাশি এগোয় আর প্রতিটি cache line পুরো কাজে লাগে।
  • sizeof(struct) সদস্যদের মাপের যোগফলের চেয়ে বড় কেন? — alignment রাখতে compiler padding বসায়; বড় থেকে ছোট ক্রমে field সাজালে তা কমে।
  • False sharing কী, কীভাবে এড়াবেন? — আলাদা thread-এর আলাদা ভ্যারিয়েবল একই cache line-এ থাকলে line নিয়ে টানাটানি; আলাদা line-এ align করুন বা local-এ গুনে শেষে যোগ করুন।
  • Amdahl's law-এর সূত্র কী? — গতি-বৃদ্ধি = 1 ÷ ((1 − p) + p ÷ s); যে অংশ দ্রুত করা যায়নি, সেটিই সর্বোচ্চ লাভের সীমা টানে।

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

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন। এগুলোই আপনাকে কোড-লেখক থেকে সিস্টেম-চিন্তক বানাবে।

প্র ০১“Memory wall” কী? ১৯৯৫ সালে দুই গবেষক যে “স্পষ্ট” কথাটি বলেছিলেন, তা আজকের CPU-র নকশা কীভাবে বদলে দিয়েছে?

১৯৯৫ সালে University of Virginia-র William Wulf আর Sally McKee একটি ছোট প্রবন্ধ লেখেন — “Hitting the Memory Wall: Implications of the Obvious”. শিরোনামেই বক্তব্য: কথাটি স্পষ্ট, কিন্তু পরিণতি কেউ গুরুত্ব দিচ্ছে না। প্রসেসরের গতি তখন প্রতি বছর কয়েক দশ শতাংশ হারে বাড়ছিল, অথচ DRAM-এর latency কমছিল বছরে মাত্র কয়েক শতাংশ। দুটি সূচকীয় বৃদ্ধির হার আলাদা হলে তাদের ফাঁকও সূচকীয় হারে বাড়ে।

তাঁদের যুক্তি ছিল সরল গণিত: গড় মেমরি-সময় = hit-এর অনুপাত × cache-এর সময় + miss-এর অনুপাত × RAM-এর সময়। CPU যত দ্রুতই হোক, miss-এর অংশ না কমলে একসময় পুরো প্রোগ্রাম RAM-এর গতিতে আটকে যাবে — একটি দেয়াল। cache বড় করলে দেয়াল পিছোয়, মিলিয়ে যায় না।

পরের তিন দশকের CPU নকশা অনেকটা এই দেয়ালের বিরুদ্ধে লড়াই: বহুস্তর cache (আজকের চিপের বড় একটি অংশ শুধু cache), out-of-order execution (একটি miss-এর অপেক্ষায় না থেকে পরের স্বাধীন নির্দেশ চালানো), hardware prefetcher, আর GPU ও AI চিপে চিপের একদম পাশে স্তূপ করা HBM মেমরি। আজকের বড় AI মডেল চালাতেও প্রায়ই গতি আটকায় হিসাবে নয়, মডেলের ওজন মেমরি থেকে আনার bandwidth-এ।

মূল উপলব্ধি: আধুনিক কম্পিউটারে হিসাব সস্তা, ডেটা সরানো দামি। প্রশ্নটা তাই প্রায়ই “কতগুলো গণনা” নয়, “কতগুলো ডেটা কতদূর থেকে আনতে হলো”।

প্র ০২Knuth-এর “premature optimization is the root of all evil” — উক্তিটি কোন প্রবন্ধ থেকে, পুরো প্রসঙ্গে এর অর্থ কী, আর আজ কীভাবে এর অপব্যবহার হয়?

উক্তিটি Donald Knuth-এর ১৯৭৪ সালের প্রবন্ধ “Structured Programming with go to Statements” থেকে, যা ACM Computing Surveys-এ ছাপা হয়। প্রেক্ষাপট সেই সময়ের তুমুল বিতর্ক: ১৯৬৮ সালে Edsger Dijkstra “Go To Statement Considered Harmful” লিখে goto বাদ দেওয়ার পক্ষে যুক্তি দেন। Knuth-এর দীর্ঘ প্রবন্ধটি মধ্যপন্থী — কাঠামোবদ্ধ প্রোগ্রামিং ভালো, কিন্তু কিছু ক্ষেত্রে, বিশেষ করে দক্ষতার জন্য, goto-র যৌক্তিক ব্যবহার আছে।

অর্থাৎ প্রবন্ধটি আসলে অনেকখানি দক্ষতার পক্ষে। Knuth লেখেন, প্রতিষ্ঠিত প্রকৌশল শাখায় সহজে পাওয়া ১২% উন্নতিকে কখনো তুচ্ছ ধরা হয় না। তারপর বিখ্যাত অংশ: অ-গুরুত্বপূর্ণ অংশের গতি নিয়ে ভেবে প্রোগ্রামাররা বিপুল সময় নষ্ট করেন, আর তা debugging ও রক্ষণাবেক্ষণে ক্ষতি করে; ছোট দক্ষতা প্রায় ৯৭% সময় ভুলে যাওয়া উচিত, “yet we should not pass up our opportunities in that critical 3%.” তিনি আরও বলেন, কোন অংশ সংকটপূর্ণ তা অনুমানে বলা কঠিন — মাপার টুল লাগে।

আজকের অপব্যবহার: অনেকে শুধু মাঝের লাইনটি তুলে বলেন “পারফরম্যান্স নিয়ে ভাবাই খারাপ”। ফলে শুরু থেকেই এমন নকশা হয় — প্রতিটি ছোট জিনিস আলাদা malloc, সব জায়গায় linked list — যার ধীরতা পুরো কোডে ছড়ানো, কোনো এক hot spot-এ নয়, তাই পরে আর ঠিক করা যায় না। Knuth-এর কথা ছিল ছোট কৌশল নিয়ে, algorithm বা ডেটা-বিন্যাসের মতো বড় সিদ্ধান্ত নিয়ে নয়। উল্টো অপব্যবহারও আছে: মাপ ছাড়াই অনুমানে কোড জটিল করা — যেটিকে তিনি সত্যিই “premature” বলেছেন।

মূল উপলব্ধি: বড় সিদ্ধান্তে (algorithm, ডেটার বিন্যাস) শুরু থেকেই পারফরম্যান্স ভাবুন; ছোট কৌশল রাখুন সেই ৩%-এর জন্য, যা মেপে খুঁজে পেয়েছেন।

প্র ০৩২০১২ সালের Stack Overflow প্রশ্ন — “সাজানো array কেন দ্রুত প্রক্রিয়া হয়?” — এত বিখ্যাত হলো কেন, আর আজকের compiler-এ সেই পরীক্ষা কী শেখায়?

২০১২ সালে একজন প্রোগ্রামার Stack Overflow-এ একটি ছোট C++ প্রোগ্রাম দিয়ে প্রশ্ন করেন: এলোমেলো সংখ্যার বড় array-তে ১২৮ বা তার বেশি মানগুলো যোগ করা হচ্ছে। একই ডেটা আগে সাজিয়ে নিলে — সর্টিংয়ের বাড়তি সময় বাদ দিয়ে মাপলে — লুপটি কয়েক গুণ দ্রুত চলছে (তাঁর মাপে প্রায় ছয় গুণ)। কাজ হুবহু এক, যোগফলও এক। প্রশ্নটি সাইটের সবচেয়ে বেশি ভোট পাওয়া প্রশ্নগুলোর একটি হয়ে ওঠে।

সবচেয়ে জনপ্রিয় উত্তরটি দেন “Mysticial” নামের একজন ব্যবহারকারী, রেলওয়ে জংশনের উপমায়: সিগন্যালম্যান জানে না ট্রেন কোন দিকে যাবে। থামিয়ে জিজ্ঞেস করলে অনেক সময় নষ্ট, তাই সে আন্দাজে লাইন বদলায়; ঠিক হলে ট্রেন না থেমেই চলে যায়, ভুল হলে থামিয়ে পিছিয়ে আবার আসতে হয়। Branch predictor ঠিক এই সিগন্যালম্যান।

আলোচনার একটি মোড় প্রায়ই ভুলে যাওয়া হয়: উঁচু optimization-এ কিছু compiler ওই if-কে conditional move বা SIMD-এ বদলে দেয়, আর তখন পার্থক্য প্রায় মুছে যায়। একই কোড, ভিন্ন compiler বা flag-এ সম্পূর্ণ ভিন্ন আচরণ।

প্রশ্নটি বিখ্যাত হওয়ার আসল কারণ — এটি হাজারো প্রোগ্রামারকে প্রথম দেখিয়েছিল যে CPU “একটার পর একটা নির্দেশ চালানো” সরল যন্ত্র নয়; ভেতরে pipeline, অনুমান আর বাতিল-করা কাজের একটি জগৎ আছে। ২০১৮ সালে প্রকাশিত Spectre নিরাপত্তা-ত্রুটিও এই অনুমাননির্ভর execution-কেই কাজে লাগিয়েছিল।

মূল উপলব্ধি: পারফরম্যান্স নির্ভর করে কোড, ডেটা, compiler আর হার্ডওয়্যারের মিলনের ওপর। অন্যের মাপ ইঙ্গিত মাত্র — নিজের পরিবেশে আবার মাপুন।

প্র ০৪Ulrich Drepper-এর ২০০৭ সালের “What Every Programmer Should Know About Memory” — শ-খানেক পৃষ্ঠার একটি প্রবন্ধ আজও কেন পড়ার তালিকায়?

Ulrich Drepper তখন Red Hat-এ কাজ করতেন এবং দীর্ঘদিন GNU C Library (glibc)-র প্রধান রক্ষণাবেক্ষণকারী ছিলেন — আপনার প্রতিটি malloc আর memcpy-র পেছনের কোড তাঁর হাত দিয়ে গেছে। ২০০৭ সালে তিনি প্রবন্ধটি প্রকাশ করেন, যা LWN.net-এ কয়েক কিস্তিতেও ছাপা হয়। শিরোনামে “every programmer” থাকলেও এটি একশো পৃষ্ঠার বেশি গভীর প্রযুক্তিগত লেখা।

প্রবন্ধটি নিচ থেকে ওপরে যায়: DRAM-এর ভেতর (কেন এটি capacitor-এর সারি, কেন refresh লাগে, কেন এলোমেলো পড়া ধীর); CPU cache-এর set-associativity, cache line আর multi-core coherence; বড় সার্ভারের NUMA, যেখানে অন্য প্রসেসরের RAM পড়তে বেশি সময় লাগে; আর প্রোগ্রামারের করণীয় — matrix গুণে tiling, prefetching, struct বিন্যাস, আর cachegrind ও হার্ডওয়্যার counter দিয়ে মাপা।

আজও প্রাসঙ্গিক, কারণ নির্দিষ্ট সংখ্যা বদলালেও কাঠামো বদলায়নি: cache line আছে, row-major-এর সুবিধা একই, false sharing একইভাবে ঘটে। কিছু বিস্তারিত (তখনকার মেমরি-বাসের নকশা) পুরোনো হয়েছে; তাই মূল ধারণা নিন, সংখ্যা আধুনিক হার্ডওয়্যারে নিজে যাচাই করুন।

মূল উপলব্ধি: abstraction-এর নিচের স্তর বুঝলে abstraction ভালোভাবে ব্যবহার করা যায়। “মেমরি একটি লম্বা byte array” — ধারণাটি সঠিক, কিন্তু অসম্পূর্ণ; পারফরম্যান্সের জন্য জানতে হয় সেই array-র কোন অংশ কতদূরে।

প্র ০৫Mike Acton-এর CppCon 2014-এর “Data-Oriented Design and C++” বক্তৃতা গেম-জগতে কেন এত আলোচিত? C প্রোগ্রামারের জন্য এর শিক্ষা কী?

Mike Acton তখন Insomniac Games-এ engine director — যে স্টুডিও PlayStation-এর বড় বড় গেম বানায়। ২০১৪ সালের CppCon-এ তাঁর মূল বক্তৃতা C++ সম্মেলনেই প্রচলিত object-ভিত্তিক চিন্তার কড়া সমালোচনা করে, তাই এত আলোচিত।

তাঁর মূল দাবি তিনটি। সব প্রোগ্রামের কাজ ডেটা রূপান্তর করা — তাই নকশা শুরু হওয়া উচিত ডেটা থেকে: কী ডেটা, কতগুলো, কত ঘন ঘন, কোন ক্রমে পড়া হয়। হার্ডওয়্যারই প্ল্যাটফর্ম — কোড চলে cache-ওয়ালা একটি আসল CPU-তে; “বাস্তব জগতের মডেল” বানানো কোডের কাজ নয়। যেখানে একটি আছে, সেখানে অনেক আছে — গেমে একটি গুলি নয়, হাজারটি; তাই প্রতিটির জন্য আলাদা object-এর বদলে সব অবস্থান এক array-তে, সব গতি আরেক array-তে — অংশ ৫-এর SoA. তিনি দেখান, একটি সাধারণ object-এর ৬৪ বাইটের line-এ কাজে লাগে মাত্র কয়েক বাইট। গেমের Entity-Component-System (ECS) নকশা এই চিন্তারই উত্তরাধিকার।

C প্রোগ্রামারের জন্য মজার ব্যাপার: data-oriented design অনেকটা C-এর স্বাভাবিক ধরনেরই আধুনিক নাম — সরল struct, array, আর তার ওপর চলা ফাংশন। এর C++-দিক (virtual function, object-এর pointer-এর vector) আছে C++ কোর্সের পারফরম্যান্স পর্বে। C-তে প্রশ্নটা দাঁড়ায়: আপনার struct “একজন গ্রাহক কেমন” ভেবে বানানো, নাকি “সবচেয়ে ঘন ঘন চলা লুপ কোন ডেটা পড়ে” ভেবে?

মূল উপলব্ধি: ডেটার আকার, পরিমাণ আর ব্যবহারের ধরন বুঝে নকশা করুন। গরম পথে ডেটা সাজান CPU-র জন্য, বাকি জায়গায় মানুষের জন্য।

অনুশীলন

বেশিরভাগই হিসাব আর পর্যবেক্ষণ — কাগজ-কলমেই করা যায়।

  1. Struct সাজান: x86-64-এ struct order { char paid; long amount; short qty; char status; int shop_id; };-এর sizeof কত? field সাজিয়ে সবচেয়ে ছোট কত করা যায়? দশ লাখ অর্ডারে কত বাইট বাঁচবে?

    বর্তমান ক্রম: paid ০-তে, তারপর ৭ বাইট ফাঁকা; amount (৮ বাইট) ৮–১৫; qty (২ বাইট) ১৬–১৭; status ১৮; shop_id ৪-এর গুণিতক চায়, তাই ১৯ ফাঁকা, ২০–২৩। মোট ২৪, যা ৮-এর গুণিতক — sizeof = ২৪।

    বড় থেকে ছোট: long amount; int shop_id; short qty; char paid; char status; — ৮ + ৪ + ২ + ১ + ১ = ১৬, কোনো ফাঁকা নেই। দশ লাখে ৮০ লাখ বাইট (প্রায় ৭.৬ MiB) বাঁচে। নিজের অনুমান মিলিয়ে নিতে উপরের দ্বিতীয় প্রোগ্রামে struct বদলে চালান।

  2. Simulator নিয়ে খেলুন: প্রথম প্রোগ্রামে SETS ৮ থেকে বাড়িয়ে ২৫৬ করুন (cache = ১৬ KB, পুরো টেবিলের সমান)। কলাম-ক্রমের miss কত হবে আন্দাজ করুন, তারপর চালিয়ে মেলান। কেন?

    এবার miss হবে ২৫৬ — সারি-ক্রমের সমান। টেবিলে মোট ২৫৬টি line (১৬ KB ÷ ৬৪), আর cache-এ ২৫৬টি জায়গা; প্রতিটি line নিজের আলাদা জায়গা পায়, কেউ কাউকে বের করে না। তাই প্রথম কলাম ঘুরতে প্রতিটি line একবার আসে, পরের সব কলামে সব hit.

    শিক্ষা: পুরো ডেটা cache-এ ধরলে ক্রমের গুরুত্ব কমে যায় — এজন্যই ছোট ইনপুটে benchmark করলে cache-এর সমস্যা চোখে পড়ে না। আসল সমস্যা দেখা দেয় ডেটা cache-এর চেয়ে বড় হলে।

  3. নিজের মেশিনে মাপুন (Linux থাকলে): একটি ৪০০০ × ৪০০০ int টেবিলের যোগফল দুই ক্রমে লিখুন, -O2-এ build করে perf stat -e cache-misses,instructions,cycles দিয়ে দুটি চালান। কোন সংখ্যাগুলো বদলাল?

    টেবিলটি ৬৪ MB — বেশিরভাগ L3-এর চেয়ে বড়। কলাম-ক্রমে cache-misses অনেক গুণ বেশি আসবে, cycles বেশি, আর IPC (instructions ÷ cycles) অনেক কম। instructions প্রায় সমান থাকবে, কারণ কাজ একই। অর্থাৎ পার্থক্য পুরোটাই অপেক্ষার সময়ে।

    খেয়াল রাখুন: যোগফল ছাপাতে ভুলবেন না, নইলে compiler লুপই মুছে দিতে পারে। কিছু virtual machine বা WSL-এ হার্ডওয়্যার counter পাওয়া যায় না; তখন valgrind --tool=cachegrind --cache-sim=yes দিয়ে D1 miss rate তুলনা করুন — সংখ্যা প্রতিবার এক আসবে।

  4. Amdahl দিয়ে সিদ্ধান্ত: একটি প্রোগ্রামে profiler দেখাল: parse ৫০%, হিসাব ৩০%, লেখা ২০%. আপনার হাতে দুটি পথ — (ক) parse-কে ২ গুণ দ্রুত করা, (খ) হিসাবকে ১০ গুণ দ্রুত করা। কোনটিতে বেশি লাভ?

    (ক) p = ০.৫, s = ২: 1 ÷ (০.৫ + ০.২৫) = 1 ÷ ০.৭৫ ≈ ১.৩৩ গুণ।

    (খ) p = ০.৩, s = ১০: 1 ÷ (০.৭ + ০.০৩) = 1 ÷ ০.৭৩ ≈ ১.৩৭ গুণ।

    প্রায় সমান! “১০ গুণ” শুনতে অনেক বড়, কিন্তু ছোট অংশে বলে লাভ সীমিত। সিদ্ধান্ত তাই নির্ভর করবে কোনটি সহজ আর কম ঝুঁকির তার ওপর — আর দুটি একসাথে করলে 1 ÷ (০.২৫ + ০.০৩ + ০.২) = 1 ÷ ০.৪৮ ≈ ২.০৮ গুণ।

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

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

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

  1. সঠিক কোড আর টেস্ট নিশ্চিত করা
  2. লক্ষ্য ঠিক করে -O2-এ বর্তমান গতি মাপা
  3. Profiler দিয়ে hot spot খুঁজে বের করা
  4. একটি পরিবর্তন — algorithm বা ডেটার বিন্যাস
  5. আবার মাপা আর টেস্ট চালানো

x86-64 প্রসেসরে একটি cache line-এর সাধারণ মাপ কত?

  • ৪ বাইট
  • ৮ বাইট
  • ৬৪ বাইট
  • ৪০৯৬ বাইট

C-তে int g[5000][5000]-এর সব ঘরের যোগফল বের করতে কোন লুপ-ক্রম সাধারণত সবচেয়ে দ্রুত?

  • বাইরের লুপ সারি (i), ভেতরের লুপ কলাম (j), পড়া g[i][j]
  • বাইরের লুপ কলাম (j), ভেতরের লুপ সারি (i), পড়া g[i][j]
  • দুটি ক্রম সমান দ্রুত, কারণ দুটিই O(n²)
  • ঘরগুলো এলোমেলো ক্রমে পড়া

x86-64 Linux-এ struct s { char a; double b; char c; };-এর sizeof কত?

  • ১০
  • ১৬
  • ১৭
  • ২৪

False sharing বলতে কী বোঝায়?

  • দুটি thread একই ভ্যারিয়েবলে mutex ছাড়া লিখছে
  • দুটি thread আলাদা ভ্যারিয়েবলে লিখছে, কিন্তু ভ্যারিয়েবল দুটি একই cache line-এ
  • একটি pointer ভুল ঠিকানায় নির্দেশ করছে
  • দুটি thread একে অপরের lock-এর জন্য অপেক্ষা করছে

একটি প্রোগ্রামের ২৫% সময় একটি ফাংশনে যায়। ফাংশনটিকে অসীম দ্রুত করা গেলেও পুরো প্রোগ্রাম সর্বোচ্চ কত গুণ দ্রুত হতে পারে?

  • অসীম গুণ
  • ৪ গুণ
  • প্রায় ১.৩৩ গুণ
  • ১ গুণ, কোনো লাভ নেই

একটি benchmark লুপ -O2-এ প্রায় ০ ns দেখাচ্ছে, অথচ -O0-এ কয়েক সেকেন্ড। সবচেয়ে সম্ভাব্য কারণ কী?

  • -O2 লুপটিকে লাখো গুণ দ্রুত করেছে
  • হিসাবের ফল কোথাও ব্যবহার হয়নি, তাই compiler পুরো লুপ মুছে দিয়েছে
  • ডেটা পুরোটা cache-এ ছিল
  • CLOCK_MONOTONIC ঘড়ি ভুল সময় দেয়

শব্দকোষ

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

মনে রাখুন

  1. Big-O কাজের বৃদ্ধি বলে, মেমরি বলে প্রতিটি কাজের দাম। L1 কয়েক cycle, RAM কয়েকশো — সংখ্যা আনুমানিক, অনুপাতটাই আসল।
  2. ডেটা আসে ৬৪ বাইটের cache line-এ (x86-64-এ সাধারণত); ক্রমানুসারে পড়ুন (spatial), একই ডেটার কাজ একসাথে সারুন (temporal); C row-major, তাই ভেতরের লুপ কলাম।
  3. ডেটা ঘন রাখুন: বড় থেকে ছোট ক্রমে field, দরকারমতো SoA ও hot/cold ভাগ, linked list-এর বদলে array, অসংখ্য malloc-এর বদলে arena বা pool.
  4. CPU ও compiler-কে সাহায্য করুন: অনুমানযোগ্য branch, -O2, restrict, loop interchange ও tiling; thread-এর counter আলাদা cache line-এ; -Ofast আর -march=native সাবধানে।
  5. সবার আগে আর সবার শেষে মাপুন: warm-up, বারবার চালানো, ফল ব্যবহার করা; perf, cachegrind দিয়ে কারণ খুঁজুন; Amdahl's law দিয়ে লাভ আগেই হিসাব করুন।

পরের পর্বে: এতদিনের সব শেখা এক জায়গায় — ক্যাপস্টোন প্রজেক্ট। module, test, sanitizer, thread, socket আর আজকের মাপজোখ মিলিয়ে নিজের হাতে একটি সত্যিকারের সিস্টেম বানানো।

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

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