Foundationপ্রথম নীতি থেকে
LEVEL 3লেসন ৮/১২কঠিন১ ঘণ্টা

Compiler Output পড়া — -O0 বনাম -O2

Reading Real Compiler Output — -O0 vs -O2

এই মডিউলের driving question-এর সরাসরি উত্তর — আমার C function compile হয়ে ঠিক কোন instruction হলো, আর কেন। একই function-এর -O0 (আক্ষরিক, ধীর, কিন্তু predictable) আর -O2 (register-ভিত্তিক, দ্রুত, কিন্তু debugger-এ বিভ্রান্তিকর) output পাশাপাশি রেখে প্রতিটা পার্থক্যের একটা নির্দিষ্ট কারণ খুঁজে বের করব — আর দেখব কেন দুটোই তাদের নিজস্ব প্রসঙ্গে সঠিক পছন্দ।

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

  • -O0 এবং -O2 flag আসলে compilation-এ কী বদলায় তা ব্যাখ্যা করতে পারবেন — compile speed, register allocation, আর debuggability-এর trade-off হিসেবে
  • একটা real function-এর -O0 output-এ প্রতিটা variable কেন stack-এ স্পিল হয়, আর প্রতিটা statement কেন independently, verbosely translate হয় তা identify করতে পারবেন
  • একই function-এর -O2 output-এ register allocation, instruction fusion (load+ALU একসাথে), আর branch elimination (conditional move) চিনতে ও ব্যাখ্যা করতে পারবেন
  • -O0 output কেন debugging-এর জন্য বেশি predictable, আর -O2 output কেন debugger-এ বিভ্রান্তিকর হতে পারে তা যুক্তি দিয়ে বলতে পারবেন
  • Compiler Explorer (godbolt.org) ব্যবহার করে নিজের কোডের compiler output স্বাধীনভাবে অন্বেষণ ও তুলনা করতে পারবেন
  • Optimization কেন semantics-preserving transformation, আর কীভাবে undefined behavior-এর উপর নির্ভরতা এই নিশ্চয়তা ভেঙে দিতে পারে তা ব্যাখ্যা করতে পারবেন

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

আগে এটা বুঝি

এই মডিউলটা একটা প্রশ্ন দিয়ে শুরু হয়েছিল: আমার 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)ধীরসাধারণত দ্রুততমকঠিনতর
-Ossize-এর জন্য optimize, speed নামাঝারিপরিবর্তনশীলকঠিন
-Ogdebugging-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 নেই — সরাসরি 00 দিয়ে 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
ret

x86-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
-O0 বনাম -O2 — একই function, instruction সংখ্যার পার্থক্য।

বোনাস — -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, %eax

movdqu/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)

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

EXPERIMENT

Compiler Explorer-এ -O0/-O2/-O3 পাশাপাশি

ব্রাউজার — godbolt.org· ২০ মিনিট

১. 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 সংখ্যা ও ধরন কতটা নাটকীয়ভাবে বদলে যায় — আর সেই পার্থক্যের প্রতিটা কারণ এই লেসনে যা শেখা হয়েছে তার সাথে মেলে।

EXPERIMENT

-fverbose-asm দিয়ে লোকালি সোর্স-লাইন mapping দেখুন

Linux/macOS — gcc বা clang· ১৫ মিনিট
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 পড়ার একটা বাস্তব সহায়ক টুল, শুধু চোখে অনুমান করা না।

নিজে বানান

BUILD IT

নিজের Compiler Output Autopsy

C · ●●●○○
  1. নিজের একটা ছোট function লিখুন — একটা loop বা কয়েকটা conditional থাকা উচিত (৫-১৫ লাইন C কোড)
  2. gcc -O0 -S আর gcc -O2 -S দুইটা আলাদা ফাইলে output নিন
  3. দুইটা assembly ফাইল পাশাপাশি রেখে প্রতিটা পার্থক্যের একটা তালিকা বানান
  4. প্রতিটা পার্থক্যকে এই লেসনের চারটা category-র একটায় ফেলুন — register allocation, instruction fusion, branch elimination, নাকি loop restructuring
  5. যেই category-তে পড়ে না এমন কোনো পার্থক্য পেলে, সেটা আলাদা করে লিখে রাখুন — নিজে অনুসন্ধান করুন কেন হলো
  6. একটা ছোট রিপোর্ট লিখুন (আধা পাতা) — 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;
}
প্রয়োগ

সমস্যা 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 কর্মক্ষমতার প্রামাণ্য, গভীর রেফারেন্স