এই পর্বে যা শিখবেন
- Data-level parallelism কী, আর Flynn-এর চার শ্রেণিতে SIMD কোথায় বসে
- SIMD register, lane, packed যোগ, saturating হিসাব, vertical বনাম horizontal কাজ আর shuffle — lane ধরে ধরে
- x86 (MMX থেকে AVX-512), ARM (NEON, SVE) আর RISC-V Vector-এর নকশা;
vsetvli, VLEN, SEW, LMUL আর strip-mining লুপ - ক্লাসিক vector machine (Cray-1): vector length register, chaining, stride, gather-scatter আর mask
- Compiler কখন নিজে vectorize করে, কখন পারে না, intrinsics কীভাবে লেখে, আর Amdahl দিয়ে বাস্তব speedup-এর হিসাব
১ · একসাথে অনেক ডেটা — data-level parallelism আর Flynn-এর শ্রেণি
ঈদের আগে একটি মিষ্টির দোকানের কথা ভাবুন। এক হাজার বাক্সে মিষ্টি ভরতে হবে, প্রতিটি বাক্সে একই কাজ — বাক্স খোলা, ছয়টি রসগোল্লা রাখা, ঢাকনা বন্ধ। একজন কর্মচারী একটি একটি করে বাক্স ভরলে সময় লাগবে অনেক। কিন্তু কাউন্টারে যদি আটটি বাক্স পাশাপাশি সাজিয়ে একটি চওড়া চামচ দিয়ে একবারে আটটি বাক্সে মিষ্টি দেওয়া যায়, তাহলে একই হাতের নড়াচড়ায় আট গুণ কাজ। শর্ত একটিই — প্রতিটি বাক্সে একই কাজ, আর এক বাক্সের কাজ অন্য বাক্সের ওপর নির্ভর করে না।
কম্পিউটারে এমন কাজ প্রচুর: একটি ছবির ২০ লক্ষ pixel-এর উজ্জ্বলতা বাড়ানো, একটি গানের প্রতিটি sample-এর volume কমানো, দুটি লম্বা array যোগ করা, AI model-এর লাখ লাখ গুণ-যোগ। প্রতিটি উপাদানে একই হিসাব, আর উপাদানগুলো একে অপরের থেকে স্বাধীন। এই সুযোগকে বলে data-level parallelism (DLP). আগের পর্বগুলোতে আমরা অন্য ধরনের সমান্তরালতা দেখেছি — pipeline-এ এক instruction-এর বিভিন্ন ধাপ একসাথে (পর্ব ৩৫), আর পর্ব ৩৯-এ প্রতি cycle-এ কয়েকটি স্বাধীন instruction একসাথে (instruction-level parallelism). DLP-র কৌশল ভিন্ন: instruction একটিই, কিন্তু সে অনেকগুলো ডেটায় কাজ করে।
Flynn-এর চার শ্রেণি
১৯৬৬ সালে কম্পিউটার বিজ্ঞানী Michael Flynn কম্পিউটারকে দুটি সরল প্রশ্নে ভাগ করার প্রস্তাব দেন: একসাথে কয়টি instruction-এর ধারা (stream) চলে, আর কয়টি ডেটার ধারা? উত্তর “এক” বা “অনেক” — তাই চারটি ঘর। এই ভাগকে বলে Flynn's taxonomy:
| শ্রেণি | Instruction ধারা | ডেটা ধারা | উদাহরণ |
|---|---|---|---|
| SISD | এক | এক | সাধারণ scalar প্রসেসর — প্রতিটি instruction একটি সংখ্যা নিয়ে কাজ করে (এই কোর্সের RV32I datapath) |
| SIMD | এক | অনেক | x86-এর SSE-AVX, ARM NEON-SVE, RISC-V Vector, Cray-1-এর মতো vector machine; GPU-ও কাছাকাছি (তার নিজস্ব রূপ SIMT, পর্ব ৫৫-এ) |
| MISD | অনেক | এক | বাস্তবে প্রায় নেই; কেউ কেউ একই ডেটা কয়েকটি আলাদা কম্পিউটারে চালিয়ে মেলানো নিরাপত্তা-সিস্টেম বা systolic array-কে উদাহরণ হিসেবে বলেন, তবে এ নিয়ে মতভেদ আছে |
| MIMD | অনেক | অনেক | Multicore CPU, server cluster — প্রতিটি core নিজের প্রোগ্রাম নিজের ডেটায় চালায় |
শ্রেণিগুলো পরিষ্কার মনে হলেও আজকের chip একসাথে কয়েকটি শ্রেণিতে পড়ে: একটি ৮-core ফোনের CPU সামগ্রিকভাবে MIMD, কিন্তু প্রতিটি core-এর ভেতরে SIMD unit আছে, আর প্রতিটি core নিজে SISD-এর মতো instruction চালায়। Flynn-এর ভাগ তাই একটি চিন্তার কাঠামো, কঠিন সীমারেখা নয়। পর্ব ৫৪-এ সমান্তরাল আর্কিটেকচার আলোচনায় এই শ্রেণিগুলোতে আবার ফিরব, NUMA আর interconnect-সহ। এই পর্বের কাজ শুধু SIMD ঘরটি — একটি core-এর ভেতরে একটি instruction কীভাবে অনেক ডেটা ছোঁয়।
SIMD-এর লাভ আসে instruction কমিয়ে। একটি scalar লুপ ১০০০টি যোগের জন্য ১০০০ বার fetch, decode, issue করে; ৮ lane-এর SIMD একই কাজ ১২৫টি instruction-এ সারে। লৌহসূত্রের ভাষায় (পর্ব ০৩) — IC কমে, আর প্রতিটি instruction-এর CPI প্রায় একই থাকে। Front-end-এর খরচ (fetch, decode, branch) অনেক ডেটায় ভাগ হয়ে যায়, তাই energy-ও বাঁচে।
২ · SIMD register আর lane — packed data, packed যোগ আর saturating হিসাব
একটি সাধারণ register-এ একটি সংখ্যা থাকে। একটি SIMD বা vector register আসলে একটি চওড়া bit-এর সারি — যেমন x86-এর XMM register 128 bit চওড়া। এই 128 bit-কে কীভাবে “পড়া” হবে, তা ঠিক করে instruction:
| উপাদানের টাইপ | প্রতিটির চওড়া | 128-bit-এ lane | 256-bit-এ | 512-bit-এ |
|---|---|---|---|---|
| 8-bit integer (pixel, ASCII অক্ষর) | 8 | 16 | 32 | 64 |
| 16-bit integer (audio sample) | 16 | 8 | 16 | 32 |
| 32-bit int বা float | 32 | 4 | 8 | 16 |
| 64-bit int বা double | 64 | 2 | 4 | 8 |
সূত্রটি সরল: lane সংখ্যা = register-এর চওড়া / উপাদানের চওড়া। পাশাপাশি গুঁজে রাখা এই মানগুলোকে বলে packed data. Lane-গুলো একে অপরের থেকে আলাদা — lane 0-র যোগে carry হলে তা lane 1-এ যায় না। পর্ব ১১-এর adder মনে করুন: একটি 128-bit adder-এর carry-শৃঙ্খল প্রতি 32 bit পরপর কেটে দিলেই চারটি স্বাধীন 32-bit adder পাওয়া যায়। Hardware-এ packed integer যোগ ঠিক এভাবেই সস্তায় বানানো হয় — একই তার, শুধু lane-এর সীমানায় carry আটকানোর একটি control signal.
এখন একটি packed float যোগ (x86-এ addps — add packed single) lane ধরে ধরে দেখুন। বোঝার সুবিধার জন্য stepper প্রতিটি lane আলাদা ধাপে দেখায়, কিন্তু মনে রাখবেন hardware চারটি lane একই সময়ে হিসাব করে:
| এই ধাপে | |
| lane 0 (bit 0–31) | |
| lane 1 (bit 32–63) | |
| lane 2 (bit 64–95) | |
| lane 3 (bit 96–127) | |
| Scalar হলে লাগত |
- Register সাজানো। xmm0-এর চারটি 32-bit lane-এ 1.5, 2, 3, 4 আর xmm1-এ 10, 20, 30, 40. Lane 0 থাকে সবচেয়ে নিচের bit-গুলোতে (bit 0–31) — little-endian মেমরি থেকে load করলে array-এর প্রথম উপাদান lane 0-তে বসে।
- Lane 0. 1.5 + 10 = 11.5. এটি একটি সম্পূর্ণ IEEE 754 single-precision যোগ — exponent মেলানো, যোগ, normalize, round (পর্ব ২৪)।
- Lane 1. 2 + 20 = 22. এই lane-এর নিজস্ব float adder আছে; lane 0-র কোনো carry বা rounding এখানে আসে না।
- Lane 2. 3 + 30 = 33. প্রতিটি lane-এর কাজ একই — এজন্যই একটি instruction দিয়ে চালানো যায়।
- Lane 3. 4 + 40 = 44. xmm2 এখন [11.5, 22, 33, 44].
- আসলে সব একসাথে। বাস্তবে চারটি lane-এর adder পাশাপাশি বসানো, তাই চারটি যোগ একই cycle-এ হয় — একটি
addps-এর latency প্রায় একটি scalaraddss-এর সমান। Scalar কোডে ৪টি instruction লাগত, এখানে ১টি — এটাই SIMD-এর পুরো লাভের উৎস।
Wrap-around বনাম saturating — ছবির উজ্জ্বলতা বাড়ানো
একটি grayscale ছবির প্রতিটি pixel একটি 8-bit unsigned সংখ্যা: 0 মানে কালো, 255 মানে সাদা। পুরো ছবি উজ্জ্বল করতে প্রতিটি pixel-এ 60 যোগ করি। সাধারণ 8-bit যোগে 200 + 60 = 260, যা 8 bit-এ ধরে না — রাখা হয় 260 − 256 = 4. এটি wrap-around (পর্ব ০৫)। ফল ভয়াবহ: প্রায় সাদা pixel হঠাৎ প্রায় কালো হয়ে যায়, ছবির উজ্জ্বল অংশে কালো দাগ পড়ে।
ছবির জন্য সঠিক আচরণ হলো saturating arithmetic — সীমা ছাড়ালে ফল সর্বোচ্চ মানে (255) আটকে থাকবে। Scalar কোডে এর জন্য প্রতিটি pixel-এ একটি তুলনা আর শর্ত লাগে। SIMD ISA-গুলো তাই আলাদা instruction দেয়: x86-এর paddusb (packed add unsigned saturate bytes), ARM NEON-এর uqadd, RISC-V Vector-এর vsaddu. একটি instruction-এ ১৬টি pixel, কোনো branch ছাড়া।
নিচের simulator একটি 128-bit register-কে JavaScript-এর typed array দিয়ে মডেল করে — একই ১৬ byte-এর ওপর Float32Array বসালে ৪টি lane, Uint8Array বসালে ১৬টি। Packed যোগ-গুণ, wrap বনাম saturate, একটি shuffle আর একটি horizontal যোগফল lane ধরে ধরে ছাপে। Run চাপুন:
// A 128-bit register = 16 bytes; view it as 4 x f32 or 16 x u8 lanes
const reg = () => new ArrayBuffer(16);
const show = (name, view) => console.log(name.padEnd(7) + "[" + Array.from(view).join(", ") + "]");
const a = reg(), b = reg(), c = reg();
const fa = new Float32Array(a), fb = new Float32Array(b), fc = new Float32Array(c);
fa.set([1.5, 2, 3, 4]); fb.set([10, 20, 30, 40]);
for (let i = 0; i < 4; i++) fc[i] = fa[i] + fb[i]; // addps: one op, 4 lanes
show("a", fa); show("b", fb); show("a+b", fc);
for (let i = 0; i < 4; i++) fc[i] = fa[i] * fb[i]; // mulps
show("a*b", fc);
// 16 x u8 pixels, brighten by +60
const p = new Uint8Array(16), wrap = new Uint8Array(16), sat = new Uint8Array(16);
p.set([0, 50, 100, 150, 180, 195, 196, 200, 210, 220, 230, 240, 250, 253, 254, 255]);
for (let i = 0; i < 16; i++) {
wrap[i] = p[i] + 60; // paddb: wraps mod 256
sat[i] = Math.min(p[i] + 60, 255); // paddusb: saturates
}
show("pixel", p); show("wrap", wrap); show("sat", sat);
// shuffle: reverse 4 lanes (like _mm_shuffle_ps(x, x, 0x1B))
const sh = new Float32Array(4), ctl = [3, 2, 1, 0];
for (let i = 0; i < 4; i++) sh[i] = fa[ctl[i]];
show("rev(a)", sh);
// horizontal sum of 8 lanes as a tree: 8 -> 4 -> 2 -> 1
let v = [3, 1, 4, 1, 5, 9, 2, 6], step = 0;
while (v.length > 1) {
const h = v.length / 2, next = [];
for (let i = 0; i < h; i++) next.push(v[i] + v[i + h]); // add upper half onto lower half
v = next; step++;
console.log("tree step " + step + ": [" + v.join(", ") + "]");
}
console.log("sum = " + v[0] + " in " + step + " vector adds (scalar needs 7)");আউটপুট (নির্ধারিত — প্রতিবার একই):
a [1.5, 2, 3, 4]
b [10, 20, 30, 40]
a+b [11.5, 22, 33, 44]
a*b [15, 40, 90, 160]
pixel [0, 50, 100, 150, 180, 195, 196, 200, 210, 220, 230, 240, 250, 253, 254, 255]
wrap [60, 110, 160, 210, 240, 255, 0, 4, 14, 24, 34, 44, 54, 57, 58, 59]
sat [60, 110, 160, 210, 240, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255, 255]
rev(a) [4, 3, 2, 1.5]
tree step 1: [8, 10, 6, 7]
tree step 2: [14, 17]
tree step 3: [31]
sum = 31 in 3 vector adds (scalar needs 7)
a+b আর a*b lane ধরে ধরে হিসাব — stepper-এর [11.5, 22, 33, 44] এখানে মিলে গেছে। wrap সারিটি দেখুন: 195 + 60 = 255 পর্যন্ত ঠিক আছে, কিন্তু 196 + 60 = 256 হয়ে যায় 0, 200 হয় 4, আর একেবারে সাদা 255 হয়ে যায় 59 — প্রায় কালো। sat সারিতে 196 থেকে সব 255-এ আটকে আছে, যা ছবির জন্য সঠিক। Uint8Array-এ মান লিখলে JavaScript নিজেই mod 256 নেয়, তাই এটি সাধারণ paddb-এর হুবহু মডেল; Math.min(…, 255) হলো paddusb-এর মডেল। rev(a) একটি shuffle — একই চারটি মান উল্টো ক্রমে। শেষ চার লাইন একটি horizontal যোগফল: ৮টি সংখ্যার যোগফল 31 মাত্র ৩টি vector যোগে, যেখানে scalar-এ ৭টি যোগ লাগত — পরের অংশে এটাই ধাপে ধাপে দেখব।
৩ · Vertical বনাম horizontal কাজ, shuffle আর reduction tree
এতক্ষণ যা দেখলাম — দুটি register-এর একই নম্বরের lane মিলিয়ে কাজ — তাকে বলে vertical কাজ। কাগজে দুই register ওপর-নিচে লিখলে প্রতিটি হিসাব খাড়া কলাম বরাবর হয়। SIMD hardware এতেই সবচেয়ে দক্ষ, কারণ প্রতিটি lane-এর তার সোজা নিজের adder-এ যায়।
কিন্তু অনেক সময় একই register-এর ভেতরের lane-গুলোকে একে অপরের সাথে মেলাতে হয় — যেমন সব lane যোগ করে একটি যোগফল (dot product-এর শেষ ধাপ), বা সব lane-এর মধ্যে সর্বোচ্চ মান। একে বলে horizontal operation. এর জন্য ডেটাকে lane-এর সীমানা পেরিয়ে সরাতে হয়, আর সেই কাজ করে shuffle (বা permute) instruction — যা register-এর lane-গুলোকে যেকোনো নতুন ক্রমে সাজাতে পারে: উল্টে দেওয়া, জোড়া অদলবদল, একটি মান সব lane-এ ছড়ানো (broadcast). x86-এ shufps, pshufd, pshufb; ARM-এ tbl, zip, uzp; RISC-V-এ vrgather, vslidedown.
গার্মেন্টস কারখানার লম্বা টেবিলে আটজন অপারেটর পাশাপাশি বসে, প্রত্যেকের সামনে নিজের ঝুড়ি। “প্রত্যেকে নিজের ঝুড়ির শার্টে বোতাম লাগান” — এটা vertical কাজ, সবাই একসাথে করতে পারেন। কিন্তু “আটটি ঝুড়ির মোট শার্ট গুনে বলুন” — এটা horizontal কাজ; ঝুড়িগুলোকে কারো না কারো কাছে সরাতেই হবে। সবচেয়ে দ্রুত উপায় হলো জোড়ায় জোড়ায় মেলানো: প্রথম চারজন পাশের চারজনের হিসাব নিজের সাথে যোগ করেন, তারপর দুজন, তারপর একজন।
ঠিক এই জোড়ায় জোড়ায় কৌশলের নাম reduction tree. প্রতিটি ধাপে register-এর ওপরের অর্ধেক shuffle করে নিচের অর্ধেকের ওপর এনে একটি vertical যোগ — ফলে প্রতিটি ধাপে বাকি মানের সংখ্যা অর্ধেক হয়:
| Register-এ বাকি মান | |
| এই ধাপের কাজ | |
| Vector যোগ (এ পর্যন্ত) | |
| Scalar-এ লাগত |
- শুরু। একটি 8-lane register-এ [3, 1, 4, 1, 5, 9, 2, 6]. Scalar কোড বাঁ থেকে ডানে একটি একটি করে যোগ করত — ৭টি যোগ, প্রতিটি আগেরটির ফলের অপেক্ষায়।
- ধাপ ১ — ৮ থেকে ৪। ওপরের চারটি lane (5, 9, 2, 6) shuffle করে নিচে এনে নিচের চারটির সাথে vertical যোগ: 3+5 = 8, 1+9 = 10, 4+2 = 6, 1+6 = 7. ফল [8, 10, 6, 7].
- ধাপ ২ — ৪ থেকে ২। একই কৌশল: 8+6 = 14, 10+7 = 17. ফল [14, 17].
- ধাপ ৩ — ২ থেকে ১। 14 + 17 = 31. Lane 0-তে চূড়ান্ত যোগফল 31, যা 3+1+4+1+5+9+2+6-এর সমান।
- তুলনা। n lane-এর যোগফলে tree-তে log₂n ধাপ (এখানে ৩), scalar-এ n − 1টি যোগ (এখানে ৭)। তবে প্রতিটি ধাপে একটি shuffle-ও লাগে, আর যোগের ক্রম বদলে গেছে — integer-এ ফল একই, কিন্তু float-এ ফল বদলাতে পারে (অংশ ৭)। তাই ভালো কোডে horizontal কাজ লুপের ভেতরে না রেখে একেবারে শেষে একবার করা হয়।
শেষ কথাটি গুরুত্বপূর্ণ। একটি লম্বা array-এর যোগফলে দক্ষ SIMD কোড লুপের ভেতরে শুধু vertical যোগ করে — ৮টি আলাদা “আংশিক যোগফল” lane-এ জমায় — আর লুপ শেষে একবার reduction tree চালায়। Horizontal কাজ প্রতি ধাপে করলে লাভের বেশিরভাগ shuffle-এ খরচ হয়ে যায়।
Float-এর ফাঁদ: Floating point যোগ associative নয় (non-associativity, পর্ব ২৪)। 32-bit float-এ [108, 1, −108, 1]-এর যোগফল বাঁ থেকে ডানে করলে আসে 1 (108 + 1 round হয়ে আবার 108, কারণ সেখানে পাশাপাশি দুটি float-এর ব্যবধান 8), কিন্তু tree-ক্রমে (108 − 108) + (1 + 1) = 2. দুটিই “সঠিকভাবে” round করা, কিন্তু ফল আলাদা। এজন্য compiler নিজে থেকে float-এর যোগফল vectorize করে না।
৪ · x86-এর SIMD — MMX থেকে AVX-512, আর ARM NEON
Desktop প্রসেসরে SIMD ঢুকেছিল মাল্টিমিডিয়ার হাত ধরে — নব্বইয়ের দশকে কম্পিউটারে ভিডিও, গান আর 3D গেম চালানোর চাপ থেকে। প্রায় তিন দশকে x86-এ একের পর এক extension যোগ হয়েছে। বছরগুলো প্রথম chip বাজারে আসার মোটামুটি সময়:
| Extension | মোটামুটি সময় | Register | কী যোগ হলো |
|---|---|---|---|
| MMX | ১৯৯৭ (Pentium MMX) | ৮টি 64-bit MM, x87 register-এর ওপর বসানো | শুধু packed integer; float কোডের সাথে মেশাতে ঝামেলা (emms) |
| SSE | ১৯৯৯ (Pentium III) | ৮টি 128-bit XMM | ৪টি single-precision float একসাথে |
| SSE2 | ২০০০–০১ (Pentium 4) | 128-bit XMM | double আর সব integer টাইপ; AMD-এর x86-64-এ বাধ্যতামূলক, register ১৬টি |
| SSE3, SSSE3, SSE4.1/4.2 | ২০০৪–২০০৮ | 128-bit XMM | horizontal যোগ, byte shuffle (pshufb), dot product, string তুলনা |
| AVX | ২০১১ (Sandy Bridge) | ১৬টি 256-bit YMM | ৮টি float একসাথে; তিন-operand রূপ (c = a + b, a না মুছে) |
| AVX2 (+ FMA) | ২০১৩ (Haswell) | 256-bit YMM | 256-bit integer, gather, FMA |
| AVX-512 | ২০১৬ (Xeon Phi), ২০১৭ (server Xeon) | ৩২টি 512-bit ZMM + ৮টি mask register (k0–k7) | ১৬টি float একসাথে, প্রতি lane-এ mask, scatter; অনেক উপ-অংশ |
লক্ষ করুন, প্রতিটি নতুন চওড়ার জন্য নতুন register নাম, নতুন instruction encoding আর নতুন intrinsic লাগে — একই “দুটি array যোগ” কাজের জন্য x86-এ এখন paddd, vpaddd xmm, vpaddd ymm, vpaddd zmm — চার রূপ। পুরোনো প্রোগ্রাম নতুন চওড়ার সুবিধা পায় না যতক্ষণ না আবার compile বা নতুন করে লেখা হয়। এই ক্রমাগত বাড়তে থাকা ISA-কে অনেকে বলেন “ISA churn” — অংশ ৬-এ দেখব vector ISA কীভাবে এ সমস্যা এড়ায়।
AVX-512 সব CPU-তে নেই। Intel-এর অনেক server chip-এ আছে, কিছু ল্যাপটপ-desktop প্রজন্মে ছিল, আবার কিছু নতুন consumer প্রজন্মে বন্ধ রাখা হয়েছিল; AMD যোগ করেছে Zen 4 (২০২২) থেকে। কিছু পুরোনো Intel server chip-এ ভারী AVX-512 কাজের সময় clock কিছুটা নেমে যেত বলে বহু রিপোর্ট আছে, যা কখনো কখনো আশেপাশের scalar কোডকেও ধীর করত; নতুন প্রজন্মে এই প্রভাব কম বলে জানা যায়। তাই AVX-512 কোড চালানোর আগে প্রোগ্রাম চলার সময় CPU-কে জিজ্ঞেস করতে হয় (runtime dispatch). Intel পরে এসব ভাগাভাগি মেলাতে AVX10 নামে নতুন একটি কাঠামো ঘোষণা করেছে — তার বিস্তারিত এখনো বদলাচ্ছে।
ARM NEON
ARM-এর SIMD-এর নাম NEON (আনুষ্ঠানিক নাম Advanced SIMD). এটি সবসময় 128 bit চওড়া; 64-bit ARM-এ (AArch64) ৩২টি V register (v0–v31). প্রায় প্রতিটি আধুনিক Android ফোন আর Apple-এর ARM-ভিত্তিক Mac-এ NEON আছে — আপনার ফোনে ক্যামেরার ছবি প্রসেস করা বা ভিডিও চালানোর সময় সম্ভবত NEON কাজ করছে। NEON-এর instruction-এর নামে উপাদানের টাইপ লেখা থাকে: add v0.4s, v1.4s, v2.4s মানে চারটি 32-bit (s = single word) lane যোগ, uqadd v0.16b, … মানে ১৬টি byte-এর saturating যোগ।
নিজের হাতে — SSE intrinsics দিয়ে C
C থেকে SIMD instruction ব্যবহারের সবচেয়ে সরাসরি পথ হলো intrinsic — দেখতে function-এর মতো, কিন্তু compiler প্রতিটিকে একটি নির্দিষ্ট instruction-এ বদলায়। x86-এর নামের নিয়ম: _mm = 128-bit (_mm256 = 256, _mm512 = 512), তারপর কাজের নাম, তারপর টাইপ — ps = packed single float, pd = packed double, epi8/epi32 = packed signed integer, epu8 = packed unsigned byte. তাই _mm_add_ps → addps, _mm_adds_epu8 → paddusb.
x86-64-এ SSE2 প্রতিটি CPU-তে থাকতেই হবে, তাই নিচের কোড যেকোনো x86-64 machine-এ চলবে। প্রোগ্রামটি দুটি ৪০৯৬-উপাদানের float array একবার scalar লুপে, একবার SSE দিয়ে (প্রতি ঘোরায় ৪টি) যোগ করে, ফল মিলিয়ে দেখে আর সময়ের অনুপাত ছাপে:
#define _POSIX_C_SOURCE 199309L
#include <stdio.h>
#include <string.h>
#include <time.h>
#include <emmintrin.h> /* SSE/SSE2: baseline on every x86-64 CPU */
#define N 4096
#define REPS 20000
static float a[N], b[N], c1[N], c2[N];
static double now(void) {
struct timespec t; clock_gettime(CLOCK_MONOTONIC, &t);
return (double)t.tv_sec + (double)t.tv_nsec / 1e9;
}
int main(void) {
for (int i = 0; i < N; i++) { a[i] = (float)i * 0.5f; b[i] = (float)(N - i); }
double t0 = now();
for (int r = 0; r < REPS; r++)
for (int i = 0; i < N; i++) c1[i] = a[i] + b[i]; /* 1 float per add */
double t1 = now();
for (int r = 0; r < REPS; r++)
for (int i = 0; i < N; i += 4) { /* 4 floats per add */
__m128 va = _mm_loadu_ps(&a[i]), vb = _mm_loadu_ps(&b[i]);
_mm_storeu_ps(&c2[i], _mm_add_ps(va, vb));
}
double t2 = now();
printf("identical results: %s\n", memcmp(c1, c2, sizeof c1) == 0 ? "yes" : "NO");
printf("c[0]=%.1f c[1]=%.1f c[4095]=%.1f\n", c1[0], c1[1], c1[N - 1]);
printf("scalar loop iterations: %d, SSE loop iterations: %d\n", N, N / 4);
printf("time ratio scalar/SSE: %.1f (varies per run)\n", (t1 - t0) / (t2 - t1));
printf("avx2 supported here: %s\n", __builtin_cpu_supports("avx2") ? "yes" : "no");
return 0;
}একটি run-এর আউটপুট (একটি cloud সার্ভারের x86-64 CPU-তে; সময়ের অনুপাত প্রতি run-এ বদলায়, বাকি লাইন সবসময় একই):
identical results: yes
c[0]=4096.0 c[1]=4095.5 c[4095]=2048.5
scalar loop iterations: 4096, SSE loop iterations: 1024
time ratio scalar/SSE: 1.3 (varies per run)
avx2 supported here: yes
প্রথম তিন লাইন নির্ধারিত: দুই পদ্ধতির ফল byte-ধরে হুবহু এক (memcmp দিয়ে মেলানো) — float যোগ প্রতিটি lane-এ scalar যোগের মতোই IEEE নিয়মে round হয়, তাই কোনো পার্থক্য নেই। SSE লুপ ১০২৪ বার ঘোরে, scalar ৪০৯৬ বার। কিন্তু সময়ের অনুপাত মাত্র প্রায় ১-এর কাছে (আমাদের কয়েকটি run-এ ০.৯ থেকে ১.৩) — চার গুণ নয়! কারণটি সৎভাবে বোঝা জরুরি: এই runner কোনো optimisation flag ছাড়া, অর্থাৎ -O0-এ compile করে। -O0-এ GCC প্রতিটি variable (va, vb, লুপের i) প্রতিবার মেমরির stack-এ লেখে আর আবার পড়ে, আর প্রতিটি intrinsic-এর argument-ও stack ঘুরে যায়। ফলে SSE লুপের প্রতি ঘোরায় বহু বাড়তি load-store জমে, আর IC-এর লাভ প্রায় খেয়ে ফেলে।
ন্যায্য তুলনার জন্য দুটিকেই optimise করতে হয়, কিন্তু সাবধান — -O2 বা -O3-এ নতুন GCC scalar লুপটিকেও নিজেই vectorize করে ফেলে (অংশ ৭), তখন আপনি SSE-কে SSE-এর সাথেই তুলনা করবেন! তাই scalar অংশের জন্য auto-vectorization বন্ধ রাখতে হয়। একই কোডের শুরুতে #pragma GCC optimize("O2,no-tree-vectorize") যোগ করে এই runner-এ একবার চালিয়ে আমরা অনুপাত পেয়েছি প্রায় ৩.২ — তাত্ত্বিক ৪-এর কাছে, পুরো ৪ নয়, কারণ load-store আর লুপের নিজের খরচ আছে। নিজের কম্পিউটারে gcc -O2 -fno-tree-vectorize দিয়ে চালিয়ে দেখুন; সংখ্যা machine ভেদে বদলাবে। শেষ লাইন শুধু বলে এই CPU-তে AVX2 আছে কি না (এখানে আছে) — আমরা ইচ্ছা করে কেবল SSE/SSE2 ব্যবহার করেছি যাতে কোড সব x86-64-এ চলে।
এবার অংশ ২-এর pixel উদাহরণটি আসল hardware-এ। _mm_set1_epi8(60) ১৬টি lane-এ 60 বসায় (broadcast), আর _mm_adds_epu8 একটি instruction-এ ১৬টি saturating যোগ করে। ফলটি scalar “তুলনা করে আটকানো” কোডের সাথে মিলিয়ে দেখা হয়:
#include <stdio.h>
#include <stdint.h>
#include <emmintrin.h> /* SSE2: _mm_adds_epu8 = PADDUSB */
int main(void) {
uint8_t px[16] = {0, 50, 100, 150, 180, 195, 196, 200,
210, 220, 230, 240, 250, 253, 254, 255};
uint8_t scal[16], simd[16], wrap[16];
for (int i = 0; i < 16; i++) { /* scalar: compare and clamp */
unsigned s = (unsigned)px[i] + 60u;
scal[i] = (uint8_t)(s > 255u ? 255u : s);
wrap[i] = (uint8_t)s; /* plain add keeps only 8 bits */
}
__m128i v = _mm_loadu_si128((const __m128i *)px);
__m128i k = _mm_set1_epi8(60); /* 60 in all 16 lanes */
_mm_storeu_si128((__m128i *)simd, _mm_adds_epu8(v, k)); /* 16 lanes, 1 instr */
int same = 1;
for (int i = 0; i < 16; i++) same &= (scal[i] == simd[i]);
printf("in :"); for (int i = 0; i < 16; i++) printf(" %3u", px[i]); printf("\n");
printf("wrap:"); for (int i = 0; i < 16; i++) printf(" %3u", wrap[i]); printf("\n");
printf("sat :"); for (int i = 0; i < 16; i++) printf(" %3u", simd[i]); printf("\n");
printf("scalar clamp == _mm_adds_epu8: %s\n", same ? "yes" : "NO");
return 0;
}আউটপুট (নির্ধারিত — প্রতিবার একই):
in : 0 50 100 150 180 195 196 200 210 220 230 240 250 253 254 255
wrap: 60 110 160 210 240 255 0 4 14 24 34 44 54 57 58 59
sat : 60 110 160 210 240 255 255 255 255 255 255 255 255 255 255 255
scalar clamp == _mm_adds_epu8: yes
JavaScript simulator-এর wrap আর sat সারির সাথে হুবহু মিলেছে — অর্থাৎ simulator-এর মডেল সঠিক ছিল। Scalar কোডে প্রতিটি pixel-এ একটি যোগ, একটি তুলনা আর একটি বাছাই (compiler হয়তো branch-এর বদলে cmov দেয়, পর্ব ২৯) — ১৬টি pixel-এ কয়েক ডজন instruction. SIMD-এ একটি load, একটি paddusb, একটি store — আর কোনো branch নেই, তাই branch misprediction-এর ভয়ও নেই। wrap সারিতে (uint8_t)s cast শুধু নিচের 8 bit রাখে — C-তে unsigned-এ এটি সুনির্দিষ্ট আচরণ, undefined নয়।
৫ · ক্লাসিক vector machine — Cray-1, chaining, stride, gather-scatter আর mask
SIMD-এর ধারণা desktop-এর চেয়ে অনেক পুরোনো। সত্তরের দশকের supercomputer-গুলো ছিল vector processor — যাদের instruction পুরো একটি সংখ্যার সারি (vector) নিয়ে কাজ করত।
Seymour Cray-এর নকশা করা Cray-1 ১৯৭৬ সালে প্রথম বসানো হয় (যুক্তরাষ্ট্রের Los Alamos গবেষণাগারে বলে জানা যায়)। এর clock ছিল প্রায় ৮০ MHz, আর ছিল ৮টি vector register, প্রতিটিতে ৬৪টি 64-bit উপাদান। তার C-আকৃতির গোল গঠন — যাতে তারগুলো যত ছোট সম্ভব হয় — আর নিচে বসার গদির জন্য একে মজা করে “পৃথিবীর সবচেয়ে দামি loveseat” বলা হতো। আবহাওয়ার পূর্বাভাস, পারমাণবিক গবেষণা আর বিমানের নকশায় এর মতো machine বহু বছর রাজত্ব করেছে।
Cray-1-এর মতো vector machine-এর কয়েকটি ধারণা আজকের RISC-V Vector-এ প্রায় হুবহু ফিরে এসেছে, তাই সেগুলো ভালো করে জানা দরকার:
- Vector length register (VL). Register-এ ৬৪টি জায়গা, কিন্তু কাজ হয়তো ৫০টি উপাদানে। প্রোগ্রাম VL-এ ৫০ লিখে দিলে পরের সব vector instruction শুধু প্রথম ৫০টি উপাদানে কাজ করে। ৬৪-এর বেশি উপাদান হলে লুপ ৬৪-র টুকরায় ভাগ হয় — এই কৌশলের নাম strip-mining (খনিতে স্তরে স্তরে খোঁড়ার মতো)। এটাই vector length-এর ধারণা।
- Pipelined functional unit, একটি একটি করে উপাদান। Packed SIMD-এর মতো ৬৪টি adder পাশাপাশি থাকত না; একটি গভীর pipelined adder প্রতি cycle-এ একটি নতুন উপাদান নিত। একটি instruction ৬৪ cycle ধরে চলত, কিন্তু fetch-decode হতো একবার।
- Chaining.
V3 = V1 × V2তারপরV5 = V3 + V4— দ্বিতীয় instruction-কে পুরো V3-এর জন্য ৬৪ cycle অপেক্ষা করতে হয় না; গুণের প্রথম উপাদান বের হতেই যোগ শুরু। এটি পর্ব ৩৬-এর forwarding-এর vector রূপ, এর ফলে গুণ আর যোগ একসাথে চলে, প্রতি cycle-এ দুটি floating point কাজ হতো। - Strided access. Row-major matrix-এর একটি column পড়তে প্রতি উপাদানে একটি পুরো row সমান লাফ দিতে হয়। Vector load-এ একটি stride দিলেই হতো — একটি instruction-এ “প্রতি N-তম উপাদান”।
- Gather / scatter. Sparse matrix-এ উপাদান এলোমেলো জায়গায়। একটি index-vector ধরে মেমরির নানা জায়গা থেকে জোগাড় (gather) বা ছড়িয়ে লেখা (scatter) — পরের প্রজন্মের vector machine-এ আসে, আর আজ AVX2/AVX-512, SVE আর RVV-তে আছে। তবে এটি সাধারণ টানা load-এর চেয়ে অনেক ধীর, কারণ প্রতিটি উপাদান আলাদা cache line-এ থাকতে পারে।
- Vector mask. “যেখানে a[i] > 0, সেখানেই b[i] = a[i] × 2” — প্রতি lane-এ একটি bit বলে দেয় সেই lane-এ ফল লেখা হবে কি না। Cray-1-এ এর জন্য একটি vector mask register ছিল। এটি পর্ব ২৯-এর predication-এর vector রূপ: branch-এর বদলে দুই দিকের হিসাব করে mask দিয়ে বাছাই।
আশি-নব্বইয়ের দশকে সস্তা microprocessor-এর অনেকগুলো একসাথে জুড়ে বানানো MIMD machine (cluster) বিশেষায়িত vector supercomputer-কে বাজার থেকে প্রায় সরিয়ে দেয়। কিন্তু ধারণাগুলো হারায়নি — প্রথমে ছোট আকারে desktop-এর packed SIMD-এ, পরে GPU-তে, আর এখন আবার প্রায় পূর্ণ রূপে ARM SVE আর RISC-V Vector-এ।
৬ · RISC-V Vector — vsetvli, VLEN, SEW, LMUL আর strip-mining
RISC-V-এর vector extension (RVV, ISA-র নামে অক্ষর “V”)-এর version 1.0 ২০২১ সালের শেষ দিকে অনুমোদিত (ratified) হয়। এর মূল সিদ্ধান্ত Cray-ধারার: register কত চওড়া, তা ISA বলে না — chip ঠিক করে। প্রোগ্রাম চলার সময় জেনে নেয়। চারটি নাম মনে রাখলেই পুরো নকশা বোঝা যায়:
| নাম | কে ঠিক করে | অর্থ |
|---|---|---|
| VLEN | Chip-নির্মাতা (hardware) | প্রতিটি vector register-এর bit-চওড়া — যেমন 128, 256, 512; ৩২টি register v0–v31 |
| SEW | প্রোগ্রাম (vsetvli-তে) | প্রতিটি উপাদানের চওড়া: e8, e16, e32, e64 |
| LMUL | প্রোগ্রাম | কয়টি register জুড়ে একটি দল: m1, m2, m4, m8 (আর ভগ্নাংশ mf2, mf4, mf8) |
vl | vsetvli-এর ফল | এই মুহূর্তে কতগুলো উপাদানে কাজ হবে, কখনো VLMAX-এর বেশি নয় |
VLMAX = VLEN × LMUL / SEWযেমন VLEN = 128, SEW = 32, LMUL = 1 → VLMAX = 4 · LMUL = 4 হলে → 16
লুপের প্রতিটি ধাপের শুরুতে vsetvli instruction-কে বলা হয় “আমার আরও AVL (application vector length) সংখ্যক উপাদান বাকি, উপাদান 32-bit” — সে vl ঠিক করে একটি register-এ ফেরত দেয়। প্রচলিত বাস্তবায়নে vl = min(AVL, VLMAX). নিচে C-এর for (i = 0; i < n; i++) c[i] = a[i] + b[i]; (32-bit int)-এর একটি হাতে লেখা RVV লুপ — -O2 -march=rv64gcv-এ GCC বা Clang যে ধরনের লুপ বানায় তার কাছাকাছি:
# উদাহরণ (চালানো হয়নি) — RVV 1.0, vector add: c[i] = a[i] + b[i], 32-bit int
# a0 = n, a1 = &a[0], a2 = &b[0], a3 = &c[0]
vadd:
vsetvli t0, a0, e32, m1, ta, ma # t0 = vl = min(n, VLMAX); SEW=32, LMUL=1
vle32.v v0, (a1) # vl ints from a -> v0
vle32.v v1, (a2) # vl ints from b -> v1
vadd.vv v2, v0, v1 # vl lane-wise adds
vse32.v v2, (a3) # v2 -> vl ints of c
sub a0, a0, t0 # n -= vl
slli t1, t0, 2 # bytes done = vl * 4
add a1, a1, t1 # advance the three pointers
add a2, a2, t1
add a3, a3, t1
bnez a0, vadd # elements left? loop again
ret
প্রতিটি লাইনের অর্থ:
vsetvli t0, a0, e32, m1, ta, ma— বাকি উপাদানa0, উপাদান 32-bit (e32), একটি register-এর দল (m1).ta(tail agnostic) মানেvl-এর পরের lane-গুলোতে কী থাকবে তা নিয়ে প্রোগ্রামের মাথাব্যথা নেই,ma(mask agnostic) mask-বন্ধ lane-এর জন্য একই কথা — এতে hardware-এর কাজ সহজ হয়। ফলvlযায়t0-তে।vle32.v— unit-stride vector load: মেমরি থেকে পরপরvlটি 32-bit উপাদান। (Strided loadvlse32.vআর gathervluxei32.v-ও আছে — Cray-এর সেই ধারণাগুলো।)vadd.vv— vector-vector যোগ, শুধু প্রথমvlটি উপাদানে। (.vxরূপে একটি scalar register সব lane-এ যোগ হয়,.vi-তে একটি ছোট immediate.)vse32.v— vector store, ঠিকvlটি উপাদান; তার পরের মেমরি ছোঁয়া হয় না।sub,slli, তিনটিadd,bnez— সাধারণ RV64I scalar instruction (পর্ব ২৬): বাকি সংখ্যা কমানো, pointer এগোনো (vl × ৪ byte), আর বাকি থাকলে আবার লুপ।
একই কাজের scalar লুপে প্রতি উপাদানে ৯টি instruction লাগে (দুটি lw, add, sw, তিনটি pointer-এর addi, counter-এর addi, bnez). Saxpy (y[i] += a × x[i])-এর float রূপেও কাঠামো একই — শুধু vadd.vv-এর জায়গায় vfmacc.vf v1, fa0, v0 (প্রতি lane-এ v1 += fa0 × v0).
এবার n = ১৩ আর VLEN = 128 (VLMAX = ৪) ধরে লুপটি ধাপে ধাপে চালাই। শেষ ধাপে vl কীভাবে বদলায়, খেয়াল করুন:
| ধাপ | |
| a0 (AVL, বাকি) | |
| t0 = vl (vsetvli) | |
| যে উপাদান হলো | |
| Pointer-এর অগ্রগতি (byte) | |
| bnez a0 | |
| চলা instruction (মোট) |
- শুরু। n = ১৩টি উপাদান, a0 = 13. এই chip-এ VLEN = 128 bit, SEW = 32, LMUL = 1, তাই VLMAX = 128 × 1 / 32 = ৪। প্রোগ্রাম কিন্তু “৪” সংখ্যাটি কোথাও লেখেনি — সে কেবল
vsetvli-কে জিজ্ঞেস করবে। - ঘোরা ১।
vsetvliদেয় vl = min(13, 4) = 4. c[0..3] হিসাব হয়। a0 = 13 − 4 = 9, pointer ৪ × ৪ = ১৬ byte এগোয়। 9 ≠ 0, তাই আবার লুপ। এ পর্যন্ত ১১টি instruction. - ঘোরা ২। vl = min(9, 4) = 4; c[4..7]. a0 = 5, pointer 32 byte-এ। মোট ২২টি instruction.
- ঘোরা ৩। vl = min(5, 4) = 4; c[8..11]. a0 = 1, pointer 48 byte-এ। মোট ৩৩টি।
- ঘোরা ৪ — tail. মাত্র ১টি উপাদান বাকি: vl = min(1, 4) = 1. একই instruction-গুলো এবার শুধু lane 0-তে কাজ করে, c[12] লেখা হয়, c[13]-এর মেমরি ছোঁয়াও হয় না। আলাদা কোনো “শেষের scalar লুপ” লাগেনি — tail handling vl নিজেই সেরে দিল। a0 = 0, pointer 52 byte (১৩ × ৪)।
- লুপ শেষ। a0 = 0, তাই
bnezপড়ে যায় আরret. মোট ৪ ঘোরা × ১১ = ৪৪টি instruction, যেখানে scalar লুপে ১৩ × ৯ = ১১৭টি লাগত। আর n = 0 হলে? প্রথমvsetvli-ই vl = 0 দেয়, কিছু load-store হয় না, a0 = 0 থাকে, লুপ বের হয়ে যায় — আলাদা শর্তের দরকার নেই।
নিচের model কয়েকটি n আর VLEN/SEW/LMUL-এর জন্য প্রতিটি vsetvli-এর vl, ঘোরার সংখ্যা আর মোট instruction (প্রতি ঘোরায় ১১টি) বনাম scalar (প্রতি উপাদানে ৯টি) হিসাব করে। প্রথম দুটি লাইনে একই binary দুটি ভিন্ন chip-এ চলছে — খেয়াল করুন:
// RVV strip-mining model of c[i] = a[i] + b[i] (32-bit ints)
// VLMAX = VLEN * LMUL / SEW; vsetvli returns vl = min(AVL, VLMAX) (common choice)
function stripMine(n, VLEN, SEW, LMUL) {
const VLMAX = VLEN * LMUL / SEW;
console.log(`n=${n} VLEN=${VLEN} SEW=${SEW} LMUL=${LMUL} -> VLMAX=${VLMAX}`);
let avl = n, iter = 0, done = 0;
const total = Math.ceil(n / VLMAX);
while (avl > 0) {
const vl = Math.min(avl, VLMAX); // what vsetvli writes to t0
iter++;
if (total <= 5 || iter <= 2 || iter === total)
console.log(` iter ${iter}: avl=${avl} -> vl=${vl}, elements ${done}..${done + vl - 1}`);
else if (iter === 3) console.log(" ...");
done += vl; avl -= vl;
}
const vec = iter * 11; // vsetvli, 2 vle32, vadd, vse32, sub, slli, 3 add, bnez
const scalar = n * 9; // 2 lw, add, sw, 3 addi (pointers), addi (count), bnez
console.log(` ${iter} iterations: ~${vec} instr vs ~${scalar} scalar instr` +
` (${(scalar / vec).toFixed(1)}x fewer)`);
}
stripMine(13, 128, 32, 1); // same binary...
stripMine(13, 256, 32, 1); // ...on a CPU with wider registers
stripMine(1000, 128, 32, 1);
stripMine(1000, 128, 32, 4); // LMUL=4: a group of 4 registers
stripMine(1000, 512, 32, 8);আউটপুট (নির্ধারিত — প্রতিবার একই):
n=13 VLEN=128 SEW=32 LMUL=1 -> VLMAX=4
iter 1: avl=13 -> vl=4, elements 0..3
iter 2: avl=9 -> vl=4, elements 4..7
iter 3: avl=5 -> vl=4, elements 8..11
iter 4: avl=1 -> vl=1, elements 12..12
4 iterations: ~44 instr vs ~117 scalar instr (2.7x fewer)
n=13 VLEN=256 SEW=32 LMUL=1 -> VLMAX=8
iter 1: avl=13 -> vl=8, elements 0..7
iter 2: avl=5 -> vl=5, elements 8..12
2 iterations: ~22 instr vs ~117 scalar instr (5.3x fewer)
n=1000 VLEN=128 SEW=32 LMUL=1 -> VLMAX=4
iter 1: avl=1000 -> vl=4, elements 0..3
iter 2: avl=996 -> vl=4, elements 4..7
...
iter 250: avl=4 -> vl=4, elements 996..999
250 iterations: ~2750 instr vs ~9000 scalar instr (3.3x fewer)
n=1000 VLEN=128 SEW=32 LMUL=4 -> VLMAX=16
iter 1: avl=1000 -> vl=16, elements 0..15
iter 2: avl=984 -> vl=16, elements 16..31
...
iter 63: avl=8 -> vl=8, elements 992..999
63 iterations: ~693 instr vs ~9000 scalar instr (13.0x fewer)
n=1000 VLEN=512 SEW=32 LMUL=8 -> VLMAX=128
iter 1: avl=1000 -> vl=128, elements 0..127
iter 2: avl=872 -> vl=128, elements 128..255
...
iter 8: avl=104 -> vl=104, elements 896..999
8 iterations: ~88 instr vs ~9000 scalar instr (102.3x fewer)
প্রথম ব্লক ঠিক উপরের stepper: vl = 4, 4, 4, 1 — ৪ ঘোরা, ৪৪ বনাম ১১৭টি instruction. দ্বিতীয় ব্লকে কোনো কোড বদলায়নি, শুধু chip-এর VLEN 256: VLMAX = 8, তাই vl = 8, তারপর 5 — দুই ঘোরায় শেষ, instruction প্রায় অর্ধেক। Packed SIMD-এ এই লাভ পেতে প্রোগ্রাম আবার লিখতে হতো (SSE থেকে AVX). তৃতীয় ব্লকে n = 1000, VLMAX = 4: ২৫০ ঘোরা, scalar-এর চেয়ে ৩.৩ গুণ কম instruction — ৪ গুণ নয়, কারণ প্রতি ঘোরার ১১টির মধ্যে ৭টি হিসাব রাখার খরচ (vsetvli, sub, slli, তিনটি add আর bnez মিলিয়ে)। LMUL = 4-এ (চারটি register জুড়ে একটি দল) VLMAX = 16, ৬৩ ঘোরা — শেষটিতে vl = 8; খরচ ১৩ গুণ কম। শেষে VLEN = 512, LMUL = 8: VLMAX = 128, মাত্র ৮ ঘোরা, শেষে vl = 104. মনে রাখবেন, এটা instruction গোনা — আসল সময় নির্ভর করে hardware প্রতি cycle-এ কতগুলো lane প্রসেস করে আর মেমরি কত দ্রুত ডেটা দেয় তার ওপর।
সূক্ষ্ম কথা: RVV স্পেসিফিকেশন vl-এর ব্যাপারে hardware-কে কিছুটা স্বাধীনতা দেয়: AVL ≤ VLMAX হলে vl = AVL, আর AVL ≥ 2 × VLMAX হলে vl = VLMAX; কিন্তু মাঝামাঝি হলে (যেমন AVL = 6, VLMAX = 4) hardware চাইলে কাজ সমান ভাগ করতে পারে — vl = 3, তারপর 3 — যতক্ষণ vl অন্তত ⌈AVL/2⌉. উপরের model প্রচলিত পছন্দ min(AVL, VLMAX) ধরেছে। সঠিক কোড তাই সবসময় vsetvli-এর ফেরত দেওয়া vl ব্যবহার করে, নিজে হিসাব করে না।
Packed SIMD বনাম vector-length agnostic নকশা
উপরের কোডকে বলে vector-length agnostic (VLA) — সে জানে না register কত চওড়া। ARM-ও একই পথে গেছে: SVE (২০১৬ সালে ঘোষিত) আর Armv9-এর SVE2-তে register-এর চওড়া chip ভেদে 128 থেকে 2048 bit (128-এর গুণিতকে) হতে পারে। SVE tail সামলায় predicate register দিয়ে: whilelo p0.s, x_i, x_n instruction প্রতিটি lane-এর জন্য “i + lane < n কি না” হিসাব করে একটি mask বানায়, আর সব load-store-যোগ সেই mask মেনে চলে। Fujitsu-র A64FX chip (জাপানের Fugaku supercomputer, যেটি ২০২০ সালে বিশ্বের দ্রুততম তালিকার শীর্ষে ছিল) 512-bit SVE ব্যবহার করে বলে জানা যায়।
Packed SIMD (SSE, AVX, NEON)
চওড়া ISA-তে খোদাই করা। সরল hardware, অনুমানযোগ্য পারফরম্যান্স। কিন্তু প্রতিটি নতুন চওড়ায় নতুন instruction (ISA churn), পুরোনো binary নতুন hardware-এর সুবিধা পায় না, আর tail-এর জন্য আলাদা scalar লুপ বা mask লাগে।
“আমি জানি register ১২৮ bit”Vector-length agnostic (RVV, SVE)
চওড়া hardware-এর হাতে। একই binary ছোট embedded chip থেকে বড় server-এ চলে, বড় chip-এ আপনাআপনি দ্রুত। Tail সামলানো vl বা predicate দিয়ে। দাম: compiler আর hardware দুটোই জটিল, আর register-এর চওড়া জানা না থাকায় কিছু কৌশল (নির্দিষ্ট shuffle) লেখা কঠিন।
“কত চওড়া? hardware জানাবে” — vsetvli৭ · Compiler আর SIMD — auto-vectorization, intrinsics আর portable পথ
বেশিরভাগ প্রোগ্রামার কখনো addps নিজে লেখেন না। আধুনিক compiler একটি সাধারণ C লুপকে নিজেই SIMD instruction-এ বদলাতে পারে — একে বলে auto-vectorization. GCC-তে এটি -O3-এ পুরোপুরি চালু, আর GCC 12 থেকে -O2-তেও একটি সাবধানী রূপে; Clang -O2-তেই করে। কিন্তু compiler-কে প্রমাণ করতে হয় যে রূপান্তরটি প্রোগ্রামের অর্থ বদলাবে না। যেসব শর্ত না মিললে compiler পিছিয়ে যায়:
- Trip count আগে থেকে গোনা যায়।
for (i = 0; i < n; i++)ভালো — লুপ শুরুর সময়েই জানা কতবার ঘুরবে।while (*p != 0)বা ভেতরেbreakথাকলে কঠিন (নতুন কিছু GCC সংস্করণ এমন কিছু “early-exit” লুপও vectorize করতে শিখছে, তবে সব ক্ষেত্রে নয়)। - Aliasing নেই।
void add(float *x, float *y, float *z, int n)-এ compiler জানে নাzআরxএকই মেমরিতে ওভারল্যাপ করে কি না (memory aliasing). যদিz = x + 1হয়, তাহলে এক ঘোরার লেখা পরের ঘোরার পড়াকে বদলায় — একসাথে চারটি পড়া ভুল ফল দেবে। Compiler তখন হয় চলার সময় ওভারল্যাপ যাচাইয়ের একটি পরীক্ষা বসায় (দুটি সংস্করণ, “loop versioning”), নয়তো vectorize-ই করে না। প্রোগ্রামার C99-এরrestrictদিয়ে প্রতিশ্রুতি দিতে পারেন:float *restrict z— “এই pointer-এর মেমরি আর কেউ ছোঁবে না”। প্রতিশ্রুতি মিথ্যা হলে আচরণ undefined, তাই সাবধানে। - লুপের এক ঘোরা থেকে পরের ঘোরায় নির্ভরতা নেই।
a[i] = a[i-1] + b[i](prefix sum)-এ প্রতিটি উপাদান আগেরটির ফলের অপেক্ষায় — এটি loop-carried dependence, সরাসরি vectorize হয় না (বিশেষ শিফট-যোগ কৌশল লাগে)।a[i] = a[i+4] + 1-এর মতো দূরের নির্ভরতা হলে ৪টি lane পর্যন্ত নিরাপদ — compiler দূরত্ব হিসাব করে। - Float-এর reduction-এ ক্রম বদলানোর অনুমতি।
s += x[i]integer-এ সহজে vectorize হয়। Float-এ হয় না, কারণ অংশ ৩-এর মতো ক্রম বদলালে ফল বদলাতে পারে, আর C মান কঠোর ক্রম চায়।-ffast-math(বা শুধু-fassociative-math) দিলে compiler ক্রম বদলাতে পারে। কিন্তু-ffast-mathআরও অনেক কিছু ধরে নেয় — যেমন NaN আর infinity কখনো আসবে না — ফলেisnan()-এর পরীক্ষা পর্যন্ত মুছে যেতে পারে। নিরাপদ বিকল্প: শুধু ওই লুপে#pragma omp simd reduction(+:s)(OpenMP), যা স্পষ্টভাবে বলে এই যোগফলের ক্রম বদলানো চলবে। - Alignment আর tail. পুরোনো SSE-তে aligned load (
movaps) unaligned-এর চেয়ে অনেক দ্রুত ছিল; আধুনিক x86-এ পার্থক্য সাধারণত সামান্য, বিশেষত cache line-এর সীমানা না পেরোলে। Compiler শুরুতে কয়েকটি উপাদান আলাদা করে (peeling) বাকিটা aligned করে, আর শেষে বাকি থাকা উপাদানের জন্য একটি scalar “epilogue” লুপ বা (AVX-512, SVE, RVV-তে) mask বসায়। - লুপের ভেতরে function call বা জটিল শাখা নেই। সরল
ifহলে compiler দুই দিক হিসাব করে mask দিয়ে মেলাতে পারে (if-conversion); কিন্তু অজানা function call বা গভীর শাখা থাকলে সাধারণত হাল ছেড়ে দেয়।
Compiler কী করল তা অনুমান না করে জিজ্ঞেস করুন: GCC-তে -fopt-info-vec-optimized (সফল) আর -fopt-info-vec-missed (কেন পারেনি), Clang-এ -Rpass=loop-vectorize আর -Rpass-missed=loop-vectorize. Compiler Explorer-এ assembly দেখুন — addps/vaddps-এর মতো packed (ps) instruction বা ymm/zmm register দেখলে vectorize হয়েছে; শুধু xmm দেখে নয়, কারণ scalar float-এর addss-ও xmm ব্যবহার করে (পর্ব ৩১)। পরিচিত একটি নমুনা — GCC -O3-এ x86-64-এর জন্য float array যোগের মূল লুপ মোটামুটি এমন দেখায়:
# উদাহরণ — gcc -O3 -masm=intel (x86-64, SSE2, Intel syntax), c[i] = a[i] + b[i] এর ভেতরের লুপ; সংস্করণভেদে কিছুটা বদলায়
.L3:
movups xmm0, XMMWORD PTR [rsi+rax] # 4 floats of b
movups xmm1, XMMWORD PTR [rdi+rax] # 4 floats of a
addps xmm0, xmm1 # 4 adds
movups XMMWORD PTR [rdx+rax], xmm0 # 4 floats to c
add rax, 16
cmp rax, rcx
jne .L3
Intrinsics বনাম portable পথ
Compiler না পারলে তিনটি পথ খোলা থাকে:
- Intrinsics (
_mm_add_ps, NEON-এরvaddq_f32, RVV-এর__riscv_vadd_vv_i32m1) — পূর্ণ নিয়ন্ত্রণ, কিন্তু কোড এক ISA-তে বাঁধা; x86, ARM আর RISC-V-এর জন্য তিনবার লিখতে হয়, আর AVX-512 থাকলে-না-থাকলে চলার সময় বেছে নেওয়ার (runtime dispatch) কোডও লাগে। - Portable SIMD library বা ভাষার সুবিধা — GCC/Clang-এর vector extension (
typedef float v4sf __attribute__((vector_size(16)));), OpenMP-এর#pragma omp simd, C++-এরstd::experimental::simd(C++26-এstd::simdহিসেবে মানে আসার কথা), Google-এর Highway library বা Intel-এর ISPC. একবার লিখে বহু ISA-তে compile. - তৈরি library — BLAS, FFmpeg, OpenCV, libjpeg-turbo, glibc-এর
memchr/strlen: বিশেষজ্ঞরা প্রতিটি ISA-র জন্য হাতে টিউন করে রেখেছেন। বাস্তবে সবচেয়ে বড় লাভ প্রায়ই এখান থেকেই আসে।
৮ · কখন SIMD কাজে লাগে, কখন লাগে না — আর Amdahl-এর ছাদ
যেখানে SIMD ঝলমল করে
- ছবি আর ভিডিও: প্রতিটি pixel-এ একই হিসাব — উজ্জ্বলতা, রং বদল, ছোট-বড় করা, video codec-এর block হিসাব। ভিডিও decoder-এ হাতে লেখা SIMD assembly খুব প্রচলিত।
- অডিও: sample-এর ওপর filter, volume, mixing — 16-bit বা float lane.
- AI আর বৈজ্ঞানিক হিসাব: matrix গুণ আর dot product মূলত গুণ-যোগ (FMA). ছোট টাইপ (int8, bfloat16) ব্যবহার করলে একই register-এ আরও বেশি lane, তাই আধুনিক CPU-তে AI-এর জন্য বিশেষ dot-product instruction যোগ হয়েছে।
- Cryptography আর checksum: ChaCha20-এর মতো cipher আর অনেক hash/CRC হিসাব SIMD-এ দ্রুত; x86-এর AES-NI instruction-ও XMM register ব্যবহার করে।
- String-এর কাজ:
memchr(একটি byte খোঁজা) — ১৬টি byte একসাথে তুলনা (pcmpeqb), ফলকে একটি 16-bit bitmask-এ আনা (pmovmskb), আর mask শূন্য না হলে প্রথম 1-bit-এর অবস্থান (__builtin_ctz) — একবারে ১৬টি অক্ষর পরীক্ষা, কোনো প্রতি-byte branch ছাড়া। simdjson-এর মতো library এই কৌশলে JSON parsing-কে খুব দ্রুত করেছে বলে পরিচিত।
# উদাহরণ (চালানো হয়নি) — SSE2 দিয়ে memchr-এর মূল ধারণা, 16 byte একসাথে
__m128i key = _mm_set1_epi8((char)c); // c in all 16 lanes
__m128i blk = _mm_loadu_si128((const __m128i *)p); // 16 bytes of text
int mask = _mm_movemask_epi8(_mm_cmpeq_epi8(blk, key)); // bit k = 1 if p[k] == c
if (mask != 0) return p + __builtin_ctz(mask); // first match
// else p += 16 and repeat; the last < 16 bytes need care (tail)
যেখানে SIMD প্রায় অকেজো
- Branch-ভরা কোড: প্রতিটি উপাদানে ভিন্ন পথ (parser-এর state machine, compiler নিজে, ব্যবসায়িক নিয়ম)। Mask দিয়ে দুই দিক হিসাব করা যায়, কিন্তু শাখা অনেক হলে বেশিরভাগ lane ফেলে দেওয়া কাজ করে।
- Pointer chasing: linked list বা tree ধরে হাঁটা — পরের ঠিকানা আগের load-এর ফলের ওপর নির্ভর, তাই একসাথে আটটি পড়ার উপায় নেই।
- ছোট n: ৩টি উপাদানের জন্য setup আর tail-এর খরচ লাভকে ছাড়িয়ে যায়।
- মেমরি-সীমাবদ্ধ (memory-bound) লুপ: দুটি বিশাল array যোগে প্রতি উপাদানে হিসাব মাত্র একটি, কিন্তু মেমরি থেকে ১২ byte আনা-নেওয়া। Cache-এ না ধরলে DRAM-এর bandwidth-ই সীমা; SIMD instruction কমায়, কিন্তু ডেটা আনার গতি বাড়ায় না। Cache-বান্ধব লুপের ক্রম আর blocking পর্ব ৪৮-এ।
Amdahl-এর ছাদ
পর্ব ০৩-এর Amdahl's law এখানে হুবহু খাটে। প্রোগ্রামের সময়ের f অংশ vectorize হলো, আর সেই অংশ s গুণ দ্রুত হলো:
Speedup = 1 / ((1 − f) + f / s), সীমা 1 / (1 − f)ধরা যাক একটি ছবি-প্রসেসিং প্রোগ্রামের ৮০% সময় pixel লুপে (f = ০.৮)। ৪ lane-এর SSE সেই অংশকে আদর্শভাবে ৪ গুণ দ্রুত করলে মোট speedup = ১ / (০.২ + ০.৮/৪) = ১ / ০.৪ = ২.৫. ১৬ lane-এর AVX-512-এ ১ / (০.২ + ০.০৫) = ৪.০ — lane চার গুণ বেড়েছে, speedup মাত্র ১.৬ গুণ। অসীম lane-এও ছাদ ১ / ০.২ = ৫. আর বাস্তবে s কখনো পুরো lane-সংখ্যা হয় না — load-store, shuffle, tail আর মেমরির গতি মিলে সাধারণত কম। তাই বড় লাভের জন্য vectorize করা অংশ বাড়ানোই (f) আসল কাজ, শুধু চওড়া register নয়।
JavaScript আর ওয়েবে SIMD
এই পাতার simulator-গুলো কেন JavaScript-এ “lane মডেল” করে, আসল SIMD চালায় না? কারণ JavaScript-এ সরাসরি SIMD-এর কোনো API নেই। একসময় SIMD.js নামে একটি প্রস্তাব অনেক দূর এগিয়েছিল, কিন্তু ২০১৭-১৮ নাগাদ সেটি সরিয়ে নিয়ে মনোযোগ দেওয়া হয় WebAssembly SIMD-এর দিকে — একটি নির্দিষ্ট 128-bit v128 টাইপ, যা Chrome আর Firefox-এ ২০২১ সালে আর Safari-তে তার কিছু পরে চালু হয়। Browser চলার সময় সেটিকে CPU-র SSE বা NEON instruction-এ বদলায়। সাধারণ JavaScript-এর লুপ JIT compiler সাধারণত vectorize করে না বলেই জানা যায়, তাই আমাদের typed array-এর লুপ হলো শেখার মডেল, দ্রুততার কৌশল নয়।
আরও গভীরে: Chip-এর ভেতরে চওড়া SIMD-এর দাম বড় — একটি 512-bit register file আর তার execution unit অনেক জায়গা আর power নেয়। তাই কিছু নকশা (যেমন AMD-এর Zen 4, প্রকাশিত বিবরণ অনুযায়ী) 512-bit instruction-কে ভেতরে দুটি 256-bit অর্ধেকে ভাগ করে চালায় — ISA-র সুবিধা পুরো, hardware-এর খরচ কম। Shuffle-এর দামও সমান নয়: AVX2-এর অনেক 256-bit shuffle আসলে দুটি স্বাধীন 128-bit “অর্ধেক”-এর ভেতরে কাজ করে, আর অর্ধেক পেরোনো (lane-crossing) shuffle-এর latency বেশি — হাতে টিউন করা কোডে এটা বড় বিবেচনা। আর RVV-র মতো vector ISA-তে hardware চাইলে Cray-র মতো কম lane দিয়ে কয়েক cycle ধরে একটি লম্বা vector প্রসেস করতে পারে — একই binary, ছোট chip-এ কম খরচে।
পরীক্ষা ও ইন্টারভিউতে — কয়েকটি প্রচলিত ধরনের প্রশ্ন আর এক লাইনের উত্তর:
- Flynn-এর চার শ্রেণি উদাহরণসহ লিখুন। SISD (scalar CPU), SIMD (SSE/AVX, vector machine), MISD (প্রায় নেই, তাত্ত্বিক), MIMD (multicore, cluster).
- 256-bit register-এ কয়টি 16-bit lane? ২৫৬ / ১৬ = ১৬টি।
- Saturating আর wrap-around যোগের পার্থক্য কী, কোথায় লাগে? সীমা ছাড়ালে saturating সর্বোচ্চ/সর্বনিম্নে আটকায়, wrap mod 2n নেয়; ছবি-অডিওতে saturating দরকার।
- Vector machine-এ chaining কী? এক vector instruction-এর ফলের প্রতিটি উপাদান বের হতেই পরের নির্ভরশীল instruction-এ পাঠানো — vector-এর forwarding.
- Compiler কেন float-এর
sum += a[i]নিজে vectorize করে না? যোগের ক্রম বদলাতে হয়, আর float যোগ associative নয় — ফল বদলাতে পারে;-ffast-mathবা OpenMP reduction লাগে।
৯ · AI-এর যুগে — SIMD কোড AI দিয়ে, যাচাই নিজে
“এই লুপটি AVX2 দিয়ে দ্রুত করে দিন” — AI এমন অনুরোধে সাথে সাথে intrinsics-ভরা কোড দেয়। দেখতে বিশ্বাসযোগ্য, কিন্তু SIMD কোডের ভুল সবচেয়ে চুপচাপ ভুল — compile হয়, চলে, শুধু কিছু উপাদানে ভুল ফল দেয়। এই বিষয়ে AI-এর সাধারণ ভুলগুলো:
- Tail ভুলে যাওয়া — n চারের গুণিতক ধরে নেওয়া, ফলে শেষের ১–৩টি উপাদান বাদ পড়ে বা array-এর বাইরে পড়া-লেখা হয়।
- Intrinsic-এর নাম বা argument-এর ক্রম বানানো — বিশেষত
_mm_shuffle_ps-এর control byte,_mm_set_ps(উল্টো ক্রম!) বনাম_mm_setr_ps, signed বনাম unsigned saturating (epi8বনামepu8). - CPU-তে নেই এমন extension ব্যবহার — AVX-512 কোড দিয়ে বলা “সব আধুনিক CPU-তে চলবে”, বা runtime যাচাই ছাড়া AVX2.
- Lane সংখ্যা, VLMAX বা strip-mining-এর ঘোরার সংখ্যা ভুল হিসাব; RVV-র পুরোনো 0.7 খসড়ার syntax (যেমন
vsetvli-এta, maছাড়া, বা পুরোনো load নাম) আর 1.0 মিশিয়ে ফেলা। - Float reduction-এ ক্রম বদলের প্রভাব উপেক্ষা করা, বা অন্ধভাবে
-ffast-mathসুপারিশ করা; আর chip-এর SIMD চওড়া বা বছর বানিয়ে বলা।
AI-কে কোড-লেখক নয়, ব্যাখ্যাকারী আর পরীক্ষক হিসেবে ব্যবহার করুন — প্রতিটি দাবির যাচাইযোগ্য প্রমাণ চেয়ে নিন:
AI-এর দেওয়া SIMD কোড যাচাইয়ের চেকলিস্ট
ভাবনার প্রশ্ন
প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।
প্র ০১Cray-1-এর মতো vector supercomputer একসময় রাজত্ব করেছিল, তারপর প্রায় হারিয়ে গেল। আজ RISC-V আর ARM কেন আবার সেই vector নকশায় ফিরছে?
১৯৭৬ সালে Cray-1 যখন আসে, তখন একটি প্রসেসর বানানো হতো অনেকগুলো আলাদা chip জুড়ে, আর প্রতিটি instruction fetch-decode করা ছিল দামি কাজ। Vector instruction এই খরচ একবারে ৬৪টি উপাদানে ভাগ করত। Pipelined adder আর multiplier প্রতি cycle-এ একটি করে উপাদান নিত, chaining দিয়ে গুণ আর যোগ একসাথে চলত, আর মেমরি এমনভাবে সাজানো ছিল যে টানা উপাদান দ্রুত আসে। আবহাওয়ার মডেল বা তরলের প্রবাহের মতো বিশাল array-এর হিসাবে এর জুড়ি ছিল না। আশির দশকে Cray, NEC, Fujitsu-র vector machine বৈজ্ঞানিক হিসাবের মানদণ্ড হয়ে ওঠে।
নব্বইয়ের দশকে দৃশ্য বদলায়। একটি chip-এর microprocessor (প্রথমে workstation-এর, পরে সাধারণ PC-র) প্রতি বছর দ্রুত হচ্ছিল আর গণ-উৎপাদনে খুব সস্তা ছিল। শত শত বা হাজার হাজার সস্তা প্রসেসর নেটওয়ার্কে জুড়ে MIMD cluster বানানো বিশেষায়িত vector machine-এর চেয়ে প্রতি টাকায় বেশি হিসাব দিত। ধীরে ধীরে supercomputer-এর তালিকা cluster-এ ভরে যায়। তবে vector-এর ধারণা মরেনি — সেটি ছোট হয়ে desktop chip-এ ঢোকে MMX আর SSE হিসেবে, যদিও Cray-র vector length register-এর মতো নমনীয়তা ছাড়া।
Packed SIMD-এর সমস্যা কয়েক দশকে স্পষ্ট হয়: প্রতিবার চওড়া বাড়াতে নতুন instruction, নতুন compiler কাজ আর পুরোনো সফটওয়্যারকে নতুন করে লেখা। RISC-V-এর নকশাকারীরা প্রকাশ্যেই যুক্তি দিয়েছিলেন যে এই পথ টেকসই নয় — একটি পরিচিত প্রবন্ধে packed SIMD-কে “ক্ষতিকর” বলেও আখ্যা দেওয়া হয়েছিল। তাঁদের সমাধান Cray-র পুরোনো ধারণাই: vl register আর chip-নির্ভর চওড়া। ARM-ও SVE-তে একই সিদ্ধান্ত নেয়, আর Fujitsu-র A64FX-এ SVE দিয়ে Fugaku supercomputer বিশ্বের তালিকার শীর্ষে ওঠে — vector যেন supercomputer-এ ঘুরে ফিরে এল।
আজ আরও একটি কারণ আছে — power. Transistor সস্তা, কিন্তু প্রতিটি instruction fetch-decode-schedule-এর energy খরচ বড়; একটি instruction দিয়ে বেশি ডেটা ছুঁলে প্রতি কাজে energy কমে। ফোন থেকে data center — সবখানে এই হিসাবই চালক।
মূল উপলব্ধি: প্রযুক্তি আর অর্থনীতি বদলালে পুরোনো আর্কিটেকচারের ধারণা ফিরে আসে; vector length register-এর মতো একটি সরল বিমূর্ততা ৪০ বছর পরেও ISA churn-এর সমাধান দিচ্ছে।
প্র ০২x86-এ এত SIMD extension কেন (MMX, SSE, SSE2…AVX-512)? এই “ISA churn” সফটওয়্যার নির্মাতাদের কী সমস্যায় ফেলে?
প্রতিটি extension তার সময়ের বাস্তবতা থেকে এসেছে। ১৯৯৭ সালের MMX-কে আলাদা register দেওয়া হয়নি — x87 floating point register-এর ওপর বসানো হয়, যাতে OS-কে context switch-এর সময় নতুন register সংরক্ষণ শেখাতে না হয় (পুরোনো OS তখনো x87 register বাঁচাত)। দাম ছিল: MMX আর float কোড মেশালে প্রতিবার emms দিয়ে অবস্থা মুছতে হতো। SSE (১৯৯৯) নতুন XMM register আনে, কিন্তু শুধু single-precision float; integer আর double আসে SSE2-তে। পরের বছরগুলোতে যোগ হয় horizontal যোগ, byte shuffle, string তুলনা — প্রতিটি নির্দিষ্ট কাজের চাপ থেকে। AVX চওড়া দ্বিগুণ করে নতুন encoding (VEX) আনে, AVX2 integer আর gather দেয়, AVX-512 mask register আর চওড়া আবার দ্বিগুণ।
সফটওয়্যারের দিক থেকে এর অর্থ কয়েকটি কঠিন বাস্তবতা। প্রথমত, একটি binary কোন extension ধরে নিয়ে compile হবে? সবচেয়ে নিচের (SSE2) ধরলে নতুন CPU-র শক্তি অব্যবহৃত, সবচেয়ে ওপরের ধরলে পুরোনো CPU-তে crash. তাই গুরুত্বপূর্ণ library-গুলো একই function-এর কয়েকটি রূপ রাখে আর চলার সময় CPU দেখে বাছে (runtime dispatch) — glibc-এর memcpy এর পরিচিত উদাহরণ। দ্বিতীয়ত, হাতে লেখা intrinsics কোড প্রতিটি চওড়ার জন্য আবার লিখতে হয়, আর tail-handling ও alignment-এর কোড প্রতিবার নতুন। তৃতীয়ত, পরীক্ষা: প্রতিটি রূপ আলাদা hardware-এ যাচাই করতে হয়।
Linux distribution-গুলো এই সমস্যা সামলাতে x86-64-এর “microarchitecture level” (x86-64-v2, v3, v4 — মোটামুটি SSE4.2, AVX2, AVX-512 পর্যন্ত) ধারণা চালু করেছে, যাতে binary-গুলো একটি নির্দিষ্ট স্তর ধরে বানানো যায়। Intel-এর ঘোষিত AVX10-ও এই ভাগাভাগি কমানোর একটি চেষ্টা।
Vector-length agnostic নকশা (RVV, SVE) এর বিপরীত দর্শন: ISA-তে একবারই vector instruction, চওড়া hardware-এর। তবে সেখানেও extension-এর সংখ্যা বাড়ছে (cryptography, bfloat16, dot product) — churn পুরোপুরি যায়নি, শুধু “চওড়া”-র মাত্রাটি সরেছে। আর packed SIMD-এর পক্ষেও যুক্তি আছে: নির্দিষ্ট চওড়া জানা থাকলে কিছু অ্যালগরিদম (নির্দিষ্ট shuffle-ভিত্তিক) লেখা আর অনুমান করা সহজ।
মূল উপলব্ধি: ISA একটি চুক্তি (পর্ব ২৫); চুক্তিতে যত বেশি hardware-এর খুঁটিনাটি খোদাই করা হয়, hardware বদলালে সফটওয়্যারকে তত বেশি দাম দিতে হয়।
প্র ০৩চওড়া SIMD (যেমন AVX-512) কেন সবসময় লাভজনক নয়? Power, clock আর বাস্তব প্রোগ্রামের দিক থেকে ভাবুন।
কাগজে হিসাব সরল: 512-bit register-এ ১৬টি float, তাই 128-bit-এর চেয়ে চার গুণ কাজ প্রতি instruction-এ। কিন্তু chip-এর ভেতরে প্রতিটি bit-এর জন্য তার, register file-এর ঘর আর execution unit লাগে। একসাথে ১৬টি FMA চালানো মানে প্রতি cycle-এ অনেক বেশি transistor-এর switching, অর্থাৎ বেশি dynamic power আর তাপ। Power-এর সীমা (পর্ব ০২-এর power wall) ছাড়াতে না দিতে কিছু Intel server chip-এ — বিশেষত AVX-512-এর প্রথম দিকের প্রজন্মে — ভারী 512-bit কাজের সময় core-এর clock নামিয়ে দেওয়া হতো বলে বহু প্রকাশিত মাপজোখ আছে। সমস্যা হলো, clock নামলে শুধু vector কোড নয়, একই core-এর আশেপাশের scalar কোডও ধীর হয়, আর clock আবার ওঠে কিছু সময় পরে। একটি web server যদি অনুরোধের ছোট একটি অংশে AVX-512 ব্যবহার করে, তাহলে লাভের চেয়ে ক্ষতি বেশি হতে পারে — এমন অভিজ্ঞতার কথা প্রকৌশলীরা লিখেছেন। নতুন প্রজন্মের chip-এ এই প্রভাব অনেক কম বলে জানা যায়, তবে নীতিটি সাধারণ: চওড়া unit power-বাজেট খায়।
দ্বিতীয় সীমা মেমরি। দুটি array যোগের মতো লুপে প্রতি উপাদানে একটি হিসাব আর ১২ byte আনা-নেওয়া; ডেটা cache-এ না থাকলে প্রসেসর মেমরির অপেক্ষায় বসে থাকে, register যত চওড়াই হোক। এই “হিসাব বনাম ডেটা”-র অনুপাত (arithmetic intensity) দেখেই বোঝা যায় SIMD কতটা কাজে লাগবে।
তৃতীয় সীমা Amdahl. প্রোগ্রামের যে অংশ vectorize হয় না — branch-ভরা logic, pointer chasing, I/O — সেটিই ছাদ টানে। ৮০% vectorize হলে অসীম চওড়াতেও সর্বোচ্চ ৫ গুণ। চতুর্থ, প্রাপ্যতা: AVX-512 সব CPU-তে নেই, তাই কোডের দুই রূপ রাখা আর পরীক্ষার খরচ।
এই কারণগুলোতেই কিছু chip-নকশা 512-bit instruction-কে দুটি 256-bit অর্ধেকে চালায় — ISA-র সুবিধা (৩২টি register, mask) থাকে, power-এর ধাক্কা কম। আর ফোনের chip-এ NEON 128-bit-এই থেকেছে বহু বছর, কারণ ব্যাটারিই সেখানে রাজা।
মূল উপলব্ধি: চওড়া register হলো সম্ভাবনা, নিশ্চয়তা নয়; power, মেমরির গতি আর vectorize না হওয়া অংশ মিলে আসল speedup ঠিক হয় — তাই সবসময় নিজের কাজের ওপর মেপে দেখতে হয়।
প্র ০৪Compiler এত বুদ্ধিমান হলেও বাস্তব কোডের অনেক লুপ কেন auto-vectorize হয় না? প্রোগ্রামার কীভাবে সাহায্য করতে পারেন, আর কোন “সাহায্য” বিপজ্জনক?
Compiler-এর প্রথম নিয়ম — প্রোগ্রামের অর্থ বদলানো যাবে না। Vectorize করা মানে কয়েকটি ঘোরার load আগে, store পরে করা; এটা তখনই নিরাপদ যখন প্রমাণ করা যায় ঘোরাগুলো একে অপরের ওপর নির্ভর করে না। C-তে pointer যেকোনো জায়গা দেখাতে পারে, তাই void f(float *a, float *b, int n)-এ compiler জানে না a আর b ওভারল্যাপ করে কি না। কখনো সে চলার সময় পরীক্ষা বসিয়ে দুই রূপ রাখে, কখনো পিছিয়ে যায়। লুপের ভেতরে অজানা function call (যা যেকোনো মেমরি বদলাতে পারে), struct-এর array যেখানে দরকারি field-গুলো মেমরিতে ছড়ানো (array of structs), অজানা trip count, জটিল শাখা — প্রতিটি বাধা।
Float আরেকটি স্তর যোগ করে। যোগফল, গুণফল বা সর্বোচ্চ মানের reduction-এ ক্রম বদলাতেই হয়, আর IEEE 754-এ ক্রম বদলালে ফল বদলাতে পারে। C মান কঠোরভাবে বাঁ থেকে ডানের ক্রম চায়, তাই compiler নিজে থেকে করে না।
প্রোগ্রামার সাহায্য করতে পারেন কয়েকভাবে। restrict দিয়ে বলা যে pointer ওভারল্যাপ করে না। ডেটাকে struct of arrays রূপে সাজানো, যাতে একই field টানা থাকে। লুপের ভেতর থেকে function call সরানো বা inline হতে দেওয়া। Trip count স্পষ্ট রাখা। Compiler-এর রিপোর্ট (-fopt-info-vec-missed) পড়ে ঠিক কোন বাধা, তা দেখা। আর reduction-এ #pragma omp simd reduction(+:s) দিয়ে শুধু ওই জায়গায় ক্রম বদলের অনুমতি দেওয়া।
বিপজ্জনক সাহায্যও আছে। restrict একটি প্রতিশ্রুতি — মিথ্যা হলে (কেউ সত্যিই ওভারল্যাপিং pointer পাঠালে) আচরণ undefined, আর ভুলটি হয়তো শুধু -O3-এ, শুধু কিছু ইনপুটে দেখা দেবে। পুরো প্রোগ্রামে -ffast-math আরও বড় ঝুঁকি: এটি NaN আর infinity নেই ধরে নেয়, ফলে isnan(x)-এর পরীক্ষা compiler মুছে দিতে পারে; subnormal শূন্য করে দিতে পারে; আর হিসাবের ক্রম বদলে বৈজ্ঞানিক ফলের পুনরুৎপাদন নষ্ট করতে পারে। কিছু ক্ষেত্রে একটি library-তে এই flag অন্য অংশের floating point আচরণও বদলেছে বলে প্রকৌশলীরা অভিযোগ করেছেন।
মূল উপলব্ধি: Auto-vectorization প্রমাণের খেলা; compiler-কে সঠিক তথ্য দিলে সে সাহসী হয়, কিন্তু মিথ্যা প্রতিশ্রুতি বা ঢালাও flag দ্রুততার বদলে চুপচাপ ভুল আনে।
প্র ০৫SIMD শুধু সংখ্যার হিসাবের জন্য নয় — string খোঁজা, JSON parsing, এমনকি ওয়েব browser-এও এটি কীভাবে কাজে লাগে? আর ওয়েবে SIMD আনার পথ কেমন ছিল?
প্রথম দেখায় string-এর কাজ SIMD-এর জন্য খারাপ মনে হয় — প্রতিটি অক্ষরে শর্ত আর branch. কিন্তু কৌশলটি হলো শর্তকে তুলনায়, আর তুলনাকে bitmask-এ বদলানো। memchr একটি byte খোঁজে: ১৬ (বা ৩২, ৬৪) byte একসাথে load, সব lane-এ খোঁজার byte broadcast, একটি packed তুলনা, তারপর প্রতিটি lane-এর ফলের এক bit করে একটি পূর্ণসংখ্যায় আনা। সেই সংখ্যা শূন্য হলে এই ব্লকে নেই, শূন্য না হলে “trailing zero count” দিয়ে প্রথম মিলের অবস্থান। প্রতি ১৬ byte-এ মাত্র একটি branch, যা প্রায় সবসময় একই দিকে যায় — branch predictor-এর জন্য সহজ (পর্ব ৩৭)। glibc-এর strlen, memchr, memcmp এভাবেই লেখা।
এই ধারণাকে অনেক দূর নিয়ে গেছে simdjson-এর মতো library: JSON টেক্সটে উদ্ধৃতিচিহ্ন, বন্ধনী, কমা কোথায় আছে তা SIMD তুলনা আর bit কৌশলে একসাথে বহু byte-এ খুঁজে একটি “কাঠামোর মানচিত্র” বানায়, তারপর সেই মানচিত্র ধরে parsing করে। ফলে প্রতি সেকেন্ডে কয়েক GB JSON parse করার কথা প্রকাশিত হয়েছে। UTF-8 যাচাই, base64 রূপান্তর, CSV পড়া — একই কৌশলে দ্রুত হয়েছে।
ওয়েবের গল্প আলাদা। JavaScript-এ সংখ্যা ছিল মূলত একটি double টাইপ, আর SIMD-এর জন্য নির্দিষ্ট টাইপ দরকার। ২০১০-এর দশকের মাঝামাঝি SIMD.js নামে একটি প্রস্তাব কয়েকটি browser-এ পরীক্ষামূলকভাবে এসেছিল, কিন্তু শেষে সেটি মান থেকে সরিয়ে নেওয়া হয় — যুক্তি ছিল, নিম্ন-স্তরের দ্রুত কোডের সঠিক জায়গা WebAssembly. পরে WebAssembly-তে একটি নির্দিষ্ট 128-bit SIMD সেট মান হয়, যা browser x86-এ SSE আর ARM-এ NEON-এ বদলায়। 128-bit বাছা হয়েছিল কারণ সব প্ল্যাটফর্মে এটি নিশ্চিতভাবে আছে — চওড়া AVX-এর সুবিধা তাই ওয়েবে পুরো পাওয়া যায় না, যা প্র ০২-এর churn-সমস্যারই আরেক রূপ। ছবি সম্পাদনা, ভিডিও কল-এর effect বা browser-এ চলা AI model-এ এই SIMD কাজে লাগে।
এই পাতার JavaScript simulator তাই আসল SIMD নয়, typed array দিয়ে lane-এর মডেল — শেখার জন্য ঠিক, দ্রুততার জন্য নয়।
মূল উপলব্ধি: SIMD-এর আসল শক্তি “একসাথে অনেক তুলনা আর তাকে bitmask-এ আনা”; শর্তকে ডেটায় বদলাতে পারলে string আর parsing-ও data-parallel হয়ে যায়।
অনুশীলন
পরীক্ষার ধাঁচের সমস্যা — কাগজ-কলমে আগে নিজে করুন, তারপর পূর্ণ সমাধান মিলিয়ে নিন।
-
Lane-এর হিসাব: (ক) একটি 256-bit register-এ কয়টি 16-bit integer আর কয়টি double ধরে? (খ) AVX-512-এ (512-bit) কয়টি 8-bit lane? (গ) RISC-V Vector-এ VLEN = 256, SEW = 16, LMUL = 2 হলে VLMAX কত? (ঘ) একই chip-এ LMUL = 1/2 (mf2) আর SEW = 32 হলে?
(ক) ২৫৬ / ১৬ = ১৬টি 16-bit integer; ২৫৬ / ৬৪ = ৪টি double.
(খ) ৫১২ / ৮ = ৬৪টি lane.
(গ) VLMAX = VLEN × LMUL / SEW = ২৫৬ × ২ / ১৬ = ৩২. দুটি register জুড়ে একটি দল, প্রতিটিতে ১৬টি 16-bit উপাদান।
(ঘ) ২৫৬ × ০.৫ / ৩২ = ৪. ভগ্নাংশ LMUL একটি register-এর অর্ধেক ব্যবহার করে — চওড়া আর সরু টাইপ মেশানো লুপে উপাদান-সংখ্যা মেলাতে কাজে লাগে: 32-bit থেকে 64-bit-এ চওড়া করার লুপে e32/mf2 আর e64/m1 — দুটিতেই VLMAX = ৪. (SEW যেন LMUL × ELEN-এর বেশি না হয়; ELEN হলো chip-এর সবচেয়ে চওড়া উপাদান, সাধারণত ৬৪ bit — তাই e64-এর সাথে mf2 সমর্থিত হওয়ার নিশ্চয়তা নেই।)
-
Strip-mining হাতে চালানো: অংশ ৬-এর RVV লুপ (প্রতি ঘোরায় ১১টি instruction; scalar রূপে প্রতি উপাদানে ৯টি) n = ১০০টি 32-bit int-এ চলছে। (ক) VLEN = 128, LMUL = 2 হলে কয়টি ঘোরা, শেষ ঘোরার vl কত, মোট কয়টি instruction? (খ) একই binary VLEN = 512-এর chip-এ (LMUL = 2) চললে? (গ) দুই ক্ষেত্রে scalar-এর তুলনায় instruction কত গুণ কম? (vl = min(AVL, VLMAX) ধরুন।)
(ক) VLMAX = ১২৮ × ২ / ৩২ = ৮. ঘোরা = ⌈১০০ / ৮⌉ = ১৩; প্রথম ১২টিতে vl = ৮ (৯৬টি উপাদান), শেষটিতে vl = ১০০ − ৯৬ = ৪. মোট instruction = ১৩ × ১১ = ১৪৩ (ret বাদে)।
(খ) VLMAX = ৫১২ × ২ / ৩২ = ৩২. ঘোরা = ⌈১০০ / ৩২⌉ = ৪: vl = ৩২, ৩২, ৩২, ৪. মোট ৪ × ১১ = ৪৪টি। কোড একটুও বদলায়নি।
(গ) Scalar = ১০০ × ৯ = ৯০০. অনুপাত ৯০০ / ১৪৩ ≈ ৬.৩ আর ৯০০ / ৪৪ ≈ ২০.৫. (অংশ ৬-এর simulator-এ
stripMine(100, 128, 32, 2)আরstripMine(100, 512, 32, 2)চালিয়ে মিলিয়ে নিন।) মনে রাখবেন, instruction কম মানেই সময় সমান অনুপাতে কম নয় — hardware প্রতি cycle-এ কতগুলো lane চালায় আর মেমরি কত দ্রুত, তার ওপর নির্ভর। -
Amdahl দিয়ে vectorization-এর লাভ: একটি audio প্রোগ্রামের ৭০% সময় filter লুপে, যা vectorize করা যায়। (ক) 8-lane SIMD-এ সেই লুপ বাস্তবে ৬ গুণ দ্রুত হলে মোট speedup কত? (খ) 16-lane-এ লুপ ১২ গুণ দ্রুত হলে? (গ) সর্বোচ্চ সম্ভব speedup কত? (ঘ) কেন লুপটি ৮ বা ১৬ গুণ না হয়ে ৬ বা ১২ গুণ হলো — দুটি কারণ বলুন।
(ক) Speedup = ১ / ((১ − ০.৭) + ০.৭ / ৬) = ১ / (০.৩ + ০.১১৬৭) = ১ / ০.৪১৬৭ = ২.৪.
(খ) ১ / (০.৩ + ০.৭ / ১২) = ১ / (০.৩ + ০.০৫৮৩) = ১ / ০.৩৫৮৩ ≈ ২.৭৯. লুপ দ্বিগুণ দ্রুত হলেও মোট লাভ মাত্র ২.৪ থেকে ২.৭৯.
(গ) s → ∞ হলে ১ / ০.৩ ≈ ৩.৩৩. বাকি ৩০%-ই ছাদ।
(ঘ) যেকোনো দুটি: load-store আর pointer-এর হিসাব প্রতিটি ঘোরায় থাকে (instruction পুরো lane-গুণ কমে না); tail-এর উপাদান; filter-এর জন্য shuffle বা horizontal কাজ; ডেটা cache-এ না থাকলে মেমরির bandwidth; চওড়া কাজে কিছু CPU-তে clock কমে যাওয়া।
-
কেন vectorize হবে না — খুঁজুন আর ঠিক করুন: নিচের চারটি লুপ GCC
-O3-এ vectorize হয় না বা হতে বাধা পায় (ধরুনx, y, zহলোfloat *প্যারামিটার,sএকটিfloat,pএকটি linked list-এর pointer). প্রতিটির কারণ বলুন আর সম্ভব হলে সমাধান দিন।
(ক)for (i = 0; i < n; i++) z[i] = x[i] + y[i];
(খ)for (i = 1; i < n; i++) x[i] = x[i-1] + y[i];
(গ)for (i = 0; i < n; i++) s += x[i];
(ঘ)while (p) { s += p->val; p = p->next; }(ক) Aliasing:
zহয়তোxবাy-এর সাথে ওভারল্যাপ করে। GCC সাধারণত চলার সময় ওভারল্যাপ পরীক্ষা বসিয়ে দুই রূপ রাখে (তাই “বাধা পায়” কিন্তু পুরো ব্যর্থ নয়); পরিষ্কার সমাধানfloat *restrict z, const float *restrict x, const float *restrict y— যদি সত্যিই ওভারল্যাপ না হয়।(খ) Loop-carried dependence:
x[i]-এর জন্য ঠিক আগের ঘোরারx[i-1]লাগে (prefix sum). সরাসরি vectorize হয় না। বিশেষ কৌশল আছে (register-এর ভেতরে shift করে log ধাপে prefix যোগ), কিন্তু compiler সাধারণত নিজে করে না; অনেক সময় অ্যালগরিদম বদলানোই পথ।(গ) Float reduction: SIMD-এ যোগের ক্রম বদলাতে হয়, আর float যোগ associative নয় — ফল বদলাতে পারে, তাই কঠোর IEEE নিয়মে compiler করে না। সমাধান: শুধু এই লুপে
#pragma omp simd reduction(+:s)(-fopenmp-simdদিয়ে), অথবা ঝুঁকি বুঝে-fassociative-math/-ffast-math. (Integer হলে সমস্যা নেই।)(ঘ) Pointer chasing আর অজানা trip count: পরের node-এর ঠিকানা আগের load-এর ফল, আর কতবার ঘুরবে আগে জানা নেই। SIMD-এর কোনো সরল সমাধান নেই; দ্রুত চাইলে ডেটাকে array-তে সাজাতে হবে (তখন এটি (গ)-এর মতো লুপ)।
নিজেকে যাচাই করুন
ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।
ক্রমRISC-V Vector-এ একটি strip-mined vector-add লুপের এক ঘোরার ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)
vsetvliদিয়ে বাকি উপাদান থেকে এই ঘোরার vl ঠিক করা- দুটি উৎস array থেকে vl সংখ্যক উপাদান vector register-এ load করা
- Lane ধরে
vadd.vvদিয়ে যোগ করা - ফলের vl সংখ্যক উপাদান মেমরিতে store করা
- বাকি সংখ্যা থেকে vl বাদ দিয়ে pointer-গুলো এগিয়ে নেওয়া
- বাকি শূন্য না হলে লুপের শুরুতে ফেরা
একটি 256-bit SIMD register-এ 16-bit integer রাখলে কয়টি lane পাওয়া যায়?
- 4
- 8
- 16
- 32
দুটি 8-bit unsigned pixel মান 200 আর 100-কে unsigned saturating যোগ (যেমন x86-এর paddusb) করলে ফল কত?
- 44
- 255
- 300
- 127
RISC-V Vector-এ VLEN = 128, SEW = 32, LMUL = 1 আর n = 10. vl = min(AVL, VLMAX) ধরে একটি strip-mined লুপে কয়টি ঘোরা লাগবে, আর vl-গুলো কী হবে?
- ৩টি ঘোরা: vl = 4, 4, 2
- ২টি ঘোরা: vl = 5, 5
- ২টি vector ঘোরা আর ২টি scalar ঘোরা
- ১টি ঘোরা: vl = 10
একটি ৮-core CPU-র প্রতিটি core আলাদা প্রোগ্রাম আলাদা ডেটায় চালায়। Flynn-এর শ্রেণিতে পুরো সিস্টেমটি কোন শ্রেণির?
- SISD
- SIMD
- MISD
- MIMD
GCC কঠোর IEEE নিয়মে (কোনো বিশেষ flag ছাড়া) float s = 0; for (i = 0; i < n; i++) s += x[i]; লুপটি সাধারণত vectorize করে না। প্রধান কারণ কী?
- SIMD-এ float যোগের instruction নেই
- x pointer-এর aliasing
- যোগের ক্রম বদলাতে হয়, আর float যোগ associative নয়
- লুপের trip count জানা যায় না
একটি প্রোগ্রামের 60% সময় এমন লুপে, যা SIMD দিয়ে 4 গুণ দ্রুত হয়; বাকি 40% অপরিবর্তিত। Amdahl-এর সূত্রে মোট speedup প্রায় কত?
- 2.4
- 1.82
- 4
- 2.5
RISC-V Vector বা ARM SVE-র মতো vector-length agnostic নকশার প্রধান সুবিধা কোনটি?
- একই binary ভিন্ন চওড়ার vector register-এর chip-এ চলে আর চওড়া chip-এ আপনাআপনি দ্রুত হয়
- যেকোনো x86 SIMD binary সরাসরি চালানো যায়
- Hardware সবচেয়ে সরল হয়
- Linked list-এর মতো pointer chasing কোডও vectorize হয়
শব্দকোষ
লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:
মনে রাখুন
- DLP আর Flynn: একই কাজ অনেক স্বাধীন ডেটায় = data-level parallelism; SIMD একটি instruction দিয়ে তা ধরে। SISD, SIMD, MISD, MIMD — পর্ব ৫৪-এ আবার।
- Lane = register-এর চওড়া / উপাদানের চওড়া (128-bit = ৪ float = ১৬ byte). Vertical কাজ সস্তা, horizontal কাজ (reduction, shuffle) দামি — log₂n ধাপের tree, লুপের শেষে একবার।
- Saturating বনাম wrap: ছবি-অডিওতে সীমায় আটকানো (
paddusb,uqadd,vsaddu), কোনো branch ছাড়া। - Packed SIMD (MMX → SSE → AVX → AVX-512, NEON) চওড়া খোদাই করে; vector-length agnostic (RVV, SVE)
vsetvli/predicate দিয়ে চওড়া জেনে নেয়:VLMAX = VLEN × LMUL / SEW, strip-mining-এ শেষ টুকরা ছোট vl-এ। - Compiler আর সীমা: aliasing (
restrict), loop-carried নির্ভরতা, float reduction-এর ক্রম, অজানা trip count auto-vectorization আটকায়; মাপুন ঠিক flag দিয়ে, আর Amdahl মনে রাখুন — সীমা1/(1 − f).
আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ
- পর্ব ৪৩ · Hardware multithreading — fine-grained, coarse-grained আর SMTপরবর্তী পর্বএকই core-এ কয়েকটি thread দিয়ে ফাঁকা cycle ভরানো।
- পর্ব ৪১ · Speculative execution আর নিরাপত্তা — Spectre, Meltdown আর প্রতিরক্ষাআগের পর্বঅনুমানে চালানো instruction-এর লুকোনো দাম।
- পর্ব ৩৯ · Instruction-level parallelism — superscalar আর VLIWতুলনাঅনেক instruction একসাথে বনাম এক instruction-এ অনেক ডেটা।
- কম্পিউটার আর্কিটেকচার ও ডিজিটাল লজিক · কোর্স হোম৬২টি পর্বপুরো কোর্সের রোডম্যাপ — logic gate থেকে multicore আর GPU.
- C প্রোগ্রামিং · পারফরম্যান্স আর cacheহাতে-কলমেC কোড মেপে দ্রুত করা — cache আর compiler-এর সাথে কাজ।
- C প্রোগ্রামিং · Bitwise operationপাশের ট্র্যাকBitmask আর bit কৌশল — SIMD-এর তুলনা-ফলকে কাজে লাগানোর ভিত্তি।