Why is it faster to process a sorted array than an unsorted array?
这里有一段C++代码,看起来很奇怪。出于某种奇怪的原因,对数据进行分类可以神奇地使代码快六倍。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 | #include #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) { // Primary loop for (unsigned c = 0; c < arraySize; ++c) { if (data[c] >= 128) sum += data[c]; } } double elapsedTime = static_cast<double>(clock() - start) / CLOCKS_PER_SEC; std::cout << elapsedTime << std::endl; std::cout <<"sum =" << sum << std::endl; } |
- 如果没有
std::sort(data, data + arraySize); ,代码将在11.54秒内运行。 - 对于排序后的数据,代码将在1.93秒内运行。
最初,我认为这可能只是一种语言或编译器异常。所以我用Java尝试过。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 | 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) { // Primary loop for (int c = 0; c < arraySize; ++c) { if (data[c] >= 128) sum += data[c]; } } System.out.println((System.nanoTime() - start) / 1000000000.0); System.out.println("sum =" + sum); } } |
有点相似但不太极端的结果。
我的第一个想法是排序将数据带到缓存中,但是我认为这是多么愚蠢,因为数组是刚生成的。
- 怎么回事?
- 为什么处理排序数组比处理未排序数组更快?
- 这段代码是对一些独立术语的总结,顺序不应该重要。
你是分支预测失败的受害者。好的。什么是分支预测?
考虑一个铁路枢纽:好的。
image by mecanismo,via wikimedia commons.由SA 3.0许可证在CC下使用。好的。 现在为了争论,假设这是在19世纪——在远距离或无线电通信之前。好的。 你是一个路口的接线员,听到一列火车来了。你不知道该往哪走。你停车问司机他们要哪个方向。然后适当地设置开关。好的。 火车很重,而且惯性很大。所以它们需要永远启动和减速。好的。 有更好的方法吗?你猜火车会朝哪个方向走!好的。 如果你每次都猜对了,火车就不必停了。如果你猜错的次数太多,火车会花很多时间停车、倒车和重新启动。好的。 考虑一条if语句:在处理器级别,它是一条分支指令:好的。 好的。 你是一个处理器,你看到一个分支。你不知道会朝哪个方向走。你是做什么的?停止执行并等待上一条指令完成。然后继续沿着正确的路径前进。好的。 现代处理器很复杂,并且有很长的管道。所以他们需要永远"热身"和"减速"。好的。 有更好的方法吗?你猜树枝会朝哪个方向走!好的。 如果你每次都猜对了,执行就不会停止。如果你猜错的次数太多,你会花很多时间来拖延、回退和重新启动。好的。 这是分支预测。我承认这不是最好的类比,因为火车可以用旗子指示方向。但在计算机中,直到最后一刻,处理器才知道一个分支将朝哪个方向发展。好的。 那么,你如何从战略上猜测,以尽量减少火车必须后退并沿另一条路径行驶的次数呢?你看看过去的历史!如果火车百分之九十九离开,那么你猜是离开了。如果它交替出现,那么您就交替进行猜测。如果每三次走一条路,你猜也是一样的…好的。 换句话说,您试图识别一个模式并遵循它。这或多或少就是分支预测器的工作原理。好的。 大多数应用程序都有行为良好的分支。因此,现代分支预测通常会达到90%以上的命中率。但是,当面对没有可识别模式的不可预测分支时,分支预测器实际上是无用的。好的。 进一步阅读:维基百科上的"分支预测器"文章。好的。正如上面所暗示的,罪魁祸首是这个if语句: 请注意,数据平均分布在0和255之间。排序数据时,大约前半个迭代不会进入if语句。之后,它们都将输入if语句。好的。 这对分支预测器非常友好,因为分支连续多次朝同一方向移动。即使是一个简单的饱和计数器也能正确地预测分支,除了在它切换方向后的少数迭代。好的。 快速可视化:好的。 然而,当数据完全随机时,分支预测器会因为无法预测随机数据而变得无用。因此可能有50%左右的预测失误。(不比随机猜测更好)好的。 那么我们能做什么呢?好的。 如果编译器无法将分支优化为条件移动,那么如果愿意牺牲可读性来提高性能,可以尝试一些黑客攻击。好的。 替换:好的。 用:好的。 这消除了分支,并用一些位操作替换它。好的。 (注意,此hack并不严格等同于原始if语句。但在这种情况下,它对 基准测试:3.5 GHz下的核心i7 920好的。 C++ Visual Studio 2010—X64版本好的。 Java- NETBea7.1.1 JDK 7 -X64好的。 观察:好的。 一般的经验法则是避免关键循环中依赖于数据的分支。(例如在本例中)好的。 更新:好的。 一般合同条款第4.6.1款中,X64上的 即使在 英特尔编译器11做了一些不可思议的事情。它将两个循环互换,从而将不可预测的分支提升到外部循环。因此,它不仅可以避免预测失误,而且速度是VC++和GCC生成速度的两倍!换句话说,ICC利用测试循环来击败基准测试……好的。 如果你给了英特尔编译器无分支的代码,它就直接向量化了它…和分支(循环交换)一样快。好的。 这表明,即使是成熟的现代编译器,其优化代码的能力也会大不相同。好的。好啊。 分支预测。 对于排序后的数组,条件 当数据排序时,性能显著提高的原因是删除了分支预测惩罚,正如神秘主义答案中所解释的那样。 现在,如果我们看看代码
2
sum += data[c];
2
3
4
5
6
7
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)
2
3
4
branch = T, T, N, T, T, T, T, N, T, N, N, T, T, T, N ...
= TTNTTTTNTNNTTTN ... (completely random - hard to predict)
2
sum += data[c];
2
sum += ~t & data[c];
2
3
4
5
6
7
8
9
10
11
seconds = 11.777
// Branch - Sorted
seconds = 2.352
// Branchless - Random
seconds = 2.564
// Branchless - Sorted
seconds = 2.587
2
3
4
5
6
7
8
9
10
11
seconds = 10.93293813
// Branch - Sorted
seconds = 5.643797077
// Branchless - Random
seconds = 3.113581453
// Branchless - Sorted
seconds = 3.186068823
1 2 | if (data[c] >= 128) sum += data[c]; |
我们可以发现,这个特定的
在
1 | sum += data[c] >=128 ? data[c] : 0; |
在保持可读性的同时,我们可以检查加速系数。
在Intel Core [email protected] GHz和Visual Studio 2010发布模式上,基准是(格式从Mysticial复制):
x86
1 2 3 4 5 6 7 8 9 10 11 | // Branch - Random seconds = 8.885 // Branch - Sorted seconds = 1.528 // Branchless - Random seconds = 3.716 // Branchless - Sorted seconds = 3.71 |
X64
1 2 3 4 5 6 7 8 9 10 11 | // Branch - Random seconds = 11.302 // Branch - Sorted seconds = 1.830 // Branchless - Random seconds = 2.736 // Branchless - Sorted seconds = 2.737 |
结果在多个测试中都是可靠的。当分支结果不可预测时,我们会得到很大的加速,但是当分支结果可预测时,我们会受到一些影响。事实上,当使用条件移动时,无论数据模式如何,性能都是相同的。
现在让我们更仔细地研究一下他们生成的
1 2 3 4 5 6 | int max1(int a, int b) { if (a > b) return a; else return b; } |
1 2 3 | int max2(int a, int b) { return a > b ? a : b; } |
在x86-64机器上,
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | :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 |
由于使用了
那么,为什么有条件的移动表现更好呢?
在典型的
在分支案例中,下面的指令是由前面的指令决定的,因此我们不能进行流水线操作。我们要么等待,要么预测。
在条件移动情况下,执行条件移动指令分为几个阶段,但早期阶段如
《计算机系统:程序员的视角》一书第二版详细解释了这一点。您可以查看第3.6.6节中的条件移动指令,整个第4章中的处理器架构,以及第5.11.2节中的分支预测和预测失误惩罚的特殊处理。
有时,一些现代编译器可以以更好的性能将代码优化为程序集,有时一些编译器不能(所讨论的代码使用的是Visual Studio的本机编译器)。在不可预测的情况下知道分支和条件移动之间的性能差异可以帮助我们在场景变得如此复杂以至于编译器无法自动优化时编写性能更好的代码。
如果您对可以对该代码进行更多的优化感到好奇,请考虑:
从原始循环开始:
1 2 3 4 5 6 7 8 | for (unsigned i = 0; i < 100000; ++i) { for (unsigned j = 0; j < arraySize; ++j) { if (data[j] >= 128) sum += data[j]; } } |
通过循环交换,我们可以安全地将此循环更改为:
1 2 3 4 5 6 7 8 | for (unsigned j = 0; j < arraySize; ++j) { for (unsigned i = 0; i < 100000; ++i) { if (data[j] >= 128) sum += data[j]; } } |
然后,您可以看到在执行
1 2 3 4 5 6 7 8 9 10 | for (unsigned j = 0; j < arraySize; ++j) { if (data[j] >= 128) { for (unsigned i = 0; i < 100000; ++i) { sum += data[j]; } } } |
然后,您会看到内部循环可以折叠成一个单独的表达式,假设浮点模型允许这样做(例如,抛出了/fp:fast)
1 2 3 4 5 6 7 | for (unsigned j = 0; j < arraySize; ++j) { if (data[j] >= 128) { sum += data[j] * 100000; } } |
那个比以前快10万倍
毫无疑问,我们中的一些人会对识别CPU分支预测器有问题的代码的方法感兴趣。valgrind工具
排序:
1 2 3 | ==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% ) |
未分类的:
1 2 3 | ==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% ) |
从
排序:
1 2 3 4 5 6 7 8 9 10 | 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]; . . . . } . . . . } |
未分类的:
1 2 3 4 5 6 7 8 9 10 | 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]; . . . . } . . . . } |
这让您很容易识别出问题行——在未排序版本中,在cachegrind的分支预测模型下,
或者,在Linux上,您可以使用性能计数器子系统来完成相同的任务,但使用CPU计数器实现本机性能。
1 | perf stat ./sumtest_sorted |
排序:
1 2 3 4 5 6 7 8 9 10 11 12 | 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 |
未分类的:
1 2 3 4 5 6 7 8 9 10 11 12 | 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 |
它还可以使用disassembly进行源代码注释。
1 2 | perf record -e branch-misses ./sumtest_unsorted perf annotate -d sumtest_unsorted |
1 2 3 4 5 6 7 8 9 10 | 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) ... |
有关详细信息,请参阅性能教程。
我刚刚读了这个问题及其答案,我觉得答案不见了。
消除分支预测的一个常见方法是使用表查找,而不是使用分支(尽管在本例中我没有测试它)。
在以下情况下,此方法通常有效:
背景和原因
从处理器的角度来看,您的内存很慢。为了补偿速度上的差异,处理器中内置了两个缓存(一级/二级缓存)。所以想象一下,你正在做你的计算,并发现你需要一段记忆。处理器将得到它的"加载"操作,并将内存块加载到缓存中——然后使用缓存进行其余的计算。因为内存相对较慢,所以这个"加载"会减慢程序的运行速度。
与分支预测一样,这是在奔腾处理器中优化的:处理器预测它需要加载一段数据,并尝试在操作实际到达缓存之前将其加载到缓存中。正如我们已经看到的,分支预测有时会出现可怕的错误——在最坏的情况下,您需要返回并实际等待一个内存负载,而这需要花费很长时间(换句话说:失败的分支预测是坏的,分支预测失败后的内存负载是可怕的!).
幸运的是,如果内存访问模式是可预测的,处理器将把它加载到其快速缓存中,一切都很好。
我们首先要知道的是什么是小的?虽然通常较小更好,但经验法则是坚持使用大小小于等于4096字节的查找表。作为上限:如果您的查阅表格大于64K,则可能需要重新考虑。
构造表
所以我们发现我们可以创建一个小表。下一步要做的是将查找函数放置到位。查找函数通常是使用两个基本整数操作的小函数(AND、OR、XOR、SHIFT、ADD、REMOVE和PLUS)。您希望通过lookup函数将输入转换为表中的某种"唯一键",然后简单地给出您想要它做的所有工作的答案。
在这种情况下:>=128意味着我们可以保留这个值,<128意味着我们可以去掉它。最简单的方法是使用一个"and":如果我们保留它,我们用7fffffff和它;如果我们想去掉它,我们用0和它。还要注意128是2的幂,所以我们可以做一个由32768/128个整数组成的表,并用一个零和大量的7ffffff填充它。
托管语言
您可能想知道为什么这种方法在托管语言中工作得很好。毕竟,托管语言使用分支检查数组的边界,以确保不会弄乱…
嗯,不完全是…-)
在消除托管语言的这个分支方面已经做了相当多的工作。例如:
1 2 3 4 | for (int i = 0; i < array.Length; ++i) { // Use array[i] } |
在这种情况下,编译器很明显永远不会碰到边界条件。至少微软JIT编译器(但我希望Java做类似的事情)会注意到这一点,并完全删除检查。哇,那意味着没有分支。同样,它也将处理其他明显的情况。
如果您在使用托管语言进行查找时遇到问题--关键是向查找函数中添加一个
这个案子的结果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 | // 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(); |
当数组排序时,数据分布在0和255之间,大约上半个迭代不会进入
1 2 | if (data[c] >= 128) sum += data[c]; |
问题是:在某些情况下,为什么上述语句不能像排序数据那样执行?这里是"分支预测器"。分支预测器是一种数字电路,它试图猜测分支(如
让我们做些基准测试来更好地理解它
让我们来测量这个循环在不同条件下的性能:
1 2 3 | for (int i = 0; i < max; i++) if (condition) sum++; |
以下是具有不同真假模式的循环的计时:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | 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 |
一个"坏"的真假模式可以使一个
因此,毫无疑问,分支预测对性能的影响!
避免分支预测错误的一种方法是构建一个查找表,并使用数据对其进行索引。斯特凡·德·布鲁恩在回答中讨论了这一点。
但在这种情况下,我们知道值在[0,255]范围内,我们只关心大于等于128的值。这意味着我们可以很容易地提取一个位,它将告诉我们是否需要一个值:通过将数据右移7位,我们只剩下0位或1位,我们只想在有1位的时候添加值。让我们把这个位称为"决策位"。
通过使用决策位的0/1值作为数组的索引,无论数据是否排序,我们都可以使代码具有相同的速度。我们的代码总是会添加一个值,但是当决策位为0时,我们会在不关心的地方添加值。代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 | // 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语句的版本快得多。
但是在我的测试中,一个显式查找表比这个稍快,可能是因为索引到一个查找表比移位稍快。这显示了我的代码如何设置和使用查找表(代码中的"查找表"不可想象地称为
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | // 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值的表,这可能是可行的。
索引到数组中的技术,而不是使用
1 2 3 4 | if (x < node->value) node = node->pLeft; else node = node->pRight; |
此库将执行以下操作:
1 2 | i = (x < node->value); node = node->link[i]; |
这是一个链接到这个代码:红黑树,永远困惑
在已排序的情况下,您可以比依赖成功的分支预测或任何无分支比较技巧做得更好:完全删除分支。
实际上,数组是在与
类似(未选中)
1 2 3 4 5 6 7 8 9 10 11 12 | 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]; |
或者,稍微模糊一点
1 2 3 4 5 | 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]; |
一个更快的方法,给出了分类或未分类的近似解是:
由于分支预测,上述行为正在发生。
要了解分支预测,首先必须了解指令管道:
任何指令都被分解成一系列步骤,以便不同的步骤可以并行执行。这种技术被称为指令管道,用于提高现代处理器的吞吐量。为了更好地理解这一点,请参阅维基百科上的这个例子。
一般来说,现代处理器有相当长的管道,但为了方便起见,我们只考虑这4个步骤。
一般为4级管道,用于2个说明。
回到上面的问题,让我们考虑以下说明:
1 2 3 4 5 6 7 8 9 10 | A) if (data[c] >= 128) /\ / \ / \ true / \ false / \ / \ / \ / \ B) sum += data[c]; C) for loop or print(). |
如果没有分支预测,将发生以下情况:
要执行指令B或指令C,处理器必须等到指令A到达管道中的前一个阶段,因为进入指令B或指令C的决定取决于指令A的结果。因此管道看起来是这样的。
当if条件返回true时:
当if条件返回false时:
由于等待指令A的结果,在上述情况下(不进行分支预测;判断正确与否)花费的总CPU周期为7。
那么什么是分支预测呢?
分支预测器将尝试猜测分支(if-then-else结构)将朝哪个方向发展,然后才能确定这一点。它不会等待指令A到达管道的前阶段,但它会猜测决定并转到该指令(在我们的示例中是B或C)。
如果猜测正确,管道看起来如下:
如果后来检测到猜测是错误的,那么部分执行的指令将被丢弃,管道将以正确的分支重新开始,从而导致延迟。分支预测失误时浪费的时间等于从获取阶段到执行阶段的管道中的阶段数。现代微处理器往往具有相当长的管道,因此预测失误延迟在10到20个时钟周期之间。管道越长,就越需要一个好的分支预测器。
在操作码中,第一次当条件发生时,分支预测器没有任何信息来作为预测的基础,所以第一次它将随机选择下一条指令。稍后在for循环中,它可以基于历史进行预测。对于按升序排序的数组,有三种可能性:
让我们假设预测器在第一次运行时总是假定为真正的分支。
因此,在第一种情况下,它总是采取真正的分支,因为历史上它的所有预测都是正确的。在第二种情况下,最初它将预测错误,但经过几次迭代后,它将正确预测。在第三种情况下,它最初将正确预测,直到元素小于128。之后,当它在历史上看到分支预测失败时,它将失败一段时间,并纠正自己。
在所有这些情况下,失败的数量将太少,因此,只需要几次就可以丢弃部分执行的指令,并使用正确的分支重新开始,从而减少CPU周期。
但是,如果是一个随机的未排序数组,那么预测将需要丢弃部分执行的指令,并在大多数时间重新开始正确的分支,与排序数组相比,这将导致更多的CPU周期。
官方答复是
你也可以从这个可爱的图表中看到为什么分支预测器会被混淆。
原始代码中的每个元素都是随机值
1 | data[c] = std::rand() % 256; |
因此,预测因子将随着
另一方面,一旦排序,预测器将首先进入强不取的状态,当值变为高值时,预测器将在三次运行中从强不取变为强取。
在同一行中(我认为没有任何答案强调这一点),值得一提的是,有时(特别是在性能很重要的软件中,如Linux内核中),您可以找到如下一些if语句:
1 2 3 4 | if (likely( everything_is_ok )) { /* Do something */ } |
或类似地:
1 2 3 4 | if (unlikely(very_improbable_condition)) { /* Do something */ } |
实际上,
通常,这种优化主要存在于实时应用程序或嵌入式系统中,在这些应用程序或嵌入式系统中,执行时间至关重要。例如,如果您正在检查只发生了1/10000000次的错误条件,那么为什么不通知编译器呢?这样,默认情况下,分支预测将假定条件为假。
C++中常用的布尔运算在编译程序中产生多个分支。如果这些分支在循环中并且难以预测,那么它们会显著降低执行速度。布尔变量存储为8位整数,值为
布尔变量被过度确定,因为所有使用布尔变量作为输入的运算符都会检查输入是否有除
1 2 3 | bool a, b, c, d; c = a && b; d = a || b; |
这通常由编译器以以下方式实现:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 | 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; } |
这段代码远不是最佳的。如果预测失误,分支可能需要很长时间。如果确信操作数没有除
1 2 3 | char a = 0, b = 1, c, d; c = a & b; d = a | b; |
使用
1 2 | bool a, b; b = !a; |
可优化为:
1 2 | char a = 0, b; b = a ^ 1; |
如果
如果操作数是变量,则使用位运算符比比较操作数更为有利:
1 2 | bool a; double x, y, z; a = x > y && z < 5.0; |
在大多数情况下都是最佳的(除非您希望
那是肯定的!…
分支预测使逻辑运行速度变慢,因为代码中发生了切换!就像你走的是一条直的街道或者一条有很多转弯的街道,当然,直的那条会做得更快!…
如果对数组进行排序,则第一步的条件为false:
看看下面我为你创建的图片。哪条街道的完工速度更快?
因此,以编程的方式,分支预测会导致进程变慢…
最后,很高兴知道我们有两种分支预测,每种预测都会对代码产生不同的影响:
1。静态的
2。动态的
Static branch prediction is used by the microprocessor the first time
a conditional branch is encountered, and dynamic branch prediction is
used for succeeding executions of the conditional branch code.In order to effectively write your code to take advantage of these
rules, when writing if-else or switch statements, check the most
common cases first and work progressively down to the least common.
Loops do not necessarily require any special ordering of code for
static branch prediction, as only the condition of the loop iterator
is normally used.
这个问题已经被很好地回答了很多次。不过,我还是想让小组注意到另一个有趣的分析。
最近,这个示例(修改得很小)也被用作演示如何在Windows上在程序本身中对一段代码进行分析的方法。在此过程中,作者还演示了如何使用结果来确定代码在排序的和未排序的情况下的大部分时间。最后,本文还展示了如何使用HAL(硬件抽象层)的一个鲜为人知的特性来确定在未排序的情况下发生了多少分支预测失误。
链接如下:http://www.geoffschappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm(研究)
正如其他人已经提到的,背后的奥秘是分支预测器。
我不是想添加什么,而是用另一种方式解释这个概念。在wiki上有一个包含文本和图表的简明介绍。我确实喜欢下面的解释,它使用一个图表来直观地阐述分支预测器。
In computer architecture, a branch predictor is a
digital circuit that tries to guess which way a branch (e.g. an
if-then-else structure) will go before this is known for sure. The
purpose of the branch predictor is to improve the flow in the
instruction pipeline. Branch predictors play a critical role in
achieving high effective performance in many modern pipelined
microprocessor architectures such as x86.Two-way branching is usually implemented with a conditional jump
instruction. A conditional jump can either be"not taken" and continue
execution with the first branch of code which follows immediately
after the conditional jump, or it can be"taken" and jump to a
different place in program memory where the second branch of code is
stored. It is not known for certain whether a conditional jump will be
taken or not taken until the condition has been calculated and the
conditional jump has passed the execution stage in the instruction
pipeline (see fig. 1).
基于所描述的场景,我编写了一个动画演示,演示如何在不同的情况下在管道中执行指令。
Without branch prediction, the processor would have to wait until the
conditional jump instruction has passed the execute stage before the
next instruction can enter the fetch stage in the pipeline.
该示例包含三个指令,第一个是条件转移指令。后两条指令可以进入管道,直到执行条件转移指令。
完成3条指令需要9个时钟周期。
完成3条指令需要7个时钟周期。
完成3条指令需要9个时钟周期。
The time that is wasted in case of a branch misprediction is equal to
the number of stages in the pipeline from the fetch stage to the
execute stage. Modern microprocessors tend to have quite long
pipelines so that the misprediction delay is between 10 and 20 clock
cycles. As a result, making a pipeline longer increases the need for a
more advanced branch predictor.
如你所见,我们似乎没有理由不使用分支预测。
这是一个非常简单的演示,阐明了分支预测器的基本部分。如果这些gif很烦人,请随意将其从答案中删除,访问者也可以从git获得演示。
分支预测增益!
重要的是要理解分支预测失误不会减慢程序的速度。错过的预测的代价就好像分支预测不存在,而您等待表达式的评估来决定运行什么代码(下一段将进一步解释)。
1 2 3 4 5 6 | if (expression) { // Run 1 } else { // Run 2 } |
每当有
分支指令可能导致计算机开始执行不同的指令序列,从而偏离其按顺序执行指令的默认行为(即,如果表达式为假,则程序根据某些条件跳过
也就是说,编译器试图在实际评估结果之前对结果进行预测。它将从
假设您需要选择1号或2号路线。等待你的搭档检查地图,你已经在停下来等待,或者你可以选择1号路线,如果你运气好(1号路线是正确的路线),那么很好,你不必等待你的搭档检查地图(你节省了他检查地图的时间),否则你会掉头。
虽然冲洗管道的速度非常快,但现在冒这个险是值得的。预测排序数据或变化缓慢的数据总是比预测快速变化更容易和更好。
1 2 3 4 5 6 | O Route 1 /------------------------------- /|\ / | ---------##/ / \ \ \ Route 2 \-------------------------------- |
这是关于分支预测的。这是怎么一回事?
分支预测器是一种古老的性能改进技术,在现代建筑中仍然具有重要意义。虽然简单的预测技术提供了快速查找和电源效率,但它们的预测失误率很高。
另一方面,复杂的分支预测——无论是基于神经的还是两级分支预测的变体——提供了更好的预测精度,但它们消耗了更多的能量,并且复杂性呈指数级增加。
除此之外,在复杂的预测技术中,预测分支所花费的时间本身非常高,从2到5个周期不等,这与实际分支的执行时间相当。
分支预测本质上是一个优化(最小化)问题,重点是以最少的资源实现可能的最低误码率、低功耗和低复杂性。
实际上有三种不同的分支:
转发条件分支-基于运行时条件,PC(程序计数器)更改为指向指令流中转发的地址。
反向条件分支-PC被更改为指向指令流中的反向。分支是基于某些条件的,例如当循环结束时的测试表明应该再次执行循环时,向后分支到程序循环的开始。
无条件分支-这包括没有特定条件的跳转、过程调用和返回。例如,无条件跳转指令可以用汇编语言简单地编码为"jmp",指令流必须立即定向到跳转指令指向的目标位置,而可能编码为"jmpen"的条件跳转只有在比较结果为前一个"比较"指令中的两个值显示值不相等。(x86体系结构使用的分段寻址方案增加了额外的复杂性,因为跳转可以是"近"(在一个段内)或"远"(在段外)。每种类型对分支预测算法都有不同的影响。)
静态/动态分支预测:微处理器在第一次遇到条件分支时使用静态分支预测,动态分支预测用于后续执行条件分支代码。
参考文献:
支路预测器
自我剖析的演示
分行预测审核
分支预测
在ARM上,不需要分支,因为每个指令都有一个4位条件字段,它是以零成本测试的。这就消除了对短分支的需求,并且不会有分支预测命中。因此,由于排序的额外开销,排序版本的运行速度将比ARM上的未排序版本慢。内部循环如下所示:
1 2 3 4 5 6 7 8 9 10 | 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 |
除了分支预测可能会减慢速度之外,排序数组还有另一个优势:
您可以有一个停止条件,而不只是检查值,这样您只循环相关的数据,而忽略其余的数据。分支预测将只错过一次。
1 2 3 4 5 6 7 8 9 | // 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]; } |
由于称为分支预测的现象,排序数组的处理速度比未排序数组快。
分支预测器是一种数字电路(在计算机体系结构中),试图预测分支的走向,从而改善指令管道中的流程。电路/计算机预测下一步并执行。
做出错误的预测会导致返回到上一步,并使用另一个预测执行。假设预测正确,代码将继续执行下一步。错误的预测导致重复相同的步骤,直到正确的预测发生。
你问题的答案很简单。
在未排序的数组中,计算机进行多个预测,从而增加出错的可能性。然而,在排序中,计算机做的预测更少,减少了出错的机会。做更多的预测需要更多的时间。
排序数组:直线
1 2 3 | ____________________________________________________________________________________ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - TTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTT |
未排序阵列:弯曲道路
1 2 | ______ ________ | |__| |
支路预测:猜测/预测哪条路是直的,不检查就沿着它走。
1 2 | ___________________________________________ Straight road |_________________________________________|Longer road |
虽然两条道路都到达同一个目的地,但笔直的道路较短,而另一条则较长。如果你错误地选择了另一条,就不会掉头,所以如果你选择更长的路,你会浪费一些额外的时间。这与计算机上发生的情况类似,我希望这能帮助您更好地理解。
另外,我想从评论中引用@simon_weaver:
It doesn’t make fewer predictions - it makes fewer incorrect predictions. It still has to predict for each time through the loop..
其他答案认为需要对数据进行排序的假设是不正确的。
下面的代码不会对整个数组进行排序,但只对其中的200个元素段进行排序,因此运行速度最快。
仅对k元素部分进行排序,以线性时间而不是
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 | #include #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; } |
这也"证明"了它与任何算法问题(如排序顺序)无关,而且它确实是分支预测。