关于Java:为什么处理一个排序数组比一个未排序数组更快?

Why is it faster to process a sorted array than an unsorted array?

这里有一段C++代码,看起来很奇怪。出于某种奇怪的原因,对数据进行分类可以神奇地使代码快六倍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
#include
#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尝试过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
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);
    }
}

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

我的第一个想法是排序将数据带到缓存中,但是我认为这是多么愚蠢,因为数组是刚生成的。

  • 怎么回事?
  • 为什么处理排序数组比处理未排序数组更快?
  • 这段代码是对一些独立术语的总结,顺序不应该重要。


你是分支预测失败的受害者。好的。什么是分支预测?

考虑一个铁路枢纽:好的。

Image showing a railroad junctionimage by mecanismo,via wikimedia commons.由SA 3.0许可证在CC下使用。好的。

现在为了争论,假设这是在19世纪——在远距离或无线电通信之前。好的。

你是一个路口的接线员,听到一列火车来了。你不知道该往哪走。你停车问司机他们要哪个方向。然后适当地设置开关。好的。

火车很重,而且惯性很大。所以它们需要永远启动和减速。好的。

有更好的方法吗?你猜火车会朝哪个方向走!好的。

  • 如果你猜对了,它会继续下去。
  • 如果你猜错了,船长会停下来,退后,冲你大喊大叫,让你打开开关。然后它可以沿着另一条路径重新启动。

如果你每次都猜对了,火车就不必停了。如果你猜错的次数太多,火车会花很多时间停车、倒车和重新启动。好的。

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

Screenshot of compiled code containing an if statement好的。

你是一个处理器,你看到一个分支。你不知道会朝哪个方向走。你是做什么的?停止执行并等待上一条指令完成。然后继续沿着正确的路径前进。好的。

现代处理器很复杂,并且有很长的管道。所以他们需要永远"热身"和"减速"。好的。

有更好的方法吗?你猜树枝会朝哪个方向走!好的。

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

如果你每次都猜对了,执行就不会停止。如果你猜错的次数太多,你会花很多时间来拖延、回退和重新启动。好的。

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

那么,你如何从战略上猜测,以尽量减少火车必须后退并沿另一条路径行驶的次数呢?你看看过去的历史!如果火车百分之九十九离开,那么你猜是离开了。如果它交替出现,那么您就交替进行猜测。如果每三次走一条路,你猜也是一样的…好的。

换句话说,您试图识别一个模式并遵循它。这或多或少就是分支预测器的工作原理。好的。

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

进一步阅读:维基百科上的"分支预测器"文章。好的。正如上面所暗示的,罪魁祸首是这个if语句:

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

请注意,数据平均分布在0和255之间。排序数据时,大约前半个迭代不会进入if语句。之后,它们都将输入if语句。好的。

这对分支预测器非常友好,因为分支连续多次朝同一方向移动。即使是一个简单的饱和计数器也能正确地预测分支,除了在它切换方向后的少数迭代。好的。

快速可视化:好的。

1
2
3
4
5
6
7
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%左右的预测失误。(不比随机猜测更好)好的。

1
2
3
4
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)

那么我们能做什么呢?好的。

如果编译器无法将分支优化为条件移动,那么如果愿意牺牲可读性来提高性能,可以尝试一些黑客攻击。好的。

替换:好的。

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

用:好的。

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

这消除了分支,并用一些位操作替换它。好的。

(注意,此hack并不严格等同于原始if语句。但在这种情况下,它对data[]的所有输入值都有效。好的。

基准测试:3.5 GHz下的核心i7 920好的。

C++ Visual Studio 2010—X64版本好的。

1
2
3
4
5
6
7
8
9
10
11
//  Branch - Random
seconds = 11.777

//  Branch - Sorted
seconds = 2.352

//  Branchless - Random
seconds = 2.564

//  Branchless - Sorted
seconds = 2.587

Java- NETBea7.1.1 JDK 7 -X64好的。

1
2
3
4
5
6
7
8
9
10
11
//  Branch - Random
seconds = 10.93293813

//  Branch - Sorted
seconds = 5.643797077

//  Branchless - Random
seconds = 3.113581453

//  Branchless - Sorted
seconds = 3.186068823

观察:好的。

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

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

更新:好的。

  • 一般合同条款第4.6.1款中,X64上的-O3-ftree-vectorize能够产生条件移动。所以排序数据和未排序数据之间没有区别——两者都很快。好的。

  • 即使在/Ox下,vc++2010也无法为此分支生成条件移动。好的。

  • 英特尔编译器11做了一些不可思议的事情。它将两个循环互换,从而将不可预测的分支提升到外部循环。因此,它不仅可以避免预测失误,而且速度是VC++和GCC生成速度的两倍!换句话说,ICC利用测试循环来击败基准测试……好的。

  • 如果你给了英特尔编译器无分支的代码,它就直接向量化了它…和分支(循环交换)一样快。好的。

这表明,即使是成熟的现代编译器,其优化代码的能力也会大不相同。好的。好啊。


分支预测。

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


当数据排序时,性能显著提高的原因是删除了分支预测惩罚,正如神秘主义答案中所解释的那样。

现在,如果我们看看代码

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

我们可以发现,这个特定的if... else...分支的含义是在满足某个条件时添加一些内容。这种类型的分支可以很容易地转换成条件移动语句,并编译成条件移动指令:cmovl,在x86系统中。从而消除了潜在的分支预测惩罚。

C中,因此C++中,直接(不经过任何优化)编译成x86中条件移动指令的语句是三元运算符... ? ... : ...。因此,我们将上述语句改写为等效语句:

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

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

在Intel Core [email protected] GHz和Visual Studio 2010发布模式上,基准是(格式从Mysticial复制):

x86

1
2
3
4
5
6
7
8
9
10
11
//  Branch - Random
seconds = 8.885

//  Branch - Sorted
seconds = 1.528

//  Branchless - Random
seconds = 3.716

//  Branchless - Sorted
seconds = 3.71

X64

1
2
3
4
5
6
7
8
9
10
11
//  Branch - Random
seconds = 11.302

//  Branch - Sorted
 seconds = 1.830

//  Branchless - Random
seconds = 2.736

//  Branchless - Sorted
seconds = 2.737

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

现在让我们更仔细地研究一下他们生成的x86组件。为了简单起见,我们使用了两个函数max1max2

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

1
2
3
4
5
6
int max1(int a, int b) {
    if (a > b)
        return a;
    else
        return b;
}

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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
: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

由于使用了cmovge指令,max2使用的代码要少得多。但真正的收益是,max2不涉及分支跳转,jmp如果预测结果不正确,将有很大的性能损失。

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

在典型的x86处理器中,指令的执行分为几个阶段。大致上,我们有不同的硬件来处理不同的阶段。所以我们不必等待一条指令完成,就可以开始一条新的指令。这叫做管道。

在分支案例中,下面的指令是由前面的指令决定的,因此我们不能进行流水线操作。我们要么等待,要么预测。

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

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

有时,一些现代编译器可以以更好的性能将代码优化为程序集,有时一些编译器不能(所讨论的代码使用的是Visual Studio的本机编译器)。在不可预测的情况下知道分支和条件移动之间的性能差异可以帮助我们在场景变得如此复杂以至于编译器无法自动优化时编写性能更好的代码。


如果您对可以对该代码进行更多的优化感到好奇,请考虑:

从原始循环开始:

1
2
3
4
5
6
7
8
for (unsigned i = 0; i < 100000; ++i)
{
    for (unsigned j = 0; j < arraySize; ++j)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

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

1
2
3
4
5
6
7
8
for (unsigned j = 0; j < arraySize; ++j)
{
    for (unsigned i = 0; i < 100000; ++i)
    {
        if (data[j] >= 128)
            sum += data[j];
    }
}

然后,您可以看到在执行i循环的整个过程中,if条件是恒定的,因此您可以将if提升出来:

1
2
3
4
5
6
7
8
9
10
for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        for (unsigned i = 0; i < 100000; ++i)
        {
            sum += data[j];
        }
    }
}

然后,您会看到内部循环可以折叠成一个单独的表达式,假设浮点模型允许这样做(例如,抛出了/fp:fast)

1
2
3
4
5
6
7
for (unsigned j = 0; j < arraySize; ++j)
{
    if (data[j] >= 128)
    {
        sum += data[j] * 100000;
    }
}

那个比以前快10万倍


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

排序:

1
2
3
==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%   )

未分类的:

1
2
3
==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产生的逐行输出向下钻取到所讨论的循环:

排序:

1
2
3
4
5
6
7
8
9
10
          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];
           .      .  .   .          }
           .      .  .   .      }

未分类的:

1
2
3
4
5
6
7
8
9
10
          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];
           .           .  .   .          }
           .           .  .   .      }

这让您很容易识别出问题行——在未排序版本中,在cachegrind的分支预测模型下,if (data >= 128)行导致164050007个预测错误的条件分支(Bcm),而在排序版本中,它只导致10006个。

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

1
perf stat ./sumtest_sorted

排序:

1
2
3
4
5
6
7
8
9
10
11
12
 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

未分类的:

1
2
3
4
5
6
7
8
9
10
11
12
 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

它还可以使用disassembly进行源代码注释。

1
2
perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
1
2
3
4
5
6
7
8
9
10
 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)
...

有关详细信息,请参阅性能教程。


我刚刚读了这个问题及其答案,我觉得答案不见了。

消除分支预测的一个常见方法是使用表查找,而不是使用分支(尽管在本例中我没有测试它)。

在以下情况下,此方法通常有效:

  • 它是一个小表,可能缓存在处理器中,并且
  • 您在一个非常紧密的循环中运行事情,并且/或者处理器可以预加载数据。
  • 背景和原因

    从处理器的角度来看,您的内存很慢。为了补偿速度上的差异,处理器中内置了两个缓存(一级/二级缓存)。所以想象一下,你正在做你的计算,并发现你需要一段记忆。处理器将得到它的"加载"操作,并将内存块加载到缓存中——然后使用缓存进行其余的计算。因为内存相对较慢,所以这个"加载"会减慢程序的运行速度。

    与分支预测一样,这是在奔腾处理器中优化的:处理器预测它需要加载一段数据,并尝试在操作实际到达缓存之前将其加载到缓存中。正如我们已经看到的,分支预测有时会出现可怕的错误——在最坏的情况下,您需要返回并实际等待一个内存负载,而这需要花费很长时间(换句话说:失败的分支预测是坏的,分支预测失败后的内存负载是可怕的!).

    幸运的是,如果内存访问模式是可预测的,处理器将把它加载到其快速缓存中,一切都很好。

    我们首先要知道的是什么是小的?虽然通常较小更好,但经验法则是坚持使用大小小于等于4096字节的查找表。作为上限:如果您的查阅表格大于64K,则可能需要重新考虑。

    构造表

    所以我们发现我们可以创建一个小表。下一步要做的是将查找函数放置到位。查找函数通常是使用两个基本整数操作的小函数(AND、OR、XOR、SHIFT、ADD、REMOVE和PLUS)。您希望通过lookup函数将输入转换为表中的某种"唯一键",然后简单地给出您想要它做的所有工作的答案。

    在这种情况下:>=128意味着我们可以保留这个值,<128意味着我们可以去掉它。最简单的方法是使用一个"and":如果我们保留它,我们用7fffffff和它;如果我们想去掉它,我们用0和它。还要注意128是2的幂,所以我们可以做一个由32768/128个整数组成的表,并用一个零和大量的7ffffff填充它。

    托管语言

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

    嗯,不完全是…-)

    在消除托管语言的这个分支方面已经做了相当多的工作。例如:

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

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

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

    这个案子的结果

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    38
    39
    // 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();


    当数组排序时,数据分布在0和255之间,大约上半个迭代不会进入if语句(下面共享if语句)。

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

    问题是:在某些情况下,为什么上述语句不能像排序数据那样执行?这里是"分支预测器"。分支预测器是一种数字电路,它试图猜测分支(如if-then-else结构)在确定这一点之前会朝哪个方向发展。分支预测器的目的是提高指令管道中的流量。分支预测器在实现高效性能方面发挥着关键作用!

    让我们做些基准测试来更好地理解它

    if语句的性能取决于其条件是否具有可预测的模式。如果条件始终为真或始终为假,处理器中的分支预测逻辑将获取模式。另一方面,如果模式是不可预测的,那么if语句将要昂贵得多。

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

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

    以下是具有不同真假模式的循环的计时:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    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语句比一个"好"模式慢6倍!当然,哪种模式是好的,哪种模式是坏的,取决于编译器生成的确切指令和特定的处理器。

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


    避免分支预测错误的一种方法是构建一个查找表,并使用数据对其进行索引。斯特凡·德·布鲁恩在回答中讨论了这一点。

    但在这种情况下,我们知道值在[0,255]范围内,我们只关心大于等于128的值。这意味着我们可以很容易地提取一个位,它将告诉我们是否需要一个值:通过将数据右移7位,我们只剩下0位或1位,我们只想在有1位的时候添加值。让我们把这个位称为"决策位"。

    通过使用决策位的0/1值作为数组的索引,无论数据是否排序,我们都可以使代码具有相同的速度。我们的代码总是会添加一个值,但是当决策位为0时,我们会在不关心的地方添加值。代码如下:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 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)。下面是C++代码:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    // 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语句,可用于决定使用哪个指针。我看到了一个实现二叉树的库,它没有两个命名指针(pLeftpRight或其他类型的指针),而是有一个长度为2的指针数组,并使用"决策位"技术来决定要使用哪个指针。例如,而不是:

    1
    2
    3
    4
    if (x < node->value)
        node = node->pLeft;
    else
        node = node->pRight;

    此库将执行以下操作:

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

    这是一个链接到这个代码:红黑树,永远困惑


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

    实际上,数组是在与data < 128相邻的区域中分区的,另一个是与data >= 128相邻的区域中分区的。因此,您应该使用二分搜索(使用Lg(arraySize) = 15比较)找到分区点,然后从该点直接进行累积。

    类似(未选中)

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    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];

    或者,稍微模糊一点

    1
    2
    3
    4
    5
    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):-)


    由于分支预测,上述行为正在发生。

    要了解分支预测,首先必须了解指令管道:

    任何指令都被分解成一系列步骤,以便不同的步骤可以并行执行。这种技术被称为指令管道,用于提高现代处理器的吞吐量。为了更好地理解这一点,请参阅维基百科上的这个例子。

    一般来说,现代处理器有相当长的管道,但为了方便起见,我们只考虑这4个步骤。

  • if——从内存中获取指令
  • id——解码指令
  • ex——执行指令
  • wb——写回CPU寄存器

    一般为4级管道,用于2个说明。4-stage pipeline in general

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
                            A) if (data[c] >= 128)
                                    /\
                                   /  \
                                  /    \
                            true /      \ false
                                /        \
                               /          \
                              /            \
                             /              \
                  B) sum += data[c];          C) for loop or print().

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

    要执行指令B或指令C,处理器必须等到指令A到达管道中的前一个阶段,因为进入指令B或指令C的决定取决于指令A的结果。因此管道看起来是这样的。

    当if条件返回true时:enter image description here

    当if条件返回false时:enter image description here

    由于等待指令A的结果,在上述情况下(不进行分支预测;判断正确与否)花费的总CPU周期为7。

    那么什么是分支预测呢?

    分支预测器将尝试猜测分支(if-then-else结构)将朝哪个方向发展,然后才能确定这一点。它不会等待指令A到达管道的前阶段,但它会猜测决定并转到该指令(在我们的示例中是B或C)。

    如果猜测正确,管道看起来如下:enter image description here

    如果后来检测到猜测是错误的,那么部分执行的指令将被丢弃,管道将以正确的分支重新开始,从而导致延迟。分支预测失误时浪费的时间等于从获取阶段到执行阶段的管道中的阶段数。现代微处理器往往具有相当长的管道,因此预测失误延迟在10到20个时钟周期之间。管道越长,就越需要一个好的分支预测器。

    在操作码中,第一次当条件发生时,分支预测器没有任何信息来作为预测的基础,所以第一次它将随机选择下一条指令。稍后在for循环中,它可以基于历史进行预测。对于按升序排序的数组,有三种可能性:

  • 所有元素都小于128
  • 所有元素都大于128
  • 一些开始的新元素小于128,之后变为大于128

    让我们假设预测器在第一次运行时总是假定为真正的分支。

    因此,在第一种情况下,它总是采取真正的分支,因为历史上它的所有预测都是正确的。在第二种情况下,最初它将预测错误,但经过几次迭代后,它将正确预测。在第三种情况下,它最初将正确预测,直到元素小于128。之后,当它在历史上看到分支预测失败时,它将失败一段时间,并纠正自己。

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

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


    官方答复是

  • 英特尔-避免分支预测失误的成本
  • 英特尔-分支和循环重组以防止预测失误
  • 科学论文.分支预测计算机体系结构
  • 书籍:J.L.Hennessy,D.A.Patterson:计算机体系结构:定量方法
  • 科学出版物上的文章:T.Y.Yeh,Y.N.Patt在分支预测上做了很多这样的文章。
  • 你也可以从这个可爱的图表中看到为什么分支预测器会被混淆。

    2-bit state diagram

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

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

    因此,预测因子将随着std::rand()的打击改变边。

    另一方面,一旦排序,预测器将首先进入强不取的状态,当值变为高值时,预测器将在三次运行中从强不取变为强取。


    在同一行中(我认为没有任何答案强调这一点),值得一提的是,有时(特别是在性能很重要的软件中,如Linux内核中),您可以找到如下一些if语句:

    1
    2
    3
    4
    if (likely( everything_is_ok ))
    {
        /* Do something */
    }

    或类似地:

    1
    2
    3
    4
    if (unlikely(very_improbable_condition))
    {
        /* Do something */    
    }

    实际上,likely()unlikely()都是宏,它们是通过使用类似gcc的__builtin_expect来帮助编译器插入预测代码来支持条件的,同时考虑到用户提供的信息。GCC支持可以更改正在运行的程序的行为或发出低级指令(如清除缓存等)的其他内置组件。请参阅此文档,其中介绍了可用的GCC内置组件。

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


    C++中常用的布尔运算在编译程序中产生多个分支。如果这些分支在循环中并且难以预测,那么它们会显著降低执行速度。布尔变量存储为8位整数,值为false0,值为true1

    布尔变量被过度确定,因为所有使用布尔变量作为输入的运算符都会检查输入是否有除01以外的任何其他值,但使用布尔值作为输出的运算符只能生成除01以外的值。这使得使用布尔变量作为输入的操作效率比必要的低。举个例子:

    1
    2
    3
    bool a, b, c, d;
    c = a && b;
    d = a || b;

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

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    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;
    }

    这段代码远不是最佳的。如果预测失误,分支可能需要很长时间。如果确信操作数没有除01以外的其他值,则布尔运算可以变得更有效。编译器之所以没有做出这样的假设,是因为如果变量未初始化或来自未知源,那么它们可能具有其他值。如果ab已初始化为有效值,或者来自产生布尔输出的运算符,则可以优化上述代码。优化后的代码如下:

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

    使用char代替bool,以便可以使用位运算符(&|),而不是布尔运算符(&&||)。位运算符是只占用一个时钟周期的单个指令。即使ab的值与01的值不同,OR运算符(|也可以工作。如果操作数的值不是01,则and运算符(&和exclusive or运算符(^可能产生不一致的结果。

    ~不能用于not。相反,您可以使用1对已知为01的变量进行xor'ing,从而使布尔值不为:

    1
    2
    bool a, b;
    b = !a;

    可优化为:

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

    如果bafalse时不应评价的表达式(&&不评价b时,a && b不能代替a & b(&将不评价b)。同样,如果atrue不应被评估的表达,那么a || b不能被a | b替换。

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

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

    在大多数情况下都是最佳的(除非您希望&&表达式生成许多分支预测失误)。


    那是肯定的!…

    分支预测使逻辑运行速度变慢,因为代码中发生了切换!就像你走的是一条直的街道或者一条有很多转弯的街道,当然,直的那条会做得更快!…

    如果对数组进行排序,则第一步的条件为false:data >= 128,然后成为街道尽头的整个道路的真值。这就是你如何更快地到达逻辑的终点。另一方面,使用一个未排序的数组,您需要进行大量的转换和处理,以确保您的代码运行速度较慢…

    看看下面我为你创建的图片。哪条街道的完工速度更快?

    Branch Prediction

    因此,以编程的方式,分支预测会导致进程变慢…

    最后,很高兴知道我们有两种分支预测,每种预测都会对代码产生不同的影响:

    1。静态的

    2。动态的

    Branch Prediction

    Static branch prediction is used by the microprocessor the first time
    a conditional branch is encountered, and dynamic branch prediction is
    used for succeeding executions of the conditional branch code.

    In order to effectively write your code to take advantage of these
    rules, when writing if-else or switch statements, check the most
    common cases first and work progressively down to the least common.
    Loops do not necessarily require any special ordering of code for
    static branch prediction, as only the condition of the loop iterator
    is normally used.


    这个问题已经被很好地回答了很多次。不过,我还是想让小组注意到另一个有趣的分析。

    最近,这个示例(修改得很小)也被用作演示如何在Windows上在程序本身中对一段代码进行分析的方法。在此过程中,作者还演示了如何使用结果来确定代码在排序的和未排序的情况下的大部分时间。最后,本文还展示了如何使用HAL(硬件抽象层)的一个鲜为人知的特性来确定在未排序的情况下发生了多少分支预测失误。

    链接如下:http://www.geoffschappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm(研究)


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

    我不是想添加什么,而是用另一种方式解释这个概念。在wiki上有一个包含文本和图表的简明介绍。我确实喜欢下面的解释,它使用一个图表来直观地阐述分支预测器。

    In computer architecture, a branch predictor is a
    digital circuit that tries to guess which way a branch (e.g. an
    if-then-else structure) will go before this is known for sure. The
    purpose of the branch predictor is to improve the flow in the
    instruction pipeline. Branch predictors play a critical role in
    achieving high effective performance in many modern pipelined
    microprocessor architectures such as x86.

    Two-way branching is usually implemented with a conditional jump
    instruction. A conditional jump can either be"not taken" and continue
    execution with the first branch of code which follows immediately
    after the conditional jump, or it can be"taken" and jump to a
    different place in program memory where the second branch of code is
    stored. It is not known for certain whether a conditional jump will be
    taken or not taken until the condition has been calculated and the
    conditional jump has passed the execution stage in the instruction
    pipeline (see fig. 1).

    figure 1

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

  • 没有分支预测器。
  • Without branch prediction, the processor would have to wait until the
    conditional jump instruction has passed the execute stage before the
    next instruction can enter the fetch stage in the pipeline.

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

    without branch predictor

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

  • 使用分支预测器,不要进行条件跳转。假设predict不接受条件跳转。
  • enter image description here

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

  • 使用分支预测器并进行条件跳转。假设predict不接受条件跳转。
  • enter image description here

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

    The time that is wasted in case of a branch misprediction is equal to
    the number of stages in the pipeline from the fetch stage to the
    execute stage. Modern microprocessors tend to have quite long
    pipelines so that the misprediction delay is between 10 and 20 clock
    cycles. As a result, making a pipeline longer increases the need for a
    more advanced branch predictor.

    如你所见,我们似乎没有理由不使用分支预测。

    这是一个非常简单的演示,阐明了分支预测器的基本部分。如果这些gif很烦人,请随意将其从答案中删除,访问者也可以从git获得演示。


    分支预测增益!

    重要的是要理解分支预测失误不会减慢程序的速度。错过的预测的代价就好像分支预测不存在,而您等待表达式的评估来决定运行什么代码(下一段将进一步解释)。

    1
    2
    3
    4
    5
    6
    if (expression)
    {
        // Run 1
    } else {
        // Run 2
    }

    每当有if-elseswitch语句时,必须对表达式进行计算,以确定应执行哪个块。在编译器生成的汇编代码中,插入条件分支指令。

    分支指令可能导致计算机开始执行不同的指令序列,从而偏离其按顺序执行指令的默认行为(即,如果表达式为假,则程序根据某些条件跳过if块的代码),在我们的情况下,这就是表达式评估。

    也就是说,编译器试图在实际评估结果之前对结果进行预测。它将从if块中获取指令,如果表达式是真的,那就太好了!我们得到了评估它所花费的时间,并在代码中取得了进展;如果没有,那么我们正在运行错误的代码,将刷新管道,并运行正确的块。

    可视化:

    假设您需要选择1号或2号路线。等待你的搭档检查地图,你已经在停下来等待,或者你可以选择1号路线,如果你运气好(1号路线是正确的路线),那么很好,你不必等待你的搭档检查地图(你节省了他检查地图的时间),否则你会掉头。

    虽然冲洗管道的速度非常快,但现在冒这个险是值得的。预测排序数据或变化缓慢的数据总是比预测快速变化更容易和更好。

    1
    2
    3
    4
    5
    6
     O      Route 1  /-------------------------------
    /|\             /
     |  ---------##/
    / \            \
                    \
            Route 2  \--------------------------------

    这是关于分支预测的。这是怎么一回事?

    • 分支预测器是一种古老的性能改进技术,在现代建筑中仍然具有重要意义。虽然简单的预测技术提供了快速查找和电源效率,但它们的预测失误率很高。

    • 另一方面,复杂的分支预测——无论是基于神经的还是两级分支预测的变体——提供了更好的预测精度,但它们消耗了更多的能量,并且复杂性呈指数级增加。

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

    • 分支预测本质上是一个优化(最小化)问题,重点是以最少的资源实现可能的最低误码率、低功耗和低复杂性。

    实际上有三种不同的分支:

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

    反向条件分支-PC被更改为指向指令流中的反向。分支是基于某些条件的,例如当循环结束时的测试表明应该再次执行循环时,向后分支到程序循环的开始。

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

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

    参考文献:

    • 支路预测器

    • 自我剖析的演示

    • 分行预测审核

    • 分支预测


    在ARM上,不需要分支,因为每个指令都有一个4位条件字段,它是以零成本测试的。这就消除了对短分支的需求,并且不会有分支预测命中。因此,由于排序的额外开销,排序版本的运行速度将比ARM上的未排序版本慢。内部循环如下所示:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    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


    除了分支预测可能会减慢速度之外,排序数组还有另一个优势:

    您可以有一个停止条件,而不只是检查值,这样您只循环相关的数据,而忽略其余的数据。分支预测将只错过一次。

    1
    2
    3
    4
    5
    6
    7
    8
    9
     // 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
    2
    3
    ____________________________________________________________________________________
    - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
    TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT

    未排序阵列:弯曲道路

    1
    2
    ______   ________
    |     |__|

    支路预测:猜测/预测哪条路是直的,不检查就沿着它走。

    1
    2
    ___________________________________________ Straight road
     |_________________________________________|Longer road

    虽然两条道路都到达同一个目的地,但笔直的道路较短,而另一条则较长。如果你错误地选择了另一条,就不会掉头,所以如果你选择更长的路,你会浪费一些额外的时间。这与计算机上发生的情况类似,我希望这能帮助您更好地理解。

    另外,我想从评论中引用@simon_weaver:

    It doesn’t make fewer predictions - it makes fewer incorrect predictions. It still has to predict for each time through the loop..


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

    下面的代码不会对整个数组进行排序,但只对其中的200个元素段进行排序,因此运行速度最快。

    仅对k元素部分进行排序,以线性时间而不是n.log(n)完成预处理。

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    #include
    #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;
    }

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