为什么处理排序数组比处理未排序数组快?


24439

这是一段C ++代码,显示了一些非常特殊的行为。出于某些奇怪的原因,奇迹般地对数据进行排序使代码快了将近六倍:

#include <algorithm>
#include <ctime>
#include <iostream>

int main()
{
    // Generate data
    const unsigned arraySize = 32768;
    int data[arraySize];

    for (unsigned c = 0; c < arraySize; ++c)
        data[c] = std::rand() % 256;

    // !!! With this, the next loop runs faster.
    std::sort(data, data + arraySize);

    // Test
    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i)
    {
        // Primary loop
        for (unsigned c = 0; c < arraySize; ++c)
        {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;

    std::cout << elapsedTime << std::endl;
    std::cout << "sum = " << sum << std::endl;
}
  • 没有std::sort(data, data + arraySize);,代码将在11.54秒内运行。
  • 使用排序的数据,代码将在1.93秒内运行。

最初,我认为这可能只是语言或编译器异常,所以我尝试了Java:

import java.util.Arrays;
import java.util.Random;

public class Main
{
    public static void main(String[] args)
    {
        // Generate data
        int arraySize = 32768;
        int data[] = new int[arraySize];

        Random rnd = new Random(0);
        for (int c = 0; c < arraySize; ++c)
            data[c] = rnd.nextInt() % 256;

        // !!! With this, the next loop runs faster
        Arrays.sort(data);

        // Test
        long start = System.nanoTime();
        long sum = 0;

        for (int i = 0; i < 100000; ++i)
        {
            // Primary loop
            for (int c = 0; c < arraySize; ++c)
            {
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

        System.out.println((System.nanoTime() - start) / 1000000000.0);
        System.out.println("sum = " + sum);
    }
}

具有相似但不太极端的结果。


我首先想到的是排序将数据带入缓存,但是后来我想到这样做是多么愚蠢,因为刚刚生成了数组。

  • 到底是怎么回事?
  • 为什么处理排序数组比处理未排序数组快?

该代码总结了一些独立的术语,因此顺序无关紧要。



15
@SachinVerma让我烦恼的是:1)JVM最终可能足够聪明,可以使用条件移动。2)代码受内存限制。200M太大,无法容纳CPU缓存。因此,性能将受到内存带宽而不是分支的瓶颈。
Mysticial

11
@Mysticial,大约2)。我认为预测表会跟踪模式(与为该模式检查的实际变量无关),并根据历史记录更改预测输出。您能否给我一个原因,为什么超大型数组无法从分支预测中受益?
Sachin Verma

14
@SachinVerma可以,但是当数组那么大时,可能会发挥更大的作用-内存带宽。内存不平。访问内存非常慢,并且带宽有限。为了简化起见,在固定的时间内只能在CPU和内存之间传输这么多字节。像这个问题中的简单代码一样,即使由于错误预测而放慢了速度,也可能会达到该极限。对于32768(128KB)的数组,这不会发生,因为它适合CPU的L2缓存。
Mysticial

11
有一个名为BranchScope的新安全漏洞:cs.ucr.edu/~nael/pubs/asplos18.pdf
Veve

Answers:


31782

您是分支预测失败的受害者。


什么是分支预测?

考虑一个铁路枢纽:

该图显示了铁路枢纽 Mecanismo的图片,通过Wikimedia Commons。在CC-By-SA 3.0许可下使用。

现在,为了论证,假设这是在1800年代-在进行长距离或无线电通信之前。

您是路口的操作员,并且听到火车驶入。您不知道应该走哪条路。您停下火车,询问驾驶员他们想要哪个方向。然后您适当地设置开关。

火车很重,惯性很大。因此,它们要花很多时间才能启动和减速。

有没有更好的办法?您猜火车将朝哪个方向行驶!

  • 如果您猜对了,它将继续进行。
  • 如果您猜错了,机长会停下来,后退并大喊大叫,以拨动开关。然后,它可以沿着其他路径重新启动。

如果您每次都猜对了,火车将永远不会停止。
如果您经常猜错,火车将花费大量时间停止,备份和重新启动。


考虑一个if语句:在处理器级别,它是一条分支指令:

包含if语句的已编译代码的屏幕截图

您是处理器,并且看到一个分支。您不知道它将走哪条路。你是做什么?您停止执行,并等待之前的说明完成。然后,您沿着正确的路径继续。

现代处理器很复杂,而且流程很长。因此,他们需要永远进行“热身”和“减速”。

有没有更好的办法?您猜分支将朝哪个方向前进!

  • 如果您猜对了,则继续执行。
  • 如果您猜错了,则需要刷新管道并回滚到分支。然后,您可以沿着其他路径重新启动。

如果您每次都猜对了,执行将永远不会停止。
如果您经常猜错,那么您将花费大量时间来拖延,回滚和重新启动。


这是分支预测。我承认这不是最好的类比,因为火车可以只用一个标志来指示方向。但是在计算机中,处理器直到最后一刻才知道分支的方向。

那么,您如何从战略上猜测如何将火车必须倒退和走另一条路的次数降至最低?您看看过去的历史!如果火车有99%的时间向左行驶,那么您就猜到了。如果它交替出现,那么您将交替猜测。如果它每三回​​去一次,您会猜到相同...

换句话说,您尝试识别一个模式并遵循它。这或多或少是分支预测变量的工作方式。

大多数应用程序具有行为良好的分支。因此,现代分支预测器通常将达到90%以上的命中率。但是,当面对没有可识别模式的不可预测分支时,分支预测变量实际上是无用的。

进一步阅读:Wikipedia上的“分支预测器”文章


从上面暗示,罪魁祸首是这个if陈述:

if (data[c] >= 128)
    sum += data[c];

请注意,数据在0到255之间均匀分布。对数据进行排序时,大约前半部分的迭代将不会进入if语句。之后,他们都会进入if语句。

这对分支预测器非常友好,因为分支连续多次朝同一方向前进。即使是简单的饱和计数器也可以正确预测分支,除了在切换方向后进行几次迭代外。

快速可视化:

T = branch taken
N = branch not taken

data[] = 0, 1, 2, 3, 4, ... 126, 127, 128, 129, 130, ... 250, 251, 252, ...
branch = N  N  N  N  N  ...   N    N    T    T    T  ...   T    T    T  ...

       = NNNNNNNNNNNN ... NNNNNNNTTTTTTTTT ... TTTTTTTTTT  (easy to predict)

但是,当数据完全随机时,分支预测器将变得无用,因为它无法预测随机数据。因此,可能会有大约50%的错误预测(没有比随机猜测好)。

data[] = 226, 185, 125, 158, 198, 144, 217, 79, 202, 118,  14, 150, 177, 182, 133, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T,   N  ...

       = TTNTTTTNTNNTTTN ...   (completely random - hard to predict)

那该怎么办呢?

如果编译器无法将分支优化为有条件的移动,那么如果您愿意牺牲可读性来提高性能,则可以尝试一些破解。

更换:

if (data[c] >= 128)
    sum += data[c];

与:

int t = (data[c] - 128) >> 31;
sum += ~t & data[c];

这消除了分支,并用一些按位运算代替了它。

(请注意,这种破解并不完全等同于原始的if语句。但是在这种情况下,它对于的所有输入值均有效data[]。)

基准:Core i7 920 @ 3.5 GHz

C ++-Visual Studio 2010-x64版本

//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587

Java-NetBeans 7.1.1 JDK 7-x64

//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823

观察结果:

  • 使用分支:排序和未排序的数据之间存在巨大差异。
  • 使用Hack:排序和未排序的数据之间没有区别。
  • 在C ++情况下,对数据进行排序时,hack实际上比分支慢一点。

一般的经验法则是避免在关键循环中避免依赖于数据的分支(例如在此示例中)。


更新:

  • 带有x64 -O3或带有-ftree-vectorizex64的GCC 4.6.1 能够产生条件移动。因此,已排序和未排序的数据之间没有区别-两者都很快速。

    (或者有点快:对于已经排序的情况,cmov可能会更慢,尤其是如果GCC将其放在关键路径上而不是仅仅在add,尤其是在Broadwell之前cmov有2个周期延迟的Intel上:gcc优化标志-O3使代码比-O2慢

  • VC ++ 2010无法在该分支下生成此分支的条件移动/Ox

  • 英特尔C ++编译器(ICC)11发挥了神奇的作用。它互换两个循环,从而将不可预测的分支提升到外部循环。因此,它不仅可以避免错误预测,而且还比VC ++和GCC生成的速度快两倍!换句话说,ICC利用测试循环来击败基准测试……

  • 如果给Intel编译器提供无分支的代码,它将直接对其进行矢量化处理……并且速度与分支一样快(通过循环交换)。

这表明即使是成熟的现代编译器,其优化代码的能力也可能存在巨大差异。


255
看看以下后续问题:stackoverflow.com/questions/11276291/…英特尔编译器几乎完全摆脱了外循环。
Mysticial

23
@Mysticial火车/编译器如何知道输入了错误的路径?
onmyway133

25
@obe:在给定的分层内存结构的情况下,无法说出高速缓存未命中所付出的代价。它可能在L1中丢失并在较慢的L2中解决,或者在L3中丢失并在系统内存中解决。但是,除非出于某种奇怪的原因,这种高速缓存未命中会导致从磁盘加载非驻留页面中的内存,否则您有个很好的主意...大约25-30年内,内存的访问时间未达到毫秒级;)
Andon M. Coleman 2014年

20
编写在现代处理器上有效的代码的经验法则:使程序的执行更加规则(不那么不均匀)的一切都将使其效率更高。由于分支预测,此示例中的排序具有此效果。由于缓存的原因,访问位置(而不是广泛的随机访问)会产生这种影响。
Lutz Prechelt'2

21
@Sandeep是的。处理器仍然具有分支预测。如果有什么变化,那就是编译器。如今,我敢打赌,他们更有可能在这里执行ICC和GCC(在-O3下),即删除分支。考虑到这个问题的知名度,很可能已经对编译器进行了更新,以专门处理该问题。绝对要注意SO。它发生在三个星期内更新了GCC的问题上。我不明白为什么它也不会在这里发生。
Mysticial 2015年

4086

分支预测。

对于排序数组,条件data[c] >= 128首先false是值的条纹,然后true是所有后续值的条件。这很容易预测。使用未排序的数组,您需要支付分支成本。


105
分支预测相对于具有不同模式的数组在排序数组上是否更好?例如,对于数组-> {10,5,20,10,40,20,...},该模式中该数组中的下一个元素为80。如果遵循模式,下一个元素是80?还是通常只对排序数组有用?
亚当·弗里曼

132
因此,基本上我通常所学的关于big-O的所有知识都不在眼前?更好的是产生分拣成本而不是分支成本?
2014年

133
@AgrimPathak取决于。对于不太大的输入,当具有较高复杂度的算法的常数较小时,具有较高复杂度的算法将比具有较低复杂度的算法更快。收支平衡点在哪里很难预测。另外,对此进行比较,位置很重要。Big-O很重要,但这不是性能的唯一标准。
丹尼尔·菲舍尔

65
分支预测何时发生?语言何时会知道数组已排序?我正在考虑看起来像数组的情况:[1,2,3,4,5,... 998,999,1000,3,10001,10002]吗?这个晦涩的3会增加运行时间吗?它会和未排序的数组一样长吗?
Filip Bartuzi 2014年

63
@FilipBartuzi分支预测在语言级别以下的处理器中进行(但是语言可能提供告诉编译器可能发生的方式,因此编译器可以发出适合该代码的方式)。在您的示例中,乱序3会导致分支预测错误(在适当的条件下,其中3给出的结果与1000不同),因此处理该数组可能要比数组多花费几十或几百纳秒。排序数组几乎不会引起注意。花费时间的是我的错误预测率很高,每1000个错误预测的发生率并不高。
丹尼尔·菲舍尔

3310

当对数据进行排序时,性能大幅提高的原因是消除了分支预测损失,如Mysticial的答案中所详细解释的那样。

现在,如果我们看一下代码

if (data[c] >= 128)
    sum += data[c];

我们发现该特定if... else...分支的含义是在满足条件时添加一些内容。这种类型的分支可以很容易地转化为有条件的举动语句,该语句将被编译成一个条件移动指令:cmovl在一个x86系统中。去除分支并因此去除潜在的分支预测损失。

C,因此C++,语句,这将直接编译(不使用任何优化)插入在条件移动指令x86,是三元运算符... ? ... : ...。因此,我们将上面的语句重写为等效的语句:

sum += data[c] >=128 ? data[c] : 0;

在保持可读性的同时,我们可以检查加速因子。

在Intel Core i7 -2600K @ 3.4 GHz和Visual Studio 2010 Release Mode上,基准是(从Mysticial复制的格式):

x86

//  Branch - Random
seconds = 8.885

//  Branch - Sorted
seconds = 1.528

//  Branchless - Random
seconds = 3.716

//  Branchless - Sorted
seconds = 3.71

x64

//  Branch - Random
seconds = 11.302

//  Branch - Sorted
 seconds = 1.830

//  Branchless - Random
seconds = 2.736

//  Branchless - Sorted
seconds = 2.737

在多次测试中,结果是可靠的。当分支结果不可预测时,我们可以大大提高速度,但是当分支结果不可预测时,我们会受到一些影响。实际上,使用条件移动时,无论数据模式如何,性能都是相同的。

现在,让我们通过研究x86它们生成的程序集来进行更仔细的研究。为简单起见,我们使用max1和两个函数max2

max1使用条件分支if... else ...

int max1(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}

max2 使用三元运算符 ... ? ... : ...

int max2(int a, int b) {
    return a > b ? a : b;
}

在x86-64机器上, GCC -S生成以下程序集。

:max1
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    -8(%rbp), %eax
    jle     .L2
    movl    -4(%rbp), %eax
    movl    %eax, -12(%rbp)
    jmp     .L4
.L2:
    movl    -8(%rbp), %eax
    movl    %eax, -12(%rbp)
.L4:
    movl    -12(%rbp), %eax
    leave
    ret

:max2
    movl    %edi, -4(%rbp)
    movl    %esi, -8(%rbp)
    movl    -4(%rbp), %eax
    cmpl    %eax, -8(%rbp)
    cmovge  -8(%rbp), %eax
    leave
    ret

max2 由于使用指令,所以使用更少的代码 cmovge。但是真正的收益是max2不涉及分支跳转,jmp如果预测结果不正确,则分支跳转将受到重大性能损失。

那么为什么有条件举动的表现更好呢?

在典型的x86处理器中,一条指令的执行分为几个阶段。大致来说,我们有不同的硬件来处理不同的阶段。因此,我们不必等待一条指令完成就可以开始一条新指令。这就是所谓的流水线

在分支情况下,以下指令由前一条指令确定,因此我们无法进行流水线操作。我们必须等待或预测。

在条件移动的情况下,执行条件移动指令分为几个阶段,但较早的阶段如FetchDecode不依赖于先前指令的结果; 只有后期才需要结果。因此,我们等待一条指令执行时间的一小部分。这就是为什么在容易预测的情况下条件移动版本比分支慢的原因。

第二版计算机系统:程序员的观点》一书对此做了详细说明。您可以检查第3.6.6节中的“ 条件移动指令”,第4章中的“ 处理器体系结构 ”和第5.11.2节中的“ 分支预测和错误预测惩罚”的特殊处理。

有时,某些现代的编译器可以优化我们的代码以使其具有更好的性能,而有时某些编译器则不能(问题代码使用Visual Studio的本机编译器)。当情况变得如此复杂以至于编译器无法自动优化它们时,了解分支与条件移动之间的性能差异(这是不可预测的)可以帮助我们编写性能更高的代码。


7
@ BlueRaja-DannyPflughoeft这是未优化的版本。编译器没有优化三元运算符,只是对其进行翻译。GCC可以优化if-然后,如果给定足够的优化级别,尽管如此,这显示了条件移动的力量,而手动优化则有所不同。
WiSaGaN 2012年

100
@WiSaGaN该代码没有显示任何内容,因为您的两段代码编译为同一机器代码。至关重要的是,人们不要让您的示例中的if语句与您的示例中的三次语句有所不同。的确,您拥有上一段中的相似性,但这并不能消除示例的其余部分有害的事实。
贾斯汀·L.

55
@WiSaGaN如果您修改答案以删除误导性-O0示例并显示两个测试用例在优化的 asm中的区别,则我的反对意见一定会变成反对意见。
贾斯汀·L.

56
@UpAndAdam测试时,即使指定了较高的优化级别,VS2010也无法将原始分支优化为条件移动,而gcc可以。
WiSaGaN

9
这个三元运算符技巧对Java来说效果很好。阅读Mystical的答案后,我想知道Java可以做些什么来避免错误的分支预测,因为Java没有与-O3等效的东西。三元运算符:2.1943s,原始值:6.0303s。
张健

2271

如果您对可以对此代码进行更多优化感到好奇,请考虑以下事项:

从原始循环开始:

for (unsigned i = 0; i < 100000; ++i)
{
    for (unsigned j = 0; j < arraySize; ++j)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

使用循环交换,我们可以安全地将此循环更改为:

for (unsigned j = 0; j < arraySize; ++j)
{
    for (unsigned i = 0; i < 100000; ++i)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

然后,您可以看到if条件在整个i循环执行期间是恒定的,因此您可以if取消:

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        for (unsigned i = 0; i < 100000; ++i)
        {
            sum += data[j];
        }
    }
}

然后,您将看到内部循环可以折叠为一个表达式,假设浮点模型允许(/fp:fast例如抛出)

for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        sum += data[j] * 100000;
    }
}

那是比以前快100,000倍。


276
如果您想作弊,则最好在循环外进行乘法运算,并在循环后进行sum * = 100000。
Jyaif 2012年

78
@Michael-我相信这个例子实际上是循环不变提升(LIH)优化和NOT loop swap的例子。在这种情况下,整个内部回路独立于外部回路,因此可以从外部回路中吊起,因此,只需将结果乘以i一个单位= 1e5 的总和即可。这对最终结果没有影响,但是我只是想保持记录连续,因为这是一个经常访问的页面。
Yair Altman

54
尽管不具有交换循环的简单精神,但此时的内部if可以转换为:sum += (data[j] >= 128) ? data[j] * 100000 : 0;编译器可以将其转换 为cmovge或等效。
Alex North-Keys

43
外循环是为了使内循环花费的时间足够大以进行分析。那么为什么要循环交换。最后,该循环将被删除。
saurabheights

34
@saurabheights:错误的问题:为什么编译器不会循环交换。微基准测试很难;)
Matthieu M.

1884

毫无疑问,我们中的某些人会对识别对CPU的分支预测器有问题的代码的方式感兴趣。Valgrind工具cachegrind具有一个分支预测器模拟器,可通过使用该--branch-sim=yes标志启用它。在此问题的示例上运行它,将外部循环数减少到10000并使用编译g++,得到以下结果:

排序:

==32551== Branches:        656,645,130  (  656,609,208 cond +    35,922 ind)
==32551== Mispredicts:         169,556  (      169,095 cond +       461 ind)
==32551== Mispred rate:            0.0% (          0.0%     +       1.2%   )

未分类:

==32555== Branches:        655,996,082  (  655,960,160 cond +  35,922 ind)
==32555== Mispredicts:     164,073,152  (  164,072,692 cond +     460 ind)
==32555== Mispred rate:           25.0% (         25.0%     +     1.2%   )

深入研究所cg_annotate看到的循环所产生的逐行输出:

排序:

          Bc    Bcm Bi Bim
      10,001      4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .      .  .   .      {
           .      .  .   .          // primary loop
 327,690,000 10,016  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .      .  .   .          {
 327,680,000 10,006  0   0              if (data[c] >= 128)
           0      0  0   0                  sum += data[c];
           .      .  .   .          }
           .      .  .   .      }

未分类:

          Bc         Bcm Bi Bim
      10,001           4  0   0      for (unsigned i = 0; i < 10000; ++i)
           .           .  .   .      {
           .           .  .   .          // primary loop
 327,690,000      10,038  0   0          for (unsigned c = 0; c < arraySize; ++c)
           .           .  .   .          {
 327,680,000 164,050,007  0   0              if (data[c] >= 128)
           0           0  0   0                  sum += data[c];
           .           .  .   .          }
           .           .  .   .      }

这样一来,您可以轻松识别出问题行-在未排序版本中,该if (data[c] >= 128)行在Bcmcachegrind的分支预测器模型下导致164,050,007错误预测的条件分支(),而在排序版本中仅导致10,006。


另外,在Linux上,您可以使用性能计数器子系统来完成相同的任务,但是要使用CPU计数器来实现本机性能。

perf stat ./sumtest_sorted

排序:

 Performance counter stats for './sumtest_sorted':

  11808.095776 task-clock                #    0.998 CPUs utilized          
         1,062 context-switches          #    0.090 K/sec                  
            14 CPU-migrations            #    0.001 K/sec                  
           337 page-faults               #    0.029 K/sec                  
26,487,882,764 cycles                    #    2.243 GHz                    
41,025,654,322 instructions              #    1.55  insns per cycle        
 6,558,871,379 branches                  #  555.455 M/sec                  
       567,204 branch-misses             #    0.01% of all branches        

  11.827228330 seconds time elapsed

未分类:

 Performance counter stats for './sumtest_unsorted':

  28877.954344 task-clock                #    0.998 CPUs utilized          
         2,584 context-switches          #    0.089 K/sec                  
            18 CPU-migrations            #    0.001 K/sec                  
           335 page-faults               #    0.012 K/sec                  
65,076,127,595 cycles                    #    2.253 GHz                    
41,032,528,741 instructions              #    0.63  insns per cycle        
 6,560,579,013 branches                  #  227.183 M/sec                  
 1,646,394,749 branch-misses             #   25.10% of all branches        

  28.935500947 seconds time elapsed

它还可以使用反汇编进行源代码注释。

perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
 Percent |      Source code & Disassembly of sumtest_unsorted
------------------------------------------------
...
         :                      sum += data[c];
    0.00 :        400a1a:       mov    -0x14(%rbp),%eax
   39.97 :        400a1d:       mov    %eax,%eax
    5.31 :        400a1f:       mov    -0x20040(%rbp,%rax,4),%eax
    4.60 :        400a26:       cltq   
    0.00 :        400a28:       add    %rax,-0x30(%rbp)
...

有关更多详细信息,请参见性能教程


74
这很可怕,在未排序的列表中,应该有50%的机会点击添加项。不知何故,分支预测的遗漏率只有25%,如何比50%的遗漏更好呢?
TallBrian 2013年

128
@ tall.b.lo:25%属于所有分支- 循环中有两个分支,一个分支data[c] >= 128(如您所建议的有50%的未命中率),另一个是循环条件的c < arraySize〜0%的未命中率。
caf 2013年

1340

我只是阅读了这个问题及其答案,所以我觉得答案丢失了。

我发现在托管语言中消除分支预测的一种通用方法是使用表查找而不是使用分支(尽管在这种情况下我没有对其进行测试),这在托管语言中特别有用。

这种方法通常在以下情况下有效:

  1. 它是一个小表,可能会缓存在处理器中,并且
  2. 您正在以非常紧密的循环运行事物和/或处理器可以预加载数据。

背景和原因

从处理器的角度来看,您的内存很慢。为了弥补速度上的差异,处理器内置了两个缓存(L1 / L2缓存)。因此,假设您正在执行出色的计算,并发现您需要一块内存。处理器将执行其“加载”操作,并将内存加载到缓存中,然后使用缓存进行其余的计算。由于内存相对较慢,因此此“加载”将减慢您的程序的速度。

像分支预测一样,它在奔腾处理器中进行了优化:处理器预测它需要加载一条数据,并在操作实际到达缓存之前尝试将其加载到缓存中。正如我们已经看到的那样,分支预测有时会出现严重的错误-在最坏的情况下,您需要返回并实际上等待内存加载,这将花费很长的时间(换句话说:失败的分支预测很糟糕,内存不足)分支预测失败后的负载简直太可怕了!)。

对我们来说幸运的是,如果内存访问模式是可预测的,则处理器会将其加载到其快速缓存中,一切都很好。

我们需要知道的第一件事是小的?虽然通常较小会更好,但经验法则是坚持使用<= 4096字节大小的查找表。作为上限:如果您的查找表大于64K,则可能值得重新考虑。

构造表

因此,我们发现可以创建一个小表。接下来要做的就是准备好查找功能。查找函数通常是使用几个基本整数运算(和,或,异或,移位,加法,移除和可能乘)的小函数。您希望通过查找功能将输入转换为表中的某种“唯一键”,然后简单地为您提供所需的所有工作的答案。

在这种情况下:> = 128表示我们可以保留该值,<128表示我们可以摆脱它。最简单的方法是使用“ AND”:如果我们将其保留,则将其与7FFFFFFF进行AND;如果要删除它,则将其与0进行“与”运算。还要注意,128是2的幂-因此,我们可以继续制作一张32768/128整数的表,并用一个零和很多零填充7FFFFFFFF。

托管语言

您可能想知道为什么这在托管语言中能很好地工作。毕竟,托管语言使用分支检查数组的边界,以确保您不会弄乱……

好吧,不完全是... :-)

关于消除托管语言的此分支,已经进行了很多工作。例如:

for (int i = 0; i < array.Length; ++i)
{
   // Use array[i]
}

在这种情况下,对于编译器显而易见的是,边界条件将永远不会被满足。至少Microsoft JIT编译器(但我希望Java做类似的事情)会注意到这一点,并完全删除该检查。哇,这意味着没有分支。同样,它将处理其他明显的情况。

如果您在使用托管语言进行查找时遇到麻烦-关键是& 0x[something]FFF在查找函数中添加a 以使边界检查可预测-并观察其运行速度。

这种情况的结果

// Generate data
int arraySize = 32768;
int[] data = new int[arraySize];

Random random = new Random(0);
for (int c = 0; c < arraySize; ++c)
{
    data[c] = random.Next(256);
}

/*To keep the spirit of the code intact, I'll make a separate lookup table
(I assume we cannot modify 'data' or the number of loops)*/

int[] lookup = new int[256];

for (int c = 0; c < 256; ++c)
{
    lookup[c] = (c >= 128) ? c : 0;
}

// Test
DateTime startTime = System.DateTime.Now;
long sum = 0;

for (int i = 0; i < 100000; ++i)
{
    // Primary loop
    for (int j = 0; j < arraySize; ++j)
    {
        /* Here you basically want to use simple operations - so no
        random branches, but things like &, |, *, -, +, etc. are fine. */
        sum += lookup[data[j]];
    }
}

DateTime endTime = System.DateTime.Now;
Console.WriteLine(endTime - startTime);
Console.WriteLine("sum = " + sum);
Console.ReadLine();

57
您想绕过分支预测器,为什么?这是一个优化。
Dustin Oprea

108
因为没有分支比分支更好:-)在很多情况下,这要快得多……如果您正在优化,那么绝对值得一试。他们还在f.ex中大量使用了它。graphics.stanford.edu/~seander/bithacks.html
atlaste

36
通常,查询表可能很快,但是您是否针对此特定条件进行了测试?您的代码中仍然会有分支条件,只是现在它已移至查找表生成部分。您仍然无法提高性能
Zain Rizvi 2013年

38
@Zain,如果您真的想知道...是:分支15秒,我的版本10秒。无论如何,这是一种有用的技巧。
atlaste 2013年

42
为什么不将包含256个条目的数组sum += lookup[data[j]]放在哪里lookup,第一个为零,最后一个等于索引?
Kris Vandermotten 2014年

1200

当对数组进行排序时,由于数据在0到255之间分布,因此在前半部分迭代中不会输入if-statement(该if语句在下面共享)。

if (data[c] >= 128)
    sum += data[c];

问题是:是什么使上述语句在某些情况下(如已排序的数据)无法执行?这是“分支预测器”。分支预测器是一种数字电路,它试图猜测if-then-else在确定之前知道分支(例如结构)将走哪条路。分支预测器的目的是改善指令管道中的流程。分支预测变量在实现高效能方面起着至关重要的作用!

让我们做一些基准测试以更好地了解它

if语句的性能取决于其条件是否具有可预测的模式。如果条件始终为真或始终为假,则处理器中的分支预测逻辑将选择模式。另一方面,如果模式是不可预测的,则- if语句将更加昂贵。

让我们在不同条件下测量此循环的性能:

for (int i = 0; i < max; i++)
    if (condition)
        sum++;

以下是使用不同真假模式的循环时间:

Condition                Pattern             Time (ms)
-------------------------------------------------------
(i & 0×80000000) == 0    T repeated          322

(i & 0xffffffff) == 0    F repeated          276

(i & 1) == 0             TF alternating      760

(i & 3) == 0             TFFFTFFF           513

(i & 2) == 0             TTFFTTFF           1675

(i & 4) == 0             TTTTFFFFTTTTFFFF   1275

(i & 8) == 0             8T 8F 8T 8F        752

(i & 16) == 0            16T 16F 16T 16F    490

一个“ ”的真假模式可以使if语句比“ ”模式慢六倍!当然,哪种模式好坏,取决于编译器和特定处理器生成的确切指令。

因此,毫无疑问分支预测对性能的影响!


23
@MooingDuck'因为它不会起作用-该值可以是任何值,但仍将处于这些阈值的范围内。那么,为什么在已经知道限制的情况下显示随机值呢?尽管我同意您可以出于完整性的考虑而展示一个,并且“仅仅为了它的真实性”。
cst1992

24
@ cst1992:现在他最慢的时间是TTFFTTFFTTFF,在我看来,这是可以预见的。随机性本质上是不可预测的,因此它完全有可能变得更慢,因此超出了此处显示的限制。OTOH,可能是TTFFTTFF完全击中了病理情况。不能说,因为他没有显示随机的时间。
Mooing Duck

21
@MooingDuck对人来说,“ TTFFTTFFTTFF”是一个可预测的序列,但是我们在这里谈论的是内置在CPU中的分支预测器的行为。分支预测器不是AI级模式识别;而是 这很简单。当您只是替换分支时,它并不能很好地预测。在大多数代码中,分支几乎一直都以相同的方式运行。考虑执行一千次的循环。循环结束处的分支返回到循环开始处999次,然后千分之一次执行不同的操作。通常,一个非常简单的分支预测器可以很好地工作。
steveha '16

18
@steveha:我想您是在对CPU分支预测器的工作方式进行假设,但我不同意这种方法。我不知道分支预测变量的先进程度,但我似乎认为它比您先进得多。您可能是对的,但测量结果绝对是不错的。
Mooing Duck

5
@steveha:两级自适应预测变量可以毫无问题地锁定TTFFTTFF模式。“大多数现代微处理器都使用了这种预测方法的变体”。局部分支预测和全局分支预测也基于两级自适应预测器。“在AMD处理器以及基于Intel Pentium M,Core,Core 2和基于Silvermont的Atom处理器中使用了全局分支预测”,还向该列表中添加了Agree预测器,Hybrid预测器,间接跳转的预测。循环预测器不会锁定,但会达到75%。剩下只有2个无法锁定
Mooing Duck

1126

避免分支预测错误的一种方法是建立查找表,并使用数据对其进行索引。Stefan de Bruijn在回答中对此进行了讨论。

但是在这种情况下,我们知道值在[0,255]范围内,我们只关心值> =128。这意味着我们可以轻松地提取单个位来告诉我们是否需要一个值:数据右边的7位,剩下的是0位或1位,我们只想在有1位的情况下将值相加。我们将此位称为“决策位”。

通过将决策位的0/1值用作数组的索引,无论数据是否排序,我们都可以使代码变得同样快。我们的代码将始终添加一个值,但是当决策位为0时,我们会将值添加到我们不在乎的位置。这是代码:

// Test
clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

此代码浪费了添加的一半,但从未发生分支预测失败。对于随机数据,它比带有实际if语句的版本快得多。

但是在我的测试中,显式查找表的速度比此表稍快,这可能是因为索引到查找表的速度比移位略快。这显示了我的代码如何设置和使用查找表(lut在代码中以虚构的方式称为“ LookUp Table”)。这是C ++代码:

// Declare and then fill in the lookup table
int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

在这种情况下,查找表只有256个字节,因此非常适合缓存,而且速度很快。如果数据是24位值,而我们只想要其中的一半,则此技术将无法很好地工作...查找表太大而无法实用。另一方面,我们可以结合上面显示的两种技术:首先将这些位移开,然后对查找表进行索引。对于只需要上半部分值的24位值,我们可能会将数据右移12位,并为表索引保留12位值。12位表索引表示一个4096个值的表,这可能是实际的。

索引而不是使用if语句的技术可用于确定要使用的指针。我看到了一个实现二叉树的库,它没有两个命名的指针(pLeft和(pRight或其他任何东西)),而是一个长度为2的指针数组,并使用“决策位”技术来决定遵循哪个。例如,代替:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;

该库将执行以下操作:

i = (x < node->value);
node = node->link[i];

这是此代码的链接:红黑树永远困惑


29
正确,您也可以直接使用该位并相乘(data[c]>>7-也在此处讨论)。我特意省略了此解决方案,但您当然是正确的。只需注意一点:查找表的经验法则是,如果它适合4KB(由于高速缓存),它将起作用-最好使表尽可能小。对于托管语言,我会将其推送到64KB,对于C ++和C这样的低级语言,我可能会重新考虑(这只是我的经验)。由于typeof(int) = 4,我会尝试最多保留10位。
atlaste

17
我认为使用0/1值索引可能比整数乘法快,但是我想如果性能真的很关键,则应该对其进行概要分析。我同意小查询表对于避免缓存压力至关重要,但是很明显,如果您拥有更大的缓存,则可以使用更大的查询表,因此4KB的经验法则而非硬性规定。我想你的意思是sizeof(int) == 4?对于32位而言,这是正确的。我有两年历史的手机具有32KB的L1缓存,因此即使4K查找表也可以工作,尤其是在查找值是字节而不是int的情况下。
steveha

12
可能我缺少了一些东西,但是在您j等于0或1的方法中,为什么不j添加值而不是使用数组索引(而不是使用数组索引)就乘以值(可能应乘以1-j而不是j
Richard Tingle 2014年

6
@steveha乘法应该更快,我尝试在Intel书籍中查找它,但是找不到它……无论哪种方式,基准测试在这里也给我带来了结果。
2014年

10
@steveha PS:另一个可能的答案是int c = data[j]; sum += c & -(c >> 7);根本不需要乘法。
atlast

1021

在排序的情况下,您可以比依靠成功的分支预测或任何无分支比较技巧来做的更好:完全删除分支。

实际上,该数组在的连续区域中分区,而在的data < 128另一个区域中分区data >= 128。因此,您应该使用二分法搜索(使用Lg(arraySize) = 15比较)来找到分区点,然后从该点开始进行直接累加。

诸如此类(未选中)

int i= 0, j, k= arraySize;
while (i < k)
{
  j= (i + k) >> 1;
  if (data[j] >= 128)
    k= j;
  else
    i= j;
}
sum= 0;
for (; i < arraySize; i++)
  sum+= data[i];

或者,更加模糊

int i, k, j= (i + k) >> 1;
for (i= 0, k= arraySize; i < k; (data[j] >= 128 ? k : i)= j)
  j= (i + k) >> 1;
for (sum= 0; i < arraySize; i++)
  sum+= data[i];

还有一种更快的方法,可以为已排序或未排序的对象提供近似的解决方案:(sum= 3137536;假设分布真正均匀,有16384个样本,期望值191.5):-)


23
sum= 3137536-聪明 这显然不是问题的重点。问题显然是关于解释令人惊讶的性能特征。我倾向于说,添加doing std::partition代替std::sort有价值。尽管实际问题不仅限于综合基准。
sehe

12
@DeadMG:这实际上不是对给定键的标准二分搜索,而是对分区索引的搜索;每个迭代需要一个比较。但是不要依赖此代码,我还没有检查它。如果您对保证正确的实施感兴趣,请告诉我。
Yves Daoust

831

由于分支预测,因此发生了上述现象。

要了解分支预测,首先必须了解指令流水线

任何指令都分为一系列步骤,以便可以并行并行执行不同的步骤。该技术称为指令流水线,用于提高现代处理器的吞吐量。为了更好地理解这一点,请参见Wikipedia上的示例

通常,现代处理器的流水线很长,但为简便起见,我们仅考虑这四个步骤。

  1. IF-从内存中获取指令
  2. ID-解码指令
  3. EX-执行指令
  4. WB-写回CPU寄存器

4级流水线一般用于2条指令。 一般为4级管道

回到上面的问题,让我们考虑以下指示:

                        A) if (data[c] >= 128)
                                /\
                               /  \
                              /    \
                        true /      \ false
                            /        \
                           /          \
                          /            \
                         /              \
              B) sum += data[c];          C) for loop or print().

如果没有分支预测,则会发生以下情况:

要执行指令B或指令C,处理器将必须等到指令A到达流水线中的EX阶段为止,因为转到指令B或指令C的决定取决于指令A的结果。因此,管线会像这样。

如果条件返回true: 在此处输入图片说明

如果条件返回假: 在此处输入图片说明

等待指令A的结果的结果是,在上述情况下(没有分支预测;对于true和false而言)花费的总CPU周期为7。

那么什么是分支预测?

分支预测器将尝试猜测在确定之前知道分支(if-then-else结构)的方向。它不会等待指令A到达流水线的EX阶段,但会猜测该决定并转到该指令(在本例中为B或C)。

在正确猜测的情况下,管道如下所示: 在此处输入图片说明

如果以后检测到猜测是错误的,则将部分执行的指令丢弃,流水线将从正确的分支重新开始,从而导致延迟。在分支预测错误的情况下所浪费的时间等于从获取阶段到执行阶段的流水线中的阶段数。现代微处理器往往具有很长的流水线,因此误预测延迟在10到20个时钟周期之间。管道越长,对好的分支预测器的需求就越大。

在OP的代码中,有条件的第一次,分支预测变量没有任何信息可作为预测的基础,因此,第一次它将随机选择下一条指令。稍后在for循环中,它可以将预测基于历史记录。对于按升序排序的数组,存在三种可能性:

  1. 所有元素均小于128
  2. 所有元素都大于128
  3. 一些开始的新元素小于128,后来又大于128

让我们假设预测变量在首次运行时将始终假设为真实分支。

因此,在第一种情况下,由于历史上所有的预测都是正确的,因此它将始终采用真正的分支。在第二种情况下,最初将预测错误,但是经过几次迭代后,它将正确预测。在第3种情况下,它最初将正确预测直到元素少于128个。此后,它将失败一段时间并在历史中看到分支预测失败时进行自我纠正。

在所有这些情况下,故障的数量将太少,因此,仅需几次就可以丢弃部分执行的指令并从正确的分支重新开始,从而减少了CPU周期。

但是,如果是随机未排序的数组,则预测将需要丢弃部分执行的指令,并在大多数时间中从正确的分支重新开始,与排序后的数组相比,将导致更多的CPU周期。


1
两条指令如何一起执行?这是用单独的cpu内核完成的还是将流水线指令集成在单个cpu内核中?
M.kazem Akhgary'17-10-11

1
@ M.kazemAkhgary都在一个逻辑核心内。如果你有兴趣,这是很好的描述,例如在英特尔软件开发人员手册
Sergey.quixoticaxis.Ivanov

727

官方答案将来自

  1. 英特尔-避免分支机构失职的成本
  2. 英特尔-分支和循环重组可防止错误预测
  3. 科学论文-分支预测计算机体系结构
  4. 书籍:JL Hennessy,DA Patterson:计算机体系结构:一种定量方法
  5. 科学出版物上的文章:TY Yeh,YN Patt在分支预测中做了很多。

您还可以从这张可爱的图表中看到分支预测变量为何会感到困惑。

2位状态图

原始代码中的每个元素都是一个随机值

data[c] = std::rand() % 256;

因此预测变量会改变其一面std::rand()

另一方面,一旦将其排序,预测变量将首先进入强烈不采用的状态,并且当值更改为高值时,预测变量将在三阶段中从强烈不采用变为强烈采取。



696

在同一行中(我认为这没有任何答案突出显示),值得一提的是,有时(特别是在性能至关重要的软件中,例如在Linux内核中),您会找到一些if语句,如下所示:

if (likely( everything_is_ok ))
{
    /* Do something */
}

或类似地:

if (unlikely(very_improbable_condition))
{
    /* Do something */    
}

实际上,两者likely()unlikely()宏都是通过使用类似GCC的东西来定义的,__builtin_expect以帮助编译器插入预测代码以考虑到用户提供的信息来满足条件。GCC支持其他可能会改变正在运行的程序的行为或发出诸如清除缓存等低级指令的内建函数。请参阅本文档,其中提供了可用的GCC内建函数。

通常,这种优化主要在执行时间很重要的硬实时应用程序或嵌入式系统中找到。例如,如果您要检查仅发生1/10000000次的错误情况,那么为什么不通知编译器呢?这样,默认情况下,分支预测将假定条件为假。


678

C ++中经常使用的布尔运算会在编译后的程序中产生许多分支。如果这些分支位于循环内并且难以预测,则它们可能会大大降低执行速度。布尔变量存储为一个值为8位整数0false1true

在所有将布尔变量作为输入的运算符都检查输入是否具有除0或之外的其他值的意义上,布尔变量被过分确定了1,但是将布尔作为输出的运算符不能产生除0或之外的任何其他值1。这使得使用布尔变量作为输入的运算的效率比必要的低。考虑示例:

bool a, b, c, d;
c = a && b;
d = a || b;

通常由编译器通过以下方式实现:

bool a, b, c, d;
if (a != 0) {
    if (b != 0) {
        c = 1;
    }
    else {
        goto CFALSE;
    }
}
else {
    CFALSE:
    c = 0;
}
if (a == 0) {
    if (b == 0) {
        d = 0;
    }
    else {
        goto DTRUE;
    }
}
else {
    DTRUE:
    d = 1;
}

此代码远非最佳。如果预测错误,分支机构可能会花费很长时间。如果可以肯定地知道操作数除0和之外没有其他值,则布尔运算可以更加高效1。编译器没有做出这样的假设的原因是,如果变量未初始化或来自未知源,则它们可能具有其他值。如果a并且b已经将其初始化为有效值,或者它们来自产生布尔输出的运算符,则可以优化上述代码。优化的代码如下所示:

char a = 0, b = 1, c, d;
c = a & b;
d = a | b;

char使用代替bool以便使用按位运算符(&|)代替布尔运算符(&&||)。按位运算符是仅占用一个时钟周期的单个指令。|即使ab具有除0或以外的其他值,OR运算符()仍然有效1。如果操作数的值不同于和,则AND运算符(&)和EXCLUSIVE OR运算符(^)可能会给出不一致的结果。01

~不能用于NOT。相反,您可以在一个已知的变量上进行布尔NOT运算,01通过对它进行XOR运算1

bool a, b;
b = !a;

可以优化为:

char a = 0, b;
b = a ^ 1;

a && b无法用a & bif b是一个if 表达式,该表达式不应被if ais 评估false&&will not expression b&will)。同样地,a || b不能被替换a | b,如果b是,如果不应该被计算的表达式atrue

如果操作数是变量,则使用按位运算符要比比较操作数更有利:

bool a; double x, y, z;
a = x > y && z < 5.0;

在大多数情况下是最佳的(除非您期望&&表达式会产生许多分支错误预测)。


341

这是肯定的!...

由于代码中发生的切换,分支预测使逻辑运行速度变慢!就像您要走在一条直街或拐弯很多的街道上一样,请确保直路会更快!

如果对数组进行了排序,则第一步的条件为false data[c] >= 128,然后到街道尽头的整个过程都变为真值。这样便可以更快地到达逻辑结尾。另一方面,使用未排序的数组,您需要进行大量的翻转和处理,这肯定会使您的代码运行缓慢。

在下面查看我为您创建的图像。哪条街的建成速度会更快?

分支预测

因此,以编程方式,分支预测会使流程变慢...

同样在最后,很高兴知道我们有两种分支预测,每种预测都会以不同的方式影响您的代码:

1.静态的

2.动态

分支预测

微处理器在第一次遇到条件分支时使用静态分支预测,而动态分支预测则用于成功执行条件分支代码。

为了有效地编写代码以利用这些规则,在编写if-elseswitch语句时,请先检查最常见的情况,然后逐步减少到最不常见的情况。对于静态分支预测,循环不一定需要任何特殊的代码顺序,因为通常仅使用循环迭代器的条件。


304

这个问题已经被回答了好多次了。我仍然想提请小组注意另一种有趣的分析。

最近,该示例(略作修改)还用作演示如何在Windows上的程序本身中分析一段代码的方法。在此过程中,作者还展示了如何使用结果来确定代码在排序和未排序情况下大部分时间都花在了哪里。最后,这篇文章还展示了如何使用HAL(硬件抽象层)的一个鲜为人知的功能来确定在未排序的情况下发生了多少分支错误预测。

链接在这里:http : //www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm


3
那是一篇非常有趣的文章(实际上,我已经阅读了所有文章),但是它如何回答这个问题呢?
彼得·莫滕森

2
@PeterMortensen我对你的问题有点困惑。例如,这是该文章的相关内容之一: When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping. 作者正在尝试在此处发布的代码的上下文中讨论概要分析,并在此过程中试图解释为什么排序后的案例要快得多。
永远学习

260

正如其他人已经提到的那样,神秘的背后是分支预测器

我不是要添加任何内容,而是以另一种方式解释该概念。Wiki上有一个简短的介绍,其中包含文本和图表。我喜欢下面的解释,该解释使用图表直观地阐述Branch Predictor。

在计算机体系结构中,分支预测器是一种数字电路,它试图猜测在确定之前知道分支(例如,if-then-else结构)将走哪条路。分支预测器的目的是改善指令管道中的流程。在许多现代流水线微处理器体系结构(例如x86)中,分支预测器在实现高效能方面起着至关重要的作用。

通常使用条件跳转指令来实现双向分支。有条件的跳转可以“不采用”并继续执行紧随条件跳转之后的代码的第一分支,也可以是“采用”并跳转到程序存储器中代码的第二分支所在的其他位置存储。在计算条件并且条件跳转通过指令流水线的执行阶段之前,不确定是否将执行条件跳转(参见图1)。

图1

基于所描述的场景,我编写了一个动画演示,以演示如何在不同情况下在管道中执行指令。

  1. 没有分支预测器。

如果没有分支预测,则处理器将必须等到条件跳转指令通过执行阶段后,下一条指令才能进入流水线中的提取阶段。

该示例包含三个指令,第一个是条件跳转指令。后两条指令可以进入管道,直到执行条件跳转指令为止。

没有分支预测器

3条指令需要9个时钟周期才能完成。

  1. 使用Branch Predictor,不要进行条件跳转。让我们假设预测没有采取条件转移。

在此处输入图片说明

3条指令需要7个时钟周期才能完成。

  1. 使用Branch Predictor并进行条件跳转。让我们假设预测没有采取条件转移。

在此处输入图片说明

3条指令需要9个时钟周期才能完成。

在分支预测错误的情况下浪费的时间等于从获取阶段到执行阶段的流水线中的阶段数。现代微处理器往往具有很长的流水线,因此误预测延迟在10到20个时钟周期之间。结果,使流水线更长会增加对更高级的分支预测器的需求。

如您所见,似乎我们没有理由不使用Branch Predictor。

这是一个非常简单的演示,它阐明了Branch Predictor的最基本部分。如果这些gif令人讨厌,请随时将其从答案中删除,访问者还可以从BranchPredictorDemo获取实时演示源代码。


1
几乎与英特尔营销动画一样好,并且它们不仅迷恋于分支机构的预测而且迷恋于乱序执行,这两种策略都是“投机性的”。提前读取内存和存储(顺序预取到缓冲区)也是推测性的。这一切加起来。
mckenzm

@mckenzm:乱序的执行程序使分支预测更加有价值;除了隐藏获取/解码气泡外,分支预测+投机exec还消除了关键路径延迟中的控件依赖性。在知道分支条件之前if()可以块内部或块之后执行代码。或者,对于类似strlen或的搜索循环memchr,互动可能会重叠。如果在运行下一个迭代之前必须等待知道匹配或否结果,那么瓶颈将是缓存负载+ ALU延迟而不是吞吐量。
Peter Cordes

209

分支预测收益!

重要的是要了解分支预测错误不会降低程序速度。错过预测的代价就像不存在分支预测一样,您等待表达式的评估来确定要运行的代码(在下一段中进一步说明)。

if (expression)
{
    // Run 1
} else {
    // Run 2
}

只要有if-else\ switch语句,就必须对表达式求值以确定应该执行哪个块。在编译器生成的汇编代码中,插入了条件分支指令。

分支指令可以使计算机开始执行不同的指令序列,从而偏离其按顺序执行指令的默认行为(即,如果表达式为false,则程序将跳过该指令的代码)。 if根据某些条件块),具体取决于以下条件:本例中的表情评估。

就是说,编译器会在实际评估结果之前尝试预测结果。它将从该if块中获取指令,如果该表达式为真,那就太好了!我们花了很多时间进行评估,并在代码上取得了进步。如果不是,那么我们运行的是错误的代码,将刷新管道,并运行正确的块。

可视化:

假设您需要选择路线1或路线2,等待您的伴侣检查地图,您已经停在##并等待,或者您可以选择路线1,如果幸运的话(路线1是正确的路线),那就太好了,您不必等待伙伴检查地图(您节省了他检查地图所需的时间),否则您只需回头即可。

尽管冲洗管道非常快,但如今采取这种赌博是值得的。预测排序的数据或变化缓慢的数据总是比预测快速变化更容易和更好。

 O      Route 1  /-------------------------------
/|\             /
 |  ---------##/
/ \            \
                \
        Route 2  \--------------------------------

虽然冲洗管道非常快,但 并非如此。与一直到DRAM的高速缓存未命中相比,它的速度很快,但是在现代高性能x86(例如Intel Sandybridge系列)上,它大约需要十几个周期。尽管快速恢复确实可以避免在开始恢复之前等待所有旧的独立指令退休,但是由于错误的预测,您仍然会损失很多前端周期。 当Skylake CPU错误预测分支时会发生什么?。(每个周期大约可以包含4个工作指令。)不利于高吞吐量代码。
Peter Cordes

153

在ARM上,不需要分支,因为每个指令都有一个4位的条件字段,该字段测试(以零成本)在处理器状态寄存器中可能出现的16种不同条件中的任何一种,并且指令上的条件是否为否,指令被跳过。这消除了对短分支的需求,并且该算法不会对分支预测产生任何影响。因此,由于排序的额外开销,该算法的排序版本在ARM上的运行速度将比未排序版本慢。

该算法的内部循环在ARM汇编语言中看起来类似于以下内容:

MOV R0, #0     // R0 = sum = 0
MOV R1, #0     // R1 = c = 0
ADR R2, data   // R2 = addr of data array (put this instruction outside outer loop)
.inner_loop    // Inner loop branch label
    LDRB R3, [R2, R1]     // R3 = data[c]
    CMP R3, #128          // compare R3 to 128
    ADDGE R0, R0, R3      // if R3 >= 128, then sum += data[c] -- no branch needed!
    ADD R1, R1, #1        // c++
    CMP R1, #arraySize    // compare c to arraySize
    BLT inner_loop        // Branch to inner_loop if c < arraySize

但这实际上是更大范围的一部分:

CMP操作码始终会更新处理器状态寄存器(PSR)中的状态位,因为这是它们的用途,但是大多数其他指令都不会触及PSR,除非您S在指令中添加了可选的后缀,并指定应基于PSR更新PSR。指令的结果。就像4位条件后缀一样,能够执行指令而不影响PSR的机制是一种减少ARM分支需求的机制,并且还有助于在硬件级别进行乱序调度,因为在执行了一些更新的操作X之后状态位,随后(或并行)您可以做其他明显不影响状态位的工作,然后可以测试X之前设置的状态位的状态。

条件测试字段和可选的“设置状态位”字段可以组合,例如:

  • ADD R1, R2, R3执行R1 = R2 + R3而不更新任何状态位。
  • ADDGE R1, R2, R3 仅当前一条影响状态位的指令导致大于或等于条件时,才执行相同的操作。
  • ADDS R1, R2, R3执行加法,然后更新NZCV标志的基础上的结果是否为负,零,开展(无符号加法)处理器状态寄存器,或溢出(对于符号相加)。
  • ADDSGE R1, R2, R3仅在GE测试为真时才执行加法,然后根据加法的结果更新状态位。

大多数处理器体系结构都没有这种能力来指定是否应为给定操作更新状态位,这可能需要编写额外的代码来保存并稍后恢复状态位,或者可能需要附加的分支,或者可能限制处理器的输出指令执行效率:大多数CPU指令集体系结构在大多数指令之后强制更新状态位的副作用之一是,很难弄清楚哪些指令可以并行运行而不互相干扰。更新状态位有副作用,因此对代码具有线性影响。对于汇编语言程序员和编译器,ARM的能力非常强大,能够在任意一条指令上混合和匹配无分支条件测试,并具有在任何一条指令之后更新或不更新状态位的选项,这非常强大,并且可以生成非常有效的代码。

如果您想知道ARM为什么如此成功,那么这两种机制的出色效力和相互作用是故事的重要组成部分,因为它们是ARM体系结构效率的最大来源之一。早在1983年,ARM ISA的原始设计师Steve Furber和Roger(现为Sophie)Wilson的才华就不能被夸大。


1
ARM的另一项创新是增加了S指令后缀,(几乎)所有指令也是可选的,如果不存在,该指令后缀可防止指令更改状态位(CMP指令除外,其工作是设置状态位,因此不需要S后缀)。只要比较为零或相似(例如,SUBS R0,R0,#1将在R0达到零时将Z(零)位置1),就可以在许多情况下避免CMP指令。条件句和S后缀的开销为零。这是一个非常漂亮的ISA。
卢克·哈奇森

2
如果不添加S后缀,则可以连续使用几条条件指令,而不必担心其中一个会更改状态位,否则可能会导致跳过其余条件指令的副作用。
卢克·哈奇森

请注意,OP 包括对其测量进行排序的时间。即使未排序的情况会使循环运行慢得多,在运行分支x86循环之前首先进行排序也可能会造成总体损失。但是对大型数组进行排序需要大量工作。
Peter Cordes

顺便说一句,您可以通过相对于数组末尾的索引在循环中保存一条指令。在循环之前,进行设置R2 = data + arraySize,然后从开始R1 = -arraySize。循环的底部变为adds r1, r1, #1/ bnz inner_loop。出于某些原因,编译器不使用此优化:/但是无论如何,在这种情况下,添加的谓词执行与在其他ISA(如x86)上的无分支代码可以执行的操作并没有根本不同cmov。尽管效果不是很好:gcc优化标志-O3使代码的运行速度比-O2慢
Peter Cordes

1
(ARM谓词执行实际上是对指令执行NOP操作,因此,与cmov带有内存源操作数的x86不同,您甚至可以在可能出错的加载或存储上使用它。大多数ISA(包括AArch64)仅具有ALU选择操作。因此,ARM谓词功能强大,并且比大多数ISA上的无分支代码更有效地使用。)
Peter Cordes

146

关于分支预测。它是什么?

  • 分支预测器是古老的性能改进技术之一,至今仍与现代建筑相关。尽管简单的预测技术可提供快速查找和功率效率,但它们的误预测率很高。

  • 另一方面,复杂的分支预测(基于神经的预测或两级分支预测的变体)可提供更好的预测精度,但它们消耗的功率更多,并且复杂度呈指数增长。

  • 除此之外,在复杂的预测技术中,预测分支本身所花费的时间非常长-从2到5个周期-与实际分支的执行时间相当。

  • 分支预测本质上是一个优化(最小化)问题,重点在于以最少的资源实现最低的未命中率,低功耗和低复杂度。

确实有三种不同的分支:

转发条件分支 -根据运行时条件,将PC(程序计数器)更改为指向指令流中的转发地址。

向后条件分支 -将PC更改为在指令流中指向向后。该分支基于某种条件,例如,当循环末尾的测试表明该循环应再次执行时,则向后跳转到程序循环的开始。

无条件分支 -包括没有特定条件的跳转,过程调用和返回。例如,无条件跳转指令可能用汇编语言编码为简单的“ jmp”,并且指令流必须立即定向到跳转指令所指向的目标位置,而条件跳转可能被编码为“ jmpne”仅当前一个“比较”指令中两个值的比较结果显示这些值不相等时,才会重定向指令流。(x86体系结构使用的分段寻址方案增加了额外的复杂性,因为跳转可以是“近”(在段内)或“远”(在段外)。每种类型对分支预测算法的影响都不同。)

静态/动态分支预测:第一次遇到条件分支时,微处理器会使用静态分支预测,而动态分支预测则用于条件分支代码的后续执行。

参考文献:


145

除了分支预测可能会使您减速之外,排序数组还具有另一个优点:

您可以有一个停止条件,而不仅仅是检查该值,这样您就可以循环遍历相关数据,而忽略其余数据。
分支预测只会丢失一次。

 // sort backwards (higher values first), may be in some other part of the code
 std::sort(data, data + arraySize, std::greater<int>());

 for (unsigned c = 0; c < arraySize; ++c) {
       if (data[c] < 128) {
              break;
       }
       sum += data[c];               
 }

1
是的,但是对数组进行排序的设置成本为O(N log N),因此,如果要对数组进行排序的唯一原因是能够尽早破坏,则尽早破坏对您没有帮助。但是,如果您还有其他原因要对阵列进行预排序,则可以,这很有价值。
卢克·哈奇森

取决于对数据进行排序的次数与对数据进行循环的次数相比。此示例中的排序只是一个示例,它不一定就在循环之前
Yochai Timmer

2
是的,这正是我在第一条评论中提出的要点:-)您说“分支预测只会丢失一次。” 但是,您没有在排序算法中算出O(N log N)个分支预测未命中,实际上比未排序情况下的O(N)个分支预测未命中要大。因此,您将需要使用整个排序数据的O(log N)次才能达到收支平衡(实际上实际上可能更接近O(10 log N),具体取决于排序算法,例如,由于高速缓存未命中而导致的快速排序-mergesort是更一致的缓存,因此您需要接近O(2 log N)的使用量才能达到收支平衡。)
Luke Hutchison

但是,一项重要的优化操作是仅“快速排序”,仅对小于目标枢轴值127的项目进行排序(假定所有小于或等于枢轴的值都在枢轴之后排序)。到达枢轴后,将枢轴之前的元素求和。这将在O(N)启动时间而不是O(N log N)上运行,尽管仍然会有很多分支预测未命中,基于我之前给出的数字,可能大约为O(5 N),因为这是一个快速排序的一半。
卢克·哈奇森

132

由于称为分支预测的现象,排序数组比未排序数组的处理速度更快。

分支预测器是一种数字电路(在计算机体系结构中),旨在预测分支将走的路,从而改善指令流水线中的流程。电路/计算机预测下一步并执行。

做出错误的预测会导致返回上一步,并执行另一个预测。假设预测正确,则代码将继续进行下一步。错误的预测会导致重复相同的步骤,直到发生正确的预测。

您问题的答案非常简单。

在未排序的阵列中,计算机会做出多个预测,从而导致出现错误的可能性增加。而在排序数组中,计算机做出的预测更少,从而减少了出错的机会。做出更多的预测需要更多的时间。

排序的数组:直线形

未排序的数组:弯曲的路

______   ________
|     |__|

分支预测:猜测/预测哪条道路是直的,并在不检查的情况下遵循

___________________________________________ Straight road
 |_________________________________________|Longer road

尽管两条道路都到达同一目的地,但直路较短,而另一条较长。如果那样的话,您错误地选择了另一条路,那就没有回头路了,因此,如果您选择更长的路,则会浪费一些额外的时间。这类似于计算机中发生的情况,希望这可以帮助您更好地理解。


我也想从评论中引用@Simon_Weaver

它不会做出更少的预测-它会做出更少的错误预测。它仍然需要对循环中的每次预测...


122

我在MacBook Pro(Intel i7,64位,2.4 GHz)上使用MATLAB 2011b尝试了相同的代码,用于以下MATLAB代码:

% Processing time with Sorted data vs unsorted data
%==========================================================================
% Generate data
arraySize = 32768
sum = 0;
% Generate random integer data from range 0 to 255
data = randi(256, arraySize, 1);


%Sort the data
data1= sort(data); % data1= data  when no sorting done


%Start a stopwatch timer to measure the execution time
tic;

for i=1:100000

    for j=1:arraySize

        if data1(j)>=128
            sum=sum + data1(j);
        end
    end
end

toc;

ExeTimeWithSorting = toc - tic;

上面的MATLAB代码的结果如下:

  a: Elapsed time (without sorting) = 3479.880861 seconds.
  b: Elapsed time (with sorting ) = 2377.873098 seconds.

我得到的@GManNickG中的C代码结果是:

  a: Elapsed time (without sorting) = 19.8761 sec.
  b: Elapsed time (with sorting ) = 7.37778 sec.

基于此,MATLAB看上去比不进行排序的C实现慢了175倍,而进行排序却慢了350倍。换句话说,(分支预测)的效果是1.46x为MATLAB实现和2.7倍的C实现。


6
仅出于完整性考虑,这可能不是在Matlab中实现的方式。我敢打赌,将问题向量化后,这样做会更快。
2013年

1
Matlab在许多情况下会执行自动并行化/矢量化,但是这里的问题是检查分支预测的效果。无论如何,Matlab都无法幸免!
Shan

1
matlab是使用本机数字还是matlab的特定实现(无穷数量的数字?)
ThorbjørnRavn Andersen

54

其他答案认为需要对数据进行排序的假设是不正确的。

以下代码不会对整个数组进行排序,而是仅对数组的200个元素进行排序,因此运行速度最快。

仅对k个元素部分进行排序可以完成线性时间的预处理O(n),而不是O(n.log(n))对整个数组进行排序所需的时间。

#include <algorithm>
#include <ctime>
#include <iostream>

int main() {
    int data[32768]; const int l = sizeof data / sizeof data[0];

    for (unsigned c = 0; c < l; ++c)
        data[c] = std::rand() % 256;

    // sort 200-element segments, not the whole array
    for (unsigned c = 0; c + 200 <= l; c += 200)
        std::sort(&data[c], &data[c + 200]);

    clock_t start = clock();
    long long sum = 0;

    for (unsigned i = 0; i < 100000; ++i) {
        for (unsigned c = 0; c < sizeof data / sizeof(int); ++c) {
            if (data[c] >= 128)
                sum += data[c];
        }
    }

    std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
    std::cout << "sum = " << sum << std::endl;
}

这也“证明”它与任何算法问题(例如排序顺序)无关,并且确实是分支预测。


4
我真的不知道这怎么证明?您显示的唯一一件事是“不完成对整个数组进行排序的所有工作都比对整个数组进行排序所需的时间更少”。您声称此“运行速度也最快”是非常依赖于体系结构的。请参阅我的答案,了解它如何在ARM上工作。PS:通过将求和值放入200个元素的块循环中,进行反向排序,然后使用Yochai Timmer的建议,一旦超出范围,就可以中断代码,从而使非ARM体系结构上的代码更快。这样,每个200个元素的块求和就可以提前终止。
卢克·哈奇森

如果只想对未排序的数据有效地实现该算法,则可以无分支地执行该操作(使用SIMD,例如使用x86 pcmpgtb查找设置了高位的元素,然后将AND设置为零个较小的元素)。实际花费任何时间对块进行排序会比较慢。无分支版本将具有与数据无关的性能,还证明了代价来自分支错误预测。或者只是使用性能计数器来直接观察到这种情况,例如Skylake int_misc.clear_resteer_cyclesint_misc.recovery_cycles从错误预测中计算前端空闲周期
Peter Cordes

上面的两个评论似乎都忽略了一般的算法问题和复杂性,而主张使用带有特殊机器指令的专用硬件。我发现第一个特别小巧,因为它在无意中使用专用机器指令的情况下,无意中忽略了该答案中重要的一般性见解。
user2297550

36

Bjarne Stroustrup对这个问题的回答

这听起来像一个面试问题。是真的吗 你怎么知道的?在不先进行测量的情况下回答有关效率的问题是一个坏主意,因此知道如何进行测量很重要。

因此,我尝试使用一百万个整数的向量,得到:

Already sorted    32995 milliseconds
Shuffled          125944 milliseconds

Already sorted    18610 milliseconds
Shuffled          133304 milliseconds

Already sorted    17942 milliseconds
Shuffled          107858 milliseconds

我跑了几次以确定。是的,这种现象是真实的。我的关键代码是:

void run(vector<int>& v, const string& label)
{
    auto t0 = system_clock::now();
    sort(v.begin(), v.end());
    auto t1 = system_clock::now();
    cout << label 
         << duration_cast<microseconds>(t1  t0).count() 
         << " milliseconds\n";
}

void tst()
{
    vector<int> v(1'000'000);
    iota(v.begin(), v.end(), 0);
    run(v, "already sorted ");
    std::shuffle(v.begin(), v.end(), std::mt19937{ std::random_device{}() });
    run(v, "shuffled    ");
}

对于这种编译器,标准库和优化器设置,至少这种现象是真实的。不同的实现可以而且确实给出不同的答案。实际上,有人确实做了更系统的研究(可以通过快速的网络搜索找到它),并且大多数实现都显示出这种效果。

原因之一是分支预测:排序算法中的关键操作“if(v[i] < pivot]) …”等于或等效。对于排序的序列,测试始终为真,而对于随机序列,选择的分支随机变化。

另一个原因是,当向量已经排序时,我们不需要将元素移到正确的位置。这些小细节的影响是我们看到的5或6倍。

快速排序(通常是排序)是一项复杂的研究,吸引了一些计算机科学的杰出人士。好的排序功能是选择好的算法并在实现过程中注意硬件性能的结果。

如果要编写高效的代码,则需要了解一些有关计算机体系结构的知识。


27

此问题源于CPU上的分支预测模型。我建议阅读这篇文章:

通过多个分支预测和分支地址缓存提高指令提取速率

对元素进行排序后,IR不会一次又一次地获取所有CPU指令,而是从缓存中获取它们。


不管错误预测如何,这些指令在CPU的L1指令高速缓存中始终处于高温状态。问题在于,在紧接之前的指令已解码并完成执行之前,以正确的顺序将它们提取到管道中。
彼得·科德斯

15

避免分支预测错误的一种方法是建立查找表,并使用数据对其进行索引。Stefan de Bruijn在回答中对此进行了讨论。

但是在这种情况下,我们知道值在[0,255]范围内,我们只关心值> =128。这意味着我们可以轻松地提取单个位来告诉我们是否需要一个值:数据右边的7位,剩下的是0位或1位,我们只想在有1位的情况下将值相加。我们将此位称为“决策位”。

通过将决策位的0/1值用作数组的索引,无论数据是否排序,我们都可以使代码变得同样快。我们的代码将始终添加一个值,但是当决策位为0时,我们会将值添加到我们不在乎的位置。这是代码:

//测试

clock_t start = clock();
long long a[] = {0, 0};
long long sum;

for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        int j = (data[c] >> 7);
        a[j] += data[c];
    }
}

double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];

此代码浪费了添加的一半,但从未发生分支预测失败。对于随机数据,它比带有实际if语句的版本快得多。

但是在我的测试中,显式查找表的速度比此表稍快,这可能是因为索引到查找表的速度比移位略快。这显示了我的代码如何设置和使用查找表(在代码中,“ LookUp Table”意为“ lut”)。这是C ++代码:

//声明然后填写查询表

int lut[256];
for (unsigned c = 0; c < 256; ++c)
    lut[c] = (c >= 128) ? c : 0;

// Use the lookup table after it is built
for (unsigned i = 0; i < 100000; ++i)
{
    // Primary loop
    for (unsigned c = 0; c < arraySize; ++c)
    {
        sum += lut[data[c]];
    }
}

在这种情况下,查找表只有256个字节,因此非常适合缓存,而且速度很快。如果数据是24位值,而我们只想要其中的一半,则此技术将无法很好地工作...查找表太大而无法实用。另一方面,我们可以结合上面显示的两种技术:首先将这些位移开,然后对查找表进行索引。对于只需要上半部分值的24位值,我们可能会将数据右移12位,并为表索引保留12位值。12位表索引表示一个4096个值的表,这可能是实际的。

可以使用索引到数组的技术(而不使用if语句)来确定要使用的指针。我看到了一个实现二叉树的库,它没有两个命名的指针(pLeft和pRight或其他),而是一个长度为2的指针数组,并使用“决策位”技术来决定遵循哪个。例如,代替:

if (x < node->value)
    node = node->pLeft;
else
    node = node->pRight;
this library would do something like:

i = (x < node->value);
node = node->link[i];

这是一个很好的解决方案,也许会起作用


您使用哪种C ++编译器/硬件对此进行了测试,以及使用哪些编译器选项?我很惊讶原始版本没有自动矢量化为漂亮的无分支SIMD代码。您启用了全面优化吗?
Peter Cordes

4096条目查找表听起来很疯狂。如果移出任何位,则要添加原始数字就不必使用LUT结果。这些听起来很愚蠢,可以使用无分支技术轻松解决编译器问题。mask = tmp < 128 : 0 : -1UL;/total += tmp & mask;
Peter Cordes
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.