高效率嵌入式系统开平方根讲解.docx
《高效率嵌入式系统开平方根讲解.docx》由会员分享,可在线阅读,更多相关《高效率嵌入式系统开平方根讲解.docx(14页珍藏版)》请在冰豆网上搜索。
![高效率嵌入式系统开平方根讲解.docx](https://file1.bdocx.com/fileroot1/2023-6/22/a8035e36-03b2-4d70-a5a1-eae043b55c57/a8035e36-03b2-4d70-a5a1-eae043b55c571.gif)
高效率嵌入式系统开平方根讲解
开平方根
1.开平方根
我们平时经常会有一些数据运算的操作,需要调用sqrt,exp,abs等函数,那么时候你有没有想过:
这个些函数系统是如何实现的?
就拿最常用的sqrt函数来说吧,系统怎么来实现这个经常调用的函数呢?
虽然有可能你平时没有想过这个问题,不过正所谓是“临阵磨枪,不快也光”,你“眉头一皱,计上心来”,这个不是太简单了嘛,用二分的方法,在一个区间中,每次拿中间数的平方来试验,如果大了,就再试左区间的中间数;如果小了,就再拿右区间的中间数来试。
比如求sqrt(16)的结果,你先试(0+16)/2=8,8*8=64,64比16大,然后就向左移,试(0+8)/2=4,4*4=16刚好,你得到了正确的结果sqrt(16)=4。
然后你三下五除二就把程序写出来了:
//用二分法
floatSqrtByBisection(floatn)
{
//小于0的按照你需要的处理
if(n<0)
returnn;
floatmid,last;
floatlow,up;
low=0,up=n;
mid=(low+up)/2;
do
{
if(mid*mid>n)
up=mid;
else
low=mid;
last=mid;
mid=(up+low)/2;
}
//精度控制
while(abs(mid-last)>eps);
returnmid;
}
然后看看和系统函数性能和精度的差别(其中时间单位不是秒也不是毫秒,而是CPUTick,不管单位是什么,统一了就有可比性)。
二分法和系统的方法结果上完全相同,但是性能上整整差了几百倍。
为什么会有这么大的区别呢?
难道系统有什么更好的办法?
难道。
。
。
。
哦,对了,回忆下我们曾经的高数课,曾经老师教过我们“牛顿迭代法快速寻找平方根”,或者这种方法可以帮助我们,具体步骤如下。
求出根号a的近似值:
首先随便猜一个近似值x,然后不断令x等于x和a/x的平均数,迭代个六七次后x的值就已经相当精确了。
例如,我想求根号2等于多少。
假如我猜测的结果为4,虽然错的离谱,但你可以看到使用牛顿迭代法后这个值很快就趋近于根号2了:
(4+2/4)/2=2.25
(2.25+2/2.25)/2=1.56944..
(1.56944..+2/1.56944..)/2=1.42189..
(1.42189..+2/1.42189..)/2=1.41423..
....
这种算法的原理很简单,我们仅仅是不断用(x,f(x))的切线来逼近方程x^2-a=0的根。
根号a实际上就是x^2-a=0的一个正实根,这个函数的导数是2x。
也就是说,函数上任一点(x,f(x))处的切线斜率是2x。
那么,x-f(x)/(2x)就是一个比x更接近的近似值。
代入f(x)=x^2-a得到x-(x^2-a)/(2x),也就是(x+a/x)/2。
相关的代码如下:
floatSqrtByNewton(floatx)
{
//最终
floatval=x;
//保存上一个计算的值
floatlast;
do
{
last=val;
val=(val+x/val)/2;
}
while(abs(val-last)>eps);
returnval;
}
牛顿迭代法性能提高了很多,可是和系统函数相比,还是有这么大差距,这是为什么呀?
想啊想啊,想了很久仍然百思不得其解。
突然有一天,我在网上看到一个神奇的方法,于是就有了今天的这篇文章,废话不多说,看代码先:
floatInvSqrt(floatx)
{
floatxhalf=0.5f*x;
inti=*(int*)&x;//getbitsforfloatingVALUE
i=0x5f375a86-(i>>1);//givesinitialguessy0
x=*(float*)&i;//convertbitsBACKtofloat
x=x*(1.5f-xhalf*x*x);//Newtonstep,repeatingincreasesaccuracy
x=x*(1.5f-xhalf*x*x);//Newtonstep,repeatingincreasesaccuracy
x=x*(1.5f-xhalf*x*x);//Newtonstep,repeatingincreasesaccuracy
return1/x;
}
这次真的是质变了,结果竟然比系统的还要好。
到现在你是不是还不明白那个“鬼函数”,到底为什么速度那么快吗?
不急,先看看下面的故事吧:
Quake-IIIArena(雷神之锤3)是90年代的经典游戏之一。
该系列的游戏不但画面和内容不错,而且即使计算机配置低,也能极其流畅地运行。
这要归功于它3D引擎的开发者约翰-卡马克(JohnCarmack)。
事实上早在90年代初DOS时代,只要能在PC上搞个小动画都能让人惊叹一番的时候,JohnCarmack就推出了石破天惊的CastleWolfstein,然后再接再励,doom,doomII,Quake...每次都把3-D技术推到极致。
他的3D引擎代码资极度高效,几乎是在压榨PC机的每条运算指令。
当初MS的Direct3D也得听取他的意见,修改了不少API。
最近,QUAKE的开发商IDSOFTWARE遵守GPL协议,公开了QUAKE-III的原代码,让世人有幸目睹Carmack传奇的3D引擎的原码。
这是QUAKE-III原代码的下载地址:
必然是精心编写的。
里面有很多有趣的函数,很多都令人惊奇,估计我们几年时间都学不完。
在game/code/q_math.c里发现了这样一段代码。
它的作用是将一个数开平方并取倒,经测试这段代码比(float)(1.0/sqrt(x))快4倍:
floatQ_rsqrt(floatnumber)
{
longi;
floatx2,y;
constfloatthreehalfs=1.5F;
x2=number*0.5F;
y=number;
i=*(long*)&y;//evilfloatingpointbitlevelhacking
i=0x5f3759df-(i>>1);//whatthefuck?
y=*(float*)&i;
y=y*(threehalfs-(x2*y*y));//1stiteration
//y=y*(threehalfs-(x2*y*y));//2nditeration,thiscanberemoved
#ifndefQ3_VM
#ifdef__linux__
assert(!
isnan(y));//bk010122-FPE?
#endif
#endif
returny;
}
函数返回1/sqrt(x),这个函数在图像处理中比sqrt(x)更有用。
注意到这个函数只用了一次叠代!
(其实就是根本没用叠代,直接运算)。
编译,实验,这个函数不仅工作的很好,而且比标准的sqrt()函数快4倍!
要知道,编译器自带的函数,可是经过严格仔细的汇编优化的啊!
这个简洁的函数,最核心,也是最让人费解的,就是标注了“whatthefuck?
”的一句:
i=0x5f3759df-(i>>1);
再加上y=y*(threehalfs-(x2*y*y));
两句话就完成了开方运算!
而且注意到,核心那句是定点移位运算,速度极快!
特别在很多没有乘法指令的RISC结构CPU上,这样做是极其高效的。
算法的原理其实不复杂,就是牛顿迭代法,用x-f(x)/f'(x)来不断的逼近f(x)=a的根。
没错,一般的求平方根都是这么循环迭代算的但是卡马克(quake3作者)真正牛B的地方是他选择了一个神秘的常数0x5f3759df来计算那个猜测值,就是我们加注释的那一行,那一行算出的值非常接近1/sqrt(n),这样我们只需要2次牛顿迭代就可以达到我们所需要的精度。
好吧如果这个还不算NB,接着看:
普渡大学的数学家ChrisLomont看了以后觉得有趣,决定要研究一下卡马克弄出来的这个猜测值有什么奥秘。
Lomont也是个牛人,在精心研究之后从理论上也推导出一个最佳猜测值,和卡马克的数字非常接近,0x5f37642f。
卡马克真牛,他是外星人吗?
传奇并没有在这里结束。
Lomont计算出结果以后非常满意,于是拿自己计算出的起始值和卡马克的神秘数字做比赛,看看谁的数字能够更快更精确的求得平方根。
结果是卡马克赢了...谁也不知道卡马克是怎么找到这个数字的。
最后Lomont怒了,采用暴力方法一个数字一个数字试过来,终于找到一个比卡马克数字要好上那么一丁点的数字,虽然实际上这两个数字所产生的结果非常近似,这个暴力得出的数字是0x5f375a86。
Lomont为此写下一篇论文,"FastInverseSquareRoot"。
论文下载地址:
http:
//www.math.purdue.edu/~clomont/Math/Papers/2003/InvSqrt.pdf,
最后,给出最精简的1/sqrt()函数:
floatInvSqrt(floatx)
{
floatxhalf=0.5f*x;
inti=*(int*)&x;//getbitsforfloatingVALUE
i=0x5f375a86-(i>>1);//givesinitialguessy0
x=*(float*)&i;//convertbitsBACKtofloat
x=x*(1.5f-xhalf*x*x);//Newtonstep,repeatingincreasesaccuracy
returnx;
}
大家可以尝试在PC机、51、AVR、430、ARM、上面编译并实验,惊讶一下它的工作效率。
前两天有一则新闻,大意是说RyszardSommefeldt很久以前看到这么样的一段code(可能出自QuakeIII的sourcecode):
floatInvSqrt(floatx)
{
floatxhalf=0.5f*x;
inti=*(int*)&x;
i=0x5f3759df-(i>>1);
x=*(float*)&i;
x=x*(1.5f-xhalf*x*x);
returnx;
}
他一看之下惊为天人,想要拜见这位前辈高人,但是一路追寻下去却一直找不到人;同时间也有其他人在找,虽然也没找到出处,但是ChrisLomont写了一篇论文(inPDF)解析这段code的算法(用的是Newton’sMethod,牛顿法;比较重要的是后半段讲到怎么找出神奇的0x5f3759df的)。
PS.这个function之所以重要,是因为求开根号倒数这个动作在3D运算(向量运算的部份)里面常常会用到,如果你用最原始的sqrt()然后再倒数的话,速度比上面的这个版本大概慢了四倍吧…XD
PS2.在他们追寻的过程中,有人提到一份叫做MITHACKMEM的文件,这是1970年代的MIT强者们做的一些笔记(hackmemo),大部份是algorithm,有些code是PDP-10asm写的,另外有少数是Ccode(有人整理了一份列表)。
2.开平方根说明
人们很早就在Quake3源代码中发现了类似如下的C代码,它可以快速的求1/sqrt(x),在3D图形向量计算方面应用很广
floatinvSqrt(floatx)
{
floatxhalf=0.5*x;
inti=*(int*)&x;//getbitsforfloatingvalue
i=0x5f3759df-(i>>1);//givesinitialguess
x=*(float*)&i;//convertbitsbacktofloat
x=x*(1.5-xhalf*x*x);//Newtonstep
returnx;
}
在分析这段代码之前,先看看传统方法是怎么求一个数的平方根的倒数的,一般采用牛顿迭代法,为描述方便,假设输入数为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这类游戏的性能是非常重要的,而且如果需要更高的精度,则将迭代那一行再重复一次就可以了,相对误差会降到百万分之一的级别,只不过速度会慢一些,接下来我们来分析下这三行代码的原理
最令人迷惑的是givesinitialguess这行,即对i做了移位和减法运算,不过熟悉C语言的人应该能看出来,这个算法和float浮点数的内部表示有关,分析应该从这里入手
正式开始前先轻松下,讲些历史故事,人们在QuakeIII源码发现了这个函数,于是很自然的认为这是卡马克(JohnCarmack)的杰作,其中0x5f3759df这个数被称为卡马克密码,我们在下面称这个数为magic,Beyond3D.com的RyszardSommefeldt一直在想到底是哪个家伙写了这些神奇的代码,于是就开始找作者,JohnCarmack在邮件回复中明确表示不是他,也不是Michael。
TerjeMathisen说他写过类似的高效代码,但上面的不是。
后来猜测这个来自于一些早期黑客的算法笔记,作者究竟是谁自然也难以追查了,可以肯定的是这个家伙对计算机和高数知识都有较好理解,很聪明
2003年普渡大学的数学家ChrisLomont写了一篇文章对这段代码进行了分析。
论文是英文的,地址在:
在这篇12页的论文中,Lomont对这个算法做了分析,并从推导出了一个理论上最优的magic0x5f37642f,有意思的是,这个数居然没有invSqrt里的0x5f3759df效果好,最大相对误差达到1.78‰,Lomont一怒之下,用暴力搜索枚举了所有可能的magic,终于找到一个最优的magic0x5f375a86,只比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中的magic0x5f3759df用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
假设Mrd2(M,F)=|1-(2+M-F/2)/2/sqrt(2/(1+F))|,2ME为偶数时,减数的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了
ChrisLomont用的推导方法类似,当然更严密些,推导到了这一步后,他的选择是继续用数学方法来求极值,这大概是因为他是个数学家的缘故。
不过悲剧的是他最后推出来的结果不是最优,计算机科学是一门应用科学,作为工科生,到这一步后我就准备选择穷举了,再往后的数学我就不懂了,毕竟哥的微积分才刚及格
所以,让我们抛开那些高深的数学来看这个问题,通过前面的推导,我们基本可以将magic的范围确定在[0x5f000000,0x5f3fffff],只要在这个范围内找到一个magic,使其对应的invSqrt函数对于所有常用浮点数的计算结果的最