Я * не * хочу правильного округления для функции exp

на GCC реализация C математическая библиотека Debian системы, по-видимому, (IEEE 754-2008)-совместимая реализация функции exp, подразумевая, что округление всегда должно быть правильным:

(из Википедии) стандарт IEEE с плавающей запятой гарантирует, что сложение, вычитание, умножение, деление, плавленое умножение-сложение, квадратный корень и остаток с плавающей запятой дадут правильно округленный результат операции бесконечной точности. В стандарте 1985 года не было дано такой гарантии в отношении более сложных функций, и они, как правило, являются точными в лучшем случае в пределах последнего бита. Однако стандарт 2008 года гарантирует, что соответствующие реализации дадут правильно округленные результаты, которые соответствуют активному режиму округления; однако реализация функций является необязательной.

оказывается, что я сталкиваюсь с случаем, когда эта функция на самом деле мешает, потому что точный результат exp функция часто находится почти точно посередине между двумя последовательными double значения (1), а затем программа выполняет множество дальнейших вычислений, теряя до коэффициента 400 (!) в скорости: это было на самом деле объяснение моему (плохо спросил :-с) вопрос #43530011.

(1) точнее, это происходит, когда аргумент exp оказывается, форма (2 k + 1) × 2-53 С k довольно небольшое целое число (например, 242). В частности, вычисления, связанные с pow (1. + x, 0.5) склонны называть exp С таким аргументом, когда x имеет порядок величины 2-44.

поскольку реализация правильного округления может быть настолько трудоемкой в определенных обстоятельствах, я думаю, что разработчики также разработают способ получить немного менее точный результат (скажем, только до 0,6 ULP или что-то вроде этого) в то время, которое (грубо) ограничено для значения аргумента в заданном диапазоне... (2)

... но как это сделать??

(2) я имею в виду, что я просто не хочу, чтобы некоторые исключительные значения аргумента, такие как (2 k + 1) × 2-53 было бы гораздо более трудоемким, чем большинство значений того же порядка величины; но, конечно, я не возражаю, если некоторые исключительные значения аргумента идти много быстрее, или если большие аргументы (в абсолютном значении) требуют большего времени вычисления.

вот минимальная программа, показывающая явление:

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

int main (void)
 {
  int i;
  double a, c;
  c = 0;
  clock_t start = clock ();
  for (i = 0; i < 1e6; ++i) // Doing a large number of times the same type of computation with different values, to smoothen random fluctuations.
   {
    a = (double) (1 + 2 * (rand () % 0x400)) / 0x20000000000000; // "a" has only a few significant digits, and its last non-zero digit is at (fixed-point) position 53.
    c += exp (a); // Just to be sure that the compiler will actually perform the computation of exp (a).
   }
  clock_t stop = clock ();
  printf ("%en", c); // Just to be sure that the compiler will actually perform the computation.
  printf ("Clock time spent: %dn", stop - start);
  return 0;
 }

теперь после gcc -std=c99 program53.c -lm -o program53:

$ ./program53
1.000000e+06
Clock time spent: 13470008
$ ./program53 
1.000000e+06
Clock time spent: 13292721
$ ./program53 
1.000000e+06
Clock time spent: 13201616

С другой стороны, с program52 и program54 (получил путем замены 0x20000000000000 по респ. 0x10000000000000 и 0x40000000000000):

$ ./program52
1.000000e+06
Clock time spent: 83594
$ ./program52
1.000000e+06
Clock time spent: 69095
$ ./program52
1.000000e+06
Clock time spent: 54694
$ ./program54
1.000000e+06
Clock time spent: 86151
$ ./program54
1.000000e+06
Clock time spent: 74209
$ ./program54
1.000000e+06
Clock time spent: 78612

остерегайтесь, это явление зависит от реализации! по-видимому, среди общего реализации, только те из Debian системы (в том числе Ubuntu) показывают это явление.

P.-S.: надеюсь, что мой вопрос не дубликат: я тщательно искал подобный вопрос, но, возможно, я заметил, что использовал соответствующие ключевые слова...: -/

3 ответов


чтобы ответить на общий вопрос о том, почему функции библиотеки должны давать правильно округленные результаты:

с плавающей запятой трудно, и часто противоречит интуиции. Не каждый программист читал что они должны есть!--15-->. Когда библиотеки допускали несколько неточное округление, люди жаловались на точность библиотечной функции, когда их неточные вычисления неизбежно шли не так и приводили к абсурду. В ответ библиотека писатели сделали свои библиотеки точно округленными, поэтому теперь люди не могут переложить вину на них.

во многих случаях конкретные знания об алгоритмах с плавающей запятой могут привести к значительным улучшениям точности и / или производительности, как в testcase:

взяв exp() чисел, очень близких к 0 в числах с плавающей запятой проблематично, так как результатом является число, близкое к 1 пока все точности в разница для один, так что большинство значащих цифр теряются. Более точно (и значительно быстрее в этом тестовом случае) вычислить exp(x) - 1 через функцию библиотеки математики C expm1(x). Если и действительно необходимо, это все еще намного быстрее сделать expm1(x) + 1.

аналогичная проблема существует для вычислений log(1 + x), для которого существует функция log1p(x).

быстрое исправление, которое ускоряет предоставленный testcase:

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

int main (void)
{
  int i;
  double a, c;
  c = 0;
  clock_t start = clock ();
  for (i = 0; i < 1e6; ++i) // Doing a large number of times the same type of computation with different values, to smoothen random fluctuations.
    {
      a = (double) (1 + 2 * (rand () % 0x400)) / 0x20000000000000; // "a" has only a few significant digits, and its last non-zero digit is at (fixed-point) position 53.
      c += expm1 (a) + 1; // replace exp() with expm1() + 1
    }
  clock_t stop = clock ();
  printf ("%e\n", c); // Just to be sure that the compiler will actually perform the computation.
  printf ("Clock time spent: %d\n", stop - start);
  return 0;
}

для этой case, тайминги на моей машине таковы:

исходный код

1.000000 e + 06

часы потраченное время: 21543338

изменен код

1.000000 e + 06

часы потраченное время: 55076

программисты с передовыми знаниями о сопутствующих компромиссах иногда могут рассмотреть возможность использования приблизительных результатов, где точность не критично

для опытного программиста можно написать аппроксимативную реализацию медленной функции, используя такие методы, как полиномы Ньютона-Рафсона, Тейлора или Маклорина, в частности, неточно округленные специальные функции из библиотек, таких как MKL Intel, AMD AMCL, ослабляя соответствие стандарта с плавающей запятой компилятора, уменьшая точность до IEEE754 binary32 (float), или их сочетание.

обратите внимание, что лучшее описание проблемы позволило бы лучше ответить.


Что касается вашего комментария к ответу @EOF, замечание "написать свой собственный" от @NominalAnimal кажется здесь достаточно простым, даже тривиальным, следующим образом.

ваш исходный код выше, кажется, имеет максимально возможный аргумент для exp ()a=(1+2*0x400)/0x2000...=4.55 е-13 (это действительно должно быть 2*0x3FF, а я считаю 13 нулей после 0x2000... что делает его 2x16^13). Так что 4.55 e-13 Макс аргумент очень, очень маленький.

и тогда тривиальное разложение Тейлора exp(a)=1+a+(a^2)/2+(A^3)/6+... что уже дает вам точность double для таких маленьких аргументов. Теперь вам придется отказаться от 1 часть, как объяснялось выше, а затем это просто сводится к expm1 (a)=a*(1.+a*(1.+a / 3.)/2.) и это должно пройти довольно быстро! Просто убедитесь a остается небольшой. Если он станет немного больше, просто добавьте следующий срок a^4/24 (вы видите, как это сделать?).

>>редактирование

Я изменил тестовую программу OP следующим образом, чтобы протестировать немного больше вещей (обсуждение следует за кодом)

/* https://stackoverflow.com/questions/44346371/
   i-do-not-want-correct-rounding-for-function-exp/44397261 */
#include <stdio.h>
#include <stdlib.h>
#include <math.h>
#include <time.h>
#define BASE 16               /*denominator will be (multiplier)xBASE^EXPON*/
#define EXPON 13
#define taylorm1(a) (a*(1.+a*(1.+a/3.)/2.)) /*expm1() approx for small args*/

int main (int argc, char *argv[]) {
  int N          = (argc>1?atoi(argv[1]):1e6),
      multiplier = (argc>2?atoi(argv[2]):2),
      isexp      = (argc>3?atoi(argv[3]):1); /* flags to turn on/off exp() */
  int isexpm1    = 1;                        /* and expm1() for timing tests*/
  int i, n=0;
  double denom = ((double)multiplier)*pow((double)BASE,(double)EXPON);
  double a, c=0.0, cm1=0.0, tm1=0.0;
  clock_t start = clock();
  n=0;  c=cm1=tm1=0.0;
  /* --- to smooth random fluctuations, do the same type of computation
         a large number of (N) times with different values --- */
  for (i=0; i<N; i++) {
    n++;
    a = (double)(1 + 2*(rand()%0x400)) / denom; /* "a" has only a few
                                 significant digits, and its last non-zero
                                 digit is at (fixed-point) position 53. */
    if ( isexp ) c += exp(a); /* turn this off to time expm1() alone */
    if ( isexpm1 ) {          /* you can turn this off to time exp() alone, */
      cm1 += expm1(a);        /* but difference is negligible */
      tm1 += taylorm1(a); }
    } /* --- end-of-for(i) --- */
  int nticks = (int)(clock()-start);
  printf ("N=%d, denom=%dx%d^%d, Clock time: %d (%.2f secs)\n",
         n, multiplier,BASE,EXPON,
         nticks, ((double)nticks)/((double)CLOCKS_PER_SEC));
  printf ("\t c=%.20e,\n\t c-n=%e, cm1=%e, tm1=%e\n",
           c,c-(double)n,cm1,tm1);
  return 0;
  } /* --- end-of-function main() --- */

скомпилируйте и запустите его как тест воспроизвести OP 0x2000... сценарий, или запустить его с (до трех) дополнительных args тест #испытания множитель timeexp здесь #испытания по умолчанию ОП 1000000 и мультиплер по умолчанию 2 для ОП 2x16^13 (измените его на 4 и т. д. Для ее других тестов). Для последнего arg,timeexp введите 0 не только expm1() (и мой ненужный Тейлор-подобный) расчет. Дело в том, чтобы показать, что отображаемые OP случаи плохого времени исчезают с expm1(), которую занимает "время все" независимо от множитель.

поэтому по умолчанию выполняется,тест и


это "ответ" / продолжение предыдущих комментариев EOF к его алгоритму trecu() и коду для его предложения" суммирования двоичного дерева". "Предпосылки", прежде чем читать это, читают эту дискуссию. Было бы неплохо собрать все это в одном организованном месте, но я еще этого не сделал...

...Что я сделал, так это встроил trecu() EOF в тестовую программу из предыдущего ответа, который я написал, изменив исходную тестовую программу OP. Но потом я обнаружил, что trecu () генерируется точно (и я имею в виду ровно) тот же ответ, что и "простая сумма" c используя exp (), а не сумма cm1 используя expm1() что мы ожидали от более точного суммирования двоичного дерева.

но эта тестовая программа немного (может быть, два бита:) "запутанная" (или, как сказал EOF, "нечитаемая"), поэтому я написал отдельную меньшую тестовую программу, приведенную ниже (с примером запуска и обсуждения ниже), чтобы отдельно тест/упражнение trecu(). Кроме того, я также написал функцию bintreesum() в код ниже, который абстрагирует/инкапсулирует итерационный код для суммирования двоичного дерева, который я встроил в предыдущую тестовую программу. В предыдущем случае, мой итеративный код действительно приблизился к cm1 ответ, поэтому я ожидал, что рекурсивный trecu () EOF сделает то же самое. Короче говоря, ниже происходит то же самое-bintreesum () остается близким к правильному ответу, в то время как trecu () получает дальше, точно воспроизводя "простую сумму".

то, что мы суммируем ниже, - это просто sum (i),i=1...n, который является просто известным n (n+1)/2. Но это не совсем правильно - чтобы воспроизвести проблему OP, слагаемое-это не sum (i), а sum (1+i*10^(- e)), где e может быть дано в командной строке. Итак, для, скажем, n=5 Вы получаете не 15, а скорее 5.000...00015, или для n=6 Вы получаете 6.000...00021 и т. д. И чтобы избежать длинного, длинного формата, я printf () sum-n, чтобы удалить эту целочисленную часть. Хорошо??? Вот код...

/* Quoting from EOF's comment...
   What I (EOF) proposed is effectively a binary tree of additions:
   a+b+c+d+e+f+g+h as ((a+b)+(c+d))+((e+f)+(g+h)).
   Like this: Add adjacent pairs of elements, this produces
   a new sequence of n/2 elements.
   Recurse until only one element is left. */
#include <stdio.h>
#include <stdlib.h>

double trecu(double *vals, double sum, int n) {
  int midn = n/2;
  switch (n) {
    case  0: break;
    case  1: sum += *vals; break;
    default: sum = trecu(vals+midn, trecu(vals,sum,midn), n-midn); break; }
  return(sum);
  } /* --- end-of-function trecu() --- */

double bintreesum(double *vals, int n, int binsize) {
  double binsum = 0.0;
  int nbin0 = (n+(binsize-1))/binsize,
      nbin1 = (nbin0+(binsize-1))/binsize,
      nbins[2] = { nbin0, nbin1 };
  double *vbins[2] = {
            (double *)malloc(nbin0*sizeof(double)),
            (double *)malloc(nbin1*sizeof(double)) },
         *vbin0=vbins[0], *vbin1=vbins[1];
  int ibin=0, i;
  for ( i=0; i<nbin0; i++ ) vbin0[i] = 0.0;
  for ( i=0; i<n; i++ ) vbin0[i%nbin0] += vals[i];
  while ( nbins[ibin] > 1 ) {
    int jbin = 1-ibin;        /* other bin, 0<-->1 */
    nbins[jbin] = (nbins[ibin]+(binsize-1))/binsize;
    for ( i=0; i<nbins[jbin]; i++ ) vbins[jbin][i] = 0.0;
    for ( i=0; i<nbins[ibin]; i++ )
      vbins[jbin][i%nbins[jbin]] += vbins[ibin][i];
    ibin = jbin;              /* swap bins for next pass */
    } /* --- end-of-while(nbins[ibin]>0) --- */
  binsum = vbins[ibin][0];
  free((void *)vbins[0]);  free((void *)vbins[1]);
  return ( binsum );
  } /* --- end-of-function bintreesum() --- */

#if defined(TESTTRECU)
#include <math.h>
#define MAXN (2000000)
int main(int argc, char *argv[]) {
  int N       = (argc>1? atoi(argv[1]) : 1000000 ),
      e       = (argc>2? atoi(argv[2]) : -10 ),
      binsize = (argc>3? atoi(argv[3]) : 2 );
  double tens = pow(10.0,(double)e);
  double *vals = (double *)malloc(sizeof(double)*MAXN),
         sum = 0.0;
  double trecu(), bintreesum();
  int i;
  if ( N > MAXN ) N=MAXN;
  for ( i=0; i<N; i++ ) vals[i] = 1.0 + tens*(double)(i+1);
  for ( i=0; i<N; i++ ) sum += vals[i];
  printf(" N=%d, Sum_i=1^N {1.0 + i*%.1e} - N  =  %.8e,\n"
         "\t plain_sum-N  = %.8e,\n"
         "\t trecu-N      = %.8e,\n"
         "\t bintreesum-N = %.8e \n",
         N, tens, tens*((double)N)*((double)(N+1))/2.0,
          sum-(double)N,
         trecu(vals,0.0,N)-(double)N,
         bintreesum(vals,N,binsize)-(double)N );
  } /* --- end-of-function main() --- */
#endif

поэтому, если вы сохраните это как trecu.c, затем скомпилируйте его как куб. DTESTTRECU trecu.c lm o trecu а затем запустите с нуля до трех дополнительных args командной строки как trecu #trials e binsize по умолчанию #trials=1000000 (например, программа OP), e=10 и binsize=2 (для моей функции bintreesum (), чтобы сделать сумму двоичного дерева, а не ячейки большего размера).

и вот некоторые результаты тестирования, иллюстрирующие описанную проблему выше,

bash-4.3$ ./trecu              
 N=1000000, Sum_i=1^N {1.0 + i*1.0e-10} - N  =  5.00000500e+01,
         plain_sum-N  = 5.00000500e+01,
         trecu-N      = 5.00000500e+01,
         bintreesum-N = 5.00000500e+01 
bash-4.3$ ./trecu 1000000 -15
 N=1000000, Sum_i=1^N {1.0 + i*1.0e-15} - N  =  5.00000500e-04,
         plain_sum-N  = 5.01087168e-04,
         trecu-N      = 5.01087168e-04,
         bintreesum-N = 5.00000548e-04 
bash-4.3$ 
bash-4.3$ ./trecu 1000000 -16
 N=1000000, Sum_i=1^N {1.0 + i*1.0e-16} - N  =  5.00000500e-05,
         plain_sum-N  = 6.67552231e-05,
         trecu-N      = 6.67552231e-05,
         bintreesum-N = 5.00001479e-05 
bash-4.3$ 
bash-4.3$ ./trecu 1000000 -17
 N=1000000, Sum_i=1^N {1.0 + i*1.0e-17} - N  =  5.00000500e-06,
         plain_sum-N  = 0.00000000e+00,
         trecu-N      = 0.00000000e+00,
         bintreesum-N = 4.99992166e-06 

таким образом, вы можете видеть, что для запуска по умолчанию, e=10, все делают все правильно. То есть верхняя строка, которая говорит "сумма", просто делает N(n+1)/2, поэтому, предположительно, отображает правильный ответ. И все, кто ниже, согласны с тестовым случаем по умолчанию e=10. Но для случаев e=15 и e=16 ниже, trecu() точно соглашается с plain_sum, в то время как bintreesum остается довольно близким к правильному ответу. И, наконец, для e=17 plain_sum и trecu () "исчезли", в то время как bintreesum () все еще висит там довольно хорошо.

таким образом, trecu () правильно делает сумму в порядке, но его рекурсия, по-видимому, не делает того типа "двоичного дерева", который Мой более простой итеративный bintreesum (), по-видимому, делает правильно. И это действительно демонстрирует, что предложение EOF для "суммирования двоичного дерева" реализует довольно улучшение по сравнению с plain_sum для этих случаев 1+epsilon. Поэтому мы очень хотели бы увидеть его рекурсию trecu ()!!! Когда я впервые посмотрел на него, я думал, что он работает. Но эта двойная рекурсия (есть ли для этого специальное название?) в его по умолчанию: дело, видимо, больше толку (по крайней мере для меня:) чем я думал. Как я уже сказал, это is выполнение суммы,но не" двоичное дерево".

хорошо, так кто хотел бы принять вызов и объяснить, что происходит в этой рекурсии trecu ()? И, может быть, что более важно, исправьте его так, чтобы он делал то, что задумано. Спасибо.