কম্পাইলার আউটপুট অটোপসি
Compiler Output Autopsy
একই C কোড -O0, -O2, আর -O3-এ কম্পাইল করে জেনারেট হওয়া অ্যাসেম্বলি পাশাপাশি রেখে তুলনা করা — কোন অপ্টিমাইজেশন কখন চালু হলো, কেন হলো, আর কোন কোড সম্পূর্ণ উবে গেল, সেটা লিখিত রিপোর্ট আকারে ব্যাখ্যা করা।
কেন এই প্রজেক্ট
এই মডিউলের বাকি সব প্রজেক্টে আমরা অ্যাসেম্বলি লিখেছি বা এনকোড করেছি। এই প্রজেক্টে কিছুই লেখা হবে না — শুধু পড়া হবে, খুব মনোযোগ দিয়ে। Reading compiler output লেসনে আমরা শিখেছি কীভাবে -O0 কোড পড়তে হয় (সরাসরি, প্রায় C-র লাইন-বাই-লাইন অনুবাদ) আর -O2 কোড কেন সম্পূর্ণ ভিন্ন দেখায় (variable আর stack-এ নেই, লুপ উধাও, ফাংশন-কল নেই)।
এই প্রজেক্ট সেই দক্ষতাকে একটা নিয়ন্ত্রিত পরীক্ষায় পরিণত করে। একই ইনপুট (একই C সোর্স) তিনটা ভিন্ন সেটিং-এ চালিয়ে আউটপুট তুলনা করা — এটাই বৈজ্ঞানিক পদ্ধতির মূল চাল। যখন optimization level ছাড়া বাকি সবকিছু স্থির থাকে, তখন অ্যাসেম্বলিতে যে পার্থক্য দেখা যায়, তার প্রতিটাই কোনো না কোনো নির্দিষ্ট অপ্টিমাইজেশন পাসের ফলাফল — আর সেই পাসগুলোর নাম আর যুক্তি চেনা মানেই compiler একটা ব্ল্যাক-বক্স না থেকে একটা বোধগম্য, predictable টুল হয়ে ওঠা।
এই দক্ষতার বাস্তব প্রয়োগ অনেক জায়গায়: performance-critical কোডে “compiler কি আসলে যা আশা করছি তাই করলো” যাচাই করা, একটা unexpected slowdown-এর মূলে যাওয়া, বা security review-তে “এই bounds-check optimize হয়ে উবে যায়নি তো” নিশ্চিত হওয়া।
এই প্রজেক্ট অন্যদের থেকে আলাদা কেন
এটা একটা “বানান” প্রজেক্ট না — এটা একটা investigation। চূড়ান্ত ডেলিভারেবল একটা রানেবল প্রোগ্রাম না, বরং একটা কাঠামোবদ্ধ লিখিত রিপোর্ট। এই পার্থক্যটা গুরুত্বপূর্ণ কারণ মূল্যায়নের মানদণ্ডও আলাদা — “কোড কম্পাইল হলো কি না” না, বরং “প্রতিটা পর্যবেক্ষিত পার্থক্যের একটা সঠিক, নির্দিষ্ট ব্যাখ্যা দেওয়া হয়েছে কি না”।
টুল — Compiler Explorer সুপারিশ
স্থানীয়ভাবে gcc/objdump দিয়েও এই কাজ করা যায়, কিন্তু এই ধরনের তুলনার জন্য সবচেয়ে কার্যকর টুল হলো Compiler Explorer (সংক্ষেপে “Godbolt”)। কারণ:
- সোর্স আর অ্যাসেম্বলি পাশাপাশি দেখা যায়, রঙ-কোডেড লাইন ম্যাচিং সহ (সোর্সের কোন লাইন কোন অ্যাসেম্বলি ব্লক তৈরি করলো তা এক নজরে বোঝা যায়)
- একাধিক optimization level একসাথে একাধিক প্যানেলে পাশাপাশি রাখা যায় — ঠিক এই প্রজেক্টের দরকার যা
- Compiler ভার্সন আর target architecture বদলানো যায় (x86-64, ARM64…) — কৌতূহলী হলে সেটাও পরীক্ষা করা যায়
- কোনো লোকাল টুলচেইন ইনস্টল করা ছাড়াই ব্রাউজারে সরাসরি কাজ করা যায়
স্থানীয়ভাবে করতে চাইলে সমতুল্য কমান্ড:
$ gcc -O0 -S -fverbose-asm -o out_O0.s snippet.c
$ gcc -O2 -S -fverbose-asm -o out_O2.s snippet.c
$ gcc -O3 -S -fverbose-asm -o out_O3.s snippet.c
-S মানে শুধু অ্যাসেম্বলি জেনারেট করে থামা (লিংক পর্যন্ত না যাওয়া), -fverbose-asm প্রতিটা ইন্সট্রাকশনের পাশে কোন C ভেরিয়েবল/এক্সপ্রেশনের সাথে সম্পর্কিত তার একটা কমেন্ট যোগ করে — যা এই তুলনার কাজে খুবই সহায়ক।
রিপোর্ট টেমপ্লেট — প্রতিটা স্নিপেটের জন্য
শুধু “দুইটা অ্যাসেম্বলি ডাম্প পেস্ট করা” কোনো রিপোর্ট না। প্রতিটা স্নিপেটের জন্য এই কাঠামো অনুসরণ করুন:
## স্নিপেট: [নাম]
### C সোর্স
[কোড ব্লক]
### -O0 অ্যাসেম্বলি (মূল অংশ)
[কোড ব্লক]
পর্যবেক্ষণ: [স্ট্যাক ব্যবহার, প্রতিটা variable-এর মেমরি লোকেশন, C লাইনের সাথে সরাসরি মিল]
### -O2 অ্যাসেম্বলি (মূল অংশ)
[কোড ব্লক]
কোন অপ্টিমাইজেশন ফায়ার করলো:
- [নাম] — [কেন এটা এখানে প্রযোজ্য, কী পরিবর্তন করলো]
- [নাম] — [...]
### -O3 অ্যাসেম্বলি (যদি -O2 থেকে আলাদা হয়)
[কোড ব্লক বা "কোনো পার্থক্য নেই" + কারণ]
### সারসংক্ষেপ টেবিল
| দিক | -O0 | -O2 | -O3 |
|---|---|---|---|
| Register allocation | ... | ... | ... |
| Stack ব্যবহার | ... | ... | ... |
| ফাংশন-কল টিকে আছে? | ... | ... | ... |
| ইন্সট্রাকশন সংখ্যা (আনুমানিক) | ... | ... | ... |
এই টেমপ্লেট তিনবার পূরণ করলে (তিনটা স্নিপেটের জন্য) পুরো প্রজেক্ট শেষ।
স্নিপেট ১ — Array-sum লুপ (auto-vectorization)
int sum_array(int *arr, int n) {
int total = 0;
for (int i = 0; i \< n; i++) {
total += arr[i];
}
return total;
}
-O0-এ কী আশা করবেন: প্রতিটা variable (total, i, arr, n) স্ট্যাকে একটা নির্দিষ্ট স্লট পাবে। প্রতিটা লুপ-ইটারেশনে i মেমরি থেকে লোড হবে, তুলনা হবে, arr[i] অ্যাক্সেস করতে arr-এর ঠিকানা লোড করে i-দিয়ে ইনডেক্স গণনা হবে, total-এ যোগ হয়ে আবার মেমরিতে স্টোর হবে — প্রতিটা C স্টেটমেন্ট প্রায় হুবহু কয়েকটা ইন্সট্রাকশনে অনুবাদ হবে, কোনো শর্টকাট ছাড়াই।
-O2/-O3-এ কী খুঁজবেন: এখানেই সবচেয়ে চোখে-পড়ার-মতো পরিবর্তন আসে — auto-vectorization। GCC/Clang লুপটা চিনে নেয় “একই অপারেশন বারবার independent ডেটার উপর” আর SIMD ইন্সট্রাকশন (SSE-তে paddd, AVX-তে vpaddd) ব্যবহার করে একসাথে ৪টা বা ৮টা int যোগ করে ফেলে, প্রতিটা আলাদা আলাদা না করে। এটা Computer Architecture module-এর SIMD টপিকের সরাসরি বাস্তব প্রমাণ — “একই ইন্সট্রাকশনে একাধিক ডেটা” এখানে compiler নিজে থেকে প্রয়োগ করছে, কোনো intrinsic না লিখেই। লক্ষ্য করার মতো জিনিস:
- লুপ বডিতে
xmm/ymmরেজিস্টার (SIMD রেজিস্টার) দেখা যাবে, সাধারণeax/edxনা - লুপ counter
n-এর multiple-of-4 (বা ৮) না হলে একটা “remainder loop” বা “scalar epilogue” যোগ হতে পারে — অবশিষ্ট ইলিমেন্ট এক-এক করে যোগ করার জন্য -O3-এ vectorization আরও আক্রমণাত্মক হতে পারে (wider vector, বেশি unrolling)-O2-এর তুলনায় — GCC-তে-O2কিছু vectorization করে,-O3আরও প্রসারিত করে (-ftree-vectorize-O3-এ ডিফল্ট-চালু,-O2-এ কিছু GCC ভার্সনে সীমিত)
স্নিপেট ২ — ছোট recursive ফাংশন (inlining, tail call)
int factorial(int n) {
if (n <= 1) {
return 1;
}
return n * factorial(n - 1);
}
int compute(int x) {
return factorial(x) + 1;
}
-O0-এ কী আশা করবেন: factorial প্রতিটা কলে একটা নতুন স্ট্যাক ফ্রেম বানাবে — call factorial ইন্সট্রাকশন সত্যিই থাকবে, n স্ট্যাকে সেভ হবে recursive কলের আগে (কারণ n * factorial(n-1)-এ factorial(n-1) রিটার্ন করার পরও n-এর পুরনো মান দরকার — এই কারণেই এটা tail call না, শেষ কাজটা multiply, রিটার্ন না)। compute-ও আলাদা একটা call factorial রাখবে।
-O2/-O3-এ কী খুঁজবেন:
-
Inlining —
computeছোট, আরfactorial(recursive হলেও) কম্পাইলার প্রায়ই partial বা সম্পূর্ণ inline করার চেষ্টা করে ছোটn-এর জন্য, নাহলে অন্ততcompute-এর ভিতরেfactorial-এর কল overhead কমানোর চেষ্টা দেখা যেতে পারে -
এটা tail-recursive না এটা লক্ষ্য করাই এই স্নিপেটের আসল শিক্ষা — যেহেতু recursive কলের ফলাফলকে
nদিয়ে গুণ করতে হয় রিটার্নের আগে, compiler এটাকে লুপে রূপান্তর করতে পারে না (tail-call optimization প্রযোজ্য না) — তাই-O2/-O3-এওcall/recursive স্ট্যাক-ব্যবহার টিকে থাকা উচিত, শুধু ফ্রেম ছোট ও দ্রুত হতে পারে -
তুলনা করার জন্য — এই ফাংশনটাকে ইচ্ছাকৃত accumulator-প্যাটার্নে (tail-recursive) নতুন করে লিখলে —
int fact_helper(int n, int acc) { if (n <= 1) return acc; return fact_helper(n - 1, n * acc); }—
-O2-এ compiler সেটাকে লুপে রূপান্তর করে ফেলতে পারে — কোনোcallইন্সট্রাকশনই আর থাকবে না। দুটো সংস্করণের-O2আউটপুট পাশাপাশি রাখলে tail-call optimization ঠিক কী করে তা স্পষ্ট দেখা যায় — এটা একটা ভালো “নিজেকে চ্যালেঞ্জ করুন” সংযোজন
স্নিপেট ৩ — Dead branch (dead code elimination)
int check(int x) {
int flag = 0;
if (flag) {
return x * 999;
}
return x + 1;
}
-O0-এ কী আশা করবেন: এখানে compiler কিছুই “বুঝবে না” — flag স্ট্যাকে সেট হবে 0, তারপর cmpl $0, flag আর je/jne দিয়ে সত্যিকারের একটা branch হবে, এমনকি x * 999 কম্পিউট করার কোডও অ্যাসেম্বলিতে থাকবে, যদিও রানটাইমে সেই পথ কখনো নেওয়া হবে না। -O0-এর কাজ হলো দ্রুত, predictable কোড জেনারেট করা — এটা কোনো constant-propagation বা branch analysis করে না।
-O2/-O3-এ কী খুঁজবেন: এখানে dead code elimination আর constant propagation একসাথে কাজ করে:
- Compiler দেখে
flagকখনো0ছাড়া অন্য কিছু হয় না (constant propagation), তাইif (flag)সবসময় মিথ্যা - একবার এটা জানা হয়ে গেলে,
return x * 999;-এর পুরো ব্লকটা কখনো চলবে না বলে সম্পূর্ণ মুছে ফেলা হয় (dead code elimination) — জেনারেট করা অ্যাসেম্বলিতে999-এর কোনো চিহ্নই থাকবে না - ফাংশনটা কার্যত
return x + 1;-এ সংকুচিত হয়ে যায় — কোনোcmp, কোনো branch, কিছুই না, শুধু একটাaddআরret - এই ফাংশনটাই এত ছোট হয়ে যায় যে
compute-এর মতো কোনো caller থাকলে সেটাও সম্পূর্ণ inline হয়ে যেতে পারে — অর্থাৎ ফাংশনটাই “উবে” যেতে পারে, শুধু dead branch না, পুরো call-ই
একটা পূর্ণাঙ্গ উদাহরণ — টেমপ্লেট পূরণ করে দেখানো
স্নিপেট ৩ (check) সবচেয়ে ছোট, তাই এটার উপরেই উপরের রিপোর্ট-টেমপ্লেট সম্পূর্ণভাবে পূরণ করে দেখানো হলো — একটা রিপোর্ট এন্ট্রি ঠিক কেমন দেখতে হওয়া উচিত তার একটা concrete নমুনা হিসেবে। x86-64, System V ABI, GCC-স্টাইল কোডজেন ধরে লেখা হয়েছে (নির্দিষ্ট GCC ভার্সনে stack offset বা রেজিস্টার বাছাই সামান্য ভিন্ন হতে পারে, কিন্তু গঠন ও instruction সিলেকশনের যুক্তি এইরকমই থাকবে)।
## স্নিপেট: check (dead branch elimination)
### C সোর্স
int check(int x) {
int flag = 0;
if (flag) return x * 999;
return x + 1;
}
# -O0
check:
pushq %rbp
movq %rsp, %rbp
movl %edi, -20(%rbp) # x সংরক্ষণ
movl $0, -4(%rbp) # flag = 0
cmpl $0, -4(%rbp) # if (flag)
je .L2
movl -20(%rbp), %eax
imull $999, %eax, %eax # x * 999 (কখনো execute হয় না)
jmp .L3
.L2:
movl -20(%rbp), %eax
addl $1, %eax # x + 1
.L3:
popq %rbp
ret
পর্যবেক্ষণ (-O0): x আর flag দুইজনেই স্ট্যাকে (-20(%rbp), -4(%rbp)) নির্দিষ্ট স্লট পেয়েছে। if (flag) সত্যিকারের cmpl+je জোড়া হয়ে গেছে। x * 999 কম্পিউট করার imull ইন্সট্রাকশনটা অ্যাসেম্বলিতে আছে, যদিও রানটাইমে কখনো চলে না — compiler এখানে কোনো বিশ্লেষণই করেনি, শুধু C-কে লাইন-বাই-লাইন অনুবাদ করেছে।
# -O2 / -O3 (দুটোই এখানে অভিন্ন)
check:
leal 1(%rdi), %eax # eax = edi + 1 (x আর্গুমেন্ট edi-তে আসে, ফলাফল eax-এ)
ret
কোন অপ্টিমাইজেশন ফায়ার করলো:
- Constant propagation —
flagকখনো0ছাড়া কিছু হয় না বলে ধরে নেওয়া হলো, তাইif (flag)compile-time-এfalse-এ পরিণত হলো - Dead code elimination —
return x * 999;ব্লকটা কখনো পৌঁছানো যায় না বলে সম্পূর্ণ মুছে ফেলা হলো —999সংখ্যাটার কোনো চিহ্নই বাকি নেই - Stack allocation বাদ —
flag-এর আর অস্তিত্বই নেই (কোনো মেমরি লাগেনি),x-ও স্ট্যাকে না গিয়ে সরাসরি আর্গুমেন্ট রেজিস্টার (%rdi) থেকে ব্যবহার হয়েছে leaকে arithmetic শর্টকাট হিসেবে ব্যবহার —addl $1, %eaxলেখার বদলেleal 1(%rdi), %eaxএকটাই ইন্সট্রাকশনে “read edi, add 1, write eax” করে ফেলছে, flags touch না করেই
-O3 এখানে -O2-এর থেকে আলাদা কিছু করেনি — কারণটা যৌক্তিক: ফাংশনটা এত ছোট আর সরল যে -O3-এর অতিরিক্ত অপ্টিমাইজেশন (আরও আক্রমণাত্মক inlining, loop transformation, vectorization) প্রয়োগ করার মতো কোনো জায়গাই বাকি নেই — -O2-ই ইতিমধ্যে সম্ভাব্য সর্বনিম্ন ফর্মে পৌঁছে গেছে। “কোনো পার্থক্য নেই কেন” এই প্রশ্নের উত্তর নিজেই একটা বৈধ, দরকারি পর্যবেক্ষণ — খালি রেখে দেওয়া না।
| দিক | -O0 | -O2 | -O3 |
|---|---|---|---|
| Register allocation | x, flag স্ট্যাকে | x সরাসরি %rdi-তে, flag নেই | -O2-এর অভিন্ন |
| Stack ব্যবহার | ২টা স্লট (x, flag) + frame pointer | কোনো স্ট্যাক ফ্রেমই নেই | -O2-এর অভিন্ন |
| ফাংশন-কল টিকে আছে? | N/A (কোনো কল নেই এই স্নিপেটে) | N/A | N/A |
| ইন্সট্রাকশন সংখ্যা (আনুমানিক) | ১১ | ২ | ২ |
নিজেকে চ্যালেঞ্জ করুন
-Os(size অপ্টিমাইজেশন) যোগ করুন তুলনায় —-O2/-O3-এর তুলনায় কোথায় ভিন্ন সিদ্ধান্ত নেয় (যেমন aggressive inlining এড়িয়ে যাওয়া, কারণ inlining প্রায়ই কোড সাইজ বাড়ায় যদিও দ্রুত করে)- Tail-call সংস্করণ যোগ করুন — স্নিপেট ২-এর accumulator-প্যাটার্ন সংস্করণ লিখে দেখান tail-call optimization ঠিক কোন শর্তে ফায়ার করে
- Clang বনাম GCC তুলনা করুন — একই স্নিপেট দুটো ভিন্ন কম্পাইলারে একই optimization level-এ চালিয়ে দেখুন সিদ্ধান্তগুলো কতটা মেলে — Compiler Explorer-এ দুটোই পাশাপাশি বসানো যায়
- একটা “compiler-defeating” স্নিপেট খুঁজুন — এমন কোনো কোড লিখুন যেখানে
-O3আসলে-O2-এর চেয়ে ধীর কোড জেনারেট করে (aggressive inlining/unrolling মাঝেমধ্যে instruction-cache pressure বাড়িয়ে দেয়) — বিরল কিন্তু বাস্তব, আর কেন হলো তার একটা অনুমান লিখুন perf statদিয়ে থিওরি যাচাই করুন — যদি লিনাক্স মেশিন থাকে, তিনটা বাইনারিই আসলে চালিয়েperf stat-এ instruction count/cycle count মিলিয়ে দেখুন অ্যাসেম্বলি-পড়ে করা অনুমান বাস্তবে সত্যি কি না
এটা যেখানে গিয়ে মিশবে
| এখানে যা শিখলেন | পরে কোথায় লাগবে |
|---|---|
| Auto-vectorization চেনা | Level 3 — Computer Architecture module-এর SIMD টপিক, Level 11-এর performance tuning |
| Inlining ও dead code elimination | Level 5 — Compilers module-এর optimization pass ডিজাইন |
| Tail-call optimization | Level 5 — Programming Languages module-এর evaluation strategies, functional language-এ recursion |
| Optimization level অনুযায়ী register allocation পার্থক্য | এই মডিউলেরই বাকি প্রজেক্ট — নিজের-লেখা অ্যাসেম্বলিতে একই কৌশল ম্যানুয়ালি প্রয়োগ করার বোঝাপড়া |
| “compiler ব্ল্যাক-বক্স না, predictable টুল” এই মানসিকতা | Level 10 — Security module-এর reverse engineering, optimized বাইনারি পড়া |