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

Inline Assembly — C-এর ভেতর থেকে সরাসরি Instruction

Inline Assembly

গত লেসনে আমরা দেখেছি কম্পাইলার প্রায় সবসময় হাতে-লেখা assembly-র চেয়ে ভালো কোড বানায়। কিন্তু কিছু CPU instruction-এর কোনো C প্রতিশব্দই নেই — rdtsc, cpuid, লক-করা atomic operation। তখনই GCC/Clang-এর extended inline assembly দরকার — asm volatile("..." : outputs : inputs : clobbers)। এই লেসনে চারটা অংশ, volatile-এর আসল প্রয়োজন, আর সবচেয়ে গুরুত্বপূর্ণ — কেন এটা প্রায় সবসময় এড়ানো উচিত, শুধু genuine edge case-এই ব্যবহার করা উচিত।

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

  • GCC/Clang extended inline assembly-র চার অংশ (template, output constraint, input constraint, clobber list) প্রতিটার ভূমিকা আলাদা করে ব্যাখ্যা করতে পারবেন
  • rdtsc-এর মতো একটা real CPU instruction-কে সঠিক register constraint (=a, =d) দিয়ে inline asm-এ wrap করতে পারবেন
  • asm volatile কেন প্রয়োজন, আর এটা বাদ দিলে optimizer কী ভুল করতে পারে তা ব্যাখ্যা করতে পারবেন
  • clobber list (cc, memory) ভুলভাবে বাদ দিলে কীভাবে নীরব miscompilation ঘটতে পারে তা যুক্তি দিয়ে দেখাতে পারবেন
  • কখন inline assembly প্রকৃতপক্ষে অপরিহার্য (কোনো C equivalent নেই) আর কখন compiler intrinsics পছন্দনীয় তা বিচার করতে পারবেন
  • একটা ছোট, সঠিক inline asm ব্লক নিজে লিখে compile ও verify করতে পারবেন

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

আগে এটা বুঝি

গত লেসনে আমরা দেখলাম কম্পাইলার কতটা যত্ন নিয়ে register allocate করে, instruction fuse করে, branch এড়ায় — প্রায় সবসময় একজন মানুষের হাতে-লেখা কোডের চেয়ে ভালো। তাহলে প্রশ্ন: কখনো কি নিজে হাতে assembly লেখার সত্যিকারের দরকার পড়ে?

একটা নির্দিষ্ট পরিস্থিতিতে — হ্যাঁ। ধরুন আপনি জানতে চান একটা function কল করতে ঠিক কত CPU cycle লাগে। x86-64-এ এর জন্য একটা instruction আছে — rdtsc (read time-stamp counter) — যেটা CPU-এর নিজস্ব cycle counter পড়ে আনে। কিন্তু C-তে কোনো rdtsc() ফাংশন নেই। <time.h>-এর কোনো ফাংশন এটা দেয় না — সেগুলো wall-clock সময় মাপে, cycle না।

uint64_t cycles = ???;   // কীভাবে rdtsc চালাবেন?

এই ফাঁকটাই এই লেসনের বিষয়। GCC/Clang একটা মেকানিজম দেয় — inline assembly — যা দিয়ে C কোডের মাঝখানে সরাসরি raw machine instruction বসানো যায়, compiler-এর সাথে register/memory শেয়ার করেই।

static inline uint64_t rdtsc(void) {
    uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi<<32) | lo;
}

এই লেসনের কাজ এই একটা লাইনের প্রতিটা অংশ — asm, volatile, "rdtsc", "=a"(lo), "=d"(hi) — সম্পূর্ণভাবে বোঝা। আর সমান গুরুত্বপূর্ণ, শেষে সৎভাবে স্বীকার করা: এটা একটা শেষ অস্ত্র, প্রতিদিনের হাতিয়ার না।

মূল ধারণা

Extended asm-এর চারটা অংশ

GCC/Clang-এর “extended” inline assembly সিনট্যাক্স (সাধারণ, খালি asm("...")-এর চেয়ে অনেক বেশি শক্তিশালী) চারটা কোলন-বিভক্ত অংশে গঠিত:

asm volatile(
    "assembly template"
    : output operands
    : input operands
    : clobber list
);

একটা ন্যূনতম, শেখার জন্য বানানো উদাহরণ দিয়ে শুরু করি — বাস্তবে কেউ এভাবে যোগ করবে না (কম্পাইলার নিজেই এটা optimal করে), কিন্তু এটা সিনট্যাক্স স্পষ্ট দেখায়:

int add_asm(int a, int b) {
    int result;
    asm("movl %1, %0\n\t"
        "addl %2, %0"
        : "=r"(result)
        : "r"(a), "r"(b));
    return result;
}

অংশ ১ — Template

"movl %1, %0\n\t"
"addl %2, %0"

আক্ষরিক assembly instruction, একটা string literal হিসেবে। %0, %1, %2 — এগুলো operand নম্বর, output থেকে শুরু করে বাম থেকে ডানে গোনা: %0 = প্রথম output (result), %1 = প্রথম input (a), %2 = দ্বিতীয় input (b)। কম্পাইলার এই নম্বরগুলোকে actual register-এ replace করে দেয় compile-time-এ।

\n\t মাল্টি-লাইন template-এ প্রতিটা instruction আলাদা লাইনে, সঠিকভাবে indent করে বসাতে — assembler-এর জন্য syntax পরিষ্কার রাখতে।

অংশ ২ — Output operands

: "=r"(result)

= মানে write-only — এই operand-এ asm ব্লক একটা মান লিখবে, আগের মান পড়বে না। r মানে “যেকোনো general-purpose register” — compiler নিজে বেছে নেবে কোনটা, আপনাকে নির্দিষ্ট করতে হবে না। (result) বলছে এই constraint কোন C variable-এর সাথে যুক্ত।

অংশ ৩ — Input operands

: "r"(a), "r"(b)

দুইটা input, দুটোই “যেকোনো register”-এ থাকবে বলে চিহ্নিত। কম্পাইলার a আর b-এর বর্তমান মান কোনো register-এ রাখবে (আগে থেকেই থাকতে পারে, নাহলে move করে দেবে), আর template-এ %1/%2 সেই register-এর নাম দিয়ে প্রতিস্থাপিত হবে।

অংশ ৪ — Clobber list

এই উদাহরণে খালি (নেই)। Clobber list বলে দেয় এই asm block আর কী কী নষ্ট করে যা output/input-এ তালিকাভুক্ত না — condition flags, অন্য কোনো register, বা memory। নিচে বিস্তারিত।

এখন real উদাহরণ — rdtsc

#include <stdint.h>

static inline uint64_t rdtsc(void) {
    uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi<<32) | lo;
}
  • Template: শুধু "rdtsc" — কোনো operand reference নেই, কারণ rdtsc নিজেই নির্দিষ্ট register ব্যবহার করে (ISA-তেই লেখা আছে)।
  • Output: "=a"(lo), "=d"(hi)a আর d হলো specific register constraint, “যেকোনো register” (r) না। a মানে %eax/%al/%rax (context অনুযায়ী), d মানে %edx। এটা বাধ্যতামূলক এখানে, কারণ rdtsc ISA অনুযায়ী সবসময় নিম্ন ৩২ বিট %eax-এ, উচ্চ ৩২ বিট %edx-এ লেখে — এটা compiler-এর পছন্দের বিষয় না, hardware-এর নিয়ম।
  • Input: নেই।
  • Clobber: এখানেও explicit কিছু নেই, কারণ %eax/%edx-এর পরিবর্তন ইতিমধ্যে output constraint-এই ঘোষিত।
  • volatile: আবশ্যক — নিচে ব্যাখ্যা।

ভেতরে কী ঘটছে

Constraint letter-গুলো — একটা সংক্ষিপ্ত রেফারেন্স

Constraintঅর্থ
rযেকোনো general-purpose register
mএকটা memory operand
iএকটা compile-time constant (immediate)
g“general” — register, memory, বা immediate, compiler-এর পছন্দ
a, b, c, dনির্দিষ্ট register — %rax, %rbx, %rcx, %rdx
S, Dনির্দিষ্ট register — %rsi, %rdi
09আরেকটা operand-এর মতো একই register ব্যবহার করো (“matching constraint”)

প্রিফিক্স:

প্রিফিক্সঅর্থ
=Write-only output — আগের মান গুরুত্বপূর্ণ না
+Read-write — asm block পড়বে ও লিখবে দুটোই
&Early-clobber — এই output-এ লেখা হয় সব input পড়ার আগে, তাই compiler input-এর জন্য একই register পুনরায় ব্যবহার করবে না

volatile কেন — dead code elimination-এর ঝুঁকি

গত লেসনে আমরা দেখেছি -O2 সেই কোড সরিয়ে দেয় যার ফলাফল ব্যবহৃত হয় না (dead code elimination)। Inline asm-এর ক্ষেত্রেও এই একই নিয়ম প্রযোজ্য — যদি output ব্যবহৃত না হয়, আর volatile না থাকে, কম্পাইলার পুরো asm block-ই মুছে দিতে পারে।

static inline uint64_t rdtsc_risky(void) {
    uint32_t lo, hi;
    asm("rdtsc" : "=a"(lo), "=d"(hi));   // volatile নেই!
    return ((uint64_t)hi<<32) | lo;
}

void benchmark_wrong(void) {
    rdtsc_risky();   // ফলাফল ব্যবহার হচ্ছে না
    do_work();
    rdtsc_risky();   // এটাও না
}

কম্পাইলারের দৃষ্টিতে rdtsc_risky()-এর কোনো “observable effect” নেই যদি রিটার্ন মান ব্যবহৃত না হয় — এটা একটা pure computation মনে করে সম্পূর্ণ সরিয়ে দিতে পারে, বা দুইটা কলকে একটায় “merge” করতে পারে (যেহেতু কম্পাইলার জানে না rdtsc আসলে সময়ের সাথে পরিবর্তনশীল একটা hardware state পড়ছে — এটা তার কাছে যেকোনো আর পাঁচটা deterministic function-এর মতোই দেখায়)।

volatile কম্পাইলারকে বলে: এই asm block-এর একটা side effect আছে যা তুমি বুঝতে পারবে না — একে মুছো না, পুনরায় সাজিয়ো না, দুইবার ডাকা দুইটা কলকে এক ধরে নিও না।

Clobber list — “cc” আর “memory”

দুইটা বিশেষ clobber আছে যা register নাম না:

"cc" — condition code flags (ZF, CF, SF, OF ইত্যাদি) বদলে গেছে। যদি asm block-এ কোনো add, cmp, বা flags-বদলানো instruction থাকে, এটা লেখা আবশ্যক — নাহলে কম্পাইলার হয়তো এই asm block-এর আগে গণনা করা কোনো flags-ভিত্তিক conditional (যেমন একটা jl) এই asm-এর পরে ব্যবহার করার চেষ্টা করবে, ধরে নিয়ে flags এখনো বৈধ — যা মিথ্যা।

"memory" — এই asm block memory পড়তে/লিখতে পারে এমন কোনোভাবে যা কম্পাইলার ট্র্যাক করতে পারছে না। এটা একটা সম্পূর্ণ memory barrier হিসেবে কাজ করে — কম্পাইলার এই asm-এর আগে-পরে কোনো memory access reorder করবে না, আর কোনো মান register-এ “cache” করে রাখবে না যা আসলে এই asm-এর দ্বারা memory-তে বদলে যেতে পারে।

static inline void atomic_increment(volatile int *counter) {
    asm volatile("lock incl %0" : "+m"(*counter) : : "memory");
}

"+m"(*counter) — read-write memory operand (আগের মান পড়ে, increment করে, আবার লেখে)। lock prefix পুরো read-modify-write-কে atomic করে (অন্য CPU core একই সময়ে একই cache line-এ হস্তক্ষেপ করতে পারবে না)। "memory" clobber নিশ্চিত করে — এই increment-এর আগে-পরে কম্পাইলার অন্য কোনো memory access-কে এই instruction-এর ওপার দিয়ে সরাবে না, যা multi-threaded কোডে সঠিকতার জন্য জরুরি।

উদাহরণ

দ্বিতীয় real উদাহরণ — cpuid

cpuid — CPU-এর ক্ষমতা query করার instruction (কোন feature আছে, কোন vendor, কত core)। ইনপুট %eax-এ (কখনো %ecx-এও, sub-leaf-এর জন্য), আউটপুট চারটা register জুড়ে:

static inline void cpuid(int leaf, int *eax, int *ebx, int *ecx, int *edx) {
    asm volatile("cpuid"
                 : "=a"(*eax), "=b"(*ebx), "=c"(*ecx), "=d"(*edx)
                 : "a"(leaf));
}

চারটা output, একটা input — সবই নির্দিষ্ট register constraint (a,b,c,d), কারণ cpuid-এর semantics ISA-তে নির্দিষ্ট, কোনো “compiler-এর choice” নেই। "a"(leaf) input হিসেবে %eax-এ leaf নম্বর বসায়; একই সময়ে "=a"(*eax) output-ও %eax-এ — এটা বৈধ, কারণ instruction চলে যাওয়ার পর %eax-এর মান বদলে যায় (input হিসেবে পড়ার পর, output হিসেবে নতুন মান)।

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

EXPERIMENT

volatile না থাকলে asm সত্যিই মুছে যায় কি না — সরাসরি দেখুন

Linux/macOS — gcc, objdump· ১৫ মিনিট
// rdtsc_test.c
#include <stdint.h>

static inline uint64_t rdtsc_no_volatile(void) {
    uint32_t lo, hi;
    asm("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi<<32) | lo;
}

int main(void) {
    rdtsc_no_volatile();
    return 0;
}
gcc -O2 -c rdtsc_test.c -o rdtsc_test.o
objdump -d rdtsc_test.o | grep -i rdtsc

-O2-এ প্রায়ই কোনো আউটপুট আসবে না — rdtsc instruction সম্পূর্ণ অদৃশ্য, কারণ রিটার্ন মান কোথাও ব্যবহৃত হয়নি। এবার asmasm volatile বদলে আবার কম্পাইল করুন — এবার rdtsc instruction-টা main-এর disassembly-তে থাকা উচিত, ব্যবহার না হলেও।

যদি প্রথম সংস্করণেও rdtsc দেখা যায় — চিন্তার কিছু নেই, compiler version/optimization heuristic ভেদে এই আচরণ পুরোপুরি guaranteed না (GCC ডকুমেন্টেশনও বলে non-volatile asm-এর আচরণ “undefined” এই অর্থে যে compiler-নির্ভর) — এটাই বরং লেসনের সতর্কতা আরও জোরালো করে: এটা ভাগ্যের ওপর ছাড়া ঠিক না, volatile স্পষ্টভাবে লেখাই একমাত্র নির্ভরযোগ্য পথ।

এটা কী প্রমাণ করে

asm volatile ছাড়া, unused output-সহ একটা inline asm block optimizer-এ সত্যিই অদৃশ্য হয়ে যেতে পারে — এটা তাত্ত্বিক সতর্কতা না, পর্যবেক্ষণযোগ্য বাস্তবতা।

EXPERIMENT

rdtsc দিয়ে সত্যিই একটা function-call-এর cycle count মাপুন

Linux/macOS — gcc· ১৫ মিনিট
#include <stdint.h>
#include <stdio.h>

static inline uint64_t rdtsc(void) {
    uint32_t lo, hi;
    asm volatile("rdtsc" : "=a"(lo), "=d"(hi));
    return ((uint64_t)hi<<32) | lo;
}

int main(void) {
    volatile int x = 0;
    uint64_t start = rdtsc();
    for (int i = 0; i \< 1000; i++) x += i;
    uint64_t end = rdtsc();
    printf("1000 iteration-এ cycle: %llu\n", (unsigned long long)(end - start));
    return 0;
}
gcc -O2 rdtsc_bench.c -o rdtsc_bench
./rdtsc_bench

কয়েকবার চালান — সংখ্যাটা প্রতিবার সামান্য বদলাবে (OS scheduling noise, cache state, CPU frequency scaling)। এটাই বাস্তব benchmarking-এর প্রথম শিক্ষা — একবার মাপা যথেষ্ট না, বহুবার মেপে median/minimum নেওয়াই নির্ভরযোগ্য (Level ১১-এর measurement methodology-র প্রিভিউ)।

এটা কী প্রমাণ করে

Inline asm শুধু তত্ত্ব না — একটা বাস্তব, কার্যকর micro-benchmarking টুল বানানো যায় মাত্র কয়েক লাইনে।

নিজে বানান

BUILD IT

একটা Cycle-Accurate Micro-benchmark টুল

C · ●●●○○
  1. rdtsc()-কে একটা reusable inline function হিসেবে একটা header ফাইলে রাখুন
  2. দুইটা ভিন্ন ছোট function বাছুন যাদের গতি তুলনা করতে চান (যেমন একটা array sum লুপ বনাম আরেকটা variant)
  3. প্রতিটাকে বহুবার (যেমন ১০০০ বার) চালিয়ে প্রতিবারের cycle count রেকর্ড করুন
  4. Minimum ও median cycle count রিপোর্ট করুন — গড় না, কারণ outlier (OS interrupt) গড়কে বিকৃত করে
  5. যাচাই করুন volatile বাদ দিলে ফলাফল কীভাবে অবাস্তব/অসামঞ্জস্যপূর্ণ হয়ে যায় (আগের experiment-এর প্রমাণ পুনরায় প্রয়োগ করুন)
  6. আপনার rdtsc-ভিত্তিক ফলাফলের সাথে gcc-র __builtin_ia32_rdtsc()-এর ফলাফল তুলনা করুন (যদি আপনার compiler সমর্থন করে) — একটা compiler intrinsic একই কাজ করে কি না দেখুন

শেষ ধাপটা ইচ্ছাকৃতভাবে যোগ করা — __builtin_ia32_rdtsc() একটা GCC/Clang built-in যা ঠিক এই লেসনের inline asm-এর মতোই rdtsc instruction emit করে, কিন্তু কোনো raw asm string ছাড়াই, কম্পাইলারের নিজস্ব semantics-বোঝা মেকানিজম দিয়ে। এই তুলনাটাই পরের (“realworld”) অংশের মূল বার্তার প্রথম হাতে-কলমে প্রমাণ — অনেক ক্ষেত্রে raw inline asm-এর বিকল্প ইতিমধ্যেই বিদ্যমান, শুধু খুঁজে বের করতে হয়।

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

Inline assembly বাস্তব কোডে

Linux kernel-এর ঐতিহাসিক ব্যবহার। Linux kernel-এর প্রথম দিকের কোডে spinlock, atomic counter, per-CPU variable access — সবই হাতে-লেখা inline asm দিয়ে হতো, কারণ C-তে এসবের কোনো portable উপায় ছিল না। আজও kernel-এ কিছু জায়গায় inline asm আছে (বিশেষত architecture-নির্দিষ্ট low-level কোডে, arch/x86/-এ), কিন্তু আধুনিক kernel ক্রমশ generic atomic builtin আর architecture-abstraction layer-এর দিকে সরে গেছে — direct inline asm দিন দিন কমছে।

glibc-র string function-এর ইতিহাস। memcpy, strlen-এর মতো hot-path function ঐতিহাসিকভাবে হাতে-লেখা assembly-তে (আলাদা .S ফাইলে, inline না) লেখা হতো — প্রতিটা CPU generation-এর জন্য আলাদা সংস্করণ (SSE, AVX, AVX-512), runtime-এ ifunc mechanism দিয়ে সেরাটা বেছে নেওয়া হতো। এটা inline asm না হলেও একই মূলনীতি — compiler generic কোড generate করতে পারে না বলে হাতে-অপ্টিমাইজড পথ দরকার।

<immintrin.h> — আধুনিক বিকল্প, SIMD-এর জন্য। computer-architecture module-এর SIMD লেসনের vaddps-জাতীয় instruction ব্যবহার করতে আজ কেউ raw inline asm লেখে না — Intel intrinsics ব্যবহার করে:

#include <immintrin.h>
__m256 sum = _mm256_add_ps(a, b);   // vaddps, কিন্তু compiler-বোধগম্য type-সহ

কম্পাইলার জানে _mm256_add_ps-এর সম্পূর্ণ semantics — register allocation, instruction scheduling, এমনকি আরও ভালো instruction-এ রূপান্তর (constant-folding) — সবকিছু করতে পারে, যা raw asm-এ অসম্ভব (কম্পাইলারের কাছে raw asm একটা black box, ভেতরে কী হচ্ছে তা বুঝতে পারে না)।

Rust-এর asm! ম্যাক্রো — একই প্যাটার্ন, ভিন্ন ভাষা। Rust ১.৫৯ (২০২২) থেকে stable asm! macro-তে ঠিক এই লেসনের ধারণাগুলোরই প্রতিধ্বনি — named operand, in/out/lateout (early-clobber-এর Rust-সংস্করণ), clobber annotation। ভাষা ভিন্ন, সমস্যা আর সমাধানের কাঠামো একই — inline asm একটা সাধারণ, ভাষা-নিরপেক্ষ প্যাটার্ন।

Memory fence intrinsics — raw asm-এর প্রয়োজনই ফুরিয়েছে। আগে mfence/lfence/sfence instruction inline asm দিয়ে ব্যবহার করতে হতো memory-ordering নিয়ন্ত্রণে। আজ _mm_mfence(), _mm_lfence(), _mm_sfence() (<immintrin.h>) বা <stdatomic.h>-এর atomic_thread_fence একই কাজ portable, compiler-বোধগম্যভাবে করে।

CPU feature detection — intrinsic-ভিত্তিক বিকল্প। এই লেসনের cpuid উদাহরণ আজও বাস্তব কোডে (compiler-এর নিজস্ব __builtin_cpu_supports() বা MSVC-র __cpuid() intrinsic না থাকলে) সরাসরি ব্যবহৃত হয় — কিন্তু GCC/Clang-এর নিজস্ব built-in থাকায় raw inline asm-এর প্রয়োজন এখন কমই পড়ে।

Sanitizer আর optimizer-এর সাথে ঘর্ষণ। যেহেতু কম্পাইলার inline asm-এর ভেতরে কী হচ্ছে “দেখতে” পারে না, একটা asm block পুরো আশেপাশের function-এর optimization সুযোগ কমিয়ে দেয় — compiler রক্ষণশীলভাবে ধরে নেয় “যেকোনো কিছু হতে পারে” (বিশেষত "memory" clobber-সহ)। AddressSanitizer/ThreadSanitizer-এর মতো টুলও raw asm-এর ভেতরের memory access ট্র্যাক করতে পারে না — এই কারণে বড় codebase-এ inline asm ন্যূনতম রাখার আরেকটা বাস্তব কারণ, নিছক “portability” ছাড়াও।

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

“প্রকৃত systems programmer-রা প্রচুর inline assembly ব্যবহার করে — এটাই 'আসল' low-level কাজ।”

আজকের বাস্তবতায় এটা বেশিরভাগ ক্ষেত্রেই মিথ্যা। বড় production codebase (Linux kernel, glibc, Chromium, PostgreSQL) ঘেঁটে দেখলে inline asm-এর occurrence সংখ্যা আশ্চর্যজনকভাবে কম — সাধারণত পুরো codebase-এর একটা ক্ষুদ্র ভগ্নাংশ, প্রায় সবসময় খুব নির্দিষ্ট, মন্তব্য-সহ, সতর্কতার সাথে লেখা জায়গায় সীমাবদ্ধ (boot code, context switch, নির্দিষ্ট hardware register access)। আধুনিক “systems programming”-এর বেশিরভাগই আসলে compiler intrinsics, careful data layout, cache-conscious algorithm design, আর profiling — raw asm লেখা না। গত লেসনের driving question-ই এর প্রমাণ — কম্পাইলার কী বানায় তা পড়তে পারা অনেক বেশি দরকারি দক্ষতা, নিজে লেখা থেকে।

“Inline assembly সবসময় দ্রুততর, কারণ এটা 'সরাসরি hardware-এ কথা বলে'।”

সাধারণত সত্যি না। একটা raw inline asm block কম্পাইলারের কাছে একটা black box — এর ভেতরের register ব্যবহার, latency, dependency কিছুই কম্পাইলার বিশ্লেষণ করতে পারে না, তাই এর আশেপাশের কোড optimize করার ক্ষমতা কমে যায় (register allocator রক্ষণশীল হতে বাধ্য হয়, instruction scheduler asm block-এর চারপাশে reorder করতে পারে না)। গত লেসনে দেখেছি কম্পাইলার নিজেই instruction fusion, branch elimination-এর মতো কৌশল প্রয়োগ করে যা মানুষ প্রায়ই মিস করে। একটা ভালোভাবে-লেখা C function, ভালো optimizer দিয়ে কম্পাইল করলে, প্রায়ই একটা naive হাতে-লেখা asm-এর চেয়ে দ্রুততর — শুধু সেই একটা instruction-ই ছাড়া যার C equivalent নেই।

“SIMD কোড লিখতে হলে inline assembly লাগবেই।”

আজ প্রায় কখনোই না। <immintrin.h> (x86) বা ARM NEON-এর intrinsic header-গুলো প্রতিটা SIMD instruction-এর জন্য একটা টাইপ-নিরাপদ C function দেয় (_mm256_add_ps, vaddq_f32), যা কম্পাইলার সম্পূর্ণ বোঝে — register allocate করতে পারে, constant-fold করতে পারে, এমনকি ভুল intrinsic কল টাইপ-চেক করে ধরতে পারে। ১৯৯০-এর দশকে যখন MMX/SSE নতুন ছিল, intrinsics পরিপক্ব ছিল না, তখন raw inline asm-ই একমাত্র উপায় ছিল — কিন্তু সেই যুগ বহু আগেই শেষ।

“clobber list বাদ দেওয়া একটা ছোটখাটো ভুল — সবচেয়ে খারাপ ক্ষেত্রে সামান্য ধীর কোড হবে।”

বিপজ্জনকভাবে ভুল। একটা ভুল বা অসম্পূর্ণ clobber list silent miscompilation ঘটাতে পারে — কম্পাইলার এমন একটা মান “cache” করে রাখতে পারে যা আসলে আপনার asm block বদলে দিয়েছে, বা একটা register পুনরায় ব্যবহার করতে পারে যা আসলে এখনো asm block-এর ভেতরে ব্যবহৃত হচ্ছে। এই বাগ প্রায়ই শুধু নির্দিষ্ট optimization level-এ (-O0-এ লুকানো, -O2-এ প্রকাশিত) বা নির্দিষ্ট surrounding code-এ (inline হওয়ার পর ভিন্ন register চাপে) দেখা যায় — যা এটাকে খুঁজে বের করা বিশেষভাবে কঠিন করে তোলে, “আমার মেশিনে কাজ করে” ধরনের বাগের একটা ক্লাসিক উৎস।

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

1asm volatile(“cpuid” : “=a”(*eax), “=b”(*ebx), “=c”(*ecx), “=d”(*edx) : “a”(leaf))-এ কয়টা output operand, কয়টা input operand, আর কয়টা clobber আছে?স্মরণ

চারটা output ("=a"(*eax), "=b"(*ebx), "=c"(*ecx), "=d"(*edx)), একটা input ("a"(leaf)), কোনো explicit clobber নেই (চতুর্থ কোলনের পরে কিছুই লেখা হয়নি — এখানে খালি, তালিকাটাই অনুপস্থিত)। লক্ষ্য করুন %eax output ও input দুটোতেই ব্যবহৃত হচ্ছে — এটা বৈধ, কারণ cpuid চালানোর আগে input হিসেবে পড়া হয়, চালানোর পরে output হিসেবে নতুন মান বসে; কম্পাইলার এই ক্রম বোঝে constraint-এর syntax থেকেই।

2

নিচের inline asm-এ কী ভুল আছে?

int square(int x) {
    asm("imull %0, %0" : "+r"(x));
    return x;
}
যুক্তি

সিনট্যাক্স নিজেই আসলে বৈধ এবং কাজ করবে — "+r"(x) একটা read-write constraint, তাই x-এর বর্তমান মান আগে register-এ বসে, imull %0, %0 সেটাকে নিজের সাথে গুণ করে (self-multiply), আর ফলাফল আবার সেই একই register-এ, যা x-এ ফেরত যায়।

তবে বাস্তব সমস্যা দুইটা:

১. volatile নেই — এই function-এর রিটার্ন মান যদি কোথাও ব্যবহৃত না হয়, পুরো asm block dead-code হিসেবে মুছে যেতে পারে (যদিও এখানে output ব্যবহৃত হচ্ছেই ধরে নেওয়া যাচ্ছে, তবু defensive practice হিসেবে volatile লেখা উচিত)।

২. "cc" clobber নেইimull condition flags বদলে দেয় (overflow flag বিশেষভাবে)। যদি এই asm-এর ঠিক পরেই কোনো flags-নির্ভর কোড থাকে (compiler যদি কোনোভাবে flags পুনঃব্যবহারের চেষ্টা করে — বাস্তবে বিরল কিন্তু তাত্ত্বিকভাবে সম্ভব একটা ভবিষ্যতের compiler optimization-এ), "cc" না থাকলে ভুল ধারণা হতে পারে।

আরও গুরুত্বপূর্ণ শিক্ষা: এই পুরো function-টাই অপ্রয়োজনীয় — return x * x; লিখলে কম্পাইলার নিজেই optimal imull (বা আরও ভালো কিছু) generate করবে। এটা এই লেসনের কেন্দ্রীয় সতর্কতার একটা উদাহরণ: যেখানে C-এর সরাসরি equivalent আছে, সেখানে inline asm লেখার কোনো কারণ নেই।

3একটা memory-mapped I/O register পড়তে হবে ঠিকানা 0x1000-এ, যার মান বারবার পড়লে বদলে যেতে পারে (hardware sensor), আর কম্পাইলার যেন এই read-কে কখনো cache/eliminate/reorder না করে। কোন clobber (বা constraint কৌশল) ব্যবহার করবেন, আর কেন?প্রয়োগ
static inline uint32_t read_sensor(volatile uint32_t *addr) {
    uint32_t val;
    asm volatile("movl (%1), %0" : "=r"(val) : "r"(addr) : "memory");
    return val;
}

volatile — নিশ্চিত করে এই asm block কখনো dead-code হিসেবে মুছে যাবে না, আর দুইটা পরপর কল কখনো একটায় merge হবে না (প্রতিটা read-ই একটা আলাদা, প্রকৃত hardware access হিসেবে গণ্য হবে)।

"memory" clobber — কম্পাইলারকে বলে এই asm-এর আশেপাশে কোনো memory access reorder না করতে, আর কোনো পূর্বে-পড়া মান “cache” করে না রাখতে যা আসলে বদলে যেতে পারে। যদিও এই নির্দিষ্ট function নিজে অন্য কোনো memory touch করছে না, "memory" একটা রক্ষণশীল, নিরাপদ পছন্দ hardware I/O-এর মতো “compiler-এর জানার বাইরের” side effect-এর ক্ষেত্রে — এটাই compiler-কে বলার একমাত্র উপায় “এই মুহূর্তে memory সম্পর্কে তোমার সব অনুমান বাতিল করো।”

অতিরিক্ত বাস্তব বিবেচনা: C-তে যদি addr-কে ইতিমধ্যেই volatile uint32_t * টাইপ করা হয় (এই উদাহরণে করা হয়েছে), তাহলে আসলে সাধারণ C dereference-ই (*addr) volatile semantics দিয়ে কম্পাইলারকে একই নিশ্চয়তা দিতে পারত, কোনো inline asm ছাড়াই — এই নির্দিষ্ট উদাহরণটা inline asm-এর প্রয়োজনীয়তা প্রদর্শনের জন্য সরলীকৃত, বাস্তবে volatile-qualified pointer-ই যথেষ্ট হতো।

4

নিচের কোডে কী ভুল হতে পারে — বিশেষত inlining-এর পরে?

int f(int x) {
    int tmp;
    asm("movl %1, %0" : "=r"(tmp) : "r"(x));
    asm("addl $1, %0" : "+r"(tmp));
    return tmp;
}
প্রয়োগ

এই কোড আসলে সঠিক — দুইটা আলাদা asm statement, প্রতিটার নিজস্ব সঠিক constraint (প্রথমটা tmp-তে x-এর মান কপি করছে, দ্বিতীয়টা "+r" দিয়ে read-modify-write করে ১ যোগ করছে)। কম্পাইলার দুইটা statement-এর মধ্যে tmp-এর জন্য একটাই consistent register বেছে নেবে, কারণ constraint সিস্টেম নিজেই এই সম্পর্ক ট্র্যাক করে — কোনো manual register নাম hardcode করা হয়নি।

যা সত্যিই সতর্কতার বিষয় (যদিও এখানে ঠিক আছে): যদি কেউ ভুল করে "a" বা "d"-এর মতো hardcoded specific register constraint ব্যবহার করত (এই উদাহরণে করেনি, "r" ব্যবহার করেছে — generic), তাহলে function inline হওয়ার পরে caller-এর context-এ সেই নির্দিষ্ট register ইতিমধ্যে অন্য কোনো জীবিত মান ধরে থাকতে পারত — যদি সেই constraint clobber list-এ বা properly output/input হিসেবে ঘোষিত না থাকে, একটা silent conflict ঘটতে পারে। এই কারণেই generic "r" constraint (compiler-কে register বেছে নিতে দেওয়া) নির্দিষ্ট register constraint-এর চেয়ে সাধারণত নিরাপদ — যেখানেই ISA বাধ্য না করে (rdtsc/cpuid-এর মতো), generic constraint-ই পছন্দনীয়।

5আপনার টিমের একজন জুনিয়র ডেভেলপার একটা hot loop-এ কর্মক্ষমতা বাড়াতে rdtsc-স্টাইলে একটা inline asm ব্লক দিয়ে “integer square root” হাতে লিখতে চাইছে, দাবি করছে এটা C-এর sqrt()-এর চেয়ে দ্রুত হবে। এই সিদ্ধান্তের আগে আপনি কী কী প্রশ্ন করবেন, আর কেন?ডিজাইন

প্রশ্ন ১ — এর জন্য কি ইতিমধ্যে একটা compiler intrinsic/builtin আছে? x86-এ integer/float square root-এর জন্য sqrtss/sqrtsd instruction আছে, আর GCC/Clang-এর __builtin_sqrt বা সরাসরি <math.h>-এর sqrt() প্রায়ই ইতিমধ্যেই এই instruction-এই compile হয় (বিশেষত -ffast-math বা উপযুক্ত target flag দিলে)। যদি তাই হয়, raw inline asm লেখা সম্পূর্ণ অপ্রয়োজনীয় — কম্পাইলার নিজেই optimal কোড দিচ্ছে।

প্রশ্ন ২ — measurement আছে? “দ্রুত হবে” একটা দাবি, প্রমাণ না। গত লেসনের একটা কেন্দ্রীয় শিক্ষা — কম্পাইলার প্রায়ই মানুষের চেয়ে ভালো optimize করে। আগে -O2/-O3-এ generated assembly (Compiler Explorer দিয়ে) দেখা উচিত — যদি ইতিমধ্যেই optimal instruction ব্যবহৃত হচ্ছে, hand-written asm কোনো লাভ দেবে না, শুধু maintainability কমাবে।

প্রশ্ন ৩ — এই asm block portable? x86-64-এর জন্য লেখা inline asm ARM-এ কাজ করবে না — অথচ sqrt() প্রতিটা architecture-এ কাজ করে। যদি প্রজেক্ট একাধিক architecture সমর্থন করে, inline asm একটা maintenance burden আর platform-নির্দিষ্ট branch তৈরি করে।

প্রশ্ন ৪ — সঠিকতা যাচাই কীভাবে হবে? Compiler-বোঝা কোডের জন্য sanitizer, static analyzer কাজ করে; raw inline asm-এর ভেতরের বাগ এসব টুল ধরতে পারে না (realworld অংশের সতর্কতা)।

সিদ্ধান্তের কাঠামো: শুধু “দ্রুত হবে মনে হচ্ছে” যথেষ্ট কারণ না — প্রথমে measure করা, তারপর compiler-এর existing output পরীক্ষা করা, তারপরই (আর প্রায় কখনোই না) inline asm বিবেচনা করা — এই লেসনের misconception অংশের প্রতিটা পয়েন্টই এই সিদ্ধান্তে প্রযোজ্য।

এরপর কী

এই তিনটা লেসন (addressing, compiler output, inline assembly) একসাথে মডিউলের driving question-এর সম্পূর্ণ উত্তর দিয়েছে — কম্পাইলার কী বানায়, কেন বানায়, আর কবে (খুব কম ক্ষেত্রে) নিজে হাতে হস্তক্ষেপ করার দরকার পড়ে।

মডিউলের বাকি topic-গুলো (position-independent code বিস্তারিতভাবে, ELF format ও sections, gdb/lldb দিয়ে instruction-level debugging) এই ভিত্তির উপর দাঁড়িয়ে বাকি প্রশ্নগুলোর উত্তর দেবে — একটা প্রোগ্রাম কম্পাইল হওয়ার পর ফাইল হিসেবে কেমন দেখতে হয়, লোড হওয়ার সময় কী ঘটে, আর একটা চলমান প্রোগ্রামকে instruction-level-এ কীভাবে পর্যবেক্ষণ করা যায়। এই লেসনের rdtsc/cpuid-এর মতো inline asm-এর প্রতিটা ব্যবহার আসলে সেই “চলমান প্রোগ্রাম কী দেখছে, কী জানে” প্রশ্নেরই একটা ছোট্ট, concrete জানালা ছিল — module-এর বাকি অংশ সেই জানালাটাই আরও বড় করবে।

আরও পড়ুন

  • How to Use Inline Assembly Language in C Code — GCC Manual · Extended asm syntax-এর প্রামাণ্য, সম্পূর্ণ ডকুমেন্টেশন — constraint letters, volatile-এর নিয়ম, সব এখানে
  • Intel Intrinsics Guide · আধুনিক বিকল্প — কম্পাইলার-বোধগম্য SIMD/special-instruction intrinsics
  • Intel 64 and IA-32 Architectures Software Developer's Manual — RDTSC, CPUID — Intel Corporation · এই লেসনের উদাহরণ instruction দুটোর প্রামাণ্য semantics