这是C++代码的一块 显示一些非常特殊的行为

由于某种原因,对数据进行分类(在时间区之前)奇迹般地使主要循环速度快近六倍:

#include 
#include 
#include 

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

    std::cout << elapsedTime << '\n';
    std::cout << "sum = " << sum << '\n';
}

没有 std: sort( 数据, 数据+数组Size); 代码在 11. 54 秒内运行。 有了分类数据, 代码在 1. 93 秒内运行 。

(分类本身需要的时间比这个通过数组的时间要长, 所以如果我们需要计算未知数组, 它实际上不值得做 。)


起初,我以为这只是一种语言或编译器异常, 所以我尝试了爪哇:

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

其结果类似,但不太极端。


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

为什么处理一个分类阵列的速度要快于处理一个未分类阵列的速度?

守则正在总结一些独立的术语,因此命令不应重要。


与不同的/后来的汇编者和备选办法具有相同效果:

为什么处理一个未排列的阵列的速度与处理一个用现代 x86-64 叮当的排序阵列的速度相同? gcc 优化标记 -O3 使代码慢于 -O2


当前回答

在对数据进行分类时,绩效大幅提高的原因是,如神秘论的回答所很好地解释的那样,分支预测罚款已经取消。

现在,如果我们看看代码

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

如果... 其它... 分支是指在满足条件时添加某种内容。 这种分支可以很容易地转换成有条件的移动说明, 并汇编成有条件的移动指示: cmovl, 在 x86 系统中。 分支和可能的分支预测处罚将被删除 。

因此,在C中,C++C中,将直接(不作任何优化)汇编成x86中有条件移动指令的语句是永久操作员.? :.。 因此,我们将上述语句改写为同等语句:

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

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

在英特尔核心i7-2600K @3.4 GHz和视觉工作室2010释放模式上,基准是:

x86x86

Scenario Time (seconds)
Branching - Random data 8.885
Branching - Sorted data 1.528
Branchless - Random data 3.716
Branchless - Sorted data 3.71

x64 x64

Scenario Time (seconds)
Branching - Random data 11.302
Branching - Sorted data 1.830
Branchless - Random data 2.736
Branchless - Sorted data 2.737

结果在多个测试中是稳健的。 当分支结果无法预测时, 我们得到一个巨大的加速, 但是当它可以预测时, 我们遭受了一点点痛苦。 事实上, 当使用有条件的动作时, 无论数据模式如何, 性能都是一样的 。

现在让我们通过调查它们生成的 x86 组装来更仔细地看一看。 为了简单起见, 我们使用两个函数 最大 1 和 最大 2 。

最大 1 使用有条件分支, 如果... 其他... :

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

最大值2 使用永久操作员... ... ?

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

在一台X86-64型机器上,海合会-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

最大值2 使用代码要少得多, 原因是使用指令 cmovge 。 但真正的收益是 最大值2 不涉及分支跳跃, jmp , 如果预测结果不对, 则会有很大的性能处罚 。

那么,为什么有条件的行动效果更好呢?

在典型的 x86 处理器中,执行指令分为几个阶段。 大致上, 我们有不同的硬件可以处理不同阶段。 因此, 我们不必等待一个指令才能开始一个新的指令。 这被称为管道 。

在一个分支中,下列的训导是由前面的训导决定的,所以我们不得管线。我们不是等待的,就是预告的。

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

《计算机系统:程序员的观点》一书第二版对此作了详细解释。您可以查看3.6.6节的有条件移动指示,整个第四章的处理结构,以及5.11.2节的预测和错误处罚处的特殊待遇。

有时,一些现代编译者可以以更好的性能优化我们的代码组装,有时有些编译者无法(有关代码是使用视觉工作室的本地编译者 ) 。 当无法预测的情况变得如此复杂,以至于编译者无法自动优化代码时,他们知道分支和有条件的动作之间的性能差异。

其他回答

以上行为之所以发生 是因为分局的预测

要了解分支预测,首先必须了解指示管道。

运行一个指令的步骤可以与运行上一个和下一个指令的步骤顺序重叠,这样可以同时同时执行不同的步骤。 这个技术被称为指令管线, 用来增加现代处理器的输送量。 要更好地了解这一点, 请在维基百科上看到这个例子 。

一般来说,现代处理器有相当长(和宽)的管道,因此许多指令都可以在飞行中。 见现代微处理器 A 90-Minute 指南!该指南首先引入基本的自序管线,然后从那里开始。

但为了方便起见,让我们考虑一个简单的单行输油管,只有这4个步骤。 (像典型的5级RISC一样,但省略了单独的 MEM 阶段。 )

IF -- -- 从内存 ID 获取指令 -- -- 解码指令 EX -- -- 执行指令 WB -- 写回到 CPU 注册簿

一般为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的结果(即从何处获取)。

没有预测:如果情况属实:

不预言:如果情况不实:

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

那么什么是分支预测?

分支预测器将尝试猜测分支( 如果- 如果- 如果- 如果- else 结构) 将往哪个方向走, 然后再确定这一点。 它不会等待指令 A 到达管道的 EX 阶段, 而是会猜测决定并转到该指令( 以我们为例 ) ( B 或 C ) 。

如果猜对了,输油管看起来是这样的:

如果后来发现猜测是错误的,那么部分执行的指示就会被丢弃,管道从正确的分支开始,造成延误。当分支错误时浪费的时间相当于从获取阶段到执行阶段的管道阶段的数量。现代微处理器往往有相当长的管道,因此错误预防的延迟时间在10到20小时的周期之间。管道越长,对良好的分支预测器的需求就越大。

在OP的代码中,当有条件的分支预测器第一次没有任何信息可以作为预测的基础,因此第一次它会随机选择下一个指令。 (或者返回静态预测,通常不前进,后退)。在循环中,它可以在历史的基础上进行预测。对于按升序排序的阵列,有三种可能性:

所有要件均大于128 有些开始的新要件小于128,稍晚则大于128

让我们假设预测器 将总是假设 真正的分支 在第一个运行。

因此,在第一种情况下,它总是要真正的分支,因为历史上它所有的预测都是正确的。 在第二种情况下,它最初预测错误,但经过几次反复,它会正确预测。 在第二种情况下,它最初将正确预测,直到元素低于128。 之后,它会失败一段时间,当它看到分支预测在历史上失败时,它会失败一段时间,它会正确。

在所有这些情况下,失败的数量将太少,因此,只需放弃部分执行的指示,从正确的分支重新开始,就只需要放弃部分执行的指示的几次,导致CPU周期减少。

但如果是随机的未排序数组,预测将需要丢弃部分执行的指示,然后大部分时间以正确的分支重新开始,结果与分类数组相比,CPU周期会增加。


进一步读作:

现代微处理器 A 90- Minute 指南! Dan Luuu 的关于分支预测的文章( 包括较老的分支预测器, 不是现代IT- TAGE 或 Perceptron) https:// en. wikipedia.org/ wiki/ Branch_ predictor 分支预测和解释器的性能 https:// en. wikipedia. org/ wiki/ Branch_ predictor 分支预测器 - 不要信任 Followlore - 2015 显示 Intel's Haswell 在预测 Python 翻译主循环的间接分支( 由不简单模式造成历史问题) , 与没有使用 IT- TAGE 的早期 CPUs 相比, 早期的CPUs presenterv( 类似循环) 没有帮助完全使用这个完全随机的 。 当源代码时, 最不可能的C- train lishing lishal listal lives liver 已经使用了, liver 。

分部门预测。

使用分类数组, 条件数据 [c] 128 首先对于一系列值来说是虚假的, 然后对所有后期值都变成真实的。 这很容易预测。 使用未排序数组, 您支付分支成本 。

在同一行中(我认为没有任何答案强调这一点),最好提到有时(特别是在软件中,在软件中,性能很重要——如Linux内核),如果声明如下,你可以找到一些:

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

或类似:

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

可能性 () 和 可能性 () 实际上都是宏, 其定义是使用海合会的 ` 内建_ 期望 ' 来帮助编译者插入预测代码, 以考虑到用户提供的信息, 从而有利于该条件。 海合会支持其他能够改变运行程序的行为或发布低级别指令, 如清除缓存等 。 请参见此文档, 内容可以通过海合会的现有内建 。

通常这种优化主要在硬实时应用程序或内嵌系统中找到,在这些系统中,执行时间很重要且至关重要。例如,如果您正在检查某些错误条件,而错误条件只发生1/10000 000次,那么为什么不通知编译者?这样,默认情况下,分支预测会假设该条件是假的。

我刚读过这个问题及其答案,我觉得缺少答案。

消除我发现在管理下语言中特别出色的分支预测的一个常见方法是, 表格搜索而不是使用分支(虽然我还没有在本案中测试过它 ) 。

如果:

它是一个小桌子, 很可能被隐藏在处理器中, 而你运行的东西在一个非常紧凑的循环中, 和/或处理器可以预加载数据。

背景和原因

从处理器的角度来看,您的内存是慢的。为了弥补速度的差异,在您的处理器( L1/L2 缓存) 中嵌入了几个缓存。 想象一下, 您正在做你的好计算, 并发现您需要一个内存。 处理器会得到它的“ 装载” 操作, 并将内存部分装入缓存中, 然后用缓存来进行其余的计算。 因为内存相对缓慢, 此“ 装载” 将会减缓您的程序 。

像分支预测一样,这在Pentium处理器中得到了优化:处理器预测,它需要在操作实际击中缓存之前装入一个数据,并试图将数据装入缓存中。我们已经看到,分支预测有时会发生可怕的错误 -- -- 在最坏的情况下,你需要回去等待一个记忆负荷,这将需要永远的时间(换句话说:不完成分支预测是坏的,在分支预测失败之后的记忆负荷实在太可怕了!)

幸运的是,对于我们来说,如果记忆存取模式可以预测,处理器将装在快速缓存中,一切都很好。

我们首先需要知道的是小什么是小什么?虽然小一般比较好,但大拇指规则是坚持使用大小为 4096 字节的搜索表格。作为一个上限:如果您的查看表格大于 64K, 可能值得重新考虑 。

构建表格

因此我们发现我们可以创建一个小表格。 接下来要做的是设置一个查找功能。 查找功能通常是使用几个基本整数操作( 以及, 或者, xor, 转换, 转换, 添加, 删除, 或倍增) 的小型函数。 您想要将您的输入通过外观功能转换为表格中某种“ 独一无二的密钥 ” , 这样就可以简单给出您想要它做的所有工作的答案 。

在此情况下 : 128 表示我们可以保留这个值, < 128 表示我们摆脱它。 最简单的方法就是使用“ 和 ” : 如果我们保留它, 我们和它使用 7FFFFFFF; 如果我们想要摆脱它, 我们和它使用 0。 注意 128 也是一种2 的功率, 所以我们可以继续制作一个32768/128 整数的表格, 并填满它 1 0 和很多 7FFFFFFFFFFFF。

受管理语言

毕竟,管理下的语言会用分支来检查阵列的界限,以确保你不会搞砸...

嗯,不确切地说... : -)

在取消管理下语文的这一分支方面,已经做了相当多的工作。

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

在此情况下, 编译者明显知道边界条件永远不会被击中 。 至少微软 JIT 编译者( 但我预计爪哇会做类似的事情) 将会注意到这一点并完全取消检查 。 WOW 表示没有分支 。 同样, 它也会处理其他明显的例子 。

如果您遇到管理下语言的查询问题 -- -- 关键是将 & 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();

你是树枝预测失败的受害者


分会的预测是什么?

考虑铁路交叉点:

依据CC-By-SA 3.

现在,为了争论起见,假设这是在1800年代, 在长途或无线电通信之前。

您是连接点的盲人接线员, 听到火车来电的声音。 您不知道该走哪条路。 您停止了火车, 询问司机他们想要的方向 。 然后您将开关设置得当 。

火车很重,而且有很多惰性, 所以它们需要永远的启动 并放慢速度。

有更好的办法吗?

如果你猜对了,它会继续。如果你猜错,船长会停下来,后退,喊你按开关。然后它就可以从另一条路重新开始。

如果你每次猜对一次,火车就永远不会停止。如果你猜错太频繁,火车就会花很多时间停下来、备份和重新开始。


考虑是否说明:在加工者一级,它是分支指令:

你是一个处理者,你看见一个分支。你不知道它会走哪条路。你做什么?你停止执行,等待以前的指令完成。然后,你继续走正确的道路。

现代处理器复杂,管道长。 这意味着它们永远需要“暖和”和“慢下来 ” 。

有更好的办法吗?

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

如果你每次都猜对了,处决永远不会停止。如果你猜错太频繁,你就会花很多时间拖延、倒退和重新开始。


这是分支预测。 我承认这不是最好的比喻, 因为火车只能用旗帜发出方向信号。 但在电脑上, 处理器不知道分支会朝哪个方向前进, 直到最后一刻。

您在战略上如何猜测如何将列车必须返回并沿着另一条路行驶的次数最小化 ? 您看看过去的历史 。 如果列车离开99%的时间, 那么您会猜到离开 。 如果列车转行, 那么您会换个猜想 。 如果列车每走三次, 您也会猜到同样的情况 。

换句话说,你尝试确定一个模式并遵循它。这或多或少是分支预测器的工作方式。

大多数应用程序都有良好的分支。 因此,现代分支预测器通常会达到超过90%的冲击率。 但是,当面对无法预见且没有可识别模式的分支时,分支预测器几乎毫无用处。

继续读到维基百科上的“Branch 预测家”文章。


正如上面所暗示的,罪魁祸首就是这个说法:

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

这将清除分支, 并替换为一些位元操作 。

(注意这个黑客并不完全等同原始的假称。 但在此情况下, 它对于数据的所有输入值都是有效的 。 )

基准:核心i7 920@3.5千兆赫

C++ - 2010 - x64 释放

Scenario Time (seconds)
Branching - Random data 11.777
Branching - Sorted data 2.352
Branchless - Random data 2.564
Branchless - Sorted data 2.587

Java - Netbeans 7.1.1 JDK 7 - x64

Scenario Time (seconds)
Branching - Random data 10.93293813
Branching - Sorted data 5.643797077
Branchless - Random data 3.113581453
Branchless - Sorted data 3.186068823

意见:

分支 : 分类的数据和未分类的数据之间有很大的差别。 在 Hack 中: 分类的数据和未分类的数据之间没有差别。 在 C++ 中, 黑客实际上比数据分类时的分支要慢一点 。

拇指的一般规则是避免在关键循环(如本例)中出现依赖数据的分支。


更新 :

GCC 4. 6.1 在 x64 上使用 -O3 或 -free-victorization 能够生成一个有条件的移动, 因此分解和未分解的数据之间没有差别, 两者都是快速的。 (或者说快速的 : 对于已经分解的个案, cmov 可以慢一些, 特别是如果 GCC 将其置于关键路径上而不是仅仅添加, 尤其是在 Broadwell 之前的Intel , 那里 cmov 有2个周期的悬浮 : gcc 优化旗 - O3 使代码慢于 - O2 ) VC+/ 2010 即使在 / Ox 下也无法为这个分支生成有条件的动作 。 Intel C++ Commonder (ICC) 11 也无法生成奇迹性的东西 。 它将两个环切换, 从而将不可预测的分支拉动到外部环 。 不仅能避免错误, , 而且它也比 VC++ 和 GC 生成的任意 还要快一倍 。 。 。 。 换 。

这表明即使是成熟的现代编译者 在优化代码的能力上 也会大不相同...