intro1:卡马克算法时最快的开根号方式吗?C/Java语言本身是怎么实现开根号的?
intro2: java的内置sqrt和c的内置sqrt哪个更快?Java的编译/JIT优化和GCC的编译优化是否有不足之处?
intro3: java的C2优化效果一定比C1效果好吗(对性能而言)?
问题或现象
这是最近看一位博主解Leet Code题想到的,原题简化一下是:给一个正整数(32位int)开根号后得到x,再对x取整返回。
博文的解法是使用二分查找,Java代码实现,不过这里想对该题再讨论几点
1)二分查找也可以优化下,建立一个简单的范围表,再二分查找,某几个国产IP库查询也是该做法(因为比BTree省太多内存)。
2)其实还可以用 牛顿切线法 ,每个ACMer入门练手时都会碰到的算法。
3)因为本题目只是要求返回正整数,那么如果我在 牛顿切分法阈值判断的时候,再加一个条件,判断本轮的整数部分和上一轮的整数部分是否相等,会不会更快?
4)开根号怎能少了卡马克算法,要知道在关于开根号方法中祭出卡马克算法,也就基本意味着本次交谈该结束了,但是卡马克是最快的方法吗?
所以下文中我写了几行代码来简单验证下(C/Java版)
先了解下牛顿切分法是什么:
许多网站链接有介绍,这里简单描述下,中学课本里的二次方程求根公式正式提出距今不过1000年多,高次方程求根公式知道高斯/阿贝尔/伽罗华才告一段落,在此之前物理学家们怎么做的呢?牛顿提出了牛顿迭代法,但这是在实/复数域上求解方程的近似根,思想就是通过直线逼近曲线(世纪更早的我国数学家刘徽也提出该思想并求出圆周率近似值),只不过那时候没有系统的考虑连续和收敛的问题。
牛顿切分转化为牛顿迭代就是比如对方程 f(n) => x(n+1)=F(x(n))转换,也就是从当前态计算出下一个状态,这是适合人们手工去计算的,更是非常适合计算机代码和执行。
我们把上述 f(n) 用于开根号情况,也就是求方程 X^2-c=0 的解,借助于大学里的泰勒展开,我们可以得到:
X(n+1) = ( X(n) + C/X(n) )/2
当X(n+1)与X(n) 差值足够小的时候,我们就认为 X(n+1) 接近于真实解了。
至于卡马克算法讨论需要更多篇幅,网上有更多介绍。简单来说是基于牛顿迭代使用一个“魔数”免于多次迭代近似对单精度数字求根。
下面代码是C实现。
其中:
1) sqrt_newton:牛顿迭代,sqrt_newton_int int比较的牛顿迭代,InvSqrt 卡马克算法开根号
2) 在main函数里,我使用简单的for循环来统计耗时,暂不考虑精度以及其他问题,且先相信只要循环次数大,耗时差距大,就可以得出足够可信的结论。
|
|
因int范围限制,故每轮采取循环1亿次,多运行几次,发现输出下面结果
1) 忽略上述InvSqrt float数值不正确的情况,这里是为了避免类型转换,减少对结果的影响。
2) 虽有for循环,以及类型/加操作,但如果测试下来耗时是一半,那有理由相信性能其实是大于一倍的。
3) 可发现第一次编译后,运行几次,那段卡马克效果都不如使用C系统库的sqrt函数,相差0.5倍左右,不过已经比牛顿迭代少一个数量级了。
4) 当开启 -O3 优化的时候,出现 耗时为1,这是GCC编译优化的缘故,也可看到优化后调用牛顿迭代耗时下降。
5) 但如果看 hot 的总耗时,还是可见-O3 效果优于 -O1 优化的。
6) 开启 -O1 优化时候,看到耗时都为1。 因为优化实际上相当只运行最后一次循环结果,这点看汇编代码可以看到。不过这里有个问题是 单是对于main函数而言,-O3优化效果反而不如 -O1。
所以为了避免上述问题,这里需要改进下代码:见附件quic_newton.c
1) 上文中 res4 = sqrt(num); 都类似对应的改为 res4 += sqrt(num+i)格式
2) hot之后将 res1-4都置零一下。
3) int num 改为double num,并且相应printf语句里 %d改为%f。
(部分可能受制于发生隐式类型转换,不过因为已经是double/float,对于再加int性能消耗是可忽略,或可能通过 -O1优化掉。查计算机指令周期可知,相对于前三个计算方法这个时间影响也很小)
|
|
请注意上述命令执行时的参数和输出,可以看到InvSqrt接近一个数量级的提升,sys sqrt 40%提升。这里的优化显然是减少了循环体内的指令。
比如:
对比一下 -O0和-O1关键部分
-O1 优化掉了一个int转浮点型,而且还巧妙的通过自增的方式减少了指令。即优化后相当于
上面这个优化有趣,我在后面的Java版代码里也会讨论下这一点。
需要说明一点,改进后的quic_newton.c耗时统计,跟你添加的gcc参数相关(O1/3支持参数多样来控制你的优化),当然,也跟你的机器,系统,硬件性能相关。比如我在一台旧的intel xeon+centos5上编译后运行,InvSqrt也在4.1秒左右,sys sqrt在1.8秒了,是mac机结果十倍了,这个时候是看不到 -O3和-O1效果的差异的。
至此,要说的其实就是,现代计算机(无论intel/amd)浮点处理器大多支持开根号指令,基于硬件的,虽然可能硬件(arm/fpga)不同他们的实现或有区别,比如x86架构该指令就是基于牛顿迭代实现的,因为精度可控。不是所有的处理器都这么干。
不过intel系列最初是基于此提供FSQRT指令,后来提供了更快的指令,SQRTSS (对于双精度是 SQRTSD) 。如果算上支持4个浮点数那就更快了。
好了,写这些,就是希望记得有人问最快的开根号代码,切勿上来就撸一个卡马克算法。
不过也有硬件指令(RSQRTSS)是基于卡马克近似算法,性能据说比上述指令快,但是精度就略差了(小于六千分之一),而且仅限单精度。RSQRTSS
sqrtss gives a correctly rounded result. rsqrtss gives an approximation to the reciprocal, accurate to about 11 bits.
如果你对这方面了解更多,欢迎指教 mail: aXRob21hc2xhdUBxcS5jb20=
需要指出上面c代码测试结果会因平台而异,比如在Intel(R) Xeon某型号 + gcc (GCC) 4.4.7 20120313 (Red Hat 4.4.7-18)上就没有sqrt的性能优化,而是call指令调用,导致-O1编译没能够优化到常量(但是 循环跑Invsqrt 则优化到常量时间了)。
同样的,上述测试的是比较的趋势,数值不作为参考,毕竟还可以有比ANSI C 的sqrt更快的选择,比如第三方(fdlibm)优化过的或者含有fpu判断的 __ieee754_sqrt之类函数。
但如果你是C和汇编语言高手的话,在Intel平台,你可以直接调用xmmintrin.h库提供的 _mm_rsqrt_ss/_mm_rsqrtsd 之类的函数实现模拟调用汇编开根号\_m128d _mm_sqrtpd(__m128d a),甚至借助 \_m128d 同时能给四个double数值开根号的特性实现四线程并行,这些就比ANSI C内置的sqrt更快了。
下面来看看Java里效果怎么样
|
|
上文问题3,这里解答下:
如果纯粹从理论上讲,通过判断减少循环次数,则耗时应该减少,但实际上并不这样,因为double强转int也是耗时的操作,当这个耗时足以弥补减少循环的耗时时,才会得到更少的耗时。可以简单修改 MM 来验证下,实际上MM=2500/100 则确实耗时更少,MM=2837耗时则几乎相等,MM=2839/2840/2850则耗时多。
因为接近平方数 INT版本收敛快。
上面验证较简单,就不展开。不过从这段代码,让我们看一点有趣的发现:
注意上面main函数里,我把每个方法都跑了一百次,并且每个方法内部还有1亿次循环。
我在这里新写一个JAVA类,不仅仅是方便JAVA/JVM/汇编代码,还是为了引出下文发现的三个问题。
每个函数都赋值给静态变量(该方法作用域外)s3,是为了避免逃逸变量的编译优化,这个简单就不详述。
不过,让我们先看 D() 这个方法,含逃逸变量,即可能会被JIT优化的情况
上述代码的 D() 在外层循环跑两轮之后就会发现 D() 耗时几乎为零了。
单单看JVM code可能看不出来
我们看看 C2优化之后的汇编代码
可以看到for循环其实优化掉了,所以上述改一下,return s3; 这样循环显然是不会消除优化掉的。
好了,我们再改一下:
即使用全局变量 s3以及 改为 s3+=i,那么耗时会是常量还是跟for循环次数有关?
更多的,如果这里改为 s3+=0.1 或者 s3+=1;又会怎样?
实际上三者耗时不一样,但都不是几纳秒,即这个时间不是常量,和循环次数有关,也就是说,即便是 C2优化后,循环依旧。
上面就是 s3 += i 版的 D() C2优化后的代码,即循环确实存在的。
GCC会怎么优化呢?实际上,C还是有点不同的地方:C这里优化后耗时为常量。
以上文C代码为例:
比如在gcc -O3 时,当 res为long类型时[JAVA版long跟double都不会有这个优化],耗时为恒定的时间约4ns,因为此时GCC编译器优化为:
即 n*M/2 直接得到结果了,而不用再跑完循环了。
但是当 res为double类型时,却是需要跑for循环了,即:
这里插一句,如果你怀疑上文测试是否足够准确,那可以验证下这段代码:
在我的Mac机器上前者耗时约为后者1/5以内,足以论证之前C的测试结果,说明sqrt指令比手写的卡马克开根号性能提升是大于40%。
1.
java -XX:TieredStopAtLevel=4 TwoSqrt
使用上面指令可以指定JIT优化级别
使用下面命令看lebel级别:
java -XX:+PrintFlagsFinal -version | grep CompileThreshold
level 0 - interpreter
level 1 - C1 with full optimization (no profiling)
level 2 - C1 with invocation and backedge counters
level 3 - C1 with full profiling (level 2 + MDO)
level 4 - C2
2.
java -XX:TieredStopAtLevel=1 -XX:+UnlockDiagnosticVMOptions -XX:CompileCommand=’print,DL.D‘ DL
使用上面命令可以输出指定方法JIT后的ASM代码
3.
如何查看java的JIT信息,网上可自行搜索教程。
好了,让我们切回刚才的JAVA代码。
再次对TwoSqrt.java 做个改动,添加了DL,即跟DD一样只是double换成了 long,运行几次,我们可看到跑出来的数据:
|
|
这个结果和我用JMH测试下来接近:JMHBnhSqrt.java
其中:
benchLoopDD: sumkk += Math.sqrt(MM + kk++); // kk为double,0开始,++
benchLoopDI: sumkk += Math.sqrt(MM + i); // i为for循环的i,0开始,++
benchLoopDIJ:sumjj += Math.sqrt(MM + jj++); // jj为int,0开始,++
benchLoopII: sumii += Math.sqrt(285 + ii++); // ii为int,0开始,++
1) 需要指出的是,这里其实benchmark的是for循环+sqrt函数的代价,而不仅仅是sqrt的代价了,所以如果你注释掉循环来用JMH benchmark的话再除以循环次数得到的值不一样了(因为for循环下编译优化的缘故)。
2) 似乎 JMH得到的 DI/DIJ性能结果和上面2次后的数据有点出入,可能是 对JMH使用掌握的还不够深入,也可能是JMH本身原因,立个flag后面再写篇JMH的文章讨论此问题。
3) 总之应该相信JMH测试标准的准确性,但是不要过分迷信JMH的优化,尤其在自己不太确定的影响性能的配置上。
源码中DNO和D因编译优化不再细说了,我们看看几个:
1) Java版的DD的耗时已经非常接近之前C代码测出来的sqrt指令耗时–167888000纳秒,差距千分之一以内了。
2) 我们看到 DI 的耗时经历了从600多毫秒到稳定在260毫秒左右,DIJ也类似–这是C2的优化。
3) 尽管有上面的优化,但是耗时还是大于 DD 很多,30%多了,而他们区别只不过是被加数是double/int的区别,这是什么原因呢?
4) DL的耗时,比较奇怪,C1优化耗时(前两轮循环)还在 211739138纳秒左右,但是第三轮之后竟然到了 450223373纳秒。
上述问题3,在C版本是无此区别的,也就是说,无论如何解释优化的原因,这里都可看出来,Java的C2对DI的优化(int类型的循环体)不如 GCC 编译器,后者可以做到 DI和DD同样的耗时。
上述问题4,我们是不是可以得到结论,C2的优化效果(性能) 未必比 C1 好?
后两个问题,我在下一篇技术文里再讨论。
其他:
DNO/D的常量消除,上文写了,这里不重复,但看下java相关代码:
实际上,GCC -C1就能把 sqrt(MM) 优化成常量了:
LCPI0_0(%rip)就是 double 479.58315233127195
相比之下,JAVA的JVM code和C1都不会把sqrt(230000)优化成常量,C2则会优化。如java 版 C1优化的循环体内调用的还是下面这句:
0x0000000110f7c229: vsqrtsd %xmm1,%xmm1,%xmm1 ;*invokestatic sqrt ; - TwoSqrt::D@19 (line 46)
java 版 C2优化的循环体内调用的还是下面这句:
就是说循环的相加根号后的数值已经不存在sqrt指令了。即,C2优化和GCC达到同样效果了。
上述也可见,DD和DI耗时在优化前差距还是很明显的,C2优化后差距减少了些。
此外,对于for循环,编译器/Java也有优化,比如for循环步长由 1 变为16(add $0x10,%ecx)为一个批次,减少了跳转指令的使用。
JAVA C2 针对DD优化采用了和GCC一样的思路,代码在下面可以看到,不过似乎有点区别是JAVA这里16个寄存器都用上了,而C用了四个,看起来不如C优化的那么紧凑。
这里贴一下C2之后代码,看看能不能发现什么。
其它
硬件:MacBook Pro 2017
JAVA Version:
java version “1.8.0_221”
Java(TM) SE Runtime Environment (build 1.8.0_221-b11)
Java HotSpot(TM) 64-Bit Server VM (build 25.221-b11, mixed mode)
outro1: 篇幅限制,后面两个问题留到下一篇技术文里解答了。
outro2: 8月初开始准备写,中间因太忙,放下一段时间,可能内容不是连贯的,欢迎反馈。