为啥处理排序数组比处理未排序数组更快?
Posted
技术标签:
【中文标题】为啥处理排序数组比处理未排序数组更快?【英文标题】:Why is processing a sorted array faster than processing an unsorted array?为什么处理排序数组比处理未排序数组更快? 【发布时间】:2012-06-28 23:30:34 【问题描述】:这是一段 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 秒。
(排序本身比遍历数组需要更多时间,因此如果我们需要为未知数组计算它,实际上并不值得这样做。)
最初,我认为这可能只是语言或编译器异常,所以我尝试了 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,但后来我认为这是多么愚蠢,因为数组刚刚生成。
这是怎么回事? 为什么处理排序数组比处理未排序数组更快?代码总结了一些独立的术语,所以顺序应该无关紧要。
相关/后续问答关于不同/以后的编译器和选项的相同效果:
Why is processing an unsorted array the same speed as processing a sorted array with modern x86-64 clang? gcc optimization flag -O3 makes code slower than -O2【问题讨论】:
为了记录,您的数据不需要排序,只需partitioned,这是一个更快的操作。 另一个观察是你不需要对数组进行排序,但你只需要使用值 128 对其进行分区。排序是 n*log(n),而分区只是线性的。基本上,这只是快速排序分区步骤的一次运行,其中枢轴选择为 128。不幸的是,在 C++ 中只有 nth_element 函数,它按位置分区,而不是按值分区。 @screwnut 这是一个实验,它表明分区就足够了:创建一个未排序但分区的数组,其中包含其他随机内容。测量时间。把它分类。再次测量时间。这两个测量值应该基本上无法区分。 (实验 2:创建一个随机数组。测量时间。对其进行分区。再次测量时间。您应该看到与排序相同的加速。您可以将两个实验合二为一。) 顺便说一句。在 Apple M1 上,未排序的代码在 17 秒内运行,排序后的代码在 7 秒内运行,因此分支预测惩罚在 RISC 架构上并没有那么糟糕。 @RomanYavorskyi:这取决于编译器。如果他们为此特定测试制作无分支汇编(例如,作为Why is processing an unsorted array the same speed as processing a sorted array with modern x86-64 clang? 中的SIMD 向量化的一部分,或者只是使用标量cmov
(gcc optimization flag -O3 makes code slower than -O2),那么排序与否无关紧要。但不可预测的分支仍然存在当它不像计数那么简单时,这是一件非常真实的事情,所以删除这个问题会很疯狂。
【参考方案1】:
你是branch prediction失败的受害者。
什么是分支预测?
考虑一个铁路枢纽:
Image 来自 Mecanismo,来自 Wikimedia Commons。在CC-By-SA 3.0 许可下使用。
现在为了争论,假设这是在 1800 年代 - 远距离或无线电通信之前。
你是一个路口的操作员,你听到火车来了。你不知道它应该走哪条路。你停下火车问司机他们想要哪个方向。然后你适当地设置开关。
火车很重并且有很大的惯性,所以它们需要很长时间才能启动和减速。
有没有更好的方法?你猜火车会开往哪个方向!
如果你猜对了,它会继续。 如果您猜错了,船长会停下来,后退并冲您大喊以拨动开关。然后它可以沿着另一条路径重新启动。如果你每次都猜对,火车永远不会停下来。如果你经常猜错,火车会花很多时间停止、备份和重新启动。
考虑一个 if 语句: 在处理器级别,它是一个分支指令:
你是一个处理器,你看到一个分支。你不知道它会朝哪个方向发展。你做什么工作?您停止执行并等待前面的指令完成。然后你继续沿着正确的路径前进。
现代处理器很复杂,而且流水线很长。这意味着他们需要永远“热身”和“减速”。
有没有更好的方法?你猜猜树枝会往哪个方向走!
如果您猜对了,则继续执行。 如果你猜错了,你需要刷新管道并回滚到分支。然后,您可以沿着另一条路径重新开始。如果你每次都猜对,执行永远不会停止。如果你经常猜错,你会花很多时间拖延,回滚,然后重新启动。
这是分支预测。我承认这不是最好的类比,因为火车只能用旗帜指示方向。但是在计算机中,处理器直到最后一刻才知道分支会朝哪个方向前进。
您将如何策略性地猜测以尽量减少火车必须倒车并沿另一条路径行驶的次数?你看看过去的历史!如果火车 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 版本
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 |
观察:
使用分支:已排序数据和未排序数据之间存在巨大差异。 With the Hack:排序数据和未排序数据没有区别。 在 C++ 案例中,在对数据进行排序时,hack 实际上比使用分支慢一点。一般的经验法则是避免关键循环中的数据相关分支(例如本例)。
更新:
在 x64 上带有 -O3
或 -ftree-vectorize
的 GCC 4.6.1 能够生成条件移动,因此排序数据和未排序数据之间没有区别 - 两者都很快。
(或者有点快:对于已经排序的情况,cmov
可能会更慢,尤其是如果 GCC 将其放在关键路径上而不是仅在 add
上,尤其是在 Broadwell 之前的 Intel 上,cmov
有 2 个周期延迟:gcc optimization flag -O3 makes code slower than -O2)
即使在/Ox
下,VC++ 2010 也无法为此分支生成条件移动。
Intel C++ Compiler (ICC) 11 做了一些神奇的事情。它interchanges the two loops,从而将不可预测的分支提升到外循环。它不仅不受错误预测的影响,而且速度也是 VC++ 和 GCC 生成的速度的两倍!换句话说,ICC 利用测试循环击败了基准测试......
如果您为英特尔编译器提供无分支代码,它会直接对其进行矢量化...并且与使用分支(使用循环交换)一样快。
这表明即使是成熟的现代编译器在优化代码的能力上也可能存在很大差异......
【讨论】:
这都是C++? @HannaMcquaig 我猜它说“Java”的那部分不是 C++,但我可能错了。 等一下,不是将负值转移到正确的产量实现定义的值吗? int t = (data[c] - 128) >> 31; sum += ~t & data[c]; 偶然分支预测失败也可以是exploited by a program to obtain crypto keys being used by another program在同一个CPU核心上。 @Mycotina,我不是专家,但我的理解是:处理器需要多个步骤来执行一条指令(获取、解码等)——这被称为“指令流水线”—— - 因此,作为一种优化,它将一次获取多条指令,并在执行当前指令时“预热”下一条指令。如果选择了错误的分支,则必须丢弃流水线中正在“预热”的指令,以便将正确分支上的指令放入流水线中。【参考方案2】:分支预测。
对于排序数组,条件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】:数据排序后性能大幅提升的原因是分支预测惩罚被移除了,正如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
Scenario | Time (seconds) |
---|---|
Branching - Random data | 8.885 |
Branching - Sorted data | 1.528 |
Branchless - Random data | 3.716 |
Branchless - Sorted data | 3.71 |
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
程序集来更仔细地观察。为简单起见,我们使用两个函数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
max2
由于使用了指令cmovge
,因此使用的代码要少得多。但真正的收获是max2
不涉及分支跳转,jmp
,如果预测结果不正确,这将有显着的性能损失。
那么为什么有条件的移动表现更好?
在典型的x86
处理器中,一条指令的执行分为几个阶段。粗略地说,我们有不同的硬件来处理不同的阶段。所以我们不必等待一条指令完成来开始新的指令。这称为 pipelining。
在分支情况下,后面的指令是由前面的指令决定的,所以我们不能进行流水线操作。我们必须等待或预测。
在条件移动情况下,执行条件移动指令分为几个阶段,但更早的阶段如Fetch
和Decode
不依赖于前一条指令的结果;只有后期需要结果。因此,我们等待一条指令执行时间的一小部分。这就是为什么在预测容易时条件移动版本比分支慢的原因。
Computer Systems: A Programmer's Perspective, second edition一书详细解释了这一点。您可以查看第 3.6.6 节的条件移动指令,查看整个第 4 章的处理器架构,查看第 5.11.2 节的分支预测和错误预测惩罚的特殊处理.
有时,一些现代编译器可以优化我们的代码以具有更好的性能,有时一些编译器不能(有问题的代码使用 Visual Studio 的本机编译器)。在不可预测的情况下了解分支和条件移动之间的性能差异可以帮助我们在场景变得如此复杂以至于编译器无法自动优化它们时编写具有更好性能的代码。
【讨论】:
***.com/questions/9745389/…【参考方案4】:如果您对可以对此代码进行的更多优化感到好奇,请考虑以下几点:
从原始循环开始:
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】:毫无疑问,我们中的一些人会对识别对 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%。【参考方案6】:
我刚刚阅读了这个问题及其答案,我觉得缺少答案。
我发现在托管语言中特别有效的消除分支预测的一种常见方法是使用表查找而不是使用分支(尽管在这种情况下我没有测试过)。
这种方法通常在以下情况下有效:
-
这是一个小表,很可能缓存在处理器中,并且
您正在一个非常紧凑的循环中运行事物和/或处理器可以预加载数据。
背景和原因
从处理器的角度来看,您的内存很慢。为了弥补速度上的差异,您的处理器中内置了几个高速缓存(L1/L2 高速缓存)。所以想象一下,你正在做你的漂亮计算,并发现你需要一块内存。处理器将执行其“加载”操作并将这块内存加载到缓存中——然后使用缓存来完成其余的计算。由于内存相对较慢,这种“负载”会减慢您的程序。
与分支预测一样,这在奔腾处理器中得到了优化:处理器预测它需要加载一段数据并尝试在操作实际命中缓存之前将其加载到缓存中。正如我们已经看到的那样,分支预测有时会出错——在最坏的情况下,您需要返回并实际等待内存加载,这将花费很长时间(换句话说:失败的分支预测很糟糕,分支预测失败后的内存负载太可怕了!)。
幸运的是,如果内存访问模式是可预测的,处理器会将其加载到其快速缓存中,一切都很好。
首先我们要知道什么是small?虽然通常越小越好,但经验法则是坚持使用
构建表格
所以我们发现我们可以创建一个小表。接下来要做的是获得一个查找功能。查找函数通常是使用几个基本整数运算(和、或、异或、移位、加、删除甚至乘)的小函数。您希望通过查找功能将您的输入翻译成表格中的某种“唯一键”,然后它会简单地为您提供您希望它完成的所有工作的答案。
在这种情况下:>= 128 意味着我们可以保留该值,
托管语言
您可能想知道为什么这在托管语言中运行良好。毕竟,托管语言使用分支检查数组的边界,以确保您不会搞砸......
嗯,不完全是……:-)
在消除托管语言的这个分支方面已经做了很多工作。例如:
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】:由于数组排序时数据分布在 0 到 255 之间,所以前半段左右的迭代不会进入if
-statement(if
语句在下面共享)。
if (data[c] >= 128)
sum += data[c];
问题是:是什么让上述语句在某些情况下不执行,就像在排序数据的情况下一样?这里出现了“分支预测器”。分支预测器是一个数字电路,它试图猜测分支(例如if-then-else
结构)在确定之前会走哪条路。分支预测器的目的是改善指令流水线中的流程。分支预测器在实现高效性能方面发挥着关键作用!
让我们做一些基准测试以更好地理解它
if
语句的性能取决于其条件是否具有可预测的模式。如果条件始终为真或始终为假,处理器中的分支预测逻辑将选择该模式。另一方面,如果模式不可预测,if
-statement 的开销会大得多。
让我们来衡量这个循环在不同条件下的性能:
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
-statement 比“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】:避免分支预测错误的一种方法是构建一个查找表,并使用数据对其进行索引。 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];
此代码浪费了一半的加法,但从未出现分支预测失败。它在随机数据上的速度比使用实际 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,永远困惑
【讨论】:
对,您也可以直接使用该位并乘以(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】:
在排序的情况下,你可以做得比依赖成功的分支预测或任何无分支比较技巧更好:完全删除分支。
确实,该阵列在一个连续区域中划分为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:这确实不是对给定键的标准二分搜索,而是对分区索引的搜索;它需要每次迭代进行一次比较。但是不要依赖这个代码,我没有检查过。如果您对有保证的正确实施感兴趣,请告诉我。【参考方案10】:
上述行为是由于分支预测而发生的。
要了解分支预测,首先必须了解指令管道:
任何指令都被分解成一系列步骤,以便不同的步骤可以同时并行执行。这种技术被称为指令流水线,用于增加现代处理器的吞吐量。为了更好地理解这一点,请查看example on Wikipedia。
一般来说,现代处理器的流水线很长,但为方便起见,我们只考虑这 4 个步骤。
-
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 的结果。所以管道将如下所示。
如果条件返回 true:
如果条件返回 false:
由于等待指令A的结果,在上述情况下(没有分支预测;真假都可以)花费的总CPU周期为7。
那么什么是分支预测?
分支预测器将尝试猜测分支(一个 if-then-else 结构)在确定之前会走哪条路。它不会等待指令 A 到达流水线的 EX 阶段,而是会猜测决定并转到该指令(在我们的示例中为 B 或 C)。
如果猜对了,管道看起来像这样:
如果后来检测到猜测错误,则丢弃部分执行的指令,流水线从正确的分支重新开始,从而产生延迟。 在分支错误预测的情况下浪费的时间等于管道中从获取阶段到执行阶段的阶段数。现代微处理器往往具有相当长的流水线,因此误预测延迟在 10 到 20 个时钟周期之间。管道越长,对好的branch predictor 的需求就越大。
在OP的代码中,第一次有条件的时候,分支预测器没有任何信息来预测,所以第一次它会随机选择下一条指令。稍后在 for 循环中,它可以根据历史记录进行预测。 对于一个升序排列的数组,有三种可能:
-
所有元素都小于 128
所有元素都大于 128
一些开始的新元素小于 128,后来它变得大于 128
让我们假设预测器在第一次运行时总是假设真正的分支。
所以在第一种情况下,它总是会选择真正的分支,因为从历史上看,它的所有预测都是正确的。 在第二种情况下,最初它会预测错误,但经过几次迭代后,它会正确预测。 在第三种情况下,它最初会正确预测,直到元素小于 128。之后它会失败一段时间,当它在历史上看到分支预测失败时会自行纠正。
在所有这些情况下,失败的数量都会太少,因此,只有几次它需要丢弃部分执行的指令并从正确的分支重新开始,从而减少 CPU 周期。
但在随机未排序数组的情况下,预测将需要丢弃部分执行的指令,并在大多数情况下从正确的分支重新开始,与排序数组相比,这会导致更多的 CPU 周期。
【讨论】:
两条指令如何一起执行?这是用单独的 cpu 内核完成的还是流水线指令集成在单个 cpu 内核中? @M.kazemAkhgary 这一切都在一个逻辑核心中。如果您有兴趣,这在Intel Software Developer Manual 中有很好的描述【参考方案11】:官方回答来自
-
Intel - Avoiding the Cost of Branch Misprediction
Intel - Branch and Loop Reorganization to Prevent Mispredicts
Scientific papers - branch prediction computer architecture
书籍:J.L. Hennessy, D.A. Patterson:计算机架构:一种定量方法
科学出版物中的文章:T.Y.是的,Y.N. Patt 在分支预测方面做了很多这样的工作。
你也可以从这个可爱的diagram 看到为什么分支预测器会混淆。
原代码中的每个元素都是一个随机值
data[c] = std::rand() % 256;
所以预测器会随着std::rand()
的打击而改变方向。
另一方面,一旦排序后,预测器将首先进入强烈未采取状态,当值变为高值时,预测器将在三个运行中从强烈未采取到强烈变化采取了。
【讨论】:
【参考方案12】:在同一行中(我认为任何答案都没有强调这一点)值得一提的是,有时(特别是在性能很重要的软件中,例如在 Linux 内核中)您可以找到一些类似以下的 if 语句:
if (likely( everything_is_ok ))
/* Do something */
或类似:
if (unlikely(very_improbable_condition))
/* Do something */
likely()
和 unlikely()
实际上都是使用 GCC 的 __builtin_expect
之类的东西定义的宏,以帮助编译器插入预测代码以支持考虑用户提供的信息的条件。 GCC 支持其他可以改变正在运行的程序的行为或发出清除缓存等低级指令的内置指令。请参阅this documentation,它会通过可用的 GCC 内置指令。
通常这种优化主要出现在执行时间很重要且至关重要的硬实时应用程序或嵌入式系统中。例如,如果您正在检查某些仅发生 1/10000000 次的错误情况,那么为什么不通知编译器呢?这样,默认情况下,分支预测会假设条件为假。
【讨论】:
【参考方案13】:C++ 中常用的布尔运算会在编译后的程序中产生许多分支。如果这些分支在循环内部并且难以预测,它们会显着减慢执行速度。布尔变量存储为 8 位整数,其值为 0
对应于 false
和 1
对应于 true
。
从某种意义上说,布尔变量是超定的,所有将布尔变量作为输入的运算符都会检查输入是否具有除 0
或 1
之外的任何其他值,但将布尔变量作为输出的运算符不能产生除 @ 之外的任何其他值987654327@ 或1
。这使得以布尔变量作为输入的操作比必要的效率低。
考虑示例:
bool a, b, c, d;
c = a && b;
d = a || b;
这通常由编译器通过以下方式实现:
bool a, b, c, d;
if (a != 0)
if (b != 0)
c = 1;
else
goto CFALSE;
else
CFALSE:
c = 0;
if (a == 0)
if (b == 0)
d = 0;
else
goto DTRUE;
else
DTRUE:
d = 1;
这段代码远非最佳。如果发生错误预测,分支可能需要很长时间。如果可以肯定地知道操作数除了0
和1
之外没有其他值,则布尔运算可以变得更加高效。编译器不做这种假设的原因是,如果变量未初始化或来自未知来源,它们可能具有其他值。如果a
和b
已初始化为有效值,或者它们来自产生布尔输出的运算符,则可以优化上述代码。优化后的代码如下:
char a = 0, b = 1, c, d;
c = a & b;
d = a | b;
使用char
代替bool
,以便可以使用按位运算符(&
和|
)代替布尔运算符(&&
和||
)。位运算符是只占用一个时钟周期的单条指令。即使a
和b
具有除0
或1
之外的其他值,OR 运算符 (|
) 也有效。如果操作数的值不是 0
和 1
,则 AND 运算符 (&
) 和异或运算符 (^
) 可能会给出不一致的结果。
~
不能用于 NOT。相反,您可以对已知为 0
或 1
的变量进行布尔 NOT 运算,方法是与 1
进行异或运算:
bool a, b;
b = !a;
可以优化为:
char a = 0, b;
b = a ^ 1;
a && b
不能被 a & b
替换,如果 b
是一个不应被计算的表达式,如果 a
是 false
(&&
将不会计算 b
,&
将)。同样,如果b
是一个不应在a
是true
时计算的表达式,则a || b
不能替换为a | b
。
如果操作数是变量,则使用位运算符比如果操作数是比较更有利:
bool a; double x, y, z;
a = x > y && z < 5.0;
在大多数情况下是最佳的(除非您期望 &&
表达式会产生许多分支错误预测)。
【讨论】:
【参考方案14】:这是肯定的!...
分支预测使逻辑运行速度变慢,因为您的代码中发生了切换!这就像你要走一条笔直的街道或一条有很多转弯的街道,肯定会走得更快!...
如果数组已排序,则您的条件在第一步为假:data[c] >= 128
,然后在整条街的尽头变成真值。这就是你如何更快地到达逻辑的结尾。另一方面,使用未排序的数组,您需要大量的转换和处理,这肯定会使您的代码运行速度变慢...
看看我在下面为你创建的图像。哪条街道会更快完工?
所以以编程方式,分支预测会导致进程变慢...
最后,很高兴知道我们有两种分支预测,每种都会以不同的方式影响您的代码:
1.静态
2。动态
微处理器第一次使用静态分支预测 遇到条件分支,动态分支预测是 用于条件分支代码的后续执行。
为了有效地编写代码以利用这些 规则,在编写 if-else 或 switch 语句时,检查最 首先是常见的情况,然后逐步减少到最不常见的情况。 循环不一定需要任何特殊的代码顺序 静态分支预测,仅作为循环迭代器的条件 正常使用。
【讨论】:
【参考方案15】:这个问题已经被很好地回答了很多次了。不过,我想提请大家注意另一个有趣的分析。
最近,这个示例(稍作修改)也被用作演示如何在 Windows 上的程序本身内分析一段代码的方式。在此过程中,作者还展示了如何使用结果来确定代码在排序和未排序情况下大部分时间花费在哪里。最后,这篇文章还展示了如何使用 HAL(硬件抽象层)的一个鲜为人知的特性来确定在未排序的情况下发生了多少分支错误预测。
链接在这里: A Demonstration of Self-Profiling
【讨论】:
那是一篇很有意思的文章(其实我也刚看完),但是它是如何回答问题的呢? @PeterMortensen 我对你的问题有点困惑。例如,这是那篇文章中的一个相关行:When the input is unsorted, all the rest of the loop takes substantial time. But with sorted input, the processor is somehow able to spend not just less time in the body of the loop, meaning the buckets at offsets 0x18 and 0x1C, but vanishingly little time on the mechanism of looping.
作者试图在此处发布的代码上下文中讨论分析,并在此过程中试图解释为什么排序后的情况要快得多。【参考方案16】:
正如其他人已经提到的,神秘的背后是Branch Predictor。
我不是想添加一些东西,而是以另一种方式解释这个概念。 wiki 上有一个简明的介绍,其中包含文本和图表。 我很喜欢下面的解释,它使用图表直观地阐述了分支预测器。
在计算机体系结构中,分支预测器是 试图猜测分支的方向的数字电路(例如 if-then-else 结构)将在确定之前进行。这 分支预测器的目的是改善 指令流水线。分支预测器在 在许多现代流水线中实现高效性能 微处理器架构,例如 x86。
双向分支通常通过条件跳转来实现 操作说明。条件跳转可以“不采取”并继续 使用紧随其后的第一个代码分支执行 在条件跳转之后,或者它可以被“采取”并跳转到 程序存储器中第二个代码分支所在的不同位置 存储。不确定是否会出现条件跳转 在计算出条件之前采取或不采取 条件跳转已通过指令中的执行阶段 管道(见图 1)。
基于所描述的场景,我编写了一个动画演示来展示指令在不同情况下如何在管道中执行。
-
没有分支预测器。
如果没有分支预测,处理器将不得不等到 条件跳转指令在 下一条指令可以进入流水线中的取指阶段。
该示例包含三个指令,第一个是条件跳转指令。后两条指令可以进入流水线,直到条件跳转指令执行完毕。
完成 3 条指令需要 9 个时钟周期。
-
使用分支预测器,不要进行条件跳转。让我们假设预测不进行条件跳转。
完成 3 条指令需要 7 个时钟周期。
-
使用分支预测器并进行条件跳转。让我们假设预测不进行条件跳转。
完成 3 条指令需要 9 个时钟周期。
在分支错误预测的情况下浪费的时间等于 从 fetch 阶段到流水线中的阶段数 执行阶段。现代微处理器往往有相当长的 流水线,以便误预测延迟在 10 到 20 个时钟之间 循环。因此,使管道更长会增加对 更高级的分支预测器。
如您所见,我们似乎没有理由不使用 Branch Predictor。
这是一个非常简单的演示,阐明了分支预测器的基本部分。如果这些 gif 很烦人,请随时从答案中删除它们,访问者也可以从BranchPredictorDemo获取现场演示源代码
【讨论】:
几乎和英特尔的营销动画一样好,他们不仅痴迷于分支预测,而且痴迷于乱序执行,这两种策略都是“投机”的。在内存和存储中提前读取(顺序预取到缓冲区)也是推测性的。这一切都加起来了。 @mckenzm:乱序推测执行使分支预测更有价值;除了隐藏获取/解码气泡外,分支预测 + 推测执行还消除了关键路径延迟中的控制依赖关系。if()
块内部或之后的代码可以在分支条件已知之前执行之前。或者对于像strlen
或memchr
这样的搜索循环,交互可以重叠。如果在运行任何下一次迭代之前必须等待匹配或不匹配结果已知,那么您将成为缓存负载 + ALU 延迟而不是吞吐量的瓶颈。
您是否使用 JavaFX 制作了示例应用程序?
@HannaMcquaig 不,它是 Swing 制作的。代码可在github.com/Eugene-Mark/branch-predictor-demo 获得。【参考方案17】:
分支预测增益!
了解分支错误预测不会减慢程序速度很重要。错过预测的代价就像不存在分支预测一样,您等待表达式的评估来决定运行什么代码(下一段中进一步解释)。
if (expression)
// Run 1
else
// Run 2
只要有if-else
\ switch
语句,就必须计算表达式以确定应该执行哪个块。在编译器生成的汇编代码中,插入有条件的branch指令。
分支指令可能导致计算机开始执行不同的指令序列,从而偏离其按顺序执行指令的默认行为(即,如果表达式为假,则程序跳过if
块的代码)取决于在某些条件下,这就是我们案例中的表达式求值。
话虽如此,编译器会在实际评估结果之前尝试预测结果。它将从if
块中获取指令,如果表达式为真,那就太好了!我们获得了评估它所需的时间,并在代码中取得了进展;如果不是,那么我们运行了错误的代码,管道被刷新,并运行了正确的块。
可视化:
假设您需要选择路线 1 或路线 2。等待您的搭档查看地图,您已在 ## 停下来等待,或者您可以选择路线 1,如果幸运的话(路线 1 是正确的)路线),那么您不必等待您的伴侣检查地图(您节省了他检查地图所花费的时间),否则您只会折返。
虽然冲洗管道的速度非常快,但如今赌一把是值得的。预测已排序的数据或变化缓慢的数据总是比预测快速变化更容易且更好。
O Route 1 /-------------------------------
/|\ /
| ---------##/
/ \ \
\
Route 2 \--------------------------------
【讨论】:
虽然刷新管道非常快 不是。与一直到 DRAM 的缓存未命中相比,它的速度很快,但在现代高性能 x86(如英特尔 Sandybridge 系列)上,它大约需要十几个周期。尽管快速恢复确实允许它避免在开始恢复之前等待所有较旧的独立指令达到退休,但您仍然会因错误预测而损失大量前端周期。 What exactly happens when a skylake CPU mispredicts a branch?。 (并且每个周期大约可以执行 4 条工作指令。)对高吞吐量代码不利。【参考方案18】:在 ARM 上,不需要分支,因为每条指令都有一个 4 位条件字段,用于测试(以零成本)处理器状态寄存器中可能出现的任何16 different different conditions,如果条件指令为假,指令被跳过。这消除了对短分支的需要,并且该算法不会有分支预测命中。 因此,由于排序的额外开销,该算法的排序版本在 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
、C
和 V
标志(用于签名添加)。
ADDSGE R1, R2, R3
仅在 GE
测试为真时执行加法,然后根据加法结果更新状态位。
大多数处理器架构没有这种能力来指定是否应该为给定的操作更新状态位,这可能需要编写额外的代码来保存和稍后恢复状态位,或者可能需要额外的分支,或者可能会限制处理器的无序执行效率:大多数 CPU 指令集架构在大多数指令之后强制更新状态位的副作用之一是,很难区分哪些指令可以并行运行而不会相互干扰。更新状态位有副作用,因此会对代码产生线性化影响。 ARM 在任何指令上混合和匹配无分支条件测试以及在任何指令之后更新或不更新状态位的选项的能力非常强大,对于汇编语言程序员和编译器来说,它可以生成非常高效的代码。
当您不必分支时,您可以避免因短分支而刷新管道的时间成本,并且可以避免多种形式的推测评估的设计复杂性。许多最近发现的处理器漏洞(Spectre 等)的缓解措施的初始简单实施对性能的影响向您展示了现代处理器的性能在多大程度上取决于复杂的推测评估逻辑。凭借较短的流水线和大幅减少的分支需求,ARM 不需要像 CISC 处理器那样依赖推测评估。 (当然,高端 ARM 实现确实包括推测性评估,但它只是性能故事的一小部分。)
如果您曾经想知道为什么 ARM 取得了如此惊人的成功,那么这两种机制的出色效率和相互作用(结合另一种机制,可以让您“桶移”任何算术运算符的两个参数之一或零附加成本的偏移内存访问运算符)是故事的重要组成部分,因为它们是 ARM 架构效率的一些最大来源。早在 1983 年 ARM ISA 的原始设计者 Steve Furber 和 Roger(现为 Sophie)Wilson 的才华怎么强调都不为过。
【讨论】:
ARM 中的另一项创新是添加了 S 指令后缀,在(几乎)所有指令上也是可选的,如果不存在,则会阻止指令更改状态位(CMP 指令除外,它的工作是设置状态位,所以它不需要 S 后缀)。这允许您在许多情况下避免 CMP 指令,只要比较为零或相似(例如,当 R0 达到零时,SUBS R0、R0、#1 将设置 Z(零)位)。条件和 S 后缀产生零开销。这是一个非常漂亮的 ISA。 不添加 S 后缀可以让您连续拥有多个条件指令,而不必担心其中一个可能会更改状态位,否则可能会产生跳过其余条件指令的副作用. 请注意,OP 不包括在其测量中排序的时间。在运行分支 x86 循环之前先排序也可能是一个整体损失,即使未排序的情况使循环运行得更慢。但是对大数组进行排序需要很多的工作。 顺便说一句,您可以通过相对于数组末尾的索引来将指令保存在循环中。在循环之前,设置R2 = data + arraySize
,然后以R1 = -arraySize
开始。循环的底部变为adds r1, r1, #1
/ bnz inner_loop
。编译器出于某种原因不使用此优化:/ 但无论如何,在这种情况下,add 的谓词执行与您可以在其他 ISA 上使用无分支代码(如 x86 cmov
)执行的操作没有根本不同。虽然不是很好:gcc optimization flag -O3 makes code slower than -O2
(ARM 谓词执行确实 NOP 指令,因此您甚至可以在会出错的加载或存储上使用它,这与带有内存源操作数的 x86 cmov
不同。大多数 ISA,包括 AArch64,只有ALU 选择操作。因此,在大多数 ISA 上,ARM 预测功能比无分支代码更强大、更有效。)【参考方案19】:
这是关于分支预测的。这是什么?
分支预测器是一种古老的性能改进技术,在现代架构中仍然具有相关性。虽然简单的预测技术提供了快速查找和电源效率,但它们的错误预测率很高。
另一方面,复杂的分支预测——无论是基于神经的还是两级分支预测的变体——提供了更好的预测精度,但它们消耗更多的能量并且复杂度呈指数级增长。
除此之外,在复杂的预测技术中,预测分支所花费的时间本身就非常高——从 2 到 5 个周期——这与实际分支的执行时间相当。
分支预测本质上是一个优化(最小化)问题,其重点在于以最少的资源实现尽可能低的未命中率、低功耗和低复杂度。
确实有三种不同的分支:
转发条件分支 - 根据运行时条件,PC(程序计数器)被更改为指向指令流中转发的地址。
后向条件分支 - PC 更改为在指令流中指向后向。分支是基于某些条件的,例如当循环结束时的测试表明应该再次执行循环时,向后分支到程序循环的开头。
无条件分支 - 这包括没有特定条件的跳转、过程调用和返回。例如,一个无条件跳转指令在汇编语言中可能被简单地编码为“jmp”,并且指令流必须立即被定向到跳转指令指向的目标位置,而可能被编码为“jmpne”的条件跳转仅当先前“比较”指令中两个值的比较结果显示值不相等时,才会重定向指令流。 (x86 架构使用的分段寻址方案增加了额外的复杂性,因为跳转可以是“近”(段内)或“远”(段外)。每种类型对分支预测算法有不同的影响。)
静态/动态分支预测:微处理器在第一次遇到条件分支时使用静态分支预测,而动态分支预测用于条件分支代码的后续执行。 p>
参考资料:
Branch predictor
A Demonstration of Self-Profiling
Branch Prediction Review
Branch Prediction(使用回程机)
【讨论】:
【参考方案20】:除了分支预测可能会减慢你的速度之外,排序数组还有另一个优点:
您可以有一个停止条件,而不是只检查值,这样您只循环相关数据,而忽略其余数据。 分支预测只会错过一次。
// sort backwards (higher values first), may be in some other part of the code
std::sort(data, data + arraySize, std::greater<int>());
for (unsigned c = 0; c < arraySize; ++c)
if (data[c] < 128)
break;
sum += data[c];
【讨论】:
对,但是对数组进行排序的设置成本是 O(N log N),所以如果你对数组进行排序的唯一原因是能够早点中断,那么早点中断对你没有帮助.但是,如果您有其他理由对数组进行预排序,那么是的,这很有价值。 取决于您对数据进行排序的次数与循环次数的比较。这个例子中的排序只是一个例子,它不必在循环之前 是的,这正是我在第一条评论中提出的观点 :-) 你说“分支预测只会错过一次”。但是您没有计算排序算法中的 O(N log N) 分支预测未命中,这实际上大于未排序情况下的 O(N) 分支预测未命中。因此,您需要使用整个排序数据 O(log N) 次来收支平衡(实际上可能更接近 O(10 log N),具体取决于排序算法,例如快速排序,由于缓存未命中 - 合并排序缓存更一致,因此您需要更接近 O(2 log N) 的使用量才能收支平衡。) 一个重要的优化是只做“半个快速排序”,只排序小于目标枢轴值 127 的项目(假设所有内容都小于或等于枢轴在枢轴之后排序)。到达枢轴后,将枢轴之前的元素相加。这将在 O(N) 启动时间而不是 O(N log N) 中运行,尽管仍然会有很多分支预测未命中,根据我之前给出的数字可能是 O(5 N) 的数量级,因为这是半个快速排序。【参考方案21】:由于一种称为分支预测的现象,排序数组的处理速度比未排序数组快。
分支预测器是一个数字电路(在计算机体系结构中),它试图预测分支将走向何方,从而改善指令流水线中的流程。电路/计算机预测下一步并执行。
做出错误的预测会导致返回上一步,并执行另一个预测。假设预测正确,代码将继续执行下一步。错误的预测会导致重复相同的步骤,直到发生正确的预测。
你的问题的答案很简单。
在未排序的数组中,计算机会做出多个预测,从而导致出错的机会增加。 而在排序数组中,计算机做出的预测更少,从而减少了出错的机会。 做出更多预测需要更多时间。
排序数组:直路
____________________________________________________________________________________
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT
未排序的数组:弯曲的道路
______ ________
| |__|
分支预测:猜测/预测哪条路是直的,并在不检查的情况下跟随它
___________________________________________ Straight road
|_________________________________________|Longer road
虽然两条路都到达同一个目的地,但笔直的路较短,而另一条路较长。如果那时你错误地选择了另一个,那就没有回头路了,所以如果你选择了更长的路,你会浪费一些额外的时间。这类似于计算机中发生的情况,我希望这有助于您更好地理解。
我还想引用 cmets 中的@Simon_Weaver:
它不会做出更少的预测 - 它会减少错误的预测。它仍然必须通过循环预测每次...
【讨论】:
【参考方案22】:我在 MacBook Pro(Intel i7,64 位,2.4 GHz)上使用 MATLAB 2011b 尝试了相同的代码,以获得以下 MATLAB 代码:
% Processing time with Sorted data vs unsorted data
%==========================================================================
% Generate data
arraySize = 32768
sum = 0;
% Generate random integer data from range 0 to 255
data = randi(256, arraySize, 1);
%Sort the data
data1= sort(data); % data1= data when no sorting done
%Start a stopwatch timer to measure the execution time
tic;
for i=1:100000
for j=1:arraySize
if data1(j)>=128
sum=sum + data1(j);
end
end
end
toc;
ExeTimeWithSorting = toc - tic;
上述MATLAB代码的结果如下:
a: Elapsed time (without sorting) = 3479.880861 seconds.
b: Elapsed time (with sorting ) = 2377.873098 seconds.
我得到的@GManNickG 中的 C 代码结果:
a: Elapsed time (without sorting) = 19.8761 sec.
b: Elapsed time (with sorting ) = 7.37778 sec.
基于此,看起来 MATLAB 比没有排序的 C 实现慢了几乎 175 倍,而使用排序则慢了 350 倍。换句话说,(分支预测的)效果对于 MATLAB 实现是 1.46x,对于 C 实现是 2.7x。
【讨论】:
为了完整起见,这可能不是您在 Matlab 中实现的方式。我敢打赌,如果在对问题进行矢量化后完成,它会快得多。 Matlab 在很多情况下会自动并行化/向量化,但这里的问题是检查分支预测的效果。无论如何,Matlab 也不能幸免! matlab 是使用原生数字还是 matlab 特定的实现(无限位数左右?)【参考方案23】:其他答案认为需要对数据进行排序的假设是不正确的。
以下代码不会对整个数组进行排序,而是仅对其中的 200 个元素段进行排序,因此运行速度最快。
仅对 k 元素部分进行排序以线性时间 O(n)
完成预处理,而不是对整个数组进行排序所需的 O(n.log(n))
时间。
#include <algorithm>
#include <ctime>
#include <iostream>
int main()
int data[32768]; const int l = sizeof data / sizeof data[0];
for (unsigned c = 0; c < l; ++c)
data[c] = std::rand() % 256;
// sort 200-element segments, not the whole array
for (unsigned c = 0; c + 200 <= l; c += 200)
std::sort(&data[c], &data[c + 200]);
clock_t start = clock();
long long sum = 0;
for (unsigned i = 0; i < 100000; ++i)
for (unsigned c = 0; c < sizeof data / sizeof(int); ++c)
if (data[c] >= 128)
sum += data[c];
std::cout << static_cast<double>(clock() - start) / CLOCKS_PER_SEC << std::endl;
std::cout << "sum = " << sum << std::endl;
这也“证明”了它与排序顺序等任何算法问题无关,确实是分支预测。
【讨论】:
我真的不明白这如何证明什么?您所展示的唯一一件事是“不完成对整个数组进行排序的所有工作比对整个数组进行排序花费的时间更少”。您声称这“也运行得最快”是非常依赖于架构的。请参阅我关于这如何在 ARM 上工作的回答。 PS,您可以通过将总和放入 200 个元素的块循环中,反向排序,然后使用 Yochai Timmer 的建议,即一旦您获得超出范围的值就中断,从而使您的代码在非 ARM 架构上更快。这样每个 200 元素的块求和可以提前终止。 如果您只想在未排序的数据上有效地实现该算法,您可以无分支地执行该操作(并且使用 SIMD,例如使用 x86pcmpgtb
来查找设置了高位的元素,然后与零个较小的元素)。花任何时间实际排序块会更慢。无分支版本将具有独立于数据的性能,也证明成本来自分支错误预测。或者直接使用性能计数器来观察,比如 Skylake int_misc.clear_resteer_cycles
或 int_misc.recovery_cycles
来计算来自错误预测的前端空闲周期
上述两个 cmets 似乎都忽略了一般的算法问题和复杂性,而是提倡使用特殊机器指令的专用硬件。我发现第一个特别琐碎,因为它轻率地忽略了此答案中的重要一般见解,而盲目支持专门的机器指令。
另外请注意,如果if
中的计算比简单的加法更复杂(在一般情况下很可能),则专用硬件指令无济于事。因此,这个答案在提供仍然是O(n)
的通用解决方案方面是独一无二的【参考方案24】:
Bjarne Stroustrup's Answer这个问题:
这听起来像是一个面试问题。这是真的吗?你怎么知道的?不先进行一些测量就回答有关效率的问题是个坏主意,因此了解如何测量很重要。
所以,我尝试使用一百万个整数的向量并得到:
Already sorted 32995 milliseconds
Shuffled 125944 milliseconds
Already sorted 18610 milliseconds
Shuffled 133304 milliseconds
Already sorted 17942 milliseconds
Shuffled 107858 milliseconds
为了确定,我跑了几次。是的,这种现象是真实的。我的关键代码是:
void run(vector<int>& v, const string& label)
auto t0 = system_clock::now();
sort(v.begin(), v.end());
auto t1 = system_clock::now();
cout << label
<< duration_cast<microseconds>(t1 — t0).count()
<< " milliseconds\n";
void tst()
vector<int> v(1'000'000);
iota(v.begin(), v.end(), 0);
run(v, "already sorted ");
std::shuffle(v.begin(), v.end(), std::mt19937 std::random_device() );
run(v, "shuffled ");
至少这种现象在这个编译器、标准库和优化器设置中是真实的。不同的实现可以并且确实给出不同的答案。事实上,有人确实做了更系统的研究(快速网络搜索会找到它)并且大多数实现都显示了这种效果。
一个原因是分支预测:排序算法中的关键操作是“if(v[i] < pivot]) …”
或等效的。对于一个排序序列,测试总是正确的,而对于一个随机序列,选择的分支是随机变化的。
另一个原因是当向量已经排序时,我们永远不需要将元素移动到正确的位置。这些小细节的影响就是我们看到的五六倍。
快速排序(以及一般的排序)是一项复杂的研究,吸引了计算机科学领域的一些最杰出的人才。一个好的排序函数是选择一个好的算法和在实现时关注硬件性能的结果。
如果你想编写高效的代码,你需要了解一点机器架构。
【讨论】:
这似乎错过了问题的重点,并且正在回答排序本身是否对已经排序的数组更快。这并不令人惊讶,因为正如这个答案所指出的那样,除了分支预测效应之外,还有更少的工作要做(使用除合并排序之外的大多数排序算法)。实际问题将这种影响排除在外,并且只是对有条件的增量进行计时。【参考方案25】:这个问题的根源在于 CPU 上的分支预测模型。我建议阅读这篇论文:
Increasing the Instruction Fetch Rate via Multiple Branch Prediction and a Branch Address Cache
当您对元素进行了排序后,IR 就不会费心地一次又一次地获取所有 CPU 指令。它从缓存中获取它们。
【讨论】:
指令在 CPU 的 L1 指令高速缓存中保持热状态,无论错误预测如何。问题是在前一条指令解码并完成执行之前,以正确的顺序将它们提取到 管道 中。【参考方案26】:BRANCH PREDICTION
这称为分支预测。如果没有分支预测,处理器将不得不等到条件跳转指令通过执行阶段,然后下一条指令才能进入流水线中的取指阶段。分支预测器试图通过猜测是否最有可能采用条件跳转来避免这种时间浪费。然后提取并推测性地执行被猜测为最有可能的分支。如果稍后检测到猜测错误,则推测执行,导致延迟。
data[c] >= 128
从此链接获得更多帮助:Multiple Branch Prediction for Wide-Issue Superscalar
【讨论】:
CPU 的指令和数据缓存与分支预测是分开的。 (BPU 本身可以被认为是分支方向的缓存,但如果这是你的意思,你应该更具体。)无论分支错误预测如何,所有代码都将在 L1i 缓存中保持热;问题出在管道本身。 CPU(或代码)没有“搜索”任何东西,所以我不确定你在谈论“搜索时间”时要表达什么意思。 如果没有分支预测,处理器将不得不等到条件跳转指令通过执行阶段,然后下一条指令才能进入流水线中的取指阶段。分支预测器试图通过猜测是否最有可能采用条件跳转来避免这种时间浪费。然后提取并推测性地执行被猜测为最有可能的分支。如果后来检测到猜测是错误的,那么推测性地执行,导致延迟。 是的,没错。如果您用该评论替换了您当前的答案,我会改变我的反对意见!但这不是你的答案解释它的方式。相反,您的答案是“下次将使用高速缓存进行搜索”,这甚至没有意义,当然也不是对 CPU 内部相关部分的准确描述。 此外,如果您的 asm 首先有分支,则未排序的数组仅“带有分支成本”。无分支计数(如 Why is processing an unsorted array the same speed as processing a sorted array with modern x86-64 clang?)不关心数据中的模式。【参考方案27】:快速简单理解的答案(阅读其他文章了解更多详情)
这个概念叫做分支预测
分支预测是一种优化技术,可在确定代码之前预测代码将采用的路径。这一点很重要,因为在代码执行期间,机器会预取几条代码语句并将它们存储在管道中。
问题出现在条件分支中,其中有两个可能的路径或可以执行的部分代码。
当预测为真时,优化技术就奏效了。
当预测为假时,简单来说,存储在管道中的代码语句被证明是错误的,而实际代码必须完全重新加载,这会占用大量时间。
正如常识所暗示的那样,对已排序事物的预测比对未排序事物的预测要准确得多。
分支预测可视化:
已排序 未分类
【讨论】:
应该是在排序的火车轨道/执行路径的中间附近发生的变化,因为循环内的分支被用于前〜一半,不被用于后〜一半要素。 (反之亦然。)此外,未排序情况下的 5 个不同级别是什么意思?这是一个 2-way 分支。 这个答案增加了现有答案中缺少的什么?【参考方案28】:为什么处理排序数组比处理未排序数组更快?
代码示例:
// CPP program to demonstrate processing
// time of sorted and unsorted array
#include <iostream>
#include <algorithm>
#include <ctime>
using namespace std;
const int N = 100001;
int main()
int arr[N];
// Assign random values to array
for (int i=0; i<N; i++)
arr[i] = rand()%N;
// for loop for unsorted array
int count = 0;
double start = clock();
for (int i=0; i<N; i++)
if (arr[i] < N/2)
count++;
double end = clock();
cout << "Time for unsorted array :: "
<< ((end - start)/CLOCKS_PER_SEC)
<< endl;
sort(arr, arr+N);
// for loop for sorted array
count = 0;
start = clock();
for (int i=0; i<N; i++)
if (arr[i] < N/2)
count++;
end = clock();
cout << "Time for sorted array :: "
<< ((end - start)/CLOCKS_PER_SEC)
<< endl;
return 0;
执行时间:
结论:
请注意,与未排序数组相比,处理排序数组所花费的时间更少。对排序数组进行这种优化的原因是分支预测。
什么是分支预测?
计算机体系结构中的分支预测侧重于确定程序指令管道中的条件分支(跳转)是否可能被采用。因为它们必须在当前指令执行之前猜测要获取的地址字段,所以所有流水线处理器都以某种方式进行分支预测。
分支预测如何不适用于上述情况?
if 条件检查 arr[i]
分支预测算法的工作原理:
分支预测适用于算法所遵循的模式,或者基本上是历史,它在前面的步骤中是如何执行的。如果猜测正确,则CPU继续执行,如果出错,则CPU需要刷新管道并回滚到分支并从头开始。
【讨论】:
编译器在此处优化代码并跳过 if 条件。不,分支预测(和分支 mispredictions)是 run-time 效果。如果编译器知道它已排序,它可以进行循环裂变优化并进行两个循环,一个只搜索第一个错误情况,另一个只运行数组的其余部分。 (或者我想优化掉第二个循环,因为它是空的。) 例2和分支预测有什么关系?您正在将线性搜索与二进制搜索和类似算法进行比较。人工搜索巨大的排序列表通常不是通过按顺序扫描每个条目来完成的。一旦到达正确的页面,您就会这样做,在这种情况下,是的,您会向下扫描一列,直到找到它或看到您已经过去,例如到约翰斯顿,是的,您可以以类似于线性搜索的方式快速扫描。但实际上,您并没有完全查看每个条目,所以即使这样也不是一个完美的类比。 @PeterCordes 立即查看。修复了问题。 这个答案添加了现有答案中缺少的什么? @GManNickG 这个答案以一种简单易懂的方式解释。【参考方案29】:也许你不应该对数据进行排序,因为输出值范围是有限的。计算每个值发生的次数要快得多。
例如您在 0..3 之间有 20 个数据,那么您可以保留 3 个计数器。最后你可能有: 0: 10x , 1: 8x, 2: 2x
将此数组转换回线性数组很简单,只需打印 10x 0、8x 1、2x 2。
当值不是 0..2 但仍然有限时,您仍然可以考虑这种方法。排序总是很慢! 其他优点:代码少,易于阅读和测试,错误少。
【讨论】:
这不是问题所在。问题是,如果数据恰好已经排序,为什么那个特定的条件增量循环运行得更快。但是,是的,如果您想回答“如何针对数组优化此查询”的问题:直方图确实会将您的数据放入一种可以更快地回答具有任意阈值的查询的形式。但是,如果您只想用该数据回答一个给定阈值的查询,那么预处理数据并不会更快。 (至少如果你能说服编译器对布尔 0/1 比较结果进行无分支求和。)以上是关于为啥处理排序数组比处理未排序数组更快?的主要内容,如果未能解决你的问题,请参考以下文章