এই পর্বে যা শিখবেন
- PC কীভাবে ধাপে ধাপে এগোয়, আর RV32I-এর প্রতিটি শাখা ও লাফের instruction কীভাবে নতুন PC হিসাব করে — হাতে-কলমে target address-এর অঙ্কসহ
- C-র if, if-else, && আর ||, while, do-while, for, break, continue আর nested লুপকে label দিয়ে হাতে RV32I-এ অনুবাদ করা
- Array-র যোগফল, ধনাত্মক সংখ্যা গোনা, string-এর দৈর্ঘ্য আর সর্বোচ্চ মান — C আর RV32I পাশাপাশি, একটি ছোট interpreter-এ চালিয়ে
- লুপে কতগুলো instruction চলে তার সূত্র বের করা আর simulation দিয়ে মেলানো
- switch-এর if-chain বনাম jump table, আর flag, compare-and-branch ও শাখাহীন (branchless) কোডের তুলনা — x86 আর ARM-এর উদাহরণসহ
১ · PC + 4 আর লাফ — প্রোগ্রাম কীভাবে ক্রমে চলে, কীভাবে পথ বদলায়
পর্ব ২৬-এ (RISC-V-এর instruction) যে instruction-গুলো দেখেছেন — add, addi, lw, sw — সবগুলো একটার পর একটা চলে। কে ঠিক করে “এর পর কোনটি”? একটি বিশেষ register, program counter বা PC. PC-তে থাকে এখন যে instruction চলছে তার মেমরি-ঠিকানা। RV32I-এর প্রতিটি instruction ৩২ বিট, অর্থাৎ ৪ byte, তাই একটি শেষ হলে hardware সাধারণত PC-তে ৪ যোগ করে। একে বলে ক্রমানুসারে চলা (sequential execution): 0x1000, 0x1004, 0x1008…
কিন্তু শুধু সোজা পথে চললে কোনো সিদ্ধান্ত নেওয়া যায় না, কোনো কাজ বারবারও করা যায় না। তাই ISA-তে থাকে এমন instruction, যা PC-তে PC + 4-এর বদলে অন্য একটি ঠিকানা বসায়। প্রোগ্রাম কোন ক্রমে চলবে, সেই পুরো ব্যাপারটিকে বলে control flow. এর দুটি মৌলিক রূপ:
- Conditional branch (শর্তসাপেক্ষ শাখা): শর্ত সত্য হলে লাফ, মিথ্যা হলে সোজা পরের instruction-এ (একে বলে fall-through). C-র if আর লুপের শর্ত এভাবেই হয়।
- Unconditional jump (নিঃশর্ত লাফ): সবসময় লাফ — else-অংশ টপকানো, লুপের শুরুতে ফেরা, function ডাকা আর সেখান থেকে ফেরা।
ঢাকা থেকে চট্টগ্রামের ট্রেন ধরুন। স্বাভাবিকভাবে সে একটি স্টেশনের পর পরের স্টেশনে যায় — এটাই PC + 4. কোনো জংশনে সিগন্যাল-কেবিনের লোক লিভার টেনে পয়েন্ট বদলে দিলে ট্রেন অন্য লাইনে চলে যায় — এটাই শাখা। শর্তসাপেক্ষ শাখা মানে “লাইন ফাঁকা থাকলে পয়েন্ট বদল, নাহলে সোজা পথ”; নিঃশর্ত লাফ মানে পয়েন্ট সবসময় বদলানো। ট্রেন নিজে কিছু “জানে” না — সে শুধু যে লাইনে তোলা হয়, সেই লাইনে চলে। CPU-ও তেমনি শুধু PC-র ঠিকানা অনুসরণ করে।
Assembly-তে লাফের গন্তব্যকে একটি নাম দিয়ে চিহ্নিত করা হয় — একে বলে label (যেমন loop:, done:). Label কোনো instruction নয়, মেমরিতে কোনো জায়গাও নেয় না; এটি শুধু পরের instruction-এর ঠিকানার একটি নাম। Assembler label-কে ঠিকানায় বদলায়, আর শাখার instruction-এ বসায় “এখান থেকে কত byte দূরে” — সেই দূরত্বকে বলে branch offset. একটি শাখা বা label থেকে পরের শাখা পর্যন্ত যে টুকরো কোড সবসময় একসাথে, শুরু থেকে শেষ পর্যন্ত চলে, তাকে compiler-এর ভাষায় বলে basic block. Compiler পুরো প্রোগ্রামকে basic block আর তাদের মধ্যের তীর (কে কোথায় লাফাতে পারে) দিয়ে একটি গ্রাফ হিসেবে দেখে।
সব control flow আসলে একটি প্রশ্ন: পরের PC কত? সাধারণ instruction-এ PC + 4; শাখায় শর্ত সত্য হলে PC + offset; jal-এ PC + offset; jalr-এ একটি register-এর মান + offset. if, while, for, switch, function call — সবকিছু এই চারটি নিয়মে তৈরি।
২ · RV32I-এর শাখা আর লাফ — beq থেকে jalr, আর target address-এর হিসাব
পর্ব ১৩-এর অংশ ৭-এ দেখেছেন, x86 আর ARM আগে cmp দিয়ে flag বানায়, তারপর আলাদা instruction-এ flag পড়ে লাফ দেয়। RISC-V-তে flag নেই; একটি instruction-ই দুটি register তুলনা করে লাফ দেয় — এই নকশার নাম compare-and-branch. RV32I-এ মোট ছয়টি:
| Instruction | লাফ দেয় যখন | তুলনার ধরন | C-তে সাধারণত কোথা থেকে আসে |
|---|---|---|---|
beq rs1, rs2, L | rs1 = rs2 | বিটে বিটে সমান | == |
bne rs1, rs2, L | rs1 ≠ rs2 | বিটে বিটে অসমান | != |
blt rs1, rs2, L | rs1 < rs2 | signed | int-এর < |
bge rs1, rs2, L | rs1 ≥ rs2 | signed | int-এর >= |
bltu rs1, rs2, L | rs1 < rs2 | unsigned | unsigned, size_t, pointer-এর < |
bgeu rs1, rs2, L | rs1 ≥ rs2 | unsigned | unsigned-এর >= |
খেয়াল করুন, bgt (বড়) বা ble (ছোট বা সমান) নেই। দরকারও নেই: a > b মানে b < a, আর a ≤ b মানে b ≥ a. তাই assembler এগুলোকে pseudo-instruction হিসেবে মেনে নেয় আর operand-দুটি অদলবদল করে আসল instruction বসায়। শূন্যের সাথে তুলনার জন্য register x0 (যার মান সবসময় ০) ব্যবহার হয়:
# উদাহরণ — RV32I pseudo-instruction আর তাদের আসল রূপ
bgt a0, a1, L = blt a1, a0, L # a0 > a1 <=> a1 < a0
ble a0, a1, L = bge a1, a0, L # a0 <= a1 <=> a1 >= a0
bgtu a0, a1, L = bltu a1, a0, L
bleu a0, a1, L = bgeu a1, a0, L
beqz a0, L = beq a0, x0, L
bnez a0, L = bne a0, x0, L
bltz a0, L = blt a0, x0, L
blez a0, L = bge x0, a0, L # 0 >= a0
bgtz a0, L = blt x0, a0, L # 0 < a0
j L = jal x0, L # link discarded: plain jump
jal L = jal ra, L # call: ra = PC + 4
jr t0 = jalr x0, 0(t0)
ret = jalr x0, 0(ra) # return from a function
দুটি লাফ: jal আর jalr
jal rd, L(jump and link): rd-তে PC + 4 রাখে, তারপর PC = PC + offset. rd = x0 দিলে ফেরার ঠিকানা ফেলে দেওয়া হয় — শুধু লাফ, যার নামj. rd = ra (x1) দিলে function call; PC + 4-কে বলে return address.jalr rd, imm(rs1): target = (rs1 + imm) এবং তার সবচেয়ে নিচের বিট ০ করে দেওয়া; rd-তে PC + 4. গন্তব্য কোডে লেখা নেই, চলার সময় register থেকে আসে — তাই এটি indirect jump. Function থেকে ফেরা (ret), function pointer দিয়ে ডাকা আর switch-এর jump table এর ওপর দাঁড়িয়ে। Function call-এর পুরো নিয়ম পর্ব ৩০-এ (Function call আর stack).
Target address — PC + offset
RISC-V-এর শাখা আর jal গন্তব্য বোঝায় শাখা-instruction-এর নিজের ঠিকানা থেকে দূরত্ব দিয়ে: branch target = PC + offset. এই পদ্ধতির নাম PC-relative addressing (পর্ব ২৮-এ addressing mode-এর অংশ হিসেবে)। কোড মেমরির যেখানেই বসানো হোক, ভেতরের লাফগুলো ঠিক থাকে, কারণ দূরত্ব বদলায় না। Offset সবসময় জোড় সংখ্যা (২-এর গুণিতক, যাতে C extension-এর ১৬-বিট compressed instruction-এও লাফানো যায়), তাই encoding-এ সবচেয়ে নিচের বিটটি রাখাই হয় না। ফলে:
- শাখা (B-type): ১৩ বিটের signed offset, −4096 থেকে +4094 byte — মোটামুটি ±৪ KiB.
jal(J-type): ২১ বিটের signed offset, −1048576 থেকে +1048574 byte — মোটামুটি ±১ MiB.jalr(I-type): ১২ বিটের signed imm, −2048 থেকে +2047, যোগ হয় register-এর সাথে — তাই ৩২-বিট ঠিকানার যেকোনো জায়গায়।
এই বিটগুলো instruction-এর ভেতরে কোন ক্রমে বসে, সেই কিছুটা অদ্ভুত বিন্যাস পর্ব ২৭-এ (Instruction format আর encoding). এখানে শুধু হিসাবটা।
MIPS-এর সাথে পার্থক্য — পরীক্ষায় গুলিয়ে যায়। পাঠ্যবইয়ের MIPS-এ শাখার target = (PC + 4) + (imm × 4): ভিত্তি পরের instruction, আর imm গোনা হয় word-এ। RISC-V-তে target = PC + imm: ভিত্তি শাখাটি নিজে, আর imm সরাসরি byte-এ (assembler-এর লেখা সংখ্যা)। MIPS-এ শাখার ঠিক পরের instruction-টি সবসময় চলত (branch delay slot); RISC-V-তে এমন কিছু নেই।
হাতে-কলমে: target-এর পাঁচটি অঙ্ক
(ক) 0x00001040: beq t0, t1, L L = 0x00001010
offset = 0x1010 - 0x1040 = -0x30 = -48 (−4096 ≤ −48 ≤ 4094: চলবে)
(খ) 0x00400000: bne a0, a1, +2044
target = 0x00400000 + 0x7FC = 0x004007FC
(গ) 0x00002000: beq t0, t1, L L = 0x00003100
offset = 0x1100 = 4352 > 4094 -> শাখার নাগালের বাইরে!
(ঘ) 0x00010000: jal ra, F F = 0x00100000
offset = 0xF0000 = 983040 (≤ 1048574: চলবে); ra = 0x00010004
(ঙ) 0x00001000: jalr ra, 4(t0) t0 = 0x00002001
target = (0x2001 + 4) এবং নিচের বিট 0 = 0x2005 -> 0x00002004; ra = 0x00001004
(গ)-এর সমাধান assembler নিজেই করে: শর্ত উল্টে একটি ছোট শাখা দিয়ে একটি দূরপাল্লার j-কে টপকায় — যার নাগাল ±১ MiB. একে বলে branch relaxation:
# উদাহরণ — beq t0, t1, L (L অনেক দূরে) হয়ে যায়:
bne t0, t1, skip # opposite condition, short hop
j L # jal x0, L : reach ±1 MiB
skip:
এই “উল্টো শর্তে ছোট লাফ, তারপর বড় লাফ” কৌশলটি মনে রাখুন — পরের অংশে if-else-এ ঠিক এটিই মূল চাবি।
৩ · if আর if-else — উল্টো শর্তে লাফ
C-তে লেখেন “শর্ত সত্য হলে এই কাজ”। কিন্তু শাখা-instruction-এর স্বভাব উল্টো: সে লাফ দেয় শর্ত সত্য হলে, আর না হলে সোজা নিচে নামে। then-অংশটি তো ঠিক নিচেই আছে। তাই সবচেয়ে সহজ অনুবাদ হলো: উল্টো শর্ত সত্য হলে then-অংশ টপকে যাওয়া। >=-এর উল্টো <, ==-এর উল্টো !=, >-এর উল্টো <=.
/* C */ # RV32I — a0 = balance, a1 = amount, a2 = fail
if (balance >= amount) blt a0, a1, else # opposite of >=
balance -= amount; sub a0, a0, a1 # then-part
else j endif # skip else-part
fail = 1; else: li a2, 1 # else-part
endif:
bKash-এর টাকা পাঠানোর মতো একটি ছোট নিয়ম: ব্যালান্সে যথেষ্ট টাকা থাকলে টাকা কাটা হবে, নাহলে “ব্যর্থ” চিহ্ন বসবে। then-অংশের শেষে একটি j লাগে, যাতে else-অংশে পড়ে না যায়। শুধু if (else ছাড়া) হলে এই j-ও লাগে না। নিচের stepper-এ একই কোড দুবার চলছে — একবার ব্যালান্স 500, পাঠাতে চান 200; আরেকবার ব্যালান্স 100. প্রতিটি ধাপে সবুজ লাইনটি সেই ধাপে চলছে। “পরে” চাপুন:
1# a0 = balance, a1 = amount = 200, a2 = fail = 020x1000 blt a0, a1, else30x1004 sub a0, a0, a140x1008 j endif50x100C else: li a2, 160x1010 endif: ...
| চলছে (PC) | |
| পরের PC | |
| a0 | |
| a2 | |
| সিদ্ধান্ত |
- প্রস্তুতি। প্রথম রান: a0 = 500 (ব্যালান্স), a1 = 200, a2 = 0. কোড শুরু 0x1000 ঠিকানায়। C-র শর্ত
a0 >= a1, কিন্তু assembly-তে লেখা তার উল্টো —blt. - শাখা, লাফ নেই।
blt: 500 < 200? না। তাই PC = 0x1000 + 4 = 0x1004 — then-অংশে fall-through. - then-অংশ।
sub: a0 = 500 − 200 = 300. PC = 0x1008. - else টপকানো।
j endifসবসময় লাফ দেয়: offset = 0x1010 − 0x1008 = +8, তাই PC = 0x1010. প্রথম রানে মোট ৩টি instruction চলল। - দ্বিতীয় রান। এবার a0 = 100.
blt: 100 < 200? হ্যাঁ — লাফ। offset = 0x100C − 0x1000 = +12, PC = 0x100C. then-অংশ পুরোটাই বাদ। - else-অংশ।
li a2, 1: fail = 1. PC = 0x1010 — এখানে কোনোjলাগে না, কারণ endif ঠিক নিচেই। - দুই পথ এক জায়গায় মিলল। দুটি রানই endif-এ পৌঁছেছে: then-পথে ৩টি, else-পথে ২টি instruction. শাখার offset-দুটি (+12, +8) assembler label থেকে হিসাব করে instruction-এ বসিয়েছে।
&& আর || — short-circuit মানে আলাদা আলাদা শাখা
C-র && আর || মানে short-circuit evaluation: বাঁ দিক দেখেই উত্তর জানা গেলে ডান দিক হিসাবই হয় না। Assembly-তে এটি স্বাভাবিকভাবে আসে — প্রতিটি ছোট শর্তের জন্য একটি শাখা:
# উদাহরণ — if (a > 0 && b > 0) c = 1; a0 = a, a1 = b, a2 = c
blez a0, skip # a <= 0 : whole condition false, b is never looked at
blez a1, skip # b <= 0 : false
li a2, 1
skip:
# উদাহরণ — if (a > 0 || b > 0) c = 1;
bgtz a0, then # a > 0 : whole condition true, jump straight into the body
blez a1, skip # here a <= 0, so b decides
then: li a2, 1
skip:
&&-এ যেকোনো একটি অংশ মিথ্যা হলেই বাইরে; ||-এ প্রথম সত্য অংশ পেলেই ভেতরে। এটি শুধু গতির ব্যাপার নয়, নিরাপত্তারও: if (p != NULL && p->count > 0)-এ প্রথম শাখা NULL দেখলে দ্বিতীয় অংশের lw কখনো চলে না, তাই NULL-এর ঠিকানা থেকে পড়ার চেষ্টাও হয় না।
৪ · লুপ — while, do-while, for আর loop rotation
লুপ মানে একটি পেছনমুখী লাফ: label-টি শাখার ওপরে, তাই offset ঋণাত্মক। প্রশ্ন শুধু শর্তটি কোথায় বসবে — ওপরে না নিচে।
/* while (i < n) { body; i++; } */ /* do { body; i++; } while (i < n); */
# (ক) top-tested — বইয়ের সরল অনুবাদ # (খ) bottom-tested — শর্ত নিচে
loop: bge t1, a1, done # exit test loop: ...body...
...body... addi t1, t1, 1
addi t1, t1, 1 blt t1, a1, loop # i < n : back
j loop # back to test
done:
(ক)-তে প্রতি ঘুরে দুটি নিয়ন্ত্রণ-instruction চলে: ওপরের bge (লাফ নেই) আর নিচের j. (খ)-তে একটিই — নিচের blt. কিন্তু do-while অন্তত একবার চলে, while বা for শূন্যবারও চলতে পারে। তাই compiler while/for-কে এভাবে সাজায়: শুরুতে একবার একটি “ঢোকার যাচাই” (loop guard), তারপর নিচে শর্তওয়ালা do-while. এই রূপান্তরের নাম loop rotation, আর gcc -O1 বা তার ওপরের প্রায় সব লুপেই এটি দেখতে পাবেন।
/* C */ # RV32I (rotated) — a0 = &a[0], a1 = n
int sum = 0; li t0, 0 # sum
for (int i = 0; i < n; i++) li t1, 0 # i
sum += a[i]; bge t1, a1, done # guard: n <= 0 ?
loop: slli t2, t1, 2 # i * 4 (int = 4 byte)
add t2, a0, t2 # &a[i]
lw t2, 0(t2) # a[i]
add t0, t0, t2 # sum += a[i]
addi t1, t1, 1 # i++
blt t1, a1, loop # i < n : again
done: mv a0, t0
নিচের stepper-এ n = 2, a[] = {40, 25} নিয়ে এই লুপটি দুবার ঘুরছে। প্রতিটি শাখায় PC-র হিসাব খেয়াল করুন — বিশেষ করে পেছনমুখী blt-এর ঋণাত্মক offset:
1# a0 = 0x2000 (a[0] = 40, a[1] = 25), a1 = n = 220x1000 li t0, 030x1004 li t1, 040x1008 bge t1, a1, done50x100C loop: slli t2, t1, 260x1010 add t2, a0, t270x1014 lw t2, 0(t2)80x1018 add t0, t0, t290x101C addi t1, t1, 1100x1020 blt t1, a1, loop110x1024 done: mv a0, t0
| t0 (sum) | |
| t1 (i) | |
| t2 | |
| পরের PC | |
| সিদ্ধান্ত | |
| চলা instruction |
- শুরু। দুটি
li: sum = 0, i = 0. PC এখন 0x1008. এখন পর্যন্ত ২টি instruction. - Guard.
bge t1, a1, done: 0 ≥ 2? না — লাফ নেই, PC = 0x100C. n ঋণাত্মক বা শূন্য হলে এখান থেকেই 0x1008 + 28 = 0x1024-এ চলে যেত। এই শাখা পুরো লুপে একবারই চলে। - প্রথম ঘুর। পাঁচটি instruction: ঠিকানা 0x2000 + 0 × 4,
lwআনে a[0] = 40, sum = 40, i = 1. PC = 0x1020; মোট ৮টি। - পেছনে লাফ।
blt: 1 < 2? হ্যাঁ। offset = 0x100C − 0x1020 = −20, তাই PC = 0x1020 − 20 = 0x100C. মোট ৯টি। - দ্বিতীয় ঘুর। ঠিকানা 0x2000 + 1 × 4 = 0x2004, a[1] = 25, sum = 65, i = 2. মোট ১৪টি।
- বেরিয়ে আসা।
blt: 2 < 2? না — fall-through, PC = 0x1024. মোট ১৫টি। - ফল।
mv a0, t0: a0 = 65. মোট ১৬টি instruction = guard-সহ শুরুর ৩ + প্রতি ঘুরে ৬ × 2 + শেষের ১ = 6n + 4. অংশ ৬-এ এই সূত্র সাধারণভাবে প্রমাণ করা হবে।
break, continue আর nested লুপ
break= লুপের বাইরের label-এ একটিj(প্রায়ই একটি শর্তসাপেক্ষ শাখার সাথে মিশে:if (a[i] == key) break;হয়ে যায়beq t2, a2, done).continue= লুপের “পরের ঘুরের” জায়গায় লাফ। while-এ সেটি শর্ত-যাচাই; কিন্তু for-এ সেটিi++, শর্ত নয়। হাতে অনুবাদে এখানে ভুল করলে i আর বাড়ে না — অসীম লুপ। তাই for-এর জন্য compiler তিনটি label রাখে: শুরু, continue-বিন্দু (increment), আর শেষ।- Nested লুপ: ভেতরের লুপের নিজস্ব label আর নিজস্ব counter, আর বাইরের প্রতিটি ঘুরে ভেতরের counter আবার শূন্য করতে হয় — এই রিসেট ভুলে যাওয়া হাতে লেখা assembly-র একটি পরিচিত বাগ। ভেতরের লুপ বাইরের লুপের body-র অংশ, তাই তার label-গুলো বাইরের দুই label-এর মাঝখানে।
৫ · চারটি ক্লাসিক লুপ — C আর RV32I পাশাপাশি
পরীক্ষা আর ইন্টারভিউতে বারবার যে লুপগুলো আসে, সেগুলোর ছাঁচ একই: একটি counter বা pointer, একটি শর্ত, একটি কাজ। নিচে চারটি — প্রথম দুটি বইয়ের সরল (top-tested) রূপে, যাতে C-র সাথে লাইন মিলিয়ে পড়া যায়।
/* (১) Array-র যোগফল */ # a0 = &a[0], a1 = n -> a0 = sum
int s = 0; li t0, 0
for (int i = 0; i < n; i++) li t1, 0
s += a[i]; loop: bge t1, a1, done
return s; slli t2, t1, 2
add t2, a0, t2
lw t2, 0(t2)
add t0, t0, t2
addi t1, t1, 1
j loop
done: mv a0, t0
/* (২) ধনাত্মক সংখ্যা গোনা */ # rotated, with guard
int c = 0; li t0, 0 # c
for (int i = 0; i < n; i++) li t1, 0 # i
if (a[i] > 0) c++; bge t1, a1, done
loop: slli t2, t1, 2
add t2, a0, t2
lw t2, 0(t2)
blez t2, skip # opposite of a[i] > 0
addi t0, t0, 1
skip: addi t1, t1, 1
blt t1, a1, loop
done: mv a0, t0
/* (৩) String-এর দৈর্ঘ্য (strlen) */ # a0 = s -> a0 = length
const char *p = s; mv t0, a0
while (*p != '\0') p++; loop: lbu t1, 0(t0) # one byte, zero-extended
return p - s; beqz t1, done # '\0' ?
addi t0, t0, 1 # p++ (1 byte)
j loop
done: sub a0, t0, a0
/* (৪) সর্বোচ্চ মান (n ≥ 1) */ # a0 = &a[0], a1 = n -> a0 = max
int m = a[0]; lw t0, 0(a0)
for (int i = 1; i < n; i++) li t1, 1
if (a[i] > m) m = a[i]; loop: bge t1, a1, done
slli t2, t1, 2
add t2, a0, t2
lw t2, 0(t2)
ble t2, t0, skip # opposite of a[i] > m
mv t0, t2
skip: addi t1, t1, 1
j loop
done: mv a0, t0
কয়েকটি খুঁটিনাটি লক্ষ করুন। int-এর array-তে index-কে ৪ দিয়ে গুণ (slli … 2) করতে হয়, কারণ ঠিকানা byte-এ; char-এর string-এ pointer বাড়ে ১ করে। lbu একটি byte পড়ে বাকি ২৪ বিটে ০ বসায় (zero extension), তাই 0x00 byte-ই register-এ ঠিক ০ হয়। আর (২) ও (৪)-এর ভেতরের if-এ আবার সেই উল্টো শর্ত।
Signed বনাম unsigned — লুপের শর্তে কোনটি বসবে
C-তে i আর n int হলে compiler বসায় blt/bge; unsigned বা size_t হলে bltu/bgeu. পর্ব ২০-এর অংশ ৬-এ এর বিপজ্জনক দিকটি দেখেছেন (−1 unsigned-এ বিশাল)। লুপে এর সবচেয়ে পরিচিত রূপ উল্টো দিকে গোনা: for (unsigned i = n - 1; i >= 0; i--). unsigned-এ i >= 0 সবসময় সত্য, তাই compiler শাখাটি পুরোপুরি মুছে দিতে পারে — নিচে থাকে শুধু একটি j, অসীম লুপ। gcc -Wextra এখানে “comparison is always true” ধাঁচের warning দেয়। নিরাপদ রূপ: for (unsigned i = n; i-- > 0; ).
এবার চারটির মধ্যে তিনটি — যোগফল, strlen আর max — একটি ছোট RV32I interpreter-এ চালানো যাক। এটি দুই ধাপে কাজ করে, ঠিক একটি আসল assembler আর CPU-র মতো: প্রথমে প্রতিটি label-কে ঠিকানায় বদলায় আর ble, beqz-এর মতো pseudo-instruction-কে আসল রূপে আনে (operand অদলবদল বা x0 বসিয়ে); তারপর PC ধরে ধরে চালায় আর প্রতিটি চলা instruction গোনে। Array a[] = {5, −2, 9, 4, −7, 3} আর string “Dhaka”. Run চাপুন:
// Tiny RV32I interpreter: labels, branches, jal/jalr, lw/lbu; counts executed instructions.
const RN = { zero: 0, ra: 1, t0: 5, t1: 6, t2: 7, a0: 10, a1: 11 }, SWAP = { bgt: "blt", ble: "bge", bgtu: "bltu", bleu: "bgeu" };
const BR = { beq: (p, q) => p === q, bne: (p, q) => p !== q, blt: (p, q) => p < q, bge: (p, q) => p >= q,
bltu: (p, q) => p >>> 0 < q >>> 0, bgeu: (p, q) => p >>> 0 >= q >>> 0 };
function run(src, init, mem) {
const lab = {}, P = [], x = new Int32Array(32), dv = new DataView(mem.buffer); let pc = 0, count = 0;
for (let s of src.split(/[\n;]/)) { // pass 1: label -> byte address, expand pseudo
const m = (s = s.split("#")[0].trim()).match(/^(\w+):\s*(.*)$/); if (m) { lab[m[1]] = P.length * 4; s = m[2]; }
let [op, a, b, c] = s.replace(/[,()]/g, " ").trim().split(/\s+/); if (!op) continue;
if (op in SWAP) [op, a, b] = [SWAP[op], b, a]; // ble a,b,L == bge b,a,L
if (op === "beqz" || op === "bnez") [op, b, c] = [op.slice(0, 3), "zero", b]; // beqz a,L == beq a,zero,L
P.push([op, a, b, c]); }
const r = n => x[RN[n]], set = (n, v) => { if (RN[n]) x[RN[n]] = v; }; // x0 stays 0
const OP = { add: (b, c) => r(b) + r(c), sub: (b, c) => r(b) - r(c), addi: (b, c) => r(b) + +c, slli: (b, c) => r(b) << +c,
li: b => +b, mv: b => r(b), lw: (b, c) => dv.getInt32(r(c) + +b, true), lbu: (b, c) => mem[r(c) + +b] };
for (const k in init) set(k, init[k]);
for (; pc / 4 < P.length && count < 1e5; count++) { // pass 2: fetch, execute, new PC
const [op, a, b, c] = P[pc / 4]; let next = pc + 4;
if (op in BR) { if (BR[op](r(a), r(b))) next = lab[c]; } // taken: PC = label
else if (op === "j" || op === "jal") { if (op === "jal") set(a, pc + 4); next = lab[op === "j" ? a : b]; } // j L == jal zero, L
else if (op === "jalr") { next = (r(c) + +b) & ~1; set(a, pc + 4); }
else set(a, OP[op](b, c));
pc = next;
} return { r, count }; }
const mem = new Uint8Array(64), dv = new DataView(mem.buffer);
[5, -2, 9, 4, -7, 3].forEach((v, i) => dv.setInt32(4 * i, v, true)); [..."Dhaka"].forEach((ch, i) => mem[32 + i] = ch.charCodeAt(0));
const SUM = `li t0, 0; li t1, 0; loop: bge t1, a1, done; slli t2, t1, 2; add t2, a0, t2; lw t2, 0(t2); add t0, t0, t2
addi t1, t1, 1; j loop; done: mv a0, t0`, STRLEN = `mv t0, a0; loop: lbu t1, 0(t0); beqz t1, done; addi t0, t0, 1; j loop; done: sub a0, t0, a0`;
const MAX = `lw t0, 0(a0); li t1, 1; loop: bge t1, a1, done; slli t2, t1, 2; add t2, a0, t2; lw t2, 0(t2)
ble t2, t0, skip; mv t0, t2 # if (a[i] > max): branch on the opposite
skip: addi t1, t1, 1; j loop; done: mv a0, t0`;
for (const [name, src, init] of [["sum", SUM, { a0: 0, a1: 6 }], ["strlen", STRLEN, { a0: 32 }], ["max", MAX, { a0: 0, a1: 6 }]]) {
const { r, count } = run(src, init, mem), show = k => `${k} = ${String(r(k)).padStart(2)}`;
console.log(name.padEnd(6), ["a0", "t0", "t1", "t2"].map(show).join(" "), " executed:", count);
}আউটপুট (নির্ধারিত — প্রতিবার একই):
sum a0 = 12 t0 = 12 t1 = 6 t2 = 3 executed: 46
strlen a0 = 5 t0 = 37 t1 = 0 t2 = 0 executed: 24
max a0 = 9 t0 = 9 t1 = 6 t2 = 3 executed: 40
sum: 5 − 2 + 9 + 4 − 7 + 3 = 12; লুপ শেষে i (t1) = 6 = n, আর t2-তে শেষ পড়া মান a[5] = 3. চলেছে ৪৬টি instruction — অংশ ৬-এর সূত্র 7n + 4-এ n = 6 বসালে 46. strlen: “Dhaka”-র দৈর্ঘ্য ৫; t0 থেমেছে ঠিকানা 37-এ, অর্থাৎ শুরু (32) থেকে ৫ byte পরে, যেখানে ‘\0’ আছে — তাই t1 = 0. গোনা ২৪ = 4L + 4 (L = 5): প্রতি অক্ষরে ৪টি, আর ‘\0’-তে lbu + beqz এবং শুরু-শেষের দুটি। max: উত্তর ৯. ৫টি ঘুরের প্রতিটিতে ৭টি instruction, আর শুধু 9 পাওয়ার ঘুরে একটি বাড়তি mv: 2 + 5 × 7 + 1 + 1 + 1 = 40. লক্ষ করুন, max-এর গোনা ডেটার ওপর নির্ভর করে — array যত এলোমেলো, mv তত বেশি বা কম।
৬ · কয়টি instruction চলে? — dynamic instruction count-এর সূত্র
দুটি সংখ্যা আলাদা করে চিনুন। প্রোগ্রামে কতগুলো instruction লেখা আছে — static instruction count; আর চালানোর সময় আসলে কতগুলো চলে — dynamic instruction count. ১০ লাইনের একটি লুপ এক লাখবার ঘুরলে static ১০, dynamic প্রায় দশ লাখ। পর্ব ০৩-এর (CPU time-এর লৌহসূত্র) iron law-এ যে instruction count, তা এই dynamic সংখ্যাই: CPU time = IC × CPI × clock cycle time.
সূত্র বের করার নিয়ম
একটি লুপকে চার ভাগে ভাগ করুন, আর প্রতিটি ভাগ কতবার চলে গুনুন:
- Top-tested যোগফল (অংশ ৫-এর (১)): আগে ২টি (
li,li). প্রতি ঘুরেbge(লাফ নেই) + ৫টি কাজ +j= ৭টি. শর্ত-যাচাই n + 1 বার চলে — শেষবার লাফ দিয়ে বেরোয় — তাই বাড়তি ১টি। পরেmv১টি। IC = 2 + 7n + 1 + 1 = 7n + 4. - Rotated যোগফল (অংশ ৪): আগে ৩টি (দুটি
liআর guard). প্রতি ঘুরে ৫টি কাজ + নিচেরblt= ৬টি. পরে ১টি। IC = 3 + 6n + 1 = 6n + 4. n = 0-তেও ঠিক: guard লাফ দিয়ে সোজাmv-এ, মোট ৪. - ধনাত্মক গোনা (অংশ ৫-এর (২)): ডেটা-নির্ভর। p = ধনাত্মক সংখ্যার সংখ্যা হলে IC = 3 + 6n + p + 1 = 6n + p + 4. a[] = {5, −2, 9, 4, −7, 3}-এ n = 6, p = 4, তাই ৪৪.
যুক্তিটি কেন নির্ভুল: একটি basic block-এ ঢুকলে তার সব instruction চলে। তাই প্রতিটি block কতবার চলে (তার “execution frequency”) জানলেই IC = Σ (block-এর দৈর্ঘ্য × কতবার চলে). লুপের body-block n বার, লুপের আগে-পরের block একবার, আর top-tested শর্ত-block n + 1 বার — কারণ প্রতিটি ঘুরের আগে একবার, আর বেরোনোর সময় আরেকবার। Rotation শর্তকে body-র শেষে জুড়ে দিয়ে এক ঘুরে একটি j বাঁচায়: (7 − 6)n = n টি instruction.
পরীক্ষার ধাঁচে একটি পূর্ণ হিসাব: একটি লুপের IC = 4n + 5, n = 1000, গড় CPI = 1.2, clock 1 GHz (cycle 1 ns). IC = 4005; cycle = 4005 × 1.2 = 4806; সময় = 4806 ns ≈ ৪.৮১ µs.
হাতে বের করা সূত্র সবসময় simulation দিয়ে মেলানো উচিত, বিশেষ করে সীমানার মানে (n = 0, 1). নিচের analyser তিনটি যোগফল-লুপ n = 0, 1, 5, 10-এ চালায়, n = 5 আর n = 10-এর দুটি বিন্দু থেকে সরলরেখা a·n + b বের করে, তারপর চারটি n-এই সূত্র যাচাই করে। তৃতীয়টি compiler-এর -O2-এর ধাঁচে pointer-ভিত্তিক লুপ: index-এর বদলে pointer বাড়ে ৪ করে, আর শেষের ঠিকানা আগেই হিসাব করা থাকে (bge zero, a1 হলো blez a1):
// Dynamic instruction count of three sum loops for n = 0, 1, 5, 10; fit count = a*n + b.
const RN = { zero: 0, t0: 5, t1: 6, t2: 7, a0: 10, a1: 11 };
const BR = { bne: (p, q) => p !== q, blt: (p, q) => p < q, bge: (p, q) => p >= q };
function count(src, init, mem) {
const lab = {}, P = [], x = new Int32Array(32), dv = new DataView(mem.buffer);
for (let s of src.split(/[\n;]/)) {
const m = (s = s.split("#")[0].trim()).match(/^(\w+):\s*(.*)$/); if (m) { lab[m[1]] = P.length; s = m[2]; }
const t = s.replace(/[,()]/g, " ").trim().split(/\s+/); if (t[0]) P.push(t);
}
for (const k in init) x[RN[k]] = init[k];
const r = n => x[RN[n]], set = (n, v) => { if (RN[n]) x[RN[n]] = v; };
let pc = 0, n = 0;
for (; pc < P.length; n++) {
const [op, a, b, c] = P[pc++];
if (op in BR) { if (BR[op](r(a), r(b))) pc = lab[c]; } else if (op === "j") pc = lab[a];
else set(a, { add: () => r(b) + r(c), addi: () => r(b) + +c, slli: () => r(b) << +c,
li: () => +b, mv: () => r(b), lw: () => dv.getInt32(r(c) + +b, true) }[op]());
}
return n;
}
const LOOPS = {
"top-tested": `li t0, 0; li t1, 0; loop: bge t1, a1, done; slli t2, t1, 2; add t2, a0, t2
lw t2, 0(t2); add t0, t0, t2; addi t1, t1, 1; j loop; done: mv a0, t0`,
"rotated ": `li t0, 0; li t1, 0; bge t1, a1, done; loop: slli t2, t1, 2; add t2, a0, t2
lw t2, 0(t2); add t0, t0, t2; addi t1, t1, 1; blt t1, a1, loop; done: mv a0, t0`,
"pointer ": `li t0, 0; bge zero, a1, done; slli t1, a1, 2; add t1, a0, t1
loop: lw t2, 0(a0); add t0, t0, t2; addi a0, a0, 4; bne a0, t1, loop; done: mv a0, t0`,
};
const mem = new Uint8Array(64);
for (const [name, src] of Object.entries(LOOPS)) {
const c = n => count(src, { a0: 0, a1: n }, mem), N = [0, 1, 5, 10], C = N.map(c);
const a = (c(10) - c(5)) / 5, b = c(5) - 5 * a; // fit from n = 5 and n = 10
const chk = N.map((n, i) => (a * n + b === C[i] ? "ok" : `MISMATCH at n=${n}`)).join(" ");
console.log(`${name} n=0,1,5,10 -> ${C.join(", ").padEnd(14)} formula ${a}n + ${b} check: ${chk}`);
}আউটপুট (নির্ধারিত):
top-tested n=0,1,5,10 -> 4, 11, 39, 74 formula 7n + 4 check: ok ok ok ok
rotated n=0,1,5,10 -> 4, 10, 34, 64 formula 6n + 4 check: ok ok ok ok
pointer n=0,1,5,10 -> 3, 9, 25, 45 formula 4n + 5 check: MISMATCH at n=0 ok ok ok
হাতে পাওয়া 7n + 4 আর 6n + 4 হুবহু মিলেছে, n = 0 সহ। Pointer-লুপে প্রতি ঘুরে মাত্র ৪টি instruction (lw, add, addi, bne) — index-কে ৪ দিয়ে গুণ আর ঠিকানা যোগ লুপ থেকে বেরিয়ে গেছে; compiler-এর এই কৌশলের নাম strength reduction. কিন্তু n = 0-তে সূত্র ভাঙল: সেখানে guard লাফ দিয়ে শেষ-ঠিকানার হিসাবটিও (slli, add) বাদ দেয়, তাই 4·0 + 5 = 5 নয়, ৩. সঠিক উত্তর তাই দুই ভাগে: n ≥ 1-এ 4n + 5, n = 0-তে 3. পরীক্ষায় “n-এর সব মানে” প্রশ্ন এলে এই সীমানাটি আলাদা করে লিখুন।
৭ · switch — if-chain বনাম jump table
একটি switch-কে compiler দুইভাবে অনুবাদ করতে পারে। If-chain: case-গুলো একে একে তুলনা — x == 1? x == 2? … RISC-V-তে immediate-এর সাথে সরাসরি তুলনার শাখা নেই, তাই প্রতিটি case-এ একটি li আর একটি beq. শেষের case পর্যন্ত পৌঁছাতে সবগুলো তুলনা পেরোতে হয়। Jump table: case-এর মানগুলো ঘন হলে (যেমন 1…5) মেমরিতে একটি টেবিল রাখা হয়, যার k-তম ঘরে k-তম case-এর কোডের ঠিকানা। তখন x দিয়ে সরাসরি টেবিলের ঘর বেছে jalr — case যতই থাকুক, খরচ একই।
লাইব্রেরিতে বই খোঁজার দুই উপায়। এক — তাকের প্রথম বই থেকে একটা একটা করে নাম মেলানো (if-chain). দুই — ক্যাটালগ কার্ডে বইয়ের নম্বর দেখে সোজা সেই তাকে যাওয়া (jump table). ক্যাটালগে যাওয়ার আগে অবশ্য দেখে নিতে হয় নম্বরটি ক্যাটালগে আছে কি না — নাহলে অচেনা তাকে গিয়ে ভুল বই হাতে আসবে। এই যাচাইটাই jump table-এর bounds check.
/* C */ # RV32I — a0 = x, a1 = &table (0x2000)
switch (x) { 0x1000 addi t0, a0, -1 # index = x - 1
case 1: r = 10; break; 0x1004 li t1, 5
case 2: r = 20; break; 0x1008 bgeu t0, t1, default # index >= 5 (unsigned)?
case 3: r = 30; break; 0x100C slli t0, t0, 2 # index * 4
case 4: r = 40; break; 0x1010 add t0, a1, t0 # &table[index]
case 5: r = 50; break; 0x1014 lw t0, 0(t0) # target address
default: r = -1; 0x1018 jr t0 # jalr x0, 0(t0)
} 0x101C case1: li a0, 10 | 0x1020 j end
0x1024 case2: li a0, 20 | 0x1028 j end
table @0x2000: 0x102C case3: li a0, 30 | 0x1030 j end
0x101C 0x1024 0x102C 0x1034 case4: li a0, 40 | 0x1038 j end
0x1034 0x103C 0x103C case5: li a0, 50 | 0x1040 j end
0x1044 default: li a0, -1
0x1048 end:
সবচেয়ে সুন্দর কৌশলটি তৃতীয় লাইনে। index = x − 1 ঋণাত্মক হতে পারে (x = 0 বা −2), আবার ৪-এর বেশিও হতে পারে (x = 7). দুটি তুলনার বদলে compiler একটিই unsigned তুলনা বসায়: ঋণাত্মক সংখ্যা unsigned চোখে বিশাল (−1 = 0xFFFFFFFF), তাই bgeu দুই ধরনের ভুল index-কেই default-এ পাঠায়। Bounds check বাদ দিলে lw টেবিলের বাইরের যেকোনো মেমরি পড়ে সেটিকে ঠিকানা ভেবে লাফ দিত — এটি শুধু বাগ নয়, নিরাপত্তার গর্তও। x = 3-এর পুরো যাত্রা ধাপে ধাপে:
10x1000 addi t0, a0, -120x1004 li t1, 530x1008 bgeu t0, t1, default40x100C slli t0, t0, 250x1010 add t0, a1, t060x1014 lw t0, 0(t0)70x1018 jr t080x101C case1: li a0, 10 ; j end90x1024 case2: li a0, 20 ; j end100x102C case3: li a0, 30 ; j end110x1034 case4: li a0, 40 ; j end120x103C case5: li a0, 50 ; j end130x1044 default: li a0, -1140x1048 end:
| a0 | |
| t0 | |
| t1 | |
| পরের PC | |
| চলা instruction |
- সবচেয়ে ছোট case বাদ। x = 3, তাই index = 3 − 1 = 2. case-গুলো 1 থেকে শুরু, টেবিল 0 থেকে — এই বিয়োগ দুটিকে মেলায়।
- Bounds check.
li t1, 5, তারপরbgeu: 2 ≥ 5 (unsigned)? না — লাফ নেই, PC = 0x100C. টেবিলে ৫টি ঘর, index 2 বৈধ। - টেবিলের ঘরের ঠিকানা। প্রতিটি ঠিকানা ৪ byte, তাই 2 × 4 = 8, আর 0x2000 + 8 = 0x2008 — টেবিলের তৃতীয় ঘর।
- গন্তব্য পড়া।
lw0x2008 থেকে আনে 0x102C — case3-এর label-এর ঠিকানা, যা assembler আর linker টেবিলে লিখে রেখেছে। - Indirect লাফ।
jr t0(=jalr x0, 0(t0)): PC = 0x102C. গন্তব্য কোডে লেখা ছিল না — ডেটা থেকে এসেছে। - case-এর কাজ আর break.
li a0, 30, তারপরj end: 0x1030 থেকে offset +24, PC = 0x1048. মোট ৯টি instruction — x = 1 হোক বা 5, সবসময় ৯. - সীমার বাইরে। আলাদা একটি রান, x = 0: index = −1 = 0xFFFFFFFF. unsigned অর্থে এটি 5-এর চেয়ে অনেক বড়, তাই
bgeuলাফ দেয় 0x1008 + 60 = 0x1044-এ, a0 = −1. মাত্র ৪টি instruction. x = 7 হলে index 6, সেটিও ≥ 5 — একই পথ।
এবার একই switch-কে দুইভাবে চালিয়ে গোনা যাক — jump table আর if-chain, কয়েকটি x-এ, সীমার বাইরের মানসহ। Interpreter টেবিলে label-এর ঠিকানা নিজে বসায়, assembler-linker যেমন করে:
// switch (x) { case 1..5: r = 10*x; default: r = -1 } as a jump table vs an if-chain (RV32I).
const RN = { zero: 0, t0: 5, t1: 6, a0: 10, a1: 11 }, BASE = 0x1000, TABLE = 0x2000;
const BR = { beq: (p, q) => p === q, bgeu: (p, q) => p >>> 0 >= q >>> 0 };
function asm(src) {
const lab = {}, P = []; for (let s of src.split(/[\n;]/)) {
const m = (s = s.split("#")[0].trim()).match(/^(\w+):\s*(.*)$/); if (m) { lab[m[1]] = BASE + 4 * P.length; s = m[2]; }
const t = s.replace(/[,()]/g, " ").trim().split(/\s+/); if (t[0]) P.push(t);
} return { lab, P }; }
function exec({ lab, P }, x0, mem) {
const x = new Int32Array(32), dv = new DataView(mem.buffer), r = n => x[RN[n]], set = (n, v) => { if (RN[n]) x[RN[n]] = v; };
x[RN.a0] = x0; x[RN.a1] = TABLE; // a1 = table base (real code: lui + addi)
let pc = BASE, n = 0;
for (; pc < BASE + 4 * P.length; n++) {
const [op, a, b, c] = P[(pc - BASE) / 4]; pc += 4;
if (op in BR) { if (BR[op](r(a), r(b))) pc = lab[c]; } else if (op === "j") pc = lab[a];
else if (op === "jr") pc = r(a) & ~1; // jr t0 == jalr zero, 0(t0)
else set(a, { add: () => r(b) + r(c), addi: () => r(b) + +c, slli: () => r(b) << +c,
li: () => +b, lw: () => dv.getInt32(r(c) + +b, true) }[op]());
}
return [r("a0"), n];
}
const CASES = `case1: li a0, 10; j end; case2: li a0, 20; j end; case3: li a0, 30; j end
case4: li a0, 40; j end; case5: li a0, 50; j end; default: li a0, -1; end:`;
const jt = asm(`addi t0, a0, -1; li t1, 5; bgeu t0, t1, default # one unsigned test: x<1 or x>5
slli t0, t0, 2; add t0, a1, t0; lw t0, 0(t0); jr t0\n` + CASES);
const chain = asm([1, 2, 3, 4, 5].map(k => `li t0, ${k}; beq a0, t0, case${k}`).join("; ") + "; j default\n" + CASES);
const mem = new Uint8Array(TABLE + 32), dv = new DataView(mem.buffer);
[1, 2, 3, 4, 5].forEach((k, i) => dv.setInt32(TABLE + 4 * i, jt.lab["case" + k], true));
console.log("table:", [0, 1, 2, 3, 4].map(i => "0x" + dv.getInt32(TABLE + 4 * i, true).toString(16)).join(" "));
console.log(" x | result | jump table | if-chain");
for (const v of [1, 3, 5, 0, 7, -2]) {
const [r1, n1] = exec(jt, v, mem), [r2, n2] = exec(chain, v, mem);
console.log(`${String(v).padStart(3)} | ${String(r1).padStart(6)} | ${String(n1).padStart(10)} | ${String(n2).padStart(8)}${r1 === r2 ? "" : " DIFFERENT"}`);
}আউটপুট (নির্ধারিত):
table: 0x101c 0x1024 0x102c 0x1034 0x103c
x | result | jump table | if-chain
1 | 10 | 9 | 4
3 | 30 | 9 | 8
5 | 50 | 9 | 12
0 | -1 | 4 | 12
7 | -1 | 4 | 12
-2 | -1 | 4 | 12
টেবিলের ঠিকানাগুলো stepper-এর সাথে হুবহু মেলে, আর দুই পদ্ধতির ফল প্রতিটি x-এ এক (কোথাও “DIFFERENT” নেই)। Jump table-এ খরচ স্থির: বৈধ case-এ ৯, সীমার বাইরে ৪. If-chain-এ case k-তে 2k + 2 (k জোড়া li/beq আর case-এর দুটি), default-এ ১২. x = 1-এ if-chain জিতেছে (৪ বনাম ৯), x = 5-এ হেরেছে।
Compiler কখন কোনটি বেছে নেয়
উপরের সংখ্যা দিয়ে একটি হিসাব: ধরুন x = 1…5 আর একটি সীমার বাইরের মান — ছয়টিই সমান সম্ভাবনার। If-chain-এর গড় = (4 + 6 + 8 + 10 + 12 + 12) / 6 = 52/6 ≈ ৮.৬৭; jump table-এর গড় = (9 × 5 + 4) / 6 = 49/6 ≈ ৮.১৭. প্রায় সমান! বাস্তবে টেবিলের ঠিকানা আনতে আরও দুটি instruction লাগে (lui + addi, এখানে a1-এ আগে থেকে রাখা ছিল), তখন বৈধ case-এ ১১, গড় ৫৯/৬ ≈ ৯.৮৩ — if-chain-ই সামান্য এগিয়ে। সাধারণভাবে N টি ঘন case-এ if-chain-এর গড় খরচ N-এর সাথে বাড়ে (মোটামুটি N + 3), jump table-এরটি স্থির। তাই:
- অল্প case: if-chain বা তুলনার একটি ছোট গাছ। gcc-র নিজস্ব সীমা target-ভেদে ভিন্ন — সাধারণত ৪–৫টির কম ভিন্ন case-এ jump table বানায় না (
--param case-values-thresholdদিয়ে বদলানো যায়)। - অনেক, ঘন case (যেমন bytecode interpreter-এর opcode, state machine): jump table.
- অনেক, ছড়ানো case (যেমন 1, 100, 5000, 70000): টেবিল হবে বিশাল আর প্রায় ফাঁকা, তাই compiler বানায় binary search-এর মতো তুলনার গাছ — প্রায় 2·log2N টি তুলনা। কয়েকটি case একই কাজ করলে (case 1, 3, 5, 7: …) bit mask দিয়ে একটি তুলনাও সম্ভব।
আরেকটি দাম আছে, যা instruction গুনে দেখা যায় না: jalr-এর গন্তব্য আগে থেকে অনুমান করা কঠিন, আর ভুল অনুমানে pipeline-এ অনেকগুলো cycle নষ্ট হয়। এর বিস্তারিত পর্ব ৩৭-এ (Control hazard আর branch prediction).
৮ · Flag, compare-and-branch আর শাখাহীন কোড — x86, ARM আর RISC-V
একই সিদ্ধান্ত তিনটি ISA তিনভাবে প্রকাশ করে। পর্ব ১৩-এ hardware-এর দিকটা দেখেছেন; এখানে একটি লুপ পাশাপাশি — n টি int-এর যোগফল, প্রতিটি “compiler-এর -O2-এর ধাঁচে” হাতে লেখা (আসল আউটপুট compiler-এর সংস্করণভেদে একটু ভিন্ন হতে পারে):
# উদাহরণ — x86-64 (Intel syntax): rdi = a, esi = n
xor eax, eax ; sum = 0
test esi, esi ; sets flags from n
jle .Ldone ; guard: n <= 0
xor ecx, ecx ; i = 0
.Lloop: add eax, DWORD PTR [rdi+rcx*4]
add rcx, 1
cmp ecx, esi ; flags := i - n
jl .Lloop ; signed less: SF != OF
.Ldone: ret
# উদাহরণ — ARM64 (AArch64): x0 = a, w1 = n
mov w2, 0 // sum
cmp w1, 0
b.le .Ldone // guard
mov x3, 0 // i
.Lloop: ldr w4, [x0, x3, lsl 2] // a[i], index scaled inside the load
add w2, w2, w4
add x3, x3, 1
cmp w3, w1 // flags NZCV := i - n
b.lt .Lloop // N != V
.Ldone: mov w0, w2
ret
x86 আর ARM-এ তুলনা আর শাখা দুটি আলাদা instruction, মাঝখানে যোগসূত্র status flags আর condition code (jl, b.lt). RISC-V-তে একটি blt দুটির কাজ করে। আবার x86 আর ARM-এর load-এ index × 4 ভেতরেই হয়, RISC-V-তে আলাদা slli + add লাগে — যে কারণে RISC-V compiler pointer-লুপ পছন্দ করে। ARM64-এরও কিছু compare-and-branch আছে: cbz/cbnz (শূন্যের সাথে) আর tbz/tbnz (একটি বিট).
শাখা না দিয়েই সিদ্ধান্ত — predication আর conditional move
শাখার একটি লুকোনো খরচ আছে। Pipeline-এ (পর্ব ৩৫) CPU শাখার ফল জানার আগেই পরের instruction আনতে শুরু করে, অনুমান করে কোন পথে যাবে। অনুমান ভুল হলে আনা কাজ ফেলে দিতে হয় — আধুনিক বড় core-এ এক-একটি ভুলে প্রায় ১০–২০ cycle. ডেটা এলোমেলো হলে (যেমন max লুপে কখন নতুন সর্বোচ্চ আসবে) অনুমান প্রায়ই ভুল হয়। তাই ছোট সিদ্ধান্তে শাখাই এড়ানো যায় — branchless code:
- Conditional move: x86-এর
cmovcc, ARM64-এরcsel— flag দেখে দুটি মানের একটি বেছে নেয়, PC বদলায় না। - Predication: পুরো instruction-ই শর্তসাপেক্ষ — শর্ত মিথ্যা হলে instruction চলে কিন্তু কোনো ফল লেখে না। পুরোনো ৩২-বিট ARM-এ প্রায় প্রতিটি instruction-এ ৪ বিটের শর্ত-ঘর ছিল (
addge,movlt); Intel-এর Itanium (IA-64) পুরো নকশাই predicate register-এর ওপর দাঁড় করিয়েছিল। AArch64 সাধারণ predication বাদ দিয়ে শুধুcsel-এর মতো কয়েকটি রেখেছে। - RISC-V: base RV32I-এ এমন কিছু নেই, তবে
sltদিয়ে নিজে বানানো যায়। পরে যোগ হয়েছে ঐচ্ছিক extension: Zbb-এ সরাসরিmax,min,maxu,minu, আর Zicond (২০২৩ সালের দিকে অনুমোদিত)-এczero.eqz/czero.nez— শর্ত-register শূন্য বা অশূন্য হলে ফলকে শূন্য করে দেয়; দুটিczeroআর একটিorমিলে একটি conditional select. কোন core-এ কোন extension আছে, তা নির্দিষ্ট chip-এর বিবরণ দেখে নিশ্চিত হতে হয়।
# উদাহরণ — int max(int a, int b); x86-64 অংশ Intel syntax-এ
# x86-64 (gcc -O2 ধাঁচে) # ARM64 # RV32I, শুধু base (শাখাহীন, mask দিয়ে)
cmp edi, esi cmp w0, w1 slt t0, a0, a1 # t0 = (a < b) ? 1 : 0
mov eax, esi csel w0, w0, w1, ge sub t0, x0, t0 # mask = 0 or 0xFFFFFFFF
cmovge eax, edi ret xor t1, a0, a1 # a ^ b
ret and t1, t1, t0 # (a ^ b) & mask
xor a0, a0, t1 # a, or a ^ a ^ b = b
# RV32I + Zicond: slt t0,a0,a1; czero.nez t1,a0,t0; czero.eqz t2,a1,t0; or a0,t1,t2
Mask কৌশলের যুক্তি: slt দেয় ০ বা ১; ০ থেকে বিয়োগ করলে ০ বা −1, আর −1 মানে সব ৩২ বিট ১ — একটি পূর্ণ bitmask. a < b হলে mask সব-১, তাই a ⊕ (a ⊕ b) = b; নাহলে mask ০, ফল a. উদাহরণ a = −3, b = 5: t0 = 1 → −1; a ⊕ b = −8; ফল −3 ⊕ −8 = 5. পাঁচটি instruction, কোনো শাখা নেই, আর চলার সময় ডেটা যা-ই হোক একই পথ — এজন্যই cryptography-র কোডে এই ধরন প্রিয়। শাখার বদলে এমন কোড বসানোর compiler-কৌশলকে বলে if-conversion.
এই কৌশলগুলো সত্যিই সব মানে ঠিক কি না, সীমানাসহ, তা C-তে যাচাই করা যায়। নিচে max, min আর abs-এর শাখাহীন রূপ সাধারণ রূপের সাথে মেলানো হচ্ছে — সীমানার ৮টি মানের সব জোড়ায় আর দশ লাখ এলোমেলো জোড়ায়। Overflow-এর undefined behaviour এড়াতে abs-এর ফল unsigned (INT32_MIN-এর পরম মান int32-এ ধরে না):
#include <stdio.h>
#include <stdint.h>
#include <string.h>
/* Branchless versions: a comparison gives 0 or 1, negating it gives a mask 0 or all-ones. */
static int32_t max_bl(int32_t a, int32_t b) { int32_t m = -(int32_t)(a < b); return a ^ ((a ^ b) & m); }
static int32_t min_bl(int32_t a, int32_t b) { int32_t m = -(int32_t)(a < b); return b ^ ((a ^ b) & m); }
static uint32_t abs_bl(int32_t x) { uint32_t m = 0u - (uint32_t)(x < 0); return ((uint32_t)x ^ m) - m; }
static int32_t to_i32(uint32_t u) { int32_t v; memcpy(&v, &u, sizeof v); return v; }
/* Ordinary versions with if/?: (the compiler may still turn these into cmov/csel) */
static int32_t max_br(int32_t a, int32_t b) { return a > b ? a : b; }
static int32_t min_br(int32_t a, int32_t b) { return a < b ? a : b; }
static uint32_t abs_br(int32_t x) { return x < 0 ? 0u - (uint32_t)x : (uint32_t)x; }
int main(void) {
const int32_t edge[] = { INT32_MIN, INT32_MIN + 1, -7, -1, 0, 1, 9, INT32_MAX };
long checks = 0, bad = 0;
for (int i = 0; i < 8; i++)
for (int j = 0; j < 8; j++) {
int32_t a = edge[i], b = edge[j]; checks += 3;
bad += (max_bl(a, b) != max_br(a, b)) + (min_bl(a, b) != min_br(a, b)) + (abs_bl(a) != abs_br(a));
}
uint32_t s = 2026u; /* deterministic LCG */
for (int k = 0; k < 1000000; k++) {
s = s * 1664525u + 1013904223u; int32_t a = to_i32(s);
s = s * 1664525u + 1013904223u; int32_t b = to_i32(s); checks += 3;
bad += (max_bl(a, b) != max_br(a, b)) + (min_bl(a, b) != min_br(a, b)) + (abs_bl(a) != abs_br(a));
}
printf("max(-7, 9) = %d, min(-7, 9) = %d, abs(-7) = %u, abs(INT32_MIN) = %u\n",
max_bl(-7, 9), min_bl(-7, 9), abs_bl(-7), abs_bl(INT32_MIN));
printf("checks: %ld, mismatches: %ld\n", checks, bad);
return 0;
}আউটপুট (Wandbox-এ gcc, warning চালু — শূন্য warning):
max(-7, 9) = 9, min(-7, 9) = -7, abs(-7) = 7, abs(INT32_MIN) = 2147483648
checks: 3000192, mismatches: 0
max(−7, 9) = 9, min = −7, abs(−7) = 7 — আর সবচেয়ে কঠিন সীমানা abs(INT32_MIN) = 2147483648, যা signed int-এ ধরত না; unsigned ফেরত দেওয়ায় নির্ভুল। মোট যাচাই 8 × 8 × 3 = 192 সীমানার আর 10,00,000 × 3 এলোমেলো, অমিল শূন্য। মনে রাখুন, -O2-তে gcc সাধারণ a > b ? a : b-কেও প্রায়ই নিজে থেকেই cmov বা csel-এ বদলে দেয় — হাতে mask লেখা দরকার হয় মূলত তখন, যখন নিশ্চিত হতে চান কোনো শাখা থাকবে না।
শাখাহীন কোড সবসময় দ্রুত নয়। এটি দুই পথের কাজই করে, আর ফল দুই পথের ইনপুটের ওপর নির্ভর করে। শাখা যদি প্রায় সবসময় একদিকে যায় (যেমন “error হলে” পরীক্ষা), predictor প্রায় নিখুঁত অনুমান করে আর শাখা প্রায় বিনা খরচে চলে — তখন শাখাই ভালো। শাখাহীন কোড জেতে যখন সিদ্ধান্ত এলোমেলো আর দুই পথ ছোট। কেন, তা পর্ব ৩৭-এ পরিমাপসহ দেখবেন।
আরও গভীরে। আধুনিক x86 core পাশাপাশি থাকা cmp + jcc-কে decode-এর সময় একটি অভ্যন্তরীণ কাজে জুড়ে দেয় (macro-op fusion; Intel-এ Core 2 প্রজন্ম থেকে) — ফলে flag-ভিত্তিক তুলনা-শাখা কার্যত RISC-V-এর একক compare-and-branch-এর মতোই চলে। কিছু উচ্চক্ষমতার RISC-V core-ও উল্টো দিকে একই কাজ করে বলে জানা যায়, যেমন slli + add-কে একসাথে। Position-independent কোডে compiler jump table-এ পূর্ণ ঠিকানার বদলে টেবিল থেকে আপেক্ষিক দূরত্ব রাখে (x86-64-এ ৩২-বিট, ARM64-এ প্রায়ই byte বা halfword-এর দূরত্ব), যাতে টেবিল ছোট হয় আর load-এর সময় ঠিকানা ঠিক করতে না হয়।
৯ · AI-এর যুগে শাখা আর লুপের assembly
AI টুল C থেকে RISC-V assembly বেশ গুছিয়ে লেখে, কিন্তু ঠিক এই পর্বের খুঁটিনাটিতেই ভুল করে — আর assembly-র ভুল চোখে দেখে ধরা কঠিন, কারণ কোড দেখতে “ঠিকঠাক” লাগে। প্রচলিত ভুলগুলো:
| AI-এর প্রচলিত ভুল | ঠিক কথা | কীভাবে যাচাই |
|---|---|---|
MIPS আর RISC-V মিশিয়ে ফেলা: target = PC + 4 + imm × 4, শাখার পরে delay slot, $t0 ধাঁচের নাম | RISC-V-তে target = PC + imm (byte-এ), কোনো delay slot নেই | RISC-V spec-এর B-type আর JAL অংশ; হাতে একটি target হিসাব |
bgt, ble-কে hardware instruction বলা, বা operand না উল্টে আসল রূপ লেখা | Pseudo: bgt a,b = blt b,a | Assembler-এর disassembly (objdump -d) দেখা |
unsigned বা size_t-এর তুলনায় blt বসানো | Unsigned-এ bltu/bgeu | −1 বা 0x80000000 দিয়ে চালিয়ে দেখা |
for লুপে continue-কে শর্তের label-এ পাঠানো (i++ বাদ পড়ে) | for-এ continue যায় increment-এ | simulator-এ continue-ওয়ালা একটি ঘুর চালানো |
| Jump table-এ bounds check ভুলে যাওয়া, বা signed তুলনায় করা | একটি bgeu দিয়ে দুই দিকের সীমা | x = 0, ঋণাত্মক আর সবচেয়ে বড় case + 1 দিয়ে চালানো |
| Dynamic count-এ শেষ শর্ত-যাচাই বা n = 0-এর ক্ষেত্র বাদ দেওয়া | শর্ত-block n + 1 বার; guard আলাদা | n = 0, 1, 5-এ গুনে সূত্রের সাথে মেলানো |
AI-কে প্রশ্ন করার সময় ISA, register-এর ভূমিকা আর প্রতিটি শাখার হিসাব চেয়ে নিন:
AI-এর উত্তর হাতে পেলে — যাচাইয়ের চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- RISC-V-তে branch target কীভাবে হিসাব হয়, নাগাল কত? — target = PC + sign-extended offset; শাখায় ±৪ KiB,
jal-এ ±১ MiB. - RISC-V-তে
bgtনেই কেন? — operand অদলবদল করলেblt-ই কাজ করে; assembler pseudo হিসেবে দেয়। - if-এর অনুবাদে শর্ত উল্টানো হয় কেন? — শাখা লাফ দেয় শর্ত সত্য হলে, আর then-অংশ ঠিক নিচে; তাই উল্টো শর্তে then টপকানো সবচেয়ে কম instruction-এ হয়।
- Loop rotation কী, আর কী লাভ? — শর্ত লুপের নিচে, শুরুতে একবার guard; প্রতি ঘুরে একটি
jকমে (যেমন 7n + 4 থেকে 6n + 4). - Jump table কখন, আর bounds check-এ unsigned কেন? — অনেক ঘন case-এ; একটি
bgeuঋণাত্মক আর বড় দুই ধরনের index-ই ধরে।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১x86 আর ARM flag রাখে, RISC-V রাখে না। flag-হীন compare-and-branch কি সত্যিই ভালো নকশা, নাকি শুধু ভিন্ন রুচি?
Flag-এর ধারণা খুব পুরোনো — ১৯৭০-এর দশকের 8080, 6502 থেকে শুরু করে x86 পর্যন্ত প্রায় সব microprocessor-এ ছিল। ছোট, একটি-একটি করে instruction চালানো CPU-তে এটি সস্তা আর সুবিধাজনক: ALU যা হিসাব করে, তার পাশেই কয়েকটি বিট লিখে রাখা যায়, আর একটি cmp থেকে পরপর কয়েকটি শাখা (সমান? ছোট? বড়?) সিদ্ধান্ত নিতে পারে। বহু-শব্দের যোগে carry flag-ও স্বাভাবিকভাবে কাজে লাগে।
সমস্যা দেখা দেয় দ্রুত, একসাথে অনেক instruction চালানো CPU-তে। flag একটি “লুকোনো” register: প্রায় প্রতিটি arithmetic instruction সেটি লেখে, অথচ instruction-এর লেখায় সেটি দেখা যায় না। ফলে দুটি স্বাধীন হিসাবও flag-এর মাধ্যমে একে অপরের ওপর নির্ভরশীল মনে হয়। Out-of-order CPU-কে (পর্ব ৪০) তাই flag-এরও register renaming করতে হয়, আর x86-এর কিছু instruction flag-এর শুধু কিছু বিট বদলায় (যেমন inc carry ছোঁয় না) — এতে আংশিক flag-নির্ভরতার জটিল সমস্যা তৈরি হয়েছিল, যা Intel আর AMD-কে বহু প্রজন্ম ধরে সামলাতে হয়েছে।
১৯৮০-র দশকের MIPS এই কারণেই flag রাখেনি; তুলনা করত slt দিয়ে, শাখা দিত beq/bne দিয়ে। RISC-V আরেক ধাপ এগিয়ে blt, bge, bltu, bgeu-ও যোগ করেছে, যাতে বেশিরভাগ তুলনা একটি instruction-এ হয়। প্রতিটি instruction তার সব input আর output নিজের লেখাতেই ঘোষণা করে — hardware-এর জন্য নির্ভরতা খোঁজা সহজ। ARM-ও AArch64 নকশার সময় (২০১০-এর দশকের শুরুতে) flag রেখেছে, কিন্তু সাধারণ arithmetic আর flag লেখে না; শুধু s-যুক্ত রূপ (adds, subs) আর cmp লেখে — অর্থাৎ ARM-ও লুকোনো নির্ভরতা কমিয়েছে।
দাম কোথায়? RISC-V-তে একটি তুলনা থেকে একাধিক শাখা নিতে প্রতিবার আবার তুলনা হয়; carry-নির্ভর হিসাবে বাড়তি sltu লাগে (পর্ব ২০); আর শাখা-instruction-এ দুটি register পড়তে হয় বলে শাখার জন্য offset-এর বিট কম (±৪ KiB). অন্যদিকে x86 macro-op fusion দিয়ে cmp + jcc-কে ভেতরে একটি কাজ বানিয়ে ফেলে — অর্থাৎ বড় core-এ দুই নকশা ভেতরে অনেকটা কাছাকাছি চলে আসে।
মূল উপলব্ধি: flag ছোট, ধারাবাহিক CPU-তে সস্তা; কিন্তু বড়, সমান্তরাল CPU-তে একটি লুকোনো ভাগাভাগির register. Compare-and-branch নির্ভরতাকে স্পষ্ট করে hardware সরল রাখে, কিছু ক্ষেত্রে instruction বাড়িয়ে। দুটিই যুক্তিসঙ্গত নকশা — ভিন্ন যুগের, ভিন্ন অগ্রাধিকারের।
প্র ০২Machine-এর কাছে আছে শুধু “লাফ”। তাহলে goto নিয়ে এত বিতর্ক কেন হয়েছিল, আর structured programming assembly-কে কীভাবে প্রভাবিত করে?
এই পর্বে দেখলেন, CPU-র কাছে if, while, for বলে কিছু নেই — আছে শুধু শর্তসাপেক্ষ আর নিঃশর্ত লাফ, অর্থাৎ goto. প্রথম দিকের উচ্চস্তরের ভাষাও (Fortran, প্রাথমিক BASIC) এই ধাঁচেই লেখা হতো: লাইন নম্বরে GOTO. বড় প্রোগ্রামে এতে “spaghetti code” তৈরি হতো — কোন লাইনে কোথা থেকে আসা যায়, তা বুঝতে পুরো প্রোগ্রাম পড়তে হতো।
১৯৬৬ সালে Corrado Böhm আর Giuseppe Jacopini দেখান, যেকোনো প্রোগ্রাম শুধু তিনটি গঠন দিয়ে লেখা যায়: ক্রম, নির্বাচন (if) আর পুনরাবৃত্তি (while). ১৯৬৮ সালে Edsger Dijkstra Communications of the ACM-এ একটি ছোট চিঠি লেখেন, যার শিরোনাম সম্পাদক Niklaus Wirth দিয়েছিলেন “Go To Statement Considered Harmful”. Dijkstra-র যুক্তি ছিল, প্রোগ্রামের লেখার গঠন আর চলার গঠন যত কাছাকাছি, তত সহজে মানুষ তার সঠিকতা নিয়ে যুক্তি দিতে পারে। এখান থেকে structured programming আন্দোলন, আর Pascal, C-র মতো ভাষায় block-ভিত্তিক if/while/for.
বিতর্ক সেখানেই থামেনি। ১৯৭৪ সালে Donald Knuth “Structured Programming with go to Statements” প্রবন্ধে দেখান, কিছু ক্ষেত্রে (নেস্টেড লুপ থেকে একবারে বেরোনো, error হলে পরিষ্কার করে বেরোনো) goto আসলে কোডকে বেশি পরিষ্কার আর দ্রুত করে। C-তে তাই break, continue, return আছে — এগুলো নিয়ন্ত্রিত goto. Linux kernel-এর কোডে আজও error হলে goto out; ধাঁচের পরিষ্কার-করার কৌশল খুব প্রচলিত।
Assembly-র দিক থেকে এর প্রভাব গুরুত্বপূর্ণ। Structured কোড থেকে compiler যে control-flow graph পায়, তা সুশৃঙ্খল — প্রতিটি লুপের একটি মাত্র প্রবেশপথ (একে বলে reducible graph). Loop rotation, loop-invariant code সরানো, register বণ্টন — অনেক optimization এই শৃঙ্খলার ওপর নির্ভর করে। অগোছালো goto দিয়ে লুপের মাঝখানে লাফ দিলে compiler অনেক optimization ছেড়ে দিতে বাধ্য হয়।
মূল উপলব্ধি: structured programming machine-এর জন্য নয়, মানুষ আর compiler-এর জন্য। Machine সবসময় goto-তেই চলে; শৃঙ্খলাবদ্ধ উচ্চস্তরের গঠন সেই goto-গুলোকে এমন নকশায় সাজায়, যা পড়া, প্রমাণ করা আর optimize করা সহজ।
প্র ০৩Jump table-এর jalr একটি indirect লাফ। নিরাপত্তার দিক থেকে indirect লাফ কেন সংবেদনশীল, আর hardware ও compiler কীভাবে প্রতিরক্ষা দেয়?
সরাসরি শাখার গন্তব্য instruction-এর ভেতরেই লেখা, আর কোড-মেমরি সাধারণত শুধু-পড়া — তাই চলার সময় কেউ সেটি বদলাতে পারে না। Indirect লাফের গন্তব্য আসে ডেটা থেকে: register, function pointer, return address, jump table. সেই ডেটা কোনো বাগে (যেমন buffer overflow) নষ্ট বা বদলে গেলে প্রোগ্রাম আক্রমণকারীর পছন্দের জায়গায় লাফ দিতে পারে। এজন্য এই পর্বের bounds check এত গুরুত্বপূর্ণ — সেটি না থাকলে বাইরে থেকে আসা একটি সংখ্যাই ঠিক করত CPU কোথায় যাবে।
Hardware-এর একটি প্রতিরক্ষা হলো control-flow integrity: indirect লাফ শুধু “অনুমোদিত প্রবেশবিন্দুতে” যেতে পারবে। Intel-এর CET-এ (indirect branch tracking) বৈধ গন্তব্যে একটি বিশেষ endbr64 instruction থাকতে হয়; ARM-এর BTI (ARMv8.5) একইভাবে বৈধ গন্তব্য চিহ্নিত করে, আর ARM-এর pointer authentication return address-এ একটি cryptographic স্বাক্ষর জোড়ে। RISC-V-তেও একই ধরনের control-flow integrity extension নিয়ে কাজ হয়েছে (Zicfilp, Zicfiss নামে) — এর অবস্থা আর chip-এ সমর্থন নির্দিষ্ট বিবরণ দেখে নিশ্চিত হওয়া ভালো।
২০১৮ সালে প্রকাশিত Spectre-এর দ্বিতীয় রূপ (branch target injection) আরেকটি দিক দেখায়: CPU indirect লাফের গন্তব্য অনুমান করে, আর অন্য প্রোগ্রাম সেই অনুমানের যন্ত্রকে প্রভাবিত করতে পারলে CPU সাময়িকভাবে ভুল জায়গার কোড অনুমানের ভিত্তিতে চালাতে পারে — যার চিহ্ন cache-এ থেকে যায়। প্রতিরক্ষা হিসেবে Google-এর প্রকৌশলীরা “retpoline” নামের একটি compiler-কৌশল প্রস্তাব করেন, যা indirect লাফকে এমন একটি ক্রমে বদলায় যেখানে অনুমান নিরাপদ জায়গায় আটকে থাকে; পরে CPU-তে নতুন hardware নিয়ন্ত্রণও আসে। Retpoline প্রতিটি indirect লাফকে ব্যয়বহুল করে বলে, Linux kernel-এর x86 build-এ ছোট switch-এ jump table এড়াতে gcc-কে বাড়তি parameter দেওয়া হয় বলে জানা যায় — অর্থাৎ নিরাপত্তা সরাসরি এই পর্বের “if-chain বনাম jump table” সিদ্ধান্ত বদলে দিয়েছে। বিস্তারিত পর্ব ৪১-এ।
মূল উপলব্ধি: indirect লাফ নমনীয়তার জন্য অপরিহার্য — switch, virtual function, function return — কিন্তু এটি ডেটাকে PC-র নিয়ন্ত্রণ দেয়। তাই এর চারপাশে স্তরে স্তরে প্রতিরক্ষা: সফটওয়্যারে bounds check, compiler-এ নিরাপদ রূপান্তর, আর hardware-এ বৈধ গন্তব্যের চিহ্ন।
প্র ০৪Sorted array-তে একই লুপ unsorted array-র চেয়ে অনেক দ্রুত চলতে পারে — কেন? আর শাখাহীন কোড কি সবসময় সমাধান?
২০১২ সালে Stack Overflow-এ একটি প্রশ্ন বিখ্যাত হয়: একটি লুপ array-র প্রতিটি উপাদান দেখে “128-এর বেশি হলে যোগফলে যোগ” — array আগে sort করলে লুপটি কয়েক গুণ দ্রুত চলে, অথচ কাজের পরিমাণ একই। Dynamic instruction count দুই ক্ষেত্রে প্রায় হুবহু এক, তাহলে পার্থক্য কোথায়? উত্তর CPI-তে। Sorted array-তে শাখাটি প্রথমে টানা “না”, তারপর টানা “হ্যাঁ” — branch predictor প্রায় নিখুঁত অনুমান করে। এলোমেলো array-তে শাখাটি মুদ্রা ছোঁড়ার মতো, প্রায় অর্ধেক অনুমান ভুল, আর প্রতিটি ভুলে pipeline-এর অনেকগুলো cycle নষ্ট।
সেই আলোচনার একটি সমাধান ছিল শাখাহীন কোড — এই পর্বের mask কৌশল বা compiler-এর cmov. তখন ডেটা যেমনই হোক, সময় প্রায় একই। মজার ব্যাপার, নতুন compiler-এর সংস্করণ অনেক সময় নিজেই এই লুপকে শাখাহীন বা vector (SIMD) কোডে বদলে দেয় — তখন sort করার সুবিধা উধাও হয়ে যায়। একই C কোড, compiler আর flag বদলালে সম্পূর্ণ ভিন্ন performance.
কিন্তু শাখাহীন কোড সবসময় জেতে না। এটি দুই পথের হিসাবই করে, আর ফলকে দুই পথের ইনপুটের ওপর নির্ভরশীল করে তোলে — একটি data dependency, যা out-of-order CPU-কে অপেক্ষা করায়। Linux-এর স্রষ্টা Linus Torvalds ২০০৭ সালের দিকে একটি আলোচনায় যুক্তি দিয়েছিলেন যে আধুনিক, ভালো predictor-ওয়ালা CPU-তে cmov প্রায়ই অনুমানযোগ্য শাখার চেয়ে ধীর — কারণ শাখা ঠিক অনুমান হলে CPU অপেক্ষা না করেই এগিয়ে যায়, কিন্তু cmov-কে দুটি input-এর জন্যই অপেক্ষা করতে হয়। Compiler তাই একটি অনুমাননির্ভর সিদ্ধান্ত নেয়, আর profile-guided optimization (আগে চালিয়ে শাখার আচরণ মেপে নেওয়া) থাকলে সিদ্ধান্ত ভালো হয়।
যেখানে শাখাহীন কোড বাধ্যতামূলক, তা হলো cryptography: গোপন key-এর ওপর নির্ভর করে শাখা নিলে সময় বা cache-এর আচরণ মেপে key আন্দাজ করা যেতে পারে (timing side channel). সেখানে গতি নয়, প্রতিটি ইনপুটে একই আচরণ চাই — mask আর czero-র মতো কৌশল ঠিক এই কাজের।
মূল উপলব্ধি: instruction গোনা গল্পের অর্ধেক; বাকি অর্ধেক শাখাটি কতটা অনুমানযোগ্য। অনুমানযোগ্য শাখা প্রায় বিনা খরচে চলে, এলোমেলো শাখা ব্যয়বহুল — শাখাহীন কোড দ্বিতীয় ক্ষেত্রে জেতে, প্রথম ক্ষেত্রে হারতে পারে। মেপে দেখাই একমাত্র নিশ্চিত উত্তর।
প্র ০৫লুপের নিয়ন্ত্রণ-খরচ (শর্ত, লাফ, counter) কমানোর আর কী কী উপায় আছে — loop unrolling থেকে DSP-র hardware loop পর্যন্ত?
Rotated যোগফল-লুপে প্রতি ঘুরে ৬টি instruction-এর মধ্যে আসল কাজ (lw আর add) মাত্র ২টি; বাকি ৪টি ঠিকানা, counter আর শাখা। এই “overhead” কমানোর প্রথম কৌশল এই পর্বেই দেখেছেন: pointer-লুপ (strength reduction) ৬ থেকে ৪-এ নামায়। দ্বিতীয় কৌশল loop unrolling: body-কে, ধরুন, ৪ বার পরপর লিখে প্রতি ঘুরে ৪টি উপাদান। তখন addi আর bne চলে ৪টি উপাদানে একবার — প্রতি উপাদানে প্রায় 2 + 2/4 = ২.৫টি instruction. দাম: কোড বড় হয় (instruction cache-এ বেশি জায়গা), আর n যদি ৪-এর গুণিতক না হয়, বাকি উপাদানের জন্য আলাদা ছোট লুপ লাগে।
১৯৮৩ সালে Lucasfilm-এ কাজ করার সময় Tom Duff C-তে unrolling-এর একটি বিখ্যাত কৌশল লেখেন — Duff's device — যেখানে একটি switch-এর case-গুলো একটি do-while লুপের ভেতরে ছড়ানো, যাতে switch-এর jump table সরাসরি unroll করা লুপের মাঝখানে লাফ দিয়ে “বাকি” উপাদান সামলায়। এটি দেখায়, C-র switch আসলে label-এ goto — ঠিক এই পর্বের jump table. আজকের compiler নিজেই unrolling করে, তাই এমন কৌশল এখন মূলত ইতিহাস আর ধাঁধা।
Hardware-ও সাহায্য করে। অনেক DSP-তে “zero-overhead loop” আছে: একটি instruction-এ লুপের শুরু, শেষ আর ঘোরার সংখ্যা বলে দিলে hardware নিজেই PC-কে শেষ থেকে শুরুতে ফেরায় — শাখার কোনো instruction বা খরচ নেই। x86-এর পুরোনো loop instruction (ECX কমিয়ে শূন্য না হলে লাফ) একই ধারণার, কিন্তু আধুনিক x86-এ সাধারণত ধীর হওয়ায় compiler এড়িয়ে চলে। RISC-V base ISA এমন কিছু রাখেনি; নীতি হলো সাধারণ instruction দিয়ে লুপ লেখা আর বড় core-এর ভেতরে fusion ও ভালো predictor-এর ওপর ভরসা করা। আর vector extension (পর্ব ৪২) এক instruction-এ অনেক উপাদান নিয়ে overhead-কে আরও ভাগ করে দেয়।
পরীক্ষার ভাষায়: iron law-এর IC কমে unrolling আর strength reduction-এ, CPI প্রভাবিত হয় predictor আর cache-এ, আর এই সবকিছু মিলিয়েই আসল সময়।
মূল উপলব্ধি: লুপের খরচ = কাজ + নিয়ন্ত্রণ। নিয়ন্ত্রণের অংশ কমাতে compiler কোড বদলায় (rotation, strength reduction, unrolling), আর কিছু hardware বিশেষ সুবিধা দেয় (hardware loop, fusion, vector) — প্রতিটির দাম কোডের আকার বা hardware-এর জটিলতায়।
অনুশীলন
পরীক্ষার ধাঁচে লেখা — আগে খাতায় সমাধান করুন, তারপর উত্তর মিলিয়ে নিন।
-
C থেকে RV32I, তারপর মান অনুমান: নিচের Collatz লুপটি RV32I-এ অনুবাদ করুন (x আছে a0-তে, steps রাখুন a1-এ; গুণ instruction ছাড়া, shift আর add দিয়ে)। তারপর x = 6 দিয়ে শুরু করলে শেষে a0, a1 কত, আর মোট কতগুলো instruction চলে?
int steps = 0; while (x != 1) { if (x & 1) x = 3 * x + 1; else x = x >> 1; steps++; }li a1, 0 # steps = 0 li t1, 1 loop: beq a0, t1, done # x == 1 ? exit (top-tested while) andi t0, a0, 1 # x & 1 beqz t0, even # opposite of "odd": go to else slli t0, a0, 1 # 2x add a0, t0, a0 # 3x addi a0, a0, 1 # 3x + 1 j next # skip else even: srai a0, a0, 1 # x >> 1 next: addi a1, a1, 1 # steps++ j loop done:- ক্রম: 6 → 3 → 10 → 5 → 16 → 8 → 4 → 2 → 1. শেষে a0 = 1, a1 = 8.
- বিজোড় ধাপে চলে ৯টি (
beq,andi,beqz,slli,add,addi,j,addi,j), জোড় ধাপে ৬টি (beq,andi,beqz,srai,addi,j). এখানে বিজোড় ধাপ ২টি (3, 5), জোড় ৬টি। - মোট = 2 (শুরু) + 2 × 9 + 6 × 6 + 1 (শেষ
beq) = ৫৭ — simulator-এ মিলিয়ে দেখা। x = 1 দিলে লুপে ঢোকেই না: ৩টি।
-
Branch target-এর হিসাব: (ক)
bne t0, t1, Lআছে 0x00010040-এ, offset −28. Target কত? (খ) 0x00010100-এ একটিj L, L = 0x00010000. offset কত? (গ) 0x00002000-এর একটিblt a0, a1, L-এর L = 0x00003100 হলে কী সমস্যা, আর assembler কী করবে? (ঘ) PC = 0x00001000-এjalr ra, 8(t0), t0 = 0x00003001. নতুন PC আর ra কত? (ঙ) একই (ক) MIPS-এর নিয়মে হলে (imm = −7) target কত হতো?- (ক) 0x10040 − 28 = 0x10040 − 0x1C = 0x00010024.
- (খ) 0x10000 − 0x10100 = −256 (−0x100),
jal-এর ±১ MiB-এর অনেক ভেতরে। - (গ) offset = 0x1100 = 4352 > 4094 — B-type-এর নাগালের বাইরে। Assembler বা linker শর্ত উল্টে লেখে:
bge a0, a1, skip;j L;skip:(branch relaxation). - (ঘ) 0x3001 + 8 = 0x3009, নিচের বিট ০ করে PC = 0x00003008; ra = 0x00001004.
- (ঙ) MIPS: (PC + 4) + (−7 × 4) = 0x10044 − 28 = 0x00010028 — একই imm-এ RISC-V-এর চেয়ে ৪ byte পরে, কারণ ভিত্তি PC + 4.
-
Dynamic instruction count (nested লুপ): নিচের কোডে a0 = 0 দিয়ে শুরু, a1 = n ≥ 1, a2 = m ≥ 1. (ক) কতগুলো instruction চলে — n আর m-এর সূত্রে? (খ) n = 3, m = 4-এ সংখ্যা আর শেষে a0 কত? (গ) n = m = 100-এ CPI = 1.25 আর 2 GHz clock হলে সময় কত?
li t0, 0 outer: li t1, 0 # inner counter reset inner: add a0, a0, t1 addi t1, t1, 1 blt t1, a2, inner # j < m addi t0, t0, 1 blt t0, a1, outer # i < n- (ক) শুরুতে ১; বাইরের প্রতি ঘুরে:
li(১) + ভেতরের লুপ 3m +addi,blt(২). IC = 1 + n(3m + 3) = 3nm + 3n + 1. (দুটিই do-while ধাঁচ, তাই n, m ≥ 1 ধরতে হয়।) - (খ) 3·3·4 + 3·3 + 1 = ৪৬. প্রতিটি বাইরের ঘুরে a0-তে যোগ হয় 0 + 1 + 2 + 3 = 6, তিন ঘুরে a0 = 18.
- (গ) IC = 30000 + 300 + 1 = 30301; cycle = 30301 × 1.25 = 37876.25; সময় = 37876.25 / (2 × 109) ≈ ১৮.৯৪ µs.
- (ক) শুরুতে ১; বাইরের প্রতি ঘুরে:
-
Register-এর মান অনুমান: (ক) a0 = −3, a1 = 5 দিয়ে নিচের শাখাহীন কোড চালালে প্রতিটি লাইনের পর t0, t1, a0 কত? (খ) কেউ “for (i = 0; i < n; i++) sum += i;”-কে guard ছাড়া do-while ধাঁচে লিখেছে (নিচে ডানে)। a1 = 4 আর a1 = 0-এ কী হবে?
# (ক) # (খ) a1 = n slt t0, a0, a1 li a0, 0 sub t0, x0, t0 loop: add a0, a0, a1 xor t1, a0, a1 addi a1, a1, -1 and t1, t1, t0 bnez a1, loop xor a0, a0, t1- (ক)
slt: −3 < 5, t0 = 1.sub: t0 = −1 (0xFFFFFFFF).xor: t1 = −3 ⊕ 5 = −8 (0xFFFFFFF8).and: t1 = −8.xor: a0 = −3 ⊕ −8 = 5 = max(−3, 5). - (খ) a1 = 4: 4 + 3 + 2 + 1 = 10, চলে 1 + 3 × 4 = ১৩টি instruction. (লক্ষ করুন, কোডটি আসলে n থেকে 1 যোগ করে — C-র 0…n − 1 নয়; দ্বিতীয় বাগ।)
- a1 = 0: প্রথম ঘুরেই a1 হয় −1, তারপর −2, …
bnezশূন্য না পাওয়া পর্যন্ত ঘোরে — প্রায় 232 বার, wrap করে ফিরে আসা পর্যন্ত। guard (blez a1, done) না থাকলে while/for-কে do-while বানানো বিপজ্জনক — loop rotation-এ guard এজন্যই অপরিহার্য।
- (ক)
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমRV32I-এ case 1…5-এর switch-কে jump table দিয়ে চালানোর ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
addi t0, a0, -1— সবচেয়ে ছোট case বাদ দিয়ে indexbgeu t0, t1, default— unsigned bounds check (t1 = 5)slli t0, t0, 2— index × 4add t0, a1, t0— টেবিলের ঘরের ঠিকানাlw t0, 0(t0)— case-এর কোডের ঠিকানা পড়াjr t0— সেই ঠিকানায় indirect লাফ
RV32I-এ ঠিকানা 0x00002010-এ একটি bge t1, t0, L আছে, যার offset −16. শাখা নেওয়া হলে নতুন PC কত?
- 0x00002000
- 0x00002004
- 0x00001FD0
- 0x00002020
RISC-V assembler bgt a0, a1, L (a0 > a1 হলে লাফ) pseudo-instruction-কে কোন আসল instruction-এ বদলায়?
blt a0, a1, Lbge a1, a0, Lblt a1, a0, Lbltu a1, a0, L
একটি rotated লুপে আগে ৩টি instruction (guard সহ), প্রতি ঘুরে ৬টি (নিচের শাখা সহ), আর পরে ১টি চলে। n = 50 হলে মোট কতগুলো instruction চলে?
- 303
- 304
- 354
- 300
Jump table-এর আগে index-এর সীমা যাচাইয়ে compiler সাধারণত bgeu t0, t1, default (t1 = case-এর সংখ্যা) বসায়। unsigned তুলনা কেন?
- কারণ মেমরির ঠিকানা সবসময় unsigned
- কারণ signed তুলনা RV32I-এ নেই
- কারণ unsigned তুলনা দ্রুত
- কারণ একটি তুলনাতেই ঋণাত্মক আর অতিরিক্ত বড় — দুই ধরনের index ধরা পড়ে
C-র একটি for (init; cond; inc) { … continue; … } লুপে continue assembly-তে কোথায় লাফ দেয়?
- increment অংশের (inc) শুরুতে
- শর্ত-যাচাইয়ের (cond) label-এ
- init-এর শুরুতে
- লুপের বাইরের label-এ
RV32I-এ a0 = −3, a1 = 5 হলে slt t0, a0, a1 আর তারপর sub t0, x0, t0 চালালে t0-এর মান কত?
- 1
- 0xFFFFFFFF (−1)
- 0
- −8
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- পরের PC-ই সব: সাধারণত PC + 4; শাখা নিলে PC + offset (±৪ KiB);
jal-এ PC + offset (±১ MiB) আর rd = PC + 4;jalr-এ (rs1 + imm), নিচের বিট ০. Target-এর ভিত্তি শাখাটি নিজে — MIPS-এর মতো PC + 4 নয়। - ছয়টি শাখা:
beq,bne,blt,bge(signed),bltu,bgeu(unsigned).bgt,ble,beqz,j,jr,retহলো operand অদলবদল বা x0-ওয়ালা pseudo. - if = উল্টো শর্তে টপকানো; if-else-এ then-এর শেষে একটি
j;&&/||= প্রতিটি অংশে আলাদা শাখা (short-circuit). for-এcontinueযায় increment-এ। - লুপ: compiler guard + নিচে শর্ত (loop rotation) বানায়; IC-এর সূত্র = আগে + প্রতি ঘুরে × n + বাড়তি যাচাই + পরে (যেমন 7n + 4 → 6n + 4); n = 0 আলাদা করে যাচাই।
- switch আর শাখাহীন কোড: ঘন, অনেক case-এ jump table (বিয়োগ,
bgeu, ×4,lw,jr); অল্প বা ছড়ানো case-এ তুলনা। ছোট, এলোমেলো সিদ্ধান্তেcmov/cselবাslt-mask, Zicond-এczero.
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩০ · Function call আর stack — calling convention, stack frame আর recursionপরবর্তী পর্বjal, jalr, ra আর stack frame.
- পর্ব ৩১ · C থেকে assembly — compiler, x86-64 আর ARM-এর তুলনাসামনেআসল compiler-এর আউটপুট পড়া।
- পর্ব ১৩ · ALU নকশা — যোগ, বিয়োগ, লজিক আর flagভিত্তিFlag, condition code আর compare-and-branch-এর hardware.
- কম্পিউটার আর্কিটেকচার ও ডিজিটাল লজিক · কোর্স হোম৬২টি পর্বলজিক গেট থেকে pipeline আর cache — পুরো রোডম্যাপ।
- C প্রোগ্রামিং · if আর switchপাশের ট্র্যাকএকই সিদ্ধান্ত C-র দিক থেকে।
- C প্রোগ্রামিং · C আর assemblyপাশের ট্র্যাকC কোড থেকে compiler কী বানায়।