一些 CPU 和编译器提供预取指令,例如:GCC 文档 中的 __builtin_prefetch。尽管 GCC 文档中有注释,但对我来说太过简短。
我想知道,在实践中,我们何时应该使用 prefetch?有哪些示例可以提供?
一些 CPU 和编译器提供预取指令,例如:GCC 文档 中的 __builtin_prefetch。尽管 GCC 文档中有注释,但对我来说太过简短。
我想知道,在实践中,我们何时应该使用 prefetch?有哪些示例可以提供?
这个问题实际上与编译器无关,它们只是提供了一些挂钩来将预取指令插入您的汇编代码/二进制文件中。不同的编译器可能提供不同的内部格式,但您可以忽略所有这些,(小心地)直接在汇编代码中添加。
现在真正的问题似乎是“何时使用预取”,答案是-在任何受内存延迟限制的情况下,在访问模式不规则且与HW预取捕获不可区分(以流或步幅组织)或者当您怀疑有太多不同流时,HW无法同时跟踪的情况下都可以使用。大多数编译器很少为您自己插入预取,因此基本上由您来测试代码并评估预取如何有用。
@Mysticial提供的链接展示了一个很好的例子,但这里有一个更简单的例子,我认为 HW 无法捕获:
#include "stdio.h"
#include "sys/timeb.h"
#include "emmintrin.h"
#define N 4096
#define REP 200
#define ELEM int
int main() {
int i,j, k, b;
const int blksize = 64 / sizeof(ELEM);
ELEM __attribute ((aligned(4096))) a[N][N];
for (i = 0; i < N; ++i) {
for (j = 0; j < N; ++j) {
a[i][j] = 1;
}
}
unsigned long long int sum = 0;
struct timeb start, end;
unsigned long long delta;
ftime(&start);
for (k = 0; k < REP; ++k) {
for (i = 0; i < N; ++i) {
for (j = 0; j < N; j ++) {
sum += a[i][j];
}
}
}
ftime(&end);
delta = (end.time * 1000 + end.millitm) - (start.time * 1000 + start.millitm);
printf ("Prefetching off: N=%d, sum=%lld, time=%lld\n", N, sum, delta);
ftime(&start);
sum = 0;
for (k = 0; k < REP; ++k) {
for (i = 0; i < N; ++i) {
for (j = 0; j < N; j += blksize) {
for (b = 0; b < blksize; ++b) {
sum += a[i][j+b];
}
_mm_prefetch(&a[i+1][j], _MM_HINT_T2);
}
}
}
ftime(&end);
delta = (end.time * 1000 + end.millitm) - (start.time * 1000 + start.millitm);
printf ("Prefetching on: N=%d, sum=%lld, time=%lld\n", N, sum, delta);
}
我在这里做的是遍历每个矩阵行(利用HW预取器帮助连续行),但预取下一个不同页面中包含相同列索引的元素所在的下一行(HW预取器应该很难捕获)。我对数据进行求和只是为了防止被优化掉,重要的是我基本上只是遍历矩阵,应该很容易检测到,但仍然能获得加速。
采用gcc 4.8.1 -O3构建,在Intel Xeon X5670上几乎可以提升20%的性能:
Prefetching off: N=4096, sum=3355443200, time=1839
Prefetching on: N=4096, sum=3355443200, time=1502
注意,即使我使控制流更加复杂(额外的循环嵌套层次),也会获得加速,分支预测器应该可以轻松捕捉到短块大小循环的模式,并且可以节省不需要的预取执行。-O3 -march=native
Prefetching off: N=4096, sum=28147495993344000, time=896
Prefetching on: N=4096, sum=28147495993344000, time=1222
Prefetching off: N=4096, sum=28147495993344000, time=886
Prefetching on: N=4096, sum=28147495993344000, time=1291
Prefetching off: N=4096, sum=28147495993344000, time=890
Prefetching on: N=4096, sum=28147495993344000, time=1234
Prefetching off: N=4096, sum=28147495993344000, time=848
Prefetching on: N=4096, sum=28147495993344000, time=1220
Prefetching off: N=4096, sum=28147495993344000, time=852
Prefetching on: N=4096, sum=28147495993344000, time=1253
编译标志:-O2 -march=native
Prefetching off: N=4096, sum=28147495993344000, time=1955
Prefetching on: N=4096, sum=28147495993344000, time=1813
Prefetching off: N=4096, sum=28147495993344000, time=1956
Prefetching on: N=4096, sum=28147495993344000, time=1814
Prefetching off: N=4096, sum=28147495993344000, time=1955
Prefetching on: N=4096, sum=28147495993344000, time=1811
Prefetching off: N=4096, sum=28147495993344000, time=1961
Prefetching on: N=4096, sum=28147495993344000, time=1811
Prefetching off: N=4096, sum=28147495993344000, time=1965
Prefetching on: N=4096, sum=28147495993344000, time=1814
对于这个特定的示例,使用预取的速度要么比不使用慢大约40%,要么快8%,具体取决于您是否分别使用-O3
或-O2
。对于-O3
来说,速度变慢是由于代码生成过程中的一个小缺陷:在-O3
下,没有使用预取的循环被向量化了,但是在我的gcc版本上,预取变体的循环的额外复杂性阻止了向量化。
因此,-O2
结果可能更为公正,并且效益约为Leeor's Westmere所看到的一半(8%加速比16%要低)。但还值得注意的是,必须小心不要更改代码生成方式,以避免出现大的速度减慢。
这个测试可能不是最理想的,因为按照int
循环会产生大量CPU开销,而不是强调内存子系统(这就是为什么向量化帮助很多)。
b
变量,而且在这一行代码 sum += a[i][j+b];
中使用了它。也许该行代码应该只是 sum += a[i][j];
,因为您在该循环中没有阻塞任何东西。 - BeeOnRopeint
相加。由于典型的内存读取带宽在15 GB/s左右,每秒处理37.5亿个整数,这就对最大速度设置了一个相当严格的限制(未矢量化的代码通常每个周期处理1 int
或更少,因此3.75 GHz 的 CPU 将大约同样受到 CPU 和内存的影响)。
首先,我得到的结果似乎表明预取在我的i7-6700HQ(Skylake)上表现出色:
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=221, MiB/s=11583
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=153, MiB/s=16732
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=221, MiB/s=11583
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=160, MiB/s=16000
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=204, MiB/s=12549
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=160, MiB/s=16000
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=200, MiB/s=12800
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=160, MiB/s=16000
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=201, MiB/s=12736
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=157, MiB/s=16305
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=197, MiB/s=12994
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=157, MiB/s=16305
通过观察数据,预取实现了略高于16 GiB/s的速度,而没有预取只有大约12.5 GiB/s,因此预取使速度提高了约30%。对吗?
不要这么快下结论。请记住,省电模式在现代芯片上有各种奇妙的交互作用,我将Linux CPU管理器从默认的powersave1更改为performance。现在我得到:
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=155, MiB/s=16516
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=157, MiB/s=16305
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=153, MiB/s=16732
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=144, MiB/s=17777
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=144, MiB/s=17777
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=153, MiB/s=16732
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=152, MiB/s=16842
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=153, MiB/s=16732
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=153, MiB/s=16732
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=159, MiB/s=16100
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=163, MiB/s=15705
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=161, MiB/s=15900
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=280, MiB/s=9142
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=277, MiB/s=9241
Prefetching off: SIZE=256 MiB, sum=1407374589952000, time=285, MiB/s=8982
相较于预取版本的大约17 GiB/s,该版本只有约6.5 GiB/s的速度:
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=149, MiB/s=17181
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=148, MiB/s=17297
Prefetching on: SIZE=256 MiB, sum=1407374589952000, time=148, MiB/s=17297
perf stat
时发生了什么,对于**关闭**版本: 2907.485684 task-clock (msec) # 1.000 CPUs utilized
3,197,503,204 cycles # 1.100 GHz
2,158,244,139 instructions # 0.67 insns per cycle
429,993,704 branches # 147.892 M/sec
10,956 branch-misses # 0.00% of all branches
...和开启版本:
1502.321989 task-clock (msec) # 1.000 CPUs utilized
3,896,143,464 cycles # 2.593 GHz
2,576,880,294 instructions # 0.66 insns per cycle
429,853,720 branches # 286.126 M/sec
11,444 branch-misses # 0.00% of all branches
现在我们之前看到过这个, 这可能是近期英特尔芯片上的“节能Turbo”功能的结果,当它们确定进程主要受限于内存时,会尝试降低CPU频率,可能是因为在这些情况下增加CPU核心速度并不能提供太多好处。正如我们在这里所看到的,这种假设并不总是正确的,但我不确定这种权衡在一般情况下是否是一个坏选择,或者也许这种启发式只偶尔会出错。
1 我正在运行intel_pstate
驱动程序,这是最近内核上Intel芯片的默认驱动程序,实现了“硬件p状态”,也称为“HWP”。使用的命令为:sudo cpupower -c 0,1,2,3 frequency-set -g performance
。
2 相反,“关闭”测试中的减速部分在“开启”测试中部分保留,尽管影响不那么极端,可能是因为节能的“上升”行为比“下降”更快。
sudo sh -c 'for i in /sys/devices/system/cpu/cpufreq/policy[0-9]*/energy_performance_preference;do echo balance_performance > "$i";done'
直接调整 EPP 设置(适用于所有 CPU)。 (有关 Skylake 能源性能偏好的详细信息,请参见 https://patchwork.kernel.org/patch/9723429/)。 我认为 cpupower
可能没问题,但在尝试弄清楚为什么我的 4.0GHz SKL 只能 Turbo 到 3.9GHz 时,我首先遇到了 sysfs 的东西。 - Peter Cordespowersave
上从空闲状态运行10次测试循环时,性能通常会按照典型模式从第一次迭代开始下降,例如(以MiB/s为单位):11689、10240、10322、10406、10199、10158、9770、9552、9481、9208
。我理解这是powersave
模式最初会升高到更高的CPU值,假设这种活动的爆发是“交互式”的,并且您可以从快速完成工作方面获得许多价值,以减少UI延迟等,但是一旦运行一段时间后,它会决定它是批处理并降低速度。 - BeeOnRopeintel_pstate
之外的通用内核中的一个功能,但它可以与pstate
驱动程序进行通信以满足期望。 - BeeOnRope2 实际上,仅仅在循环中添加一个预取并不像看起来那么简单,因为在前五次迭代之后,加载将开始命中已经预取的值,从而将 MLP 降低到 5 - 但是这个想法仍然成立。真正的实现还涉及重新组织循环,以便可以维持 MLP(例如,每隔几次迭代将加载和预取“挤压”在一起)。
有些情况下,软件预取可以显著提高性能。
例如,如果您正在访问相对缓慢的存储设备(如Optane DC Persistent Memory),其访问时间为几百纳秒,如果您能够足够提前地进行预取,那么预取可以将有效延迟降低50%或更多。
目前这种情况并不常见,但如果此类存储设备成为主流,则会变得更加普遍。
这篇文章“关于内存,每个程序员都应该知道什么——Ulrich Drepper”讨论了预取的优势;http://www.akkadia.org/drepper/cpumemory.pdf ,警告:这是一篇相当长的文章,讨论了内存架构/ CPU工作原理等内容。
如果数据对齐到缓存行,并且您正在加载算法即将访问的数据,则预取会带来一些好处;
在任何情况下,当尝试优化高使用代码时,都应该这样做;基准测试是必须的,事情通常会与人们想象的不同。
看起来,遵循的最佳策略是根本不使用__builtin_prefetch(以及它的朋友__builtin_expect)。在某些平台上,这些可能会有所帮助(甚至非常有帮助)-然而,必须始终进行一些基准测试来确认这一点。真正的问题是,短期性能收益是否值得长期麻烦。
首先,人们可以问以下问题:当输入到高端现代CPU时,这些语句实际上是做什么的?答案是:没有人真正知道(除了可能是CPU核心架构团队的少数人,但他们不会告诉任何人)。现代CPU是非常复杂的机器,能够重新排序指令,在可能未被采取的分支上执行指令的推测执行等等。此外,这种复杂行为的细节可能(并且将)在CPU代和供应商之间有很大差异(例如Intel Core vs Intel I * vs AMD Opteron;对于更分散的平台,如ARM,情况甚至更糟)。
一个不错的例子(与预取无关,但仍然)CPU功能, 过去可以加速旧的英特尔CPU的运行速度, 但在现代化的处理器上表现很差,在这里概述: http://lists-archives.com/git/744742-git-gc-speed-it-up-by-18-via-faster-hash-comparisons.html。在那个特定情况下,通过用显式的(“天真”的话)循环替换gcc优化版本提供的memcmp,可以实现18%的性能提升。