- 论坛徽章:
- 13
|
本帖最后由 karma303 于 2017-06-24 14:29 编辑
* 把项目里的一处代码优化的例子拿出来跟大家分享。
先说说这处代码的上下文。项目本身是一个模拟vim的文本解析器。
vim在visual/Visual Block模式(也就是小v和ctrl-v)下按x,d或s,会把删掉的文本保存在寄存器里,不足一行的,送到"-"编号寄存器(称作“小删除”寄存器),否则送到1号寄存器。但如果是visual line模式(大V),就不加区分直接送到1号寄存器。因为是行选择,总是足行。
所以在用户在visual选择后(此处visual指代v/V/VB三种,下文请自行区分)按下d,x之类的删除键时,程序要判断光标的选区是否跨行,如果未跨行,再判断是不是v或VB模式,如果是,就要对这次visual selection做降级处理,得到真实的visual等级。后续的代码依赖这个真实等级。 例如前面提到的,决定使用哪个寄存器。
根据上面的需求,就有了这样一个函数:
- enum{
- VMOD_NONE , // 0
- VMOD_v, // 1
- VMOD_V, //2
- VMOD_VB , //3
- };
- /* @head_l 光标选区的起始行
- @tail_l 光标选区的终止行
- static inline int
- degrade(int vmod, int head_l, int tail_l){
- if(head_l == tail_l){
- if(vmod == VMOD_v || vmod == VMOD_VB){
- vmod = VMOD_NONE;
- }
- }
- return vmod;
- }
复制代码 我起初觉得这样写,条件判断太多,对流水线有影响。因为实际环境中,处于v/V/VB这三种模式的可能性不能说谁大谁小,cpu的分支预测器等同虚设。
于是我写了第一个优化版本:
- /* 索引0~4分别对应VMOD_NONE, VMOD_v, VMOD_V, VMOD_VB
- * [0] ~ [4]分别对应不足行时的被"降"到哪一级". 可见VMOD_v和VMOD_VB不足行时,
- * 都被当做VMOD_NONE对待,VMOD_NONE这个命名不太形象,我是想指代”不足行的小的片段选区“。
- */
- int real_level[4] = {0, 0, 2, 0 };
- /* 通过查表来避免条件判断 */
- static inline int
- degrade_with_tbl(int vmod, int head_l, int tail_l){
- if(head_l == tail_l){
- vmod = real_level[vmod];
- }
- return vmod;
- }
复制代码 我没有完全消灭所有的if语句,因为我想像if(head_l == tail_l){}里是如此明显和简单的赋值语句,足够暗示gcc把这个if转成一条”条件传送指令“。
然后我做benchmark。
- /* 先准备好长度为100万的三个数组,分别存储将”下达“给degrade()函数的三个参数,@mod, @head_l,
- @tail_l。
- * @mod在1~3之间随机。
- @head_l和@tail_l在0,1两个值之间随机,因为要保证一定的”同行“(same-line)的概率。
- *
- * 下面3个数组还是声明为全局的放心一些,因为全局数组,gcc不敢随便优化。即使未初始化,它也不敢断定里面是全0。
- */
- #define MAX (10*100000)
- int random_mods[MAX];
- int random_head_l[MAX];
- int random_tail_l[MAX];
- .....省略degrade()函数代码............
- #pragma GCC push_options
- #pragma GCC optimize ("O0") //从这儿开始关闭编译优化,防止gcc做一些奇怪的事。(后来证明不用防止)
- int main(void){
- ...
- for(int i = 0; i < MAX; i++){
- random_mods[i] = random() % 3 + 1;
- random_head_l[i] = random() % 2;
- random_tail_l[i] = random() % 2;
- }
复制代码 然后四组for循环,两个版本的degrade_with_xx()各调用200万次。
- TSTAMP_INIT();
- TSTAMP();
- for(i = 0; i < MAX; i++){
- total1 +=
- degrade_with_if(random_mods[i], random_head_l[i], random_tail_l[i]);
- }
- TSTAMP(&timecost_if);
-
- for(i = 0; i < MAX; i++){
- total2 +=
- degrade_with_tbl(random_mods[i], random_head_l[i], random_tail_l[i]);
- }
- TSTAMP(&timecost_tbl);
- for(i = 0; i < MAX; i++){
- total3 +=
- degrade_with_tbl(random_mods[i], random_head_l[i], random_tail_l[i]);
- }
- TSTAMP(&timecost2_tbl);
- for(i = 0; i < MAX; i++){
- total4 +=
- degrade_with_if(random_mods[i], random_head_l[i], random_tail_l[i]);
- }
- TSTAMP(&timecost2_if);
复制代码 后两组循环和前两组是一模一样的,请不要费眼睛看了~ 重复两次是为了放心。
累加total1/2/3/4是我怕gcc把for循环里的degrage_with_xx()函数直接优化掉了,所以让它的返回值对外部造成一些影响。
TSTAMP()顾名思义是”时间戳“,它计算距离前一个TSTAMP()的时间间隔。然后保存在给它的参数里。
最后打印:
- printf("version timecost timecost:\n"
- "if %d %d\n"
- "table %d %d\n",
- timecost_if, timecost2_if, timecost_tbl, timecost2_tbl);
复制代码 编译: gcc -c -o t t.c ../base/benchmark.c -O3
运行:./t
- version timecost timecost:
- if 9577 9639
- table 9795 9687
复制代码 上面是以微妙为单位的。
结果竟然相差无几。。。table版本比if还要慢一些。。。
看汇编就明白了:
- 08048520 <degrade_with_tbl>:
- 8048520: 39 ca cmp %ecx,%edx
- 8048522: 74 0c je 8048530 <degrade_with_tbl+0x10>
- 8048524: f3 c3 repz ret
- 8048526: 8d 76 00 lea 0x0(%esi),%esi
- 8048529: 8d bc 27 00 00 00 00 lea 0x0(%edi,%eiz,1),%edi
- 8048530: 8b 04 85 40 a0 04 08 mov 0x804a040(,%eax,4),%eax
- 8048537: c3 ret
复制代码 @head_l和@tail_l是通过ecx, edx传进来,上来就是一个cmp + je,可见gcc没有把这个if优化掉。
再看if版本的汇编:
- 080484f0 <degrade_with_if>:
- 80484f0: 39 ca cmp %ecx,%edx
- 80484f2: 74 0c je 8048500 <degrade_with_if+0x10>
- 80484f4: f3 c3 repz ret
- 80484f6: 8d 76 00 lea 0x0(%esi),%esi
- 80484f9: 8d bc 27 00 00 00 00 lea 0x0(%edi,%eiz,1),%edi
- 8048500: 89 c2 mov %eax,%edx
- 8048502: 83 e2 fd and $0xfffffffd,%edx
- 8048505: 83 fa 01 cmp $0x1,%edx
- 8048508: ba 00 00 00 00 mov $0x0,%edx
- 804850d: 0f 44 c2 cmove %edx,%eax
- 8048510: c3 ret
复制代码 同样没有优化第一个if,但
- if(vmod == VMOD_v || vmod == VMOD_VB){
- vmod = VMOD_NONE;
- }
复制代码 被优化成了:
8048502: 83 e2 fd and $0xfffffffd,%edx
8048505: 83 fa 01 cmp $0x1,%edx
8048508: ba 00 00 00 00 mov $0x0,%edx
804850d: 0f 44 c2 cmove %edx,%eax
因为VMOD和VMOD_VB分别是1和3,于是gcc先做一个 vmod & 1111..111101。 (二进制)
11111..111101只有与上1和3时,才能得到1。
然后测试是否得到了1,并使用条件传送指令cmove往@vmod写入0.
注意最后两条指令
mov $0x0, %edx
cmove %edx, %eax
正好在趁着cmp得出结果的空隙被乱序执行。
难怪这么快。我的table版本能跟它打成平手,还多亏了大循环时100%的cache命中率,实际运行时,很可能比它慢不少。
我决定改良table版本,强制gcc帮我消除if语句:
- int real_level[4][2] = {{0, 0}, {1, 0}, {2, 2}, {3, 0}};
- static inline int
- degrade_with_tbl(int vmod, int head_l, int tail_l){
- int equal = head_l == tail_l;
- vmod = real_level[vmod][equal];
- return vmod;
- }
复制代码 编译,看下汇编码:
- 08048520 <degrade_with_tbl>:
- 8048520: 39 ca cmp %ecx,%edx
- 8048522: 0f 94 c2 sete %dl
- 8048525: 0f b6 d2 movzbl %dl,%edx
- 8048528: 8d 04 42 lea (%edx,%eax,2),%eax
- 804852b: 8b 04 85 40 a0 04 08 mov 0x804a040(,%eax,4),%eax
- 8048532: c3 ret
复制代码
cmp + sete, 这下放心了。
运行:
- version timecost timecost:
- if 9583 9808
- table 3791 3708
复制代码
如果注释掉前面的#pragma GCC optimize ("O0"),即允许gcc把这两个函数inline,效果会更明显:
(-O3, inline, 100万长数组随机0/1)
- version timecost timecost:
- if 7116 7011
- table 1698 1652
复制代码 因为我们的汇编码很小巧。
由此可见,分支预测失败造成的流水线丢弃对性能的影响有多大。如果把前面的
- random_head_l[i] = random() % 2;
- random_tail_l[i] = random() % 2;
复制代码
这两行注释掉,即,100万长的数组全是默认的0,让if版本100%分支预测成功,看它有多快:
(-O3, inline, 100万长数组全0)
- version timecost timecost:
- if 2053 1935
- table 1353 1239
复制代码 快了有3倍之多。
上面的测试结果是在#pragma GCC optimize ("O0")被注释掉后得到的。100万长的全0数组似乎让table版本也快了一点点(我反复测了几次),这应该(我猜的,没看汇编码)是gcc优化了for循环,或者就是操作系统的周期波动,或者是gettimeofday()的问题(哎,我的TSTAMP是用它实现的),因为table版没有分支预测,是不受这个影响的。
为了完整性,再关闭inline,测试:
(-O3, no-inline, 100万长数组全0 )
- version timecost timecost:
- if 4262 3895
- table 4104 4058
复制代码 分支预测100%成功时,是不输给table版本的。
测试到这里,我不准备把这个优化引入到项目里了,因为还是初期,而且这种table已经没有可读性了。
项目稳定后,可能会优化,但肯定要一同优化别的if。程序里处处分布的这种小if语句,虽然不是性能瓶颈,但批量优化一下,估计还是有收益。
很多时候,大家都在说不要过度和过早地优化,我想要能够自然的做到这一点的话,是得对编译器的行为有基本的了解。没事读一读gcc的汇编码,不然写性能严苛的程序会很难受。
最后忘了说了,用table来消除if,是有局限性的。像这个程序,因为vmod字段是程序内部维护的,所以我敢断定它是0~4。而且在发布版里不会再assert()它的值,如果真的真的被意外修改了,这个degrade_with_tbl()肯定会造成崩溃,但不要紧,因为反正是要崩溃的,在哪里崩溃无所谓。
|
|