1. 前言

计算机想要计算一个浮点数的平方根,一般的做法是不断迭代使得估计值靠近真实值,最简单的迭代方法是二分查找,LeetCode 69.题就可以这么做出来。但毕竟二分查找的复杂度是\(\log n\),在一些极限场景下可能还是太慢,有没有更快的方法呢?那么就要利用一些数值分析和浮点数表示的知识,本文就来分析著名的快速计算浮点数反平方根算法(也称为卡马克算法),该算法是由John Carmack(约翰 · 卡马克)在1999年开发一款第一人称射击游戏时写出来的,直到2002~2003年人们在阅读该游戏源码时,其惊人的效率和叹为观止的解法震惊众人,该算法才出现在大众视野,被誉为经典。

反平方根算法要求输入任意一个浮点数\(a\)(在当时是32位浮点数),计算\(\frac{1}{\sqrt{a}}\)。

2. 预备知识

2.1 牛顿法

牛顿法是机器学习中用得比较多的一种优化算法,基本思想是将函数在点\(x_k\)处二阶泰勒展开,用一阶导数(梯度)和二阶导数(Hessian矩阵)对目标函数进行近似,把近似后的函数的极小点作为新的迭代点,如此迭代直到得到精度足够的估计值。

假设目标函数为\(f(x)\),在\(x_k\)处进行二阶泰勒展开(不熟悉泰勒展开的可看这篇):

\[f(x) \approx f(x_k) + f'(x_k)(x-x_k) + \frac{1}{2} f''(x_k)(x-x_k)^2 \tag{2.1}\]

极值点在\(f'(x) = 0\)时取得,所以对上式求导并令其为0:

\[f'(x) \approx f'(x_k) + f''(x_k)(x-x_k) = 0 \tag{2.2}\]

得到:

\[x = x_k - \frac{f'(x_k)}{f''(x_k)} \tag{2.3}\]

以上就是牛顿法的更新公式,利用函数在点\(x_k\)处的一阶导数和二阶导数进行更新,在其他的教程中可能会把\(f'(x_k)\)写为\(g_k\)以表示梯度,把\(f''(x_k)\)写为\(H_k\)以表示Hessian矩阵,殊途同归。

2.2 IEEE 754浮点数表示

关于IEEE 754标准的浮点数表示的建立历史可以移步到Wiki,这里直接介绍单精度浮点数的表示:

单精度浮点数由32位表示,分为三个部分:符号位\(S\)(1位)、指数位\(E\)(8位)和小数位\(M\)(23位)。

浮点数\(V\)表示公式如下:

\[V = (-1)^S \times (1 + m) \times 2^e \tag{2.4}\]

其中:\(M = m \times L\),\(E = e + B\),\(L=2^{23}\),\(B = 127\)。

如上图的例子所示:\(S=0\),\(E=124\),\(M=2^{21}=2097152\),

所以:\(m = \frac{M}{L} = 0.25\),\(e = E - B = -3\),

所以:\(V = (1 + 0.25) \times 2^{-3} = 0.15625\)

3. 卡马克算法

代码:

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

    x2 = number * 0.5F;
    y  = number;
    i  = * ( long * ) &y;                       // evil floating point bit level hacking
    i  = 0x5f3759df - ( i >> 1 );               // what the fuck? 
    y  = * ( float * ) &i;
    y  = y * ( threehalfs - ( x2 * y * y ) );   // 1st iteration

    return y;
}

以上代码中有两行比较难理解:

i  = 0x5f3759df - ( i >> 1 );               // what the fuck? 

以及

y  = y * ( threehalfs - ( x2 * y * y ) );   // 1st iteration

3.1 牛顿迭代

我们首先理解这一行代码:

y  = y * ( threehalfs - ( x2 * y * y ) );   // 1st iteration

对于浮点数\(a\),我们构造一个函数\(f(x) = x^{-2} - a\),当\(f(x) = 0\)时,得到的\(x\)即为答案(对应式\(2.2\)),首先对\(f(x)\)在\(x_k\)处进行一阶泰勒展开并令其为0:

\[f(x) \approx f(x_k) + f'(x_k)(x-x_k) = 0 \tag{3.1}\]

可以得到:

\[x = x_k - \frac{f(x_k)}{f'(x_k)} \tag{3.2}\]

即利用函数在\(x_k\)的值和一阶导数的值即可进行一次牛顿法迭代。

将\(f(x_k) = x_k^{-2} - a\),\(f'(x_k) = -2 x_k^{-3}\)代入式\(3.2\),得到:

\[\begin{aligned} x &= \frac{3}{2} x_k - \frac{1}{2}x_k^3a \\ &= x_k (\frac{3}{2} - \frac{1}{2}x_k^2 a) \end{aligned} \tag{3.3}\]

代码中的\(y\)就是\(x_k\),\(x2\)就是\(\frac{1}{2}a\),这一行代码就是进行了一次牛顿法迭代。

这里就出现了一个疑问!为什么他只需要迭代一次?按道理不是应该迭代直到精度足够小吗?原因是代码作者预先选择的出发点已经足够接近答案了,另外一行神仙代码就是做这件事情。

3.2 初始值估计

下面理解这行神仙代码:

i  = 0x5f3759df - ( i >> 1 );               // what the fuck? 

为什么这一行代码计算得到的值,就已经非常接近答案?以下分析参考自Wiki

对于一个正浮点数\(x\):

\[x = 2^{e_x} (1 + m_x) \tag{3.4}\]

考虑其对数:

\[\log x = e_x + \log (1 + m_x) \tag{3.5}\]

由于\(m_x \in [0, 1)\),所以\(\log x\)可以表示为:

\[\log x \approx e_x + m_x + \sigma \tag{3.6}\]

其中\(\sigma \approx 0.0430357\)。

接下来我们尝试表示出一个浮点数的整数部分,假设\(I_x\)表示浮点数\(x\)的整数部分(为什么整数部分是这么表示?笔者也未理解):

\[\begin{aligned} I_x &= E_x L + M_x \\ &= L (e_x + B + m_x) \\ &= L (e_x + m_x + \sigma + B - \sigma) \\ &\approx L \log x + L(B - \sigma) \\ \end{aligned} \tag{3.7}\]

进一步推导得到:

\[\log x \approx \frac{I_x}{L} - (B - \sigma) \tag{3.8}\]

令\(y = \frac{1}{\sqrt{x}}\),则\(\log y = -\frac{1}{2} \log x\),代入式\(3.8\):

\[\log y \approx \frac{I_y}{L} - (B - \sigma) \approx -\frac{1}{2} (\frac{I_x}{L} - (B - \sigma)) \tag{3.9}\]

化简得到:

\[I_y = \frac{3}{2} (B-\sigma)L -\frac{1}{2}I_x \tag{3.10}\]

其中\(I_x\)表示浮点数\(x\)的整型部分,在代码中用强制转换得到;\(I_y\)表示\(\frac{1}{\sqrt{x}}\)的整数部分,作为牛顿迭代的初始值。

神仙代码就是实现了式\(3.10\),其中\((i>>1)\)表示\(\frac{1}{2}I_x\)(右移操作实现除2);0x5f3759df作为\(\frac{3}{2}(B-\sigma)\)的估计。在当时计算条件这么有限的情况下,这个数字是怎么获得的呢?这要问作者本人了。

3.3 精度

这个算法得到的精度如何呢?图片来源于Wiki

绿线表示算法得到的值,红线表示误差,可以看到误差随着数值的增大而减小,且恒定小于0.012。

参考

Fast inverse square root
IEEE 754
快速浮点开方运算