حسابات دقيقة وسريعة لأرقام الفاصلة العائمة باستخدام مثال دالة الجيب. مقدمة وجزء 1

اقرأ بعناية مقالات جيدة جدًا من أرتيم كارافاييفعلى إضافة أرقام الفاصلة العائمة. الموضوع ممتع للغاية وأود أن أواصله وأعرض بأمثلة كيفية العمل بأرقام الفاصلة العائمة عمليًا. لنأخذ مكتبة GNU glibc (libm) كمرجع. ولكي لا تكون المقالة مملة للغاية ، سنضيف عنصرًا تنافسيًا: سنحاول ليس فقط التكرار ، ولكن أيضًا تحسين كود المكتبة ، مما يجعله أسرع / أكثر دقة.



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



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



ستتم كتابة المقالات بطريقة الانغماس. ستتم مناقشة المهام الفرعية ، والتي ستجمع بعد ذلك في حل واحد للمشكلة.



تحلل الجيب إلى سلسلة تايلور.



تتوسع وظيفة الجيب إلى سلسلة لا نهائية من تايلور.



sin(x)=xx33!+x55!x77!+x99!



من الواضح أننا لا نستطيع حساب سلسلة لا نهائية ، باستثناء الحالات التي توجد فيها صيغة تحليلية لمجموع لا نهائي. لكن هذه ليست حالتنا))) افترض أننا نريد حساب الجيب في الفترة[0,π2]... سنناقش العمل بفواصل زمنية بمزيد من التفصيل في الجزء 3. مع العلم بذلكsin(π2)=1 تقدير ، ابحث عن المصطلح الأول الذي يمكن التخلص منه بناءً على الحالة التي (π/2)nn!<eأين eإنه الفرق بين الرقم 1 وأصغر رقم أكبر من 1. تقريبًا ، هذا هو الجزء الأخير من الجزء العشري ( ويكي ). من الأسهل حل هذه المعادلة بالقوة الغاشمة. إلى عن علىe2.22×1016... تمكنتn=23يمكن بالفعل إسقاطها. ستتم مناقشة الاختيار الصحيح لعدد المصطلحات في أحد الأجزاء التالية ، لذلك سنقوم اليوم "بإعادة التأمين" ونأخذ الشروط حتىn=25شامل.

المصطلح الأخير هو حوالي 10000 مرة أقل منe...



أبسط حل



الأيدي حكة بالفعل ، نكتب:



النص الكامل للبرنامج للاختبار
#include <iostream>
#include <iomanip>
#include <cmath>
#include <array>
#include <bitset>
#include <quadmath.h>
//      clang
//#include "/usr/lib/gcc/x86_64-linux-gnu/10/include/quadmath.h"
#include <numeric>
#include <limits>
#include <vector>

#include <boost/timer/timer.hpp>
#include <boost/math/special_functions/factorials.hpp>

namespace bm = boost::math;

using namespace std;

typedef union { uint32_t i[2]; double x; } mynumber;

array<double, 25> fc;

double sin_e1(double x) {
  double result = 0;
  int sign = 1;
  for(int i = 1; i < 25; i += 2) {
    result += sign * pow(x, i) / bm::unchecked_factorial<double>(i);
    sign = -sign;
  }
  return result;
}

double sin_e2(double x) {
  double result = 0;
  int sign = 1;
  double xx = x * x;
  double pw = x;
  double fti = 1.0;
  for(int i = 1; i < 25; i += 2) {
    fti /= i;
    result += sign * pw * fti;
    fti /= ( i + 1 );
    sign = -sign;
    pw  *= xx;
  }
  return result;
}

double sin_e3(double x) {
  double result = 0;
  for(int i = 25; i >= 1; i -= 2) {
    result += (((i - 1) % 4 == 0) ? 1 : -1 ) * pow(x, i) / bm::unchecked_factorial<double>(i);
  }
  return result;
}

double sin_e4(double x) {
  double xx = x * x;
  double res = fc[25];
  for(int i = 23; i >= 1; i -= 2) {
    res = fc[i] + xx * res;
  }
  return x * res;
}

double sin_e5(double x) {
  double xx = x * x;
  double res = fc[25];
  for(int i = 23; i >= 3; i -= 2) {
    res = fc[i] + xx * res;
  }
  return x + x * xx * res;
}

inline
double fsin(double x) {
  double result;
  asm ("fsin" :"=t" (result) : "0" (x));
  return result;
}

#define SIN(a) fsin(a)
//#define SIN(a) sin(a)
//#define SIN(a) sin_e5(a)
// ^^     . ^^

int main() {

  __uint128_t ft = 1;
  fc[1] = 1.0; //3 * 5;
  for(int i = 2; i < fc.size(); i++) {
    ft *= i;
    // factorial with sign for Taylor series
    fc[i] = (1.0 / ft) * (( (i - 2) % 4 < 2) ? -1 : 1);
  }
  vector<double> xv;
  xv.resize(8 * 2000000);
  //      0  M_PI/2
  for (int i = 0; i < xv.size(); i++) {
    //      .
    xv[i] = (M_PI / 2) * i / double(xv.size());
  }

  double res = 0;
  {
    boost::timer::auto_cpu_timer at;
    for(int i = 0; i < xv.size(); i++) {
      res += SIN(xv[i]);
    }
  }

  int co = 0, cn = 0;
  //      .
  __float128 avg = 0.0, div = 0.0;
  for(int i = 0; i < xv.size(); i++) {
    mynumber dold, dnew;
    dold.x = sin(xv[i]);
    dnew.x = SIN(xv[i]);
    __float128 q = sinq(xv[i]); // <= sinq  .
    __float128 dd = __float128(dnew.x) - q;
    //     .
    div += dd * dd;
    avg += dd;
    //  ,         .
    //   ,         .
    if( dold.i[0] != dnew.i[0] || dold.i[1] != dnew.i[1] ) {
      if( fabsq(q - dold.x) <= fabsq(q - dnew.x) )
        co++;
      else
        cn++;
    }
  }
  avg /= xv.size();
  div /= xv.size();

  cout << res << endl;

  //  ,           .
  cout << "Better libm: " <<  co << " / " << xv.size() << "(" << 100.0 * co / xv.size() << "%)" << endl;

  //  ,  ""         .
  cout << "Better new: " <<  cn << " / " << xv.size() << "(" << 100.0 * cn / xv.size() << "%)" << endl;

  //         .
  cout << "  Avg / std new: " << double(avg) << " / " << double(sqrtq( div - avg * avg )) << endl;
  return 0;
}








double sin_e1(double x) {
  double result = 0;
  int sign = 1;
  for(int i = 1; i < 25; i += 2) {
    result += sign * pow(x, i) / bm::factorial<double>(i);
    sign = -sign;
  }
  return result;
}


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



نسخة محسنة من البرنامج.
double sin_e2(double x) {
  double result = 0;
  int sign = 1;
  double xx = x * x;
  double pw = x;
  double fti = 1.0;
  for(int i = 1; i < 25; i += 2) {
    fti /= i;
    result += sign * pw * fti;
    fti /= ( i + 1 );
    sign = -sign;
    pw  *= xx;
  }
  return result;
}


10000 (GNU C++ v10; -O2)



تحسين الدقة



المنهجية



سيتم تحديد دقة حساب الوظيفة بواسطة معلمتين قياسيتين.



الانحراف المعياري عن الخطيئة الحقيقية (float128) ومتوسط ​​هذا الانحراف. يمكن أن تعطي المعلمة الأخيرة معلومات مهمة حول كيفية تصرف وظيفتنا. يمكنها التقليل بشكل منهجي أو المبالغة في تقدير النتيجة.



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



ترتيب الجمع



دعنا نعود إلى المثال الأصلي. كيف يمكنك زيادة دقته "السريعة"؟ أولئك الذين قرأوا المقال بعناية هل من الممكن إضافة أرقام N من النوع المزدوج بدقة أكبر؟ على الأرجح إجابة على الفور. من الضروري قلب الدورة في الاتجاه المعاكس. للإضافة من أصغر نموذج إلى أكبر.



double sin_e3(double x) {
  double result = 0;
  for(int i = 25; i >= 1; i -= 2) {
    result += (((i - 1) % 4 == 0) ? 1 : -1 ) * pow(x, i) / bm::unchecked_factorial<double>(i);
  }
  return result;
}


وتظهر النتائج في الجدول.



وظيفة متوسط ​​الخطأ STD لنا أفضل أفضل libm
sin_e1 -1.28562e-18 8.25717e-17 0.0588438٪ 53.5466٪
sin_e3 -3.4074 هـ-21 3.39727e-17 0.0423٪ 10.8049٪
sin_e4 8.79046e-18 4.77326e-17 0.0686٪ 27.6594٪
sin_e5 8.78307e-18 3.69995e-17 0.0477062٪ 13.5105٪


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



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



sin(x)x(1+x2(1/3!+x2(1/5!+x2(1/7!+x21/9!))))





يمكنك فك الأقواس والتحقق من الحصول على التعبير الأصلي. من السهل جدًا دمج هذا المنظر في حلقة.



double sin_e4(double x) {
  double xx = x * x;
  double res = fc[25];
  for(int i = 23; i >= 1; i -= 2) {
    res = fc[i] + xx * res;
  }
  return x * res;
}


يعمل بسرعة لكنه فقد دقته مقارنة بـ e3. مرة أخرى ، التقريب مشكلة. دعنا نلقي نظرة على الخطوة الأخيرة من الحلقة ونحول قليلاً التعبير الأصلي.

sin(x)x+xx2(1/3!+))





والرمز المقابل.



double sin_e5(double x) {
  double xx = x * x;
  double res = fc[25];
  for(int i = 23; i >= 3; i -= 2) {
    res = fc[i] + xx * res;
  }
  return x + x * xx * res;
}


تضاعفت الدقة مقارنة بـ libm. إذا كان بإمكانك تخمين سبب زيادة الدقة ، فاكتب في التعليقات. بالإضافة إلى ذلك ، هناك شيء آخر غير سار حول sin_e4 ، مفقود في sin_e5 ، يتعلق بالدقة. حاول أن تخمن ما هي المشكلة. في الجزء التالي سأخبرك بالتأكيد عن ذلك بالتفصيل.



إذا أعجبك المقال ، فسأخبرك في المقالة التالية كيف يحسب GNU libc شرطًا بحد أقصى ULP 0.548.



All Articles