Quake 3 中的 InvSqrt 算法

· · 个人记录

[toc]

0. 前言

在一些图形化界面,我们很经常需要取单位向量。这就需要乘以模长的逆元。具体地,我们需要使用函数 f(x)=x^{-0.5}。因为这个函数将要被调用很多次,除了给它加上 inline 之外,我们还希望它能有一定程度的优化。我们允许的代价是一定范围内的精度误差(微小的误差在图形化时可以忽略)。

广为流传的快速 InvSqrt 代码据说是来自 John Carmack 的 Quake 3,传世版本大概如下:

float invSqrt(float x) {
    float xhalf = x * .5f;
    int i = *(int*)&x; // get bits for float type.
    i = 0x5f3759df - (i >> 1); // **Words having been harmonised**
    x = *(float*)&i; // return to float type.
    x = x * (1.5f - xhalf * x * x); // Newtown Step.
    // x = x * (1.5f - xhalf * x * x); // Repetition of Newtown Step enhances accuracy.
    return x;
}

这段代码据说比 1/std::sqrt(x) 快 4 倍,而精度误差也不算大。这个方法最迷人的地方就是第 4 行的操作。对应的注释是一句被和谐化的语言。对于这行代码的操作,有一篇英文论文 InvSqrt.dvi (lomont.org) 专门研究了这行代码。

然而,当我以不写暑假作业为代价读完了这篇论文之后,回顾网上的中文解释,似乎都不尽人意。

所以就有了这篇文章。

1. 前置知识

为了不影响阅读体验,在阅读主菜之前您应该先了解以下知识:

1.1 牛顿迭代法求函数零点

对于连续曲线,如何求曲线与 x 轴的交点?

首先我们有个原始猜测 x_0(initial guess)。然后作曲线在 x_0 处的切线。切线与 x 轴的交点作为更精准的猜测 x_1

然后再作 x_1 处的切线,与 x 轴交点为 x_2。以此类推

比如说,下面这张图,蓝色是给定曲线,绿色是切线。

原始猜测 x_0=0.8

得到交点 x_1=0.6603,然后作下一条切线。

得到交点 x_2=0.6213

可以发现,x_0,x_1,x_2,\dots 这些交点越来越接近零点 0.618。无论原始猜测是什么,牛顿迭代的次数越多,得到的结果就越接近零点。

一般地,对于曲线 y=f(x),给定初始猜测 x_0,使用牛顿迭代法得到第 n+1 次猜测时所做切线为:

y-f(x_n)=f'(x_n)(x-x_n)

代入 y=0 得到其与 x 轴的交点。

x_{n+1}=x_n-\dfrac{f(x_n)}{f'(x_n)}\quad(n\in N)

其中,N 为自然数集。上述式子即为牛顿迭代法的递推式。

1.2 浮点数的二进制表示

计算机是二进制的,所以计算机能表示的数也只能是关于 x=2 的多项式,对次数还有一定的要求。

比如,9=1\times 2^3+0\times 2^2+0\times 2^1+1\times 2^0

可以证明,所有的自然数都可以表示为形如 \sum_{i=0}^{+\infty}a_i2^i,a_i\in\{0,1\} 的多项式。当然,计算机的内存不可能去到正无穷,所以 unsigned int 类型仅能表示所有形如 \sum_{i=0}^{31}a_i2^i 的多项式(对于 signed 类型还需要考虑符号位,在这里不加以讨论)计算机中保存的就是多项式的系数 a_i

对于小数部分(即小数点后的部分),二进制同样只能表示形如 \sum_{i=1}^{+\infty}b_i2^{-i},b_i\in\{0,1\} 的多项式。

比如,0.6875=1\times2^{-1}+0\times2^{-2}+1\times2^{-3}+1\times2^{-4}

同样,由于计算机内存不可能去到正无穷,所以对于 i 的最大值也会有限制。一些小数不可能精确表示,比如 0.1 就不能表示为有限个 2 的若干次幂相加。如果设置 i 的最大值为 24,那么 0.1 的二进制表示将会是:

0.000110011001100110011001

转换为十进制就是:

0.09999996423721313

它非常接近 0.1,所以在实际处理中,计算机就会直接把他当做 0.1 输出。这是一种近似处理,在有效位数 7 位的情况下,它仍然可以被视为 0.1。而当有效位数是 8 位时,他就是 0.09999996 了。所以计算机表示的小数会存在有效位数。

那么我们保存多项式的系数 b_i?不行,对于一个数,可以同时拥有整数部分和小数部分。这样一来我们或许还要保存小数点的位置。如果钦定整数部分和小数部分的范围,会造成浪费:比如,我要表示 (0,1) 之间的小数,那么整数部分就非常鸡肋;或者,我要表示整数部分很大的一位小数,小数部分又会非常鸡肋。

为了协调需求,计算机中一般采用 IEEE 754-1985 表示浮点数。这是一种科学记数法。

当然,在一些场合还是会使用定点数,比如 ttf 字体文件中字形顶点的位置。

十进制中科学计数法大概是这样的:对于任意正实数 x,可以表示为:

x=m\times 10^e,\quad m\in[1,10)

类似的,在二进制中:

x = m\times2^e,\quad m\in[1,2)

其中,m,e 都是实数,e 不表示自然对数的底数,而表示 2 的指数。

这样,我们只要保存一个符号位以及 m,e 就可以了。只要协调好 m,e 之间的内存分配问题。对于 32 位有符号浮点数 float,它是这样分配的:

  1. 首位为符号位,保存符号 s
  2. 接下来 8 位为指数位,保存与指数相关的数 E
  3. 最后 23 位为系数位,保存与系数相关的数 M

为了表示 e 为负数的情况,在保存时会给其加上 127。所以 E=e+127

注意到 m 的整数部分一定是 1,可以不保存。所以 M=m-1

这里 E 一定是整数,但是 M 是实数,未必可以用有限位二进制来保存。所以这里保存的是其 23 位近似值。

总的来说,一个 float 类型变量 x 代表的数是:

x = (-1)^s\cdot(1+M)2^{E-127}

举个例子,13.5=(-1)^0\cdot(1+0.6875)2^{130-127},所以其 32 位二进制表示是这样的:

0 10000010 10110000000000000000000

2. 算法基础

给定 a,要求 x=a^{-0.5} 的近似解。两边平方取倒数,即 \frac 1{x^2}=a。也就是说,我们要求 f(x)=\dfrac1{x^2}-a 的零点。

根据牛顿迭代法的递推式,给定初始猜测 x_0,可知 x_1=x_0(1.5-0.5ax_0^2)

代码的大意是:通过第 4 行计算出一个比较准确的初始猜测 x_0,然后第 6 行用一次牛顿迭代修正。因为 x_0 已经比较精确了,所以最后的结果也会较为精确。

现在我们就要弄清楚,第 4 行究竟干了什么?

3. 神奇猜测

我们已经知道浮点数在计算机中是使用科学记数法表示的。但是,对于浮点数 13.5,如何得到其位数?其实很简单,只需要将其地址强制转换为 int 类型的地址,然后计算机就会按照 int 的规则去读取。

比如下面代码:

float x = 13.5f;
int i = *(int*)&x;

然后输出看一下 i 的值是多少?应该输出的是 1096286208。因为其二进制表示就是 0 10000010 10110000000000000000000,恰好是 13.5 按照 IEEE 754-1985 法则储存的二进制序列。

所以,将其地址类型进行偷梁换柱就可以得到储存的二进制序列。

其中,有 8 位储存的是指数相关的数 E。现在我们希望把指数减小一半并取相反数,那么一定会有的操作就是

  1. 指数右移一位
  2. 取反

其中,取反操作并不能很好地给出较为准确的初始猜测,于是我们选择用某个数 R 减去指数,这样也能起到类似取反的效果。

当然,我们不可能只对 E 进行操作。哪怕我们把二进制序列拿了出来存储在 int 里面,我们也需要对整个 int 进行操作。不妨把 R 切分成 3 部分:符号位,接下来 8 位记为 R_1,最后 23 位记为 R_2。这样切分就和 IEEE 754-1985 法则对应上了,相减的时候就是 R_1ER_2M。还有借位的情况,这些稍后再讨论。

为了使算法正常工作,我们必须时刻保持符号位为 0(实数的开平方根操作不涉及负数),所以 R 的符号位也是 0

接下来就要分析,R_1R_2 分别取什么值能使得误差最小。

3.1 指数部分

指数部分 E 先右移一位,这在整个序列中会出现一个问题:如果 E 是奇数,那么就会有一个 1 被右移到系数部分。简单起见我们先讨论偶数情况。

3.1.1 指数为奇数

当指数 e=2d+1 为奇数时,对应的 E 就会是偶数,这时右移对系数部分没有任何影响。

首先看一下右移再相减对指数部分的效果:

\begin{aligned} E'&=R_1-\frac E2\\ &=R_1-\frac{e+127}{2}\\ &=R_1-\frac{2d+128}{2}\\ &=R_1-64-d \end{aligned}

无论把上面的字母看做二进制序列还是一个具体的数,这个等式都是成立的。因为在对应法则的约束下,二进制序列和具体的数一一对应,他们的减法有同构关系。

请记住这个结果 必须是正数。当它是负数时,二进制序列相减就会向符号位借位,然后你将会得到一个负数作为算术平方根,这很明显是不对的。当它是 0 时,就无法应对来自系数部分可能得借位。所以这个结果 必须是正数

偶数 E\in[0, 254],根据这个范围可以得出 d 的范围,从而 R_1 必须大于 127,即 R_1\ge 128

E 为偶数时,指数部分的右移对系数部分没有影响,所以系数部分正常运算,只需要考虑是否借位。

仍然先考虑简单的,若无需借位,则直接相减。

下面等式中,使用 \frac M2 来计算右移。因为右移舍弃的末位即使是 1,导致的误差在 IEEE 754-1985 法则中也只是 2^{-24},对于算法本身的误差来说可以忽略不计。

M'=R_2-\frac M2

那么我们将要得到的初始猜测 x_0 就会是:

\begin{aligned} x_0&=(1+M')\times 2^{E'-127}\\ &=(1+R_2-\frac M2)\times 2^{R_1-64-d-127}\\ &=(2+2R_2-M)\times 2^{R_1-192-d} \end{aligned}

如果需要借位,那么也会对指数部分产生一点影响(借走了 1)。这是系数就是

M'=1+R_2-\frac M2

初始猜测将会是:

\begin{aligned} x_0&=(1+M')\times2^{E'-1-127}\\ &=(2+R_2-\frac M2)\times 2^{R_1-192-d} \end{aligned}

对比一下两个式子,只有中间一点点不同。为了将它们写在一起,记

\beta_{M}^{R_2}=\begin{cases}2R_2-M&R_2\ge \frac M2\\R_2-M&R_2<\frac M2\end{cases}

x_0=(2+\beta_M^{R_2})\times 2^{R_1-192-d}

注意到 x_0 被表示成了一个关于 R_2 的分段函数(把 M 看作常量),而且函数图像连续,在每一段上可导,连接处不可导。说直接一点,就是因为 \beta_M^{R_2} 的图像是一条折线。

现在让我们来做误差分析:设真实值 a^{-0.5}=x,因为 a 可以表示成 (1+M)\times 2^e,那么对应的真实值 x 就应该是 \frac{1}{\sqrt{1+M}}\cdot2^{-0.5e},代入 e=2d+1 可以得到:

x = \frac{1}{\sqrt{1+M}}\cdot 2^{-d-\frac 12}=\frac{1}{\sqrt{2+2M}}\cdot2^{-d}

相对误差为 |\frac{x-x_0}{x}|,代入展开即:

\begin{aligned} &\left|\frac{\frac{1}{\sqrt{2+2M}}\cdot2^{-d} - (2+\beta_M^{R_2})\cdot2^{R_1-192-d}}{\frac{1}{\sqrt{2+2M}}\cdot 2^{-d}}\right|\\\\ =&|1-\sqrt{2+2M}(2+\beta_M^{R_2})2^{R_1-192}| \end{aligned}

记为 |\varepsilon_0(M,R_1,R_2)|。我们希望其最大值最小。若其最大值为 A_0,则必有 A_0 \ge |\varepsilon_0(0,R_1,R_2)|。如果想要找到 R 使得 A_0 < \frac 18,则必有:

\begin{aligned} \frac 18 &> A_0\\ &\geq|\varepsilon(0,R_1,R_2)|\\ &=\left|1-\sqrt2(2+2R_2)\cdot2^{R_1-192}\right|\\ &=|1-(1+R_2)\cdot2^{R_1-190.5}| \end{aligned}

拆绝对值,即

-\frac 18<1-(1+R_2)\cdot2^{R_1-190.5}<\frac18

注意到 1\le1+R_2<2,可以得到

\frac 7{16}<2^{R_1-190.5}<\frac 98

取对数,估算出 189.2<R_1<190.7。又因为 R_1 必须是整数,所以 R_1 = 190 就是唯一一个有机会把最大误差控制在 \frac 18 以内的数。

3.1.2 指数为偶数

这个时候,指数部分右移就会导致有一个 $1$ 右移到系数部分的最高位,对应地,就是给系数的实际值增加了 $2^{-1}=0.5$。这是后话。 让我们同样先从指数部分开始分析。 $$ \begin{aligned} E'&=R_1-\left\lfloor\frac E2\right\rfloor\\ &=R_1-\left\lfloor\frac {e+127}2\right\rfloor\\ &=R_1-d-63 \end{aligned} $$ 然后计算系数部分,当不需要借位时: $$ \begin{aligned} x_0&=\left[1+R_2-\left(\frac M2+\frac 12\right)\right]\cdot 2^{R_1-63-d-127}\\ &=(2+4R_2-2M)\cdot 2^{R_1-192-d} \end{aligned} $$ 当需要借位时: $$ \begin{aligned} x_0&=\left[1+1+R_2-\left(\frac M2 + \frac 12\right)\right]\cdot2^{R_1-63-d-1-127}\\ &=(3+2R_2-M)\cdot 2^{R_1-192-d} \end{aligned} $$ 同样,记 $$ \gamma_M^{R_2}=\begin{cases}4R_2-2M&R_2\ge\frac M2+\frac12\\1+2R_2-M&R_2<\frac M2+\frac 12\end{cases} $$ 有 $$ x_0=(2+\gamma_m^{R_2})\cdot 2^{R_1-192-d} $$ 同样地,误差分析函数: $$ \varepsilon_1(M,R_1,R_2)=1-\sqrt{1+M}(2+\gamma_M^{R_2})\cdot2^{R_1-192} $$ 相比于第一种情况少了一个 $\sqrt 2$。 同样设其最大值为 $A_1$,必有 $A_1\ge\varepsilon_1(1,R_1,R_2)$。虽说 $M\in[0,1)$ 不包含 $1$,但是函数连续且表达式在 $1$ 处有意义,为了不分类讨论就直接取 $M=1$ 了。 若最大误差小于 $\frac 18$,必有: $$ \begin{aligned} \frac 18 &> A_1\\ &\ge|\varepsilon_1(1,R_1,R_2)|\\ &=|1-\sqrt2(2+2R_2)\cdot 2^{R_1-192}| \end{aligned} $$ 非常眼熟,因为和上面的一模一样! 所以得出结论:$R_1=190$ 是唯一一个有机会把最大误差控制在 $\frac 18$ 以内的指数部分的取值。 ## 3.2 系数部分 现在有必要提醒您:接下来的内容涉及比较多暴力成分(基本没有技巧可言)。 既然 $R_1=190$,那么我们就可以重新定义误差函数了: $$ \varepsilon_0(M,R_2)=1-\frac{\sqrt{2+2M}}4\cdot(2+\beta_M^{R_2}) $$ $$ \varepsilon_1(M,R_2)=1-\frac{\sqrt{1+M}}4\cdot(2+\gamma_M^{R_2}) $$ 我们希望找到一个 $R_2$,使得 $\max\{\varepsilon_0(M,R_2),\varepsilon_1(M,R_2)\}$ 最小。 让我们先固定 $R_2$,然后枚举 $M$ 取什么值可能使得误差最大。 由于误差函数都是连续的、分段可导的,所以最值只会在段端点或极值点取到。 先看 $\epsilon_0$,最大值可能是下面的 5 种情况: 1. 端点 $M=0$。记此时最大值为 $f_1(R_2)=\varepsilon_0(0,R_2)=1-\dfrac{1+R_2}{\sqrt2}
  1. 端点 M=1。记此时最大值为 f_2(R_2)=\varepsilon_0(1,R_2)=\begin{cases}\frac{1-2R_2}4&R<\frac 12\\\frac{1-2R_2}2&R\ge\frac 12\end{cases}

  2. 端点 M=2R_2。记此时最大值为 f_3(R_2)=\varepsilon_0(2R_2,R_2)=1-\sqrt{\dfrac{1+2R_2}2}

  3. M\le 2R_2$ 时的极值点。此时 $M=\frac 23R_2$。记最大值为 $f_4(R_2)=\varepsilon_0(\frac 23R_2,R_2)=1-\dfrac{(1+\frac 23R_2)^{\frac 32}}{\sqrt 2}
  4. M > 2R_2$ 时的极值点。此时 $M=\frac 23(1+R_2)$。注意到 $M>2R_2$ 所以 $0\le R_2 < \frac 12$。记最大值为 $f_5(R_2) = \begin{cases}\varepsilon_0(\frac 23(1+R_2),R_2)=1-\dfrac{(5+2R_2)^{\frac 32}}{6\sqrt 6}&R_2 < \frac 12\\0&R_2\ge\frac 12\end{cases}

对于 \varepsilon_1,同样分为 5 种情况:

端点 3 种:

\begin{aligned} &f_6(R_2)=\varepsilon_1(0, R_2)=\begin{cases}\frac{1-2R_2}4&R_2<\frac 12\\\frac 12-R_2&R_2 \ge \frac 12\end{cases}\\ &f_7(R_2)=\varepsilon_1(1, R_2)=1-\dfrac{1+R_2}{\sqrt 2}\\ &f_8(R_2)=\begin{cases}0&R_2<\frac 12\\\varepsilon_1(2R_2-1, R_2)=1-\sqrt{2R_2}&R_2\ge \frac 12\end{cases} \end{aligned}

极值点 2 种:

于是乎合起来写:

f_9(R_2)=\begin{cases} \varepsilon_1(\frac{2R_2+1}3,R_2)=1-\sqrt2(\frac{2+R_2}3)^{\frac 32}&R_2 < \frac 12\\ \varepsilon_1(\frac{2R_2-1}3, R_2)=1-[\frac 23(1+R_2)]^{\frac 32}&R_2\ge \frac 12 \end{cases}

现在,最大值所有可能得情况都分析完了。如果 R_2 能使得 \max\{\varepsilon_0(M, R_2),\varepsilon_1(M, R_2)\} 最小,那么 R_2 也必定使得 \max_{i=1}^9f_i(R_2) 最小。

这个时候让我们把问题交给 matplotlib。

可以发现,在 0.4 附近的时候最大值最小。

让我们放大一下。

可以发现,是在 0.43275 附近 f_3(R_2)f_4(R_2) 的交点。

  1. 端点 M=2R_2。记此时最大值为 f_3(R_2)=\varepsilon_0(2R_2,R_2)=1-\sqrt{\dfrac{1+2R_2}2}

  2. M\le 2R_2$ 时的极值点。此时 $M=\frac 23R_2$。记最大值为 $f_4(R_2)=\varepsilon_0(\frac 23R_2,R_2)=1-\dfrac{(1+\frac 23R_2)^{\frac 32}}{\sqrt 2}

显然它们应该都是单调递减的。而黄色线却在上升,就是绝对值起了作用。去绝对值后应该有 f_3(R_2) + f_4(R_2) = 0。展开,即

(1+\frac23R_2)^{\frac 32}+(1+2R_2)^{\frac 12}=2^{\frac 32}

所以 R_2 就是方程 (1+\frac 23x)^{\frac 32}+(1+2x)^{\frac 12}-2^{\frac 32}=0[0,1] 内的根。

很好啊,三个根式,两次平方走不脱了。

左边函数是单调的,所以方程至多一个解。求解过程我们希望尽量精确,所以使用二分法(二分法可以使得近似解能用 IEEE 754-1985 法则表示)。为了减少误差我们希望方程中都是整数。

先直接平方

(1+\frac 23x)^3+(1+2x)+2(1+\frac23x)\sqrt{(1+\frac 23x)(1+2x)}=8

然后乘以 27 去分母,移项

(3+2x)^3+(27+54x)-216=-2(9+6x)\sqrt{12x^2+24x+9}

接着平方、展开、化简,最终得到

64x^6+576x^5+1296x^4-3456x^3-15552x^2-46656x+23328=0

(科技真好用)

然后对左边的式子轻轻在脑子里求导。导函数中,常数项 -46656,一次项系数 -31104,次数再高一点的对应系数都不过万,然后 x\in[0,1] 就导致它更小,所以整个导函数在 [0,1] 内都是负的,原函数单调递减。

所以就只有一个零点,取左端点为 0,右端点为 1,要求精度为 2^{-23},进行二分求解。

然而,如果在小数范围内求解仍然会面临精度问题,所以将等式两边同时乘以 (2^{23})^6,然后换元令 t=2^{23}x。只要将 t 保留到整数即可,左端点为 0,右端点为 2^{23} 对下面方程进行二分。

64t^6M^0+576t^5M^1+1296t^4M^2-3456t^3M^3-15552t^2M^4-46656tM^5+23328M^6=0

其中,M=2^{23} 是一个常数。

很明显计算的过程需要用到高精度,所以我选择 python

vec = [23328, -46656, -15552, -3456, 1296, 576, 64]
M = 2**23

def func(x):
    res = 0
    for i in range(len(vec)):
        res += (vec[i] * M ** (6-i)) * x ** i
    return res

l, r = 0, M

while l + 1 < r:
    mid = (l+r) // 2
    if func(mid) > 0:
        l = mid
    else:
        r = mid

print(hex(l), func(l))
print(hex(r), func(r))

输出结果是:

0x37642f 628423524624020249592480082211712497728
0x376430 -1927464207580585094875690763200495616000

0x37642f 的结果更接近 0。事实上,这两个数都可以,他们对整个方法的误差不会太大。

在 IEEE 754-1985 规定的表示方法中,保存系数 R_2 的二进制序列,如果把这个序列看做一个整数,它其实就是 R_2 对应的二进制小数乘上 2^{23}。因为他们都是二进制下的多项式,系数对应的指数都恰好相差了 23,所以这里就取 0x37642f 作为 R_2 的二进制序列。

然后前面提到 R_1 = 190,它被保存在符号位之后的 8 位。

所以使得初始猜测 i = R - (i >> 1); 误差最小的常数 R 在 IEEE 754-1985 法则下的二进制表示就是

0 10111110 01101110110010000101111

四位一分就是:

0101 1111 0011 0111 0110 0100 0010 1111

转成十六进制即为 0x5f37642f

你可能会发现:这个值怎么和开头代码给的值不太一样?实际上,这个值得到的初始猜测确实更加接近真实值,但是在都进行一次牛顿迭代后,0x5f3759df 反而能够做到更优。

可以说,0x5f37642f 在初始猜测上更贴近,但是牛顿迭代对它不怎么友好。所以 Chris Lomont 在 0x5f37642f 附近进行一通暴力搜索后,找到了一个最“牛迭友好”的常数 0x5f375a86

一般来说,这个方法都会进行两次牛顿迭代。因为双迭和单迭时间上相差极小,而精确度却提升了将近三个数量级。这是一笔亏小赚大的买卖。

(完)

4. 后记

翻译自开始时提到的论文。

这篇文章对代码中出现的神奇常数加以解释,并且尝试理解它以达到可能的优化。新常数 0x5f375a86 比原来的常数表现好一点点。然而这两者都是近似估计,所以两个常数都是可以的。如果可以的话,我希望找到原作者,询问一下原来的常数是算出来的还是枚举出来的。

这篇文章的功能是梳理这个方法的原理,为这个方法迁移到其他平台或其他数据类型提供理论基础。当然,任何理论都必须在实际中加以检验,因为硬件未必符合所有理论(有些平台可能不使用 IEEE 754-1985 法则)。