约翰·卡马克的不寻常的快速反平方根算法(Quake III)

139

John Carmack在Quake III源代码中有一个特殊函数可以计算浮点数的平方根倒数,速度比普通的(float)(1.0/sqrt(x))快4倍,其中包括奇怪的0x5f3759df常量。请见下面的代码。有人可以逐行解释一下这里到底发生了什么,以及为什么这比常规实现要快得多吗?

float Q_rsqrt( float number )
{
  long i;
  float x2, y;
  const float threehalfs = 1.5F;

  x2 = number * 0.5F;
  y  = number;
  i  = * ( long * ) &y;
  i  = 0x5f3759df - ( i >> 1 );
  y  = * ( float * ) &i;
  y  = y * ( threehalfs - ( x2 * y * y ) );

  #ifndef Q3_VM
  #ifdef __linux__
    assert( !isnan(y) );
  #endif
  #endif
  return y;
}

7
以下是解释Here's an explanation的内容:这篇文章讨论了现代图形加速器如何处理三维图形渲染问题。文章介绍了一种流水线架构,该架构将输入的三维几何数据转换为最终的二维像素图像。文章详细介绍了每个阶段的功能,包括几何处理、光栅化、矢量图形和纹理映射等。此外,还讨论了不同的硬件实现方法以及它们的优缺点。 - sepp2k
11
这个话题已经被写了很多次了。请参考:http://www.google.com/search?q=0x5f3759df。 - Greg Hewgill
18
谢谢你。这是一个比“如何在C#中将正数变为负数”更有趣的问题。 - MusiGenesis
11
不是卡马克。http://en.wikipedia.org/wiki/Fast_inverse_square_root - h4xxr
1
在这行代码 i = * ( long * ) &y; 中,为什么要将 y 的地址作为长整型指针来获取,然后再将其解除引用? - Nubcake
1
@Nubcake:因为y是一个float,这里将其强制转换为整数。这是不安全的,因为它违反了C语言的严格别名规则。在C99中使用union,或在C89 / C++中使用memcpy可以遵循语言规则做同样的事情,并且至少在现代优化编译器中编译结果相同。 - Peter Cordes
6个回答

85

顺便提一下,Carmack并没有编写它。Terje Mathisen和Gary Tarolli都在部分(非常谦虚的)认领了它,并给出了其他一些来源的贡献。

这个神秘的常数是如何得出的还有点不明确。

引用Gary Tarolli的话:

实际上是在整数中执行浮点计算——花费了很长时间才弄清楚为什么以及如何工作,我已经记不起细节了。

一个稍微更好的常数,由一位专业数学家(Chris Lomont)开发,他试图弄清楚原始算法的工作方式,它的链接是:http://www.lomont.org/Math/Papers/2003/InvSqrt.pdf

float InvSqrt(float x)
{
    float xhalf = 0.5f * x;
    int i = *(int*)&x;              // get bits for floating value
    i = 0x5f375a86 - (i >> 1);      // gives initial guess y0
    x = *(float*)&i;                // convert bits back to float
    x = x * (1.5f - xhalf * x * x); // Newton step, repeating increases accuracy
    return x;
}
尽管如此,他最初尝试创建一种在数学上“更优”的id的sqrt版本(与几乎相同的常数),但最终被发现不如Gary最初开发的版本出色,尽管在数学上更加“纯粹”。他无法解释为什么id的版本如此出色。

7
“数学上更纯粹”意味着什么? - Tara
2
我认为第一个猜测可以从可证明的常数中推导出来,而不是看起来是任意的。虽然如果你想要一个技术描述,你可以查一下。我不是数学家,关于数学术语的语义讨论不适合在SO上进行。 - Rushyo
11
这正是我在那个词加引号的原因,为了避免这种无聊的争论。我想读者应该熟悉口语化的英语写作,常识也足够了吧。我没有使用模糊的术语,因为我不想让那些懒得在谷歌上花两秒钟查找原始来源的人来质疑我。 - Rushyo
3
你其实还没有回答这个问题。 - BJovke
6
牛顿-拉弗森法求解后,最优的第一次猜测变差的一个好的解释是:如此论文所示,一个高估的值收敛速度比一个低估的值慢,这就是原因:https://cs.uwaterloo.ca/~m32rober/rsqrt.pdf - EdL
显示剩余2条评论

61

当然,在当今这些日子里,它的速度比仅使用FPU的sqrt要慢得多(特别是在360/PS3上),因为在浮点寄存器和整数寄存器之间切换会导致load-hit-store,而浮点单位可以通过硬件执行倒数平方根。

这只是展示了随着底层硬件性质的变化,优化必须不断地发展进化。


6
尽管如此,它仍比std::sqrt()快得多。 - Tara
2
你有源代码吗?我想测试运行时间,但我没有Xbox 360开发套件。 - DucRP
2
现在,英特尔处理器中有rsqrt。即_sse指令_mm_rsqrt_ss,而且它仍然更快。 - aselle

50

Greg HewgillIllidanS4提供了一个出色的数学解释链接。

对于那些不想深入了解的人,我将在此进行总结。

任何数学函数(有一些例外情况)都可以用多项式求和表示:

y = f(x)

可以被精确地转换为:

y = a0 + a1*x + a2*(x^2) + a3*(x^3) + a4*(x^4) + ...

其中a0、a1、a2等是常数。问题在于对于许多函数(如平方根),要想获得精确值,这个求和式需要无限多个成员,它不会以某个x^n结束。但是,如果我们在某个x^n处停止,则仍然可以获得一定精度的结果。

因此,如果我们有:

y = 1/sqrt(x)
在这种情况下,他们决定放弃所有高于二次项的多项式成员,可能是因为计算速度的原因:
y = a0 + a1*x + [...discarded...]

现在的任务是计算a0和a1,以便y与确切值之间的差异最小。他们已经计算出最合适的值为:

a0 = 0x5f375a86
a1 = -0.5

因此,当你将这个放入公式中,你得到:

y = 0x5f375a86 - 0.5*x

这与您在代码中看到的那一行相同:

i = 0x5f375a86 - (i >> 1);

编辑:实际上,在这里,y = 0x5f375a86 - 0.5*x不同于i = 0x5f375a86 - (i >> 1);,因为将浮点数作为整数进行移位不仅会将其除以二,还会将其指数除以二并引起一些其他的影响,但这仍然归结于计算某些系数a0、a1、a2...。

此时,他们发现这个结果的精度不够用。因此,他们又做了牛顿迭代的一步,以提高结果的准确性:

x = x * (1.5f - xhalf * x * x)
他们可以在一个循环中进行更多次迭代,每次迭代都会改善结果,直到达到所需的精度。这正是CPU / FPU的工作原理!但似乎只需要一次迭代就足够了,这也是速度的福音。 CPU / FPU将执行尽需要的迭代次数来达到存储结果的浮点数的精度,而且它具有适用于所有情况的通用算法。

简而言之,他们做的是:

使用(几乎)与CPU / FPU相同的算法,利用特殊情况下1 / sqrt(x)的初始条件改进,不计算全部到CPU / FPU将到达的精度,而是提前停止,从而获得计算速度上的优势。

2
将指针转换为长整型是对log_2(float)的近似。将其转换回来是对2^long的近似。这意味着您可以使比率近似线性化。 - wizzwizz4
这是我听过的最清晰的解释。 - user3724404

32

我很好奇将常量转换为浮点数后得到的结果是多少,于是我写了这段代码,并在谷歌上搜索弹出的整数。

long i = 0x5F3759DF;
float* fp = (float*)&i;
printf("(2^127)^(1/2) = %f\n", *fp);
//Output
//(2^127)^(1/2) = 13211836172961054720.000000

看起来这个常数是“对于2的127次方的整数近似值,在其浮点表示的十六进制形式0x5f3759df更为出名。”

在同一个网站上,它解释了整个过程。

https://mrob.com/pub/math/numbers-18.htmlhttps://mrob.com/pub/math/numbers-16.html#le009_16

13
这值得更多关注。在意识到它只是2的127次方根后,一切都有了意义。 - u8y7541
仅为完整起见 - 十六进制并不完全是 sqrt(2^127),而是一个接近的近似值(最高有效位数为两位)。sqrt(2^127) = 1.3043x10^190x5F3759DF = 1.3211x10^19 - Loves Probability
有点挑剔:该代码调用了未定义的行为(别名),即使float与long具有相同的大小,它也很有可能无法在现代编译器中正常工作。 - Remember Monica

25
根据这篇很好的文章写的一段时间前的内容...
引用: 代码的神奇之处,即使您无法跟随它,也会突出显示i = 0x5f3759df - (i >> 1);行。简化来说,牛顿-拉弗森是一种近似方法,它从猜测开始并通过迭代进行精细调整。利用32位x86处理器的特性,整数i最初设置为要求其逆平方的浮点数的值,使用整数转换。然后将i设置为0x5f3759df,减去自己向右移动一位。右移丢弃i的最低有效位,基本上将其减半。
这是一篇非常好的阅读材料。这只是其中的一小部分。

这里提到的牛顿-拉弗森方法类似于神经网络中使用的梯度下降法。主要的奥妙在于常数。通过使用这个常数,以及对其进行一次牛顿-拉弗森迭代就足以达到所需的精度。 - Harsha Reddy

2
代码由两个主要部分组成。第一部分计算1/sqrt(y)的近似值,第二部分使用牛顿迭代法运行一次,以获得更好的近似值。 计算1/sqrt(y)的近似值
i  = * ( long * ) &y;
i  = 0x5f3759df - ( i >> 1 );
y  = * ( float * ) &i;

第一行将浮点数y的表示形式作为整数i处理。第二行将i向左移动一位并从一个神秘的常量中减去它。第三行将得到的数字转换回标准的float32格式。那么这是为什么呢?

假设g是一个将浮点数映射到其浮点表示形式(读作整数)的函数。上面的第一行设置i = g(y)

以下好的近似g存在(*):g(y) ≈ Clog_2 y + D,其中C和D是常数。这样一个很好的近似之所以存在的直觉是,y的浮点表示形式在指数方面大致呈线性关系。

第2行的目的是将从g(y)映射到g(1/sqrt(y)),然后第3行可以使用g^-1将该数字映射到1/sqrt(y)。使用上述近似,我们有g(1/sqrt(y)) ≈ Clog_2 (1/sqrt(y)) + D = -C/2 log_2 y + D。我们可以使用这些公式来计算从g(y)g(1/sqrt(y))的映射,即g(1/sqrt(y)) ≈ 3D/2 - 1/2 * g(y)。在第2行中,我们有0x5f3759df ≈ 3D/2i >> 1 ≈ 1/2*g(y)

常数0x5f3759df略小于给出g(1/sqrt(y))最佳近似的常数。这是因为该步骤不是孤立完成的。由于牛顿法倾向于错过方向,使用稍小的常数往往会产生更好的结果。在此设置中要使用的确切最优常数取决于y的输入分布,但0x5f3759df是一个在相当广泛的范围内产生良好结果的常数之一。

有关此过程的更详细描述,请参见维基百科:https://en.wikipedia.org/wiki/Fast_inverse_square_root#Algorithm

更明确地说,令 y = 2^e*(1+f)。取两边的对数,得到 log_2 y = e + log_2(1+f),这可以近似为 log_2 y ≈ e + f + σ,其中 sigma 是一个小常数。另外,y 的 float32 编码 表示为整数是 g(y) ≈ 2^23 * (e+127) + f * 2^23。将两个方程组合起来,我们得到 g(y) ≈ 2^23 * log_2 y + 2^23 * (127 - σ)使用牛顿法
y  = y * ( threehalfs - ( x2 * y * y ) );

考虑函数 f(y) = 1/y^2 - num。正的零点是 y = 1/sqrt(num),这是我们想要计算的。
牛顿法是一种迭代算法,用于取函数 f 的零点的近似值 y_n,并使用以下公式计算更好的近似值 y_n+1: y_n+1 = y_n - f(y_n)/f'(y_n)
计算我们函数 f 的表达式如下:y_n+1 = y_n - (-y_n+y_n^3*num)/2 = y_n * (3/2 - num/2 * y_n * y_n)。上面代码行所做的就是这个。
你可以在这里了解有关 Newton's method 的详细信息: https://en.wikipedia.org/wiki/Newton%27s_method

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