看下面的C/C++代码片段:
if (a < b) {
// a<b的处理逻辑
} else {
// a>=b的处理逻辑
}
和
if (a >= b) {
// a>=b的处理逻辑
} else {
// a<b的处理逻辑
}
它们有区别吗?
首先在业务逻辑上没有任何区别,无论哪一种形式的if条件语句都能达到要求,但是在程序执行性能上还是有区别的。我们可以写一个demo代码例子来看一下:
void foo(int a, int b) {
if (a < b) {
puts("a<b");
} else {
puts("a>=b");
}
}
void bar(int a, int b) {
if (a >= b) {
puts("a>=b");
} else {
puts("a<b");
}
}
显然foo()和bar()函数它们的功能完全一样,使用GCC10 -O2进行编译,下面是生成的汇编语言:
.LC0:
.string "a<b"
.LC1:
.string "a>=b"
foo(int, int):
cmp edi, esi
jge .L2
mov edi, OFFSET FLAT:.LC0
jmp puts
.L2:
mov edi, OFFSET FLAT:.LC1
jmp puts
bar(int, int):
cmp edi, esi
jl .L5
mov edi, OFFSET FLAT:.LC1
jmp puts
.L5:
mov edi, OFFSET FLAT:.LC0
jmp puts
两个函数的参数a在寄存器edi中,参数b在寄存器esi中,先看第6、14行指令:cmp edi, esi是对a和b的值进行比较。
在foo()函数中,比较结果使用了指令:jge .L2(第7行),它的意思是如果a>=b(ge即大于等于,是greater equals的缩写),会跳到标号.L2指向的位置处(即第11行)处开始执行,否则(即a<b)继续执行。
而在bar()函数中,比较结果使用了指令:jl .L5(第15行),它的意思是如果a<b(l即小于,是less的缩写),会跳到标号.L5指向的位置处(即第19行)开始执行,否则(即a>=b)继续执行。
也就是说,foo()函数当a>=b时发生条件跳转,当a<b时继续执行,而bar()函数是当a<b时发生条件跳转,当a>=b时继续执行。既然foo()和bar()函数逻辑一样,那我们在实际开发中使用哪一种形式呢?答案是看情况,应该根据a<b或者a>=b哪一种更可能发生来选择使用哪一种形式。
我们知道,指令是以指令流的形式在CPU流水线中解码并执行的,如果指令流的流程没有发生跳转的话,它就会按照指令流中的指令顺序依次执行,并且在前面指令的执行过程中,CPU的解码单元还会预取后面的指令并预先进行译码操作,前面的指令执行完毕紧接着执行已经译码完成的后面的指令,一环扣一环的向前推进。如果指令流在执行过程中发生了跳转,显然跳转指令执行完成后,尽管它后面的指令已经译码完了,但是因为需要执行跳转后的指令,需要重新译码新的指令,不但前面预先译码的指令被废弃不用,而且CPU的流水线需要暂停下来等待新的译码操作完成,也就是执行和译码环节前后衔接不上了,造成了性能损失。因此,为了减少这样没必要的跳转造成的流水线性能损失,编译器在产生条件语句的汇编指令时,尽可能的让能够大概率满足条件的指令能够顺序执行,而小概率满足条件的指令放在跳转以后的地方去执行。
基于此,对于函数foo(),它的if语句是if (a<b),既然编程人员这么写,编译器就会认为a小于b是一个大概率事件,就产生了更有利于满足条件a<b的执行的指令流,第8行汇编指令就是a<b的分支逻辑,汇编指令6、7、8、9是顺序的执行流,没有被打断。那么,a>=b就被编译器认为是一个小概率的事件了,如果发生了a>=b的条件,指令流就从第7行跳转到第14行,中间发生了断档,没有顺次执行,它的执行效率肯定要比a<b时要低。如果在实际应用时,确实更可能a<b,那么编译器就猜对了,它产生的汇编指令流会有更好的性能,在实际执行时,指令流会大概率不会被中断,从而可以获得较高的执行性能。同样的原因,在函数bar()中,编译器生成了更有利于满足条件a>=b的执行的指令流。
因此,根据上面分析的结论,我们在编程实践中,如果能够预先知道那种条件发生的可能性更大,可以针对性的编写if条件语句,让编译器生成更有利于发生该条件时的指令流。比如,对于前面的例子,在编程时如果我们认为a小于b的概率非常大,那就采用foo()函数的写法,如果a大于等于b的概率非常大,那就采用bar()函数的写法。
不过,编译器并不是严格的按照程序使用的if语句的条件生成汇编指令流的,它也有特殊情况。如果是整数类型(如int、short、long等)在进行相等=和不等!=的比较时,编译器会认为不相等是一个大概率的事件,显然int型的取值范围是0-4G,因此如果a和b的值是均匀分布的话,它们相等的可能性是1/4G,在编译器看来是极低的概率。因此不管if语句是写成if (a==b),还是if (a!=b),编译器都生成更有利于a!=b的指令流。看下面的代码:
void foo(int a, int b) {
if (a == b) {
puts("a<b");
} else {
puts("a>=b");
}
}
void bar(int a, int b) {
if (a != b) {
puts("a>=b");
} else {
puts("a<b");
}
}
编译器生成的汇编指令完全一样:
foo(int, int):
cmp edi, esi
je .L4
mov edi, OFFSET FLAT:.LC1
jmp puts
.L4:
mov edi, OFFSET FLAT:.LC0
jmp puts
bar(int, int):
cmp edi, esi
je .L6
mov edi, OFFSET FLAT:.LC1
jmp puts
.L6:
mov edi, OFFSET FLAT:.LC0
jmp puts
可见,编译器在第3行和第11行生成的汇编指令完全一样,都是更有利于执行a!=b的条件。同样编译器也认为一个指针是空指针的情况也是一个小概率事件,如果有判断一个指针p是否是空指针的if语句:if (p==nullptr),编译器也会生成更有利于执行p!=nullptr条件的指令流。
当然这些优化并不是C++标准规定,是否使用这样的优化策略,取决于编译器,并不是编译器的标准行为,在实际编程中如果需要这样的优化策略,就得要查看一下编译器优化后生成的汇编指令了,看编译器是否提供这样的优化策略,显然有点麻烦。好在C++20标准中提供了两种属性来支持这种情况的优化:likely和unlikey,程序员可以在他认为更有可能发生的if条件上标注属性[[likely]],而更不可能发生的if条件上标注[[unlikely]],来给编译器优化时进行指示。
比如前面说过,对于int型的相等比较,GCC编译器生成了更有利于不相等的指令流,如果编程人员认为在实际运行时二者相等概率更大,可以使用likely属性来对if语句进行标记,告诉编译器该条if语句为真的可能性更大,这样编译器就会生成更有利于该条件为真时执行的指令流。
代码片段如下:
void foo(int a, int b) {
if (a ==b) [[likely]] { // [[likely]] 告诉编译器,a==b的可能性更高
puts("a==b");
} else {
puts("a!=b");
}
}
生成的汇编指令如下:
foo(int, int):
cmp edi, esi
jne .L2
mov edi, OFFSET FLAT:.LC0
jmp puts
.L2:
mov edi, OFFSET FLAT:.LC1
jmp puts
同不使用[[likely]]的版本相比,第三行的条件跳转指令由je变成了jne,它的意思是,当a!=b时,跳转到.L2处执行,如果a== b继续执行后面的指令流,显然编译器遵循了[[likely]]的指示,生成的指令流更有利于a==b条件为真时的执行。
likely和unlikely属性是C++20标准引入的,如果开发低于该标准的C++代码,也可以使用下面的宏来指示条件判断为真的可能性:
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
使用上面的宏来指示编译器生成if条件语句:
void foo(int a, int b) {
if (likely(a==b)) {
puts("a==b");
} else {
puts("a!=b");
}
}
编译器生成的汇编指令如下:
foo(int, int):
cmp edi, esi
jne .L2
mov edi, OFFSET FLAT:.LC0
jmp puts
.L2:
mov edi, OFFSET FLAT:.LC1
jmp puts
和使用C++20的属性[[likely]]生成的汇编指令完全一样,生成的汇编指令流也是有利于a==b条件分支的执行。
上面分析的是if-else条件语句块的指令生成情况,同样的道理,这些优化指示规则也可以用在if条件语句块,比如:
void foo(int x) {
if (x > 0) {
puts("x>0");
}
}
编译器会生成更有利于当x>0时的程序指令流,也就是相当于使用[[likely]]修饰了if (x>0)条件语句。如果程序员确定x<=0更可能发生,那么可以[[unlikely]]来修饰:
void foo(int x) {
if (x > 0) [[unlikely]] {
puts("x>0");
}
}
对于if-else if-else形式的语句块,如果不加likely/unlikely修饰的话,经过分析编译器GCC和CLANG生成的汇编代码,发现它们的方式不一样,比如:
void bar(int x) {
if (x > 10) {
puts("x>10");
} else if (x > 0) {
puts("x>0");
} else {
puts("x<=0");
}
}
当GCC编译器在使用-O0(即不优化)时和CLANG编译器会生成更有利于执行if分支的指令流,而GCC编译器在优化时会生成更有利于执行else if分支的指令流,可见,如果不使用likely/unlikely属性来修饰条件表达式的话,如何生成指令流,编译器没有统一的标准,取决于编译器自己的策略。因此,如果想要按照各个分支依次出现的顺序,生成有利于它们的执行流代码,最好手动加上[[likely]]修饰语,以明确地指示编译器生成相关的指令流,如:
void bar(int x) {
if (x > 10) [[likely] {
puts("x>10");
} else if (x > 0) [[likely]] {
puts("x>0");
} else {
puts("x<=0");
}
}
指示编译器生成更有利于执行x>10分支的指令流,而当x<=10时,则生成更有利于x>0分支的指令流。
综上,如果编写程序时,对程序的执行性能要求很高,在编写一些条件语句时,如果知道实际运行时该条件为真的可能性,可以针对性地添加likely或者unlikely标志来指示编译器来生成性能更好的条件跳转指令流。