卡马克是最快的开根号方法吗

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循环来统计耗时,暂不考虑精度以及其他问题,且先相信只要循环次数大,耗时差距大,就可以得出足够可信的结论。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
#include <stdio.h>
#include <math.h>
#include <stdlib.h>
#include <time.h>
double FLT_MIN = 1e-7;
double sqrt_newton(int x) {
if(x <= 0) return 0;
double res, lastres;
res = x; //初始值,可以为任意非0的值
do{
lastres = res;
res = (res + x/res)/2;
}while(fabs(lastres-res) > FLT_MIN);
return res;
}
double sqrt_newton_int(int x) {
if(x <= 0) return 0;
double res, lastres;
res = x;
int last = 0;
do{
lastres = res;
res = (res + x/res)/2;
int cur = (int)res;
if (last == cur){
return res;
}
last = cur;
}while(fabs(lastres-res) > FLT_MIN);
return res;
}
float InvSqrt(float x){
float xhalf = 0.5f * x;
int i = *(int*)&x;
i = 0x5f375a86 - (i>>1);
x = *(float*)&i;
x = x*(1.5f-xhalf*x*x);
x = x*(1.5f-xhalf*x*x);
x = x*(1.5f-xhalf*x*x);
return 1/x;
}
int main(int argc, char **argv){
clock_t begin, end;
int num = atoi(argv[1]);
double res1=0,res2=0,res4=0;
float res3=0;
int i;
float num_f;
int loopcnts = 1000000;
if(sizeof(argv)>2){
loopcnts = atoi(argv[2]);
}
begin = clock();
for(i = 0; i < loopcnts; i++){
res1+=sqrt_newton_int(num+i);
res2+=sqrt_newton(num+i);
res3+=InvSqrt(num+i);
res4+=sqrt(num+i);
}
end = clock();
printf("hot %f\t:%f,%f,%f,%f\n", (double)(end-begin),res1,res2,res3,res4);
begin = clock();
for(i = 0; i < loopcnts; i++)
res1 = sqrt_newton(num);
end = clock();
printf(" newton_cos(%d) = %f, \t\tcost: %f\n", num, res1, (double)(end-begin));
begin = clock();
for(i = 0; i < loopcnts; i++)
res2 = sqrt_newton_int(num);
end = clock();
printf("newton_int(%d) = %f, \t\tcost: %f\n", num, res2, (double)(end-begin));
num_f=1.0f*num;
begin = clock();
for(i = 0; i < loopcnts; i++)
res3 = InvSqrt(num_f);
end = clock();
printf(" InvSqrt(%d) = %f, \t\tcost: %f\n", num, res3, (double)(end-begin));
begin = clock();
for(i = 0; i < loopcnts; i++)
res4 = sqrt(num);
end = clock();
printf("sys sqrt(%d) = %f, \t\tcost: %f\n", num, res4, (double)(end-begin));
// printf("res:%f, %f, %f, %f", res1,res2,res3,res4);
}

因int范围限制,故每轮采取循环1亿次,多运行几次,发现输出下面结果

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜ Test gcc quic_newton.c -o quic_newton
➜ Test ./quic_newton 2855 100000000
hot 40405059.000000 :666695110416.069336,666695110197.906128,274877906944.000000,666695110197.906128
newton_cos(2855) = 53.432200, cost: 11803758.000000
newton_int(2855) = 53.432201, cost: 10048574.000000
InvSqrt(2855) = 53.432198, cost: 967581.000000
sys sqrt(2855) = 53.432200, cost: 614861.000000
➜ Test
➜ Test gcc -O3 quic_newton.c -o quic_newton
➜ Test ./quic_newton 2855 100000000
hot 14997438.000000 :666695110416.069336,666695110197.906128,274877906944.000000,666695110197.906128
newton_cos(2855) = 53.432200, cost: 3292126.000000
newton_int(2855) = 53.432201, cost: 2845886.000000
InvSqrt(2855) = 53.432198, cost: 1.000000
sys sqrt(2855) = 53.432200, cost: 0.000000
...
➜ Test gcc -O1 quic_newton.c -o quic_newton
➜ Test ./quic_newton 2855 100000000
hot 19265085.000000 :666695110416.069336,666695110197.906128,274877906944.000000,666695110197.906128
newton_cos(2855) = 53.432200, cost: 1.000000
newton_int(2855) = 53.432201, cost: 0.000000
InvSqrt(2855) = 53.432198, cost: 0.000000
sys sqrt(2855) = 53.432200, cost: 0.000000
➜ Test

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优化掉。查计算机指令周期可知,相对于前三个计算方法这个时间影响也很小)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
➜ Test gcc quic_newton.c -o quic_newton
➜ Test ./quic_newton 2500 100000000
hot 35850465.000000 :666691578733.306519,666691578514.627563,274877906944.000000,666691578514.627686
newton_inl(2500.000000) = 666691578514.627563, cost: 17829585.000000
newton_cos(2500.000000) = 666691578514.627563, cost: 17944181.000000
newton_int(2500.000000) = 666691578733.306519, cost: 18475511.000000
InvSqrt(2500.000000) = 274877906944.000000, cost: 3108139.000000
sys sqrt(2500.000000) = 666691578514.627686, cost: 252962.000000
➜ Test gcc -O3 quic_newton.c -o quic_newton
➜ Test ./quic_newton 2500 100000000
hot 15424798.000000 :666691578733.306519,666691578514.627563,274877906944.000000,666691578514.627686
newton_inl(2500.000000) = 666691578514.627563, cost: 7050614.000000
newton_cos(2500.000000) = 666691578514.627563, cost: 7113696.000000
newton_int(2500.000000) = 666691578733.306519, cost: 7668225.000000
InvSqrt(2500.000000) = 274877906944.000000, cost: 417802.000000
sys sqrt(2500.000000) = 666691578514.627686, cost: 168760.000000
➜ Test ./quic_newton 2500 100000000
hot 15394188.000000 :666691578733.306519,666691578514.627563,274877906944.000000,666691578514.627686
newton_inl(2500.000000) = 666691578514.627563, cost: 7063698.000000
newton_cos(2500.000000) = 666691578514.627563, cost: 7111073.000000
newton_int(2500.000000) = 666691578733.306519, cost: 7663540.000000
InvSqrt(2500.000000) = 274877906944.000000, cost: 416777.000000
sys sqrt(2500.000000) = 666691578514.627686, cost: 167816.000000
➜ Test

请注意上述命令执行时的参数和输出,可以看到InvSqrt接近一个数量级的提升,sys sqrt 40%提升。这里的优化显然是减少了循环体内的指令。
比如:

1
2
3
4
begin = clock();
for(i = 0; i < loopcnts; i++)
res4 += sqrt(num+i);
end = clock();

对比一下 -O0和-O1关键部分

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
# -O0
callq _clock
movq %rax, -24(%rbp)
movl $0, -72(%rbp)
LBB3_24: ## =>This Inner Loop Header: Depth=1
movl -72(%rbp), %eax
cmpl -80(%rbp), %eax
jge LBB3_27
## %bb.25: ## in Loop: Header=BB3_24 Depth=1
movsd -40(%rbp), %xmm0 ## xmm0 = mem[0],zero
movl -72(%rbp), %eax
cvtsi2sdl %eax, %xmm1
addsd %xmm1, %xmm0
sqrtsd %xmm0, %xmm0
addsd -64(%rbp), %xmm0
movsd %xmm0, -64(%rbp)
## %bb.26: ## in Loop: Header=BB3_24 Depth=1
movl -72(%rbp), %eax
addl $1, %eax
movl %eax, -72(%rbp)
jmp LBB3_24
LBB3_27:
callq _clock
#-O1
callq _clock
movsd -48(%rbp), %xmm3 ## 8-byte Reload
## xmm3 = mem[0],zero
movq %rax, %r14
testl %r15d, %r15d
xorpd %xmm4, %xmm4
jle LBB3_22
## %bb.20:
xorpd %xmm0, %xmm0
movsd LCPI3_0(%rip), %xmm1 ## xmm1 = mem[0],zero
xorpd %xmm4, %xmm4
.p2align 4, 0x90
LBB3_21: ## =>This Inner Loop Header: Depth=1
movapd %xmm3, %xmm2
addsd %xmm0, %xmm2
sqrtsd %xmm2, %xmm2
addsd %xmm2, %xmm4
addsd %xmm1, %xmm0
decl %r15d
jne LBB3_21
LBB3_22:
movsd %xmm4, -40(%rbp) ## 8-byte Spill
callq _clock

-O1 优化掉了一个int转浮点型,而且还巧妙的通过自增的方式减少了指令。即优化后相当于

1
2
3
4
5
double initial=2500f;
for(int i=10...0;i>0;i--){
sqrt(initial);
initial=initial+1.0f
}

上面这个优化有趣,我在后面的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里效果怎么样

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
// JAVA 1
public class MathFunc {
public static double sqrt_(double t, Double precise) {
double x0 = t, x1, differ;
double prec = 1e-7;
while (true) {
x1 = (x0/2 + t/(2*x0));
differ = x1 * x1 - t;
if (differ < prec && differ > -prec) {
return x1;
}
x0 = x1;
}
}
public static double sqrt_INT(double t, Double precise) {
double x0 = t, x1, differ;
double prec = 1e-7;
int last = (int)x0;
while (true) {
x1 = (x0/2 + t/(2*x0));
differ = x1 * x1 - t;
if ((differ <= prec && differ >= -prec)) {
return x1;
}
x0 = x1;
int cur = (int)x0;
if (cur == last) {
return cur;
}
last = cur;
}
}
public static void main(String[] args) {
for (int i = 0; i < 1000; i++) {
sqrt_(230, null);
sqrt_INT(230, null);
Math.sqrt(230000);
}
double MM = 2855;//230000
double s1=0,s2=0,s3=0;
long start = System.nanoTime();
int NN = 1_0000_0000;
for (int i = 0; i < NN; i++) {
s1 += sqrt_(MM, null);
}
long start2 = System.nanoTime();
for (int i = 0; i < NN; i++) {
s2+= sqrt_INT(MM, null);
}
long start3 = System.nanoTime();
for (int i = 0; i < NN; i++) {
s3+= Math.sqrt(MM+i);
}
long start4 = System.nanoTime();
System.out.println(String.format("%s-%s-%s", (start2-start)/1e3, (start3-start2)/1e3, (start4-start3)/1e3));
System.out.println(String.format("%s-%s-%s", s1,s2,s3));
}
}

上文问题3,这里解答下:
如果纯粹从理论上讲,通过判断减少循环次数,则耗时应该减少,但实际上并不这样,因为double强转int也是耗时的操作,当这个耗时足以弥补减少循环的耗时时,才会得到更少的耗时。可以简单修改 MM 来验证下,实际上MM=2500/100 则确实耗时更少,MM=2837耗时则几乎相等,MM=2839/2840/2850则耗时多。
因为接近平方数 INT版本收敛快。
上面验证较简单,就不展开。不过从这段代码,让我们看一点有趣的发现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
// JAVA 2
public class TwoSqrt {
final static double MM = 2855;
static int NN = 1_0000_0000;
static double s3 = 0;
static long start3 = 0, start4 = 0;
static double j = 0;
public static void main(String[] args) {
for (int i = 0; i < 100; i++) {
s3 = 0;j=0;
long d = D();
System.out.println("D:" + s3 + "\t" + d / 1e3);
s3 = 0;j=0;
long dd = DD();
System.out.println("DD:" + s3 + "\t" + dd / 1e3);
s3 = 0;
long di = DI();
System.out.println("DI:" + s3 + "\t" + di / 1e3);
}
}
static long D() {
//int s3=0;
long start3 = System.nanoTime();
for (int i = 0; i < NN; i++) {
s3 += Math.sqrt(MM);//0.1;//
}
long start4 = System.nanoTime();
return start4 - start3;
}
static long DD() {
long start3 = System.nanoTime();
for (int i = 0; i < NN; i++) {
s3 += Math.sqrt(MM + j++);
}
long start4 = System.nanoTime();
return start4 - start3;
}
static long DI() {
long start3 = System.nanoTime();
for (int i = 0; i < NN; i++) {
s3 += Math.sqrt(MM + i);
}
long start4 = System.nanoTime();
return start4 - start3;
}
}

注意上面main函数里,我把每个方法都跑了一百次,并且每个方法内部还有1亿次循环。
我在这里新写一个JAVA类,不仅仅是方便JAVA/JVM/汇编代码,还是为了引出下文发现的三个问题。
每个函数都赋值给静态变量(该方法作用域外)s3,是为了避免逃逸变量的编译优化,这个简单就不详述。
不过,让我们先看 D() 这个方法,含逃逸变量,即可能会被JIT优化的情况

1
2
3
4
5
6
7
8
9
static long D() {
double s3=0;
long start3 = System.nanoTime();
for (int i = 0; i < NN; i++) {
s3 += Math.sqrt(MM);//0.1;//i;//0.2;//
}
long start4 = System.nanoTime();
return start4 - start3;
}

上述代码的 D() 在外层循环跑两轮之后就会发现 D() 耗时几乎为零了。
单单看JVM code可能看不出来

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
0: iconst_0
1: istore_0
2: invokestatic #20 // Method java/lang/System.nanoTime:()J
5: lstore_1
6: iconst_0
7: istore_3
8: iload_3
9: getstatic #21 // Field NN:I
12: if_icmpge 25
15: iload_0
16: iload_3
17: iadd
18: istore_0
19: iinc 3, 1
22: goto 8
25: invokestatic #20 // Method java/lang/System.nanoTime:()J
28: lstore_3
29: lload_3
30: lload_1
31: lsub
32: lreturn

我们看看 C2优化之后的汇编代码

1
2
3
4
5
6
7
8
9
10
....
0x000000011408c5d6: callq *%r10 ;*invokestatic nanoTime
; - TwoSqrt::D@2 (line 25)
0x000000011408c5d9: mov %rax,%rbx
0x000000011408c5dc: movabs $0x10728ec10,%r10
0x000000011408c5e6: callq *%r10 ;*invokestatic nanoTime
; - TwoSqrt::D@25 (line 29)
0x000000011408c5e9: sub %rbx,%rax ;*lsub
; - TwoSqrt::D@31 (line 30)
....

可以看到for循环其实优化掉了,所以上述改一下,return s3; 这样循环显然是不会消除优化掉的。
好了,我们再改一下:

1
2
3
4
5
6
7
8
9
static long D() {
// double s3=0;
long start3 = System.nanoTime();
for (int i = 0; i < NN; i++) {
s3 += i;
}
long start4 = System.nanoTime();
return start4 - start3;
}

即使用全局变量 s3以及 改为 s3+=i,那么耗时会是常量还是跟for循环次数有关?
更多的,如果这里改为 s3+=0.1 或者 s3+=1;又会怎样?
实际上三者耗时不一样,但都不是几纳秒,即这个时间不是常量,和循环次数有关,也就是说,即便是 C2优化后,循环依旧。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
0x000000011a2f1130: vaddsd %xmm1,%xmm0,%xmm0
0x000000011a2f1134: mov %r11d,%r8d
0x000000011a2f1137: add $0xd,%r8d
0x000000011a2f113b: mov %r11d,%r9d
0x000000011a2f113e: add $0xe,%r9d
0x000000011a2f1142: vcvtsi2sd %r8d,%xmm3,%xmm3
0x000000011a2f1147: vcvtsi2sd %r9d,%xmm4,%xmm4
0x000000011a2f114c: mov %r11d,%r8d
0x000000011a2f114f: add $0xc,%r8d
0x000000011a2f1153: mov %r11d,%r9d
0x000000011a2f1156: add $0xb,%r9d
0x000000011a2f115a: vcvtsi2sd %r8d,%xmm5,%xmm5
0x000000011a2f115f: vcvtsi2sd %r9d,%xmm6,%xmm6
0x000000011a2f1164: mov %r11d,%r8d
0x000000011a2f1167: add $0xa,%r8d
0x000000011a2f116b: mov %r11d,%r9d
0x000000011a2f116e: add $0x8,%r9d
0x000000011a2f1172: vcvtsi2sd %r8d,%xmm7,%xmm7
0x000000011a2f1177: vcvtsi2sd %r9d,%xmm8,%xmm8
0x000000011a2f117c: mov %r11d,%r8d
0x000000011a2f117f: add $0x7,%r8d
0x000000011a2f1183: mov %r11d,%r9d
0x000000011a2f1186: add $0x6,%r9d
0x000000011a2f118a: vcvtsi2sd %r8d,%xmm9,%xmm9
0x000000011a2f118f: vcvtsi2sd %r9d,%xmm10,%xmm10
0x000000011a2f1194: mov %r11d,%r8d
0x000000011a2f1197: add $0x5,%r8d
0x000000011a2f119b: mov %r11d,%r9d
0x000000011a2f119e: add $0x4,%r9d
0x000000011a2f11a2: vcvtsi2sd %r8d,%xmm11,%xmm11
0x000000011a2f11a7: vcvtsi2sd %r9d,%xmm12,%xmm12
0x000000011a2f11ac: mov %r11d,%r8d
0x000000011a2f11af: add $0x3,%r8d
0x000000011a2f11b3: mov %r11d,%r9d
0x000000011a2f11b6: add $0x2,%r9d
0x000000011a2f11ba: vcvtsi2sd %r8d,%xmm13,%xmm13
0x000000011a2f11bf: vcvtsi2sd %r9d,%xmm1,%xmm1
0x000000011a2f11c4: mov %r11d,%r8d
0x000000011a2f11c7: add $0xf,%r8d
0x000000011a2f11cb: mov %r11d,%r9d
0x000000011a2f11ce: inc %r9d
0x000000011a2f11d1: vcvtsi2sd %r8d,%xmm14,%xmm14
0x000000011a2f11d6: vcvtsi2sd %r9d,%xmm2,%xmm2
0x000000011a2f11db: vaddsd %xmm0,%xmm2,%xmm0
0x000000011a2f11df: vaddsd %xmm1,%xmm0,%xmm0
0x000000011a2f11e3: vaddsd %xmm13,%xmm0,%xmm0
0x000000011a2f11e8: vaddsd %xmm12,%xmm0,%xmm0
0x000000011a2f11ed: vaddsd %xmm11,%xmm0,%xmm0
0x000000011a2f11f2: vaddsd %xmm10,%xmm0,%xmm0
0x000000011a2f11f7: vaddsd %xmm9,%xmm0,%xmm0
0x000000011a2f11fc: vaddsd %xmm8,%xmm0,%xmm0
0x000000011a2f1201: mov %r11d,%r8d
0x000000011a2f1204: add $0x9,%r8d
0x000000011a2f1208: vcvtsi2sd %r8d,%xmm1,%xmm1
0x000000011a2f120d: vaddsd %xmm0,%xmm1,%xmm0
0x000000011a2f1211: vaddsd %xmm7,%xmm0,%xmm0
0x000000011a2f1215: vaddsd %xmm6,%xmm0,%xmm0
0x000000011a2f1219: vaddsd %xmm5,%xmm0,%xmm0
0x000000011a2f121d: vaddsd %xmm0,%xmm3,%xmm0
0x000000011a2f1221: vaddsd %xmm4,%xmm0,%xmm0 ;*dadd
; - TT3::D@18 (line 27)
0x000000011a2f1225: vmovsd %xmm0,0x70(%rdx) ;*putstatic s3
; - TT3::D@19 (line 27)
0x000000011a2f122a: vaddsd %xmm0,%xmm14,%xmm1 ;*dadd
; - TT3::D@18 (line 27)
0x000000011a2f122e: vmovsd %xmm1,0x70(%rdx) ;*putstatic s3
; - TT3::D@19 (line 27)
0x000000011a2f1233: add $0x10,%r11d ;*iinc
; - TT3::D@22 (line 26)
0x000000011a2f1237: vcvtsi2sd %r11d,%xmm0,%xmm0 ;*i2d
; - TT3::D@17 (line 27)
0x000000011a2f123c: cmp %ecx,%r11d
0x000000011a2f123f: jl 0x000000011a2f1130 ;*if_icmpge
; - TT3::D@10 (line 26)

上面就是 s3 += i 版的 D() C2优化后的代码,即循环确实存在的。
GCC会怎么优化呢?实际上,C还是有点不同的地方:C这里优化后耗时为常量。
以上文C代码为例:

1
2
3
4
5
begin = clock();
for(int i = 0; i < loopcnts; i++)
res += i;
end = clock();
printf("asm(%f) sumd = %ld, \t\tcost: %f\n", MM, res, (double)(end-begin));

比如在gcc -O3 时,当 res为long类型时[JAVA版long跟double都不会有这个优化],耗时为恒定的时间约4ns,因为此时GCC编译器优化为:

1
2
3
4
leal -1(%r15), %eax
leal -2(%r15), %ebx
imulq %rax, %rbx
shrq %rbx

即 n*M/2 直接得到结果了,而不用再跑完循环了。
但是当 res为double类型时,却是需要跑for循环了,即:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
LBB0_17: ## =>This Inner Loop Header: Depth=1
xorps %xmm0, %xmm0
cvtsi2sdl %ecx, %xmm0
leal 1(%rcx), %esi
xorps %xmm1, %xmm1
cvtsi2sdl %esi, %xmm1
addsd %xmm3, %xmm0
leal 2(%rcx), %esi
xorps %xmm2, %xmm2
cvtsi2sdl %esi, %xmm2
addsd %xmm0, %xmm1
leal 3(%rcx), %esi
xorps %xmm0, %xmm0
cvtsi2sdl %esi, %xmm0
addsd %xmm1, %xmm2
leal 4(%rcx), %esi
xorps %xmm1, %xmm1
cvtsi2sdl %esi, %xmm1
addsd %xmm2, %xmm0
leal 5(%rcx), %esi
xorps %xmm2, %xmm2
cvtsi2sdl %esi, %xmm2
addsd %xmm0, %xmm1
leal 6(%rcx), %esi
xorps %xmm0, %xmm0
cvtsi2sdl %esi, %xmm0
addsd %xmm1, %xmm2
leal 7(%rcx), %esi
xorps %xmm3, %xmm3
cvtsi2sdl %esi, %xmm3
addsd %xmm2, %xmm0
addsd %xmm0, %xmm3
addl $8, %ecxa
cmpl %ecx, %edx
jne LBB0_17
## %bb.6:
testl %eax, %eax
je LBB0_9

这里插一句,如果你怀疑上文测试是否足够准确,那可以验证下这段代码:

1
2
3
4
5
6
7
8
9
10
11
begin = clock();
for(int i = 0; i < loopcnts; i++)
res += i;
end = clock();
printf("asm(%f) sumd = %f, \t\tcost: %f\n", MM, res, (double)(end-begin));
res=0;
begin = clock();
for(int i = 0; i < loopcnts; i++)
res += sqrt(MM+i);
end = clock();
printf("asm(%f) sums = %f, \t\tcost: %f\n", MM, res, (double)(end-begin));

在我的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,运行几次,我们可看到跑出来的数据:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
DD :6.666951101979061e+11 176899.189
DI :6.666951101979061e+11 672762.318
DL :6.666951101979061e+11 211739.138
DIJ:6.666951101979061e+11 292870.325
II :6.666695084696677e+11 228337.677
------------
DD :6.666951101979061e+11 171914.117
DI :6.666951101979061e+11 732866.289
DL :6.666951101979061e+11 220012.218
DIJ:6.666951101979061e+11 405955.404
II :6.666695084696677e+11 223352.986
------------
DD :6.666951101979061e+11 168237.792
DI :6.666951101979061e+11 254137.578
DL :6.666951101979061e+11 450223.373
DIJ:6.666951101979061e+11 265043.075
II :6.666695084696677e+11 255281.007
------------
DD :6.666951101979061e+11 169216.409
DI :6.666951101979061e+11 258624.100
DL :6.666951101979061e+11 443366.215
DIJ:6.666951101979061e+11 265902.974
II :6.666695084696677e+11 255865.587
....

这个结果和我用JMH测试下来接近:JMHBnhSqrt.java

1
2
3
4
5
6
Benchmark Mode Cnt Score Error Units
JMHBnhSqrt.benchLoopDD avgt 3 170126363.572 ± 18897173.028 ns/op
JMHBnhSqrt.benchLoopDI avgt 3 304366448.848 ± 10242902.502 ns/op
JMHBnhSqrt.benchLoopDIJ avgt 3 380274911.025 ± 311415636.755 ns/op
JMHBnhSqrt.benchLoopII avgt 3 270527877.936 ± 68992367.132 ns/op
JMHBnhSqrt.sqrt avgt 3 1.482 ± 0.059 ns/op

其中:
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相关代码:

1
2
3
4
// 如对于:
double ss=0;
for(int i = 0; i < 1000000; i++)
ss += sqrt(230000);

实际上,GCC -C1就能把 sqrt(MM) 优化成常量了:

1
2
3
4
5
6
7
8
9
_main: ## @main
...
movl $1000000, %eax ## imm = 0xF4240
movsd LCPI0_0(%rip), %xmm1 ## xmm1 = mem[0],zero
.p2align 4, 0x90
LBB0_1: ## =>This Inner Loop Header: Depth=1
addsd %xmm1, %xmm0
decl %eax
jne LBB0_1

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优化的循环体内调用的还是下面这句:

1
2
3
4
5
L0002: vaddsd -0x138(%rip),%xmm0,%xmm0 # 0x0000000110f84480 ;*dadd ; - TwoSqrt::D@22 (line 46) ;
0x0000000110f845b8: vmovsd %xmm0,0x70(%r10) ;*putstatic s3 ; - TwoSqrt::D@23 (line 46)
0x0000000110f845be: inc %ecx ;*iinc ; - TwoSqrt::D@26 (line 45)
0x0000000110f845c0: cmp %r8d,%ecx
0x0000000110f845c3: jl L0002 ;*if_icmpge ; - TwoSqrt::D@10 (line 45)

就是说循环的相加根号后的数值已经不存在sqrt指令了。即,C2优化和GCC达到同样效果了。

上述也可见,DD和DI耗时在优化前差距还是很明显的,C2优化后差距减少了些。
此外,对于for循环,编译器/Java也有优化,比如for循环步长由 1 变为16(add $0x10,%ecx)为一个批次,减少了跳转指令的使用。
JAVA C2 针对DD优化采用了和GCC一样的思路,代码在下面可以看到,不过似乎有点区别是JAVA这里16个寄存器都用上了,而C用了四个,看起来不如C优化的那么紧凑。
这里贴一下C2之后代码,看看能不能发现什么。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
L0000: vaddsd -0xd8(%rip),%xmm1,%xmm2 # 0x000000010feed180
; {section_word}
0x000000010feed258: vaddsd -0xe0(%rip),%xmm2,%xmm1 # 0x000000010feed180
;*dadd
; - TwoSqrt::DD@26 (line 64)
; {section_word}
0x000000010feed260: vaddsd -0xe0(%rip),%xmm2,%xmm2 # 0x000000010feed188
; {section_word}
0x000000010feed268: vaddsd -0xe8(%rip),%xmm1,%xmm5 # 0x000000010feed188
; {section_word}
0x000000010feed270: vsqrtsd %xmm2,%xmm3,%xmm3
0x000000010feed274: vsqrtsd %xmm5,%xmm2,%xmm2 ;*invokestatic sqrt
; - TwoSqrt::DD@29 (line 64)
L0001: vaddsd %xmm4,%xmm0,%xmm0
0x000000010feed27c: vaddsd -0x104(%rip),%xmm1,%xmm1 # 0x000000010feed180
0x000000010feed284: vaddsd %xmm0,%xmm3,%xmm0
0x000000010feed288: vaddsd -0x108(%rip),%xmm1,%xmm3 # 0x000000010feed188
0x000000010feed290: vaddsd %xmm2,%xmm0,%xmm4
0x000000010feed294: vaddsd -0x11c(%rip),%xmm1,%xmm0 # 0x000000010feed180
0x000000010feed29c: vaddsd -0x124(%rip),%xmm0,%xmm1 # 0x000000010feed180
0x000000010feed2a4: vaddsd -0x124(%rip),%xmm0,%xmm2 # 0x000000010feed188
0x000000010feed2ac: vaddsd -0x12c(%rip),%xmm1,%xmm5 # 0x000000010feed188
0x000000010feed2b4: vaddsd -0x13c(%rip),%xmm1,%xmm0 # 0x000000010feed180
0x000000010feed2bc: vaddsd -0x144(%rip),%xmm0,%xmm1 # 0x000000010feed180
0x000000010feed2c4: vaddsd -0x144(%rip),%xmm0,%xmm6 # 0x000000010feed188
0x000000010feed2cc: vaddsd -0x14c(%rip),%xmm1,%xmm7 # 0x000000010feed188
0x000000010feed2d4: vaddsd -0x15c(%rip),%xmm1,%xmm0 # 0x000000010feed180
0x000000010feed2dc: vaddsd -0x164(%rip),%xmm0,%xmm1 # 0x000000010feed180
0x000000010feed2e4: vaddsd -0x164(%rip),%xmm0,%xmm8 # 0x000000010feed188
0x000000010feed2ec: vaddsd -0x16c(%rip),%xmm1,%xmm9 # 0x000000010feed188
0x000000010feed2f4: vaddsd -0x17c(%rip),%xmm1,%xmm0 # 0x000000010feed180
0x000000010feed2fc: vaddsd -0x184(%rip),%xmm0,%xmm1 # 0x000000010feed180
0x000000010feed304: vaddsd -0x184(%rip),%xmm0,%xmm10 # 0x000000010feed188
0x000000010feed30c: vaddsd -0x18c(%rip),%xmm1,%xmm11 # 0x000000010feed188
0x000000010feed314: vaddsd -0x19c(%rip),%xmm1,%xmm0 # 0x000000010feed180
0x000000010feed31c: vaddsd -0x1a4(%rip),%xmm0,%xmm1 # 0x000000010feed180
0x000000010feed324: vaddsd -0x1a4(%rip),%xmm0,%xmm12 # 0x000000010feed188
0x000000010feed32c: vaddsd -0x1ac(%rip),%xmm1,%xmm13 # 0x000000010feed188
0x000000010feed334: vaddsd -0x1bc(%rip),%xmm1,%xmm0 # 0x000000010feed180
0x000000010feed33c: vaddsd -0x1c4(%rip),%xmm0,%xmm1 # 0x000000010feed180
0x000000010feed344: vaddsd -0x1c4(%rip),%xmm0,%xmm0 # 0x000000010feed188
0x000000010feed34c: vaddsd -0x1cc(%rip),%xmm1,%xmm14 # 0x000000010feed188
0x000000010feed354: vaddsd -0x1dc(%rip),%xmm1,%xmm1 # 0x000000010feed180
;*dadd
; - TwoSqrt::DD@26 (line 64)
; {section_word}
0x000000010feed35c: vaddsd -0x1dc(%rip),%xmm1,%xmm15 # 0x000000010feed188
; {section_word}
0x000000010feed364: vsqrtsd %xmm3,%xmm3,%xmm3
0x000000010feed368: vaddsd %xmm4,%xmm3,%xmm3
0x000000010feed36c: vsqrtsd %xmm15,%xmm4,%xmm4 ;*invokestatic sqrt
; - TwoSqrt::DD@29 (line 64)
0x000000010feed371: vsqrtsd %xmm14,%xmm14,%xmm14
0x000000010feed376: vsqrtsd %xmm0,%xmm15,%xmm15
0x000000010feed37a: vsqrtsd %xmm13,%xmm13,%xmm13
0x000000010feed37f: vsqrtsd %xmm12,%xmm12,%xmm12
0x000000010feed384: vsqrtsd %xmm11,%xmm11,%xmm11
0x000000010feed389: vsqrtsd %xmm10,%xmm10,%xmm10
0x000000010feed38e: vsqrtsd %xmm9,%xmm9,%xmm9
0x000000010feed393: vsqrtsd %xmm8,%xmm8,%xmm8
0x000000010feed398: vsqrtsd %xmm7,%xmm7,%xmm7
0x000000010feed39c: vsqrtsd %xmm6,%xmm6,%xmm6
0x000000010feed3a0: vsqrtsd %xmm5,%xmm5,%xmm5
0x000000010feed3a4: vsqrtsd %xmm2,%xmm0,%xmm0
0x000000010feed3a8: vaddsd %xmm3,%xmm0,%xmm0
0x000000010feed3ac: vaddsd %xmm0,%xmm5,%xmm0
0x000000010feed3b0: vaddsd %xmm0,%xmm6,%xmm0
0x000000010feed3b4: vaddsd %xmm0,%xmm7,%xmm0
0x000000010feed3b8: vaddsd %xmm0,%xmm8,%xmm0
0x000000010feed3bc: vaddsd %xmm9,%xmm0,%xmm0
0x000000010feed3c1: vaddsd %xmm0,%xmm10,%xmm0
0x000000010feed3c5: vaddsd %xmm0,%xmm11,%xmm0
0x000000010feed3c9: vaddsd %xmm0,%xmm12,%xmm0
0x000000010feed3cd: vaddsd %xmm0,%xmm13,%xmm0
0x000000010feed3d1: vaddsd %xmm0,%xmm15,%xmm0 ;*dadd
; - TwoSqrt::DD@32 (line 64)
0x000000010feed3d5: vmovsd %xmm0,0x70(%r10) ;*putstatic s3
; - TwoSqrt::DD@33 (line 64)
0x000000010feed3db: vaddsd %xmm0,%xmm14,%xmm0 ;*dadd
; - TwoSqrt::DD@32 (line 64)
0x000000010feed3df: vmovsd %xmm0,0x70(%r10) ;*putstatic s3
; - TwoSqrt::DD@33 (line 64)
0x000000010feed3e5: add $0x10,%ecx ;*iinc
; - TwoSqrt::DD@36 (line 63)
0x000000010feed3e8: cmp %r9d,%ecx
0x000000010feed3eb: jl L0000 ;*if_icmpge
; - TwoSqrt::DD@14 (line 63)
L0002: cmp %r8d,%ecx
0x000000010feed3f4: jge L0004
0x000000010feed3f6: xchg %ax,%ax ;*getstatic s3
; - TwoSqrt::DD@17 (line 64)

其它

硬件: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月初开始准备写,中间因太忙,放下一段时间,可能内容不是连贯的,欢迎反馈。

Ref

  1. 牛顿迭代法求开方根
  2. 如何通俗易懂地讲解牛顿迭代法
  3. Best-Square-Root-Method
  4. RSQRTSS — Compute Reciprocal of Square Root of Scalar Single-Precision Floating-Point Value
  5. performance implications of using vmovups and vmovapd
  6. Intel指令周期
  7. To my father. [1955-2019.08.23]