এই পর্বে যা শিখবেন
- ৫ ধাপের pipeline-এ শাখা কেন সমস্যা, penalty কত cycle, আর stall, ID-তে শাখা, not-taken অনুমান ও delay slot — চারটি পুরোনো সমাধানের দাম
- ভুল পথের instruction কীভাবে flush হয় (IF/ID register শূন্য করে), cycle ধরে ধরে
- CPI-তে শাখার খরচ হিসাব — পরীক্ষার ধাঁচের পূর্ণ অঙ্ক আর একটি calculator
- Static (always taken, BTFN) আর dynamic (১-বিট, ২-বিট) predictor — লুপে ভুলের সংখ্যা হাতে গোনা, state diagram আর simulator দিয়ে মেলানো
- BHT, BTB, RAS, correlating predictor, gshare, tournament, TAGE আর perceptron — আর sorted array কেন দ্রুত চলে, Wandbox-এ মেপে
১ · Control hazard — শাখার ফল জানার আগেই পরের instruction আনতে হয়
পর্ব ৩৫-এ (Pipelining) দেখেছেন, RISC-V-এর ৫ ধাপের pipeline-এ একটি instruction পাঁচটি ধাপ পার হয়: IF (instruction আনা), ID (decode আর register পড়া), EX (ALU-র কাজ), MEM (মেমরি) আর WB (register-এ লেখা)। প্রতি cycle-এ একটি নতুন instruction ঢোকে, তাই আদর্শ অবস্থায় CPI = 1. পর্ব ৩৬-এ (Pipeline hazard) data hazard আর forwarding-এর কথা — মান তৈরি হওয়ার আগেই দরকার পড়লে কী হয়। এই পর্বের সমস্যাটি অন্য রকম: পরের instruction কোনটি, সেটাই জানা নেই।
একটি সাধারণ instruction-এর পরে PC + 4. কিন্তু conditional branch-এর পরে PC + 4 নাকি branch target, তা ঠিক হয় দুটি register তুলনা করার পরে। সরল নকশায় তুলনা হয় EX ধাপের ALU-তে। অথচ শাখাটি যখন ID-তে, তখনই IF-কে পরের instruction আনতে হচ্ছে; শাখা EX-এ পৌঁছালে IF আরেকটি এনে ফেলেছে। এই সমস্যার নাম control hazard (বা branch hazard).
রেলস্টেশনের জংশনে একটি মালগাড়ি আসছে, কিন্তু কোন লাইনে যাবে সেই নির্দেশ আসবে কয়েক মিনিট পরে। পেছনে আরও ট্রেন লাইনে দাঁড়িয়ে। স্টেশনমাস্টারের তিনটি উপায়: (১) নির্দেশ না আসা পর্যন্ত সব থামিয়ে রাখা — নিরাপদ, কিন্তু সময় নষ্ট; (২) নির্দেশটা আরও তাড়াতাড়ি আনার ব্যবস্থা করা — সিগন্যাল কেবিন কাছে আনা; (৩) অভিজ্ঞতা থেকে আন্দাজ করে পয়েন্ট বদলে দেওয়া, আর ভুল হলে ট্রেন পিছিয়ে আনা। CPU ঠিক এই তিনটি কৌশলই নেয় — stall, early resolution আর prediction. তৃতীয়টিই আধুনিক CPU-র প্রধান অস্ত্র, কারণ অভিজ্ঞ স্টেশনমাস্টারের আন্দাজ প্রায় সবসময় মিলে যায়।
Penalty কত cycle?
শাখা নেওয়া (taken) হলে, শাখার পরে আর target-এর আগে যতগুলো instruction ভুল পথে আনা হয়ে গেছে, ততগুলো cycle নষ্ট — একে বলে branch penalty. শাখা IF-এ cycle ১-এ ঢুকলে:
| শাখার ফল আর target জানা যায় | ভুল পথে আনা instruction | Penalty (taken হলে) | কোথায় দেখা যায় |
|---|---|---|---|
| MEM ধাপের শেষে (PC বদলায় MEM-এ) | cycle ২, ৩, ৪-এর তিনটি | ৩ cycle | পাঠ্যবইয়ের প্রথম, সরল datapath |
| EX ধাপের শেষে | cycle ২, ৩-এর দুটি | ২ cycle | ALU-তে তুলনা, target-এর adder EX-এ |
| ID ধাপের শেষে (early branch resolution) | cycle ২-এর একটি | ১ cycle | Patterson–Hennessy-র উন্নত ৫ ধাপের নকশা |
| গভীর pipeline-এ (অনেক ধাপ পরে) | কয়েক ডজন পর্যন্ত | প্রায় ১০–২০ cycle (নকশাভেদে) | আধুনিক বড় x86 আর ARM core |
Branch penalty = শাখা যে ধাপে নিষ্পত্তি হয় তার আগের ধাপের সংখ্যা। IF-এর পরে যত ধাপ পরে ফল জানা যায়, তত instruction ভুল পথে এসে পড়ে। তাই পুরো বিষয়টির দুটি অস্ত্র: ফল আগে জানা (penalty ছোট করা) আর অনুমান ঠিক রাখা (penalty কম বার দেওয়া)।
২ · চারটি পুরোনো সমাধান — stall, ID-তে শাখা, not-taken অনুমান আর delay slot
(ক) ফল জানা পর্যন্ত থামা
সবচেয়ে নিরাপদ উপায়: decode-এ শাখা চিনলেই IF থামিয়ে দেওয়া (stall), ফল জানা গেলে সঠিক ঠিকানা থেকে আনা। থামার সময় pipeline-এ যা ঢোকে তা bubble — কোনো কাজ করে না। দাম: প্রতিটি শাখায় পুরো penalty, শাখা নেওয়া হোক বা না হোক। ২০% instruction শাখা আর penalty ২ হলে CPI = 1 + 0.2 × 2 = 1.4 — আদর্শের চেয়ে ৪০% ধীর।
(খ) শাখার সিদ্ধান্ত ID-তে আনা
RISC-V-এর শাখার তুলনাগুলো সরল: সমান কি না, বা signed/unsigned ছোট কি না। পুরো ALU লাগে না। তাই ID ধাপে, register file পড়ার ঠিক পরে, একটি ছোট comparator আর target-এর জন্য একটি আলাদা adder (PC + imm) বসানো যায়। একে বলে early branch resolution — penalty ২ থেকে ১-এ নামে। দুটি দাম আছে:
- নতুন data hazard: তুলনার register-গুলো এখন ID-তেই দরকার, EX-এ নয়। ঠিক আগের ALU instruction যদি সেই register লেখে (যেমন
add x1, …তারপরbeq x1, x3, L), ফল তখনো EX-এ — তাই ১ cycle stall, তারপর forwarding ID-তে। আগের instruction load হলে (lw x1তারপরbeq x1) ২ cycle stall. Forwarding-এর নতুন পথও লাগে ID-তে। - ID ধাপ লম্বা হয়: register পড়া, তারপর তুলনা — একই cycle-এ। ID যদি critical path হয়ে যায়, পুরো clock ধীর হয় (পর্ব ১৮-এর critical path).
(গ) “শাখা নেওয়া হবে না” ধরে এগোনো, আর ভুল হলে flush
থামার বদলে সবচেয়ে সস্তা অনুমান: predict not-taken — শাখা নেওয়া হবে না ধরে PC + 4 থেকে আনতেই থাকা। শাখা সত্যিই না নেওয়া হলে কোনো ক্ষতি নেই। নেওয়া হলে অনুমানটি ভুল (branch misprediction), আর ভুল পথে আনা instruction-গুলোকে মুছে দিতে হয় — একে বলে flush. কীভাবে মোছা হয়? ভুল instruction-টি যে pipeline register-এ বসে আছে (ID-তে সিদ্ধান্ত হলে IF/ID), তার instruction-এর ঘরটি শূন্য করে দেওয়া হয় (পাঠ্যবইয়ের সরল নকশায়; RISC-V-এ সব-শূন্য word আসলে একটি অবৈধ বা illegal instruction, তাই অনেক নকশা বরং একটি valid বিট ০ করে), বা ঘরটিকে nop বানানো হয় — RISC-V-এ addi x0, x0, 0. সঙ্গে নিয়ন্ত্রণ সংকেতগুলো (RegWrite, MemWrite) ০, তাই সেটি পরের ধাপগুলোয় গিয়েও কোনো register বা মেমরিতে কিছু লেখে না। ভুল instruction কখনো কিছু লিখতে পারার আগেই মোছা হয় — এটাই অনুমানকে নিরাপদ করে।
নিচের stepper-এ ৫ ধাপের pipeline, ID-তে শাখার সিদ্ধান্ত, not-taken অনুমান। beq x1, x3, L-এ x1 = x3, তাই শাখা নেওয়া হয় — অনুমান ভুল। প্রতিটি ধাপ একটি clock cycle. “পরে” চাপুন:
1# predict not-taken; x1 = 7, x3 = 7 (branch will be taken)20x40 sub x10, x4, x830x44 beq x1, x3, L # L = 0x6040x48 and x12, x2, x5 # wrong path50x4C or x13, x2, x6 # wrong path60x60 L: lw x4, 8(x7)70x64 add x14, x2, x6
| Cycle | |
| IF | |
| ID | |
| EX | |
| MEM | |
| WB |
- Cycle ১.
subIF-এ। এখনো কোনো শাখা নেই; PC = 0x40 থেকে স্বাভাবিকভাবে আনা হলো। - Cycle ২ — অনুমান।
beqIF-এ আনা হলো। ফল এখনো অজানা, তাই not-taken অনুমানে পরের PC = 0x44 + 4 = 0x48. - Cycle ৩ — সিদ্ধান্ত ID-তে।
beqID-তে: register পড়া হলো, comparator দেখল x1 = x3 = 7 — শাখা নেওয়া হবে। একই cycle-এ IF ভুল পথেরand(0x48) এনে ফেলেছে। Cycle-এর শেষে দুটি কাজ: IF/ID register-এর instruction-এর ঘর শূন্য (flush), আর PC = target = 0x44 + 28 = 0x60. - Cycle ৪ — bubble. IF সঠিক ঠিকানা থেকে
lwআনছে। ID-তেand-এর জায়গায় এখন nop — সে কোনো register লিখবে না।beqEX-এ গেছে, কিন্তু তার কাজ আগেই শেষ। - Cycle ৫.
lwID-তে, nop EX-এ। শাখা না থাকলেlwযে cycle-এ ID-তে আসত, তার ঠিক ১ cycle পরে এল। - Cycle ৬. Bubble-টি MEM-এ এগোচ্ছে — একটি খালি জায়গা, যা pipeline দিয়ে বয়ে চলে।
subআগের cycle-এ WB শেষ করেছে,beqএখন WB-তে (লেখার কিছু নেই)। - Cycle ৭ — হিসাব। nop WB-তে পৌঁছে কিছু না লিখে বেরিয়ে যাচ্ছে। মোট ক্ষতি ঠিক একটি cycle — ID-তে সিদ্ধান্তের penalty. সিদ্ধান্ত EX-এ হলে
andআরorদুটিই flush হতো (ID/EX আর IF/ID শূন্য), ক্ষতি ২ cycle.
(ঘ) Branch delay slot — শাখার পরের instruction-টি সবসময় চলে
১৯৮০-র দশকের MIPS (আর SPARC, PA-RISC-এর মতো আরও কিছু RISC) অন্য পথ নিয়েছিল: penalty-র cycle-টিকে নষ্ট না করে ISA-র নিয়মই বদলে দেওয়া — শাখার ঠিক পরের instruction-টি শাখা নেওয়া হোক বা না হোক, সবসময় চলবে। এই জায়গাটির নাম branch delay slot. Compiler শাখার আগের কোনো স্বাধীন instruction সেখানে সরিয়ে বসায়; কিছু না পেলে nop.
# উদাহরণ — MIPS ধাঁচের delay slot (RISC-V-এ এমন কিছু নেই)
# আগে: # compiler সাজানোর পরে:
add $s1, $s2, $s3 beq $t0, $t1, L
beq $t0, $t1, L add $s1, $s2, $s3 # delay slot: always runs
nop # শাখার শর্ত add-এর ওপর নির্ভর করে না, তাই সরানো নিরাপদ
৫ ধাপের, ১ cycle penalty-র pipeline-এ এটি নিখুঁত খাপ খায়। কিন্তু নিয়মটি ISA-তে চিরকালের জন্য বাঁধা পড়ে যায়, অথচ microarchitecture (ISA-র নিচের আসল hardware নকশা) বদলায়। গভীর pipeline-এ penalty ১০ cycle-এরও বেশি — একটি slot সেখানে প্রায় কিছুই বাঁচায় না, তবু পুরোনো সব প্রোগ্রামের জন্য রাখতেই হয়। Superscalar CPU-তে (প্রতি cycle-এ কয়েকটি instruction, পর্ব ৩৯), exception-এ (slot-এর instruction-এ exception হলে কোথায় ফেরা হবে?), আর পরে যোগ হওয়া ছোট (compressed) instruction-এ এটি জটিলতা বাড়ায়। তাই RISC-V-এর নকশাকারেরা ইচ্ছা করে delay slot রাখেননি; MIPS-এর নতুন সংস্করণেও (MIPS32/64 Release 6-এ) delay slot ছাড়া “compact” শাখা যোগ করা হয়েছিল। শিক্ষা: একটি নির্দিষ্ট pipeline-এর খুঁটিনাটি ISA-তে ঢোকানো বিপজ্জনক।
৩ · শাখার দাম CPI-তে — সূত্র আর হাতে-কলমে অঙ্ক
পর্ব ০৩-এর (পারফরম্যান্স মাপা) iron law: CPU time = IC × CPI × cycle time. শাখা IC বদলায় না; বদলায় CPI. প্রতিটি ভুল (বা অপেক্ষা) একটি নির্দিষ্ট সংখ্যক bubble যোগ করে, তাই গড়ে প্রতি instruction-এ বাড়তি cycle = (শাখার অনুপাত) × (প্রতি শাখায় গড় বাড়তি cycle).
যুক্তি: N টি instruction-এ শাখা f·N টি। তাদের মধ্যে ভুল অনুমান m·f·N টি, আর প্রতিটি ভুলে P টি bubble. আদর্শ pipeline-এ (data hazard বাদ) প্রতি instruction-এ ১ cycle, তাই মোট cycle ≈ N + m·f·N·P, আর CPI = মোট cycle / N = 1 + f·m·P. Pipeline ভরতে প্রথম কয়েকটি cycle লাগে, কিন্তু N বড় হলে তা নগণ্য। দুটি বিশেষ ক্ষেত্র: stall-এ প্রতিটি শাখাই penalty দেয় (m = 1); predict not-taken-এ শুধু নেওয়া শাখাগুলো দেয় (m = taken-এর হার)। Data hazard-এর stall থাকলে (পর্ব ৩৬) সেগুলোও যোগ হয়: CPI = 1 + data-stall + branch-stall.
হাতে-কলমে তিনটি অঙ্ক
(ক) ৫ ধাপ, শাখা EX-এ (penalty 2), শাখা 20%, তার 60% taken
stall : CPI = 1 + 0.20 × 1.0 × 2 = 1.40
predict-NT : CPI = 1 + 0.20 × 0.6 × 2 = 1.24
2-bit, 90% ঠিক : CPI = 1 + 0.20 × 0.1 × 2 = 1.04
(খ) একই, কিন্তু শাখা ID-তে (penalty 1); 30% শাখার আগে ঠিক আগের ALU instruction
তুলনার register লেখে (1 cycle stall):
predict-NT : CPI = 1 + 0.20 × (0.6 × 1 + 0.3 × 1) = 1.18
-> hazard না থাকলে 1.12 হতো; নতুন data hazard লাভের অর্ধেক খেয়ে ফেলে (1.24 -> 1.18)
(গ) গভীর pipeline, penalty 20, শাখা 20%
95% ঠিক: CPI = 1 + 0.20 × 0.05 × 20 = 1.20
97% ঠিক: CPI = 1 + 0.20 × 0.03 × 20 = 1.12 -> speedup 1.20 / 1.12 ≈ 1.071 (প্রায় 7%)
(গ) গুরুত্বপূর্ণ শিক্ষা দেয়: নির্ভুলতা ৯৫% থেকে ৯৭% — শুনতে সামান্য, কিন্তু প্রতি ১০০টি শাখায় ভুল ৫টি থেকে ৩টি — ৪০% কম। গভীর pipeline-এ predictor-এর প্রতিটি শতাংশের দাম অনেক। নিচের calculator চারটি pipeline আর পাঁচটি কৌশলের পুরো টেবিল বানায় (শাখা ২০% আর taken ৬০% — দুটিই উদাহরণের জন্য ধরে নেওয়া মান), আর iron law দিয়ে ছোট বনাম গভীর pipeline-এর তুলনা করে:
// CPI with branch penalties: CPI = 1 + (branch fraction) x (misprediction rate) x (penalty in cycles)
const fBranch = 0.20, takenRate = 0.60; // assumed workload: 20% branches, 60% of them taken
const pipes = [["5-stage, branch in ID", 1], ["5-stage, branch in EX", 2], ["deep, penalty 15", 15], ["deep, penalty 20", 20]];
const schemes = [["stall", 1], ["predict-N", takenRate], ["90%", 0.10], ["95%", 0.05], ["99%", 0.01]]; // miss rate
const cpi = (pen, miss) => 1 + fBranch * miss * pen;
console.log("pipeline".padEnd(23) + schemes.map(([s]) => s.padStart(10)).join(""));
for (const [name, pen] of pipes)
console.log(name.padEnd(23) + schemes.map(([, m]) => cpi(pen, m).toFixed(3).padStart(10)).join(""));
// Iron law: a deeper pipeline pays a bigger penalty but may run a faster clock.
const t5 = cpi(1, 0.10) / 1.0, tDeep = cpi(15, 0.05) / 3.0; // ns per instruction at 1 GHz and 3 GHz
console.log(`5-stage @1 GHz, 90%: ${t5.toFixed(3)} ns/instr deep @3 GHz, 95%: ${tDeep.toFixed(3)} ns/instr`);
// How much does going from 95% to 97% accuracy help the 20-cycle pipeline?
const a = cpi(20, 0.05), b = cpi(20, 0.03);
console.log(`penalty 20: CPI ${a.toFixed(2)} -> ${b.toFixed(2)}, speedup ${(a / b).toFixed(3)}`);আউটপুট (নির্ধারিত):
pipeline stall predict-N 90% 95% 99%
5-stage, branch in ID 1.200 1.120 1.020 1.010 1.002
5-stage, branch in EX 1.400 1.240 1.040 1.020 1.004
deep, penalty 15 4.000 2.800 1.300 1.150 1.030
deep, penalty 20 5.000 3.400 1.400 1.200 1.040
5-stage @1 GHz, 90%: 1.020 ns/instr deep @3 GHz, 95%: 0.383 ns/instr
penalty 20: CPI 1.20 -> 1.12, speedup 1.071
দ্বিতীয় সারি অঙ্ক (ক)-র সাথে মেলে: EX-এ শাখা হলে stall 1.400, not-taken 1.240, ৯০%-এ 1.040; প্রথম সারির not-taken 1.120 হলো অঙ্ক (খ), data hazard-এর stall বাদে। গভীর pipeline-এ (penalty 15 বা 20) stall-এর CPI 4.000 বা 5.000 — অনুমান ছাড়া গভীর pipeline অর্থহীন। ৯৯% নির্ভুলতাতেও 20-cycle pipeline-এর CPI 1.040, অর্থাৎ তখনো প্রায় ৪% সময় শাখার ভুলে। তবু iron law-এর লাইনটি দেখায় কেন গভীর pipeline বানানো হয়: ৫ ধাপ, 1 GHz আর ৯০%-এ প্রতি instruction 1.020 ns; গভীর pipeline, 3 GHz আর ৯৫%-এ CPI 1.150 হলেও সময় মাত্র 0.383 ns — clock-এর লাভ penalty-র ক্ষতির চেয়ে বেশি, যদি predictor ভালো হয়। শেষ লাইন অঙ্ক (গ): 1.20 থেকে 1.12, speedup 1.071.
৪ · Static prediction — always taken, BTFN আর compiler-এর ইঙ্গিত
Branch prediction-এর প্রথম ধাপ: চলার আগেই ঠিক করা একটি নিয়ম, যা প্রোগ্রাম চলার সময় বদলায় না — static branch prediction. তিনটি প্রচলিত নিয়ম:
সবসময় not-taken
Hardware-এ সবচেয়ে সস্তা — PC + 4 তো এমনিই হিসাব হয়। লুপের শাখা প্রায় সবসময় taken, তাই লুপে খারাপ।
অংশ ২-এর (গ)সবসময় taken
সাধারণ প্রোগ্রামে শাখার বেশিরভাগ নেওয়া হয় (লুপের কারণে), তাই প্রায়ই ভালো। কিন্তু target লাগে IF-এই — তার জন্য BTB (অংশ ৬)।
target আগে জানা চাইBTFN
Backward taken, forward not taken: offset ঋণাত্মক (পেছনে লাফ) হলে taken, ধনাত্মক হলে not-taken. Offset-এর sign বিট দেখলেই চলে।
লুপ-বান্ধবকেন BTFN কাজ করে: পর্ব ২৯-এ (লুপ আর loop rotation) দেখেছেন, compiler লুপের শর্ত নিচে বসায়, আর সেখান থেকে শুরুতে ফেরার শাখা পেছনমুখী। n বার ঘোরা লুপে সেই শাখা n − 1 বার taken, একবার not-taken — n = 100 হলে ৯৯% ঠিক। আর সামনের দিকের শাখা সাধারণত if-এর “then-অংশ টপকানো” বা error-এর পথ — প্রায়ই কম ঘটে। একটি পরীক্ষার ধাঁচের হিসাব: একটি লুপ-শাখা (পেছনমুখী) ১০০০ বার চলে, ৯০% taken; সামনের শাখা A ৫০০ বার, ৩০% taken; সামনের শাখা B ৫০০ বার, ৮০% taken. BTFN-এর ঠিক = 900 + 350 + 100 = 1350, অর্থাৎ ১৩৫০/২০০০ = ৬৭.৫%. সবসময় taken-এর ঠিক = 900 + 150 + 400 = 1450, অর্থাৎ ৭২.৫% — এখানে B-এর কারণে BTFN হেরে গেল। সমাধান compiler-এর হাতে: B-এর শর্ত উল্টে কোড এমনভাবে সাজানো যাতে বেশি ঘটা পথটি fall-through হয় (শাখা না নিয়ে সোজা পরের instruction-এ চলে যাওয়া; তখন B ২০% taken) — BTFN তখন (900 + 350 + 400)/2000 = ৮২.৫%.
Compiler-এর ইঙ্গিত
কোন পথ বেশি ঘটে, প্রোগ্রামার বা compiler জানতে পারে দুইভাবে। এক, branch hint: GCC/Clang-এর __builtin_expect(x, 0) (Linux kernel-এ likely()/unlikely() macro), বা C++20-এর [[likely]]/[[unlikely]]. দুই, profile-guided optimization (PGO): আগে একবার চালিয়ে আসল আচরণ মেপে নেওয়া (gcc -fprofile-generate, তারপর -fprofile-use). আজকের CPU-তে এই তথ্যের প্রধান ব্যবহার কোড সাজানো — সম্ভাব্য পথ fall-through-এ, কম-ঘটা পথ দূরে — যাতে static নিয়ম আর instruction cache দুটিই খুশি থাকে। কিছু ISA-তে শাখার encoding-এ hint বিট ছিল (যেমন PowerPC-তে); x86-এর Pentium 4-এ শাখার আগে hint prefix ছিল, যা পরের বেশিরভাগ core উপেক্ষা করে বলে জানা যায়। RISC-V-এর শাখায় কোনো hint বিট নেই; spec শুধু পরামর্শ দেয়, software যেন ধরে নেয় পেছনমুখী শাখা taken আর সামনের শাখা not-taken অনুমান হবে (অন্তত প্রথমবার) — বাকিটা hardware-এর দায়িত্ব।
Static কোথায় এখনো কাজে লাগে। Dynamic predictor কোনো শাখা প্রথমবার দেখলে তার কোনো ইতিহাস নেই; অনেক core তখন BTFN-ধাঁচের নিয়মে পড়ে বলে বর্ণনা পাওয়া যায়। আর খুব ছোট microcontroller-এ (pipeline ২–৩ ধাপের, penalty ১–২ cycle) পুরো dynamic predictor-এর জায়গা আর শক্তির দাম লাভের চেয়ে বেশি হতে পারে — সেখানে not-taken বা BTFN-ই যথেষ্ট।
৫ · Dynamic prediction — ১-বিট আর ২-বিট saturating counter
Static নিয়ম প্রতিটি শাখার আসল আচরণ দেখে না। Dynamic branch prediction-এ hardware চলার সময় প্রতিটি শাখার অতীত মনে রাখে। সবচেয়ে সরল রূপ 1-bit predictor: প্রতিটি শাখার জন্য একটি বিট — “শেষবার নেওয়া হয়েছিল কি না”। অনুমান: শেষবার যা হয়েছিল, এবারও তাই।
১-বিটের দুর্বলতা — লুপে দুবার ভুল
১০ বার ঘোরা একটি লুপ ধরুন, শর্ত নিচে: শাখার ফল T T T T T T T T T N (৯ বার taken, শেষে বেরোনোর সময় not-taken). লুপটি বারবার চলে (যেমন একটি বাইরের লুপের ভেতরে)। আগের প্রবেশ শেষ হয়েছে N দিয়ে, তাই বিটটি এখন N:
- প্রথম ঘুর: অনুমান N, আসল T — ভুল ১. বিট হলো T.
- ২য় থেকে ৯ম ঘুর: অনুমান T, আসল T — ঠিক।
- ১০ম ঘুর (বেরোনো): অনুমান T, আসল N — ভুল ২. বিট হলো N — আর পরের প্রবেশের প্রথম ঘুরে আবার ভুল।
প্রতি প্রবেশে ১০টিতে ২টি ভুল: নির্ভুলতা ৮০% — অথচ শাখাটি ৯০% সময় taken; “সবসময় taken”-ও ৯০% পেত! একটিমাত্র ব্যতিক্রম (বেরোনো) বিটটি উল্টে দেয়, আর তার দাম দিতে হয় দুবার।
২-বিট saturating counter — একটি ব্যতিক্রমে মত বদলায় না
সমাধান: প্রতিটি শাখার জন্য ২ বিটের একটি counter, মান ০ থেকে ৩. Taken হলে ১ বাড়ে, not-taken হলে ১ কমে, কিন্তু ৩-এর ওপরে বা ০-র নিচে যায় না — তাই নাম 2-bit saturating counter (পর্ব ১৬-এর counter-এর একটি সীমাবদ্ধ রূপ)। মান ২ বা ৩ (উঁচু বিট ১) হলে অনুমান taken, ০ বা ১ হলে not-taken. চারটি state-এর নাম:
কেন লুপে শুধু একবার ভুল: লুপ চলার সময় counter থাকে 11 (strongly taken). বেরোনোর N-এ একবার ভুল, counter নামে 10 (weakly taken) — কিন্তু উঁচু বিট এখনো ১, অনুমান এখনো T. পরের প্রবেশের প্রথম T-তে অনুমান ঠিক, counter আবার 11. অর্থাৎ একটি ব্যতিক্রম অনুমান উল্টাতে পারে না — উল্টাতে পরপর দুটি লাগে। ১০ বারের লুপে প্রতি প্রবেশে ১টি ভুল: ৯০%. সাধারণভাবে n বারের লুপে ১-বিট (n − 2)/n আর ২-বিট (n − 1)/n — এটি এক ধরনের hysteresis, যেমন ঘরের থার্মোস্ট্যাটে তাপমাত্রা একটু ওঠানামায় বারবার চালু-বন্ধ হয় না। নিচে একই শাখায় দুটি predictor পাশাপাশি — একটি ৪ বারের লুপের বেরোনো আর আবার ঢোকা:
1# inner loop: 4 iterations per entry, branch at the bottom2loop: ...body...3 addi t1, t1, 14 blt t1, t2, loop # T T T N5 ...other code, then the loop is entered again
| ঘুর (j) | |
| আসল ফল | |
| ২-বিট: অনুমান | |
| ২-বিট counter | |
| ১-বিট: অনুমান | |
| ১-বিট বিট | |
| ভুল (২-বিট / ১-বিট) |
- প্রস্তুতি। লুপটি আগে বহুবার চলেছে; এখন এক প্রবেশের মাঝখানে, j = 1 শেষ। ২-বিট counter 11 (strongly taken), ১-বিট T. এখান থেকে ভুল গোনা শুরু।
- j = 2, T. দুজনেই T অনুমান — ঠিক। counter 11-এ আটকে থাকে (saturate), ৪-এ যায় না।
- j = 3, T. আবার দুজনেই ঠিক। লুপের মাঝখানে দুই predictor সমান ভালো।
- j = 4 — বেরোনো। আসল ফল N, দুজনেই T বলেছিল — দুজনেরই ১টি ভুল। পার্থক্য পরের অবস্থায়: ২-বিট নামল 10 (weakly taken, এখনো T বলবে), ১-বিট উল্টে গেল N-এ।
- লুপের বাইরে। অন্য কোড চলছে; এই শাখার ঘর কেউ ছোঁয় না (ধরে নিচ্ছি অন্য শাখা একই ঘরে পড়ে না — অংশ ৬-এর aliasing দেখুন)। দুই predictor-এর state অপরিবর্তিত।
- আবার ঢোকা, j = 1, T. ২-বিট: 10-এর উঁচু বিট ১, অনুমান T — ঠিক, counter আবার 11. ১-বিট: শেষবার N দেখেছিল, অনুমান N — ভুল, দ্বিতীয়টি।
- ফল। একটি বেরোনো আর একটি ঢোকায় ২-বিটের ১টি ভুল, ১-বিটের ২টি। প্রতিটি প্রবেশে এটাই ঘটে: ৪ বারের লুপে ২-বিট ৭৫%, ১-বিট ৫০%; ১০ বারের লুপে ৯০% বনাম ৮০%.
Nested লুপে ভেতরের শাখার আচরণ ঠিক এমন। নিচের tracer একটি for i (3 বার) { for j (4 বার) }-এর ভেতরের শাখার প্রতিটি চলা দেখায় — counter শুরু 01 (weakly not-taken) থেকে, ১-বিট N থেকে — আর শেষে সাধারণ সূত্রটি দেয়:
// Inner-loop branch of for (i = 0; i < 3; i++) for (j = 1; j <= 4; j++) {...} (bottom-tested: blt j, 4 -> loop)
// Outcome per inner run: T, T, T, N. 2-bit counter starts at 01; 1-bit bit starts at N.
const NAME = ["00 strong-N", "01 weak-N", "10 weak-T", "11 strong-T"];
let s = 1, one = false, miss2 = 0, miss1 = 0;
console.log(" i j 2-bit before pred actual ok? after | 1-bit pred ok?");
for (let i = 0; i < 3; i++)
for (let j = 1; j <= 4; j++) {
const taken = j < 4, p2 = s >= 2, p1 = one, before = s;
if (p2 !== taken) miss2++;
if (p1 !== taken) miss1++;
s = taken ? Math.min(3, s + 1) : Math.max(0, s - 1); // saturating update
one = taken; // 1-bit: remember last outcome
const TN = b => (b ? "T" : "N");
console.log(` ${i} ${j} ${NAME[before].padEnd(11)} ${TN(p2)} ${TN(taken)} ${p2 === taken ? "yes" : "NO "} ${NAME[s].padEnd(11)} | ${TN(p1)} ${p1 === taken ? "yes" : "NO"}`);
}
console.log(`mispredictions in 12 executions: 2-bit = ${miss2}, 1-bit = ${miss1}`);
// General rule (same start states): an inner loop of n iterations entered m times
for (const [n, m] of [[4, 3], [10, 100]])
console.log(`n=${n}, m=${m}: 2-bit ~ ${m + 1} misses (${(100 * (1 - (m + 1) / (n * m))).toFixed(1)}%), 1-bit ~ ${2 * m} (${(100 * (1 - 2 * m / (n * m))).toFixed(1)}%)`);আউটপুট (নির্ধারিত):
i j 2-bit before pred actual ok? after | 1-bit pred ok?
0 1 01 weak-N N T NO 10 weak-T | N NO
0 2 10 weak-T T T yes 11 strong-T | T yes
0 3 11 strong-T T T yes 11 strong-T | T yes
0 4 11 strong-T T N NO 10 weak-T | T NO
1 1 10 weak-T T T yes 11 strong-T | N NO
1 2 11 strong-T T T yes 11 strong-T | T yes
1 3 11 strong-T T T yes 11 strong-T | T yes
1 4 11 strong-T T N NO 10 weak-T | T NO
2 1 10 weak-T T T yes 11 strong-T | N NO
2 2 11 strong-T T T yes 11 strong-T | T yes
2 3 11 strong-T T T yes 11 strong-T | T yes
2 4 11 strong-T T N NO 10 weak-T | T NO
mispredictions in 12 executions: 2-bit = 4, 1-bit = 6
n=4, m=3: 2-bit ~ 4 misses (66.7%), 1-bit ~ 6 (50.0%)
n=10, m=100: 2-bit ~ 101 misses (89.9%), 1-bit ~ 200 (80.0%)
প্রথম লাইনে ঠান্ডা শুরুর দাম: counter 01, অনুমান N, আসল T — ভুল, counter 10. তারপর প্রতি i-তে একটিই ভুল, j = 4-এ (বেরোনো), আর counter 11 → 10 নামে কিন্তু উঁচু বিট ১ থাকে। তাই i = 1 আর i = 2-এর j = 1 দুটিতেই ২-বিট ঠিক (10 → 11), যেখানে ১-বিট প্রতিবার ভুল। মোট ১২টির মধ্যে ২-বিট ৪টি, ১-বিট ৬টি। সাধারণ সূত্র (এই শুরুর state ধরে): n বারের ভেতরের লুপে m বার ঢুকলে ২-বিট ≈ m + 1 টি ভুল, ১-বিট 2m টি — n = 10, m = 100-এ ৮৯.৯% বনাম ৮০.০%.
২-বিটও নিখুঁত নয়। T N T N … এমন পর্যায়ক্রমিক শাখায় (যেমন if (i % 2 == 0)) counter 01 থেকে শুরু হলে 01 → 10 → 01 → 10 দোল খায়, আর প্রতিটি অনুমান ভুল — ০%! 00 থেকে শুরু হলে ৫০%. কোনো নিজস্ব-ইতিহাসহীন counter এই ধরন ধরতে পারে না; লাগে history (অংশ ৭)। তিন বা তার বেশি বিটের counter-এ সাধারণত তেমন লাভ হয় না বলে গবেষণায় দেখা গেছে, তাই ২-বিটই প্রচলিত ভিত্তি।
৬ · BHT, BTB আর RAS — কোন ঘরে মনে রাখা, কোথায় লাফ, কোথা থেকে ফেরা
Branch history table আর aliasing
প্রতিটি শাখার জন্য আলাদা counter রাখতে hardware শাখার PC-কে চাবি হিসেবে ব্যবহার করে। সস্তা উপায়: PC-র কয়েকটি নিচের বিট দিয়ে একটি ছোট টেবিলের ঘর বাছাই — branch history table (BHT; কোনো বইয়ে pattern history table). RV32I-এ instruction ৪ byte, তাই PC-র নিচের ২ বিট সবসময় 00 — সেগুলো বাদ দিয়ে PC[k+1:2] নেওয়া হয়। ১০২৪ ঘরের টেবিলে k = 10. টেবিলে কোনো tag (পুরো PC) রাখা হয় না, তাই দুটি শাখার PC-র ওই বিটগুলো মিলে গেলে তারা একই counter ভাগ করে — aliasing. দুই শাখা একই দিকে গেলে ক্ষতি নেই (এমনকি লাভ), বিপরীত দিকে গেলে একে অপরের অনুমান নষ্ট করে। এটি জেনেশুনে নেওয়া আপস: tag রাখলে টেবিল কয়েক গুণ বড় হয়, অথচ ভুল অনুমান শুধু গতি কমায়, ফল ভুল করে না — flush সব ঠিক করে দেয়।
Branch target buffer — দিক জানলেই হবে না, ঠিকানাও চাই
Counter বলে “taken”, কিন্তু কোথায়? ID-তে decode না করা পর্যন্ত জানাই যায় না যে instruction-টি শাখা, offset তো দূরের কথা। Taken অনুমানে penalty শূন্য করতে হলে IF-এই, শুধু PC দেখে, জানতে হবে (১) এটি কি শাখা, আর (২) target কত। এর জন্য branch target buffer (BTB): একটি ছোট cache, যার চাবি শাখার PC (tag সহ) আর মান আগের বার দেখা target. IF-এ instruction cache আর BTB একসাথে দেখা হয়। BTB-তে মিল পেলে আর predictor taken বললে পরের cycle-এই PC = সেই target; মিল না পেলে PC + 4. ভুল হলে (দিক বা ঠিকানা) সাধারণ flush, আর BTB হালনাগাদ। BTB-তে tag থাকে, কারণ এখানে ভুল ঘর মানে ভুল ঠিকানায় লাফ — খরচ বেশি।
Return address stack — function থেকে ফেরা
ret (jalr x0, 0(ra)) একটি indirect jump — গন্তব্য register-এ। BTB শেষবারের target মনে রাখে, কিন্তু একটি function অনেক জায়গা থেকে ডাকা হলে প্রতিবার ফেরার ঠিকানা ভিন্ন, তাই BTB প্রায়ই ভুল। সমাধান সুন্দর: call আর return-এর ক্রম একটি stack-এর মতো — শেষে ডাকা function আগে ফেরে। তাই hardware-এ একটি ছোট stack, return address stack (RAS): call-এ return address (PC + 4) ঠেলে রাখা, return-এ তুলে নিয়ে সেটিকেই target অনুমান করা। RISC-V spec নিজেই ইঙ্গিত দেয় কোন instruction call আর কোনটি return: rd = x1 (ra) বা x5 হলে jal/jalr push করার ইঙ্গিত; rs1 = x1 বা x5 আর rd = x0 হলে jalr pop. RAS ছোট (কয়েক থেকে কয়েক ডজন ঘর) — খুব গভীর recursion-এ উপচে পড়ে পুরোনো ঘর হারায়, তখন সেই return-গুলো ভুল অনুমান হয়।
| কাঠামো | কী উত্তর দেয় | Index / চাবি | কখন দেখা হয় |
|---|---|---|---|
| BHT (counter-এর টেবিল) | দিক: taken না not-taken | PC-র কয়েকটি বিট (অংশ ৭-এ history-ও); সাধারণত tag নেই | IF (বা ID) |
| BTB | এটি কি শাখা, আর target কত | PC, tag সহ | IF, instruction cache-এর সাথে একই cycle-এ |
| RAS | Return-এর target | Stack-এর মাথা | Return চিনলে (BTB বা decode থেকে) |
৭ · Correlating predictor, gshare আর tournament — ইতিহাস দিয়ে অনুমান
এই কোডটি দেখুন: if (x == 0) a = 1; তারপর if (x == 0) b = 2; (বা if (a == 1)). দ্বিতীয় শাখার ফল প্রথমটির ফলের সমান। প্রতিটি শাখার নিজের counter এটি জানে না; x এলোমেলো হলে দুজনেই প্রায় ৫০%. কিন্তু “ঠিক আগের শাখা কী করেছিল” দেখলে দ্বিতীয়টি নিখুঁত অনুমান করা যায়। এই ধারণার নাম correlating predictor (বা two-level predictor).
- Global history register (GHR): শেষ m টি শাখার ফল (taken = 1) রাখা একটি m-বিটের shift register — প্রতিটি শাখার পরে বাঁয়ে সরে, নতুন ফল ডানে ঢোকে।
- (m, n) predictor: প্রতিটি PC-র জন্য 2m টি n-বিট counter; GHR ঠিক করে কোনটি ব্যবহার হবে। যেমন (2, 2): শেষ দুই শাখার চার রকম ফলের জন্য চারটি আলাদা ২-বিট counter. সাধারণ ২-বিট BHT আসলে (0, 2) predictor. মোট বিট = 2m × n × (PC-র ঘরের সংখ্যা) — যেমন (2, 2), ১০২৪ ঘর: 4 × 2 × 1024 = 8192 বিট।
- Local history: GHR সব শাখার মিলিত ইতিহাস; local history একটি নির্দিষ্ট শাখার নিজের শেষ কয়েকটি ফল। “৪ বার ঘুরে বেরোয়” ধরনের নিয়মিত লুপ local history দিয়ে ধরা যায়, যদি history অন্তত n − 1 বিট লম্বা হয় (n বার ঘোরা লুপে; ৪ বারের লুপে ৩ বিট)।
gshare — PC আর history-কে XOR
প্রতিটি PC-র জন্য আলাদা আলাদা 2m টি counter রাখা ব্যয়বহুল। ১৯৯৩ সালে Scott McFarling (DEC-এর গবেষণাগারে) একটি প্রতিবেদনে একটি সস্তা কৌশল জনপ্রিয় করেন: gshare — একটি মাত্র counter-টেবিল, আর index = (PC-র বিট) ⊕ (GHR). একই শাখা ভিন্ন ইতিহাসে ভিন্ন ঘরে যায়; আর XOR দুই তথ্যকে টেবিল জুড়ে ছড়িয়ে দেয়, যাতে শুধু PC বা শুধু history-র চেয়ে aliasing কম হয়। নিচে ১৬ ঘরের টেবিল (৪-বিট index), ৪-বিটের GHR, সব counter শুরুতে 01:
| শাখা | |
| PC[5:2] | |
| GHR | |
| index = PC ⊕ GHR | |
| counter (আগে) | |
| অনুমান | |
| আসল ফল | |
| counter (পরে) | |
| GHR (পরে) |
- প্রস্তুতি। ১৬টি ২-বিট counter, সব 01 (weakly not-taken). GHR = 0110 — শেষ চারটি শাখা ছিল N, T, T, N (ডানের বিট সবচেয়ে নতুন)।
- B1-এর index. B1 আছে 0x108-এ। 0x108 = 1 0000 1000; নিচের ২ বিট বাদ দিয়ে পরের ৪ বিট PC[5:2] = 0010. index = 0010 ⊕ 0110 = 0100 = ৪. counter[4] = 01, উঁচু বিট ০ — অনুমান N; IF সেই অনুযায়ী PC + 4 আনতে থাকে।
- B1-এর ফল। আসলে T — ভুল অনুমান, flush. হালনাগাদ: counter[4] 01 → 10; GHR বাঁয়ে সরে নতুন ১ ঢোকে: 0110 → 1101.
- B2. PC 0x114 → PC[5:2] = 0101; index = 0101 ⊕ 1101 = 1000 = ৮. counter[8] = 01 → অনুমান N, আসল N — ঠিক। counter[8] 01 → 00; GHR 1101 → 1010.
- পরে, একই পথে B1. কিছুক্ষণ পরে প্রোগ্রাম আবার একই পথে এসে B1-এ পৌঁছাল, GHR আবার 0110. একই index ৪, counter এখন 10 → অনুমান T, আসল T — ঠিক; counter 11, GHR 1101. ইতিহাসটি মনে আছে।
- ভিন্ন পথে B1. এবার B1-এ পৌঁছানোর আগের শাখাগুলো ভিন্ন: GHR = 0111. index = 0010 ⊕ 0111 = 0101 = ৫ — একই শাখার জন্য সম্পূর্ণ আলাদা counter. এই পথে B1 not-taken হয়, আর counter[5] (01) সেটিই বলে — ঠিক; 01 → 00, GHR 1110. “কোন পথে এসেছি” অনুযায়ী আলাদা অনুমান — correlation-এর মূল কথা।
- Aliasing. অন্য এক সময়ে B3 (0x10C, PC[5:2] = 0011) GHR 0111 নিয়ে এল: 0011 ⊕ 0111 = 0100 = ৪ — B1-এর ঘর! counter[4] = 11 বলে T, অথচ B3 এখানে N — ভুল, আর B1-এর শেখা counter নামল 10-এ। সাধারণ BHT-তে (শুধু PC) B1 যেত ঘর ২-এ, B3 ঘর ৩-এ — সংঘর্ষ হতো না। XOR সংঘর্ষ কমায় গড়ে, কিন্তু শূন্য করে না।
এখন সব predictor একসাথে মেলানো যাক। নিচের simulator চার ধরনের শাখার trace চালায় — ১০ বারের লুপ ৩ বার (আর তুলনার জন্য ৩০ বার), T N পর্যায়ক্রম, একটি correlated if-জোড়া (দ্বিতীয় শাখা প্রথমটির ফল হুবহু অনুসরণ করে, প্রথমটি নির্দিষ্ট seed-এর pseudo-random ডেটায় চলে), আর এলোমেলো ডেটা-নির্ভর শাখা — ছয়টি predictor দিয়ে। BHT আর gshare দুটিই ১৬ ঘরের, gshare-এর history ৪ বিট:
// Branch-predictor simulator: 4 branch traces x 6 predictors. 2-bit counters start at 01 (weakly not-taken).
let seed = 2026; // fixed seed: the "random" data is the same every run
const coin = () => ((seed = (Math.imul(seed, 1664525) + 1013904223) >>> 0) >>> 31) === 1;
const T = {}; // each event: [pc, backward branch?, taken?]
for (const reps of [3, 30]) { const L = T[`loop 10 x${reps}`] = []; // loop branch: T x9, then N (exit)
for (let e = 0; e < reps; e++) for (let j = 1; j <= 10; j++) L.push([0x40, true, j < 10]); }
T["alternating"] = []; for (let k = 0; k < 40; k++) T["alternating"].push([0x80, false, k % 2 === 0]);
T["if-pair"] = []; for (let k = 0; k < 200; k++) { const c = coin(); T["if-pair"].push([0x100, false, c], [0x10C, false, c]); }
T["random"] = []; for (let k = 0; k < 400; k++) T["random"].push([0x200, false, coin()]);
const sat = (v, k) => (k ? Math.min(3, v + 1) : Math.max(0, v - 1)); // 2-bit saturating counter
const P = {
"always-T": () => ({ p: () => true, u() {} }),
"always-N": () => ({ p: () => false, u() {} }),
"BTFN": () => ({ p: (pc, back) => back, u() {} }), // backward -> taken, forward -> not taken
"1-bit": () => { const t = Array(16).fill(false), ix = pc => (pc >> 2) & 15;
return { p: pc => t[ix(pc)], u: (pc, k) => { t[ix(pc)] = k === 1; } }; },
"2-bit": () => { const t = Array(16).fill(1), ix = pc => (pc >> 2) & 15;
return { p: pc => t[ix(pc)] >= 2, u: (pc, k) => { t[ix(pc)] = sat(t[ix(pc)], k); } }; },
"gshare": () => { const t = Array(16).fill(1); let h = 0; const ix = pc => ((pc >> 2) ^ h) & 15; // 4-bit history
return { p: pc => t[ix(pc)] >= 2, u: (pc, k) => { t[ix(pc)] = sat(t[ix(pc)], k); h = ((h << 1) | k) & 15; } }; },
};
console.log("trace".padEnd(12) + "n".padStart(5) + " " + Object.keys(P).map(s => s.padStart(11)).join(""));
for (const [name, tr] of Object.entries(T)) {
let row = name.padEnd(12) + String(tr.length).padStart(5) + " ";
for (const make of Object.values(P)) {
const pr = make(); let miss = 0;
for (const [pc, back, taken] of tr) { if (pr.p(pc, back) !== taken) miss++; pr.u(pc, taken ? 1 : 0); }
row += `${Math.round(100 * (tr.length - miss) / tr.length)}% (${miss})`.padStart(11);
}
console.log(row);
}
console.log("cell = accuracy (mispredictions)");আউটপুট (নির্ধারিত — seed স্থির, তাই প্রতিবার একই):
trace n always-T always-N BTFN 1-bit 2-bit gshare
loop 10 x3 30 90% (3) 10% (27) 90% (3) 80% (6) 87% (4) 63% (11)
loop 10 x30 300 90% (30) 10% (270) 90% (30) 80% (60) 90% (31) 87% (38)
alternating 40 50% (20) 50% (20) 50% (20) 0% (40) 0% (40) 93% (3)
if-pair 400 51% (198) 50% (202) 50% (202) 53% (188) 49% (204) 70% (122)
random 400 49% (204) 51% (196) 51% (196) 50% (200) 50% (200) 44% (225)
cell = accuracy (mispredictions)
- লুপ: ১-বিট ৩০-এ ৬টি ভুল (প্রতি প্রবেশে ২), ২-বিট ৪টি (শুরুর ঠান্ডা অবস্থায় বাড়তি ১, তারপর প্রতি প্রবেশে ১), আর BTFN/always-T ৩টি — শুধু বেরোনোগুলো। ৩০ বারে ২-বিট ৩১টি ভুল, প্রায় ৯০% — BTFN-এর (৩০টি ভুল) প্রায় সমান। পেছনমুখী লুপ-শাখায় static নিয়মও খুব ভালো।
- gshare লুপে কেন খারাপ: ৪ বিটের history ১০ বারের লুপের বেরোনো আগে থেকে দেখতে পায় না (শেষ চারটি তো সবসময় TTTT), আর বেরোনোর পরের কয়েকটি ঘুরে history-তে N থাকায় সেগুলো আলাদা ঘরে যায়, প্রতিটিকে আলাদা করে শিখতে হয়। ৩ বারে ৬৩%, ৩০ বারে ৮৭% — শেখার (warm-up) খরচ ছোট trace-এ বড়।
- পর্যায়ক্রম: ১-বিট আর ২-বিট (01 থেকে) ০% — উপরের সতর্কবার্তার দোল। gshare ৯৩%: history-র শেষ বিট দেখেই পরেরটা জানা যায়, শুধু শুরুর ৩টি ভুল।
- if-জোড়া: PC-ভিত্তিক সবাই ৫০%-এর কাছে; gshare ৭০% — দ্বিতীয় শাখাটি নীতিগতভাবে নিখুঁত অনুমান করা যায় (এখানে ২০০-তে মাত্র ১৬টি ভুল), প্রথমটি মুদ্রা ছোঁড়া, তাই আদর্শ সীমা ৭৫%. বাকি পার্থক্য warm-up আর ১৬ ঘরের টেবিলে aliasing.
- এলোমেলো: সবাই ~৫০% (gshare ৪৪% — এই ছোট নমুনায় এলোমেলো ওঠানামা)। সত্যিকারের এলোমেলো ডেটা কোনো predictor-ই ধরতে পারে না — সেখানে সমাধান শাখাই না রাখা (অংশ ৮)।
Tournament predictor
কোনো শাখা local history-তে ভালো ধরা পড়ে, কোনোটি global-এ। Tournament predictor দুটিকেই পাশাপাশি চালায়, আর প্রতিটি শাখার (কোনো নকশায় প্রতিটি history-র) জন্য একটি “chooser” ২-বিট counter রাখে — কোনটি সম্প্রতি বেশি ঠিক বলেছে, তার দিকে সরে। McFarling-এর একই প্রতিবেদনে এই সমন্বয়ের ধারণাও ছিল। সবচেয়ে বিখ্যাত উদাহরণ DEC-এর Alpha 21264 (১৯৯০-এর দশকের শেষ দিকে): একটি local predictor (প্রতিটি শাখার নিজস্ব ইতিহাস), একটি global predictor (GHR দিয়ে index), আর একটি choice predictor — পাঠ্যবইয়ে tournament-এর আদর্শ উদাহরণ হিসেবে এটিই দেখানো হয়। এর টেবিলের সঠিক আকার নিয়ে বিভিন্ন উৎসে বিস্তারিত পাবেন; মূল ধারণাটুকুই এখানে জরুরি।
৮ · আধুনিক predictor, গভীর pipeline-এ ভুলের দাম আর sorted array-র রহস্য
TAGE আর perceptron — গবেষণা থেকে chip-এ
- TAGE (TAgged GEometric history length; André Seznec আর Pierre Michaud, ২০০৬): একটি সাধারণ ২-বিট টেবিল, তার সাথে কয়েকটি tag-যুক্ত টেবিল, যাদের history-র দৈর্ঘ্য জ্যামিতিক হারে বাড়ে (যেমন ৪, ৮, ১৬, ৩২ … বিট)। সবচেয়ে লম্বা history-র যে টেবিলে tag মেলে, তার অনুমান নেওয়া হয়। ফলে ছোট ধরন অল্প history-তে, দূরের correlation লম্বা history-তে — একসাথে। Branch prediction-এর গবেষণা প্রতিযোগিতায় (Championship Branch Prediction) TAGE-ভিত্তিক নকশা বহুবার শীর্ষে ছিল বলে জানা যায়।
- Perceptron predictor (Daniel Jiménez আর Calvin Lin, ২০০১): history-র প্রতিটি বিটের একটি ওজন; অনুমান = ওজন × (±1) এর যোগফলের চিহ্ন। ভুল হলে (বা যোগফল একটি সীমার চেয়ে ছোট হলে) ওজনগুলো একটু করে সংশোধন — একটি সরলতম neural network. সুবিধা: খুব লম্বা history-তেও টেবিল সূচকীয় হারে বাড়ে না।
- বাস্তব chip: AMD প্রকাশ্যে বলেছে তাদের Zen core-এ perceptron-ধাঁচের predictor ছিল, আর Zen 2 থেকে TAGE-ধাঁচের একটি অংশ যোগ হয়েছে বলে জানা যায়; Intel আর Apple সাধারণত বিস্তারিত প্রকাশ করে না। আধুনিক core-এ সাধারণ কোডে নির্ভুলতা ৯০%-এর অনেক ওপরে থাকে — সঠিক সংখ্যা workload আর chip-ভেদে আলাদা, তাই নির্দিষ্ট কোনো শতাংশ মুখস্থ না করাই ভালো।
গভীর pipeline-এ এক ভুলের দাম
আধুনিক বড় core-এ fetch থেকে শাখার নিষ্পত্তি পর্যন্ত অনেকগুলো ধাপ, আর প্রতি cycle-এ ৪–৮টি instruction একসাথে আনা হয় (পর্ব ৩৯)। ভুল অনুমানে penalty মোটামুটি ১০–২০ cycle (core-ভেদে) — আর প্রতি cycle-এ কয়েকটি instruction-এর সুযোগ, তাই একটি ভুল মানে কয়েক ডজন instruction-এর কাজ নষ্ট। Out-of-order core-এ (পর্ব ৪০) অনুমানের পথে instruction শুধু আনা নয়, আগেভাগে চালানোও হয় — speculative execution — আর ভুল হলে ফলগুলো ফেলে দেওয়া হয়। Intel-এর Pentium 4 (NetBurst, ২০০০-এর দশকের শুরু) প্রায় ২০ ধাপের pipeline নিয়ে উচ্চ clock-এর পেছনে ছুটেছিল, আর পরের প্রজন্মে আরও গভীর হয়েছিল — শাখার ভুলের বড় দাম আর শক্তি-খরচ তার দুর্বলতা হিসেবে প্রায়ই উল্লেখ করা হয়; পরের Core নকশা তুলনামূলক কম গভীর pipeline-এ ফেরে।
Sorted বনাম unsorted — একই কাজ, ভিন্ন গতি
পর্ব ২৯-এর ভাবনার প্রশ্নে (শাখাহীন কোড) যে বিখ্যাত উদাহরণ এসেছিল, এবার মেপে দেখা যাক। একটি array-তে 0–255 এর মান; লুপ যোগ করে শুধু 128 বা তার বেশি মানগুলো। Unsorted array-তে শাখাটি মুদ্রা ছোঁড়ার মতো — কোনো predictor-ই এর চেয়ে ভালো পারে না (অংশ ৭-এর “random” সারি)। Sort করার পর প্রথম অর্ধেক টানা N, বাকি অর্ধেক টানা T — মাঝখানে একবার মত বদলানো ছাড়া প্রায় নিখুঁত। নিচের প্রোগ্রাম দুই অবস্থায় শাখাওয়ালা আর শাখাহীন (branchless, mask দিয়ে) — দুটি লুপের সময়ের অনুপাত ছাপে। প্রতিটি মাপ ৭ বার নিয়ে সবচেয়ে কমটি রাখা হয়, যাতে ভাগাভাগি করা সার্ভারের গোলমাল কম লাগে:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
#define N 32768
#define PASSES 100
static unsigned char a[N];
static int cmp(const void *p, const void *q) { return *(const unsigned char *)p - *(const unsigned char *)q; }
static double now(void) { struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t); return t.tv_sec + t.tv_nsec * 1e-9; }
static long long with_branch(void) { /* if (a[i] >= 128) sum += a[i]; */
long long s = 0;
for (int p = 0; p < PASSES; p++)
for (int i = 0; i < N; i++)
if (a[i] >= 128) s += a[i];
return s;
}
static long long branchless(void) { /* mask = 0 or all-ones: no branch on the data */
long long s = 0;
for (int p = 0; p < PASSES; p++)
for (int i = 0; i < N; i++) { int m = -(a[i] >= 128); s += a[i] & m; }
return s;
}
static double timed(long long (*f)(void), long long *s) { /* best of 7 runs: less noise */
double best = 1e9; for (int r = 0; r < 7; r++) { double t = now(); *s = f(); t = now() - t; if (t < best) best = t; }
return best;
}
int main(void) {
unsigned x = 2026u; long long s[4];
for (int i = 0; i < N; i++) { x = x * 1664525u + 1013904223u; a[i] = (unsigned char)(x >> 24); }
double ub = timed(with_branch, &s[0]), ul = timed(branchless, &s[1]);
qsort(a, N, 1, cmp); /* same values, now in increasing order */
double sb = timed(with_branch, &s[2]), sl = timed(branchless, &s[3]);
printf("sums equal: %s (sum = %lld)\n", (s[0] == s[1] && s[1] == s[2] && s[2] == s[3]) ? "yes" : "NO", s[0]);
printf("with branch: unsorted / sorted = %.1f\nbranchless : unsorted / sorted = %.1f\n", ub / sb, ul / sl);
printf("unsorted : branch / branchless = %.1f\n", ub / ul);
return 0; }আউটপুট (Wandbox-এ gcc, warning চালু, শূন্য warning; একটি রানের ফল — অনুপাত প্রতিবার আর প্রতিটি machine-এ বদলায়):
sums equal: yes (sum = 315495400)
with branch: unsorted / sorted = 2.7
branchless : unsorted / sorted = 1.0
unsorted : branch / branchless = 1.7
যোগফল চার ক্ষেত্রেই এক (৩১,৫৪,৯৫,৪০০ = ১০০ পাস × প্রতি পাসে ৩১,৫৪,৯৫৪) — কাজ হুবহু একই। তবু শাখাওয়ালা লুপ unsorted ডেটায় কয়েক গুণ ধীর (এই রানে 2.7 গুণ); আমাদের কয়েকটি পরীক্ষামূলক রানে এই অনুপাত প্রায় ২ থেকে ৫-এর মধ্যে এসেছে। কারণ unsorted-এ প্রায় অর্ধেক অনুমান ভুল, প্রতিটিতে অনেকগুলো cycle নষ্ট। শাখাহীন লুপে অনুপাত ১-এর কাছে (এই রানে 1.0), আর unsorted ডেটায় শাখাহীন লুপ শাখাওয়ালাটির চেয়ে দ্রুত (এই রানে 1.7 গুণ) — ডেটার ক্রমে কিছু যায় আসে না, কারণ অনুমান করার মতো কোনো ডেটা-নির্ভর শাখাই নেই। লক্ষ করুন, sorted ডেটায় শাখাওয়ালা লুপই সবচেয়ে দ্রুত হতে পারে, কারণ শাখাহীন লুপ প্রতিবার mask আর and-এর বাড়তি কাজ করে — অনুমানযোগ্য শাখা প্রায় বিনামূল্যে।
একটি সতর্কতা: এই runner কোনো -O flag ছাড়া (gcc-র ডিফল্ট -O0) compile করে, তাই C-র if সত্যিই একটি শাখা হয়ে থাকে। gcc -O2 বা -O3-এ নতুন compiler প্রায়ই এই লুপকে নিজে থেকেই cmov (শর্ত মিললে তবেই মান সরানোর x86 instruction) বা SIMD (vectorization) কোডে বদলে দেয় — তখন sorted আর unsorted প্রায় সমান সময় নেয়, আর “রহস্য” উধাও। নিজের machine-এ দুই flag-এ চালিয়ে আর gcc -S দিয়ে assembly দেখে মিলিয়ে নিন: jl/jge জাতীয় শাখা আছে, নাকি cmov বা vpaddq-জাতীয় vector instruction.
Indirect শাখার অনুমান
দিক নয়, গন্তব্যই যেখানে প্রশ্ন — indirect branch prediction. C++/Java-র virtual function call, function pointer, switch-এর jump table (পর্ব ২৯) আর Python/JavaScript-এর interpreter-এর dispatch লুপ (প্রতিটি bytecode opcode-এর জন্য একটি লাফ) — সবগুলোর target অনেক রকম হতে পারে। সরল BTB শুধু শেষ target মনে রাখে; আধুনিক core history দিয়েও target বাছাই করে (TAGE-এর ধারণায় তৈরি indirect predictor, যেমন ITTAGE, গবেষণায় পরিচিত)। পুরোনো interpreter-এ “প্রতিটি opcode-এর শেষে নিজস্ব লাফ” (threaded code) কৌশল শাখাকে বেশি অনুমানযোগ্য করত; নতুন predictor-এ এর সুবিধা কমে এসেছে বলে কিছু গবেষণা দেখিয়েছে।
Predictor আর নিরাপত্তা
Predictor-এর টেবিল একটি ভাগাভাগি করা অবস্থা: একই core-এ চলা ভিন্ন প্রোগ্রাম একই BTB আর counter ব্যবহার করতে পারে। ২০১৮ সালে প্রকাশিত Spectre দেখায়, কেউ predictor-কে ইচ্ছামতো “শিখিয়ে” অন্য প্রোগ্রামকে ভুল পথে অনুমান করাতে পারলে, সেই অনুমানের পথে চলা instruction-গুলো cache-এ চিহ্ন রেখে যায় — ফল ফেলে দিলেও। প্রতিরক্ষার মধ্যে আছে speculation-বাধা instruction, compiler-এর retpoline, context switch-এ predictor-এর অবস্থা মুছে ফেলা বা আলাদা রাখা, আর নতুন chip-এ hardware-স্তরের বিচ্ছিন্নতা। পুরো আলোচনা, শুধু প্রতিরক্ষার দিক থেকে, পর্ব ৪১-এ (Speculative execution আর নিরাপত্তা)।
আরও গভীরে। আধুনিক core-এ branch predictor আসলে front end-এর চালক: সে instruction cache-এর আগে আগে চলে, পরের কয়েকটি fetch-ঠিকানা একটি সারিতে (fetch target queue) জমা করে, আর fetch সেই সারি অনুসরণ করে — একে decoupled front end বলা হয়। BTB নিজেই কয়েক স্তরের (ছোট-দ্রুত আর বড়-ধীর), যাতে taken শাখাতেও প্রায় কোনো bubble না পড়ে; অনেক core-এ লুপের ঘোরার সংখ্যা মনে রাখা আলাদা loop predictor-ও থাকে বলে বর্ণনা পাওয়া যায়। Predictor-এর মোট আকার বহু কিলোবাইট হতে পারে, আর শক্তি ও জায়গার হিসাবে এটি core-এর একটি উল্লেখযোগ্য অংশ।
৯ · AI-এর যুগে branch prediction
AI টুল branch prediction-এর ধারণা সুন্দর ব্যাখ্যা করে, কিন্তু সংখ্যার কাজে প্রায়ই ভুল করে — আর এই বিষয়ের প্রায় সব প্রশ্নই সংখ্যার। প্রচলিত ভুলগুলো:
| AI-এর প্রচলিত ভুল | ঠিক কথা | কীভাবে যাচাই |
|---|---|---|
| ১-বিট predictor লুপে “প্রতি প্রবেশে একবার” ভুল করে বলা | প্রতি প্রবেশে দুবার: বেরোনোয় আর পরের প্রবেশের প্রথম ঘুরে | T…TN কয়েকবার হাতে বা simulator-এ চালানো |
| ২-বিট counter-এর state diagram-এ 10 থেকে N-এ সোজা 00-তে নিয়ে যাওয়া | Saturating counter এক ঘর করে সরে; (কিছু বইয়ের “hysteresis” রূপ আলাদা — কোনটি, তা প্রশ্নে স্পষ্ট করা চাই) | চারটি state-এ দুই input-এর টেবিল বানানো |
| CPI-তে penalty সব শাখায় গোনা, যদিও শুধু ভুলগুলো দেয় | CPI = 1 + f × miss rate × penalty | একক মিলিয়ে দেখা: f আর miss rate দুটিই অনুপাত |
| MIPS-এর delay slot RISC-V-তে আছে বলা, বা MIPS-এর ৩-cycle আর ১-cycle নকশা গুলিয়ে ফেলা | RISC-V-তে delay slot নেই; penalty নির্ভর করে কোন ধাপে নিষ্পত্তি | RISC-V unprivileged spec; datapath-এর ছবি |
| gshare-এ PC-র নিচের ২ বিট বাদ না দেওয়া, বা XOR-এর বদলে concatenation (সেটি gselect) | index = PC[k+1:2] ⊕ GHR | বিট ধরে ধরে হাতে XOR |
| নির্দিষ্ট chip-এর predictor-এর আকার বা নির্ভুলতার শতাংশ বানিয়ে বলা | বেশিরভাগ কোম্পানি বিস্তারিত প্রকাশ করে না | প্রতিষ্ঠানের নিজস্ব প্রকাশনা, optimization manual, বা perf দিয়ে মাপা |
AI-কে প্রশ্ন করার সময় শুরুর state, টেবিলের আকার আর ধাপগুলো স্পষ্ট করে চেয়ে নিন:
AI-এর উত্তর হাতে পেলে — যাচাইয়ের চেকলিস্ট
পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:
- Control hazard কী, আর penalty কীসের ওপর নির্ভর করে? — শাখার ফল জানার আগেই পরের instruction আনতে হয়; penalty = ফল জানার আগেই শাখার পরে কতগুলো instruction আনা হয়ে যায়, অর্থাৎ নিষ্পত্তির ধাপের আগে কয়টি ধাপ।
- শাখা ID-তে আনলে কী লাভ, কী দাম? — penalty ১ cycle; কিন্তু তুলনার register ঠিক আগের ALU বা load instruction লিখলে নতুন stall আর বাড়তি forwarding লাগে।
- লুপে ১-বিট দুবার আর ২-বিট একবার ভুল করে কেন? — ১-বিট বেরোনোর N-এ উল্টে যায়; ২-বিট strongly থেকে weakly-তে নামে, অনুমান উল্টায় না।
- BTB আর BHT-এর পার্থক্য? — BHT দিক বলে (counter, সাধারণত tag ছাড়া); BTB IF-এই বলে এটি শাখা কি না আর target কত (tag সহ)।
- Sorted array-তে লুপ দ্রুত কেন? — ডেটা-নির্ভর শাখা অনুমানযোগ্য হয়ে যায়; শাখাহীন কোড বা compiler-এর cmov/vectorization-এ পার্থক্য মুছে যায়।
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১MIPS-এর নকশাকারেরা branch delay slot-কে বুদ্ধিমান সমাধান ভেবেছিলেন, RISC-V-এর নকশাকারেরা সেটি বাদ দিলেন। দুই সিদ্ধান্তই কি নিজের সময়ে সঠিক ছিল?
১৯৮০-র দশকের শুরুতে Stanford-এ John Hennessy-র দলের MIPS আর Berkeley-র RISC প্রকল্পের মূল দর্শন ছিল: hardware সরল রাখা, জটিলতা compiler-এর হাতে তুলে দেওয়া। তখনকার chip-এ ট্রানজিস্টর খুব কম; একটি branch predictor-এর টেবিল বসানোর জায়গাই ছিল না। ৫ ধাপের pipeline-এ শাখার একটিমাত্র নষ্ট cycle — সেটিকে ISA-র নিয়ম বানিয়ে compiler-কে দিয়ে কাজে লাগানোই ছিল সবচেয়ে সস্তা সমাধান। গবেষণায় দেখা গিয়েছিল, compiler অনেক delay slot-এ কাজের instruction বসাতে পারে, তাই প্রায় বিনা hardware-এ গতি বাড়ত। সেই যুগের মাপকাঠিতে এটি সত্যিই বুদ্ধিমান ছিল; SPARC, PA-RISC-সহ আরও কিছু ISA একই পথ নেয়।
সমস্যা দেখা দেয় পরে। ISA একটি চুক্তি — একবার প্রকাশিত হলে দশকের পর দশক পুরোনো প্রোগ্রাম চালাতে হয়। ১৯৯০-এর দশকে MIPS-এর R10000-এর মতো out-of-order, একসাথে কয়েকটি instruction চালানো core এল, যাদের শাখার penalty অনেক cycle. সেখানে একটি delay slot প্রায় কিছুই বাঁচায় না, কিন্তু hardware-কে বাধ্যতামূলকভাবে মনে রাখতে হয় “এই instruction-টি একটি শাখার slot-এ” — exception হলে ফেরার ঠিকানা, debugger-এ এক ধাপ এগোনো, সব জায়গায় বাড়তি জটিলতা। অর্থাৎ একটি নির্দিষ্ট microarchitecture-এর খুঁটিনাটি ISA-তে চিরস্থায়ী হয়ে গেল।
২০১০ সালের দিকে Berkeley-তে RISC-V-এর নকশা শুরু হয় এই অভিজ্ঞতা মাথায় রেখে। RISC-V-এর নকশাকারেরা নিজেদের লেখায় ব্যাখ্যা করেছেন কেন delay slot রাখা হয়নি: এটি সরল in-order pipeline-এর জন্য উপযুক্ত হলেও উন্নত নকশায় জটিলতা বাড়ায়, আর compressed instruction-এর সাথেও মেলে না। ততদিনে সস্তা ট্রানজিস্টরে ছোট core-এও একটি ছোট dynamic predictor বসানো যায়, যা slot-এর চেয়ে বেশি cycle বাঁচায়। MIPS নিজেও পরে Release 6-এ delay slot ছাড়া compact শাখা যোগ করে — নিজের পুরোনো সিদ্ধান্তের একরকম স্বীকৃতি।
দুই সিদ্ধান্তই নিজের সময়ের সীমাবদ্ধতায় যুক্তিসঙ্গত ছিল। পার্থক্য দৃষ্টিভঙ্গিতে: MIPS অপ্টিমাইজ করেছিল একটি নির্দিষ্ট chip-এর জন্য, RISC-V অপ্টিমাইজ করেছে অজানা ভবিষ্যতের বহু রকম chip-এর জন্য।
মূল উপলব্ধি: ISA-তে যা ঢোকে তা চিরকাল থাকে, microarchitecture প্রতি প্রজন্মে বদলায়। আজকের pipeline-এর সুবিধার জন্য ISA বদলানো আগামীকালের pipeline-এর বোঝা হয়ে দাঁড়াতে পারে — delay slot তার পাঠ্যপুস্তকীয় উদাহরণ।
প্র ০২Pipeline গভীর করলে clock দ্রুত হয়। তাহলে Pentium 4-এর মতো খুব গভীর pipeline কেন শেষ পর্যন্ত টেকেনি?
Pipeline-এর প্রতিটি ধাপ যত ছোট, প্রতিটি cycle তত ছোট হতে পারে — তাই ধাপ বাড়ালে clock-এর frequency বাড়ে। ২০০০ সালে Intel Pentium 4 (NetBurst নকশা) এই যুক্তিকে চরমে নেয়: প্রায় ২০ ধাপের pipeline, পরে Prescott প্রজন্মে আরও গভীর (৩০ ধাপের কাছাকাছি বলে বর্ণনা পাওয়া যায়)। লক্ষ্য ছিল খুব উঁচু GHz, আর বিজ্ঞাপনেও GHz-ই তখন গতির প্রতীক ছিল।
এই পর্বের সূত্রেই সমস্যাটি দেখা যায়: CPI = 1 + f × m × P. Penalty P মোটামুটি pipeline-এর গভীরতার সাথে বাড়ে। শাখা ২০%, ভুল ৫% হলে P = 10-এ CPI 1.1, কিন্তু P = 30-এ 1.3. অর্থাৎ clock যত বাড়ল, প্রতিটি cycle-এ গড়ে কাজ তত কমল। তার ওপর প্রতিটি নতুন ধাপে একটি pipeline register যোগ হয়, যার নিজস্ব delay আছে (পর্ব ১৮-এর setup আর clock-to-Q) — ধাপ যত ছোট, এই স্থির খরচ তত বড় অংশ। Data hazard-এর stall-ও গভীর pipeline-এ বাড়ে।
সবচেয়ে বড় ধাক্কা এল শক্তি থেকে। Dynamic power মোটামুটি frequency-র সাথে বাড়ে, আর উঁচু frequency-তে voltage-ও বাড়াতে হয় — পর্ব ০২-এর power wall. Prescott-এর তাপ আর শক্তি-খরচ এত বেশি ছিল যে Intel পরিকল্পিত আরও উঁচু clock-এর পথ ছেড়ে দেয়। ২০০৬ সালের Core নকশা (মোবাইলের Pentium M-এর বংশধর) তুলনামূলক কম গভীর pipeline, ভালো branch predictor আর প্রতি cycle-এ বেশি কাজের পথে যায়, আর একই সময় শিল্প একাধিক core-এর দিকে মোড় নেয়।
গবেষণাতেও ২০০০-এর দশকের শুরুতে কয়েকটি প্রবন্ধ performance আর শক্তি মিলিয়ে “সর্বোত্তম গভীরতা” হিসাব করে দেখিয়েছিল যে সেটি অসীম নয় — শাখার ভুল, hazard আর register-এর খরচ একটি নির্দিষ্ট গভীরতার পরে লাভ খেয়ে ফেলে। আজকের বড় core-এর penalty মোটামুটি ১০–২০ cycle-এর ঘরে, যা এই আপসের ফল।
মূল উপলব্ধি: clock-এর frequency একা গতি নয় — iron law-এর তিনটি গুণকই একসাথে দেখতে হয়। গভীর pipeline cycle time কমায় কিন্তু CPI আর শক্তি বাড়ায়; branch predictor যত ভালো, তত বেশি গভীরতা “সহ্য” করা যায়, কিন্তু সীমাহীন নয়।
প্র ০৩১৯৮১ সালের ২-বিট counter থেকে আজকের TAGE আর perceptron — branch prediction-এর গবেষণা কীভাবে এগিয়েছে, আর এখনো কেন শেষ হয়নি?
১৯৮১ সালে James E. Smith-এর “A Study of Branch Prediction Strategies” প্রবন্ধ নানা static আর dynamic কৌশল প্রকৃত প্রোগ্রামের trace-এ মেপে তুলনা করে, আর ২-বিট saturating counter-কে একটি সরল, কার্যকর সমাধান হিসেবে প্রতিষ্ঠিত করে। প্রায় এক দশক এটিই ছিল মান। সমস্যা ছিল এমন শাখা, যাদের আচরণ নিজের অতীতে নয়, অন্য শাখার ফলে লুকিয়ে — এই পর্বের if-জোড়ার মতো।
১৯৯১–৯২ সালে Tse-Yu Yeh আর Yale Patt “two-level adaptive” predictor প্রস্তাব করেন: প্রথম স্তরে history (global বা প্রতিটি শাখার local), দ্বিতীয় স্তরে সেই history দিয়ে index করা counter. প্রায় একই সময়ে Pan, So আর Rahmeh correlation-ভিত্তিক অনুমান নিয়ে লেখেন। ১৯৯৩ সালে Scott McFarling-এর প্রতিবেদন gshare আর দুই predictor মিলিয়ে বেছে নেওয়ার (tournament) ধারণা জনপ্রিয় করে, যার একটি রূপ Alpha 21264-এ বাস্তবে আসে।
২০০১ সালে Daniel Jiménez আর Calvin Lin machine learning-এর সবচেয়ে পুরোনো মডেলগুলোর একটি — perceptron — দিয়ে অনুমান করেন; এতে খুব লম্বা history ব্যবহার করা সম্ভব হয়। ২০০৬ সালে André Seznec আর Pierre Michaud-এর TAGE জ্যামিতিক হারে বাড়া history-র কয়েকটি tag-যুক্ত টেবিল দিয়ে ছোট আর লম্বা দুই ধরনের correlation একসাথে ধরে। গবেষকদের মধ্যে নিয়মিত “Championship Branch Prediction” প্রতিযোগিতা হয়েছে — একই trace-এ একই বিট-বাজেটে কে কম ভুল করে — আর সেখানে TAGE-পরিবার বহুবার শীর্ষে ছিল বলে জানা যায়। AMD প্রকাশ্যে perceptron আর TAGE-ধাঁচের অংশ ব্যবহারের কথা বলেছে।
তবু কাজ শেষ হয়নি। কয়েকটি “কঠিন শাখা” — ডেটা-নির্ভর, প্রায় এলোমেলো — এখনো বেশিরভাগ ভুলের উৎস, আর core যত চওড়া, প্রতিটি ভুলে তত বেশি কাজ নষ্ট। তাই গবেষণা এখন খোঁজে: শাখার সাথে সম্পর্কিত ডেটার মান দেখে অনুমান, compiler আর hardware-এর সহযোগিতা, আর শক্তি ও নিরাপত্তার (প্র ০৫) সাথে আপস।
মূল উপলব্ধি: প্রতিটি নতুন predictor আগের ধারণার ওপর দাঁড়িয়ে — counter, history, correlation, বেছে নেওয়া, শেখা। মৌলিক প্রশ্নটি একই: অতীতের কোন অংশ ভবিষ্যৎ সবচেয়ে ভালো বলে দেয়, আর সেটি কত অল্প বিটে মনে রাখা যায়।
প্র ০৪Python বা JavaScript-এর interpreter, C++-এর virtual function আর বড় switch — এই indirect শাখাগুলো predictor-এর জন্য কেন কঠিন, আর software কীভাবে সাহায্য করে?
শর্তসাপেক্ষ শাখার উত্তর দুটির একটি — taken বা not-taken. Indirect শাখার উত্তর যেকোনো ঠিকানা হতে পারে। একটি bytecode interpreter-এর কেন্দ্রে থাকে একটি লুপ: পরের opcode পড়া, তারপর switch দিয়ে সেই opcode-এর কোডে লাফ। শত রকম opcode মানে একটি jalr-এর শত রকম গন্তব্য। পুরোনো BTB শুধু “শেষবার কোথায় গিয়েছিল” মনে রাখত, তাই একটি মাত্র dispatch-লাফ প্রায় প্রতিবার ভুল অনুমান করত — interpreter ধীর হওয়ার একটি বড় কারণ।
Software-এর পুরোনো সমাধান threaded code: switch-এর একটি লাফের বদলে প্রতিটি opcode-এর কোডের শেষে নিজস্ব dispatch-লাফ (GCC-র “labels as values”, goto *table[op] দিয়ে)। এতে লাফ অনেকগুলো, আর প্রতিটির BTB-ঘর শেখে “এই opcode-এর পরে সাধারণত কোন opcode আসে” — একটি স্বাভাবিক correlation. CPython-সহ কয়েকটি interpreter এই কৌশল ব্যবহার করে বলে জানা যায়।
Hardware-ও এগিয়েছে। আধুনিক core indirect শাখার target বাছাই করে global history দিয়ে, TAGE-এর ধারণায় (ITTAGE নামে গবেষণায় পরিচিত)। ২০১৫ সালের দিকে Rohou, Swamy আর Seznec-এর একটি গবেষণা দেখায়, নতুন প্রজন্মের Intel core-এ সাধারণ switch-ভিত্তিক interpreter-এর dispatch-ও বেশ ভালো অনুমান হয় — অর্থাৎ threaded code-এর পুরোনো সুবিধা অনেকটা কমে এসেছে। এটি একটি ভালো শিক্ষা: “লোকমুখের” performance-পরামর্শ hardware বদলালে পুরোনো হয়ে যায়।
Virtual function call-এ compiler সাহায্য করে devirtualization দিয়ে: object-এর আসল ধরন নিশ্চিত জানা গেলে indirect call-কে সরাসরি call বানানো, বা “সম্ভবত এই ধরন” পরীক্ষা করে সরাসরি call আর না মিললে indirect (PGO-র তথ্যে)। JIT (just-in-time — প্রোগ্রাম চলার সময়ই compile করে এমন) compiler, যেমন JavaScript-এর V8, চলার সময় দেখে কোন ধরন আসছে, আর সেই অনুযায়ী শাখাহীন বা সরাসরি কোড বানায়।
মূল উপলব্ধি: indirect শাখা “কোথায়” প্রশ্নের উত্তর চায়, যা “হ্যাঁ/না”-র চেয়ে অনেক কঠিন। History-ভিত্তিক hardware আর compiler-এর রূপান্তর মিলে এখন এটি বেশ ভালো সামলায় — তবু সবচেয়ে নিশ্চিত উপায় হলো দরকার না থাকলে indirect লাফই না রাখা।
প্র ০৫Predictor শুধু গতির যন্ত্র — তাহলে সেটি নিরাপত্তার সমস্যা হলো কীভাবে, আর প্রতিরক্ষার দাম কে দেয়?
বহু বছর ধরে ধরে নেওয়া হতো, ভুল অনুমানের কোনো স্থায়ী ফল নেই: flush সব মুছে দেয়, register আর মেমরি অপরিবর্তিত। ISA-র দৃষ্টিতে এটি সত্য। কিন্তু microarchitecture-এর অবস্থা — cache-এ কোন লাইন এল, predictor-এর টেবিলে কী লেখা হলো — flush-এ মোছা হয় না। আর ভাগাভাগি করা predictor মানে এক প্রোগ্রামের আচরণ অন্য প্রোগ্রামের অনুমান বদলাতে পারে।
২০১৮ সালের জানুয়ারিতে প্রকাশিত Spectre (Google Project Zero-র Jann Horn আর Paul Kocher-সহ কয়েকটি স্বাধীন দলের কাজ) এই দুটি তথ্য জুড়ে দেখায়: একটি bounds check-এর (index সীমার ভেতরে কি না, সেই পরীক্ষার) শাখাকে বারবার “ভেতরে” প্রশিক্ষণ দিলে, একবার সীমার বাইরের index দিলেও CPU অনুমানে ভেতরের কোড চালায়, আর সেই পথে পড়া ডেটা cache-এ চিহ্ন রেখে যায়। আরেকটি রূপে BTB-কে প্রভাবিত করে অন্য প্রোগ্রামের indirect শাখাকে ভুল গন্তব্যে অনুমান করানো যায়। মূল কথা — তথ্য ফাঁস হয় পাশের পথে (side channel), সময় মেপে; কোনো নিয়ম ভাঙা instruction চলে না।
প্রতিরক্ষা কয়েক স্তরে, আর প্রতিটির দাম গতিতে। Compiler সংবেদনশীল bounds check-এর পরে speculation-বাধা (x86-এ lfence) বা index-কে mask করে দেয়। Indirect শাখার জন্য retpoline, যা অনুমানকে একটি নিরীহ লুপে আটকে রাখে — প্রতিটি indirect call তাতে ধীর হয়। Operating system context switch-এ predictor-এর অবস্থা মুছে দেয় বা আলাদা রাখে (Intel-এর IBPB, IBRS-এর মতো নিয়ন্ত্রণ), যাতে প্রতিবার শেখা নতুন করে শুরু হয়। নতুন chip-এ hardware নিজেই বিভিন্ন privilege স্তরের predictor-অবস্থা আলাদা করার চেষ্টা করে। Cryptography-র কোড শুরু থেকেই গোপন ডেটায় শাখা না রাখার (constant-time) নিয়ম মানে।
দাম শেষ পর্যন্ত সবাই দেয়: কিছু workload-এ, বিশেষ করে ঘন system call-ওয়ালা কাজে, প্রতিরক্ষার কারণে লক্ষণীয় ধীরতার কথা জানা যায়, আর chip-নকশায় জায়গা ও জটিলতা বাড়ে। বিস্তারিত, শুধু প্রতিরক্ষার দিক থেকে, পর্ব ৪১-এ।
মূল উপলব্ধি: অনুমান গতি দেয় এই শর্তে যে ভুলের কোনো চিহ্ন থাকবে না — কিন্তু microarchitecture-এ চিহ্ন থাকে। তাই আধুনিক নকশায় “দ্রুত” আর “নিরাপদ” একসাথে ভাবতে হয়; predictor-এর অবস্থা কে দেখতে আর বদলাতে পারে, সেটিও এখন একটি নিরাপত্তার প্রশ্ন।
অনুশীলন
পরীক্ষার ধাঁচে লেখা — আগে খাতায় সমাধান করুন, তারপর উত্তর মিলিয়ে নিন।
-
ভুলের সংখ্যা হাতে গোনা: একটি ভেতরের লুপ ৫ বার ঘোরে (শর্ত নিচে), আর পুরো লুপটি ৪ বার চলে — শাখার ফল (T T T T N) × 4, মোট ২০টি। (ক) ১-বিট predictor (শুরু N) আর (খ) ২-বিট saturating counter (শুরু 00)-এ কতগুলো ভুল, নির্ভুলতা কত? (গ) T N T N … (২০টি) ধরনের শাখায় ২-বিট counter 00 থেকে আর 01 থেকে শুরু হলে কতগুলো ভুল?
- (ক) ১-বিট: প্রতিটি প্রবেশে প্রথম T (আগের N মনে আছে) আর শেষের N — ২টি ভুল। প্রথম প্রবেশেও শুরু N, তাই একই। মোট 2 × 4 = ৮টি ভুল, নির্ভুলতা ১২/২০ = ৬০%.
- (খ) ২-বিট, প্রথম প্রবেশ: T (00 → 01) ভুল, T (01 → 10) ভুল, T ঠিক (→ 11), T ঠিক, N ভুল (→ 10) — ৩টি। পরের তিন প্রবেশে প্রথম T ঠিক (10 → 11), শুধু N ভুল — ১টি করে। মোট 3 + 3 = ৬টি ভুল, ৭০%.
- (গ) 00 থেকে: T ভুল (00 → 01), N ঠিক (01 → 00) — প্রতি জোড়ায় ১টি: ১০টি ভুল, ৫০%. 01 থেকে: T ভুল (01 → 10), N ভুল (10 → 01) — ২০টিই ভুল, ০%. শুরুর state বদলালে উত্তর বদলায় — তাই পরীক্ষায় শুরুর state লিখে নিন। সব সংখ্যা একটি স্ক্রিপ্টে চালিয়ে মেলানো।
-
CPI আর সময়: একটি প্রোগ্রামে 109 টি instruction, যার ১৫% শাখা, আর শাখার ২/৩ ভাগ taken. CPU 1 GHz, শাখা ছাড়া CPI = 1. তিনটি নকশা: (ক) predict not-taken, শাখা EX-এ (penalty 2); (খ) predict not-taken, শাখা ID-তে (penalty 1), কিন্তু ৩০% শাখায় তুলনার register ঠিক আগের ALU instruction লেখে বলে ১ cycle stall; (গ) (খ)-এর সাথে BTB-সহ dynamic predictor, নির্ভুলতা ৯২% (ভুলে penalty 1). প্রতিটির CPI আর সময়, আর (ক)-র তুলনায় (গ)-এর speedup কত?
- (ক) CPI = 1 + 0.15 × (2/3) × 2 = 1 + 0.2 = 1.200; সময় = 109 × 1.2 × 1 ns = ১.২ s.
- (খ) প্রতি শাখায় গড় বাড়তি = (2/3) × 1 + 0.3 × 1 = 0.9667; CPI = 1 + 0.15 × 0.9667 = 1.145; সময় ১.১৪৫ s.
- (গ) প্রতি শাখায় বাড়তি = 0.08 × 1 + 0.3 × 1 = 0.38; CPI = 1 + 0.15 × 0.38 = 1.057; সময় ১.০৫৭ s.
- Speedup (ক)/(গ) = 1.200 / 1.057 ≈ ১.১৩৫. লক্ষ করুন, (গ)-তে বাকি খরচের বেশিরভাগ (0.3 বনাম 0.08) এখন data hazard-এর — predictor ভালো হলে পরের বাধা অন্য জায়গায় সরে যায়।
-
Static predictor-এর নির্ভুলতা: একটি প্রোগ্রামের profile: পেছনমুখী লুপ-শাখা L চলে ১০০০ বার, ৯০% taken; সামনের শাখা A ৫০০ বার, ৩০% taken; সামনের শাখা B ৫০০ বার, ৮০% taken. (ক) always not-taken, (খ) always taken, (গ) BTFN-এর নির্ভুলতা কত? (ঘ) Compiler B-এর শর্ত উল্টে কোড এমনভাবে সাজাল যে B-এর বেশি ঘটা পথ fall-through হলো। এখন BTFN-এর নির্ভুলতা?
- মোট ২০০০টি শাখা। (ক) not-taken ঠিক হয় যখন শাখা না নেওয়া হয়: 100 + 350 + 100 = 550 → ২৭.৫%.
- (খ) taken: 900 + 150 + 400 = 1450 → ৭২.৫%.
- (গ) BTFN: L-এ taken অনুমান (900 ঠিক), A আর B-এ not-taken (350 + 100): 1350 → ৬৭.৫% — always taken-এর চেয়েও কম, B-এর কারণে।
- (ঘ) শর্ত উল্টানোয় B এখন ২০% taken, সামনের দিকেই: BTFN = 900 + 350 + 400 = 1650 → ৮২.৫%. Static নিয়ম কোডের সাজানোর ওপর নির্ভর করে — এজন্য compiler-এর hint আর PGO-র তথ্য কাজে লাগে।
-
Circuit লিখুন: ২-বিট saturating counter-এর state S1S0, input t (1 = taken). (ক) পরের state N1, N0-এর Boolean সূত্র বের করুন (A′ মানে A-এর complement). (খ) অনুমানের output কী? (গ) Predictor-এর একটি ঘরের synthesizable Verilog লিখুন — reset-এ 01,
updateসংকেত এলে হালনাগাদ।- (ক) State table থেকে: t = 1-এ 00→01, 01→10, 10→11, 11→11; t = 0-এ 00→00, 01→00, 10→01, 11→10. N1 = 1 হয় যখন (t = 1 আর S1 + S0) অথবা (t = 0 আর S1S0) — সরল করে N1 = S1S0 + tS1 + tS0, অর্থাৎ তিন ইনপুটের majority function! N0 = tS1 + tS0′ + S1S0′. দুটি সূত্রই আটটি সারিতে স্ক্রিপ্ট দিয়ে যাচাই করা।
- (খ) অনুমান = S1 (উঁচু বিট)। কোনো gate লাগে না।
// উদাহরণ — Verilog (synthesizable): one 2-bit predictor entry module sat_counter2 ( input wire clk, input wire rst, // synchronous reset to 01 (weakly not-taken) input wire update, // 1 when the branch has resolved input wire taken, // actual outcome output wire predict_taken ); reg [1:0] s; assign predict_taken = s[1]; always @(posedge clk) begin if (rst) s <= 2'b01; else if (update) begin if (taken && s != 2'b11) s <= s + 2'b01; // count up, stop at 11 else if (!taken && s != 2'b00) s <= s - 2'b01; // count down, stop at 00 end end endmodule
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমBTB আর predictor-ওয়ালা pipeline-এ একটি শাখার ভুল অনুমানের ঘটনাগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
- IF-এ শাখার PC দিয়ে BTB আর counter-এর টেবিল দেখা
- অনুমান অনুযায়ী পরের PC বসানো আর সেই পথে instruction আনা
- শাখাটি নিষ্পত্তির ধাপে পৌঁছে দুটি register তুলনা করা
- আসল ফল অনুমানের সাথে না মেলা — ভুল ধরা পড়া
- ভুল পথের instruction-গুলোর pipeline register শূন্য করে flush
- সঠিক ঠিকানা থেকে fetch, আর counter ও BTB হালনাগাদ
একটি pipeline-এ instruction-এর ২০% শাখা, ভুল অনুমানের হার ১০%, আর প্রতিটি ভুলে penalty ৩ cycle. অন্য কোনো stall না থাকলে CPI কত?
- 1.02
- 1.06
- 1.60
- 1.10
১০ বার ঘোরা একটি লুপের নিচের শাখা (৯ বার T, তারপর N) লুপটি বহুবার চলার সময় ১-বিট predictor প্রতি প্রবেশে গড়ে কতবার ভুল অনুমান করে?
- ০ বার
- ১ বার
- ২ বার
- ৯ বার
একটি ২-বিট saturating counter এখন 11 (strongly taken) অবস্থায়। শাখাটি একবার not-taken হলো। নতুন state আর পরের অনুমান কী?
- 10, অনুমান taken
- 00, অনুমান not-taken
- 01, অনুমান not-taken
- 11, অনুমান taken
BTFN static নিয়মে একটি শাখার offset ঋণাত্মক (পেছনের ঠিকানায় লাফ)। অনুমান কী, আর কেন এটি সাধারণত ভালো কাজ করে?
- Not-taken, কারণ পেছনে লাফ বিরল
- শেষবারের ফল, কারণ BTFN ইতিহাস রাখে
- এলোমেলো, কারণ দিক জানা নেই
- Taken, কারণ পেছনমুখী শাখা সাধারণত লুপের শাখা
১৬ ঘরের একটি gshare predictor-এ শাখার PC[5:2] = 1010 আর ৪-বিটের global history register = 0110. কোন ঘরের counter ব্যবহার হবে?
- ঘর ১০ (1010)
- ঘর ১২ (1100)
- ঘর ২ (0010)
- ঘর ১৪ (1110)
একটি function অনেক জায়গা থেকে ডাকা হয়। তার ret instruction-এর গন্তব্য সবচেয়ে ভালো অনুমান করে কোন কাঠামো?
- Branch history table (BHT)
- Branch target buffer (BTB)
- Return address stack (RAS)
- Global history register (GHR)
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- Control hazard: শাখার ফল জানার আগেই IF-কে পরের instruction আনতে হয়। Penalty = নিষ্পত্তির ধাপের আগে আনা instruction-এর সংখ্যা — MEM-এ ৩, EX-এ ২, ID-তে ১, গভীর pipeline-এ প্রায় ১০–২০.
- পুরোনো চার সমাধান: stall (সব শাখায় দাম), ID-তে শাখা (penalty ১, কিন্তু নতুন data hazard), predict not-taken আর flush (ভুল instruction মুছে bubble), delay slot (MIPS-এ ছিল, RISC-V-তে নেই)।
- CPI = 1 + f × miss rate × penalty; stall-এ miss rate = 1, not-taken-এ = taken-এর হার। গভীর pipeline-এ নির্ভুলতার প্রতিটি শতাংশ দামি।
- Predictor-এর সিঁড়ি: static (not-taken, taken, BTFN, hint) → ১-বিট (লুপে প্রবেশপ্রতি ২ ভুল) → ২-বিট saturating counter (১ ভুল) → correlating/gshare (PC ⊕ history) → tournament → TAGE, perceptron.
- দিক আর গন্তব্য: BHT দিক বলে (aliasing সম্ভব), BTB IF-এই target বলে, RAS return-এর target. এলোমেলো ডেটার শাখা কেউ অনুমান করতে পারে না — sorted ডেটা বা শাখাহীন কোডই সমাধান।
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৩৮ · Exception আর interrupt — pipeline-এ ব্যতিক্রম সামলানোপরবর্তী পর্বFlush-এর আরেক ব্যবহার, precise exception.
- পর্ব ৩৬ · Pipeline hazard — structural, data hazard, forwarding আর stallভিত্তিStall আর forwarding, CPI-তে stall-এর হিসাব।
- পর্ব ২৯ · শাখা আর লুপ assembly-তে — if, while, for আর switchভিত্তিশাখার instruction, loop rotation আর শাখাহীন কোড।
- পর্ব ৪১ · Speculative execution আর নিরাপত্তা — Spectre, Meltdown আর প্রতিরক্ষাসামনেঅনুমানের নিরাপত্তা-দিক, প্রতিরক্ষাসহ।
- কম্পিউটার আর্কিটেকচার ও ডিজিটাল লজিক · কোর্স হোম৬২টি পর্বলজিক গেট থেকে pipeline আর cache — পুরো রোডম্যাপ।
- C প্রোগ্রামিং · Performance আর cacheপাশের ট্র্যাকদ্রুত C কোড লেখার hardware-ভিত্তিক কৌশল।