الأدلة التقنية

Floating Point من الداخل: كيف يخزن الحاسب الكسور

كيف يحفظ معيار IEEE 754 الكسور في البتات، ولماذا تكبر الفجوة بين الأرقام وتضيع خانات المعرفات، ومتى تحتاج النوع العشري.

Floating Point من الداخل: كيف يخزن الحاسب الكسور

في مقال الأنظمة العددية للمبرمجين رأينا أن 0.1 + 0.2 تعطي 0.30000000000000004، وعرفنا أن السبب هو أن 0.1 كسر لا ينتهي بالنظام الثنائي، ثم قلنا في قسم المبالغ المالية إن double لا يصلح للمال. ولكن هذه القاعدة وحدها لا تفسر لماذا يجمع برنامج الرقم 0.1 عشرة ملايين مرة فيعطي 1087937 بدلاً من مليون، ولا لماذا يصل معرف الطلب من ال API ناقصاً آخر خاناته، ولا لماذا يعطي Math.Round(2.5) في C# القيمة 2، ولا لماذا عجزت بطارية صواريخ Patriot في الظهران سنة 1991 عن اعتراض صاروخ لأن ساعتها تعد بأعشار الثانية. والإجابة في كل هذه الحالات موجودة في البتات نفسها، أي في الطريقة التي يقسم بها المعيار IEEE 754 الرقم إلى إشارة وأس وكسر، وما يترتب على هذا التقسيم من فجوات بين الأرقام وقيم خاصة وقواعد للتقريب.

وهذا المقال من مرحلة أساسيات علوم الحاسب Computer Science Fundamentals، ويأتي بعد مقال الأنظمة العددية مباشرة، فإذا لم تقرأه فابدأ به لأننا لن نعيد شرح الثنائي والـ Hex والمتمم الثنائي هنا. وسوف نناقش في هذا المقال ما يلي:

  • تشريح الرقم حسب IEEE 754 في float وdouble: الإشارة والأس مع الإزاحة Bias والكسر والبت المخفي، وفك بتات 0.1 و3.14 الحقيقية بيدك.
  • كم رقماً عشرياً يحفظ كل نوع فعلاً، ولماذا تكبر المسافة بين الرقم والذي يليه ULP كلما كبر الرقم.
  • القيم الخاصة: الصفر السالب واللانهاية و NaN والأرقام دون الطبيعية Subnormal، وكيف تتعامل معها كل لغة بطريقتها.
  • التقريب Rounding إلى الزوجي، والمقارنة بقيمة سماح نسبية، وتراكم الخطأ وجمع Kahan مع قياس حقيقي.
  • الأرقام الصحيحة الكبيرة داخل double ومعرفات JSON، والأنواع العشرية في اللغات وفي PostgreSQL.
  • الأنواع الصغيرة في الذكاء الاصطناعي: fp16 وbfloat16 وfp8، ثم حادثتان موثقتان: Patriot وAriane 5.
  • تجربة عملية كاملة في Containers بلغة C# وPython وJavaScript (Node.js) وقاعدة PostgreSQL، مع المخرجات الحقيقية.

تشريح الرقم: الإشارة والأس والكسر

الفكرة التي ذكرناها في مقال الأنظمة العددية هي أن الرقم يكتب بصيغة علمية بالأساس 2، والآن سوف ننزل إلى التفاصيل، فكل رقم طبيعي Normal في IEEE 754 قيمته تساوي:

value = (-1)^sign × 1.fraction × 2^(exponent - bias)

والجدول التالي يبين كيف يقسم المعيار البتات في النوعين الأشهر، وهما float بحجم 32 بت، ويسميه المعيار binary32، وdouble بحجم 64 بت، ويسميه binary64، وهو النوع الافتراضي للأرقام العشرية في C# وJava وPython، والنوع الوحيد للأرقام في JavaScript:

النوعالإشارة Signالأس Exponentالإزاحة Biasالكسر Fractionالدقة الفعلية
float (binary32)1 بت8 بتات12723 بتاً24 بتاً
double (binary64)1 بت11 بتاً102352 بتاً53 بتاً

وفي هذا الجدول شيئان يحتاجان إلى شرح. الأول هو البت المخفي Hidden Bit، ويسمى أيضاً الواحد الضمني Implicit Leading 1، فكل رقم طبيعي بالثنائي إذا كتبته بالصيغة العلمية يبدأ بالواحد قبل الفاصلة، تماماً كما أن الرقم العشري بالصيغة العلمية يبدأ برقم من 1 إلى 9، والفرق أن الثنائي ليس فيه إلا رقم واحد غير الصفر، وهو الواحد، ولذلك لا داعي لحفظه أصلاً، فيخزن المعيار ما بعد الفاصلة فقط ويضيف الواحد عند القراءة، وبهذا يكسب بتاً إضافياً من الدقة مجاناً، فتصبح دقة double الفعلية 53 بتاً مع أن الكسر 52 بتاً فقط.

والثاني هو الإزاحة Bias، وقد يتساءل البعض: لماذا يخزن الأس بعد إضافة 1023 إليه بدلاً من استخدام المتمم الثنائي Two's Complement الذي شرحناه في قسم الأرقام السالبة؟ والإجابة أن الإزاحة تجعل الأس المخزن رقماً موجباً دائماً، وبالتالي إذا وضعت الأس قبل الكسر وقرأت بتات رقمين موجبين كأنهما عددان صحيحان، فإن ترتيبهما كأعداد صحيحة هو نفس ترتيبهما كأرقام عشرية، وهذا يجعل المقارنة والترتيب أبسط في العتاد Hardware، وهو أيضاً السبب في أن الرقم الذي يلي أي رقم موجب هو ببساطة بتاته زائد واحد، كما سوف نرى في تجربة Node.js.

مثال: نفك بتات 3.14 بيدنا

لنأخذ الرقم 3.14، فقد طبعت بتاته البرامج الثلاثة في التجربة العملية بنفس القيمة 0x40091EB851EB851F، وطريقة فكها كما يلي:

  1. نحول الـ Hex إلى ثنائي ونقسمه 1 و11 و52، فالإشارة 0، أي أن الرقم موجب.
  2. الأس المخزن 10000000000 وقيمته 1024، فنطرح الإزاحة 1024 - 1023 ويكون الأس الحقيقي 1.
  3. الكسر 1001000111101011100001010001111010111000010100011111، فنضع قبله البت المخفي فيصبح 1.1001000111101011100001010001111…، وقيمته بالعشري 1.5700000000000000621724893790087662637233734130859375.
  4. نضرب في 21 فيكون الناتج 3.140000000000000124344978758017532527446746826171875، وهذه بالضبط القيمة التي طبعتها Decimal(3.14) في Python.

لاحظ أن الكسر ينتهي بالبتات …0001111 ثم يقطع، والسبب أن 0.14 بالثنائي مثل 0.1 كسر لا ينتهي، فقرب المعيار آخر بت إلى أقرب قيمة، وكانت هذه القيمة أكبر قليلاً من 3.14 الحقيقية. أما في float فبتات 3.14 هي 0x4048F5C3، والأس المخزن 128 - 127 = 1 أيضاً، ولكن الكسر 23 بتاً فقط، فالقيمة المخزنة 3.1400001049041748046875، أي أن الخطأ ظهر في الخانة السابعة بدلاً من الخانة السابعة عشرة. والصورة التالية تبين البتات الحقيقية للرقم 3.14 في النوعين وكيف تتحول إلى القيمة:

64 خانة لبتات 3.14 من النوع double بالقيمة 0x40091EB851EB851F مقسمة إلى بت إشارة بنفسجي و11 بتاً للأس بالبرتقالي و52 بتاً للكسر بالأزرق، وتحتها خطوات الفك: الأس 1024 ناقص 1023 يساوي 1، والبت المخفي 1 قبل الكسر، والناتج 3.14000000000000012، ثم 32 خانة للنوع float بالقيمة 0x4048F5C3 وقيمته 3.14000010
البت المخفي لا يخزن، والأس يخزن بعد إضافة الإزاحة، والكسر يقطع عند آخر بت متاح

كم رقماً عشرياً يحفظ float وdouble؟

الدقة في IEEE 754 تقاس بالبتات وليس بالخانات العشرية، فـ float يحفظ 24 بتاً من الأرقام المهمة، و224 يساوي 16,777,216، أي أكثر قليلاً من سبع خانات عشرية، وdouble يحفظ 53 بتاً، و253 يساوي تقريباً 9 × 1015، أي قرابة 16 خانة. ولهذا تجد في جدول أنواع C# أن دقة float من 6 إلى 9 خانات، ودقة double من 15 إلى 17، وهذان الرقمان لهما معنيان مختلفان:

  • الرقم الأصغر (6 و15): هو عدد الخانات العشرية الذي تضمن أن يعود كما هو إذا كتبته كنص ثم حولته إلى الرقم ثم أعدته نصاً، ولهذا تطبع Python القيمة sys.float_info.dig = 15، وتقول وثائق PostgreSQL إن دقة real ست خانات على الأقل ودقة double precision خمس عشرة خانة على الأقل.
  • الرقم الأكبر (9 و17): هو عدد الخانات الذي تحتاجه لتطبع الرقم المخزن بحيث لا يختلط بجاره، ولهذا طبعت Python الرقم 0.1 من النوع float بسبع عشرة خانة فظهر 0.10000000149011612.

وبعد هذه الحدود يبدأ الرقم في فقدان خاناته بصمت، فالرقم 16777217 أصبح 16777216 في float، والرقم 9007199254740993 أصبح 9007199254740992 في double، ولم تظهر أي رسالة خطأ في الحالتين. وفي PostgreSQL ظهرت مفاجأة إضافية، فالاستعلام SELECT 0.1::real يعرض 0.1، بينما تحويل نفس القيمة إلى float8 يعرض 0.10000000149011612، والسبب أن PostgreSQL يعرض أقصر نص يعيد نفس القيمة، أما تحويل 16777217::real إلى numeric فأعطى 16777200، أي أن التحويل احتفظ بست خانات فقط، وهذا مثال واضح على أن الرقم الذي تراه على الشاشة ليس دائماً الرقم المخزن.

والقاعدة العملية: اجعل double هو الاختيار الافتراضي، واستخدم float فقط عندما تكون الأرقام تقريبية أصلاً وكثيرة جداً، مثل الرسوميات وقراءات الحساسات وأوزان نماذج التعلم الآلي، والسبب أن float يأخذ نصف ذاكرة double كما شرحنا في مقال الذاكرة، ويتسع منه في مسجل SIMD بحجم 256 بت ثمانية أرقام بدلاً من أربعة، كما رأينا في مقال المعالج، ولكن مقابل ذلك تخسر نصف الخانات تقريباً.

المسافة بين الرقم والذي يليه تكبر مع حجمه

من القسم السابق ينتج أمر مهم، وهو أن الأرقام العشرية في الحاسب ليست موزعة بالتساوي على خط الأعداد، فبين 1 و2 يوجد 252 رقماً في double، وبين 2 و4 يوجد نفس العدد بالضبط، وبين 1024 و2048 كذلك، لأن الأس يحدد المجال والكسر يقسمه إلى نفس عدد الخطوات دائماً، وبالتالي كلما تضاعف حجم الرقم تضاعفت المسافة بينه وبين الرقم الذي يليه. وهذه المسافة تسمى ULP، اختصاراً لـ Unit in the Last Place، أي قيمة آخر بت في الكسر عند هذا الحجم. والصورة التالية تبين ذلك:

خط أعداد مقسم إلى مجالات من 1 إلى 2 ومن 2 إلى 4 ومن 4 إلى 8 ومن 8 إلى 16، في كل مجال نفس عدد العلامات فتتباعد العلامات كلما اتجهنا يميناً، وتحته جدول بالمسافة الحقيقية بين الرقم والذي يليه في double من math.ulp: عند 1 تساوي 2.2e-16 وعند مليون 1.2e-10 وعند 2 أس 53 تساوي 2، وفي float عند مليون تساوي 0.0625
نفس عدد الأرقام في كل مجال، فتتضاعف المسافة بينها كلما تضاعف المجال

وفي Python تحسب هذه المسافة بالدالة math.ulp، وتحصل على الرقم التالي بالدالة math.nextafter، وفي C# بالدالة Math.BitIncrement، وفي Java بالدالتين Math.ulp وMath.nextUp في الكلاس Math، أما JavaScript فليس فيها دالة جاهزة، فكتبنا في التجربة دالة تزيد بتات الرقم بمقدار واحد، وهذه بعض القيم الحقيقية من المخرجات:

الرقمالمسافة إلى الرقم التالي في doubleالمسافة في float
12.22 × 10-161.19 × 10-7
1,000,0001.16 × 10-100.0625
1,000,000,0001.19 × 10-7لم نقسها
1,791,460,000 (وقت Unix بالثواني في أكتوبر 2026)2.38 × 10-7 ثانية128 ثانية
253 و10162
103001.49 × 10284

وفي الجدول أعلاه لاحظ التالي:

  • عند 1016 أصبحت المسافة 2، ولهذا طبعت Python أن 1e16 + 1 == 1e16 صحيح، فالرقم 1 أصغر من نصف المسافة، فيقرب الناتج إلى نفس الرقم ويضيع الجمع كله.
  • في float عند مليون المسافة 0.0625، فإذا أضفت 0.1 إلى مليون لا يمكن أن يزيد الرقم 0.1، وإنما يقرب إلى أقرب مضاعف للمسافة، ولهذا طبعت C# أن (1e6f + 0.1f) - 1e6f يساوي 0.125، وهذا هو سر الرقم 1087937 الذي سوف نراه في قسم تراكم الخطأ.
  • وقت Unix الحالي في float يحفظ بفجوة 128 ثانية، فالرقم 1791460000 خزن كـ 1791459968، لذلك لا تحفظ الوقت أبداً في float، واحفظه كعدد صحيح 64 بت كما شرحنا في مقال التاريخ والوقت.
⚠️
في C# لا تستخدم double.Epsilon كقيمة سماح في المقارنة، فهو حسب توثيقه أصغر رقم موجب أكبر من الصفر، أي 5E-324 كما طبعته التجربة، وهو رقم دون طبيعي Subnormal وليس إبسلون الآلة Machine Epsilon، والمقارنة به تساوي تقريباً المقارنة بـ ==. أما إبسلون الآلة، أي المسافة بين 1 والرقم التالي، فهو 2.220446049250313E-16، ويسمى Number.EPSILON في JavaScript وsys.float_info.epsilon في Python.

القيم الخاصة: صفران ولا نهاية و NaN

حجز المعيار قيمتين من قيم الأس لحالات خاصة، فإذا كانت كل بتات الأس أصفاراً فالرقم صفر أو رقم دون طبيعي، وإذا كانت كلها آحاداً فالرقم لا نهاية Infinity أو ليس رقماً NaN، اختصاراً لـ Not a Number. والصورة التالية تبين بتات كل حالة في double:

ستة صفوف من البتات: الصفر الموجب كل بتاته أصفار، والصفر السالب بت الإشارة فقط واحد، واللانهاية الأس كله آحاد والكسر أصفار، و NaN الأس كله آحاد والكسر ليس صفراً، والرقم دون الطبيعي الأس أصفار والكسر ليس صفراً وأصغره 5e-324، وأصغر رقم طبيعي 2.2e-308
قيمتا الأس الأصغر والأكبر محجوزتان للصفر والأرقام دون الطبيعية واللانهاية و NaN

وكل قيمة من هذه القيم لها سلوك يفاجئ المطور عندما يقابله لأول مرة، وهذا ما ظهر في التجربة:

  • الصفر السالب Negative Zero: بما أن الإشارة بت مستقل، فهناك صفر موجب وصفر سالب، وهما متساويان في المقارنة، فـ 0.0 == -0.0 صحيح في كل اللغات، ولكن القسمة عليهما مختلفة، فـ 1 / -0 في JavaScript يعطي -Infinity، ولا يفرق بينهما في JavaScript إلا Object.is. ويظهر الصفر السالب عادة عندما تقرب رقماً سالباً صغيراً، فـ Math.round(-0.5) أعطى -0، وقد يظهر في التقارير كـ -0 فيسأل عنه المستخدم.
  • اللانهاية Infinity: ناتج الفيضان، فـ 1e308 * 10 يعطي inf بدلاً من رمي خطأ، و1.0 / 0.0 يعطي Infinity في C# وJavaScript، أما Python فاختارت أن ترمي ZeroDivisionError، وهذا اختيار اللغة وليس المعيار.
  • NaN: ناتج العمليات التي ليس لها جواب، مثل inf - inf و0.0 / 0.0 وMath.Sqrt(-1). والقاعدة الأغرب في المعيار أن NaN لا يساوي أي شيء ولا حتى نفسه، فـ NaN == NaN خطأ في اللغات الثلاث، وتحقق منه بـ math.isnan أو double.IsNaN أو Number.isNaN.
  • الأرقام دون الطبيعية Subnormal: عندما يصبح الأس أصغر من أقل قيمة طبيعية، وهي 2.2250738585072014e-308، يتوقف المعيار عن إضافة البت المخفي، فتصغر الأرقام تدريجياً مع فقدان الدقة حتى أصغر رقم ممكن 5e-324، وهذا يسمى التناقص التدريجي Gradual Underflow، ونصف هذا الرقم يقرب إلى صفر كما طبعت Python وNumber.MIN_VALUE / 2 في Node.js.

والمشكلة الحقيقية في NaN أن كل لغة وكل دالة تتعامل معه بطريقة مختلفة، والجدول التالي من مخرجات التجربة نفسها:

البيئةالسلوكالناتج
Pythonnan in [nan] مع نفس الكائن، ثم مع كائنين مختلفينTrue ثم False، لأن البحث يقارن هوية الكائن قبل القيمة
Pythonmax(nan, 1) وmax(1, nan)nan و1، فالنتيجة تعتمد على الترتيب
Pythonsorted([3, nan, 1, 2])[3, nan, 1, 2]، أي أن الترتيب فشل بصمت
JavaScript[NaN].indexOf(NaN) و[NaN].includes(NaN)-1 وtrue
JavaScriptJSON.stringify([NaN, Infinity, -0])[null,null,0]
Pythonjson.dumps(nan)NaN، وهو نص ليس JSON صحيحاً
C#nan == double.NaN وnan.Equals(nan)False وTrue
C#Array.Sort على مصفوفة فيها NaN[NaN, 1, 2, 3]
PostgreSQL'NaN'::float8 = 'NaN'::float8t، وNaN أكبر من Infinity

والسبب في سلوك PostgreSQL مذكور صراحة في وثائق الأنواع الرقمية: المعيار يقول إن NaN لا يساوي شيئاً، ولكن قاعدة البيانات تحتاج أن ترتب القيم وتبني عليها فهارس Indexes، لذلك تعامل NaN كقيمة تساوي نفسها وأكبر من كل الأرقام. وهذا يعني أن نفس البيانات قد تعطي نتيجة مختلفة إذا قارنتها في الكود وإذا قارنتها في الاستعلام، فلا تسمح لـ NaN بالدخول إلى بياناتك أصلاً، وتحقق منه عند الإدخال. أما json.dumps في Python فيكتب NaN افتراضياً مع أن صيغة JSON في RFC 8259 لا تسمح به، فإذا قرأ الملف برنامج آخر فسوف يفشل، لذلك مرر allow_nan=False كما في توثيق الوحدة json حتى ترمي الدالة خطأ واضحاً بدلاً من كتابة ملف لا يقرأ.

التقريب: لماذا يعطي Math.Round(2.5) القيمة 2؟

كل عملية على الأرقام العشرية تنتهي بتقريب، لأن الناتج الحقيقي لجمع أو ضرب رقمين نادراً ما يكون له تمثيل دقيق، فيختار المعالج أقرب رقم ممكن. والطريقة الافتراضية في IEEE 754 هي التقريب إلى الأقرب، وعند التعادل إلى الزوجي Round Half to Even، أي إذا كان الناتج في المنتصف تماماً بين رقمين، اختر الرقم الذي ينتهي آخر بت فيه بصفر. ونفس القاعدة تستخدمها بعض دوال التقريب في اللغات، ويسميها البعض تقريب البنوك Banker's Rounding:

  • في Python أعطت round(0.5), round(1.5), round(2.5), round(3.5) القيم 0 و2 و2 و4، وتوثيق round يقول إن التقريب يتجه إلى الخيار الزوجي.
  • في C# أعطى Math.Round(2.5) القيمة 2، لأن الوضع الافتراضي في Math.Round هو MidpointRounding.ToEven، وحتى (2.5).ToString("F0") أعطى 2.
  • في PostgreSQL أعطى round(2.5::float8) القيمة 2، بينما أعطى round(2.5::numeric) القيمة 3، وتوثيق الدوال الرياضية يقول إن numeric يقرب التعادل بعيداً عن الصفر، أما double precision فيعتمد على النظام والأشهر هو التقريب إلى الزوجي.
  • أما JavaScript فتختلف عن الجميع، فـ Math.round يقرب التعادل نحو الموجب، فأعطى Math.round(2.5) القيمة 3 وMath.round(-2.5) القيمة -2.

وقد يتساءل البعض: لماذا يختار أحد التقريب إلى الزوجي وهو يخالف ما تعلمناه في المدرسة؟ والإجابة أن التقريب الذي تعلمناه يرفع كل حالة تعادل إلى الأعلى، فإذا قربت الأرقام 0.5 و1.5 و2.5 و3.5 بالطريقة المدرسية فمجموعها بعد التقريب 1 + 2 + 3 + 4 = 10، بينما مجموعها الحقيقي 8، أما التقريب إلى الزوجي فيعطي 0 + 2 + 2 + 4 = 8، لأن نصف حالات التعادل تنزل ونصفها يصعد، فلا يتراكم انحياز Bias في اتجاه واحد عندما تجمع آلاف القيم المقربة. ولهذا يستخدمه المعيار داخل كل عملية حسابية.

وهناك فخ ثان أخطر، وهو أن الرقم الذي تظن أنه في المنتصف ليس في المنتصف أصلاً، فقد رأينا في مقال الأنظمة العددية أن 2.675 مخزنة كـ 2.67499999999999982236431605997495353221893310546875، فأعطت round(2.675, 2) في Python القيمة 2.67. والجديد هنا أن C# أعطت لنفس الرقم Math.Round(2.675, 2, MidpointRounding.AwayFromZero) القيمة 2.68، والسبب أن خوارزمية التقريب إلى خانتين تختلف بين اللغتين، فالأولى تعمل على القيمة المخزنة بالضبط والثانية تضرب في 100 أولاً، وهذا وحده كاف لتعرف أن تقريب double إلى خانات عشرية لا يعطي نتيجة يمكن الاعتماد عليها بين نظامين. والحل أن تقرب بنوع عشري وتحدد الطريقة صراحة، فـ Decimal("2.675").quantize(Decimal("0.01"), rounding=ROUND_HALF_UP) في Python وMath.Round(2.675m, 2, MidpointRounding.AwayFromZero) في C# وround(2.675::numeric, 2) في PostgreSQL أعطت كلها 2.68، وفي Java تحدد الطريقة بـ RoundingMode مثل HALF_UP أو HALF_EVEN. واختر الطريقة التي يطلبها النظام المحاسبي أو الضريبي الذي تعمل له، ولا تتركها للقيمة الافتراضية للغة.

كيف تقارن رقمين عشريين؟

ذكرنا في مقال الأنظمة العددية أن المقارنة بـ == لا تصلح، والآن سوف نرى لماذا لا تصلح أيضاً قيمة السماح الثابتة التي يستخدمها أغلب المطورين. فالحل البسيط هو abs(a - b) < 1e-9، وهو يعمل مع 0.1 + 0.2 و0.3 لأن الفرق بينهما 5.551115123125783e-17 كما طبعته Python، ولكنه يفشل في الاتجاهين:

  • مع الأرقام الكبيرة: الرقمان 1e15 + 0.3 و1e15 + 0.5 متجاوران تقريباً عند هذا الحجم، لأن المسافة بين الأرقام هناك 0.125، ومع ذلك أعطت المقارنة بقيمة السماح الثابتة False، فقيمة 1e-9 أصغر من المسافة نفسها، وبالتالي فهي تساوي == عند هذا الحجم.
  • مع الأرقام الصغيرة جداً: إذا كانت أرقامك قياسات بحجم 10-10، فقيمة السماح 1e-9 أكبر من الأرقام نفسها، فتعتبر كل القياسات مساوية للصفر.

والطريقة الصحيحة هي قيمة سماح نسبية Relative Tolerance، أي أن يكون الفرق صغيراً بالنسبة لحجم الرقمين، مع قيمة سماح مطلقة Absolute Tolerance للأرقام القريبة من الصفر فقط. وهذا ما تفعله math.isclose في Python، فهي تعتبر الرقمين متساويين إذا كان abs(a - b) <= max(rel_tol * max(abs(a), abs(b)), abs_tol)، والقيمة الافتراضية rel_tol=1e-09 وabs_tol=0.0. ولاحظ النتائج في التجربة: isclose أعطت True للرقمين الكبيرين، وأعطت False عند مقارنة 1e-10 بالصفر، لأن أي نسبة من الصفر تساوي صفراً، فلما أضفنا abs_tol=1e-9 أصبحت True. وفي C# وJavaScript لا توجد دالة جاهزة، فاكتب نفس المعادلة في دالة مساعدة واحدة في مشروعك، واختر قيمة السماح من طبيعة المسألة، فدقة الحساس أو أصغر وحدة في العملة هي التي تحدد متى يكون الرقمان «متساويين»، وليس رقماً تنسخه من الإنترنت.

تراكم الخطأ: جمع 0.1 عشرة ملايين مرة

خطأ التقريب في عملية واحدة لا يتجاوز نصف المسافة بين الأرقام عند حجم الناتج، وهو صغير جداً، ولكن عندما تجمع ملايين القيم في متغير واحد فإن كل عملية تضيف خطأها، والأسوأ أن المجموع يكبر فتكبر المسافة بين الأرقام عنده، فيكبر خطأ كل عملية جديدة. ولكي نرى ذلك بالأرقام جمعنا 0.1 عشرة ملايين مرة، والناتج الصحيح مليون، في أربع بيئات على نفس الجهاز:

البيئةالطريقةالناتجالوقت
C#double بالجمع العادي999999.999838975422 ms
C#double مع Kahan100000081 ms
C#decimal1000000.0196 ms
C#float بالجمع العادي108793722 ms
Node.jsNumber بالجمع العادي999999.999838975441 ms
Node.jsNumber مع Kahan100000059 ms
Node.jsعدد صحيح بالهللات ثم القسمة على 100100000019 ms
Node.jsfloat32 عبر Math.fround108793739 ms
Pythonfloat بالجمع العادي999999.99983897543.09 s
PythonKahan1000000.06.37 s
Pythonmath.fsum1000000.00.78 s
PythonDecimal1000000.07.35 s
PostgreSQLsum على عمود float8999999.99983897542.2 s
PostgreSQLsum على عمود real1.087937e+061.7 s
PostgreSQLsum على عمود numeric(12,2)1000000.002.1 s

والأوقات في الجدول للتوضيح فقط، فقد قسناها مرة واحدة على جهاز تطوير وهي تتغير بين تشغيل وآخر، فوقت Kahan في C# تراوح بين 81 و204 ms في ثلاثة تشغيلات، وأوقات PostgreSQL الثلاثة تراوحت بين 1.1 و4 ثوان في تشغيلات متتالية، لأن قراءة عشرة ملايين صف تأخذ أغلب الوقت. أما النتائج نفسها فثابتة ومتطابقة، وفي الجدول أعلاه لاحظ التالي:

  • نتيجة double هي 999999.9998389754 في البيئات الأربع حرفاً بحرف، لأنها كلها تطبق نفس المعيار بنفس طريقة التقريب، فالخطأ ليس في لغة معينة.
  • نتيجة float أكبر من مليون بـ 87937، والسبب ما رأيناه في قسم المسافة بين الأرقام، فعندما يقترب المجموع من مليون تصبح المسافة 0.0625، فيقرب كل 0.1 إلى 0.125، فيزيد المجموع أكثر مما يجب في كل خطوة.
  • جمع Kahan أعطى الناتج الصحيح بنفس النوع double، ودفعنا مقابله وقتاً أطول، أما math.fsum في Python فكان أسرع من الجمع العادي نفسه لأنه مكتوب بلغة C، وتصفه الوثائق بأنه يتتبع عدة مجاميع جزئية حتى لا يفقد الدقة.
  • العدد الصحيح بالهللات هو الأسرع والأدق معاً، وهذا يؤكد ما قلناه في قسم المبالغ المالية.

كيف يعمل جمع Kahan؟

الفكرة التي نشرها William Kahan، ويشرحها David Goldberg في ورقته المشهورة What Every Computer Scientist Should Know About Floating-Point Arithmetic، أن الجزء الذي يضيع من الرقم الصغير عند جمعه مع المجموع الكبير يمكن حسابه، فنحفظه في متغير تعويض Compensation ونضيفه في الخطوة التالية. والخطوات في كل دورة أربع:

  1. y = x - c: نطرح من القيمة الجديدة ما ضاع في الخطوة السابقة.
  2. t = sum + y: نجمع، وهنا تضيع البتات المنخفضة من y لأن sum كبير.
  3. c = (t - sum) - y: الفرق t - sum هو ما أضيف فعلاً، وطرح y منه يعطي ما ضاع بالسالب.
  4. sum = t: ونكمل.
مقارنة بين حلقتين: في الجمع العادي يدخل 0.1 إلى المجموع الكبير فيسقط جزء صغير منه في سلة المهملات في كل دورة، وفي جمع Kahan يحفظ الجزء الساقط في متغير c ويعاد إلى الدورة التالية، مع الناتجين الحقيقيين 999999.9998389754 و1000000
جمع Kahan يحسب الجزء الذي ضاع في كل عملية ويعيده في العملية التالية

ويجدر الانتباه إلى أن هذه الخطوات تبدو للمترجم Compiler كأنها لا تفعل شيئاً، لأن (t - sum) - y يساوي صفراً في الرياضيات العادية، وتذكر ورقة Goldberg أن المترجم الذي يعيد ترتيب العمليات كأنها أرقام حقيقية قد يحذف التعويض كله، لذلك إذا استخدمت Kahan في C أو C++ فلا تفعل على هذا الكود خيارات المترجم التي تسمح بإعادة ترتيب العمليات العشرية، مثل -ffast-math في GCC. وفي C# وJavaScript وPython لم يحدث ذلك، والدليل أن الناتج كان مليوناً بالضبط.

الأرقام الصحيحة الكبيرة في double ومعرفات JSON

الكسر في double مع البت المخفي 53 بتاً، لذلك يمثل كل الأرقام الصحيحة حتى 253 بالضبط، وبعدها تصبح المسافة بين الأرقام 2 كما رأينا، فتختفي الأرقام الفردية، ثم تصبح 4 بعد 254، وهكذا. ولهذا فإن Number.MAX_SAFE_INTEGER في JavaScript هو 9007199254740991، أي 253 - 1، و2**53 + 1 === 2**53 صحيح كما طبعته التجربة. وقد ذكرنا هذا الحد في مقال الأنظمة العددية، والآن سوف نرى أين يؤذيك فعلاً، وهو JSON.

فصيغة JSON نفسها لا تحدد حجماً للأرقام، ولكن القسم 6 من RFC 8259 ينبه إلى أن أغلب البرامج تقرأ الأرقام كـ double، وأن الأرقام الصحيحة مضمونة التوافق بين الأنظمة فقط إذا كانت قيمتها المطلقة لا تتجاوز 253 - 1. ولنفرض أن السيرفر يرسل طلباً معرفه 1234567890123456789، وهو معرف 64 بت عادي، فهذا ما حدث في التجربة:

  • في Node.js أعطى JSON.parse القيمة 1234567890123456800، أي أن آخر ثلاث خانات تغيرت دون أي خطأ، فإذا استخدم الكلاينت هذا المعرف ليطلب تفاصيل الطلب فسوف يطلب طلباً آخر أو لا يجد شيئاً.
  • في Python أعطى json.loads الرقم الصحيح، لأن int في Python بدقة غير محدودة، ولكن عندما حولناه إلى float أصبح 1234567890123456768، وهو نفس الرقم الذي خزنته JavaScript، ولكنها تطبع أقصر نص يعيده فتظهر 800 بدلاً من 768.
  • في PostgreSQL أعطى 9007199254740993::float8::bigint القيمة 9007199254740992، فإذا مر المعرف بعمود أو حساب من نوع float8 في أي مرحلة فقد يتغير.

والحل أن ترسل المعرفات الكبيرة كنصوص في JSON، وهذا ما تفعله مواصفة ProtoJSON في gRPC تلقائياً، فالأنواع int64 وuint64 تكتب في JSON كنص عشري، ولهذا السبب تحديداً، وقد شرحنا تصميم الواجهات وأنواع الحقول في مقال تصميم واجهات API، والمعرفات من نوع Snowflake في مقال المعرفات الفريدة. وإذا كنت تستقبل JSON لا تتحكم فيه، فإن JSON.parse في النسخ الحديثة يمرر للدالة Reviver معاملاً ثالثاً فيه النص الأصلي للقيمة context.source، فتستطيع تحويله إلى BigInt قبل أن يضيع، وقد نجح ذلك في Node.js 22 فأعطى 1234567890123456789n. ولكن انتبه إلى أن JSON.stringify لا يعرف BigInt، فقد رمى TypeError: Do not know how to serialize a BigInt، لذلك حوله إلى نص قبل الإرسال.

الأنواع العشرية: ما الذي تحله وما الذي لا تحله

في مقال الأنظمة العددية قلنا إن المال يحفظ بنوع عشري أو بعدد صحيح بأصغر وحدة، والآن سوف نرى ما الذي يحدث داخل هذه الأنواع، لأن فهمها يمنعك من خطأين: أن تظن أنها دقيقة في كل شيء، وأن تستخدمها في مكان لا تحتاجها فيه.

فالنوع decimal في C# هو أيضاً فاصلة عائمة، ولكن بالأساس 10 بدلاً من 2، فهو يخزن عدداً صحيحاً بحجم 96 بت ومعه معامل قياس Scale يحدد مكان الفاصلة العشرية، وحجمه 16 بايتاً كما طبعت sizeof(decimal)، وأكبر قيمة له 79228162514264337593543950335. وبما أن الأساس 10، فكل كسر عشري مثل 0.1 و0.01 يمثل بالضبط، فأعطى 0.1m + 0.2m القيمة 0.3 والمقارنة بـ 0.3m صحيحة. ولكن الكسور التي لا تنتهي بالعشري ما زالت مقربة، فـ 1m / 3m * 3m أعطى 0.9999999999999999999999999999 وليس 1، لأن الثلث بالعشري لا ينتهي، تماماً كما لا تنتهي 0.1 بالثنائي. ونفس الشيء في Python، فالوحدة decimal تقرب كل عملية إلى دقة السياق Context، وهي 28 خانة افتراضياً، وفي Java يرمي BigDecimal.divide الاستثناء ArithmeticException إذا كان الناتج كسراً لا ينتهي ولم تحدد له الدقة وطريقة التقريب، وهذا السلوك من Java أوضح في رأينا لأنه يجبرك على القرار.

وفي قواعد البيانات الصورة مشابهة، فـ PostgreSQL فيه real بحجم 4 بايتات وdouble precision أو float8 بحجم 8 بايتات، وكلاهما IEEE 754، وnumeric بحجم متغير يحفظ الخانات العشرية بالضبط، والجدول التالي يجمع ما ظهر في التجربة:

الاختبارrealfloat8numeric
0.1 + 0.20.3 (العرض مختصر)0.300000000000000040.3
جمع 0.1 عشرة ملايين مرة1.087937e+06999999.99983897541000000.00
round(2.5)23
الحجم بالأمر pg_column_size4812 للقيمة 123456.78
NaN وInfinityنعمنعمنعم، و Infinity فقط في عمود بدون دقة محددة

والقاعدة في اختيار النوع، سواء في الكود أو في قاعدة البيانات، كما يلي:

  • المال والكميات التي لها خانات عشرية محددة قانوناً، مثل الأسعار والضرائب والأرصدة: decimal أو BigDecimal أو Decimal أو numeric(p, s)، أو عدد صحيح بأصغر وحدة، والسبب أن المحاسب يقارن بالخانة الأخيرة، ووثائق PostgreSQL تنصح بـ numeric صراحة لهذه الحالة.
  • القياسات والحسابات العلمية والإحصاءات والرسوميات: double أو float8، لأن القياس نفسه تقريبي، والسرعة والمساحة أهم من آخر خانة، والنوع العشري هنا أبطأ بكثير دون فائدة، فقد كان decimal في تجربتنا أبطأ من double قرابة 9 مرات في نفس التشغيل.
  • الأعداد الهائلة من القيم التقريبية، مثل أوزان نماذج الذكاء الاصطناعي ومصفوفات الصور: float أو أصغر منه، وهذا موضوع القسم التالي.

الأرقام العشرية في الذكاء الاصطناعي: fp16 وbfloat16 وfp8

نموذج اللغة عبارة عن مليارات الأرقام العشرية، وكل بت في كل رقم يكلف ذاكرة وعرض نطاق للذاكرة Memory Bandwidth، لذلك ظهرت أنواع أصغر من float، وكلها تتبع نفس فكرة IEEE 754، أي إشارة وأس وكسر، والفرق في عدد البتات لكل جزء، وهذا الاختيار هو الذي يحدد ما تخسره:

النوعإشارة / أس / كسرأكبر قيمةالرقم 0.1 المخزن (من التجربة)
fp32 (float)1 / 8 / 23تقريباً 3.4 × 10380.100000001490116
fp16 (Half)1 / 5 / 10655040.0999755859375
bfloat161 / 8 / 7نفس مجال fp32 تقريباً0.10009765625
fp8 E4M31 / 4 / 3448لم نقسه
fp8 E5M21 / 5 / 257344لم نقسه

وفي الجدول أعلاه لاحظ التالي:

  • fp16 فيه كسر أطول من bfloat16، أي دقة أعلى، ولكن الأس 5 بتات فقط، فأكبر قيمة 65504، وفي التجربة أصبح الرقم 70000 لا نهاية في C# بالنوع Half، ورمت Python OverflowError، والرقم 1e-8 أصبح صفراً، والرقم 2049 أصبح 2048. ولاحظ أن C# طبعت Half.MaxValue كـ 65500 لأنها تطبع أقصر نص يعيد نفس القيمة، بينما القيمة الحقيقية 65504 كما طبعتها Python.
  • bfloat16 هو ببساطة أعلى 16 بتاً من fp32، فبتات 0.1 فيه 0x3DCD وهي بداية بتات float نفسها 0x3DCCCCCD بعد التقريب، ولذلك له نفس الأس ونفس المجال كما تشرح مدونة Google Cloud، فالنوع ظهرت فكرته في فريق Google Brain واستخدم في معالجات TPU، فالرقم 70000 أصبح 70144 بدلاً من أن يفيض، و1e-8 بقي تقريباً كما هو، ولكن الدقة أقل بكثير، فالرقم 1000.5 أصبح 1000.
  • fp8 له نوعان حسب ورقة FP8 Formats for Deep Learning من NVIDIA وArm وIntel: E4M3 بدقة أعلى ومجال حتى 448، وE5M2 بمجال حتى 57344 ودقة أقل، والقيم القصوى في الجدول من جدول الورقة نفسها.

والدرس من هذه الأنواع أن عدد بتات الأس يحدد المجال، أي هل يفيض الرقم أو يختفي، وعدد بتات الكسر يحدد الدقة، ونماذج التعلم العميق تتحمل خسارة الدقة أكثر بكثير من خسارة المجال، لأن قيمة واحدة تفيض إلى لا نهاية قد تفسد التدريب كله، بينما خطأ صغير في كل وزن لا يغير النتيجة كثيراً، وتقول مدونة Google نفسها إن الشبكات العصبية أكثر حساسية لحجم الأس منها لحجم الكسر، ولهذا اختاروا لـ bfloat16 أس fp32 كاملاً. والصورة التالية تقارن البتات في هذه الأنواع:

خمسة أشرطة بتات بنفس الألوان: fp32 بثمانية بتات أس و23 كسر، وfp16 بخمسة بتات أس و10 كسر، وbfloat16 بثمانية بتات أس و7 كسر، وfp8 E4M3 وE5M2، وبجانب كل شريط أكبر قيمة وقيمة 0.1 المخزنة، ثم جدول ذاكرة نموذج من 8 مليارات معامل: 32 GB بـ fp32 و16 GB بـ 16 بت و8 GB بـ 8 بت و4 GB بـ 4 بت
بتات الأس تحدد المجال وبتات الكسر تحدد الدقة، وكل نصف في الحجم ينصف ذاكرة النموذج

أما التكميم Quantization إلى int8 أو int4 فهو فكرة مختلفة، فالأوزان تحفظ كأعداد صحيحة صغيرة ومعها معامل قياس Scale مشترك لكل مجموعة من الأوزان، فتضرب العدد الصحيح في المعامل لتحصل على قيمة تقريبية للوزن الأصلي. والحساب البسيط للذاكرة هو عدد المعاملات في عدد البايتات لكل معامل: 4 بايتات في fp32 و2 في fp16 وbfloat16 و1 في fp8 وint8 ونصف بايت في 4 بت، فنموذج من 8 مليارات معامل يحتاج للأوزان وحدها قرابة 32 GB أو 16 أو 8 أو 4 على الترتيب. وقد شرحنا هذا الحساب بالتفصيل في مقال كيف تعمل نماذج اللغة، وكيف تختار نوع التكميم لجهازك في مقال تشغيل النماذج على جهازك.

حادثتان موثقتان: Patriot وAriane 5

الحادثتان التاليتان تذكران كثيراً كأمثلة على أخطاء الأرقام العشرية، ولكن الكثير من الشروح تخلط بينهما، فالأولى خطأ دقة تراكم مع الوقت، والثانية ليست خطأ دقة أصلاً وإنما تحويل رقم عشري إلى عدد صحيح أصغر منه، ولهذا سوف نعرض كل واحدة كما في تقريرها الرسمي.

📡
في 25 فبراير 1991، أثناء عملية عاصفة الصحراء، لم تتمكن بطارية صواريخ Patriot في الظهران من تتبع صاروخ Scud واعتراضه، فسقط الصاروخ على ثكنة للجيش الأمريكي وقتل 28 جندياً. وبحسب تقرير مكتب المحاسبة الحكومي الأمريكي GAO رقم IMTEC-92-26، فإن ساعة النظام تعد الوقت بأعشار الثانية كعدد صحيح، ولكي يتوقع النظام مكان الصاروخ القادم يحول هذا العدد إلى رقم حقيقي، وبما أن مسجلات الحاسب 24 بتاً فقط، فإن التحويل فقد جزءاً من الدقة، وهذا الخطأ يكبر كلما طال تشغيل النظام. وكانت البطارية تعمل دون توقف أكثر من 100 ساعة، فانحرفت الساعة 0.3433 ثانية، وانزاحت منطقة البحث Range Gate قرابة 687 متراً عن مكان الصاروخ، فلم يره النظام. وكان الجيش قد تلقى بيانات من إسرائيل عن هذا الانحراف، وجهز نسخة معدلة من البرنامج، ولكنها وصلت إلى الظهران في 26 فبراير، أي بعد الحادثة بيوم.

والتقرير لا يذكر طريقة التمثيل الداخلية بالتفصيل، ولكننا نستطيع أن نعيد حساب أرقامه، فإذا قطعنا الرقم 0.1 بعد 23 بتاً من الفاصلة الثنائية دون تقريب، فالقيمة المخزنة 0.09999990463256836، والخطأ في كل عشر ثانية 9.5367e-08 ثانية، وبضربه في عدد الأعشار في كل فترة نحصل على هذا الجدول من تجربة Python، وبجانبه أرقام الملحق الثاني من تقرير GAO:

مدة التشغيلعدد الأعشارالانحراف من حسابناالانحراف في تقرير GAOانزياح منطقة البحث في التقرير
1 ساعة36,0000.0034 ثانية0.0034 ثانية7 أمتار
8 ساعات288,0000.0275 ثانية0.0275 ثانية55 متراً
20 ساعة720,0000.0687 ثانية0.0687 ثانية137 متراً
48 ساعة1,728,0000.1648 ثانية0.1648 ثانية330 متراً
72 ساعة2,592,0000.2472 ثانية0.2472 ثانية494 متراً
100 ساعة3,600,0000.3433 ثانية0.3433 ثانية687 متراً
ساعة تعد بأعشار الثانية تحول إلى رقم في مسجل 24 بت يخسر 0.000000095 ثانية في كل عشر، وتحتها رسم بياني لانزياح منطقة البحث بالمتر مقابل ساعات التشغيل من تقرير GAO: 7 أمتار بعد ساعة و137 بعد 20 ساعة و687 بعد 100 ساعة، مع خط عند 20 ساعة حيث يخرج الهدف من منطقة البحث
الخطأ في كل عشر ثانية صغير جداً، ولكنه يضرب في عدد الأعشار فيكبر مع كل ساعة تشغيل

والدرس هنا ليس أن 24 بتاً قليلة فقط، وإنما أن ثابتاً لا يمثل بالضبط بالثنائي، مثل 0.1، إذا ضرب في عداد يكبر مع الوقت فإن خطأه يكبر مع الوقت أيضاً، وأن إعادة تشغيل النظام كانت تصفر العداد وتخفي المشكلة، فلم تظهر في الاختبارات القصيرة. لذلك عد الوقت بعدد صحيح بالوحدة الأصغر، مثل الميلي ثانية، وحوله عند الحاجة فقط، واختبر الأنظمة التي تعمل طويلاً لمدة طويلة فعلاً.

🚀
في 4 يونيو 1996 انفجر صاروخ Ariane 5 في رحلته الأولى بعد قرابة 40 ثانية من بدء الإطلاق، على ارتفاع 3700 متر تقريباً. وبحسب تقرير لجنة التحقيق الذي نشرته وكالة الفضاء الأوروبية ESA، فإن برنامج نظام الملاحة بالقصور الذاتي حول رقماً عشرياً من 64 بت إلى عدد صحيح بإشارة من 16 بت، وكان الرقم، وهو متغير يسمى BH أي الانحراف الأفقي Horizontal Bias، أكبر مما يتسع له 16 بت، فحدث خطأ Operand Error لأن هذا التحويل في كود Ada لم يكن محمياً، مع أن تحويلات أخرى بجانبه كانت محمية. والبرنامج منقول من Ariane 4، ومسار Ariane 5 فيه سرعة أفقية أعلى بكثير، ولم تستخدم بيانات مسار Ariane 5 في تحليل هذه المتغيرات. ثم أوقف نظام معالجة الأخطاء المعالج كما تنص المواصفة، والنظام الاحتياطي يشغل نفس البرنامج فتوقف قبله بدورة واحدة، والدالة التي سببت الخطأ لم يكن لها أي فائدة بعد الإقلاع أصلاً.

لاحظ أن Ariane 5 لم يفشل بسبب دقة الرقم العشري، فالرقم المحسوب كان صحيحاً، وإنما بسبب تحويله إلى نوع أصغر دون التحقق من المجال. ونفس الخطأ تستطيع أن تكتبه في C# اليوم، ففي التجربة حولنا 40000.5 إلى short، وأكبر قيمة لـ short هي 32767:

  • بدون فحص Unchecked أعطى التحويل -25536 بصمت، وهذا أسوأ من خطأ واضح، لأن البرنامج يكمل بقيمة خاطئة.
  • مع checked رمى OverflowException، وهذا ما حدث في Ariane، والفرق أن عليك أن تقرر ماذا تفعل بالاستثناء، لا أن توقف النظام كله.
  • وتحويل 1e10 إلى int أعطى 2147483647، وتحويل NaN أعطى صفراً، لأن .NET 9 جعل هذه التحويلات تتوقف عند الحد الأقصى Saturating على x86 وx64 كما في صفحة التغييرات، أي أن نفس الكود كان يعطي نتيجة مختلفة في النسخ السابقة.

لذلك كل تحويل من رقم عشري إلى عدد صحيح أصغر يحتاج إلى فحص للمجال وقرار مكتوب لما يحدث إذا خرجت القيمة عنه، ولا تعتمد على السلوك الافتراضي للغة لأنه يختلف بين اللغات وبين نسخ اللغة الواحدة.

التجربة العملية: الكود والمخرجات كاملة

شغلنا كل الأمثلة في Containers منفصلة على جهاز x86-64 بإصدارات محددة من ال Images، حتى تستطيع إعادة التجربة بنفس النتائج. ضع الملفات الأربعة في مجلد واحد، ثم شغل كل لغة بالأمر الخاص بها من داخل المجلد.

Python: البتات والمسافات والقيم الخاصة والجمع وساعة Patriot

الملف floats.py يستخدم الوحدة struct لقراءة البتات، والصيغة e فيها هي fp16، أما bfloat16 فحسبناه بأخذ أعلى 16 بتاً من float مع التقريب إلى الزوجي:

import json, math, struct, sys, time
from decimal import Decimal, ROUND_HALF_UP, ROUND_HALF_EVEN
from fractions import Fraction

def bits64(x):
    (u,) = struct.unpack('>Q', struct.pack('>d', x))
    b = f'{u:064b}'
    return u, b[0], b[1:12], b[12:]

def bits32(x):
    (u,) = struct.unpack('>I', struct.pack('>f', x))
    b = f'{u:032b}'
    return u, b[0], b[1:9], b[9:]

print('== 1) the real bits')
for x in (0.1, 3.14):
    u, s, e, m = bits64(x)
    print(f'double {x}: 0x{u:016X}')
    print(f'  sign={s} exponent={e} ({int(e, 2)} - 1023 = {int(e, 2) - 1023})')
    print(f'  mantissa={m}')
    print(f'  value = (-1)^{s} x 1.{m[:12]}... x 2^{int(e, 2) - 1023}')
    print(f'  exact stored value = {Decimal(x)}')
    u, s, e, m = bits32(x)
    f32 = struct.unpack('>f', struct.pack('>f', x))[0]
    print(f'float  {x}: 0x{u:08X} sign={s} exponent={e} ({int(e, 2)} - 127 = {int(e, 2) - 127}) mantissa={m}')
    print(f'  exact stored value = {Decimal(f32)}')

print('== 2) precision in decimal digits')
print('float32(0.1) printed with 17 digits:', f'{struct.unpack(">f", struct.pack(">f", 0.1))[0]:.17g}')
print('sys.float_info.dig =', sys.float_info.dig, ' mant_dig =', sys.float_info.mant_dig)
print('16777217 as float32 ->', struct.unpack('>f', struct.pack('>f', 16777217.0))[0])
print('9007199254740993 as double ->', float(9007199254740993))

print('== 3) the gap between neighbours (ULP)')
for x in (1.0, 1000.0, 1e6, 1e9, 2.0**53, 1e16, 1e300):
    print(f'math.ulp({x:g}) = {math.ulp(x):.6g}')
print('math.nextafter(1.0, 2.0) =', repr(math.nextafter(1.0, 2.0)))
print('1e16 + 1 == 1e16 ?', 1e16 + 1 == 1e16)
print('sys.float_info.epsilon =', sys.float_info.epsilon)
now = 1791460000.0  # a Unix time in October 2026, in seconds
f32 = struct.unpack('>f', struct.pack('>f', now))[0]
up = struct.unpack('>f', struct.pack('>I', struct.unpack('>I', struct.pack('>f', now))[0] + 1))[0]
print(f'Unix time {now:.0f}: double gap = {math.ulp(now):.3g} s, float32 stores {f32:.0f}, float32 gap = {up - f32:.0f} s')

print('== 4) special values')
print('0.0 == -0.0 ?', 0.0 == -0.0, ' copysign(1, -0.0) =', math.copysign(1, -0.0))
inf, nan = float('inf'), float('nan')
print('inf - inf =', inf - inf, ' inf * 0 =', inf * 0, ' 1e308 * 10 =', 1e308 * 10)
print('nan == nan ?', nan == nan, ' math.isnan(nan) =', math.isnan(nan))
print('nan in [nan] ?', nan in [nan], ' float("nan") in [float("nan")] ?', float('nan') in [float('nan')])
print('max(nan, 1) =', max(nan, 1), ' max(1, nan) =', max(1, nan))
print('sorted([3, nan, 1, 2]) =', sorted([3, nan, 1, 2]))
print('smallest normal  =', sys.float_info.min)
print('smallest subnormal =', math.ulp(0.0), ' half of it =', math.ulp(0.0) / 2)
print('json.dumps(nan) =', json.dumps(nan))
try:
    json.dumps(nan, allow_nan=False)
except ValueError as e:
    print('json.dumps(nan, allow_nan=False) ->', e)
try:
    1.0 / 0.0
except ZeroDivisionError as e:
    print('1.0 / 0.0 ->', type(e).__name__, e)

print('== 5) rounding')
print('round(0.5), round(1.5), round(2.5), round(3.5) =', round(0.5), round(1.5), round(2.5), round(3.5))
print('round(2.675, 2) =', round(2.675, 2), ' because 2.675 is', Decimal(2.675))
print('Decimal("2.5") HALF_EVEN ->', Decimal('2.5').quantize(Decimal('1'), rounding=ROUND_HALF_EVEN),
      ' HALF_UP ->', Decimal('2.5').quantize(Decimal('1'), rounding=ROUND_HALF_UP))
print('Decimal("2.675") to 2 places HALF_UP ->', Decimal('2.675').quantize(Decimal('0.01'), rounding=ROUND_HALF_UP))

print('== 6) comparing')
a = 0.1 + 0.2
print('a == 0.3 ?', a == 0.3, ' abs(a - 0.3) =', abs(a - 0.3))
print('math.isclose(a, 0.3) =', math.isclose(a, 0.3))
print('math.isclose(1e-10, 0.0) =', math.isclose(1e-10, 0.0), ' with abs_tol=1e-9 ->', math.isclose(1e-10, 0.0, abs_tol=1e-9))
big1, big2 = 1e15 + 0.3, 1e15 + 0.5
print('abs(big1 - big2) < 1e-9 ?', abs(big1 - big2) < 1e-9, ' isclose ->', math.isclose(big1, big2))

print('== 7) summing 0.1 ten million times')
N = 10_000_000
t = time.perf_counter(); s = 0.0
for _ in range(N):
    s += 0.1
t1 = time.perf_counter() - t
t = time.perf_counter(); s2 = 0.0; c = 0.0
for _ in range(N):
    y = 0.1 - c
    tt = s2 + y
    c = (tt - s2) - y
    s2 = tt
t2 = time.perf_counter() - t
t = time.perf_counter(); s3 = math.fsum(0.1 for _ in range(N)); t3 = time.perf_counter() - t
t = time.perf_counter(); s4 = sum(Decimal('0.1') for _ in range(N)); t4 = time.perf_counter() - t
print(f'naive  {s!r:<22} error {s - 1e6:+.3e}  {t1:.2f}s')
print(f'kahan  {s2!r:<22} error {s2 - 1e6:+.3e}  {t2:.2f}s')
print(f'fsum   {s3!r:<22} error {s3 - 1e6:+.3e}  {t3:.2f}s')
print(f'Decimal {s4!s:<21}                  {t4:.2f}s')

print('== 8) the Patriot clock: 0.1 chopped to 23 bits after the binary point')
tenth = Fraction(int(Fraction(1, 10) * 2**23), 2**23)
err = Fraction(1, 10) - tenth
print('stored 0.1 =', float(tenth), ' error per tick =', f'{float(err):.4e}')
for hours in (1, 8, 20, 48, 72, 100):
    ticks = hours * 3600 * 10
    print(f'{hours:>3} h: {ticks:>9} ticks, clock drift = {float(err * ticks):.4f} s')

print('== 9) big integers in JSON')
doc = '{"id": 1234567890123456789}'
print('json.loads ->', json.loads(doc)['id'])
print('as float  ->', float(json.loads(doc)['id']), '->', int(float(json.loads(doc)['id'])))

print('== 10) fp16 and bfloat16')
def f16(x):
    return struct.unpack('<e', struct.pack('<e', x))[0]
def bf16_bits(x):
    (u,) = struct.unpack('>I', struct.pack('>f', x))
    r = ((u >> 16) & 1) + 0x7FFF
    return (u + r) >> 16
def bf16(x):
    return struct.unpack('>f', struct.pack('>I', bf16_bits(x) << 16))[0]
for x in (0.1, 3.14159265, 1000.5, 65504.0, 70000.0, 1e-8):
    try:
        h = f16(x)
    except OverflowError:
        h = 'OverflowError'
    print(f'{x:>12g} -> fp16 {h!s:<22} bf16 {bf16(x)!r}')
print('fp16 bits of 0.1 = 0x%04X' % struct.unpack('<H', struct.pack('<e', 0.1))[0], ' bf16 bits of 0.1 = 0x%04X' % bf16_bits(0.1))
docker run --rm --name lab-float-python -v "$PWD:/lab" -w /lab python:3.13.8-slim python floats.py

والمخرج سوف يكون كما يلي:

== 1) the real bits
double 0.1: 0x3FB999999999999A
  sign=0 exponent=01111111011 (1019 - 1023 = -4)
  mantissa=1001100110011001100110011001100110011001100110011010
  value = (-1)^0 x 1.100110011001... x 2^-4
  exact stored value = 0.1000000000000000055511151231257827021181583404541015625
float  0.1: 0x3DCCCCCD sign=0 exponent=01111011 (123 - 127 = -4) mantissa=10011001100110011001101
  exact stored value = 0.100000001490116119384765625
double 3.14: 0x40091EB851EB851F
  sign=0 exponent=10000000000 (1024 - 1023 = 1)
  mantissa=1001000111101011100001010001111010111000010100011111
  value = (-1)^0 x 1.100100011110... x 2^1
  exact stored value = 3.140000000000000124344978758017532527446746826171875
float  3.14: 0x4048F5C3 sign=0 exponent=10000000 (128 - 127 = 1) mantissa=10010001111010111000011
  exact stored value = 3.1400001049041748046875
== 2) precision in decimal digits
float32(0.1) printed with 17 digits: 0.10000000149011612
sys.float_info.dig = 15  mant_dig = 53
16777217 as float32 -> 16777216.0
9007199254740993 as double -> 9007199254740992.0
== 3) the gap between neighbours (ULP)
math.ulp(1) = 2.22045e-16
math.ulp(1000) = 1.13687e-13
math.ulp(1e+06) = 1.16415e-10
math.ulp(1e+09) = 1.19209e-07
math.ulp(9.0072e+15) = 2
math.ulp(1e+16) = 2
math.ulp(1e+300) = 1.48702e+284
math.nextafter(1.0, 2.0) = 1.0000000000000002
1e16 + 1 == 1e16 ? True
sys.float_info.epsilon = 2.220446049250313e-16
Unix time 1791460000: double gap = 2.38e-07 s, float32 stores 1791459968, float32 gap = 128 s
== 4) special values
0.0 == -0.0 ? True  copysign(1, -0.0) = -1.0
inf - inf = nan  inf * 0 = nan  1e308 * 10 = inf
nan == nan ? False  math.isnan(nan) = True
nan in [nan] ? True  float("nan") in [float("nan")] ? False
max(nan, 1) = nan  max(1, nan) = 1
sorted([3, nan, 1, 2]) = [3, nan, 1, 2]
smallest normal  = 2.2250738585072014e-308
smallest subnormal = 5e-324  half of it = 0.0
json.dumps(nan) = NaN
json.dumps(nan, allow_nan=False) -> Out of range float values are not JSON compliant: nan
1.0 / 0.0 -> ZeroDivisionError float division by zero
== 5) rounding
round(0.5), round(1.5), round(2.5), round(3.5) = 0 2 2 4
round(2.675, 2) = 2.67  because 2.675 is 2.67499999999999982236431605997495353221893310546875
Decimal("2.5") HALF_EVEN -> 2  HALF_UP -> 3
Decimal("2.675") to 2 places HALF_UP -> 2.68
== 6) comparing
a == 0.3 ? False  abs(a - 0.3) = 5.551115123125783e-17
math.isclose(a, 0.3) = True
math.isclose(1e-10, 0.0) = False  with abs_tol=1e-9 -> True
abs(big1 - big2) < 1e-9 ? False  isclose -> True
== 7) summing 0.1 ten million times
naive  999999.9998389754      error -1.610e-04  3.09s
kahan  1000000.0              error +0.000e+00  6.37s
fsum   1000000.0              error +0.000e+00  0.78s
Decimal 1000000.0                              7.35s
== 8) the Patriot clock: 0.1 chopped to 23 bits after the binary point
stored 0.1 = 0.09999990463256836  error per tick = 9.5367e-08
  1 h:     36000 ticks, clock drift = 0.0034 s
  8 h:    288000 ticks, clock drift = 0.0275 s
 20 h:    720000 ticks, clock drift = 0.0687 s
 48 h:   1728000 ticks, clock drift = 0.1648 s
 72 h:   2592000 ticks, clock drift = 0.2472 s
100 h:   3600000 ticks, clock drift = 0.3433 s
== 9) big integers in JSON
json.loads -> 1234567890123456789
as float  -> 1.2345678901234568e+18 -> 1234567890123456768
== 10) fp16 and bfloat16
         0.1 -> fp16 0.0999755859375        bf16 0.10009765625
     3.14159 -> fp16 3.140625               bf16 3.140625
      1000.5 -> fp16 1000.5                 bf16 1000.0
       65504 -> fp16 65504.0                bf16 65536.0
       70000 -> fp16 OverflowError          bf16 70144.0
       1e-08 -> fp16 0.0                    bf16 1.0011717677116394e-08
fp16 bits of 0.1 = 0x2E66  bf16 bits of 0.1 = 0x3DCD

في المخرج أعلاه لاحظ التالي:

  • القسم الأول يطبع البتات مقسمة إلى إشارة وأس وكسر، وهي نفس البتات التي في الصورة الأولى، وDecimal(x) يطبع القيمة المخزنة بكل خاناتها دون تقريب.
  • sorted([3, nan, 1, 2]) أعاد القائمة دون ترتيب، لأن كل مقارنة مع NaN خطأ، فتظن الخوارزمية أن القائمة مرتبة.
  • القسم الثامن يعيد حساب جدول تقرير Patriot بالكسور الدقيقة Fraction، حتى لا يدخل خطأ double نفسه في الحساب.

Node.js: البتات والمعرفات الكبيرة وتقريب JavaScript

الملف floats.js يقرأ البتات بـ DataView، والدالة nextUp تزيد بتات الرقم بواحد لتحصل على الرقم التالي، وهذا يعمل للأرقام الموجبة بفضل الإزاحة التي شرحناها في أول المقال:

const dv = new DataView(new ArrayBuffer(8));
const bits = (x) => { dv.setFloat64(0, x); return dv.getBigUint64(0); };
const fromBits = (u) => { dv.setBigUint64(0, u); return dv.getFloat64(0); };
const nextUp = (x) => fromBits(bits(x) + 1n);

console.log('== 1) the real bits');
for (const x of [0.1, 3.14]) console.log(x, '0x' + bits(x).toString(16).toUpperCase(), ' Math.fround:', Math.fround(x));

console.log('== 2) the gap between neighbours');
console.log('Number.EPSILON =', Number.EPSILON);
for (const x of [1, 1e6, 2 ** 53, 1e16]) console.log(`next after ${x} = ${nextUp(x)}  gap = ${nextUp(x) - x}`);

console.log('== 3) big integers');
console.log('MAX_SAFE_INTEGER =', Number.MAX_SAFE_INTEGER, ' isSafeInteger(2**53) =', Number.isSafeInteger(2 ** 53));
console.log('2**53 + 1 =', 2 ** 53 + 1, ' 2**53 + 1 === 2**53 ?', 2 ** 53 + 1 === 2 ** 53);
const doc = '{"id": 1234567890123456789, "id_str": "1234567890123456789"}';
const o = JSON.parse(doc);
console.log('JSON.parse id     ->', o.id);
console.log('JSON.parse id_str ->', o.id_str, '-> BigInt', BigInt(o.id_str));
const keep = JSON.parse(doc, (k, v, ctx) => (k === 'id' && ctx && ctx.source) ? BigInt(ctx.source) : v);
console.log('reviver with source ->', keep.id, typeof keep.id);
try { JSON.stringify({ id: 10n }); } catch (e) { console.log('JSON.stringify(10n) ->', e.constructor.name + ':', e.message); }

console.log('== 4) special values');
console.log('0 === -0 ?', 0 === -0, ' Object.is(0, -0) =', Object.is(0, -0), ' 1 / -0 =', 1 / -0);
console.log('NaN === NaN ?', NaN === NaN, ' Object.is(NaN, NaN) =', Object.is(NaN, NaN));
console.log('[NaN].indexOf(NaN) =', [NaN].indexOf(NaN), ' [NaN].includes(NaN) =', [NaN].includes(NaN));
console.log('Math.max(1, NaN) =', Math.max(1, NaN), ' JSON.stringify([NaN, Infinity, -0]) =', JSON.stringify([NaN, Infinity, -0]));
console.log('Number.MIN_VALUE =', Number.MIN_VALUE, ' Number.MIN_VALUE / 2 =', Number.MIN_VALUE / 2);

console.log('== 5) rounding');
console.log('Math.round(2.5), Math.round(-2.5), Math.round(-0.5) =', Math.round(2.5), Math.round(-2.5), Math.round(-0.5));
console.log('(2.5).toFixed(0) =', (2.5).toFixed(0), ' (1.005).toFixed(2) =', (1.005).toFixed(2), ' (1.005).toPrecision(20) =', (1.005).toPrecision(20));

console.log('== 6) summing 0.1 ten million times');
const N = 10_000_000;
let t = performance.now(), s = 0;
for (let i = 0; i < N; i++) s += 0.1;
const t1 = performance.now() - t;
t = performance.now(); let s2 = 0, c = 0;
for (let i = 0; i < N; i++) { const y = 0.1 - c; const tt = s2 + y; c = (tt - s2) - y; s2 = tt; }
const t2 = performance.now() - t;
t = performance.now(); let cents = 0;
for (let i = 0; i < N; i++) cents += 10;
const t3 = performance.now() - t;
t = performance.now(); let f = Math.fround(0);
for (let i = 0; i < N; i++) f = Math.fround(f + Math.fround(0.1));
const t4 = performance.now() - t;
console.log(`naive double  ${s}  ${t1.toFixed(0)} ms`);
console.log(`kahan double  ${s2}  ${t2.toFixed(0)} ms`);
console.log(`integer cents ${cents / 100}  ${t3.toFixed(0)} ms`);
console.log(`naive float32 ${f}  ${t4.toFixed(0)} ms`);
docker run --rm --name lab-float-node -v "$PWD:/lab" -w /lab node:22.20.0-alpine node floats.js

والمخرج سوف يكون كما يلي:

== 1) the real bits
0.1 0x3FB999999999999A  Math.fround: 0.10000000149011612
3.14 0x40091EB851EB851F  Math.fround: 3.140000104904175
== 2) the gap between neighbours
Number.EPSILON = 2.220446049250313e-16
next after 1 = 1.0000000000000002  gap = 2.220446049250313e-16
next after 1000000 = 1000000.0000000001  gap = 1.1641532182693481e-10
next after 9007199254740992 = 9007199254740994  gap = 2
next after 10000000000000000 = 10000000000000002  gap = 2
== 3) big integers
MAX_SAFE_INTEGER = 9007199254740991  isSafeInteger(2**53) = false
2**53 + 1 = 9007199254740992  2**53 + 1 === 2**53 ? true
JSON.parse id     -> 1234567890123456800
JSON.parse id_str -> 1234567890123456789 -> BigInt 1234567890123456789n
reviver with source -> 1234567890123456789n bigint
JSON.stringify(10n) -> TypeError: Do not know how to serialize a BigInt
== 4) special values
0 === -0 ? true  Object.is(0, -0) = false  1 / -0 = -Infinity
NaN === NaN ? false  Object.is(NaN, NaN) = true
[NaN].indexOf(NaN) = -1  [NaN].includes(NaN) = true
Math.max(1, NaN) = NaN  JSON.stringify([NaN, Infinity, -0]) = [null,null,0]
Number.MIN_VALUE = 5e-324  Number.MIN_VALUE / 2 = 0
== 5) rounding
Math.round(2.5), Math.round(-2.5), Math.round(-0.5) = 3 -2 -0
(2.5).toFixed(0) = 3  (1.005).toFixed(2) = 1.00  (1.005).toPrecision(20) = 1.0049999999999998934
== 6) summing 0.1 ten million times
naive double  999999.9998389754  41 ms
kahan double  1000000  59 ms
integer cents 1000000  19 ms
naive float32 1087937  39 ms

في المخرج أعلاه لاحظ التالي:

  • Math.fround يقرب الرقم إلى أقرب float ثم يعيده Number، ولهذا استخدمناه لمحاكاة جمع float32 فظهر نفس الرقم 1087937 الذي ظهر في C# وPostgreSQL.
  • الحقل id_str وصل سليماً لأنه نص، والحقل id تغير، والـ Reviver مع context.source أنقذ القيمة الأصلية.
  • (1.005).toFixed(2) أعطى 1.00 لأن القيمة المخزنة 1.0049999999999998934 كما طبعها toPrecision(20).

C#: الأنواع والتقريب والتحويل إلى عدد صحيح

الملف floats.cs يعمل دون مشروع بالأمر dotnet run floats.cs، وهي ميزة التطبيقات المكونة من ملف واحد في .NET 10:

using System;
using System.Diagnostics;

Console.WriteLine("== 1) the real bits");
foreach (double x in new[] { 0.1, 3.14 })
{
    long b = BitConverter.DoubleToInt64Bits(x);
    int sign = (int)(b >> 63), exp = (int)((b >> 52) & 0x7FF);
    long frac = b & 0xFFFFFFFFFFFFF;
    Console.WriteLine($"{x}: 0x{b:X16} sign={sign} exponent={exp}-1023={exp - 1023} fraction=0x{frac:X13}");
    Console.WriteLine($"  float: 0x{BitConverter.SingleToInt32Bits((float)x):X8}  (float){x} = {(float)x:G9}");
}

Console.WriteLine("== 2) neighbours and the confusing double.Epsilon");
Console.WriteLine($"Math.BitIncrement(1.0) - 1.0 = {Math.BitIncrement(1.0) - 1.0:R}");
Console.WriteLine($"double.Epsilon = {double.Epsilon:R}  (the smallest subnormal, not the machine epsilon)");
Console.WriteLine($"float: 16777216f + 1f = {16777216f + 1f:R}");
float million = 1_000_000f;
Console.WriteLine($"float gap at 1 = {MathF.BitIncrement(1f) - 1f:R}  float gap at 1e6 = {MathF.BitIncrement(million) - million:R}  (1e6f + 0.1f) - 1e6f = {(million + 0.1f) - million:R}");

Console.WriteLine("== 3) special values");
double zero = 0.0, negZero = -0.0;
Console.WriteLine($"0.0 == -0.0: {zero == negZero}  1/-0.0 = {1 / negZero}  double.IsNegative(-0.0) = {double.IsNegative(negZero)}");
double nan = double.NaN;
Console.WriteLine($"NaN == NaN: {nan == double.NaN}  NaN.Equals(NaN): {nan.Equals(nan)}  double.IsNaN: {double.IsNaN(nan)}");
Console.WriteLine($"1.0 / 0.0 = {1.0 / zero}  0.0 / 0.0 = {zero / zero}  Math.Sqrt(-1) = {Math.Sqrt(-1)}");
var arr = new[] { 3.0, double.NaN, 1.0, 2.0 }; Array.Sort(arr);
Console.WriteLine($"Array.Sort with NaN: [{string.Join(", ", arr)}]");

Console.WriteLine("== 4) rounding");
Console.WriteLine($"Math.Round(0.5)={Math.Round(0.5)} Math.Round(1.5)={Math.Round(1.5)} Math.Round(2.5)={Math.Round(2.5)}");
Console.WriteLine($"Math.Round(2.5, MidpointRounding.AwayFromZero) = {Math.Round(2.5, MidpointRounding.AwayFromZero)}");
Console.WriteLine($"Math.Round(2.675, 2, AwayFromZero) = {Math.Round(2.675, 2, MidpointRounding.AwayFromZero)}");
Console.WriteLine($"Math.Round(2.675m, 2, AwayFromZero) = {Math.Round(2.675m, 2, MidpointRounding.AwayFromZero)}");
Console.WriteLine($"(2.5).ToString(\"F0\") = {(2.5).ToString("F0")}");

Console.WriteLine("== 5) float to integer: what happened to Ariane 5");
double bh = 40000.5;
Console.WriteLine($"(short){bh} unchecked = {unchecked((short)bh)}");
try { short s = checked((short)bh); Console.WriteLine(s); }
catch (OverflowException e) { Console.WriteLine($"checked((short){bh}) -> {e.GetType().Name}: {e.Message}"); }
double huge = 1e10;
Console.WriteLine($"(int)1e10 = {(int)huge}  (int)NaN = {(int)nan}");

Console.WriteLine("== 6) decimal");
Console.WriteLine($"0.1m + 0.2m = {0.1m + 0.2m}  equal to 0.3m: {0.1m + 0.2m == 0.3m}");
Console.WriteLine($"1m / 3m * 3m = {1m / 3m * 3m}");
Console.WriteLine($"decimal.MaxValue = {decimal.MaxValue}");
Console.WriteLine($"sizeof: float {sizeof(float)}, double {sizeof(double)}, decimal {sizeof(decimal)}, Half 2");

Console.WriteLine("== 7) Half (fp16)");
Console.WriteLine($"(Half)0.1 = {(Half)0.1}  bits 0x{BitConverter.HalfToUInt16Bits((Half)0.1):X4}  Half.MaxValue = {Half.MaxValue}  (Half)70000 = {(Half)70000.0}");
Console.WriteLine($"(Half)2049 = {(Half)2049.0}  Half.Epsilon = {Half.Epsilon}");

Console.WriteLine("== 8) summing 0.1 ten million times");
const int N = 10_000_000;
var sw = Stopwatch.StartNew(); double sum = 0; for (int i = 0; i < N; i++) sum += 0.1; var t1 = sw.ElapsedMilliseconds;
sw.Restart(); double ks = 0, c = 0; for (int i = 0; i < N; i++) { double y = 0.1 - c; double t = ks + y; c = (t - ks) - y; ks = t; } var t2 = sw.ElapsedMilliseconds;
sw.Restart(); decimal ds = 0; for (int i = 0; i < N; i++) ds += 0.1m; var t3 = sw.ElapsedMilliseconds;
sw.Restart(); float fs = 0; for (int i = 0; i < N; i++) fs += 0.1f; var t4 = sw.ElapsedMilliseconds;
Console.WriteLine($"double naive  {sum:R}  {t1} ms");
Console.WriteLine($"double kahan  {ks:R}  {t2} ms");
Console.WriteLine($"decimal       {ds}  {t3} ms");
Console.WriteLine($"float naive   {fs:R}  {t4} ms");
docker run --rm --name lab-float-dotnet -v "$PWD:/lab" -w /lab mcr.microsoft.com/dotnet/sdk:10.0.100 dotnet run floats.cs

والمخرج سوف يكون كما يلي:

== 1) the real bits
0.1: 0x3FB999999999999A sign=0 exponent=1019-1023=-4 fraction=0x999999999999A
  float: 0x3DCCCCCD  (float)0.1 = 0.100000001
3.14: 0x40091EB851EB851F sign=0 exponent=1024-1023=1 fraction=0x91EB851EB851F
  float: 0x4048F5C3  (float)3.14 = 3.1400001
== 2) neighbours and the confusing double.Epsilon
Math.BitIncrement(1.0) - 1.0 = 2.220446049250313E-16
double.Epsilon = 5E-324  (the smallest subnormal, not the machine epsilon)
float: 16777216f + 1f = 16777216
float gap at 1 = 1.1920929E-07  float gap at 1e6 = 0.0625  (1e6f + 0.1f) - 1e6f = 0.125
== 3) special values
0.0 == -0.0: True  1/-0.0 = -Infinity  double.IsNegative(-0.0) = True
NaN == NaN: False  NaN.Equals(NaN): True  double.IsNaN: True
1.0 / 0.0 = Infinity  0.0 / 0.0 = NaN  Math.Sqrt(-1) = NaN
Array.Sort with NaN: [NaN, 1, 2, 3]
== 4) rounding
Math.Round(0.5)=0 Math.Round(1.5)=2 Math.Round(2.5)=2
Math.Round(2.5, MidpointRounding.AwayFromZero) = 3
Math.Round(2.675, 2, AwayFromZero) = 2.68
Math.Round(2.675m, 2, AwayFromZero) = 2.68
(2.5).ToString("F0") = 2
== 5) float to integer: what happened to Ariane 5
(short)40000.5 unchecked = -25536
checked((short)40000.5) -> OverflowException: Arithmetic operation resulted in an overflow.
(int)1e10 = 2147483647  (int)NaN = 0
== 6) decimal
0.1m + 0.2m = 0.3  equal to 0.3m: True
1m / 3m * 3m = 0.9999999999999999999999999999
decimal.MaxValue = 79228162514264337593543950335
sizeof: float 4, double 8, decimal 16, Half 2
== 7) Half (fp16)
(Half)0.1 = 0.1  bits 0x2E66  Half.MaxValue = 65500  (Half)70000 = Infinity
(Half)2049 = 2048  Half.Epsilon = 6E-08
== 8) summing 0.1 ten million times
double naive  999999.9998389754  22 ms
double kahan  1000000  81 ms
decimal       1000000.0  196 ms
float naive   1087937  22 ms

في المخرج أعلاه لاحظ التالي:

  • (Half)0.1 طبع 0.1 مع أن القيمة المخزنة 0.0999755859375 كما طبعتها Python، لأن C# تطبع أقصر نص يعيد نفس القيمة، وبتاته 0x2E66 مطابقة لبتات Python.
  • NaN.Equals(NaN) صحيح بينما == خطأ، وهذا مذكور صراحة في توثيق Double.Equals، ونتيجته أن NaN يعمل كمفتاح في Dictionary بينما يفشل في المقارنة العادية.
  • لاحظ أيضاً أن المترجم رفض في المحاولة الأولى السطر (int)1e10 برسالة error CS0221: Constant value '10000000000' cannot be converted to a 'int'، لأن القيمة ثابتة ويعرف أنها لا تتسع، لذلك وضعناها في متغير حتى يحدث التحويل وقت التشغيل.

PostgreSQL: real وfloat8 وnumeric جنباً إلى جنب

الملف floats.sql ينفذ نفس الاختبارات داخل قاعدة البيانات، ثم ينشئ جدولاً مؤقتاً فيه عشرة ملايين صف ويجمع العمود بالأنواع الثلاثة:

\pset footer off
\echo == 1) the same sum in three types
SELECT 0.1::float8 + 0.2::float8 AS float8_sum,
       0.1::real + 0.2::real     AS real_sum,
       0.1::numeric + 0.2::numeric AS numeric_sum,
       0.1::float8 + 0.2::float8 = 0.3::float8 AS float8_equal;
\echo == 2) what real really stores
SELECT 0.1::real AS shown, 0.1::real::float8 AS stored, 16777217::real::numeric AS big_real;
\echo == 3) rounding: numeric vs float8
SELECT round(2.5::numeric) AS numeric_2_5, round(2.5::float8) AS float8_2_5,
       round(3.5::float8) AS float8_3_5, round(2.675::numeric, 2) AS numeric_2_675;
\echo == 4) NaN and Infinity
SELECT 'NaN'::float8 = 'NaN'::float8 AS nan_equal,
       'NaN'::float8 > 'Infinity'::float8 AS nan_biggest,
       'Infinity'::numeric AS numeric_inf,
       '-0'::float8 AS neg_zero, '-0'::float8 = 0 AS neg_zero_equal;
\echo == 5) big integers through float8
SELECT 9007199254740993::bigint AS exact,
       9007199254740993::float8::bigint AS through_float8;
\echo == 6) summing 0.1 ten million times
CREATE TEMP TABLE t AS SELECT 0.1::float8 AS f, 0.1::real AS r, 0.1::numeric(12,2) AS n FROM generate_series(1, 10000000);
\timing on
SELECT sum(f) AS sum_float8 FROM t;
SELECT sum(r) AS sum_real FROM t;
SELECT sum(n) AS sum_numeric FROM t;
\timing off
SELECT pg_column_size(1.5::real) AS real_bytes, pg_column_size(1.5::float8) AS float8_bytes,
       pg_column_size(123456.78::numeric) AS numeric_bytes;
SELECT version();
docker run -d --name lab-float-pg -e POSTGRES_PASSWORD=lab -v "$PWD:/lab" postgres:17.11
docker exec lab-float-pg psql -U postgres -f /lab/floats.sql
docker rm -f -v lab-float-pg

والمخرج سوف يكون كما يلي:

== 1) the same sum in three types
     float8_sum      | real_sum | numeric_sum | float8_equal
---------------------+----------+-------------+--------------
 0.30000000000000004 |      0.3 |         0.3 | f

== 2) what real really stores
 shown |       stored        | big_real
-------+---------------------+----------
   0.1 | 0.10000000149011612 | 16777200

== 3) rounding: numeric vs float8
 numeric_2_5 | float8_2_5 | float8_3_5 | numeric_2_675
-------------+------------+------------+---------------
           3 |          2 |          4 |          2.68

== 4) NaN and Infinity
 nan_equal | nan_biggest | numeric_inf | neg_zero | neg_zero_equal
-----------+-------------+-------------+----------+----------------
 t         | t           |    Infinity |       -0 | t

== 5) big integers through float8
      exact       |  through_float8
------------------+------------------
 9007199254740993 | 9007199254740992

== 6) summing 0.1 ten million times
SELECT 10000000
Timing is on.
    sum_float8
-------------------
 999999.9998389754

Time: 2159.382 ms (00:02.159)
   sum_real
--------------
 1.087937e+06

Time: 1731.492 ms (00:01.731)
 sum_numeric
-------------
  1000000.00

Time: 2143.081 ms (00:02.143)
Timing is off.
 real_bytes | float8_bytes | numeric_bytes
------------+--------------+---------------
          4 |            8 |            12

                                                       version
----------------------------------------------------------------------------------------------------------------------
 PostgreSQL 17.11 (Debian 17.11-1.pgdg13+2) on x86_64-pc-linux-gnu, compiled by gcc (Debian 14.2.0-19) 14.2.0, 64-bit

في المخرج أعلاه لاحظ التالي:

  • real_sum ظهر 0.3 لأن PostgreSQL يعرض أقصر نص يعيد القيمة، أما القيمة المخزنة فليست 0.3، كما ظهر في السطر التالي عند تحويل 0.1 إلى float8.
  • '-0'::float8 عرض -0 وهو يساوي الصفر في المقارنة، تماماً كما في اللغات.
  • الخيار -v في الأمر الأخير يحذف ال Volume الذي ينشئه Docker Image الخاص بـ PostgreSQL تلقائياً، فلا يبقى على جهازك بعد التجربة.

أخطاء شائعة

  1. المقارنة بقيمة سماح ثابتة لكل الأحجام: 1e-9 أكبر من اللازم للأرقام الصغيرة وأصغر من المسافة بين الأرقام الكبيرة، فاستخدم قيمة سماح نسبية مثل math.isclose.
  2. double.Epsilon في C# كقيمة سماح: هو 5E-324 وليس إبسلون الآلة، فالمقارنة به تساوي ==.
  3. الجمع الطويل في float: عداد أو مجموع يكبر مع الوقت يجب أن يكون double على الأقل، أو عدداً صحيحاً، وإلا توقف عن الزيادة أو زاد أكثر مما يجب كما رأينا مع 1087937.
  4. الوقت في رقم عشري صغير: وقت Unix في float فجوته 128 ثانية، وعداد بأعشار الثانية مضروب في 0.1 يتراكم خطأه كما في Patriot، فاحفظ الوقت كعدد صحيح بأصغر وحدة.
  5. إدخال NaN إلى البيانات: يكسر الترتيب في Python ويتصرف بشكل مختلف في كل لغة وفي قاعدة البيانات، فتحقق منه عند الإدخال وارفضه.
  6. تقريب double إلى خانات عشرية للعرض أو المال: اللغات تختلف في النتيجة لنفس الرقم، فقرب بنوع عشري وحدد طريقة التقريب صراحة.
  7. معرفات 64 بت كأرقام في JSON: أرسلها كنصوص، فـ JavaScript تغير آخر خاناتها بصمت.
  8. تحويل رقم عشري إلى عدد صحيح أصغر دون فحص: هذا خطأ Ariane 5 نفسه، فافحص المجال أو استخدم checked وقرر ما يحدث عند الفشل.
  9. اختيار fp16 لقيم مجالها كبير: أكبر قيمة فيه 65504، فإذا كانت قيمك قد تتجاوز ذلك أو تصغر جداً فاستخدم bfloat16 أو fp32.

تدريب عملي

  • فك بيدك بتات الرقم -2.5 في double: اكتب الإشارة والأس المخزن والكسر، ثم تأكد من الناتج بالأمر struct.pack('>d', -2.5).hex() في Python.
  • احسب المسافة بين الأرقام عند 224 في float، ثم فسر لماذا لا يستطيع عداد من النوع float أن يتجاوز 16777216 إذا زاد بواحد في كل مرة، وجربه في الكود.
  • اكتب دالة isClose في C# أو JavaScript بنفس معادلة math.isclose، واختبرها على الأمثلة الثلاثة في قسم المقارنة.
  • عدل جدول PostgreSQL في التجربة ليستخدم numeric بدون دقة محددة، ثم جرب إدخال 'Infinity' فيه وفي عمود numeric(12,2)، وقارن الرسالتين.

الخلاصة

وصلنا لنهاية الموضوع، وأهم ما فيه:

  • الرقم في IEEE 754 إشارة وأس مع إزاحة وكسر مع بت مخفي، فدقة float 24 بتاً، أي 6 إلى 9 خانات عشرية، ودقة double 53 بتاً، أي 15 إلى 17 خانة.
  • المسافة بين الرقم والذي يليه تتضاعف كلما تضاعف الرقم، لذلك تضيع الإضافات الصغيرة إلى الأرقام الكبيرة، ويفقد double الأرقام الصحيحة الفردية بعد 253.
  • الصفر السالب واللانهاية و NaN قيم حقيقية في المعيار، و NaN لا يساوي نفسه في الكود ولكنه يساوي نفسه في PostgreSQL، فامنعه من الدخول إلى بياناتك.
  • التقريب الافتراضي إلى الزوجي يمنع الانحياز، ولكنه يفاجئ من ينتظر 3 من round(2.5)، فحدد طريقة التقريب صراحة بنوع عشري عندما يهمك الناتج.
  • قارن بقيمة سماح نسبية، واجمع الأعداد الكثيرة بـ Kahan أو math.fsum أو بنوع عشري أو بعدد صحيح بأصغر وحدة.
  • الأنواع العشرية دقيقة في الكسور العشرية فقط، والثلث يبقى مقرباً فيها، وأنواع الذكاء الاصطناعي الصغيرة تختار بين المجال والدقة.
  • Patriot خطأ دقة تراكم مع وقت التشغيل، وAriane 5 تحويل من رقم عشري إلى عدد صحيح من 16 بت دون حماية، ولكل منهما درس مختلف.

وفي مرحلة أساسيات علوم الحاسب نفسها تجد المقالات التي يعتمد عليها هذا المقال أو يكملها:

دليلالأنظمة العددية للمبرمجين: من Binary إلى Floating Pointلماذا يظهر القرص 465 بدل 500، ويصبح العداد سالباً فجأة، ولا يساوي 0.1 + 0.2 القيمة 0.3؟ الإجابة في طريقة تخزين الأرقام.دليلالذاكرة للمبرمجين: كيف تعمل ال Stack وال Heapلماذا تتغير قائمة لم تطلب تغييرها، وكيف تتسرب الذاكرة في لغة فيها جامع قمامة Garbage Collector، بأمثلة حقيقية من عدة لغات.دليلكيف يعمل المعالج CPU من الداخل ولماذا يهمك ذلكنتابع تعليمة واحدة داخل نواة المعالج، ونرى لماذا يصبح نفس الكود أبطأ بعشر مرات عندما تغير ترتيب المرور على البيانات.دليلأساسيات SQL للمطورين من SELECT إلى المعاملات Transactionsتتعلم SQL من الصفر على قاعدة بيانات صغيرة في PostgreSQL، من أول SELECT إلى المعاملات ومستويات العزل التي تحمي بياناتك.دليلكيف تصمم API لا تضطر إلى كسره بعد سنةمبادئ تصميم API يتطور دون أن يكسر التطبيقات التي تعتمد عليه، وكيف تختار الشكل المناسب لنظامك.دليلتشغيل نماذج اللغة على جهازك: كم ذاكرة وأي GPU تحتاج؟كيف تحسب الذاكرة التي يحتاجها النموذج، وماذا تتوقع من جهازك الحالي قبل أن تشتري بطاقة رسومية GPU.

سجل التحديثات

  • أكتوبر 2026: كتابة المقال، وتنفيذ الأمثلة في Containers على x86-64 مع Python 3.13.8 وNode.js 22.20.0 و.NET SDK 10.0.100 وPostgreSQL 17.11.
نشرة عرب رووت | ArabRoot

معرفة تستحق مكاناً في بريدك.

مقالات مختارة وأدوات مفيدة وأفكار لمشروعك القادم، في رسالة واحدة كل أسبوع.

يمكنك إلغاء الاشتراك متى شئت. الخصوصية

تم استلام طلبك. افتح بريدك واضغط رابط التأكيد لإتمام الاشتراك.