这是一段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)
{
// 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:
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);
}
}
具有相似但不太极端的结果。
我首先想到的是排序将数据带入缓存,但是后来我想到这样做是多么愚蠢,因为刚刚生成了数组。
- 到底是怎么回事?
- 为什么处理排序数组要比处理未排序数组快?
该代码总结了一些独立的术语,因此顺序无关紧要。
#1楼
分支预测。
对于排序数组,条件data[c] >= 128
对于值的条纹首先为false
,然后对所有后续值变为true
。 这很容易预测。 使用未排序的数组,您需要支付分支成本。
#2楼
您是分支预测失败的受害者。
什么是分支预测?
考虑一个铁路枢纽:
Mecanismo的图片 ,通过Wikimedia Commons。在CC-By-SA 3.0许可下使用。
现在,为了论证,假设这是在1800年代-在进行长距离或无线电通信之前。
您是路口的操作员,并且听到火车驶入。 您不知道应该走哪条路。 您停下火车,询问驾驶员他们想要哪个方向。 然后您适当地设置开关。
火车很重,惯性很大。 因此,它们要花很多时间才能启动和减速。
有没有更好的办法? 您猜火车将朝哪个方向行驶!
- 如果您猜对了,它将继续进行。
- 如果您猜错了,机长会停下来,后退并大喊大叫,以拨动开关。 然后,它可以沿着其他路径重新启动。
如果您每次都猜对了 ,火车将永远不会停止。
如果您经常猜错 ,火车将花费大量时间停止,备份和重新启动。
考虑一个if语句:在处理器级别,它是一条分支指令:
您是处理器,并且看到一个分支。 您不知道它将走哪条路。 你是做什么? 您停止执行,并等待之前的说明完成。 然后,您沿着正确的路径继续。
现代处理器很复杂,而且流程很长。 因此,他们需要永远进行“热身”和“减速”。
有没有更好的办法? 您猜分支将朝哪个方向前进!
- 如果您猜对了,则继续执行。
- 如果您猜错了,则需要刷新管道并回滚到分支。 然后,您可以沿着其他路径重新启动。
如果您每次都猜对了 ,执行将永远不会停止。
如果您经常猜错 ,那么您将花费大量时间来拖延,回滚和重新启动。
这是分支预测。 我承认这不是最好的类比,因为火车可以只用一个标志来指示方向。 但是在计算机中,处理器直到最后一刻才知道分支的方向。
那么,您如何从战略上猜测如何将火车必须倒退和走另一条路的次数降至最低? 您看看过去的历史! 如果火车有99%的时间向左行驶,那么您就猜到了。 如果它交替出现,那么您将交替猜测。 如果它每三回去一次,您会猜到相同...
换句话说,您尝试识别一个模式并遵循它。 这或多或少是分支预测变量的工作方式。
大多数应用程序具有行为良好的分支。 因此,现代分支预测器通常将达到90%以上的命中率。 但是,当面对没有可识别模式的不可预测分支时,分支预测变量实际上是无用的。
进一步阅读: 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, 133, ...
branch = T, T, N, T, T, T, T, N, T, N, N, T, T, T, N ...
= TTNTTTTNTNNTTTN ... (completely random - hard to predict)
那该怎么办呢?
如果编译器无法将分支优化为有条件的移动,那么如果您愿意牺牲可读性来提高性能,则可以尝试一些破解。
更换:
if (data[c] >= 128)
sum += data[c];
与:
int t = (data[c] - 128) >> 31;
sum += ~t & data[c];
这消除了分支,并用一些按位运算将其替换。
(请注意,这种破解并不严格等同于原始的if语句。但是在这种情况下,它对data[]
所有输入值均有效。)
基准:Core i7 920 @ 3.5 GHz
C ++-Visual Studio 2010-x64版本
// Branch - Random
seconds = 11.777
// Branch - Sorted
seconds = 2.352
// Branchless - Random
seconds = 2.564
// Branchless - Sorted
seconds = 2.587
Java-NetBeans 7.1.1 JDK 7-x64
// Branch - Random
seconds = 10.93293813
// Branch - Sorted
seconds = 5.643797077
// Branchless - Random
seconds = 3.113581453
// Branchless - Sorted
seconds = 3.186068823
观察结果:
- 使用分支:排序和未排序的数据之间存在巨大差异。
- 使用Hack:排序和未排序的数据之间没有区别。
- 在C ++情况下,对数据进行排序时,hack实际上比分支慢一点。
一般的经验法则是避免在关键循环中避免依赖于数据的分支(例如在此示例中)。
更新:
在x64上带有
-O3
或-ftree-vectorize
GCC 4.6.1能够生成条件移动。 因此,已排序和未排序的数据之间没有区别-两者都很快速。即使在
/Ox
下,VC ++ 2010也无法为此分支生成条件移动。英特尔C ++编译器 (ICC)11发挥了神奇的作用。 它互换两个循环 ,从而将不可预测的分支提升到外部循环。 因此,它不仅可以避免错误预测,而且还比VC ++和GCC生成的速度快两倍! 换句话说,ICC利用测试循环来击败基准测试……
如果给Intel编译器提供无分支的代码,它将直接对其进行矢量化处理……并且速度与分支一样快(通过循环交换)。
这表明即使是成熟的现代编译器,其优化代码的能力也可能存在巨大差异。
#3楼
当对数据进行排序时,性能大幅提高的原因是消除了分支预测损失,如Mysticial的答案中所详细解释的那样。
现在,如果我们看一下代码
if (data[c] >= 128)
sum += data[c];
我们可以发现if... else...
分支的特定含义是在满足条件时添加一些内容。 这种类型的分支可以很容易地转换为条件移动语句,该条件语句将被编译为条件移动指令: cmovl
,在x86
系统中。 去除分支并因此去除潜在的分支预测损失。
在C
,因此在C++
,将直接(不进行任何优化)编译为x86
的条件移动指令的语句是三元运算符... ? ... : ...
... ? ... : ...
。 因此,我们将上面的语句重写为等效的语句:
sum += data[c] >=128 ? data[c] : 0;
在保持可读性的同时,我们可以检查加速因子。
在Intel Core i7 -2600K @ 3.4 GHz和Visual Studio 2010 Release Mode上,基准是(从Mysticial复制的格式):
x86
// Branch - Random
seconds = 8.885
// Branch - Sorted
seconds = 1.528
// Branchless - Random
seconds = 3.716
// Branchless - Sorted
seconds = 3.71
x64
// Branch - Random
seconds = 11.302
// Branch - Sorted
seconds = 1.830
// Branchless - Random
seconds = 2.736
// Branchless - Sorted
seconds = 2.737
在多次测试中,结果是可靠的。 当分支结果不可预测时,我们可以大大提高速度,但是当分支结果不可预测时,我们会受到一些影响。 实际上,使用条件移动时,无论数据模式如何,性能都是相同的。
现在,通过研究它们生成的x86
程序集,让我们更加仔细地研究。 为了简单起见,我们使用两个函数max1
和max2
。
if... else ...
max1
将使用条件分支:
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
处理器中,一条指令的执行分为几个阶段。 大致来说,我们有不同的硬件来处理不同的阶段。 因此,我们不必等待一条指令完成就可以开始一条新指令。 这称为流水线 。
在分支情况下,以下指令由前一条指令确定,因此我们无法进行流水线操作。 我们必须等待或预测。
在条件移动的情况下,执行条件移动指令分为几个阶段,但较早的阶段(如Fetch
和Decode
并不取决于前一条指令的结果; 只有后期才需要结果。 因此,我们等待一条指令执行时间的一小部分。 这就是为什么在容易预测的情况下有条件移动版本比分支慢的原因。
第二版 《 计算机系统:程序员的观点 》一书对此进行了详细说明。 您可以检查第3.6.6节中的“ 条件移动指令” ,第4章中的“ 处理器体系结构 ”和第5.11.2节中的“ 分支预测和错误预测惩罚”的特殊处理。
有时,某些现代的编译器可以优化我们的代码以使其具有更好的性能,而有时某些编译器则不能(问题代码使用Visual Studio的本机编译器)。 当情况变得如此复杂以至于编译器无法自动优化它们时,了解分支与条件移动之间的性能差异(这是不可预测的)可以帮助我们编写性能更高的代码。
#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
产生的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的branch-predictor模型下导致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)
...
有关更多详细信息,请参见性能教程 。
#6楼
我在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倍 。 换句话说,(分支预测)的效果是1.46x为MATLAB实现和2.7倍的C实现。
#7楼
当对数组进行排序时,由于数据分布在0到255之间,因此在迭代的前半部分将不会输入if
-statement(下面共享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
一个“ 坏 ”的真假模式会使if
语句的速度比“ 好 ”模式慢六倍! 当然,哪种模式好坏,取决于编译器和特定处理器生成的确切指令。
因此,毫无疑问分支预测对性能的影响!
#8楼
我只是阅读了这个问题及其答案,所以我觉得答案丢失了。
我发现在托管语言中消除分支预测的一种常见方法是使用表查找而不是使用分支(尽管在这种情况下我没有对其进行测试),这是消除查找的一种常见方法。
这种方法通常在以下情况下有效:
- 它是一个小表,可能会缓存在处理器中,并且
- 您正在以非常紧密的循环运行事物和/或处理器可以预加载数据。
背景以及原因
从处理器的角度来看,您的内存很慢。 为了弥补速度上的差异,处理器内置了两个缓存(L1 / L2缓存)。 因此,假设您正在执行出色的计算,并发现您需要一块内存。 处理器将执行其“加载”操作,并将内存加载到缓存中,然后使用缓存进行其余的计算。 由于内存相对较慢,因此此“加载”将减慢您的程序的速度。
像分支预测一样,它在奔腾处理器中进行了优化:处理器预测它需要加载一条数据,并在操作实际到达缓存之前尝试将其加载到缓存中。 正如我们已经看到的那样,分支预测有时会出现严重的错误-在最坏的情况下,您需要返回并实际上等待内存加载,这将永久占用内存( 换句话说:失败的分支预测很糟糕,内存不足)分支预测失败后的负载简直太可怕了! )。
对我们来说幸运的是,如果内存访问模式是可预测的,则处理器会将其加载到其快速缓存中,一切都很好。
我们需要知道的第一件事是小的 ? 虽然通常较小会更好,但经验法则是坚持使用<= 4096字节大小的查找表。 作为上限:如果您的查找表大于64K,则可能值得重新考虑。
构造表
因此,我们发现可以创建一个小表。 接下来要做的就是准备好查找功能。 查找函数通常是使用几个基本整数运算(以及,或“异或”,“移位”,“加法”,“删除”以及“也许乘”)的小函数。 您希望通过查找功能将您的输入转换为表中的某种“唯一键”,然后简单地为您提供所需的所有工作的答案。
在这种情况下:> = 128表示我们可以保留该值,<128表示我们可以摆脱它。 最简单的方法是使用“ AND”:如果我们保留它,我们将其与7FFFFFFF进行AND; 如果要删除它,则将其与0进行“与”运算。还要注意128是2的幂-因此我们可以继续制作一张32768/128整数的表,并用一个零和很多零填充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();
#9楼
避免分支预测错误的一种方法是建立查找表,并使用数据对其进行索引。 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语句的版本快得多。
但是在我的测试中,显式查找表的速度比此表稍快,这可能是因为索引到查找表的速度比移位略快。 这显示了我的代码如何设置和使用查找表(在代码中,“ LookUp Table”意为“ 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
或其他), pRight
一个长度为2的指针数组,并使用“决策位”技术来决定遵循哪个。 例如,代替:
if (x < node->value)
node = node->pLeft;
else
node = node->pRight;
该库将执行以下操作:
i = (x < node->value);
node = node->link[i];
这是此代码的链接: 红黑树 , 永远困惑
#10楼
在排序的情况下,您可以比依靠成功的分支预测或任何无分支比较技巧来做的更好:完全删除分支。
实际上,该数组在data < 128
和data >= 128
的连续区域中分区。 因此,您应该通过二分查找 (使用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) :-)
#11楼
由于分支预测,因此发生了上述现象。
要了解分支预测,首先必须了解指令流水线 :
任何指令都分为一系列步骤,以便可以并行并行执行不同的步骤。 该技术称为指令流水线,用于提高现代处理器的吞吐量。 为了更好地理解这一点,请参见Wikipedia上的示例 。
通常,现代处理器的流水线很长,但为简便起见,我们仅考虑这四个步骤。
- IF-从内存中获取指令
- ID-解码指令
- EX-执行指令
- WB-写回CPU寄存器
4级流水线一般用于2条指令。
回到上面的问题,让我们考虑以下指示:
A) if (data[c] >= 128)
/\
/ \
/ \
true / \ false
/ \
/ \
/ \
/ \
B) sum += data[c]; C) for loop or print().
如果没有分支预测,则会发生以下情况:
要执行指令B或指令C,处理器将必须等到指令A到达流水线中的EX阶段为止,因为转到指令B或指令C的决定取决于指令A的结果。因此,管线会像这样。
如果条件返回true:
如果条件返回假:
等待指令A的结果的结果是,在上述情况下(没有分支预测;对于true和false而言)花费的总CPU周期为7。
那么什么是分支预测?
分支预测器将尝试猜测在确定之前知道分支(if-then-else结构)的方向。 它不会等待指令A到达流水线的EX阶段,但会猜测该决定并转到该指令(在本例中为B或C)。
在正确猜测的情况下,管道如下所示:
如果以后检测到猜测是错误的,则将部分执行的指令丢弃,流水线将从正确的分支重新开始,从而导致延迟。 在分支预测错误的情况下浪费的时间等于从获取阶段到执行阶段的流水线中的阶段数。 现代微处理器往往具有很长的流水线,因此误预测延迟在10到20个时钟周期之间。 管道越长,对好的分支预测器的需求就越大。
在OP的代码中,有条件的第一次,分支预测变量没有任何信息可作为预测的基础,因此,第一次它将随机选择下一条指令。 稍后在for循环中,它可以将预测基于历史记录。 对于按升序排序的数组,存在三种可能性:
- 所有元素均小于128
- 所有元素都大于128
- 一些开始的新元素小于128,后来又大于128
让我们假设预测变量在首次运行时将始终假设为真实分支。
因此,在第一种情况下,由于历史上所有的预测都是正确的,因此它将始终采用真正的分支。 在第二种情况下,最初将预测错误,但是经过几次迭代后,它将正确预测。 在第3种情况下,它最初将正确预测直到元素少于128个。此后,它将失败一段时间并在历史中看到分支预测失败时进行自我纠正。
在所有这些情况下,故障的数量将太少,因此,仅需几次就可以丢弃部分执行的指令并从正确的分支重新开始,从而减少了CPU周期。
但是,如果是随机未排序的数组,则预测将需要丢弃部分执行的指令,并在大多数时间中从正确的分支重新开始,与排序后的数组相比,将导致更多的CPU周期。
#12楼
在同一行中(我认为这没有任何答案突出显示),值得一提的是,有时(尤其是在性能至关重要的软件中,例如在Linux内核中),您会找到一些if语句,如下所示:
if (likely( everything_is_ok ))
{
/* Do something */
}
或类似的:
if (unlikely(very_improbable_condition))
{
/* Do something */
}
实际上,approximate likely()
和unlikely()
都是通过使用类似GCC的__builtin_expect
定义的宏,以帮助编译器插入预测代码以考虑到用户提供的信息来满足条件。 GCC支持其他可能会改变正在运行的程序的行为或发出诸如清除缓存等低级指令的内建函数。请参阅本文档 ,其中提供了可用的GCC内建函数。
通常,这种优化主要在执行时间很重要的硬实时应用程序或嵌入式系统中找到。 例如,如果您要检查仅发生1/10000000次的错误情况,那么为什么不通知编译器呢? 这样,默认情况下,分支预测将假定条件为假。
#13楼
C ++中经常使用的布尔运算会在编译后的程序中产生许多分支。 如果这些分支位于循环内并且难以预测,则它们可能会大大降低执行速度。 布尔变量被存储为与值的8位整数0
为false
和1
为true
。
从所有将布尔变量作为输入的运算符检查输入是否具有除0
或1
之外的任何其他值的意义上,布尔变量被过分确定,但是将布尔作为输出的运算符不能产生除0
或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运算符( &
)和EXCLUSIVE OR运算符( ^
)的结果可能不一致。
~
不能用于NOT。 相反,您可以通过将变量与1
进行XOR运算,在已知为0
或1
的变量上进行布尔NOT运算:
bool a, b;
b = !a;
可以优化为:
char a = 0, b;
b = a ^ 1;
如果b
是在a
为false
不应该求值的表达式( &&
将不求b
, &
will),则b
a && b
不能用a & b
替换。 同样, a || b
a || b
不能用a | b
代替 a | b
如果b
是,如果不应该被求值的表达式a
是true
。
如果操作数是变量,则使用按位运算符要比比较操作数更有利:
bool a; double x, y, z;
a = x > y && z < 5.0;
在大多数情况下是最佳选择(除非您期望&&
表达式会产生许多分支错误预测)。
#14楼
官方答案将来自
- 英特尔-避免分支机构失职的成本
- 英特尔-分支和循环重组可防止错误预测
- 科学论文-分支预测计算机体系结构
- 书籍:JL Hennessy,DA Patterson:计算机体系结构:一种定量方法
- 科学出版物上的文章:TY Yeh,YN Patt在分支预测中做了很多。
您还可以从这张可爱的图表中看到分支预测变量为何会感到困惑。
原始代码中的每个元素都是一个随机值
data[c] = std::rand() % 256;
因此,随着std::rand()
打击,预测变量将改变方向。
另一方面,一旦将其排序,预测变量将首先进入强烈不采用的状态,并且当值更改为高值时,预测变量将在三个过程中从强烈不采用变为强烈采取。
#15楼
这个问题已经被回答了好多次了。 不过,我还是想提请小组注意另一个有趣的分析。
最近,该示例(略作修改)还用作演示如何在Windows上的程序本身中分析一段代码的方法。 在此过程中,作者还展示了如何使用结果来确定代码在排序和未排序情况下大部分时间都花在了哪里。 最后,这篇文章还展示了如何使用HAL(硬件抽象层)的一个鲜为人知的功能来确定在未排序的情况下发生了多少分支错误预测。
链接在这里: http : //www.geoffchappell.com/studies/windows/km/ntoskrnl/api/ex/profile/demo.htm
#16楼
这是肯定的!...
由于代码中发生的切换, 分支预测使逻辑运行速度变慢! 就像您要走在一条笔直的街道或拐弯处很多的街道上一样,确保笔直的街道会更快!
如果对数组进行了排序,则第一步的条件为false: data[c] >= 128
,然后到街道尽头的整个过程变为真值。 这样便可以更快地到达逻辑结尾。 另一方面,使用未排序的数组,您需要进行大量的翻转和处理,这肯定会使您的代码运行缓慢。
在下面查看我为您创建的图像。 哪条街的建成速度会更快?
因此,以编程方式, 分支预测会使流程变慢...
同样在最后,很高兴知道我们有两种分支预测,每种预测都会以不同的方式影响您的代码:
1.静态
2.动态
微处理器在第一次遇到条件分支时使用静态分支预测,而动态分支预测则用于成功执行条件分支代码。
为了有效地编写代码以利用这些规则,在编写if-else或switch语句时,请先检查最常见的情况,然后逐步减少到最不常见的情况。 对于静态分支预测,循环不一定需要任何特殊的代码顺序,因为通常仅使用循环迭代器的条件。
#17楼
分支预测收益!
重要的是要了解分支预测错误不会降低程序速度。 错过预测的代价就像不存在分支预测一样,您等待表达式的评估来确定要运行的代码(下一段中的进一步说明)。
if (expression)
{
// Run 1
} else {
// Run 2
}
只要有if-else
\\ switch
语句,就必须对表达式求值以确定应该执行哪个块。 在编译器生成的汇编代码中,插入了条件分支指令。
分支指令会导致计算机开始执行不同的指令序列,从而偏离计算机按顺序执行指令的默认行为(即,如果表达式为假,则程序将跳过if
块的代码),具体取决于某些条件。是本例中的表达评估。
就是说,编译器会在实际评估结果之前尝试预测结果。 它将从if
块中获取指令,并且如果表达式证明是正确的,那就太好了! 我们花了很多时间进行评估,并在代码上取得了进步。 如果不是,那么我们运行的是错误的代码,将刷新管道,并运行正确的块。
可视化:
假设您需要选择路线1或路线2,等待您的伴侣检查地图,您已经停在##并等待,或者您可以选择路线1,如果幸运的话(路线1是正确的路线),那就太好了,您不必等待您的伴侣检查地图(您节省了他检查地图所需的时间),否则您只需回头即可。
尽管冲洗管道非常快,但如今采取这种赌博是值得的。 预测排序的数据或变化缓慢的数据总是比预测快速变化更容易和更好。
O Route 1 /-------------------------------
/|\ /
| ---------##/
/ \ \
\
Route 2 \--------------------------------
#18楼
关于分支预测。 它是什么?
分支预测器是古老的性能改进技术之一,至今仍与现代建筑相关。 尽管简单的预测技术可提供快速查找和功率效率,但它们的误预测率很高。
另一方面,复杂的分支预测(基于神经的预测或两级分支预测的变体)可提供更好的预测精度,但它们消耗的功率更多,并且复杂度呈指数增长。
除此之外,在复杂的预测技术中,预测分支本身所花费的时间非常长-从2到5个周期-与实际分支的执行时间相当。
分支预测本质上是一个优化(最小化)问题,重点在于以最少的资源实现最低的未命中率,低功耗和低复杂度。
确实有三种不同的分支:
转发条件分支 -根据运行时条件,将PC(程序计数器)更改为指向指令流中的转发地址。
向后条件分支 -将PC更改为在指令流中指向向后。 该分支基于某种条件,例如,当循环末尾的测试表明该循环应再次执行时,则向后跳转到程序循环的开始。
无条件分支 -包括没有特定条件的跳转,过程调用和返回。 例如,无条件跳转指令可能用汇编语言编码为简单的“ jmp”,并且指令流必须立即定向到跳转指令所指向的目标位置,而条件跳转可能被编码为“ jmpne”仅当前一个“比较”指令中两个值的比较结果显示这些值不相等时,才会重定向指令流。 (x86体系结构使用的分段寻址方案增加了额外的复杂性,因为跳转可以是“近”(在段内)或“远”(在段外)。每种类型对分支预测算法的影响都不同。)
静态/动态分支预测 :第一次遇到条件分支时,微处理器会使用静态分支预测,而动态分支预测则用于条件分支代码的后续执行。
参考文献:
#19楼
正如其他人已经提到的那样,神秘的背后是Branch Predictor 。
我不是要添加任何内容,而是以另一种方式解释该概念。 Wiki上有一个简短的介绍,其中包含文本和图表。 我喜欢下面的解释,该解释使用图表直观地阐述Branch Predictor。
在计算机体系结构中,分支预测器是一种数字电路,它试图猜测在确定之前知道分支(例如,if-then-else结构)将走哪条路。 分支预测器的目的是改善指令管道中的流程。 在许多现代流水线微处理器体系结构(例如x86)中,分支预测器在实现高效能方面起着至关重要的作用。
通常通过条件跳转指令来实现双向分支。 有条件的跳转可以“不采用”并继续执行紧随条件跳转之后的代码的第一分支,也可以是“采用”并跳转到程序存储器中代码的第二分支所在的其他位置存储。 在计算条件并且条件跳转通过指令流水线的执行阶段之前,不确定是否将执行条件跳转(参见图1)。
基于所描述的场景,我编写了一个动画演示,以演示在不同情况下如何在管道中执行指令。
- 没有分支预测器。
如果没有分支预测,则处理器将必须等到条件跳转指令通过执行阶段后,下一条指令才能进入流水线中的提取阶段。
该示例包含三个指令,第一个是条件跳转指令。 后两条指令可以进入管道,直到执行条件跳转指令为止。
3条指令需要9个时钟周期才能完成。
- 使用Branch Predictor,不要进行条件跳转。 让我们假设预测没有采取条件转移。
3条指令需要7个时钟周期才能完成。
- 使用Branch Predictor并进行条件跳转。 让我们假设预测没有采取条件转移。
3条指令需要9个时钟周期才能完成。
在分支预测错误的情况下浪费的时间等于从获取阶段到执行阶段的流水线中的阶段数。 现代微处理器往往具有很长的流水线,因此误预测延迟在10到20个时钟周期之间。 结果,使流水线更长会增加对更高级的分支预测器的需求。
如您所见,似乎我们没有理由不使用Branch Predictor。
这是一个非常简单的演示,它阐明了Branch Predictor的最基本部分。 如果这些gif令人讨厌,请随时将其从答案中删除,访问者还可以从BranchPredictorDemo获取演示
#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];
}
#21楼
由于称为分支预测的现象,排序数组比未排序数组的处理速度更快。
分支预测器是一种数字电路(在计算机体系结构中),旨在预测分支将走的路,从而改善指令流水线中的流程。 电路/计算机预测下一步并执行。
做出错误的预测会导致返回上一步,并执行另一个预测。 假设预测正确,则代码将继续进行下一步。 错误的预测会导致重复相同的步骤,直到发生正确的预测。
您问题的答案非常简单。
在未排序的阵列中,计算机会做出多个预测,从而导致出现错误的可能性增加。 而在排序数组中,计算机做出的预测更少,从而减少了出错的机会。 做出更多的预测需要更多的时间。
排序的数组:直线____________________________________________________________________________________________________-------------------------------------
未排序的数组:弯曲的路
______ ________
| |__|
分支预测:猜测/预测哪条道路是直的,并在不检查的情况下遵循
___________________________________________ Straight road
|_________________________________________|Longer road
尽管两条道路都到达同一目的地,但直路较短,而另一条较长。 如果那样的话,您错误地选择了另一条路,那就没有回头路了,因此,如果您选择更长的路,则会浪费一些额外的时间。 这类似于计算机中发生的情况,希望这可以帮助您更好地理解。
我也想从评论中引用@Simon_Weaver :
它不会做出更少的预测-它会做出更少的错误预测。 它仍然需要对循环中的每次预测...
#22楼
在ARM上,不需要分支,因为每条指令都有一个4位条件字段,该条件字段以零成本进行了测试。 这消除了对短分支的需求,并且不会对分支预测造成任何影响。 因此,由于排序的额外开销,排序后的版本运行速度会比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
#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;
}
这也“证明”它与任何算法问题(例如排序顺序)无关,并且确实是分支预测。
#24楼
Bjarne Stroustrup对这个问题的回答 :
这听起来像一个面试问题。 是真的吗 你怎么知道的? 在不先进行测量的情况下回答有关效率的问题是一个坏主意,因此知道如何进行测量很重要。
因此,我尝试使用一百万个整数的向量,得到:
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]) …”
或等效操作。 对于排序的序列,测试始终为真,而对于随机序列,选择的分支随机变化。
另一个原因是,当向量已经排序时,我们不需要将元素移到正确的位置。 这些小细节的影响是我们看到的5或6倍。
快速排序(通常是排序)是一项复杂的研究,吸引了一些计算机科学的杰出人士。 好的排序功能是选择好的算法并在实现过程中注意硬件性能的结果。
如果要编写高效的代码,则需要了解一些有关计算机体系结构的知识。
#25楼
此问题源于CPU上的分支预测模型。 我建议阅读此报告:
对元素进行排序后,IR不会一次又一次地获取所有CPU指令,而是从缓存中获取它们。
#26楼
正如我前面有很多人提到的那样,有很多细节; 但是,简单来说如下
可以较早地做出决定,例如搜索一个数字,在这种情况下,如果您要查找的num比数组中存在的数少,那么由于对数组进行了排序,因此可以消除一半的搜索将是O(1/2)。
顺便说一句,正如任何人都会注意到的那样,以上行为是与语言无关的,我的意思是,总是会有更少的指令被执行,因此时间更快。