Я * не * хочу правильного округления для функции 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 ()? И, может быть, что более важно, исправьте его так, чтобы он делал то, что задумано. Спасибо.