性能优化,关于Profiling

先贤的教诲

对于大型软件系统而言,可维护性是很重要的一个方面,否则到了最后没人能够搞懂全部的代码那就是一个灾难了。这方面的例子可谓比比皆是,比如华为VRP平台的Linux内核,因为模块间耦合太严重很难升级Linux内核版本,搞到最后实在搞不下去,于是团队解散,投奔中软的RTOS。松本行弘在《代码的未来》这本书里说,开发人员的成本越来越高,机器成本则是越来越便宜,所以我们的编程语言应该要侧重于开发效率和维护效率,而不应该太过于关注性能,至于性能这件事,完全可以通过升级更牛逼的硬件来很轻松的搞定。其实Ruby之父的这个观点在很久之前就被Donald Kunth说过了,“premature  optimization is the root of all evil”。两位大师的观点可谓殊途同归。
Donald Knuth在大多数软件工程师眼里可谓神一般的存在,它的这句名言也一直被码农们牢记在心。 Donald Knuth前辈并不是反对optimization,它只是反对premature optimization,即盲目无意义的优化,optimization应该要针对critical的东西。如何寻找critical的代码,就是一个profiling系统需要做的事情。当然profiling本身也属于可维护性的一部分。

CPU体系结构

Profiling的本质,是为了充分发挥CPU的能力。现代CPU的体系结构已经很牛逼了,但是相对而言,软件开发语言则没多大进步,再加上软件开发人员水平的良莠不齐,直接的后果就是,CPU的能力没有得到充分利用。虽然说使用更牛逼的CPU更牛逼的内存更牛逼的散热系统可以大幅的提高性能,但是在相同的配置下,比别人有更牛逼的表现不是更好嘛。iPhone和三星Andriod手机的对比不就是很好的证明嘛。

现代CPU的流水线体系结构大概是这个样的。

Instruction Fetch Unit用来从memory中取指令到寄存器中,Decode and Issue Unit则是将指令翻译成CPU能够识别的编码然后发射到Functional Units(比如运算器等等)来执行这些指令,FU的执行结果再放回到寄存器中。 所以一条指令的执行完毕最好的情况下需要4个CPU cycles就可以搞定了。我们都已经知道流水线的原理就是,指令A在FU执行时,指令B在译码准备发射,指令C在被从内存中读取到寄存器中,于是这样就实现了不同指令的并行工作来提高CPU的效率。 CPU的这个设计思想被Unix开发人员学来了,于是就是有了管道(pipe)。再接着Erlang又把管道的原理用在了语言特性上来实现并发编程,最后Golang又把Erlang的管道特性给学了去。这并不是说CPU的设计人员有多么牛逼,而是说,软件开发人员要在CPU设计人员制定的规则下做事才能充分利用CPU的性能。

编译器分支预测

CPU的这种流水线存在的缺点,或者说,要依赖编程人员水平的地方在于,指令A可能是JUMP D,这样就导致指令B和指令C要被从流水线里面刷出去了,就造成CPU cycle的浪费。 要想避免这种浪费,就要要求程序自身具有很好的分支预测特性,即要充分迎合CPU的这种预测执行的特性。 在Linux内核里面随处可见的likely()/unlikely()就是做这个事情的,likely()/unlikely()是告诉编译器把if/while的代码块是紧挨着前面的代码还是放置到整个函数的最后面(因为if/while的汇编实现本来就是一个jump指令,所以可以把它跳转到任意合适的位置,这优点类似于goto)。因为CPU最后执行的是编译器生成的二进制,而不是我们写的代码,所以一个好的编译器对于程序性能的影响是巨大的。现在的编译器要比程序员聪明的多,它往往能够预测出程序接下来要执行的指令是什么,有时候我们加的likely()/unlikely()可能根本就没有意义甚至适得其反,因为程序员相比编译器而言太蠢了。有一个例子就是real-time Linux Kernel的maintainer Steven Rostedt做的一个统计,他发现内核里面有大量误用likely()/unlikely()的地方,其中在page_mapping()函数里面的一个unlikely()有39%的概率是错误的,然后这位大神就提交了一个patch把这个unlikely()给去掉了。所以,如果你没有十足的把握,就不要随便使用unlikely()/likely()这俩宏。 不过有一个基本准确的经验就是,在错误处理的分支上加unlikely()大致不会是坏事。比如:

1
2
3
4
char * p = (char *)malloc(SIZE);
if (unlikely(!p)) {
    perror("malloc");  
}

这里的unlikely()在绝大多数情况下都不是有害的,不过,如果这段代码是non-critical的,那么这个unlikely()就是non-sense的,因为在这种情况下它对性能的提升可能近似为0.0000%。即使这段代码真的是critical的话,这个unlikely()并非就有必要去添加,因为编译器在编译的时候自己也能够判断出来。
曾有人对linux kernel里面likely/unlikely的使用情况做过统计,发现unlikely()的使用次数大概是likely()的10倍,就是很多错误处理的分支都加了unlikely()的缘故。

对于likely/unlikely的使用的一个基本原则是,只有在critical path里面,我们才去考虑使用它,是否有必要去使用likely/unlikely还需要借助它的反汇编代码来进一步验证。比如,如下两段反汇编是添加了likely和没有添加likely的的对比:

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
# 1. if (likely($v1 == 0x7fffffff))
20015fec:   8c8240ec    lw  v0,16620(a0)
20015ff0:   00073880    sll a3,a3,0x2
20015ff4:   248a0010    addiu   t2,a0,16
20015ff8:   00ea3821    addu    a3,a3,t2
20015ffc:   aca20000    sw  v0,0(a1)
20016000:   8ce30014    lw  v1,20(a3)
20016004:   3c027fff    lui v0,0x7fff
20016008:   344bffff    ori t3,v0,0xffff
2001600c:   00804821    move    t1,a0
                        if ($v1 != $t3) {
                            goto 20016038;
                        }
20016010:   146b0009    bne v1,t3,20016038 <xxxx+0x4c>
20016014:   24854074    addiu   a1,a0,16500
20016018:   24020001    li  v0,1
2001601c:   0000000f    sync
20016020:   c083001c    ll  v1,28(a0)
20016024:   00624021    addu    t0,v1,v0
20016028:   e088001c    sc  t0,28(a0)
2001602c:   1100fffc    beqz    t0,20016020 <xxxx+0x34>
20016030:   00000000    nop
20016034:   ace30014    sw  v1,20(a3)
                20016038:
20016038:   8ce20014    lw  v0,20(a3)
2001603c:   8d430010    lw  v1,16(t2)
20016040:   0043102b    sltu    v0,v0,v1
20016044:   1040001f    beqz    v0,200160c4 <xxxx+0xd8>
20016048:   00000000    nop
2001604c:   252340cc    addiu   v1,t1,16588
20016050:   24020001    li  v0,1
20016054:   70620018    saa v0,(v1)
20016058:   0000000f    sync
2001605c:   8ce40014    lw  a0,20(a3)
20016060:   8d230010    lw  v1,16(t1)
20016064:   0083001b    divu    zero,a0,v1

# 2. if (unlikely($t1 == 0x7fffffff))
20016004:   3c027fff    lui v0,0x7fff
20016008:   344bffff    ori t3,v0,0xffff
2001600c:   00804821    move    t1,a0
                        if ($v1 == $t3) {
                            goto 200160ac;
                        }
20016010:   106b0026    beq v1,t3,200160ac <xxxx+0xc0>
20016014:   24854074    addiu   a1,a0,16500
20016018:   8ce20014    lw  v0,20(a3)
2001601c:   8d430010    lw  v1,16(t2)
20016020:   0043102b    sltu    v0,v0,v1
20016024:   1040001f    beqz    v0,200160a4 <xxxx+0xb8>
20016028:   00000000    nop
2001602c:   252340cc    addiu   v1,t1,16588
20016030:   24020001    li  v0,1
...
...
200160a8:   2402ffff    li  v0,-1
                200160ac:
200160ac:   24020001    li  v0,1  # 这是另外一个cache line
200160b0:   0000000f    sync
200160b4:   c083001c    ll  v1,28(a0)
200160b8:   00624021    addu    t0,v1,v0
200160bc:   e088001c    sc  t0,28(a0)
200160c0:   1100fffc    beqz    t0,200160b4 <xxxx+0xc8>
200160c4:   00000000    nop

假设cacheline的大小是128bytes,我们可以看到在第二种情况时,if语句会跳转到另外一个cache line里面,这必然会造成cache miss,如果t1在大多数情况下都是0x7fffffff,那么这里就会有较明显的性能损失。 除了这个分支预测之外,还有一个性质对流水线的影响较大,那就是数据依赖。指令B已经译码完成,准备要发出去的时候,发现它的操作数不可用,有可能别的CPU在使用这个操作数,于是指令B只能在这里傻傻的等待另外的CPU释放这个操作数。
所以对于流水线型的CPU,主要就这两方面决定着性能的好坏:1)数据依赖 2)分支预测。现在的并发编程也主要是解决的这两件事。 CPU的设计人员为了让程序员能够更直观的感受这两件事的表现,就给程序员提供了一些性能统计的寄存器,这些寄存器都在协处理0里面。他们做的事情,就是统计多长时间cpu cycle内,指令的issue/retire的数目,icache/dcache的hit/miss,以及l2 icache/dcache的hit/miss等等。比如说,如果icache的miss较大,那显然是程序的分支预测较差。 对于应用程序开发人员而言,是没有必要了解这么详细的性能寄存器信息的,或者说在这个上面花费精力意义不大。因而就有了perf、oprofile、systemtap、dtrace这些性能调试工具的应运而生。这些工具提供给程序员更友好的方式来分析程序,借助这些工具能够很直观的找到程序的critical部分。

P.S.

[1] 图是用Google Doc画的,第一次使用Google Doc画图,画的不太好:(

Comments