Foundationপ্রথম নীতি থেকে
LEVEL 1লেসন ৮/১৪অ্যাডভান্সড১ ঘণ্টা ২০ মিনিট

IEEE 754 — Floating-Point-এর Bit-বাই-Bit ব্যাকরণ

IEEE 754 Floating-Point Standard

একটা 32-বিট বা 64-বিট pattern-কে sign, biased exponent, আর implicit-leading-1 mantissa-তে ভাগ করে ঠিক কীভাবে সংখ্যায় রূপান্তর করা হয় — আর হাতে-কলমে দেখানো, কেন 0.1 store করলে যা জমা থাকে তা ঠিক 0.1 নয়।

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

  • Single (32-bit) ও double (64-bit) precision-এর সম্পূর্ণ bit layout — sign, exponent, mantissa — মুখস্থ না করে যুক্তি দিয়ে পুনর্গঠন করতে পারবেন
  • যেকোনো দশমিক সংখ্যাকে হাতে ৩২-বিট IEEE 754-এ এনকোড এবং একটা raw bit pattern-কে ডিকোড করতে পারবেন, rounding-সহ
  • কেন exponent field two's complement নয়, biased — সেটা প্রমাণ করতে পারবেন যে biased encoding float-দুটোকে সরাসরি integer-এর মতো তুলনা করা সম্ভব করে
  • +0/−0, ±Infinity, quiet ও signaling NaN, আর subnormal number-এর bit pattern চিনতে এবং তাদের আচরণগত পার্থক্য ব্যাখ্যা করতে পারবেন
  • Round-to-nearest-even rounding rule প্রয়োগ করে tie-breaking case সমাধান করতে এবং এটা কেন 'শুধু নিকটতম' rule-এর চেয়ে ভালো তা ব্যাখ্যা করতে পারবেন
  • একটা float-এর raw bytes থেকে sign/exponent/mantissa সরাসরি decode করার function লিখতে পারবেন (Python struct এবং C union/memcpy দুই ভাবেই)

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

আগে এটা বুঝি

একটা চ্যালেঞ্জ দিয়ে শুরু করি। এই ৩২-বিট pattern-টা একটা float:

0 01111011 10011001100110011001101

সংখ্যাটা কত? এই লেসনের শেষে আপনি এটা হাতে বের করতে পারবেন — আর দেখবেন সংখ্যাটা এমন একটা মান যা আপনি প্রতিদিন লেখেন, তবু bit হিসেবে এটা যা প্রতিনিধিত্ব করে সেটা সেই মানের সাথে হুবহু মেলে না

গত লেসনে আমরা floating-point-এর ধারণাটা দেখেছি — sign × significand × 2^exponent, scientific notation-এর বাইনারি সংস্করণ। কিন্তু ধারণা আর engineering-এর মাঝে একটা বিশাল ফাঁক আছে: ঠিক কয়টা বিট sign-এ, কয়টা exponent-এ, কয়টা significand-এ? Exponent negative হলে কীভাবে লেখা হয়? আর 0, , “সংজ্ঞাহীন” (0/0)-এর মতো বিশেষ মান কীভাবে প্রতিনিধিত্ব হয়?

১৯৮৫-এর আগে প্রতিটা hardware vendor এই প্রশ্নগুলোর নিজস্ব উত্তর দিত — আর ফলাফল ছিল বিশৃঙ্খলা (গত লেসনের ইতিহাস অংশ মনে করুন)। IEEE 754 এই সবগুলো প্রশ্নের একটাই, সার্বজনীন উত্তর দেয়। আজ পৃথিবীর প্রায় প্রতিটা CPU, GPU, প্রতিটা ভাষার float/double — সবাই ঠিক এই নিয়মে চলে। এই লেসনটা শেষ হলে আপনি জানবেন ঠিক কী ঘটে যখন আপনি লেখেন float x = 0.1f; — শুধু ধারণাগতভাবে নয়, বিট-বাই-বিট।

মূল ধারণা

সম্পূর্ণ bit layout

IEEE 754 তিনটা field সংজ্ঞায়িত করে, পাশাপাশি, একটা fixed ক্রমে:

Ssign, 1 bitEbiased exponentMmantissa (fraction)\underbrace{S}_{\text{sign, 1 bit}} \quad \underbrace{E}_{\text{biased exponent}} \quad \underbrace{M}_{\text{mantissa (fraction)}}

Single precision (float32) — মোট 32 bit
বিট:  31   30 ────────────── 23  22 ──────────────────────── 0
     ┌───┬──────────────────┬───────────────────────────────┐
     │ S │   Exponent (8)   │         Mantissa (23)          │
     └───┴──────────────────┴───────────────────────────────┘
       sign    bias = 127          implicit leading 1 আলাদা


Double precision (float64) — মোট 64 bit
বিট:  63  62 ───────────── 52  51 ──────────────────────── 0
     ┌───┬─────────────────┬─────────────────────────────────┐
     │ S │  Exponent (11)  │           Mantissa (52)          │
     └───┴─────────────────┴─────────────────────────────────┘
       sign    bias = 1023        implicit leading 1 আলাদা
Single ও double precision-এর bit layout পাশাপাশি — অনুপাত একই স্থাপত্য, শুধু scale ভিন্ন।
Formatমোট bitSignExponent bitMantissa bitBias~ দশমিক digitRange (finite, normal)
half (float16)161510153.36.10×10⁻⁵65504
bfloat16161871272.4float32-এর কাছাকাছি range
single (float32)3218231277.21.18×10⁻³⁸3.40×10³⁸
double (float64)6411152102315.92.23×10⁻³⁰⁸1.80×10³⁰⁸

(IEEE 754 আরও একটা format সংজ্ঞায়িত করে — binary128 quad precision, ১১২-বিট mantissa, 1+15+112=128 বিট মোট, HPC আর কিছু বৈজ্ঞানিক simulation-এ মাঝে মাঝে ব্যবহৃত।)

মূল সূত্র — normalized সংখ্যার জন্য

value=(1)S×1.M×2(Ebias)\text{value} = (-1)^S \times 1.M \times 2^{(E - \text{bias})}

1.M মানে mantissa field-এর সামনে একটা অদৃশ্য 1 বসিয়ে পড়া — এই “implicit leading bit”-এর গল্প একটু পরে।

দশমিক digit-এর হিসাব কোথা থেকে এলো

n বিট mantissa (implicit বিট-সহ) থেকে প্রায় কত দশমিক digit নির্ভরযোগ্য, তার সূত্র:

decimal digitsn×log102n×0.30103\text{decimal digits} \approx n \times \log_{10} 2 \approx n \times 0.30103

Single precision-এ 24 বিট কার্যকর mantissa (২৩ stored + ১ implicit): 24 \times 0.301 \approx 7.2 digit। এই কারণেই বলা হয় “float-এ প্রায় ৭ digit নির্ভরযোগ্য” — অষ্টম digit-এর পর থেকে যা দেখছেন তা প্রায়ই noise।

Biased exponent — কেন two’s complement নয়

Exponent negative হতে পারে (খুব ছোট সংখ্যার জন্য) বা positive (খুব বড় সংখ্যার জন্য)। স্বাভাবিক প্রবৃত্তি হতো — আগের লেসনগুলোয় signed integer-এর জন্য যা শিখেছি — two’s complement ব্যবহার করা। IEEE 754 তা করে না। এর বদলে exponent-এ একটা bias (single-এ 127, double-এ 1023) যোগ করে store করা হয়:

Estored=eactual+biasE_{\text{stored}} = e_{\text{actual}} + \text{bias}

e_actual = -4 হলে single precision-এ E_stored = -4 + 127 = 123, সবসময় একটা অ-negative সংখ্যা, সাধারণ unsigned binary-তে সরাসরি লেখা যায়।

Implicit leading bit — বিনামূল্যে একটা বাড়তি বিট

একটা normalized বাইনারি সংখ্যাকে সবসময় এভাবে লেখা যায়:

1.xxxxx×2e1.xxxxx \times 2^e

কেন সবসময় 1? কারণ normalization মানেই radix point-কে সেই জায়গায় সরানো যেখানে ঠিক একটা non-zero digit তার আগে থাকে — আর বাইনারিতে non-zero digit মানে অবশ্যই 1 (0 ছাড়া অন্য কোনো digit বাইনারিতে নেই)।

তার মানে normalized সংখ্যার mantissa-র প্রথম বিট সবসময়ই 1 — সেটা store করার কোনো দরকার নেই! IEEE 754 এই বিটটা বাদ দেয় (“implicit” বা “hidden” bit), আর বাকি ২৩ (single) বা ৫২ (double) বিট শুধু 1.-এর পরের অংশ store করে।

ফলাফল: ২৩টা stored বিট দিয়ে আসলে ২৪-বিট precision পাওয়া যায় — বিনামূল্যে একটা বাড়তি বিট। এই একটা trick-ই single precision-কে ~7.2 digit থেকে বাস্তবে সামান্য বেশি নির্ভরযোগ্য করে তোলে, যদি বিটটা সরাসরি store করা হতো।

ভেতরে কী ঘটছে

যেখানে representation-এর আসল রূপ বেরিয়ে আসে

১. 0.1-কে হাতে এনকোড করা — সম্পূর্ণ প্রক্রিয়া

এটাই এই লেসনের কেন্দ্রীয় হিসাব। চলুন 0.1-কে ৩২-বিট IEEE 754-এ রূপান্তর করি, প্রতিটা ধাপ দেখিয়ে।

ধাপ ১ — Normalize করা। 0.1-কে 1.xxxx \times 2^e আকারে লিখতে হবে। 0.1 আছে 2^{-4} = 0.0625 আর 2^{-3} = 0.125-এর মাঝে, তাই e = -4

0.124=0.1×16=1.6\frac{0.1}{2^{-4}} = 0.1 \times 16 = 1.6

তাহলে 0.1 = 1.6 \times 2^{-4}

ধাপ ২ — 1.6-এর বাইনারি ভগ্নাংশ বের করা। 0.6-এর বাইনারি digit বের করি বারবার 2 দিয়ে গুণ করে (আগের লেসনের হুবহু একই পদ্ধতি):

0.6 × 2 = 1.2   → বিট 1   (remainder 0.2)
0.2 × 2 = 0.4   → বিট 0   (remainder 0.4)
0.4 × 2 = 0.8   → বিট 0   (remainder 0.8)
0.8 × 2 = 1.6   → বিট 1   (remainder 0.6 — চক্র পুনরাবৃত্তি!)

চক্র দৈর্ঘ্য ৪: 1001 অনন্তকাল পুনরাবৃত্তি হবে।

1.6_{10} = 1.\overline{1001}_2 = 1.100110011001100110011001100...__2

ধাপ ৩ — ২৩ বিটে কাটা, round করা। Single precision-এ mantissa field মাত্র ২৩ বিট। প্রথম ২৪ বিট (২৩ রাখব + ১টা “round bit” দেখব) বের করি:

বিট নং:  1234 5678 9012 3456 7890 123  |  24
মান:     1001 1001 1001 1001 1001 100  |  1
         └──────── রাখা হবে (23 বিট) ──┘  └ round bit

বিট ২৪ (round bit) = 1, আর তার পরেও আরও 1 আছে (বিট ২৫ = 1) — তাই এটা tie নয়, discarded অংশ স্পষ্টভাবে অর্ধেকের চেয়ে বেশি। নিয়ম: round up

২৩-বিট string 10011001100110011001100-এর শেষ বিট 0 কে 1 করে দিতে হয় (round up):

10011001100110011001100    10011001100110011001101\texttt{10011001100110011001100} \;\longrightarrow\; \texttt{10011001100110011001101}

ধাপ ৪ — exponent বায়াস করা। e = -4, bias 127:

Estored=4+127=123=011110112E_{\text{stored}} = -4 + 127 = 123 = \texttt{01111011}_2

ধাপ ৫ — সব জোড়া লাগানো।

sign      exponent    mantissa (23 bit)
  0      01111011     10011001100110011001101

হেক্সে: 0x3DCCCCCD

0.1f — মেমরির raw bit থেকে যে মান আসলে বোঝায়, সেই পর্যন্ত
  1. 0x3DCCCCCDমেমরিতে যা আছে — মাত্র ৩২টা বিট, কোনো "অর্থ" এখনো নেই
  2. sign = 0ধনাত্মক
  3. biased exponent = 123unbias: 123 − 127 = −4
  4. stored mantissa = 1001100110011001100110123 বিট, round-up হওয়ার পর
  5. implicit leading 1 যোগsignificand = 1.10011001100110011001101₂
  6. value = significand × 2⁻⁴= 1.6000000238... × 0.0625
  7. 0.100000001490116119384765625আসল stored decimal মান — 0.1 নয়!

যাচাই — আসল stored মান ঠিক কত?

0.1000000014901161193847656250.100000001490116119384765625

এটাই 0.1f হিসেবে মেমরিতে যা সত্যিই আছে। 0.1-এর চেয়ে বেশি, পার্থক্য প্রায় 1.49 \times 10^{-9} — খুবই ছোট, কিন্তু শূন্য নয়। Python-এর print(0.1) যে 0.1 দেখায়, সেটা display-এর সময় round করার ফল, ভেতরের raw মান নয়।

Double precision-এ তুলনা। ঠিক একই প্রক্রিয়া, কিন্তু ৫২ বিট mantissa দিয়ে (round bit ৫৩ নম্বরে):

হেক্স:         0x3FB999999999999A
sign          0
exponent      01111111011  (= 1019, unbias: 1019 − 1023 = −4)
mantissa      1001100110011001100110011001100110011001100110011010

স্টোর করা মান: 0.1000000000000000055511151231257827021181583404541015625

double-এর error (~5.55 \times 10^{-18}) float-এর error-এর (~1.49 \times 10^{-9}) চেয়ে প্রায় দশ কোটি গুণ ছোট — এটাই বেশি mantissa বিটের সরাসরি সুবিধা। কিন্তু শূন্য নয়double-ও 0.1-কে exactly ধরতে পারে না — শুধু অনেক বেশি নিখুঁতভাবে আনুমানিক করে। এই একই fact আপনি নিজে struct.pack দিয়ে verify করবেন Experiment অংশে।

২. বিশেষ মান — যখন exponent field-এর দুই প্রান্তে বিশেষ অর্থ

Exponent field-এর দুইটা মান সংরক্ষিত — সব 0, আর সব 1 (single-এ 255, double-এ 2047)। এই দুই প্রান্ত normal সংখ্যার নিয়ম মানে না, বরং বিশেষ মানের জন্য জায়গা তৈরি করে:

Exponent fieldMantissaঅর্থ
সব 0 (0)সব 0±0 (sign bit অনুযায়ী)
সব 0 (0)অ-শূন্যSubnormal — implicit বিট 0, e = 1 - bias
1 থেকে সর্বোচ্চ−১যেকোনোNormal — implicit বিট 1
সব 1 (255/2047)সব 0±Infinity
সব 1 (255/2047)অ-শূন্য, MSB=1quiet NaN
সব 1 (255/2047)অ-শূন্য, MSB=0signaling NaN

+0 বনাম −0। IEEE 754-এ শূন্যের দুইটা bit pattern আছে — 0x00000000 (+0) আর 0x80000000 (−0)। +0 == -0 তুলনায় true, কিন্তু তারা identical bit pattern নয়। পার্থক্যটা observable:

>>> 1.0 / 0.0
inf
>>> 1.0 / -0.0
-inf
>>> 0.0 == -0.0
True

কেন দরকার? Signed zero জটিল সংখ্যা, limit calculation-এ direction সংরক্ষণ করে — -0.0 বলে “আমি শূন্যের দিকে negative side থেকে পৌঁছেছি”, যা কিছু গাণিতিক function-এর (যেমন log(x) যখন x শূন্যের দিকে যায়) সঠিক আচরণের জন্য গুরুত্বপূর্ণ।

±Infinity। Overflow (যেমন float32-এর সীমা 3.4 \times 10^{38} ছাড়িয়ে গেলে) বা 1.0/0.0-এর ফলাফল। সাধারণ arithmetic সংজ্ঞা মেনে চলে: ∞ + 1 = ∞, ∞ - ∞ = NaN, 1/∞ = 0

int division-এর সাথে তুলনা করুন — 1/0 integer-এ একটা crash (division-by-zero exception/trap), কিন্তু 1.0/0.0 floating-point-এ একটা সংজ্ঞায়িত মান (inf), কোনো crash ছাড়াই। এটা একটা সচেতন ডিজাইন সিদ্ধান্ত — floating-point-এ exception ছাড়াই হিসাব চালিয়ে যাওয়া যায়, শেষে ফলাফল পরীক্ষা করা যায় (isinf() দিয়ে)।

NaN — Not a Number। যখন কোনো operation-এর কোনো অর্থবহ ফলাফল নেই: 0.0/0.0, inf - inf, sqrt(-1.0) (real domain-এ)।

দুই ধরনের NaN:

  • quiet NaN (qNaN): নীরবে propagate করে — কোনো exception ছাড়াই ফলাফলে ছড়িয়ে যায়। সাধারণ ব্যবহারিক NaN।
  • signaling NaN (sNaN): ব্যবহার করা হলে একটা floating-point exception/trap তৈরি করতে পারে — কিছু hardware-এ “uninitialized memory” চিহ্নিত করতে ব্যবহৃত হয় (একটা sNaN দিয়ে memory pre-fill করে রাখলে, কেউ সেটা না জেনে ব্যবহার করলে সাথে সাথে trap হয়ে ধরা পড়ে)।

কোনটা কোনটা তা ঠিক হয় mantissa-র সবচেয়ে বড় (MSB) বিট দিয়ে — IEEE 754-2008-এর সুপারিশ, x86/ARM-এ ব্যবহৃত convention: 1 = quiet, 0 = signaling (বাকি mantissa বিটের অন্তত একটা 1 হতেই হবে, নাহলে সেটা Infinity হয়ে যাবে)।

quiet NaN (canonical, x86):      0 11111111 10000000000000000000000  = 0x7FC00000
signaling NaN (একটা উদাহরণ):     0 11111111 00000000000000000000001  = 0x7F800001

৩. Subnormal সংখ্যা — শূন্যের কাছে একটা ফাঁক এড়ানো

Normal সংখ্যার ক্ষুদ্রতম positive মান কত? Exponent field-এর ক্ষুদ্রতম বৈধ normal মান 1 (কারণ 0 subnormal-এর জন্য সংরক্ষিত), তাই ক্ষুদ্রতম normal exponent e = 1 - 127 = -126। Mantissa সব 0 ধরলে:

ক্ষুদ্রতম normal (float32)=1.0×21261.1754944×1038\text{ক্ষুদ্রতম normal (float32)} = 1.0 \times 2^{-126} \approx 1.1754944 \times 10^{-38}

এখন প্রশ্ন — এর চেয়ে ছোট positive সংখ্যা কী হবে? যদি IEEE 754 এখানেই থেমে যেত (শুধু normal সংখ্যা), তাহলে 0 আর 1.18 \times 10^{-38}-এর মধ্যে একটা বিশাল, আকস্মিক ফাঁক থাকত — কোনো representable সংখ্যা নেই, subtraction করলে a - b = 0 হয়ে যেত যদিও a \ne b

Subnormal (denormalized) সংখ্যা এই ফাঁক বন্ধ করে। যখন exponent field সব 0, নিয়ম বদলে যায়:

value=(1)S×0.M×2126(float32, implicit বিট এখন 0)\text{value} = (-1)^S \times 0.M \times 2^{-126} \quad \text{(float32, implicit বিট এখন } 0 \text{)}

লক্ষ্য করুন — exponent এখনো -126 (bias-থেকে 1-127, 0-127 নয়!), শুধু implicit leading bit 1-এর বদলে 0। এর মানে subnormal সংখ্যাগুলো ক্রমশ কম precision নিয়ে শূন্যের দিকে এগোয় — একে বলে gradual underflow

ক্ষুদ্রতম subnormal (float32)=223×2126=21491.401×1045\text{ক্ষুদ্রতম subnormal (float32)} = 2^{-23} \times 2^{-126} = 2^{-149} \approx 1.401 \times 10^{-45}

subnormal ছাড়া হলে:
  0 ─────────────── (বিশাল ফাঁক, কোনো representable মান নেই) ─────────────── 2⁻¹²⁶
                                                                    (ক্ষুদ্রতম normal)

subnormal সহ (বাস্তব IEEE 754):
  0 ─┬──┬──┬──┬──┬──┬──┬── ... ──┬── 2⁻¹²⁶
     │  │  │  │  │  │  │         │
   ধাপ ২⁻¹⁴⁹ সমান দূরত্বে, একদম শূন্য পর্যন্ত (linear spacing)
Subnormal ছাড়া শূন্যের কাছে একটা 'sudden gap' থাকত — gradual underflow সেই ফাঁক বন্ধ করে, প্রতিটা ধাপে precision কমিয়ে।

৪. Rounding mode — round-to-nearest-even কেন “even”

যখন কোনো সংখ্যা দুইটা representable মানের ঠিক মাঝখানে পড়ে (একটা tie), কোনদিকে round করা উচিত? IEEE 754-এর default rounding mode — round-to-nearest, ties-to-even (সংক্ষেপে “round-half-even”, বা প্রচলিত নাম “banker’s rounding”)।

নিয়ম: সাধারণত নিকটতম representable মানে round হয়। কিন্তু ঠিক মাঝখানে (tie) হলে, সেই মানে round হয় যার শেষ stored বিট 0 (জোড়/even)

কেন শুধু “সবসময় উপরে round করো” নয়? কারণ সেটা একটা পদ্ধতিগত bias তৈরি করে। যদি প্রতিটা tie সবসময় উপরে যায়, লক্ষ লক্ষ operation-এ ছোট ছোট round-up জমতে জমতে গড় মানকে সত্যিকারের গড়ের চেয়ে সামান্য বেশি দেখাবে — একটা পরিসংখ্যানগত পক্ষপাত। Round-to-even এই bias এড়ায় — অর্ধেক tie উপরে যায়, অর্ধেক নিচে (কারণ “শেষ বিট জোড়” শর্তটা এলোমেলোভাবে উপরে বা নিচের প্রতিবেশীর সাথে মেলে)।

একটা সহজ decimal analogy: 2.5-কে নিকটতম integer-এ round করতে হবে। 2 (জোড়) না 3 (বিজোড়)? Round-half-even বলবে 2 — কারণ 2 জোড়। 3.5 হলে round হবে 4 (জোড়), 3 নয়। বহুবার round করলে গড়ে bias শূন্যের কাছাকাছি থাকে।

0.1-এর encoding-এ আমরা যে rounding দেখেছি (round bit 1, পরের বিটও 1) সেটা tie ছিল না — discarded অংশ স্পষ্টভাবে অর্ধেকের বেশি ছিল, তাই round up অবধারিত ছিল, even/odd নিয়ম প্রযোজ্যই হয়নি। প্রশ্ন অংশে (Question ৫) আমরা একটা প্রকৃত tie case দেখব।

উদাহরণ

দুইটা সম্পূর্ণ hand-worked উদাহরণ

উদাহরণ ১ — একটা exact case: −12.5

−12.5-এর বাইনারি: 12.5 = 1100.1₂ (8+4=12, .5=2⁻¹)। Normalize: 1100.1₂ = 1.1001₂ \times 2^3

  • sign: 1 (negative)
  • exponent: 3 + 127 = 130 = 10000010₂
  • mantissa: 1001 তারপর ১৯টা 0 দিয়ে ২৩ বিট পূরণ: 10010000000000000000000

পুরো pattern: 1 10000010 10010000000000000000000 = 0xC1480000

কোনো rounding দরকার হয়নি — 12.5 এর বাইনারি expansion সসীম (কারণ 12.5 = 25/2, হর শুধু 2-এর ঘাত, আগের লেসনের নিয়ম অনুযায়ী)। এই ধরনের “গোল” সংখ্যা IEEE 754-এ exactly store হয় — কোনো error নেই।

উদাহরণ ২ — উল্টো দিকে: bit pattern থেকে decimal

দেওয়া আছে 0x42480000। এটা কোন সংখ্যা?

বাইনারি: 0100 0010 0100 1000 0000 0000 0000 0000

  • sign (বিট ৩১) = 0 → ধনাত্মক
  • exponent (বিট ৩০-২৩) = 10000100₂ = 132 → unbias: 132 - 127 = 5
  • mantissa (বিট ২২-০) = 1001000...01.1001₂-এর ভগ্নাংশ অংশ

significand=1.10012=1+12+116=1.5625\text{significand} = 1.1001_2 = 1 + \tfrac{1}{2} + \tfrac{1}{16} = 1.5625

value=1.5625×25=1.5625×32=50.0\text{value} = 1.5625 \times 2^5 = 1.5625 \times 32 = 50.0

উত্তর: 50.0 — আরেকটা exact, “গোল” সংখ্যা।

দুইটা উদাহরণই ইচ্ছাকৃতভাবে exact বেছে নেওয়া হয়েছে যাতে encode/decode প্রক্রিয়াটা rounding-এর জটিলতা ছাড়া স্পষ্ট দেখা যায়। “hood” অংশের 0.1 উদাহরণটা মনে করুন — সেখানেই আসল জটিলতা: বেশিরভাগ বাস্তব-জীবনের দশমিক সংখ্যাই exact নয়, round করতে হয়।

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

EXPERIMENT

যেকোনো float-এর raw bits দেখুন — struct দিয়ে

Python 3· ১০ মিনিট
import struct

def show_float32(x: float):
    packed = struct.pack('>f', x)          # big-endian 4 byte
    hex_str = packed.hex()
    bits = ''.join(f'{b:08b}' for b in packed)
    sign, exp, mant = bits[0], bits[1:9], bits[9:]
    print(f"value        : {x}")
    print(f"hex          : 0x{hex_str.upper()}")
    print(f"bits         : {sign} {exp} {mant}")
    print(f"biased exp   : {int(exp, 2)}  (unbiased: {int(exp, 2) - 127})")
    # আসল stored decimal মান — struct দিয়ে ফেরত আনি
    (back,) = struct.unpack('>f', packed)
    print(f"stored exact : {back!r}")
    print()

show_float32(0.1)
show_float32(-12.5)
show_float32(50.0)
show_float32(0.0)
show_float32(-0.0)
show_float32(float('inf'))
show_float32(float('nan'))

প্রত্যাশিত output:

value        : 0.1
hex          : 0x3DCCCCCD
bits         : 0 01111011 10011001100110011001101
biased exp   : 123  (unbiased: -4)
stored exact : 0.10000000149011612

value        : -12.5
hex          : 0xC1480000
bits         : 1 10000010 10010000000000000000000
biased exp   : 130  (unbiased: 3)
stored exact : -12.5

value        : 50.0
hex          : 0x42480000
bits         : 0 10000100 10010000000000000000000
biased exp   : 132  (unbiased: 5)
stored exact : 50.0

value        : 0.0
hex          : 0x00000000
...

value        : -0.0
hex          : 0x80000000
...

value        : inf
hex          : 0x7F800000
...

value        : nan
hex          : 0x7FC00000
...

লক্ষ্য করুন 0x3DCCCCCD আর stored exact : 0.10000000149011612 — হাতে করা হিসাবের সাথে হুবহু মিলে যাচ্ছে। আর nan-এর হেক্স 0x7FC00000 — mantissa-র MSB 1, যেটা এই লেসনে দেখানো quiet NaN convention-এর সাথে মেলে।

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

আমরা হাতে যে 0.1f এনকোডিং বের করেছি (0x3DCCCCCD), সেটাই সত্যিকারের hardware/interpreter store করে — হাতের হিসাব আর মেশিনের বাস্তবতা মিলে যায়।

EXPERIMENT

Special value-এর আচরণ — signed zero, Infinity, NaN

Python 3, বা C· ১০ মিনিট
import math

# signed zero
print(0.0 == -0.0)              # True — সমান
print(math.copysign(1, 0.0))    # 1.0
print(math.copysign(1, -0.0))   # -1.0  — কিন্তু sign আলাদা!
print(1.0 / 0.0 if False else "skip")  # (Python এ ZeroDivisionError ওঠে, C-তে ওঠে না)

# NaN
nan = float('nan')
print(nan == nan)               # False
print(nan != nan)               # True  — NaN চেনার পুরনো পদ্ধতি
print(math.isnan(nan))          # True

# subnormal — স্বাভাবিক arithmetic দিয়েই পৌঁছানো যায়
import struct
tiny = struct.unpack('>f', struct.pack('>f', 1e-40))[0]
print(tiny)                     # একটা subnormal মান — normal-এর সীমার নিচে
packed = struct.pack('>f', tiny)
bits = ''.join(f'{b:08b}' for b in packed)
print(f"exponent bits: {bits[1:9]}")   # সব 0 — subnormal-এর চিহ্ন

C-তে infinity সরাসরি দেখা যায় (কোনো exception ছাড়াই):

#include <stdio.h>
#include <math.h>

int main(void) {
    float a = 1.0f, zero = 0.0f, negzero = -0.0f;
    printf("1/0.0  = %f\n", a / zero);      /* inf */
    printf("1/-0.0 = %f\n", a / negzero);   /* -inf */
    printf("0.0 == -0.0 : %d\n", zero == negzero);   /* 1 (true) */

    float nan = 0.0f / 0.0f;
    printf("nan == nan : %d\n", nan == nan); /* 0 (false)! */
    printf("isnan(nan) : %d\n", isnan(nan));  /* 1 */
    return 0;
}

প্রত্যাশিত output (C):

1/0.0  = inf
1/-0.0 = -inf
0.0 == -0.0 : 1
nan == nan : 0
isnan(nan) : 1

int হলে a / zero সাথে সাথে crash করত (SIGFPE)। এখানে floating-point চুপচাপ inf ফেরত দিয়ে চালিয়ে যাচ্ছে — এটাই আগের অংশে বলা “exception ছাড়া হিসাব চালিয়ে যাওয়া”-র ডিজাইন দর্শন।

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

±0 সমান কিন্তু identical নয়, NaN নিজের সাথেও অসমান, আর subnormal সংখ্যা স্বাভাবিক arithmetic-এই তৈরি হতে পারে — এগুলো edge case না, প্রতিদিনের arithmetic-এর অংশ।

নিজে বানান

BUILD IT

একটা float-এর raw bits ডিকোড করে sign/exponent/mantissa আলাদা করুন

Python এবং C · ●●●○○
  1. float-এর raw বাইট বের করুন (Python: struct.pack; C: union বা memcpy)
  2. সেই বাইট থেকে sign, exponent, mantissa তিনটা field আলাদা করে বের করুন bit-shifting দিয়ে
  3. exponent-কে unbias করুন এবং normal/subnormal/special case নির্ণয় করুন
  4. mantissa-কে fraction-এ রূপান্তর করে (implicit বিট যোগ করে) চূড়ান্ত decimal মান পুনর্গঠন করুন
  5. পুনর্গঠিত মান আসল input-এর সাথে মিলিয়ে ১০০টা random float-এ যাচাই করুন

এই একটা exercise-ই এই পুরো লেসনের সারমর্ম — যদি আপনি নিজে bits থেকে সংখ্যা পুনর্গঠন করতে পারেন, IEEE 754 আর কোনো “black box” থাকবে না।

Python — struct দিয়ে:

import struct

def decode_float32(x: float) -> dict:
    raw = struct.unpack('>I', struct.pack('>f', x))[0]   # 32-bit unsigned int হিসেবে পড়া

    sign = (raw >> 31) & 0x1
    exp_field = (raw >> 23) & 0xFF
    mant_field = raw & 0x7FFFFF          # নিচের 23 বিট

    if exp_field == 0 and mant_field == 0:
        kind, value = 'zero', 0.0 if sign == 0 else -0.0
    elif exp_field == 0:
        kind = 'subnormal'
        value = ((-1) ** sign) * (mant_field / 2**23) * 2**(1 - 127)
    elif exp_field == 0xFF and mant_field == 0:
        kind, value = 'infinity', float('inf') if sign == 0 else float('-inf')
    elif exp_field == 0xFF:
        kind, value = 'nan', float('nan')
    else:
        kind = 'normal'
        significand = 1 + mant_field / 2**23    # implicit leading 1 যোগ
        value = ((-1) ** sign) * significand * 2**(exp_field - 127)

    return {
        'sign': sign, 'exponent_field': exp_field, 'mantissa_field': mant_field,
        'kind': kind, 'reconstructed': value,
    }


for x in [0.1, -12.5, 50.0, 0.0, -0.0, float('inf'), 1e-40]:
    d = decode_float32(x)
    print(f"{x!r:>12} → sign={d['sign']} exp={d['exponent_field']:>3} "
          f"kind={d['kind']:>9}  reconstructed={d['reconstructed']!r}")

C — union দিয়ে type punning (নিরাপদ পদ্ধতি):

#include <stdio.h>
#include <stdint.h>
#include <math.h>

typedef union {
    float f;
    uint32_t bits;
} float_bits;

void decode_float32(float x) {
    float_bits fb;
    fb.f = x;                          /* একই memory, দুইভাবে পড়া */
    uint32_t raw = fb.bits;

    int sign = (raw >> 31) & 0x1;
    int exp_field = (raw >> 23) & 0xFF;
    uint32_t mant_field = raw & 0x7FFFFF;

    printf("value=%g  sign=%d  exp_field=%d  mant_field=0x%06X\n",
           x, sign, exp_field, mant_field);

    if (exp_field == 0xFF) {
        printf("  -> %s\n", mant_field == 0 ? "infinity" : "NaN");
    } else if (exp_field == 0 && mant_field == 0) {
        printf("  -> zero\n");
    } else if (exp_field == 0) {
        double val = (mant_field / 8388608.0) * pow(2, -126);
        printf("  -> subnormal, reconstructed = %.20g\n", sign ? -val : val);
    } else {
        double significand = 1.0 + mant_field / 8388608.0;   /* 2^23 = 8388608 */
        double val = significand * pow(2, exp_field - 127);
        printf("  -> normal, reconstructed = %.20g\n", sign ? -val : val);
    }
}

int main(void) {
    decode_float32(0.1f);
    decode_float32(-12.5f);
    decode_float32(50.0f);
    return 0;
}

নিজে বাড়ান:

  1. Double precision (৬৪-বিট) সংস্করণ লিখুন — একই যুক্তি, শুধু bit width আর bias বদলাবে (11 বিট exponent, bias 1023, 52 বিট mantissa)
  2. encode_float32(sign, exponent, mantissa) — উল্টো দিকের function লিখুন, যেটা তিনটা field থেকে raw bits জোড়া লাগায়
  3. একটা function লিখুন যেটা বলে দেবে দুইটা float ঠিক কতগুলো “representable step” (ULP — unit in the last place) দূরে, raw bit pattern-কে integer হিসেবে বিয়োগ করে (biased exponent trick-এর সরাসরি প্রয়োগ!)
  4. 0.1f + 0.2f-এর bits decode করুন, 0.3f-এর bits-এর সাথে তুলনা করুন — এটাই পরের লেসনের শুরুর বিন্দু

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

IEEE 754 যেখানে প্রতিদিন কাজ করছে

JavaScript-এর একমাত্র number type। JavaScript-এ 1, 1.5, 1e300 — সবই একই টাইপ: IEEE 754 double। এমনকি “integer”ও। ফলাফল: Number.MAX_SAFE_INTEGER = 2^{53} - 1 — কারণ double-এর ৫২-বিট mantissa + implicit বিট মিলে ৫৩ বিট পর্যন্ত integer exactly represent করতে পারে, তার বেশি হলে সব integer representable থাকে না (2^{53} + 1 কে double-এ store করলে 2^{53} হয়ে যায়!)।

GPU shader-এর মূল ভাষা। প্রতিটা GPU shader core native-ভাবে float32 (কখনো float16) arithmetic-এ ডিজাইন করা — graphics pipeline-এর প্রতিটা vertex transform, প্রতিটা pixel color হিসাব এই bit layout-এর উপর নির্ভর করে।

Machine learning-এর reduced-precision format। আধুনিক deep learning training-এ full float32 প্রায়ই অপ্রয়োজনীয় ব্যয়বহুল (memory + compute)। তাই দুইটা ১৬-বিট বিকল্প জনপ্রিয় —

  • IEEE float16 (৫ exponent, ১০ mantissa) — কম range, বেশি precision।
  • bfloat16 (Google-এর ডিজাইন, TPU-তে native) — float32-এর সমান ৮ exponent বিট রাখে (তাই একই range, overflow-এর ভয় কম) কিন্তু মাত্র ৭ mantissa বিট। ML training-এ range বেশি গুরুত্বপূর্ণ (gradient বিশাল বা ক্ষুদ্র হতে পারে), precision-এর ক্ষতি সহনীয় — তাই এই trade-off ইচ্ছাকৃত।

PostgreSQL/MySQL-এর REAL/DOUBLE PRECISION সরাসরি IEEE 754 single/double — SQL standard specifically IEEE 754 reference করে যখন এই টাইপগুলো সংজ্ঞায়িত করে।

Denormal number আর audio DSP-এর performance bug। অনেক CPU-তে (বিশেষত পুরনো x86) subnormal number নিয়ে arithmetic normal সংখ্যার চেয়ে ১০-১০০ গুণ ধীর — কারণ hardware fast-path সেগুলো হ্যান্ডেল করে না, একটা slow microcode fallback লাগে। Audio processing chain-এ silence-এর পর একটা exponentially-decaying signal (যেমন reverb tail) ধীরে ধীরে subnormal range-এ নেমে যেতে পারে — হঠাৎ পুরো audio thread CPU খেয়ে ফেলে, শ্রবণযোগ্য glitch তৈরি করে। প্রতিকার: CPU-র “flush-to-zero” (FTZ) আর “denormals-are-zero” (DAZ) mode চালু করা (x86-এ MXCSR register বিট), যেটা subnormal-কে সরাসরি শূন্যে round করে দেয় — precision-এর সামান্য ক্ষতির বিনিময়ে predictable performance।

HDR ইমেজ ফরম্যাট। ILM-এর OpenEXR (চলচ্চিত্র VFX শিল্পে ব্যবহৃত HDR ইমেজ format) প্রতি pixel channel-এ IEEE float16 ব্যবহার করে — কারণ HDR-এ brightness range খুব বিশাল (সূর্যের আলো থেকে ছায়া পর্যন্ত), যা 8-bit বা এমনকি uint16 integer দিয়ে ধরা যায় না, কিন্তু float16-এর floating exponent দিয়ে সহজেই যায়।

NaN-কে “missing data”-র সংকেত হিসেবে ব্যবহার। বৈজ্ঞানিক computing-এ (NumPy, pandas) NaN প্রায়ই “sensor থেকে ডেটা আসেনি” বা “গণনাযোগ্য নয়” বোঝাতে ব্যবহার হয় — আর IEEE 754-এর NaN-propagation নিয়ম (যেকোনো operation-এ NaN জড়ালে ফলাফল NaN) স্বয়ংক্রিয়ভাবে “অজানা” মান পুরো হিসাবের মধ্য দিয়ে ছড়িয়ে দেয়, প্রতিটা ধাপে manual check ছাড়াই।

File format-এর magic number হিসেবে বিটের ব্যবহার। এই লেসনের biased-exponent insight (raw bits-কে integer হিসেবে তুলনা করলে সঠিক ক্রম পাওয়া যায়) সরাসরি ব্যবহৃত হয় sorting algorithm আর database index-এ, যেখানে floating-point column-এর উপর ORDER BY বা B-tree index build করার সময় engine প্রায়ই raw bit representation-এর উপর ভিত্তি করেই তুলনা করে — কোনো আলাদা floating-point-নির্দিষ্ট comparator ছাড়াই, ঠিক এই লেসনের Question ৩-এর প্রমাণ অনুযায়ী।

Text-based diff tool আর floating-point-এর সংঘাত। Git-এর মতো version control system টেক্সট হিসেবে ফাইল তুলনা করে, bit বা মান হিসেবে নয় — তাই একটা scientific dataset-এ যদি সামান্য ভিন্ন floating-point library বা platform ব্যবহারে একই হিসাবের টেক্সট-রূপ সামান্য বদলায় (যেমন শেষ digit-এ), পুরো লাইনটাই “changed” দেখাবে, যদিও অন্তর্নিহিত মান কার্যত অভিন্ন। এই কারণে scientific/ML dataset version control-এ প্রায়ই tolerance-ভিত্তিক, floating-point-সচেতন diff tool ব্যবহার করা হয়, প্লেইন টেক্সট diff-এর বদলে।

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

“NaN == NaN true হওয়া উচিত — অন্য ভাষায় তো undefined == undefined true।”

IEEE 754-এর সিদ্ধান্ত ইচ্ছাকৃতভাবে উল্টো। NaN-এর অর্থ “এই মানটা কী তা সংজ্ঞায়িত নয়” — দুইটা ভিন্ন, অসম্পর্কিত হিসাব থেকে আসা দুইটা NaN আদৌ “একই” কি না বলাটাই অর্থহীন (0.0/0.0-এর NaN আর sqrt(-1.0)-এর NaN “একই” এই দাবি করার কোনো ভিত্তি নেই)।

তাই IEEE 754 বেছে নিয়েছে: NaN-এর সাথে যেকোনো তুলনা (এমনকি নিজের সাথেও) false, শুধু != ছাড়া (NaN != NaN সবসময় true)। এই একটা ব্যতিক্রমী নিয়মই দশকের পর দশক ধরে NaN চেনার সবচেয়ে নির্ভরযোগ্য, portable কৌশল ছিল — isnan() function সবসময় সব জায়গায় ছিল না।

“+0.0 আর −0.0 একই জিনিস, যেহেতু তুলনা করলে সমান আসে।”

0.0 == -0.0 সত্যিই True, কিন্তু bit pattern হিসেবে তারা সম্পূর্ণ ভিন্ন (0x00000000 বনাম 0x80000000), আর এই পার্থক্য observable:

>>> 1.0 / 0.0
inf
>>> 1.0 / -0.0
-inf

Equality operator (==) এই পার্থক্যটা ইচ্ছাকৃতভাবে আড়াল করে (কারণ বেশিরভাগ ব্যবহারিক ক্ষেত্রে +0 আর -0 সমান আচরণ করা উচিত), কিন্তু division-এর মতো operation-এ sign-টা টিকে থাকে, কারণ সেটা limit-এর দিক নির্দেশ করে।

“Subnormal number একটা rare edge case, উপেক্ষা করলে ক্ষতি নেই।”

Subnormal একটা deliberate, প্রয়োজনীয় design feature — এটা ছাড়া শূন্যের কাছে একটা “sudden gap” থাকত, যেখানে দুইটা ভিন্ন সংখ্যা বিয়োগ করলে 0 আসতে পারত।

তবে এটা সত্য যে subnormal নিয়ে সাবধান থাকতে হয় performance-এর কারণে, correctness-এর কারণে নয় — অনেক CPU subnormal arithmetic অনেক ধীরে চালায় (আগের “বাস্তব সিস্টেমে” অংশের audio DSP উদাহরণ মনে করুন)। তাই real-time system-এ FTZ/DAZ চালু করা সাধারণ practice — কিন্তু এটা একটা conscious trade-off (সামান্য precision বনাম predictable speed), “subnormal গুরুত্বহীন” এই বিশ্বাসের কারণে নয়।

“বেশি exponent বিট মানেই বেশি precision।”

উল্টো ভাগ — exponent বিট নিয়ন্ত্রণ করে range, mantissa বিট নিয়ন্ত্রণ করে precision। এই দুটো সম্পূর্ণ স্বতন্ত্র resource, আর তাদের মধ্যে trade-off করা যায়।

float16 (৫ exponent, ১০ mantissa) বনাম bfloat16 (৮ exponent, ৭ mantissa) — দুটোই ১৬ বিট মোট, কিন্তু সম্পূর্ণ ভিন্ন trade-off: bfloat16-এর range float32-এর সমান (বেশি exponent বিট), কিন্তু precision কম (float16-এর চেয়েও কম mantissa বিট)। কোনটা “ভালো” সম্পূর্ণ নির্ভর করে ব্যবহারের উপর — deep learning training-এ range (overflow এড়ানো) বেশি গুরুত্বপূর্ণ, তাই bfloat16 জনপ্রিয়; scientific simulation-এ প্রায়ই precision বেশি গুরুত্বপূর্ণ।

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

1

Raw bit pattern 0xC1480000 দেওয়া আছে (এটা IEEE 754 single precision)। sign, exponent, mantissa আলাদা করে চূড়ান্ত decimal মান বের করুন।

প্রয়োগ

হেক্স 0xC1480000-কে বাইনারিতে রূপান্তর করি (প্রতিটা হেক্স digit = ৪ বিট):

C = 1100    1 = 0001    4 = 0100    8 = 1000    0000 0000 0000 0000

পূর্ণ ৩২ বিট: 1100 0001 0100 1000 0000 0000 0000 0000

Field আলাদা করা:

  • sign (বিট ৩১) = 1 → ঋণাত্মক
  • exponent (বিট ৩০-২৩, ৮ বিট) = 10000010 = 130
  • mantissa (বিট ২২-০, ২৩ বিট) = 10010000000000000000000

Unbias: 130 - 127 = 3

Significand: 1.M = 1.10010000000000000000000₂ = 1 + 2^{-1} + 2^{-4} = 1 + 0.5 + 0.0625 = 1.5625

চূড়ান্ত মান:

(1.5625×23)=(1.5625×8)=12.5-(1.5625 \times 2^3) = -(1.5625 \times 8) = -12.5

এটা এই লেসনের “example” অংশে হাতে এনকোড করা -12.5-এরই ঠিক উল্টো (decode) প্রক্রিয়া — এনকোড আর ডিকোড একে অপরের সম্পূর্ণ বিপরীত অপারেশন, আর এই সংখ্যাটা এনকোড/ডিকোড দুইদিকেই exact (কোনো rounding error নেই), কারণ 12.5-এর বাইনারি expansion সসীম।

2

100.375-কে ৩২-বিট IEEE 754-এ হাতে এনকোড করুন। (ইঙ্গিত: 0.375 = 0.011₂)

প্রয়োগ

পূর্ণসংখ্যা অংশ: 100 = 1100100₂ (64+32+4=100)

ভগ্নাংশ অংশ: 0.375 = 0.25 + 0.125 = 2^{-2} + 2^{-3} = 0.011₂

পূর্ণ বাইনারি: 100.375 = 1100100.011₂

Normalize: radix point-কে বাঁয়ে ৬ ঘর সরাতে হবে যাতে প্রথম 1-এর ঠিক পরে বিন্দু আসে:

1100100.0112=1.1001000112×261100100.011_2 = 1.100100011_2 \times 2^6

Exponent: 6 + 127 = 133 = 10000101₂

Mantissa: 1. -এর পরের অংশ 100100011 (৯ বিট), ২৩ বিট পূরণ করতে আরও ১৪টা 0 লাগবে:

10010001100000000000000\texttt{10010001100000000000000}

পূর্ণ pattern: 0 10000101 10010001100000000000000

হেক্সে: বিটগুলো নিবলে ভাগ করি — 0100 0010 1100 1000 1100 0000 0000 0000 = 0x42C8C000

এখানেও কোনো rounding দরকার হয়নি — 100.375-এর বাইনারি expansion সসীম, কারণ 0.375 = 3/8, হর 8 = 2^3 শুধু 2-এর ঘাত। এই লেসনের মূল শিক্ষা মনে করিয়ে দেয়: শুধু সেসব ভগ্নাংশ exact যাদের হর 2-এর ঘাত0.1-এর মতো বেশিরভাগ “স্বাভাবিক” দশমিক সংখ্যা এই কপালটা পায় না।

3

প্রমাণ করুন কেন biased exponent দুইটা ধনাত্মক float-কে raw bit pattern হিসেবে unsigned integer তুলনা করলে সঠিক numeric order দেয়। একটা concrete উদাহরণ দিয়ে দেখান।

যুক্তি

দাবি: দুইটা ধনাত্মক float a, b-এর জন্য, a \lt b (প্রকৃত সংখ্যা হিসেবে) হলে এবং শুধু তখনই তাদের raw ৩২-বিট pattern-কে unsigned integer পড়লে \text{bits}(a) \lt \text{bits}(b)

যুক্তি: Sign বিট দুটোরই 0 (ধরে নিচ্ছি ধনাত্মক), তাই তুলনাটা কার্যত exponent+mantissa field-এর (৩১ বিট) unsigned তুলনায় নেমে আসে। এই ৩১ বিটকে একটা single big-endian integer হিসেবে দেখুন — exponent field mantissa field-এর চেয়ে বেশি “গুরুত্বপূর্ণ” বিট পজিশনে বসে (আগে আসে), ঠিক দশমিকে শত-দশক-এক digit-এর মতো।

তাই:

  • যদি দুইটা সংখ্যার exponent ভিন্ন হয়, বড় exponent-এর সংখ্যাটার bit pattern অবশ্যই বড় হবে (mantissa যাই হোক না কেন) — কারণ exponent field উচ্চতর বিট position দখল করে।
  • আর যেহেতু exponent biased (তাই সবসময় একটা non-negative integer, monotonically সংখ্যার প্রকৃত exponent-এর সাথে সমানুপাতিক), বড় biased exponent মানেই বড় প্রকৃত exponent, মানেই বড় সংখ্যা।
  • যদি exponent সমান হয়, বড় mantissa field মানেই বড় significand, মানেই বড় সংখ্যা — mantissa field-ও সরাসরি unsigned integer হিসেবে সঠিক ক্রমে থাকে (কোনো bias লাগে না, কারণ mantissa সবসময় non-negative fraction)।

Concrete উদাহরণ (এই লেসন থেকেই):

  • 0.1f → exponent field 123, পুরো bits (unsigned int হিসেবে) = 0x3DCCCCCD = 1,036,831,949
  • 50.0f → exponent field 132, পুরো bits = 0x42480000 = 1,111,752,704

0.1 \lt 50.0 (প্রকৃত সংখ্যা), আর সত্যিই 1,036,831,949 \lt 1,111,752,704 (raw bit pattern, unsigned integer হিসেবে)। মিলে যায়।

যদি exponent two’s complement হতো, 0.1-এর মতো ছোট সংখ্যার (negative exponent) MSB 1 হতো — unsigned পড়লে সেটা একটা বিশাল সংখ্যা মনে হতো, বড় সংখ্যার (positive exponent, MSB 0) চেয়েও বড়। Order সম্পূর্ণ ভেঙে পড়ত। এটাই biased exponent-এর আসল কারণ — শুধু “negative সংখ্যা লেখার একটা উপায়” নয়, বরং একটা তুলনা-বান্ধব encoding

(Sign বিট ভিন্ন হলে বাড়তি একটা ধাপ লাগে — negative float-এর জন্য magnitude অনুযায়ী উল্টো ক্রম, কিন্তু সেই কেসটা হ্যান্ডেল করাও sign বিট আলাদা চেক করে সহজেই সম্ভব।)

4

Bit pattern 0 00000000 00000000000000000000001 (single precision) — সবচেয়ে ছোট non-zero mantissa, exponent field সব 0। এটা কোন সংখ্যা? এটা normal সংখ্যার ক্ষুদ্রতম মানের (≈1.18 × 10⁻³⁸) সাথে কীভাবে তুলনা করে?

প্রয়োগ

Exponent field সব 0 আর mantissa অ-শূন্য — টেবিল অনুযায়ী এটা subnormal

Subnormal সূত্র: value = 0.M × 2^{-126} (implicit বিট এখানে 1 নয়, 0)।

Mantissa field = 1 (দশমিকে), যার মান 0.M = 1 / 2^{23} = 1/8388608

value=1223×2126=223126=2149\text{value} = \frac{1}{2^{23}} \times 2^{-126} = 2^{-23-126} = 2^{-149}

21491.401×10452^{-149} \approx 1.401 \times 10^{-45}

এটাই single precision-এর সম্পূর্ণ ক্ষুদ্রতম representable positive সংখ্যা — সবচেয়ে ছোট subnormal।

তুলনা: ক্ষুদ্রতম normal 2^{-126} \approx 1.175 \times 10^{-38}

অনুপাত:

21262149=223=8,388,608\frac{2^{-126}}{2^{-149}} = 2^{23} = 8,388,608

অর্থাৎ ক্ষুদ্রতম normal সংখ্যা ক্ষুদ্রতম subnormal-এর চেয়ে প্রায় ৮৪ লক্ষ গুণ বড়। Subnormal range (2^{-149} থেকে 2^{-126} পর্যন্ত) precision হারাতে হারাতে ধীরে ধীরে শূন্যের দিকে নামে — gradual underflow-এর সংজ্ঞা অনুযায়ীই। ঠিক 2^{-149} থেকে 2 \times 2^{-149} = 2^{-148}-এ যেতে mantissa field 1 থেকে 2-এ বাড়ে (এখনো শুধু ১ বিট তথ্য), যেখানে normal range-এ প্রতিটা ধাপে পূর্ণ ২৩ বিট mantissa পাওয়া যায়।

5

ধরুন একটা hypothetical ৪-বিট mantissa float-এ (শুধু এই উদাহরণের জন্য সরলীকৃত) দুইটা representable মান আছে 10.0₂ (mantissa শেষ বিট 0, জোড়) আর 10.1₂ (পরের representable মান, মানে পরের mantissa 11.0, শেষ বিট 0 — অপেক্ষা করুন, এটা ভুল সেটআপ; বরং ভাবুন 1.010₂ \times 2^1 (mantissa শেষ বিট 0) আর 1.011₂ \times 2^1 (mantissa শেষ বিট 1) দুইটা পরপর representable মান, আর প্রকৃত ফলাফল ঠিক এই দুইয়ের মাঝখানে পড়েছে। Round-to-nearest-even কোনদিকে round করবে, আর round-half-up করলে কী পার্থক্য হতো?

ডিজাইন

এই পরিস্থিতি একটা classic tie — প্রকৃত ফলাফল ঠিক দুইটা representable মানের মাঝখানে, দুই দিক থেকেই সমান দূরত্বে।

দুইটা প্রার্থী:

  • 1.010₂ \times 2^1 — শেষ mantissa বিট 0 (জোড়)
  • 1.011₂ \times 2^1 — শেষ mantissa বিট 1 (বিজোড়)

Round-to-nearest-even নিয়ম: tie হলে সেই প্রতিবেশী বেছে নাও যার শেষ বিট 0। এখানে সেটা প্রথমটা — 1.010₂ \times 2^1

যদি round-half-up (সবসময় বড় মানের দিকে) হতো: তাহলে সবসময় 1.011₂ \times 2^1 (দ্বিতীয়টা) বেছে নেওয়া হতো, tie-এর দিক নির্বিশেষে — কারণ round-half-up শুধু “উপরের প্রতিবেশী কোনটা” জিজ্ঞেস করে, “কোনটা জোড়” নয়।

পার্থক্যটা কেন গুরুত্বপূর্ণ — পরিসংখ্যানগত যুক্তি:

কল্পনা করুন এমন হাজার হাজার tie-case ঘটছে একটা বড় সংখ্যক হিসাবে (যেমন একটা লম্বা সময় ধরে চলা simulation, বা একটা বড় dataset-এর উপর repeated averaging)। যদি প্রতিটা tie সবসময় বড় মানের দিকে round হয় (round-half-up), তাহলে প্রতিটা tie-এ গড়ে একটা ছোট, একই দিকের bias যোগ হয় — লক্ষ লক্ষ operation-এর পর চূড়ান্ত ফলাফল সত্যিকারের মানের চেয়ে পদ্ধতিগতভাবে বড় দেখাতে পারে।

Round-to-nearest-even-এ, tie কোনদিকে যাবে তা নির্ভর করে “কোন প্রতিবেশীর শেষ বিট জোড়” তার উপর — যেটা ব্যবহারিকভাবে data-নির্ভর, এলোমেলো (কোনো systematic pattern নেই যে সবসময় উপরে বা সবসময় নিচে যাবে)। ফলে বহু tie-এর গড়ে bias প্রায় বাতিল হয়ে যায় — round-half-up-এর তুলনায় statistically নিরপেক্ষ।

এই একই যুক্তি accounting/statistics-এ “banker’s rounding” নামে পরিচিত — bank-এর সুদের হিসাবে বহু ছোট ছোট rounding হয়, আর সবসময় একদিকে bias করলে দীর্ঘমেয়াদে ব্যাংকের পক্ষে (বা বিপক্ষে) একটা পদ্ধতিগত সুবিধা/ক্ষতি জমতে থাকত।

6

“hood” অংশে আমরা 0.1-কে হাতে এনকোড করেছি। এবার নিজে করুন — 1/3-কে ৩২-বিট IEEE 754-এ হাতে এনকোড করুন, rounding সহ। চূড়ান্ত হেক্স মান দিন।

প্রয়োগ

ধাপ ১ — normalize। 1/3 আছে 2^{-2}=0.25 আর 2^{-1}=0.5-এর মাঝে, তাই e=-2

1/322=13×4=43=1.333...\frac{1/3}{2^{-2}} = \frac{1}{3} \times 4 = \frac{4}{3} = 1.333...

ধাপ ২ — 0.333...-এর বাইনারি বের করা। আগের লেসনে (fixed vs floating) আমরা দেখিয়েছি 1/3 = 0.(01)_2 — পুনরাবৃত্তি দৈর্ঘ্য , কারণ 2-এর multiplicative order mod 3 হলো

1.333..._{10} = 1.\overline{01}_2 = 1.01010101010101...__2

ধাপ ৩ — ২৩ বিটে কাটা, round করা। Bit পজিশন বিজোড় হলে 0, জোড় হলে 1 (প্যাটার্ন 01 repeating)। বিট ২৩ (বিজোড়) = 0, বিট ২৪ (round bit, জোড়) = 1

বিট ২৪ =1, আর তার পরেও নন-জিরো বিট আছে (বিট ২৬, ২৮…) — tie নয়, স্পষ্ট round-up।

২৩-বিট stored mantissa (round করার আগে): 01010101010101010101010 (১১ বার 01, তারপর 0) — শেষ বিট 0, round-up করলে 1 হয়ে যায়:

01010101010101010101010    01010101010101010101011\texttt{01010101010101010101010} \;\longrightarrow\; \texttt{01010101010101010101011}

ধাপ ৪ — exponent বায়াস করা।

E=2+127=125=011111012E = -2 + 127 = 125 = \texttt{01111101}_2

ধাপ ৫ — জোড়া লাগানো।

sign      exponent    mantissa (23 bit)
  0      01111101     01010101010101010101011

হেক্সে: 0x3EAAAAAB

এই মানটাই struct.pack('>f', 1/3) চালালে সত্যিই পাওয়া যায় — এই লেসনের প্রথম Experiment-এ show_float32(1/3) যোগ করে নিজে যাচাই করে দেখুন। লক্ষ্য করুন 0.1-এর মতো এখানেও mantissa-র প্যাটার্ন পুনরাবৃত্ত (01 বনাম 0.1-এর 1001) — কারণ দুটোই আসলে একই ধরনের সমস্যা: হর-এ 2-বহির্ভূত মৌলিক উৎপাদক থাকা ভগ্নাংশ, যা বাইনারিতে কখনো সসীম হতে পারে না।

এরপর কী

এরপর কী — যখন এই ছোট্ট error-গুলো জমতে শুরু করে

এই লেসনে আমরা দেখেছি 0.1f-এর আসল stored মান 0.1000000014901161193847656250.1-এর চেয়ে সামান্য বেশি। একটা সংখ্যায় এই পার্থক্য অকল্পনীয়ভাবে ছোট — কার্যত অদৃশ্য।

কিন্তু প্রশ্ন হলো: এই ছোট্ট error যখন হাজার, লক্ষ, কোটিবার arithmetic operation-এর মধ্য দিয়ে যায়, তখন কী হয়? কখনো তারা একে অপরকে বাতিল করে, কখনো জমতে থাকে, আর কখনো কখনো একটা একক subtraction-ই বিশাল একটা relative error তৈরি করে দেয় (catastrophic cancellation)।

পরের লেসনে আমরা দেখব:

  • কেন 0.1 + 0.2 \ne 0.3 — এই লেসনের bit-level knowledge দিয়ে সরাসরি প্রমাণ
  • Catastrophic cancellation — দুইটা প্রায়-সমান বড় সংখ্যা বিয়োগ করলে কীভাবে নির্ভুলতা ধ্বংস হয়, একটা সম্পূর্ণ hand-worked উদাহরণ সহ
  • Machine epsilon আর কেন float-দুটোকে কখনো সরাসরি == দিয়ে তুলনা করা উচিত নয়
  • Kahan summation — কীভাবে একটা চতুর algorithm accumulated rounding error প্রায় বাতিল করে দেয়
  • ১৯৯১ সালের Patriot missile ব্যর্থতা — একটা truncated 0.1-এর error কীভাবে ১০০ ঘণ্টা ধরে জমে একটা মারাত্মক ব্যর্থতায় রূপ নিয়েছিল

আজকের লেসনের প্রতিটা bit layout, প্রতিটা rounding rule — সবকিছুই সেই গল্পগুলোর ভিত্তি।

আরও পড়ুন

  • IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019) — IEEE Computer Society · মূল standard — প্রতিটা bit field-এর আনুষ্ঠানিক সংজ্ঞা
  • What Every Computer Scientist Should Know About Floating-Point Arithmetic — David Goldberg (1991) · IEEE 754-এর সবচেয়ে বেশি উদ্ধৃত, প্রামাণ্য ব্যাখ্যা
  • Lecture Notes on the Status of IEEE Standard 754 for Binary Floating-Point Arithmetic — William Kahan · standard-এর মূল স্থপতির নিজের বয়ানে ডিজাইন সিদ্ধান্তগুলোর যুক্তি