快速开平方根算法

人们很早就在Quake3源代码中发现了类似如下的C代码,它可以快速的求1/sqrt(x),在3D图形向量计算方面应用很广
float invSqrt(float x)
{
float xhalf = 0.5 * x;
int i = *(int*)&x; // get bits for floating value
i = 0x5f3759df - (i >> 1); // gives initial guess
x = *(float*)&i; // convert bits back to float
x = x * (1.5 - xhalf * x * x); // Newton step
return x;
}
在分析这段代码之前,先看看传统方法是怎么求一个数的平方根的倒数的,一般采用牛顿迭代法,为描述方便,假设输入数为a,显然需要满足a>0,sqrt是C语言的求平方根函数,为方便起见,下面用sqrt(x)的形式代替x^(1/2)
求1/sqrt(a),用迭代法,即求方程f(x)=x^(-2)-a在f(x)=0时的解,选择适当的初始值x0,代入迭代式:
x=x-f(x)/ f'(x)
化简此式得:
x=3x/2-ax^3/2
这实际上就是上面函数倒数第二行,从函数注释也可以直接看出,这一步就是牛顿迭代,一般选择一个合适的初始值开始迭代后,迭代次数越多越接近解,换句话说就是精度越高,误差越小,当误差小于可接受值,即可获得近似结果了,就这个问题而言,初始值的选择一般要在区间(0, sqrt(3/a)),证明从略
而invSqrt这个函数厉害的地方就在于,在正式迭代开始前的三行计算已经得到了一个非常接近于解的数,因此只需一次迭代,即可得到近似值,经测试,对于常用的浮点数范围,invSqrt(x)与标准解1/sqrt(x)的最大相对误差为1.75‰,平均相对误差为0.95‰,这个精度在很多时候已经满足QuakeIII的基本要求1‰了,而invSqrt(x)的速度则比直接计算1/sqrt(x)快4倍,这对于Quake和CS这类游戏的性能是非常重要的,而且如果需要更高的精度,则将迭代那一行再重复一次就可以了,相对误差会降到百万分之一的级别,只不过速度会慢一些,接下来我们来分析下这三行代码的原理
最令人迷惑的是gives initial guess这行,即对i做了移位和减法运算,不过熟悉C语言的人应该能看出来,这个算法和float浮点数的内部表示有关,分析应该从这里入手
正式开始前先轻松下,讲些历史故事,人们在QuakeIII源码发现了这个函数,于是很自然的认为这是卡马克(John Carmack)的杰作,其中0x5f3759df这个数被称为卡马克密码,我们在下面称这个数为magic,Beyond3D.com的Ryszard Sommefeldt一直在想到底是哪个家伙写了这些神奇的代码,于是就开始找作者,John Carmack在邮件回复中明确表示不是他,也不是Michael。Terje Mathisen说他写过类似的高效代码,但上面的不是。后来猜测这个来自于一些早期黑客的算法笔记,作者究竟是谁自然也难以追查了,可以肯定的是这个家伙对计算机和高数知识都有较好理解,很聪明
2003年普渡大学的数学家Chris Lomont写了一篇文章对这段代码进行了分析。论文是英文的,地址在:
http://wenku.baidu.com/view/80b84d1fb7360b4c2e3f644b.html
在这篇12页的论文中,Lomont对这个算法做了分析,并从推导出了一个理论上最优的magic 0x5f37642f,有意思的是,这个数居然没有invSqrt里的0x5f3759df效果好,最大相对误差达到1.78‰,Lomont一怒之下,用暴力搜索枚举了所有可能的magic,终于找到一个最优的magic 0x5f375a86,只比0x5f3759df效果好一点点,至于invSqrt的作者究竟如何找到0x5f3759df的,也就是个迷了
开始正式分析,这三行代码是把float在内存中的表示作为一个整数i看待,然后对i进行一次移位和减法,然后再将i的值作为一个float看待,所以我们先看看float在内存中的表示,一般计算机的浮点数遵循IEEE754标准,采用以2为底数的科学计数法,例如二进制的11010.11001记为1.101011001*10^100,float和int都是32位,占4个字节(注意,这个函数早期的代码中整数类型应该是long,因为那时候在dos下,int是16位的):
最高位d31:符号位,0表示非负,1表示非正,为什么不直接说正负呢,因为有数值0的存在,浮点数有+0和-0的区别,这个位用S表示
d30~d23:指数域,存放一个整数,表示127+E,E为指数,由于指数域的范围是0~255,因此理论上可以表示的指数范围是-127~128,不过0和255有特殊含义,所以范围实际要稍微小一点,这个先按下不表,我们认为常用浮点数不包括这两种极端情况
d22~d0:有效数字域,只是小数部分,由于科学计数法的规定,整数部分肯定是1,就省略了,这样可以避免不必要的精度浪费,为描述方便,这个域所表示的小数设为F。当然有人会问,那0怎么办,+0和-0有自身的特殊表示法,S位表示符号,其他位都为0的时候是+0或-0
于是除去0和IEEE754规定的特殊值,一个常用浮点数的表示可以看做:(-1)^S*(1+F)*2^E,具体到我们需要分析的问题,由于输入是正数,S位肯定是0,就不做考虑了,简化为:(1+F)*2^E
好,现在我们需要求(1+F)*2^E的平方根的倒数,即求1/sqrt((1+F)*2^E),求得的结果当然也要用这个浮点数表示法,有效数字必须在[1, 2),指数域为整数,则结果分两种情况推导出结果:
E为奇数:sqrt(2/(1+F))*2^(-(E+1)/2)
E为偶数:2/sqrt(1+F)*2^(-E/2-1)
先看指数,如果我们需要通过计算机的整数运算(移位、位运算和加减法等)来逼近解,首先要在数量级上尽量靠近,因为只要数量级一样,两个数的误差范围是最小的,也就是说,我们需要:
E为奇数时,将127+E变成127-(E+1)/2
E为偶数时,将127+E变成127-E/2-1
于是,通过右移一位来实现除以二,通过用一个数减去指数域来将E变成负的,这样invSqrt中的那句就很容易理解了:
E为奇数时,127+E为偶数,右移等于除以二,190-(127+E)/2 = 190-63-(E+1)/2 = 127-(E+1)/2
E为偶数时,127+E为奇数,右移等于先减一再除以二,189-(127+E-1)/2 = 189-63-E/2 = 127-E/2-1
然后我们把invSqrt中的magic 0x5f3759df用float的形式展开,则其指数域为0x5f<<1,也就是190,那当E在偶数时怎么生成189呢,注意到当E为偶数时,i>>1这个操作将指数域最后一位的1(即上面说的“先减一”)也向右移了一位,i右移后d22位为1,这样一来只要被减数的d22这一位是0,就会因为不够减而产生借位,指数域被借了1,自然就变成189了
细心的童鞋应该发现了,当E为偶数时,减法做完后,指数域一定是127-E/2-1,但是如果E为奇数,则不保证指数域是127-(E+1)/2,因为这时候被减数和减数的d22位都是0,但如果减数的d21到d0这个域的数字比被减数的大,就会产生借位而使得指数域比预期的127-(E+1)/2要小1,这个问题会导致一定的误差,但是在最后的迭代中误差会被缩小,这个误差具体有多大,这里就不详细讨论了,有兴趣的童鞋可以自己算算看
我们还是先证明用这种方法得到的x0是落在上述区间(0, sqrt(3/a))的,这里a就是(1+F)*2^E
由于输入是一个非负数,则S为0,指数域肯定不为0(右移后不可能刚好为190),因此x0肯定大于0,我们将sqrt(3/a)展开成期望的解的指数的乘法形式:
E为奇数时,展开为sqrt(6/(1+F))*2^(-(E+1)/2),如果按上面说的那种情况产生借位,则展开为sqrt(24/(1+F))*2^(-(E+3)/2)
E为偶数时,展开为sqrt(12/(1+F))*2^(-E/2-1)
可以看到,无论是哪种情况,在指数相同的情况下,有效数字都大于2,反过来说,用上述算法得到的x0是小于sqrt(3/a)的,而且还小了很多,非常接近解,这时候只需要一次迭代,就得到了误差很小的近似结果
到这里,基本原理都清楚了,只要我们保证x0在这个区间中,再做迭代总是能进一步接近解的,现在的问题就在于magic中d21到d0这个域的值应该怎么取了,这个取值关系到每次迭代的误差,比如说,我们全取1,这样也避免了E为奇数时的借位情况,这样magic就是0x5f3fffff,用这个magic测试,结果最大误差超过1%,平均误差超过6‰,显然效果太差了
我们假设将magic作为float看时,小数部分的值是M,由于d22位已确定是0,则0<=M<0.5,在magic-(i>>1)的减法运算中,d22到d0域的运算可看做是定点小数减法,分三种情况讨论:
E为奇数时,减数的d22位为0,则小数部分的值为F/2,因为右移对小数来说也是除以二,d0位如果是1则会舍弃,这个因为太小而忽略,假设M>=F/2,则不需要向指数域借位,计算结果的小数部分为M-F/2,指数域符合结果预期,此时的相对误差为:rd1(M,F)=|1-(1+M-F/2)/sqrt(2/(1+F))|,0<=F<=2M
假设M
E为偶数时,减数的d22位为1,则小数部分的值为F/2+1/2,此时必定借位,相对误差为:rd3(M,F)=|1-(2+M-F/2-1/2)/2/sqrt(1+F)|,0<=F<1
然后,我们构造一个M的函数,映射M和此M下最大相对误差:maxrd(M),这个函数的算法是对每个M,分别求rd1、rd2和rd3三个函数的最大值,并取其中最大的值。于是,我们的最后问题就是找到一个M,使maxrd(M)最小,就得到最优magic了
Chris Lomont用的推导方法类似,当然更严密些,推导到了这一步后,他的选择是继续用数学方法来求极值,这大概是因为他是个数学家的缘故。不过悲剧的是他最后推出来的结果不是最优,计算机科学是一门应用科学,作为工科生,到这一步后我就准备选择穷举了,再往后的数学我就不懂了,毕竟哥的微积分才刚及格
所以,让我们抛开那些高深的数学来看这个问题,通过前面的推导,我们基本可以将magic的范围确定在[0x5f000000, 0x5f3fffff],只要在这个范围内找到一个magic,使其对应的invSqrt函数对于所有常用浮点数的计算结果的最大相对误差最小,就可以了,用上面的话讲,就是maxrd(magic)的最小值,由于magic取值是离散有限的,而所有常用浮点数个数也是有限的,所以至少理论上讲是可通过穷举计算,不过可惜两者的个数都太大,全部算一遍不切实际
在magic的取值范围内均匀地取一些数,用统计的办法遍历所有常用浮点数计算maxrd(magic),可以看到曲线是一个波谷的样子,即前半段单调递减,后半段单调递增,于是我们近似地认为maxrd(magic)的曲线就是个波谷,然后可以迭代求极值:
1 定义start和end两个变量,表示magic取值范围[start, end],自然一开始为[0x5f000000, 0x5f3fffff]
2 将范围平均分成32段(由于整数除法有误差,最后一段稍长),则有magic0~magic32,计算33个magic值对应的maxrd(magic),找到其中的最小值对应的magicN
3 将magic(N-1)和magic(N+1)作为新的start和end,如果范围比较大则重复第2步,范围比较小则直接遍历范围中的每个magic得到结果
经过若干次迭代,我们得到的最优magic为0x5f375a85,刚好比Chris Lomont的最优结果小1
当然了,这个方法虽然简单但是不够严密,我们没有证明maxrd(magic)的曲线是先单调递减后单调递增,也没有严格证明magic的取值在[0x5f000000, 0x5f3fffff]之外的话误差会更大,但我们毕竟是得到结果了,解决了应用问题
然后我们讨论下特殊的浮点数,输入一个0是无意义的,因为0不能做除数,但是invSqrt对输入的0会返回一个比较大的结果,IEEE754规定的NAN值和正负无穷在实际中都不太可能出现,需要注意的是当输入数非常小,例如指数域为0的时候,E为-127,这时候由于值很小,相对误差会达到很多倍,在实际应用中要尽量避免极小数字的计算
invSqrt(a)返回1/sqrt(a),如果我们需要快速地近似计算sqrt(a),可能很多人会自然而然想到1/invSqrt(a),实际上,invSqrt(a)*a更快,也更准确,因为在计算机中除法比乘法要慢很多,这也是invSqrt(a)能比直接计算1/sqrt(a)快那么多的原因,invSqrt中没有除法运算,而sqrt的迭代不能避免除法,取倒数又得做一次除法(其实除法和取倒数也可以用迭代法来规避除法,但这又得有额外开销了)
用上面的办法可以推导出取倒数的迭代函数:
float myInv(float x)
{
float a = x;
int i = *(int *)&x;
i = 0x7ef311c3 - i;
x = *(float*)&i;
x = x * (2 - a * x);
return x;
}
但是这个效果比较令人失望,不但精度差,而且速度比直接算1/a慢,基本没用……
虽然可以通过invSqrt(a)*a计算a的平方根,但是我们可以推导出一个更直接的函数:
float mySqrt(float x)
{
float a = x;
unsigned int i = *(unsigned int *)&x;
i = (i + 0x3f76cf62) >> 1;
x = *(float *)&i;
x = (x + a / x) * 0.5;
return x;
}
推导过程从略,有兴趣的童鞋可以自行研究,注意这里是先加再右移,所以用unsigned int防止负数右移,这个mySqrt计算平方根速度比invSqrt(a)*a快,而且最大误差只有0.6‰,那么,有没有更快的算法呢,如果单纯用计算,可能很难超越mySqrt了,想要更快,得从另外的方向想办法
比较直接的想法是,造一个巨大的table,储存a到sqrt(a)的映射关系,这样不需要计算,只要查表就行,而且由于指数域可以通过整数加减得到,只用做有效数字部分的映射即可,内存消耗约为33M,这似乎是个好办法,而且当从小到大顺序生成常用浮点数是,其速度比mySqrt稍慢一点点
但是,如果我们随机生成常用浮点数,这个算法的耗时就惨不忍睹了,几乎是顺序生成浮点数时耗时的7倍,在实际应用中,每次计算输入的数都是随机的,这说明这个查表的办法没有实用价值,因为如果我们单纯的计算,浮点数运算是在FPU协处理器中进行的,使用x87浮点数运算指令,整数运算则是在CPU中,存储使用寄存器,非常快,而CPU访问内存的速度是要慢很多的,那为什么顺序生成浮点数的时候速度没那么慢呢,因为这时候CPU的一二三级cache发挥了巨大的作用,而随机生成的时候内存也要随机访问,则cache miss很高,速度自然慢了很多了
所以说,查表法不可行的原因在于占用内存太大,我们要想办法将内存降下来,考虑函数sqrt(x),这是一条连续曲线,当然对于float来讲,其实是离散的一个个点,数量还是有限的,查表法是每个点的值都记录,误差为0,那么我们可以考虑用牺牲精度的办法,将这条曲线近似成一条条线段组成的折线,然后在内存中记录每条线段的斜率,于是我们将[1, 2)这个区间分散成若干段,事先计算每段的平均斜率,计算sqrt(a)的时候,先通过整数运算确定指数域的值,然后查表得到对应点的斜率,做一次浮点乘法即可得到近似值,为了保证运算速度,分段的段数需要保证用一次移位运算即可得到,避免整数除法(这个比浮点数除法更费时间):
float fastSqrt(float x)
{
float a = x;
unsigned int i = *(unsigned int *)&x;
i = (i + 0x3f800000) >> 1;
x = *(float *)&i;
x *= g_sqrt_adj_table[(i & 0x7fffff) >> SHR_CNT];
return x;
}
其中SHR_CNT是有效数字域右移的位数,g_sqrt_adj_table储存平均斜率,当SHR_CNT为16的时候,g_sqrt_adj_table存放了128个整数,占内存512字节,已经足以放在CPU一级cache里了(我的测试机器CPU一级cache是双路64k,一般个人PC最少也有8k左右),而且此时fastSqrt的精度也还可以接受,SHR_CNT越小,占内存越多,精度越高,不过,貌似比mySqrt还是快不了太多=,=

你可能感兴趣的:(快速开平方根算法)