সরাসরি পর্বে যান
পর্ব ২৪ · ৬২-এর মধ্যে · মডিউল ৪

Floating point-এর হিসাব — যোগ, গুণ, rounding আর ভুলের ফাঁদ

Floating-Point Arithmetic — Addition, Multiplication, Guard/Round/Sticky Bits, Rounding Modes, Exceptions and Numerical Pitfalls

Taught in Bangla, this lesson works floating-point addition step by step, aligning exponents, adding, normalising, rounding and checking range, in decimal, binary and real float bits. It explains guard, round and sticky bits, the four IEEE 754 rounding modes and ties-to-even, multiplication and FP unit hardware, FMA, the five exception flags, broken associativity, catastrophic cancellation, Kahan and pairwise summation, float comparison, money and mixed precision in AI.

এই পর্বে যা শিখবেন

  • Floating point যোগ-বিয়োগের algorithm দশমিকে আর binary-তে হাতে কষা, আর আসল float-এর বিট ধরে মেলানো
  • Guard, round আর sticky বিট কেন লাগে, আর IEEE 754-এর চারটি rounding mode — প্রমাণ ও উদাহরণসহ
  • Floating point গুণ (exponent যোগ, bias বাদ, significand গুণ, sign XOR), ভাগের সংক্ষেপ, adder-multiplier-এর hardware আর latency
  • পাঁচটি exception flag; associativity ভাঙা, catastrophic cancellation আর যোগফলে ভুল জমা — আর Kahan, pairwise ও FMA দিয়ে প্রতিকার
  • Float তুলনার সঠিক উপায়, টাকার হিসাব, বাস্তবের দুর্ঘটনা আর AI-তে mixed precision কেন

১ · একটু ঝালিয়ে নেওয়া — float কীভাবে রাখা থাকে, আর হিসাব কেন কঠিন

পর্ব ২৩-এ (Floating point — IEEE 754) আপনি দেখেছেন floating point সংখ্যা কীভাবে বিটে রাখা হয়। এখানে শুধু সেটুকু মনে করিয়ে দিই, যা হিসাবের জন্য লাগবে। IEEE 754 মানদণ্ডে একটি normal সংখ্যার মান

x = (−1)s × 1.f × 2e − bias

এখানে s হলো চিহ্নের বিট, e হলো biased exponent (excess-K রূপ, পর্ব ০৫), আর 1.f হলো significand — সামনের ১-টি লুকানো (hidden bit), তাই লেখা থাকে শুধু ভগ্নাংশ f. ৩২ বিটের single precision (float)-এ ১ + ৮ + ২৩ বিট, bias 127, নির্ভুলতা p = ২৪ বিট। ৬৪ বিটের double-এ ১ + ১১ + ৫২ বিট, bias 1023, p = ৫৩ বিট। Subnormal, ∞, NaN, machine epsilon আর ULP-এর সংজ্ঞাও পর্ব ২৩-এ আছে; এখানে আমরা সেগুলো ব্যবহার করব।

ক্যালকুলেটরের পর্দায় ধরুন মাত্র ৪টি অঙ্ক আর একটি সূচক দেখা যায়: 9.999 × 101. এমন একটি যন্ত্রে 99.99-এর সাথে 0.161 যোগ করতে হলে আপনি কী করবেন? খাতায় দুটিকে একই দশমিক বিন্দুর নিচে বসাবেন, অর্থাৎ ছোটটিকে ডানে সরাবেন — আর ৪ ঘরের পর্দায় সরানোর সময় ডানের কিছু অঙ্ক পড়ে যাবে। Floating point hardware ঠিক এই সমস্যার সাথেই লড়ে: প্রতিটি হিসাবের পরে ফলকে আবার সেই ছোট পর্দায় ফেরাতে হয় — আর সেখানেই rounding আর ভুলের জন্ম।

Integer যোগে (পর্ব ২০) একটি adder-ই যথেষ্ট। Floating point যোগে কিন্তু সংখ্যা দুটি আলাদা “স্কেলে” থাকে: একটি হয়তো 210-এর ঘরে, অন্যটি 2−3-এর ঘরে। তাই আগে স্কেল মেলানো, তারপর যোগ, তারপর ফলকে আবার নিয়মের রূপে আনা (normalization), তারপর বাড়তি বিট ফেলে দেওয়ার নিয়ম (rounding). এই পর্বের বেশিরভাগই এই ক্রমের ভেতরের খুঁটিনাটি।

মূল ধারণা

IEEE 754-এর প্রতিশ্রুতি: যোগ, বিয়োগ, গুণ, ভাগ আর বর্গমূল — প্রতিটির ফল হবে আসল (অসীম নির্ভুল) ফলকে বর্তমান rounding mode অনুযায়ী round করলে যা হয়, ঠিক তাই। একে বলে correct rounding. অর্থাৎ প্রতিটি একক operation-এ ভুল সর্বোচ্চ আধা ULP (nearest mode-এ)। সমস্যা আসে অনেক operation পরপর করলে।

২ · যোগ-বিয়োগের algorithm — exponent মেলানো থেকে সীমা যাচাই

Floating point যোগ (আর বিয়োগ, যা আসলে চিহ্ন উল্টে যোগ) পাঁচটি ধাপে হয়। প্রথমে দশমিকে, ৪ অঙ্কের নির্ভুলতায় — Patterson-Hennessy-র বইয়ের পরিচিত উদাহরণ: 9.999 × 101 + 1.610 × 10−1.

  1. Exponent মেলানো (alignment): ছোট exponent-এর সংখ্যাটিকে বড়টির exponent-এ আনা হয়। 1.610 × 10−1 = 0.01610 × 101. দুই ঘর ডানে সরল। পর্দায় ৪ অঙ্ক রাখা গেলে থাকে 0.016 × 101 — শেষের “10” পড়ে গেল।
  2. Significand যোগ: 9.999 + 0.016 = 10.015, অর্থাৎ 10.015 × 101.
  3. Normalise: দশমিক বিন্দুর আগে একটিমাত্র অশূন্য অঙ্ক রাখতে হবে: 1.0015 × 102. Significand এক ঘর ডানে সরল, তাই exponent এক বাড়ল। বিয়োগে উল্টোটাও হয় — ফল ছোট হয়ে গেলে বাঁয়ে সরিয়ে exponent কমাতে হয়।
  4. Round: ৪ অঙ্কে আনতে হবে। 1.0015-এর বাড়তি অঙ্ক “5”, ঠিক মাঝখান; nearest-even নিয়মে 1.001 বিজোড়, তাই ওপরে: 1.002 × 102. (আসল যোগফল 100.151; ৪ অঙ্কে সবচেয়ে কাছের মানও 1.002 × 102 — এবার ভাগ্য ভালো ছিল, কেন সবসময় থাকে না তা অংশ ৩-এ।)
  5. সীমা যাচাই: exponent বেশি বড় হলে overflow (ফল ±∞), বেশি ছোট হলে underflow (subnormal বা ০)। Round-এর পরে আবার normalise লাগতে পারে (যেমন 9.9999 → 10.000), তাই যাচাই শেষে।

একই কাজ binary-তে: 0.5 + (−0.4375)

এবার বইয়ের binary উদাহরণ। 0.5 = 1.0002 × 2−1, আর 0.4375 = 7/16 = 0.01112 = 1.1102 × 2−2. ৪ বিট নির্ভুলতায় (1.xxx):

  • Align: ছোট exponent −2, বড় −1; পার্থক্য ১. তাই −1.110 × 2−2 = −0.111 × 2−1.
  • যোগ: 1.000 − 0.111 = 0.001, অর্থাৎ 0.001 × 2−1.
  • Normalise: সামনে তিনটি শূন্য, তাই significand তিন ঘর বাঁয়ে, exponent তিন কম: 1.000 × 2−4.
  • সীমা আর round: single precision-এ normal exponent −126 থেকে 127; −4 ভেতরে। Significand ঠিক ৪ বিটে ধরে, round-এর দরকার নেই। ফল 1.000 × 2−4 = 1/16 = 0.0625 — আর 0.5 − 0.4375 সত্যিই 0.0625.

এখানে একটি নতুন জিনিস লক্ষ করুন: বিয়োগে সামনের অনেকগুলো বিট বাতিল হয়ে যেতে পারে (এখানে তিনটি), তখন বড় করে বাঁয়ে shift লাগে। কতগুলো শূন্য আছে গোনার জন্য hardware-এ একটি leading-zero counter থাকে। এই “বাতিল হয়ে যাওয়া” বিটের কথা অংশ ৭-এ আবার আসবে — সেটিই catastrophic cancellation-এর মূল।

আসল float-এ: 0.1f + 0.2f, বিট ধরে ধরে

পর্ব ২৩-এ দেখেছেন double-এ 0.1 + 0.2 ≠ 0.3. Float-এ কী হয়? নিচের stepper-এ একটি আসল single-precision যোগ বিট ধরে দেখানো হলো — exponent মেলানো, যোগ, normalise আর round. সবগুলো বিট একটি script দিয়ে যাচাই করা। “পরে” চাপুন:

ধাপ ১দুটি operand-এর field
0.2f = 0x3E4CCCCD  s=0  e=124 (2^-3)
       1.1001 1001 1001 1001 1001 101
0.1f = 0x3DCCCCCD  s=0  e=123 (2^-4)
       1.1001 1001 1001 1001 1001 101
ধাপ ২exponent তুলনা
d = 124 - 123 = 1
smaller number: 0.1f  -> shift it right by 1
result exponent (for now): 2^-3
ধাপ ৩align — ডানে ১ ঘর, বাড়তি G R S
                                     G R S
0.2f : 1.1001 1001 1001 1001 1001 101 0 0 0
0.1f : 0.1100 1100 1100 1100 1100 110 1 0 0
       (the last 1 of 0.1f fell into G)
ধাপ ৪significand যোগ
   1.1001 1001 1001 1001 1001 101 000
 + 0.1100 1100 1100 1100 1100 110 100
 ------------------------------------
  10.0110 0110 0110 0110 0110 011 100   x 2^-3
ধাপ ৫normalise — ডানে ১ ঘর, exponent +১
   1.0011 0011 0011 0011 0011 001 1 1 0   x 2^-2
                                  G R S
ধাপ ৬round to nearest even
GRS = 110  (more than half)  -> add 1 ulp
   1.0011 0011 0011 0011 0011 001
 +                              1
   1.0011 0011 0011 0011 0011 010
ধাপ ৭সীমা যাচাই আর pack
e = -2 + 127 = 125   (1..254: no overflow/underflow)
0 01111101 0011 0011 0011 0011 0011 010
= 0x3E99999A = 0.300000011920929
same bits as 0.3f  -> (0.1f + 0.2f == 0.3f) is true
  1. দুটি সংখ্যা খুলে দেখা। 0.2f-এর biased exponent 124, অর্থাৎ 2−3; 0.1f-এর 123, অর্থাৎ 2−4. দুটির significand হুবহু এক, কারণ 0.2 = 2 × 0.1 — পার্থক্য শুধু exponent-এ।
  2. Exponent-এর পার্থক্য। 124 − 123 = ১. ছোট সংখ্যা 0.1f-কে ১ ঘর ডানে সরাতে হবে, আর ফলের exponent আপাতত বড়টির, 2−3.
  3. Align করা। 0.1f-এর significand ডানে ১ ঘর সরল; সামনে এল ০, আর সবচেয়ে ডানের ১-টি ২৪ বিটের বাইরে গিয়ে পড়ল guard (G) ঘরে। এই বিট ফেলে দিলে ভুল হতো — তাই hardware আরও তিনটি ঘর (G, R, S) রাখে।
  4. যোগ করা। দুটি ২৭ বিটের সংখ্যা (২৪ + GRS) সাধারণ integer adder-এ যোগ হলো। ফল 10.0110… — বিন্দুর আগে দুটি বিট, অর্থাৎ ফল ২-এর সমান বা বড়; normalise করতে হবে।
  5. Normalise. পুরো সংখ্যা ডানে ১ ঘর, exponent এক বেড়ে 2−2. সরানোর সময় ২৩তম ভগ্নাংশ-বিট ১ গেল G-তে, পুরোনো G-র ১ গেল R-এ, আর পুরোনো R আর S-এর OR (০) হলো নতুন S. এখন GRS = 110.
  6. Round. GRS = 110 মানে বাদ পড়া অংশ আধা ULP-এর চেয়ে বেশি (১০০ হলে ঠিক আধা)। তাই শেষ বিটে ১ যোগ: …001 হলো …010. যোগে carry ছড়িয়ে significand ২ হয়ে গেলে আবার normalise লাগত — এখানে লাগেনি।
  7. যাচাই আর pack. Biased exponent 125, সীমার ভেতরে। ফলের বিট 0x3E99999A — হুবহু 0.3f-এর বিট! আসল যোগফল 0.30000000447…; 0.3f থেকে এর দূরত্ব প্রায় 7.45 × 10−9, যা আধা ULP (1.49 × 10−8)-এর কম — অর্থাৎ correct rounding. Double-এ একই যোগ 0.3-এর এক ULP ওপরে পড়ে; float-এ মিলে যাওয়াটা কাকতাল, নিয়ম নয়।

৩ · Guard, round আর sticky — তিনটি বাড়তি বিট কেন লাগে

Align করার সময় ছোট সংখ্যার যে বিটগুলো ডানে সরে significand-এর বাইরে চলে যায়, সেগুলো কি সোজা ফেলে দেওয়া যায়? না। তাতে correct rounding-এর প্রতিশ্রুতি ভাঙে। আবার সবগুলো বিট রাখাও অসম্ভব — exponent-এর পার্থক্য ২০০ হলে ২০০টি বাড়তি বিট লাগত। মজার ব্যাপার হলো, মাত্র তিনটি বাড়তি বিট দিয়েই সব ক্ষেত্রে সঠিক rounding পাওয়া যায়:

Guard (G)

Significand-এর ঠিক পরের প্রথম বিট। বিয়োগের পরে ফল ১ ঘর বাঁয়ে normalise হলে এই বিটটি ফলের শেষ বিট হয়ে যায় — না রাখলে ফলের শেষ বিটই ভুল হতো। এর নাম guard bit.

বাঁয়ে shift-এর পাহারাদার

Round (R)

দ্বিতীয় বাড়তি বিট। বাঁয়ে ১ ঘর normalise হওয়ার পরে এটিই হয়ে যায় নতুন “প্রথম বাদ পড়া বিট” — আধা ULP-এর ওপরে না নিচে, তা বলে। এর নাম round bit.

আধা ULP-এর রেখা

Sticky (S)

R-এর ডানে যত বিট সরে গেছে, তাদের সবার OR. একবার কোনো ১ ঘরটিতে পড়লে S “আটকে” থাকে ১ হয়ে। এর নাম sticky bit. ঠিক মাঝখান (tie) নাকি মাঝখানের সামান্য ওপরে — এটাই আলাদা করে।

কিছু কি হারিয়েছে?

কেন guard লাগে — একটি দশমিক উদাহরণ

৩ অঙ্কের নির্ভুলতায় 2.34 × 102 + 2.56 × 100. Align করলে 2.56 হয় 0.0256 × 102. বাড়তি অঙ্ক না রাখলে থাকে 0.02, যোগফল 2.36 × 102. দুটি বাড়তি অঙ্ক (guard 5, round 6) রাখলে 2.3400 + 0.0256 = 2.3656, round করে 2.37 × 102. আসল যোগফল 236.56; ৩ অঙ্কে সঠিক উত্তর 237, অর্থাৎ বাড়তি অঙ্ক ছাড়া উত্তর ভুল।

কেন sticky লাগে — যেখানে শুধু G আর R ভুল করে

ধরুন একটি খেলনা format: significand 1.ffff (৪ বিট ভগ্নাংশ)। যোগ করুন 1.0000 × 20 + 1.0001 × 2−5. দ্বিতীয়টিকে ৫ ঘর ডানে সরালে পাই 0.0000 1 0 001 — significand-এর পরে G = 1, R = 0, আর তার পরে 001, যার মধ্যে একটি ১ আছে, তাই S = 1.

  • G, R, S তিনটিই থাকলে: যোগফল 1.0000 | 101. বাদ পড়া অংশ 101 > 100, অর্থাৎ আধা ULP-এর বেশি, তাই ওপরে: 1.0001 = 1.0625.
  • শুধু G আর R থাকলে: যোগফল 1.0000 | 10 — দেখতে ঠিক মাঝখান। Ties-to-even-এ 1.0000 জোড়, তাই নিচে: 1.0.

আসল যোগফল 1 + 0.033203125 = 1.033203125. দুই প্রতিবেশী 1.0 আর 1.0625-এর মাঝখান 1.03125; আসল মান মাঝখানের সামান্য ওপরে, তাই সঠিক উত্তর 1.0625. Sticky ছাড়া hardware ভেবেছে tie, অথচ tie ছিল না — ফলে ভুল দিকে round হয়েছে।

তিনটি বিটই যথেষ্ট কেন — যুক্তি

পরীক্ষায় এর যুক্তি চাওয়া হয়। দুটি ক্ষেত্র ভাবুন। (ক) exponent-এর পার্থক্য d ≤ ১: ছোট সংখ্যা সর্বোচ্চ ১ ঘর সরে, তাই G-তেই সব বাড়তি বিট ধরে যায় — হিসাব সম্পূর্ণ নির্ভুল। বিয়োগে অনেক বিট বাতিল হয়ে বড় বাঁ-shift লাগতে পারে, কিন্তু তখন হারানোর মতো কোনো বিটই ছিল না, ফল exact. (খ) d ≥ ২: বড় সংখ্যাটির exponent E হলে সেটি অন্তত 2E, আর ছোটটি 2E−1-এর কম — তাই বিয়োগ করলেও ফল 2E−1-এর বেশি থাকে, অর্থাৎ normalise-এ বাঁয়ে সর্বোচ্চ ১ ঘর shift. সেই এক ঘরে G ফলের ভেতরে ঢোকে, R হয় নতুন rounding বিট, আর বাকি সব বিটের খবর লাগে শুধু “শূন্য কি না” — সেটি S দেয়। যোগে ফল ডানে সর্বোচ্চ ১ ঘর সরে, তখন G হয় rounding বিট আর R ও S মিলে নতুন sticky. দুই ক্ষেত্রেই “আধা ULP-এর চেয়ে কম, সমান, না বেশি” প্রশ্নের উত্তর তিন বিটে পুরো জানা যায়।

নিচের simulator এই খেলনা format-এর (৪ বিট ভগ্নাংশ, exponent −6 … 7) একটি adder — align, যোগ-বিয়োগ, normalise, round আর সীমা যাচাই করে, আর প্রতিটি যোগ দুবার চালায়: G, R, S দিয়ে একবার, sticky ছাড়া একবার। শেষে Math.fround দিয়ে একটি আসল float32 যোগ করে field খুলে দেখায়। Run চাপুন, তারপর cases-এ নিজের সংখ্যা বসিয়ে দেখুন:

// Toy float: sign s, exponent E, significand m = 1.ffff (16..31) plus G R S bits.
const b = (v, n) => v.toString(2).padStart(n, "0");
const show = r => b(r >> 3, 5).replace(/^(.)/, "$1.") + " " + b(r & 7, 3);
const val = t => (t.s ? -1 : 1) * t.m / 16 * 2 ** t.E;
function add(x, y, sticky) {
  if (y.E > x.E || (y.E === x.E && y.m > x.m)) [x, y] = [y, x];   // |x| >= |y|
  const d = x.E - y.E, kept = (y.m << 2) >> d;            // align: keep G, R
  const S = sticky && ((y.m << 2) & ((1 << d) - 1)) ? 1 : 0;   // OR of the rest
  const yy = (kept << 1) | S;
  let E = x.E, r = x.s === y.s ? (x.m << 3) + yy : (x.m << 3) - yy;
  if (r >= 256) { r = (r >> 1) | (r & 1); E++; }          // 1x.xxxx -> 1.xxxxx
  while (r && r < 128) { r <<= 1; E--; }                  // cancellation
  let m = r >> 3, grs = r & 7;
  if (grs > 4 || (grs === 4 && (m & 1))) m++;             // nearest, ties to even
  if (m === 32) { m = 16; E++; }
  const flag = E > 7 ? " OVERFLOW" : E < -6 ? " UNDERFLOW" : "";   // range check
  return { d, r, E, out: val({ s: x.s, m, E }) + flag };
}
const cases = [[{ s: 0, E: -1, m: 0b10000 }, { s: 1, E: -2, m: 0b11100 }],
               [{ s: 0, E: 0, m: 0b10000 }, { s: 0, E: -5, m: 0b10001 }],
               [{ s: 0, E: 0, m: 0b10000 }, { s: 1, E: -3, m: 0b10011 }]];
for (const [x, y] of cases) {
  console.log(`${val(x)} + (${val(y)})   exact = ${val(x) + val(y)}`);
  for (const st of [true, false]) {
    const t = add(x, y, st);
    console.log(`  ${st ? "G,R,S" : "G,R  "}  shift ${t.d}  normalised ${show(t.r)}` +
                ` x2^${t.E}  ->  ${t.out}`);
  }
}
const f32 = v => new Uint32Array(new Float32Array([v]).buffer)[0];   // raw bits
const a = Math.fround(0.1), c = Math.fround(0.2);
for (const v of [a, c, Math.fround(a + c), Math.fround(0.3)]) {   // float32 a + c
  const u = f32(v); console.log(`float32 ${v.toPrecision(9)}: s=${u >>> 31} e=${(u >>> 23) & 255}` +
              ` f=${b(u & 0x7FFFFF, 23)} (0x${u.toString(16)})`);
}

আউটপুট (নির্ধারিত — প্রতিবার একই):

0.5 + (-0.4375)   exact = 0.0625
  G,R,S  shift 1  normalised 1.0000 000 x2^-4  ->  0.0625
  G,R    shift 1  normalised 1.0000 000 x2^-4  ->  0.0625
1 + (0.033203125)   exact = 1.033203125
  G,R,S  shift 5  normalised 1.0000 101 x2^0  ->  1.0625
  G,R    shift 5  normalised 1.0000 100 x2^0  ->  1
1 + (-0.1484375)   exact = 0.8515625
  G,R,S  shift 3  normalised 1.1011 010 x2^-1  ->  0.84375
  G,R    shift 3  normalised 1.1011 100 x2^-1  ->  0.875
float32 0.100000001: s=0 e=123 f=10011001100110011001101 (0x3dcccccd)
float32 0.200000003: s=0 e=124 f=10011001100110011001101 (0x3e4ccccd)
float32 0.300000012: s=0 e=125 f=00110011001100110011010 (0x3e99999a)
float32 0.300000012: s=0 e=125 f=00110011001100110011010 (0x3e99999a)

প্রথম জোড়া বইয়ের 0.5 + (−0.4375): exponent-এর পার্থক্য ১ (shift 1), তাই কোনো বিট হারায়নি — দুই পদ্ধতিতেই নির্ভুল 0.0625, normalise-এ তিন ঘর বাঁয়ে (2−4). দ্বিতীয় জোড়া ওপরের sticky-র উদাহরণ: GRS = 101 দেখে সঠিক 1.0625, আর sticky ছাড়া 100 দেখে ভুল করে 1. তৃতীয়টি বিয়োগ: 1 − 0.1484375 = 0.8515625; প্রতিবেশী 0.84375 আর 0.875, সঠিক উত্তর 0.84375. Sticky ছাড়া বিয়োগ থেকে হারানো ছোট অংশটুকু বাদ পড়ে, তাই ফল দেখায় ঠিক মাঝখান (100) আর 1.1011 বিজোড় বলে ওপরে যায় — 0.875, ভুল। শেষ চার লাইনে আসল float32: stepper-এর মতোই 0.1f + 0.2f-এর বিট 0x3e99999a, যা 0.3f-এর বিটের হুবহু সমান। (আলাদা একটি script-এ হাজারো জোড়ায় যাচাই করা হয়েছে: G, R, S থাকলে খেলনা adder-এর একটি ফলও correct rounding থেকে আলাদা নয়।)

৪ · IEEE-এর চারটি rounding mode — আর ties-to-even কেন

বাদ পড়া বিট দেখে ফল ওপরে যাবে না নিচে — এই নিয়মকে বলে rounding mode. IEEE 754 binary format-এর জন্য চারটি mode দেয় (২০০৮ সালের সংস্করণে পঞ্চম একটি, ties-to-away, যোগ হয়েছে — মূলত decimal format-এর জন্য):

Modeনিয়ম+2.5 → পূর্ণসংখ্যা−2.5 → পূর্ণসংখ্যা+2.7 / −2.7
Nearest, ties to even (ডিফল্ট)সবচেয়ে কাছের মান; ঠিক মাঝখানে হলে যার শেষ বিট (বা অঙ্ক) জোড়2−23 / −3
Toward zero (truncate)বাড়তি অংশ কেটে ফেলা — পরম মান কখনো বাড়ে না2−22 / −2
Toward +∞ (ceiling)সবসময় ওপরের দিকে3−23 / −2
Toward −∞ (floor)সবসময় নিচের দিকে2−32 / −3

শেষ তিনটিকে একসাথে বলে directed rounding. এগুলোর বড় ব্যবহার interval arithmetic-এ: একই হিসাব −∞ আর +∞ দুই দিকে round করে চালালে আসল উত্তর নিশ্চিতভাবে দুটির মাঝখানে থাকে। Float থেকে int-এ রূপান্তরে C ভাষা নিজে truncate করে ((int)2.7 = 2), যা rounding mode-এর ওপর নির্ভর করে না।

Bit-এর ভাষায় চার mode

Hardware-এ কাজটি সরল: বাদ পড়া অংশকে আধা ULP-এর সাথে তুলনা করা, GRS দিয়ে। Nearest-even-এর নিয়ম: GRS < 100 হলে নিচে; > 100 হলে ওপরে; ঠিক 100 হলে শেষ বিট ০ থাকলে নিচে, ১ থাকলে ওপরে। Toward zero-তে সবসময় কেটে ফেলা। Toward +∞-তে ধনাত্মক সংখ্যায় বাদ পড়া অংশ শূন্য না হলেই ওপরে, ঋণাত্মক সংখ্যায় কেটে ফেলা (কারণ sign-magnitude রূপে পরম মান কমালে মান বাড়ে); toward −∞ এর ঠিক উল্টো। নিচের simulator কয়েকটি binary significand-কে ৪ বিট ভগ্নাংশে চার mode-এ round করে, তারপর ties-to-even-এর পক্ষপাতহীনতা হাতে-কলমে দেখায়:

// Round binary significands to K fraction bits under the four IEEE 754 modes.
const K = 4;
const modes = {
  "nearest-even": (q, rem, half, neg) =>
      rem > half || (rem === half && q % 2n === 1n) ? q + 1n : q,
  "toward 0":     (q) => q,                                   // chop
  "toward +inf":  (q, rem, half, neg) => rem > 0n && !neg ? q + 1n : q,
  "toward -inf":  (q, rem, half, neg) => rem > 0n && neg ? q + 1n : q,
};
function roundBin(str, mode) {                 // str like "-1.0101 1"
  const neg = str.startsWith("-"), [ip, fp] = str.replace(/[-+ ]/g, "").split(".");
  const extra = fp.length - K, all = BigInt("0b" + ip + fp);
  const q = all >> BigInt(extra), rem = all - (q << BigInt(extra));
  const half = 1n << BigInt(extra - 1);
  const r = modes[mode](q, rem, half, neg).toString(2).padStart(K + 1, "0");
  return (neg ? "-" : "+") + r.slice(0, -K) + "." + r.slice(-K);
}
const inputs = ["+1.0100 01", "+1.0100 1", "+1.0101 1", "+1.0100 11",
                "-1.0100 11", "-1.0101 1", "+1.1111 1"];
console.log("exact        " + Object.keys(modes).map(m => m.padEnd(13)).join(""));
for (const s of inputs)
  console.log(s.padEnd(13) + Object.keys(modes)
    .map(m => roundBin(s, m).padEnd(13)).join(""));

// Bias check: round 0.5, 1.5, ..., 7.5 to integers and add them up.
const ties = [0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5];
const even = x => { const f = Math.floor(x); return x - f === 0.5 ? (f % 2 ? f + 1 : f) : Math.round(x); };
const sum = a => a.reduce((s, x) => s + x, 0);
console.log("\nexact sum        :", sum(ties));
console.log("half-up rounding :", sum(ties.map(x => Math.floor(x + 0.5))));
console.log("ties-to-even     :", sum(ties.map(even)), ties.map(even).join(" "));

আউটপুট:

exact        nearest-even toward 0     toward +inf  toward -inf  
+1.0100 01   +1.0100      +1.0100      +1.0101      +1.0100      
+1.0100 1    +1.0100      +1.0100      +1.0101      +1.0100      
+1.0101 1    +1.0110      +1.0101      +1.0110      +1.0101      
+1.0100 11   +1.0101      +1.0100      +1.0101      +1.0100      
-1.0100 11   -1.0101      -1.0100      -1.0100      -1.0101      
-1.0101 1    -1.0110      -1.0101      -1.0101      -1.0110      
+1.1111 1    +10.0000     +1.1111      +10.0000     +1.1111      

exact sum        : 32
half-up rounding : 36
ties-to-even     : 32 0 2 2 4 4 6 6 8

সারিগুলো পড়ুন। +1.0100 1 আর +1.0101 1 দুটিই ঠিক মাঝখান (বাদ পড়া অংশ 1 = আধা ULP); nearest-even প্রথমটিকে রাখে 1.0100-এ (শেষ বিট ০, জোড়) আর দ্বিতীয়টিকে নেয় 1.0110-এ (1.0101 বিজোড়)। +1.0100 11 মাঝখানের ওপরে, তাই ওপরে যায়; +1.0100 01 নিচে। ঋণাত্মক সারিতে toward +∞ আর toward −∞ উল্টে গেছে: -1.0100 11 toward +∞-এ −1.0100 (পরম মান ছোট মানে বড় মান), toward −∞-এ −1.0101. শেষ সারি +1.1111 1 ওপরে round হলে carry ছড়িয়ে 10.0000 — significand ২ হয়ে গেছে, তাই আবার normalise করে 1.0000 × 21 করতে হবে আর exponent এক বাড়বে; এটিই অংশ ২-এর ধাপ ৫-এর “round-এর পরে আবার normalise”.

নিচের অংশে 0.5, 1.5, … 7.5 — আটটি tie-কে পূর্ণসংখ্যায় round করে যোগ করা হয়েছে। আসল যোগফল 32. স্কুলের “৫ হলে ওপরে” (half-up) নিয়মে প্রতিটি tie ওপরে যায়, যোগফল 36 — আটবারে +৪ পক্ষপাত। Ties-to-even-এ অর্ধেক ওপরে, অর্ধেক নিচে (0, 2, 2, 4, 4, 6, 6, 8), যোগফল ঠিক 32.

কেন এটি গুরুত্বপূর্ণ: Binary-তে হিসাবের ফল প্রায়ই ঠিক মাঝখানে পড়ে — বিশেষ করে exponent-এর পার্থক্য ছোট হলে, বা ২ দিয়ে ভাগে। লাখো operation-এ প্রতিটি tie ওপরে গেলে গড় ভুল আর শূন্য থাকে না, একদিকে জমতে থাকে। Ties-to-even দীর্ঘমেয়াদে গড় ভুল শূন্যের কাছে রাখে। এজন্য একে “banker's rounding”-ও বলে।

CPU-তে rounding mode একটি control register-এ থাকে: x86-64-এ MXCSR-এর দুটি বিট, ARM-এ FPCR, RISC-V-তে fcsr-এর frm ঘর। RISC-V-র বিশেষত্ব: rounding লাগে এমন প্রায় প্রতিটি floating point instruction-এর ভেতরেই ৩ বিটের একটি rounding-mode ঘর থাকে — চাইলে একটি instruction-এর জন্য আলাদা mode দেওয়া যায় (যেমন fcvt.w.s a0, fa0, rtz), নয়তো dyn মানে fcsr-এর mode. RISC-V-র পঞ্চম mode RMM হলো ties-to-away. C-তে fesetround(FE_UPWARD) দিয়ে mode বদলানো যায় — তবে compiler সাধারণত ধরে নেয় mode কখনো বদলায় না, তাই GCC-তে -frounding-math দরকার হতে পারে।

৫ · গুণ, ভাগ আর FP unit-এর hardware

অবাক করার মতো হলেও floating point গুণ যোগের চেয়ে সহজ — exponent মেলাতে হয় না। নিয়ম পাঁচটি: (১) biased exponent দুটি যোগ করে একবার bias বাদ (কারণ দুটিতেই bias আছে, যোগফলে দুবার); (২) significand দুটি গুণ (unsigned integer গুণ, পর্ব ২১); (৩) normalise — দুটি 1.x গুণ করলে ফল ১ থেকে ৪-এর কম, তাই সর্বোচ্চ ১ ঘর ডানে; (৪) round আর সীমা যাচাই; (৫) চিহ্ন = s1 ⊕ s2 (একই চিহ্ন হলে ধনাত্মক)।

দুটি worked উদাহরণ

দশমিকে (৪ অঙ্ক): 1.110 × 1010 × 9.200 × 10−5. Exponent: 10 + (−5) = 5. Significand: 1.110 × 9.200 = 10.212. Normalise: 1.0212 × 106. Round: 1.021 × 106. চিহ্ন: দুটিই ধনাত্মক, ফল ধনাত্মক।

Binary-তে (বইয়ের উদাহরণ): 0.5 × (−0.4375) = 1.0002 × 2−1 × (−1.1102 × 2−2).

ধাপহিসাবফল
Biased exponent(−1 + 127) + (−2 + 127) − 127 = 126 + 125 − 127124 (অর্থাৎ 2−3)
Significand গুণ1.000 × 1.1101.110000
Normaliseইতিমধ্যে 1.x রূপে1.110000 × 2−3
Round (৪ বিট)বাড়তি বিট সব ০1.110 × 2−3
সীমা−126 ≤ −3 ≤ 127overflow-underflow নেই
চিহ্ন0 ⊕ 1১, অর্থাৎ ঋণাত্মক

ফল −1.1102 × 2−3 = −0.21875, আর 0.5 × (−0.4375) সত্যিই −0.21875. আসল single precision-এ একটি উদাহরণ: 1.5f (0x3FC00000, e = 127) × 2.75f (0x40300000, e = 128). Exponent 127 + 128 − 127 = 128; significand 1.12 × 1.0112 = 10.00012 (1.5 × 1.375 = 2.0625); normalise করে 1.00001 × 21, তাই biased exponent 129. ফল 4.125 = 0x40840000.

পরীক্ষায় সবচেয়ে প্রচলিত ভুল: biased exponent দুটি যোগ করে bias বাদ দিতে ভুলে যাওয়া। 126 + 125 = 251 লিখলে মান দাঁড়ায় 2124, আসল উত্তরের প্রায় 1.7 × 1038 গুণ! Unbiased পথে গেলে (−1 + −2 = −3) শেষে 127 যোগ করতে ভুলবেন না।

ভাগ — সংক্ষেপে

ভাগে উল্টো নিয়ম: exponent বিয়োগ করে bias আবার যোগ (e1 − e2 + bias), significand ভাগ, normalise (ফল ০.৫ থেকে ২-এর কম, তাই সর্বোচ্চ ১ ঘর বাঁয়ে), round, আর চিহ্ন আবার XOR. Significand ভাগের পদ্ধতিগুলো — restoring, non-restoring, SRT, আর Newton–Raphson দিয়ে reciprocal বের করা — আর SRT টেবিলের ভুলে ১৯৯৪ সালের বিখ্যাত Pentium FDIV বাগ পর্ব ২২-এ (ভাগ) পাবেন। ভাগ আর বর্গমূল গুণের চেয়ে অনেক ধীর, আর প্রায়ই পুরোপুরি pipelined নয়।

FP adder-এর hardware

অংশ ২-এর পাঁচ ধাপ hardware-এ একটি পাইপের মতো সাজানো থাকে। নিচের ছবিটি Patterson-Hennessy-র FP adder-এর সরল রূপ:

A = (s, e, 1.f) B = (s, e, 1.f) ছোট ALU: d = e_A − e_B কার exponent বড়, কত ঘর shift Mux + ডানে shifter (d ঘর) ছোট significand সরে; G, R, S তৈরি বড় ALU: significand যোগ / বিয়োগ চিহ্ন দেখে যোগ বা বিয়োগ Leading-zero গোনা + normalise shift ডানে ১ বা বাঁয়ে k ঘর; exponent ± k Rounding hardware (GRS + mode) +1 ulp হলে carry → আবার normalise ফল (s, e, f) + exception flag
FP adder-এর মূল অংশ (সরল রূপ)। ডান দিকের ড্যাশ দেওয়া পথ: round-এর পরে significand ২ হয়ে গেলে আবার normalise.

Multiplier-এর গঠন আরও সরল: exponent-এর জন্য একটি adder (bias বাদসহ), significand-এর জন্য একটি বড় integer multiplier (single-এ ২৪ × ২৪, double-এ ৫৩ × ৫৩ বিট — partial product-এর গাছ, পর্ব ২১), তারপর ১ ঘরের normalise, round আর চিহ্নের একটি XOR গেট।

কত দ্রুত — latency আর pipelining

প্রতিটি ধাপ একটি clock cycle-এ শেষ হয় না বলে আধুনিক CPU এগুলোকে কয়েকটি pipeline ধাপে ভাগ করে। মোটামুটি হিসেবে, সাম্প্রতিক x86-64 আর বড় ARM core-এ FP যোগ, গুণ বা FMA-র latency প্রায় ৩ থেকে ৫ cycle, কিন্তু pipelined বলে প্রতি cycle-এ নতুন একটি (প্রায়ই দুটি unit-এ দুটি) operation শুরু করা যায় — অর্থাৎ throughput প্রতি cycle-এ ১–২টি। ভাগ আর বর্গমূলে latency সাধারণত ১০ থেকে কয়েক দশক cycle. সঠিক সংখ্যা chip-ভেদে বদলায় — নির্মাতার optimization manual দেখুন। Pipelining-এর পুরো গল্প পর্ব ৩৫-এ, আর একটি instruction-এ অনেকগুলো float একসাথে হিসাব (SIMD) পর্ব ৪২-এ।

# উদাহরণ — RISC-V F extension (single precision) instructions
fadd.s   ft2, ft0, ft1          # ft2 = ft0 + ft1, rounding mode from fcsr.frm
fadd.s   ft2, ft0, ft1, rtz     # same add, but round toward zero for this one
fmul.s   ft3, ft0, ft1          # ft3 = ft0 * ft1
fmadd.s  ft4, ft0, ft1, ft2     # ft4 = ft0 * ft1 + ft2 with ONE rounding
frflags  t0                     # read the 5 sticky flags NV DZ OF UF NX

; x86-64 with FMA3 (Intel syntax)
vfmadd231sd xmm0, xmm1, xmm2    ; xmm0 = xmm1 * xmm2 + xmm0, one rounding

Fused multiply-add (FMA) — এক rounding-এ a × b + c

উপরের fmadd.s একটি বিশেষ instruction: fused multiply-add (FMA). সাধারণভাবে a × b + c হিসাব করলে দুবার round হয় — গুণফলে একবার, যোগফলে একবার। FMA পুরো গুণফল (single-এ ৪৮ বিট) না কেটে রেখে c-র সাথে যোগ করে, তারপর একবারই round করে। লাভ দুটি: নির্ভুলতা (একটি rounding কম, আর গুণফলের নিচের বিট হারায় না) আর গতি (দুটি instruction-এর কাজ একটিতে, প্রায় একটির latency-তে)। Dot product, matrix গুণ, বহুপদীর Horner পদ্ধতি — সবই “গুণ করে যোগ” এর লম্বা শিকল, তাই বৈজ্ঞানিক হিসাব আর AI-এর matrix কাজে FMA-ই মূল ইট। IBM-এর POWER1 (১৯৯০) FMA hardware-এ আনে বলে ধরা হয়; IEEE 754-2008 একে মানদণ্ডের অংশ করে; x86-এ FMA3 এসেছে মোটামুটি ২০১২–২০১৩ সালে (AMD Piledriver, Intel Haswell). অংশ ৬-এর C প্রোগ্রামে FMA আর সাধারণ a × b + c-এর পার্থক্য চোখে দেখবেন।

৬ · পাঁচটি exception আর flag — প্রোগ্রাম থামে না, শুধু খাতায় লেখা থাকে

Integer-এ শূন্য দিয়ে ভাগ করলে x86-এ প্রোগ্রাম সাধারণত crash করে। Floating point-এ কিন্তু 1.0 / 0.0 করলে প্রোগ্রাম চলতেই থাকে, ফল আসে +∞. কারণ IEEE 754-এর নকশা: পাঁচ রকম floating point exception-এর প্রতিটির একটি ডিফল্ট ফল আছে, আর ঘটনাটি একটি status flag-এ লিখে রাখা হয়।

Exceptionকখনডিফল্ট ফল
Invalid operationগাণিতিকভাবে অর্থহীন: 0/0, ∞ − ∞, 0 × ∞, sqrt(−1), NaN-এর তুলনা (কিছু ক্ষেত্রে)quiet NaN
Division by zeroসসীম অশূন্য সংখ্যাকে ০ দিয়ে ভাগ (log(0)-ও)±∞ (চিহ্ন XOR নিয়মে)
OverflowRound করা ফল সবচেয়ে বড় সসীম মানের চেয়েও বড়±∞ (nearest mode-এ); সাথে inexact
Underflowফল অতি ছোট (normal সীমার নিচে) এবং inexactsubnormal বা ০
InexactRound করতে হয়েছে — ফল আসল মানের সমান নয়round করা ফল

Flag-গুলো sticky: একবার উঠলে নিজে থেকে নামে না, প্রোগ্রাম মুছে না দেওয়া পর্যন্ত থাকে। ফলে লাখো হিসাবের শেষে একবার দেখে নেওয়া যায় — “এর মধ্যে কোথাও কি NaN জন্মেছিল?” চাইলে trap চালু করা যায়, তখন exception হলেই CPU interrupt দিয়ে প্রোগ্রাম থামায় (debug-এর জন্য কাজের); কিন্তু ডিফল্টে সব trap বন্ধ (masked). C ভাষায় এই flag দেখার মানক পথ <fenv.h>: feclearexcept(FE_ALL_EXCEPT) দিয়ে মোছা, fetestexcept(FE_INVALID) দিয়ে দেখা। Linux-এর glibc-তে এই function-গুলো math library-তে থাকে, তাই নিজের কম্পিউটারে gcc prog.c -lm লাগে।

নিচের প্রোগ্রাম কোনো library ছাড়াই সরাসরি hardware-এর flag register পড়ে — x86-64-এর MXCSR, যার নিচের ছয়টি বিট এই flag (বিট ১, denormal-input, IEEE-র অংশ নয়, তাই বাদ)। তাই এটি শুধু x86-64-এ চলবে; বর্গমূলের জন্য সরাসরি SSE2-র sqrtsd instruction, আর শেষে FMA বনাম a × b + c:

#include <stdio.h>
#include <float.h>
/* x86-64 only: MXCSR status bits IE=1 ZE=4 OE=8 UE=16 PE=32 */
static void show(const char *what, double r) {
    unsigned f = __builtin_ia32_stmxcsr();
    printf("%-11s = %-12g flags:%s%s%s%s%s\n", what, r,
           (f & 1) ? " invalid" : "", (f & 4) ? " div-by-zero" : "",
           (f & 8) ? " overflow" : "", (f & 16) ? " underflow" : "",
           (f & 32) ? " inexact" : "");
    __builtin_ia32_ldmxcsr(f & ~63u);            /* clear the sticky flags */
}
static double hw_sqrt(double x) {               /* the SSE2 sqrtsd instruction */
    double r; __asm__("sqrtsd %1, %0" : "=x"(r) : "x"(x)); return r;
}
__attribute__((target("fma")))
static double hw_fma(double a, double b, double c) { return __builtin_fma(a, b, c); }
int main(void) {
    volatile double zero = 0.0, one = 1.0, big = DBL_MAX, tiny = DBL_MIN;
    volatile double p1 = 0.1, p2 = 0.2, a = 1.0 + 0x1p-27;
    show("1.0 / 0.0", one / zero);
    show("0.0 / 0.0", zero / zero);
    show("sqrt(-1.0)", hw_sqrt(-one));
    show("DBL_MAX * 2", big * 2.0);
    show("DBL_MIN / 3", tiny / 3.0);
    show("0.1 + 0.2", p1 + p2);
    show("0.5 + 0.25", one * 0.5 + 0.25);
    /* exact a*a = 1 + 2^-26 + 2^-54; a*a - 1 rounds the product first */
    printf("a*a - 1       = %.17g\n", a * a - 1.0);
    if (__builtin_cpu_supports("fma"))
        printf("fma(a, a, -1) = %.17g\n", hw_fma(a, a, -1.0));
    else
        puts("no FMA instruction on this CPU");
    printf("2^-26 + 2^-54 = %.17g\n", 0x1p-26 + 0x1p-54);
    return 0;
}

Wandbox-এ (gcc, একটি cloud সার্ভারের x86-64 CPU) আউটপুট:

1.0 / 0.0   = inf          flags: div-by-zero
0.0 / 0.0   = -nan         flags: invalid
sqrt(-1.0)  = -nan         flags: invalid
DBL_MAX * 2 = inf          flags: overflow inexact
DBL_MIN / 3 = 7.41691e-309 flags: underflow inexact
0.1 + 0.2   = 0.3          flags: inexact
0.5 + 0.25  = 0.75         flags:
a*a - 1       = 1.4901161193847656e-08
fma(a, a, -1) = 1.4901161249358807e-08
2^-26 + 2^-54 = 1.4901161249358807e-08

প্রোগ্রাম কোথাও থামেনি — প্রতিটি exception-এ ডিফল্ট ফল এসেছে আর flag উঠেছে। 1/0 দিল inf আর div-by-zero; 0/0 আর sqrt(−1) দিল NaN আর invalid. NaN-এর সামনে “−” দেখে অবাক হবেন না: x86-এর তৈরি ডিফল্ট NaN-এর sign বিট ১ (“indefinite” NaN), তাই glibc ছাপে -nan; RISC-V-র canonical NaN-এর sign ০. DBL_MAX × 2-এ overflow, আর সাথে inexact — কারণ ∞ আসল ফল নয়। DBL_MIN / 3-এ ফল subnormal 7.41691e-309, তাই underflow আর inexact. 0.1 + 0.2-এ কোনো বিপদ নেই, তবু inexact ওঠে — প্রায় সব সাধারণ হিসাবেই ওঠে। শুধু 0.5 + 0.25-এ কোনো flag নেই, কারণ ফল 0.75 নির্ভুলভাবে রাখা যায়।

শেষ তিন লাইনে a = 1 + 2−27. আসল a × a = 1 + 2−26 + 2−54, কিন্তু double-এর ৫৩ বিটে 2−54 ধরে না — সাধারণ গুণে সেটি round-এ হারায়, তাই a*a - 1 = 2−26 = 1.4901161193847656e-08. FMA পুরো গুণফল রেখে −1 যোগ করে তারপর round করে, তাই পায় 1.4901161249358807e-08 — তৃতীয় লাইনের নির্ভুল উত্তরের হুবহু সমান।

ডিফল্টে trap বন্ধ রাখার পেছনে IEEE 754-এর প্রধান স্থপতি William Kahan-এর একটি দর্শন: বড় হিসাবের মাঝখানে প্রোগ্রাম থামানো প্রায়ই সমস্যা বাড়ায়, আর ∞ ও NaN হিসাবের ভেতর দিয়ে বয়ে গিয়ে অনেক সময় নিজেরাই অর্থবহ উত্তর দেয় — যেমন 1/(1 + 1/x)-এ x = 0 হলে 1/∞ = 0, ফল 0, যা গাণিতিকভাবে ঠিক সীমা। Ariane 5-এর দুর্ঘটনায় (পর্ব ০৫-এ, Integer-এর বিট-রূপ) উল্টো ঘটনা দেখেছেন: একটি রূপান্তরের exception সামলানো না থাকায় পুরো unit বন্ধ হয়ে যায়।

৭ · যখন গণিতের নিয়ম ভাঙে — associativity, cancellation আর quadratic সূত্র

বাস্তব সংখ্যায় (a + b) + c = a + (b + c) — এটি associativity. Floating point-এ প্রতিটি যোগের পরে round হয় বলে ক্রম বদলালে ফল বদলাতে পারে; একে বলে non-associativity. Double-এ 1016-এর আশেপাশে পাশাপাশি দুটি মানের দূরত্ব (ULP) ২. তাই:

  • (1016 + 1) − 1016: প্রথম যোগফল 1016 + 1 ঠিক দুই প্রতিবেশী 1016 আর 1016 + 2-এর মাঝখানে; ties-to-even 1016-এ ফেরায়। তারপর বিয়োগে 0.
  • (1016 − 1016) + 1 = 0 + 1 = 1.

একই কারণে বড় x-এর জন্য x + 1 == x সত্য: float-এ 224 = 16777216-এর পরে ULP ২, তাই 16777216 + 1 আবার 16777216 (16777217 float-এ রাখাই যায় না); double-এ একই সীমা 253. লুপের counter float রাখলে এক সময় সেটি বাড়া বন্ধ করে দেয় — চিরকাল চলা লুপ। Distributivity-ও ভাঙে: double-এ 10 × (0.1 + 0.2) = 3.0000000000000004, কিন্তু 10 × 0.1 + 10 × 0.2 = 3. আর (0.1 + 0.2) + 0.3 = 0.6000000000000001, অথচ 0.1 + (0.2 + 0.3) = 0.6.

এজন্যই compiler নিজে থেকে floating point হিসাবের ক্রম বদলায় না — (a + b) + c-কে a + (b + c) করা তার জন্য নিষেধ। -ffast-math (বা -Ofast) দিলে এই নিষেধ উঠে যায়: compiler ক্রম বদলায়, NaN-∞ নেই ধরে নেয় — গতি বাড়ে, কিন্তু ফল বদলাতে পারে আর Kahan summation-এর মতো যত্নের কৌশল (অংশ ৮) পুরোপুরি মুছে যেতে পারে।

Catastrophic cancellation — প্রায় সমান দুটি সংখ্যার বিয়োগ

বিয়োগ নিজে খুব নির্ভুল: দুটি সংখ্যা পরস্পরের দুই গুণের মধ্যে থাকলে তাদের পার্থক্য floating point-এ নির্ভুলভাবে রাখা যায় (Sterbenz lemma). বিপদ অন্যখানে। ধরুন a আর b দুটিই আগের হিসাব থেকে এসেছে, প্রতিটিতে শেষের এক-দুই বিট ভুল। a − b করলে সামনের মিলে যাওয়া বিটগুলো সব বাতিল হয়ে যায় — থাকে শুধু শেষের সেই ভুল-মেশানো বিটগুলো, আর normalise সেগুলোকে টেনে সামনে আনে। আপেক্ষিক ভুল হঠাৎ বিশাল। একে বলে catastrophic cancellation.

দুটি ভবনের উচ্চতা ফিতা দিয়ে মাপা হলো, প্রতিটিতে এক সেন্টিমিটার পর্যন্ত ভুল: ১২০.৪৫ আর ১২০.৪৩ মিটার। পার্থক্য ২ সেন্টিমিটার — কিন্তু আসল পার্থক্য ০ থেকে ৪ সেন্টিমিটারের যেকোনো কিছু। প্রতিটি মাপ প্রায় নিখুঁত, অথচ পার্থক্যটি ১০০% পর্যন্ত ভুল। বিয়োগ ভুল বানায়নি; আগের ভুলকে উন্মুক্ত করেছে।

পাঠ্যবইয়ের সবচেয়ে বিখ্যাত উদাহরণ দ্বিঘাত সমীকরণের সূত্র x = (−b ± √(b² − 4ac)) / 2a, যখন b² ≫ 4ac. তখন √(b² − 4ac) প্রায় |b|-এর সমান, তাই −b + √(…)-এ প্রায় সমান দুটি সংখ্যা বিয়োগ হয়। প্রতিকার একটি পুরোনো বীজগণিত: দুটি মূলের গুণফল c/a (Vieta-র সূত্র)। তাই প্রথমে বাতিলহীন মূলটি বের করুন — q = −(b + sign(b)·√(b² − 4ac)) / 2, x1 = q / a — তারপর অন্যটি x2 = c / q. নিচের stepper-এ x² + 108x + 1 = 0, double-এ:

সমস্যাa = 1, b = 1e8, c = 1
true roots:  x1 = -99999999.99999999  (about -1e8)
             x2 = -1.0000000000000000e-08
small root is what we care about
ধাপ ১discriminant
b*b       = 1e16              (exact)
4*a*c     = 4
b*b - 4ac = 9999999999999996  (exact: ulp here is 2)
ধাপ ২বর্গমূল — প্রথম rounding
true sqrt = 99999999.99999998000...
double    = 99999999.99999998510  = 1e8 - 2^-26
            (ulp near 1e8 is 2^-26 = 1.49e-8)
ধাপ ৩সরাসরি সূত্র: −b + d
-1e8 + (1e8 - 2^-26) = -2^-26   (cancellation!)
x2 = -2^-26 / 2 = -7.4505805969238281e-09
relative error = 25.5 %
ধাপ ৪স্থিতিশীল সূত্র
q  = -(b + d) / 2 = -1e8      (same signs: no cancellation)
x1 = q / a        = -1e8
x2 = c / q        = 1 / -1e8 = -1e-08
তুলনাএকই ডেটা, দুই সূত্র
naive  x2 = -7.45e-09   error 2.5e-01
stable x2 = -1.00e-08   error ~1e-16
check: x1 * x2 = c / a = 1
  1. সমস্যা। b² (1016) 4ac (৪)-এর তুলনায় বিশাল। একটি মূল প্রায় −108, অন্যটি প্রায় −10−8 (আসলে −1.00000000000000010 × 10−8).
  2. Discriminant. 1016 − 4 = 9999999999999996, double-এ হুবহু রাখা যায় (এই উচ্চতায় ULP ২, আর সংখ্যাটি জোড়)। এখনো কোনো ভুল ঢোকেনি।
  3. বর্গমূল। আসল √ ≈ 99999999.99999998, কিন্তু 108-এর কাছে double-এর ঘর 2−26 ≈ 1.49 × 10−8 দূরে দূরে। সবচেয়ে কাছের ঘর 108 − 2−26. ভুল মাত্র ৫ × 10−9 — আপেক্ষিকভাবে ৫ × 10−17, একদম নিখুঁত rounding.
  4. সরাসরি সূত্র। −108 + (108 − 2−26) — বিয়োগটি নিজে নির্ভুল, ফল −2−26. কিন্তু এর মধ্যে বেঁচে আছে শুধু ধাপ ২-এর rounding-এর ছাপ, আসল তথ্য নয়। ২ দিয়ে ভাগে x2 = −7.45 × 10−9, অথচ উত্তর −1.0 × 10−8 — ২৫% ভুল, আর একটি অঙ্কও সঠিক নয়।
  5. স্থিতিশীল সূত্র। b আর d একই চিহ্নের, তাই b + d-তে কোনো বাতিল নেই; যোগফল round হয়ে 2 × 108, q = −108, আর বড় মূল x1 = −108. ছোট মূল ভাগ দিয়ে: x2 = c / q = −10−8 — প্রায় পূর্ণ নির্ভুল।
  6. শিক্ষা। Hardware একটিও ভুল করেনি — প্রতিটি operation correctly rounded. ভুল ছিল সূত্র বাছাইয়ে। একই সূত্রের দুটি রূপ floating point-এ আলাদা আচরণ করে — একে বলে algorithm-এর স্থিতিশীলতা (stability). Vieta দিয়ে যাচাই: x1 × x2 = c/a = 1.

৮ · যোগফলে ভুল জমা — Kahan আর pairwise summation

লম্বা তালিকা যোগ করা — গড়, মোট বিক্রি, dot product — কম্পিউটারের সবচেয়ে সাধারণ কাজগুলোর একটি। সাধারণ লুপ s += x[i]-এ প্রতিটি যোগে নতুন rounding ভুল ঢোকে, আর যোগফল যত বড় হয়, প্রতিটি নতুন ছোট সংখ্যার তত বেশি বিট align-এর সময় ডানে পড়ে যায়। একে বলে accumulation error. n টি সংখ্যার সাধারণ যোগে ভুলের সীমা মোটামুটি (n − 1) × u × Σ|xi|, যেখানে u আধা ULP-এর আপেক্ষিক মান (float-এ প্রায় 6 × 10−8). n = এক কোটি হলে এই সীমা ভয়ংকর।

বাস্তবে দেখুন: float-এ 0.1f এক কোটি বার যোগ করলে কত আসে? 0.1f নিজেই 0.100000001490116…, তাই আসল উত্তর প্রায় 1000000.0149. কিন্তু যোগফল 219 = 524288 পেরোলে float-এর ঘর 0.0625 দূরে দূরে, 220 পেরোলে 0.125 দূরে — প্রতিটি যোগের rounding ভুল বড়, আর তা একদিকে জমে। নিচের C প্রোগ্রামে দেখবেন উত্তর 1087937 — প্রায় ৮.৮% বেশি।

Kahan summation — হারানো বিটগুলো আলাদা খাতায়

চায়ের দোকানের মালিক প্রতিটি বিক্রির হিসাব রাখেন শুধু পূর্ণ টাকায় — খাতায় পয়সার ঘর নেই। ১২.৬০ টাকার বিক্রি লেখেন ১৩, ১২.৪০-কে ১২. দিনের শেষে বড় অমিল। বুদ্ধিমান মালিক একটি ছোট চিরকুটে লিখে রাখেন প্রতিবার কত পয়সা বাড়িয়ে বা কমিয়ে লিখলেন, আর পরের বিক্রি লেখার আগে সেটি সমন্বয় করেন। Kahan-এর algorithm ঠিক এই চিরকুট।

William Kahan ১৯৬৫ সালে এই পদ্ধতি প্রকাশ করেন; একে বলে Kahan (compensated) summation. যোগফল sum-এর পাশে একটি ছোট সংশোধনী c রাখা হয়:

/* উদাহরণ — Kahan compensated summation */
float sum = 0.0f, c = 0.0f;
for (i = 0; i < n; i++) {
    float y = x[i] - c;      /* first fix the next number with the old error  */
    float t = sum + y;       /* big + small: low bits of y are lost here      */
    c = (t - sum) - y;       /* (t - sum) is what was really added; minus y
                                gives the lost part (with a minus sign)       */
    sum = t;
}

কৌশলটি হলো (t - sum) - y: t আর sum কাছাকাছি, তাই t − sum নির্ভুলভাবে বলে দেয় y-এর কতটুকু আসলে যোগ হয়েছে; সেখান থেকে y বাদ দিলে পাওয়া যায় হারানো অংশ। গণিতে c সবসময় শূন্য — তাই -ffast-math দিলে compiler সত্যিই একে মুছে দিতে পারে! নিচের stepper-এ ৪ অঙ্কের দশমিক যন্ত্রে 1000-এর সাথে 3.142 চারবার যোগ:

শুরু৪টি significant অঙ্ক
sum = 1000    c = 0      naive = 1000
add 3.142 four times
exact answer = 1012.568
যোগ ১
y   = 3.142 - 0          = 3.142
t   = 1000 + 3.142       = 1003.142 -> 1003
c   = (1003 - 1000) - 3.142 = -0.142
sum = 1003               naive = 1003
যোগ ২
y   = 3.142 - (-0.142)   = 3.284
t   = 1003 + 3.284       = 1006.284 -> 1006
c   = (1006 - 1003) - 3.284 = -0.284
sum = 1006               naive = 1006
যোগ ৩
y   = 3.142 + 0.284      = 3.426
t   = 1006 + 3.426       = 1009.426 -> 1009
c   = (1009 - 1006) - 3.426 = -0.426
sum = 1009               naive = 1009
যোগ ৪জমা অংশ এবার কাজে লাগল
y   = 3.142 + 0.426      = 3.568
t   = 1009 + 3.568       = 1012.568 -> 1013
c   = (1013 - 1009) - 3.568 = +0.432
sum = 1013               naive = 1009 + 3.142 -> 1012
ফলতুলনা
exact          = 1012.568  (4 digits: 1013)
naive          = 1012      (lost 0.568)
Kahan sum      = 1013      (correctly rounded)
sum - c        = 1013 - 0.432 = 1012.568  (c keeps the rest)
  1. যন্ত্রের সীমা। এই কাল্পনিক যন্ত্র প্রতিটি ফল ৪টি significant অঙ্কে round করে (nearest-even). 1000-এর কাছে মানে দশমিকের পরে কোনো অঙ্কই থাকে না। আমরা 3.142 চারবার যোগ করব; আসল উত্তর 1012.568.
  2. প্রথম যোগ। 1000 + 3.142 = 1003.142, কিন্তু ৪ অঙ্কে 1003 — 0.142 হারাল। Kahan সেটি ধরে ফেলে: t − sum = 3 (আসলে যা যোগ হয়েছে), 3 − 3.142 = −0.142. অর্থাৎ c বলছে “০.১৪২ কম যোগ হয়েছে।” এই ছোট সংখ্যাগুলো ৪ অঙ্কে নির্ভুলভাবে রাখা যায়।
  3. দ্বিতীয় যোগ। পরের সংখ্যা যোগ করার আগে ঘাটতি মেটানো হয়: y = 3.142 − (−0.142) = 3.284. যোগফল আবার round হয় (1006), আর নতুন ঘাটতি −0.284 — আগের আর এখনকার মিলিয়ে।
  4. তৃতীয় যোগ। একই নিয়মে y = 3.426, sum = 1009, c = −0.426. সাধারণ যোগও এখন পর্যন্ত 1009 — পার্থক্য এখনো দেখা যায়নি, কিন্তু c-তে জমছে।
  5. চতুর্থ যোগ। y = 3.568 যোগ করে 1012.568, যা round হয়ে 1013. সাধারণ যোগ 1009 + 3.142 = 1012.142 → 1012. এবার c ধনাত্মক (+0.432): বেশি যোগ হয়ে গেছে, পরের বার কমিয়ে নেওয়া হবে।
  6. ফলাফল। Kahan-এর 1013 হলো আসল উত্তরের ৪ অঙ্কে সবচেয়ে কাছের মান; সাধারণ যোগ 1012, অর্থাৎ শেষ অঙ্ক ভুল। আরও সুন্দর: sum − c = 1012.568 — হারানো অংশ পুরোটাই c-তে আছে। লক্ষ লক্ষ যোগে সাধারণ পদ্ধতির ভুল বাড়তেই থাকে, Kahan-এর ভুল প্রায় এক ULP-এ আটকে থাকে।

Pairwise summation — যোগের ক্রম বদলে দেওয়া

আরেকটি সরল কৌশল: তালিকাকে দুই ভাগ করে প্রতিটি অর্ধেক আলাদা যোগ, তারপর দুই যোগফল যোগ — আর একইভাবে ভেতরে ভেতরে (একটি binary tree). প্রতিটি সংখ্যা মাত্র log2 n বার rounding-এর মধ্য দিয়ে যায়, তাই ভুলের সীমা n-এর বদলে log n-এর অনুপাতে বাড়ে। একে বলে pairwise summation. একটি আলাদা script-এ float32-এ এক কোটি 0.1f-কে এভাবে tree ধরে যোগ করে পাওয়া গেছে ঠিক 1000000.0. বাড়তি খরচ প্রায় নেই, আর এটি parallel (দুই অর্ধেক দুই core-এ) করাও সহজ — তাই NumPy-র মতো library ভেতরে এর একটি রূপ ব্যবহার করে বলে জানা যায়। (Python-এর math.fsum আরও এগিয়ে — correctly rounded যোগফল দেয়।)

নিচের প্রোগ্রাম এই অংশ আর আগের অংশের তিনটি দাবি একসাথে যাচাই করে: ক্রম বদলালে ফল বদলায়, float-এ বড় x-এ x + 1 == x, এক কোটি 0.1f-এর তিন রকম যোগ, আর quadratic সূত্রের দুই রূপ (বর্গমূলের জন্য আবার সরাসরি sqrtsd, তাই x86-64):

#include <stdio.h>
static double hw_sqrt(double x) {               /* x86-64 sqrtsd, no libm needed */
    double r;
    __asm__("sqrtsd %1, %0" : "=x"(r) : "x"(x));
    return r;
}
int main(void) {
    volatile double big = 1e16, one = 1.0;
    printf("(1e16 + 1) - 1e16 = %g\n", (big + one) - big);
    printf("(1e16 - 1e16) + 1 = %g\n", (big - big) + one);
    volatile float f24 = 16777216.0f;           /* 2^24 */
    printf("2^24f + 1 == 2^24f : %d\n", f24 + 1.0f == f24);
    const int N = 10000000;
    float naive = 0.0f, sum = 0.0f, c = 0.0f;   /* c = Kahan compensation */
    double dsum = 0.0;
    for (int i = 0; i < N; i++) {
        naive += 0.1f;
        dsum  += 0.1f;
        float y = 0.1f - c, t = sum + y;
        c = (t - sum) - y;                      /* the low bits that were lost */
        sum = t;
    }
    printf("float naive = %.1f\ndouble      = %.6f\nfloat Kahan = %.1f\n",
           naive, dsum, sum);
    volatile double a = 1.0, b = 1e8, cc = 1.0;   /* roots near -1e-8 and -1e8 */
    double d = hw_sqrt(b * b - 4 * a * cc);
    double naive_x = (-b + d) / (2 * a);        /* subtracts two nearly equal numbers */
    double q = -(b + d) / 2, stable_x = cc / q; /* no cancellation */
    printf("naive  small root = %.17g\nstable small root = %.17g\n", naive_x, stable_x);
    return 0;
}

Wandbox-এ (gcc, x86-64, optimization ছাড়া) আউটপুট:

(1e16 + 1) - 1e16 = 0
(1e16 - 1e16) + 1 = 1
2^24f + 1 == 2^24f : 1
float naive = 1087937.0
double      = 1000000.014901
float Kahan = 1000000.0
naive  small root = -7.4505805969238281e-09
stable small root = -1e-08

প্রথম দুই লাইন associativity ভাঙার প্রমাণ: একই তিনটি সংখ্যা, ক্রম আলাদা, উত্তর 0 আর 1. তৃতীয় লাইনে 1 মানে সত্য — float-এ 224 + 1 == 224. এরপর এক কোটি 0.1f: সাধারণ float যোগ 1087937.0 (প্রায় ৮.৮% বেশি); double-এ জমালে 1000000.014901 — যা আসলে 0.1f-এর নিজের মান × 107, অর্থাৎ যোগে প্রায় কোনো ভুল নেই, যা আছে তা 0.1-কে float-এ রাখার ভুল; আর float-এ Kahan দিল 1000000.0 — float-এ এর চেয়ে নির্ভুল উত্তর সম্ভব নয়। শেষ দুই লাইন stepper-এর হুবহু মান: সরাসরি সূত্র −7.4505805969238281e-09, স্থিতিশীল সূত্র −1e-08. (-ffast-math দিলে Kahan-এর লাইনটি মুছে যেতে পারে।)

৯ · ভিন্ন যন্ত্রে ভিন্ন উত্তর, float তুলনা, টাকা আর AI-এর mixed precision

একই কোড, ভিন্ন উত্তর — x87 আর FMA

IEEE 754 প্রতিটি operation-এর ফল নির্দিষ্ট করে, তবু একই C প্রোগ্রাম দুই কম্পিউটারে শেষ অঙ্কে আলাদা উত্তর দিতে পারে। দুটি প্রধান কারণ ইতিহাসে আর বর্তমানে:

  • x87 আর ৮০ বিট: Intel-এর 8087 coprocessor (১৯৮০) আর তার পরের x87 FPU ভেতরে সব হিসাব করত ৮০ বিটের extended precision-এ (৬৪ বিট significand). ৩২ বিট x86-এর পুরোনো compiler-এ double-এর মধ্যবর্তী ফল register-এ ৮০ বিটে থাকত, আর মেমরিতে লেখার (spill) সময় ৬৪ বিটে round হতো। কোন মান কখন মেমরিতে যাবে তা optimization-এর ওপর নির্ভর করে, তাই -O0 আর -O2-এ ফল বদলাত; আর দুবার round হওয়া (double rounding) কখনো ১ ULP সরিয়ে দিত। x86-64-এর ABI-তে float আর double SSE2-তে হয়, তাই এই সমস্যা আজ অনেক কম (C-তে FLT_EVAL_METHOD 0).
  • FMA contraction: a*b + c লিখলে compiler চাইলে একে একটি FMA instruction বানাতে পারে (C মানদণ্ড এই “contraction” অনুমতি দেয়)। FMA-ওয়ালা CPU-তে, বা -march=native দিলে, ফল এক rounding-এর; FMA ছাড়া দুই rounding-এর — অংশ ৬-এর C প্রোগ্রামে যেমন দেখেছেন, পার্থক্য শেষ বিটে। GCC-র নথি অনুযায়ী GNU mode-এ ডিফল্ট -ffp-contract=fast, কিন্তু -std=c11-এর মতো ISO mode-এ বন্ধ। নিশ্চিত মিল চাইলে -ffp-contract=off.

Library function-এর শেষ-বিটের পার্থক্য আর parallel যোগের ক্রম — আরও দুটি কারণ — ভাবনার প্রশ্ন ৩-এ।

Float তুলনা — কখনো সরাসরি == নয়

0.1 + 0.2 আর 0.3 double-এ ঠিক এক ULP দূরে (5.55 × 10−17), তাই == মিথ্যা বলে। তিনটি সঠিক উপায়:

  1. Absolute tolerance: |a − b| ≤ εabs. শূন্যের কাছে কাজের, কিন্তু বড় সংখ্যায় অর্থহীন — 109-এর কাছে double-এর ঘরই প্রায় 1.2 × 10−7 দূরে, তাই εabs = 10−9 সেখানে ==-এর মতোই কঠোর।
  2. Relative tolerance: |a − b| ≤ εrel × max(|a|, |b|). মানের আকারের সাথে মানিয়ে নেয়; কিন্তু একটি মান ঠিক ০ হলে কাজ করে না। তাই বাস্তবে দুটি একসাথে: absolute দিয়ে শূন্যের কাছে, relative দিয়ে বাকি জায়গায়। Python-এর math.isclose ঠিক এভাবে কাজ করে (ডিফল্ট rel_tol = 10−9).
  3. ULP দূরত্ব: একই চিহ্নের দুটি float-এর বিটকে integer হিসেবে পড়লে তাদের পার্থক্যই বলে মাঝখানে কয়টি float আছে — কারণ IEEE-র বিন্যাসে বিট-প্যাটার্নের ক্রম আর মানের ক্রম এক (excess-K exponent-এর সুফল, পর্ব ০৫)। 0.1 + 0.2 আর 0.3-এর দূরত্ব ঠিক ১ ULP. “৪ ULP-এর মধ্যে হলে সমান” ধরনের পরীক্ষা গাণিতিক library-র test-এ প্রচলিত।

Catastrophic cancellation পার হয়ে আসা মানে লাখো ULP ভুল থাকতে পারে, তাই tolerance ঠিক করুন হিসাবের ইতিহাস দেখে। NaN-এর কথাও মনে রাখুন: NaN == NaN মিথ্যা, তাই x != x দিয়ে NaN ধরা যায় (তবে -ffast-math-এ এই পরীক্ষাও মুছে যেতে পারে; isnan() নিরাপদ)।

টাকার হিসাব — float নয়, পূর্ণসংখ্যা পয়সা

১০ পয়সা মানে 0.10 টাকা, আর 0.1 binary-তে অসীম ভগ্নাংশ — লাখো লেনদেনে এক-দুই পয়সার অমিল জমতে পারে। নিয়ম: টাকা রাখুন পূর্ণসংখ্যা পয়সায় (১,২৩৪.৫০ টাকা = 123450 পয়সা, ৬৪ বিট integer-এ) বা decimal type-এ, আর rounding-এর নিয়ম স্পষ্ট লিখে রাখুন। বিস্তারিত পাবেন পর্ব ২৩-এর Floating point অংশে।

বাস্তবের ঘটনা

Vancouver Stock Exchange-এর সূচক (১৯৮২–১৯৮৩): বহুল উদ্ধৃত বিবরণ অনুযায়ী ১৯৮২ সালের জানুয়ারিতে নতুন একটি শেয়ার-সূচক 1000.000 মান দিয়ে শুরু হয়। প্রতিটি লেনদেনের পরে সূচক নতুন করে হিসাব হতো আর দশমিকের পরে তিন ঘরে কেটে (truncate) রাখা হতো, round নয়। প্রতিদিন হাজারো লেনদেন, প্রতিবার গড়ে প্রায় আধা “শেষ ঘর” হারানো — সব ভুল একই দিকে। প্রায় দুই বছর পরে সূচক দেখাচ্ছিল ৫২০-এর কাছাকাছি, অথচ ঠিকমতো হিসাব করলে মান হতো ১১০০-এর কাছাকাছি (প্রকাশিত সংখ্যাগুলো উৎসভেদে সামান্য আলাদা)। ভুল ধরা পড়ার পর সূচক সংশোধন করা হয়। এই পর্বের ভাষায়: toward-zero rounding-এর পক্ষপাত, আর accumulation error.

আরও দুটি ঘটনা আগের পর্বগুলোতে আছে, এখানে শুধু সংযোগটুকু: Patriot ক্ষেপণাস্ত্র-প্রতিরোধ ব্যবস্থা (১৯৯১) — ০.১ সেকেন্ডকে কেটে রাখার ছোট ভুল ঘণ্টার পর ঘণ্টা জমে বড় হয়েছিল; বিবরণ পর্ব ০৪-এ (সংখ্যা পদ্ধতি)। Ariane 5 (১৯৯৬) — ৬৪ বিট floating point থেকে ১৬ বিট integer-এ রূপান্তরের overflow; বিবরণ পর্ব ০৫-এ। তিনটির শিক্ষা একই: ছোট, একমুখী ভুল সময়ের সাথে জমে; আর সীমা ছাড়ানোর পরিকল্পনা না থাকলে বিপদ।

AI-তে mixed precision — নিচু precision-এ গুণ, উঁচুতে যোগ

বড় neural network-এর প্রশিক্ষণ আর চালানোর মূল কাজ বিশাল matrix গুণ — কোটি কোটি FMA. মেমরি আর শক্তি বাঁচাতে আধুনিক AI hardware গুণ করে ১৬ বিটে — FP16 বা bfloat16-এ (বিট-বিন্যাস পর্ব ২৩-এ) — কিন্তু যোগফল জমায় ৩২ বিট FP32-এ। কেন? অংশ ৮-ই উত্তর: dot product মানে হাজারো গুণফলের যোগ। bfloat16-এ 256 + 1 = 256 (৮ বিটে 257 রাখাই যায় না), FP16-এ 2048 + 1 = 2048. ছোট precision-এ জমাতে থাকলে যোগফল একটু বড় হলেই নতুন গুণফলগুলো আর যোগই হয় না (“stagnation”). তাই NVIDIA-র Tensor Core আর Google-এর TPU-র মতো hardware-এর মূল নকশা: নিচু precision-এ গুণ, FP32-এ জমা — একে বলে mixed precision (বিস্তারিত ভাবনার প্রশ্ন ৪-এ; GPU-র ভেতরের গঠন পর্ব ৫৫-এ)।

আরও গভীরে: বাস্তব FP adder সাধারণত দুটি পথে ভাগ করা থাকে — “near path” (exponent-এর পার্থক্য ০ বা ১, যেখানে বড় cancellation আর বাঁ-shift হয়, কিন্তু বড় ডানে-shift লাগে না) আর “far path” (পার্থক্য বড়, ডানে-shift আছে কিন্তু normalise সর্বোচ্চ ১ ঘর) — অংশ ৩-এর যুক্তিটিই। Leading-zero গোনার জন্য বিয়োগ শেষ হওয়ার অপেক্ষা না করে একটি leading-zero anticipator সমান্তরালে অনুমান করে। অনেক আধুনিক core আলাদা adder না রেখে FMA unit দিয়েই যোগ (a × 1 + c) আর গুণ (a × b + 0) করে; Intel-এর কিছু প্রজন্মে এই কারণে FP যোগের latency গুণের সমান হয়ে গেছে বলে প্রকাশিত হিসাবে দেখা যায়। আর posit বা logarithmic number system-এর মতো বিকল্প format নিয়ে গবেষণা চলছে, যদিও IEEE 754-এর আধিপত্য এখনো অটুট।

১০ · AI-এর যুগে floating point-এর হিসাব

AI টুল floating point-এর সংজ্ঞা ঝরঝরে বলতে পারে, কিন্তু বিট-স্তরের হিসাবে প্রায়ই ভুল করে — আর সেই ভুল দেখতে একদম আত্মবিশ্বাসী। কয়েকটি প্রচলিত ভুল:

AI-এর প্রচলিত ভুলঠিক কথাকীভাবে যাচাই
গুণে biased exponent যোগ করে bias বাদ না দেওয়া, বা hidden ১ ভুলে যাওয়াe = e1 + e2 − 127; significand 1.f × 1.fফলের hex Python/JS দিয়ে বের করে মেলানো
“Round করতে শুধু guard বিট দেখলেই হয়”Tie আলাদা করতে R আর S লাগে; বিয়োগে S-এর প্রভাব আরও সূক্ষ্মএই পর্বের খেলনা simulator-এ দুই রকমে চালানো
Ties-কে সবসময় ওপরে round করা (half-up)IEEE-র ডিফল্ট ties-to-even2.5 আর 3.5-এর ফল দেখা: 2 আর 4
“0.1f + 0.2f == 0.3f মিথ্যা, double-এর মতোই”Float-এ সত্য, double-এ মিথ্যা — কাকতাল, নিয়ম নয়দুই type-এ চালিয়ে বিট ছাপানো
Float তুলনায় সবখানে fabs(a-b) < 1e-9মানের আকার অনুযায়ী relative tolerance বা ULPবড় আর শূন্যের কাছের মান দিয়ে পরীক্ষা
Latency, throughput বা FMA সমর্থন নিয়ে নির্দিষ্ট chip-এর বানানো সংখ্যাপ্রজন্মভেদে আলাদা; প্রায়ই AI-এর সংখ্যা ভুলনির্মাতার manual বা নির্ভরযোগ্য instruction টেবিল

AI-কে দিয়ে হিসাব করালে প্রতিটি ধাপ আর বিট চেয়ে নিন, যাতে নিজে মেলাতে পারেন:

আমি IEEE 754 single precision-এ floating point যোগ শিখছি। 12.75 আর −3.0625 যোগ করো, প্রতিটি ধাপ আলাদা করে দেখিয়ে: ১. দুটি সংখ্যার sign, biased exponent আর ২৩ বিট fraction (hex-সহ) লেখো। ২. Exponent-এর পার্থক্য, আর ছোট significand কত ঘর ডানে সরল — guard, round, sticky বিটসহ দেখাও। ৩. Significand যোগ/বিয়োগ, normalise (কত ঘর, কোন দিকে) আর round to nearest even ধাপে ধাপে দেখাও। ৪. চূড়ান্ত বিট-প্যাটার্ন hex-এ দাও। শেষে একটি ছোট Python বা JavaScript স্ক্রিপ্ট দাও (struct বা Float32Array দিয়ে), যা ফলের hex বের করে — আমি নিজে চালিয়ে মিলিয়ে দেখব। কোনো ধাপে নিশ্চিত না হলে সেটি বলো।

AI-এর উত্তর হাতে পেলে — যাচাইয়ের চেকলিস্ট

পরীক্ষা ও ইন্টারভিউতে এই বিষয়ে প্রায়ই যা জিজ্ঞেস করা হয়:

  • Floating point যোগের ধাপগুলো কী? — Exponent মেলানো (ছোটটিকে ডানে shift), significand যোগ-বিয়োগ, normalise, round, overflow/underflow যাচাই (দরকারে আবার normalise).
  • Guard, round, sticky বিট কেন? — Align-এ হারানো বিটের খবর রাখতে; G-R বাঁ-shift সামলায়, S বলে tie নাকি তার সামান্য ওপরে — তিনটিতেই correct rounding নিশ্চিত।
  • IEEE-র ডিফল্ট rounding mode আর কেন? — Round to nearest, ties to even; tie-তে একদিকে না যাওয়ায় দীর্ঘ হিসাবে পক্ষপাত থাকে না।
  • Floating point গুণে exponent কীভাবে হয়? — দুটি biased exponent যোগ করে একবার bias বাদ: e = e1 + e2 − 127 (single); চিহ্ন XOR.
  • FMA কী, লাভ কী? — a × b + c একবার round করে; বেশি নির্ভুল ও দ্রুত, dot product আর AI-এর matrix কাজের মূল instruction.

ভাবনার প্রশ্ন

প্রতিটি প্রশ্ন নিয়ে নিজে কিছুক্ষণ ভাবুন — তারপর “→ উত্তর” চাপুন।

প্র ০১IEEE 754-এর আগে প্রতিটি কম্পিউটারের floating point আলাদা ছিল। মানদণ্ডটি কেন এত গুরুত্বপূর্ণ হলো, আর “correct rounding” কেন তার কেন্দ্রে?

১৯৭০-এর দশকে বিজ্ঞানীরা একটি অদ্ভুত সমস্যায় ভুগতেন: একই Fortran প্রোগ্রাম IBM, CDC, DEC আর Cray যন্ত্রে আলাদা উত্তর দিত — কখনো শেষ অঙ্কে, কখনো একদম ভিন্ন। IBM System/360-এর floating point ছিল hexadecimal ভিত্তির (exponent ১৬-এর ঘাত), ফলে normalise করার পরেও সামনের তিনটি বিট পর্যন্ত শূন্য থাকতে পারত আর নির্ভুলতা সংখ্যাভেদে লাফাত। কিছু Cray যন্ত্রের গুণ correctly rounded ছিল না বলে বর্ণিত আছে, কিছু যন্ত্রে বিয়োগে guard অঙ্ক না থাকায় ফল অনেক ভুল হতো, আর gradual underflow না থাকা যন্ত্রে x ≠ y হলেও x − y শূন্য হয়ে যেতে পারত।

১৯৭৬ সালের দিকে Intel তাদের 8087 coprocessor-এর নকশায় University of California, Berkeley-র অধ্যাপক William Kahan-কে পরামর্শক হিসেবে নেয়। প্রায় একই সময়ে IEEE একটি কমিটি গঠন করে, আর Kahan, Jerome Coonen ও Harold Stone-এর প্রস্তাব (প্রায়ই “K-C-S draft” নামে পরিচিত) দীর্ঘ বিতর্কের পরে ১৯৮৫ সালে IEEE 754 হয়। মূল সিদ্ধান্তগুলো এই পর্বেই দেখেছেন: প্রতিটি মৌলিক operation correctly rounded, ডিফল্ট ties-to-even, পাঁচটি exception-এর নির্দিষ্ট ডিফল্ট ফল আর sticky flag, ∞ আর NaN, আর gradual underflow (subnormal). Kahan এই কাজের জন্য ১৯৮৯ সালে Turing Award পান।

Correct rounding কেন কেন্দ্রে? কারণ এটি floating point-কে বিশ্লেষণযোগ্য করে। প্রতিটি operation-এর ফল = আসল ফল × (1 + δ), যেখানে |δ| ≤ u — এই একটি সমীকরণ দিয়ে numerical analyst-রা পুরো algorithm-এর ভুলের সীমা প্রমাণ করতে পারেন, যন্ত্র যাই হোক। Kahan summation বা অংশ ৭-এর স্থিতিশীল সূত্র কাজ করে এই নিশ্চয়তার ওপর ভর করেই। যন্ত্রভেদে নিয়ম আলাদা হলে কোনো প্রমাণই সব জায়গায় খাটত না। আর একটি বাড়তি লাভ: একটি “reference” উত্তর থাকায় hardware যাচাই করা যায় — যে কারণে Pentium-এর ভাগের ভুল (পর্ব ২২) প্রমাণসহ ধরা পড়েছিল।

মূল উপলব্ধি: মানদণ্ডের আসল উপহার গতি নয়, পূর্বানুমেয়তা। প্রতিটি ছোট ধাপের ভুলের সীমা জানা থাকলে বড় algorithm-এর ভুলও যুক্তি দিয়ে বাঁধা যায় — আর তখনই floating point বিজ্ঞান ও প্রকৌশলের ভরসাযোগ্য হাতিয়ার হয়।

প্র ০২Vancouver-এর সূচক আর Patriot-এর ঘটনা দুটিতেই প্রতিটি ধাপের ভুল ছিল অতি ক্ষুদ্র। তবু ফল এত বড় হলো কেন? Truncation আর rounding-এর পার্থক্য এখানে কী ভূমিকা রাখে?

দুটি ঘটনার গণিত একই ছাঁচের: একটি ছোট ভুল, যা প্রতিবার একই দিকে হয়, আর অসংখ্যবার পুনরাবৃত্তি। Vancouver-এর সূচক প্রতিটি লেনদেনের পরে নতুন করে হিসাব হয়ে দশমিকের পরে তিন ঘরে কেটে রাখা হতো। কেটে ফেলা (toward zero) মানে ধনাত্মক সংখ্যায় সবসময় নিচের দিকে — গড়ে প্রায় ০.০০০৫ করে কম। একটি ভুল নগণ্য; কিন্তু দিনে কয়েক হাজার বার, প্রায় দুই বছর ধরে, সব ভুল যোগ হয়ে সূচক প্রায় অর্ধেক হয়ে দাঁড়ায়। Nearest rounding হলে ভুলগুলো কখনো ওপরে, কখনো নিচে হয়ে অনেকটাই কাটাকাটি হতো — মোট ভুল বাড়ত মোটামুটি পুনরাবৃত্তির বর্গমূলের হারে।

Patriot-এর ক্ষেত্রে (বিবরণ পর্ব ০৪-এ) ০.১ সেকেন্ডকে ২৪ বিটের fixed-point ঘরে কেটে রাখা হয়েছিল, তাই প্রতি tick-এ সময় সামান্য কম গোনা হতো — আবার একমুখী ভুল। সেখানে আরও একটি স্তর ছিল: সিস্টেমের এক অংশে উন্নত রূপান্তর বসানো হয়েছিল কিন্তু সব জায়গায় নয়, ফলে দুটি সময়-মানের বিয়োগে ভুলগুলো কাটাকাটি না হয়ে উন্মুক্ত হয়েছিল — অংশ ৭-এর cancellation-এর আত্মীয়।

প্রকৌশলের শিক্ষাগুলো স্পষ্ট। প্রথম, rounding mode একটি নকশা-সিদ্ধান্ত: হিসাবরক্ষণ, সূচক বা সময়ের মতো জমতে থাকা মানে truncation বিপজ্জনক। দ্বিতীয়, “একটি ধাপে ভুল কত” প্রশ্নের সাথে “কতবার, আর কোন দিকে” প্রশ্নও করতে হয়। তৃতীয়, দীর্ঘ চলমান মান মাঝে মাঝে আসল উৎস থেকে নতুন করে হিসাব করা (Vancouver-এর সমাধানও তাই ছিল — সূচক শুরু থেকে আবার হিসাব), বা Kahan-এর মতো সংশোধনী রাখা। চতুর্থ, সময় বা টাকার মতো মান পূর্ণসংখ্যা একক (মিলিসেকেন্ড, পয়সা) দিয়ে গোনা, যাতে প্রতিটি ধাপ নির্ভুল থাকে।

মূল উপলব্ধি: ভুলের আকার নয়, তার দিক আর পুনরাবৃত্তি বিপদ তৈরি করে। এলোমেলো ভুল কাটাকাটি হয়, একমুখী ভুল জমে — তাই ties-to-even আর nearest rounding নিছক সৌন্দর্য নয়, নিরাপত্তার বিষয়।

প্র ০৩IEEE 754 থাকা সত্ত্বেও একই প্রোগ্রাম ল্যাপটপ, সার্ভার আর GPU-তে শেষ অঙ্কে আলাদা উত্তর দেয় কেন? Bit-for-bit একই ফল (reproducibility) পেতে কী করা লাগে?

IEEE 754 প্রতিটি একক operation-এর ফল নির্দিষ্ট করে, কিন্তু প্রোগ্রাম কোন operation কোন ক্রমে করবে তা ঠিক করে ভাষা, compiler, library আর hardware মিলে। প্রথম উৎস ক্রম: (a + b) + c আর a + (b + c) আলাদা (অংশ ৭)। Multicore CPU বা GPU-তে একটি বড় যোগফল ভাগ করে আলাদা thread-এ করা হয়, তারপর মেলানো — কোন thread আগে শেষ হবে তা প্রতিবার আলাদা হলে (যেমন atomic যোগে) ফলও প্রতিবার সামান্য আলাদা। Deep learning-এ একই seed দিয়েও দুই run-এ আলাদা ফল পাওয়ার একটি পরিচিত কারণ এটি, আর কিছু framework তাই আলাদা “deterministic” mode দেয়, সাধারণত গতির বিনিময়ে।

দ্বিতীয় উৎস precision আর contraction: পুরোনো x87-এর ৮০ বিট মধ্যবর্তী ফল, আর FMA — একটি যন্ত্রে a*b + c এক rounding-এ, অন্যটিতে দুই rounding-এ। Java এক সময় strictfp keyword রেখেছিল ঠিক এই সমস্যার জন্য; x87-এর যুগ ফুরোনোয় Java 17 থেকে কঠোর আচরণই ডিফল্ট। তৃতীয় উৎস library: IEEE 754 sin, exp, pow-এর জন্য correct rounding বাধ্যতামূলক করেনি (সুপারিশ করে), তাই glibc, Microsoft-এর library আর GPU vendor-এর library শেষ বিটে ভিন্ন হতে পারে। চতুর্থ, -ffast-math-এর মতো flag, যা compiler-কে ক্রম বদলানোর ছাড়পত্র দেয়।

Bit-for-bit মিল চাইলে তালিকা লম্বা: যোগের ক্রম নির্দিষ্ট রাখা (নির্দিষ্ট আকারের block-এ ভাগ করে নির্দিষ্ট ক্রমে মেলানো), -ffp-contract=off বা সব জায়গায় স্পষ্ট fma(), -ffast-math বাদ, একই গাণিতিক library (বা correctly rounded library, যেমন CORE-MATH প্রকল্পের মতো উদ্যোগ), আর একই rounding mode. কিছু ক্ষেত্রে fixed-point বা integer গণিতই বেছে নেওয়া হয় — যেমন বহু খেলোয়াড়ের online গেমের lockstep simulation-এ, যেখানে সব কম্পিউটারকে হুবহু একই অবস্থায় থাকতে হয়।

মূল উপলব্ধি: “IEEE মানে একই উত্তর” কথাটি শুধু একক operation-এর বেলায় সত্য। পুরো প্রোগ্রামের পুনরুৎপাদনযোগ্যতা একটি আলাদা নকশা-লক্ষ্য — যার দাম সাধারণত গতি দিয়ে দিতে হয়।

প্র ০৪AI hardware গুণ করে ১৬ বা ৮ বিটে, অথচ যোগফল রাখে ৩২ বিটে। এই নকশার পেছনের যুক্তি কী, আর precision আরও কমালে কী সমস্যা হয়?

একটি neural network-এর প্রতিটি স্তরে মূল কাজ y = Wx — matrix আর vector-এর গুণ, যার প্রতিটি উপাদান একটি dot product: Σ wi xi, প্রায়ই হাজারো পদের। এখানে দুটি আলাদা প্রশ্ন। গুণফল wi xi-এর জন্য কতটা নির্ভুলতা লাগে? দেখা গেছে, network-এর ওজন আর activation-এ এমনিতেই অনেক “শব্দ” (noise) থাকে, তাই ৮–১১ বিটের significand-ও প্রায়ই যথেষ্ট। ১৬ বিটের সংখ্যা অর্ধেক মেমরি আর bandwidth নেয়, আর multiplier-এর আকার মোটামুটি significand বিটের বর্গের অনুপাতে — ২৪ × ২৪-এর বদলে ৮ × ৮ বা ১১ × ১১ গুণের circuit অনেক ছোট, তাই একই chip-এ অনেক বেশি multiplier বসে।

দ্বিতীয় প্রশ্ন যোগফলের নির্ভুলতা, আর সেখানে গল্প আলাদা। অংশ ৮-এ দেখেছেন সাধারণ যোগে ভুল পদ-সংখ্যার সাথে বাড়ে, আর যোগফল বড় হলে ছোট পদ হারিয়ে যায়: bfloat16-এ 256 + 1 = 256. হাজারো পদের যোগ ১৬ বিটে জমালে ফল প্রায় অর্থহীন হতে পারে। তাই NVIDIA-র Volta (২০১৭) প্রজন্মের Tensor Core থেকে শুরু করে প্রচলিত নকশা: FP16 বা bfloat16-এ গুণ, গুণফল নির্ভুল রেখে FP32-এ যোগ — মূলত একটি mixed-precision FMA. প্রশিক্ষণে আরও কৌশল লাগে: ওজনের একটি FP32 “master copy” রাখা, কারণ ছোট আপডেট (learning rate × gradient) ১৬ বিটের ওজনে যোগ করলে হারিয়ে যায় — আবার সেই x + ছোট = x; আর FP16-এ ছোট gradient underflow ঠেকাতে loss-কে বড় একটি সংখ্যা দিয়ে গুণ করে রাখা (loss scaling). এই পদ্ধতির পরিচিত বিবরণ ২০১৮ সালের “Mixed Precision Training” গবেষণাপত্রে।

Precision আরও কমানো এখন সক্রিয় গবেষণা ও বাণিজ্যের বিষয়। FP8-এর দুটি রূপ (E4M3 আর E5M2 — exponent আর fraction-এর ভিন্ন ভাগ) সাম্প্রতিক GPU-তে এসেছে, আর ৪ বিটের format নিয়েও কাজ চলছে, বিশেষ করে প্রশিক্ষিত model চালানোর (inference) জন্য। তত নিচে গেলে প্রতিটি ছোট block-এর জন্য আলাদা scale factor রাখতে হয়। মূল শিক্ষা বদলায় না: নিচু precision যেখানে ভুল ছড়িয়ে পড়ে না সেখানে, উঁচু precision যেখানে ভুল জমে সেখানে।

মূল উপলব্ধি: Mixed precision এই পর্বের দুটি বিষয়কে মেলায় — FMA আর accumulation error. কোথায় নির্ভুলতা দরকার আর কোথায় বিলাসিতা, তা জানলেই hardware-কে কয়েক গুণ দ্রুত আর কম শক্তির করা যায়।

প্র ০৫Floating point-এ এত ফাঁদ থাকলে আমরা বিকল্প — decimal floating point, arbitrary precision, interval arithmetic বা posit — কেন সবখানে ব্যবহার করি না?

প্রতিটি বিকল্প একটি নির্দিষ্ট সমস্যা সমাধান করে, আর বিনিময়ে কিছু নেয়। Decimal floating point (IEEE 754-2008-এ decimal32/64/128 format যোগ হয়েছে) ০.১-কে নির্ভুলভাবে রাখে, তাই টাকার হিসাবে মানুষের প্রত্যাশার সাথে মেলে। কিন্তু এটি ভুল দূর করে না — 1/3 decimal-এও অসীম — শুধু ভুলকে মানুষের চেনা জায়গায় সরায়। IBM-এর কিছু mainframe আর POWER processor-এ decimal hardware আছে বলে জানা যায়, কিন্তু সাধারণ PC বা ফোনে এটি software-এ, অনেক ধীরে চলে। তাই ব্যাংক-হিসাবে decimal বা পূর্ণসংখ্যা পয়সা, বৈজ্ঞানিক হিসাবে binary.

Arbitrary precision (যেমন GMP বা MPFR library, Python-এর decimal-এ ইচ্ছেমতো অঙ্ক) যত খুশি নির্ভুলতা দেয়, কিন্তু প্রতিটি operation শত শত গুণ ধীর, আর মেমরি অনিশ্চিত। বিপজ্জনক হিসাবের অংশবিশেষে বা ফল যাচাইয়ে এটি অমূল্য — GCC নিজেই compile-এর সময় ধ্রুবক ভাঁজ করতে MPFR ব্যবহার করে। Interval arithmetic (অংশ ৪) প্রতিটি মানের বদলে একটি সীমা রাখে আর directed rounding দিয়ে নিশ্চিত করে যে আসল উত্তর ভেতরে আছে; কিন্তু অনেক algorithm-এ সীমা দ্রুত অকেজো রকম চওড়া হয়ে যায়, আর খরচ অন্তত দ্বিগুণ। Posit (John Gustafson-এর ২০১৭ সালের প্রস্তাব) ১-এর কাছাকাছি মানে বেশি নির্ভুলতা আর দূরে কম দেয়, NaN-∞-এর ঝামেলা কমায়; কিন্তু hardware, compiler, library আর দশকের পর দশকের যাচাই-করা কোডের ভিত্তি IEEE 754-এর ওপর দাঁড়িয়ে।

আসলে binary floating point-এর ফাঁদগুলো “ভুল নকশা” নয়, সীমিত বিটে অসীম বাস্তব সংখ্যা ধরার অনিবার্য দাম — যেকোনো সসীম format-এ round করতেই হবে। IEEE 754-এর শক্তি হলো সেই দাম স্পষ্ট, সীমিত আর প্রমাণযোগ্য। তাই প্রকৌশলের সঠিক পথ সাধারণত format বদলানো নয়, বরং ঠিক জায়গায় ঠিক হাতিয়ার: স্থিতিশীল সূত্র, compensated বা pairwise যোগ, FMA, দরকারি অংশে double বা উঁচু precision, টাকায় integer, আর ফল যাচাইয়ে interval বা arbitrary precision.

মূল উপলব্ধি: কোনো সংখ্যা-format সব সমস্যার সমাধান নয়। Floating point-এর ভুল কোথায় জন্মায় আর কীভাবে বাড়ে, তা বোঝাই আসল দক্ষতা — format বাছাই তার পরের সিদ্ধান্ত।

অনুশীলন

পরীক্ষার ধাঁচে লেখা — আগে খাতায় সমাধান করুন, তারপর উত্তর মিলিয়ে নিন।

  1. হাতে FP যোগ (IEEE single precision): 12.75 আর −3.0625-এর যোগফল IEEE 754 single precision-এর algorithm মেনে বের করুন। প্রতিটি operand আর ফলের hex লিখুন, আর বলুন rounding লেগেছে কি না।

    Operand: 12.75 = 1100.112 = 1.10011 × 23 → s = 0, e = 130, f = 1001 1000 … → 0x414C0000. 3.0625 = 11.00012 = 1.10001 × 21 → s = 1, e = 128, f = 1000 1000 … → −3.0625 = 0xC0440000.

    Align: পার্থক্য 130 − 128 = 2. 1.10001 × 21 = 0.0110001 × 23 (দুই ঘর ডানে; বাদ পড়া কোনো ১ ২৪ বিটের বাইরে যায়নি, G = R = S = 0).

    বিয়োগ (চিহ্ন ভিন্ন): 1.1001100 − 0.0110001 = 1.0011011, × 23.

    Normalise: ফল ইতিমধ্যে 1.x রূপে, shift লাগে না; exponent 3 → biased 130. Round: GRS = 000, কোনো rounding নেই — ফল নির্ভুল। সীমা: 130 সীমার ভেতরে।

    ফল: 1.0011011 × 23 = 1001.10112 = 9.6875 (12.75 − 3.0625 = 9.6875 ✓). বিট: 0 10000010 0011 0110 0000 … = 0x411B0000.

  2. GRS আর rounding mode: একটি format-এ significand 1.ffff (৪ বিট ভগ্নাংশ)। (ক) round-এর আগে significand আর GRS নিচে দেওয়া; প্রতিটিকে round to nearest even করুন: (i) 1.0110 | 100 (ii) 1.0111 | 100 (iii) 1.0110 | 101 (iv) 1.0110 | 011. (খ) −1.0110 | 011-কে toward +∞ আর toward −∞ mode-এ round করুন। (গ) একই format-এ 1.0110 × 20 + 1.0001 × 2−5 হিসাব করুন — একবার G, R, S দিয়ে, একবার sticky ছাড়া (শুধু G, R). কোনটি সঠিক?

    (ক) (i) GRS = 100, ঠিক মাঝখান; শেষ বিট ০ (জোড়) → নিচে: 1.0110. (ii) tie, শেষ বিট ১ → ওপরে: 1.0111 + 0.0001 = 1.1000. (iii) 101 > 100 → ওপরে: 1.0111. (iv) 011 < 100 → নিচে: 1.0110.

    (খ) বাদ পড়া অংশ শূন্য নয়। Toward +∞: ঋণাত্মক সংখ্যার পরম মান ছোট করলে মান বাড়ে → −1.0110. Toward −∞: পরম মান বাড়াতে হবে → −1.0111.

    (গ) 1.0001 × 2−5-কে ৫ ঘর ডানে: 0.0000 1 0 001 → G = 1, R = 0, আর বাকি 001-এ ১ আছে বলে S = 1. যোগফল 1.0110 | 101 → ওপরে → 1.0111 = 1.4375. Sticky ছাড়া: 1.0110 | 10 → tie মনে হয়, 1.0110 জোড় → নিচে → 1.375. আসল যোগফল 1.375 + 0.033203125 = 1.408203125; দুই প্রতিবেশীর মাঝখান 1.40625, আসল মান তার ওপরে — তাই সঠিক উত্তর 1.4375 (G, R, S দিয়ে পাওয়া)। এই পর্বের JS simulator-এ { s: 0, E: 0, m: 0b10110 } আর { s: 0, E: -5, m: 0b10001 } বসিয়ে মিলিয়ে নিতে পারেন।

  3. আউটপুট অনুমান করুন: x86-64 Linux-এ gcc দিয়ে (optimization ছাড়া) নিচের অংশ কী ছাপবে, আর কেন?
    float s = 0.0f;
    for (int i = 0; i < 10; i++) s += 0.1f;
    printf("%.9g %d\n", s, s == 1.0f);
    double d = 0.0;
    for (int i = 0; i < 10; i++) d += 0.1;
    printf("%.17g %d\n", d, d == 1.0);
    printf("%d %d\n", 0.1f + 0.2f == 0.3f, 0.1 + 0.2 == 0.3);
    printf("%d\n", 16777216.0f + 1.0f == 16777216.0f);
    1.00000012 0
    0.99999999999999989 0
    1 0
    1
    • Float-এ দশটি 0.1f-এর যোগফল 1.00000012 (1-এর ঠিক এক ULP ওপরে, 1 + 2−23) — প্রতিটি যোগের rounding জমে; তাই == 1.0f মিথ্যা।
    • Double-এ দশটি 0.1 যোগ করলে 0.99999999999999989 (1-এর ঠিক নিচের double) — এবার ভুল উল্টো দিকে; আবার মিথ্যা।
    • 0.1f + 0.2f == 0.3f সত্য (stepper ১-এর হিসাব: দুটিরই বিট 0x3E99999A), কিন্তু double-এ 0.1 + 0.2 == 0.3 মিথ্যা — দুটির দূরত্ব ১ ULP.
    • 224-এর পরে float-এর ঘর ২ দূরে, 16777217 tie হয়ে জোড় 16777216-এ ফেরে → সত্য (1).

    শিক্ষা: একই কোড float আর double-এ ভিন্ন দিকে ভুল করে, আর কাকতালীয় সমতার ওপর ভরসা করা যায় না।

  4. হাতে গুণ, তারপর কোড লিখুন: (ক) একটি খেলনা format-এ (significand 1.ffff, bias 7) A = 1.0101 × 21 আর B = −1.0011 × 2−2. Floating point গুণের ধাপ মেনে A × B বের করুন — biased exponent, significand গুণফল, normalise, GRS দেখে round আর চিহ্ন। (খ) C-তে int nearly_equal(double a, double b, double rel, double abs_tol) লিখুন, যা absolute আর relative — দুই রকম tolerance মিলিয়ে দুটি double “প্রায় সমান” কি না বলে।

    (ক) Biased exponent: (1 + 7) + (−2 + 7) − 7 = 8 + 5 − 7 = 6, অর্থাৎ 2−1. Significand: 1.0101 × 1.0011 = (21 × 19) / 256 = 399 / 256 = 1.1000 11112 (399 = 1 1000 1111). ফল ইতিমধ্যে 1.x রূপে, normalise লাগে না। ৪ বিটে round: 1.1000 | GRS = 1, 1, 1 (বাকি বিটের OR) → আধা ULP-এর বেশি → ওপরে: 1.1001. চিহ্ন: 0 ⊕ 1 = 1. ফল −1.1001 × 2−1 = −0.78125. যাচাই: আসল গুণফল 2.625 × (−0.296875) = −0.779296875; প্রতিবেশী −0.75 আর −0.78125-এর মধ্যে −0.78125 বেশি কাছে ✓.

    #include <math.h>
    
    int nearly_equal(double a, double b, double rel, double abs_tol) {
        if (a == b) return 1;                        /* also handles equal infinities */
        double diff = fabs(a - b);
        if (diff <= abs_tol) return 1;               /* near zero: absolute test */
        return diff <= rel * fmax(fabs(a), fabs(b)); /* elsewhere: relative test */
    }

    NaN থাকলে প্রতিটি তুলনা মিথ্যা, তাই ফল 0 — যা চাওয়া আচরণ। পরীক্ষা: nearly_equal(0.1 + 0.2, 0.3, 1e-9, 1e-12) → 1; nearly_equal(1e-20, 0.0, 1e-9, 1e-12) → 1 (absolute অংশে); nearly_equal(1e9, 1e9 + 10, 1e-9, 0) → 0, কারণ পার্থক্য 10, আর সীমা মাত্র প্রায় 1e-9 × 1e9 = 1. Tolerance-এর মান বাছাই করুন সমস্যার প্রকৃতি দেখে — কোনো একটি সংখ্যা সব ক্ষেত্রে চলে না। Link করার সময় -lm লাগবে।

নিজেকে যাচাই করুন

ভুল হলে কারণসহ ব্যাখ্যা পাবেন, আবার চেষ্টাও করতে পারবেন।

ক্রমIEEE 754 floating point যোগের ধাপগুলো সঠিক ক্রমে সাজান (▲▼ চেপে সরান)

  1. Exponent তুলনা করে ছোট সংখ্যার significand ডানে shift করা (G, R, S রেখে)
  2. চিহ্ন দেখে significand দুটি যোগ বা বিয়োগ করা
  3. ফল normalise করা — ডানে বা বাঁয়ে shift করে exponent সমন্বয়
  4. G, R, S আর rounding mode দেখে round করা
  5. দরকার হলে আবার normalise করে exponent-এর overflow বা underflow যাচাই করা

Floating point যোগে align করার সময় guard আর round বিটের পরে আরও একটি sticky বিট রাখা হয় কেন?

  • Exponent-এর overflow ধরার জন্য
  • বিয়োগের পরে বাঁয়ে shift সামলানোর জন্য
  • ঠিক মাঝখান (tie) আর মাঝখানের সামান্য ওপরের মধ্যে পার্থক্য করার জন্য
  • Inexact exception flag রাখার জন্য

IEEE 754-এর ডিফল্ট rounding mode-এ 2.5 আর 3.5-কে পূর্ণসংখ্যায় round করলে কী পাওয়া যায়?

  • 3 আর 4
  • 2 আর 4
  • 2 আর 3
  • 3 আর 3

IEEE 754 double-এ (1e16 + 1) − 1e16 আর (1e16 − 1e16) + 1 হিসাব করলে ফল কত?

  • দুটিই 1
  • দুটিই 0
  • প্রথমটি 1, দ্বিতীয়টি 0
  • প্রথমটি 0, দ্বিতীয়টি 1

IEEE single precision-এ দুটি সংখ্যার biased exponent 130 আর 125. তাদের গুণফলের biased exponent (normalise-এর আগে) কত?

  • 128
  • 255
  • 5
  • 1

ডিফল্ট সেটিংয়ে একটি C প্রোগ্রামে 0.0 / 0.0 (double) হিসাব হলে সাধারণত কী ঘটে?

  • প্রোগ্রাম সঙ্গে সঙ্গে crash করে
  • ফল NaN হয়, invalid flag ওঠে, প্রোগ্রাম চলতে থাকে
  • ফল +∞ হয়, division-by-zero flag ওঠে
  • ফল 0 হয়, কোনো flag ওঠে না

x² + 108x + 1 = 0-এর ছোট মূল double-এ সূত্র (−b + √(b² − 4ac)) / 2a দিয়ে হিসাব করলে প্রায় 25% ভুল আসে। এর সবচেয়ে ভালো প্রতিকার কী?

  • double-এর বদলে float ব্যবহার করা
  • আরও নির্ভুল বর্গমূলের function ব্যবহার করা
  • Rounding mode toward zero করা
  • বাতিলহীন বড় মূল আগে বের করে ছোট মূল x₂ = c / q দিয়ে নেওয়া

শব্দকোষ

লেখার মধ্যে ডটেড দাগ দেওয়া শব্দে মাউস রাখুন বা চাপুন — অর্থ দেখাবে। এই পর্বের সব নতুন শব্দ এখানে একসাথে:

মনে রাখুন

  1. FP যোগ: align (ছোটটিকে ডানে) → যোগ/বিয়োগ → normalise → round → সীমা যাচাই (দরকারে আবার normalise). FP গুণ: e1 + e2 − bias, significand গুণ, normalise, round, চিহ্ন XOR.
  2. Guard, round, sticky — মাত্র তিনটি বাড়তি বিটে correct rounding; sticky আলাদা করে tie আর tie-এর সামান্য ওপর।
  3. চার rounding mode: nearest-even (ডিফল্ট, পক্ষপাতহীন), toward 0, toward +∞, toward −∞. পাঁচটি exception-এর ডিফল্ট ফল আর sticky flag — ডিফল্টে trap নেই।
  4. নিয়ম ভাঙে: associativity আর distributivity নেই, বড় x-এ x + 1 == x, প্রায় সমান সংখ্যার বিয়োগে catastrophic cancellation — প্রতিকার স্থিতিশীল সূত্র।
  5. যোগফলে ভুল জমে: Kahan বা pairwise summation, FMA, উঁচু precision-এ জমা (AI-এর mixed precision); তুলনায় == নয় — tolerance বা ULP; টাকা পূর্ণসংখ্যা পয়সায়।

পরের পর্বে: মডিউল ৪ শেষ — hardware কীভাবে হিসাব করে তা জানলেন। এবার নতুন মডিউল: software কীভাবে hardware-কে কাজ বলে দেয়? Instruction set architecture (ISA) — hardware আর software-এর চুক্তি, আর RISC বনাম CISC-এর পুরোনো বিতর্ক।

আরও পড়ুন · ABCL TECH-এ আপনার পরবর্তী পদক্ষেপ

এই পর্বটি শেয়ার করুন:
এই পর্বে ভুল পেয়েছেন? জানান