Foundationপ্রথম নীতি থেকে
LEVEL 3লেসন ১৭/১৭কঠিন৫৫ মিনিট

DMA আর I/O — CPU-কে কপি করা থেকে মুক্তি

DMA and I/O

একটা device থেকে memory-তে ডেটা আনতে CPU-কে প্রতিটা byte নিজে কপি করতে হলে তার পুরো সময় সেখানেই যেত। DMA একটা dedicated controller-কে সেই কাজ দিয়ে CPU-কে মুক্ত করে — আর কাজ শেষে জানায় ঠিক আগের লেসনের সেই interrupt দিয়ে।

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

  • Programmed I/O-র সমস্যা ব্যাখ্যা করতে পারবেন — কেন CPU-নিয়ন্ত্রিত copy loop অপচয়ী
  • DMA controller কীভাবে device ও memory-র মধ্যে সরাসরি ডেটা সরায়, CPU-কে বাদ দিয়ে, তা বর্ণনা করতে পারবেন
  • DMA সম্পন্ন হওয়ার সংকেত হিসেবে interrupt-এর ভূমিকা আগের লেসনের সাথে যুক্ত করে ব্যাখ্যা করতে পারবেন
  • Memory-mapped I/O ও port-mapped I/O-র পার্থক্য এবং এটা কেন একটা ISA-design সিদ্ধান্ত তা যুক্তি দিয়ে বলতে পারবেন
  • DMA-জনিত cache coherency সমস্যা ও তার সমাধান (cache-coherent DMA, flush/invalidate) চিনতে পারবেন
  • পুরো computer-architecture module-এর ১৭টা লেসন কীভাবে একটা সুসংগত গল্প তৈরি করে তা সংক্ষিপ্তভাবে বর্ণনা করতে পারবেন

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

আগে এটা বুঝি

এই module জুড়ে আমরা দেখেছি CPU কীভাবে computation দ্রুততর করে — pipeline, cache, branch prediction, out-of-order execution, SIMD। কিন্তু একটা মৌলিক প্রশ্ন এখনো অস্পর্শিত: computation শুরু করার আগে, ডেটা কোথা থেকে আসে?

একটা বাস্তব দৃশ্য কল্পনা করুন — একটা প্রোগ্রাম একটা ১০০ মেগাবাইট ফাইল disk থেকে পড়তে চায়। সবচেয়ে সরল ডিজাইনে, CPU নিজেই প্রতিটা byte disk controller থেকে পড়বে, আর memory-তে লিখবে — একটা সাধারণ loop:

while (আরো byte বাকি আছে):
    byte = disk_controller-এর status register থেকে পড়ো  # অপেক্ষা করো device ready হওয়া পর্যন্ত
    memory[address] = byte
    address += 1

এই পদ্ধতিকে বলে programmed I/O — কারণ CPU নিজেই, instruction দিয়ে, প্রতিটা data movement প্রোগ্রাম করছে। সমস্যাটা এখন স্পষ্ট: ১০০ মেগাবাইট মানে ১০ কোটির বেশি এমন loop iteration — আর এই পুরো সময়টায় CPU অন্য কিছুই করতে পারছে না। এটা এমন যেন একজন দক্ষ সার্জন প্রতিটা রোগীর ফাইল নিজে হাতে টাইপ করে ডেটাবেসে তুলছেন — কাজটা তার দক্ষতার সাথে সম্পূর্ণ অসামঞ্জস্যপূর্ণ।

এই লেসন এই সমস্যার সমাধান নিয়ে — DMA (Direct Memory Access) — একটা dedicated hardware controller যে device আর memory-র মধ্যে সরাসরি ডেটা সরায়, CPU-কে পুরোপুরি মুক্ত রেখে। আর এটাই এই module-এর শেষ লেসন — শেষে আমরা পুরো ১৭টা লেসনের যাত্রাটা একসাথে দেখব, আর পরের module-এর দিকে তাকাব।

মূল ধারণা

DMA — কাজটা device-এর নিজস্ব controller-কে দেওয়া

মূল ধারণা সহজ: CPU নিজে copy লুপ না চালিয়ে, একটা আলাদা, dedicated hardware — DMA controller — কে কাজটা দিয়ে দেয়। CPU শুধু তিনটা জিনিস “প্রোগ্রাম” করে (registers-এ লিখে): উৎস ঠিকানা (device-এর কোন buffer থেকে), গন্তব্য ঠিকানা (memory-র কোথায় লিখতে হবে), আর দৈর্ঘ্য (কত byte)। তারপর CPU সম্পূর্ণ মুক্ত — অন্য যেকোনো instruction চালাতে পারে, অন্য কোনো প্রোগ্রাম চালাতে পারে, যা খুশি — DMA controller স্বাধীনভাবে ব্যাকগ্রাউন্ডে ডেটা সরাতে থাকে, memory bus ব্যবহার করে সরাসরি, CPU-র মধ্যস্থতা ছাড়াই।

Programmed I/O:

  CPU ─┬─ byte ১ পড়ে, memory-তে লেখে ─┬─ byte ২ পড়ে, memory-তে লেখে ─┬─ ... (১০ কোটি বার)
       │                                │                              │
       └── এই পুরো সময়টায় CPU অন্য কিছুই করতে পারে না ──────────────────┘


DMA:

  CPU ─┬─ DMA controller-কে প্রোগ্রাম করে (src, dst, length) ─┬─ অন্য কাজ করে... ─┬─ interrupt পায়: "শেষ!"
       │  (কয়েকটা instruction, কয়েক cycle)                    │  (স্বাধীনভাবে)     │
       │                                                        │                    │
       │              DMA controller ব্যাকগ্রাউন্ডে ডেটা সরায় ──┘                    │
       │              (device → memory, সরাসরি bus দিয়ে)                            │
       └──────────────────────────────────────────────────────────────────────────────┘
Programmed I/O বনাম DMA — CPU-র ভূমিকার পার্থক্য।

কাজ শেষের সংকেত — আগের লেসনের সরাসরি প্রয়োগ

DMA controller যখন তার কাজ শেষ করে (পুরো length-এর ডেটা সরানো হয়ে গেছে), তাকে CPU-কে জানাতে হবে — নাহলে CPU কখনো জানবে না ডেটা প্রস্তুত। এই সংকেত পাঠানোর প্রক্রিয়াটা ঠিক আগের লেসনের সেই interrupt mechanism — DMA controller নিজেই একটা hardware device, যেটা একটা interrupt তোলে (ঠিক keyboard বা timer যেভাবে তোলে), CPU সেই interrupt-এ সাড়া দিয়ে vector table থেকে DMA completion handler চালায়, আর সেই handler (OS-এর অংশ) waiting process-কে জাগিয়ে দেয় — “তোমার ডেটা এখন memory-তে প্রস্তুত।”

এটা এই module-এর দুটো লেসনকে একসাথে বেঁধে দেয়: DMA সমস্যাটার সমাধান দেয় (CPU-কে data-movement থেকে মুক্ত রাখা), আর interrupt সেই সমাধানকে সম্পূর্ণ করে (CPU-কে জানানো কাজ কখন শেষ হলো, পোলিং — বারবার জিজ্ঞেস করা — ছাড়াই)। দুটো একসাথে না থাকলে সমাধানটা অসম্পূর্ণ থেকে যেত — DMA থাকলেও CPU যদি বারবার “শেষ হয়েছে কি?” জিজ্ঞেস করতে বাধ্য হতো (polling), তাহলে programmed I/O-এর সমস্যাটাই অন্যভাবে ফিরে আসত।

CPU device register কীভাবে “দেখে” — দুইটা ISA-design দর্শন

DMA শুরু করতে CPU-কে device-এর control register-এ লিখতে হয় (উৎস, গন্তব্য, দৈর্ঘ্য জানাতে)। কিন্তু CPU কীভাবে একটা device register address করে? এখানে দুইটা সম্পূর্ণ ভিন্ন ঐতিহাসিক সিদ্ধান্ত আছে — লেসন ২-এর RISC/CISC আলোচনা আর লেসন ৪-এর addressing mode আলোচনার সরাসরি সম্প্রসারণ।

Memory-mapped I/O (MMIO)

Device register-গুলোকে সাধারণ memory address space-এরই একটা অংশ হিসেবে ধরা হয়। CPU-র কাছে device-এর একটা status register পড়া, আর একটা সাধারণ RAM location পড়া — দুটোই দেখতে একই instruction (LOAD/STORE, বা x86-এ MOV) — শুধু address ভিন্ন একটা নির্দিষ্ট, device-এর জন্য সংরক্ষিত range-এ পড়ে।

volatile uint32_t *disk_status = (uint32_t *)0xFE000000;   // একটা device address
uint32_t status = *disk_status;    // সাধারণ memory load, কিন্তু আসলে device register পড়া

Port-mapped I/O (PMIO)

একটা সম্পূর্ণ আলাদা address space — device port-গুলোর নিজস্ব numbering, memory address space থেকে সম্পূর্ণ আলাদা। CPU-তে বিশেষ instruction লাগে এগুলো access করতে — সাধারণ LOAD/STORE কাজ করে না।

; x86 -- IN/OUT instruction, শুধু port I/O-র জন্য
IN  AL, 0x60      ; keyboard controller port থেকে এক byte পড়া
OUT 0x64, AL      ; একটা command port-এ লেখা
Memory-mapped I/OPort-mapped I/O
Address spaceMemory-র সাথে ভাগাভাগিসম্পূর্ণ আলাদা
Instruction লাগেসাধারণ LOAD/STOREবিশেষ (x86-এ IN/OUT)
কম্পাইলার-বান্ধবহ্যাঁ — সাধারণ pointer dereferenceনা — assembly বা intrinsic দরকার
x86-64ব্যবহার করে, বেশিরভাগ আধুনিক device-এLegacy device-এ এখনও (keyboard controller, ইত্যাদি)
ARM64 / RISC-Vএকমাত্র বিকল্পনেই — এই ISA-গুলোতে কোনো port I/O instruction-ই নেই

ভেতরে কী ঘটছে

volatile-এর গুরুত্ব — MMIO-তে একটা লুকানো ফাঁদ

Memory-mapped I/O একটা সূক্ষ্ম কিন্তু গুরুত্বপূর্ণ সমস্যা তৈরি করে। সাধারণ memory-তে, যদি আপনি একই address বারবার পড়েন আর মাঝখানে কিছু না লেখেন, মান বদলাবে না — একটা optimizing compiler তাই ধরে নেয় সে বারবার পড়া বাদ দিয়ে প্রথম পড়া মানটাই পুনর্ব্যবহার করতে পারে (register-এ cache করে রাখে)। এটা normal memory-র জন্য একটা সঠিক, নিরাপদ optimization।

কিন্তু একটা device status register-এর ক্ষেত্রে এই অনুমানটা ভুল — status register-এর মান device নিজেই যেকোনো মুহূর্তে বদলাতে পারে (যেমন “DMA সম্পন্ন” bit hardware সেট করে), CPU-র কোনো লেখা ছাড়াই। যদি compiler এই read optimize করে বাদ দেয়, প্রোগ্রাম কখনো device-এর প্রকৃত পরিবর্তন দেখবে না — একটা অসীম loop যা কখনো “সম্পন্ন” bit দেখতে পাবে না, কারণ compiler ধরেই নিয়েছিল বারবার পড়ার দরকার নেই।

// ভুল -- compiler এই loop-কে অসীম বা এক-বার-পড়া loop-এ optimize করে দিতে পারে
while (status_register != READY) { }   // compiler ভাবতে পারে: "status_register তো বদলায়নি (আমার জানা মতে), এই condition চিরকাল একই থাকবে"

// সঠিক -- volatile compiler-কে বলে "প্রতিবার সত্যিই memory থেকে পড়ো, ধরে নিও না"
volatile uint32_t *status_register = ...;
while (*status_register != READY) { }

volatile keyword কম্পাইলারকে বলে: এই memory location-এর মান প্রোগ্রামের নিয়ন্ত্রণের বাইরে থেকেও বদলাতে পারে, তাই প্রতিটা read/write সত্যিই hardware-এ পাঠাও, optimize করে বাদ দিও না বা পুনর্বিন্যাস করো না। এটা device driver কোডে সর্বত্র অপরিহার্য — একটা ভুলে যাওয়া volatile একটা device driver-কে নীরবে ভুল বা অসীম-loop আচরণে ফেলে দিতে পারে, এমন একটা বাগ যা শুধু -O2-এ দেখা যায়, -O0-এ না (কারণ -O0 এমনিতেই কোনো read optimize করে না) — একটা বিশেষভাবে বিরক্তিকর ধরনের bug।

DMA আর cache coherency — আরেকটা লুকানো জটিলতা

Memory hierarchy লেসনগুলো থেকে মনে করুন — CPU সরাসরি DRAM পড়ে না, সে cache (L1, L2, L3) ব্যবহার করে। যখন CPU একটা memory address পড়ে, সেটা প্রথমে cache-এ খোঁজে — আর যদি cache-এ একটা পুরনো (stale) কপি থাকে, CPU সেটাই পড়বে, DRAM-এর প্রকৃত সাম্প্রতিক মান না।

এখন সমস্যাটা দেখুন: DMA controller memory-তে সরাসরি লেখে — CPU cache-কে পাশ কাটিয়ে, সরাসরি DRAM-এ। যদি CPU-র cache-এ সেই একই address-এর একটা পুরনো কপি বসে থাকে (আগে থেকেই read হয়েছিল), DMA লেখার পরেও CPU cache থেকে পুরনো, ভুল ডেটা পড়তে থাকবে — DRAM-এ নতুন ডেটা থাকা সত্ত্বেও।

১. CPU একটা memory address পড়ে -- ডেটা L2 cache-এ কপি হয়ে যায়
২. DMA controller সেই একই address-এ নতুন ডেটা লেখে -- সরাসরি DRAM-এ,
   cache-কে না জানিয়ে
৩. CPU আবার সেই address পড়ে -- cache hit! পুরনো ডেটা ফেরত দেয়,
   DRAM-এর নতুন ডেটা না -- একটা stale read bug
DMA write যখন cache-এর অজান্তে ঘটে যায়।

আধুনিক সিস্টেমে দুটো সমাধান আছে। প্রথমত, অনেক আধুনিক DMA controller/bus (যেমন ARM-এর AMBA/ACE, আর PCIe-এর কিছু configuration) cache-coherent — অর্থাৎ DMA write সরাসরি cache-এর সাথেও সমন্বিত হয় (একটা hardware-স্তরের mechanism cache-কে invalidate বা update করে দেয় DMA লেখার সাথে সাথেই), যেন CPU-র জন্য সম্পূর্ণ transparent। দ্বিতীয়ত, যেসব সিস্টেমে এই hardware coherency নেই (অনেক embedded/microcontroller সিস্টেমে), OS driver-কে ম্যানুয়ালি cache flush (CPU-র বদলে যাওয়া ডেটা DRAM-এ পাঠানো, DMA read-এর আগে) বা invalidate (পুরনো cache line বাতিল করে দেওয়া, DMA write-এর পরে, যাতে পরের CPU read সরাসরি DRAM থেকে টাটকা ডেটা আনে) করতে হয় — একটা explicit, error-prone ধাপ যা device driver লেখা এত সতর্কতাপূর্ণ কাজ বানায়।

DMA-র কয়েকটা অপারেশন মোড

মোডবর্ণনা
Burst modeDMA controller পুরো transfer একটানা সম্পন্ন করে, memory bus পুরোপুরি দখল করে — দ্রুততম, কিন্তু এই সময় CPU bus ব্যবহার করতে পারে না
Cycle stealingDMA প্রতি কয়েক cycle-এ একটা bus cycle “ধার” নেয় (steal করে), CPU-র সাথে পালা করে bus ব্যবহার করে — ধীর transfer কিন্তু CPU সম্পূর্ণ থামে না
Transparent modeDMA শুধুই তখন transfer করে যখন CPU এমনিতেও bus ব্যবহার করছে না — সবচেয়ে ধীর, কিন্তু CPU-র উপর শূন্য প্রভাব

আর একটা গুরুত্বপূর্ণ ধারণা: bus mastering — আধুনিক PCIe ডিভাইসগুলো নিজেরাই “bus master” হতে পারে, মানে তারা নিজে থেকেই memory bus-এ transaction শুরু করতে পারে (শুধু একটা কেন্দ্রীয় DMA controller-এর নির্দেশে না) — এটা পুরনো, কেন্দ্রীভূত ৮২৩৭-স্টাইল DMA controller থেকে একটা বিবর্তন, যেখানে প্রতিটা device নিজের DMA ক্ষমতা বহন করে, আরও নমনীয় আর দ্রুত।

উদাহরণ

একটা সম্পূর্ণ trace — disk read, শুরু থেকে শেষ

সব ধারণা একসাথে জুড়ে একটা বাস্তব দৃশ্য সম্পূর্ণভাবে ট্রেস করি — একটা প্রোগ্রাম একটা ফাইল থেকে ১ মেগাবাইট পড়তে চায়।

ধাপ ১ — Application read() কল করে। এটা আগের লেসনের সেই trap — একটা syscall instruction চালায়, CPU kernel mode-এ ঢোকে।

ধাপ ২ — Kernel disk driver-কে ডাকে। Driver device-কে প্রোগ্রাম করে — memory-mapped control register-এ লেখে (তিনটা মান: disk-এ কোথা থেকে পড়তে হবে, memory-তে কোথায় লিখতে হবে, কত byte)।

// একটা সরলীকৃত driver code -- সবই memory-mapped register-এ লেখা
volatile uint64_t *dma_src   = (uint64_t*)DEVICE_BASE + 0x00;
volatile uint64_t *dma_dst   = (uint64_t*)DEVICE_BASE + 0x08;
volatile uint32_t *dma_len   = (uint32_t*)DEVICE_BASE + 0x10;
volatile uint32_t *dma_start = (uint32_t*)DEVICE_BASE + 0x14;

*dma_src   = disk_sector_address;
*dma_dst   = physical_memory_buffer;
*dma_len   = 1024 * 1024;             // ১ মেগাবাইট
*dma_start = 1;                        // ট্রিগার -- এখান থেকে DMA controller স্বাধীন

ধাপ ৩ — Kernel process-কে “ঘুম পাড়িয়ে” (sleep/block) দেয়, CPU অন্য কাজে চলে যায়। এটাই DMA-র মূল লাভ — এই process এখন কিছুই করছে না (data অপেক্ষায়), তাই OS scheduler অন্য কোনো process চালায়। CPU সম্পূর্ণ উৎপাদনশীল কাজে ব্যস্ত, ডেটা-অপেক্ষায় “idle spin” করছে না।

ধাপ ৪ — DMA controller ব্যাকগ্রাউন্ডে ডেটা সরায়। স্বাধীনভাবে, memory bus-এ, disk থেকে সরাসরি physical memory buffer-এ, byte-by-byte বা burst-এ — CPU-র কোনো instruction execute না করেই।

ধাপ ৫ — DMA সম্পন্ন, interrupt তোলে। ঠিক আগের লেসনের mechanism — controller একটা interrupt line assert করে, CPU (যেই instruction-ই তখন চালাচ্ছিল তা instruction boundary-তে থামিয়ে) vector table lookup করে DMA-completion handler চালায়।

ধাপ ৬ — Handler process-কে জাগায়। Kernel-এর interrupt handler scheduler-কে জানায় sleeping process-এর ডেটা এখন প্রস্তুত — process আবার “runnable” হয়ে যায়, পরের সুযোগে scheduler তাকে আবার চালাবে।

ধাপ ৭ — Application resume করে, ডেটা ব্যবহার করে। read() call finally return করে, application-এর কাছে এখন সেই ১ মেগাবাইট memory buffer-এ প্রস্তুত।

পুরো সময় জুড়ে CPU যা করেনি: একটাও byte নিজে copy করেনি, একবারও ব্যস্ত-অপেক্ষা (busy-wait/poll) করেনি — শুধু শুরুতে কয়েকটা register লিখেছে (কয়েক cycle), শেষে একটা interrupt handle করেছে (কয়েকশ cycle), আর মাঝখানের পুরো সময়টা (যা লক্ষ লক্ষ cycle হতে পারে, disk latency-র তুলনায়) সম্পূর্ণ অন্য কাজে ব্যয় করেছে।

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

EXPERIMENT

/proc/interrupts দিয়ে বাস্তব DMA-চালিত interrupt observe করুন

Linux· ১৫ মিনিট

Linux kernel প্রতিটা hardware interrupt line-এর জন্য কতবার সেটা fire হয়েছে তার একটা running count রাখে, /proc/interrupts-এ দৃশ্যমান।

# ধাপ ১ -- বেসলাইন observe করুন
cat /proc/interrupts | grep -E "nvme|ahci|eth|wlan"
সাধারণ আউটপুট (disk controller, নেটওয়ার্ক adapter):
  45:   1250340    PCI-MSI  nvme0q0
  67:    892103    PCI-MSI  eth0-TxRx-0
# ধাপ ২ -- একটা বড় ফাইল কপি করুন (disk I/O ট্রিগার করতে)
dd if=/dev/zero of=/tmp/bigfile bs=1M count=2000 conv=fdatasync

# ধাপ ৩ -- আবার count দেখুন
cat /proc/interrupts | grep nvme
আগে:  45:   1250340    PCI-MSI  nvme0q0
পরে:  45:   1250412    PCI-MSI  nvme0q0     ← বেড়েছে!

nvme0q0-এর counter সরাসরি বেড়ে যাওয়া দেখায় প্রতিটা DMA transfer সম্পন্ন হওয়ার পর controller নিয়মিতভাবে interrupt তুলছে — এটাই এই লেসনের কেন্দ্রীয় mechanism-এর প্রত্যক্ষ প্রমাণ।

নেটওয়ার্কের জন্য একই পরীক্ষা:

cat /proc/interrupts | grep eth0
# একটা বড় ফাইল download করুন (curl বড় কোনো ফাইল)
curl -o /dev/null https://example.com/large-file.bin
cat /proc/interrupts | grep eth0    # counter আবার বেড়ে থাকবে
এটা কী প্রমাণ করে

বড় ফাইল I/O বা network transfer করার সময় disk/NIC-এর interrupt counter সত্যিই বাড়ে -- এটা সরাসরি প্রমাণ করে DMA completion signal (interrupt) বাস্তব, পরিমাপযোগ্য hardware ঘটনা, শুধু তাত্ত্বিক বর্ণনা না।

নিজে বানান

BUILD IT

একটা সফটওয়্যার DMA + Interrupt সিমুলেটর

Python (threading) · ●●●○○
  1. একটা CPU thread model করুন যেটা একটা কাজের তালিকা (unrelated computation) sequentially সম্পন্ন করে
  2. একটা DMA thread model করুন যেটা "device"-এর ডেটা "memory"-তে ব্যাকগ্রাউন্ডে কপি করে, একটা কৃত্রিম বিলম্ব দিয়ে
  3. DMA সম্পন্ন হলে একটা event/flag সেট করে CPU thread-কে জানান (interrupt-এর অনুকরণ)
  4. তুলনা করুন programmed I/O (CPU সরাসরি copy করে, ব্লক করে) বনাম DMA (CPU সমান্তরালে কাজ করে) স্টাইলে মোট সময় কতটা আলাদা
  5. দেখান DMA-স্টাইলে CPU-র "useful work" সময় উল্লেখযোগ্যভাবে বেশি

মূল ধারণা: threading দিয়ে DMA-র asynchronous প্রকৃতি অনুকরণ করা — একটা background thread ডেটা “কপি” করছে (একটা কৃত্রিম sleep দিয়ে transfer সময় অনুকরণ করে), মূল thread স্বাধীনভাবে অন্য কাজ চালিয়ে যাচ্ছে, আর একটা threading.Event দিয়ে “interrupt” সংকেত পাঠানো হচ্ছে।

import threading
import time

TRANSFER_TIME = 0.5     # সেকেন্ড -- device transfer কতটা সময় নেয় তার অনুকরণ
COMPUTE_UNITS = 20       # CPU-র "useful work"-এর একক সংখ্যা


def useful_work(units, label):
    """CPU-র প্রকৃত computation -- ডেটা transfer-এর সাথে সম্পূর্ণ সম্পর্কহীন।"""
    done = 0
    for i in range(units):
        time.sleep(0.02)   # একটা computation একক অনুকরণ
        done += 1
    return done


# ── পদ্ধতি ১: Programmed I/O -- CPU নিজেই ডেটা কপি করে, ব্লক হয়ে থাকে ──
def programmed_io_demo():
    print("\n=== Programmed I/O (CPU নিজে copy করে) ===")
    t0 = time.perf_counter()

    print("CPU: ডেটা copy শুরু করছে (নিজে, ব্লক হয়ে)...")
    time.sleep(TRANSFER_TIME)     # CPU পুরোপুরি এখানে আটকে -- অন্য কিছু করতে পারছে না
    print("CPU: copy সম্পন্ন")

    done = useful_work(COMPUTE_UNITS, "compute")
    print(f"CPU: {done} একক useful work সম্পন্ন করেছে")

    total = time.perf_counter() - t0
    print(f"মোট সময়: {total:.2f}s")
    return total


# ── পদ্ধতি ২: DMA -- একটা background thread transfer করে, CPU সমান্তরালে কাজ করে ──
def dma_demo():
    print("\n=== DMA (background thread + interrupt) ===")
    t0 = time.perf_counter()
    dma_done_event = threading.Event()

    def dma_controller_thread():
        """DMA controller-এর ভূমিকায় -- স্বাধীনভাবে ডেটা সরায়।"""
        time.sleep(TRANSFER_TIME)      # প্রকৃত transfer সময়
        print("  [DMA controller] transfer সম্পন্ন -- interrupt তুলছি")
        dma_done_event.set()            # ← এটাই আমাদের "interrupt"

    dma_thread = threading.Thread(target=dma_controller_thread)
    print("CPU: DMA controller প্রোগ্রাম করেছে (src, dst, length), শুরু করছে...")
    dma_thread.start()

    print("CPU: এখন স্বাধীন -- useful work করছে DMA চলাকালীনই...")
    done = useful_work(COMPUTE_UNITS, "compute")
    print(f"CPU: {done} একক useful work সম্পন্ন করেছে (DMA চলাকালীনই!)")

    if not dma_done_event.is_set():
        print("CPU: এখনো DMA শেষ হয়নি -- বাকিটুকু অপেক্ষা করছি")
    dma_done_event.wait()               # interrupt handler-এর মতো -- সংকেতের অপেক্ষা
    print("CPU: DMA-completion interrupt পেয়েছে, ডেটা এখন প্রস্তুত")

    total = time.perf_counter() - t0
    print(f"মোট সময়: {total:.2f}s")
    return total


t_pio = programmed_io_demo()
t_dma = dma_demo()

print(f"\n--- তুলনা ---")
print(f"Programmed I/O: {t_pio:.2f}s (CPU পুরো transfer সময় idle ছিল)")
print(f"DMA:            {t_dma:.2f}s (CPU transfer চলাকালীন useful work করেছে)")

প্রত্যাশিত আউটপুট আকৃতি:

Programmed I/O: 0.90s (CPU পুরো transfer সময় idle ছিল)
DMA:            0.50s (CPU transfer চলাকালীন useful work করেছে)

DMA সংস্করণে মোট সময় কমে যায় কারণ useful_work (০.৪ সেকেন্ড) আর transfer (০.৫ সেকেন্ড) সমান্তরালে ঘটে, sequentially না — programmed I/O-তে এই দুটো সবসময় একের পর এক (০.৫ + ০.৪ = ০.৯ সেকেন্ড)।

নিজে বাড়ান:

  1. একাধিক DMA transfer একসাথে চালিয়ে দেখুন (একাধিক thread) — বাস্তব সিস্টেমে একাধিক device একসাথে DMA করতে পারে
  2. dma_done_event.wait(timeout=...) দিয়ে একটা “timeout” যোগ করুন — device সাড়া না দিলে কী হওয়া উচিত?
  3. Interrupt coalescing অনুকরণ করুন — একাধিক ছোট transfer জমিয়ে রেখে একটা মাত্র “interrupt” পাঠান, ছোট transfer-এ আলাদা আলাদা না পাঠিয়ে
  4. একটা “cache staleness” বাগ ইচ্ছাকৃতভাবে তৈরি করুন — CPU thread transfer-এর আগে একটা মান “cache” করে রাখুক (একটা local variable-এ), DMA থ্রেড সেই buffer বদলে দিক, আর দেখান CPU-র “cached” মান stale হয়ে গেছে — এটাই cache coherency সমস্যার একটা সরলীকৃত রূপক

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

যেখানে DMA প্রতিদিন কাজ করে

NVMe SSD। আধুনিক NVMe drive multiple DMA queue ব্যবহার করে (একাধিক transfer সমান্তরালে পরিচালনা করতে) — প্রতিটা queue নিজস্বভাবে completion interrupt (বা polling, নিচে দেখুন) তোলে। এই ডিজাইনই NVMe-কে পুরনো SATA-র চেয়ে বহুগুণ দ্রুত করে তোলে।

Network card (NIC) — DMA ring buffer আর NAPI। আধুনিক NIC একটা circular “ring” buffer ব্যবহার করে — packet আসা মাত্র সরাসরি DMA দিয়ে memory-তে লেখা হয়, CPU শুধু ring-এর pointer আপডেট পড়ে জানে কতটুকু নতুন ডেটা এসেছে। Linux kernel-এর NAPI (New API) framework উচ্চ-throughput-এ interrupt আর polling-এর একটা hybrid ব্যবহার করে — কম ট্রাফিকে interrupt-driven (দ্রুত সাড়া), বেশি ট্রাফিকে polling-এ স্যুইচ করে (interrupt storm এড়াতে, আগের experiment-এর coalescing ধারণার সম্প্রসারণ)।

GPU-CPU ডেটা transfer। CUDA-র cudaMemcpy বা OpenCL-এর buffer transfer ভেতরে DMA ব্যবহার করে GPU-র নিজস্ব VRAM আর system RAM-এর মধ্যে ডেটা সরাতে — CPU নিজে কোনো byte স্পর্শ করে না। Level 13-এর AI/ML হার্ডওয়্যার আলোচনায় এই transfer-ই প্রায়ই training-এর একটা bottleneck হিসেবে সামনে আসে (compute দ্রুত কিন্তু data movement ধীর)।

সাউন্ড কার্ড অডিও streaming। একটা audio buffer ক্রমাগত DMA দিয়ে CPU-থেকে-স্বাধীনভাবে সাউন্ড কার্ডে পাঠানো হয় — এই কারণেই audio playback smooth থাকে এমনকি CPU অন্য ভারী কাজে ব্যস্ত থাকলেও; শুধু buffer ফুরিয়ে গেলে (underrun) click/pop শোনা যায়, যা “Asymptotic Notation” লেসনের সেই amortised-বনাম-tail-latency আলোচনার একটা বাস্তব প্রতিধ্বনি।

ঐতিহাসিক Intel 8237A। IBM PC-র মূল DMA controller (১৯৮১) — floppy disk transfer-এর জন্য ব্যবহৃত হতো। এটা ছিল একটা কেন্দ্রীভূত, সীমিত (মাত্র ৪টা চ্যানেল, ৬৪ কিলোবাইট address সীমা) controller — আধুনিক PCIe bus mastering-এর তুলনায় অনেক প্রাথমিক, কিন্তু মূল ধারণাটা অভিন্ন।

IOMMU — নিরাপত্তা যোগসূত্র। যেহেতু DMA controller CPU-র সাধারণ memory-protection checks বাইপাস করে সরাসরি physical memory access করে, একটা compromised বা misbehaving device তাত্ত্বিকভাবে যেকোনো memory address-এ লিখতে পারত — একটা বড় নিরাপত্তা ঝুঁকি। আধুনিক সিস্টেমে IOMMU (I/O Memory Management Unit) একটা device-নির্দিষ্ট address translation ও protection স্তর যোগ করে — অনেকটা CPU-র নিজস্ব MMU-র (আগের লেসনের page fault আলোচনায় উল্লেখিত) মতো, কিন্তু device-এর জন্য — নিশ্চিত করে একটা device শুধু তার জন্য বরাদ্দ memory region-ই touch করতে পারে।

PostgreSQL/database-এর disk I/O। একটা database-এর query কতটা দ্রুত চলবে তার একটা বড় অংশ নির্ভর করে কতটা দ্রুত disk থেকে page memory-তে আনা যায় — এই পুরো পথটা DMA-চালিত, আর database engine এই async transfer-এর সুবিধা নিতে নিজস্ব I/O scheduling (prefetching, asynchronous I/O API) ব্যবহার করে।

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

“DMA মানে CPU-র আর কোনো ভূমিকাই থাকে না -- সম্পূর্ণ device-নির্ভর।”

DMA CPU-কে data movement থেকে মুক্ত করে, কিন্তু CPU-র ভূমিকা সম্পূর্ণ শূন্য হয়ে যায় না।

CPU-কেই প্রথমে DMA controller প্রোগ্রাম করতে হয় (source, destination, length লিখে) — এটা কয়েক cycle-এর কাজ, কিন্তু প্রয়োজনীয়। CPU-কেই completion interrupt handle করতে হয় — সেটাও একটা active hardware/software দায়িত্ব। আর যেসব সিস্টেমে hardware cache coherency নেই, CPU driver-কে ম্যানুয়ালি cache flush/invalidate পরিচালনা করতে হয়, যা এই লেসনের “Under the Hood” অংশে দেখানো একটা সূক্ষ্ম কিন্তু বাস্তব দায়িত্ব।

DMA যা কমায় তা হলো CPU-র সক্রিয় participation প্রতিটা byte-এ — একবার শুরু করার পর মাঝের অংশটায় CPU সম্পূর্ণ মুক্ত, কিন্তু শুরু আর শেষে CPU-র ভূমিকা থেকেই যায়।

“Memory-mapped I/O device register-কে সাধারণ RAM-এর মতোই ব্যবহার করা যায় -- কোনো পার্থক্য নেই।”

Address space একই হলেও, semantics সম্পূর্ণ ভিন্ন — এই লেসনের volatile আলোচনাতেই এর প্রথম প্রমাণ দেখা গেছে।

সাধারণ RAM-এ একটা read কোনো side effect তৈরি করে না — বারবার পড়লে একই মান পাওয়া যায়, আর optimize করে read বাদ দেওয়া নিরাপদ। কিন্তু একটা device register পড়া side effect তৈরি করতে পারে — উদাহরণস্বরূপ, অনেক device-এ একটা status register পড়লেই সেই status bit স্বয়ংক্রিয়ভাবে clear (reset) হয়ে যায় (read-to-clear semantics) — যাতে device জানে CPU সেই সংকেতটা “গ্রহণ” করেছে। যদি আপনি ভুলবশত একই register দুইবার পড়েন (ডিবাগিং-এর সময়, বা optimize না হওয়া কোডে), দ্বিতীয় read-এ ভিন্ন (বা খালি) ফলাফল পাবেন — RAM-এ যা কখনো ঘটে না।

এছাড়া write-এর ক্রম গুরুত্বপূর্ণ হতে পারে — device হয়তো আশা করছে প্রথমে address register-এ লিখবেন, তারপর data register-এ; কম্পাইলার যদি সাধারণ optimization নিয়মে এই দুটো write পুনর্বিন্যাস করে (যা সাধারণ RAM-এ নিরাপদ, কারণ কোনো external observer নেই), device ভুল আচরণ করবে। এই কারণেই MMIO কোডে volatile শুধু “বারবার পড়ো” না, বরং ক্রম বজায় রাখারও একটা সংকেত — যদিও পূর্ণাঙ্গ memory-ordering guarantee-র জন্য প্রায়ই আরও শক্তিশালী primitive (memory barrier) লাগে।

“বেশি interrupt মানেই বেশি responsive সিস্টেম -- যত বেশি তত ভালো।”

এই লেসনের experiment-এর Callout-এই এই ভুল ধারণার বিপরীত প্রমাণ দেখানো হয়েছে — অতিরিক্ত ঘন ঘন interrupt আসলে ক্ষতিকর হতে পারে।

প্রতিটা interrupt-এর একটা fixed খরচ আছে (আগের লেসনের সেই state-save, vector lookup, handler-run, resume প্রক্রিয়া) — যদি একটা high-throughput device (যেমন একটা 10 Gbps নেটওয়ার্ক কার্ড, প্রতি সেকেন্ডে লক্ষ লক্ষ packet) প্রতিটা ছোট ঘটনার জন্য আলাদা interrupt তোলে, CPU তার প্রায় পুরো সময় শুধু interrupt handling-এই কাটাতে পারে — প্রকৃত কাজের জন্য কিছুই না বাঁচিয়ে। একে বলে interrupt storm, আর চরম ক্ষেত্রে এটা একটা সিস্টেমকে কার্যত অচল (livelock) করে দিতে পারে — সিস্টেম ব্যস্ত থাকে, কিন্তু কোনো প্রকৃত progress হয় না।

সমাধান হিসেবেই interrupt coalescing (একাধিক ঘটনা জমিয়ে একটা interrupt) আর polling hybrid (Linux NAPI-র মতো, উচ্চ-লোডে polling-এ স্যুইচ) ব্যবহার হয় — সঠিক ব্যালান্স latency (দ্রুত সাড়া) বনাম throughput (কম overhead)-এর মধ্যে, আর এই ব্যালান্স workload অনুযায়ী পরিমাপ করেই ঠিক করতে হয়, কোনো সার্বজনীন “সঠিক” মান নেই।

“Port-mapped আর memory-mapped I/O মৌলিকভাবে ভিন্ন সক্ষমতা দেয় -- একটা দিয়ে যা করা যায় আরেকটা দিয়ে যায় না।”

বাস্তবে দুটোই কার্যত সমান সক্ষম — উভয়ই device register পড়া/লেখার একটা উপায়, শুধু encoding আর address space সংগঠনের পার্থক্য।

যেকোনো port-mapped device functionally একটা memory-mapped device হিসেবেও ডিজাইন করা যেত (আর ARM/RISC-V ঠিক সেটাই করে — port I/O ছাড়াই সব device পরিচালনা করে)। পার্থক্যটা মূলত ঐতিহাসিক আর ISA-design পছন্দের বিষয়, কোনো মৌলিক ক্ষমতার তারতম্য না — এই লেসনের Callout-এ দেখানো হয়েছে কীভাবে এটা RISC/CISC দর্শনের একটা প্রতিফলন মাত্র, functional প্রয়োজনীয়তা না।

যা সত্যিই ভিন্ন তা হলো programming model-এর সুবিধা: memory-mapped I/O সাধারণ pointer/array syntax দিয়ে C কোডে সরাসরি প্রকাশ করা যায় (কম্পাইলার-বান্ধব), যেখানে port I/O-র জন্য বিশেষ assembly বা compiler intrinsic লাগে — এটাই একটা বাস্তব practical পার্থক্য, কিন্তু “কী করা সম্ভব” তার পার্থক্য না, “কতটা সহজে করা যায়” তার পার্থক্য।

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

1

একটা ১০০ মেগাবাইট ফাইল, disk থেকে memory-তে transfer করতে হবে। Programmed I/O-তে প্রতি byte কপি করতে CPU-র ৪টা cycle লাগে (read status, read data, write memory, loop overhead)। DMA-তে CPU শুধু শুরুতে ৫০ cycle (controller প্রোগ্রাম করতে) আর শেষে ২০০ cycle (interrupt handle করতে) খরচ করে। ৩ GHz CPU-তে, দুই পদ্ধতিতে CPU-র প্রকৃত ব্যস্ত-সময় (busy time) কত?

যুক্তি

Programmed I/O:

100 মেগাবাইট=100×1,048,576 byte1.049×108 byte100 \text{ মেগাবাইট} = 100 \times 1{,}048{,}576 \text{ byte} \approx 1.049 \times 10^8 \text{ byte}

প্রতি byte-এ ৪ cycle:

1.049×108×4=4.19×108 cycle1.049 \times 10^8 \times 4 = 4.19 \times 10^8 \text{ cycle}

৩ GHz-এ (3×1093 \times 10^9 cycle/সেকেন্ড):

4.19×1083×1090.14 সেকেন্ড\frac{4.19 \times 10^8}{3 \times 10^9} \approx 0.14 \text{ সেকেন্ড}

CPU পুরো এই ০.১৪ সেকেন্ড ব্যস্ত থাকে, কিছুই অন্য কাজ করতে পারে না।

DMA:

50+200=250 cycle50 + 200 = 250 \text{ cycle}

2503×1090.083 মাইক্রোসেকেন্ড\frac{250}{3 \times 10^9} \approx 0.083 \text{ মাইক্রোসেকেন্ড}

পার্থক্য: DMA-তে CPU-র প্রকৃত busy time programmed I/O-র তুলনায় প্রায় ১৭ লক্ষ গুণ কম (০.১৪ সেকেন্ড বনাম ৮৩ ন্যানোসেকেন্ড)। মাঝের পুরো transfer সময়টায় (যেটা disk latency দিয়ে নির্ধারিত, হয়তো কয়েক মিলিসেকেন্ড, CPU cycle দিয়ে না) CPU সম্পূর্ণ অন্য প্রোগ্রামের জন্য মুক্ত — এই বিশাল পার্থক্যটাই DMA-র পুরো যুক্তির সারমর্ম।

একটা গুরুত্বপূর্ণ সতর্কতা: এই হিসাব শুধু CPU cycle খরচ তুলনা করছে, wall-clock সময় না — programmed I/O-তে total wall-clock সময় disk-এর প্রকৃত transfer গতি দিয়েও সীমাবদ্ধ (CPU হয়তো disk-এর জন্য অপেক্ষা করছে, cycle “খরচ” না করেই — কিন্তু তবু সেই সময়টা ব্যবহার করতে পারছে না অন্য কাজে, যা DMA-র আসল সুবিধা)।

2

একটা device driver-এর কোডে একটা status register volatile ছাড়া ঘোষণা করা হয়েছে:

uint32_t *status = (uint32_t *)0xFE000000;
while (*status != 1) { }   // DMA সম্পন্ন হওয়ার অপেক্ষা

-O0-এ compile করলে এটা ঠিকঠাক কাজ করে, কিন্তু -O2-এ প্রোগ্রাম “আটকে যায়” (hang করে)। কী ঘটছে, আর সমাধান কী?

প্রয়োগ

যা ঘটছে: -O2 optimization-এ compiler লক্ষ্য করে loop-এর ভেতরে status-এ কোনো write নেই (compiler-এর দৃষ্টিতে, এই function-এর মধ্যে কেউ *status বদলাচ্ছে না)। সাধারণ নিয়মে এই অনুমান সঠিক — যদি কোনো memory location-এ write না হয়, তার মান বদলাবে না, তাই বারবার পড়ার কোনো মানে হয় না।

তাই compiler optimize করে দেয়: প্রথমবার *status পড়ে register-এ রাখে, তারপর condition চেক করে — যদি প্রথমবারই 1 না হয়, ধরে নেয় এটা কখনোই 1 হবে না (যেহেতু কেউ বদলাচ্ছে না বলে মনে হচ্ছে), তাই এটাকে কার্যত একটা শর্তহীন অসীম loop-এ পরিণত করে দেয় — প্রতিবার memory পড়ার বদলে।

কিন্তু বাস্তবে *status-এর মান hardware বদলায় (DMA controller, সম্পন্ন হলে) — compiler-এর অজানা একটা source থেকে। Compiler এই সম্ভাবনা জানে না কারণ কিছুই তাকে বলেনি এই memory location “বিশেষ” — ফলাফল: প্রোগ্রাম আসলে কখনো loop থেকে বের হয় না, DMA সত্যিই সম্পন্ন হয়ে গেলেও।

-O0-এ এই সমস্যা দেখা যায় না কারণ -O0 কার্যত কোনো read-elimination optimization করেই না — প্রতিটা *status সত্যিই মেমোরি থেকে আবার পড়া হয়, ভাগ্যক্রমে সঠিক আচরণ দেয়, কিন্তু ভুল কারণে (optimization না থাকার কারণে, সঠিক declaration-এর কারণে না)।

সমাধান:

volatile uint32_t *status = (uint32_t *)0xFE000000;
while (*status != 1) { }

volatile compiler-কে স্পষ্টভাবে বলে দেয়: এই memory location-এর মান প্রোগ্রামের নিজস্ব নিয়ন্ত্রণের বাইরে থেকে বদলাতে পারে, তাই প্রতিটা access সত্যিই hardware-এ পাঠাও, optimize করে বাদ দিও না। এটা একটা ক্লাসিক bug যা শুধু নির্দিষ্ট optimization level-এ প্রকাশ পায় — এই কারণেই device driver code সবসময় সব optimization level-এ পরীক্ষা করা উচিত।

3

একটা সিস্টেমে DMA controller cache-coherent না। একটা প্রোগ্রাম একটা buffer-এ ডেটা লিখেছে (এখন CPU cache-এ আছে, এখনো DRAM-এ flush হয়নি), আর এখন DMA দিয়ে সেই buffer disk-এ পাঠাতে চাইছে (memory থেকে device-এ, “read” direction DMA-র দৃষ্টিতে)। কী ভুল হতে পারে, আর driver-কে কী করতে হবে?

যুক্তি

সমস্যা: DMA controller সরাসরি DRAM থেকে পড়ে, CPU cache থেকে না (এটা cache-coherent না বলা হয়েছে)। প্রোগ্রাম buffer-এ যে নতুন ডেটা লিখেছে, সেটা এখনো শুধু CPU cache-এ আছে — যদি সেই cache line এখনো DRAM-এ write-back হয়ে না থাকে (একটা “dirty” cache line, memory hierarchy লেসনের সেই write-back policy মনে করুন), DRAM-এ তখনও পুরনো ডেটা বসে আছে।

DMA controller যখন এই মুহূর্তে DRAM থেকে পড়ে disk-এ পাঠায়, সে পুরনো, stale ডেটা পাঠাবে — প্রোগ্রামের সাম্প্রতিক লেখা সম্পূর্ণ হারিয়ে যাবে, disk-এ কখনোই না পৌঁছেই।

সমাধান — driver-কে explicit cache flush করতে হবে DMA শুরুর আগে:

write_data_to_buffer(buffer, new_data);
cache_flush(buffer, length);      // dirty cache line-গুলো জোর করে DRAM-এ পাঠানো
start_dma_transfer(buffer, disk_device, length);   // এখন DRAM-এ টাটকা ডেটা, নিরাপদ

cache_flush নিশ্চিত করে DMA শুরুর আগেই সব সাম্প্রতিক CPU write DRAM-এ পৌঁছে গেছে — তারপরই DMA controller নিরাপদে সঠিক, টাটকা ডেটা পড়তে পারবে।

উল্টো দিকের (device → memory) ক্ষেত্রে একটা সমান্তরাল সমস্যা: যদি DMA memory-তে লেখে (device থেকে read direction), আর CPU cache-এ সেই একই address-এর একটা পুরনো কপি বসে থাকে, CPU পরে সেই address পড়লে stale cache copy পড়বে, DMA-র লেখা নতুন DRAM ডেটা না — এক্ষেত্রে driver-কে DMA শেষ হওয়ার পরে সেই cache line invalidate করতে হয় (পুরনো কপি বাতিল, পরের read বাধ্যতামূলকভাবে DRAM থেকে টাটকা ডেটা আনবে)।

সাধারণ নিয়ম: memory → device DMA-র আগে flush; device → memory DMA-র পরে invalidate। এই দুই ধাপ ভুলে যাওয়া (বা ভুল ক্রমে করা) একটা ক্লাসিক, খুঁজে বের করা কঠিন device-driver বাগের উৎস — শুধু নির্দিষ্ট hardware-এ, নির্দিষ্ট timing-এ প্রকাশ পায়, যা এই ধরনের বাগকে বিশেষভাবে বিরক্তিকর করে তোলে।

4

একটা নতুন high-speed network card ডিজাইন করছেন যা প্রতি সেকেন্ডে ১০ লক্ষ ছোট packet গ্রহণ করতে পারে। যদি প্রতিটা packet-এর জন্য আলাদা interrupt তোলেন, কী সমস্যা হবে? দুটো বিকল্প ডিজাইন প্রস্তাব করুন।

ডিজাইন

সমস্যা — এই লেসনের misconception অংশে যা বিস্তারিত আলোচিত হয়েছে তারই একটা বাস্তব ডিজাইন প্রশ্ন:

যদি প্রতি সেকেন্ডে ১০ লক্ষ interrupt আসে, আর প্রতিটা interrupt handle করতে (state save, vector lookup, handler run, resume) ধরুন ৫০০ cycle লাগে, একটা ৩ GHz CPU-তে:

106×500=5×108 cycle প্রতি সেকেন্ডে10^6 \times 500 = 5 \times 10^8 \text{ cycle প্রতি সেকেন্ডে}

5×1083×1090.167 সেকেন্ড, প্রতি সেকেন্ডের\frac{5 \times 10^8}{3 \times 10^9} \approx 0.167 \text{ সেকেন্ড, প্রতি সেকেন্ডের}

অর্থাৎ প্রতি সেকেন্ডের প্রায় ১৭% শুধু interrupt handling-এই চলে যাচ্ছে, packet-এর প্রকৃত প্রসেসিং-এর জন্য কিছুই না গণনা করে। আর এই হিসাব ধরে না নিচ্ছে cache/TLB পুনরায়-গরম-করার (warming) খরচ, যা প্রতিটা interrupt-context-switch-এ বাস্তবে আরও যোগ হয় — বাস্তব প্রভাব আরও বেশি হতে পারে।

বিকল্প ১ — Interrupt coalescing। একাধিক packet একটা buffer-এ জমা হতে দিন (বা একটা টাইমার-ভিত্তিক থ্রেশহোল্ড — যেমন প্রতি ১০০ মাইক্রোসেকেন্ডে একবার, বা প্রতি ৬৪টা packet জমলে একবার), তারপর একটা interrupt তুলুন পুরো ব্যাচের জন্য। এটা interrupt সংখ্যা নাটকীয়ভাবে কমায় (এই উদাহরণে ৬৪-গুণ কমাতে পারে), বিনিময়ে সামান্য latency যোগ করে (একটা packet-কে ব্যাচ পূর্ণ হওয়া বা timer trigger হওয়া পর্যন্ত অপেক্ষা করতে হতে পারে) — একটা throughput-বনাম-latency trade-off, যা tunable রাখা উচিত (উচ্চ-throughput workload-এ বড় batch, latency-sensitive workload-এ ছোট batch)।

বিকল্প ২ — Polling hybrid (Linux NAPI-স্টাইল)। কম ট্রাফিকে স্বাভাবিক interrupt-driven মোডে থাকুন (দ্রুত সাড়া, কম overhead কারণ কম packet)। কিন্তু যখন ট্রাফিক একটা থ্রেশহোল্ডের বেশি হয়ে যায় (interrupt rate বেশি), interrupt বন্ধ করে দিয়ে polling-এ স্যুইচ করুন — একটা dedicated loop (বা kernel thread) নিয়মিত বিরতিতে নিজে থেকেই “নতুন packet এসেছে কি?” চেক করে, কোনো interrupt overhead ছাড়াই। উচ্চ-throughput-এ polling আসলে interrupt-driven-এর চেয়ে দক্ষ — কারণ প্রতিটা individual event-এর জন্য প্রতিবার পুরো interrupt mechanism (context save, vector lookup) চালানোর দরকার হয় না।

বাস্তব সিদ্ধান্ত: দুটোই একসাথে ব্যবহার করা সবচেয়ে সাধারণ বাস্তব সমাধান — কম-থেকে-মাঝারি ট্রাফিকে coalescing দিয়ে interrupt কমানো, খুব বেশি ট্রাফিকে সম্পূর্ণ polling-এ স্যুইচ — ঠিক এভাবেই Linux NAPI বাস্তবে ডিজাইন করা।

5

এই পুরো module-এ (১৭টা লেসন) parallelism-এর তিনটা ভিন্ন “স্তর” নিয়ে কথা হয়েছে: pipelining/hazards, superscalar/out-of-order/renaming, আর SIMD। DMA কি এই তালিকায় parallelism-এর চতুর্থ একটা রূপ? যুক্তি দিন।

যুক্তি

হ্যাঁ — DMA parallelism-এর একটা চতুর্থ, স্বতন্ত্র রূপ, আর এই পার্থক্যটা বোঝা এই module-এর সামগ্রিক ছবিটা সম্পূর্ণ করে।

আগের তিনটা রূপ পুনরায় স্মরণ করি:

  • Pipelining — একই CPU-র ভেতরে, একই instruction stream-এর বিভিন্ন instruction, বিভিন্ন pipeline stage-এ একই সময়ে ভিন্ন ধাপে থাকা (temporal overlap)
  • Superscalar/OoO — একই CPU-র ভেতরে, একাধিক execution unit-এ ভিন্ন ভিন্ন instruction সত্যিই সমান্তরালে চালানো (ILP)
  • SIMD — একই CPU instruction-এর ভেতরে, একই operation বহু data element-এ একসাথে প্রয়োগ করা (data parallelism)

এই তিনটার একটা common থিম: সবগুলোই CPU-র নিজের ভেতরে ঘটে, CPU-র নিজস্ব execution resource ব্যবহার করে।

DMA সম্পূর্ণ ভিন্ন ধরনের parallelism — এটা CPU-র বাইরে, একটা সম্পূর্ণ আলাদা hardware unit (DMA controller) দিয়ে, CPU যা করছে তার সাথে সম্পূর্ণ স্বাধীনভাবে, সমান্তরালে চলে। এটাকে বলা যেতে পারে system-level parallelism বা heterogeneous parallelism — CPU একটা কাজ করছে (computation), আরেকটা সম্পূর্ণ ভিন্ন hardware unit আরেকটা কাজ করছে (data movement), দুটো সত্যিই independent hardware, একে অন্যের অপেক্ষায় না থেকে।

এই পার্থক্যটা গুরুত্বপূর্ণ কারণ এটা দেখায় parallelism একটা single, সমরূপ ধারণা না — এটা বিভিন্ন স্তরে, বিভিন্ন granularity-তে, বিভিন্ন hardware mechanism দিয়ে প্রয়োগ হতে পারে। আধুনিক সিস্টেম চারটাই একসাথে ব্যবহার করে — একটা CPU pipeline-এ, out-of-order-এ, SIMD instruction চালাচ্ছে, একই সময়ে একটা DMA controller ব্যাকগ্রাউন্ডে ডেটা সরাচ্ছে, একই সময়ে হয়তো আরেকটা GPU সম্পূর্ণ ভিন্ন একটা massive-SIMD কাজ করছে — parallelism-এর এই স্তরগুলো একে অপরের প্রতিস্থাপক না, সংযোজক

এরপর কী

পুরো module-টা একসাথে দেখা — CPU Architecture শেষ

সতেরোটা লেসন। থামুন আর পুরো পথটা একবার দেখুন।

শুরু হয়েছিল ISA-কে hardware আর software-এর মধ্যে একটা contract হিসেবে চিনে — একটা compiler কী instruction তৈরি করতে পারবে, আর hardware সেগুলোর ঠিক কী মানে বাস্তবায়ন করবে, এই দুইয়ের মাঝের চুক্তি। তারপর RISC বনাম CISC — x86-64, ARM64, RISC-V-এর দার্শনিক পার্থক্য, যা এই module জুড়েই বারবার ফিরে এসেছে (port I/O-তে, addressing mode-এ, encoding density-তে)। Register file, PC, stack pointer, flags দিয়ে CPU-র ভেতরের “অবস্থা” (state) কী তা জানলাম — যা পরে interrupt/exception-এর “state সংরক্ষণ” আলোচনায় সরাসরি কাজে লাগল। Instruction encoding, addressing mode, আর তারপর সম্পূর্ণ fetch-decode-execute cyclea = b + c ঠিক কীভাবে সিলিকনে রূপান্তরিত হয়, সেই মূল driving question-এর প্রথম সম্পূর্ণ উত্তর।

তারপর গল্পটা memory-র দিকে ঘুরল — memory hierarchy, cache organization (direct-mapped, set-associative), replacement ও write policy — কেন CPU দ্রুত হলেও memory ধীর হলে কিছুই লাভ হয় না। তারপর pipelining — একই datapath-কে ধাপে ভাগ করে throughput বাড়ানো, যার সাথে এলো hazard (structural, data, control) আর তাদের সমাধান forwarding ও stalling, আর branch prediction — অনিশ্চয়তার সাথে বাজি ধরে গতি বজায় রাখা।

শেষ পাঁচটা লেসন (যা আপনি এইমাত্র শেষ করলেন) module-এর সবচেয়ে গভীর অংশ ছিল। Superscalar ও out-of-order execution দেখাল CPU কীভাবে program order ভেঙে, dependency মেনে, দ্রুততর হয়। Register renaming ও reorder buffer সেই out-of-order-কে সঠিক আর নির্ভরযোগ্য করল — false dependency ভেঙে, কিন্তু commit সবসময় প্রোগ্রাম-order-এ রেখে। SIMD দেখাল parallelism-এর সম্পূর্ণ ভিন্ন একটা অক্ষ — একই কাজ বহুগুণ, ভিন্ন কাজ একসাথে না। Interrupt, exception, trap দেখাল CPU কীভাবে তার সুশৃঙ্খল জগতের বাইরের ঘটনার সাথে মোকাবিলা করে, আর কেন সেই মোকাবিলা নির্ভুল হওয়ার জন্য reorder buffer-ই মূল ভিত্তি। আর এই শেষ লেসন, DMA ও I/O, দেখাল CPU কীভাবে data movement-এর বোঝা থেকে নিজেকে মুক্ত রাখে, ঠিক interrupt-এরই সাহায্যে।

একটা লাইনে সংক্ষিপ্ত করলে: একটা বাস্তব, আধুনিক CPU আসলে Level 2-এর সেই সরল single-cycle datapath-ই — শুধু parallelism (pipeline, superscalar, SIMD, DMA), speculation (branch prediction, out-of-order), আর memory-hierarchy কৌশল (cache) দিয়ে বিশাল মাত্রায় স্কেল করা। মূল driving question বদলায়নি — a = b + c লিখলে সিলিকনের ভেতর ঠিক কী ঘটে — শুধু এখন উত্তরটা অনেক বেশি স্তরযুক্ত, অনেক বেশি চতুর।

পরবর্তী module — Assembly Language

এতক্ষণ আমরা CPU কীভাবে কাজ করে তা শিখেছি — তার ভেতরের প্রক্রিয়া, তার কৌশল, তার সীমাবদ্ধতা। কিন্তু একটা প্রশ্ন এখনো সরাসরি উত্তর পায়নি: এই সব জেনে, এখন আমি নিজের চোখে দেখব কীভাবে আমার লেখা কোড ঠিক কোন instruction-এ পরিণত হয়?

পরের module-এর driving question ঠিক এটাই:

আমার C function compile হয়ে ঠিক কোন instruction-গুলো হলো, আর কেন?

Assembly শেখার উদ্দেশ্য assembly-তে software লেখা না। উদ্দেশ্য হলো compiler কী বানায় সেটা পড়তে পারা, debugger-এ কী দেখছি বুঝতে পারা, আর performance/security-র প্রশ্নে নিচে নামতে পারা। এই module শুরু হবে assembler, linker, loader-এর ভূমিকা দিয়ে — সোর্স কোড থেকে চলমান বাইনারি পর্যন্ত পুরো পথ। তারপর সরাসরি হাতে-কলমে x86-64 syntax (AT&T ও Intel দুটো রূপেই) আর ARM64 (AArch64) basics — এই module-এ যে register file, instruction encoding, addressing mode বিমূর্তভাবে শিখেছেন, সেগুলো এখন সরাসরি, নাম ধরে ধরে চোখের সামনে দেখবেন।

সেখানে RAX, RSP, RIP (এই module-এর register/PC আলোচনার হুবহু বাস্তব রূপ) দিয়ে সরাসরি কোড পড়বেন, calling convention আর stack frame দিয়ে function call-এর প্রকৃত প্রক্রিয়া দেখবেন, আর -O0 বনাম -O2 compiler output তুলনা করে দেখবেন optimization আসলে কী বদলায়। এই module-এ যা বিমূর্তভাবে শেখা হলো — ISA, register, encoding, addressing — পরের module-এ তা হাতে-কলমে, সরাসরি পড়া আর লেখা যাওয়া বাস্তবতায় পরিণত হবে।

আরও পড়ুন

  • Computer Organization and Design (RISC-V Edition), Chapter 5 — I/O — Patterson & Hennessy · Programmed I/O থেকে DMA পর্যন্ত I/O ব্যবস্থার প্রামাণ্য পরিচয়
  • Linux Device Drivers, 3rd Edition, Chapter 15 — Corbet, Rubini, Kroah-Hartman · বাস্তব Linux driver-এ DMA বাস্তবায়নের বিস্তারিত
  • PCI Express System Architecture · আধুনিক bus mastering DMA কীভাবে PCIe-তে কাজ করে
  • Intel 8237A Programmable DMA Controller Datasheet · ঐতিহাসিক প্রথম দিকের x86 DMA controller-এর ডিজাইন