Why does fast inverse square root work?
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
// y = y * ( threehalfs - ( x2 * y * y ) ); // 2nd iteration, this can be removed
return y;
}
So what the fuck?
我们假设读者已经熟悉数值分析、IEEE 754 浮点数表示,以及基本的 C 语言知识。
Newton 迭代法
首先看到代码注释里面出现了两次 iteration,我们容易想到 Newton 迭代法,其具体内容如下:
假如我们要近似求解
解
当然这里有两个重要的问题:
- 对于初始选取的近似解
有讲究吗? - 收敛速度有多快?
对于第一个问题,答案是 yes。简单的例子形如在
所以我们有一个直觉:如果我们能取到一个离真正的解比较接近的初始值
对于第二个问题,答案是二次收敛。为了建立相关方法论的直觉,这里我们不考虑一般情况的证明,而是以
我们知道这个方程的正根是
首先我们证明在
令误差
因此我们求得
初始值近似
然后我们回到 1st iteration 的前五行,根据上一节提到的 Newton 的局限性,我们可以合理猜想 i = 0x5f3759df - ( i >> 1 ); 这一行其实也对结果做了一个粗略但足够“讲究”的初始值近似——我们可以先写一段脚本验证几个值。
我们定义相对误差
则对于一些常用的输入
| 输入 |
运算后整数 |
得到的 |
相对误差 | |
|---|---|---|---|---|
0x4970fb79 |
987063.6 | 1000000 | −1.294% | |
0x44743e01 |
976.9688 | 1000 | −2.303% | |
| 0.001 | 0x41f5d0a8 |
30.72688 | 31.62278 | −2.833% |
| 0.01 | 0x41256e5a |
10.33944 | 10 | +3.394% |
| 0.1 | 0x4050f379 |
3.264860 | 3.162278 | +3.244% |
| 0.5 | 0x3fb759df |
1.432430 | 1.414214 | +1.288% |
| 1 | 0x3f7759df |
0.966215 | 1 | −3.378% |
| 2 | 0x3f3759df |
0.716215 | 0.707107 | +1.288% |
| 3 | 0x3f1759df |
0.591215 | 0.577350 | +2.401% |
| 4 | 0x3ef759df |
0.483108 | 0.5 | −3.378% |
| 10 | 0x3ea759df |
0.326858 | 0.316228 | +3.361% |
| 100 | 0x3dd359df |
0.103199 | 0.1 | +3.199% |
| 1000 | 0x3cfa59df |
0.030560 | 0.031623 | −3.359% |
0x3a7d47df |
0.000966189 | 0.001 | −3.381% | |
0x3582ef8d |
−2.445% | |||
0x2692607a |
+1.569% |
可以发现结果虽然不够准确,但对于
那么紧接着下一个问题就是,为什么这段位运算代码能够用来近似
我们先考虑一个简单的 case:
而实际上,
进一步化简后,我们发现式子可以统一为 E'=(E+127)>>1,而 127>>1 等于 63,而 0x5f3759df 对应的浮点数是
现在我们考虑要求的式子本身。同样,
也就是:
而 0x5f3759df 的指数位!而 - ( i >> 1 ) 那一步。因为整个 IEEE 754 表示右移一位,指数位的确会变成原来的一半(如果是奇数就再去掉 0.5,但实际上也跑到 mantissa 的最高位了,所以影响不大)。
所以我们可以得出结论,0x5f3759df 这个 magic number 真正重要的是指数位的值,mantissa 最多会影响初始值的精度,进而通过 Newton 迭代影响最终取值的精度,对算法本身的正确性影响不大。
我们会在下下节有更好的尾数吗,讨论尾数如何影响精确程度的问题。
再看 Newton 迭代
接下来到了一个整篇文章最头痛的时刻——我们知道 Newton 迭代的公式是 y = y * ( threehalfs - ( x2 * y * y ) );,长得有点像,但一眼也没法直接套上去。
问题的瓶颈就出在,我们知道根是
整理后得到
这里
对于形如
的微分方程,只要在
的区间上,两边除以 : 注意:
因此直接积分:
这就是一阶可分离变量微分方程最基础的求解方法。
你的方程正好属于这一类,并不需要学习高阶 ODE、常微分方程组或数值 ODE。
那么学习完之后,代入
对于分式积分,我还依稀记得需要拆开,先换成比较熟悉的符号:
通分后也就是
把符号换回来,就是:
验证一下 y = y * ( threehalfs - ( x2 * y * y ) ); 这一行代码的确就是标准的 Newton 迭代步骤。
有更好的尾数吗?
假如我们的浮点数都是正规的,现在回到之前忽略的尾数问题。假如我们不考虑尾数的 mess,把代码改成 i = 0x5f000000 - (i >> 1);,对于一些较为极端的例子,误差结果如下:
| 输入 |
近似值 |
真值 |
相对误差 |
|---|---|---|---|
| 0.5 | 1 | 1.414214 | −29.289% |
| 1 | 0.75 | 1 | −25.000% |
| 2 | 0.5 | 0.707107 | −29.289% |
| 3 | 0.4375 | 0.577350 | −24.223% |
| 4 | 0.375 | 0.5 | −25.000% |
并且可以证明,在保持 magic number 为 0x5f000000 的情况,对于任何输入
如果连尾数都可以自由选取(从 0x5f000000 到 0x5f7fffff),那么全局最大误差出现在 1.33333337,算出来的最大误差 1.33333337):
与前文约
为了选取最好的 magic number,我们可以设计这样的筛选策略:
- 直觉告诉我,尾数
的轻微变化应该不会让误差 turbulence,因此我们可以先分段选取样本,步长比如说为 0x1000,从 0x5f000000到0x5f7ff000。 - 对于输入
,随机抽 10000 个 binary32 范围内的正数并计算误差;之后的 0x1000 步长内部的遍历环节,再随机抽 10000 个 binary32 范围内的正数并固定,让这 4096 个 magic number 计算各自误差,然后选出误差最低的那几个。
然后这里就引来了一个重要问题——如何定义“最好”,或者说“误差最小”?作为写过论文的人,我当即想出了几种 metric:
- geomean 最小:用于衡量整体的误差程度,只不过误差为 0 的时候需要使用极小
替代; - average 最小:考虑 outlier 给数据带来的影响;
- minimax(最极端的误差)最小:极端 outlier 的 weight 最重。
我这里选择的第三种 metric,因为这段代码是出自 FPS 游戏 Quake III Arena。作为游戏玩家的哲学,就拿 FPS 来说,肯定是希望“平均略坏”大于“突然卡住”,所以我们要尽可能优化最坏的情况。
选择适合的优化目标后,我们开始第一步的粗筛,并尝试分析 magic number 与 minimax 值的函数关系(前文我猜测应该没有 turbulence)。为了方便复现,这里的随机数种子是 0xC0FFEE,绘图如下(完整实验代码见附录 A:Minimax 粗筛绘图程序):
可以发现,我的直觉是正确的,图像确实形成了一个 V 形结构。进行 0 次 Newton 迭代(蓝色)和 1 次迭代(黄色),均在 0x5f376000 处分别取到了 3.427736% 和 0.176516% 的极值。
接下来是确定精筛的范围,我们可以有对 V 形函数有以下直觉:如果 [0x5f375000, 0x5f377fff] 这个区间就应该足够(代码见附录 B:Minimax 精筛绘图程序)。
然后,有趣的事情发生了:我们发现 0 次迭代的最优值和 1 次迭代的最优值并不一致!前者是 0x5f37642e,后者是 0x5f375a71 和 0x5f375a73 并列第一。两者的函数图象如下:
这就意味着一个非常神奇的现象——牛顿迭代法并不保证对于两个初始误差
当然,另外一个问题是,为什么这两个结果都和原始 magic number 0x5f3759df 虽然很接近,但又不完全一致呢?原因其实是比较 mundane 的:样本量不够,导致选取的样本集合有某种统计意义不可避免的 bias(感性理解就是你投 1000 次币,不太可能刚好正面 500 次反面 500 次,总得偏向某一边几次十几次一样),导致精筛的时候出现针对该样本的“局部最优”。
解决办法也很暴力:覆盖所有不同的相对误差模式。我这里选取的是
| Magic number | 0 次 Newton | 1 次 Newton |
|---|---|---|
Quake III 0x5f3759df |
3.437577282% | 0.175233867% |
0x5f37642e(为 0 次优化) |
3.421289021% | 0.177588172% |
Lomont 0x5f37642f(同上) |
3.421283763% | 0.177588946% |
0x5f375a87(为 1 次优化) |
3.436540281% | 0.175128778% |
Lomont 0x5f375a86(同上) |
3.436546454% | 0.175130156% |
我们可以发现以下几点(pp = percentage point,百分点):
- 我和 Lomont 选择的 0 次 Newton 迭代的 magic number,比 Quake III
0x5f3759df这个 baseline 小 0.016 pp,但在 1 次迭代后比它误差大 0.002 pp。 - 我和 Lomont 选择的 1 次 Newton 迭代的 magic number,对比 baseline,实际上无论是 0 次迭代还是 1 次,误差都小 0.001 pp 和 0.0001 pp。
- 至于我和 Lomont 的比较,则是 0 次 Newton 迭代常数
0x5f37642f略优于我的0x5f37642e;而 1 次 Newton 迭代常数,我的0x5f375a87又略优于0x5f375a86——两边都只是 1 bit 的差距!考虑到浮点数舍入本身也有量级的误差,基本上都可以算是不分伯仲的“最优”了。
所以回到这一节的标题,答案是:至少从我们假想的 Quake III 性能优化哲学来看,yes。
64 位的情况又如何?
我们概括一下之前 32 位的分析,我们主要探索了以下几个问题:
- 什么是 Newton 迭代?
- 这段代码前面的 shift 部分的大概直觉,确认指数部分的取值。
- 理解最后那一行确实在做 Newton 迭代。
- 筛选尾数部分,找到在 minimax 意义下的最优解。
而 Newton 迭代公式本身并不会因为从 binary32 变成 binary64 就有所改变,因此问题 1 和 3 无需重新探索,只用看 2 和 4 即可。
对于问题 2,solution 是 immediate 的,因为公式
仍然成立,只不过 bias 从 127 变成了 1023,经过计算后得知指数位为 1534。
对于问题 4,此时 magic number 的搜索空间从原来的
当时那段回忆录我写:
“回到2题,我抛弃了数学方法并尝试三分,仍然因为精度问题爆零,我气愤地把所有的变量全部改为double,事情便有了进展,获得了8分的好成绩。”
这里那道题的满分是 10 分。当年我用 double 来优化当时的三分算法,现在我又用三分来优化 FISR 在 double 情况下的 magic number,挺美妙的。
回到正题,三分的思路本身很简单,对于有极小值的单峰函数
- 若
,则更新 ; - 若
,则更新 ; - 为了 robustness,当
小于某个设定的距离 后,就可以恢复穷举了。
对于本文的 case,0xC0FFEE,计算 10000 个随机正规 binary64 值的 minimax。选取的
| 优化目标 | 论文结果 | 我们的随机搜索结果 | ||
|---|---|---|---|---|
| 0 次 Newton | 0x5FE6EC85E7DE30DA |
0x5FE6EC853A893173 |
3.421% | |
| 1 次 Newton | 0x5FE6EB50C7B537A9 |
0x5FE6EB50BC36EFCB |
0.175% |
这里的对照组是论文 Improving the Accuracy of the Fast Inverse Square Root by Modifying Newton–Raphson Corrections。对照组跟 Lomont 的论文一样,采用了详细的不等式推导进行了极小值的求解。观察如下:
- 虽然我们选出的 magic number 与论文并非完全相同,但在 binary32 尺度上也就是 1 bit 以内的差距。
- 两种 magic number 算出来的结果,与真实值的
的差距也在 量级以内。
当然,为了尽量规避先前提到的 selection bias,10000 个随机正规 binary64 值肯定不够,样本量仍然需要扩大。但遍历
内的等距 输入。 内的随机 输入。 - 直接将先前 binary32 测的
转换到 binary64 作为输入。
具体实现见附录 E,最终结果如下:
| Newton | 方法 | Magic number | |||
|---|---|---|---|---|---|
| 0 次 | 论文 | 0x5fe6ec85e7de30da |
— | 3.421281331784% | — |
| 等距 | 0x5fe6ec85e7c18a4c |
3.421281298320% | |||
| 随机 | 0x5fe6ec85e7b577a0 |
3.421281284218% | |||
| float32 网格 | 0x5fe6ec85e7a5ce2a |
3.421281265925% | |||
| 1 次 | 论文 | 0x5fe6eb50c7b537a9 |
— | 0.175118367122% | — |
| 等距 | 0x5fe6eb50c7b33619 |
0.175118366879% | |||
| 随机 | 0x5fe6eb50c736bce6 |
0.175118351810% | |||
| float32 网格 | 0x5fe6eb50c77c4635 |
0.175118360228% |
其中:
另一方面,为了检验方案是否能够避免过拟合,我同时也做了 cross-evaluation:把每种采样方案选出的冠军,分别放到另外两种输入集合上测试,并同时对比论文的 baseline,结果如下图所示:
因此我们得到的最终结论是:
- 对于做 0 次 Newton 迭代,我们的筛选结果稳定优于论文的 magic number。
- 对于做 1 次 Newton 迭代,至少在等距、随机与 binary32 网格这几种 case 中,等距样本筛选出来的 magic number 稳定优于论文的 magic number。
做 1 次 Newton 迭代出现的 discrepancies,解释也比较容易:
- 等距的话就相当于完全公平的抛硬币正 500 反 500 情形,没有 bias;
- random 就是最不均匀的,容易出现过拟合,并在等距 case 中出现明显退化;
- binary32 网格由于 IEEE 754 表示本身就不 even,但也远好于随机数选择的 bias,所以效果介于两者之间。
参考代码
运行需要依赖 numpy 和 matplotlib:
python3 -m pip install numpy matplotlib
建议使用 Linux 环境,精度高达 long double 可能与 double 无异,从而影响最后几位精度。
附录 A:Minimax 粗筛绘图
运行命令:
python appendix_a_coarse.py
无命令行参数;脚本固定使用 10,000 个随机正规 binary32 输入、种子 0xC0FFEE 和步长 0x1000。输出图片为脚本同目录下的 appendix_a_coarse.png。
附录 B:Minimax 精筛绘图
运行命令:
python appendix_b_fine.py --output-dir .
--output-dir . 指定在当前目录保存 CSV 和图片;包括 fisr_minimax_fine_0_newton.png、fisr_minimax_fine_1_newton.png 和 fisr_minimax_fine_comparison.png。
附录 C:区间 [1,4) 最优求解
运行命令:
python appendix_c_verify.py --chunk-size 262144
--chunk-size 262144 指定每批验证的输入数量;完整遍历 fisr_full_range_results_python.csv。
附录 D:三分搜索
运行命令:
python appendix_d_ternary.py --samples 10000 --seed 0xC0FFEE --bracket-width 512
--samples 10000 指定随机样本数,--seed 0xC0FFEE 固定随机种子,--bracket-width 512 指定三分搜索停止后穷举的区间宽度。结果默认保存为 fisr_binary64_ternary_results.csv。
附录 E:binary64 最终实验
运行命令:
python appendix_e_final.py --stage all --out-dir .
--stage all 执行三种采样方案的优化、交叉验证和绘图,--out-dir . 将 CSV 和图片保存到当前目录。每种数据集包含 appendix_e_cross_0_newton.png 和 appendix_e_cross_1_newton.png。