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

出于某种原因,对数据进行分类(之前奇迹般地使主环速度快近六倍:

#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秒内运行

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


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

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

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


我第一种想法是 分类能把数据带进缓存缓存,但那是愚蠢的 因为阵列是刚刚产生的。

  • 这是怎么回事?
  • 为什么处理一个分类阵列的速度要快于处理一个未排序阵列的速度?

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


相关/后续行动不同/以后的编译者和选项的相同效果:


当前回答

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

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

链接在此 :自我辩护示范

其他回答

分部门预测。

以排序数组数组, 条件data[c] >= 128第一个是false一连串的数值,然后变成true后期所有值。 这很容易预测。 使用一个未排序的阵列, 您支付分支成本 。

你是受害者子分支预测失败 。


分会的预测是什么?

考虑铁路交叉点:

Image showing a railroad junction 图像图像图像图像依据创用CC BY-ND 2.CC-By-SA 3.0 CC-By-SA 3.0许可证。

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

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

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

有更好的办法吗?

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

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


考虑如果报表:在加工一级,它是一个分支指令:

Screenshot of compiled code containing an if statement

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

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

有更好的办法吗?

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

如果你每次猜对死刑将永远不会停止
如果你猜错太频繁,你花了很多时间拖延, 后退,重新开始。


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

您在战略上如何猜测如何将列车必须返回并沿着另一条路行驶的次数最小化 ? 您看看过去的历史 。 如果列车离开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];

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

(注意这个黑客并不完全等同原始的如果声明。 但在这种情况下,它对于所有输入值都有效。data[].)

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

C++ - 2010 - x64 释放

假设情景 时间( 秒)
分处 - 随机数据 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 和-O3-ftree-vectorize在 x64 上能够生成一个有条件的移动, 所以分类的数据和未分类的数据之间没有区别, 两者都是快速的 。

    (或稍快:对于已经分类的案件,cmov特别是如果海合会将海合会置于关键道路上,而不是公正add特别是英特尔 之前的英特尔 Broadwellcmov有2个周期的延迟:gcc 优化标记 -O3 使代码慢于 -O2)

  • VC++/2010 即使在/Ox.

  • Intel C+++ 编译器(ICC) 11 做了奇迹般的事情。交换两个循环从而将无法预测的分支拉到外环。 它不仅能避免错误, 而且速度是 VC++ 和 GCC 所能生成的两倍。 换句话说, ICC 利用试流击败基准...

  • 如果您给 Intel 编译者无分支代码, 它会直接向导它... 并且和分支( 循环交换) 一样快 。

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

这是肯定的!

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

如果对数组进行了排序,您的状态在第一步是虚假的:data[c] >= 128,然后成为通向街道尽头的整个路程的真正价值。这就是你如何更快地达到逻辑的终点。另一方面,使用一个未分类的阵列,你需要大量转动和处理,这可以保证你的代码运行速度较慢...

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

Branch Prediction

因此,从方案上说,子分支预测导致进程变慢...

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

1. 静态

2. 动态

Branch Prediction

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

为了有效地编写你的代码,以便利用这些规则,在撰写时if-ele 单位开关循环不一定需要固定分支预测的任何特殊代码顺序,因为通常只使用循环迭代器的条件。

在ARARAR上,不需要分支,因为每项教学都有一个4位条件字段,该字段测试(零成本)任何(零成本)16种不同条件处理器状态登记簿中可能会出现这种情况, 如果指令的条件是假的, 则跳过指令 。 这样就不再需要短的分支, 并且不会为此算法进行分支预测 。因此,这种算法的分类版本将比ARM上未分类版本的运行慢,因为分类的间接费用增加。

这个算法的内环在ARM组装语言中 看起来像是:

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

但这其实是大局的一部分:

CMP处理器状态登记册(PSR)中的状态位数总是更新,因为这是它们的目的,但大多数其他指令都不触动 PSR,除非添加一个选项S指示的后缀,规定应根据指示的结果更新PSR。就像4位条件的后缀一样,能够执行指示而不影响PSR,这个机制减少了对ARM分支的需求,也便利了硬件一级的不按订单发送,因为执行一些操作X更新状态位数后,随后(或平行)你可以做一系列其他工作,这些工作显然不应影响(或受到)状态位数的影响,然后可以测试X早先设定的状态位数状态状态。

条件测试字段和可选的“ 设定状态位” 字段可以合并, 例如 :

  • ADD R1, R2, R3表演 表演R1 = R2 + R3不更新任何状态位元 。
  • ADDGE R1, R2, R3仅在影响状态位数的先前指令导致大于或等于条件时,才执行相同的操作。
  • ADDS R1, R2, R3执行添加,然后更新N, Z, CV根据结果是否为负、零、载(未签字添加)或oVerflowed(已签署添加),在处理者地位登记册中的标记。
  • ADDSGE R1, R2, R3仅在以下情况下执行添加:GE测试是真实的, 然后根据添加结果更新状态比特 。

大多数处理器结构没有这种能力来说明是否应该为特定操作更新状态位元,这可能需要写入额外的代码来保存和随后恢复状态位元,或者可能需要额外的分支,或者可能限制处理器的运行效率:大多数 CPU 指令设置的架构的副作用之一是,在大多数指令之后强制更新状态位元,是很难分离哪些指令可以平行运行而不相互干扰的。更新状态位元具有副作用,因此对代码具有线性效果。ARM在任何指令上混合和匹配无分支条件测试的能力,在任何指令非常强大后,可以更新或不更新状态位数,对集会语言程序员和编译员来说,都极为强大,并制作非常高效的代码。

当您不需要分行时, 您可以避免冲刷管道的时间成本, 否则就是短的分支, 您也可以避免许多投机性蒸发形式的设计复杂性。 缓解最近发现的很多处理器弱点( 特例等)的最初天真效果影响 表明现代处理器的性能在多大程度上取决于复杂的投机性评估逻辑。 由于输油管很短,对分支的需求也大大减少, ARM不需要像 CISC 处理器那样依赖投机性评估。 ( 当然, 高端的ARM 实施过程包括投机性评估, 但是它只是绩效故事中的一小部分 ) 。

如果你曾经想过为什么ARM如此成功,那么这两种机制(加上另一个允许你“轮回”左转或右转的机制,任何算术操作员的两个论点之一或以零额外费用抵消内存存存取操作员的两种论点之一)的辉煌效力和互动作用是故事的一大部分,因为它们是ARM结构效率的最大来源。 1983年ARM ISA原设计师Steve Furber和Roger(现为Sophie)Wilson的聪明才智无论怎样强调都不为过。

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

事实上,阵阵列被分割在一个毗连区内,data < 128data >= 128。因此,您应该用 a 来找到分区点脑细胞细胞研究(使用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;(假设分布真正统一,预计价值为191.5的16384个样本):-)