এই পর্বে যা শিখবেন
- একটি C লাইন কীভাবে assembly, object file, linked program হয়ে CPU-তে চলে — প্রতিটি ধাপে কী তৈরি হয়
- gcc/clang, objdump আর Compiler Explorer দিয়ে compiler-এর আউটপুট বের করা ও পড়া — x86-64 আর ARM64-এর পড়ার গাইডসহ
- পাঁচটি ছোট C ফাংশন তিন ISA-তে -O0 আর -O2-এ — কী বদলায়, কেন বদলায়
- Constant folding থেকে cmov পর্যন্ত নয়টি optimization listing দেখে চেনা
- Code size-এর তুলনা, undefined behaviour-এর সাথে optimizer-এর সম্পর্ক,
volatileআর inline assembly-র জায়গা
১ · Compiler: C আর ISA-র মাঝখানের সেতু
পর্ব ২৫ থেকে ৩০ পর্যন্ত আপনি দেখেছেন ISA কী, RISC-V-এর instruction কেমন, সেগুলো কীভাবে ৩২ বিটে encode হয়, শাখা-লুপ আর function call assembly-তে কেমন দেখায়। কিন্তু বাস্তবে খুব কম মানুষ সরাসরি assembly লেখেন। লেখেন C, Rust, Go বা অন্য কোনো ভাষায় — আর সেই লেখাকে ISA-র ভাষায় অনুবাদ করে compiler. এই পর্বে মডিউল ৫ শেষ করছি সেই অনুবাদটি খুলে দেখে: আসল compiler আসলে কী লেখে, আর কেন।
একটি রেস্তোরাঁর কথা ভাবুন। অতিথি মেনু দেখে বলেন “এক প্লেট কাচ্চি” — এটি C কোড, কী চাই তার বর্ণনা। রান্নাঘরের প্রধান বাবুর্চি সেটিকে ভেঙে দেন রান্নাঘরের নির্দিষ্ট কাজে: কোন চুলা, কোন হাঁড়ি, কত মিনিট — এটি assembly. একই অর্ডার দুটি আলাদা রান্নাঘরে (একটিতে গ্যাসের চুলা, আরেকটিতে মাটির চুলা) আলাদা ধাপে রান্না হয়, কিন্তু প্লেটে আসে একই কাচ্চি। ঠিক তেমনি একই C কোড RISC-V, x86-64 আর ARM64-এ আলাদা instruction পায়, ফল একই থাকে। আর অভিজ্ঞ বাবুর্চি (-O2) নতুন বাবুর্চির (-O0) চেয়ে কম ধাপে, কম হাঁটাহাঁটিতে একই রান্না সারেন।
C course-এর টুলচেইন পর্বে চার ধাপের কারখানা দেখেছেন। এখানে আমরা প্রতিটি ধাপ দেখব আর্কিটেকচারের চোখে — কোন ধাপের আউটপুট কোন ISA-র ওপর নির্ভর করে:
| ধাপ | যে টুল | যা তৈরি হয় | ISA কোথায়? |
|---|---|---|---|
| Preprocess | preprocessor (cpp, gcc -E) | .i — #include আর macro বসানো C | প্রায় নেই; শুধু __x86_64__, __riscv-এর মতো macro ঠিক হয় |
| Compile | compiler-এর মূল অংশ (cc1, clang) | .s — assembly লেখা | এখানেই প্রথম: register, instruction, calling convention বাছাই |
| Assemble | assembler (as) | .o — object file: machine code + symbol table + relocation-এর তালিকা | পর্ব ২৭-এর encoding: প্রতিটি instruction বিটে |
| Link | linker (ld) | executable (ELF, PE বা Mach-O) | relocation পূরণ: ISA-নির্দিষ্ট নিয়মে ঠিকানা বসানো |
| Load | OS-এর loader (Linux-এ execve + dynamic linker) | মেমরিতে প্রসেস, PC = entry point | CPU সরাসরি instruction fetch শুরু করে |
নিচের stepper-এ একটিমাত্র C লাইন — int r = add(40, 2); — পুরো পথ পাড়ি দিচ্ছে। add আরেকটি ফাইলে লেখা, তাই linker-কে দরকার হবে। Listing-গুলো clang (LLVM trunk) -O2 --target=riscv32 -march=rv32im দিয়ে সত্যিই তৈরি করে যাচাই করা; শুধু link-এর পরের ঠিকানাগুলো (ধাপ ৫) হাতে হিসাব করা।
- C
main.cঅর্ডার - Preprocess
main.iউপকরণ সাজানো - Compile
main.sISA এখানে - Assemble
main.oবিট + ফাঁকা - Link
progঠিকানা বসানো - Load + চালানো
a0 = 84CPU
int add(int a, int b); /* শুধু ঘোষণা */ int main(void) { int r = add(40, 2); return r * 2; }
# 1 "main.c"
int add(int a, int b);
int main(void) {
int r = add(40, 2);
return r * 2;
}
main:
addi sp, sp, -16
sw ra, 12(sp) # ফেরার ঠিকানা বাঁচানো
li a0, 40 # ১ম argument
li a1, 2 # ২য় argument
call add
slli a0, a0, 1 # r * 2 → বাঁয়ে ১ shift
lw ra, 12(sp)
addi sp, sp, 16
ret
8: 02800513 li a0, 40 c: 00200593 li a1, 2 10: 00000097 auipc ra, 0 R_RISCV_CALL_PLT add 14: 000080e7 jalr ra, 0(ra) 18: 00151513 slli a0, a0, 1 add কোথায় জানা নেই → offset 0, সাথে একটি relocation-নোট
10010: 00000097 auipc ra, 0 # ra = 0x10010 10014: 018080e7 jalr ra, 24(ra) # 0x10010 + 24 = 0x10028 10018: 00151513 slli a0, a0, 1 … 10028: 00a58533 add a0, a1, a0 # add() — add.o থেকে 1002c: 00008067 ret
li a0, 40 a0 = 40 li a1, 2 a1 = 2 jalr PC = 0x10028, ra = 0x10018 add a0 = 2 + 40 = 42 ret PC = ra = 0x10018 slli a0 = 42 << 1 = 84 # main ফেরত দেয় 84
- C — কী চাই।
mainজানে শুধুadd-এর ঘোষণা (prototype): দুটিintনেয়, একটিintদেয়। কোথায় আছে, কত বাইট — কিছুই জানে না। এখানে কোনো ISA নেই; এই ফাইল যেকোনো CPU-র জন্য compile করা যায়। - Preprocess — উপকরণ সাজানো। এই ফাইলে
#includeবা macro নেই, তাইmain.iপ্রায় অপরিবর্তিত; শুধু comment মুছে যায় আর# 1 "main.c"ধরনের লাইন-চিহ্ন বসে, যাতে পরে error-এ সঠিক লাইন নম্বর দেখানো যায়। - Compile — ISA প্রথম দেখা দিল। এখন সিদ্ধান্তগুলো RISC-V-নির্দিষ্ট: argument যায়
a0,a1-এ (পর্ব ৩০-এর calling convention), ফল আসেa0-তে,raবাঁচাতে stack-এ ১৬ বাইট। আরr * 2গুণ নয়,slli a0, a0, 1— এটাই strength reduction.callআসলে একটি pseudo-instruction, assembler একে দুটি আসল instruction-এ ভাঙবে। - Assemble — বিট, কিন্তু একটি ফাঁকা। প্রতিটি লাইন ৩২-বিট শব্দে encode হলো, যেমন
li a0, 40=addi a0, zero, 40=0x02800513. কিন্তুaddকোথায় তা assembler জানে না, তাইauipc ra, 0আরjalr ra, 0(ra)-এ offset 0 বসিয়ে একটি relocation-নোট রাখল: “এখানে add-এর PC-relative দূরত্ব বসাতে হবে”। x86-64-এ ঠিক একই জিনিস দেখতেe8 00 00 00 00আরR_X86_64_PLT32 add-0x4. - Link — ঠিকানা বসানো। ধরা যাক linker
main-কে 0x10000-এ আরadd-কে ঠিক পরে 0x10028-এ বসাল (main-এর আকার 0x28 বাইট)। দূরত্ব = 0x10028 − 0x10010 = 0x18 = 24.auipc-এর ওপরের ২০ বিট 0-ই থাকে,jalr-এর ১২-বিট immediate-এ 24 বসে:0x000080e7থেকে0x018080e7. (বাস্তব RISC-V linker দূরত্ব ছোট দেখলে linker relaxation করে জোড়াটিকে একটিমাত্রjal-এ ছোট করতে পারে।) - Load আর চালানো। OS-এর loader (বা microcontroller-এ bootloader) ফাইলটি মেমরিতে এনে PC-তে entry point বসায়। এরপর আর কোনো সফটওয়্যার অনুবাদক নেই — CPU পর্ব ১-এর fetch-decode-execute চক্রে চালায়:
a0= 42, ফেরার পর shift করে 84. Linking আর loading-এর OS-দিক (dynamic linking, PLT/GOT, ASLR) পাবেন OS course-এর মেমরির ভিত্তি পর্বে আর প্রসেস খোলার দিক fork-exec পর্বে।
Preprocess পর্যন্ত কোড প্রায় ISA-মুক্ত; compile ধাপেই ISA ঢোকে — register, instruction আর ABI বাছাই। Assembler সেটিকে বিটে রূপ দেয়, linker ISA-র নিয়মে ঠিকানার ফাঁকা পূরণ করে। তাই একই main.c থেকে তিন ISA-র জন্য তিনটি আলাদা .s, .o আর executable হয় — একটির executable আরেকটিতে চলে না।
২ · Compiler-এর লেখা দেখার তিন উপায়
Compiler কী লিখল, তা দেখার জন্য তিনটি টুল জানলেই যথেষ্ট। প্রতিটি আলাদা প্রশ্নের উত্তর দেয়।
| যা জানতে চান | কমান্ড | মন্তব্য |
|---|---|---|
| Compiler কী assembly লিখল | gcc -O2 -S f.c → f.s | ডিফল্ট AT&T লেখা; -masm=intel দিলে Intel লেখা; -fverbose-asm দিলে C ভ্যারিয়েবলের নাম comment-এ |
| অন্য ISA-র জন্য | riscv64-linux-gnu-gcc -S, aarch64-linux-gnu-gcc -S, অথবা clang --target=riscv32 -march=rv32im -S | Clang একটি binary দিয়েই বহু ISA-র জন্য assembly লিখতে পারে (cross-compile) |
| Object/executable-এর ভেতরে আসল বাইট | objdump -d prog (-M intel, -r দিলে relocation) | বাঁয়ে ঠিকানা, মাঝে hex বাইট, ডানে instruction — উপরের stepper-এর ধাপ ৪-এ দেখেছেন |
| C আর তিন ISA পাশাপাশি, লাইনে লাইনে রং মিলিয়ে | godbolt.org (Compiler Explorer) | শত শত compiler version, x86-64/ARM64/RISC-V সব; ব্রাউজারেই চলে |
Compiler Explorer এই পর্বের সবচেয়ে কাজের টুল। Matt Godbolt ২০১২ সালের দিকে এটি বানাতে শুরু করেন, এখন পৃথিবীজুড়ে compiler-প্রকৌশলীরাও এটি দিয়ে bug রিপোর্ট করেন। ব্যবহার: বাঁয়ে C কোড, ডানে একটি compiler বেছে নিন (যেমন “x86-64 gcc”, “ARM64 gcc”, “RISC-V (32-bits) gcc”), option-এ -O2 লিখুন। আরও দুটি panel খুলে তিন ISA পাশাপাশি রাখা যায়; একই রঙের লাইন মানে একই C লাইন থেকে এসেছে। এই পাতার runnable কোড Compiler Explorer-এ চলে না — সেটি আলাদা ওয়েবসাইট — কিন্তু এই পর্বের প্রতিটি listing সেখানে বসিয়ে নিজে মিলিয়ে নিতে পারেন।
একটি সতর্কতা পুরো পর্বের জন্য: compiler-এর আউটপুট version, flag আর OS-ভেদে বদলায়। এই পর্বের প্রতিটি listing “উদাহরণ” — Wandbox-এ ২০২৬ সালের অক্টোবরে থাকা gcc 17 (experimental) আর clang (LLVM trunk) দিয়ে তৈরি করে directive আর comment বাদ দিয়ে সংক্ষেপ করা। আপনার compiler-এ register-এর নাম, ক্রম বা দু-একটি instruction আলাদা হলে অবাক হবেন না; দেখার বিষয় ধরনটা।
এবার উল্টো দিক থেকে দেখা যাক: compile হয়ে যাওয়া একটি ফাংশনের বাইটগুলোই প্রোগ্রামের ভেতর থেকে পড়ি। নিচের প্রোগ্রামে একই add দুবার লেখা — একটি পুরো ফাইলের মতো -O0-এ, আরেকটি GCC-র optimize("O2") attribute দিয়ে -O2-এ। Function pointer-কে unsigned char *-এ cast করে বাইট ছাপা হচ্ছে:
#include <stdio.h>
/* Same C body, two optimisation levels in one program (GCC function attribute) */
__attribute__((noinline)) int add_O0(int a, int b) { return a + b; }
__attribute__((noinline, optimize("O2"))) int add_O2(int a, int b) { return a + b; }
/* Print the machine-code bytes of a function, stopping at the first 0xc3 (x86 ret).
Reading code through a data pointer is implementation-defined: it works with
GCC on this x86-64 Linux setup, but is not portable C. */
static void dump(const char *name, int (*f)(int, int)) {
const unsigned char *p = (const unsigned char *)f;
printf("%s:", name);
for (int i = 0; i < 32; i++) {
printf(" %02x", p[i]);
if (p[i] == 0xc3) { printf(" <- %d bytes", i + 1); break; }
}
printf("\n");
}
int main(void) {
printf("add_O0(40, 2) = %d, add_O2(40, 2) = %d\n", add_O0(40, 2), add_O2(40, 2));
dump("add_O0", add_O0);
dump("add_O2", add_O2);
return 0;
}যাচাইয়ের সময় আসল আউটপুট ছিল:
add_O0(40, 2) = 42, add_O2(40, 2) = 42
add_O0: 55 48 89 e5 89 7d fc 89 75 f8 8b 55 fc 8b 45 f8 01 d0 5d c3 <- 20 bytes
add_O2: 8d 04 37 c3 <- 4 bytes
দুটির ফল একই (42), কিন্তু একটি ২০ বাইট, আরেকটি ৪ বাইট। -O2-এর বাইট পড়া যাক: 8d 04 37 = lea eax, [rdi+rsi] (8d হলো LEA-এর opcode, 04 আর 37 বলে “ফল eax-এ, ঠিকানা = rdi + rsi×1”), আর c3 = ret. অর্থাৎ যোগটি করা হলো ঠিকানা-হিসাবের circuit দিয়ে, কোনো মেমরি না ছুঁয়ে। -O0-এর বাইট: 55 push rbp · 48 89 e5 mov rbp, rsp · 89 7d fc আর 89 75 f8 — দুই argument stack-এ লেখা · 8b 55 fc আর 8b 45 f8 — আবার stack থেকে পড়া · 01 d0 add eax, edx · 5d pop rbp · c3 ret. লক্ষ করুন x86-এর instruction ১, ২ বা ৩ বাইটের — দৈর্ঘ্য একরকম নয়। Function pointer থেকে data pointer-এ cast করা ISO C-তে নির্দিষ্ট নয়, এখানে শুধু এই x86-64 Linux সেটআপে চলে; আর runner-এর compiler বদলালে বাইটও বদলাতে পারে।
৩ · x86-64 আর ARM64 পড়ার গাইড
RISC-V আপনি পর্ব ২৬–৩০-এ শিখেছেন। এবার বাকি দুটি ISA পড়ার মতো যতটুকু দরকার, শুধু ততটুকু। x86-64-এর register, flag আর stack frame-এর বিস্তারিত পরিচয় আছে C course-এর C আর assembly পর্বে; এখানে তুলনার দিকটাই মূল।
x86-64: একই register-এর চার নাম
x86 ১৬-বিট 8086 (১৯৭৮) থেকে ৩২-বিট হয়ে ৬৪-বিটে বড় হয়েছে, আর প্রতিটি ধাপে পুরোনো নামগুলো রয়ে গেছে। তাই একটি register-এর বিভিন্ন অংশের আলাদা নাম:
| ৬৪ বিট | নিচের ৩২ | নিচের ১৬ | নিচের ৮ | System V ABI-তে কাজ |
|---|---|---|---|---|
rax | eax | ax | al | return value |
rdi, rsi, rdx, rcx | edi, esi, edx, ecx | di… | dil… | ১ম–৪র্থ argument (৫ম–৬ষ্ঠ: r8, r9) |
r8–r15 | r8d–r15d | r8w… | r8b… | সাধারণ কাজ |
rsp, rbp | esp, ebp | — | — | stack pointer, frame pointer |
একটি গুরুত্বপূর্ণ নিয়ম: ৩২-বিট নামে লিখলে (যেমন mov eax, 5) CPU ওপরের ৩২ বিট শূন্য করে দেয় (zero extension), কিন্তু ১৬ বা ৮ বিটে লিখলে ওপরের বাকি বিট অপরিবর্তিত থাকে। এজন্যই C-র int নিয়ে কাজ করা কোডে আপনি বেশিরভাগ সময় eax, edi দেখবেন, আর pointer নিয়ে rdi, rax.
AT&T বনাম Intel লেখা
একই x86 instruction দুই ভাবে লেখা হয়। AT&T syntax GNU টুল (gcc, objdump)-এর ডিফল্ট; Intel syntax Intel-এর manual, Windows-এর টুল আর এই পর্বের listing-এ। মূল পার্থক্য operand-এর ক্রম:
| বিষয় | Intel (-masm=intel) | AT&T (ডিফল্ট) |
|---|---|---|
| ক্রম | mov eax, edi — গন্তব্য আগে | movl %edi, %eax — উৎস আগে |
| Register ও ধ্রুবক | eax, 5 | %eax, $5 |
| আকার | DWORD PTR [rdi+4] | নামের শেষে অক্ষর: b ৮, w ১৬, l ৩২, q ৬৪ বিট |
| মেমরি | [rdi+rsi*4+8] | 8(%rdi,%rsi,4) |
সবচেয়ে বেশি দেখা x86-64 instruction: mov (কপি; মেমরি থেকে পড়া বা লেখাও এটিই — x86-এ আলাদা load/store নেই), lea (ঠিকানা হিসাব, মেমরি না ছুঁয়ে — গুণ-যোগের কৌশল), add/sub/imul (প্রায়ই একটি operand সরাসরি মেমরি থেকে — CISC-এর চিহ্ন), cmp/test (শুধু flag বসায়), jcc (je, jne, jl, jg, ja… flag দেখে লাফ), call/ret (ফেরার ঠিকানা stack-এ রাখে আর তোলে — RISC-V-এর মতো ra register নয়), push/pop. বিরল কিন্তু চিনে রাখার মতো: xor eax, eax = “eax = 0” (ছোট instruction), cdqe/movsx = sign extension, cmovcc = শর্তসাপেক্ষ কপি।
ARM64 (AArch64): RISC-V-এর কাছাকাছি, কিন্তু flag আছে
ARM64-এ ৩১টি সাধারণ register: ৬৪-বিট নামে x0–x30, তাদের নিচের ৩২ বিট w0–w30. Argument x0–x7-এ, ফল x0-তে, ফেরার ঠিকানা x30 (link register)-এ, x29 frame pointer. xzr/wzr সবসময় শূন্য — RISC-V-এর x0-এর মতো। সব instruction ৪ বাইট, মেমরি ছোঁয় শুধু load/store — RISC-এর মূল নিয়ম। কিন্তু RISC-V-এর সাথে পার্থক্য: ARM64-এ status flag (NZCV) আছে।
| কাজ | RISC-V | ARM64 | x86-64 (Intel) |
|---|---|---|---|
| যোগ | add a0, a1, a0 | add w0, w1, w0 | add eax, edx (২ operand) |
| মেমরি থেকে পড়া | lw a0, 4(a1) | ldr w0, [x1, #4] | mov eax, DWORD PTR [rsi+4] |
| মেমরিতে লেখা | sw a0, 4(a1) | str w0, [x1, #4] | mov DWORD PTR [rsi+4], eax |
| তুলনা করে লাফ | blt a0, a1, L (এক instruction) | cmp w0, w1 → b.lt L | cmp eax, edx → jl L |
| Function call / ফেরা | call f (auipc + jalr, বা jal ra, f) / ret | bl f / ret | call f / ret (stack-এ) |
| শর্তসাপেক্ষ বাছাই | মূল ISA-তে নেই (সাধারণত শাখা দিয়ে করা হয়) | csel w0, w0, w1, gt | cmovge eax, esi |
ARM64-এর কয়েকটি বৈশিষ্ট্য listing-এ প্রায়ই দেখবেন: ldp/stp একসাথে দুটি register পড়ে বা লেখে; [x8], #4 মানে “x8 থেকে পড়া, তারপর x8-এ ৪ যোগ” (post-index addressing — পর্ব ২৮-এর addressing mode-এর ARM রূপ); add w0, w0, w10, lsr #31-এর মতো একটি operand-কে আগে shift করে নেওয়া; আর madd = গুণ করে যোগ, এক instruction-এ।
৪ · পাঁচটি ফাংশন, তিন ISA, দুই optimization স্তর
এবার মূল পরীক্ষা। পাঁচটি ছোট C ফাংশন — যোগ, array-র যোগফল, switch, recursion আর struct — compile করা হয়েছে তিন ISA-তে, optimization স্তর -O0 আর -O2-এ। x86-64-এর listing gcc 17 (-masm=intel), RISC-V (rv32im) আর ARM64-এর listing clang (LLVM trunk) — সবই Wandbox-এ তৈরি, directive বাদ দিয়ে।
int add(int a, int b) { return a + b; }
int sum(const int *a, int n) {
int s = 0;
for (int i = 0; i < n; i++)
s += a[i];
return s;
}
int fee(int k) { /* k = 0..4 → নির্দিষ্ট ফি, নইলে 0 */
switch (k) {
case 0: return 10; case 1: return 25; case 2: return 40;
case 3: return 70; case 4: return 95; default: return 0;
}
}
int fact(int n) { if (n <= 1) return 1; return n * fact(n - 1); }
struct item { char tag; int qty; int price; };
int total(const struct item *p) { return p->qty * p->price; }(ক) add — -O0-এ stack-এর ঘুরপথ
# RISC-V rv32im, clang -O0 (13টি) # ARM64, clang -O0 (8টি)
add: add:
addi sp, sp, -16 sub sp, sp, #16
sw ra, 12(sp) str w0, [sp, #12]
sw s0, 8(sp) str w1, [sp, #8]
addi s0, sp, 16 ldr w8, [sp, #12]
sw a0, 4(sp) # a → stack ldr w9, [sp, #8]
sw a1, 0(sp) # b → stack add w0, w8, w9
lw a0, 4(sp) # আবার পড়া add sp, sp, #16
lw a1, 0(sp) ret
add a0, a0, a1
lw ra, 12(sp) # x86-64, gcc -O0 (9টি)
lw s0, 8(sp) add:
addi sp, sp, 16 push rbp
ret mov rbp, rsp
mov DWORD PTR [rbp-4], edi
mov DWORD PTR [rbp-8], esi
mov edx, DWORD PTR [rbp-4]
mov eax, DWORD PTR [rbp-8]
add eax, edx
pop rbp
ret# RISC-V rv32im, clang -O2
add:
add a0, a1, a0
ret
# ARM64, clang -O2
add:
add w0, w1, w0
ret
# x86-64, gcc -O2
add:
lea eax, [rdi+rsi] # x86-এর add দুই operand-এর; lea দুটি রেখে তৃতীয়টিতে লেখে
ret-O0-এ তিন ISA-ই একই কাজ করছে: argument register থেকে stack-এ লেখা, তারপর সঙ্গে সঙ্গে আবার পড়া। এটা বোকামি মনে হলেও উদ্দেশ্যমূলক — debugger-এ print a লিখলে মানটি stack-এর একটি নির্দিষ্ট ঘরে খুঁজে পাওয়া যায়, আর প্রতিটি C লাইনের জন্য আলাদা instruction থাকে বলে breakpoint সঠিক লাইনে থামে। -O2-এ তিনটিই দুই instruction. x86-এর add দুই operand-এর (add eax, edx মানে eax = eax + edx), তাই ফল তৃতীয় register-এ চাইলে compiler lea ব্যবহার করে — RISC-V আর ARM64-এর তিন-operand add-এর কাজটি।
(খ) sum — লুপ থেকে কী কী হারায়
নিচের stepper-এ একই লুপ RISC-V-তে -O0 থেকে -O2. Instruction-গুলো হুবহু clang-এর আউটপুট (comment আমাদের)।
- -O0 শুরু
stack-এ ৪টিa, n, s, i - -O0 লুপ
১৭টি/ঘুর৭ load, ২ store - -O2 pointer
i উধাওশেষ-ঠিকানা - -O2 লুপ
৪টি/ঘুর১ load - হিসাব
17n+18 → 4n+6n = 100
sum:
addi sp, sp, -32
sw ra, 28(sp)
sw s0, 24(sp)
addi s0, sp, 32
sw a0, 20(sp) # a → sp+20
sw a1, 16(sp) # n → sp+16
li a0, 0
sw a0, 12(sp) # s = 0 → sp+12
sw a0, 8(sp) # i = 0 → sp+8
j .LBB1_1
.LBB1_1: lw a0, 8(sp) # i lw a1, 16(sp) # n bge a0, a1, .LBB1_4 j .LBB1_2 .LBB1_2: lw a0, 20(sp) # a lw a1, 8(sp) # i আবার slli a1, a1, 2 # i * 4 add a0, a0, a1 # &a[i] lw a1, 0(a0) # a[i] lw a0, 12(sp) # s add a0, a0, a1 sw a0, 12(sp) # s ফেরত j .LBB1_3 .LBB1_3: lw a0, 8(sp) addi a0, a0, 1 sw a0, 8(sp) # i++ j .LBB1_1
sum:
blez a1, .LBB1_4 # n ≤ 0 হলে সরাসরি 0
mv a2, a0 # a2 = p = a
li a0, 0 # s থাকবে a0-তে
slli a1, a1, 2
add a1, a2, a1 # a1 = end = a + 4n
.LBB1_2:
lw a3, 0(a2) # *p
addi a2, a2, 4 # p++ (i*4-এর গুণ নেই)
add a0, a3, a0 # s += *p, register-এ
bne a2, a1, .LBB1_2 # p ≠ end ?
ret
-O0: 10 (শুরু) + 17 × n + 3 (শেষ পরীক্ষা) + 5 (ফেরা) = 17n + 18 = 1718 -O2: 5 (শুরু) + 4 × n + 1 (ret) = 4n + 6 = 406 অনুপাত ≈ 4.2 ; লুপে মেমরি-access: 9n বনাম n
- -O0: চারটি ভ্যারিয়েবল, চারটি stack-ঘর। Argument
aআরnregister-এ এসেছিল, তবু সঙ্গে সঙ্গে stack-এ লেখা হলো;sআরi-ও stack-এ। প্রতিটি C ভ্যারিয়েবলের একটি স্থায়ী মেমরি-ঠিকানা — debugger-এর জন্য আদর্শ। - -O0 লুপ: ১৭টি instruction, যার ৯টি মেমরি ছোঁয়। শর্ত পরীক্ষায়
iআরnপড়া, দেহেa,i,a[i],sপড়া আরsলেখা, বৃদ্ধিতেiপড়া-লেখা — মোট ৭টি load আর ২টি store, প্রতি ঘুরে। সাথে তিনটি অপ্রয়োজনীয়j: -O0 প্রতিটি C অংশকে আলাদা block রাখে। - -O2:
iনিজেই উধাও। Compiler দেখলiশুধু ঠিকানাa + 4iবানাতে লাগে। তাই একটি pointera2রাখল যা প্রতি ঘুরে ৪ বাড়বে, আর লুপের শেষ চিনবে শেষ-ঠিকানাa1 = a + 4nদিয়ে। একে বলে induction variable elimination;i * 4গুণের বদলে+ 4হলো strength reduction. - -O2 লুপ: ৪টি instruction, ১টি load.
sথাকেa0-তে (যেটি শেষে ফেরত দেওয়ার register-ও), তাই লুপে store নেই। মেমরি থেকে আসে শুধু যা আসতেই হবে —a[i]. এটাই register allocation-এর ফল: compiler ঠিক করল কোন ভ্যারিয়েবল কোন register-এ থাকবে। - গুনে দেখা। n = ১০০ হলে -O0 চালায় ১৭ × ১০০ + ১৮ = ১৭১৮টি instruction, -O2 চালায় ৪ × ১০০ + ৬ = ৪০৬টি — প্রায় ৪.২ গুণ কম; লুপের মেমরি-access ৯০০ থেকে ১০০. পর্ব ৩-এর CPU time = IC × CPI × T সূত্রে IC কমল, আর কম load মানে CPI-ও সাধারণত কমে।
একই -O2 লুপ বাকি দুই ISA-তে প্রায় হুবহু একই আকৃতির:
# x86-64, gcc -O2 # ARM64, clang -O2 -fno-vectorize
.L5: .LBB1_2:
add eax, DWORD PTR [rdi] # load+add ldr w10, [x8], #4 # load, তারপর x8 += 4
add rdi, 4 subs x9, x9, #1 # বাকি গোনা
cmp rdi, rdx add w0, w10, w0
jne .L5 b.ne .LBB1_2x86-এ load আর যোগ এক instruction-এ (add eax, [rdi]) — CISC-এর মেমরি-operand. ARM64-এ post-index load একসাথে pointer-ও বাড়ায়, আর লুপ গোনে নিচের দিকে একটি counter দিয়ে। তবে সাবধান: clang ARM64-এ সাধারণ -O2-তে (-fno-vectorize ছাড়া) এই ফাংশন প্রায় ষাট instruction-এর হয়ে যায় — ldp q4, q5 দিয়ে একবারে ৮টি int পড়ে আর add v0.4s দিয়ে চারটি করে যোগ করে। একে বলে vectorization, যার hardware-দিক আসবে পর্ব ৪২-এ (SIMD). GCC ১২ থেকে -O2-তে শুধু খুব সস্তা ক্ষেত্রে vectorize করে, তাই এখানে gcc-র x86 লুপ সাধারণই রইল; -O3-তে gcc-ও paddd দিয়ে vectorize করল।
(গ) switch — তুলনার শিকল থেকে টেবিল
# x86-64, gcc -O0 (সংক্ষেপে): একটার পর একটা তুলনা
cmp DWORD PTR [rbp-4], 4
je .L8
cmp DWORD PTR [rbp-4], 4
jg .L9
cmp DWORD PTR [rbp-4], 3
... # মোট ৩৩টি instruction
# RISC-V, clang -O2: সীমা পরীক্ষা, তারপর বাইট-টেবিল থেকে পড়া
fee:
li a1, 4
bltu a1, a0, .LBB2_2 # unsigned তুলনা: k < 0 বা k > 4 দুটোই ধরে
lui a1, %hi(.Lswitch.table.fee)
addi a1, a1, %lo(.Lswitch.table.fee)
add a0, a1, a0
lbu a0, 0(a0) # টেবিল: 10, 25, 40, 70, 95 (প্রতিটি ১ বাইট)
ret
.LBB2_2:
li a0, 0
ret
# ARM64, clang -O2 # x86-64, gcc -O2
cmp w0, #4 xor eax, eax
b.hi .LBB2_2 cmp edi, 4
adrp x8, .Lswitch.table.fee ja .L8
add x8, x8, :lo12:.Lswitch.table.fee mov edi, edi
ldrb w0, [x8, w0, uxtw] mov eax, DWORD PTR CSWTCH.3[0+rdi*4]
ret .L8: retপর্ব ২৯-এ দেখেছেন, ঘন case-এর switch jump table-এ যায় — clang -O0-তে RISC-V-এ সত্যিই jr a0 দিয়ে jump table বানিয়েছিল। কিন্তু -O2 এক ধাপ এগোল: প্রতিটি case যেহেতু শুধু একটি ধ্রুবক ফেরত দেয়, লাফানোরই দরকার নেই — মানগুলোই একটি টেবিলে রেখে সরাসরি পড়া (switch-to-lookup-table). RISC-V আর ARM64 মানগুলো এক বাইটে (০–২৫৫) আঁটে দেখে ১ বাইটের টেবিল নিল (lbu, ldrb), gcc নিল ৪ বাইটের। আর একটি কৌশল লক্ষ করুন: k < 0 || k > 4 দুটি তুলনা না করে একটি unsigned তুলনা — ঋণাত্মক k unsigned-এ বিশাল সংখ্যা, তাই 4-এর চেয়ে বড় (পর্ব ০৫-এর two's complement).
(ঘ) fact — recursion থেকে লুপ
# x86-64, gcc -O0: সত্যিকারের recursion
mov eax, DWORD PTR [rbp-4]
sub eax, 1
mov edi, eax
call fact # নিজেকে ডাকা — প্রতি ডাকে নতুন stack frame
imul eax, DWORD PTR [rbp-4]
# x86-64, gcc -O2 # RISC-V rv32im, clang -O2
fact: fact:
mov eax, 1 li a2, 2
cmp edi, 1 bge a0, a2, .LBB3_2
jle .L11 li a0, 1
.L12: ret
imul eax, edi .LBB3_2:
sub edi, 1 mv a1, a0
cmp edi, 1 li a0, 1
jne .L12 .LBB3_3:
.L11: mv a3, a1
ret mul a0, a1, a0
addi a1, a1, -1
# ARM64, clang -O2 -fno-vectorize bltu a2, a3, .LBB3_3
.LBB3_3: ret
mul w0, w8, w0
cmp w8, #2
sub w8, w8, #1
b.hi .LBB3_3n * fact(n - 1) সরাসরি tail call নয় — ফেরার পর একটি গুণ বাকি থাকে। তবু দুই compiler-ই গুণের সাহচর্য (associativity) ব্যবহার করে একটি “accumulator” বানাল: ফল = n × (n−1) × … × 2, লুপে। কোনো call নেই, stack frame নেই, তাই বড় n-এও stack overflow নেই (যদিও ১৩! int-এ উপচে পড়ে — সেটা অন্য সমস্যা)। আরেকটি ISA-শিক্ষা: শুধু -march=rv32i দিলে RISC-V-এ mul-ই নেই (সেটি M extension-এ), তাই clang সেখানে প্রতিটি গুণের জন্য library ফাংশন __mulsi3 ডাকল — base ISA ছোট রাখার দাম।
(ঙ) struct — offset আর padding
# RISC-V rv32im, clang # ARM64, clang # x86-64, gcc
total: total: total:
lw a1, 4(a0) ldp w8, w9, [x0, #4] mov eax, DWORD PTR [rdi+4]
lw a0, 8(a0) mul w0, w9, w8 imul eax, DWORD PTR [rdi+8]
mul a0, a0, a1 ret ret
retqty offset 4-এ, price 8-এ — যদিও tag মাত্র ১ বাইট। কারণ int-কে ৪-এর গুণিতক ঠিকানায় রাখতে হয় (alignment), তাই tag-এর পরে ৩ বাইট padding বসে (পর্ব ০৬)। Struct-এর field access তাই assembly-তে স্রেফ base + ধ্রুবক offset — পর্ব ২৮-এর base+offset addressing. ARM64 পাশাপাশি দুটি field একটি ldp-তে পড়ল; x86 দ্বিতীয় field-কে সরাসরি imul-এর মেমরি-operand বানাল। -O0-এ x86 gcc pointer p-কেও stack-এ রেখে প্রতিটি field-এর আগে আবার পড়েছিল — দুবার।
৫ · -O0 বনাম -O2: আসলে কী বদলায়
পাঁচটি উদাহরণ থেকে একটি তালিকা দাঁড়ায়। এগুলো চিনতে পারলে যেকোনো listing পড়া অনেক সহজ হয়:
🐢 -O0 (debug-এর জন্য)
প্রতিটি ভ্যারিয়েবল stack-এ নিজের ঘরে; প্রতিটি ব্যবহারে load, প্রতিটি বদলে store. প্রতিটি C লাইন আলাদা instruction-গুচ্ছ, ক্রম অক্ষরে অক্ষরে; frame pointer সবসময়। দ্রুত compile হয়, debugger-এ সব দেখা যায়।
ধীর, বড়, কিন্তু স্বচ্ছ⚡ -O2 (প্রকাশের জন্য)
ভ্যারিয়েবল register-এ; অপ্রয়োজনীয় load-store, লাফ আর frame বাদ; গুণ-ভাগ shift/lea-তে; ছোট ফাংশন inline; লুপের বাইরে সরানো যায় এমন হিসাব বাইরে; recursion লুপে; tail call হয় jmp; কখনো vectorize.
-O2 আর -O0-এর মাঝে আছে -O1 (হালকা), -Og (debug-বান্ধব হালকা optimization), ওপরে -O3 (আরও আগ্রাসী: বেশি vectorization আর unrolling, code বড় হতে পারে), আর -Os/-Oz (আকার ছোট রাখা — microcontroller-এর flash মেমরির জন্য)।
Optimizer-এর প্রভাব চোখে দেখার একটি সহজ পরীক্ষা: একই যোগফলের লুপ দুইভাবে — একটিতে সাধারণ accumulator, আরেকটিতে volatile accumulator. volatile compiler-কে বলে “এর প্রতিটি পড়া-লেখা সত্যিই মেমরিতে করতে হবে”, তাই সেই লুপ register-এ বা vector-এ যেতে পারে না। Runner ফাইলটিকে -O0-এ compile করে, তাই ফাইলের মাথায় #pragma GCC optimize("O2") দিয়ে আমরা -O2 চাইছি:
#include <stdio.h>
#include <time.h>
#pragma GCC optimize("O2") /* optimise this file even though the runner compiles at -O0 */
#define N 4096
#define REPS 20000
static int a[N];
/* noipa: GCC may not analyse across these calls, so every call really runs its loop */
__attribute__((noipa)) int sum_plain(void) {
int s = 0;
for (int i = 0; i < N; i++) s += a[i];
return s;
}
__attribute__((noipa)) int sum_volatile(void) {
volatile int s = 0; /* every s += ... must load and store memory */
for (int i = 0; i < N; i++) s += a[i];
return s;
}
static double now(void) {
struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
return (double)t.tv_sec + (double)t.tv_nsec * 1e-9;
}
int main(void) {
for (int i = 0; i < N; i++) a[i] = i % 7;
long r1 = 0, r2 = 0;
double t0 = now();
for (int k = 0; k < REPS; k++) r1 += sum_plain();
double t1 = now();
for (int k = 0; k < REPS; k++) r2 += sum_volatile();
double t2 = now();
printf("same result: %s (%ld)\n", r1 == r2 ? "yes" : "NO", r1);
printf("plain : %.0f ms\nvolatile : %.0f ms\n", (t1 - t0) * 1e3, (t2 - t1) * 1e3);
printf("volatile is about %.1fx slower\n", (t2 - t1) / (t1 - t0));
return 0;
}যাচাইয়ের তিনটি run-এ পাওয়া গেছে same result: yes (245700000), আর সময় plain : 16 ms বনাম volatile : 90 ms (about 5.5x slower), পরের বার 11 ms বনাম 114 ms (10.1x), আর চূড়ান্ত যাচাইয়ে 17 ms বনাম 56 ms (3.2x). ফল প্রতিবারই এক — দুটি ফাংশন গাণিতিকভাবে একই কাজ করে। কিন্তু গতি আলাদা, আর অনুপাতটি প্রতি run-এ, প্রতি মেশিনে বদলায় — একটি cloud সার্ভারের x86-64 CPU অন্য প্রোগ্রামের সাথে ভাগাভাগি হয়, তাই সংখ্যা নয়, অনুপাতের মাপ দেখুন। কেন এত পার্থক্য? একই ফাংশন gcc -O2-এ compile করে দেখা গেছে: sum_plain-এর লুপ paddd xmm0, XMMWORD PTR [rax] — একবারে চারটি int যোগ (লুপের দৈর্ঘ্য 4096 আগেই জানা, তাই -O2-এর সস্তা vectorization খাটল)। sum_volatile-এর লুপে প্রতিটি উপাদানের জন্য stack-এ রাখা s মেমরি থেকে পড়া, যোগ, আবার মেমরিতে লেখা হয়, এবং পরের যোগ আগেরটির লেখা শেষ হওয়ার অপেক্ষায় থাকে।
৬ · Listing দেখে optimization চেনা — নয়টি পরিচিত মুখ
প্রতিটি optimization-এর জন্য একটি ছোট C উদাহরণ আর -O2-এ যা দেখা গেছে (x86-64 gcc 17; যেখানে বলা আছে RISC-V বা ARM64 clang). Compiler-প্রকৌশলের বইয়ে এগুলোর প্রতিটির আলাদা অধ্যায় আছে; আমাদের লক্ষ্য শুধু চিনতে পারা।
| Optimization | C | -O2-এ যা দেখবেন |
|---|---|---|
| Constant folding | return 60 * 60 * 24; | mov eax, 86400 — গুণ compile-এর সময়েই শেষ। RISC-V-এ ধ্রুবক বড় বলে দুই instruction: lui a0, 21 + addi a0, a0, 384 (21 × 4096 + 384 = 86400) |
| Common subexpression elimination | (a+b)*(a+b) + (a+b) | add edi, esi একবারই; তারপর imul eax, edi, add eax, edi. Clang RISC-V আরও এগিয়ে s × (s + 1) বানাল: add, addi a1, a0, 1, mul |
| Loop-invariant code motion | for (…) v[i] = v[i] * (a * b); | imul edx, ecx (a × b) লুপের আগে একবার; লুপে শুধু imul eax, edx |
| Strength reduction | x * 8, x * 3, x / 4 | lea eax, [rdi+rdi*2] (×3), ×8 lea-র scale-এর ভেতরেই; signed x / 4 → lea eax, [rdi+3], cmovns, sar eax, 2 (ঋণাত্মকে আগে ৩ যোগ — পর্ব ২০, ২২) |
| Induction-variable elimination | for (i…) s += a[i]; | লুপে i নেই; add rdi, 4 আর শেষ-ঠিকানার সাথে cmp (অংশ ৪খ) |
| Loop unrolling | একই লুপ, -O2 -funroll-loops | লুপের দেহ কয়েকবার কপি; sum-এর ১৫ লাইনের listing (label-সহ) কয়েক গুণ লম্বা হয়ে যায়। ঘোরার সংখ্যা কমে, শাখা কমে, কোড বড় হয় |
| Inlining | ছোট static ফাংশন ডাকা | call নেই; ফাংশনের দেহ ডাকার জায়গায় বসে, তারপর আশপাশের কোডের সাথে মিলে আরও optimize হয় (নিচের stepper) |
| Dead code elimination | int unused = x * 37; if (x > x) return 99; return x + 1; | শুধু lea eax, [rdi+1] — অব্যবহৃত গুণ আর কখনো-সত্য-নয় এমন শাখা উধাও |
| Branchless cmov | return a > b ? a : b; | x86: cmp esi, edi, mov eax, edi, cmovge eax, esi; ARM64: csel w0, w0, w1, gt; RISC-V (মূল ISA-তে cmov নেই): blt a1, a0, … + mv |
শাখাহীন (branchless) কোড কেন ভালো হতে পারে? পর্ব ৩৭-এ দেখবেন, শাখার দিক ভুল অনুমান করলে আধুনিক pipeline-কে প্রায় ১০–২০ cycle-এর কাজ ফেলে দিতে হয়। cmov/csel শাখা না রেখে দুটি মানের একটি বেছে নেয় — অনুমানের ঝুঁকিই নেই, তবে দুটি মানই আগে হিসাব করতে হয়। তাই compiler শুধু সস্তা ক্ষেত্রেই এটি বেছে নেয়। আর tail call: int tc(int x) { return g(x + 1); } -O2-এ add edi, 1 আর jmp g (RISC-V-এ tail g, ARM64-এ b g) — call-ret জোড়ার বদলে একটি লাফ, কারণ g-এর ret সরাসরি tc-কে যে ফাংশন ডেকেছিল, সেখানে ফিরলেই চলে।
এবার একটি listing-এ একসাথে দুটি optimization চিনি। C কোড: static int area(int w, int h) { return w * h; } আর int cost(int w, int h) { return area(w, h) * 8 + w * 3; }. gcc -O2-এর x86-64 আউটপুট w = 5, h = 7 দিয়ে চালাই:
1cost:2 imul esi, edi3 lea eax, [rdi+rdi*2]4 lea eax, [rax+rsi*8]5 ret
| edi (w) | |
| esi (h) | |
| eax |
- প্রথমে খুঁজুন কী নেই। C-তে
costডাকেarea-কে, কিন্তু listing-এ কোনোcall areaনেই — চার instruction-এর পুরো ফাংশন। এটাই inlining-এর প্রথম চিহ্ন। শুরুতে System V নিয়মে w আসেedi-তে (5), h আসেesi-তে (7). imul esi, edi— area-র দেহ এখানে। esi = 7 × 5 = 35. ফাংশন ডাকার খরচ (argument বসানো, call, ret, stack ঠিক রাখা) বাদ গেল, আর ফলটি সরাসরি পরের হিসাবে যাবে। h আর লাগবে না বলে তার register-টাই ফলের জন্য ব্যবহার হলো।lea eax, [rdi+rdi*2]— w * 3 গুণ ছাড়া। ঠিকানা-হিসাবের circuit দিয়ে w + 2w = 5 + 10 = 15.imul-এর বদলেlea— strength reduction; বহু x86 CPU-তে সরলlea১ cycle-এ শেষ হয়, যেখানেimul-এ সাধারণত ৩ cycle.lea eax, [rax+rsi*8]— × 8 আর যোগ একসাথে। area × 8 আলাদা গুণ বা shift-ও নয়: lea-র scale factor (১, ২, ৪ বা ৮) দিয়েই হলো, সাথে যোগ। eax = 15 + 35 × 8 = 15 + 280 = 295.- ফল যাচাই। C অনুযায়ী area(5, 7) × 8 + 5 × 3 = 280 + 15 = 295 — মিলেছে। চার instruction, একটিমাত্র গুণ। Clang RISC-V একই ফাংশনকে বীজগণিত দিয়ে আরও বদলাল: 8wh + 3w = w × (8h + 3) →
slli a1, a1, 3,addi a1, a1, 3,mul a0, a1, a0. দুই compiler, দুই কৌশল, একই অর্থ।
৭ · নিজের হাতে একটি ছোট্ট compiler
Compiler-এর ভেতরে তিনটি বড় কাজ: লেখা পড়ে একটি গাছ (syntax tree) বানানো, গাছ থেকে instruction লেখা (code generation), আর সেই instruction-কে ছোট-দ্রুত করা (optimization). নিচের JavaScript প্রোগ্রাম তিনটিই করে, একটি ছোট ভাষার জন্য: d = (a + b) * 4 - c. ভ্যারিয়েবলগুলো মেমরিতে থাকে gp থেকে offset-এ (-O0-এর মতো), register বরাদ্দ হয় সরল নিয়মে — পরের খালি t register, আর ফল বাঁয়ের operand-এর register-এ। তারপর একটি peephole optimization — একটি ছোট “জানালা” দিয়ে পাশাপাশি দুটি instruction দেখে সস্তা বিকল্প বসানো। শেষে একটি ছোট RV32 interpreter দুটি সংস্করণই চালিয়ে ফল মেলায়।
// Tiny compiler: "d = (a + b) * 4 - c" -> RV32 assembly, then run it on a tiny RV32 interpreter
const src = "d = (a + b) * 4 - c", OFF = { a: 0, b: 4, c: 8, d: 12 }; // variables live in memory at gp + offset
const tk = src.match(/\w+|[()+\-*=]/g); let p = 0; const peek = () => tk[p], eat = () => tk[p++];
function prim() { if (peek() === "(") { eat(); const e = expr(); eat(); return e; } const t = eat(); return /^\d+$/.test(t) ? { num: +t } : { v: t }; }
function term() { let l = prim(); while (peek() === "*") { eat(); l = { op: "*", l, r: prim() }; } return l; }
function expr() { let l = term(); while (peek() === "+" || peek() === "-") { const op = eat(); l = { op, l, r: term() }; } return l; }
const dest = eat(); eat(); const tree = expr();
// Code generation, naive register allocation: next free t-register; a result reuses its left operand's register
let next = 0; const code = [];
function gen(n) {
if (n.num !== undefined) { code.push(`li t${next}, ${n.num}`); return next++; }
if (n.v) { code.push(`lw t${next}, ${OFF[n.v]}(gp)`); return next++; }
const x = gen(n.l), y = gen(n.r);
code.push(`${{ "+": "add", "-": "sub", "*": "mul" }[n.op]} t${x}, t${x}, t${y}`); next = x + 1; return x;
}
code.push(`sw t${gen(tree)}, ${OFF[dest]}(gp)`);
const opt = []; // Peephole: "li tY, 2^k" + "mul tX, tX, tY" -> "slli tX, tX, k" (mul needs the M extension; slli is RV32I)
for (let i = 0; i < code.length; i++) {
const li = code[i].match(/^li\s+(t\d), (\d+)$/), mu = (code[i + 1] || "").match(/^mul\s+(t\d), (t\d), (t\d)$/);
const k = li ? Math.log2(+li[2]) : -1;
if (li && mu && mu[3] === li[1] && Number.isInteger(k)) { opt.push(`slli ${mu[1]}, ${mu[2]}, ${k}`); i++; } else opt.push(code[i]);
}
function run(prog, m) { // m = memory words [a, b, c, d], gp = 0
const r = {};
for (const line of prog) {
const [op, x, y, z] = line.split(/[\s,()]+/).filter(Boolean);
if (op === "li") r[x] = +y; else if (op === "lw") r[x] = m[+y / 4]; else if (op === "sw") m[+y / 4] = r[x];
else if (op === "slli") r[x] = (r[y] << +z) | 0;
else r[x] = { add: (u, v) => (u + v) | 0, sub: (u, v) => (u - v) | 0, mul: Math.imul }[op](r[y], r[z]);
}
return m[3];
}
console.log(`naive (${code.length} instructions):\n ${code.join("\n ")}\nafter peephole (${opt.length} instructions):\n ${opt.join("\n ")}`);
for (const [a, b, c] of [[7, 5, 6], [-3, 1, 2]])
console.log(`a=${a} b=${b} c=${c}: naive d=${run(code, [a, b, c, 0])}, optimised d=${run(opt, [a, b, c, 0])}, JS says ${(a + b) * 4 - c}`);আসল আউটপুট:
naive (8 instructions):
lw t0, 0(gp)
lw t1, 4(gp)
add t0, t0, t1
li t1, 4
mul t0, t0, t1
lw t1, 8(gp)
sub t0, t0, t1
sw t0, 12(gp)
after peephole (7 instructions):
lw t0, 0(gp)
lw t1, 4(gp)
add t0, t0, t1
slli t0, t0, 2
lw t1, 8(gp)
sub t0, t0, t1
sw t0, 12(gp)
a=7 b=5 c=6: naive d=42, optimised d=42, JS says 42
a=-3 b=1 c=2: naive d=-10, optimised d=-10, JS says -10
গাছটি ছিল sub(mul(add(a, b), 4), c); code generator গাছের বাঁ দিক থেকে নিচে নেমে প্রতিটি পাতার জন্য একটি lw বা li লিখল, প্রতিটি গিঁটের জন্য একটি হিসাব। পুরো কাজে মাত্র দুটি register লাগল (t0, t1), কারণ প্রতিটি ফল বাঁয়ের register-এ বসে আর ডানেরটি মুক্ত হয়। Peephole ধাপ li t1, 4 + mul জোড়াকে slli t0, t0, 2-এ বদলাল: একটি instruction কম, আর mul না থাকায় কোডটি এখন খাঁটি RV32I — M extension ছাড়া CPU-তেও চলবে। ঋণাত্মক a = −3 দিয়েও ফল মিলল (−10), কারণ two's complement-এ বাঁয়ে shift মানে ২-এর ঘাত দিয়ে গুণ, overflow না হলে চিহ্নসহ ঠিক থাকে (পর্ব ০৫)।
এই খেলনা compiler আর gcc-র মধ্যে দূরত্ব বিশাল, কিন্তু কাঠামো একই। আসল compiler গাছ থেকে প্রথমে একটি মাঝামাঝি ভাষায় (IR) যায়, সেখানে শত শত optimization pass চালায়, তারপর প্রতিটি ISA-র জন্য আলাদা back-end-এ instruction বাছে। আর register allocation সেখানে একটি কঠিন সমস্যা: RISC-V-এ ৩১টি ব্যবহারযোগ্য register, x86-64-এ ১৬টি সাধারণ register; একসাথে বেঁচে থাকা মান তার বেশি হলে কিছু মানকে stack-এ সরিয়ে রাখতে হয় — একে বলে spilling, আর তখন -O2 কোডেও load-store ফিরে আসে। কম register-এর ISA-তে spill বেশি হয় — RISC নকশায় ৩২টি register রাখার এটি একটি বড় কারণ।
আরও গভীরে: GCC আর LLVM দুটোই মাঝের ভাষাকে SSA (static single assignment) রূপে রাখে, যেখানে প্রতিটি মান ঠিক একবার লেখা হয়; constant propagation, dead code elimination আর induction variable-এর বিশ্লেষণ এতে অনেক সহজ হয়। Register allocation-এর ধ্রুপদী পদ্ধতি graph coloring (১৯৮০-এর দশকের শুরুতে IBM-এ Gregory Chaitin-দের কাজ), যেখানে একসাথে বেঁচে থাকা দুটি মান একই রং (register) পেতে পারে না; LLVM এখন একটি greedy allocator ব্যবহার করে। এর বাইরে আছে profile-guided optimization (PGO — আসল চালানোর পরিসংখ্যান দিয়ে সিদ্ধান্ত), link-time optimization (LTO — ভিন্ন ফাইলের ফাংশনও inline), আর গবেষণায় superoptimizer, যা ছোট কোডের জন্য সম্ভাব্য সব instruction-ক্রম খুঁজে সবচেয়ে ছোটটি বের করে।
৮ · Instruction সংখ্যা আর code size — তিন ISA পাশাপাশি
RISC বনাম CISC-এর পুরোনো তর্কের একটি অংশ (পর্ব ২৫): CISC-এ এক instruction বেশি কাজ করে, তাই instruction কম লাগে; RISC-এর instruction সরল, তাই বেশি লাগে কিন্তু প্রতিটি দ্রুত। আসল compiler-এর আউটপুট দিয়ে মাপা যাক। পাঁচটি ফাংশন -O2-এ (RISC-V আর ARM64 clang, ARM64-এ -fno-vectorize যাতে তুলনা ন্যায্য থাকে; x86-64 gcc), বাইট মাপা হয়েছে object file-এর symbol size থেকে (nm -S):
| ফাংশন | RV32IM instr / বাইট | RV32IMC বাইট | ARM64 instr / বাইট | x86-64 instr / বাইট |
|---|---|---|---|---|
add | 2 / 8 | 4 | 2 / 8 | 2 / 4 |
sum | 12 / 48 | 28 | 12 / 48 | 12 / 35 |
fee | 9 / 36 | 26 | 8 / 32 | 6 / 17 |
fact | 11 / 44 | 28 | 11 / 44 | 8 / 28 |
total | 4 / 16 | 10 | 3 / 12 | 3 / 8 |
| মোট | 38 / 152 | 96 | 36 / 144 | 31 / 92 |
কী শিখলাম (সতর্কভাবে — পাঁচটি ছোট ফাংশন কোনো benchmark নয়, আর এখানে দুটি ভিন্ন compiler মেশানো):
- Instruction সংখ্যায় পার্থক্য কম: ৩৮, ৩৬, ৩১. x86-এর মেমরি-operand (
add eax, [rdi],imul eax, [rdi+8]) কয়েকটি instruction বাঁচায়, ARM64-এরldpআর post-index-ও কিছু বাঁচায়। - বাইটে পার্থক্য বেশি: RV32IM আর ARM64-এর সব instruction ৪ বাইট, তাই বাইট = ৪ × instruction. x86-এর গড় এখানে তিন বাইটের কম —
ret১ বাইট,lea eax, [rdi+rsi]৩ বাইট। (x86-এর বাইটে loop-এর আগে alignment-এর জন্য বসানো কিছু ফাঁকা বাইটও ধরা আছে।) - RISC-V-এর C extension: compressed instruction-এ বহুল ব্যবহৃত instruction-এর ১৬-বিট রূপ (
add a0, a0, a1=952e,ret=8082). একই কোড ১৫২ থেকে ৯৬ বাইট — প্রায় ৩৭% ছোট, x86-এর কাছাকাছি। RISC-V-এর spec নিজে সাধারণত ২৫–৩০% কমার কথা বলে; ছোট উদাহরণে বেশি দেখাতেই পারে।
Code size কেন গুরুত্বপূর্ণ? Code density বেশি হলে instruction cache-এ বেশি কোড ধরে (পর্ব ৪৪–৪৭), আর microcontroller-এ flash মেমরিই খরচের বড় অংশ। তবে মনে রাখুন, আসল গতি ঠিক করে instruction সংখ্যা × CPI × clock period (পর্ব ৩) — x86-এর জটিল instruction decoder ভেতরে ছোট ছোট micro-operation-এ ভাঙে, তাই “কম instruction” স্বয়ংক্রিয়ভাবে “দ্রুত” নয়।
Runner-এর shell-এ কোন gcc আছে, আর প্রতিটি -O স্তরে কতগুলো optimization flag চালু হয়, নিজে দেখুন:
# Which gcc does this runner's shell have, and how many optimisation passes does each -O level switch on?
if command -v gcc >/dev/null; then
gcc --version | head -1
for o in -O0 -O1 -O2 -O3 -Os; do
printf '%-4s : %3s optimiser flags enabled\n' "$o" "$(gcc -Q --help=optimizers $o 2>/dev/null | grep -c '\[enabled\]')"
done
# same tiny function, count real instructions (lines that are not directives or labels)
for o in -O0 -O2; do
n=$(echo 'int add(int a, int b) { return a + b; }' | gcc $o -S -x c - -o - | grep -c -E '^\s+[a-z]')
echo "add() at $o: $n instructions"
done
echo 'int add(int a, int b) { return a + b; }' | gcc -O2 -S -masm=intel -x c - -o - | grep -E '^\s+[a-z]'
else
echo "gcc নেই"
fiযাচাইয়ের সময় আউটপুট:
gcc (Ubuntu 13.3.0-6ubuntu2~24.04) 13.3.0
-O0 : 52 optimiser flags enabled
-O1 : 92 optimiser flags enabled
-O2 : 137 optimiser flags enabled
-O3 : 150 optimiser flags enabled
-Os : 129 optimiser flags enabled
add() at -O0: 10 instructions
add() at -O2: 3 instructions
endbr64
lea eax, [rdi+rsi]
ret
এই shell-এর gcc হলো Ubuntu-র gcc 13.3 — C-র runnable block যে gcc 17 (experimental) দিয়ে চলে, তার থেকে আলাদা; তাই উপরের বাইট-প্রোগ্রামের সাথে এখানে একটি পার্থক্য: প্রথমে endbr64. Ubuntu তার gcc-তে ডিফল্টভাবে -fcf-protection চালু রাখে, যা প্রতিটি ফাংশনের শুরুতে এই ৪-বাইটের “এখানে লাফানো বৈধ” চিহ্ন বসায় (Intel CET-এর indirect branch tracking; পুরোনো CPU-তে এটি nop). -O0-তেও ৫২টি flag “enabled” দেখাচ্ছে, কারণ তালিকায় এমন অনেক flag আছে যা আসলে optimization নয় (যেমন exception-এর জন্য unwind টেবিল রাখা); -O1 থেকে -O2-তে ৪৫টির মতো নতুন pass যোগ হয়, -O3 যোগ করে মূলত আরও vectorization আর loop রূপান্তর। Version বদলালে এই সংখ্যাগুলোও বদলায়।
৯ · Undefined behaviour, volatile আর inline assembly
Optimizer ধরে নেয় UB কখনো ঘটে না
পর্ব ০৫-এ দেখেছেন, C-তে signed int-এর overflow undefined behaviour — hardware-এ wrap-around হলেও ভাষা কোনো ফল নির্দিষ্ট করে না। Optimizer এর সুযোগ নেয়: “overflow হতেই পারে না” ধরে নিয়ে কোড ছোট করে। তিনটি বাস্তব আউটপুট (gcc 17 -O2, x86-64):
# int grows(int x) { return x + 1 > x; } → সবসময় 1 !
grows:
mov eax, 1
ret
# unsigned ugrows(unsigned x) { return x + 1 > x; } → সত্যিকারের তুলনা
ugrows:
xor eax, eax
cmp edi, -1 # x == 0xFFFFFFFF হলে x + 1 = 0, তাই মিথ্যা
setne al
ret
# int first(int *p) { int v = *p; if (p == 0) return -1; return v; }
first:
mov eax, DWORD PTR [rdi] # null পরীক্ষা পুরোপুরি উধাও
retপ্রথম ক্ষেত্রে x = INT_MAX হলে hardware-এ x + 1 ঋণাত্মক হতো, কিন্তু ভাষার চোখে সেটা UB, তাই compiler উত্তর সবসময় 1 ধরে নিল (লক্ষ করার মতো: gcc এটি -O0-তেও করেছে, কারণ এই সরলীকরণ front-end-এই হয়)। unsigned-এ wrap-around ভাষায় নির্দিষ্ট, তাই সেখানে সত্যিকারের তুলনা রইল। তৃতীয় ক্ষেত্রে *p আগেই পড়া হয়েছে; p null হলে সেটাই UB, তাই “p == 0” পরীক্ষা অর্থহীন ধরে compiler মুছে দিল। এই ধরনের অপসারণ একসময় Linux kernel-এ একটি নিরাপত্তা-ত্রুটির জন্ম দিয়েছিল (ভাবনার প্রশ্ন ৪)। প্রতিরক্ষা: -fsanitize=undefined (UBSan) দিয়ে পরীক্ষা, ইচ্ছাকৃত wrap-এর জন্য unsigned বা __builtin_add_overflow, আর নিরাপত্তা-সংবেদনশীল কোডে -fwrapv-এর মতো flag. C-র দিক থেকে বিস্তারিত: Undefined behavior ও নিরাপদ কোডিং।
volatile: যখন মেমরি আসলে একটি যন্ত্র
Microcontroller-এ একটি UART (serial port) ধরা যাক, যার status register মেমরি-ঠিকানা 0x10000004-এ বসানো (memory-mapped I/O, পর্ব ৫১)। বিট ০ = “পাঠানোর জন্য প্রস্তুত”। Hardware নিজে এই বিট বদলায় — প্রোগ্রাম নয়।
# #define UART_STATUS (*(volatile unsigned *)0x10000004u)
# void wait_ready(void) { while ((UART_STATUS & 1u) == 0) { } }
wait_ready:
lui a0, 65536 # a0 = 0x10000000
.LBB3_1:
lw a1, 4(a0) # প্রতি ঘুরে নতুন করে পড়া
andi a1, a1, 1
beqz a1, .LBB3_1
ret
# একই কোড, শুধু volatile ছাড়া:
wait_ready_nv:
lui a0, 65536
lbu a0, 4(a0) # মাত্র একবার পড়া!
andi a0, a0, 1
bnez a0, .LBB4_2
.LBB4_1:
j .LBB4_1 # বিট 0 হলে চিরকালের লুপ
.LBB4_2:
retvolatile ছাড়া compiler-এর যুক্তি নিখুঁত: লুপের ভেতরে কেউ ঠিকানাটিতে লেখে না, তাই মান বদলাতে পারে না, তাই একবার পড়লেই যথেষ্ট — আর বিট ০ থাকলে লুপ চিরকাল। এমনকি পুরো শব্দের বদলে শুধু একটি বাইট পড়ল (lbu), যা অনেক যন্ত্র-register-এ নিষিদ্ধ। volatile দিলে প্রতিটি পড়া সত্যিকারের lw, পূর্ণ আকারে, ক্রম মেনে। Embedded কোডে তাই যন্ত্রের register, interrupt handler-এর সাথে ভাগ করা flag আর setjmp-এর পরের local ভ্যারিয়েবলে volatile লাগে। কিন্তু volatile lock নয়, memory barrier-ও বসায় না — একাধিক core-এর সমন্বয়ে লাগে atomic আর fence (পর্ব ৫৩)। এর বাস্তব প্রয়োগ পর্ব ৫৬-এ (embedded সিস্টেম) আর C course-এর embedded C পর্বে।
Inline assembly — কদাচিৎ, সাবধানে
GCC আর Clang-এ __asm__("...") দিয়ে C-র ভেতরে সরাসরি instruction লেখা যায় (inline assembly), সাথে কোন C ভ্যারিয়েবল কোন register-এ যাবে তার “constraint”. কখন দরকার? CPU-র এমন বিশেষ instruction, যার C-রূপ নেই: RISC-V-এ csrr দিয়ে control register পড়া, fence, wfi (interrupt-এর অপেক্ষায় ঘুম), context switch-এর কোড, boot-এর প্রথম লাইনগুলো। কিন্তু উপরের নয়টি optimization দেখার পর বুঝতেই পারছেন, সাধারণ হিসাবে compiler আপনার হাতের লেখার চেয়ে ভালো বা সমান — আর inline asm-এর ভেতরে compiler কিছু optimize করে না, constraint ভুল হলে নিঃশব্দে অন্য ভ্যারিয়েবল নষ্ট হয়, কোডটি এক ISA-তে বাঁধা পড়ে। তাই আগে খুঁজুন compiler-এর builtin বা intrinsic (__builtin_popcount, __rdtsc(), SIMD intrinsic). একটি x86 উদাহরণ আর তার ফাঁদগুলো আছে C আর assembly পর্বে।
১০ · AI-এর যুগে — assembly ব্যাখ্যায় AI, আর যাচাইয়ের অভ্যাস
Assembly-র ব্যাখ্যা চাওয়া — এই কাজে AI বেশ ভালো, বিশেষ করে লম্বা listing-এ কোন অংশ কোন C লাইনের, তা দেখাতে। কিন্তু assembly এমন জায়গা যেখানে একটি ভুল register বা ভুল operand-ক্রম পুরো অর্থ উল্টে দেয়, আর AI-এর ভুলগুলোর একটি পরিচিত ধরন আছে:
| AI-এর সাধারণ ভুল | কীভাবে ধরবেন |
|---|---|
AT&T আর Intel মিশিয়ে mov eax, edi-কে “eax থেকে edi-তে” বলা | % চিহ্ন আছে কি না দেখুন; Compiler Explorer-এ দুই syntax পাল্টে দেখুন |
MIPS-এর নিয়ম RISC-V-তে চাপানো ($t0, branch delay slot, মাত্র চারটি argument register $a0–$a3) | RISC-V-এ delay slot নেই, register a0–a7; ISA manual মিলিয়ে নিন |
| “-O2 এই কোডই লিখবে” বলে একটি বানানো listing দেওয়া | নিজে Compiler Explorer-এ একই version আর flag দিয়ে চালান |
| instruction সংখ্যা বা বাইট “অনুমানে” বলা | objdump -d বা nm -S দিয়ে গুনুন |
| UB-র কারণে মুছে যাওয়া কোডকে “compiler bug” বলা | UBSan দিয়ে চালান; C standard-এর নিয়ম দেখুন |
volatile-কে thread-safety-র সমাধান বলা | Atomic, mutex, fence — এগুলোই সমাধান (পর্ব ৫৩) |
AI-কে listing ব্যাখ্যা করতে দিলে compiler, version, ISA আর flag স্পষ্ট বলুন, আর তাকে দিয়ে যাচাইযোগ্য দাবি লেখান:
AI-এর দেওয়া assembly-ব্যাখ্যা হাতে পেলে — চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Compilation-এর ধাপ আর প্রতিটির আউটপুট? — Preprocess (
.i) → compile (.s) → assemble (.o: machine code + symbol + relocation) → link (executable) → load (প্রসেস)। ISA প্রথম আসে compile ধাপে। - -O0-তে কেন সব ভ্যারিয়েবল stack-এ? — Debugger যাতে প্রতিটি ভ্যারিয়েবলকে একটি নির্দিষ্ট ঠিকানায় পায় আর প্রতিটি C লাইনে থামতে পারে।
- x86-64 gcc -O2-এ
int add(int a, int b)? —lea eax, [rdi+rsi];ret— বাইটে8d 04 37 c3. - Strength reduction-এর দুটি উদাহরণ? —
x * 8→slli/shlবা lea-র scale; লুপেa[i]-এরi * 4→ pointer-এ প্রতি ঘুরে+ 4. volatileকী নিশ্চিত করে, কী করে না? — প্রতিটি access সত্যিই মেমরিতে, বাদ বা একত্র না করে; কিন্তু atomicity বা core-এর মধ্যে ক্রম নয়।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১RISC-এর জন্ম আর compiler — কেন বলা হয় RISC আসলে compiler-প্রকৌশলীদের আবিষ্কার?
১৯৭০-এর দশকের CPU-নকশার প্রবণতা ছিল instruction-কে উচ্চ-স্তরের ভাষার কাছাকাছি আনা — DEC-এর VAX-এ বহুপদী (polynomial) মূল্যায়ন বা string-কপির মতো একটি instruction, বহু addressing mode, microcode-এ লেখা জটিল কাজ। ধারণা ছিল: hardware যত ভাষার কাছে, compiler তত সহজ, প্রোগ্রাম তত ছোট।
কিন্তু যাঁরা compiler লিখতেন, তাঁরা উল্টো জিনিস দেখলেন। IBM-এ John Cocke-এর দল ১৯৭০-এর দশকের মাঝামাঝি 801 প্রকল্পে মেপে দেখল, compiler-এর তৈরি কোডে জটিল instruction-গুলো খুব কমই আসে; বেশিরভাগ সময় চলে load, store, add, branch-এর মতো সরল কাজ। জটিল instruction সঠিকভাবে ব্যবহার করা compiler-এর পক্ষে কঠিন, আর অনেক সময় কয়েকটি সরল instruction দিয়ে একই কাজ দ্রুততর হয়। ১৯৮০-এর দশকের শুরুতে Berkeley-তে David Patterson-এর RISC I আর Stanford-এ John Hennessy-র MIPS এই চিন্তাকে চিপে রূপ দিল: সরল, একই দৈর্ঘ্যের instruction, শুধু load/store মেমরি ছোঁয়, অনেক register — আর জটিলতার দায়িত্ব compiler-এর।
এই পর্বের listing-গুলোতে সেই সিদ্ধান্তের ছাপ স্পষ্ট। RISC-V-এর ৩১টি ব্যবহারযোগ্য register থাকায় বড় লুপেও spill কম হয় (sum-এর ছোট -O2 লুপে কোনো spill-ই নেই); সমান দৈর্ঘ্যের instruction-এ বাইট গোনা সহজ, decode সহজ। আর x86? Intel-AMD-এর আধুনিক CPU ভেতরে x86 instruction-কে RISC-ধাঁচের micro-operation-এ ভেঙে চালায় — অর্থাৎ বাইরে CISC-এর পোশাক, ভেতরে RISC-এর ইঞ্জিন। আবার x86-এর ঘন encoding আর মেমরি-operand এই পর্বের code size টেবিলে একটি সত্যিকারের সুবিধা দেখাল, যা RISC-V পরে C extension দিয়ে পূরণ করেছে।
তাই প্রশ্নটা “RISC না CISC” নয়, বরং “hardware আর compiler-এর মধ্যে কাজ কীভাবে ভাগ হবে”। Compiler যা ভালো পারে (register বাছাই, instruction-ক্রম সাজানো, ধ্রুবক হিসাব), hardware-কে তা করতে হয় না; hardware যা ভালো পারে (চলার সময়ের তথ্য দেখে branch অনুমান, out-of-order চালানো), compiler-কে তা আগে থেকে জানতে হয় না।
মূল উপলব্ধি: ISA হলো compiler আর hardware-এর চুক্তি; ভালো ISA সেটাই যার নিয়ম compiler সহজে কাজে লাগাতে পারে আর hardware দ্রুত চালাতে পারে।
প্র ০২-O2 এত ভালো হলে debug build কেন -O0-তে হয়? “optimized out” আসলে কী, আর -Og কী সমাধান দেয়?
Debugger কাজ করে compiler-এর রেখে যাওয়া একটি মানচিত্র দিয়ে — Linux-এ DWARF ফরম্যাটের debug তথ্য (-g). সেই মানচিত্র বলে: এই instruction-টি C-র কোন লাইনের, আর এই মুহূর্তে ভ্যারিয়েবল s কোথায় আছে — কোন register-এ, না stack-এর কোন ঘরে। -O0-তে উত্তরটি সরল: প্রতিটি ভ্যারিয়েবল সারাজীবন stack-এর একটি নির্দিষ্ট ঘরে, প্রতিটি C লাইনের instruction পাশাপাশি আর ক্রম মেনে। আপনি যেকোনো লাইনে থামতে পারেন, যেকোনো ভ্যারিয়েবল দেখতে বা বদলাতে পারেন।
-O2-এ মানচিত্র জটিল হয়ে যায়। অংশ ৪-এর sum-এ i ভ্যারিয়েবলটি আর নেই — তার জায়গায় একটি pointer. তাই debugger-এ print i লিখলে দেখবেন <optimized out>: compiler সৎভাবে বলছে, “এই মান আর কোথাও রাখা নেই।” কোনো ভ্যারিয়েবল এক সময় register-এ, পরে অন্য register-এ, আবার কখনো কোথাও না। Inline হওয়া ফাংশনে breakpoint দিলে সেটি দশ জায়গায় ছড়ানো থাকতে পারে; instruction scheduling-এর কারণে “পরের লাইন” চাপলে কার্সর আগে-পিছে লাফায়; আর optimizer যে লুপকে ধ্রুবক বানিয়ে দিয়েছে, তার ভেতরে থামার জায়গাই নেই।
এ কারণেই প্রচলিত নিয়ম: উন্নয়নের সময় -O0 -g, প্রকাশের সময় -O2 (প্রায়ই -g-সহ, যাতে crash রিপোর্ট পড়া যায়)। কিন্তু -O0 এত ধীর যে কিছু প্রোগ্রাম (গেম, বড় সিমুলেশন) debug করাই কঠিন হয়ে পড়ে। তাই GCC ৪.৮ (২০১৩) থেকে আছে -Og: যেসব optimization debug-অভিজ্ঞতা নষ্ট করে না সেগুলো চালু, বাকিগুলো বন্ধ। Clang-এর -Og এখনো মূলত -O1-এর মতো আচরণ করে বলে জানা যায়।
আরেকটি গুরুত্বপূর্ণ বিষয়: যে বাগ শুধু -O2-তে দেখা দেয়, সেটি প্রায়ই compiler-এর দোষ নয় — কোডে লুকিয়ে থাকা undefined behaviour বা volatile-এর অভাব, যা -O0-এর “সব মেমরিতে” আচরণে চাপা পড়ে ছিল। তাই শুধু -O0-তে পরীক্ষা করা কোড পুরোপুরি পরীক্ষিত নয়।
মূল উপলব্ধি: Optimization আর debuggability পরস্পরবিরোধী — একটি বাড়ালে অন্যটি কমে; পেশাদাররা দুই ধরনের build-ই রাখেন আর দুটিতেই পরীক্ষা চালান।
প্র ০৩ARM-এর Thumb থেকে RISC-V-এর C extension — code density নিয়ে এত মাথাব্যথা কেন, আর ARM64 কেন সেই পথে যায়নি?
১৯৯০-এর দশকের শুরুতে ARM-এর ৩২-বিট instruction ছিল সরল আর দ্রুত, কিন্তু মোবাইল ফোনের মতো যন্ত্রে মেমরি ছিল দামি আর ছোট। প্রতিটি instruction ৪ বাইট হওয়ায় একই প্রোগ্রাম x86 বা ৮/১৬-বিট chip-এর চেয়ে বেশি জায়গা নিত। ARM-এর সমাধান ছিল Thumb (১৯৯৪ সালের দিকে ARM7TDMI core-এ): বহুল ব্যবহৃত instruction-এর একটি ১৬-বিট রূপ। সেই সময়ের বহু ARM-ভিত্তিক মোবাইল ফোনে এটি ব্যবহার হতো। পরে Thumb-2 (২০০৩ সালের দিকে) ১৬ আর ৩২ বিট মিশিয়ে প্রায় পূর্ণ ক্ষমতা দিল; আজও Cortex-M microcontroller-গুলো শুধু Thumb চালায়।
RISC-V একই শিক্ষা থেকে শুরুতেই “C” (compressed) extension রাখল: সাধারণ instruction-এর ১৬-বিট রূপ, যা ৩২-বিট instruction-এর সাথে যেকোনো জায়গায় মিশে থাকতে পারে। এই পর্বের টেবিলে পাঁচটি ফাংশন ১৫২ থেকে ৯৬ বাইটে নামল। Linux চালানোর মতো প্রায় সব RISC-V chip-ই C extension সমর্থন করে, আর বড় Linux distribution-গুলোও এটি ধরে নিয়ে build করে।
কেন এত গুরুত্ব? প্রথমত, microcontroller-এ flash মেমরিই chip-এর বড় অংশ আর খরচ; কোড ছোট মানে ছোট, সস্তা chip. দ্বিতীয়ত, বড় CPU-তেও instruction cache সীমিত (প্রায়ই ৩২–৬৪ KB); ঘন কোডে একই cache-এ বেশি কোড ধরে, miss কমে। তৃতীয়ত, মেমরি থেকে instruction আনার bandwidth আর শক্তিও কমে।
তাহলে ARM64 (২০১১ সালে ঘোষিত ARMv8-A) কেন Thumb বাদ দিল? বড় core-এর জন্য ARM-এর যুক্তি ছিল: সব instruction ৪ বাইট হলে একসাথে অনেকগুলো decode করা সহজ — প্রতিটি instruction কোথায় শুরু, তা আগে থেকেই জানা। ভিন্ন দৈর্ঘ্য মানে প্রতিটির শুরু খুঁজতে আগেরটি আংশিক decode করতে হয়, যা x86 decoder-এর একটি বড় জটিলতা। ARM64 তাই code size-এর ক্ষতি পূরণ করল ঘন instruction দিয়ে (ldp/stp, shift-করা operand, madd, post-index). এই পর্বের টেবিলে ARM64-এর instruction সংখ্যা তাই RISC-V-এর চেয়ে কিছুটা কম।
মূল উপলব্ধি: Code density একটি trade-off — ছোট কোড বনাম সহজ, চওড়া decoder; microcontroller আর বড় server CPU-র উত্তর তাই আলাদা হতেই পারে।
প্র ০৪Optimizer একটি null পরীক্ষা মুছে দিল আর kernel-এ নিরাপত্তা-ত্রুটি হলো — দোষ compiler-এর, না প্রোগ্রামারের?
২০০৯ সালে Linux kernel-এর একটি নেটওয়ার্ক ড্রাইভারে (tun) এমন একটি কোড পাওয়া যায়: প্রথমে একটি pointer-এর ভেতরের field পড়া হচ্ছে, তার পরের লাইনে পরীক্ষা হচ্ছে pointer-টি null কি না — অংশ ৯-এর first() ফাংশনের মতোই। GCC যুক্তি দিল: pointer আগেই dereference হয়েছে, null হলে সেটাই undefined behaviour, তাই এখানে pointer null হতে পারে না — পরীক্ষাটি অপ্রয়োজনীয়, মুছে ফেলা যায়। সাধারণ প্রোগ্রামে null dereference মানে crash, কারণ ঠিকানা 0-তে কিছু map করা থাকে না। কিন্তু kernel-এ, কিছু পরিস্থিতিতে আক্রমণকারী user প্রোগ্রাম ঠিকানা 0-তে নিজের ডেটা বসাতে পারত; তখন crash না হয়ে kernel আক্রমণকারীর ডেটা পড়ত, আর যে পরীক্ষা বাঁচাতে পারত সেটি নেই। ত্রুটিটি CVE-2009-1897 নামে নথিভুক্ত।
দোষ কার? C standard-এর দিক থেকে compiler সম্পূর্ণ নিয়ম মেনেছে — UB থাকলে compiler-এর কোনো দায় নেই। প্রোগ্রামারের ভুল ছিল পরীক্ষার আগে dereference করা। কিন্তু বাস্তব প্রকৌশলের দিক থেকে দুই পক্ষই শিখেছে। Linux kernel এখন -fno-delete-null-pointer-checks flag দিয়ে build হয়, যাতে compiler এমন পরীক্ষা না মোছে; একইভাবে -fno-strict-overflow দিয়ে signed overflow-কে wrap ধরে নেওয়া হয়। আর ঠিকানা 0-এর আশপাশে user প্রোগ্রামের map করা সাধারণত নিষিদ্ধ (mmap_min_addr সেটিং)।
এই ঘটনা একটি বড় বিতর্কও উসকে দেয়: compiler কি UB-কে “অনুমতি” হিসেবে দেখবে, নাকি সতর্কভাবে? Compiler-প্রকৌশলীদের যুক্তি, এই অনুমানগুলোই লুপ vectorize বা induction variable সরলীকরণের মতো বড় optimization সম্ভব করে — অংশ ৪-এর মতো লুপে int i-কে pointer-এ বদলানোর সময়ও compiler প্রায়ই “i overflow করবে না” অনুমানটি কাজে লাগায়, বিশেষ করে ৬৪-বিট মেশিনে, যেখানে ৩২-বিট i থেকে ৬৪-বিট ঠিকানা বানাতে হয়। সমালোচকদের যুক্তি, নিরাপত্তা-সংবেদনশীল কোডে এই ঝুঁকি অগ্রহণযোগ্য। ফলাফল হিসেবে এসেছে UBSan-এর মতো টুল, -fwrapv-এর মতো flag, আর Rust-এর মতো ভাষা যেখানে সাধারণ (safe) কোডে UB ঘটানোই সম্ভব নয়।
মূল উপলব্ধি: Compiler আপনার ইচ্ছা নয়, ভাষার নিয়ম অনুসরণ করে; নিয়মের ফাঁক বোঝা আর টুল দিয়ে ধরা — দুটোই প্রোগ্রামারের দায়িত্ব।
প্র ০৫Compiler যদি এত ভালো হয়, তাহলে আজও কেউ হাতে assembly লেখেন কেন? কোথায় লেখেন?
সাধারণ কোডে আজকের compiler বেশিরভাগ মানুষের চেয়ে ভালো assembly লেখে — অংশ ৬-এর lea কৌশল, clang-এর w × (8h + 3) বীজগণিত, বা ARM64-এ vectorize করা লুপ একজন মানুষ হাজার লাইনে ধারাবাহিকভাবে করতে পারবেন না। তবু কিছু জায়গায় হাতের assembly আজও টিকে আছে।
প্রথম — যা C-তে বলাই যায় না। CPU চালু হওয়ার পরের প্রথম instruction (stack তখনো তৈরি নয়, তাই C চলবে না), context switch-এ সব register বাঁচানো আর ফেরানো, interrupt vector, privileged register (RISC-V-এর CSR, x86-এর control register) পড়া-লেখা। প্রতিটি OS kernel আর firmware-এ এমন কয়েকশো বা কয়েক হাজার লাইন assembly থাকে।
দ্বিতীয় — গতির শেষ বিন্দু। ভিডিও decoder-এর মতো প্রকল্প (যেমন FFmpeg বা AV1 decoder dav1d) প্রতিটি ISA-র SIMD-এর জন্য হাতে লেখা assembly ব্যবহার করে, কারণ এখানে কয়েক শতাংশ গতি মানে কোটি কোটি যন্ত্রে ব্যাটারি আর বিদ্যুৎ। Compiler-এর auto-vectorizer এসব জটিল লুপে প্রায়ই পিছিয়ে থাকে।
তৃতীয় — নিরাপত্তা। Cryptography-র কোডে সময়ের পার্থক্য থেকেই গোপন চাবি ফাঁস হতে পারে (timing side channel). গোপন ডেটার ওপর নির্ভর করে কোনো branch বা মেমরি-ঠিকানা থাকা চলবে না। কিন্তু compiler “সমান-সময়” নিশ্চিত করার প্রতিশ্রুতি দেয় না — অংশ ৬-এর মতো সে শাখা যোগ বা বাদ দিতে পারে। তাই OpenSSL-এর মতো library-র গুরুত্বপূর্ণ অংশ প্রায়ই assembly-তে বা assembly তৈরির script-এ লেখা।
আর সবচেয়ে বড় ব্যবহার আসলে পড়া, লেখা নয়। Performance engineer listing পড়ে দেখেন লুপ vectorize হলো কি না, spill আছে কি না; নিরাপত্তা গবেষক binary পড়ে দুর্বলতা খোঁজেন; compiler-প্রকৌশলী আর embedded প্রকৌশলী নিশ্চিত হন যে যন্ত্রের register সত্যিই পড়া হচ্ছে। এই পর্বের দক্ষতাগুলো ঠিক এর জন্য।
মূল উপলব্ধি: Assembly লেখা আজ বিশেষজ্ঞের কাজ, কিন্তু assembly পড়া প্রতিটি systems প্রকৌশলীর মৌলিক দক্ষতা — compiler-কে বিশ্বাস করার আগে যাচাই করার উপায়।
অনুশীলন
পরীক্ষার ধাঁচে: listing পড়ে C লেখা, -O2-এর আচরণ অনুমান, আর instruction গোনা। আগে নিজে চেষ্টা করুন।
-
Listing থেকে C লিখুন: নিচের কোড clang -O2 RISC-V rv32im-এর আউটপুট (
f(const int *a, int n)). C ফাংশনটি লিখুন, আরsrli a3, a3, 31লাইনের অর্থ ব্যাখ্যা করুন।f: blez a1, .LBB0_4 mv a2, a0 li a0, 0 slli a1, a1, 2 add a1, a2, a1 .LBB0_2: lw a3, 0(a2) addi a2, a2, 4 srli a3, a3, 31 add a0, a3, a0 bne a2, a1, .LBB0_2 ret .LBB0_4: li a0, 0 retকাঠামো অংশ ৪-এর sum-এর মতোই: n ≤ 0 হলে 0; নইলে pointer
a2থেকে শেষ-ঠিকানাa + 4nপর্যন্ত প্রতিটি int পড়া। পার্থক্য একটি লাইনে: পড়া মানa3-কে ৩১ ঘর ডানে logical shift. ৩২-বিট two's complement-এ তখন শুধু চিহ্ন-বিট থাকে — ঋণাত্মক হলে 1, নইলে 0. তাইa0 += (a[i] < 0). ফাংশনটি ঋণাত্মক সংখ্যা গোনে:int f(const int *a, int n) { int c = 0; for (int i = 0; i < n; i++) if (a[i] < 0) c++; return c; }C-তে
ifথাকলেও listing-এ কোনো শর্তের শাখা নেই — compiler তুলনাকে বিট-কৌশলে বদলে শাখাহীন করেছে। gcc x86-64-এ একই জিনিসshr eax, 31আর ARM64-এadd w0, w0, w10, lsr #31. -
-O2 কী করবে, অনুমান করুন:
int k(int x) { return (x * 8) + (x * 2) - 60 * 60; }— x86-64 gcc -O2-এ কোনোimulথাকবে কি? কতগুলো instruction আশা করেন? RISC-V-এ কী হতে পারে?প্রথমে compiler যা নিশ্চিতভাবে করবে:
60 * 60→ 3600 (constant folding), আর 8x + 2x → 10x (বীজগণিত)। ফাংশনটি তাই 10x − 3600. ১০ দিয়ে গুণimulছাড়াই করা যায়। gcc 17 -O2-এর আসল আউটপুট:lea eax, [rdi-1800+rdi*4] ; eax = 5x - 1800 add eax, eax ; eax = 10x - 3600 retতিনটি instruction, কোনো
imulনেই: compiler 10x − 3600-কে লিখল 2 × (5x − 1800), আর 5x − 1800 একটিlea-তে (base + index×4 + displacement). Clang RISC-V একই ধারণা অন্যভাবে: (x − 360) × 10 = (x − 360)·8 + (x − 360)·2 →addi a0, a0, -360,slli a1, a0, 1,slli a0, a0, 3,add a0, a0, a1,ret. ARM64 clang বেছে নিলmadd(গুণ আর যোগ এক instruction-এ)। হুবহু instruction অনুমান করা কঠিন; পরীক্ষায় যা প্রত্যাশিত তা হলো: ধ্রুবক ভাঁজ হবে, গুণ shift/lea-তে যাবে বা একটি গুণে নামবে, instruction ৩–৫টি। -
Instruction গুনুন: অংশ ৪খ-এর RISC-V listing ধরে n = 10 হলে sum() -O0 আর -O2-এ মোট কতগুলো instruction চালায়? n = 0 হলে? কতগুলো load আর store?
-O0: শুরু (prologue থেকে
jপর্যন্ত) ১০টি; প্রতি ঘুরে শর্ত ৪টি (lw,lw,bge,j) + দেহ ৯টি + বৃদ্ধি ৪টি = ১৭টি; শেষবার শর্ত পরীক্ষা ৩টি (bgeলাফ দেয়,jচলে না); ফেরা ৫টি (lw a0,lw ra,lw s0,addi,ret). মোট 17n + 18. n = 10 → 188; n = 0 → 18.-O2: n > 0 হলে শুরু ৫টি (
blez,mv,li,slli,add), প্রতি ঘুরে ৪টি, শেষেret১টি: 4n + 6. n = 10 → 46. n = 0 হলেblezলাফ দেয়, তারপরli,ret: 3.মেমরি: -O0-তে প্রতি ঘুরে ৭ load + ২ store; শুরুতে ৬টি store (
ra,s0, a, n, s, i), শেষবার শর্তে ২ load, ফেরায় ৩ load. n = 10: load = 70 + 2 + 3 = 75, store = 20 + 6 = 26. -O2-তে load = n = 10, store = 0. অনুপাত (188 ÷ 46 ≈ 4.1) n বাড়লে 17/4 ≈ 4.25-এর দিকে যায়। -
Struct, offset আর char-এর চিহ্ন:
struct rec { char c; short h; int i; char d; };— প্রতিটি field-এর offset আরsizeofবলুন (৪-বাইট int alignment ধরে)।return p->d;RISC-V আর x86-64-এ কোন load instruction-এ হবে, আর কেন দুটি আলাদা?coffset 0 (১ বাইট);short h-কে ২-এর গুণিতকে বসতে হয় → offset 2 (মাঝে ১ বাইট padding);int i→ offset 4;char d→ offset 8; struct-এর মোট আকারকে সবচেয়ে বড় alignment (৪)-এর গুণিতক হতে হয়, তাই 9 থেকে বেড়েsizeof = 12. Compiler-এর আউটপুট মিলেছে:sizeof(struct rec)ফেরত দেওয়া ফাংশন →li a0, 12আরmov eax, 12;p->h→lh a0, 2(a0)আরmovsx eax, WORD PTR [rdi+2].p->d: RISC-V clang দিলlbu a0, 8(a0)(zero-extend), x86-64 gcc দিলmovsx eax, BYTE PTR [rdi+8](sign-extend). কারণ ABI: x86-64 System V-এ সাধারণcharsigned, কিন্তু RISC-V আর ARM64-এর Linux/ELF ABI-তেcharunsigned. একই C কোড, ১২৭-এর বেশি মানের বাইটে দুই ISA-তে ভিন্ন ফল! বহনযোগ্য কোডে তাইsigned charবাunsigned char(বাint8_t/uint8_t) স্পষ্ট লিখুন।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমএকটি C প্রোগ্রাম চলা পর্যন্ত ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- 📦 Preprocess —
#includeআর macro বসিয়ে.i - 📐 Compile — নির্দিষ্ট ISA-র assembly,
.s - 🔢 Assemble — machine code আর relocation-সহ
.o - 🔗 Link — relocation পূরণ করে executable
- ⚡ Load — মেমরিতে এনে PC-তে entry point বসানো
x86-64-এ gcc -O2 int add(int a, int b) { return a + b; }-এর জন্য lea eax, [rdi+rsi] লেখে। add-এর বদলে lea কেন?
- lea মেমরি থেকে a আর b পড়ে আনে
- x86-এর add দুই-operand-এর; lea দুটি register যোগ করে তৃতীয়টিতে রাখতে পারে, একটি mov বাঁচে
- add overflow-তে exception দেয়, lea দেয় না
- lea দিয়ে floating-point যোগ দ্রুত হয়
শুধু -march=rv32i (M extension ছাড়া) দিয়ে int mul2(int q, int p) { return q * p; } compile করলে clang কী লেখে?
- একটি
mul a0, a0, a1 - Compile error: গুণ সম্ভব নয়
- Library ফাংশন
__mulsi3-এ call বা tail call - একটিমাত্র
slli
gcc -O2 int grows(int x) { return x + 1 > x; }-কে mov eax, 1 আর ret-এ বদলে দেয়। কারণ কী?
- Signed overflow undefined behaviour, তাই compiler ধরে নেয় x + 1 সবসময় x-এর চেয়ে বড়
- এটি gcc-র একটি পরিচিত bug
- x86-এ int যোগ কখনো wrap করে না
- x-এর মান compile-এর সময়েই জানা, তাই constant folding
একটি microcontroller-এ while ((STATUS & 1) == 0) { } লুপ -O0-তে ঠিক চলে, কিন্তু -O2-তে চিরকাল আটকে থাকে। STATUS একটি যন্ত্র-register-এর ঠিকানা। সবচেয়ে সম্ভাব্য সমাধান কী?
- সবসময় -O0 দিয়ে build করা
- লুপের চারপাশে একটি mutex lock দেওয়া
- লুপের ভেতরে একটি অর্থহীন হিসাব বসানো
- STATUS-এর pointer-কে
volatileহিসেবে ঘোষণা করা
AT&T syntax-এ লেখা movl %edi, %eax-এর অর্থ কী?
- eax-এর মান edi-তে কপি
- edi-এর ৩২-বিট মান eax-এ কপি
- edi-তে রাখা ঠিকানা থেকে মেমরি পড়ে eax-এ
- ৬৪-বিট rdi থেকে rax-এ কপি
int tc(int x) { return g(x + 1); } -O2-এ x86-64-এ add edi, 1 আর jmp g হয়। call g + ret-এর বদলে jmp কেন বৈধ?
- g inline হয়ে গেছে, তাই কোনো call লাগে না
- jmp ফেরার ঠিকানা stack-এ রাখে
- g ফেরার পর tc-এর আর কোনো কাজ নেই, তাই g-এর
retসরাসরি tc-এর caller-এ ফিরতে পারে - x86-এ call আর jmp সবসময় সমান
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Preprocess → compile → assemble → link → load; ISA ঢোকে compile-এ, object file-এ থাকে machine code + relocation, linker ঠিকানা বসায়।
- দেখার টুল:
gcc -S(-masm=intel),objdump -d, আর তিন ISA পাশাপাশি দেখতে Compiler Explorer; আউটপুট version-ভেদে বদলায়। - -O0 = সব ভ্যারিয়েবল stack-এ (debug-এর জন্য); -O2 = register, কম load, strength reduction, induction variable elimination, inline, recursion থেকে লুপ, tail call, কখনো vectorization.
- x86-64-এ instruction ১–১৫ বাইট আর মেমরি-operand; ARM64 আর RV32-এ ৪ বাইট, RVC-তে ২ — instruction সংখ্যা আর code size আলাদা প্রশ্ন।
- Optimizer ধরে নেয় UB ঘটে না; যন্ত্র-register-এ volatile; inline assembly শুধু যেখানে C-তে বলা অসম্ভব।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩২ · Single-cycle datapath — একটি instruction-এর পুরো পথপরবর্তী পর্বInstruction encoding থেকে hardware-এর সংকেত পর্যন্ত।
- কম্পিউটার আর্কিটেকচার ও ডিজিটাল লজিক · কোর্স হোম৬২টি পর্বপুরো কোর্সের রোডম্যাপ।
- C প্রোগ্রামিং · C আর assemblyপাশের ট্র্যাকx86-64-এর register, stack frame, calling convention আর buffer overflow — C-র চোখে।
- C প্রোগ্রামিং · টুলচেইনভিত্তিPreprocessor, compiler, assembler, linker আর debugger — শুরু থেকে।
- Operating Systems · মেমরির ভিত্তিপাশের ট্র্যাকLinking আর loading, PLT/GOT, position-independent code আর ASLR.