ليس سرا أن المعلومات المالية (الحسابات ، الترحيلات وغيرها من مسك الدفاتر) ليست ودية للغاية مع أرقام الفاصلة العائمة ، وتوصي العديد من المقالات باستخدام حساب نقطة ثابتة. في Java ، يتم تمثيل هذا التنسيق ، في الواقع ، بواسطة فئة BigDecimal فقط ، والتي لا يمكن استخدامها دائمًا لأسباب تتعلق بالأداء. علينا أن نبحث عن بدائل. توضح هذه المقالة مكتبة Java مكتوبة ذاتيًا لإجراء العمليات الحسابية على أرقام ثابتة الدقة. تم إنشاء المكتبة للعمل في التطبيقات المالية عالية الأداء وتسمح لك بالعمل بدقة 9 منازل عشرية مع الحفاظ على الأداء المقبول. يوجد رابط للمصادر وعلامات القياس في نهاية المقال.
حساب النقطة العائمة
يمكن لأجهزة الكمبيوتر الحديثة إجراء العمليات الحسابية بدقة محدودة فقط. هذه أجهزة منفصلة قد لا تعمل مع جميع الأرقام الممكنة ، ولكن فقط مع مجموعة فرعية قابلة للعد منها. التنسيق الأكثر شيوعًا للعمل مع الأرقام الحقيقية في ذاكرة الكمبيوتر هو النقطة العائمة (الثنائية) - النقطة العائمة (الثنائية) ، عندما يتم تخزين الأرقام في النموذج M * 2 ^ E ، حيث يكون M و E عددًا صحيحًا وترتيب العدد. لكن بعض الأرقام ، مثل 0.1 ، لا يمكن تمثيلها بدقة في هذا التنسيق. لذلك ، في سياق الحسابات المعقدة ، يتراكم بعض الخطأ حتمًا. أي أن نتيجة حساب الآلة ، على سبيل المثال 0.1 + 0.1 + 0.1 ، لا تتزامن مع 0.3 الصحيحة حسابياً. بالنظر إلى ما سبق ، عند برمجة الحساب المعقد ، يمكنك اتباع عدة استراتيجيات:
الاستراتيجية 1 - تجاهل. لا تنتبه إلى الخطأ ، واعتبر جميع العمليات حسابية مثالية وتأمل أن تكون الدقة المتاحة كافية لتحقيق نتائج مقبولة. الخيار الأكثر شيوعًا.
الاستراتيجية 2 - احسب بدقة. الصيغ لحساب أخطاء الآلة معروفة منذ عقود. أنها تجعل من الممكن تقدير من فوق الخطأ النسبي لأي عملية حسابية. ربما ، هذا ما عليك فعله للمحاكاة العددية الخطيرة. المشكلة هي أنها تستغرق الكثير من الوقت. في الواقع ، يجب أن يكون كل حرف + - * / في الرمز مصحوبًا بحساب خطأ. عليك أن تأخذ في الاعتبار جميع التبعيات بين الحسابات وتكرار الإجراء في كل مرة تقوم فيها بتغيير الرمز.
إستراتيجية 3 - استخدم الفاصلة العشرية (الفاصلة العشرية العائمة) بدلاً من ثنائي. أي ، قم بتخزين الأرقام في شكل M * 10 ^ E. هذا لا يحل مشاكل الخطأ (لا يزال الجزء العشري تقريبًا إلى عدد محدود من الأرقام المهمة) ، ولكن على الأقل يتم تمثيل جميع الأرقام "البسيطة" لشخص (مثل 1.1) بدقة في الذاكرة. العائد سيكون الأداء. يتطلب أي تطبيع للأرقام (أي انخفاض مكافئ في الجزء العشري وزيادة في الترتيب) قسمة بقوة 10 ، وهي ليست سريعة جدًا ، على عكس القسمة بقوة 2. ويجب عليك تطبيع الكثير - مع كل إضافة أو طرح بأوامر مختلفة.
الاستراتيجية 4 - استخدم نقطة ثابتة (علامة عشرية ثابتة). تبسيط الإستراتيجية 3 ، عندما نصلح الترتيب E. في هذه الحالة ، لا يكون التطبيع ضروريًا للجمع / الطرح. بالإضافة إلى ذلك ، سيكون لجميع الحسابات نفس الخطأ المطلق. هذه المقالة مخصصة لهذه الاستراتيجية.
حساب النقطة الثابتة
على النقيض من الفيزياء ، حيث يكون الخطأ النسبي مهمًا ، هناك حاجة مطلقة فقط في التمويل. إذا ، بعد معاملة مالية معقدة ، فاتورة العميل 1،000،000.23 دولار بينما كان يتوقع 1،000،000،18 دولار ، فقد تنشأ بعض الصعوبات. تفسيرات مثل "لماذا تحتاج إلى الدقة في 8 أرقام مهمة ؟؟" قد لا تركب. والنقطة هنا ليست في 5 سنتات من الخسارة (من الخطأ على العكس من ذلك ، "لصالح" العميل ليس أفضل بكثير) ، ولكن في التناقضات في المحاسبة. لذلك ، يتم تحديد قواعد الحسابات والتقريب بوضوح بين الأطراف ، كما أن القطع الأثرية من استخدام المتغيرات المزدوجة والتعويم في بعض الأحيان تعقد الحياة.
تحتوي Java على فئة قياسية لحساب النقطة الثابتة - BigDecimal. هناك مشكلتان معها: إنها بطيئة (بسبب عالميتها) وليست مستقرة. عدم الاستقرار يعني أن أي عملية تخصيص كائن على كومة الذاكرة المؤقتة. يستغرق التحديد والتحرير من كائن ما وقتًا قصيرًا ، ولكن الحسابات المكثفة في الرمز "الساخن" تخلق حملًا لائقًا على GC ، وهو أمر غير مقبول في بعض الحالات. يمكنك الاعتماد على تحليل الهروب والتحجيم ، لكنها غير مستقرة للغاية بمعنى أنه حتى التغيير الطفيف في الشفرة أو في JIT (مثل التحميل البطيء لتنفيذ واجهة جديدة) يمكن أن يقلب البنية المضمنة بالكامل رأساً على عقب ، وعملت الطريقة بشكل جيد منذ دقيقة ، يبدأ فجأة في تخصيص الذاكرة بشراسة.
UPD بسبب الأسئلة في التعليقات: السبب الرئيسي للتخلي عن BigDecimal و BigInteger ليس على الإطلاق أداءًا حسابيًا منخفضًا ، ولكن عدم الاستقرار واختيار الكائنات.
المكتبة الموصوفة هي نتيجة التعب من إعادة كتابة الحساب بدون ذاكرة من نقطة الصفر لكل صاحب عمل جديد ، وقررت كتابة مكتبتي الخاصة للتأمين اللاحق.
سأعرض على الفور مثالًا للاستخدام قبل الانتقال إلى تفاصيل التنفيذ:
public class Sample { private final Decimal margin; private final Quantity cumQuantity = new Quantity(); private final Quantity contraQuantity = new Quantity(); private final Quantity cumContraQuantity = new Quantity(); private final Price priceWithMargin = new Price(); private final Price avgPrice = new Price(); public Sample(int marginBp) {
فكرة التنفيذ
لذا ، نحتاج إلى غلاف قابل للتغيير من بدائي صحيح ، بشكل أدق ، long'a ، والذي سيعطينا ما يقرب من 19 رقمًا مهمًا (يكفي للجزء الصحيح والجزء الكسري). في المدى الطويل ، نعني N العشرية. على سبيل المثال ، مع N = 2 ، يتم تخزين الرقم 2.56 كـ 256 (ثنائي 100000000). يتم تخزين الأرقام السالبة كمعيار ، في رمز إضافي:
-2.56
-256
(فيما يلي ، تشير الخطوط المائلة إلى الأرقام والحسابات "الرياضية" ، وبخط غامق لتمثيلها الداخلي)
يبدو لي أنه من المفيد أيضًا إدخال NaN كقيمة منفصلة ، والتي يتم إرجاعها في حالة وجود أخطاء حسابية (بدلاً من استثناء أو قمامة). يتم تمثيل NaN داخليًا باسم Long.MIN_VALUE ، " منتشر " من خلال جميع العمليات ويسمح بتحديد انعكاس الإشارة لجميع الأرقام المتبقية.
دعونا نحاول تقدير خوارزميات العمليات الحسابية للحالة عندما N = 2.
لا يتطلب الجمع والطرح أي إيماءات إضافية ، فقط استخدم القيم كما هي:
1.20 + 2.30 = 3.50
120 + 230 = 350
يتطلب الضرب والقسمة تطبيعًا إضافيًا ، أي الضرب / القسمة على 10 ^ N (على 100 في مثالنا)
1.20 * 2.00 = 2.40
120 * 200/100 = 240
1.20 / 2.00 = 0.60
100 * 120/200 = 60
القسمة الإضافية ليست أسرع عملية. لكن في هذه الحالة ، هذا هو قسمة على ثابت ، لأننا أصلحنا سابقًا N = 2 و 10 ^ N = 100. القسمة على الثابت ، وخاصة بواسطة "جميل" (النوع 10) ، يتم تحسينها بشكل مكثف في وحدة المعالجة المركزية وأسرع بكثير من القسمة على رقم عشوائي. نقوم بالعديد من الأقسام بمقدار 10 في كل مرة نقوم فيها بتحويل أي رقم إلى سلسلة (على سبيل المثال ، في السجلات) ، ويعرف مصنعو وحدة المعالجة المركزية عن ذلك ( لمزيد من التفاصيل حول التحسينات انظر "القسمة على ثابت").
لتعزيز فهم ما نقوم به ، سأقدم عملية واحدة أخرى: الانعكاس الأحادي للرقم ، أي 1 / x. هذه حالة خاصة من التقسيم ، ما عليك سوى إرسال 1.00 بتنسيقنا ولا تنسى التطبيع:
1.00 / 2.00 = 0.50
100 * 100/200 = 50
حسنًا ، في حين أن كل شيء بسيط للغاية ، فلنحاول الخوض في التفاصيل.
التقريب
لنحاول رسم رقم آخر:
1.00 / 3.00 = 0.33
100 * 100/300 = 33
تقع النتيجة الرياضية الصادقة بين 0.33 و 0.34 ، لكن لا يمكننا تخيلها بالضبط. أي طريق للتقريب؟ عادة يتم تقريبه إلى 0 ، وهذه هي أسرع طريقة (الأجهزة مدعومة). ولكن ، بالعودة إلى مشاكل مالية حقيقية ، ليس هذا هو الحال دائمًا. عادة ، عند معالجة المعاملات مع العميل ، يكون التقريب "لصالح العميل". أي ، يتم تقريب السعر لأعلى إذا كان العميل يبيع ، ولأسفل إذا كان العميل يشتري. ولكن قد تكون هناك حاجة إلى خيارات أخرى ، على سبيل المثال ، التقريب الحسابي إلى أقرب رقم مع الأنواع الفرعية (نصف لأعلى ، ونصف لأسفل ، ونصف حتى) لتقليل التناقضات المحاسبية. أو التقريب إلى ± ما لا نهاية للأسعار السلبية (لبعض الأدوات المالية). يحتوي Java BigDecimal بالفعل على قائمة بأوضاع التقريب القياسية ، والمكتبة الموصوفة تدعمها جميعًا. غير ضروري بإرجاع NaN إذا كانت العملية تتطلب تقريبًا بشكل غير متوقع.
في وضع التقريب ، يجب أن يعطي حسابنا:
1.00 / 3.00 = 0.34
100 * 100/300 + 1 = 34
كيف تعرف ما تحتاجه لإضافة وحدة؟ تحتاج إلى الباقي من القسمة 10،000٪ 300 = 100. وهي بطيئة مثل القسمة نفسها. لحسن الحظ ، إذا كتبت في صف في الكود "a / b ؛ a٪ b" ، فسوف يدرك JIT أنه لا توجد حاجة إلى قسمين ، فقط أمر تجميع واحد div يقوم بإرجاع رقمين (حاصل الباقي والباقي).
خيارات التقريب الأخرى أكثر تعقيدًا بعض الشيء ، ولكن يمكن أيضًا حسابها بناءً على الباقي والمقسوم عليه.
في واجهة برمجة التطبيقات ، أشرت عمدًا إلى التقريب أينما يحدث ، إما كمعلمة أو لاحقة R Round الخاصة في الأساليب حيث تكون القيمة الافتراضية هي صفر.
تجاوز
نأتي إلى الجزء الأكثر صعوبة. أذكر مرة أخرى الضرب لدينا:
1.20 * 2.00 = 2.40
120 * 200/100 = 240
تخيل الآن أننا في الثمانينيات ولدينا معالجات 16 بت. بمعنى ، لا يتوفر لنا سوى اختصار فقط بقيمة قصوى تبلغ 65535. سوف يتم تجاوز الضرب الأول وسيكون يساوي 240000 و 0xFFFF = 44392 (إذا كان غير موقّع ، فستكون النتيجة سلبية أيضًا) ، مما سيؤدي إلى كسر النتيجة بالنسبة لنا.
لن ينجح. لدينا وسيطتان عاديتان (تتناسبان مع مجموعة قيمنا) ، ونفس النتيجة المتوقعة الطبيعية نفسها ، لكننا تجاوزنا نصف الطريق. نفس الموقف الدقيق ممكن مع 64'm long-bit ، مجرد أرقام تحتاج إلى المزيد.
في الثمانينيات ، سنحتاج إلى ضرب يعطي نتيجة 32 بت. نحتاج اليوم إلى الضرب بنتيجة 128 بت. الأكثر إزعاجًا هو أن كلا المضاعفات متوفرة في المجمعات 8086 و x86-64 ، على التوالي ، ولكن لا يمكننا استخدامها من Java! تقوم JNI ، حتى في حالة الاختراق باستخدام JavaCritical السريع ، بإعطاء عشرات من النانو ثانية ، وتسبب صعوبات في النشر والتوافق ، وتجمد GC طوال مدة المكالمة. بالإضافة إلى ذلك ، سيكون علينا بطريقة ما أن نرجع نتيجة 128 بت من الطريقة الأصلية ، والكتابة بالرجوع إلى مصفوفة (في الذاكرة) هي تأخير إضافي.
بشكل عام ، كان علي أن أكتب الضرب والقسمة اليدوية. العمود كنت بحاجة إلى عمليتين إضافيتين:
- A (64) * B (64) = T (128) ؛ T (128) / N (32) = Q (64)، R (32) - كجزء من نقطة الضرب الثابتة A * B
- N (32) * A (64) = T (96) ؛ T (96) / B (64) = Q (64)، R (64) - كجزء من قسم النقاط الثابتة A / B
(بين قوسين يشير إلى أبعاد البيانات بالبتات ، T هو متغير مؤقت لا يجب تجاوزه)
تعيد كلتا العمليتين حاصل القسمة والباقي (أحدهما نتيجة للطريقة ، والثاني في حقل الكائن). يمكنهم أيضًا تجاوز الحد ، ولكن فقط في الخطوة الأخيرة ، عندما يكون هذا أمرًا لا مفر منه. هنا مثال (من 1980s):
500.00 / 0.50 = 1000.00
100 * 50،000 / 50 = 100،000 - تجاوز السعة!
تقسيم العمود a la Knut ليس أسهل خوارزمية. بالإضافة إلى ذلك ، يجب أن تكون سريعة نسبيًا. لذلك ، فإن كود كلتا العمليتين هو مئات الأسطر من السحر البسيط إلى حد ما ، وسوف يستغرق مني الكثير من الوقت لأتذكر مرة أخرى ما يحدث بالضبط هناك. قمت بسحبهم إلى فصل منفصل وعلقت بالتفصيل قدر استطاعتي.
لا تقتصر خوارزمية الضرب على استدعاء العملية 1 ، ولكن الرمز المتبقي ليس معقدًا للغاية ويضيف فقط دعمًا للأرقام السالبة والتقريب و NaN.
عادة (باستثناء الحالات الخاصة) ، تحتوي كلتا العمليتين على 4 ضربات و 2 أقسام. العملية 1 أسرع بكثير من 2 ، حيث أن هذه الانقسامات تكون ثابتة.
بالمناسبة ، إذا لاحظ أي شخص ، N (32) هو 10 ^ N للتطبيع. إنه 32 بت ، مما يلي أنه يمكن أن يكون N بحد أقصى 9. في التطبيقات الحقيقية التي رأيتها ، تم استخدام 2 أو 4 أو 8 منازل عشرية. أنا لم أر أكثر من 9 ، لذلك يجب أن يكون كافيا. إذا قمت بعمل 10 ^ N 64 بت ، يصبح الرمز أكثر تعقيدًا (ويبطئ) أكثر.
عدة دقة مختلفة
في بعض الأحيان يكون من الضروري إجراء عملية على الحجج بعدد مختلف من المنازل العشرية. كحد أدنى ، أدخل العمليات التي تتضمن المدة المعتادة.
على سبيل المثال:
2.0000 (N = 4) + 3.00 (N = 2) = 5.0000 (N = 4)
20000 + 300 * 100 = 50،000
3.00 (N = 2) + 2.0000 (N = 4) = 5.00 (N = 2)
300 + 20،000 / 100 = 500
في هذه الحالة ، مطلوب تطبيع إضافي لإحدى الحجج. لاحظ أن كلا العمليتين متكافئتان حسابيًا ، ولكن نظرًا للدقة المختلفة للنتيجة ، يتم حسابهما بشكل مختلف. وتجدر الإشارة أيضًا إلى أن العملية الثانية تتطلب تقريبًا التقريب.
لا يتم تخزين عدد المنازل العشرية في الكائن. بدلاً من ذلك ، يتم افتراض فئة فرعية منفصلة لكل دقة. يمكن أن تكون أسماء الفئات موجهة نحو الأعمال ، على سبيل المثال السعر (N = 8) ، الكمية (N = 2). ويمكن تعميمها: Decimal1، Decimal2، Decimal3، ... كلما زادت الدقة ، كلما كان نطاق القيم المخزنة أصغر ، كان النطاق الأدنى يحتوي على Decimal9: ± 9223372036. من المفترض أن فئة واحدة أو فئتين ستكون كافية لتغطية الوظائف اللازمة ، وفي هذه الحالة من المرجح أن تكون طريقة getScale المجردة غير مضمنة ومضمنة. تسمح لك الفئات الفرعية (بدلاً من حقل إضافي) بالتعبير بدقة دقة الحجج والنتيجة ، بالإضافة إلى الإشارة حول التقريب المحتمل في مرحلة التجميع.
تسمح المكتبة بعمليات بحد أقصى 2 (ولكن ليس 3) بدقة مختلفة. أي أن دقة الحجج يجب أن تتزامن ، أو دقة إحدى الحجج والنتيجة. مرة أخرى ، يؤدي دعم 3 دقة مختلفة إلى إبطاء الرمز بشكل كبير وتعقيد واجهة برمجة التطبيقات. كحجج ، يمكنك تمرير فترة طويلة منتظمة ، حيث يتم افتراض دقة N = 0.
2.0000 / 3.0 = 0.6667 - حسنًا ( دقتان مختلفتان)
2/3 = 0.6667 - حسنًا (وسيطات طويلة ، نتيجة عشرية)
2 / 3.0 = 0.6667 - مستحيل! (3 دقة مختلفة)
مزايا وعيوب
من الواضح أن الحوسبة عالية البت التي تقوم بها المكتبة أبطأ من الحواسيب المدعومة. ومع ذلك ، فإن النفقات العامة ليست كبيرة (انظر المعايير أدناه).
بالإضافة إلى ذلك ، بسبب عدم وجود حمل زائد في Java ، فإن استخدام الطرق بدلاً من العمليات الحسابية يعقد إدراك الكود.
وبناءً على ذلك ، تُستخدم المكتبة عادةً في الأماكن التي يكون فيها فقدان الدقة المطلقة أمرًا بالغ الأهمية. على سبيل المثال ، حساب إحصائيات مالية دقيقة ، مع مراعاة المؤشرات المالية الحالية (مراكز التداول ، PnL ، الأوامر المنفذة). في تبادل الشبكة للمعلومات المالية بين الأنظمة ، من الملائم أيضًا استخدام التنسيقات ذات الفاصلة العشرية (بدلاً من الثنائية).
عادة ما يكون تنفيذ الخوارزميات الرياضية المعقدة (النمذجة والإحصاءات والتنبؤ) أسهل بشكل مزدوج في الحالة المزدوجة ، لأن نتيجتها ليست دقيقة على الإطلاق.
الرمز والمعايير
كود
المعيار | الوضع | Cnt | يسجل | خطأ | الوحدات
|
---|
DecimalBenchmark.control | متوسط | 200 | 10.072 | ± 0.074 | نانوثانية / المرجع السابق
|
DecimalBenchmark.multiplyNative | متوسط | 200 | 10.625 | ± 0.142 | نانوثانية / المرجع السابق
|
DecimalBenchmark.multiplyMyDecimal | متوسط | 200 | 35.840 | ± 0.121 | نانوثانية / المرجع السابق
|
DecimalBenchmark.multiplyBigDecimal | متوسط | 200 | 126.098 | ± 0.408 | نانوثانية / المرجع السابق
|
DecimalBenchmark.quotientNative | متوسط | 200 | 70.728 | ± 0.230 | نانوثانية / المرجع السابق
|
DecimalBenchmark.quotientMyDecimal | متوسط | 200 | 138.581 | ± 7.102 | نانوثانية / المرجع السابق
|
DecimalBenchmark.quotientBigDecimal | متوسط | 200 | 179.650 | ± 0.849 | نانوثانية / المرجع السابق
|
بشكل عام ، الضرب أسرع 4 مرات من BigDecimal ، القسمة 1.5. يعتمد معدل القسمة بشكل كبير على الحجج ، وبالتالي تشتت القيم.