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

121 阅读18分钟

原文链接:How branches influence the performance of your code and what can you do about it?

原作者:Johnny’s Software Lab LLC

时间:2020-7-5

实验

         现在让我们来看最有意思的部分——实验。我们准备了两个实验,一个是遍历数组并计算具有特定属性的元素。这是一个有利于缓存的算法,因为硬件预取器很可能会保持数据在CPU中的流动。

        第二个算法是我们在关于缓存友好程序文章中介绍的二分搜索算法。由于二分搜索算法的性质,他并不是一个缓存友好的算法,大部分的速度减缓原因都因为等待数据。我们将缓存性能和分支之间的关系先按下不表。

        为了测试我们使用三块有三种不同架构的芯片:

  • AMD A8-4500M quad-core x86-64处理器每个独立的核心有16kB的L1级缓存和由一对核心共享的2M的L2级缓存。这是个现代流水线式的处理器,具有分支预测、推测执行和乱序执行功能。根据技术规格,这款 芯片 的误判惩罚大约为 20 个时钟周期。

  • Allwinner sun7i A20 dual-core ARMv7处理器每个独立核心有32KB的L1级缓存和256KB的共享缓存。这是个适用于嵌入式的成本低廉的处理器,具有分支预测和推测执行功能,但是没有乱序执行功能。

  • Ingenic JZ4780 dual-core MIPS32r2处理器每个独立和性有32KB的L1级缓存和512kB的L级共享缓存。这是一个用于嵌入式设备的流水线式处理器,具有简单的分支预测功能。根据技术规格,这款 芯片 的误判惩罚大约为 3 个时钟周期。

计数示例

        为了代码中演示分支语句的影响,我们写了一个非常简单的算法,它可以计算数组中比给定限制大的元素数量。你可以在我们的GitHub仓库中获取代码,只需要在2020-07-breaches文件夹中输入make counting

        以下是代码中的核心部分:

int count_bigger_than_limit_regular(int* array, int n, int limit) {
    int limit_cnt = 0;
    for (int i = 0; i < n; i++) {
        if (array[i] > limit) {
            limit_cnt++;
        }
    }

    return limit_cnt;
}

        如果你被要求编写算法,你可能会想出类似以上的内容。

        为了确保适当的测试,我们使用了优化级别 -O0 来编译所有的函数。在其他优化等级中,编译器会使用算法来替换分支,并进行一些复杂的循环处理,从而遮蔽了我们想要看到的内容。

分支语句预测失误的代价

        首先让我们测量一下分支错误预测的代价。刚刚提到的算法用于计算数组中所有大于limit的元素数量。因此,根据数组的值和限制值,我们可以调整(array[i] > limit) if (array[i] > limit) { limit_cnt++ } 中为真的概率。

        我们生成了输入数组的元素,使它们在 0 到数组长度(arr_len)之间均匀分布。接着为了测试错误预测惩罚我们设置限制值为0(条件始终为真),arr_len/2(条件50%的情况下为真且很难预测)和arr_len(条件永不为真)。以下是我们的测试结果:

条件永为真条件无法预测条件为false
运行时间(ms)5533141765478
指令14G13.5G13G
每周期指令1.360.501.27
分支错误预测概率(%)032.960

               数组长度为1百万,在AMD A8-4500M运行1000次搜索

        条件无法预测的代码版本在x86-64慢3倍,这是因为每次分支预测错误流水线都会被刷新。

        以下是ARM和MIPS芯片的运行时间:

条件永为真条件无法预测条件永为假
ARM30.59s32.23s25.89s
MIPS37.35s35.59s31.55s

在MIPS和ARM芯片上的运行时间,数组长度为1百万,搜索1000次

        根据我们的测试MIPS芯片没有错误预测惩罚(根据规格说明不是这样的),而在ARM芯片上有一个小惩罚,但肯定不像 x86-64 芯片那样剧烈。

        我们能修复这个问题吗?接着往下读

使用无分支语句

        根据之前给的建议让我们重写条件,以下是重写条件的三种实现:

int count_bigger_than_limit_branchless(int* array, int n, int limit) {
    int limit_cnt[] = { 0, 0 };
    for (int i = 0; i < n; i++) {
        limit_cnt[array[i] > limit]++;
    }
    return limit_cnt[1];
}

int count_bigger_than_limit_arithmetic(int* array, int n, int limit) {
    int limit_cnt = 0;
    for (int i = 0; i < n; i++) {
        limit_cnt += (array[i] > limit);
    }
    return limit_cnt;
}

int count_bigger_than_limit_cmove(int* array, int n, int limit) {
    int limit_cnt = 0;
    int new_limit_cnt;
    for (int i = 0; i < n; i++) {
        new_limit_cnt = limit_cnt + 1;
        // The following line is pseudo C++, originally it is written in inline assembly
        limit_cnt = conditional_load_if(array[i] > limit, new_limit_cnt);
    }
    return limit_cnt;
}

        这里有三个版本的代码:

  • count_bigger_than_limit_branchless(之后在文本中称为 branchless)内部使用一个包含两个元素的数组来计算数组元素既大于限制又小于限制值的情况。
  • count_bigger_than_limit_arithmetic(之后在文本中称为 arithmetic)利用表达式(array[i] > limit)只能取值0或1的事实,通过该表达式的值来增加计数器。
  • count_bigger_than_limit_cmove(之后在文本中称为 conditional move)计算新值,然后如果条件为真,使用条件移动来加载它。我们使用内联汇编来确保编译器会生成 cmov 指令。

        请注意,所有版本都有一个共同点。在分支内部有一个我们必须完成的任务。当我们移除分支时,我们仍然在完成这个任务,但这次即使不需要这个任务,我们仍然在执行它。这让CPU可以执行更多指令,但我们期望通过减少分支误判和提高每周期指令数量的比率来弥补这一点。

在 x86-64 架构上采用无分支方法

        我们采用的三种不同的避免分支的策略在数值上表现如何?以下是可预测条件的数值:

常规方法无分支语句算术方法条件移动
运行时间5502749261009845
指令执行14G19G15G19G
每周期指令数量1.371.371.331.04

Array length = 1M, searches 1000 on AMD A8-4500M for predictable branches

        综上所述,分支语句有预测功能时,常规的实现方式是最好的。这个实现方式还具有最少的执行指令数量和最佳的每周期指令比率的优点1

        总是为假的条件的运行时间与总是为真的条件的运行时间差异很小,对所有四种实现都适用。除了常规实现方法之外,其他实现方式数值都相同。在常规实现方法中,每周期指令数值较低,但是执行数量也较少因此执行速度没有发现差异。

        指令无法预测时会发生什么?以下数值看起来与之前有所不同

常规方式无分支方式算术方法条件移动
运行时间(ms)14225742760849836
指令执行13.5G19G15G19G
每周期指令0.51.381.321.04

数组长度为1百万,针对 AMD A8-4500M 处理器的不可预测分支进行了 1000 次搜索

        常规实现方式表现要差得多,是最慢的方式。由于分支的错误预测,流水线不得不被刷新,这导致每周期指令数量非常少。就其他实现方式来说,数据几乎没有变化。

        值得注意的一点是,如果我们使用 -O3 编译选项编译这个程序,编译器不会为常规实现方式生成分支指令。我们可以看到这是因为分支误判率很低,而运行时间数值与算术实现的数值非常接近。

在ARMv7上使用无分支方法

        在ARM芯片的情况下,这个数值看起来依然不同。因为作者不熟悉 ARM 汇编语言,所以我们没有显示条件移动实现的结果。以下是数值:

可预测条件常规方式算术方式无分支方式
始终为真3.059s3.385s4.359s
无法预测3.223s3.371s4.360s
始终为假2.589s3.370s4.360s

在 Allwinner sun71 A20 (ARMv7) 上的运行时间,数组长度为 100 万,进行了 100 次搜索

        在这种情况下常规方式版本更快,算术方式和无分支方式版本并没有带来任何速度上的提升,确切来说他们更慢。

        请记住,无法预测版本是最慢的,这表明该芯片具有某种分支预测能力。然而,分支错误预测的代价较低,否则我们会在那种情况下看到其他实现方式更快。

在MIPS32r2 架构上采用无分支方法

以下结果与MIPS架构相同:

条件预测常规方法算术方法无分支方法条件移动方法
永为真37.352s37.333s41.987s39.686s
无法预测35.590s37.353s42.033s39.731s
永为假31.551s37.396s42.055s39.763s

在Ingenic JZ4780(MIPS32r2),数组长度1百万,1000次搜索

        从以上数值来看,MIPS芯片似乎没有分支预测失误,因为运行时间只取决于常规实现方式中执行的指令数量,这与技术规范相反。就常规实现方式而言,为真的条件越少,程序运行速度越快。

        同样的,无分支方式相对来说运行成本较低,因为算术实现方式和常规实现方式在条件始终为真的情况下,具有相同的性能。

        总结:关于"likely"和"unlikely"宏,我们的研究显示,它们在具有分支预测器的处理器上没有任何帮助。不幸的是,我们没有一个无分支预测功能的处理器去测试这一行为。

组合条件

        为了在if语句中测试组合条件,我们这样修改代码:

int count_bigger_than_limit_joint_simple(int* array, int n, int limit) {
    int limit_cnt = 0;
    for (int i = 0; i < n/2; i+=2) {
        // The two conditions in this if can be joined with & or &&
        if (array[i] > limit && array[i + 1] > limit) {
            limit_cnt++;
        }
    }
    return limit_cnt;
}

        基本上,这是一个非常简单的修改,其中两个条件都难以预测。唯一的不同点是在第四行:if (array[i] > limit && array[i + 1] > limit),我们想要测试使用&&操作符和使用&操作符连接条件是否有所不同。我们将第一个版本称为简单版本,将第二个版本称为算术版本。

        我们使用-O0来编译上述函数,因为当我们使用-O3编译时,在x80-64上算术版本运行非常快而且不存在分支错误预测。这说明编译器对分支进行了充分的优化。

        以下是如果两种条件预测困难且存在优化的情况下,三种架构的数值:

合并简化组合算术运算符方法
x86-645.18s3.37s
ARM12.756s15.317s
MIPS13.221s15.337s

对于三种不同的架构,数组包含1百万个元素,进行了1000次搜索,分别测试了两种不同的条件联合方式的运行时。

        以上的数值显示对于具有分支预测和错误预测惩罚高的CPU来说,组合算术运算符方式更快。但是对于错误预测惩罚低的CPU来说合并简化方式更快,因为CPU执行的指令更少。

二分搜索算法

        为了进一步测试分支行为,我们采用了在数据缓存友好编程文章中测试缓存预取的二分搜索算法。源码可以在我们的GitHub仓库获取,只需要在2020-07-branches文件夹中输入make binary_search

以下是实现二分搜索的代码:

int binary_search(int* array, int number_of_elements, int key) {
    int low = 0, high = number_of_elements-1, mid;
    while(low <= high) {
        mid = (low + high)/2;
        if (array[mid] == key) {
            return mid;
        }
        if(array[mid] < key) {
            low = mid + 1; 
        } else {
            high = mid-1;
        }
    }
    return -1;
}

        上面的算法是一个经典的二分搜索算法,下文中我们将其称之为常规实现。请注意在8-12行有一个至关重要的if/else条件语句,它决定了搜索的流程。由于二分搜索算法的特性,条件array[mid]<key很难预测。同样的,因为数据并不在数据缓冲中,获取array[mid]的值代价也很昂贵。

        我们使用了两种方法来消除分支——条件移动和算术方法,以下是这两个版本的代码:

// 条件移动方法实现
int new_low = low + 1;
int new_high = high - 1;
bool condition = array[mid] > key;
// The bellow two lines are pseudo C++, the actual code is written in assembler
low = conditional_move_if(new_low, condition);
high = conditional_move_if_not(new_high, condition);


//算术方法实现
int new_low = mid + 1;
int new_high = mid - 1;
int condition = array[mid] < key;
int condition_true_mask = -condition;
int condition_false_mask = -(1 - condition);

low += condition_true_mask & (new_low - low);
high += condition_false_mask & (new_high - high); 

        条件移动通过CPU提供的指令来条件式的加载准备好的值来实现。

        算术方式通过使用巧妙的条件操作来生成 "condition_true_mask" 和 "condition_false_mask"来实现,依赖于这些masks的值,算术方式会加载合适的值到变量lowhigh中。

二分搜索算法在x86-64上

        以下是x86-64CPU在工作集较大且无法适应缓存的情况下的数值。我们测试了使用 __builtin_prefetch 进行显式数据预取和不预取的算法版本。

常规方法算术方法条件移动方法
无数据预取2.919s3.623s3.243
有数据预取2.667s2.748s2.609s

运行不同二分搜索实现,4百万数组元素,4百万次搜索

        上述表格展示的东西非常有趣。在二分搜索中分支并不能很好地进行预测,然而当不存在数据预取时,常规算法表现最好。为什么?因为分支预测、猜测执行和乱序执行在等待数据从内存到达时为 CPU 提供了帮助。为了不在这里增加文本负担,我们稍后再讨论这个问题。

        以下是当工作集完全适合 L1 缓存时相同算法的结果:

常规方法算术方法条件移动方法
无数据预取0.774s0.681s0.553s
有数据预取0.825s0.704s0.618s

运行不同二分搜索实现,4千数组元素,4百万次搜索

        与先前实验相比数值并不相同,当工作集完全适应L1缓存时,条件移动版本永远是最快的,其次是算术版本。常规版本由于许多分支预测错误而性能表现不佳。

        在工作集较小的情况下,预取并不会有帮助,即这些算法会更慢。因为所有数据已经在缓存中,而预取指令只是需要更多需要执行的指令,并没有任何附加好处。

二分搜索算法在ARM和MIPS上

        对于ARM和MIPS芯片来说,预取算法比非预取更慢,所以我们不考虑这些数字。

        以下是在具有 4 百万个元素的数组上运行的 ARM 和 MIPS 芯片的二分查找运行时间:

常规方法算术方法条件移动方法
ARM10.85s11.06s——
MIPS11.79s11.80s11.87s

ARM 和 MIPS 芯片的运行时间,数组中包含 4 百万个元素,进行 4 百万次搜索

        在 MIPS 上,这三种版本的性能大致相同。在 ARM 上,常规版本略快于算术版本。

        以下是在具有1万个元素数组上运行的ARM和MIPS芯片的二分查找运行时间:

常规方法算术方法条件移动方法
ARM1.71s1.79s——
MIPS1.42s1.48s1.51s

ARM 和 MIPS 芯片的运行时间,数组中包含 4 百万个元素,进行 4 百万次搜索

        工作集的大小不会改变数值的相对比例。在这些芯片上,与分支相关的优化不会提高速度。

为什么在 x86-64 架构上,在大工作集的情况下,带有分支的二分查找最快?

        好的,现在让我们回到这个有趣的问题上。在 x86-64 架构的芯片上,我们观察到如果工作集很大,带有分支的版本是最快的。在工作集很小的情况下,条件移动版本是最快的。当我们引入显式的软件预取以增加缓存命中率时,我们会看到常规版本的优势逐渐减弱。为什么会这样?

乱序执行的限制

        为了解释这个概念,请记住我们谈到的CPU是具有分支预测、猜测执行和乱序执行的高端CPU。这意味着CPU虽然可以并行执行许多指令,但是一次性执行的指令是有限制的。这一限制取决于两个因素:

  • 处理器内的资源是有限的。一个典型的高端处理器可能会同时处理四个简单的算术指令、两个加载指令、两个存储指令,或一个复杂的算术指令。当指令执行完毕(术语是指令退出),资源会被释放以便于处理器可以处理新的指令。

  • 指令之间有数据依赖关系。如果当前指令的输入参数以来依赖上一个指令的结果,当前指令在上一个指令执行完毕之后才会被处理。它卡在处理器中,占用资源并阻止其他指令进入。

       所有代码都有数据依赖关系,而有数据依赖关系的代码并不一定不好,但数据依赖降关系低了处理器每个周期能够执行的指令数量。

        对于顺序执行的CPU,如果当前指令依赖于前一指令且前一指令尚未完成,流水线将会在当前指令上阻塞。对于乱序执行的CPU,处理器会尝试加载其他阻塞在后面的指令。如果这些指令并不依赖于前一个指令,他们就会被安全地执行。这就是CPU利用闲置资源的方式。

解释带有分支的二分查找性能

        那么这与我们的二分查找的性能有什么关联呢?以下是我们在伪汇编中常规实现方法的一个重要部分::

    element = load(base_address = array, index = mid)
    if_not (element < key) goto ELSE
    low = mid + 1
    goto ENDIF
ELSE:
    high = mid - 1
ENDIF:
    // These are the instructions at the beginning of the next loop
    mid = low + high
    mid = mid / 2

        让我们来做一些假设:如果数据缓存中不存在array[mid],则操作element = load(base_address = array, index = mid)需要300个周期才能完成,否则只需3个周期。分支条件element < key 的预测准确率最差情况下为50%,分支错误预测的代价是15个周期。

        让我们来分析一下我们的代码是怎么执行的。处理器为了执行第一行的加载,需要等待300个周期。因为有乱序执行功能,处理器开始执行第二行的分支。第二行的指令依赖于第一行的结果,所以CPU无法执行它。然而,CPU进行了猜测并开始执行第3、4、9和10行上的指令。如果猜测是正确的,执行所有指令就需要300个周期。反之,除了额外的15个周期用于分支错误预测之外,还需要额外的时间来执行指令6、9和10。

解释带有条件移动的二分查找性能

        条件移动是如何实现的?以下是实现方式,还是使用伪汇编代码:

    element = load(base_address = array, index = mid)
    load_low =  element < key
    new_low = mid + 1
    new_high = mid - 1
    low = move_if(condition = load_low, value = new_low)
    high = move_if_not(condition = load_low, value = new_high)

    // These are the instructions at the beginning of the next loop
    mid = low + high
    mid = mid / 2

         这里没有分支语句,因此不存在分支错误预测惩罚。让我们假设与常规实现方法相同的前提条件(即,如果array[mid]不在数据缓存中,则操作element = load(base_address = array, index = mid)需要300个周期才能完成,否则只需3个周期)。

        代码执行过程如下:处理器为了执行第一行的加载,需要等待300个周期。因为有乱序执行功能,处理器开始执行第二行的指令,但是他会阻塞等待第一行的数据。处理器会执行第三行和第四行的指令。但是无法执行第五行和第六行的指令,因为这两行指令依赖第二行的指令。第9行的指令受阻,因为它依赖于第5行和第6行的指令,第10行的指令同因为依赖第9行的指令而受阻。由于这里没有涉及到猜测执行,达到第10条指令将需要300个时钟周期,再加上执行第2、5、6和9条指令所需的一些时间。

分支与条件移动性能比较

        现在让我们进行一些简单的数学计算,在使用分支预测的二分查找情况下,运行时间如下:

MISSPREDICTION_PENALTY = 15 cycles
INSTRUCTIONS_NEEDED_TO_EXECUTE_DUE_MISSPREDICTION = 50 cycles

RUNTIME = (RUNTIME_PREDICTION_CORRECT + RUNTIME_PREDICTION_NOTCORRECT) / 2
RUNTIME_PREDICTION_CORRECT = 300 cycles
RUNTIME_PREDICTION_NOTCORRECT = 300 cycles + MISSPREDICTION_PENALTY + INSTRUCTIONS_NEEDED_TO_EXECUTE_DUE_MISSPREDICTION = 365 cycles

RUNTIME = 332.5 cycles

        在使用条件移动的二分查找情况下,运行时间如下:

INSTRUCTIONS_BLOCKED_WAITING_FOR_DATA = 50 cycles

RUNTIME = 300 cycles + INSTRUCTIONS_BLOCKED_WAITING_FOR_DATA = 350 cycles

        综上所述,在需要等待数据从内存到达的情况下,分支预测版本平均快了17.5个时钟周期。

结论

        目前的处理器不会对条件移动进行猜测,只会对分支进行猜测。分支预测允许处理器掩盖由于较慢的内存访问而产生的一些惩罚。条件移动(以及其他分支消除技术)可以消除分支错误预测的惩罚,但会引入数据依赖性的惩罚。处理器会更频繁地被阻塞,因此可以推测执行较少的指令。在缓存未命中率较低的情况下,数据依赖性的惩罚可能比分支错误预测的惩罚更昂贵。

        因此结论如下:分支预测打破了一些数据依赖关系,有效地掩盖了CPU需要等待内存数据的时间。如果分支预测器猜测正确,当数据从内存到达时,很多工作已经完成。而对于没有分支的代码来说,情况则不同。

Footnotes

  1. Instruction per cycle ratio measures how effective the CPU utilizes its pipeline