【译】分支语句如何影响代码的性能?对于这些你可以做些什么?(3)

原文链接: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. 他需要许多指令去计算

  2. 需要计算的数据并不在缓存中,因此单体指令需要很久才能完成计算

第一个含义通过指令数量可以清楚得知,第二个含义并不能但是也非常重要。如果我们以随机的方式访问内存^1^,数据可能并不在内存中,这就会导致流水线就会暂停并且性能降低。

现在让我们回到程序技巧。以下是几种通过重写程序的关键部分来使程序运行更快的技术。需要这些小技巧也可能会是你的程序运行更慢,这取决于两个条件:

  1. 你的CPU是否支持分支预测

  2. 你的CPU是否需要等待内存中的数据

因此,记得评估一下!

组合条件------成本低廉和高昂的条件

组合条件是值类似于(条件1 && 条件2)(条件1 || 条件2)的条件。根据C和C++的标准,就(条件1 && 条件2)而言,一旦条件1false,条件2就不会被计算。同样的,就(条件1 || 条件2)而言,如果条件1true,条件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] > xa[i] < y很容易就能得出结果(所有的数据都存储在寄存器或缓存中)但是很难被预测。这个序列将翻译成以下伪汇编语言:

nasm 复制代码
    if_not (a[i] > x) goto ENDIF;
    if_not (a[i] < y) goto ENDIF;
    do_something;
    ENDIF

您这里有两个难以预测的分支,如果我们使用&组合两个条件而不是&&,我们会得到:

  1. 强制同时评估两个条件:& 操作符是算术 AND 操作,它必须同时计算两侧。

  2. 使条件语句更容易预测以便于减少分支错误预测率:两个完全独立的条件,每个条件的概率为50%,将会产生一个联合条件,其为真的概率为25%。

  3. 消除一个分支:与其使用最初的两个分支而不如使用一个更容易预测的分支。

&操作符会同时评估两个条件,在生成的会面代码中只有一个分支而不是两个。相同的情况也适用于操作符 || 及其对应的 | 操作符。

请牢记:根据C++的标准,bool 类型的值为 0 表示假(false),而任何其他值表示真(true)。C++ 标准保证逻辑操作和算术比较的结果始终为零或一,但并不保证所有的布尔值只有这两个值。你可以通过对布尔变量应用 !! 操作符来进行规范化(将其转换为 0 或 1)。

向编译器建议哪个分支概率更大

GCCCLANG为程序员提供了一些关键字,以便于程序员告诉编译器哪个分支具有更高的可能性。例如:

c++ 复制代码
#define likely(x)      __builtin_expect(!!(x), 1)
#define unlikely(x)    __builtin_expect(!!(x), 0)

if (likely(ptr)) {
    ptr->do_something();
}

通常情况下,我们会通过 likelyunlikely 这两个宏指令来使用 __builtin_expect,因为直接在每个地方使用它的语法较为繁琐。

当像这样进行注释时,编译器将重新排列 ifelse 分支中的指令,以便最优地利用底层硬件。请确保条件的概率是正确的,否则您可能会遇到性能下降的情况。

使用无分支算术运算符

一些本来使用分支表达的算法可以转换为无分支算法。例如,下方的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_visibleanimation_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

  1. Random memory access is often the case with algorithms that are fast like binary search, quicksort etc.
相关推荐
明月与玄武9 小时前
关于性能测试:数据库的 SQL 性能优化实战
数据库·sql·性能优化
_乐无17 小时前
Unity 性能优化方案
unity·性能优化·游戏引擎
2402_857589361 天前
Spring Boot编程训练系统:实战开发技巧
数据库·spring boot·性能优化
爱搞技术的猫猫1 天前
实现API接口的自动化
大数据·运维·数据库·性能优化·自动化·产品经理·1024程序员节
EterNity_TiMe_1 天前
【论文复现】STM32设计的物联网智能鱼缸
stm32·单片机·嵌入式硬件·物联网·学习·性能优化
saturday-yh1 天前
性能优化、安全
前端·面试·性能优化
青云交2 天前
大数据新视界 -- 大数据大厂之 Impala 性能优化:基于数据特征的存储格式选择(上)(19/30)
大数据·性能优化·金融数据·impala·存储格式选择·数据特征·社交媒体数据
数据智能老司机2 天前
Rust原子和锁——Rust 并发基础
性能优化·rust·编程语言
2401_857026232 天前
Spring Boot编程训练系统:性能优化实践
spring boot·后端·性能优化
数据智能老司机2 天前
Rust中的异步编程——创建我们自己的Fiber
性能优化·rust·编程语言