Compiler Output পড়া — -O0 বনাম -O2
Reading Real Compiler Output — -O0 vs -O2
এই মডিউলের driving question-এর সরাসরি উত্তর — আমার C function compile হয়ে ঠিক কোন instruction হলো, আর কেন। একই function-এর -O0 (আক্ষরিক, ধীর, কিন্তু predictable) আর -O2 (register-ভিত্তিক, দ্রুত, কিন্তু debugger-এ বিভ্রান্তিকর) output পাশাপাশি রেখে প্রতিটা পার্থক্যের একটা নির্দিষ্ট কারণ খুঁজে বের করব — আর দেখব কেন দুটোই তাদের নিজস্ব প্রসঙ্গে সঠিক পছন্দ।
আগে এটা বুঝি
এই মডিউলটা একটা প্রশ্ন দিয়ে শুরু হয়েছিল: আমার C function compile হয়ে ঠিক কোন instruction-গুলো হলো, আর কেন?
গত ছয়টা লেসনে আমরা টুকরো টুকরো উত্তর জমা করেছি — register কীভাবে কাজ করে, stack frame কীভাবে বসে, control flow কীভাবে jump-এ রূপান্তরিত হয়, memory operand কীভাবে ঠিকানা গণনা করে। এখন সময় হয়েছে সবকিছু একসাথে দেখার — একটা সম্পূর্ণ function, দুইটা ভিন্ন compilation flag-এ, পাশাপাশি।
int sum_array(const int *arr, int n) {
int total = 0;
for (int i = 0; i \< n; i++) {
total += arr[i];
}
return total;
}এটা একটা সাধারণ, ছোট্ট function — একটা array যোগ করে। এখন এটা
gcc -O0 দিয়ে কম্পাইল করলে যা পাবেন, আর gcc -O2 দিয়ে কম্পাইল
করলে যা পাবেন — দুটো এতটাই আলাদা যে মনে হতে পারে দুইটা ভিন্ন
function। একই ইনপুটে একই আউটপুট দেবে দুটোই — কিন্তু পথ সম্পূর্ণ
ভিন্ন।
এই লেসনের কাজ সেই পার্থক্যের প্রতিটা লাইন ব্যাখ্যা করা। কোনো লাইনই কাকতালীয় না — প্রতিটা compiler-এর একটা সুনির্দিষ্ট সিদ্ধান্তের ফলাফল, আর প্রতিটা সিদ্ধান্তের একটা নির্দিষ্ট কারণ আছে।
মূল ধারণা
Optimization level আসলে কী
-O0, -O1, -O2, -O3 — এগুলো compiler-কে বলে দেয় কতগুলো
optimization pass সক্রিয় করতে হবে। প্রতিটা pass একটা নির্দিষ্ট
রূপান্তর — dead code সরানো, common subexpression একবারই গণনা করা,
loop-এর গঠন বদলানো, variable-কে register-এ রাখা। GCC-তে শতাধিক
আলাদা pass আছে; -O-flag গুলো এদের পূর্বনির্ধারিত সংকলন মাত্র।
| Flag | দর্শন | Compile সময় | Runtime গতি | Debug করা কতটা সহজ |
|---|---|---|---|---|
-O0 | কোনো optimization না — ডিফল্ট | সবচেয়ে দ্রুত | সবচেয়ে ধীর | সবচেয়ে সহজ |
-O1 | মৌলিক optimization | দ্রুত | মাঝারি | মোটামুটি |
-O2 | প্রায় সব “নিরাপদ” optimization, code size না বাড়িয়ে | মাঝারি | দ্রুত | কঠিন |
-O3 | -O2 + আক্রমণাত্মক (loop unrolling, auto-vectorization) | ধীর | সাধারণত দ্রুততম | কঠিনতর |
-Os | size-এর জন্য optimize, speed না | মাঝারি | পরিবর্তনশীল | কঠিন |
-Og | debugging-friendly optimization | দ্রুত | মাঝারি | তুলনামূলক সহজ |
-O0-এর “কোনো optimization না” মানে literal। কম্পাইলার সোর্স
কোডের প্রতিটা statement আক্ষরিকভাবে, প্রায় এক-এক করে, একটা নিরাপদ
কিন্তু নিষ্পাপ (naive) পদ্ধতিতে translate করে — কোনো variable-কে
সে “মনে রাখার” চেষ্টা করে না, প্রতিটা statement-এর পরেই সব কিছু
memory-তে লিখে ফেলে।
-O2 সম্পূর্ণ ভিন্ন লক্ষ্য রাখে — সম্পূর্ণ function-টাকে একসাথে
দেখে, একই ফলাফল যতটা সম্ভব কম কাজে বের করার চেষ্টা করে। Variable
যতক্ষণ সম্ভব register-এ থাকে, বারবার গণনা করা এক্সপ্রেশন একবারই
গণনা হয়, অপ্রয়োজনীয় শাখা সরানো হয়।
ভেতরে কী ঘটছে
-O0 output — সম্পূর্ণ, লাইন বাই লাইন
gcc -O0 -S sum.c চালালে বাস্তবে যা পাওয়া যায় (x86-64, System V,
arr আসে %rdi-তে, n আসে %esi-তে):
sum_array:
pushq %rbp
movq %rsp, %rbp
movq %rdi, -24(%rbp)
movl %esi, -28(%rbp)
movl $0, -4(%rbp)
movl $0, -8(%rbp)
jmp .L2
.L3:
movl -8(%rbp), %eax
cltq
leaq 0(,%rax,4), %rdx
movq -24(%rbp), %rax
addq %rdx, %rax
movl (%rax), %eax
addl %eax, -4(%rbp)
addl $1, -8(%rbp)
.L2:
movl -8(%rbp), %eax
cmpl -28(%rbp), %eax
jl .L3
movl -4(%rbp), %eax
popq %rbp
retপুরো ২১ লাইন একটা তিন-লাইনের loop-এর জন্য। কেন এত verbose?
প্রতিটা variable-এর জন্য একটা fixed stack slot
movq %rdi, -24(%rbp) # arr → স্ট্যাক frame-এর একটা fixed জায়গা
movl %esi, -28(%rbp) # n → আরেকটা fixed জায়গা
movl $0, -4(%rbp) # total → আরেকটা
movl $0, -8(%rbp) # i → আরেকটাফাংশন প্যারামিটার System V convention অনুযায়ী register-এ আসে
(%rdi, %esi), কিন্তু -O0 সাথে সাথে সেগুলো স্ট্যাকে
কপি করে দেয়। এমনকি total আর i, যাদের কোনো caller নেই — সরাসরি
দিয়ে initialize হচ্ছে সরাসরি স্ট্যাক memory-তে, কোনো register
হয়ে না।
কেন। -O0-এ কম্পাইলার ধরে নেয় প্রতিটা C variable-এর একটাই,
স্থায়ী “ঠিকানা” থাকবে সম্পূর্ণ function জুড়ে — একটা register না,
কারণ register allocation নিজেই একটা optimization pass যা -O0-এ
বন্ধ। এই সিদ্ধান্তের একটা সরাসরি সুবিধা আছে যা নিচে “debugging”
অংশে ব্যাখ্যা হবে।
প্রতিটা array access সম্পূর্ণ পুনরায় গণনা
Loop body-র প্রথম চার লাইন লক্ষ্য করুন:
movl -8(%rbp), %eax # eax = i (স্ট্যাক থেকে আবার পড়া)
cltq # rax = sign-extend(eax)
leaq 0(,%rax,4), %rdx # rdx = i * 4
movq -24(%rbp), %rax # rax = arr (স্ট্যাক থেকে আবার পড়া)
addq %rdx, %rax # rax = arr + i*4
movl (%rax), %eax # eax = *(arr+i*4) = arr[i]গত লেসনে (addressing-and-pointers) আমরা দেখেছিলাম arr[i] একটা
একক instruction-এ (movl (%rdi,%rax,4), %eax) SIB byte দিয়ে
এনকোড হতে পারে — base, index, scale সবই memory operand-এর ভেতর।
কিন্তু এখানে -O0 সেই সুযোগ ব্যবহার করেনি — প্রতিটা sub-expression
(i পড়া, sign-extend করা, ×4 করা, arr পড়া, যোগ করা, dereference
করা) আলাদা আলাদা instruction-এ, প্রতিটা মধ্যবর্তী ফলাফল register-এ
বসিয়ে।
Loop-এর গঠন — condition-first, তবু test নিচে
movl $0, -8(%rbp) # i = 0
jmp .L2 # সরাসরি condition check-এ লাফ
.L3:
; ... body ...
addl $1, -8(%rbp) # i++
.L2:
movl -8(%rbp), %eax
cmpl -28(%rbp), %eax
jl .L3 # if i \< n, আবার body-তেC-তে for loop condition-প্রথম (body চালানোর আগেই i \< n চেক হয়)।
এখানে প্রথম jmp .L2 সেই semantics ধরে রাখে — body-তে না গিয়ে
সরাসরি condition check-এ যায়। এরপর body-র শেষে পড়ে যায় .L2-তে,
যেখানে condition আবার চেক হয়ে .L3-এ ফিরে যায় যদি সত্য হয়।
এই “condition একবার প্রথমে জাম্প করে, তারপর নিচে সবসময়” প্যাটার্নটা
এমনকি -O0-এই দেখা যায় — এটা optimization না, এটা শুধু একটা uniform
compilation strategy যা প্রতিটা loop-এই কম্পাইলার নিঃশর্তভাবে
প্রয়োগ করে (control-flow-in-assembly লেসনের সেই “loop = condition
- backward jump” প্যাটার্নের একটা variant)।
-O2 output — একই function, সম্পূর্ণ ভিন্ন আকার
এখন একই sum.c, gcc -O2 -S:
sum_array:
testl %esi, %esi
jle .L4
movl %esi, %edx
xorl %eax, %eax
xorl %ecx, %ecx
.L3:
addl (%rdi,%rax,4), %ecx
addq $1, %rax
cmpq %rax, %rdx
jne .L3
movl %ecx, %eax
ret
.L4:
xorl %eax, %eax
ret১৩ লাইন, কোনো স্ট্যাক অ্যাক্সেস নেই, কোনো %rbp ফ্রেম নেই। প্রতিটা
পার্থক্য এক এক করে দেখি।
১. কোনো stack frame নেই
pushq %rbp / movq %rsp, %rbp / popq %rbp — সব উধাও। কেন?
এই function-এ local variable-এর কোনো ঠিকানা কখনো নেওয়া হয় না
(&total বা &i কোথাও ব্যবহৃত হয়নি), আর কোনো nested function call
নেই যার জন্য একটা স্থিতিশীল frame pointer দরকার — তাই কম্পাইলার
পুরো frame-setup-ই বাদ দিয়েছে। total আর i এখন শুধুই register-এর
মান, কোনো memory ঠিকানা নেই — তাই frame pointer রাখার কোনো কারণ
নেই।
২. n <= 0 কেসটা আলাদা করে ফেলা হলো
testl %esi, %esi
jle .L4এটা মূল loop-এর আগেই — n যদি ০ বা negative হয়, সরাসরি .L4-এ
লাফ দিয়ে ০ রিটার্ন করে, মূল loop-এ ঢোকেই না। C-এর for (i=0; i<n; i++)-এ এই কেসটা “loop body ০ বার চলে” হিসেবে স্বাভাবিকভাবেই
হ্যান্ডেল হতো, কিন্তু এভাবে আলাদা করে ফেললে মূল loop-টাকে (নিচে)
আরও সরল রাখা যায় — একটা edge-case check প্রতিটা iteration-এ বারবার
না করে, একবারই, শুরুতে।
৩. Total ও i সম্পূর্ণ loop জুড়ে register-এ থাকে
xorl %eax, %eax # i = 0 (rax)
xorl %ecx, %ecx # total = 0 (ecx)
.L3:
addl (%rdi,%rax,4), %ecx # total += arr[i]
addq $1, %rax # i++%rax (index/counter) আর %ecx (accumulator) — এই দুইটা register
পুরো loop-এর জীবনভর একই জায়গায় থাকে। কোনো স্ট্যাক write/read নেই
কোথাও loop-এর ভেতরে। এটাই register allocation-এর মূল কাজ — প্রতিটা
frequently-used variable-কে একটা register-এ “বাঁধা” যতক্ষণ সম্ভব,
memory access সম্পূর্ণ এড়িয়ে।
৪. Load আর add একটা instruction-এ fuse হয়ে গেছে
addl (%rdi,%rax,4), %ecx-O0-এ এই একই কাজ লেগেছিল ছয়টা instruction (arr[i] পড়তে) +
একটা addl (যোগ করতে) = সাত। এখানে একটা instruction — x86-64-এর
ALU instruction-গুলোর একটা operand সরাসরি memory হতে পারে (গত
লেসনের addressing mode, এখানে source operand হিসেবে ব্যবহৃত)। CPU
নিজেই memory থেকে পড়ে, %ecx-এর সাথে যোগ করে, %ecx-এ ফেরত লেখে
— সব এক instruction fetch/decode-এ।
৫. i কখনো sign-extend করতে হয়নি
-O0-এ cltq (sign-extend) প্রতিটা iteration-এ লাগত, কারণ i
একটা ৩২-বিট int হিসেবে স্ট্যাকে বসানো ছিল, প্রতিবার আবার পড়ে
৬৪-বিটে বদলাতে হতো। -O2-এ কম্পাইলার লক্ষ্য করেছে — i শুধু
0 থেকে গুণে গুণে বাড়ে, আর সরাসরি একটা ৬৪-বিট register-এ (%rax)
রাখা যায় শুরু থেকেই, কোনো ৩২-বিট আকারে বাঁধা না রেখে। ফলে sign-extension-এর
প্রয়োজনই আর নেই — এটা একটা classic strength reduction/induction
variable optimization।
৬. Return value-এর জন্য একটা শেষ কপি
movl %ecx, %eax
retx86-64 System V-তে return value যেতেই হবে %eax-এ, কিন্তু accumulator
রাখা হয়েছিল %ecx-এ (কারণ %eax/%rax ইতিমধ্যে index-এর জন্য
ব্যবহৃত হচ্ছিল লুপের ভেতর) — তাই শেষে একটা movl লাগলো। এটাই একমাত্র
জায়গা যেখানে register allocator-এর সিদ্ধান্তের একটা ছোট “দাম”
দেখা যাচ্ছে — সম্পূর্ণ বিনামূল্যে optimization হয় না, প্রতিটা
সিদ্ধান্তে trade-off থাকে।
| -O0 | -O2 | |
|---|---|---|
| মোট instruction | ১৮ | ১১ |
| স্ট্যাক access | প্রতি iteration-এ ৫ বার | ০ বার |
| Register ব্যবহৃত | %rax, %rdx (অস্থায়ী) | %rax, %rcx, %rdx, %rdi, %rsi (সব অর্থবহ, স্থায়ী ভূমিকায়) |
| Frame pointer | আছে (%rbp) | নেই |
| Edge case (n≤0) | loop condition-এর মধ্যেই | আলাদা fast-path |
বোনাস — -O3-এ কী হতে পারে
-O2 GCC-তে loop vectorization (-ftree-vectorize) সক্রিয় করে
না — এটা -O3-এর অংশ (Clang-এ নিয়ম ভিন্ন, সেখানে -O2-ই
vectorize করতে পারে)। এই function যদি -O3-এ কম্পাইল করা হয়,
computer-architecture module-এর SIMD লেসনের ধারণাগুলো সরাসরি চোখে
পড়বে — কোরের অংশ:
pxor %xmm0, %xmm0
.L6:
movdqu (%rdi,%rax,4), %xmm1
addq $4, %rax
paddd %xmm1, %xmm0
cmpq %rax, %rdx
jne .L6
movdqa %xmm0, %xmm1
psrldq $8, %xmm1
paddd %xmm1, %xmm0
movdqa %xmm0, %xmm1
psrldq $4, %xmm1
paddd %xmm1, %xmm0
movd %xmm0, %eaxmovdqu/paddd — একটা instruction চারটা int একসাথে যোগ করছে
(SIMD, ১২৮-বিট %xmm0 register)। শেষের তিনটা psrldq/paddd জোড়া
horizontal reduction — চার-উপাদানের vector-কে একটা scalar-এ নামানো
(shift করে অর্ধেক নিজের সাথে যোগ, দুইবার)।
এই লেসনের মূল ফোকাস -O0 বনাম -O2-ই থাকবে, কারণ সেই পার্থক্যটাই
সবচেয়ে সাধারণ ও শেখার জন্য সবচেয়ে স্পষ্ট। Vectorization একটা
সম্পূর্ণ আলাদা মাত্রার optimization — SIMD লেসনে এটা বিস্তারিত।
উদাহরণ
দ্বিতীয় উদাহরণ — branch elimination (cmov)
Register allocation আর instruction fusion ছাড়াও -O2 মাঝে মাঝে
branch-ই সম্পূর্ণ সরিয়ে দেয়। এটা দেখতে একটা ভিন্ন ধরনের function
দরকার — একটা যেখানে conditional logic আছে, loop না:
int clamp(int x, int lo, int hi) {
if (x \< lo) return lo;
if (x > hi) return hi;
return x;
}-O0:
clamp:
pushq %rbp
movq %rsp, %rbp
movl %edi, -4(%rbp)
movl %esi, -8(%rbp)
movl %edx, -12(%rbp)
movl -4(%rbp), %eax
cmpl -8(%rbp), %eax
jge .L2
movl -8(%rbp), %eax
jmp .L4
.L2:
movl -4(%rbp), %eax
cmpl -12(%rbp), %eax
jle .L3
movl -12(%rbp), %eax
jmp .L4
.L3:
movl -4(%rbp), %eax
.L4:
popq %rbp
retসোর্স কোডের দুইটা if-ই দুইটা cmp+conditional-jump জোড়ায় সরাসরি
translate হয়েছে — control-flow-in-assembly লেসনের প্যাটার্নই,
কোনো surprise নেই।
-O2:
clamp:
movl %edi, %eax
cmpl %esi, %edi
cmovl %esi, %eax
cmpl %edx, %eax
cmovg %edx, %eax
retএকটাও jmp বা conditional jump নেই। cmovl (move if less)
আর cmovg (move if greater) — conditional move instruction —
branch না নিয়েই condition-নির্ভর একটা assignment করে ফেলে:
cmpl %esi, %edi→ flags সেট হয়x - lo-এর ভিত্তিতেcmovl %esi, %eax→ যদিx \< lo(flags অনুযায়ী),%eax ← %esi (lo); নাহলে%eaxঅপরিবর্তিত থাকে (এখনোx)cmpl %edx, %eax→ flags আবার সেট হয়, এবার বর্তমান%eax-এর ভিত্তিতেcmovg %edx, %eax→ যদি%eax > hi,%eax ← %edx (hi)
নিজে চালিয়ে দেখুন
Compiler Explorer-এ -O0/-O2/-O3 পাশাপাশি
১. godbolt.org-এ যান, বাম প্যানেলে এই লেসনের sum_array ফাংশন
পেস্ট করুন।
2. Compiler হিসেবে x86-64 gcc বেছে নিন, প্রথমে flag বক্সে -O0
লিখুন। ডান প্যানেলে assembly output আসবে — এই লেসনের -O0
listing-এর সাথে মিলিয়ে দেখুন (ঠিক না মিললেও কাছাকাছি হবে —
GCC version ভেদে সামান্য পার্থক্য স্বাভাবিক)।
3. একই উইন্ডোতে দ্বিতীয় একটা compiler প্যান খুলুন (Godbolt-এর ”+”
বাটন), সেখানে flag -O2 দিন। দুইটা output পাশাপাশি।
4. Godbolt-এর color-coding লক্ষ্য করুন — সোর্স-এর একটা লাইনে cursor
রাখলে সেই লাইনের সাথে সংশ্লিষ্ট assembly instruction highlight
হয়। -O0-এ প্রতিটা সোর্স লাইনের একটা পরিষ্কার, contiguous
instruction-block দেখবেন। -O2-এ instruction-গুলো মিশে গেছে
— একটা মাত্র instruction একাধিক সোর্স লাইনের প্রতিনিধিত্ব
করছে (যেমন addl (%rdi,%rax,4), %ecx — array read আর addition,
দুইটা আলাদা সোর্স-অপারেশন, একসাথে)।
5. Flag বদলে -O3 করুন, vectorized output (movdqu/paddd)
খুঁজুন। যদি না আসে, n compile-time constant না হওয়ার কারণে
বা GCC version-এর কারণে হতে পারে — -O3 -fopt-info-vec যোগ
করে GCC-কে জিজ্ঞেস করুন কেন vectorize করলো না বা করলো।
একই সোর্স কোড, শুধু flag বদলে, instruction সংখ্যা ও ধরন কতটা নাটকীয়ভাবে বদলে যায় — আর সেই পার্থক্যের প্রতিটা কারণ এই লেসনে যা শেখা হয়েছে তার সাথে মেলে।
-fverbose-asm দিয়ে লোকালি সোর্স-লাইন mapping দেখুন
gcc -O0 -S -fverbose-asm sum.c -o sum_o0.s
gcc -O2 -S -fverbose-asm sum.c -o sum_o2.s
diff -u sum_o0.s sum_o2.s | less-fverbose-asm প্রতিটা instruction-এর পাশে একটা comment যোগ করে
দেয় — কোন variable, কোন register। -O0 ফাইলে প্রায় প্রতিটা
instruction-এর নিজস্ব comment থাকবে (# total, # i)। -O2
ফাইলে অনেক কম, আর কিছু instruction-এ একাধিক variable-এর নাম একসাথে
থাকতে পারে (fusion-এর প্রমাণ)।
diff output-এ লক্ষ্য করুন কতগুলো লাইন -O0-এ আছে যা -O2-এ
সম্পূর্ণ অনুপস্থিত — প্রতিটা অনুপস্থিত লাইনই একটা “eliminated”
memory access।
Compiler নিজেই বলে দিতে পারে কোন instruction কোন C variable-এর সাথে সম্পর্কিত — এটা compiler output পড়ার একটা বাস্তব সহায়ক টুল, শুধু চোখে অনুমান করা না।
নিজে বানান
নিজের Compiler Output Autopsy
- নিজের একটা ছোট function লিখুন — একটা loop বা কয়েকটা conditional থাকা উচিত (৫-১৫ লাইন C কোড)
- gcc -O0 -S আর gcc -O2 -S দুইটা আলাদা ফাইলে output নিন
- দুইটা assembly ফাইল পাশাপাশি রেখে প্রতিটা পার্থক্যের একটা তালিকা বানান
- প্রতিটা পার্থক্যকে এই লেসনের চারটা category-র একটায় ফেলুন — register allocation, instruction fusion, branch elimination, নাকি loop restructuring
- যেই category-তে পড়ে না এমন কোনো পার্থক্য পেলে, সেটা আলাদা করে লিখে রাখুন — নিজে অনুসন্ধান করুন কেন হলো
- একটা ছোট রিপোর্ট লিখুন (আধা পাতা) — instruction সংখ্যার তুলনা, আর সবচেয়ে চমকপ্রদ পার্থক্যটা কী ছিল
Function বাছাইয়ের পরামর্শ। সবচেয়ে শিক্ষণীয় ফলাফল পান এমন
function-এ যেখানে (ক) একটা array বা pointer আছে (addressing mode
observation-এর জন্য), (খ) অন্তত একটা if বা min/max-জাতীয়
logic আছে (branch elimination দেখার সুযোগ), (গ) একটা loop আছে
(register allocation পার্থক্য সবচেয়ে নাটকীয় loop-এই)।
সতর্কতা — GCC version পার্থক্য। এই লেসনের সব listing একটা সাধারণ, প্রতিনিধিত্বমূলক GCC output — আপনার লোকাল GCC version-এ হুবহু না মিললেও চিন্তার কিছু নেই, instruction-এর ধরন (register allocation, fusion) একই থাকবে, শুধু exact register নাম বা label নম্বর ভিন্ন হতে পারে। Clang-এ চেষ্টা করলে আরও বেশি পার্থক্য দেখবেন — GCC আর Clang-এর optimization heuristic ভিন্ন, একই সোর্সে দুইটা compiler ভিন্ন (কিন্তু semantically সমতুল্য) output দিতেই পারে।
বাস্তব সিস্টেমে
Optimization level বাস্তব সিদ্ধান্তে
Debug build বনাম release build। প্রায় প্রতিটা বড় C/C++ প্রজেক্টের
বিল্ড সিস্টেমে দুইটা profile — Debug (-O0 -g) আর Release
(-O2/-O3 -DNDEBUG)। CMake-এর ডিফল্ট CMAKE_BUILD_TYPE=Debug
ঠিক এই কারণেই -O0 ব্যবহার করে — developer-এর breakpoint আর
variable-inspection experience এই লেসনের “প্রতিটা variable-এর
fixed stack slot” গুণটার উপর নির্ভর করে।
-Og — একটা মধ্যপন্থা। GCC 4.8 (২০১৩) থেকে -Og যোগ হয়েছে —
কিছু নিরাপদ optimization চালু (compile দ্রুত, runtime -O0-এর
চেয়ে ভালো) কিন্তু debug-experience নষ্ট করে এমন pass (aggressive
inlining, ভারী instruction reordering) বন্ধ রাখে। বড় codebase-এ
যেখানে -O0 অসহনীয় ধীর (interpreter-জাতীয় বড় প্রোগ্রাম), -Og
প্রায়ই ব্যবহারিক সমাধান।
Production-এ profiler-এর জন্য frame pointer রাখা। এই লেসনের
-O2 output-এ %rbp frame pointer সম্পূর্ণ উধাও হয়ে যাওয়া
observation করেছি। কিন্তু perf-জাতীয় sampling profiler প্রায়ই
frame pointer দিয়ে stack unwind করে দ্রুত backtrace বানায় —
-O2-এর ডিফল্ট আচরণ (-fomit-frame-pointer, -O2-এর অংশ)
সেই backtrace-কে অনির্ভরযোগ্য করে দেয়। তাই performance-critical
production সার্ভিস প্রায়ই স্পষ্টভাবে -fno-omit-frame-pointer
যোগ করে — সামান্য runtime খরচে profiling সহজ রাখার জন্য (Google,
Facebook-এর মতো কোম্পানি এটা ডিফল্ট নীতি হিসেবে ব্যবহার করে)।
Undefined behavior optimization-কে বিপজ্জনক করে তোলে। কম্পাইলার
-O2-এ ধরে নেয় সোর্স কোডে কোনো undefined behavior (UB) নেই —
signed integer overflow, null pointer dereference, uninitialized
read। যদি আসলে থাকে, -O0-এ হয়তো “কাকতালীয়ভাবে” প্রত্যাশিত ফল
দিত, কিন্তু -O2-এ কম্পাইলার সেই UB-নির্ভর কোড path-কে সম্পূর্ণ
আলাদাভাবে optimize করতে পারে — dead code হিসেবে সরিয়েও দিতে পারে।
এই কারণেই “-O0-এ কাজ করে, -O2-এ crash করে” — এটা সাধারণত
optimizer-এর দোষ না, সোর্স কোডে লুকানো UB-র লক্ষণ।
Profile-Guided Optimization (PGO)। -O2/-O3-এর পরেও আরেকটা
স্তর আছে — প্রথমে একটা instrumented build চালিয়ে বাস্তব ব্যবহারের
ডেটা জোগাড় করা (কোন branch কতবার নেওয়া হয়, কোন function hot),
তারপর সেই ডেটা দিয়ে দ্বিতীয়বার কম্পাইল করা। Clang/GCC দুটোই সমর্থন
করে (-fprofile-generate/-fprofile-use)। Chrome, Python interpreter-এর
মতো বড় প্রজেক্ট PGO দিয়ে ৫-১৫% পর্যন্ত গতি বাড়ায় — কারণ compiler
জানে বাস্তবে কোন branch predictable, কোন function inline করা
লাভজনক।
Link-Time Optimization (LTO)। -O2/-O3 প্রতিটা .c ফাইল
আলাদাভাবে দেখে optimize করে। -flto পুরো প্রোগ্রামকে একসাথে দেখার
সুযোগ দেয় (একটা function অন্য ফাইলে থাকলেও inline করা যায়) —
এই লেসনের সব optimization (register allocation, fusion) তখন
file-boundary পেরিয়েও কাজ করে।
JIT compiler-ও একই trade-off মোকাবেলা করে। JVM-এর HotSpot বা
JavaScript engine-এর V8 প্রথমে interpreted/lightly-compiled কোড
চালায় (দ্রুত শুরু, -O0-এর মতো), তারপর “hot” code চিহ্নিত করে
আক্রমণাত্মকভাবে recompile করে (-O2/-O3-এর মতো) — compile সময়
আর runtime গতির একই trade-off, শুধু runtime-এ ঘটে, build-time-এ
না।
যে ভুলগুলো সবাই করে
“-O2 সবসময় -O0-এর চেয়ে দ্রুত প্রোগ্রাম দেয়, তাই সবসময় -O2 ব্যবহার করা উচিত।”
Runtime-এর জন্য প্রায় সবসময় সত্যি, কিন্তু “সবসময় ব্যবহার করা উচিত”
অংশটা প্রসঙ্গ-নির্ভর ভুল। Development-এর সময় -O0-এর predictable
stack layout আর 1-to-1 সোর্স-mapping ছাড়া debugger দিয়ে কাজ করা
কষ্টকর হয়ে যায় (এই লেসনের central থিম)। এছাড়া compile সময়ও গুরুত্বপূর্ণ
— বড় codebase-এ -O2 compile -O0-এর চেয়ে কয়েকগুণ ধীর হতে পারে,
যা iteration cycle ধীর করে দেয়। তাই বাস্তবে debug build-এ -O0/-Og,
release build-এ -O2/-O3 — দুটোই সঠিক পছন্দ, শুধু ভিন্ন প্রসঙ্গে।
“Optimization প্রোগ্রামের আচরণ বদলে দিতে পারে — তাই ঝুঁকিপূর্ণ, এড়ানো উচিত।”
সঠিকভাবে-লেখা (কোনো undefined behavior ছাড়া) C কোডের জন্য optimization
semantics-preserving — output ঠিক একই থাকার কথা, শুধু পথ ভিন্ন।
এই লেসনের sum_array উদাহরণে -O0 আর -O2 একই ইনপুটে একই সংখ্যা
রিটার্ন করে, প্রতিবার। সমস্যা তখনই হয় যখন সোর্স কোডে UB থাকে
(signed overflow, uninitialized variable পড়া) — তখন কম্পাইলার
“correct” কোড ধরে নিয়ে এমন অনুমান করে যা আসলে ভুল, আর ফলাফল আলাদা
হয়ে যায়। এটা optimizer-এর বাগ না — এটা প্রমাণ যে C-এর “as-if rule”
শুধু defined behavior-এর জন্য গ্যারান্টি দেয়।
“-O3 সবসময় -O2-এর চেয়ে ভালো, কারণ এটা 'বেশি' optimization।”
“বেশি” pass মানে সবসময় “দ্রুততর বাইনারি” না। -O3-এর আক্রমণাত্মক
loop unrolling আর inlining code size বাড়িয়ে দিতে পারে — বড় binary
মানে instruction cache (i-cache)-এ কম fit করা, যা মাঝে মাঝে
পুরো লাভটাই কেটে দেয় বা উল্টে দেয়। Auto-vectorization মাঝে মাঝে
ভুল heuristic-এ ভুল সিদ্ধান্ত নেয় (একটা loop যেটা আসলে vectorize
করা উচিত না, সেটাও করে ফেলে, overhead-ই বাড়ায়)। এই কারণে বহু
বড় প্রজেক্ট (Linux kernel সহ) ডিফল্ট -O2 ব্যবহার করে, -O3 না
— measure করেই সিদ্ধান্ত নেওয়া হয়, “বেশি সংখ্যা মানে ভালো” এই
অনুমানে না।
“যেহেতু -O2 output pattern predictable, একবার শিখে ফেললে যেকোনো compiler-এর output হুবহু অনুমান করা যায়।”
Pattern-গুলো (register allocation, instruction fusion, branch
elimination) সাধারণ নীতি হিসেবে GCC আর Clang-এ প্রায় একই, কিন্তু
exact instruction choice, register বাছাই, label নম্বর — এগুলো
compiler version, target CPU (-march=), এমনকি compiler-এর
internal heuristic আপডেটের সাথে বদলাতে পারে। এই লেসনের listing-গুলো
প্রতিনিধিত্বমূলক, letter-for-letter guarantee না। বাস্তব কোডে
সবসময় Compiler Explorer বা -S দিয়ে নিজের নির্দিষ্ট compiler+flag
combination-এ যাচাই করাই একমাত্র নির্ভরযোগ্য পদ্ধতি — অনুমান না,
পর্যবেক্ষণ।
বুঝেছেন কি না দেখুন
1-O0-এ প্রতিটা local variable-এর জন্য একটা fixed stack slot বরাদ্দ হয় কেন? এটা কোন optimization pass বন্ধ থাকার ফলাফল?স্মরণ
Register allocation pass বন্ধ থাকার ফলাফল। Register allocation-ই
সিদ্ধান্ত নেয় কোন variable কোন register-এ (কখন) থাকবে, আর কখন
memory-তে স্পিল করতে হবে। -O0-এ এই pass সম্পূর্ণ অনুপস্থিত, তাই
কম্পাইলার সবচেয়ে সরল নিয়ম মেনে চলে — প্রতিটা variable-এর একটা
স্থায়ী stack ঠিকানা, কোনো register-allocation সিদ্ধান্ত না নিয়েই।
এটাই কম্পাইল দ্রুত রাখে (কোনো জটিল analysis লাগে না) আর প্রতিটা
variable-এর মান সবসময় একই, predictable জায়গায় থাকে (debugging-এর
সুবিধা)।
2এই লেসনের sum_array-এর -O2 output-এ addl (%rdi,%rax,4), %ecx একটাই instruction, যেখানে -O0-এ একই কাজ ছয়টা instruction লেগেছিল। কর্মক্ষমতার দিক থেকে এই পার্থক্য কেন গুরুত্বপূর্ণ — শুধু “কম instruction” ছাড়া আর কী কারণ আছে?যুক্তি
শুধু instruction-count কমা না — কয়েকটা আলাদা কারণ একসাথে কাজ করছে:
১. কম instruction fetch/decode ওভারহেড — CPU-কে কম bytes পড়তে, decode করতে হয়।
২. কম register pressure/dependency chain — -O0-এ ছয়টা
instruction-এর মধ্যে data dependency chain (প্রতিটা পরেরটার
জন্য অপেক্ষা করছে) — out-of-order execution-এও এই chain-টা
সম্পূর্ণ ভাঙা যায় না, কারণ প্রতিটা ধাপ আগেরটার ফলাফলের উপর
নির্ভরশীল। একটা instruction-এ সেই chain অনেক ছোট।
৩. স্ট্যাক memory access সম্পূর্ণ অনুপস্থিত — -O0-এ -8(%rbp)
বারবার পড়া মানে (যদিও L1 cache-এ হিট হয়, দ্রুত) তবু একটা
memory-access latency; -O2-এ %rax/%rcx register access
virtually বিনামূল্যে, cache-এর প্রশ্নই আসে না।
৪. Loop-এর প্রতিটা iteration-এ এই পার্থক্য পুনরাবৃত্তি হয় —
একটা n=1000000-এর array-তে এই পার্থক্যটা দশ লক্ষ বার ঘটছে,
তাই ছোট একটা পার্থক্যও সামগ্রিকভাবে বিশাল প্রভাব ফেলে।
3clamp function-এর -O2 output-এ cmovl আর cmovg ব্যবহৃত হয়েছে, jl/jg না। যদি clamp-কে একটা এমন loop-এর ভেতর কল করা হয় যেখানে x প্রায় সবসময় lo আর hi-এর মধ্যেই থাকে (clamp খুব কম সময়েই আসলে কিছু বদলায়), তাহলে branch-ভিত্তিক (jl/jg) সংস্করণ কি cmov-ভিত্তিক সংস্করণের চেয়ে দ্রুত হতে পারে? ব্যাখ্যা করুন।প্রয়োগ
হ্যাঁ, সম্ভব। যদি x প্রায় সবসময় lo আর hi-এর মধ্যেই থাকে
(condition প্রায় সবসময় “false” — clamp করার দরকার নেই), তাহলে
branch predictor খুব দ্রুত এই প্যাটার্ন শিখে ফেলবে (“এই branch
প্রায় সবসময় not-taken”) আর প্রায় নিখুঁতভাবে predict করবে — কোনো
misprediction penalty ছাড়াই, branch-ভিত্তিক কোড কার্যত “বিনামূল্যে”
চলবে।
cmov-ভিত্তিক কোড এই সুবিধা নিতে পারে না — এটা সবসময় উভয়
operand read করে আর condition চেক করে, prediction যতই সহজ হোক না
কেন। ফলে predictable condition-এ branch-ভিত্তিক কোড দ্রুততর হতে
পারে।
GCC কীভাবে এটা মিস করে/করে না: GCC-র heuristic উৎস কোড দেখে
condition-এর predictability অনুমান করতে পারে না (এটা runtime ডেটা,
compile-time-এ অজানা) — তাই এটা একটা সাধারণ নিয়মে সিদ্ধান্ত নেয়
(“ছোট, সরল condition হলে cmov বেছে নাও”)। Profile-Guided Optimization
(realworld অংশে উল্লেখিত) এই সীমাবদ্ধতা ঠিক এই কারণেই সমাধান করে —
বাস্তব রানের ডেটা দেখে branch predictability জেনে সঠিক সিদ্ধান্ত
নিতে পারে।
4নিচের C ফাংশনটা -O0 আর -O2-এ ভিন্ন ফলাফল দিতে পারে — কেন, আর কোন optimization-এর দোষে না, বরং কোন সোর্স-কোড সমস্যার কারণে?
int mystery(int x) {
int y = x * 100000;
return y / x;
}
প্রয়োগ
int mystery(int x) {
int y = x * 100000;
return y / x;
}সমস্যা signed integer overflow — যদি x যথেষ্ট বড় হয় (যেমন
x = 50000), x * 100000 int-এর সীমা (~2.1 বিলিয়ন) ছাড়িয়ে
যায়, যা C standard-এ undefined behavior।
-O0-এ কম্পাইলার সাধারণত এই multiplication আক্ষরিকভাবে করে —
হার্ডওয়্যার overflow হলেও একটা wrapped (mod 2^32) মান পায়, যা
হয়তো “যুক্তিসঙ্গত” দেখায় (যদিও technically ভুল)।
-O2-এ কম্পাইলার UB নেই এই অনুমানে optimize করতে পারে — যেমন,
(x * 100000) / x কে সরাসরি 100000-এ সরল করে ফেলতে পারে (algebraically
সঠিক, যদি কোনো overflow না ঘটে)। যদি বাস্তবে overflow ঘটে, -O2
রিটার্ন করবে 100000, কিন্তু -O0 রিটার্ন করতে পারে ভিন্ন কিছু —
দুইটা আলাদা ফলাফল, একই ইনপুটে।
এটা optimizer-এর বাগ না — এই লেসনের misconception অংশের সরাসরি
উদাহরণ। সোর্স কোডে UB আছে, তাই C standard কোনো নির্দিষ্ট আচরণের
গ্যারান্টি দেয় না — compiler যেকোনো “সংগত” আচরণ বেছে নেওয়ার অধিকার
রাখে। সমাধান: unsigned ব্যবহার (যেখানে overflow defined, wraparound),
অথবা overflow-checking (__builtin_mul_overflow)।
5একজন developer বলছেন, “আমি -O0-এ প্রোগ্রামটা ডিবাগ করে বাগ ঠিক করেছি, রিলিজে -O2 দিয়ে বিল্ড করলাম, বাগটা আবার এসেছে!” এটা কীভাবে সম্ভব যদি প্রোগ্রামে কোনো undefined behavior না থাকে?যুক্তি
যদি সত্যিই কোনো UB না থাকে, তাহলে সেই “বাগ” আসলে দুই ধরনের একটা সমস্যা হতে পারে:
১. বাগটা আসলে আগেও ছিল, কিন্তু -O0-এ কাকতালীয়ভাবে লুকানো ছিল।
উদাহরণ: race condition — -O0-এ প্রোগ্রাম অনেক ধীর, thread-গুলো
এমনভাবে interleave করে যে race window কার্যত কখনো ট্রিগার হয়
না। -O2-এ দ্রুত execution race window-কে বাস্তবিক করে তোলে —
বাগটা আগেও ছিল, আচরণে না, timing-এ।
২. -O0-এ “ফিক্স” আসলে আসল কারণটা লুকিয়েছিল, ঠিক করেনি। যেমন
কেউ একটা uninitialized variable-এর সমস্যায় একটা “ভাগ্যক্রমে
কাজ করা” মান পেয়েছিল কারণ -O0-এ স্ট্যাকের সেই জায়গায় আগের
কোনো নির্দিষ্ট মান বসে ছিল (যদিও int y; নিজে technically একটা
uninitialized read, যা প্রযুক্তিগতভাবে UB — তাই আসলে এটাও
প্রথম কেসেই পড়ে)।
ব্যবহারিক শিক্ষা: “কাজ করছে” আর “সঠিক” এক জিনিস না। -O0-এ
পরীক্ষা পাস করা কোনো গ্যারান্টি না যে কোডে কোনো ভুল নেই — এই কারণেই
CI pipeline-এ সবসময় release configuration-এও টেস্ট চালানো উচিত,
আর AddressSanitizer/UndefinedBehaviorSanitizer-এর মতো টুল (যা
runtime-এ UB সরাসরি ধরে) নিয়মিত ব্যবহার করা উচিত, শুধু “মনে হচ্ছে
ঠিক আছে” ধরে না নিয়ে।
6আপনি একটা নতুন compiler ডিজাইন করছেন যেটার শুধু দুইটা optimization level থাকবে — “debug” আর “release”। “debug” mode-এ কী কী গুণ থাকা আবশ্যক বলে মনে করেন (এই লেসনের -O0 পর্যবেক্ষণের ভিত্তিতে), আর “release”-এ কোনগুলো ছেড়ে দেওয়া যায়?ডিজাইন
“debug” mode-এ আবশ্যক (এই লেসনের -O0 পর্যবেক্ষণ থেকে):
- প্রতিটা source variable-এর একটা predictable, স্থায়ী ঠিকানা (register বা stack) — যাতে debugger যেকোনো breakpoint-এ সঠিক মান দেখাতে পারে
- প্রতিটা source statement-এর সাথে assembly-র একটা স্পষ্ট, non-overlapping mapping — যাতে single-step করলে ঠিক একটা source line-ই এগোয়
- কোনো instruction reordering যা একটা statement-এর “আগে” আরেকটার effect ঘটায় (যাতে breakpoint-এর অর্থ predictable থাকে)
- Frame pointer সবসময় বজায় রাখা (সহজ stack unwinding, backtrace)
“release”-এ ছেড়ে দেওয়া যায়:
- Variable-এর স্থায়ী ঠিকানার গ্যারান্টি — register allocation স্বাধীনভাবে সিদ্ধান্ত নিতে পারে
- Statement-ভিত্তিক instruction grouping — fusion/combining স্বাধীনভাবে করা যায়
- Frame pointer — যদি অন্য কোনো mechanism (DWARF CFI) দিয়ে stack unwind সম্ভব হয়
- Source-order execution — যতক্ষণ observable behavior (as-if rule) একই থাকে
এটাই বাস্তবে GCC/Clang-এর -O0/-Og বনাম -O2/-O3-এর দর্শন,
আর -g flag (debug info তৈরি, optimization level থেকে independent)
এই দুই জগতের মধ্যে সেতু — DWARF debug info-তে location list রেখে
এমনকি -O2-এও (আংশিকভাবে) debugger-কে বলে দেওয়া যায় “এই মুহূর্তে
total ভেরিয়েবলটা কোন register-এ আছে” — যদিও optimized কোডে এই
তথ্য সবসময় নিখুঁত হয় না, কিছু variable “optimized out” দেখাতে পারে।
এরপর কী
এই লেসনটাই মডিউলের driving question-এর মূল উত্তর ছিল — compiler কী করে, আর কেন করে। কিন্তু একটা প্রশ্ন এখনো বাকি: যদি কম্পাইলার এত ভালো optimize করতে পারে, তাহলে কখনো কি নিজে হাতে assembly লেখার দরকার পড়ে?
উত্তর: প্রায় কখনোই না — কিন্তু একটা নির্দিষ্ট সংকীর্ণ পরিস্থিতিতে,
হ্যাঁ। কিছু CPU instruction-এর কোনো C equivalent নেই (rdtsc,
cpuid), আর C11-এর আগে কিছু atomic operation-এরও না। পরের লেসনে
আমরা দেখব কীভাবে C-এর ভেতর থেকে সরাসরি raw assembly instruction
বসানো যায় — GCC/Clang-এর extended inline assembly সিনট্যাক্স —
আর কেন এটা এত সতর্কতার সাথে, শেষ অস্ত্র হিসেবেই ব্যবহার করা উচিত।
আরও পড়ুন
- Compiler Explorer · এই মডিউলের driving question exploration করার প্রামাণ্য টুল — Matt Godbolt-এর তৈরি
- GCC Optimize Options — Using the GNU Compiler Collection (GCC) · -O0 থেকে -O3, -Os, -Og পর্যন্ত প্রতিটা optimization flag ঠিক কী কী pass সক্রিয় করে তার প্রামাণ্য তালিকা
- What Every C Programmer Should Know About Undefined Behavior — Chris Lattner · কেন optimization আক্রমণাত্মক আচরণ করে যখন সোর্স কোডে undefined behavior থাকে
- Agner Fog's Optimization Manuals · x86 instruction-level কর্মক্ষমতার প্রামাণ্য, গভীর রেফারেন্স