问题描述:
这是一段 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(data, data + arraySize);,代码运行时间为 11.54 秒。
使用排序后的数据,代码运行时间为 1.93 秒。
(排序本身比遍历数组需要更多时间,因此如果我们需要为未知数组计算它,实际上不值得这样做。)
最初,我认为这可能只是语言或编译器异常,所以我尝试了 Java:
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);
}
}
有类似但不太极端的结果。
我的第一个想法是排序将数据带入 cache,但后来我认为这是多么愚蠢,因为数组刚刚生成。
到底是怎么回事?
为什么处理排序数组比处理未排序数组更快?
该代码总结了一些独立的术语,因此顺序无关紧要。
关于不同/更高版本的编译器和选项的相同效果的相关/后续问答:
为什么处理未排序数组的速度与使用现代 x86-64 clang 处理已排序数组的速度相同?
gcc 优化标志 -O3 使代码比 -O2 慢
解决方案1:
一个优秀的自由职业者,应该有对需求敏感和精准需求捕获的能力,而huntsbot.com提供了这个机会
您是 branch prediction 失败的受害者。
什么是分支预测?
考虑一个铁路枢纽:
https://i.stack.imgur.com/muxnt.jpg
现在为了争论,假设这是在 1800 年代——在远距离或无线电通信之前。
你是一个路口的操作员,你听到火车来了。你不知道它应该走哪条路。你停下火车问司机他们想要哪个方向。然后你适当地设置开关。
火车很重并且有很大的惯性,所以它们需要很长时间才能启动和减速。
有没有更好的办法?你猜火车会开往哪个方向!
如果你猜对了,它会继续。
如果你猜错了,船长会停下来,后退,然后对你大喊大叫来拨动开关。然后它可以重新启动另一条路径。
如果你每次都猜对了,火车就永远不用停下来。如果您经常猜错,火车将花费大量时间停止、倒车和重新启动。
考虑一个 if 语句:在处理器级别,它是一个分支指令:
https://i.stack.imgur.com/pyfwC.png
你是一个处理器,你看到一个分支。你不知道它会朝哪个方向发展。你做什么工作?您停止执行并等待前面的指令完成。然后你继续沿着正确的路径前进。
现代处理器很复杂并且有很长的流水线。这意味着他们需要永远“热身”和“减速”。
有没有更好的办法?你猜这个分支会往哪个方向走!
如果你猜对了,你继续执行。
如果你猜错了,你需要刷新管道并回滚到分支。然后,您可以重新启动另一条路径。
如果你每次都猜对了,执行将永远不会停止。如果您经常猜错,您会花费大量时间停止、回滚和重新启动。
这是分支预测。我承认这不是最好的类比,因为火车只能用旗帜指示方向。但是在计算机中,处理器直到最后一刻才知道分支将走向哪个方向。
您将如何策略性地猜测以最小化火车必须倒车并沿另一条路径行驶的次数?你看看过去的历史!如果火车 99% 的时间都是左转,那么你猜是左转。如果它交替,那么你交替你的猜测。如果它每 3 次去一个方向,你猜同样…
换句话说,您尝试识别一种模式并遵循它。这或多或少是分支预测器的工作方式。
大多数应用程序都有良好的分支。因此,现代分支预测器通常会达到 >90% 的命中率。但是当面对无法识别的模式的不可预测的分支时,分支预测器实际上是无用的。
进一步阅读:“Branch predictor” article on Wikipedia。
正如上面所暗示的,罪魁祸首是这个 if 语句:
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];
这消除了分支并用一些按位操作替换它。
(请注意,这个 hack 并不严格等同于原始 if 语句。但在这种情况下,它对 data[] 的所有输入值都有效。)
基准测试:Core i7 920 @ 3.5 GHz
C++ - Visual Studio 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++ 的情况下,当数据被排序时,hack 实际上比使用分支慢一点。
一般的经验法则是避免关键循环中的数据相关分支(例如在此示例中)。
更新:
在 x64 上带有 -O3 或 -ftree-vectorize 的 GCC 4.6.1 能够生成条件移动,因此已排序和未排序的数据之间没有区别 - 两者都很快。 (或者有点快:对于已经排序的情况,cmov 可能会更慢,特别是如果 GCC 将它放在关键路径上而不是仅仅添加,尤其是在 Broadwell 之前的 Intel 上,其中 cmov 有 2 个周期延迟:gcc 优化标志 -O3 使代码变慢比 -O2)
即使在 /Ox 下,VC++ 2010 也无法为此分支生成条件移动。
英特尔 C++ 编译器 (ICC) 11 做了一些神奇的事情。它交换两个循环,从而将不可预测的分支提升到外部循环。它不仅不受错误预测的影响,而且速度也是 VC++ 和 GCC 生成的速度的两倍!换句话说,ICC 利用测试循环击败了基准测试…
如果您为英特尔编译器提供无分支代码,它会直接对其进行矢量化…并且与分支(使用循环交换)一样快。
这表明即使是成熟的现代编译器在优化代码的能力上也会有很大的不同…
等一下,是否将负值转移到正确的产量实现定义的值? int t = (data[c] - 128) >> 31;总和 += ~t & 数据[c];
顺便说一句,分支预测失败也可能是 exploited by a program to obtain crypto keys being used by another program 在同一个 CPU 内核上。
@Mycotina,我不是专家,但我的理解是:处理器需要多个步骤来执行一条指令(获取、解码等)——这被称为“指令流水线”——所以,作为一种优化,它将一次获取多条指令并在执行当前指令时“预热”下一条指令。如果选择了错误的分支,则必须丢弃流水线中正在“预热”的指令,以便可以将正确分支上的指令放入流水线中。
@Mycotina 当您将指令管道缓存视为轨道,将火车(有汽车)视为指令以及在火车末端由某个家伙向左还是向右的指示器时,会更容易理解;不是开始。当你看到他就知道你猜对了,不仅换东西为时已晚,前面的管道已经填满了,而且方向错了。如果您猜错了,则需要扔掉预测的管道(使火车脱轨;将其拖回开关房前,将其放回轨道上,然后将其发送到另一个方向)。
@C.Binair 主要是运行时,即处理器在执行代码时预测分支。处理器还记得以前的结果,并用它来预测下一次跳跃。但是,编译器可以在编译时为分支预测提供一些初始提示——搜索“可能”和“不太可能”的属性。所以你可以说答案是两者兼而有之,但运行时是它实际发生的时间。
解决方案2:
huntsbot.com汇聚了国内外优秀的初创产品创意,可按收入、分类等筛选,希望这些产品与实践经验能给您带来灵感。
分支预测。
对于排序数组,条件 data[c] >= 128 首先是 false 用于一系列值,然后变为 true 用于所有后面的值。这很容易预测。使用未排序的数组,您需要支付分支成本。
分支预测在排序数组与具有不同模式的数组上效果更好吗?例如,对于数组 --> { 10, 5, 20, 10, 40, 20, ... } 模式中数组中的下一个元素是 80。这种数组是否会通过分支预测加速如果遵循模式,下一个元素是 80?或者它通常只对排序数组有帮助?
所以基本上我从传统上学到的关于 big-O 的一切都在窗外?分拣成本比分支成本更好吗?
@AgrimPathak 这取决于。对于不太大的输入,当具有较高复杂度的算法的常数较小时,具有较高复杂度的算法比具有较低复杂度的算法更快。盈亏平衡点在哪里很难预测。此外,compare this,位置很重要。 Big-O 很重要,但它不是性能的唯一标准。
何时进行分支预测?语言何时会知道数组已排序?我正在考虑看起来像这样的数组情况: [1,2,3,4,5,...998,999,1000, 3, 10001, 10002] ?这个不起眼的3会增加运行时间吗?它会和未排序的数组一样长吗?
@FilipBartuzi 分支预测发生在处理器中,低于语言级别(但语言可能会提供告诉编译器可能发生什么的方法,因此编译器可以发出适合该情况的代码)。在您的示例中,无序的 3 将导致分支错误预测(对于适当的条件,其中 3 给出的结果与 1000 不同),因此处理该数组可能需要比排序后的数组几乎不会引起注意。花费时间的是我的错误预测率很高,每 1000 个错误预测并不多。
解决方案3:
与HuntsBot一起,探索全球自由职业机会–huntsbot.com
数据排序后性能显着提高的原因是分支预测惩罚被移除,如 Mysticial’s answer 中所述。
现在,如果我们看一下代码
if (data[c] >= 128)
sum += data[c];
我们可以发现这个特定的if… else…分支的含义是在满足条件时添加一些东西。这种类型的分支可以很容易地转换为 条件移动 语句,该语句将在 x86 系统中编译为条件移动指令:cmovl。分支和潜在的分支预测惩罚被移除。
在 C 中,因此在 C++ 中,将直接编译(不进行任何优化)成 x86 中的条件移动指令的语句是三元运算符 … ? … : …。所以我们把上面的语句改写成等价的:
sum += data[c] >=128 ? data[c] : 0;
在保持可读性的同时,我们可以检查加速因子。
在 Intel Core i7-2600K @ 3.4 GHz 和 Visual Studio 2010 发布模式下,基准测试为:
x86
场景时间(秒) 分支 - 随机数据 8.885 分支 - 排序数据 1.528 无分支 - 随机数据 3.716 无分支 - 排序数据 3.71
x64
场景时间(秒) 分支 - 随机数据 11.302 分支 - 排序数据 1.830 无分支 - 随机数据 2.736 无分支 - 排序数据 2.737
结果在多次测试中是稳健的。当分支结果不可预测时,我们得到了很大的加速,但当它是可预测的时,我们会受到一点影响。事实上,当使用条件移动时,无论数据模式如何,性能都是相同的。
现在让我们通过调查它们生成的 x86 程序集来更仔细地观察。为简单起见,我们使用两个函数 max1 和 max2。
max1 使用条件分支 if… else …:
int max1(int a, int b) {
if (a > b)
return a;
else
return b;
}
max2 使用三元运算符 … ? … : …:
int max2(int a, int b) {
return a > b ? a : b;
}
在 x86-64 机器上,GCC -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
由于使用了指令 cmovge,max2 使用的代码要少得多。但真正的收获是 max2 不涉及分支跳转,jmp 如果预测结果不正确,这将产生显着的性能损失。
那么为什么有条件的移动表现更好呢?
在典型的 x86 处理器中,一条指令的执行分为几个阶段。粗略地说,我们有不同的硬件来处理不同的阶段。所以我们不必等待一条指令完成来开始新的指令。这称为 pipelining。
在分支情况下,后面的指令是由前面的指令决定的,所以我们不能进行流水线操作。我们必须等待或预测。
在条件移动情况下,条件移动指令的执行分为几个阶段,但较早的阶段如Fetch和Decode不依赖于前一条指令的结果;只有后期需要结果。因此,我们等待一条指令执行时间的一小部分。这就是为什么当预测很容易时条件移动版本比分支慢的原因。
Computer Systems: A Programmer’s Perspective, second edition 一书详细解释了这一点。您可以查看第 3.6.6 节的条件移动指令,查看整个第 4 章的处理器架构,查看第 5.11.2 节的分支预测和错误预测惩罚的特殊处理。
有时,一些现代编译器可以将我们的代码优化为具有更好性能的汇编,而有时一些编译器则不能(有问题的代码使用 Visual Studio 的本机编译器)。当场景变得如此复杂以至于编译器无法自动优化它们时,了解分支和条件移动之间的性能差异可以帮助我们编写性能更好的代码。
stackoverflow.com/questions/9745389/…
您忘记启用优化;对 store/reload everything to the stack 的调试版本进行基准测试没有用处。如果您想要希望使用 cmov 的高效标量汇编,请使用 gcc -O2 -fno-tree-vectorize -S。 (-O3 可能会自动矢量化,使用 GCC12 或更高版本的 -O2 也会如此。)另请参阅 gcc optimization flag -O3 makes code slower than -O2(对于已排序的情况,当它对 if 使用 cmov 时效果不佳)。
解决方案4:
HuntsBot周刊–不定时分享成功产品案例,学习他们如何成功建立自己的副业–huntsbot.com
如果您对可以对此代码进行的更多优化感到好奇,请考虑以下几点:
从原始循环开始:
for (unsigned i = 0; i < 100000; ++i)
{
for (unsigned j = 0; j < arraySize; ++j)
{
if (data[j] >= 128)
sum += data[j];
}
}
通过循环交换,我们可以安全地将这个循环更改为:
for (unsigned j = 0; j < arraySize; ++j)
{
for (unsigned i = 0; i < 100000; ++i)
{
if (data[j] >= 128)
sum += data[j];
}
}
然后,您可以看到 if 条件在整个 i 循环的执行过程中是不变的,因此您可以将 if 提升出来:
for (unsigned j = 0; j < arraySize; ++j)
{
if (data[j] >= 128)
{
for (unsigned i = 0; i < 100000; ++i)
{
sum += data[j];
}
}
}
然后,您会看到内部循环可以折叠成一个表达式,假设浮点模型允许它(例如,抛出 /fp:fast)
for (unsigned j = 0; j < arraySize; ++j)
{
if (data[j] >= 128)
{
sum += data[j] * 100000;
}
}
那个速度比以前快了 100,000 倍。
解决方案5:
HuntsBot周刊–不定时分享成功产品案例,学习他们如何成功建立自己的副业–huntsbot.com
毫无疑问,我们中的一些人会对识别对 CPU 的分支预测器有问题的代码的方法感兴趣。 Valgrind 工具 cachegrind 有一个分支预测器模拟器,通过使用 --branch-sim=yes 标志启用。在这个问题中的示例上运行它,外部循环的数量减少到 10000 并使用 g++ 编译,得到以下结果:
排序:
==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% )
未分类:
==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 产生的逐行输出,我们看到有问题的循环:
排序:
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];
. . . . }
. . . . }
未分类:
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];
. . . . }
. . . . }
这使您可以轻松识别有问题的行 - 在未排序的版本中,if (data[c] >= 128) 行在 cachegrind 的分支预测器模型下导致 164,050,007 个错误预测的条件分支 (Bcm),而在排序版本中它仅导致 10,006 个。
或者,在 Linux 上,您可以使用性能计数器子系统来完成相同的任务,但使用 CPU 计数器具有本机性能。
perf stat ./sumtest_sorted
排序:
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
未分类:
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
它还可以通过反汇编进行源代码注释。
perf record -e branch-misses ./sumtest_unsorted
perf annotate -d sumtest_unsorted
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)
...
有关详细信息,请参阅 the performance tutorial。
这很可怕,在未排序的列表中,应该有 50% 的机会点击添加。不知何故,分支预测只有 25% 的未命中率,它怎么能比 50% 的未命中率更好呢?
@tall.b.lo:25% 是所有分支的 - 循环中有 两个 分支,一个用于 data[c] >= 128(如您所建议的那样有 50% 的未命中率),一个用于循环条件 c < arraySize 的缺失率约为 0%。
请注意,基准测试/分析未优化(“调试模式”)代码通常是一个坏主意。通过优化,没有分支未命中的版本会更快,并且不会因局部变量的存储/重新加载延迟而停滞不前。但是,关键分支的实际分支错误预测率应该大致相同(假设有一个:现代编译器可以 vectorize this 或以其他方式制作无分支 asm)。循环展开可以通过减少运行可预测的循环分支来改变整体未命中率。
解决方案6:
打造属于自己的副业,开启自由职业之旅,从huntsbot.com开始!
我刚刚阅读了这个问题及其答案,我觉得缺少答案。
我发现在托管语言中特别有效的消除分支预测的一种常用方法是使用表查找而不是使用分支(尽管在这种情况下我没有对其进行测试)。
这种方法通常在以下情况下有效:
它是一个小表,可能会缓存在处理器中,并且您正在以非常紧凑的循环运行事物和/或处理器可以预加载数据。
背景和原因
从处理器的角度来看,您的内存很慢。为了弥补速度上的差异,您的处理器中内置了几个高速缓存(L1/L2 高速缓存)。所以想象一下,你正在做你的漂亮计算,并发现你需要一块内存。处理器将执行其“加载”操作并将这块内存加载到缓存中——然后使用缓存来完成其余的计算。因为内存相对较慢,所以这个“负载”会减慢你的程序。
与分支预测一样,这在奔腾处理器中进行了优化:处理器预测它需要加载一段数据并尝试在操作实际命中缓存之前将其加载到缓存中。正如我们已经看到的那样,分支预测有时会出错——在最坏的情况下,您需要返回并实际等待内存加载,这将花费很长时间(换句话说:失败的分支预测是不好的,内存分支预测失败后加载太可怕了!)。
对我们来说幸运的是,如果内存访问模式是可预测的,处理器会将其加载到其快速缓存中,一切都很好。
我们首先要知道什么是小?虽然通常越小越好,但经验法则是坚持使用 <= 4096 字节大小的查找表。作为上限:如果您的查找表大于 64K,则可能值得重新考虑。
构建表
所以我们发现我们可以创建一个小表。接下来要做的是获得一个查找功能。查找函数通常是使用几个基本整数运算(和、或、异或、移位、加、删除甚至乘)的小函数。您希望通过查找功能将您的输入翻译成表格中的某种“唯一键”,然后它会简单地为您提供您希望它完成的所有工作的答案。
在这种情况下:>= 128 意味着我们可以保留该值,< 128 意味着我们摆脱它。最简单的方法是使用“AND”:如果我们保留它,我们用 7FFFFFFF 与它;如果我们想去掉它,我们用 0 与它。还要注意 128 是 2 的幂——所以我们可以继续制作一个包含 32768/128 整数的表,并用一个 0 和很多7FFFFFFFF 的。
托管语言
您可能想知道为什么这在托管语言中运行良好。毕竟,托管语言使用分支检查数组的边界,以确保您不会搞砸…
好吧,不完全是… 😃
在消除托管语言的这个分支方面已经做了很多工作。例如:
for (int i = 0; i < array.Length; ++i)
{
// Use array[i]
}
在这种情况下,编译器很明显永远不会遇到边界条件。至少 Microsoft JIT 编译器(但我希望 Java 会做类似的事情)会注意到这一点并完全删除检查。哇,这意味着没有分支。同样,它将处理其他明显的情况。
如果您在使用托管语言进行查找时遇到问题 - 关键是在查找函数中添加 & 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();
解决方案7:
huntsbot.com精选全球7大洲远程工作机会,涵盖各领域,帮助想要远程工作的数字游民们能更精准、更高效的找到对方。
由于在对数组进行排序时数据分布在 0 到 255 之间,因此大约前半部分的迭代不会进入 if 语句(if 语句在下面共享)。
if (data[c] >= 128)
sum += data[c];
问题是:是什么让上述语句在某些情况下不像排序数据那样执行?这里出现了“分支预测器”。分支预测器是一种数字电路,它试图猜测分支(例如 if-then-else 结构)在确定之前会走哪条路。分支预测器的目的是改善指令流水线中的流程。分支预测器在实现高效性能方面发挥着关键作用!
让我们做一些基准测试以更好地理解它
if 语句的性能取决于其条件是否具有可预测的模式。如果条件始终为真或始终为假,处理器中的分支预测逻辑将选择该模式。另一方面,如果模式不可预测,则 if 语句的开销会大得多。
让我们在不同的条件下测量这个循环的性能:
for (int i = 0; i < max; i++)
if (condition)
sum++;
以下是具有不同真假模式的循环时间:
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
“bad”真假模式可以使if-语句比“good”模式慢六倍!当然,哪种模式好,哪种模式不好取决于编译器和特定处理器生成的确切指令。
所以分支预测对性能的影响是毫无疑问的!
@MooingDuck'因为它不会产生影响-该值可以是任何值,但仍会在这些阈值的范围内。那么,当您已经知道限制时,为什么还要显示一个随机值呢?虽然我同意你可以为了完整性而展示一个,并且“只是为了它的见鬼”。
@cst1992:现在他最慢的时间是 TTFFTTFFTTFF,在我看来,这似乎是可以预测的。 Random 本质上是不可预测的,因此完全有可能它会更慢,因此超出此处显示的限制。 OTOH,可能是 TTFFTTFF 完美地击中了病理案例。不能说,因为他没有显示随机时间。
@MooingDuck 在人眼看来,“TTFFTTFFTTFF”是一个可预测的序列,但我们在这里谈论的是内置于 CPU 中的分支预测器的行为。分支预测器不是 AI 级别的模式识别;这很简单。当您只是交替分支时,它不能很好地预测。在大多数代码中,分支几乎总是以相同的方式进行。考虑一个执行一千次的循环。循环结束的分支回到循环开始 999 次,然后第 1000 次做了不同的事情。一个非常简单的分支预测器通常效果很好。
@steveha:我认为您正在对 CPU 分支预测器的工作方式做出假设,我不同意这种方法。我不知道那个分支预测器有多先进,但我似乎认为它比你先进得多。你可能是对的,但测量肯定会很好。
@steveha:两级自适应预测器可以毫无问题地锁定 TTFFTTFF 模式。 “大多数现代微处理器都使用这种预测方法的变体”。局部分支预测和全局分支预测基于两级自适应预测器,它们也可以。 “全局分支预测用于 AMD 处理器,以及基于 Intel Pentium M、Core、Core 2 和 Silvermont 的 Atom 处理器” 还将同意预测器、混合预测器、间接跳转预测添加到该列表中。循环预测器不会锁定,但会达到 75%。只剩下2个无法锁定
解决方案8:
huntsbot.com – 高效赚钱,自由工作
避免分支预测错误的一种方法是构建一个查找表,并使用数据对其进行索引。 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(clock() - start) / CLOCKS_PER_SEC;
sum = a[1];
此代码浪费了一半的添加,但从未出现分支预测失败。它在随机数据上的速度比带有实际 if 语句的版本快得多。
但是在我的测试中,显式查找表比这稍微快一点,可能是因为索引到查找表比位移略快。这显示了我的代码如何设置和使用查找表(代码中“查找表”的名称为 lut)。这是 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 个值的表,这可能是实用的。
索引到数组的技术,而不是使用 if 语句,可用于决定使用哪个指针。我看到一个实现二叉树的库,而不是有两个命名指针(pLeft 和 pRight 或其他),而是有一个长度为 2 的指针数组,并使用“决策位”技术来决定遵循哪一个。例如,而不是:
if (x < node->value)
node = node->pLeft;
else
node = node->pRight;
这个库会做类似的事情:
i = (x < node->value);
node = node->link[i];
这是此代码的链接:Red Black Trees,Eternally Confuzzled
是的,您也可以直接使用该位并相乘(data[c]>>7 - 此处也有讨论);我故意忽略了这个解决方案,但你当然是正确的。只是一个小提示:查找表的经验法则是,如果它适合 4KB(由于缓存),它会起作用 - 最好使表尽可能小。对于托管语言,我会将其推至 64KB,对于 C++ 和 C 等低级语言,我可能会重新考虑(这只是我的经验)。从 typeof(int) = 4 开始,我会尽量坚持最多 10 位。
我认为使用 0/1 值进行索引可能会比整数乘法更快,但我想如果性能真的很关键,你应该对其进行分析。我同意小型查找表对于避免缓存压力至关重要,但很明显,如果您有更大的缓存,您可以使用更大的查找表,因此 4KB 更多的是经验法则而不是硬性规则。我想你的意思是sizeof(int) == 4?这对于 32 位是正确的。我两岁的手机有一个 32KB 的 L1 缓存,所以即使是 4K 的查找表也可以工作,特别是如果查找值是一个字节而不是一个 int。
可能我遗漏了一些东西,但是在您的 j 等于 0 或 1 方法中,为什么不将值乘以 j 然后再添加它而不是使用数组索引(可能应该乘以 1-j 而不是j)
@steveha 乘法应该更快,我尝试在英特尔书籍中查找它,但找不到它......无论哪种方式,基准测试也在这里给了我这个结果。
@steveha PS:另一个可能的答案是 int c = data[j]; sum += c & -(c >> 7); ,它根本不需要乘法。
解决方案9:
huntsbot.com精选全球7大洲远程工作机会,涵盖各领域,帮助想要远程工作的数字游民们能更精准、更高效的找到对方。
在排序的情况下,您可以比依赖成功的分支预测或任何无分支比较技巧做得更好:完全删除分支。
实际上,该阵列被划分在一个用 data < 128 和另一个用 data >= 128 的连续区域中。因此,您应该找到具有 dichotomic search 的分区点(使用 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;(假设真正均匀分布,16384 个样本,期望值为 191.5)😃
sum= 3137536 - 聪明。这显然不是问题的重点。问题显然是关于解释令人惊讶的性能特征。我倾向于说增加做 std::partition 而不是 std::sort 是有价值的。尽管实际问题不仅仅局限于给出的综合基准。
@DeadMG:这确实不是对给定键的标准二分搜索,而是对分区索引的搜索;它需要每次迭代进行一次比较。但是不要依赖这个代码,我没有检查过。如果您对有保证的正确实施感兴趣,请告诉我。
不需要对切点进行二进制搜索,只需从您想要保留的末尾开始求和,当您达到您不想要的值时停止。 (除非硬件预取在向下循环而不是向上循环时效果更差)。当然,实际上花时间进行排序并不是以未排序数据开始的算法的有用部分。直方图可能是,如果您想从相同的数据回答不同切点的多个查询,而不是 128 个。 (因为小值范围意味着这个小数组中有很多重复项。)
解决方案10:
保持自己快人一步,享受全网独家提供的一站式外包任务、远程工作、创意产品订阅服务–huntsbot.com
由于分支预测,上述行为正在发生。
要了解分支预测,首先必须了解指令流水线。
运行一条指令的步骤可以与运行上一条和下一条指令的步骤顺序重叠,从而可以并行并行执行不同的步骤。这种技术称为指令流水线,用于提高现代处理器的吞吐量。要更好地理解这一点,请参阅此example on Wikipedia。
通常,现代处理器具有相当长(且宽)的管道,因此可以运行许多指令。请参阅 Modern Microprocessors A 90-Minute Guide!,它首先介绍了基本的有序流水线,然后从那里开始。
但为方便起见,让我们考虑一个仅包含这 4 个步骤的简单有序管道。(类似于 classic 5-stage RISC,但省略了单独的 MEM 阶段。)
IF – 从内存中取指令 ID – 解码指令 EX – 执行指令 WB – 写回 CPU 寄存器
https://i.stack.imgur.com/PqBBR.png
回到上面的问题,让我们考虑以下说明:
A) if (data[c] >= 128)
/\
/ \
/ \
true / \ false
/ \
/ \
/ \
/ \
B) sum += data[c]; C) for loop or print().
如果没有分支预测,将发生以下情况:
为了执行指令 B 或指令 C,处理器必须等待(停止)直到指令 A 离开流水线中的 EX 阶段,因为执行指令 B 或指令 C 的决定取决于指令 A 的结果。从下一个获取。)因此管道将如下所示:
https://i.stack.imgur.com/0H4gP.png
https://i.stack.imgur.com/APpca.png
由于等待指令 A 的结果,在上述情况下花费的总 CPU 周期(没有分支预测;对于真和假)是 7。
那么什么是分支预测呢?
分支预测器将尝试猜测分支(if-then-else 结构)在确定之前会走哪条路。它不会等待指令 A 到达流水线的 EX 阶段,但它会猜测决定并转到该指令(在我们的示例中为 B 或 C)。
https://i.stack.imgur.com/ZYUbs.png
如果稍后检测到猜测错误,则丢弃部分执行的指令,流水线从正确的分支重新开始,从而产生延迟。在分支错误预测的情况下浪费的时间等于管道中从获取阶段到执行阶段的阶段数。现代微处理器往往具有相当长的流水线,因此误预测延迟在 10 到 20 个时钟周期之间。管道越长,对良好 branch predictor 的需求就越大。
在OP的代码中,第一次有条件的时候,分支预测器没有任何信息来预测,所以第一次它会随机选择下一条指令。 (或者回退到静态预测,通常是向前不采用,向后采用)。稍后在 for 循环中,它可以根据历史记录进行预测。对于按升序排序的数组,有三种可能:
所有元素都小于 128 所有元素都大于 128 一些开始的新元素小于 128,后来它变得大于 128
让我们假设预测器在第一次运行时总是假设真正的分支。
所以在第一种情况下,它总是会选择真正的分支,因为历史上它的所有预测都是正确的。在第二种情况下,最初它会预测错误,但经过几次迭代后,它会正确预测。在第三种情况下,它最初会正确预测,直到元素小于 128。之后它会失败一段时间,当它在历史上看到分支预测失败时会自行纠正。
在所有这些情况下,失败的数量都会太少,因此,只有几次它需要丢弃部分执行的指令并从正确的分支重新开始,从而减少 CPU 周期。
但是在随机未排序数组的情况下,预测将需要丢弃部分执行的指令并在大多数情况下从正确的分支重新开始,并且与排序数组相比会导致更多的 CPU 周期。
进一步阅读:
现代微处理器 90 分钟指南!
Dan Luu 关于分支预测的文章(涵盖较旧的分支预测器,而不是现代 IT-TAGE 或感知器)
https://en.wikipedia.org/wiki/Branch_predictor
Branch Prediction and the Performance of Interpreters - Don’t Trust Folklore - 2015 年的论文展示了 Intel 的 Haswell 在预测 Python 解释器主循环的间接分支方面的表现(由于不简单的模式而在历史上存在问题),而早期的 CPU没有使用 IT-TAGE。 (不过,它们对这种完全随机的情况没有帮助。当源被编译为分支 asm 时,Skylake CPU 上循环内的 if 仍然有 50% 的错误预测率。)
较新的 Intel 处理器上的静态分支预测 - CPU 在运行没有可用动态预测的分支指令时实际执行的操作。从历史上看,前向未采用(如 if 或 break),后向采用(如循环)已被使用,因为它总比没有好。布局代码以使快速路径/常见情况最小化所采用的分支对于 I-cache 密度和静态预测都有好处,因此编译器已经这样做了。 (这是 C 源代码中可能/不太可能提示的真实效果,实际上并未提示大多数 CPU 中的硬件分支预测,除非可能通过静态预测。)
两条指令如何一起执行?这是用单独的 cpu 内核完成的还是流水线指令集成在单个 cpu 内核中?
@M.kazemAkhgary 这一切都在一个逻辑核心中。如果您有兴趣,可以在 Intel Software Developer Manual 中很好地描述这一点
解决方案11:
一个优秀的自由职业者,应该有对需求敏感和精准需求捕获的能力,而huntsbot.com提供了这个机会
官方答案将来自
英特尔 - 避免分支错误预测的成本 英特尔 - 分支和循环重组以防止错误预测 科学论文 - 分支预测计算机架构 书籍:JL Hennessy,DA Patterson:计算机架构:定量方法 科学出版物中的文章:TY Yeh,YN Patt很多这些关于分支预测。
您还可以从这个可爱的 diagram 中看出为什么分支预测器会混淆。
https://i.stack.imgur.com/pBMV2.png
原始代码中的每个元素都是一个随机值
data[c] = std::rand() % 256;
因此预测器将在 std::rand() 打击时改变方向。
另一方面,一旦排序后,预测器将首先进入强烈不采用的状态,当值变为高值时,预测器将在三个运行中从强烈不采用到强烈采用变化。
原文链接:https://www.huntsbot.com/qa/9rma/why-is-processing-a-sorted-array-faster-than-processing-an-unsorted-array?lang=zh_CN
huntsbot.com – 高效赚钱,自由工作