原文链接:How branches influence the performance of your code and what can you do about it?
原作者:Johnny's Software Lab LLC
时间:2020-7-5
使程序运行更快的技术
在讨论技术之前,我们要先明确两个东西。当我们说条件概率时,我们的实际意思是,条件为真的几率有多大。有些条件大多为真,有些条件大多为假,也有真假概率相等的条件。
有分支预测功能的CPU可以迅速判断出哪些条件为真,哪些条件为假,因此不会出现性能下降的情况。然而,当遇到难以预测的情况时,分支预测只有一半正确的概率。这些都是隐藏着优化潜力的条件。
再者,我们会使用一个术语------计算密集型、"昂贵的"或者"繁重的条件"。这一术语表达了两个含义:
-
他需要许多指令去计算
-
需要计算的数据并不在缓存中,因此单体指令需要很久才能完成计算
第一个含义通过指令数量可以清楚得知,第二个含义并不能但是也非常重要。如果我们以随机的方式访问内存^1^,数据可能并不在内存中,这就会导致流水线就会暂停并且性能降低。
现在让我们回到程序技巧。以下是几种通过重写程序的关键部分来使程序运行更快的技术。需要这些小技巧也可能会是你的程序运行更慢,这取决于两个条件:
-
你的CPU是否支持分支预测
-
你的CPU是否需要等待内存中的数据
因此,记得评估一下!
组合条件------成本低廉和高昂的条件
组合条件是值类似于(条件1 && 条件2)
或(条件1 || 条件2)
的条件。根据C和C++的标准,就(条件1 && 条件2)
而言,一旦条件1
是false
,条件2
就不会被计算。同样的,就(条件1 || 条件2)
而言,如果条件1
是true
,条件2
就不会被计算。
所以,一旦你有两个条件,一个容易计算另一个则很难,那就把容易的那个放在前面,难的那个放在后面。者可以确保成本高昂的条件不会被进行不必要的条件。
优化连续的if/else
命令链
在你代码的重要部分如果有一个连续的if/else
命令链,为了优化这个命令链你就需要关注一下条件概率和条件计算密集度。例如:
c++
if (a > 0) {
do_something();
} else if (a == 0) {
do_something_else();
} else {
do_something_yet_else();
}
现在,想象一下(a < 0 )
的概率是70%,(a > 0)
是20%且(a == 0)
是10%。在这种情况下,重新排列上述代码会是最合乎逻辑的:
c++
if (a < 0) {
do_something_yet_else();
} else if (a > 0) {
do_something();
} else {
do_something_else();
}
使用查找表而不是switch
查找表(LUT)在消除分支指令时偶尔非常有用。不幸的是,在switch语句中,大多数情况下分支是很容易预测的,因此这种优化可能没有任何效果。然而,这里还是提供一下:
c++
switch(day) {
case MONDAY: return "Monday";
case TUESDAY: return "Tuesday";
...
case SUNDAY: return "Sunday";
default: return "";
};
以上的语句使用LUT实现是这样的:
c++
if (day < MONDAY || day > SUNDAY) return "";
char* days_to_string = { "Monday", "Tuesday", ... , "Sunday" };
return days_to_string[day - MONDAY];
通常编译器可以为您完成这项工作,即通过查找表替换switch
语句。然而,这种情况并不一定会发生,您需要查看编译器的向量化报告。
这是一个GUN语言拓展,称为计算标签,它允许您使用存储在数组中的标签来实现查找表:它在实现解析器时非常有用。以下是我们示例的实现方式:
c
static const void* days[] = { &&monday, &&tuesday, ..., &&sunday };
goto days[day];
monday:
return "Monday";
tuesday:
return "Tuesday";
...
sunday:
return "Sunday";
将最常见的情况移到switch
之外。
一旦你使用了switch
语句且有的case
很常见,你可以将她移出switch
语句中,对它进行特殊处理。继续前一节的示例:
C++
day get_first_workday() {
std::chrono::weekday first_workday = read_first_workday();
if (first_workday == Monday) { return day::Monday; }
switch(first_workday) {
case Tuesday: return day::Tueasday;
....
};
}
重写组合条件语句
之前提到过,对于组合条件语句,如果第一个条件语句有一个明确的值,第二个条件语句就不需要去计算了。编译器是如何实现他的?看一下下面的例子:
c++
if (a[i] > x && a[i] < y) {
do_something();
}
现在假设a[i] > x
和a[i] < y
很容易就能得出结果(所有的数据都存储在寄存器或缓存中)但是很难被预测。这个序列将翻译成以下伪汇编语言:
nasm
if_not (a[i] > x) goto ENDIF;
if_not (a[i] < y) goto ENDIF;
do_something;
ENDIF
您这里有两个难以预测的分支,如果我们使用&
组合两个条件而不是&&
,我们会得到:
-
强制同时评估两个条件:
&
操作符是算术 AND 操作,它必须同时计算两侧。 -
使条件语句更容易预测以便于减少分支错误预测率:两个完全独立的条件,每个条件的概率为50%,将会产生一个联合条件,其为真的概率为25%。
-
消除一个分支:与其使用最初的两个分支而不如使用一个更容易预测的分支。
&
操作符会同时评估两个条件,在生成的会面代码中只有一个分支而不是两个。相同的情况也适用于操作符 ||
及其对应的 |
操作符。
请牢记:根据C++
的标准,bool
类型的值为 0 表示假(false),而任何其他值表示真(true)。C++ 标准保证逻辑操作和算术比较的结果始终为零或一,但并不保证所有的布尔值只有这两个值。你可以通过对布尔变量应用 !!
操作符来进行规范化(将其转换为 0 或 1)。
向编译器建议哪个分支概率更大
GCC
和CLANG
为程序员提供了一些关键字,以便于程序员告诉编译器哪个分支具有更高的可能性。例如:
c++
#define likely(x) __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
if (likely(ptr)) {
ptr->do_something();
}
通常情况下,我们会通过 likely
和 unlikely
这两个宏指令来使用 __builtin_expect
,因为直接在每个地方使用它的语法较为繁琐。
当像这样进行注释时,编译器将重新排列 if
和 else
分支中的指令,以便最优地利用底层硬件。请确保条件的概率是正确的,否则您可能会遇到性能下降的情况。
使用无分支算术运算符
一些本来使用分支表达的算法可以转换为无分支算法。例如,下方的abs
函数使用了一个技巧去计算数字的绝对值,你能猜出这个技巧是什么吗?
c++
int abs(int a) {
int const mask =
a >> sizeof(int) * CHAR_BIT - 1;
return = (a + mask) ^ mask;
}
使用条件式加载而不是分支指令
许多CPU都支持条件移动指令,可以用来移除分支。示例如下:
c++
if (x > y) {
x++;
}
可以重写成这样:
c++
int new_x = x + 1;
x = (x > y) ? new_x : x; // the compiler should recognize this and emit a conditional branch
编译器能够识别出第二行的指令可以被重写成对变量x
的条件加载,并生成条件移动指令。不幸的是,编译器在何时生成条件分支方面有其自己的内部逻辑,这并是开发人员所期望的。然而,你可以使用内联汇编来强制执行条件加载(稍后会详细介绍)。
请注意,无分支版本会执行更多的工作,无论分支是否执行变量x
都会增加。加法是一种廉价的操作,但对于其他昂贵的操作(如除法),这种优化可能会对性能产生不利影响。
使用算法运算符实现无分支
有一种方法可以通过巧妙地使用算术操作来实现无分支。条件递增的示例:
c++
// With branch
if (a > b) {
x += y;
}
// Branchless
x += -(a > b) & y;
在上述示例中,表达式-(a > b)
会常见一个掩码,当条件为假时为零,当条件为真时为全1。
条件赋值的示例:
c++
// With branch
x = (a > b) ? val_a : val_b;
// Branchless
x = val_a;
x += -(a > b) & (val_b - val_a);
上面例子使用了算法来避免分支语句。这取决于你的CPU分支预测错误的惩罚以及数据缓存命中率,这可能会带来性能提升,也可能不会。
循环缓冲区中移动索引的示例:
c++
// With branch
int get_next_element(int current, int buffer_len) {
int next = current + 1;
if (next == buffer_len) {
return 0;
}
return next;
}
// Branchless
int get_next_element_branchless(int current, int buffer_len) {
int next = current + 1;
return (next < buffer_len) * next;
}
重新组织你的以避免使用分支
一旦你要写高性能的软件,你应该要了解数据导向设计原则。这是其中一个适用于分支的建议。
假设您有一个名为 animation
的类,可以是可见的或隐藏的。处理可见动画与处理隐藏动画非常不同。有一个包含动画的列表称为 animation_list
,您的处理过程大致如下:
c++
for (const animation& a: animation_list) {
a.step_a();
if (a.is_visible()) {
a.step_av();
}
a.step_b();
if (a.is_visible) {
a.step_bv();
}
在处理上述代码时,如果不根据可见性对动画进行排序,那么分支预测器可能会遇到困难。有两种方法可以解决这个问题。一种方法是根据 is_visible()
对 animation_list
中的动画进行排序。第二种方法是创建两个列表,animation_list_visible
和 animation_list_hidden
,然后像这样重新编写代码:
c++
for (const animation& a: animation_list_visible) {
a.step_a();
a.step_av();
a.step_b();
a.step_bv();
}
for (const animation& a: animation_list_hidden) {
a.step_a();
a.step_b();
}
所有条件都消失了,而且没有分支预测错误。
使用模板去除分支
如果一个布尔值作为参数传递给函数,并且在函数内部被用作参数,你可以通过传递一个模板参数来去掉他。例如:
c++
int average(int* array, int len, bool include_negatives) {
int average = 0;
int count = 0;
for (int i = 0; i < n; i++) {
if (include_negatives) {
average += array[i];
} else {
if (array[i] > 0) {
average += array[i];
count++;
}
}
}
if (include_negatives) {
return average / len;
} else {
return average / count;
}
}
在这个函数中,与 include_negatives
相关的条件可能会被多次计算。为了避免这种计算,可以将参数作为模板参数传递,而不是作为函数参数。
c++
template <bool include_negatives>
int average(int* array, int len) {
int average = 0;
int count = 0;
for (int i = 0; i < n; i++) {
if (include_negatives) {
average += array[i];
} else {
if (array[i] > 0) {
average += array[i];
count++;
}
}
}
if (include_negatives) {
return average / len;
} else {
return average / count;
}
}
使用这种实现方式,编译器会生成两个版本的函数,一个带有 include_negatives
,另一个没有(如果调用函数时这个参数的值不同)。分支完全消失了,未使用分支中的代码也被删除了。
但是现在你需要以稍微不同的方式调用你的函数。因此,你将以以下方式调用它:
c++
int avg;
bool should_include_negatives = get_should_include_negatives();
if (should_include_negatives) {
avg = average<true>(array, len);
} else {
avg = average<false>(array, len);
}
事实上,这是一种称为分支优化
的编译器优化。如果在编译时知道 include_negatives
的值,并且编译器决定内联 average
函数,它将消除分支和未使用的代码。然而,使用模板的版本确保了这一点,而原始版本则不会这样。
编译器通常可以为您执行此优化。如果编译器可以保证在循环执行期间,include_negatives
的值不会改变,它可以创建两个版本的循环:一个用于 include_negatives
为 true 的情况,另一个用于 include_negatives
为 false 的情况。这种优化称为循环不变代码提取,您可以在我们关于循环优化的帖子中了解更多信息。使用这种模板就能这种优化始终会发生。
其他避免分支指令的技巧
如果您在代码中多次检查一个不可更改的条件,通过一次检查然后进行一些代码复制,您可能会获得更好的性能。因此,在下面的示例中,可以用一个分支替换两个分支。
c++
if (is_visible) {
hide();
}
process();
if (is_active) {
display();
}
可以替换成这样
c++
if (is_visible) {
hide();
process();
display();
} else {
process();
}
您还可以引入一个包含两个元素的数组,一个用于在条件为真时保存结果,另一个用于在条件为假时保存结果。以下是一个示例:
c++
int larger = 0;
for (int i = 0; i < n; i++) {
if (a[i] > m) {
larger++;
}
}
return larger;
可以替换成这样:
c++
int result[] = { 0, 0 };
for (int i = 0; i < n; i++) {
result[a>i]++;
}
return result[1];
Footnotes
- Random memory access is often the case with algorithms that are fast like binary search, quicksort etc. ↩