跳到正文
Carol's Blog
返回

快速反平方根算法

什么是反平方根

反平方根即平方根的倒数:

y=1xy = \frac{1} {\sqrt x}

该计算表达式在图形学中的向量正规化中经常用到,对于大规模场景的法线向量计算,如果仅使用

float rsqrt(float number)
{
	float y = 1 / sqrt(number);
  	return y;
}

就显得非常笨拙。

因为在计算机中,一般加法与乘法都是经过硬件优化的,计算速度会很快,求平方根则不同。

为了更快的计算一个浮点数的平方根的倒数,一个更快更奇怪的算法在《雷神之锤3》中被提出(可能更早),该算法也成为了Magic Number的典型案例。

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;
}

要理解该算法是如何工作的,我们需要掌握一些内容:

  1. 浮点数在计算机内存中的存储方式
  2. c语言中的类型转换与重解释
  3. 牛顿迭代法(求函数的近似零点)

IEEE 754

IEEE 754标准中,定义了浮点数在计算机中的存储方式:

以32位浮点数为例(在c语言中为float

IEEE 754 单精度浮点数位布局

其中32位bit被分割成了3部分(科学计数法):

  1. Sign 符号位,0为正数,1为负数。
  2. Exponent 指数位,简写为EE,一共包含8位bit。
  3. FractionMantissa 尾数位,简写为MM,一共包含23bit。

对于求反平方根的算法,符号位可以不进行讨论,因为输入的浮点数只有为正该算法才在实数域中有意义。

对于指数位EE(移码),可以表示-126到127(-127和128被用作特殊值处理),实际表示的2的指数应该是E127E-127

对于尾数位MM,表示的是科学计数法中,尾数的小数点后的数字,因为在二进制的科学计数法表示里,尾数的小数点前的数字必为1

那么,根据IEEE 754的定义,一个浮点数(32位)F就可以表示为:

F=(1)S2E127(1+223M)F = (-1)^S \cdot 2^{E - 127} \cdot (1 + 2^{-23}M)

在本算法中,符号位可以认为恒0,即可化简为:

F=2E127(1+223M)F = 2^{E-127}\cdot (1+2^{-23}M)

同时,如果我们将该32位看作是intlong(在c语言中二者可以看做同一类型),那其对应的整形数LL为:

L=223E+ML = 2^{23}E + M

对于同样的32位bit,我们可以将其解释为浮点数FF,也可以解释为整数LL。那么FFLL之间有没有数量上的关系?

LLFF的关系

对于FF的表达式,我们可以进行这样的运算:

F=2E127(1+223M)log2(F)=log2(2E127(1+223M))log2(F)=log2(2E127)+log2(1+223M)log2(F)=E127+log2(1+223M)\begin{align} F=& 2^{E-127}\cdot(1+2^{-23}M) \\[1ex] \log_2(F)=&\log_2(2^{E-127} \cdot (1+2^{-23}M)) \\[1ex] \log_2(F)=&\log_2(2^{E-127}) + \log_2(1+2^{-23}M)\\[1ex] \log_2(F)=&E-127 + \log_2(1+2^{-23}M) \end{align}

观察函数f(x)=xf(x)=xg(x)=log2(x+1)g(x)=\log_2(x+1)

0x10\le x \le 1时,f(x)g(x)f(x)\approx g(x)即可表示为f(x)=g(x)+μf(x) = g(x) + \mu,其中μ\mu为误差。

log2(1+223M)223M+μ\log_2(1+2^{-23}M)\approx2^{-23}M + \mu,即:

log2(F)=E127+log2(1+223M)log2(F)=E127+223M+μlog2(F)=223(223E+M)+μ127\begin{align} \log_2(F) =& E - 127 + \log_2(1 + 2^{-23}M) \\[1ex] \log_2(F) =& E - 127 + 2^{-23}M + \mu \\[1ex] \log_2(F) =& 2^{-23}(2^{23}E + M) + \mu - 127 \end{align}

又因为L=223E+ML=2^{23}E + M,得

log2(F)=223L+μ127\log_2(F) = 2^{-23}L + \mu - 127

至此,我们得到了对于同一32位二进制串的浮点数解释FF与整数解释LL之间的近似数量关系。

c语言中的类型转换与重解释

Type punning (类型双关): c++中可以使用reinterpret_cast实现双关

在c语言中,如果我们需要将32位浮点数float转为long(或int),我们可以显示得:

float num_float = 3.3;
long num_long = (long)num_float;

得到的结果num_long为3。

但是如果我们更底层的,对于IEEE 754中提到的,我想把32位浮点数,转换成32bit都相同的整形,该如何做呢?

在程序运行中,变量的数据一般是存储在内存中,在c语言里可以通过&来获取变量的地址。而(long *) 地址即可使用long的指针指向该地址,再使用*获取该指针的内容,即可实现了在c语言里使用指针重解释内存中的数据类型。

Q_rsqrt函数中的i = * ( long * ) &y; y本为float类型,使用该方法可以将其bit不变得解释为long类型的i变量。

求解反平方根的近似

在求解之前,我们先回顾上面的结论:

log2(F)=223L+μ127\begin{align} \log_2(F) = 2^{-23}L + \mu - 127 \end{align}

其中FF为32位bit的浮点数解释,LL为32位bit的整形解释。而在c语言中使用指针可以实现FFLL的转换,更重要的是,转换是极快的。

至此,我们可以明确快速平方根的算法的目的:

  1. 求解有效32位浮点数的平方根的倒数
  2. 尽可能的只使用加法乘法位运算
  3. 时间复杂度为常数
  4. 可以有一些误差(计算机存储浮点数本身就有误差)

值得注意的是,浮点数本身不支持位运算


回到一开始的问题,求解:y=1/xy=1/\sqrt x

问题可以表示为:

Fy=1/FxF_y = 1 / \sqrt{F_x} Fy=1/Fxlog2(Fy)=log2(1/Fx)log2(Fy)=log2(Fx1/2)log2(Fy)=12log2(Fx)\begin{align} F_y =& 1 / \sqrt{F_x} \\[1ex] \log_2(F_y) =& \log_2(1/\sqrt{F_x}) \\[1ex] \log_2(F_y) =& \log_2(F_x^{-1/2}) \\[1ex] \log_2(F_y) =& -\frac{1}{2}\log_2(F_x) \end{align}

log2(F)=223L+μ127\log_2(F) = 2^{23}L + \mu - 127带入上式:

log2(Fy)=12log2(Fx)223Ly+μ127=12(223Lx+μ127)223Ly=12(223Lx+3(μ127))Ly=32223(127μ)12Lx\begin{align} \log_2(F_y) =& -\frac{1}{2}\log_2(F_x) \\[1ex] 2^{-23}L_y + \mu - 127 =& -\frac{1}{2}(2^{-23}L_x + \mu - 127) \\[1ex] 2^{-23}L_y =& -\frac{1}{2}(2^{-23}L_x + 3\cdot(\mu - 127))\\[1ex] L_y=& \frac{3}{2}\cdot2^{23}\cdot(127 - \mu) -\frac{1}{2}L_x \end{align}

对于上式结果:Ly=32223(127μ)12LxL_y=\frac{3}{2}\cdot 2^{23} \cdot (127 - \mu) - \frac{1}{2} L_x

FxF_x表示32位bit的xx的浮点数表示,LxL_x表示32位bit的xx的整形表示。在c语言中,二者是可以互相转换的。

前半部分32223(127μ)=0x5f3759df\frac{3}{2}\cdot 2^{23}\cdot (127-\mu) = \mathrm{0x5f3759df},在μ=0.04505\mu=0.04505时成立,更通用的,可以使μ=0.0430\mu=0.0430

后半部分12Lx-\frac{1}{2 }L_x在c语言中可以使用位操作实现:-(Lx >> 1)

故使用c语言实现该表达式为:

long Ly = 0x5f3759df - (Lx >> 1);

即原算法实现中的内容。

通过该公式可以求出LyL_y,再在c语言中通过Fy = * (float *) &Ly即可求得FyF_y

牛顿迭代

在上述推导中,我们用到了log2(x+1)x+μ\log_2(x+1) \approx x + \mu

一般情况下μ=0.0430\mu=0.0430,该算法实现中取了μ=0.04505\mu=0.04505。都是为了使用x+μx+\mu逼近logx(x+1)\log_x(x+1)

虽然误差总是不可避免,为了减少这种逻辑上带来的误差,我们需要将误差尽可能的减少。

牛顿迭代法是一种用来近似函数零点的方法:

牛顿迭代

对于某复杂函数,我们无法推导出其零点的表达式,或者零点的表达式不利于计算机的计算,我们就可以使用牛顿法来近似得到零点。

牛顿法的核心思想是:切线是曲线的线性逼近

对于某一可导函数,我可以取其定义域内的任意xx

  1. 在函数上对应xx的位置求一阶导数,即函数在xx位置的切线。
  2. 该切线与X轴最多有一个交点,如果有交点的话,我们就以该交点为xx重复求切线,求切线与X轴交点的这个过程。

每次求的切线与X轴的交点都更加逼近函数的真正零点

根据上图,容易得到:

Δx=ΔyΔyΔxΔx=f(x)f(x)xnew=xf(x)f(x)\begin{align} \Delta x =& \frac{\Delta y}{\frac{\Delta y}{\Delta x}} \\[1ex] \Delta x =& \frac{f(x)}{f^\prime(x)} \\[1ex] x_{new} =& x - \frac{f(x)}{f^\prime(x)} \end{align}

对于牛顿法求零点,我们需要知道

  1. 函数的表达式
  2. 初始迭代值

所以对于本算法,我们需要构造yy的函数,使得其零点为y=1xy=\frac{1}{\sqrt x}

经过一些变换,我们就可以写出该函数:

f(y)=1y2xf(y) = \frac{1}{y^2} - x

约束y>0y>0,易得:f(y)=2y3f^\prime(y) = -2 \cdot y^{-3}

使用牛顿法的前提:函数表达式我们有了,初始迭代值我们也在上面求出来了FyF_y

那就可以开始进行牛顿迭代的推导:

ynew=yf(y)f(y)ynew=yy2x2y3ynew=y+12(yxy3)ynew=y(3212xy2)\begin{align} y_{new} =& y - \frac{f(y)}{f^\prime(y)} \\[1ex] y_{new} =& y - \frac{y^{-2} - x}{-2 \cdot y^{-3}} \\[1ex] y_{new} =& y + \frac{1}{2} (y - xy^3) \\[1ex] y_{new} =& y(\frac{3}{2} - \frac{1}{2}xy^2) \end{align}

我们可以明确上式中:yy是迭代的值,初识迭代值为FyF_yxxy=1xy=\frac{1}{\sqrt x}中的xx

那么就很容易可以转换成c语言中的:

Fy = Fy * (1.5 - 0.5 * x * Fy * Fy);

对应源代码中的:

y  = y * ( threehalfs - ( x2 * y * y ) );

在该算法过程中,进行一次迭代即可满足精度的需求。

现在我们就可以把原码进行完整的解释:

float Q_rsqrt( float number )
{
	long i;
	float x2, y;
	const float threehalfs = 1.5F;   // 牛顿迭代中的1.5

	x2 = number * 0.5F;
	y  = number;
	i  = * ( long * ) &y;                      // 将32bit浮点数y重新解释为32bit整形i
	i  = 0x5f3759df - ( i >> 1 );              // 对i求反平方根的近似
	y  = * ( float * ) &i;                      // 把32bit整形i重新解释为32bit浮点数y
	y  = y * ( threehalfs - ( x2 * y * y ) );  // 第一次牛顿迭代
//y  = y * ( threehalfs - ( x2 * y * y ) );   // 第二次迭代,看起来不需要

	return y;
}

推广到double类型

以上的算法只针对32位浮点数float,对于64位浮点数double我们也可以应用该思想设计出快速的反平方根算法。

double类型的64位bit中:

  1. 符号位占1bit
  2. 指数位EE 占11bit
  3. 尾数位MM 占52bit

有了这些数据,我们就可以写出c语言中的函数实现:

double Q_rsqrt(double number)
{
	long long i;
	double x2, y;
	const double threehalfs = 1.5F;   // 牛顿迭代中的1.5

	x2 = number * 0.5F;
	y  = number;
	i  = * ( long long * ) &y;                       // 将64bit浮点数y重新解释为64bit整形i
	i  = 0x5fdd3020c49ba400 - ( i >> 1 );               // 对i求反平方根的近似
	y  = * ( double * ) &i;                      // 把64bit整形i重新解释为64bit浮点数y
	y  = y * ( threehalfs - ( x2 * y * y ) );   // 第一次牛顿迭代
//y  = y * ( threehalfs - ( x2 * y * y ) );   // 第二次迭代,看起来不需要

	return y;
}

其中0x5fdd3020c49ba400根据μ\mu的实际取值不同而不同。


分享这篇文章:
通过邮件分享这篇文章

分享到微信

微信对普通网页没有开放通用直连分享协议。更稳妥的方式是复制链接、扫码打开,或在支持的设备上调用系统分享。

上一篇
对SCU网络服务安全性的第一次探索
下一篇
C++-架构之路