这是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


当前回答

正如其他人已经提到的那样,神秘背后的是部门预测员。

我不是要补充一些东西,而是要用另一种方式解释这个概念。维基文字有一个简明的介绍,里面有文字和图表。我确实喜欢下面的解释,下面用一个图表来用直觉来描述处的预言。

在计算机结构中,分支预测器是一种数字电路,它试图猜测分支(如如果是当时的else结构)将以何种方式进行,然后才能确定这一点。分支预测器的目的是改善教学管道的流量。分支预测器在很多现代管道式微处理器结构(如x86)中实现高有效性能方面发挥着关键作用。双向分支通常是通过有条件的跳跃指令来实施。有条件跳跃可以是“不采取”的,也可以是有条件跳跃后立即实施的代码的第一个分支,或者可以是“获取”的,然后跳到存储第二分支的程序内存中的不同位置。在计算条件和有条件跳动通过指令管道的执行阶段之前,无法确定是否进行有条件跳动(见图1)。

根据所述情况,我写了动画演示,以显示在不同情况下如何在管道中执行指示。

没有部门预言家。

没有分支预测,处理器必须等到有条件跳跃指令通过执行阶段后,下一个指令才能进入管道的接货阶段。

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

完成3项指示需要9小时周期。

使用预测器,不要采取有条件的跳跃。让我们假设预测不会采取有条件的跳跃。

完成3项指示需要7小时周期。

我们假设预测不会采取有条件的跳跃

完成3项指示需要9小时周期。

在分支误用的情况下,浪费的时间相当于从取货阶段到执行阶段的输油管阶段的数量。 现代微处理器往往有相当长的输油管,因此误用延迟时间在10到20小时之间。 结果,输油管更长时间增加了对更先进的分支预测器的需求。

如你所见,我们似乎没有理由不使用 部门预言家。

这是一个很简单的演示, 澄清了分支预测器的基本部分。 如果这些 gifs 令人烦恼, 请随意将其从答案中删除, 访问者也可以从 PredictorDemo 获得现场演示源代码 。

其他回答

官方的回答是来自

英特尔 -- -- 避免误用英特尔分公司的成本 -- -- 分公司和循环重组以防止误用科学论文 -- -- 分公司预测计算机建筑书籍:J.L. Hennessy, D.A. Patterson:计算机结构:定量方法 科学出版物中的文章:T.Y. Yeh, Y.N. Patt在分支预测方面做了许多这些。

你也可以从这张可爱的图表中看到 树枝预测器为什么会被混淆。

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

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

所以预测器会随着 : rand () 的打击而改变两边。

另一方面,一旦对预测进行分类, 预测器将首先进入一个 强烈未被采纳的状态, 当值变化到高值时, 预测器将分三步走, 从强烈未被采纳到强烈被采纳。


这是肯定的!

部门预测使得逻辑运行速度放慢, 因为代码中的转换会发生! 就像你走一条直街或一条街, 转得很多,

If the array is sorted, your condition is false at the first step: data[c] >= 128, then becomes a true value for the whole way to the end of the street. That's how you get to the end of the logic faster. On the other hand, using an unsorted array, you need a lot of turning and processing which make your code run slower for sure...

看看我在下面为你们创造的图象,哪条街会更快完工?

因此,在程序上,分支预测导致过程的慢化...

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

1. 静态

2. 动态

微处理器在第一次遇到有条件分支时使用静态分支预测,在随后执行有条件分支代码时则使用动态分支预测。为了有效编写代码以利用这些规则,在撰写 if-else 或 开关 语句时,先检查最常见的情况,然后逐步工作到最不常见的情况。循环不一定要求固定分支预测使用任何特殊的代码顺序,因为通常只使用循环迭代器的条件。

这个问题根植于CPUs的分支预测模型。

通过多分支预测和分支处理缓存来提高教学取回率(但现在的实际 CPU 仍然不能在每时钟周期中做出多个支流控制,但Haswell 和后来在循环缓冲中有效释放的小循环除外。 现代 CPU 可以预测多个未取用的分支, 以利用大毗连区块中的提取。 )

当您对元素进行分类时,分支预测很容易预测正确,除非在边界正确,允许指示有效通过CPU管道,而不必倒转和正确选择错误预测路径。

这个问题已经回答过很多次了。我还是想提醒大家注意另一个有趣的分析。

最近,这个例子(稍作修改)也被用来演示如何在 Windows 上显示一个代码在程序本身中被剖析。 顺便提一下, 作者还展示了如何使用结果来确定代码的大部分时间用于分解和未排序的案例中。 最后, 文章还展示了如何使用HAL( Hardware Empaction Develople) 的一个鲜为人知的特征来确定未分类案例中的分支错误发生多少。

连结就在这里:自我辩护示范

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


分会的预测是什么?

考虑铁路交叉点:

依据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 生成的任意 还要快一倍 。 。 。 。 换 。

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