了解分支预测效率
Understanding branch prediction efficiency
我尝试测量分支预测成本,我创建了一个小程序。
它在堆栈上创建了一个小缓冲区,用随机的 0/1 填充。我可以用 N
设置缓冲区的大小。该代码重复导致相同 1<<N
个随机数的分支。
现在,我预计,如果 1<<N
足够大(比如 >100),那么分支预测器将不会有效(因为它必须预测 >100 个随机数)。然而,这些是结果(在 5820k 机器上),随着 N
的增长,程序变慢了:
N time
=========
8 2.2
9 2.2
10 2.2
11 2.2
12 2.3
13 4.6
14 9.5
15 11.6
16 12.7
20 12.9
作为参考,如果缓冲区用零初始化(使用注释init
),时间或多或少是常数,对于N
8..16,它在1.5-1.7之间变化.
我的问题是:分支预测器能否有效预测如此大量的随机数?如果不是,那这是怎么回事?
(更多解释:代码执行 2^32 个分支,与 N
无关。所以我预计,代码运行相同的速度,无论 N
,因为根本无法预测分支。但似乎如果缓冲区大小小于 4096(N
<=12),某些东西会使代码变快。分支预测对 4096 个随机数是否有效?)
代码如下:
#include <cstdint>
#include <iostream>
volatile uint64_t init[2] = { 314159165, 27182818 };
// volatile uint64_t init[2] = { 0, 0 };
volatile uint64_t one = 1;
uint64_t next(uint64_t s[2]) {
uint64_t s1 = s[0];
uint64_t s0 = s[1];
uint64_t result = s0 + s1;
s[0] = s0;
s1 ^= s1 << 23;
s[1] = s1 ^ s0 ^ (s1 >> 18) ^ (s0 >> 5);
return result;
}
int main() {
uint64_t s[2];
s[0] = init[0];
s[1] = init[1];
uint64_t sum = 0;
#if 1
const int N = 16;
unsigned char buffer[1<<N];
for (int i=0; i<1<<N; i++) buffer[i] = next(s)&1;
for (uint64_t i=0; i<uint64_t(1)<<(32-N); i++) {
for (int j=0; j<1<<N; j++) {
if (buffer[j]) {
sum += one;
}
}
}
#else
for (uint64_t i=0; i<uint64_t(1)<<32; i++) {
if (next(s)&1) {
sum += one;
}
}
#endif
std::cout<<sum<<"\n";
}
(该代码还包含一个非缓冲版本,使用 #if 0
。它的运行速度与使用 N=16
的缓冲版本大致相同)
这是内部循环反汇编(用 clang 编译。它为 8..16 之间的所有 N
生成相同的代码,只是循环计数不同。Clang 展开循环两次):
401270: 80 3c 0c 00 cmp BYTE PTR [rsp+rcx*1],0x0
401274: 74 07 je 40127d <main+0xad>
401276: 48 03 35 e3 2d 00 00 add rsi,QWORD PTR [rip+0x2de3] # 404060 <one>
40127d: 80 7c 0c 01 00 cmp BYTE PTR [rsp+rcx*1+0x1],0x0
401282: 74 07 je 40128b <main+0xbb>
401284: 48 03 35 d5 2d 00 00 add rsi,QWORD PTR [rip+0x2dd5] # 404060 <one>
40128b: 48 83 c1 02 add rcx,0x2
40128f: 48 81 f9 00 00 01 00 cmp rcx,0x10000
401296: 75 d8 jne 401270 <main+0xa0>
分支预测可以这么有效。正如 Peter Cordes 所建议的,我已经用 perf stat
检查了分支未命中。以下是结果:
N time cycles branch-misses (%) approx-time
===============================================================
8 2.2 9,084,889,375 34,806 ( 0.00) 2.2
9 2.2 9,212,112,830 39,725 ( 0.00) 2.2
10 2.2 9,264,903,090 2,394,253 ( 0.06) 2.2
11 2.2 9,415,103,000 8,102,360 ( 0.19) 2.2
12 2.3 9,876,827,586 27,169,271 ( 0.63) 2.3
13 4.6 19,572,398,825 486,814,972 (11.33) 4.6
14 9.5 39,813,380,461 1,473,662,853 (34.31) 9.5
15 11.6 49,079,798,916 1,915,930,302 (44.61) 11.7
16 12.7 53,216,900,532 2,113,177,105 (49.20) 12.7
20 12.9 54,317,444,104 2,149,928,923 (50.06) 12.9
Note: branch-misses (%) is calculated for 2^32 branches
如您所见,当N<=12
时,分支预测器可以预测大部分分支(令人惊讶:分支预测器可以记住4096个连续随机分支的结果!)。当 N>12
时,分支未命中数开始增加。在 N>=16
,它只能正确预测 ~50%,这意味着它与随机抛硬币一样有效。
所花费的时间可以通过查看时间和分支未命中 (%) 列来估算:我添加了最后一列,approx-time
。我是这样计算的:2.2+(12.9-2.2)*branch-misses %/100
。如您所见,approx-time
等于 time
(不考虑舍入误差)。所以这个效果完全可以用分支预测来解释。
最初的目的是计算一个分支未命中的周期数(在这种特殊情况下 - 对于其他情况,这个数字可能不同):
(54,317,444,104-9,084,889,375)/(2,149,928,923-34,806) = 21.039 = ~21 cycles.
我尝试测量分支预测成本,我创建了一个小程序。
它在堆栈上创建了一个小缓冲区,用随机的 0/1 填充。我可以用 N
设置缓冲区的大小。该代码重复导致相同 1<<N
个随机数的分支。
现在,我预计,如果 1<<N
足够大(比如 >100),那么分支预测器将不会有效(因为它必须预测 >100 个随机数)。然而,这些是结果(在 5820k 机器上),随着 N
的增长,程序变慢了:
N time
=========
8 2.2
9 2.2
10 2.2
11 2.2
12 2.3
13 4.6
14 9.5
15 11.6
16 12.7
20 12.9
作为参考,如果缓冲区用零初始化(使用注释init
),时间或多或少是常数,对于N
8..16,它在1.5-1.7之间变化.
我的问题是:分支预测器能否有效预测如此大量的随机数?如果不是,那这是怎么回事?
(更多解释:代码执行 2^32 个分支,与 N
无关。所以我预计,代码运行相同的速度,无论 N
,因为根本无法预测分支。但似乎如果缓冲区大小小于 4096(N
<=12),某些东西会使代码变快。分支预测对 4096 个随机数是否有效?)
代码如下:
#include <cstdint>
#include <iostream>
volatile uint64_t init[2] = { 314159165, 27182818 };
// volatile uint64_t init[2] = { 0, 0 };
volatile uint64_t one = 1;
uint64_t next(uint64_t s[2]) {
uint64_t s1 = s[0];
uint64_t s0 = s[1];
uint64_t result = s0 + s1;
s[0] = s0;
s1 ^= s1 << 23;
s[1] = s1 ^ s0 ^ (s1 >> 18) ^ (s0 >> 5);
return result;
}
int main() {
uint64_t s[2];
s[0] = init[0];
s[1] = init[1];
uint64_t sum = 0;
#if 1
const int N = 16;
unsigned char buffer[1<<N];
for (int i=0; i<1<<N; i++) buffer[i] = next(s)&1;
for (uint64_t i=0; i<uint64_t(1)<<(32-N); i++) {
for (int j=0; j<1<<N; j++) {
if (buffer[j]) {
sum += one;
}
}
}
#else
for (uint64_t i=0; i<uint64_t(1)<<32; i++) {
if (next(s)&1) {
sum += one;
}
}
#endif
std::cout<<sum<<"\n";
}
(该代码还包含一个非缓冲版本,使用 #if 0
。它的运行速度与使用 N=16
的缓冲版本大致相同)
这是内部循环反汇编(用 clang 编译。它为 8..16 之间的所有 N
生成相同的代码,只是循环计数不同。Clang 展开循环两次):
401270: 80 3c 0c 00 cmp BYTE PTR [rsp+rcx*1],0x0
401274: 74 07 je 40127d <main+0xad>
401276: 48 03 35 e3 2d 00 00 add rsi,QWORD PTR [rip+0x2de3] # 404060 <one>
40127d: 80 7c 0c 01 00 cmp BYTE PTR [rsp+rcx*1+0x1],0x0
401282: 74 07 je 40128b <main+0xbb>
401284: 48 03 35 d5 2d 00 00 add rsi,QWORD PTR [rip+0x2dd5] # 404060 <one>
40128b: 48 83 c1 02 add rcx,0x2
40128f: 48 81 f9 00 00 01 00 cmp rcx,0x10000
401296: 75 d8 jne 401270 <main+0xa0>
分支预测可以这么有效。正如 Peter Cordes 所建议的,我已经用 perf stat
检查了分支未命中。以下是结果:
N time cycles branch-misses (%) approx-time
===============================================================
8 2.2 9,084,889,375 34,806 ( 0.00) 2.2
9 2.2 9,212,112,830 39,725 ( 0.00) 2.2
10 2.2 9,264,903,090 2,394,253 ( 0.06) 2.2
11 2.2 9,415,103,000 8,102,360 ( 0.19) 2.2
12 2.3 9,876,827,586 27,169,271 ( 0.63) 2.3
13 4.6 19,572,398,825 486,814,972 (11.33) 4.6
14 9.5 39,813,380,461 1,473,662,853 (34.31) 9.5
15 11.6 49,079,798,916 1,915,930,302 (44.61) 11.7
16 12.7 53,216,900,532 2,113,177,105 (49.20) 12.7
20 12.9 54,317,444,104 2,149,928,923 (50.06) 12.9
Note: branch-misses (%) is calculated for 2^32 branches
如您所见,当N<=12
时,分支预测器可以预测大部分分支(令人惊讶:分支预测器可以记住4096个连续随机分支的结果!)。当 N>12
时,分支未命中数开始增加。在 N>=16
,它只能正确预测 ~50%,这意味着它与随机抛硬币一样有效。
所花费的时间可以通过查看时间和分支未命中 (%) 列来估算:我添加了最后一列,approx-time
。我是这样计算的:2.2+(12.9-2.2)*branch-misses %/100
。如您所见,approx-time
等于 time
(不考虑舍入误差)。所以这个效果完全可以用分支预测来解释。
最初的目的是计算一个分支未命中的周期数(在这种特殊情况下 - 对于其他情况,这个数字可能不同):
(54,317,444,104-9,084,889,375)/(2,149,928,923-34,806) = 21.039 = ~21 cycles.