Foundationপ্রথম নীতি থেকে
LEVEL 4লেসন ১০/২৯অ্যাডভান্সড১ ঘণ্টা ২৫ মিনিট

Paging আর Page Table — ভার্চুয়াল ঠিকানাকে সত্যিকারের RAM-এ অনুবাদ করার যন্ত্র

Paging and Page Tables

Virtual address কীভাবে সত্যিকারের DRAM-এর ঠিকানায় পরিণত হয় — তার পুরো যন্ত্রপাতি। x86-64-এ একটা ৪৮-বিট ঠিকানা ভেঙে যায় চারটা ৯-বিট index আর একটা ১২-বিট offset-এ, আর MMU সেই index ধরে চারটা table-এর মধ্য দিয়ে হেঁটে (PML4 → PDPT → PD → PT) শেষ physical frame-এ পৌঁছায়। কেন এতগুলো স্তর, প্রতিটা table entry-র বিটগুলো কী করে, huge page কী বাঁচায় — সব হাতে-কলমে, /proc/self/pagemap দিয়ে যাচাইযোগ্য।

এই লেসন শেষে আপনি পারবেন

  • Paging কেন segmentation-কে হারিয়ে দিল সেটা external fragmentation-এর যুক্তি দিয়ে ব্যাখ্যা করতে পারবেন, আর fixed-size ৪KB page-এর trade-off (internal fragmentation, গড়ে page-প্রতি ২KB অপচয়) সংখ্যাসহ বিশ্লেষণ করতে পারবেন
  • x86-64-র ৪৮-বিট virtual address-কে হাতে ৯+৯+৯+৯+১২ বিটে ভাগ করে PML4/PDPT/PD/PT index আর offset বের করতে পারবেন, কোনো টুল ছাড়াই শুধু shift আর mask দিয়ে
  • চার-স্তরের page walk-এর প্রতিটা ধাপ (CR3 → PML4E → PDPTE → PDE → PTE → physical frame) ক্রমানুসারে বর্ণনা করতে পারবেন, আর গুনে বলতে পারবেন একটা translation-এ কতটা memory access লাগে
  • Multi-level page table কেন single-level-এর চেয়ে ভালো সেটা sparsity-র যুক্তি দিয়ে প্রমাণ করতে পারবেন — single-level-এ process-প্রতি ৫১২GB বনাম multi-level-এ কয়েক KB
  • একটা PTE-র প্রতিটা গুরুত্বপূর্ণ বিট (P, R/W, U/S, A, D, PS, NX) কী নিয়ন্ত্রণ করে আর OS সেটা কীভাবে ব্যবহার করে (demand paging, COW, page replacement, W^X) বলতে পারবেন
  • /proc/PID/pagemap ব্যবহার করে একটা virtual address-এর প্রকৃত physical frame number বের করতে পারবেন, আর /proc/meminfo-র PageTables ফিল্ড থেকে page table-এর বাস্তব memory overhead মাপতে পারবেন

আগে যা বোঝা থাকা দরকার

আগে এটা বুঝি

গত লেসনে একটা প্রতিশ্রুতি দেওয়া হয়েছিল — প্রতিটা process ভাবে সে পুরো ঠিকানা-জগতের একমাত্র মালিক, অথচ তারা সবাই একই কয়েক গিগাবাইট DRAM ভাগ করে নেয়। সেই বিভ্রমটা কে তৈরি করে, আর ঠিক কীভাবে — সেটাই এই লেসনের বিষয়।

একটা ছোট পরীক্ষা দিয়ে শুরু করি। দুইটা টার্মিনালে দুইবার একই প্রোগ্রাম চালান, যেটা একটা global variable-এর ঠিকানা ছাপে:

#include <stdio.h>
int marker = 42;
int main(void) { printf("%p\n", (void *)&marker); getchar(); return 0; }
টার্মিনাল ১:  0x55d3f8a02010
টার্মিনাল ২:  0x55d3f8a02010

দুইটা সম্পূর্ণ আলাদা process, একই ঠিকানা ছাপছে, আর দুইজনেই সেই ঠিকানায় লিখতে পারে — একজনের লেখা অন্যজন দেখে না। এটা কোনো কাকতালীয় ঘটনা না, আর কোনো “OS চালাকি করে নম্বরটা বদলে দেখাচ্ছে” ব্যাপারও না। ওই 0x55d3f8a02010 সংখ্যাটা প্রতিটা mov instruction-এ আক্ষরিকভাবে CPU-তে যায় — কিন্তু CPU-র ভেতরে, register file আর L1 cache-এর ঠিক মাঝখানে বসে থাকা একটা hardware unit (MMU, Memory Management Unit) সেই সংখ্যাটাকে প্রতিটা access-এ অন্য একটা সংখ্যায় অনুবাদ করে দেয়, আর process ১-এর জন্য অনুবাদের ফলাফল হয় হয়তো physical 0x1a2b3010, process ২-এর জন্য 0x7c4d5010

সেই অনুবাদের নিয়মগুলো কোথায় লেখা থাকে? Memory-তেই — একটা গাছের মতো data structure-এ, যার নাম page table। প্রতিটা process-এর নিজস্ব একটা আছে, আর CPU-র একটা বিশেষ register (CR3) সবসময় বর্তমান process-এর গাছের গোড়ায় দেখিয়ে থাকে। Context switch মানে মূলত CR3-এ নতুন একটা সংখ্যা লেখা — এক লাইনেই পুরো ঠিকানা-জগৎ বদলে যায়।

এই লেসনে সেই গাছটা পুরোপুরি খুলে দেখব: কেন চারটা স্তর, প্রতিটা স্তরে ঠিক কোন বিটগুলো index হিসেবে কাজ করে, একটা entry-র ভেতরে কী কী পতাকা থাকে, আর সবশেষে /proc/self/pagemap দিয়ে নিজের চোখে একটা virtual ঠিকানার প্রকৃত physical frame number বের করব।

মূল ধারণা

কেন fixed-size page — segmentation-এর মৃত্যু-সনদ

Virtual-থেকে-physical অনুবাদের সবচেয়ে সরল ধারণা হলো segmentation — প্রতিটা process-এর memory-কে কয়েকটা পরিবর্তনশীল-আকারের টুকরোয় (code segment, data segment, stack segment) ভাগ করা, প্রতিটার জন্য একটা base ঠিকানা আর একটা limit রাখা। অনুবাদ তখন এক লাইনের arithmetic: physical = segment_base + offset, সাথে offset < limit চেক।

এটা সরল, দ্রুত, আর একটা মারাত্মক সমস্যায় ভোগে — external fragmentation। ধরুন ১৬GB RAM-এ চারটা process চলছে, যাদের segment আকার যথাক্রমে ৩GB, ৫GB, ২GB, ৪GB। মাঝখানের ৫GB-র process শেষ হলে ৫GB ফাঁকা হলো, কিন্তু সেটা RAM-এর মাঝখানে একটা গর্ত। এখন একটা ৬GB-র নতুন process এলো — মোট ফাঁকা ৫ + ২ = ৭GB আছে (মাঝখানের গর্ত + শেষের বাকিটা), কিন্তু একটানা ৬GB কোথাও নেই। Process চালানো যাবে না, যদিও memory আছে।

সমস্যাSegmentationPaging (৪KB fixed)
External fragmentationমারাত্মক — ফাঁকা memory থাকা সত্ত্বেও allocation ব্যর্থ হয়শূন্য — প্রতিটা ফাঁকা frame যেকোনো page ধরতে পারে, কারণ সবার আকার সমান
Internal fragmentationনেই (segment ঠিক প্রয়োজনমতো আকারের)আছে — শেষ page-এ গড়ে ২KB অপচয় per allocation
Compaction দরকার?হ্যাঁ, memory এলোমেলো হলে সব সরাতে হয় (ব্যয়বহুল)কখনো না
Hardware translation২টা সংখ্যা (base, limit) per segmentএকটা table lookup per page
Sharing-এর granularityপুরো segmentএকটা page — অনেক সূক্ষ্ম

Paging-এর কেন্দ্রীয় সিদ্ধান্ত হলো — সব টুকরোর আকার একই করে ফেলো। Virtual address space ভাগ হয় সমান আকারের page-এ, physical memory ভাগ হয় ঠিক একই আকারের frame-এ। যেকোনো page যেকোনো frame-এ বসতে পারে, কারণ মাপ নিয়ে দরকষাকষির আর কিছু নেই। External fragmentation নামের সমস্যাটা সংজ্ঞাগতভাবেই অদৃশ্য হয়ে যায়।

বিনিময়ে যা দিতে হয় সেটা internal fragmentation — একটা ১০০ বাইটের allocation-ও একটা পুরো ৪০৯৬-বাইট page দখল করবে, ৩৯৯৬ বাইট নষ্ট। কিন্তু এই অপচয়ের একটা সুন্দর উপরের সীমা আছে: গড়ে page-প্রতি অর্ধেক page, অর্থাৎ ২KB। ১০,০০০ mapping থাকলে সর্বোচ্চ ২০MB অপচয় — একটা ১৬GB মেশিনে ০.১২%। External fragmentation-এর অসীম-খারাপ আচরণের বিপরীতে এটা একটা সীমাবদ্ধ, পূর্বানুমেয় খরচ, আর সেটাই সিদ্ধান্তটা নির্ধারণ করে দেয়।

ঠিকানার শারীরস্থান — ৪৮ বিট, পাঁচ টুকরো

x86-64-এ virtual address ৬৪ বিট চওড়া, কিন্তু বর্তমান hardware শুধু নিচের ৪৮ বিট ব্যবহার করে (LA57-সহ CPU-তে ৫৭, নিচে দেখব)। বাকি উপরের ১৬ বিট অবশ্যই বিট ৪৭-এর অনুলিপি হতে হবে — এই নিয়মের নাম canonical form, আর এটা ভাঙলে CPU সরাসরি একটা general protection fault ছোঁড়ে।

ফলে বৈধ ঠিকানার দুইটা দ্বীপ তৈরি হয়:

0x0000000000000000  –  0x00007FFFFFFFFFFF     নিচের অর্ধেক (userspace), ১২৮ TiB
        [ ---------- বিশাল অবৈধ ফাঁক ---------- ]
0xFFFF800000000000  –  0xFFFFFFFFFFFFFFFF     উপরের অর্ধেক (kernel), ১২৮ TiB

এই কারণেই Linux-এ userspace pointer সবসময় 0x00007f...-জাতীয় দেখায় আর kernel pointer 0xffff...-জাতীয়। নিচের ৪৮ বিট তারপর ঠিক পাঁচ টুকরোয় ভাগ হয়:

 63            48 47      39 38      30 29      21 20      12 11         0
┌────────────────┬──────────┬──────────┬──────────┬──────────┬───────────┐
│ sign extension │  PML4    │  PDPT    │   PD     │   PT     │  offset   │
│   (১৬ বিট)      │ (৯ বিট)  │ (৯ বিট)  │ (৯ বিট)  │ (৯ বিট)  │ (১২ বিট)  │
└────────────────┴──────────┴──────────┴──────────┴──────────┴───────────┘
        │             │          │          │          │           │
   canonical      ৫১২টার     ৫১২টার     ৫১২টার     ৫১২টার      ৪০৯৬
    নিয়ম          একটা       একটা       একটা       একটা        বাইটের
                  entry      entry      entry      entry      একটার
x86-64 ৪-স্তর paging — একটা ৪৮-বিট virtual address-এর পাঁচটা ক্ষেত্র। চারটা ৯-বিট index (প্রতিটা ৫১২টা entry-র একটা table-এ ঢোকে) আর একটা ১২-বিট offset (৪০৯৬-বাইট page-এর ভেতরে)।

সংখ্যাগুলো একে অপরের সাথে নিখুঁতভাবে মিলে যায়, আর সেটা ঘটনাচক্রে না:

  • একটা table entry ৮ বাইট (একটা ৬৪-বিট শব্দ, যাতে physical ঠিকানা + সব পতাকা আঁটে)
  • একটা table নিজে ঠিক একটা page (৪০৯৬ বাইট) — তাই table গুলোকেও সাধারণ frame-এ রাখা যায়, কোনো বিশেষ allocator লাগে না
  • ৪০৯৬ ÷ ৮ = ৫১২টা entry per table
  • ৫১২টা entry index করতে লাগে log2512=9\log_2 512 = 9 বিট

4×9+12=48 বিট4 \times 9 + 12 = 48 \text{ বিট}

অর্থাৎ ৯+৯+৯+৯+১২ বিভাজনটা কোনো নকশাকারের খেয়াল না — এটা “table নিজে এক page-এ আঁটুক” আর “entry ৮ বাইট হোক” এই দুইটা সিদ্ধান্তের অনিবার্য গাণিতিক ফলাফল।

কেন একটা table যথেষ্ট না — ৫১২ গিগাবাইটের হিসাব

সবচেয়ে সরল নকশা হতো একটাই বিশাল array: virtual page number-কে index হিসেবে ব্যবহার করে সরাসরি frame number তোলা। কত বড় হতো সেই array?

entry সংখ্যা=248212=23668.7×109\text{entry সংখ্যা} = \frac{2^{48}}{2^{12}} = 2^{36} \approx 68.7 \times 10^9

আকার=236×8 বাইট=239 বাইট=512 GiB\text{আকার} = 2^{36} \times 8 \text{ বাইট} = 2^{39} \text{ বাইট} = 512 \text{ GiB}

প্রতিটা process-এর জন্য ৫১২ গিগাবাইট page table — একটা /bin/true চালাতেও। এটা কেবল অবাস্তব না, এটা হাস্যকর: table নিজেই RAM-এর চেয়ে হাজার গুণ বড়।

Multi-level table এই সমস্যাটা একটা সরল পর্যবেক্ষণ দিয়ে সমাধান করে — address space প্রায় পুরোটাই ফাঁকা। একটা সাধারণ process ১২৮ TiB ঠিকানা-জগতের হয়তো ১০ MB ব্যবহার করে, তিনটা ছোট গুচ্ছে (code নিচে, heap তার উপরে, stack একদম উপরে)। বাকি ৯৯.৯৯৯৯৯% কিছুই map করা নেই।

Multi-level কাঠামোয় যে subtree-তে কিছু map করা নেই, সেই subtree-র জন্য কোনো table বানাতেই হয় না — উপরের স্তরের entry-তে present বিট শূন্য রেখে দিলেই হলো। একটা হালকা process-এর প্রকৃত খরচ:

Tableসংখ্যাআকার
PML4৪ KB
PDPT২-৩ (code/heap-এর জন্য একটা, stack-এর জন্য একটা)৮-১২ KB
PD৩-৪১২-১৬ KB
PT৫-১০ (প্রতিটা ২MB অঞ্চলের জন্য একটা)২০-৪০ KB
মোটপ্রায় ৫০-৭০ KB

৫১২ GiB থেকে ৬০ KB — প্রায় ১ কোটি গুণ সাশ্রয়, শুধু sparsity-কে কাজে লাগিয়ে। বিনিময়ে যা দিতে হয়: একটা অনুবাদে এখন এক-লাফে না গিয়ে চারটা memory access লাগে। সেই দামটা কীভাবে প্রায় শূন্যে নামিয়ে আনা হয়, সেটাই পরের লেসনের (TLB) পুরো বিষয়বস্তু।

Page table entry — ৬৪ বিটের ভেতরে কী আছে

প্রতিটা entry একটা ৬৪-বিট শব্দ। এর মাঝখানের ৪০ বিট (১২ থেকে ৫১) ধরে পরের স্তরের table-এর (বা শেষ স্তরে, প্রকৃত data frame-এর) physical ঠিকানার উপরের অংশ — নিচের ১২ বিট লাগে না, কারণ table আর frame সবসময় ৪KB-সীমায় সারিবদ্ধ, তাই নিচের ১২ বিট এমনিতেই শূন্য। সেই মুক্ত হওয়া নিচের বিটগুলোতেই সব পতাকা বসানো হয়েছে — একটা চমৎকার bit-packing কৌশল।

বিটনামমানেOS একে কীভাবে ব্যবহার করে
P (Present)এই mapping কি বৈধ?০ হলে access-এ page fault — demand paging আর swap-এর মূল হাতিয়ার
R/Wলেখা যাবে?০ করে রেখে copy-on-write বাস্তবায়ন, read-only data রক্ষা
U/S (User/Supervisor)userspace কি ছুঁতে পারবে?০ মানে শুধু ring 0 — kernel memory-কে process থেকে আড়াল করে
PWTwrite-through cachingDevice memory-র জন্য cache আচরণ নিয়ন্ত্রণ
PCDcache disableMMIO region-এ caching বন্ধ করতে
A (Accessed)hardware সেট করে, পড়া/লেখা হলেPage replacement — কোন page সম্প্রতি ব্যবহৃত হয়েছে জানার একমাত্র সস্তা উপায়
D (Dirty)hardware সেট করে, শুধু লেখা হলেDirty page disk-এ লিখে দিতে হবে; clean page শুধু ফেলে দিলেই চলে
PS (Page Size)PD/PDPT স্তরে ১ মানে এখানেই থামোHuge page — ২MB (PD-তে) বা ১GB (PDPT-তে)
G (Global)context switch-এ TLB থেকে মুছো নাKernel mapping-এর জন্য, যা সব process-এ একই
৯-১১hardware উপেক্ষা করেLinux এখানে নিজের বুককিপিং রাখে (যেমন _PAGE_SPECIAL)
১২-৫১PFNপরের table বা frame-এর physical ঠিকানা >> ১২মূল payload
৫৯-৬২PKEYProtection key (Skylake-SP+)pkey_mprotect() — একই page-এর অনুমতি TLB flush ছাড়াই বদলানো
৬৩NXNo-eXecuteData page থেকে code চালানো নিষিদ্ধ — W^X নীতির hardware ভিত্তি

Linux-এ এই বিটগুলোর নাম হুবহু পাওয়া যায় arch/x86/include/asm/pgtable_types.h-এ — _PAGE_BIT_PRESENT = 0, _PAGE_BIT_RW = 1, _PAGE_BIT_NX = 63। কোডে যখন pte_write(pte) লেখা দেখবেন, সেটা আক্ষরিকভাবে বিট ১ পরীক্ষা করছে।

Huge page — স্তর কমিয়ে দেওয়া

PS বিট (বিট ৭) একটা সরল কিন্তু শক্তিশালী কৌশল সক্ষম করে: হাঁটা থামিয়ে দাও

একটা PD entry-তে PS=1 থাকলে MMU আর PT-তে নামে না — সেই entry-র PFN সরাসরি একটা ২MB frame-এর দিকে দেখায়, আর virtual address-এর নিচের ২১ বিট (৯ + ১২) offset হিসেবে ব্যবহৃত হয়। একইভাবে PDPT entry-তে PS=1 মানে একটা ১GB frame, নিচের ৩০ বিট offset।

Page আকারকোথায় থামেOffset বিটকতগুলো PTE বাঁচলTLB reach (৬৪ entry)
৪ KBPT (স্তর ১)১২২৫৬ KB
২ MBPD (স্তর ২)২১৫১২টা PTE + একটা পুরো PT১২৮ MB
১ GBPDPT (স্তর ৩)৩০২৬২,১৪৪টা PTE + ৫১২টা PT + একটা PD৬৪ GB

Huge page তিনটা জিনিস একসাথে দেয়: (১) page walk এক ধাপ ছোট, (২) page table-এর memory overhead নাটকীয়ভাবে কম, (৩) সবচেয়ে গুরুত্বপূর্ণ — একটা TLB entry এখন ৪KB-র বদলে ২MB ঢাকে, অর্থাৎ ৫১২ গুণ বেশি TLB reach। শেষ সুবিধাটাই বাস্তবে সবচেয়ে বড়, আর সেটাই পরের লেসনের কেন্দ্রীয় বিষয়।

দাম? ২MB-র granularity মানে একটা ২MB huge page-এর মধ্যে ৪KB ব্যবহার করলেও পুরো ২MB আটকে থাকে — internal fragmentation ৫১২ গুণ বেড়ে যায়। এই কারণেই Linux-এর transparent huge pages (THP) ডিফল্টে madvise মোডে চলে অনেক distro-তে: সব allocation-এ huge page না দিয়ে, শুধু যে অ্যাপ্লিকেশন স্পষ্টভাবে চায় (madvise(MADV_HUGEPAGE)) তাকে দেওয়া।

LA57 — পাঁচ স্তর, ১২৮ পেবিবাইট

১২৮ TiB সীমাটা ২০০৩-এ অসীম মনে হয়েছিল। ২০২০-এর দশকে বড় in-memory database আর persistent-memory মেশিনে সেটা আর অসীম না। Intel-এর উত্তর হলো LA57 — একটা পঞ্চম স্তর যোগ করা, যার নাম PML5:

৪-স্তর:  [16 sign]  [9 PML4] [9 PDPT] [9 PD] [9 PT] [12 offset]  = ৪৮ বিট → ২৫৬ TiB
৫-স্তর:  [7 sign] [9 PML5] [9 PML4] [9 PDPT] [9 PD] [9 PT] [12 offset] = ৫৭ বিট → ১২৮ PiB

Linux ৪.১৪ (২০১৭) থেকে LA57 সমর্থন করে, hardware-এ এসেছে Ice Lake Xeon (২০১৯) থেকে। কিন্তু Linux একটা চমৎকার সামঞ্জস্য-কৌশল ব্যবহার করে: ৫-স্তর সক্রিয় থাকলেও ডিফল্টে userspace-কে ৪৭ বিটের বেশি ঠিকানা দেওয়া হয় না। কারণ অসংখ্য পুরনো প্রোগ্রাম pointer-এর উপরের বিটগুলোকে নিজের ট্যাগ রাখার জায়গা হিসেবে ব্যবহার করে (JavaScript engine-এর NaN-boxing, garbage collector-এর mark বিট)। একটা প্রোগ্রামকে বড় ঠিকানা পেতে হলে স্পষ্টভাবে চাইতে হয় — mmap()-এ একটা ৪৭-বিটের চেয়ে বড় hint ঠিকানা পাস করে।

ভেতরে কী ঘটছে

এক ধাপ এক ধাপ — MMU যা করে

একটা সাধারণ mov rax, [rbx] instruction-এ rbx-এ থাকা virtual ঠিকানা থেকে data পেতে ঠিক কী ঘটে? TLB miss ধরে নিয়ে (TLB hit হলে পুরো হাঁটাটাই এড়ানো যায় — পরের লেসন) পুরো পথটা:

একটা virtual address-এর অনুবাদ — CR3 থেকে DRAM পর্যন্ত
  1. mov rax, [rbx]CPU-র execution unit একটা virtual address তৈরি করল, ধরুন 0x00007f3ab2c4d678
  2. canonical চেকবিট ৬৩-৪৮ কি বিট ৪৭-এর অনুলিপি? না হলে সরাসরি #GP fault, কোনো table স্পর্শ না করেই
  3. TLB lookupএই page-এর অনুবাদ কি ইতিমধ্যে ক্যাশে আছে? থাকলে এখানেই শেষ, ০ অতিরিক্ত cycle। এই ট্রেসে ধরে নিচ্ছি miss হলো
  4. CR3 পড়াবর্তমান process-এর PML4 table-এর physical base address, hardware register থেকে — কোনো memory access লাগে না
  5. PML4E পড়া [memory access ১]physical address = CR3 + (index_PML4 × 8)। Entry-তে P=1 কি? না হলে page fault। PFN তুলে নাও → PDPT-র base
  6. PDPTE পড়া [memory access ২]PDPT_base + (index_PDPT × 8)। এখানে PS=1 থাকলে থামো — এটা একটা ১GB huge page, offset = নিচের ৩০ বিট
  7. PDE পড়া [memory access ৩]PD_base + (index_PD × 8)। এখানে PS=1 থাকলে থামো — ২MB huge page, offset = নিচের ২১ বিট
  8. PTE পড়া [memory access ৪]PT_base + (index_PT × 8)। চূড়ান্ত entry — এখানে PFN, R/W, U/S, NX সব পতাকা
  9. অনুমতি যাচাইলেখার চেষ্টা কিন্তু R/W=0? User mode কিন্তু U/S=0? Instruction fetch কিন্তু NX=1? — যেকোনোটায় page fault (#PF), CR2-তে ব্যর্থ ঠিকানা
  10. A/D বিট আপডেটHardware নিজেই PTE-তে Accessed=1 লেখে (আর write হলে Dirty=1) — এটা একটা atomic read-modify-write, তাই একটা লুকানো memory write
  11. physical address গঠন(PTE.PFN << 12) | offset — এই সংখ্যাটাই এখন cache hierarchy-তে যায়
  12. L1d cache / DRAMphysical address দিয়ে cache tag মেলানো; miss হলে L2, L3, তারপর memory controller হয়ে DRAM

গুনে দেখুন — একটা TLB miss মানে চারটা অতিরিক্ত memory access, তারপর প্রকৃত data-র জন্য পঞ্চমটা। যদি প্রতিটা access DRAM-এ যেত (~৮০ ns), একটা সাধারণ pointer dereference-এ ৪০০ ns লাগত — আধুনিক CPU-তে প্রায় ১২০০ cycle। বাস্তবে page table-এর উপরের স্তরগুলো প্রায় সবসময় L1/L2 cache-এ থাকে (কারণ সেগুলো বারবার ব্যবহৃত হয়), আর আধুনিক CPU-তে আলাদা page-structure cache (Intel-এর ভাষায় “paging-structure caches”) আছে যা মাঝের স্তরগুলো আলাদাভাবে ক্যাশ করে। তবু একটা প্রকৃত TLB miss-এর দাম বাস্তবে ১০০-৩০০ cycle — এবং সেটাই পরের লেসনের সমস্যা-বিবৃতি।

Context switch — এক register লেখাতেই নতুন জগৎ

Process A থেকে B-তে switch করার সময় memory management-এর দিক থেকে যা ঘটে, সেটা আশ্চর্যজনকভাবে সংক্ষিপ্ত:

/* Linux, arch/x86/mm/tlb.c থেকে সরলীকৃত */
write_cr3(__pa(next->pgd) | new_asid);

next->pgd হলো B-র PML4 table-এর kernel virtual ঠিকানা, __pa() সেটাকে physical-এ বদলায়, আর write_cr3() সেটা CR3-এ বসায়। এক instruction — আর পরের mov-টাই সম্পূর্ণ ভিন্ন একটা ঠিকানা-জগতে ঘটবে। এই সরলতাই paging-এর মূল সৌন্দর্য: process isolation-এর পুরো ব্যবস্থাটা একটা register-এর মান।

কিন্তু একটা লুকানো খরচ আছে — CR3-এ লেখা ঐতিহাসিকভাবে পুরো TLB flush করে দিত, কারণ পুরনো অনুবাদগুলো এখন ভুল। প্রতিটা context switch-এর পর সব memory access আবার cold TLB-তে শুরু হতো, শত শত miss। এর সমাধান PCID (Process Context ID) — CR3-এর নিচের ১২ বিটে একটা ১২-বিট process ট্যাগ, যা TLB entry-তেও সংরক্ষিত থাকে, ফলে ভিন্ন process-এর entry পাশাপাশি TLB-তে থাকতে পারে flush ছাড়াই। পরের লেসনে এটা বিস্তারিত, KPTI-র সাথে তার জটিল সম্পর্কসহ।

Linux-এর পাঁচ-স্তরের বিমূর্ততা

মজার ব্যাপার — Linux kernel-এর page table কোড সবসময় পাঁচটা স্তরের কথা বলে, এমনকি ৪-স্তর x86-64-এও, আর ARM-এর ৩-স্তর কনফিগেও:

Linux নামপূর্ণরূপx86-64 ৪-স্তরে যা মেলে
pgd_tPage Global DirectoryPML4
p4d_tPage 4th-level Directory(৪-স্তরে “folded” — কিছুই না)
pud_tPage Upper DirectoryPDPT
pmd_tPage Middle DirectoryPD
pte_tPage Table EntryPT

যে স্তর একটা নির্দিষ্ট architecture-এ নেই, সেটা folded — অর্থাৎ সেই স্তরের accessor ম্যাক্রো শুধু উপরের pointer-টা অপরিবর্তিত ফেরত দেয়, আর compiler পুরো কোডটা optimize করে মুছে ফেলে, শূন্য runtime খরচে। এই কৌশলের ফলে mm/memory.c-এর একই generic কোড x86-64 (৪ বা ৫ স্তর), ARM64 (৩ বা ৪ স্তর, granule-নির্ভর), RISC-V (Sv39/Sv48/Sv57) — সবখানে অপরিবর্তিত চলে। p4d_t স্তরটা যোগই করা হয়েছিল ২০১৭-তে LA57 সমর্থনের জন্য, আর তার আগে Linux-এ চারটা স্তর ছিল।

উদাহরণ

একটা প্রকৃত ঠিকানা, বিট ধরে ধরে

0x00007f3ab2c4d678 — Linux-এ একটা mmap-করা অঞ্চলের ঠিকানা এরকমই দেখতে হয়। একে হাতে ভেঙে ফেলি।

প্রথমে পূর্ণ ৬৪-বিট binary:

0000000000000000 011111110 011101010 110010110 001001101 011001111000
└──── sign ────┘ └─PML4──┘ └─PDPT──┘ └── PD ─┘ └── PT ─┘ └── offset ─┘
     ১৬ বিট        ৯ বিট     ৯ বিট     ৯ বিট     ৯ বিট      ১২ বিট

উপরের ১৬ বিট সব শূন্য, আর বিট ৪৭-ও শূন্য — canonical, এবং userspace-এর নিচের অর্ধেকে। বৈধ।

এখন প্রতিটা ক্ষেত্রের মান, shift আর mask দিয়ে:

ক্ষেত্রসূত্রBinaryDecimalHex
PML4 index(addr >> 39) & 0x1FF011111110২৫৪0xFE
PDPT index(addr >> 30) & 0x1FF011101010২৩৪0xEA
PD index(addr >> 21) & 0x1FF110010110৪০৬0x196
PT index(addr >> 12) & 0x1FF001001101৭৭0x4D
Offsetaddr & 0xFFF011001111000১৬৫৬0x678

0x1FF mask-টা ঠিক ৯টা ১ বিট (111111111 = ৫১১), অর্থাৎ ০ থেকে ৫১১ পর্যন্ত একটা index — ঠিক ৫১২-entry table-এর জন্য যা দরকার।

এবার ধরা যাক CR3-এ আছে 0x00000001_23456000 (একটা ৪KB-সারিবদ্ধ physical ঠিকানা — নিচের ১২ বিট শূন্য, যেমনটা হওয়া উচিত)। MMU যে ঠিকানাগুলো পড়বে:

ধাপযে physical ঠিকানা পড়া হয়গণনাধরা যাক entry-তে PFN আছে
0x1234567F00x123456000 + 254×8 = base + 0x7F00x00A1B
0x00A1B7500x00A1B000 + 234×8 = base + 0x7500x00C3D
0x00C3DCB00x00C3D000 + 406×8 = base + 0x CB00x00E5F
0x00E5F2680x00E5F000 + 77×8 = base + 0x2680x1A2B3 ← চূড়ান্ত data frame

চূড়ান্ত physical ঠিকানা:

physical=(PFN12)offset=(0x1A2B312)0x678=0x1A2B3678\text{physical} = (\text{PFN} \ll 12) \mathbin{|} \text{offset} = (\text{0x1A2B3} \ll 12) \mathbin{|} \text{0x678} = \text{0x1A2B3678}

লক্ষ করুন — offset-এর ১২ বিট কখনো অনুবাদ হয় না, সরাসরি অক্ষত অবস্থায় ফলাফলে চলে যায়। শুধু উপরের ৩৬ বিট (virtual page number) অনুবাদ হয়ে অন্য একটা সংখ্যায় (physical frame number) পরিণত হয়। এই কারণেই একটা page-এর ভেতরে সংলগ্ন বাইটগুলো physical memory-তেও সংলগ্ন থাকে, কিন্তু পাশাপাশি দুইটা page physical memory-তে সম্পূর্ণ বিচ্ছিন্ন জায়গায় থাকতে পারে।

Page table নিজে কত memory খায় — একটা বাস্তব হিসাব

একটা process যদি ১ GB anonymous memory ছুঁয়ে ফেলে (সব page প্রকৃতপক্ষে fault-in হয়েছে), তার page table কত?

১ GB = ২৬২,১৪৪টা ৪KB page। প্রতিটার জন্য একটা PTE (৮ বাইট):

PT স্তর=262144×8=2 MB\text{PT স্তর} = 262144 \times 8 = 2 \text{ MB}

প্রতিটা PT ৫১২টা PTE ধরে, তাই ৫১২টা PT দরকার, অর্থাৎ ৫১২টা PDE — ঠিক একটা পূর্ণ PD (৪ KB)। তার উপরে একটা PDPTE, একটা PML4E। মোট:

2MB+4KB+4KB+4KB2.01 MB2\,\text{MB} + 4\,\text{KB} + 4\,\text{KB} + 4\,\text{KB} \approx 2.01\ \text{MB}

অর্থাৎ ম্যাপ করা memory-র প্রায় ০.২% (১/৫১২, কারণ ৮ বাইট metadata প্রতি ৪০৯৬ বাইট data)। শুনতে সামান্য, কিন্তু বড় স্কেলে না — একটা ৫০০ GB in-memory database-এর page table হবে প্রায় ১ GB, এবং সেটা এমন memory যা কোনো useful data ধরে না।

২MB huge page ব্যবহার করলে PT স্তরটা পুরো অদৃশ্য হয়ে যায় — ১ GB-র জন্য মাত্র ৫১২টা PDE = ৪ KB, অর্থাৎ ৫০০ গুণ কম। বড় database-এ huge page ব্যবহারের এটাই দ্বিতীয় বড় কারণ (প্রথমটা TLB reach)।

এই সংখ্যাটা আপনি নিজের মেশিনে সরাসরি দেখতে পারেন — /proc/meminfo-র PageTables লাইন পুরো সিস্টেমের page table-এ ব্যয়িত memory দেখায়, আর সেটাই পরের experiment-এর বিষয়।

নিজে চালিয়ে দেখুন

EXPERIMENT

একটা virtual address-এর প্রকৃত physical frame খুঁজে বের করুন

Linux (root দরকার)· ২০ মিনিট

/proc/<pid>/pagemap হলো kernel-এর একটা binary ইন্টারফেস যা প্রতিটা virtual page-এর জন্য একটা ৬৪-বিট entry দেয়। ফাইলের (vaddr / 4096) * 8 বাইট offset-এ সেই page-এর entry।

Entry-র বিট-বিন্যাস (Documentation/admin-guide/mm/pagemap.rst থেকে):

বিটমানে
০-৫৪Page Frame Number (present হলে)
৫৫soft-dirty
৫৬exclusively mapped
৬১file-backed বা shared-anonymous
৬২swapped
৬৩present

প্রথমে একটা প্রোগ্রাম যা একটা page ছুঁয়ে তার ঠিকানা ছাপে আর অপেক্ষা করে:

/* toucher.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>
#include <sys/mman.h>

int main(void) {
    char *p = mmap(NULL, 4096, PROT_READ | PROT_WRITE,
                   MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    p[0] = 'X';                       /* fault-in করে বাধ্যতামূলকভাবে */
    printf("pid=%d  vaddr=%p\n", getpid(), (void *)p);
    fflush(stdout);
    getchar();                        /* পরীক্ষার সময় বাঁচিয়ে রাখো */
    return 0;
}
gcc -O2 -o toucher toucher.c
./toucher
pid=48213  vaddr=0x7f1c9a3f8000

এখন আরেকটা টার্মিনালে, root হিসেবে, সেই entry পড়ুন:

sudo python3 - <<'PY'
pid, vaddr = 48213, 0x7f1c9a3f8000
with open(f"/proc/{pid}/pagemap", "rb") as f:
    f.seek((vaddr // 4096) * 8)
    entry = int.from_bytes(f.read(8), "little")
present = (entry >> 63) & 1
pfn     = entry & ((1 << 55) - 1)
print(f"raw entry = 0x{entry:016x}")
print(f"present   = {present}")
print(f"PFN       = 0x{pfn:x}  ({pfn})")
print(f"physical  = 0x{(pfn << 12) | (vaddr & 0xfff):x}")
PY
raw entry = 0xa500000000148a3f
present   = 1
PFN       = 0x148a3f  (1345599)
physical  = 0x148a3f000

Virtual 0x7f1c9a3f8000 আসলে physical 0x148a3f000 — সম্পূর্ণ ভিন্ন দুটো সংখ্যা। এখন দ্বিতীয় একটা ./toucher চালান আর একই কাজ করুন: virtual address প্রায় নিশ্চিতভাবে আলাদা হবে (ASLR-এর কারণে), কিন্তু আরও গুরুত্বপূর্ণভাবে PFN সম্পূর্ণ ভিন্ন হবে — দুইটা process দুইটা ভিন্ন physical frame পেয়েছে।

এটা কী প্রমাণ করে

Virtual address আর physical address সত্যিই ভিন্ন সংখ্যা, আর একই virtual address দুইটা process-এ সম্পূর্ণ ভিন্ন physical frame-এ map হয় — page table-ভিত্তিক isolation-এর প্রত্যক্ষ প্রমাণ।

EXPERIMENT

Page table-এর প্রকৃত memory overhead মাপুন

Linux· ১৫ মিনিট

/proc/meminfo-র PageTables ফিল্ড পুরো সিস্টেমের সব process-এর page table-এ ব্যয়িত memory দেখায়।

grep -E 'PageTables|MemAvailable' /proc/meminfo
MemAvailable:   12043216 kB
PageTables:        38492 kB

এখন একটা প্রোগ্রাম যা ১ GB ছোঁয় — লক্ষ করুন, শুধু malloc যথেষ্ট না, প্রতিটা page-এ লিখতে হবে, নাহলে page table entry তৈরিই হবে না (demand paging, পরের লেসনের বিষয়):

/* hog.c */
#include <stdio.h>
#include <stdlib.h>
#include <unistd.h>

#define GB (1024UL * 1024 * 1024)

int main(void) {
    char *p = malloc(GB);
    for (size_t i = 0; i < GB; i += 4096) p[i] = 1;   /* প্রতিটা page ছোঁয়া */
    printf("%lu MB ছোঁয়া হয়েছে, pid=%d\n", GB >> 20, getpid());
    getchar();
    return 0;
}
gcc -O2 -o hog hog.c
./hog &
sleep 2
grep PageTables /proc/meminfo
1024 MB ছোঁয়া হয়েছে, pid=48887
PageTables:        40584 kB

পার্থক্য: ৪০৫৮৪ − ৩৮৪৯২ = ২০৯২ kB ≈ ২.০ MB — ঠিক আমাদের হিসাব করা ১ GB ÷ ৫১২ = ২ MB। তত্ত্ব আর পরিমাপ মিলে গেল।

প্রতি-process হিসাবও দেখা যায় /proc/<pid>/status-এ:

grep -E 'VmRSS|VmPTE' /proc/48887/status
VmRSS:	 1049152 kB
VmPTE:	    2084 kB

VmPTE হলো এই process-এর নিজের page table। ১,০৪৯,১৫২ ÷ ২,০৮৪ ≈ ৫০৩ — প্রায় নিখুঁতভাবে সেই ১/৫১২ অনুপাত।

Huge page দিয়ে একই পরীক্ষা — অনুপাতটা ভেঙে পড়ে:

# THP-কে সবসময় চালু করুন (root দরকার)
echo always | sudo tee /sys/kernel/mm/transparent_hugepage/enabled
./hog &
sleep 2
grep -E 'VmRSS|VmPTE' /proc/$!/status
grep AnonHugePages /proc/meminfo
VmRSS:	 1049152 kB
VmPTE:	      36 kB
AnonHugePages:   1048576 kB

VmPTE ২০৮৪ kB থেকে নেমে ৩৬ kB — ৫৮ গুণ কম, কারণ পুরো PT স্তরটা আর তৈরিই হয়নি। পরীক্ষার পর সেটিংটা ফিরিয়ে দিন: echo madvise | sudo tee /sys/kernel/mm/transparent_hugepage/enabled

এটা কী প্রমাণ করে

Page table কোনো বিমূর্ত ধারণা না — এটা প্রকৃত RAM খায়, ম্যাপ করা memory-র প্রায় ০.২% (১/৫১২), আর সেই সংখ্যাটা একটা প্রোগ্রাম memory ছোঁয়ার সাথে সাথে বাস্তব সময়ে বাড়তে দেখা যায়।

নিজে বানান

BUILD IT

নিজের হাতে address decoder — বিট থেকে PFN, আর kernel-এর সাথে মিলিয়ে দেখা

C · ●●●●○
  1. একটা 4KB anonymous page mmap করুন এবং তাতে লিখে fault-in করান
  2. সেই ঠিকানার চারটা table index আর offset শুধু shift ও mask দিয়ে হাতে বের করুন এবং টেবিল আকারে ছাপুন
  3. /proc/self/pagemap থেকে সেই page-এর 64-bit entry পড়ুন এবং PFN, present, swapped বিট আলাদা করুন
  4. আপনার হাতে গণনা করা offset আর kernel-এর দেওয়া PFN জোড়া লাগিয়ে চূড়ান্ত physical ঠিকানা গঠন করুন
  5. একই ঠিকানার পাশের page (vaddr + 4096) দেখুন — index কোনটা বাড়ল সেটা যাচাই করুন
  6. root ছাড়া এবং root-সহ দুইবার চালিয়ে PFN শূন্য বনাম প্রকৃত মান তুলনা করুন

এই প্রোগ্রামটা এই লেসনের সব হিসাব একসাথে করে: বিট-ভাঙা, pagemap পড়া, আর দুইটা মিলিয়ে যাচাই।

/* vaddr_decode.c — একটা virtual address-এর page table index গুলো হাতে বের করে
 *                  এবং /proc/self/pagemap থেকে প্রকৃত PFN নিয়ে মিলিয়ে দেখে।
 *
 * বানান:  gcc -O2 -Wall -o vaddr_decode vaddr_decode.c
 * চালান:  ./vaddr_decode          (PFN শূন্য দেখাবে)
 *         sudo ./vaddr_decode     (প্রকৃত PFN দেখাবে)
 */
#define _GNU_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/mman.h>

#define PAGE_SHIFT 12
#define PAGE_SIZE  (1UL << PAGE_SHIFT)
#define IDX_MASK   0x1FFUL              /* ৯ বিট: ০..৫১১ */

/* --- অংশ ১: বিশুদ্ধ bit arithmetic, কোনো kernel সাহায্য ছাড়া --- */

struct vaddr_parts {
    unsigned pml4, pdpt, pd, pt;
    unsigned long offset;
    unsigned long sign_ext;             /* উপরের ১৬ বিট */
};

static struct vaddr_parts decode(unsigned long v) {
    struct vaddr_parts p;
    p.sign_ext = v >> 48;
    p.pml4     = (v >> 39) & IDX_MASK;
    p.pdpt     = (v >> 30) & IDX_MASK;
    p.pd       = (v >> 21) & IDX_MASK;
    p.pt       = (v >> 12) & IDX_MASK;
    p.offset   =  v & (PAGE_SIZE - 1);
    return p;
}

static int is_canonical(unsigned long v) {
    /* বিট ৬৩..৪৮ অবশ্যই বিট ৪৭-এর অনুলিপি হতে হবে */
    unsigned long top = v >> 47;
    return top == 0 || top == 0x1FFFFUL;
}

static void print_bits(unsigned long v) {
    for (int b = 63; b >= 0; b--) {
        putchar((v >> b) & 1 ? '1' : '0');
        if (b == 48 || b == 39 || b == 30 || b == 21 || b == 12) putchar(' ');
    }
    putchar('\n');
    printf("|--- sign ------| |-PML4--| |-PDPT--| |--PD---| |--PT---| |-- offset --|\n");
}

/* --- অংশ ২: kernel কী বলে — /proc/self/pagemap --- */

struct pm_entry {
    int      valid;                     /* entry পড়া গেছে? */
    int      present, swapped, exclusive, file_shared;
    uint64_t pfn;
    uint64_t raw;
};

static struct pm_entry pagemap_lookup(unsigned long vaddr) {
    struct pm_entry e;
    memset(&e, 0, sizeof e);

    int fd = open("/proc/self/pagemap", O_RDONLY);
    if (fd < 0) { perror("open pagemap"); return e; }

    off_t off = (off_t)(vaddr / PAGE_SIZE) * sizeof(uint64_t);
    uint64_t raw = 0;
    if (pread(fd, &raw, sizeof raw, off) != sizeof raw) {
        perror("pread pagemap");
        close(fd);
        return e;
    }
    close(fd);

    e.valid       = 1;
    e.raw         = raw;
    e.present     = (raw >> 63) & 1;
    e.swapped     = (raw >> 62) & 1;
    e.file_shared = (raw >> 61) & 1;
    e.exclusive   = (raw >> 56) & 1;
    e.pfn         = raw & ((1ULL << 55) - 1);
    return e;
}

/* --- অংশ ৩: একটা ঠিকানার পূর্ণ প্রতিবেদন --- */

static void report(const char *label, unsigned long v) {
    struct vaddr_parts p = decode(v);
    struct pm_entry    e = pagemap_lookup(v);

    printf("\n=== %s : 0x%016lx ===\n", label, v);
    print_bits(v);
    printf("canonical           : %s\n", is_canonical(v) ? "হ্যাঁ" : "না (#GP হবে)");
    printf("  sign-extension    : 0x%04lx\n", p.sign_ext);
    printf("  PML4 index        : %4u   (0x%03x)   → CR3 + %u*8 = +0x%x\n",
           p.pml4, p.pml4, p.pml4, p.pml4 * 8);
    printf("  PDPT index        : %4u   (0x%03x)   → PDPT_base + 0x%x\n",
           p.pdpt, p.pdpt, p.pdpt * 8);
    printf("  PD   index        : %4u   (0x%03x)   → PD_base   + 0x%x\n",
           p.pd, p.pd, p.pd * 8);
    printf("  PT   index        : %4u   (0x%03x)   → PT_base   + 0x%x\n",
           p.pt, p.pt, p.pt * 8);
    printf("  page offset       : %4lu   (0x%03lx)\n", p.offset, p.offset);
    printf("  virtual page num  : 0x%lx\n", v >> PAGE_SHIFT);

    if (!e.valid) { printf("  pagemap           : পড়া যায়নি\n"); return; }

    printf("  pagemap raw       : 0x%016llx\n", (unsigned long long)e.raw);
    printf("  present / swapped : %d / %d\n", e.present, e.swapped);
    printf("  exclusive / file  : %d / %d\n", e.exclusive, e.file_shared);

    if (e.present && e.pfn) {
        unsigned long phys = (e.pfn << PAGE_SHIFT) | p.offset;
        printf("  PFN               : 0x%llx  (%llu)\n",
               (unsigned long long)e.pfn, (unsigned long long)e.pfn);
        printf("  PHYSICAL ADDRESS  : 0x%lx\n", phys);
        printf("                      = (PFN << 12) | offset\n");
    } else if (e.present) {
        printf("  PFN               : ০ — root ছাড়া চালাচ্ছেন (CAP_SYS_ADMIN দরকার)\n");
    } else {
        printf("  PFN               : mapping present নয় — এখনো fault-in হয়নি\n");
    }
}

int main(void) {
    /* ছোঁয়া হয়নি এমন একটা page — present বিট ০ থাকবে */
    char *untouched = mmap(NULL, PAGE_SIZE * 2, PROT_READ | PROT_WRITE,
                           MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (untouched == MAP_FAILED) { perror("mmap"); return 1; }

    report("mmap করা কিন্তু ছোঁয়া হয়নি", (unsigned long)untouched);

    untouched[0] = 'A';                 /* এখন fault-in হলো */
    report("একই page, ছোঁয়ার পর", (unsigned long)untouched);

    /* পাশের page — শুধু PT index এক বাড়া উচিত */
    untouched[PAGE_SIZE] = 'B';
    report("পাশের page (+4096)", (unsigned long)untouched + PAGE_SIZE);

    /* stack-এ থাকা একটা local variable — সম্পূর্ণ ভিন্ন অঞ্চল */
    int on_stack = 0;
    report("stack-এ একটা local", (unsigned long)&on_stack);

    /* একটা অ-canonical ঠিকানা — শুধু গণনা, dereference করবেন না! */
    report("অ-canonical উদাহরণ", 0x0000800000000000UL);

    munmap(untouched, PAGE_SIZE * 2);
    return 0;
}

চালানোর পর (root হিসেবে) যা দেখতে পাবেন, সংক্ষেপে:

=== mmap করা কিন্তু ছোঁয়া হয়নি : 0x00007f2b41c3a000 ===
canonical           : হ্যাঁ
  PML4 index        :  254   (0x0fe)
  PDPT index        :  173   (0x0ad)
  PD   index        :   30   (0x01e)
  PT   index        :   58   (0x03a)
  page offset       :    0   (0x000)
  pagemap raw       : 0x0000000000000000
  present / swapped : 0 / 0
  PFN               : mapping present নয় — এখনো fault-in হয়নি

=== একই page, ছোঁয়ার পর : 0x00007f2b41c3a000 ===
  pagemap raw       : 0x8500000000163b71
  present / swapped : 1 / 0
  PFN               : 0x163b71  (1456497)
  PHYSICAL ADDRESS  : 0x163b71000

=== পাশের page (+4096) : 0x00007f2b41c3b000 ===
  PT   index        :   59   (0x03b)        ← ৫৮ থেকে ৫৯, শুধু PT index বাড়ল
  PFN               : 0x1a04c2  (1704130)   ← কিন্তু PFN সম্পূর্ণ অসংলগ্ন!

এই আউটপুটের তিনটা পর্যবেক্ষণ এই লেসনের কেন্দ্রীয় দাবিগুলোর প্রত্যক্ষ প্রমাণ:

  1. mmap করা মানেই মেমরি বরাদ্দ না — প্রথম রিপোর্টে present=0, কোনো physical frame নেই। mmap শুধু একটা প্রতিশ্রুতি; প্রকৃত frame আসে প্রথম স্পর্শে।
  2. সংলগ্ন virtual, অসংলগ্ন physical — পাশাপাশি দুইটা virtual page (...a000 আর ...b000) সম্পূর্ণ ভিন্ন, অসংলগ্ন physical frame-এ (0x163b71 আর 0x1a04c2) বসেছে। এটাই paging-এর সেই সুবিধা যা external fragmentation দূর করে।
  3. শুধু PT index বদলাল — পাশের page-এ যাওয়ায় শুধু নিচের স্তরের index এক বাড়ল, উপরের তিনটা index অপরিবর্তিত। এর মানে দুইটা page একই PT ভাগ করে — একই PT ৫১২টা page, অর্থাৎ ২ MB পর্যন্ত ঢাকে।

নিজে বাড়ান

  1. PTE-র উপরের বিটগুলোও দেখান। pagemap swapped page-এর জন্য বিট ০-৪-এ swap type আর ৫-৫৪-এ swap offset রাখে। একটা প্রোগ্রাম লিখুন যা প্রচুর memory বরাদ্দ করে swap-এ ঠেলে দেয় (swapoff -a না চালিয়ে!), তারপর একটা swapped page-এর entry decode করে swap type + offset ছাপে।
  2. /proc/kpageflags যোগ করুন। PFN হাতে পেলে /proc/kpageflags-এ PFN * 8 offset-এ সেই physical frame-এর kernel flag (KPF_ANON, KPF_DIRTY, KPF_HUGE, KPF_REFERENCED) পড়া যায়। রিপোর্টে সেই flag-গুলো নাম ধরে ছাপুন।
  3. Huge page শনাক্ত করুন। madvise(ptr, 2*1024*1024, MADV_HUGEPAGE) দিয়ে একটা ২ MB-সারিবদ্ধ অঞ্চল নিন, ছুঁয়ে ফেলুন, তারপর তার ভেতরের ৫১২টা ৪KB page-এর pagemap entry পড়ুন — সবগুলোর PFN কি ধারাবাহিক? (হ্যাঁ হওয়া উচিত, আর প্রথমটা ৫১২-এর গুণিতক হওয়া উচিত।) এই পরীক্ষাটাই huge page শনাক্ত করার একটা নির্ভরযোগ্য উপায়।
  4. দুইটা process, একই ফাইল। একই ফাইল দুইটা process-এ MAP_SHARED দিয়ে mmap করুন, দুই দিক থেকে PFN ছাপুন — একই PFN পাওয়া উচিত, কারণ shared mapping মানে একই physical frame দুইটা page table-এ দুইবার উল্লেখ করা। তারপর MAP_PRIVATE দিয়ে একই পরীক্ষা করুন এবং একজন লেখার পর PFN কীভাবে বদলে যায় দেখুন — সেটাই copy-on-write, লেসন ১৩-এর বিষয়।
  5. ৫-স্তর সমর্থন যোগ করুন। /proc/cpuinfo-তে la57 flag আছে কি না দেখে, থাকলে একটা পঞ্চম index ((v >> 48) & 0x1FF) বের করুন এবং sign-extension ক্ষেত্রটা ১৬ থেকে ৭ বিটে সংকুচিত করুন।

বাস্তব সিস্টেমে

Meltdown আর KPTI — page table-এর সবচেয়ে ব্যয়বহুল নিরাপত্তা-প্যাচ। ঐতিহাসিকভাবে kernel-এর পুরো mapping প্রতিটা process-এর page table-এর উপরের অর্ধেকে থাকত (U/S বিট ০ দিয়ে সুরক্ষিত), কারণ তাতে syscall-এ page table বদলাতে হতো না — বিশাল পারফরম্যান্স সুবিধা। Meltdown (CVE-2017-5754, ২০১৮) দেখাল যে out-of-order execution-এর সময় CPU U/S চেক সম্পূর্ণ হওয়ার আগেই speculatively kernel data পড়ে ফেলে, আর সেই data cache-এ ছাপ রেখে যায় — একটা cache timing side-channel দিয়ে unprivileged process পুরো kernel memory পড়ে ফেলতে পারত। সমাধান KPTI (Kernel Page Table Isolation): প্রতিটা process এখন দুইটা page table পায় — একটা userspace-এর জন্য যেখানে kernel প্রায় সম্পূর্ণ অনুপস্থিত, আরেকটা kernel mode-এর জন্য। প্রতিটা syscall আর interrupt-এ CR3 দুইবার বদলাতে হয়। খরচ: syscall-ভারী কাজে ৫-৩০% ধীরগতি (একটা getpid()-এর মতো ছোট syscall প্রায় দ্বিগুণ ব্যয়বহুল হয়ে গেল)। এটাই সম্ভবত কম্পিউটিং ইতিহাসের সবচেয়ে দামি একক নিরাপত্তা-প্যাচ, আর তার পুরোটাই page table-এর গঠন নিয়ে।

Database আর huge page — PostgreSQL, MySQL, Oracle। প্রতিটা বড় database-এর ডকুমেন্টেশনে huge page কনফিগার করার একটা অধ্যায় আছে। PostgreSQL-এর huge_pages = on সেটিং shared buffer-কে explicit huge page দিয়ে বরাদ্দ করে। একটা ৬৪ GB shared buffer-এর জন্য ৪KB page-এ page table হতো ১২৮ MB (এবং প্রতিটা backend process-এর নিজের কপি!), ২MB page-এ সেটা ২৫৬ KB। Oracle-এর ডকুমেন্টে হুবহু এই হিসাব দেওয়া আছে, আর তারা বলে ১০০টা connection-এ পার্থক্যটা ১২ GB বনাম ২৫ MB — page table-এর জন্যই। Level 12-এর cloud module দেখবে কেন cloud VM-এ এই সংখ্যাগুলো আরও খারাপ (nested paging-এর কারণে দুইবার হাঁটতে হয়)।

Nested paging — VM-এর ভেতরে VM-এর page table। একটা virtual machine-এর ভেতরে guest OS নিজের page table বানায়, guest-physical ঠিকানায়। কিন্তু guest-physical নিজেই একটা বিভ্রম — hypervisor-এর আরেক স্তরের table (Intel-এ EPT, AMD-তে NPT) সেটাকে host-physical-এ অনুবাদ করে। ফলাফল: একটা TLB miss-এ ৪ × ৪ + ৪ = ২৪টা memory access পর্যন্ত লাগতে পারে (guest-এর প্রতিটা স্তরের ঠিকানা নিজেই EPT দিয়ে অনুবাদ করতে হয়)। এই কারণেই virtualized environment-এ huge page-এর সুবিধা আরও অনেক বেশি নাটকীয়। Level 12 এই “two-dimensional page walk” বিস্তারিত দেখবে।

io_uring আর MAP_POPULATE — fault এড়ানোর কৌশল। Latency-সংবেদনশীল সিস্টেম (high-frequency trading, real-time audio) প্রায়ই startup-এ mmap(..., MAP_POPULATE) দিয়ে সব page table entry আগেভাগে তৈরি করে নেয়, বা mlockall(MCL_CURRENT | MCL_FUTURE) দিয়ে সব page RAM-এ আটকে রাখে — কারণ একটা অপ্রত্যাশিত page fault-এর মাইক্রোসেকেন্ড-স্তরের বিলম্ব তাদের জন্য অগ্রহণযোগ্য। Level 11-এর performance module এই প্রি-ফল্টিং কৌশল আর তার মাপার পদ্ধতি দেখবে।

perf mem আর page-walk counter। আধুনিক Intel CPU-তে page walk-এর সময় সরাসরি মাপার hardware counter আছে: dtlb_load_misses.walk_active (কত cycle page walker ব্যস্ত ছিল) আর dtlb_load_misses.walk_completed (কতগুলো walk শেষ হলো)। বড় workload-এ এই দুইটার অনুপাত দেখায় গড়ে একটা walk কত cycle নিচ্ছে — বাস্তবে ২০-৪০ cycle (কারণ উপরের স্তরগুলো cache-এ থাকে) থেকে ৩০০+ cycle (সব স্তর DRAM-এ) পর্যন্ত। পরের লেসনে এই counter-গুলো নিজে চালাব।

যে ভুলগুলো সবাই করে

“Page table CPU-র ভেতরে থাকে, তাই lookup দ্রুত।”

Page table সাধারণ DRAM-এ থাকে, ঠিক আপনার প্রোগ্রামের data-র মতোই। CPU-র ভেতরে শুধু একটা register (CR3) থাকে যা গাছের গোড়ার physical ঠিকানা ধরে রাখে — গাছটা নিজে পুরোপুরি memory-তে।

এই কারণেই একটা TLB miss এত ব্যয়বহুল: চারটা প্রকৃত memory access লাগে, আর প্রতিটার নিজস্ব cache miss হতে পারে। CPU-র ভেতরে যা থাকে সেটা page table না, বরং তার ফলাফলের ক্যাশ — TLB (এবং paging-structure cache)। এই দুইটা গুলিয়ে ফেললে পরের লেসনের পুরো যুক্তিটাই অর্থহীন হয়ে যায়: যদি page table সত্যিই CPU-র ভেতরে থাকত, TLB-র কোনো প্রয়োজনই হতো না।

“প্রতিটা process-এর page table তার পুরো ৪৮-বিট address space ঢাকে, তাই বিশাল।”

এটাই ঠিক সেই ভুল ধারণা যেটা multi-level design খণ্ডন করে। একটা process-এর page table শুধু সেই অংশটুকু বর্ণনা করে যা প্রকৃতপক্ষে map করা আছে — বাকিটার জন্য উপরের স্তরে present=0 রাখাই যথেষ্ট, নিচের কোনো table তৈরিই হয় না।

সংখ্যায়: single-level হলে লাগত ৫১২ GiB per process। বাস্তবে একটা bash process-এর VmPTE (যা /proc/<pid>/status-এ দেখা যায়) সাধারণত ৫০-১০০ kB — দশ কোটি গুণ কম। এই বিশাল ব্যবধানটাই multi-level কাঠামোর পুরো অস্তিত্বের কারণ, আর সেটা সম্ভব হয়েছে শুধু একটা পর্যবেক্ষণ থেকে: address space প্রায় পুরোটাই ফাঁকা।

“Huge page সবসময় ভালো — যত বড় page, তত ভালো পারফরম্যান্স।”

Huge page একটা trade-off, বিনামূল্যের উন্নতি না, আর তিনটা বাস্তব খরচ আছে।

প্রথম — internal fragmentation ৫১২ গুণ বাড়ে। একটা প্রোগ্রাম যদি বিক্ষিপ্তভাবে অনেক ছোট অঞ্চল ছোঁয়, transparent huge page চালু থাকলে প্রতিটার জন্য ২ MB বরাদ্দ হবে; RSS আকাশ ছুঁতে পারে। এটাই Redis-এর ডকুমেন্টেশনে THP বন্ধ করার সুপারিশের কারণ।

দ্বিতীয় — একটা ২ MB huge page পেতে ৫১২টা ধারাবাহিক, সারিবদ্ধ free frame দরকার। দীর্ঘদিন চলা সিস্টেমে physical memory খণ্ডবিখণ্ড হয়ে যায়, আর kernel-কে compaction চালাতে হয় (khugepaged), যা CPU খায় এবং latency spike তৈরি করে।

তৃতীয় — copy-on-write একটা ২ MB page-এ মানে একটা বাইট লিখলেই পুরো ২ MB কপি করতে হবে, ৪ KB-র বদলে। fork-ভারী workload-এ (যেমন Redis-এর background save) এটা মারাত্মক।

তাই Linux-এর ডিফল্ট অনেক distro-তে madvise — শুধু যে প্রোগ্রাম স্পষ্টভাবে চায় তাকেই দাও।

“mmap() ডাকলেই memory বরাদ্দ হয়ে যায়, তাই তারপর ব্যবহার করা নিরাপদ।”

mmap() কোনো physical frame বরাদ্দ করে না, এমনকি একটা page table entry-ও (সাধারণত) তৈরি করে না। এটা শুধু kernel-এর ভেতরে একটা VMA (virtual memory area) নথিভুক্ত করে — একটা রেকর্ড যা বলে “এই ঠিকানা-পরিসরটা বৈধ, এই অনুমতি নিয়ে”।

এই লেসনের BuildIt-এর আউটপুটে এটা সরাসরি দেখা যায়: mmap-এর পরপরই pagemap entry পুরো শূন্য, present=0। প্রথমবার সেই page-এ লেখার পর তবেই একটা প্রকৃত frame আসে এবং present=1 হয়।

এর একটা গুরুত্বপূর্ণ বাস্তব ফল — mmap() সফল হওয়া মানে এই না যে memory আসলে আছে। Linux ডিফল্টে overcommit করে (/proc/sys/vm/overcommit_memory = 0), অর্থাৎ RAM-এর চেয়ে বেশি প্রতিশ্রুতি দেয়, এই ধরে নিয়ে যে সবাই একসাথে সব ছোঁবে না। যদি সবাই সত্যিই ছুঁয়ে ফেলে, তখন কোনো malloc ব্যর্থ হয় না — বরং OOM killer এসে একটা process মেরে ফেলে। “malloc সফল হয়েছে মানে memory আছে” — এই ধারণাটা Linux-এ ভুল।

বুঝেছেন কি না দেখুন

1

Virtual address 0x00005555_5555_6000-এর চারটা page table index আর offset বের করুন। এটা কি canonical? (Hint: হাতে করুন, প্রতিটা ৯-বিট ক্ষেত্র আলাদা করে।)

প্রয়োগ

প্রথমে binary-তে (নিচের ৪৮ বিট):

0000000000000000 010101010 101010101 010101010 100000110 000000000000
└──── sign ────┘ └─PML4──┘ └─PDPT──┘ └── PD ─┘ └── PT ─┘ └── offset ─┘
ক্ষেত্রসূত্রগণনাফল
PML4(a >> 39) & 0x1FF0x55555555 6000 >> 39 = 0xAA১৭০
PDPT(a >> 30) & 0x1FF৩৪১ (0x155)
PD(a >> 21) & 0x1FF১৭০ (0xAA)
PT(a >> 12) & 0x1FF২৬২ (0x106)
Offseta & 0xFFF

Canonical? হ্যাঁ। বিট ৪৭ = ০ (কারণ ঠিকানাটা 0x0000_7FFF_FFFF_FFFF-এর চেয়ে ছোট), আর উপরের ১৬ বিট সবই ০ — নিয়ম মেনে চলছে।

offset = ০ কেন গুরুত্বপূর্ণ: ঠিকানাটা 0x...6000-এ শেষ, অর্থাৎ নিচের ১২ বিট শূন্য — এটা একটা page-সারিবদ্ধ ঠিকানা, একটা page-এর ঠিক শুরু। mmap সবসময় এরকম ঠিকানা ফেরত দেয়।

অতিরিক্ত পর্যবেক্ষণ: 0x5555... উপসর্গটা আকস্মিক না — এটা Linux-এ একটা PIE (position-independent executable)-এর ডিফল্ট load base-এর চিহ্ন, যখন ASLR বন্ধ থাকে (setarch -R)। ASLR চালু থাকলে এই ঠিকানা প্রতিবার বদলায়, কিন্তু index-বের করার পদ্ধতি অভিন্ন। Level 10-এর security module দেখবে কেন এই randomization-টা প্রয়োজনীয় ছিল, আর কত বিট entropy সেটা আসলে দেয় (x86-64 Linux-এ mmap-এর জন্য ২৮ বিট)।

2

একটা process ঠিক দুইটা page ছোঁয়: একটা 0x7f0000000000-এ, আরেকটা 0x7f0000200000-এ (২ MB দূরে)। এই দুইটা mapping ধরে রাখতে সর্বনিম্ন কতগুলো page table (৪ KB করে) দরকার, আর মোট কত বাইট?

যুক্তি

প্রথমে দুইটা ঠিকানার index তুলনা করি:

0x7f00000000000x7f0000200000একই?
PML4২৫৪২৫৪হ্যাঁ
PDPT৩৮৪৩৮৪হ্যাঁ
PDনা
PT(ভিন্ন PT-তে)

মূল পর্যবেক্ষণ — ২ MB ঠিক একটা PD entry-র পরিসর (৫১২ page × ৪ KB = ২ MB)। তাই দুইটা ঠিকানা একই PD ভাগ করে কিন্তু ভিন্ন PDE ব্যবহার করে, ফলে দুইটা আলাদা PT লাগে।

স্তরকতগুলোকারণ
PML4সবসময় একটাই, process-প্রতি
PDPTদুইটার PML4 index এক
PDদুইটার PDPT index এক
PTPD index ভিন্ন (০ আর ১) — প্রতিটার নিজের PT
মোট৫টা table৫ × ৪ KB = ২০ KB

অর্থাৎ মাত্র ৮ KB প্রকৃত data (দুইটা page) ধরে রাখতে ২০ KB page table — metadata data-র আড়াই গুণ! এটা multi-level table-এর সবচেয়ে খারাপ কেসের একটা উদাহরণ: অত্যন্ত বিক্ষিপ্ত (sparse) access প্যাটার্ন।

বিপরীত কেস: যদি দুইটা page পাশাপাশি হতো (0x7f0000000000 আর 0x7f0000001000), তাদের PD index একই হতো, তাই একটাই PT যথেষ্ট — মোট ৪টা table = ১৬ KB। আরও ভালো: সেই একটা PT দিয়ে আরও ৫১০টা page বিনামূল্যে ঢাকা যেত।

Level 11-এর সংযোগ: এই হিসাবটাই ব্যাখ্যা করে কেন data structure-এর memory layout পারফরম্যান্সে এত গুরুত্বপূর্ণ। বিক্ষিপ্ত allocation শুধু cache locality নষ্ট করে না, page table-ও ফুলিয়ে দেয় এবং TLB-ও (পরের লেসন)। Level 11 এই “locality” ধারণাটার তিনটা স্তর — cache line, page, আর NUMA node — একসাথে বিশ্লেষণ করবে।

3

Hardware কেন PTE-তে Accessed (A) আর Dirty (D) বিট নিজে সেট করে, OS-এর হাতে না ছেড়ে? আর OS এই দুইটার কোনটা কোন সিদ্ধান্তে ব্যবহার করে?

যুক্তি

কেন hardware — কারণ software-এর পক্ষে এটা করা অসম্ভব-রকম ব্যয়বহুল।

যদি OS-কে জানতে হতো কোন page সম্প্রতি ব্যবহৃত হয়েছে, তার একমাত্র উপায় হতো প্রতিটা page-কে present=0 করে রাখা, তারপর প্রতিটা access-এ page fault নেওয়া, fault handler-এ “এই page ব্যবহৃত হয়েছে” লিখে রাখা, present=1 করা, আবার কিছুক্ষণ পর present=0 করা। একটা page fault-এর দাম ~১-৫ মাইক্রোসেকেন্ড; একটা সাধারণ memory access ~১ ন্যানোসেকেন্ড। অর্থাৎ হাজার গুণ ধীর — সম্পূর্ণ অচল।

Hardware-এ এটা প্রায় বিনামূল্যে: page walker যখন এমনিতেই PTE পড়ছে, তখন একটা বিট সেট করে ফেরত লেখা প্রায় কোনো অতিরিক্ত খরচ না (যদিও এটা একটা atomic read-modify-write, তাই একেবারে বিনামূল্যে না — বহু-core সিস্টেমে এটা cache line-এর মালিকানা নিতে হয়)।

দুইটার ভিন্ন ভূমিকা:

বিটকখন সেট হয়OS কী সিদ্ধান্তে ব্যবহার করে
A (Accessed)যেকোনো read বা writeকোন page স্মৃতি থেকে সরাব? — Linux-এর active/inactive LRU list এই বিট নিয়মিত পড়ে ও মুছে (page_referenced()), যে page-এর A বারবার সেট হচ্ছে সে “hot”, তাকে রাখো
D (Dirty)শুধু writeসরানোর সময় কি disk-এ লিখতে হবে? — D=0 হলে page-টা disk-এর অনুলিপির সাথে অভিন্ন, তাই শুধু ফেলে দিলেই চলে (মুহূর্তের কাজ)। D=1 হলে আগে writeback করতে হবে (মিলিসেকেন্ডের কাজ)

একটা গুরুত্বপূর্ণ সূক্ষ্মতা: OS যখন A বিট মুছে দেয় (page-এর “বয়স” মাপার জন্য), তাকে TLB shootdown করতে হয় — কারণ TLB-তে ক্যাশ করা অনুবাদে A বিট ইতিমধ্যে সেট, আর hardware ক্যাশ-করা entry-তে আবার সেট করার চেষ্টাই করবে না। এই খরচটাই কারণ Linux A-বিট scanning-কে সীমিত রাখে, প্রতিটা page-কে প্রতি সেকেন্ডে পরীক্ষা করে না। পরের লেসনে TLB shootdown-এর প্রকৃত খরচ (IPI, প্রতিটা core-এ interrupt) বিস্তারিত দেখব, আর লেসন ১৩-এ Linux-এর প্রকৃত page replacement অ্যালগরিদম।

Level 6-এর সংযোগ: A বিট আসলে LRU-র একটা অত্যন্ত মোটা আনুমানিক রূপ — প্রকৃত LRU-র জন্য প্রতিটা access-এর সময় রাখতে হতো, যা অসম্ভব। Level 6-এর algorithms module দেখাবে কীভাবে এই ধরনের “১ বিট তথ্য দিয়ে আনুমানিক ranking” সমস্যাটা (CLOCK, second-chance, ARC) একটা সাধারণ অ্যালগরিদমিক প্যাটার্ন, আর Level 0-এর সেই প্রমাণ — কেন কোনো online অ্যালগরিদম OPT-র সমান হতে পারে না।

4

আপনি একটা নতুন ৬৪-বিট architecture ডিজাইন করছেন। কেউ প্রস্তাব দিল: “চারটা স্তর অনেক বেশি, দুইটা স্তরে করি — ২৪ বিট + ১২ বিট + ১২ বিট offset।” এই নকশার সুবিধা আর সমস্যা বিশ্লেষণ করুন, সংখ্যাসহ।

ডিজাইন

প্রস্তাবিত নকশা: ৪৮-বিট virtual address = ২৪ বিট (স্তর ১ index) + ১২ বিট (স্তর ২ index) + ১২ বিট offset।

সুবিধা — page walk অর্ধেক ছোট।

TLB miss-এ ৪-এর বদলে ২টা memory access। যদি প্রতিটা access গড়ে ৩০ cycle নেয়, walk-এর খরচ ১২০ থেকে ৬০ cycle-এ নামে — ৫০% উন্নতি, যা TLB-miss-ভারী workload-এ বাস্তব লাভ।

সমস্যা ১ — উপরের table বিশাল, আর সেটা বাধ্যতামূলক।

স্তর ১ table=224×8=227=128 MB\text{স্তর ১ table} = 2^{24} \times 8 = 2^{27} = 128 \text{ MB}

এবং এই table-টা প্রতিটা process-এর জন্য পুরোটা বরাদ্দ করতেই হবে — কারণ এটাই গোড়া, একে “sparse” করার কোনো উপায় নেই। /bin/true চালাতেও ১২৮ MB। ১০০টা process = ১২.৮ GB শুধু page table-এ। বর্তমান নকশায় গোড়ার PML4 মাত্র ৪ KB।

সমস্যা ২ — table আর এক page-এ আঁটে না।

2242^{24} entry × ৮ বাইট = ১২৮ MB, অর্থাৎ ৩২,৭৬৮টা ধারাবাহিক page দরকার। এটা একটা শারীরিকভাবে ধারাবাহিক ১২৮ MB allocation — দীর্ঘদিন চলা সিস্টেমে physical memory খণ্ডবিখণ্ড হয়ে গেলে এমন allocation ব্যর্থ হবে। বর্তমান নকশায় প্রতিটা table ঠিক এক page, তাই যেকোনো free frame চলে — কোনো ধারাবাহিকতার দাবি নেই। এটা paging-এর মূল যুক্তিরই (fixed-size টুকরো, external fragmentation নেই) একটা লঙ্ঘন।

মাপকাঠিপ্রস্তাবিত ২-স্তরবর্তমান ৪-স্তর
TLB miss-এ memory access
সর্বনিম্ন page table (খালি process)১২৮ MB৪ KB
গোড়ার table-এর ধারাবাহিকতা দরকার৩২,৭৬৮ page১ page
১০০টা হালকা process-এর মোট PT~১২.৮ GB~৬ MB

সিদ্ধান্ত: নকশাটা একটা বাস্তব সুবিধা (দ্রুত walk) কিনছে একটা অগ্রহণযোগ্য দামে (process-প্রতি ১২৮ MB fixed খরচ)। মূল ভুলটা হলো sparsity-কে কাজে লাগানোর সুযোগ হারানো — একটা গভীর গাছে প্রতিটা স্তরে subtree বাদ দেওয়া যায়, একটা অগভীর গাছে শুধু নিচের স্তরে।

তবে একটা মধ্যপথ আছে, এবং সেটা বাস্তবে ব্যবহৃত হয়: হাইব্রিড। বর্তমান নকশা রেখে huge page দিয়ে যেখানে সম্ভব সেখানে walk ছোট করা (২ MB page-এ ৩ ধাপ, ১ GB page-এ ২ ধাপ) — সুবিধাটা পাওয়া যায় শুধু সেখানেই যেখানে বড় ধারাবাহিক অঞ্চল আসলেই আছে, আর ছোট বিক্ষিপ্ত mapping-এর জন্য গভীর গাছের sparsity-সুবিধা অক্ষত থাকে। এটাই বাস্তব CPU-গুলোর সমাধান, আর এটা একটা ভালো সাধারণ ডিজাইন-পাঠ: একটা কঠিন trade-off-এ প্রায়ই সবচেয়ে ভালো উত্তর “একটা বেছে নাও” না, বরং “দুইটাই রাখো, workload-কে বেছে নিতে দাও”।

Level 12-এর সংযোগ: cloud module-এ দেখবেন nested paging-এ এই হিসাবটা কীভাবে বর্গাকারে খারাপ হয় — guest-এর ৪ স্তর × host-এর ৪ স্তর, আর সেখানে huge page-এর সুবিধা আরও অনেক বড়।

5

একটা প্রোগ্রামের /proc/<pid>/status-এ দেখা যাচ্ছে VmRSS: 4194304 kB (৪ GB) আর VmPTE: 132 kB। এই সংখ্যা দুইটা কি সঙ্গতিপূর্ণ? কী উপসংহার টানবেন?

প্রয়োগ

না, ৪ KB page ধরে নিলে এই দুইটা সংখ্যা একেবারেই সঙ্গতিপূর্ণ না — আর সেই অসঙ্গতিটাই উত্তর।

৪ KB page-এ প্রত্যাশিত হিসাব:

VmPTEপ্রত্যাশিত=4GB512=4194304 kB512=8192 kB=8 MB\text{VmPTE}_{\text{প্রত্যাশিত}} = \frac{4\,\text{GB}}{512} = \frac{4194304\ \text{kB}}{512} = 8192\ \text{kB} = 8\ \text{MB}

কিন্তু প্রকৃত মান ১৩২ kB — প্রত্যাশিতের ৬২ গুণ কম। এই ব্যবধান একটা জিনিসই মানে হতে পারে: process-টা প্রায় পুরো ৪ GB huge page দিয়ে ম্যাপ করেছে।

যাচাই করি — ২ MB huge page ধরে:

ধরে নিলামদরকারি PDEPD tableমোট PT-স্তর
সব ৪ KB১,০৪৮,৫৭৬ PTE৮,১৯২ kB
সব ২ MB২,০৪৮ PDE৪টা PD (৫১২ entry করে)১৬ kB

২ MB huge page-এ PD স্তরে মাত্র ১৬ kB, তার উপরে PDPT/PML4-এ আরও কয়েক KB, আর বাকি ~১১০ kB সম্ভবত process-এর অন্যান্য mapping (code, library, stack — যেগুলো ৪ KB page-এ) থেকে। সংখ্যাটা সম্পূর্ণ সঙ্গতিপূর্ণ হয়ে যায়।

যাচাই করার কমান্ড:

grep -E 'AnonHugePages|VmRSS|VmPTE' /proc/<pid>/status
grep -E 'AnonHugePages|ShmemHugePages' /proc/meminfo
awk '/AnonHugePages/ && $2 != 0' /proc/<pid>/smaps

AnonHugePages যদি প্রায় ৪ GB দেখায়, নিশ্চিত হলো।

কী উপসংহার টানবেন — ব্যবহারিকভাবে:

  1. Process-টা সম্ভবত একটা database বা JVM যা explicit huge page (hugetlbfs) বা THP ব্যবহার করছে।
  2. এর TLB আচরণ চমৎকার হবে — ২ MB page-এ ৪ GB ঢাকতে মাত্র ২,০৪৮টা TLB entry লাগে, যেখানে ৪ KB-তে ১০ লাখ (পরের লেসনের মূল বিষয়)।
  3. কিন্তু এই process fork() করলে সাবধান — একটা ২ MB page-এ COW fault মানে ২ MB কপি।

উল্টো দিকটাও গুরুত্বপূর্ণ: যদি কখনো VmPTE-কে প্রত্যাশিতের চেয়ে বড় দেখেন (ধরুন ৪ GB RSS-এ ৫০ MB VmPTE), সেটাও একটা সংকেত — সম্ভবত অত্যন্ত বিক্ষিপ্ত mapping (প্রশ্ন ২-এর সেই খারাপ কেস), অথবা একই memory বহুবার ভিন্ন ঠিকানায় ম্যাপ করা। Level 11-এর performance module এই ধরনের অনুপাত-ভিত্তিক নির্ণয় (ratio diagnostics) একটা নিয়মিত টুল হিসেবে ব্যবহার করবে — একটা সংখ্যা একা কিছু বলে না, কিন্তু দুইটা সংখ্যার অনুপাত প্রত্যাশা থেকে সরে গেলে সেটাই তদন্তের শুরু।

এরপর কী

পরের লেসন — TLB

এই লেসনের hood সেকশনে একটা অস্বস্তিকর সংখ্যা রেখে এসেছি: প্রতিটা memory access-এ চারটা অতিরিক্ত memory access। একটা সাধারণ mov rax, [rbx]-এ CPU-কে PML4, PDPT, PD, PT — চারটা table পড়তে হয়, তারপর আসল data। অর্থাৎ প্রতিটা memory operation পাঁচ গুণ ব্যয়বহুল।

এই হিসাবে যেকোনো কম্পিউটার অচল হয়ে যেত। বাস্তবে যায় না — কারণ CPU-র ভেতরে একটা ছোট, অত্যন্ত দ্রুত associative cache বসানো আছে যা সম্প্রতি ব্যবহৃত অনুবাদগুলো মনে রাখে: TLB (Translation Lookaside Buffer)। TLB hit হলে পুরো হাঁটাটাই এড়ানো যায়, খরচ প্রায় শূন্য — pipeline-এর ভেতরেই মিটে যায়।

পরের লেসনে দেখব TLB-র প্রকৃত গঠন (আধুনিক Intel-এ L1 dTLB-তে মাত্র ৬৪টা entry, L2 STLB-তে ২০৪৮), “TLB reach” ধারণাটা কেন huge page-এর সবচেয়ে বড় যুক্তি, বহু-core সিস্টেমে একটা page table বদলালে কেন সব core-কে interrupt পাঠাতে হয় (TLB shootdown), আর PCID কীভাবে context switch-এ পুরো flush এড়ায় — আর KPTI কীভাবে সেই সুবিধাটা আংশিক কেড়ে নিল। সাথে একটা benchmark লিখব যা stride বাড়িয়ে বাড়িয়ে TLB reach-এর প্রকৃত সীমা নিজের মেশিনে খুঁজে বের করে — cache cliff আর TLB cliff, দুইটা আলাদা খাদ, একই গ্রাফে।

আরও পড়ুন