为什么“?:”运算比“if”运算来得快
曾经在做Leetcode的时候发现了一个很有趣的现象:在代码的逻辑不变的情况下,使用三目运算符?:
代替if
条件语句后运行速度总会提升一大截。
进行试验
- 试验代码
#include <stdio.h>
#include <time.h>
#include <stdlib.h>
int func_1(int x, int y);
int func_2(int x, int y);
int main()
{
for (int i = 0; i < 10; i ++)
{
int n = 10000000;
clock_t start = clock(); // start
for (int i = 0; i < n; i ++)
{
int tmp = func_1(rand(), rand());
}
printf("%.3f\n", (clock() - (double)start) / CLOCKS_PER_SEC);
start = clock();
for (int i = 0; i < n; i ++)
{
int tmp = func_2(rand(), rand());
}
printf("%.3f\n", (clock() - (double)start) / CLOCKS_PER_SEC);
printf("\n");
}
return 0;
}
int func_1(int x, int y)
{
int res;
if (x > y)
{
res = x;
}
else
{
res = y;
}
return res;
}
int func_2(int x, int y)
{
int res = (x > y) ? x : y;
return res;
}
- 试验结果
0.314
0.245
0.340
0.246
0.326
0.280
0.291
0.237
0.314
0.288
0.306
0.281
0.280
0.272
0.291
0.310
0.344
0.241
0.315
0.257
从结果很容易能看出来,使用三目运算符总是会比if
运算来得快一点
这是为什么呢?
深入底层
打开linux,深入剖析fun_1与fun_2的底层实现
将以上代码复制粘贴到Linux中,使用以下命令生成可重定位目标文件(.o
文件)
gcc -Og -c test.c
参数解析:
-Og
的意思是使用Og
优化等级,这个优化等级能生成尽量符合原始C代码的机器语言-c
的意思是:生成.o
文件
接下来使用linux命令objdump -d
来进行反汇编的操作——将机器语言变回汇编语言
objdump -d test.o
来看一下结果
- fun_1
0000000000000000 <func_1>:
0: 89 f0 mov %esi,%eax
2: 39 f7 cmp %esi,%edi
4: 7f 02 jg 8 <func_1+0x8>
6: f3 c3 repz retq
8: 89 f8 mov %edi,%eax
a: eb fa jmp 6 <func_1+0x6>
- fun_2
000000000000000c <func_2>:
c: 39 fe cmp %edi,%esi
e: 89 f8 mov %edi,%eax
10: 0f 4d c6 cmovge %esi,%eax
13: c3 retq
可以看到,二者的实现方式在底层并不相同(尽管代码逻辑是一样的)
解析
为什么会出现这种现象呢?
先来分析一下这两种实现方式的汇编语句到底是什么意思
分析汇编语句
在正式开始分析之前,先对以上出现的几个指令做简单的介绍
mov S D
:将数据S
转移到地址D
中cmp S D
:比较D
与S
大小,将结果存入标志寄存器jg S
:当标志寄存器的存储结果为>
时,进行跳转,跳转的目的地址为S + 下一条指令的起始地址
jmp S
:无条件跳转,跳转地址与条目3相同cmovge S D
:取标志寄存器进行判断,当结果是>
时,将S
的值赋予D
retq
:相当于return
repz
:无实际意义
- 分析fun_1的汇编代码,
0: mov %esi,%eax 将第二个参数(即y)赋予返回值
2: cmp %esi,%edi 比较第一个参数与第二个参数的大小,将结果存入标志寄存器
4: jg 8 <func_1+0x8> 如果标志寄存器结果为大于,跳转到地址8处
6: repz retq 返回
8: mov %edi,%eax 将第一个参数(即x)赋予返回值
a: jmp 6 <func_1+0x6> 跳转到地址6处
转化为C语言即
res = y;
if (x > y)
{
res = x;
}
else
{
res = y;
}
return res;
基本上和源代码没有什么差别
- 分析fun_2的汇编代码
c: cmp %edi,%esi 比较第一个参数与第二个参数大小,将结果存入标志寄存器
e: mov %edi,%eax 将第二个参数赋予返回值
10: cmovge %esi,%eax 若比较的结果是大于等于,将第一个参数赋予返回值
13: retq 返回
翻译成C语言即
flag = x >= y;
res = y;
if (flag)
res = x;
return res;
可以发现,代码结构发生了很大的改变——即使代码的结果并没有发生变动
通过汇编我们发现,fun_2甚至在代码长度上都比fun_1来得短——fun_2只有4条指令,而fun_1有6条
解释
想要充分了解发生这种事情的原因,还得从处理器的发展历史说起。
注:以下均为本人拙见,参照了各种版本的书籍、博客得出的浅薄见解,不一定正确,如果有发现定义性与描述性问题,请评论或发送电子邮件提醒我!不胜感激!
在很久很久以前,处理器处理指令的方式很简单:从卡带上读取指令,然后按照指令进行操作、跳转、存储等。在那个时候计算机的运算速度并不是很快,因此这么做是最合理的。
但是,随着科技的发展,计算机的运算速度越来越快,计算机的运行速度与I/O操作之间的速度差异矛盾就越来越明显了:经常出现处理器花费大量的时间在等待I/O操作的现象。
为了能充分利用计算机等待的时间,人们把计算机指令拆分开来:
原来完成指令的方式是一条一条取,执行完一条再执行下一条,在上一条指令执行完毕之前第二条指令不得不陷入等待;
而现在,人们把指令拆分成多个步骤进行执行——至于具体怎么拆,那不是现代程序员需要关心的。每一条指令的每一部分的执行都有相应的部分进行,真正把CPU利用了起来:
譬如第一条指令在执行第一部分后,开始执行第二部分,这个时候执行指令第一部分的处理器硬件处于闲置状态,于是我们把第二条指令的第一部分交给处理器进行处理;
当第一条指令第二部分执行完毕后,第二条指令的第一部分也执行完毕了,这个时候就将第二条指令的第二部分和第一条指令的第三部分交给处理器运行,正好,第三条指令的第一部分可以加入处理器……
一条指令能分成多少部分完成,CPU的效率理论上就能增加多少倍。
人们把这种处理方式称之为流水线式处理;而能在一个时钟周期内处理多条指令的处理器也被称之为超标量处理器。当代大多数处理器都支持超标量操作。
但是,这种流水线操作想要发挥最大的威力,还需要解决一个问题:只有确切知道指令的执行顺序,才可以使用流水线。
这个很好理解。我必须确实知道第二条指令执行完后一定是第三条指令在执行,才能放心使用流水线操作。
但是,实际操作中,有些时候必须等到一条指令处理结束,才能知道下一条指令的位置——例如条件分支。如果按照原始的方式,等待这条指令执行完成,那么将会浪费大量的时间。
为了解决这个问题,现代处理器大多使用分支预测的办法:
在等待条件判断语句执行完毕的时候,我预先预测下一条指令可能开始的位置,并在这个间隙开始进行指令的执行。如果刚刚好就碰到了我预测的指令,那么我就顺势运行下去,运行速度就上去了;如果我预测失败了,那就只好清空原来的内容,加载新的语句。
可以看到,预测失败的惩罚是特别大的:不仅要加载新的代码,等同于原始的等待条件指令执行所需的代价,而且还要花费时间清理原来的错误的运行结果。
并且,这种预测模式直接导致了幽灵BUG的诞生——具体请百度。
但是,这种方法确实能在某些清空提升运行速度,并且,当代计算机正在努力做到将近90%的正确率。
然而,在某些典型的应用场景下,再精密的预测基本上等同于瞎猜:50%的正确率。
这也是if
判断语句总是很慢的原因,也是我建议大家最好避免if-else
出现的原因
// 下面这一段计算请跳过,不要相信一个数分考过63的人说的梦话
假设执行条件判断所需要的时间与执行后续操作所需的时间均为 t t t,使用原始方法的成本大概为 2 t 2t 2t,使用现代流水线预测成功所花费的成本大约为 t t t,预测失败所花费的成本大约为 2 t + b 2t+b 2t+b,在完全随机的情况下,数学期望约为 t / 2 + ( t + b / 2 ) t/2+(t+b/2) t/2+(t+b/2)
(这一段是我瞎算的)
但是不要只看数据就否决现代的流水线操作——原始方法中, 2 t 2t 2t时间内只能执行两条指令,而流水线中,在这 t / 2 + ( t + b / 2 ) t/2+(t+b/2) t/2+(t+b/2)的时间里同时执行的可远远不止两条指令~
接下来说说为什么fun_1与fun_2的汇编代码会如此不同。
这涉及到两种处理分支语句的模式:使用条件控制实现条件分支,与使用条件传送来实现条件分支
使用条件控制,就是我们上面所说的,两个指令段里面“随便”(实际上是很精密的过程)挑一段指令执行,然后等待条件判断指令执行完毕后再听天由命
使用条件传送,就是另一种思路了:
既然我有充足的时间来等待条件判断指令的执行——实际上执行一条指令的时间仍然是t,只是我们可以同时执行多条指令而已——那么为什么我不干脆把所有代码段全部执行掉?那么无论结果是什么,我都是只赚不亏的——毕竟等待的时间闲着也是闲着。
很显然,在我们所使用的-Og
的优化度中,编译器对if
语句使用了条件分支,而对三目运算?:
使用了条件传送
这也是为什么我们总是觉得三目运算符会比if语句来的快:
使用条件传送能尽量占满流水线,并且避免了因为判断失误而洗牌重来的代价
而使用条件分支则有一定概率洗牌重来
并且条件分支的指令数量都更多一点点
实际上,如果你使用了-O1
或者更高级别的优化等级,你就会发现,编译器对我们这个例子中的if
也使用了条件传送~
这又是为什么呢?
首先,当然是因为优化等级变化了。
至于什么是优化等级,建议百度。
其次,来看下面的一个代码
int func_3(int x, int y)
{
int res;
res = (x > y) ? x ++ : y ++;
return res;
}
结果并不会有不同,至少按照一般程序员的观点来看,结果和原来一样
但是当我们查看它的汇编代码时,却会发现一些不同:
000000000000000c <func_2>:
c: 89 f0 mov %esi,%eax
e: 39 f7 cmp %esi,%edi
10: 7f 02 jg 14 <func_2+0x8>
12: f3 c3 repz retq
14: 89 f8 mov %edi,%eax
16: eb fa jmp 12 <func_2+0x6>
三目运算的汇编代码反而变成了条件分支控制的结构!!
这是为什么呢?
因为,虽然条件传送控制分支的方式能减少运行时间、提高运行效率,但是,它是有条件的,而且应用范围极其苛刻——不能有任何副作用产生
这里的副作用意义很广泛,类似于fun_3里的届不到的自加运算,还有一些对其它变量的处理,都会导致这种结果
这是为什么呢?
让我们梳理一下条件传送的过程:是不是先将所有可能出现的代码都执行一遍,然后把符合条件的结果抽取出来?
那么如果这些代码会导致状态的改变呢?
例如x++
,是不是就导致了x
变量的状态的改变?
如果还是按照条件传送的方法,是不是运行一遍之后x、y都会自增1?这样还符合代码的逻辑吗?
此处应该留五分钟时间回顾一下前面的内容并体会体会。
因此,虽然条件传送很好,但是它也不是什么时候都能使用的,否则条件分支的存在的意义不就没了?
让我们考虑另外一种情况:如果条件传送的每个部分的运算代码都很复杂,超出了执行一条判断语句所需的时间,那么,条件传送加快时间的意义不就没有了?
换句话说,我本来做的额外的运算都是在条件判断语句还没有执行完成的时候所做的,不会消耗额外的时间,条件判断一结束马上就有结果了。
但是现在,我如果要把全部运算都做完,所消耗的时间比条件判断语句还多得多,那么,条件传送的意义不就没了?这个时候还不如使用条件分支语句来撞撞运气呢!
因此,当运算指令过于复杂的时候,编译器也会选择使用条件分支进行组织
事实上,只要运算指令超过一条,编译器就会直接选择条件分支进行组织——它才不会判断你的指令运算时间到底是多少。
结论
所以事实上,我们要是想要享受条件传送带来的高效,那就不得不满足各项严苛的条件:
- 编译器的优化等级设定。如果设置为
-Og
,那么if
语句就不会被优化,使用了-O1
就可以被优化; - 不能产生状态的改变。假如我的运算涉及到了真正的变量状态改变,那么无论使用什么等级的优化,编译器都不会理你
- 不能涉及多条复杂语句的运算——实际上只允许一条简单语句。要是使用了两条语句,那么,编译器也会毫不犹豫地使用条件分支控制
条件是如此严苛!
但是,即使这样,在实际应用中仍然有不少可以用到这个知识的时候,这个一个很典型的应用场景。
况且,(x > y) ? x : y
只有一行,而写成if
语句要整整四行
所以让我们得到最后的结论:
去tm的if
语句!爷看到if
就咳嗽!
题外话:不会吧不会吧,不会真的有人觉得自己写了十几层的if-else
嵌套看起来就很nb了吧?不会真有人能看的下那种代码吧?