家介绍经典C语言平方根倒数速算法(或称Fast Inverse Square Root)。详细推导魔法数字0x5f3759df究竟从何而来,并最终在MCU上对比该算法和<math.h>中double sqrt(double)方法的性能表现并给出使用建议。本文不涉及高深的数学变换或复杂的计算机原理,但希望读者具备一些C语言基础;此外任何相关领域的积累都可能有助于内容理解。3 p) [& S# \% w% g
- ] s) @, Y. T0 h( s1 L$ @
希望本文能帮助到有需要的朋友。
+ M5 e7 w' q, B; D2 P. ]6 h2 D
9 k9 z" A* o! }6 n平方根倒数简介2 `) _6 D: L# W2 |, S4 K% K% i
求平方根倒数在向量单位化操作中很常见,先举个例子,假设在二维平面内有一向量:
3 D% U' d8 ?5 @1 O3 h+ v
9 G' X" J* b) x8 U将该向量单位化,即将向量模长缩放为1,用向量除以它的模,可以表达成下式:
8 Y: z4 K/ x' U& w [1 ]8 o' N3 q$ F9 @
此处,单位向量的坐标,即是原向量坐标和模长倒数的乘积。( y0 x: R" B% z8 A
一般而言,对任一数x,  即被称作平方根倒数。9 Z# R0 F9 T) p6 |
/ T* ]( y7 t! `4 x9 {! `C语言内求平方根倒数' v8 n/ t W9 c" p* O6 ?* [1 _
C语言内求平方根属于常规操作,因为<math.h>中已经提供了double sqrt(double)函数给大家调用,该函数接收一个double型变量,并返回其平方根。部分场合使用float类型,如果是我,我会这么写: - #include <math.h>' G# |' n; c9 p! d! J
- 6 v( Q" a$ i9 i) H( T/ b' R) j
- float x =0;
& S6 R. J; z; T) e8 u. s -
4 o8 N" e' l- l7 J) g j4 a; P - float inverse_square_root =0;
: _9 D% a: S0 B/ T, d -
; |3 A f8 D' |0 T* S: [ - inverse_square_root =1/sqrt(x);
复制代码
6 W1 h9 N7 T+ l+ J9 p3 ~* M5 p! s: j. e, R
事实上,大多数计算机处理加法和乘法时更快,对于除法和开方运算,速度相对较慢。定义在<math.h>中的sqrt()方法其系统开销是比较大的,尤其在部分MCU上,有时不能很好地满足系统实时性要求。
2 g1 W. y$ _* h 速算法介绍
8 i% O; c" ^- ~& @( R9 w该情景与上世纪末PC算力较低的情况颇有几分相似,而在那段PC内存按MB计算的岁月里,优秀的C语言程序员们找到了该问题的解决方法:平方根倒数速算法(Fast Inverse Square Root),据说该算法最早由GaryTarolli在SGI Indigo开发中使用,对起源感兴趣的朋友可自行探究。. \1 x0 [# q+ N
平方根倒数速算法是一个单独的函数实现,代码如下:- float FInvSqrtRoot(float x); B* ~9 X( U" e9 p: i* j* M
-
' X P0 T U% z3 Z, I3 { - {0 r0 n7 Z& `/ }, @- O t
-
; X2 z# N1 ]( M$ T& ~8 { - long i = 0;
5 |" B5 ?. J' P+ Y - & Q1 G7 H7 \& j8 Z9 k1 R \
- float y, halfx;! u4 }% z, {3 K' S1 D
- 6 s0 `! e) S% L; f! }8 y, k
- halfx = 0.5 * x;2 m/ h5 \: C9 z
-
6 P; e% k) ?. d1 O4 ?5 d - i = *(long*) &x; // 按照整型方法操作浮点数
" d- F! z) m, l0 X: G" h -
4 O9 f7 [, {' y6 r - i = 0x5f3759df - (i >> 1); // 魔法?
: Q! C8 K+ A% V* m3 z1 L- c! q -
$ _" Y% i3 ?2 t - y = *(float*) &i; // 获得平方根倒数近似值. h* b7 x& I) @6 [" e; m
-
2 [1 r# `6 i9 g/ {8 n9 s L - y = y * (1.5 - halfx * y * y); // 一次牛顿迭代/ j1 y- p+ V8 N. M
- ! x" n; J" K. T9 ^4 P9 F$ F2 M y: h
- return y;' a, T: W2 U' w1 U% J. s3 ~" \% m3 T
- 2 b9 H& Y9 g( K5 @. z
- }
复制代码 8 J! t, N; A5 v2 j# z5 w
# F2 a! b8 b" Z3 }1 ]& H# @1 t& f
. Q' N! l1 N% g) R
该函数接收一个float型变量,并返回其平方根倒数。
* H" K# R) K2 v) C
Step1.奇怪的地址类型转换 - i = *(long *) &x; // 按照整型方法操作浮点数
复制代码
1 Q5 h/ u7 Y& i; H1 d* k代码第四行将浮点数x按照整型数据操作,这里的操作方法与类型转换有很大不同;类型转换将内存中按照浮点数标准表示的值近似为最接近的整数,并按照整数标准储存在内存中,像这样:i=(long)x;要理解*(long *) &x与(long)x的不同,需要了解浮点数在计算机中的表示方法。5 g; b! P. \! [( W
浮点数在计算机中使用类似科学计数法的形式储存,float类型的浮点数使用32bit表示,结构如下: % @% ]( E* p* f, l; G

# }" O# {6 Q8 t* U7 S0 ]2 Y( W
最高位,第31位,为符号位,若S=0,该浮点数为正数;若S=1,该浮点数为负数。9 o& o: `$ \ Q c) @& I
接下来的8位数字,为指数部分,指数E范围为[0,255],为可以表示负指数,规定在E的范围基础上-127,即[-127,128];例如E=3表示2^(3-127)=2^(-124)。. U7 r) e/ R: A
接下来的23位数字,为尾数部分,即有效数字部分。由于二进制下有效数字必为1.xxx的形式,整数部分必为1,因此无需使用第22位表示整数1,而是直接表示小数点后一位,整数部分不在尾数M中体现,只在计算时加上;即M=0101…表示二进制的1.0101。以上,float型浮点数在内存中的存放形式已经明确。7 R. u$ f7 D5 K- h& y9 q; [
现假设有一float型浮点正数x,其在内存中按照上述方式存放,即: 
则x表示的浮点数值为: 
注意平方根倒数速算法只接收正数,所以全文只讨论x为正浮点数的情况。 此时再回来看i=*(long *) &x这行代码; 第一步;取x的地址,注意此时x的地址指向一个浮点数,内存中对应的数据为:E*2^23+M 第二步;将该地址转换为指向long整型的地址,注意转换的是地址类型而不是数据,现在内存中的数据仍然是:E*2^23+M 第三步,取该地址指向的数据,赋给i;此时程序将按照整型标准去读取这个数据。因此,可以将i理解为x对应内存数据的整型解释。现在可以对i进行整型支持的操作,比如接下来要用到的位移操作。 准备工作已经完成,现在进入算法的核心,即施放魔法: - i = 0x5f3759df - (i >> 1); // 魔法?
复制代码 ) R; H1 f- Y- {6 U* s; w
要理解这行代码,需要一点点数学铺垫;我们先给x表示的浮点数取个对数: 
其中,M表示尾数,M/2^23范围在[0,1)之间;在极限理论中,若x属于[0,1],有以下关系: 
即在x接近0或1的条件下,log2(1+x)接近x;很明显当x不靠近0或1时,误差会变大,如下图左侧所示: 
如果在等式右侧加入一个微小量u,可以使得x+u在[0,1]范围内近似log2(1+x),如上图右侧所示: 
所以有: 
因此, 
% O1 t$ _2 `* s/ A+ n9 a1 Y; v( I I/ ~& V/ [6 s( F, A
此时再倒回去看浮点数x在内存中的存放形式,即:E*2^23+M; 因此可以认为浮点数的对数形式与其内存存放形式相似,只是添加了缩放和平移。我们知道: 
令: 
所以有: 
将上式展开: 6 l$ M. _- @7 p. `

稍作整理: 
这太棒了,因为浮点数在内存中的存放形式,即:E*2^23+M 而上式中 
则相当于对其内存形式右移1位,即 
由此可知,魔法数字0x5f3759df就是包含u在内的修正项: 
至此我们已经完成了魔法代码的解析: 这太棒了,因为浮点数在内存中的存放形式,即:E*2^23+M 而上式中 
则相当于对其内存形式右移1位,即 
由此可知,魔法数字0x5f3759df就是包含u在内的修正项: 
至此我们已经完成了魔法代码的解析: - i = 0x5f3759df - (i >> 1); // 魔法
复制代码 [- [' W9 h. s8 u1 S. j
i就是x对应内存数据的整型解释,将i右移1位相当于乘以二分之一,也就是1/2*(Ex*2^23+Mx); 等式右侧的运算结果,就是 
,也就是 
即 
注意此时程序认为i是整型变量,所以需要下一行代码再将其转换为浮点型; y = *(float *) &i; // 获得平方根倒数估计值此时y即是x的平方根倒数。 ( j; K7 {% R7 W
Step3.优化结果) ^: U7 Y( o5 a& w i
由于上述推导中,引入了修正系数u,所以y只是近似解。
& w1 }% p% m4 E9 [1 S, n
% A7 c" ^1 Z* I接下来使用牛顿法(Newton Method)进行一次迭代,使y更接近准确值。简单来说,使用牛顿法,可以从一个近似解获得一个更接近准确答案的解;牛顿法的核心思想是迭代,对于我们正在讨论的问题,属于已知x求y的类型,即
6 N- M5 a- a6 Q% A8 U( `9 N7 E9 V5 b+ C+ p7 D% [

9 ^5 B4 ?$ ?1 q' Z8 h3 d* f/ g( Q& j, [2 z- w; F8 Q& k& n
假设已经获取一个近似解y1,则可以通过一次迭代求取更优解y2:
g, X: k0 ?, v$ m6 w* ~0 O U2 k' r. X( s1 a

/ v) W# `8 j5 ~% A( q u
. c, e! \/ _ `/ R3 j6 G神奇的是计算y2仅需要乘法和减法运算,于是有如下代码:
# n) A) ?1 l2 w5 @" B
7 M4 O& V, I. p( V* s$ Yy = y * (1.5 - halfx * y * y); // 一次牛顿迭代7 R7 \: D9 ^6 o ]% f$ t/ _9 A& l
w2 W* J+ i# H
该行代码返回优化后的平方根倒数,也就是最终运算结果。& A0 B5 @$ S, T2 _/ W
& O8 g3 K6 E2 i0 s
性能测试6 _# E: f' Y7 @. V3 f: y
接下来我会在STM32G071上测试Fast Inverse Square Root算法与<math.h>中提供的double sqrt(double)算法的性能,并尝试以此证明它为何被尊为经典。STM32G071运行在64MHz下,编译器和调试器优化分别为-O0和-g3。! n/ o/ t4 i* c/ a `: W( Z R
使用uint32_t替代long型:
+ k, g; O8 J/ H+ K, R
, i3 t; ?% x9 c* d m, t! ~5 A& V: P- float FInvSqrtRoot(float x)# ~0 Y* T: n6 F" I/ v, t. X
- # q' C$ ^, q9 W( x+ K
- {
/ ~/ v3 Y3 |, D) y5 \ -
* i7 `6 v; R: g- f9 g+ X& M3 i9 ^ - uint32_t i = 0;
8 v& u( J: a) k0 Q9 ]/ R -
/ \4 f& p y1 b+ L - float y, halfx;6 Z: { p2 g- `. Q, E6 h" ^, W$ C) n
- : c6 J" h3 ?5 k* X# j
- halfx = 0.5 * x;
2 v. k, I. r- r) L3 ` - 7 m5 B2 W+ k5 P+ a
- i = *(uint32_t*) &x; // 按照整型方法操作浮点数
8 F% U) v p. i/ N/ w3 F - - o I$ x! b, w* I# ]+ o
- i = 0x5f3759df - (i >> 1); // 施放魔法
$ S$ a% l$ t- Z+ B* u5 s+ I -
6 X. S+ ?7 X- k# S! `1 V4 Z7 b - y = *(float*) &i; // 获得平方根倒数近似值$ z' m) O$ p! G# U R2 E1 M3 I0 J/ U
-
) `1 w& `) i# A/ G9 }% h% B8 r - y = y * (1.5 - halfx * y * y); // 一次牛顿迭代) y9 G/ |# ?; y' x- O
-
: i3 f R% p+ h: t - return y;" |# m' f8 I( {+ O! S$ F; W
- 1 K( R! ~7 r% P b
- }
复制代码

/ x$ ~: b8 `& Z6 A) Q9 U7 X! I
0 L% I# c9 ? z9 j9 h0 c/ b因为STM32G071的RAM大小为36KB,先构造了8000个float数据的数组,大小为32KB;构造方法如下:
) T6 s* I$ ^: _+ D+ }7 H2 T& J) t7 m6 J7 z: K; W
, D0 \9 S/ j/ A
- float test_data[8000] ={ 0 };
! ~7 u' Z% N8 B: {7 A -
* ?0 E6 L4 \ @4 @7 P, \ - for (uint16_t i = 0; i < 8000; i++)
- d3 r# ^+ ]" |* }* z, b - " d7 R( f4 \0 j% j* ~
- test_data[i] = i * 1000 + (float)i / 1000;
复制代码
/ \6 `% ]6 C& n& G使用两种不同算法处理上述8000个数据,测试代码如下(注意此处仅作演示,测试方法不一定严谨):
0 r& Q; d2 [: z7 ?' J / J" K8 G1 I" c$ L, q$ E# W4 J7 f
- //平方根倒数速算法
3 e; C, M0 B& O' g( {+ Z - 9 y, w, w$ }1 o8 ], `5 s
- printf("FISR Test Start!\r\n");
% f9 r2 _% G+ @& S! P2 }- P7 h - 7 c% R( w }% V! J3 B5 Y5 V
- ticks = 0;//定时器计数,1ms/tick2 \& g, F! Q' T6 B& `/ F; @. M
- / k6 @" K8 D* T* h
- HAL_TIM_Base_Start_IT(&htim1);! t& p7 Q/ d- A! V/ A( F
- 4 e3 E2 W: E9 R1 @. n7 T( ?$ E
- for (uint16_t i = 0; i < 8000; i++)4 A$ e* z- L9 k# ?: s6 T
-
! y; b: h2 ~: e, }- T - sroot=FInvSqrtRoot(test_data[i]);
* ?$ v; D6 v k# c$ X - 2 F4 C/ Z$ ^, v/ p# d" A6 I7 b1 l2 ?
- HAL_TIM_Base_Stop_IT(&htim1);
% @# H' X* q- a; L7 g/ c -
4 a" d# J9 a/ Y! B1 e6 g - printf("FISR Test Stop!\r\n");8 G* ]& v' L" f' F
-
% D2 z0 a4 h! P- C0 S ]+ q" U4 O t - printf("FISR Time Elapsed:%f\r\n", ((float) ticks) / 1000);
+ Q9 ?" w" n( P+ S o# T -
+ s- q8 _4 }- s. L2 B' Q - 2 L9 R5 x& D: s2 u* ~
- / v7 ?9 N- K5 o5 {
- //传统1/sqrt()
p! K- |" C2 f0 V/ c, m2 g -
# }9 v# R. V* _3 G3 U - printf("SQRT Test Start!\r\n");
0 {" J+ {- O5 p; u5 T3 q -
# [3 }( d1 h$ u - ticks = 0;
# C5 a6 W6 n2 e: y) R2 B$ v - 5 u" ~7 w; Z8 D( A% Z; Q
- HAL_TIM_Base_Start_IT(&htim1);3 ^# ^" ~1 z, K+ {+ Y
- , f2 F* w; \1 ~, i/ A1 @
- for (uint16_t i = 0; i < 8000; i++)
/ ], l3 U, N. ^# X3 | -
# v2 T+ C3 Q L- B6 a B - sroot=1 / sqrt(test_data[i]);
/ Q! V1 f" L6 H; a - + ?5 W1 T6 O p
- HAL_TIM_Base_Stop_IT(&htim1);7 n1 W9 a6 e1 T# y8 D, T* p
- 1 r% w% n7 e2 p
- printf("SQRT Test Stop!\r\n");2 U G8 }# P# @. v7 }2 j
-
, W) f* d; i. w- i8 O4 j2 i0 W - printf("SQRT Time Elapsed:%f\r\n", ((float) ticks) / 1000);
复制代码 . s! S+ ~+ ]0 J% M0 J
在上述测试环境下,FInvSqrtRoot耗时181ms,1 / sqrt()耗时325ms,FInvSqrtRoot比1 / sqrt()快大约79%。由于sroot是float型,sqrt()返回double型,所以1 / sqrt()测试时有额外的类型转换损耗;将sroot更改为double型,则类型转换损耗由FInvSqrtRoot承担;该状态下,FInvSqrtRoot耗时187ms,1 / sqrt()耗时315ms,FInvSqrtRoot比1 / sqrt()快大约68%。注意FInvSqrtRoot求取的仅是平方根倒数的近似值,和1 / sqrt()比对,误差约在1%以内。0 h/ I6 t( C+ n2 ~7 L# w
6 N( N; T7 l' N5 u0 h) U
魔改:平方根速算法3 |1 X9 N* P! t7 C* F/ e6 m1 @
根据上述原理,可将平方根倒数速算法修改为平方根速算法,代码如下:
6 R. Y/ |: |7 L
P9 s, n7 P7 R4 k7 \- float FSqrtRoot(float x) {
$ y5 {' I/ n% O1 a - ' b# o. c# I$ b" @9 f; G) x
- uint32_t i = 0;1 V" }9 g5 O/ h
- ( ^& @& ]2 W; g0 h/ w/ [4 _% g! T
- float y;/ H6 V. c! q$ z G) ? O8 J6 I
-
" d2 D5 i' G( e) _4 D - i = *(uint32_t *) &x; // 按照整型方法操作浮点数
6 y' b# ]! C% E0 |; z8 s3 o - 0 S. c4 L. O1 p- C. h/ e
- i = 0x1fbd1df5 + (i >> 1); // 施放魔法7 {4 |( v0 p3 Z8 A% p3 U! ?
- 4 j; i$ ^! q! |8 x
- y = *(float *) &i; // 获得平方根近似值
0 r2 F k2 [# V2 @! G) p0 f% Z -
" J2 H- A2 |: u, \0 e - y = 0.5 * (y + x / y); // 一次牛顿迭代0 C5 R- P! r/ e" N2 ]) |* C% Q* X( [
-
: e* U# b$ B8 v: j% f4 g - return y;& S( G) @. B& u7 }1 z
-
# n* e- h9 F3 S3 R& s - }
复制代码 7 r+ x, d u- A2 B3 W! Z
在上述测试环境下,FSqrtRoot耗时109ms,sqrt()耗时221ms,FInvSqrtRoot比sqrt()快大约100%。由于sroot是float型,sqrt()返回double型,所以sqrt()测试时有额外的类型转换损耗;将sroot更改为double型,则类型转换损耗由FSqrtRoot承担;该状态下,FSqrtRoot耗时115ms,sqrt()耗时208ms,FInvSqrtRoot比sqrt()快大约80%。: K l8 W$ g8 C6 ~( `/ `. y( u
感兴趣的朋友可自行推导实验,算法性能也请根据实际需求比对。0 R7 Q1 z/ K* D: V3 P8 N
---------------------- N& h# U+ A) |+ g- ^# I9 f
作者:Litthins+ ^" G7 c9 k( P, o" f, c9 Z8 g
( N; K. |! L* H: R
4 A, {# l i3 b |