返回信息流[不要黑Go语言] 这段程序 Java 速度不是 Go 的 2 倍!
十分感谢暖神 (@nuanyangyang) 在 https://bbs.byr.cn/#!article/Golang/177 中发帖,对于 Go 的性能做了有趣的 benchmark。看到下面同学的回复,尤其是 @gaotianlong 同学的跑了 perf,我更好奇了:为什么 Go 会这么慢呢?
环境:Arch Linux
* linux 4.6.2-1
* gcc 6.1.1-1
* go 2:1.6.2-2
* jdk 8u92-1
* perf 4.6-4
* Intel(R) Core(TM) i5-3230M CPU @ 2.60GHz
* 4GB RAM
测试方法:
* g++ t.cc -o c++ -O0
* javac IsPrime.java; java IsPrime
* go build -x -v t.go
因为 JIT 不方便测试,这里以 C++ 版本和 Go 版本做比较。跑一下 perf:
C++:
│ Disassembly of section .text:
│
│ 0000000000400646 <is_prime(int)>:
│ _Z8is_primei():
│ push %rbp
│ mov %rsp,%rbp
│ mov %edi,-0x14(%rbp)
│ movl $0x2,-0x4(%rbp)
10.71 │ e: mov -0x4(%rbp),%eax
│ cmp -0x14(%rbp),%eax
│ ↓ jge 30
0.06 │ mov -0x14(%rbp),%eax
│ cltd
10.53 │ idivl -0x4(%rbp)
68.78 │ mov %edx,%eax
│ test %eax,%eax
│ ↓ jne 2a
│ mov $0x0,%eax
│ ↓ jmp 35
9.92 │2a: addl $0x1,-0x4(%rbp)
│ ↑ jmp e
│30: mov $0x1,%eax
│35: pop %rbp
│ ← retq
Go:
│ Disassembly of section .text:
│
│ 0000000000401000 <main.isPrime>:
│ main.isPrime():
│ import (
│ "fmt"
│ "time"
│ )
│
│ func isPrime(n int) bool {
│ mov 0x8(%rsp),%rsi
│ for i := 2; i < n; i++ {
│ mov $0x2,%rcx
│ cmp %rsi,%rcx
│ ↓ jge 36
│ if n%i == 0 {
3.22 │11: mov %rsi,%rax
│ cmp $0xffffffffffffffff,%rcx
│ ↓ je 3c
0.02 │ cqto
│ idiv %rcx
94.12 │ mov %rdx,%rbx
2.62 │22: cmp $0x0,%rbx
│ ↓ jne 2e
│ return false
│ movb $0x0,0x10(%rsp)
│ ← retq
│ "fmt"
│ "time"
│ )
│
│ func isPrime(n int) bool {
│ for i := 2; i < n; i++ {
│2e: inc %rcx
│ cmp %rsi,%rcx
│ ↑ jl 11
│ if n%i == 0 {
│ return false
│ }
│ }
│ return true
│36: movb $0x1,0x10(%rsp)
0.02 │ ← retq
│ "time"
│ )
│
│ func isPrime(n int) bool {
│ for i := 2; i < n; i++ {
│ if n%i == 0 {
│3c: xor %ebx,%ebx
│ ↑ jmp 22
看起来 Go 的版本,比 GCC 开 O0 产生的代码更干净,大多数操作都跑到寄存器里了。但为什么这么慢呢?94.12% 的时间都花在 mov %rdx,%rbx 了,而 GCC 运行等同的逻辑(取余数),mov %edx,%eax,只有 68.78%?
等等!好像有点不对劲。Go 用的是 64bit 的寄存器(rdx, rbx),而 GCC 用的是 32bit 的寄存器(edx, eax)!整数的长度都不一样,这还能一起愉快地玩耍吗?
来验证一下猜想。把 t.cc 里面的 int 换成 int64_t,把 IsPrime.java 的 int 换成 long,再跑一下。
tmp % ./c++ 12:45
Result: true, time: 1.134506 seconds
tmp % ./t 12:45
Result: true, time: 1.163265429 seconds% tmp % java IsPrime 12:45
Picked up _JAVA_OPTIONS: -Dawt.useSystemAAFontSettings=on -Dswing.aatext=true -Dswing.defaultlaf=com.sun.java.swing.plaf.gtk.GTKLookAndFeel
Result: true, time: 1.163896 seconds%
哈!耗时差不多了。Go 和 Java 不相上下,虽然比 C 慢了一些,但完全是可以接受的。
最后还是要拜一下暖神,希望他能继续带来更多有趣且有价值的讨论。
PS:我是一个 C 程序员,Java 只有课程上写过一点,Go 完全没有写过,所以谈不上是 Go 的黑或粉。不过 C 大法好!大法好!好!
这是一条镜像帖。来源:北邮人论坛 / golang / #253同步于 2016/6/12
该镜像源已超过 30 天没有更新,可能在源站已被删除。
Golang机器人发帖
【不要黑Go语言】这段程序 Java 速度是 Go 的 2 倍?
wmzhere
2016/6/12镜像同步10 回复
订阅后,新回复会通过你的通知中心匿名送达。
9 条回复
你用perf测量的吗?
perf的测量原理是每隔一段时间用信号中断进程一次,然后看每个栈。换句话说,就是“随机地中断进程,看它停在哪里”。这样注定了perf的测量精度只能达到几ms一个样本,这个频率实在是太低了。
而且,我对于“大量时间耗在对两个寄存器进行mov的指令上”非常怀疑。对于Ivy Bridge和以后的Intel CPU,它可以将某些idiom(常见的用法,如mov从一个寄存器移到另一个寄存器,用xor把一个寄存器置零,等等)从前端直接去除。现代的CPU是超标量流水线的,虽说指令集定义了只有16个通用寄存器(64位),实际上的物理寄存器可以有100多个!而CPU真正内部会用register renaming来把architectrual register映射成physical register。所以,mov rdx, rax真正做的只是告诉CPU里的执行引擎:现在rdx的值是和原来rax的值一样的,让rdx指向和rax一样的物理寄存器即可。在这种情况下,mov指令根本进入不了ALU,解码的时候就没有了。而Intel现代的处理器每个核每个指令周期都可以解码4-5条指令。这种情况下mov指令应该是0代价的才对,绝不应该是90%的时间耗在mov上。
之所以会得出这样的结论,我猜测是和“中断”的实现方式有关。CPU会进行speculative execution(就是预测式的执行。在跳转指令还没有确定之前,先假设一个分支是真,预测执行,如果不对,就回滚),但一旦发生中断,还没执行完的要么要执行完,要么回滚,要么给出某个相当于顺序执行的结果(毕竟speculation只是优化)。很有可能由于CPU内部实现的细节,CPU经常回滚到那个mov上,然后告诉perf:“当前的RIP寄存器指向那个mov指令”。这就造成了“90%的时间在执行mov指令”的假象。
至于perf报告的IPC(instruction per cycle),是用CPU内部的硬件performance counter读出来的,是可靠的。上下文切换次数可以用操作系统的统计来读出来。perf更常用于大型程序里统计各个函数的占用的时间比(栈里面的帧变化得远不及RIP这么快),对这么小的程序就……
我是描述解决问题的过程,通过 perf “歪打正着”得到了结果。就算不用 perf,直接拿 objdump 看汇编,也是可以发现问题的。无论是 perf 还是 objdump,最终都得到了一个结论:除了 Go 外,其他的语言都使用的是 32bit 的 int;当全部的语言都使用 64bit 时,性能的鸿沟消失了。
脱离语言谁快谁慢的背景,讨论 “mov” 的耗时是很有趣的。我原先猜想,大量时间是花费在 mov 前的 idiv 上的;两个指令在乱序执行的时候被“绑”在一起。谢谢暖神的科普,有深度。估计透彻的分析只有 Intel 的工程师才可以做到吧……
还有就是你的C++没有开优化。G++在没开优化的时候,局部变量都是真的写入内存的。看这些:
│ mov %edi,-0x14(%rbp)
│ movl $0x2,-0x4(%rbp)
10.71 │ e: mov -0x4(%rbp),%eax
│ cmp -0x14(%rbp),%eax
│ ↓ jge 30
0.06 │ mov -0x14(%rbp),%eax
│ cltd
那些-0x4(%rbp)都是从内存里读写,而后边的%eax决定读的是4字节。这时候显然读4个字节和读8个字节就有区别了。
【 在 wmzhere 的大作中提到: 】
: 我是描述解决问题的过程,通过 perf “歪打正着”得到了结果。就算不用 perf,直接拿 objdump 看汇编,也是可以发现问题的。无论是 perf 还是 objdump,最终都得到了一个结论:除了 Go 外,其他的语言都使用的是 32bit 的 int;当全部的语言都使用 64bit 时,性能的鸿沟消失了。
: 脱离语言谁快谁慢的背景,讨论 “mov” 的耗时是很有趣的。我原先猜想,大量时间是花费在 mov 前的 idiv 上的;两个指令在乱序执行的时候被“绑”在一起。谢谢暖神的科普,有深度。估计透彻的分析只有 Intel 的工程师才可以做到吧……
主要是懒得开 fno-inline。最开始开 O3 了,然后 is_prime 被内联到 main 里面,读汇编更费劲一些。吐槽:AT&T 风格的汇编太难受了……
如果要 gcc 开 O3,还不如直接上 icc 呢,哈哈!
【 在 nuanyangyang 的大作中提到: 】
: 还有就是你的C++没有开优化。G++在没开优化的时候,局部变量都是真的写入内存的。看这些:
: [code=text]
: │ mov %edi,-0x14(%rbp)
: ...................
【 在 wmzhere 的大作中提到: 】
: 主要是懒得开 fno-inline。最开始开 O3 了,然后 is_prime 被内联到 main 里面,读汇编更费劲一些。吐槽:AT&T 风格的汇编太难受了……
: 如果要 gcc 开 O3,还不如直接上 icc 呢,哈哈!
重新实验了一下。换成int64_t以后就和go差别在10%以内了。看样子除法运算才是瓶颈。
这样也可以通过你的例子解释:因为mov总是紧接在idiv之后。如果idiv消耗了90%的时间,那么大多数中断也就发生在“idiv的过程中”。所以程序停下来的时间就很可能是mov了。
重新做了个实验。go也换用32位整数。下面是新的程序和时间。
package main
import (
"fmt"
"time"
)
func isPrime(n int32) bool {
var i int32
for i = 2; i < n; i++ {
if n%i == 0 {
return false
}
}
return true
}
const NUM int32 = 111181111
func main() {
t1 := time.Now() // 不是单调的。应该使用Linux的MONOTONIC_CLOCK
result := isPrime(NUM)
t2 := time.Now()
fmt.Printf("Result: %v, time: %v seconds", result, t2.Sub(t1).Seconds())
}
同样的,C++和Java也用32位和64位数据类型重复。时间如下:
go 32bit: Result: true, time: 0.573170198 seconds
g++ 32bit -O3: Result: true, time: 0.597054 seconds
java 32bit: Result: true, time: 0.518461 seconds
go 64bit: Result: true, time: 1.237803342 seconds
g++ 64bit -O3: Result: true, time: 1.079401 seconds
java 64bit: Result: true, time: 1.119760 seconds
32位的情况下go和g++都可能在0.55到0.60之间波动,可以认为基本上一样了。
面壁去了……
如果中断的时候 ip 是 X,理论上不应该 X 当作正在执行的指令,而应该是上一条指令。
但是从方便处理的角度,通过 opcode 算指令的长度比较麻烦,而回退一条指令需要不断尝试,而且不一定有唯一解。
为了解决这个问题,可以直接从 X 之前最近的符号开始,一步步计算指令的长度,直到下一条指令的地址为 X 停止。这样对于长的函数(比如大量内联 / 算法相关)而言,需要计算很多指令的长度,效率很低。
所以从简化问题的角度考虑,perf 可能直接把 X 当作正在执行的指令。但是 perf 既然给了指令级的统计,想必也不会为了性能而牺牲准确度。而且显示指令统计的界面,也会反汇编整个函数。
所以 perf 到底是怎样算的呢?下载了 perf 的源码,4MB,找的太累。
【 在 nuanyangyang 的大作中提到: 】
:
: 重新实验了一下。换成int64_t以后就和go差别在10%以内了。看样子除法运算才是瓶颈。
: 这样也可以通过你的例子解释:因为mov总是紧接在idiv之后。如果idiv消耗了90%的时间,那么大多数中断也就发生在“idiv的过程中”。所以程序停下来的时间就很可能是mov了。
不知道。那都是我的猜测。我并不完全清楚CPU的行为会如何。
但是我觉得既然已经到了这么细的粒度,基本上没什么好的办法测量性能了吧。毕竟CPU里指令并不是串行执行的。
【 在 wmzhere 的大作中提到: 】
: 如果中断的时候 ip 是 X,理论上不应该 X 当作正在执行的指令,而应该是上一条指令。
: 但是从方便处理的角度,通过 opcode 算指令的长度比较麻烦,而回退一条指令需要不断尝试,而且不一定有唯一解。
: 为了解决这个问题,可以直接从 X 之前最近的符号开始,一步步计算指令的长度,直到下一条指令的地址为 X 停止。这样对于长的函数(比如大量内联 / 算法相关)而言,需要计算很多指令的长度,效率很低。
: ...................