快速反余弦算法?

28

我有自己的非常快的cos函数:

float sine(float x)
{
    const float B = 4/pi;
    const float C = -4/(pi*pi);

    float y = B * x + C * x * abs(x);

    //  const float Q = 0.775;
    const float P = 0.225;

    y = P * (y * abs(y) - y) + y;   // Q * y + P * y * abs(y)


    return y;
}

float cosine(float x)
{
    return sine(x + (pi / 2));
}

但是现在当我进行性能分析时,我发现acos()函数会使处理器卡顿。我不需要非常精确的计算结果。有没有一种快速计算acos(x)的方法呢?谢谢。


9
你的高速函数在[-pi, pi]区间内平均误差为16%,在该区间之外完全无法使用。我的系统上标准的 math.h 库中的 sinf 函数仅花费大约2.5倍于你的近似函数的时间。考虑到你的函数是内联的而库调用不是,这并没有太大的区别。我猜测,如果你添加范围缩减,使它可以像标准函数一样使用,你将会有完全相同的速度。 - Damon
6
不,最大误差是0.001(1/10th %)。你有没有忘记应用校正?(y = P * bla...)请查看原始来源和讨论:http://devmaster.net/forums/topic/4648-fast-and-accurate-sinecosine/ 其次,对于sin和cos预绑定为+ -pi是一个非常常见的情况,特别是在图形和模拟中经常需要快速近似的sin / cos。 - jcwenger
这是一个非常有趣的问题,谢谢提问! - JosephDoggie
10个回答

41

一个简单的立方近似,针对 x ∈ {-1, -½, 0, ½, 1} 的 Lagrange 插值多项式为:

double acos(x) {
   return (-0.69813170079773212 * x * x - 0.87266462599716477) * x + 1.5707963267948966;
}

它的最大误差约为0.18弧度。


2
最大误差为10.31度。相当大,但在某些解决方案中可能已经足够。适用于计算速度比精度更重要的情况。也许四次方近似会产生更高的精度,而且仍然比本地acos更快? - Timo Kähkönen
1
这个公式没有错误吗?我刚试了一下 Wolfram Alpha,看起来不对啊:http://www.wolframalpha.com/input/?i=y%3D%282%2F9*pi*x*x-5*pi%2F18%29*x%2Bpi%2F2 - miho

27

有多余的内存吗?使用查找表(如有必要,包含插值)会是最快的方式。


1
我该如何将这个实现为C函数? - jmasterx
7
@Jex: 对你的参数进行边界检查(它必须在-1和1之间)。然后乘以一个好的2的幂,比如64,得到范围(-64, 64)。加上64使其非负(0, 128)。使用整数部分来索引查找表,如果需要,在两个最接近的条目之间使用小数部分进行插值。如果你不想进行插值,可以尝试添加64.5并取底部,这与四舍五入相同。 - Ben Voigt
4
查找表需要索引,这将需要将浮点数转换为整数,这很可能会降低性能。 - phkahler
2
@phkahler:在x86上,将浮点数转换为整数的成本非常低廉,几乎与FP加法一样便宜,正如您可以在Agner Fog的延迟/吞吐量/uop表中看到的那样(http://agner.org/optimize)。对索引进行范围检查以确保它不会超出表之外的部分可能是同样昂贵的。在Intel Haswell上,“int idx = x * 4096.0”将具有约9个周期的延迟。这其中最昂贵的部分远远是来自一个相当大的表的缓存未命中。如果没有大量不依赖于acos结果的并行计算,那么一个大表可能会更慢(特别是在缓存竞争方面)。 - Peter Cordes

26

nVidia提供一些非常好的资源,展示了如何近似计算一些本来很昂贵的数学函数,例如:acosasinatan2等等...

当执行速度比精确度更重要时(在合理范围内),这些算法可以产生良好的结果。下面是它们的acos函数:

// Absolute error <= 6.7e-5
float acos(float x) {
  float negate = float(x < 0);
  x = abs(x);
  float ret = -0.0187293;
  ret = ret * x;
  ret = ret + 0.0742610;
  ret = ret * x;
  ret = ret - 0.2121144;
  ret = ret * x;
  ret = ret + 1.5707288;
  ret = ret * sqrt(1.0-x);
  ret = ret - 2 * negate * ret;
  return negate * 3.14159265358979 + ret;
}

这里是计算acos(0.5)的结果:

nVidia:   result: 1.0471513828611643
math.h:   result: 1.0471975511965976

这非常接近!根据您所需的精度程度,这可能是一个很好的选择。


2
与 nvidia 网站上“参考实现”中的评论相反,绝对误差不是 <= 6.7e-5,但我能够观察到 6.759167e-05 的误差。此外,您有多确定这个函数实际上比普通的 acos 更快?在第四代 Core i5(Haswell)上,nvidia 函数始终比 math.h 中的 acos 慢 25%。 - josch
3
使用 3.14159265358979 而不是 math.pi 的理由是什么? - ideasman42
1
@ideasman42 Python对我的回答并不重要。我的目标只是指出nVidia的近似方法文档作为资源。因此,我编辑了我的答案以使其更清晰。但是回答你的问题:我猜这些数字被选择是为了很好地配合使用。因此,在大多数情况下使用math.pi可能看起来没有太大的区别,直到你遇到误差阈值会变得更糟的边缘情况。 - Fnord
2
询问的原因是有一个非常小的差异导致了这个问题:当将其移植到任何其他语言时,应该使用pi的常数吗?还是这是一个故意略微修改的pi,调整得更好地适应近似?(当然可以轻松测试) - ideasman42
1
float(x < 0) 是什么意思?我该如何将其转换为Scala? - mingzhao.pro
显示剩余2条评论

11

我有自己的算法。它非常精确并且速度相当快。它是基于我构建的四次收敛理论工作的。这个方程式很有趣,你可以在这里看到它以及它对我的对数近似收敛速度的影响:https://www.desmos.com/calculator/yb04qt8jx4

这是我的反余弦代码:

function acos(x)
    local a=1.43+0.59*x a=(a+(2+2*x)/a)/2
    local b=1.65-1.41*x b=(b+(2-2*x)/b)/2
    local c=0.88-0.77*x c=(c+(2-a)/c)/2
    return (8*(c+(2-a)/c)-(b+(2-2*x)/b))/6
end
很多都是平方根估算。它非常有效,除非您接近于对0取平方根。它的平均误差(不包括x=0.99到1)为0.0003。问题在于,当x等于0.99时,它开始变得糟糕,而当x等于1时,准确度的差异变为0.05。当然,这可以通过对平方根进行更多迭代来解决(呵呵,不行),或者像这样小小的事情,如果x>0.99,则使用不同的平方根线性化,但这会使代码变得冗长和丑陋。
如果您不太关心准确性,只需每个平方根进行一次迭代,这应该仍然可以使您保持在0.0162或其他某个准确度范围内:
function acos(x)
    local a=1.43+0.59*x a=(a+(2+2*x)/a)/2
    local b=1.65-1.41*x b=(b+(2-2*x)/b)/2
    local c=0.88-0.77*x c=(c+(2-a)/c)/2
    return 8/3*c-b/3
end

如果你可以接受的话,你可以使用预先存在的平方根代码。这将消除在x=1时方程变得有点疯狂的情况:

function acos(x)
    local a = math.sqrt(2+2*x)
    local b = math.sqrt(2-2*x)
    local c = math.sqrt(2-a)
    return 8/3*d-b/3
end

不过,如果你真的时间紧迫,记住你可以将arccos线性化为3.14159-1.57079x,然后只需执行:

function acos(x)
    return 1.57079-1.57079*x
end
无论如何,如果你想要查看我的反余弦逼近方程列表,你可以前往https://www.desmos.com/calculator/tcaty2sv8l。我知道我的逼近对于某些事情来说并不是最好的,但如果你正在做一些需要我的逼近方程的事情,请使用它们,但请尽量在引用中提到我。

1
这是你上一个问题的意思吗?1.57079-1.57079*x。 - FocusedWolf
1
对于任何使用C#的人来说,这可能是一个不错的第一行代码:if (x < -1D || x > 1D || Double.IsNaN(x)) return Double.NaN; 这与.NET框架acos函数保持一致:https://msdn.microsoft.com/zh-cn/library/system.math.acos(v=vs.110).aspx - FocusedWolf
1
你的第一个实现在 x = -1 处偏移太多了,大约 0.5 弧度。 - Steve

9
您可以使用多项式来近似反余弦函数,如dan04建议的那样,但在接近-1和1的地方,反余弦函数的导数会趋于无穷大,因此多项式是一个相当糟糕的逼近方法。当您增加多项式的次数时,很快就会遇到收益递减的情况,并且仍然难以在端点附近得到良好的逼近。在这种情况下,有理函数(两个多项式的商)可以给出更好的逼近结果。
acos(x) ≈ π/2 + (ax + bx³) / (1 + cx² + dx⁴)

在哪里

a = -0.939115566365855
b =  0.9217841528914573
c = -1.2845906244690837
d =  0.295624144969963174

该函数在区间(-1, 1)上的最大绝对误差为0.017弧度(0.96度)。这是一个图形(反余弦为黑色,三次多项式逼近为红色,上述函数为蓝色)进行比较:

Plot of acos(x) (black), a cubic polynomial approximation (red), and the function above (blue)

上述系数已被选择以使整个定义域内的最大绝对误差最小化。如果您愿意允许端点处的误差更大,则可以使区间(-0.98, 0.98)上的误差变得更小。分子为5次,分母为2次的多项式与上述函数速度相当,但略微不准确。通过使用更高次数的多项式,可以提高精度,但会牺牲性能。

关于性能的说明:计算这两个多项式仍然非常便宜,并且您可以使用融合乘加指令。除法并不那么糟糕,因为您可以使用硬件倒数近似和乘法。与acos近似的误差相比,倒数近似的误差可以忽略不计。在2.6 GHz Skylake i7上,使用AVX,这种逼近法可以每6个周期大约执行8个反余弦操作。(这是吞吐量,延迟时间比6个周期长。)


这些系数的来源在哪里? - Gokul
@Gokul 这些值是由此脚本计算得出的:https://github.com/ruuda/convector/blob/2f5f2428fa6c54002bd2ee8ce3d0f2188aab49f8/tools/approx_acos.py - Ruud

6

另一种方法是使用复数。从de Moivre's formula可以得出:

x = cos(π/2*x) + ⅈ*sin(π/2*x)

令θ = π/2*x。那么x = 2θ/π,因此

  • sin(θ) = ℑ(ⅈ^2θ/π)
  • cos(θ) = ℜ(ⅈ^2θ/π)

如何在没有sin和cos的情况下计算ⅈ的幂次?从预先计算的2的幂表开始:

  • 4 = 1
  • 2 = -1
  • 1 = ⅈ
  • 1/2 = 0.7071067811865476 + 0.7071067811865475*ⅈ
  • 1/4 = 0.9238795325112867 + 0.3826834323650898*ⅈ
  • 1/8 = 0.9807852804032304 + 0.19509032201612825*ⅈ
  • 1/16 = 0.9951847266721969 + 0.0980171403295606*ⅈ
  • 1/32 = 0.9987954562051724 + 0.049067674327418015*ⅈ
  • 1/64 = 0.9996988186962042 + 0.024541228522912288*ⅈ
  • 1/128 = 0.9999247018391445 + 0.012271538285719925*ⅈ
  • 1/256 = 0.9999811752826011 + 0.006135884649154475*ⅈ

要计算任意值的ⅈx,请将指数近似为二进制分数,然后从表中相应地乘以对应值。

例如,要找到72° = 0.8π/2的sin和cos:

0.8 ≈ ⅈ205/256 = ⅈ0b11001101 = ⅈ1/2 * ⅈ1/4 * ⅈ1/32 * ⅈ1/64 * ⅈ1/256
= 0.3078496400415349 + 0.9514350209690084*ⅈ

  • sin(72°) ≈ 0.9514350209690084(“精确”值为0.9510565162951535)
  • cos(72°) ≈ 0.3078496400415349(“精确”值为0.30901699437494745)。

要找到asin和acos,您可以使用此带有二分法的表:

例如,要查找asin(0.6)(3-4-5三角形中最小的角度):
  • 0 = 1 + 0*ⅈ。正弦值太小,因此将 x 增加 1/2。
  • 1/2 = 0.7071067811865476 + 0.7071067811865475*ⅈ。正弦值太大,因此将 x 减少 1/4。
  • 1/4 = 0.9238795325112867 + 0.3826834323650898*ⅈ。正弦值太小,因此将 x 增加 1/8。
  • 3/8 = 0.8314696123025452 + 0.5555702330196022*ⅈ。正弦值仍然太小,因此将 x 增加 1/16。
  • 7/16 = 0.773010453362737 + 0.6343932841636455*ⅈ。正弦值太大,因此将 x 减少 1/32。
  • 13/32 = 0.8032075314806449 + 0.5956993044924334*ⅈ。
每次增加x时,乘以相应的ⅈ的幂。每次减小x时,除以相应的ⅈ的幂。
如果我们止步于此,我们得到acos(0.6)≈13/32*π/2=0.6381360077604268(“精确”值为0.6435011087932844)。
当然,精度取决于迭代次数。对于快速且简单的近似,请使用10次迭代。对于“极致精度”,请使用50-60次迭代。

5

一种快速且准确到约0.5度的反余弦函数实现可以基于观察,对于 x ∈ [0,1],acos(x) ≈ √(2*(1-x))。一个额外的比例因子可以在接近零时提高精度。最优的因子可以通过简单的二分查找找到。负参数根据 acos(-x)=π-acos(x) 进行处理。

#include <stdio.h>
#include <stdlib.h>
#include <stdint.h>
#include <string.h>
#include <math.h>

// Approximate acos(a) with relative error < 5.15e-3
// This uses an idea from Robert Harley's posting in comp.arch.arithmetic on 1996/07/12
// https://groups.google.com/forum/#!original/comp.arch.arithmetic/wqCPkCCXqWs/T9qCkHtGE2YJ
float fast_acos (float a)
{
    const float PI = 3.14159265f;
    const float C  = 0.10501094f;
    float r, s, t, u;
    t = (a < 0) ? (-a) : a;  // handle negative arguments
    u = 1.0f - t;
    s = sqrtf (u + u);
    r = C * u * s + s;  // or fmaf (C * u, s, s) if FMA support in hardware
    if (a < 0) r = PI - r;  // handle negative arguments
    return r;
}

float uint_as_float (uint32_t a)
{
    float r;
    memcpy (&r, &a, sizeof(r));
    return r;
}

int main (void)
{
    double maxrelerr = 0.0;
    uint32_t a = 0;
    do {
        float x = uint_as_float (a);
        float r = fast_acos (x);
        double xx = (double)x;
        double res = (double)r;
        double ref = acos (xx);
        double relerr = (res - ref) / ref;
        if (fabs (relerr) > maxrelerr) {
            maxrelerr = fabs (relerr);
            printf ("xx=% 15.8e  res=% 15.8e  ref=% 15.8e  rel.err=% 15.8e\n",
                    xx, res, ref, relerr);
        }
        a++;
    } while (a);
    printf ("maximum relative error = %15.8e\n", maxrelerr);
    return EXIT_SUCCESS;
}

以上测试支架的输出应该类似于这样:
xx= 0.00000000e+000  res= 1.56272149e+000  ref= 1.57079633e+000  rel.err=-5.14060021e-003
xx= 2.98023259e-008  res= 1.56272137e+000  ref= 1.57079630e+000  rel.err=-5.14065723e-003
xx= 8.94069672e-008  res= 1.56272125e+000  ref= 1.57079624e+000  rel.err=-5.14069537e-003
xx=-2.98023259e-008  res= 1.57887137e+000  ref= 1.57079636e+000  rel.err= 5.14071269e-003
xx=-8.94069672e-008  res= 1.57887149e+000  ref= 1.57079642e+000  rel.err= 5.14075044e-003
maximum relative error = 5.14075044e-003

3

这是一个拥有许多选项的优秀网站: https://www.ecse.rpi.edu/Homepages/wrf/Research/Short_Notes/arcsin/onlyelem.html

个人而言,我使用了Chebyshev-Pade商近似法,并附上以下代码:

double arccos(double x) {
const double pi = 3.141592653;
    return pi / 2 - (.5689111419 - .2644381021*x - .4212611542*(2*x - 1)*(2*x - 1)
         + .1475622352*(2*x - 1)*(2*x - 1)*(2*x - 1))
         / (2.006022274 - 2.343685222*x + .3316406750*(2*x - 1)*(2*x - 1) +
             .02607135626*(2*x - 1)*(2*x - 1)*(2*x - 1));
}

2
这个在 x = -1 处偏差很大,大概是0.5弧度。无法使用。 - Steve

2
如果你正在使用微软VC++,这里提供了一个内联__asm x87 FPU代码版本,没有所有的CRT填充、错误检查等,与你能找到的最早的经典ASM代码不同,它使用FMUL而不是更慢的FDIV。它可以编译/在我因各种原因一直坚持使用的Microsoft VC++ 2005 Express/Pro中工作。
要设置一个带有"__declspec(naked)/__fastcall"函数,正确提取参数,处理堆栈有些棘手,所以不适合心脏虚弱者。如果它在你的版本上无法编译并出现错误,请不要尝试,除非你很有经验。或者问我,我可以将其重写为稍微友好的__asm{}块。如果需要进一步提高性能,我会手动内联这个关键部分的函数循环。
extern float __fastcall fs_acos(float x);
extern double __fastcall fs_Acos(double x);

// ACOS(x)- Computes the arccosine of ST(0)
// Allowable range: -1<=x<=+1
// Derivative Formulas: acos(x) = atan(sqrt((1 - x * x)/(x * x))) OR
// acos(x) = atan2(sqrt(1 - x * x), x)
// e.g. acos(-1.0) = 3.1415927

__declspec(naked) float __fastcall fs_acos(float x) { __asm {
    FLD   DWORD PTR [ESP+4] ;// Load/Push parameter 'x' to FPU stack
    FLD1            ;// Load 1.0
    FADD  ST, ST(1) ;// Compute 1.0 + 'x'
    FLD1            ;// Load 1.0
    FSUB  ST, ST(2) ;// Compute 1.0 - 'x'
    FMULP ST(1), ST ;// Compute (1-x) * (1+x)
    FSQRT           ;// Compute sqrt(result)
    FXCH  ST(1)
    FPATAN          ;// Compute arctangent of result / 'x' (ST1/ST0)
    RET 4
}}

__declspec(naked) double __fastcall fs_Acos(double x) { __asm { //
    FLD   QWORD PTR [ESP+4] ;// Load/Push parameter 'x' to FPU stack
    FLD1            ;// Load 1.0
    FADD  ST, ST(1) ;// Compute (1.0 + 'x')
    FLD1            ;// Load 1.0
    FSUB  ST, ST(2) ;// Compute (1.0 - 'x')
    FMULP ST(1), ST ;// Compute (1-x) * (1+x)
    FSQRT           ;// Compute sqrt((1-x) * (1+x))
    FXCH  ST(1) 
    FPATAN          ;// Compute arctangent of result / 'x' (ST1/ST0)
    RET 8
}}

2
我怀疑FPU不会比SSE指令更快,而且对于x64目标来说是无用的,因为MSVC不允许在这些目标上使用内联汇编块。 - Iris Technologies

1

很遗憾,我没有足够的声望来进行评论。 这是Nvidia函数的一个小修改,可以处理应该小于等于1的数字,同时尽可能保持性能。

这可能很重要,因为舍入误差可能导致应该为1.0的数字略微大于1.0。


double safer_acos(double x) {
  double negate = double(x < 0);
  x = abs(x);
  x -= double(x>1.0)*(x-1.0); // <- equivalent to min(1.0,x), but faster
  double ret = -0.0187293;
  ret = ret * x;
  ret = ret + 0.0742610;
  ret = ret * x;
  ret = ret - 0.2121144;
  ret = ret * x;
  ret = ret + 1.5707288;
  ret = ret * sqrt(1.0-x);
  ret = ret - 2 * negate * ret;
  return negate * 3.14159265358979 + ret;

  // In a single line (no gain using gcc)
  //return negate * 3.14159265358979 + (((((-0.0187293*x)+ 0.0742610)*x - 0.2121144)*x + 1.5707288)* sqrt(1.0-x))*(1.0-2.0*negate);

}

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接