关于更快地逼近 log(x)

计算科学 数值分析
2021-12-09 04:47:04

前段时间我写了一个代码,它试图在不使用库函数的情况下昨天,我正在审查旧代码,并试图使其尽可能快(并且正确)。到目前为止,这是我的尝试:log(x)

const double ee = exp(1);

double series_ln_taylor(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now;
    int i, flag = 1;

    if ( n <= 0 ) return 1e-300;
    if ( n * ee < 1 )
        n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    for ( term = 1; term < n ; term *= ee, lgVal++ );
    n /= term;

    /* log(1 - x) = -x - x**2/2 - x**3/3... */
    n = 1 - n;
    now = term = n;
    for ( i = 1 ; ; ){
        lgVal -= now;
        term *= n;
        now = term / ++i;
        if ( now < 1e-17 ) break;
    }

    if ( flag == -1 ) lgVal = -lgVal;

    return lgVal;
}

在这里,我试图找到使得的对数值,它小于 1。此时,的泰勒展开式可以放心使用。aeanealog(1  x)

我最近对数值分析产生了兴趣,这就是为什么我不禁要问这个问题,这个代码段在实践中可以运行多快,同时又足够正确?我是否需要切换到其他方法,例如使用连分数,像这样

C 标准库提供的函数几乎比这个实现快 5.1 倍。log(x)

更新 1 :使用Wikipedia中提到的双曲 arctan 系列,计算似乎比 C 标准库 log 函数慢 2.2 倍。虽然,我没有广泛检查性能,而且对于更大的数字,我当前的实现似乎真的很慢。如果可以的话,我想检查我的两个实现的错误界限和平均时间范围广泛的数字。这是我的第二次努力。

double series_ln_arctanh(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now, sm;
    int i, flag = 1;

    if ( n <= 0 ) return 1e-300;
    if ( n * ee < 1 ) n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    for ( term = 1; term < n ; term *= ee, lgVal++ );
    n /= term;

    /* log(x) = 2 arctanh((x-1)/(x+1)) */
    n = (1 - n)/(n + 1);

    now = term = n;
    n *= n;
    sm = 0;
    for ( i = 3 ; ; i += 2 ){
        sm += now;
        term *= n;
        now = term / i;
       if ( now < 1e-17 ) break;
    }

    lgVal -= 2*sm;

    if ( flag == -1 ) lgVal = -lgVal;
    return lgVal;
}

任何建议或批评表示赞赏。

更新 2:根据下面的建议,我在这里添加了一些增量更改,这比标准库实现慢了大约 2.5 倍。但是,我这次仅对整数进行了测试,对于较大的数字,运行时间会增加。目前。我还不知道生成随机双数的技术,所以它还没有完全进行基准测试。为了使代码更健壮,我添加了对极端情况的更正。我所做的测试的平均误差约为1e81e3084e15

double series_ln_better(double n){ /* n = e^a * b, where a is an non-negative integer */
    double lgVal = 0, term, now, sm;
    int i, flag = 1;

    if ( n == 0 ) return -1./0.; /* -inf */
    if ( n < 0 ) return 0./0.;   /* NaN*/
    if ( n < 1 ) n = 1.0 / n, flag = -1; /* for extremely small n, use e^-x = 1/n */

    /* the cutoff iteration is 650, as over e**650, term multiplication would
       overflow. For larger numbers, the loop dominates the arctanh approximation
       loop (with having 13-15 iterations on average for tested numbers so far */

    for ( term = 1; term < n && lgVal < 650 ; term *= ee, lgVal++ );
    if ( lgVal == 650 ){
        n /= term;
        for ( term = 1 ; term < n ; term *= ee, lgVal++ );
    }
    n /= term;

    /* log(x) = 2 arctanh((x-1)/(x+1)) */
    n = (1 - n)/(n + 1);

    now = term = n;
    n *= n;
    sm = 0;

    /* limiting the iteration for worst case scenario, maximum 24 iteration */
    for ( i = 3 ; i < 50 ; i += 2 ){
        sm += now;
        term *= n;
        now = term / i;
        if ( now < 1e-17 ) break;
    }

    lgVal -= 2*sm;

    if ( flag == -1 ) lgVal = -lgVal;

    return lgVal;
}
2个回答

这不是一个真正的权威答案,更多的是我认为你应该考虑的问题列表,我没有测试你的代码。

0.你是如何测试你的代码的正确性和速度的?两者都很重要,做得好有点棘手,而且你不提供细节。换句话说,如果将您的功能与log机器 上的功能进行比较,我是否也会得到相同的数字,2.1,5.1? 根据我在学术文献中阅读他人计时基准的经验,需要非常小心和精确才能获得 可重复的计时,这是任何人都会关心的唯一计时。尤其是微基准测试是出了名的不可靠。

1.评估函数的常见问题f(x)直接与其(未修改的)泰勒级数是收敛所需的项数。a 的尾数有 52 位 double,所以当n12在 Taylor 级数循环开始时,您可以预期循环进行大约 50 次迭代。这是相当昂贵的,应该优化。

1.5。你检查过你的代码是否大n? 我试过1.7976e+308,这导致term=inf,然后n=1在泰勒级数循环中,导致收敛极慢:它像谐波级数一样收敛,即它不会但最多会有1017条款。根据经验,您应该为循环设置某种“最大迭代计数”界限。在这种情况下,它的行为是这样的,因为n是有限的,但term *= e溢出到在参数缩减循环中。正确答案是709.78266108405500745.

2.标准库中实现的功能预计非常健壮。返回10300(0) 因为负数(或零)的对数不正确。的对数0 应该,负数的对数应该是NaN。

我怀疑只要付出一点努力,您就可以牺牲一些鲁棒性来提高性能,例如,通过限制参数范围或返回稍微不太准确的结果。

3.这种代码的性能很大程度上取决于它所运行的 CPU 架构。这是一个深入而复杂的话题,但是像英特尔这样的 CPU 制造商发布了优化指南,解释了你的代码和运行它的 CPU 之间的不同交互。缓存可能相对简单,但是在高级代码中很难准确地看到分支预测、指令级并行性和由于数据依赖性导致的流水线停顿等问题,但对性能来说却很重要。

4.正确实现这样的函数通常意味着你保证对于输入的浮点数x~, 输出y~=f~(x~)在离真实值最近的浮点数的一定距离内y=f(x~). 验证这一点并非完全微不足道,您的代码中没有任何证据表明您已经这样做了,所以我不知道您的函数是否正确(我确信它非常准确,但有多准确?)。由于存在浮点舍入误差,这与显示泰勒级数收敛不同。

4.5. 测试未测试函数的准确性的一个好方法是在 40 亿个单精度浮点数中的每一个(如果您正确地进行参数减少,则更少)单精度浮点数进行评估,并将错误与来自的标准日志进行比较库。需要一点时间,但至少它是彻底的。

5.因为您从一开始就知道双精度数的精度,所以您不必有一个无限循环:可以预先计算出迭代次数(可能约为 50)。使用它从代码中删除分支,或者至少提前设置迭代次数。

所有关于循环展开的常见想法也适用。

6.可以使用泰勒级数以外的近似技术。还有切比雪夫级数(具有 Clenshaw 递归)、Pade 逼近,有时还有像牛顿法这样的求根方法,只要您的函数可以重铸为更简单函数的根(例如,著名的 sqrt 技巧)。

连分数可能不会太大,因为它们涉及除法,这比乘法/加法要昂贵得多。如果您查看https://software.intel.com/sites/landingpage/IntrinsicsGuide/,根据架构,除法的延迟为 13-14 个周期,吞吐量为 5-14,与 3-5/0.5-1_mm_div_ss相比 用于乘法/加法/疯狂。因此,总的来说(并非总是)尽可能地消除分歧是有意义的。

不幸的是,数学在这里并不是一个很好的指南,因为带有简短公式的表达式不一定是最快的。例如,数学不会惩罚除法。

7.浮点数内部存储在表格中x=m×2e(尾数m,12<m1, 指数 e)。自然对数x比 base-2 日志更不自然,可以将代码的第一部分替换为对frexp.

8.将您的logloginlibmopenlibm(例如:https ://github.com/JuliaLang/openlibm/blob/master/src/e_log.c )进行比较。这是迄今为止找出其他人已经弄清楚的最简单的方法。也有专门针对libm CPU 制造商的优化版本,但通常没有发布其源代码。

Boost::sf 有一些特殊功能,但不是基本功能。不过,查看 log1p 的来源可能会有所帮助:http: //www.boost.org/doc/libs/1_58_0/libs/math/doc/html/math_toolkit/powers/log1p.html

还有像 mpfr 这样的开源任意精度算术库,由于需要更高的精度,它可能使用与 libm 不同的算法。

9. Higham 的 Accuracy and Stability of Numerical Algorithms 是分析数值算法误差的一个很好的上层介绍。对于近似算法本身,Trefethen 的 Approximation Theory Approximation Practice 是一个很好的参考。

10.我知道这句话说得太多了,但是相当大的软件项目很少依赖于一个小函数的运行时间被一遍又一遍地调用。不必担心日志的性能并不常见,除非您已经对程序进行了概要分析并确保它很重要。

基里尔的回答已经触及大量相关问题。我想根据实际的数学库设计经验来扩展其中的一些。预先说明:数学库设计者倾向于使用每个已发布的算法优化,以及许多特定于机器的优化,并非所有这些都会被发布。代码经常会用汇编语言编写,而不是使用编译代码。因此,假设相同的功能集(准确性、特殊情况处理、错误报告、舍入模式支持),一个简单且编译的实现不太可能实现现有高质量数学库实现的 75% 以上的性能。

在准确性方面,对于基本函数(例如exp,log)以及简单的特殊功能(例如erfc,Γ),ulp误差已取代相对误差作为相关误差度量。初等函数的一个共同设计目标是最大误差小于 1 ulp,从而得到一个如实舍入的函数。忠实四舍五入的函数返回最接近无限精确结果的浮点数,或直接相邻的浮点数。

准确性通常通过与(第三方)更高精度的参考进行比较来评估。单参数单精度函数可以很容易地进行详尽的测试,其他函数需要使用(有向的)随机测试向量进行测试。显然,人们无法计算出无限精确的参考结果,但对制表者困境的研究表明,对于许多简单的函数来说,计算一个精度约为目标精度三倍的参考就足够了。参见例如:

Vincent Lefèvre,Jean-Michel Muller,“在双精度中正确舍入基本函数的最坏情况”。第 15 届 IEEE 计算机算术研讨会论文集上,2001,111-118)。(在线预印本)

在性能方面,必须区分延迟优化(在查看相关操作的执行时间时很重要)和吞吐量优化(在考虑独立操作的执行时间时相关)。在过去的 20 年中,硬件并行化技术如指令级并行(例如超标量、乱序处理器)、数据级并行(例如 SIMD 指令)和线程级并行(例如超线程、多核处理器)导致强调计算吞吐量作为更相关的指标。

基本函数的核心近似几乎完全是极小极大近似由于除法成本相对较高,多项式极小极大近似。Mathematica 或 Maple 等工具具有用于生成这些的内置工具;还有专门的工具,例如Sollya对于对数,在参数减少到接近统一的值之后,基本的核心近似选择是log(1+x)=p(x)log(x)=2atanh((x1)/(x+1))=p(((x1)/(x+1))2), 在哪里p是多项式极小极大近似。在性能方面,前者通常更适合单精度实现(请参阅此答案以获取工作示例),而后者更适合双精度实现。

融合乘加运算 ( FMA ) 于 25 年前由 IBM 首次引入,现在可用于所有主要处理器架构,是现代数学库实现的关键构建块。它减少了舍入误差,提供了对减法取消的有限保护,并极大地简化了双双运算

下面的示例 IEEE-754 双精度C99实现log()演示了 FMA 的使用(C99作为fma()标准数学函数公开),以及非常有限地使用双双技术来提高具有超越常数的产品的精度。提供了两种不同的核心近似值,都提供了忠实的四舍五入结果,如测试所示232随机测试向量。使用的极小极大近似值是使用我自己的工具基于Remez 算法计算得出的。二阶霍纳方案用于评估大多数直线多项式近似 ( USE_ATANH = 0) 以最大化指令级并行性。

#include <math.h>

/* compute natural logarithm

   USE_ATANH == 1: maximum error found: 0.84555 ulp
   USE_ATANH == 0: maximum error found: 0.84995 ulp
*/
double my_log (double a)
{
    const double LOG2_HI = 0x1.62e42fefa39efp-01; // 6.9314718055994529e-01
    const double LOG2_LO = 0x1.abc9e3b39803fp-56; // 2.3190468138462996e-17
    double m, r, i, s, t, p, f, q;
    int e;

    m = frexp (a, &e);
    if (m < 0.70703125) { // 181/256
        m = m + m;
        e = e - 1;
    }
    i = (double)e;

    /* m in [181/256, 362/256] */

#if USE_ATANH

    /* Compute q = (m-1) / (m+1) */
    p = m + 1.0;
    m = m - 1.0;
    q = m / p;

    /* Compute (2*atanh(q)/q-2*q) as p(q**2), q in [-75/437, 53/309] */
    s = q * q;
    r =            0x1.2f1da230fc404p-3;  // 1.4800574028006974e-1
    r = fma (r, s, 0x1.399f73f9346b4p-3); // 1.5313616375219896e-1
    r = fma (r, s, 0x1.746654253068cp-3); // 1.8183580149166223e-1
    r = fma (r, s, 0x1.c71c51a8bf1eep-3); // 2.2222198291991851e-1
    r = fma (r, s, 0x1.249249425f16bp-2); // 2.8571428744887467e-1
    r = fma (r, s, 0x1.999999997f6b1p-2); // 3.9999999999404695e-1
    r = fma (r, s, 0x1.5555555555593p-1); // 6.6666666666667351e-1
    r = r * s;

    /* log(a) = 2*atanh(q) + i*log(2) = LOG2_LO*i + p(q**2)*q + 2q + LOG2_HI*i.
       Use K.C. Ng's trick to improve the accuracy of the computation, like so:
       p(q**2)*q + 2q = p(q**2)*q + q*t - t + m, where t = m**2/2.
    */
    t = m * m * 0.5;
    r = fma (q, t, fma (q, r, LOG2_LO * i)) - t + m;
    r = fma (LOG2_HI, i, r);

#else // USE_ATANH

    /* Compute f = m-1 */
    f = m - 1.0;
    s = f * f;

    /* Approximate log1p (f), f in [-75/256, 106/256] */
    r =            -0x1.961d64dc5342dp-6;  // -2.4787281510089954e-2
    t =             0x1.d35fd59a178ebp-5;  //  5.7052533332113096e-2
    r = fma (r, s, -0x1.fcf513884b8f8p-5); // -6.2128580235695396e-2
    t = fma (t, s,  0x1.b9711474aee8ep-5); //  5.3886928513262253e-2
    r = fma (r, s, -0x1.b5b5054106abcp-5); // -5.3431043874487355e-2
    t = fma (t, s,  0x1.dd660c0bc8ec6p-5); //  5.8276198890124628e-2
    r = fma (r, s, -0x1.00bda5ecd0075p-4); // -6.2680862564777076e-2
    t = fma (t, s,  0x1.1159b2e3be946p-4); //  6.6735934054951235e-2
    r = fma (r, s, -0x1.2489f14dd7c1cp-4); // -7.1420614809071414e-2
    t = fma (t, s,  0x1.3b0ee248a04fbp-4); //  7.6918491287887678e-2
    r = fma (r, s, -0x1.55557d3b4941fp-4); // -8.3333481965909048e-2
    t = fma (t, s,  0x1.745d4666f7eecp-4); //  9.0909266480135364e-2
    r = fma (r, s, -0x1.999999d9593d1p-4); // -1.0000000092766405e-1
    t = fma (t, s,  0x1.c71c70bbce7b6p-4); //  1.1111110722131809e-1
    r = fma (r, s, -0x1.fffffffa6167cp-4); // -1.2499999991822536e-1
    t = fma (t, s,  0x1.249249262c6b7p-3); //  1.4285714290376969e-1
    r = fma (r, s, -0x1.555555555f02ap-3); // -1.6666666666776681e-1
    t = fma (t, s,  0x1.99999999975b5p-3); //  1.9999999999974497e-1
    r = fma (r, f, t);
    r = fma (r, f, -0x1.fffffffffff54p-3); // -2.4999999999999523e-1
    r = fma (r, f,  0x1.555555555555bp-2); //  3.3333333333333365e-1
    r = fma (r, f, -0x1.0000000000000p-1); // -5.0000000000000000e-1

    /* log(a) = log1p (f) + i * log(2) */
    p = fma ( LOG2_HI, i, f);
    t = fma (-LOG2_HI, i, p);
    f = fma ( LOG2_LO, i, f - t);
    r = fma (r, s, f);
    r = r + p;

#endif // USE_ATANH

    /* Handle special cases */
    if (!((a > 0.0) && (a <= 0x1.fffffffffffffp1023))) {
        r = a + a;  // handle inputs of NaN, +Inf
        if (a  < 0.0) r =  0.0 / 0.0; //  NaN
        if (a == 0.0) r = -1.0 / 0.0; // -Inf
    }
    return r;
}