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


当前回答

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

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

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

一般来说,现代处理器有相当长(和宽)的管道,因此许多指令都可以在飞行中。 见现代微处理器 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 。

其他回答

是关于分支预测的 是什么?

分支预测器是一种古老的改进性能的技术,在现代建筑中仍然具有相关性。 虽然简单的预测技术提供了快速的外观和电力效率,但它们受到高误用率的影响。 另一方面,复杂的分支预测 — — 无论是基于神经的预测还是两级分支预测的变体 — — 提供了更好的预测准确性,但是它们消耗的动力和复杂性也成倍增加。 此外,在复杂的预测技术中,预测分支所需的时间也非常长 — — 从2到5个周期不等 — — 这与实际分支的执行时间相当。 分支预测基本上是一个优化(最小化 ) 问题, 重点是实现尽可能最低的误用率、低电耗和最低资源低的复杂度。

确实有三种不同的分支:

附加条件的前进分支 - 根据运行时间条件, PC (程序反) 被修改为指示流中前方的地址 。

后向有条件的分支 - PC 被修改为指令流中的后向点。 分支基于某些条件, 例如当循环结尾的测试显示循环应该再次执行时, 将程序循环的起始处向后转至程序循环的起始处 。

无条件分支 - 包括跳跃、 程序电话和返回等没有特定条件的无条件跳跃指令。 例如, 无条件跳跃指令可能会以组合语言编码为简单的“ jmp ” , 且指令流必须立即被指向跳跃指令指向的目标位置, 而有条件跳跃可能以“ jmpne ” 编码为“ jmpne ” , 只有在对先前“ 比较” 指令中两个数值进行比较的结果显示数值不相等时, 才会改变教学流的方向 。 ( x86 结构使用的分段处理方案增加了额外的复杂性, 因为跳跃可以是“ 接近” (在一段内) , 也可以是“ far”(在段外) 。 每种类型对分支预测算法都有不同的效果 。

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

参考文献:

预测处预测处(Ussing回背机器)

避免分支预测错误的一种方法是建立一个搜索表,并用数据来编制索引。 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];

此代码浪费了一半的添加值, 但从未出现分支预测失败 。 随机数据比有实际的如果声明的版本要快得多 。

但在我的测试中,一个清晰的查看表比这个稍快一些, 可能是因为对查看表的索引比位移略快一点。 这显示了我的代码是如何设置和使用搜索表的( 在代码中“ 查看表” 中, 不可想象地称之为润滑 ) 。 以下是 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 个值的表格, 这可能是实用的 。

将技术指数化为数组,而不是使用“如果”的语句,可以用来决定使用哪个指针。 我看到了一个图书馆,它安装了二进制树,而不是有两个名为指针(Pleft and pRight or whatever)的指针有长至2的指针阵列,并且使用“决定位”技术来决定要遵循哪个指针。例如,没有:

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

这个图书馆会做一些事情,比如:

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

这是这个代码的链接: 红黑树,永远封存

由于一种被称为分支预测的现象,分类的阵列的处理速度要快于未排序的阵列。

分支预测器是一个数字电路(在计算机结构中),它试图预测一个分支会走哪条路,从而改善教学管道的流量。电路/计算机预测下一步并进行执行。

错误的预测导致回到前一步,执行另一个预测。 假设预测是正确的,代码将持续到下一步骤。 错误的预测导致重复同一步骤,直到出现正确的预测。

你问题的答案很简单

在未排列的阵列中,计算机进行多次预测,导致误差的可能性增加。而在分类的阵列中,计算机的预测减少,误差的可能性减少。 做更多的预测需要更多的时间。

排序的数组: 直路

____________________________________________________________________________________
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT

未排列的队列: 曲线路

______   ________
|     |__|

部门预测: 猜测/预测哪条道路是直的,未检查就沿着这条道路走

___________________________________________ Straight road
 |_________________________________________|Longer road

虽然两条道路都到达同一目的地,但直路更短,另一条更长。如果你错误地选择另一条道路,就没有回头路,所以如果你选择更长的路,你就会浪费一些更多的时间。这与计算机中发生的事情相似,我希望这能帮助你更好地了解。


@Simon_Weaver在评论中也提到:

它不会减少预测数量 — — 它会减少不正确的预测。 它仍然必须通过循环预测每一次...

在分类的情况下,你可以做的比依靠成功的分支预测或任何无分支比较的把戏:完全删除分支。

事实上,阵列被分割在一个毗连区,数据小于128,另一个数据小于128。 因此,你应该用二组搜索(使用 Lg(数组)=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];

一种既快又快的方法,为分类或未分类两种方法提供了大致的解决办法,即:总和=3137536;(假设真正统一分布,预计价值为191.5的16384个样品:-)

快速和简单理解的答案(阅读其他细节)

这个概念叫做分支预测

分支预测是一种优化技术,它预言代码在被确知之前将走的道路。 这一点很重要,因为在代码执行过程中,机器预设了几条代码声明并将其储存在管道中。

问题出在有条件的分支中,有两种可能的路径或代码部分可以执行。

当预测是真实的, 优化技术 完成。

当预测是虚假的,用简单的方式解释, 管道中储存的代码声明被证明是错误的, 而实际的代码必须全部重新加载, 这需要很多时间。

正如常识所显示的,对某类物品的预测比对某类未分类物品的预测更准确。

分支预测可视化:

未排序