Fixed-Point বনাম Floating-Point — দশমিক সংখ্যা bit-এ কীভাবে বাঁচে
Fixed-Point vs Floating-Point Representation
সংখ্যার ভগ্নাংশ আর বিশাল dynamic range ধারণ করতে দুইটা পথ — fixed-point একটা স্থির scale-এর integer, floating-point একটা 'ভাসমান বিন্দুর' scientific notation; দুটোর মধ্যেকার trade-off আর IEEE 754-এর জন্মকথা।
আগে এটা বুঝি
আগের লেসনে আমরা integer overflow দেখেছি — একটা fixed সংখ্যক bit-এ কতদূর পর্যন্ত গোনা যায় তার একটা কঠোর সীমা আছে। কিন্তু integer-এর আরেকটা সীমাবদ্ধতা আছে, আরও মৌলিক: integer দিয়ে ভগ্নাংশ লেখাই যায় না।
3 / 2 যদি integer division হয়, উত্তর 1 — বাকি অর্ধেকটা কোথায়
গেল? পদার্থবিজ্ঞানের একটা মৌলিক ধ্রুবক লিখতে চান?
ইলেকট্রনের ভর ≈ 0.000000000000000000000000000000911 kg
অ্যাভোগাড্রো সংখ্যা ≈ 602,000,000,000,000,000,000,000দুটোই বৈধ সংখ্যা, কিন্তু ম্যাগনিচিউডে ৫৬ ডিজিটের ব্যবধান। কোনো ৩২-বিট integer এই দুটোকেই একই সাথে অর্থবহভাবে ধরে রাখতে পারবে না — একটা প্রায় শূন্য দেখাবে, আরেকটা overflow করবে।
তাহলে প্রশ্ন দুইটা, আলাদা কিন্তু সম্পর্কিত:
- ভগ্নাংশ কীভাবে bit-এ লেখা যায়?
- অবিশ্বাস্য রকম ছোট আর অবিশ্বাস্য রকম বড় — দুটোই একই fixed bit বাজেটে কীভাবে ধরা যায়?
এই লেসনে আমরা দুইটা উত্তর দেখব — fixed-point, যেটা প্রথম প্রশ্নের একটা সরল, সস্তা সমাধান কিন্তু দ্বিতীয় প্রশ্নে ব্যর্থ হয়; আর floating-point, যেটা দুটোই সমাধান করে কিন্তু একটা নতুন সমস্যা নিয়ে আসে — যেটা লেসন ৯-এ আমরা পুরোপুরি খুলে দেখব।
মূল ধারণা
Fixed-point — সবচেয়ে সরল উত্তর
ধারণাটা প্রায় প্রতারণামূলক রকম সরল: আপনি যা-ই সংখ্যা চান, সেটাকে একটা বড় ধ্রুবক দিয়ে গুণ করে integer বানিয়ে ফেলুন, আর মনে মনে মনে রাখুন সেই গুণকটা কত ছিল।
উদাহরণ — টাকা-পয়সার হিসাব। ১৯.৯৯ টাকা লিখতে চান? গুণ করুন
১০০ দিয়ে, রাখুন ১৯৯৯ — মানে পয়সায়। CPU-র চোখে এটা
এখন নিছক একটা integer, কিন্তু আপনার program জানে এই integer-কে
১০০ দিয়ে ভাগ করলে আসল মান পাওয়া যাবে। এই ১০০-টাই scale
factor — bit pattern নিজে সেটা বহন করে না, সফটওয়্যার এটা
জানে, ঠিক যেমন গত লেসনের bit pattern-এর interpretation
convention-এর মতো।
Binary computer-এ decimal ১০০-এর বদলে ২-এর ঘাত ব্যবহার
করা হয় — কারণ তখন গুণ-ভাগ শুধু bit shift, যেটা CPU-র জন্য
প্রায় বিনামূল্যে (এক cycle)।
Q-format — সেই ধারণাটার আনুষ্ঠানিক নাম
Qm.n notation বলে: m বিট পূর্ণসংখ্যা অংশের জন্য (sign
সহ), n বিট ভগ্নাংশ অংশের জন্য। মোট bit সংখ্যা m + n।
Q16.16 মানে: ৩২-বিট signed integer, যার নিচের ১৬ বিট
ভগ্নাংশ, উপরের ১৬ বিট পূর্ণসংখ্যা (sign সহ)। Scale factor
2^16 = 65536।
বিট: 31 16 15 0
┌────────────────────────┬──────────────────────────┐
│ পূর্ণসংখ্যা অংশ (16) │ ভগ্নাংশ অংশ (16) │
│ sign সহ, two's │ weight: 2⁻¹...2⁻¹⁶ │
│ complement │ │
└────────────────────────┴──────────────────────────┘
মান = raw_int32 / 65536| Q-format | মোট bit | পূর্ণসংখ্যা bit | ভগ্নাংশ bit | Range | Precision |
|---|---|---|---|---|---|
| Q8.8 | 16 | 8 | 8 | −128.0 … 127.996 | 1/256 ≈ 0.0039 |
| Q16.16 | 32 | 16 | 16 | −32768.0 … 32767.99998 | 1/65536 ≈ 0.0000153 |
| Q1.31 | 32 | 1 | 31 | −1.0 … 0.9999999995 | 1/2³¹ ≈ 4.7×10⁻¹⁰ |
| Q4.28 | 32 | 4 | 28 | −8.0 … 7.999999996 | 1/2²⁸ ≈ 3.7×10⁻⁹ |
লক্ষ্য করুন Q1.31 — এটাই audio DSP-র প্রিয় format। Audio
sample প্রায় সবসময় −1.0-থেকে-1.0-এর মধ্যে normalize করা
থাকে, তাই পুরো ৩১ বিটই precision-এ ঢালা যায়, একটা বিটও পূর্ণসংখ্যা
অংশে নষ্ট হয় না।
বাস্তব DSP library-তে এই নামগুলো (Q15, Q31 ইত্যাদি) প্রায়ই
আরেকটা convention-এ লেখা হয় — শুধু ভগ্নাংশ bit সংখ্যা, কারণ
পূর্ণসংখ্যা অংশ ধরে নেওয়া হয় সবসময় 1 (শুধু sign)। ARM-এর
নিজস্ব CMSIS-DSP library, বা পুরনো Texas Instruments TMS320
codec — দুটোই এই একই সংক্ষিপ্ত নামকরণ ব্যবহার করে:
| সংক্ষিপ্ত নাম | পূর্ণ Q-format | ব্যবহার |
|---|---|---|
Q15 | Q1.15 (১৬ বিট মোট) | 16-bit audio codec, ADC/DAC sample |
Q31 | Q1.31 (৩২ বিট মোট) | high-precision audio, sensor fusion |
Q7 | Q1.7 (৮ বিট মোট) | 8-bit microcontroller-এর সস্তা sensor filter |
Round করা বনাম কেটে ফেলা (truncation) — এনকোডিং-এর একটা সিদ্ধান্ত
উপরের সূত্রে \text{round}(x \times 2^n) লেখা হয়েছে — কিন্তু
“round” ঠিক কীভাবে? এখানেই fixed-point ডিজাইনের একটা সূক্ষ্ম
কিন্তু গুরুত্বপূর্ণ সিদ্ধান্ত লুকানো, যেটা এই লেসনের শেষ দিকে
(এবং পরের module-এর লেসনগুলোয়) বারবার ফিরে আসবে।
দুইটা সহজ বিকল্প:
- Truncate (chop):
x \times 2^n-এর ভগ্নাংশ অংশ শুধু ফেলে দেওয়া — C-তে(int)(x * ONE)লিখলে এটাই ঘটে (ধনাত্মক সংখ্যায় শূন্যের দিকে কাটা)। - Round-to-nearest: নিকটতম integer-এ যাওয়া — `(int)(x * ONE
- 0.5)` (ধনাত্মকের জন্য)।
পার্থক্যটা তুচ্ছ মনে হতে পারে — প্রতিটা conversion-এ error
সর্বোচ্চ এক ULP (1/2^n)। কিন্তু দিকটা গুরুত্বপূর্ণ:
truncation-এর error সবসময় একই দিকে (ধনাত্মক সংখ্যায় সবসময়
নিচের দিকে), যেখানে round-to-nearest-এর error দুই দিকেই সমান
সম্ভাবনায় ছড়ায়।
Q-format library ডিজাইন করার সময় তাই round-to-nearest (এবং আদর্শভাবে, tie-case-এ round-to-even — IEEE 754-এর পরের লেসনে পুরোপুরি আলোচনা হবে) ডিফল্ট হিসেবে বেছে নেওয়া হয়, যদি না performance-এর কারণে সচেতনভাবে truncation বেছে নেওয়া হচ্ছে।
Arithmetic — শুধুই integer arithmetic, একটা বাড়তি নিয়মসহ
যোগ-বিয়োগ: যদি দুটো সংখ্যার scale একই হয়, শুধু raw integer দুটো যোগ করলেই হয় — scale factor আপনা-আপনি মিলে যায়।
গুণ: এখানেই সাবধান হতে হয়। দুটো Q-format সংখ্যা গুণ করলে
scale factor-ও গুণ হয়ে যায় — 2^n × 2^n = 2^(2n), তাই ফলাফলকে
আবার n বিট ডানে shift করে সঠিক scale-এ ফেরাতে হয়:
আর a × b সাময়িকভাবে মূল bit width-এর দ্বিগুণ জায়গা লাগতে
পারে — তাই বাস্তব implementation-এ সবসময় একটা চওড়া intermediate
type (যেমন int64_t) ব্যবহার করা হয়, শেষে shift করে আবার
আসল width-এ নামিয়ে আনা হয়।
ভাগ: উল্টো দিকে — আগে numerator-কে n বিট বাঁয়ে shift
করে (precision হারানো আটকাতে), তারপর ভাগ করুন:
Saturating arithmetic — overflow-কে wraparound না করে থামানো
আগের লেসনে আমরা দেখেছি integer overflow-এর তিনটা policy — checked, wrapping, saturating। Fixed-point যেহেতু ভেতরে আসলে integer-ই, এই একই তিনটা policy এখানেও প্রযোজ্য — আর audio/DSP-তে saturating policy-ই সবচেয়ে বেশি ব্যবহৃত হয়।
কেন wrapping বিপজ্জনক এখানে? ধরুন একটা audio sample Q1.31
format-এ সর্বোচ্চ মান (0.999...) ছাড়িয়ে গেছে (দুইটা জোরে
শব্দ যোগ হয়ে)। যদি wraparound হয় (আগের লেসনের মতো), মান
হঠাৎ সর্বোচ্চ ধনাত্মক থেকে সর্বোচ্চ ঋণাত্মকে লাফ দেবে —
speaker-এ এটা শোনাবে একটা কর্কশ, আকস্মিক “পপ” শব্দ হিসেবে।
Saturating arithmetic-এ বদলে মান সীমার (min/max) মধ্যে আটকে (clamp) রাখা হয় — শব্দ বিকৃত (distorted) হয়, কিন্তু আকস্মিক বিপরীত মেরুতে লাফ দেয় না। মানুষের কান বিকৃতি সহ্য করতে পারে, আকস্মিক পোলারিটি-পরিবর্তন নয়।
#include <stdint.h>
#include <limits.h>
int32_t fx_add_saturating(int32_t a, int32_t b) {
int64_t wide = (int64_t)a + (int64_t)b; /* wide intermediate — এই লেসনের বারবার আসা নীতি */
if (wide > INT32_MAX) return INT32_MAX;
if (wide \< INT32_MIN) return INT32_MIN;
return (int32_t)wide;
}ARM-এর কিছু DSP-নির্দিষ্ট instruction set extension (যেমন
Cortex-M-এর DSP instruction, বা পুরনো ARM SIMD QADD) হার্ডওয়্যারেই
saturating add/subtract সরাসরি সমর্থন করে — একটা single cycle-এ,
কোনো software branch ছাড়াই, ঠিক এই কারণে যে audio/video/sensor
processing-এ এই pattern এত সাধারণ যে হার্ডওয়্যার-এ বেক করে দেওয়াই
লাভজনক।
কেন fixed-point যথেষ্ট না
Fixed-point-এর সমস্যাটা তার সংজ্ঞাতেই লুকানো — scale factor স্থির, compile-time-এ ঠিক করা। তার মানে range আর precision একটা zero-sum trade-off:
- বেশি integer bit → বড় range, কম precision
- বেশি fractional bit → বেশি precision, ছোট range
Q16.16-এ সর্বোচ্চ মান ৩২৭৬৭.99998 — অ্যাভোগাড্রো সংখ্যার
ধারেকাছেও নেই। বিট বাড়িয়ে Q48.16 বানালে অ্যাভোগাড্রো ধরা
যাবে, কিন্তু ইলেকট্রনের ভরের মতো ছোট সংখ্যার জন্য আরও অনেক বেশি
ভগ্নাংশ bit লাগবে — একই সংখ্যায় দুটো প্রান্তিক magnitude
ধরার কোনো fixed bit budget নেই।
Scientific notation — উত্তরটা ইতিমধ্যে স্কুলে শেখা
পদার্থবিজ্ঞানে অ্যাভোগাড্রো সংখ্যা কীভাবে লেখা হয়?
লক্ষ্য করুন এটা কী করছে — সংখ্যাটাকে দুই ভাগে ভেঙেছে:
- significand (বা mantissa):
6.022— precision বহন করে - exponent:
23— magnitude (scale) বহন করে
একই কৌশলে ইলেকট্রনের ভর: 9.11 × 10⁻³¹। একই সংখ্যক
significant digit (9.11 — তিনটা digit) দিয়ে দুটো সংখ্যাই
লেখা গেল, যদিও তারা magnitude-এ ৫৬ ডিজিট দূরে। কারণ precision
আর magnitude আলাদা করে এনকোড হচ্ছে।
Floating-point ঠিক এই ধারণাটাই বাইনারিতে প্রয়োগ করে:
sign, significand (বা mantissa), আর exponent — তিনটা
আলাদা bit-field, প্রতিটার নিজস্ব কাজ।
“ভাসমান বিন্দু” নামটা কোথা থেকে এলো
Fixed-point-এ radix point (binary-এ “দশমিক বিন্দু”-র সমতুল্য)
একটা নির্দিষ্ট bit position-এ পেরেক মারা — Q16.16-এ সবসময়
বিট ১৬-১৭-এর মাঝে।
Floating-point-এ radix point-এর অবস্থান exponent দিয়ে নিয়ন্ত্রিত — exponent বদলালে বিন্দুটা কার্যত বাঁয়ে-ডানে “ভাসতে” থাকে:
1.101₂ × 2⁰ = 1.101₂ (বিন্দু এখানে)
1.101₂ × 2² = 110.1₂ (বিন্দু ডানে সরল)
1.101₂ × 2⁻² = 0.01101₂ (বিন্দু বাঁয়ে সরল)একই bit pattern (1101), ভিন্ন exponent, ভিন্ন কার্যকর
বিন্দু-অবস্থান। এই “ভাসা”-টাই নামের উৎস — floating radix
point, সংক্ষেপে floating-point।
ভেতরে কী ঘটছে
যেখানে দুটো representation-ই একইভাবে মিথ্যা বলে
এখানেই সবচেয়ে গুরুত্বপূর্ণ, প্রায়ই ভুল বোঝা সত্যটা — fixed হোক বা floating, কোনোটাই দশমিক ভগ্নাংশের সমস্যা “সমাধান” করে না। দুটোই বাইনারি (base-2), আর সমস্যাটা base conversion-এর, representation scheme-এর নয়।
কোন দশমিক ভগ্নাংশ বাইনারিতে সসীম?
গণিতের লেসনে (number systems) আমরা base conversion দেখেছি।
এখানে সেই একই সত্যের একটা কঠোর result: একটা দশমিক ভগ্নাংশ
p/q-এর (lowest terms-এ) বাইনারি representation সসীম হবে
শুধুমাত্র যদি q-এর সব prime factor 2 হয়।
কেন? 1/2ᵏ কে বাইনারিতে লিখলে ঠিক k বিট পরে শেষ হয় —
1/2 = 0.1₂, 1/4 = 0.01₂, 1/8 = 0.001₂। কিন্তু q-এ যদি
5 (বা অন্য যেকোনো মৌলিক সংখ্যা 2 ছাড়া) থাকে, তাহলে কখনো
2-এর কোনো ঘাত দিয়ে ভাগ শেষ হয় না — infinite repeating
fraction।
| ভগ্নাংশ | Decimal-এ | Binary-এ | সসীম? |
|---|---|---|---|
1/2 | 0.5 | 0.1₂ | ✓ |
1/4 | 0.25 | 0.01₂ | ✓ |
3/8 | 0.375 | 0.011₂ | ✓ |
1/10 | 0.1 | 0.0(0011)₂ (repeating) | ✕ |
1/5 | 0.2 | 0.(0011)₂ (repeating) | ✕ |
1/3 | 0.333... (নিজেই decimal-এ অসীম) | 0.(01)₂ (repeating) | ✕ |
আরও কয়েকটা সাধারণ ভগ্নাংশ দেখলে প্যাটার্নটা আরও স্পষ্ট হয় — আর একটা চমৎকার connection number theory-র (Level 0) modular arithmetic লেসনের সাথে:
| ভগ্নাংশ | হর-এর মৌলিক উৎপাদক | Binary-এ সসীম? | পুনরাবৃত্তির দৈর্ঘ্য |
|---|---|---|---|
1/2 | {2} | ✓ | — |
1/4 | {2} | ✓ | — |
1/6 | {2, 3} | ✕ | 2 বিট (0.0(01)₂) |
1/7 | {7} | ✕ | 3 বিট |
1/9 | {3} | ✕ | 6 বিট |
1/10 | {2, 5} | ✕ | 4 বিট |
1/12 | {2, 2, 3} | ✕ | 2 বিট |
লক্ষ্য করুন 1/12-এর হর-এ 2 আছে (12 = 4 \times 3), তবু এটা
সসীম নয় — কারণ সব prime factor 2 হতে হবে, শুধু কিছু
নয়। 12-এর 3 factor-টাই যথেষ্ট এটাকে infinite করতে দিতে।
পুনরাবৃত্তির দৈর্ঘ্যটাও এলোমেলো নয় — এটা ঠিক 2-এর
[[modular-arithmetic|multiplicative order]] mod q' (যেখানে
q' হলো q-এর 2-বহির্ভূত অংশ) — সংখ্যা তত্ত্বের একটা প্রমিত
ফলাফল, ঠিক যেমন 1/7-এর দশমিক আবর্তন দৈর্ঘ্য 6 হয় কারণ 10-এর
multiplicative order mod 7 হলো 6। এখানে base 10-এর বদলে
base 2, বাকি গণিত অভিন্ন।
1/10-এর বাইনারি expansion হাতে বের করা যাক — বারবার ২ দিয়ে
গুণ করে integer অংশ তুলে নেওয়া (long division-এর বাইনারি সংস্করণ):
0.1 × 2 = 0.2 → বিট 0
0.2 × 2 = 0.4 → বিট 0
0.4 × 2 = 0.8 → বিট 0
0.8 × 2 = 1.6 → বিট 1 (এখান থেকে চক্র শুরু)
0.6 × 2 = 1.2 → বিট 1
0.2 × 2 = 0.4 → বিট 0 (remainder 0.2 আগে দেখা গেছে — চক্র পুনরাবৃত্তি)
0.4 × 2 = 0.8 → বিট 0
0.8 × 2 = 1.6 → বিট 1
...0.1₁₀ = 0.0001100110011001100110011...₂ — চিরকাল চলতে
থাকবে, ঠিক যেমন 1/3 decimal-এ 0.3333... চিরকাল চলে।
Fixed-point-এর নিজস্ব leak — static commitment
Floating-point-এর সমস্যাটা universal (base conversion)। কিন্তু fixed-point-এর একটা বাড়তি সমস্যা আছে যেটা floating-point-এর নেই — range compile-time-এ স্থির করতে হয়।
Q16.16 বেছে নিলে, আপনার প্রোগ্রাম কখনো ৩২৭৬৮-এর বেশি কোনো
মান হ্যান্ডেল করতে পারবে না — চুপচাপ wrap around করবে, ঠিক
আগের লেসনের integer overflow-এর মতোই (কারণ ভেতরে এটা তো
আসলে integer-ই)। যদি একটা physics simulation-এর মধ্যবর্তী
হিসাবে হঠাৎ ৫০,০০০-এর মতো মান আসে (হয়তো একটা বাগের কারণে,
হয়তো একটা legitimate edge case-এর কারণে), fixed-point চুপচাপ
ভুল উত্তর দেবে — কোনো warning, কোনো crash ছাড়াই।
একটা concrete দৃশ্যকল্প ভাবুন — একটা গেম physics engine বল-বেগ
(velocity) Q16.16-এ রাখছে। স্বাভাবিক gameplay-তে বেগ কখনো
\pm 500 ছাড়ায় না, তাই ডেভেলপার নিশ্চিন্ত। কিন্তু একটা bug-এ
(ধরুন দুইটা বল একই ফ্রেমে বারবার collide করে বেগ exponentially
বাড়িয়ে ফেলে) বেগ কয়েক ফ্রেমেই ৩৫,০০০-এ পৌঁছে যায় — Q16.16-এর
সীমা (৩২,৭৬৭.99998) ছাড়িয়ে। ফলাফল: bit pattern wrap করে
একটা negative বেগ দেখায় (ঠিক এই লেসনের experiment অংশে
৪০,০০০-এর wraparound-এর মতো) — বলটা হঠাৎ উল্টো দিকে ছুটতে
শুরু করে, ডেভেলপারের কাছে মনে হয় এটা physics engine-এর কোনো
“অদ্ভুত bug”, যখন প্রকৃত কারণ শুধু একটা representation সীমা
নীরবে অতিক্রম হয়েছে।
Floating-point-এর exponent field এই নির্দিষ্ট সমস্যাটা সমাধান করে — range dynamically বদলায়, প্রতিটা সংখ্যা তার নিজের প্রয়োজন অনুযায়ী exponent বেছে নেয়। কিন্তু বিনিময়ে (পরের দুই লেসনে দেখব) precision-টা সংখ্যার magnitude-এর উপর নির্ভরশীল হয়ে যায় — একটা নতুন, সূক্ষ্মতর সমস্যা।
উদাহরণ
হাতে-কলমে — একটা সম্পূর্ণ Q8.8 হিসাব
ধাপ ১ — এনকোড করা: 3.75-কে Q8.8-এ রূপান্তর করুন।
960 দশমিকে, বাইনারিতে ১৬ বিট: 0000 0011 1100 0000
হেক্সে: 0x03C0
বিট-বিভাজন যাচাই করি — উপরের বাইট (পূর্ণসংখ্যা অংশ) 00000011
= 3; নিচের বাইট (ভগ্নাংশ অংশ) 11000000 = 128 + 64 = 192,
আর 192/256 = 0.75। মোট: 3 + 0.75 = 3.75 ✓
ধাপ ২ — গুণ করা: 2.5 × 1.5 — প্রত্যাশিত উত্তর 3.75।
এনকোড করি: 2.5 × 256 = 640 (0x0280), 1.5 × 256 = 384
(0x0180)।
Raw integer গুণ করি — লক্ষ্য করুন এটা Q8.8 × Q8.8, তাই ফলাফল
সাময়িকভাবে Q16.16-এর মতো scale-এ থাকে:
এই মধ্যবর্তী মান 245760 ইতিমধ্যে ১৬-বিট signed integer-এর
সীমা (32767) ছাড়িয়ে গেছে — তাই বাস্তব implementation-এ এই
গুণটা অবশ্যই একটা চওড়া (৩২-বিট) intermediate-এ করতে হবে,
নাহলে overflow হয়ে ভুল উত্তর আসবে। এখন 8 বিট ডানে shift করে
আবার Q8.8-এ ফেরাই:
960 কে decode করলে 960 / 256 = 3.75 ✓ — মিলে গেল।
ধাপ ৩ — ভাগ করা: 7.0 ÷ 3.0 — প্রত্যাশিত উত্তর 2.333...
(অসীম repeating, 1/3-এর মতোই — এই লেসনের “hood” অংশ মনে
করুন)।
এনকোড করি: 7.0 \times 256 = 1792 (0x0700), 3.0 \times 256 = 768 (0x0300)।
এই লেসনের arithmetic নিয়ম অনুযায়ী, ভাগে আগে numerator-কে 8
বিট বাঁয়ে shift করতে হয় (নাহলে ভাগফলে সব precision হারিয়ে
যেত — চেষ্টা করে দেখুন 1792 / 768 সরাসরি করলে কী হয়):
597-কে decode করি: 597 / 256 = 2.33203125।
প্রকৃত মান 7/3 = 2.3333...। পার্থক্য \approx 0.0013 — এই
লেসনের আগের “প্রত্যাশিত output” অংশে আমরা যেমন দেখেছি, Q8.8
precision (1/256 \approx 0.0039) থেকেই এই ধরনের ছোট error
আসে। 7/3-এর বাইনারি expansion নিজেই অসীম (3-এর মৌলিক উৎপাদক
2 নয়), তাই কোনো সসীম Q-format সেটা exactly ধরতে পারবে না —
আরও বেশি fractional bit দিলে error ছোট হবে, শূন্য হবে না।
ধাপ ৪ — floating-point-এর ধারণাগত সমতুল্য (bit-level এনকোডিং পরের লেসনে):
3.75-কে scientific-notation স্টাইলে বাইনারিতে normalize করি —
significand-এর প্রথম বিট সবসময় 1 হবে এভাবে:
তুলনা করুন 6.022 \times 10^{23}-এর সাথে — গঠনটা অভিন্ন:
sign = +, significand = 1.111, exponent = 1 (base 2-তে)।
পরের লেসনে আমরা এই তিনটা টুকরোকে ঠিক কতগুলো বিটে, কীভাবে
এনকোড করা হয় সেটা bit-by-bit দেখব — এবং কেন 0.1-এর মতো
সংখ্যা এনকোড করলে যা জমা থাকে সেটা ঠিক 0.1 নয়।
নিজে চালিয়ে দেখুন
Q16.16 fixed-point লাইব্রেরি — Python ও C দুই ভাষাতেই
SCALE = 16
ONE = 1 << SCALE # 65536
def to_fixed(x: float) -> int:
return round(x * ONE)
def from_fixed(v: int) -> float:
return v / ONE
def fx_add(a: int, b: int) -> int:
return a + b # scale একই, তাই শুধু যোগ
def fx_mul(a: int, b: int) -> int:
# মধ্যবর্তী পূর্ণ 64-bit precision-এ, তারপর নামিয়ে আনা
return (a * b) >> SCALE
def fx_div(a: int, b: int) -> int:
return (a << SCALE) // b
a = to_fixed(2.5)
b = to_fixed(1.5)
print(f"a = {a} ({from_fixed(a)})")
print(f"b = {b} ({from_fixed(b)})")
prod = fx_mul(a, b)
print(f"a * b = {prod} ({from_fixed(prod)})")
s = fx_add(a, b)
print(f"a + b = {s} ({from_fixed(s)})")
quot = fx_div(a, b)
print(f"a / b = {quot} ({from_fixed(quot)})")
# overflow দেখানো — Python-এর int নিজে কখনো overflow করে না
# (arbitrary precision), তাই আমরা নিজেই int32 wraparound simulate করি —
# ঠিক যেমন C/hardware-এ ঘটত (আগের লেসনের two's complement নিয়মে)।
def to_int32(v: int) -> int:
v &= 0xFFFFFFFF
if v >= 0x80000000:
v -= 0x100000000
return v
raw = to_fixed(40000.0) # Q16.16-এর সীমা (32767.99998) ছাড়িয়ে গেছে
wrapped = to_int32(raw)
print(f"to_fixed(40000.0) raw = {raw}")
print(f"to_fixed(40000.0) as int32 = {wrapped} → decode: {from_fixed(wrapped)}")প্রত্যাশিত output:
a = 163840 (2.5)
b = 98304 (1.5)
a * b = 245760 (3.75)
a + b = 262144 (4.0)
a / b = 109226 (1.666656494140625)
to_fixed(40000.0) raw = 2621440000
to_fixed(40000.0) as int32 = -1673527296 → decode: -25536.0শেষ লাইনটাই এই লেসনের “hood” অংশে বলা static-range-commitment
সমস্যার সরাসরি প্রদর্শন — ৪০,০০০ লিখতে চেয়েছিলাম, কিন্তু
Q16.16-এর ৩২,৭৬৭.99998 সীমা ছাড়িয়ে যাওয়ায় int32-তে চুপচাপ
wrap করে দাঁড়াল −25536.0 — সম্পূর্ণ ভুল sign, সম্পূর্ণ
ভুল magnitude, কোনো error বা warning ছাড়াই।
লক্ষ্য করুন a / b-এর ফলাফল 1.666656... — সঠিক উত্তর
1.6666...-এর কাছাকাছি কিন্তু হুবহু নয়। Q16.16-এর precision
মাত্র 1/65536 ≈ 0.0000153, তাই 1/6-এর মতো অসসীম ভগ্নাংশ
এখানেও সসীম আকারে চাপা পড়ে — fixed-point-ও সব সংখ্যা exactly
ধরতে পারে না, শুধু সীমাটা floating-point-এর চেয়ে আলাদা জায়গায়।
C-তে একই লাইব্রেরি — লক্ষ্য করুন int64_t intermediate কেন
বাধ্যতামূলক:
#include <stdio.h>
#include <stdint.h>
#define SCALE 16
#define ONE (1 \<\< SCALE)
int32_t to_fixed(double x) { return (int32_t)(x * ONE + (x >= 0 ? 0.5 : -0.5)); }
double from_fixed(int32_t v) { return (double)v / ONE; }
int32_t fx_mul(int32_t a, int32_t b) {
int64_t wide = (int64_t)a * (int64_t)b; /* 64-bit না হলে overflow */
return (int32_t)(wide >> SCALE);
}
int32_t fx_div(int32_t a, int32_t b) {
int64_t wide = (int64_t)a \<\< SCALE;
return (int32_t)(wide / b);
}
int main(void) {
int32_t a = to_fixed(2.5);
int32_t b = to_fixed(1.5);
int32_t prod = fx_mul(a, b);
printf("a * b = %d (%f)\n", prod, from_fixed(prod));
/* যদি int64_t intermediate না ব্যবহার করতাম: */
int32_t bad_prod = a * b; /* সরাসরি int32_t গুণ — এখানেই overflow */
printf("bad (no wide intermediate): %d\n", bad_prod);
return 0;
}gcc -O2 -o fixed fixed.c && ./fixedদ্বিতীয় লাইনটা (bad_prod) দেখাবে কেন wide intermediate ছাড়া
fixed-point multiplication ভুল — 163840 × 98304 int32_t-এর
সীমা (2147483647) ছাড়িয়ে যায় (161,061,273,600 — প্রায়
75 গুণ বড়), silent overflow ঘটবে।
Fixed-point arithmetic আসলে বিশুদ্ধ integer arithmetic, শুধু একটা implied scale factor সহ — আর multiplication-এ shift না করলে scale ভুল হয়ে যায়।
নিজে বানান
একটা সম্পূর্ণ Fixed-Point Vector Math Library
- একটা fixed_t টাইপ ঠিক করুন (int32_t, Q16.16)
- to_fixed / from_fixed conversion function লিখুন, round-to-nearest সহ
- fx_add, fx_sub, fx_mul, fx_div — চারটা arithmetic operation লিখুন
- একটা 2D vector struct বানান (x, y দুটোই fixed_t), তার উপর dot product আর magnitude approximation লিখুন
- floating-point দিয়ে একই হিসাব করে দুটো ফলাফলের পার্থক্য measure করুন
উদ্দেশ্য: আজকের অনেক embedded/DSP/game-engine কোডবেসে এভাবেই math library শুরু হয়। শুরু করুন core operation দিয়ে:
#include <stdint.h>
typedef int32_t fx_t;
#define FX_SCALE 16
#define FX_ONE ((fx_t)1 \<\< FX_SCALE)
static inline fx_t fx_from_float(float x) {
return (fx_t)(x * FX_ONE + (x >= 0 ? 0.5f : -0.5f));
}
static inline float fx_to_float(fx_t v) {
return (float)v / FX_ONE;
}
static inline fx_t fx_add(fx_t a, fx_t b) { return a + b; }
static inline fx_t fx_sub(fx_t a, fx_t b) { return a - b; }
static inline fx_t fx_mul(fx_t a, fx_t b) {
return (fx_t)(((int64_t)a * (int64_t)b) >> FX_SCALE);
}
static inline fx_t fx_div(fx_t a, fx_t b) {
return (fx_t)(((int64_t)a \<\< FX_SCALE) / b);
}
typedef struct { fx_t x, y; } fx_vec2;
fx_t fx_dot(fx_vec2 a, fx_vec2 b) {
return fx_add(fx_mul(a.x, b.x), fx_mul(a.y, b.y));
}
/* magnitude-এর জন্য sqrt দরকার — এটাই আপনার চ্যালেঞ্জ:
Newton-Raphson iteration দিয়ে fixed-point sqrt লিখুন,
library-এর sqrt() ব্যবহার না করে। */
fx_t fx_sqrt_approx(fx_t v) {
/* TODO: আপনি লিখুন — শুরুর অনুমান v/2, তারপর
x_{n+1} = (x_n + v/x_n) / 2, ৪-৫ বার iterate করুন */
return 0;
}নিজে বাড়ান:
fx_sqrt_approxসম্পূর্ণ করুন,math.sqrt/sqrtf-এর সাথে ১০০টা ভিন্ন মানে তুলনা করে max error বের করুন- একটা
fx_vec2normalize করার function লিখুন (magnitude দিয়ে ভাগ করে unit vector বানানো) — খেয়াল রাখুন division-এ overflow না হয় - একই সব operation float দিয়ে আবার লিখুন,
১০,০০০বার পরপর যোগ-গুণ করে দুই সংস্করণের ফলাফল কতটা আলাদা হয় দেখুন - Q16.16-এর বদলে Q8.24 ব্যবহার করে দেখুন — range কমে, precision বাড়ে; কোন কাজে কোনটা ভালো তা যুক্তি দিয়ে লিখুন
বাস্তব সিস্টেমে
Fixed-point যেখানে আজও রাজত্ব করে
DOOM (1993)-এর renderer। id Software-এর DOOM পুরো ৩D
rendering pipeline fixed_t (৩২-বিট, ১৬.১৬ format) দিয়ে লেখা —
m_fixed.h-এ সংজ্ঞায়িত। তখনকার x86 CPU-তে floating-point
operation হয় ধীর ছিল, নয়তো একদমই ছিল না (486SX-এ কোনো FPU
ছিল না) — তাই deterministic, দ্রুত fixed-point-ই একমাত্র
practical পথ ছিল।
Embedded microcontroller, FPU ছাড়া। ARM Cortex-M0/M3-এর মতো সস্তা microcontroller-এ hardware FPU নেই। PID motor controller, sensor filtering — এসবের firmware আজও fixed-point-এ লেখা হয়, কারণ software floating-point emulation cycle-এর তুলনায় অনেক ধীর, আর real-time deadline মিস করা মানে motor নিয়ন্ত্রণ হারানো।
Audio DSP hardware। পুরনো Texas Instruments TMS320-সিরিজ
DSP চিপ (মোবাইল ফোনের প্রথম যুগের codec, MP3/AAC decoder-এ
ব্যবহৃত) fixed-point core — audio sample প্রায় সবসময় Q15/Q1.31
format-এ থাকে, কারণ −1.0 থেকে 1.0-এর মধ্যেই সব সীমাবদ্ধ,
তাই পুরো bit budget precision-এ যায়।
GPS coordinate — Android-এর পুরনো API। প্রাথমিক Android
mapping API (com.google.android.maps.GeoPoint) latitude/longitude
কে “microdegrees” — degree × 10⁶ — একটা int-এ রাখত
(getLatitudeE6())। কারণ compact, deterministic, আর
serialization/network transfer-এ সস্তা।
RTS game-এর lockstep multiplayer। Age of Empires-এর মতো real-time strategy game multiplayer synchronization-এ “lockstep” মডেল ব্যবহার করে — প্রতিটা player-এর মেশিন একই simulation independently চালায়, শুধু input পাঠানো হয়। যদি simulation-এ floating-point থাকত, ভিন্ন CPU/compiler/optimization-flag-এ সামান্য ভিন্ন rounding হতে পারত (বিশেষত পুরনো x87 80-bit extended-precision registers-এর যুগে), আর একটা মেশিনও যদি এক বিট আলাদা হিসাব করত, পুরো game state desync হয়ে যেত। তাই বহু RTS-এর simulation core deterministic fixed-point-এ লেখা হয়েছে (দেখুন Paul Bettner-এর GDC talk “1500 Archers on a 28.8”)।
Financial/accounting সফটওয়্যার। টাকার amount কখনো binary
float-এ রাখা হয় না — SQL-এর DECIMAL/NUMERIC type,
বা integer cents, বা Python-এর decimal.Decimal — সবই
মূলত decimal fixed-point। লেসন ৯-এ আমরা দেখব ঠিক কেন এটা
আলোচনার বিষয় নয়, বাধ্যতামূলক নিয়ম।
Blockchain / smart contract। Solidity (Ethereum-এর ভাষা)-এ কোনো native floating-point type নেই — ইচ্ছাকৃতভাবে। সব টোকেন amount integer (সাধারণত ১৮ decimal place স্কেল করা, ERC-20 স্ট্যান্ডার্ড) — কারণ blockchain-এ প্রতিটা node-কে বিট-বিট identical ফলাফল পেতে হয়, আর floating-point-এর platform-নির্ভর সূক্ষ্ম পার্থক্য (দেখুন উপরের RTS উদাহরণ) consensus ভেঙে দিতে পারে।
Old-school GPU fixed-function pipeline। ৩D graphics hardware-এর প্রথম প্রজন্মে (১৯৯০-এর দশক) texture coordinate interpolation প্রায়ই fixed-point-এ হতো — perspective-correct floating-point interpolation তখনকার silicon budget-এ ব্যয়বহুল ছিল।
Bresenham-এর line drawing algorithm (১৯৬২)। IBM-এর Jack Bresenham একটা algorithm ডিজাইন করেছিলেন যা একটা সরলরেখা আঁকতে শুধু integer addition, subtraction, আর bit shift ব্যবহার করে — কোনো division বা floating-point multiplication ছাড়াই। মূল কৌশলটা কার্যত fixed-point-এর একটা বিশেষ রূপ — error term-টাকে একটা scaled integer হিসেবে বহন করা, প্রতি ধাপে যোগ করা, আর একটা threshold পার হলে সংশোধন করা। ১৯৬০-এর দশকের hardware-এ (যেখানে floating-point multiply শত শত cycle লাগত) এই কৌশলটাই ২D/৩D graphics-কে ব্যবহারিক করে তুলেছিল, আর আজও প্রতিটা modern GPU driver-এর কোথাও না কোথাও এর কোনো না কোনো রূপ টিকে আছে।
Block floating-point — fixed আর floating-এর মাঝামাঝি একটা হাইব্রিড। Radar signal processing, কিছু আধুনিক audio codec, আর পুরনো FFT hardware-এ একটা তৃতীয় কৌশল দেখা যায় — একগুচ্ছ (block) সংখ্যাকে একটাই common exponent ভাগাভাগি করতে দেওয়া, কিন্তু প্রতিটার নিজস্ব fixed-point mantissa রাখা। ধারণাটা: block-এর মধ্যে সবচেয়ে বড় সংখ্যার magnitude অনুযায়ী একটা exponent বেছে নেওয়া হয়, বাকি সবাই সেই একই scale-এ fixed-point হিসেবে store হয়। ফলাফল — প্রতিটা সংখ্যার জন্য আলাদা exponent store করার (পুরো floating-point-এর) খরচ ছাড়াই, pure fixed-point-এর চেয়ে বেশি dynamic range। এটাই দেখায় fixed আর floating-point কোনো কঠোর দ্বৈত (binary choice) না — এই বর্ণালীর মাঝে আরও অনেক design point সম্ভব, নির্দিষ্ট ব্যবহারের প্রয়োজন অনুযায়ী।
যে ভুলগুলো সবাই করে
“Floating-point সবসময় fixed-point-এর চেয়ে বেশি accurate।”
উল্টো হতে পারে — নির্দিষ্ট, সীমিত range-এর জন্য fixed-point প্রায়ই বেশি precise, কারণ প্রতিটা bit সরাসরি precision-এ যায়, কোনো bit exponent-এর জন্য “নষ্ট” হয় না।
Q1.31-এ (audio) প্রতিটা প্রতিনিধিত্বকারী মানের মধ্যে দূরত্ব
সবসময় ঠিক 2⁻³¹ — সব magnitude-এ সমান। কিন্তু ৩২-বিট
floating-point-এ (পরের লেসনে বিস্তারিত) precision ধ্রুবক নয় —
সংখ্যা যত বড় হয়, ধারাবাহিক দুইটা representable মানের মধ্যে
ফাঁক তত বড় হয়। −1.0-থেকে-1.0 সীমায় সীমাবদ্ধ audio sample-এর
জন্য, সেই “বিশাল range ধরার” ক্ষমতাটাই অপ্রয়োজনীয় — আর
fixed-point সেই অপ্রয়োজনীয় bit budgetটা precision-এ ঢেলে দেয়।
নিয়ম: যদি range আগে থেকে জানা এবং সীমিত থাকে, fixed-point প্রায়ই ভালো পছন্দ — বেশি precision, দ্রুত hardware (কোনো FPU লাগে না), আর deterministic।
“Fixed-point এখন 'পুরনো প্রযুক্তি', সব জায়গায় FPU আছে এখন।”
Desktop/server CPU-তে হ্যাঁ, প্রায় সবখানে FPU আছে। কিন্তু বিশ্বের বেশিরভাগ microcontroller (গাড়ি, IoT sensor, হোম অ্যাপ্লায়েন্স, industrial control) এখনো FPU ছাড়া চিপে চলে — কারণ FPU সিলিকন এরিয়া আর power খরচ করে, আর $০.৫০ মূল্যের একটা চিপে সেটা অপচয়।
আর যেখানে determinism critical (blockchain, lockstep multiplayer, regulatory-compliant financial calculation), সেখানে FPU থাকলেও ইচ্ছাকৃতভাবে fixed-point বেছে নেওয়া হয় — কারণ সমস্যাটা speed নয়, reproducibility।
“Binary-তে দশমিক বিন্দু বসানো মানেই যেকোনো দশমিক সংখ্যা লেখা যায়, ঠিক যেমন কাগজে লিখি।”
Hardware-এ আক্ষরিক অর্থে কোনো “বিন্দু” নেই — শুধু bit-এর সারি।
0.75-কে fixed-point-এ লিখলে raw bit 11000000 (Q8.8-এর
নিচের বাইট) — এই bit pattern নিজে কোথাও ”.” চিহ্ন বহন করে না।
বিন্দুর অবস্থানটা সম্পূর্ণভাবে সফটওয়্যার convention — CPU
জানেও না এটা fixed-point সংখ্যা, নাকি একটা সাধারণ integer, নাকি
দুটো ৮-বিট flag। এটাই আগের লেসনের bit-pattern-interpretation
থিসিসের আরেকটা উদাহরণ।
আর — বিন্দু বসালেও 0.1-এর মতো অনেক সংখ্যা binary-তে কখনোই
সসীম আকারে লেখা যায় না, ঠিক যেমনটা এই লেসনের “hood” অংশে
দেখানো হয়েছে। এটা decimal-এ 1/3 লেখার সমস্যার সমতুল্য — বিন্দু
বসানো সমস্যাটা সমাধান করে না, শুধু জায়গা তৈরি করে।
“Fixed-point-এ ঋণাত্মক সংখ্যা হ্যান্ডেল করতে আলাদা, বিশেষ hardware লাগে।”
একেবারেই না — এটাই fixed-point-এর সবচেয়ে সুন্দর সুবিধাগুলোর
একটা। যেহেতু ভেতরে এটা নিছক একটা integer (একটা implied scale
factor সহ), negation-এর জন্য আগের লেসনের two’s complement
নিয়মই হুবহু কাজ করে — bit উল্টে 1 যোগ। কোনো নতুন circuit,
কোনো নতুন instruction লাগে না, বিদ্যমান integer ALU-ই যথেষ্ট
(এই লেসনের Question ২-এ এই হিসাবটাই হাতে-কলমে দেখানো হয়েছে)।
এটাই fixed-point বনাম floating-point-এর একটা গভীর পার্থক্য — floating-point-এর negation এক অর্থে “সহজ” (শুধু sign bit flip করা, IEEE 754-এর পরের লেসনে দেখব), কিন্তু floating-point-এর addition/multiplication-এর জন্য সম্পূর্ণ নতুন, বিশেষায়িত hardware circuit লাগে (exponent align করা, mantissa শিফট করা, normalize করা, round করা)। Fixed-point ঠিক উল্টো ট্রেড-অফ করে — negation ঠিক ততটাই সহজ, কিন্তু বাকি সব operation-ই সাধারণ integer ALU দিয়ে চলে, কোনো বিশেষ circuit ছাড়াই।
বুঝেছেন কি না দেখুন
1Q8.8 format-এ ক্ষুদ্রতম ধনাত্মক সংখ্যা আর সর্বোচ্চ সংখ্যা কত?
হিসাব দেখিয়ে বলুন।
যুক্তি
Q8.8 মানে মোট ১৬ বিট, উপরের ৮ বিট (sign-সহ two’s complement)
পূর্ণসংখ্যা, নিচের ৮ বিট ভগ্নাংশ। Scale factor 2⁸ = 256।
ক্ষুদ্রতম ধনাত্মক সংখ্যা: raw integer 1 (সবচেয়ে ছোট
অ-শূন্য মান)।
সর্বোচ্চ সংখ্যা: ১৬-বিট signed integer-এর সর্বোচ্চ মান
32767 (2^{15} - 1, কারণ MSB sign bit)।
সর্বনিম্ন সংখ্যা (প্রশ্নে না চাওয়া হলেও সম্পূর্ণতার জন্য):
−32768 / 256 = −128.0।
লক্ষ্য করুন সর্বনিম্ন মান একদম গোল সংখ্যায় শেষ হয় (−128.0)
কিন্তু সর্বোচ্চ মান গোল হয় না (127.996..., ঠিক 128.0 নয়) —
এটা two’s complement-এর সেই পরিচিত অসামঞ্জস্য, আগের integer
overflow লেসনে যেটা দেখা গেছে (negative range সবসময় positive
range-এর চেয়ে একটা মান বেশি ধরে)।
2−2.5-কে Q8.8 two’s complement fixed-point-এ এনকোড করুন।
হেক্সে চূড়ান্ত উত্তর দিন।
প্রয়োগ
−2.5-কে Q8.8 two’s complement fixed-point-এ এনকোড করুন।
হেক্সে চূড়ান্ত উত্তর দিন।ধাপ ১ — ধনাত্মক সংস্করণ এনকোড করুন:
640 ষোল বিটে বাইনারি: 0000 0010 1000 0000
ধাপ ২ — two’s complement negate করুন (আগের লেসনের কৌশল:
সব বিট উল্টান, তারপর ১ যোগ করুন):
0000 0010 1000 0000 (640)
~ 1111 1101 0111 1111 (bit-invert)
+ 0000 0000 0000 0001 (1 যোগ)
= 1111 1101 1000 0000ফলাফল: 1111 1101 1000 0000 = হেক্সে 0xFD80
যাচাই: 0xFD80 কে signed ১৬-বিট হিসেবে পড়লে
−640 (দশমিকে), আর −640 / 256 = −2.5 ✓
এই পুরো প্রক্রিয়াটা লক্ষ্য করুন — negate করার জন্য fixed-point কোনো নতুন নিয়ম আনেনি, শুধু raw integer-এর উপর আগের লেসনের two’s complement নিয়মটাই প্রয়োগ হয়েছে। এটাই fixed-point-এর সবচেয়ে বড় সুবিধা: এটা কোনো নতুন hardware লজিক দাবি করে না, বিদ্যমান integer ALU-ই যথেষ্ট।
3Q16.16-এ দুইটা সংখ্যা 100.0 আর 50.0 গুণ করলে raw integer
গুণফল কত হবে, আর এটা কেন সরাসরি ৩২-বিট signed integer-এ রাখা
যায় না?
প্রয়োগ
Q16.16-এ দুইটা সংখ্যা 100.0 আর 50.0 গুণ করলে raw integer
গুণফল কত হবে, আর এটা কেন সরাসরি ৩২-বিট signed integer-এ রাখা
যায় না?এনকোড করি: 100.0 × 65536 = 6,553,600 আর
50.0 × 65536 = 3,276,800।
Raw গুণফল:
এই মান 2.1 \times 10^{13} — যেখানে ৩২-বিট signed integer-এর
সর্বোচ্চ ধারণক্ষমতা মাত্র 2,147,483,647 (~2.1 \times 10^9)।
গুণফলটা সীমার প্রায় ১০,০০০ গুণ বড়!
এই overflow ঘটার কারণ স্পষ্ট — দুটো Q16.16 সংখ্যা গুণ করলে
scale factor-ও গুণ হয়ে যায় (2^{16} \times 2^{16} = 2^{32}),
তাই মধ্যবর্তী ফলাফলের জন্য কমপক্ষে সমতুল্য চওড়া space লাগে।
এই কারণেই বাস্তব fixed-point লাইব্রেরি (এই লেসনের Experiment ও
BuildIt অংশে দেখা) সবসময় int64_t-এর মতো একটা চওড়া intermediate
type ব্যবহার করে, শেষে >> 16 করে আবার Q16.16-এ নামিয়ে আনে —
যা সঠিক — 100 \times 50 = 5000। কিন্তু মধ্যবর্তী পদক্ষেপে যদি
৩২-বিট-এ আটকে থাকা হতো, সেই পদক্ষেপেই silent wraparound হয়ে
একটা সম্পূর্ণ ভুল উত্তর আসত।
4DOOM-এর fixed-point renderer কেন যেকোনো মেশিনে বিট-বিট identical
ফলাফল দিতে পারে, কিন্তু একটা আধুনিক floating-point-ভিত্তিক physics
engine-এ একই replay দুই ভিন্ন মেশিনে সামান্য ভিন্ন ফল দিতে পারে?
যুক্তি
মূল পার্থক্যটা কোথায় “মান” থাকে তাতে — fixed-point-এ প্রতিটা
সংখ্যা আসলে একটা সাধারণ integer, আর integer arithmetic (যোগ,
বিয়োগ, গুণ, shift) সংজ্ঞা অনুযায়ীই exact — কোনো rounding
নেই, কোনো ambiguity নেই। যেকোনো CPU, যেকোনো compiler, যেকোনো
optimization level — 640 + 384 সবসময় এবং সর্বত্র ঠিক 1024।
Floating-point-এর গল্পটা আলাদা (এই module-এর পরের দুই লেসনে বিস্তারিত): IEEE 754 standard নিজে deterministic, কিন্তু বাস্তব execution পথ একাধিক জায়গায় ভিন্ন হতে পারে —
- Intermediate precision: পুরনো x86 x87 FPU internally ৮০-বিট extended precision ব্যবহার করত, ফলাফল ৬৪-বিট-এ নামানোর আগে extra বিট থাকত। SSE/SSE2 (আধুনিক x86 default) ৬৪-বিট-এই থাকে। একই source code, ভিন্ন instruction set target করলে ভিন্ন rounding হতে পারে।
- Compiler optimization:
-ffast-math-এর মতো flag associativity বদলে দিতে পারে ((a+b)+cকেa+(b+c)-এ পাল্টানো) — floating-point addition associative নয় (পরের লেসনে দেখব), তাই ভিন্ন গ্রুপিং ভিন্ন উত্তর দিতে পারে। - Fused multiply-add (FMA): কিছু CPU-তে
a*b+cএকটা single instruction-এ, extra precision-সহ, হিসাব হয় (কোনো intermediate rounding ছাড়াই) — অন্য CPU-তে সেটা দুই ধাপে (আলাদা rounding সহ) হয়।
fixed-point-এ এই কোনোটাই প্রাসঙ্গিক নয় — এটা এতটাই “boring” (শুধু integer shift-add-multiply) যে কোনো hardware/compiler-এর কাছে optimize করার মতো ambiguity অবশিষ্ট থাকে না। ঠিক এই “বিরক্তিকর predictability”-টাই DOOM-এর মতো বিট-নির্ভুল রেন্ডারার আর AoE-এর মতো lockstep multiplayer game-এর জন্য মূল্যবান।
5আপনি একটা সস্তা microcontroller (FPU নেই)-এর firmware লিখছেন,
যেটা একটা temperature sensor থেকে 0.0 °C থেকে 125.0 °C
রেঞ্জের রিডিং নেয়, 0.01 °C precision দরকার, আর একটা ২০-স্যাম্পল
moving average রাখতে হবে। Fixed নাকি floating-point ব্যবহার
করবেন, আর কোন Q-format?
ডিজাইন
0.0 °C থেকে 125.0 °C
রেঞ্জের রিডিং নেয়, 0.01 °C precision দরকার, আর একটা ২০-স্যাম্পল
moving average রাখতে হবে। Fixed নাকি floating-point ব্যবহার
করবেন, আর কোন Q-format?Fixed-point-ই সঠিক পছন্দ — তিনটা কারণে:
- FPU নেই — software floating-point emulation প্রতিটা operation-এ বহু cycle লাগাবে, real-time sensor loop-এ যেটা ব্যয়বহুল।
- Range আগে থেকেই জানা এবং সীমিত (
0থেকে125) — fixed-point-এর সবচেয়ে বড় দুর্বলতা (dynamic range না থাকা) এখানে কোনো সমস্যাই না, কারণ dynamic range দরকারই নেই। - Determinism দরকার — sensor reading-এর moving average যদি প্রতিবার সামান্য ভিন্ন rounding দেয়, downstream logic (যেমন alarm threshold) অস্থির আচরণ করতে পারে।
Q-format বাছাই:
Precision দরকার 0.01, range [0, 125]।
ভগ্নাংশ বিট: 2^{-n} \le 0.01 সমাধান করলে n \ge 7 (2^{-7} = 0.0078125, যথেষ্ট মিহি)। নিরাপদ margin-এর জন্য n = 8
নিলে precision 1/256 ≈ 0.0039 — দাবির চেয়ে ভালো।
পূর্ণসংখ্যা বিট: 125-কে ধরতে (এবং negative reading বা sensor
glitch handle করার জন্য একটু headroom সহ) m = 8 যথেষ্ট
(signed 8-bit → −128 থেকে 127)।
সিদ্ধান্ত: Q8.8 (মোট ১৬ বিট, একটা int16_t)।
Moving average-এর জন্য বিশেষ সতর্কতা: ২০টা Q8.8 sample যোগ
করলে সাময়িকভাবে মান 20 \times 125 \times 256 = 640,000
পর্যন্ত যেতে পারে — এটা int16_t-এর সীমা ছাড়িয়ে যায় (সর্বোচ্চ
32767), তাই running sum রাখতে হবে একটা চওড়া টাইপে
(int32_t), শুধু final average বের করার সময় int16_t-এ
narrow করে ফেরত আনা — এই লেসনের multiplication উদাহরণের মতোই
নীতি: intermediate সবসময় চওড়া রাখুন, শেষে narrow করুন।
যদি range অজানা বা পরিবর্তনশীল হতো (ধরুন sensor একই
firmware দিয়ে −40°C থেকে 1200°C পর্যন্ত ভিন্ন মডেলে ব্যবহার
হয়), তখন floating-point-এর dynamic range-এর সুবিধাটা কাজে
লাগত — কিন্তু এই নির্দিষ্ট সমস্যায় সেটা অপ্রয়োজনীয় জটিলতা এবং
FPU-বিহীন hardware-এ সরাসরি performance খরচ।
6“hood” অংশের টেবিলে 1/7-এর বাইনারি expansion-এর পুনরাবৃত্তি
দৈর্ঘ্য ৩ বিট দেখানো হয়েছে। হাতে দেখান কেন, আর তারপর বলুন
1/14-এর পুনরাবৃত্তি দৈর্ঘ্য কত হবে।
যুক্তি
1/7-এর বাইনারি expansion-এর পুনরাবৃত্তি
দৈর্ঘ্য ৩ বিট দেখানো হয়েছে। হাতে দেখান কেন, আর তারপর বলুন
1/14-এর পুনরাবৃত্তি দৈর্ঘ্য কত হবে।1/7-এর expansion হাতে বের করি:
1/7 × 2 = 2/7 → বিট 0 (remainder 2/7)
2/7 × 2 = 4/7 → বিট 0 (remainder 4/7)
4/7 × 2 = 8/7 → বিট 1 (remainder 1/7 — শুরুতে ফেরত!)মাত্র ৩ ধাপেই remainder 1/7-এ ফিরে এসেছে — তাই পুনরাবৃত্তি
দৈর্ঘ্য ঠিক ৩: 1/7 = 0.(001)_2।
কেন ঠিক ৩, অন্য কোনো সংখ্যা নয়: পুনরাবৃত্তি দৈর্ঘ্য সবসময়
সেই ক্ষুদ্রতম k যার জন্য 2^k \equiv 1 \pmod{7} (এটাই 2-এর
multiplicative order mod 7, Level 0-এর modular arithmetic
লেসনের সরাসরি প্রয়োগ)। 2^1=2, 2^2=4, 2^3=8 \equiv 1 \pmod 7 —
তাই order = 3, আর remainder-চক্রও ঠিক ৩ ধাপে ফিরে আসে, কাকতালীয়
নয়।
1/14-এর জন্য: 14 = 2 \times 7। এই 2-এর factor-টা শুধু
একটা non-repeating prefix যোগ করে (ঠিক যেমন 1/6 = 1/2 \times 1/3 এই লেসনের টেবিলে একটা prefix বিট পেয়েছিল), পুনরাবৃত্তি
অংশের দৈর্ঘ্য বদলায় না — কারণ পুনরাবৃত্তি দৈর্ঘ্য নির্ভর করে
শুধু q-এর odd part-এর (এখানে 7) উপর।
হাতে যাচাই করলে:
1/14 × 2 = 1/7 → বিট 0 (এখান থেকে ঠিক 1/7-এর চক্রই শুরু হয়ে যায়)
1/7 × 2 = 2/7 → বিট 0
2/7 × 2 = 4/7 → বিট 0
4/7 × 2 = 8/7 → বিট 1 (remainder 1/7 — এই বিন্দু থেকে চক্র শুরু)1/14 = 0.0(001)_2 — একটা prefix বিট 0, তারপর সেই একই (001)
চক্র যা 1/7-এ ছিল, দৈর্ঘ্য এখনো ৩।
সাধারণ নিয়ম: কোনো ভগ্নাংশ 1/q-এর বাইনারি পুনরাবৃত্তি
দৈর্ঘ্য নির্ধারিত হয় শুধু q-এর odd (২-বহির্ভূত) অংশের দ্বারা —
q-এর 2-এর ঘাত অংশটা শুধু একটা non-repeating prefix-এর
দৈর্ঘ্য ঠিক করে, চক্রের দৈর্ঘ্য নয়। এটাই ব্যাখ্যা করে কেন Q-format-এ
যত বেশি fractional bit-ই দিন না কেন, 1/7-জাতীয় ভগ্নাংশ কখনো
exact হবে না — শুধু চক্রের আরও বেশি পুনরাবৃত্তি capture হবে,
precision বাড়বে কিন্তু error শূন্যে পৌঁছাবে না।
এরপর কী
এরপর কী — bit-level-এ নেমে যাওয়া
এই লেসনে আমরা floating-point-এর ধারণা দেখেছি — sign, significand, exponent, আর কেন এই তিনটা টুকরো দরকার। কিন্তু এখনো একটা প্রশ্নের উত্তর দেওয়া হয়নি: ঠিক কতগুলো বিট কোন অংশে যায়, আর সেই বিটগুলো ঠিক কীভাবে সংখ্যায় রূপান্তরিত হয়?
পরের লেসন — IEEE 754 — এই module-এর সবচেয়ে গুরুত্বপূর্ণ লেসন। আমরা সেখানে দেখব:
- ৩২-বিট আর ৬৪-বিট float-এর সঠিক bit layout, বিট-বাই-বিট
- Exponent কেন two’s complement-এ নয়, biased — একটা চমৎকার ডিজাইন সিদ্ধান্ত যেটা float-দুটোকে সরাসরি integer-এর মতো তুলনা করা সম্ভব করে
- একটা “লুকানো” বিট যেটা কখনো store হয় না, তবু precision বাড়ায়
0.1-কে ৩২-বিট float-এ হাতে এনকোড করে দেখব আসল stored মান ঠিক0.1নয় — সেই মানটাই এই module-এর মূল প্রশ্নের চূড়ান্ত প্রমাণ
আর তারপর, লেসন ৯-এ, আমরা দেখব সেই ছোট্ট error কীভাবে arithmetic-এর
মধ্য দিয়ে জমে, বাতিল হয়, বা বিপর্যয়কর হয়ে ওঠে — আর কীভাবে একটা
০.১-এর ২৪-বিট truncated approximation, ১৯৯১ সালে, প্রায় ১০০
ঘণ্টা জমতে জমতে একটা ক্ষেপণাস্ত্র প্রতিরক্ষা ব্যবস্থাকে তার
লক্ষ্য থেকে বঞ্চিত করেছিল — এই লেসনের “concept” অংশের rounding
বনাম truncation আলোচনার ঠিক সেই সতর্কবাণীর বাস্তব রূপ।
সংক্ষেপে — আজ আমরা শিখলাম কেন floating-point দরকার। পরের দুই লেসনে আমরা শিখব কীভাবে এটা বানানো হয়েছে, আর সেই নির্মাণে কোথায় কোথায় সাবধান থাকতে হয়।
আরও পড়ুন
- Fixed-Point Arithmetic: An Introduction — Randy Yates · Q-format, scaling, overflow — DSP-এর প্রেক্ষাপটে fixed-point-এর প্রামাণ্য introduction
- IEEE Standard for Floating-Point Arithmetic (IEEE 754-2019) — IEEE Computer Society · যে standard পরের লেসনে বিস্তারিত আলোচনা হবে — এখানে এর জন্মের ঐতিহাসিক প্রেক্ষাপট
- Game Engine Black Book: DOOM — Fabien Sanglard · id Software কেন renderer-এ 16.16 fixed-point বেছেছিল — বাস্তব ইঞ্জিনিয়ারিং সিদ্ধান্ত