为什么处理一个已排序的数组比处理一个未排序的数组要快?

27157
在这段C++代码中,对数据进行排序(在定时区域之前)可以使主循环的速度提高约6倍。
#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)
    {
        for (unsigned c = 0; c < arraySize; ++c)
        {   // Primary loop.
            if (data[c] >= 128)
                sum += data[c];
        }
    }

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

    std::cout << elapsedTime << '\n';
    std::cout << "sum = " << sum << '\n';
}
  • 没有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)
        {
            for (int c = 0; c < arraySize; ++c)
            {   // Primary loop.
                if (data[c] >= 128)
                    sum += data[c];
            }
        }

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

与类似但较不极端的结果。
我的第一个想法是排序会将数据放入缓存中,但这是愚蠢的,因为数组刚刚生成。
  • 到底发生了什么?
  • 为什么处理排序后的数组比处理未排序的数组快?

代码正在对一些独立的项求和,所以顺序不应该有影响。


相关/后续问答关于使用不同/较新编译器和选项产生相同效果的问题:


119
另一个观察结果是,您不需要对数组进行排序,只需要使用值128对其进行划分即可。排序的时间复杂度为n*log(n),而划分的时间复杂度仅为线性。基本上只需要运行快速排序划分步骤一次,选择值为128作为枢轴。不幸的是,在C ++中只有nth_element函数,它按位置进行划分,而不是按值进行划分。 - Šimon Hrabec
46
这是一个实验,可以证明分区已经足够:创建一个无序但已分区的数组,并填充随机内容。测量时间。对其进行排序。再次测量时间。这两个测量结果应该基本相同。(实验2:创建一个随机数组。测量时间。对其进行分区。再次测量时间。您应该会看到与排序相同的加速效果。您可以将这两个实验合并为一个。) - Jonas Kölker
41
顺便说一下,在苹果M1上,代码在未排序的情况下运行需要17秒,排序后只需要7秒,因此在RISC架构上,分支预测惩罚并不那么严重。 - Piotr Czapla
36
这取决于编译器。如果编译器为这个特定的测试生成无分支汇编代码(例如作为使用 SIMD 向量化的一部分,就像在为什么处理未排序的数组与处理已排序的数组在现代 x86-64 clang 中速度相同?中所述的那样,或者只是使用标量 cmovgcc 优化标志 -O3 使代码比 -O2 更慢)),那么有序或无序并不重要。但是当问题不像计数那样简单时,不可预测的分支仍然是一个非常真实的问题,因此删除这个问题是不明智的。 - Peter Cordes
20
公正地说,尽管如此,将其分区仍然不值得,因为分区需要根据相同的array[i]>128比较进行条件复制或交换。(除非您要多次计数,并且希望将数组的大部分分区以使其仍然快速,在一些附加或修改后未分区的部分中出现错误预测)。如果您可以让编译器执行此操作,最好使用SIMD进行向量化,或者至少在数据不可预测时使用无分支标量。(请参见上面的评论获取链接。) - Peter Cordes
显示剩余5条评论
26个回答

34855
你是分支预测失败的受害者。
什么是分支预测?
想象一下一个铁路交叉口:
(图片链接1:https://istack.dev59.com/muxnt.webp) (图片来源2:Mecanismo,通过Wikimedia Commons。根据CC-By-SA 3.0许可使用。)
现在假设这是在19世纪之前,没有远程或无线通信。
你是一个盲目操作员,听到一辆火车驶来。你不知道它应该走哪条路。你停下火车询问司机想要去哪个方向。然后你相应地设置开关。
火车很重,惯性很大,所以启动和减速需要很长时间。
有没有更好的方法?你猜测火车会走哪个方向!
如果你猜对了,它会继续前进。 如果你猜错了,司机会停下来,倒车,并大声叫你翻转开关。然后它可以重新开始走另一条路。
如果你每次都猜对,火车就永远不会停下来。 如果你猜错的次数太多,火车将花费很多时间停下来,倒车和重新启动。
考虑一个if语句:在处理器级别上,它是一条分支指令。

if(x >= 128) compiles into a jump-if-less-than processor instruction.

你是一台处理器,面对一个分支,你不知道它会走哪个方向。你会怎么做?你会停止执行,等待前面的指令完成。然后你会继续走下正确的路径。
现代处理器非常复杂,拥有很长的流水线。这意味着它们需要很长时间才能“热身”和“冷却”。
有没有更好的方法呢?你可以猜测分支会走哪个方向!
- 如果你猜对了,你会继续执行。 - 如果你猜错了,你需要清空流水线并回到分支处。然后你可以重新开始走另一条路径。
如果你每次都猜对,执行就永远不会停止。 如果你猜错的次数太多,你会花费很多时间停顿、回滚和重新开始。
这是分支预测。我承认这个比喻不是最好的,因为火车可以用旗帜来指示方向。但在计算机中,处理器直到最后一刻才知道分支会走向哪里。
你如何策略性地猜测,以最小化火车必须倒退并选择另一条路径的次数?你看过去的历史!如果火车99%的时间都往左走,那么你就猜左边。如果它交替进行,那么你就交替猜测。如果它每三次都往同一个方向走,你就猜相同的方向...
换句话说,你试图识别出一种模式并遵循它。这基本上就是分支预测器的工作原理。
大多数应用程序的分支行为是良好的。因此,现代分支预测器通常能够达到90%以上的命中率。但当面对没有可识别模式的不可预测分支时,分支预测器几乎没有用处。
进一步阅读:"维基百科上的分支预测器文章"
正如上面所暗示的那样,罪魁祸首就是这个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, ...
branch =   T,   T,   N,   T,   T,   T,   T,  N,   T,   N,   N,   T,   T,   T  ...

       = TTNTTTTNTNNTTT ...   (completely random - impossible 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 Release 场景 时间(秒) 分支 - 随机数据 11.777 分支 - 排序数据 2.352 无分支 - 随机数据 2.564 无分支 - 排序数据 2.587
Java - NetBeans 7.1.1 JDK 7 - x64
场景 时间(秒)
分支 - 随机数据 10.93293813
分支 - 排序数据 5.643797077
无分支 - 随机数据 3.113581453
无分支 - 排序数据 3.186068823

观察结果:

  • 使用分支:排序和未排序数据之间存在巨大差异。
  • 使用技巧:排序和未排序数据之间没有差异。
  • 在C++的情况下,当数据排序时,使用技巧实际上比使用分支稍慢。

一个经验法则是在关键循环中避免依赖数据的分支(就像这个例子中一样)。


更新:

GCC 4.6.1在x64上使用-O3或-ftree-vectorize能够生成条件移动指令,因此排序和未排序的数据之间没有区别 - 都很快。
(或者有点快:对于已排序的情况,如果GCC将条件移动指令放在关键路径上而不仅仅是add指令上,特别是在Broadwell之前的Intel处理器上,条件移动指令的延迟为2个周期,可能会更慢:gcc optimization flag -O3 makes code slower than -O2
VC++ 2010无法在这个分支中生成条件移动指令,即使在/Ox下也不行。 Intel C++ Compiler(ICC)11做了一些神奇的事情。它交换了两个循环,从而将不可预测的分支移到了外部循环。它不仅对错误预测免疫,而且比VC++和GCC生成的代码快两倍!换句话说,ICC利用了测试循环来击败基准测试...
如果你给Intel编译器无分支的代码,它会直接对其进行向量化...并且和有分支的代码(包括循环交换)一样快。
这就表明即使是成熟的现代编译器在优化代码方面也可能存在巨大的差异...

49
等一下,将负值向右移位不会产生实现定义的结果吗? int t = (data[c] - 128) >> 31; sum += ~t & data[c];注意:本翻译仅供参考,具体语境需要根据实际情况而定。 - Matias Chara
69
顺带提一下,分支预测失败也可以被一个程序利用来获取另一个程序在同一CPU核心上正在使用的加密密钥。 - mins
45
@Mycotina,我不是专家,但我的理解是:处理器需要多个步骤才能执行单个指令(获取、解码等)-- 这被称为“指令流水线” -- 因此,作为一种优化,它会一次获取多个指令并在执行当前指令时“预热”下一个指令。如果选择了错误的分支,则必须丢弃在管道中“预热”的指令,以便将正确分支上的指令放入管道中。 - Raphael
22
当你把指令管道缓存看作轨道,把装有指令的火车看作指令,把指示器(左转或右转)看作在火车尾部的某个人时,它就更容易理解了;而不是在火车头部。当你看到他以确认你猜对时,已经为时过晚,因为管道前方已经被填满,但方向错误。如果你猜错了,预测的管道需要被废弃(出轨;在转换站前拖回火车,将其放回轨道并发送到另一个方向)。 - WhozCraig
18
主要是运行时,即处理器在执行代码时预测分支。处理器还会记住先前的结果并用于预测下一次跳转。然而,编译器可以在编译时为分支预测提供一些初始提示 - 搜寻“likely”和“unlikely”属性。因此你可以说答案有点两个方面,但运行时才是实际发生的时候。 - Tom
显示剩余5条评论

4662

分支预测。

对于一个有序数组,条件 data[c] >= 128 在接下来的数值中首先连续出现false,之后所有的数值都是true。这很容易预测。但是对于无序数组,你需要付出分支成本。


165
在排序数组和具有不同模式的数组中,分支预测是否工作得更好?例如,对于数组--> {10, 5, 20, 10, 40, 20,...},从该模式中的下一个元素是80。如果遵循该模式,这种类型的数组是否可以通过下一个元素是80的分支预测加快速度?还是它通常只对排序数组有帮助? - Adam Freeman
210
基本上我传统学习的大O内容都没用了?选择产生排序成本比产生分支成本更好? - Agrim Pathak
198
这取决于输入规模。对于输入规模不太大的情况,当具有更高复杂度的算法中的常数较小时,它比具有较低复杂度的算法更快。很难预测交叉点在哪里。此外,局部性很重要。大O符号很重要,但不是性能的唯一标准。 - Daniel Fischer
108
分支预测是何时发生的?编程语言何时会知道数组已排序?我在考虑这样一种情况,即数组看起来像这样:[1,2,3,4,5,...998,999,1000, 3, 10001, 10002]?这个模糊的数字3会增加运行时间吗?它是否与未排序的数组一样耗时? - Filip Bartuzi
107
分支预测发生在处理器内部,低于语言级别(但语言可以提供告诉编译器哪些情况是可能的,以便编译器生成适合该情况的代码)。在您的示例中,乱序的3将导致分支错误预测(对于适当的条件,其中3会产生与1000不同的结果),因此处理该数组可能需要比排序数组多几十或几百纳秒的时间,几乎不会引起注意。成本高的是高错误预测率,每1000个错误预测一次并不多。 - Daniel Fischer
显示剩余7条评论

3800
当数据排序时,性能显著提高的原因是消除了分支预测惩罚,正如Mysticial's answer中所美妙地解释的那样。
现在,如果我们看一下代码
if (data[c] >= 128)
    sum += data[c];

我们可以发现,这个特定的if...else...分支的含义是在满足条件时添加一些内容。这种类型的分支可以很容易地转换为一个条件移动语句,它将被编译成一个条件移动指令:cmovl,在一个x86系统中。分支和潜在的分支预测惩罚被消除。
CC++中,这个语句(在没有任何优化的情况下)会直接编译成x86中的条件移动指令,即三元运算符...?...:...。因此,我们将上述语句重写为等效语句:
sum += data[c] >=128 ? data[c] : 0;

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

在Intel Core i7-2600K @ 3.4 GHz和Visual Studio 2010 Release Mode上,基准测试结果如下:

x86

场景 时间(秒)
分支 - 随机数据 8.885
分支 - 排序数据 1.528
无分支 - 随机数据 3.716
无分支 - 排序数据 3.71

x64

场景 时间(秒)
分支-随机数据 11.302
分支-排序数据 1.830
无分支-随机数据 2.736
无分支-排序数据 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处理器中,指令的执行被分为几个阶段。大致上,我们有不同的硬件来处理不同的阶段。因此,我们不必等待一条指令完成才开始新的指令。这称为pipelining

在分支情况下,后续指令由前面的指令决定,因此我们无法进行流水线操作。我们必须等待或预测。

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

这本书计算机系统:程序员的视角,第二版详细解释了这个问题。您可以查看第3.6.6节了解条件移动指令,整个第4章了解处理器架构,以及第5.11.2节了解分支预测和错误惩罚的特殊处理

有时候,一些现代编译器可以将我们的代码优化为具有更好性能的汇编代码,而有时候一些编译器则不行(所讨论的代码是使用Visual Studio的本地编译器)。当情况变得非常复杂以至于编译器无法自动优化它们时,了解分支和条件移动之间的性能差异可以帮助我们编写具有更好性能的代码。


10
在Java中,三元运算符和if条件语句在执行速度上几乎没有区别。编译器通常会将它们转换为相同的字节码指令,因此生成的代码几乎相同。选择哪种语法结构应该基于可读性和代码风格的考虑而不是性能。 - Linus Fernandes
3
你忘了启用优化;基准测试调试版本,那些会将所有内容存储/重新加载到堆栈中的版本是没有用处的。如果你想要使用有效的标量汇编(并且希望使用 cmov),请使用“gcc -O2 -fno-tree-vectorize -S”。(“-O3”可能会自动向量化,因此对于 GCC12 或更高版本,也可以使用“-O2”)。另请参见“gcc 优化标志 -O3 使代码比 -O2 更慢”(针对排序后,当它对“if”使用 cmov 不好的情况)。 - Peter Cordes

2612

如果你对这段代码还有更多的优化感兴趣,可以考虑以下内容:

从原始循环开始:

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];
    }
}

接下来,你会发现在 i 循环的执行中,if 条件语句始终不变,因此你可以将 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倍。


13
这很有趣,但对于可能不理解这个笑话的人来说:原始代码中的 for (unsigned i = 0; i < 100000; ++i) 的目的是运行被基准测试的代码多次,以便更准确地测量执行所需的时间。否则,由于计时器分辨率受限和/或其他进程(包括操作系统)不可预测地抢占此进程而导致的抖动等问题将会影响测量结果的准确性。当你只运行一次测试代码时,测试的代码确实需要 非常 短的时间才能执行完毕! - cpcallen

2163

毫无疑问,我们中的一些人会对识别对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) 这一行导致了在 cachegrind 的分支预测模型下 164,050,007 次错误的条件分支 (Bcm),而在已排序的版本中仅导致了 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)
...

请参阅性能教程以获取更多详情。


102
这很吓人,在未排序的列表中,应该有50%的机会命中添加操作。但是分支预测却只有25%的失误率,它是如何比50%的失误率表现得更好的? - TallBrianL
171
25%是所有分支的比例 - 循环中有两个分支,一个是用于data[c] >= 128的分支(正如您所建议的,它有50%的未命中率),另一个是用于循环条件c < arraySize的分支,它有近似为0%的未命中率。 - caf
6
请注意,对未经优化(“调试模式”)的代码进行基准测试/分析通常是不明智的。通过优化,没有分支缺失的版本将更快地执行,不会因为本地变量的存储/重新加载延迟而停顿。然而,关键分支的实际预测错误率应该是相同的(假设存在一个关键分支:现代编译器可以矢量化此过程,或以其他方式生成无分支汇编)。循环展开可以通过较少运行可预测的循环分支来改变整体缺失率。 - Peter Cordes

1595

我刚刚看了这个问题及其答案,感觉缺少一个答案。

在托管语言中消除分支预测的一种常见方法是使用表查找而不是使用分支(尽管我没有在这种情况下测试过它的效果)。

如果:

  1. 这是一个小表,并且可能被处理器缓存,
  2. 您正在运行紧密循环的操作和/或处理器可以预加载数据,则此方法通常有效。

背景和原因

从处理器的角度来看,内存是慢速的。为弥补速度差异,处理器内建了几个缓存(L1/L2缓存)。因此,想像一下,当您进行漂亮的计算并发现需要一个内存片段时,处理器将获取其“加载”操作并将内存片段加载到缓存中,然后使用缓存来完成其余计算。由于内存相对较慢,这个“加载”会减慢程序的运行速度。

与分支预测一样,Pentium处理器对此进行了优化:处理器预测需要加载某个数据片段,并尝试在操作实际击中缓存之前将其加载到缓存中。正如我们已经看到的,分支预测有时会出错,最坏的情况是您需要返回并等待内存加载,这将花费很长时间(换句话说:分支预测失败很糟糕,分支预测失败后进行内存加载更加糟糕!)。

幸运的是,如果内存访问模式可预测,处理器将在其快速缓存中加载它,所有事情都很好。

我们需要知道的第一件事是什么是?尽管越小越好,但经验法则是要使用大小<= 4096字节的查找表。作为上限:如果您的查找表大于64K,则可能需要重新考虑。

构建表格

因此,我们已经知道可以创建一个小表。下一步是放置一个查找函数。查找函数通常是使用几个基本整数操作(和、或、异或、移位、加、删除和可能是乘法)的小函数。您希望将输入转换为表中某种“唯一键”,然后简单地给出您希望它执行的所有工作的答案。

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

托管语言

您可能会想知道为什么这在托管语言中效果很好。毕竟,托管语言通过分支检查数组的边界以确保您不会搞砸...

嗯,不完全是... :-)

已经有相当多的工作致力于消除托管语言的此分支。例如:

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

在这种情况下,编译器很明显会永远不会达到边界条件。至少微软 JIT 编译器(但我预计 Java 也会做类似的事情)会注意到这一点并完全删除检查。哇,这意味着没有分支。同样地,它还将处理其他明显的情况。

如果您在托管语言中遇到查找问题 - 关键是向您的查找函数添加& 0x[something]FFF以使边界检查可预测 - 并观察其运行更快。

这种情况的结果

// 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();

1420

由于在数组排序时数据分布在0到255之间,所以大约在前一半迭代中不会进入if语句(下面共享了if语句)。

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

问题是:为什么在某些情况下(如已排序的数据)上述语句无法执行?这里涉及到“分支预测器”。分支预测器是一种数字电路,它尝试猜测一个分支(例如一个if-then-else结构)走向,在确定前就进行预测。分支预测器的目的是改善指令流水线中的流程。分支预测器在实现高效性能方面起着至关重要的作用!

让我们进行一些基准测试以更好地理解

if语句的性能取决于其条件是否具有可预测的模式。如果条件始终为真或始终为假,则处理器中的分支预测逻辑将捕获该模式。另一方面,如果模式是不可预测的,则if语句的成本会更高。

让我们使用不同的条件来衡量此循环的性能:

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

以下是使用不同true-false模式的循环时间:

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语句变慢高达六倍,而“好的”模式则不会!当然,哪种模式是好的哪种是不好的取决于编译器生成的确切指令和特定处理器。

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


52
“@MooingDuck 因为这不会有任何影响-该值可以是任何值,但仍将在这些阈值范围内。因此,当您已经知道限制时,为什么要显示随机值呢?虽然我同意出于完整性和“只是为了好玩”的目的,您可以显示一个值。” - cst1992
53
目前他最慢的计时是TTFFTTFFTTFF,依我人类的眼光看来,这似乎相当可预测。随机本质上是不可预测的,因此它完全可能会更慢,因此超出了此处显示的限制。另一方面,也有可能TTFFTTFF完美地符合病态情况。无法确定,因为他没有展示随机的计时。 - Mooing Duck
47
对于人眼来说,“TTFFTTFFTTFF”是一种可预测的序列,但我们在这里讨论的是CPU内置的分支预测器的行为。分支预测器并不具备人工智能级别的模式识别能力,它非常简单。当你只是交替使用分支(branch)时,预测效果并不好。在大多数代码中,分支几乎总是朝着同一个方向走;比如执行一千次的循环,循环末尾的分支会返回到循环开头999次,然后在第一千次时做一些不同的事情。一个非常简单的分支预测器通常能够良好地发挥作用。 - steveha
44
@steveha:我认为你对CPU分支预测器的工作方式作了假设,并且我不同意这种方法。我不知道这个分支预测器有多先进,但我似乎认为它比你想象的要先进得多。你可能是正确的,但测量肯定是好的。 - Mooing Duck
27
@steveha说,"Two-level adaptive predictor"能够轻松地锁定"TTFFTTFF"模式。 "这种预测方法的变体被用于大多数现代微处理器"。局部分支预测和全局分支预测都基于两级自适应预测器,它们也可以锁定该模式。 "全局分支预测在AMD处理器和Intel Pentium M、Core、Core 2以及基于Silvermont的Atom处理器中使用"。此外,还有Agree预测器、混合预测器和间接跳转预测等。循环预测器不能完全锁定,但命中率为75%。这只剩下了两个无法锁定的。 - Mooing Duck
显示剩余5条评论

1353

避免分支预测错误的一种方法是构建一个查找表,并使用数据进行索引。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];

这段代码浪费了一半的加法运算,但从未出现分支预测失败。它在随机数据上比有实际条件语句的版本快得多。

但在我的测试中,显式查找表比这个稍微快一些,可能是因为索引查找表比位移操作稍微快一些。以下是我代码中设置和使用查找表(在代码中无创意地称为lut,代表“查找表”)的方法。下面是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];

这里是一份代码链接:红黑树Eternally Confuzzled


52
你也可以直接使用数据的特定比特位并乘以 (data[c]>>7 - 这个讨论中已经提到过)。我有意将这种解决方案留在外面,但你当然是正确的。只是一个小提示:查找表的经验法则是,如果它适合于4KB(由于缓存原因),它就会起作用 - 最好使表尽可能小。对于托管语言,我会将其推到64KB,对于像C++和C这样的低级语言,我可能会重新考虑(这只是我的经验)。由于 typeof(int) = 4,我会尽量坚持最多10个比特位。 - atlaste
38
使用0/1值进行索引可能比整数乘法快,但如果性能真的很重要,应该对其进行性能分析。我同意小查找表是避免缓存压力的关键,但如果你有更大的缓存,你可以使用更大的查找表,因此4KB更多地是一个经验法则而不是硬性规定。我认为你的意思是sizeof(int) == 4?这对于32位是正确的。我的两年前的手机具有32KB L1缓存,因此即使是4K的查找表也可能起作用,特别是如果查找值是字节而不是整数。 - steveha
31
也许我有所遗漏,但在你的j等于0或1的方法中,为什么不在加上值之前将其乘以j,而不是使用数组索引(可能应该乘以1-j而不是j)? - Richard Tingle
19
“@steveha乘法应该更快,我尝试在英特尔的书中查找,但找不到……无论如何,基准测试也给出了这个结果。” - atlaste
24
另一个可能的答案是 int c = data[j]; sum += c & -(c >> 7);,它根本不需要乘法。 - atlaste
显示剩余6条评论

1216
在排序的情况下,您可以比依赖成功的分支预测或任何无分支比较技巧更好:完全删除分支。
实际上,该数组被划分为一个连续区域,其中 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):-)

41
“sum= 3137536” - 聪明。但这似乎并不是问题的重点。问题显然在于解释惊人的性能特征。我倾向于认为使用std::partition而不是std::sort是有价值的。虽然实际问题并不仅限于给定的合成基准。 - sehe
24
这确实不是针对给定键执行的标准二分查找,而是搜索划分索引;它每次迭代需要一次比较。但不要依赖这段代码,我没有检查过。如果您对一个保证正确的实现感兴趣,请告诉我。 - user1196549
二分查找切点是不必要的,只需从您想要保留的末尾开始求和,在到达不想要的值时停止即可(除非HW预取在向下循环而不是向上循环时效果明显更差)。当然,实际花时间进行排序并不是从未排序的数据开始的算法的有用部分。如果您想要从相同的数据中回答不同切点(128以外)的多个查询,则可能需要直方图。(由于小值范围意味着在这个较小的数组中有许多重复项。) - Peter Cordes

1021
上述行为是由分支预测引起的。
要理解分支预测,首先必须了解指令流水线。
运行指令的步骤可以与运行前一个和后一个指令的步骤序列重叠,以便不同的步骤可以并行执行。这种技术称为指令流水线,并用于增加现代处理器的吞吐量。要更好地理解这一点,请参见维基百科上的示例
通常,现代处理器具有相当长(且宽)的流水线,因此许多指令可以在执行中。请参见现代微处理器90分钟指南!,该指南从介绍基本的顺序流水线开始。
但为了方便起见,让我们考虑一个只有这4个步骤的简单顺序流水线。
(像经典的5级RISC,但省略了单独的MEM阶段。)
  1. IF -- 从内存中获取指令
  2. ID -- 解码指令
  3. EX -- 执行指令
  4. WB -- 写回到CPU寄存器

一般情况下,2条指令的4级流水线。
一般情况下的4级流水线

回到上面的问题,考虑以下指令:

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

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

为了执行指令B或指令C,处理器将不得不等待(停顿),直到指令A离开流水线中的EX阶段,因为去往指令B或指令C的决定取决于指令A的结果。(即下一个从哪里获取。)所以流水线将如下图所示:

没有预测:当if条件为真时: enter image description here

没有预测:当if条件为假时: enter image description here

由于等待指令A的结果,上述情况(没有分支预测;对于真和假)所花费的总CPU周期为7个。

那么什么是分支预测?

分支预测器会在确定之前尝试猜测一个分支(if-then-else结构)的方向。它不会等待指令A到达管道的EX阶段,而是会猜测决策并转到该指令(在我们的示例中为B或C)。
如果猜测正确,则管道看起来像这样:
如果后来发现猜测错误,则部分执行的指令将被丢弃,并使用正确的分支重新启动管道,从而产生延迟。在分支错误预测的情况下浪费的时间等于从获取阶段到执行阶段的流水线阶段数。现代微处理器倾向于具有相当长的流水线,因此错误预测的延迟在10到20个时钟周期之间。流水线越长,需要良好的分支预测器的需求就越大。
在 OP 的代码中,第一次条件判断时,分支预测器没有任何信息可供预测,因此第一次它会随机选择下一个指令。(或者退回到静态预测,通常是向前不采取,向后采取)。在 for 循环的后面,它可以基于历史记录进行预测。 对于按升序排序的数组,有三种可能性:
  1. 所有元素都小于 128
  2. 所有元素都大于 128
  3. 一些起始新元素小于 128,后来变得大于 128
假设预测器在第一次运行时总是假定真分支。
因此,在第一种情况下,它将始终采取真分支,因为从历史上看,它的所有预测都是正确的。 在第二种情况下,最初它会预测错误,但经过几次迭代后,它将预测正确。 在第三种情况下,它最初会正确地预测,直到元素小于 128。之后,在看到历史上的分支预测失败时,它将在一段时间内失败并纠正自己。
在所有这些情况下,失败的次数都会很少,因此只有很少的时候需要丢弃部分执行的指令并重新开始正确的分支,从而减少CPU周期。
但是对于一个随机未排序的数组,在大多数情况下,预测将需要丢弃部分执行的指令并重新开始正确的分支,与排序后的数组相比,需要更多的CPU周期。
进一步阅读:
  • 现代微处理器 90分钟指南!
  • Dan Luu关于分支预测的文章(涵盖旧的分支预测器,而不是现代IT-TAGE或感知器)
  • https://en.wikipedia.org/wiki/Branch_predictor
  • 分支预测和解释器性能 - 不要相信民间传说 - 2015年的论文展示了英特尔的Haswell在预测Python解释器主循环的间接分支方面表现得如何(由于非简单模式而在历史上存在问题),而早期的CPU则没有使用IT-TAGE。(但它们对于这种完全随机的情况没有帮助。即使在Skylake CPU上将源编译为分支汇编时,在循环内部的if仍然有50%的错误预测率。)
  • 新款英特尔处理器上的静态分支预测-当运行没有动态预测的分支指令时,CPU实际上会做什么。 历史上,前向未采取(例如 if break ),后向采取(例如循环)已被使用,因为它比没有好。 布置代码以使快速路径/常见情况最小化采取的分支对于I-cache密度以及静态预测是有益的,因此编译器已经这样做。(这就是C源中 likely / unlikely 提示的真实效果,在大多数CPU中实际上并没有暗示硬件分支预测,除非通过静态预测。)

7
两条指令如何一起执行?是通过单独的 CPU 核心实现还是使用流水线指令集成在单个 CPU 核心中实现? - M.kazem Akhgary
6
所有内容都在一个逻辑核心内。如果您有兴趣,例如Intel软件开发人员手册中描述得非常好。 - Sergey.quixoticaxis.Ivanov

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接