Bee*_*ope 25 optimization performance x86 assembly x86-64
我看到一个简单的存储循环出乎意料地表现不佳,这个存储循环有两个存储:一个具有16字节的正向步长,另一个总是位于同一位置1,如下所示:
volatile uint32_t value;
void weirdo_cpp(size_t iters, uint32_t* output) {
uint32_t x = value;
uint32_t *rdx = output;
volatile uint32_t *rsi = output;
do {
*rdx = x;
*rsi = x;
rdx += 4; // 16 byte stride
} while (--iters > 0);
}
Run Code Online (Sandbox Code Playgroud)
在汇编这个循环可能3看起来像:
weirdo_cpp:
...
align 16
.top:
mov [rdx], eax ; stride 16
mov [rsi], eax ; never changes
add rdx, 16
dec rdi
jne .top
ret
Run Code Online (Sandbox Code Playgroud)
当访问的存储区域在L2中时,我希望每次迭代运行少于3个周期.第二个商店只是一直在同一个位置,应该添加一个周期.第一个商店意味着从L2引入一条线,因此每4次迭代也会驱逐一条线.我不确定你如何评估L2成本,但即使你保守估计L1只能在每个周期中执行以下操作之一:(a)提交商店或(b)从L2接收一行或(c)将一条线驱逐到L2,对于stride-16商店流,你会得到1 + 0.25 + 0.25 = 1.5个周期.
实际上,你注释掉一个商店你得到的第一个商店每次迭代大约1.25个周期,第二个商店每个迭代大约1.01个周期,所以每次迭代2.5个周期似乎是一个保守的估计.
然而,实际表现非常奇怪.这是测试工具的典型运行:
Estimated CPU speed: 2.60 GHz
output size : 64 KiB
output alignment: 32
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
3.89 cycles/iter, 1.49 ns/iter, cpu before: 0, cpu after: 0
3.90 cycles/iter, 1.50 ns/iter, cpu before: 0, cpu after: 0
4.73 cycles/iter, 1.81 ns/iter, cpu before: 0, cpu after: 0
7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.33 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.34 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.26 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.31 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.29 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.29 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.27 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.30 cycles/iter, 2.81 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
7.28 cycles/iter, 2.80 ns/iter, cpu before: 0, cpu after: 0
Run Code Online (Sandbox Code Playgroud)
这里有两件事很奇怪.
首先是双峰时序:有快速模式和慢速模式.我们从慢速模式开始,每次迭代大约需要7.3个周期,并且在某些时候每次迭代过渡到大约3.9个周期.这种行为是一致的和可重复的,并且两个时间总是非常一致地聚集在两个值周围.转换显示在从慢速模式到快速模式的两个方向上,反之亦然(有时在一次运行中有多个转换).
另一个奇怪的是表现非常糟糕.即使在快速模式下,在大约3.9个周期,性能也比1.0 + 1.3 = 2.3周期差得多,你可以期望将每个案例与单个商店加在一起(假设绝对零工作可以重叠)当两个商店都在循环中).在慢速模式下,与基于第一原则所期望的相比,性能非常糟糕:它需要7.3个周期来完成2个存储,如果你把它放在L2存储带宽术语中,那么每个L2存储大约需要29个周期(因为我们每4次迭代只存储一个完整的缓存行).
Skylake被记录为在L1和L2之间具有64B /循环吞吐量,这远高于此处观察到的吞吐量(在慢速模式下约2个字节/周期).
什么解释了吞吐量和双峰性能差,我可以避免它吗?
如果这在其他架构甚至其他Skylake盒子上再现,我也很好奇.随意在评论中包含本地结果.
您可以在github上找到测试代码和线束.有一个Makefile适用于Linux或类Unix的平台,但在Windows上也应该相对容易.如果你想运行asm你需要的变体nasm或yasm程序集4 - 如果你没有,你可以尝试C++版本.
以下是我考虑并在很大程度上消除的一些可能性.很多可能性都被简单的事实所消除,你可以在基准测试循环的中间随机看到性能转换,当许多事情根本没有改变时(例如,如果它与输出数组对齐有关,它就不能因为整个时间使用相同的缓冲区,所以在运行中间进行更改.我将在下面将其称为默认消除(即使对于默认消除的事物,通常还会有另一个参数).
stress -vm 4),或多或少地观察到效果.基准测试本身应该完全是核心本地的,因为它适合L2,并且perf确认每次迭代很少有L2未命中(每300-400次迭代大约1次错过,可能与printf代码有关).intel_pstate处于performance模式状态.在测试期间没有观察到频率变化(CPU基本上保持锁定在2.59GHz).perf没有报告任何特别奇怪的TLB行为.我使用了toplev.py来实现英特尔的Top Down分析方法,毫不奇怪它将基准标识为存储绑定:
BE Backend_Bound: 82.11 % Slots [ 4.83%]
BE/Mem Backend_Bound.Memory_Bound: 59.64 % Slots [ 4.83%]
BE/Core Backend_Bound.Core_Bound: 22.47 % Slots [ 4.83%]
BE/Mem Backend_Bound.Memory_Bound.L1_Bound: 0.03 % Stalls [ 4.92%]
This metric estimates how often the CPU was stalled without
loads missing the L1 data cache...
Sampling events: mem_load_retired.l1_hit:pp mem_load_retired.fb_hit:pp
BE/Mem Backend_Bound.Memory_Bound.Store_Bound: 74.91 % Stalls [ 4.96%] <==
This metric estimates how often CPU was stalled due to
store memory accesses...
Sampling events: mem_inst_retired.all_stores:pp
BE/Core Backend_Bound.Core_Bound.Ports_Utilization: 28.20 % Clocks [ 4.93%]
BE/Core Backend_Bound.Core_Bound.Ports_Utilization.1_Port_Utilized: 26.28 % CoreClocks [ 4.83%]
This metric represents Core cycles fraction where the CPU
executed total of 1 uop per cycle on all execution ports...
MUX: 4.65 %
PerfMon Event Multiplexing accuracy indicator
Run Code Online (Sandbox Code Playgroud)
这并没有真正说明问题:我们已经知道必须要把商店搞砸了,但为什么呢?英特尔对这种情况的描述并没有多说.
以下是 L1-L2交互中涉及的一些问题的合理总结.
更新2019年2月:我不能再重现性能的"双峰"部分:对于我来说,在相同的i7-6700HQ盒子上,在相同的情况下,性能现在总是很慢,双峰性能缓慢且非常慢,即,结果大约每行16-20个周期,如下所示:
这一变化似乎已在2018年8月Skylake微码更新版本0xC6中引入.先前的微码0xC2显示了问题中描述的原始行为.
1这是我原始循环的一个大大简化的MCVE,它至少是大小的3倍,并且做了很多额外的工作,但表现出与这个简单版本完全相同的性能,瓶颈在同一个神秘的问题上.
3特别是,它看起来正是这样,如果你手工编写的组件,或者如果你有编译gcc -O1(5.4.1版本),也可能是最合理的编译器(volatile用于避免下沉大多是死的第二家店外循环).
4毫无疑问,您可以通过一些小的编辑将其转换为MASM语法,因为程序集非常简单.接受拉请求.
Bee*_*ope 11
到目前为止我发现了什么.遗憾的是它并没有真正提供了表现不佳的解释,而不是在所有的双峰分布,但更多的是因为当你可能会看到它减轻了性能和笔记一套规则:
原始问题任意使用16的步幅,但让我们从最简单的情况开始:64的步幅,即一个完整的缓存行.事实证明,任何步幅都可以看到各种效果,但64确保每次步幅都会出现L2缓存缺失,因此会删除一些变量.
我们现在也要删除第二个商店 - 所以我们只是在64K内存上测试一个64字节的跨步存储:
top:
mov BYTE PTR [rdx],al
add rdx,0x40
sub rdi,0x1
jne top
Run Code Online (Sandbox Code Playgroud)
在与上面相同的线束中运行它,我得到大约3.05个周期/存储2,尽管与我以前看到的相比有很多变化( - 你甚至可以在那里找到3.0).
因此,我们已经知道了,我们可能都不会做得比这对于持续店更好纯属L2 1.虽然Skylake显然在L1和L2之间具有64字节的吞吐量,但在存储流的情况下,必须为从L1驱逐的两个驱动共享带宽,并将新线路加载到L1中.3个循环似乎是合理的,如果它们分别需要1个循环来(a)将脏的受害者线从L1推出到L2(b)用来自L2的新行更新L1并且(c)将存储器提交到L1.
当你在循环中添加第二次写入同一个高速缓存行(到下一个字节,虽然结果不重要)时会发生什么?像这样:
top:
mov BYTE PTR [rdx],al
mov BYTE PTR [rdx+0x1],al
add rdx,0x40
sub rdi,0x1
jne top
Run Code Online (Sandbox Code Playgroud)
以下是上述循环的1000次测试工具运行时间的直方图:
count cycles/itr
1 3.0
51 3.1
5 3.2
5 3.3
12 3.4
733 3.5
139 3.6
22 3.7
2 3.8
11 4.0
16 4.1
1 4.3
2 4.4
Run Code Online (Sandbox Code Playgroud)
所以大多数时间都聚集在3.5个周期左右.这意味着这个额外的商店只增加了0.5个周期.这可能是类似于存储缓冲区能够将两个存储器排放到L1,如果它们在同一行中,但这只发生了大约一半的时间.
考虑存储缓冲区包含一系列存储,如1, 1, 2, 2, 3, 3where 1表示缓存行:一半位置具有来自同一缓存行的两个连续值,而另一半不具有.由于存储缓冲区正在等待耗尽存储,并且L1正忙着驱逐并接受来自L2的线路,因此L1将在"任意"点处可用于商店,并且如果它位于该位置,则1, 1可能存储器流入一个周期,但如果它在1, 2它需要两个周期.
请注意,在3.1左右而不是3.5左右,还有另外一个峰值约为6%.这可能是一个稳定的状态,我们总能得到幸运的结果.在4.0-4.1时还有另外一个大约3%的峰值 - "总是不吉利"的安排.
让我们通过查看第一个和第二个商店之间的各种偏移来测试这个理论:
top:
mov BYTE PTR [rdx + FIRST],al
mov BYTE PTR [rdx + SECOND],al
add rdx,0x40
sub rdi,0x1
jne top
Run Code Online (Sandbox Code Playgroud)
我们尝试的所有值FIRST和SECOND从0到256中的8的结果的步骤,用不同FIRST的垂直轴和值SECOND在水平:
我们看到一个特定的模式 - 白色值是"快速"(大约上面讨论的3.0-4.1值,偏移量为1).黄色值较高,最多8个循环,红色最多10个.紫色异常值最高,通常是OP中描述的"慢速模式"开始的情况(通常以18.0个周期/秒为时钟).我们注意到以下内容:
从白色单元格的模式来看,只要第二个存储位于同一缓存行或下一个存储位于第一个存储区中,我们就会看到快速~3.5周期结果.这与上面的想法一致,即更有效地处理对相同高速缓存行的存储.在下一个缓存行中使用第二个存储的原因是该模式最终是相同的,除了第一个第一次访问:0, 0, 1, 1, 2, 2, ...vs 0, 1, 1, 2, 2, ...- 在第二种情况下,它是第一个接触每个缓存行的第二个存储.但是商店缓冲区并不在意.一旦你进入不同的缓存行,你会得到一个类似的模式0, 2, 1, 3, 2, ...,显然这很糟糕?
紫色的"异常值"永远不会出现在白色区域中,因此显然局限于已经很慢的场景(而且这里的慢点使得它慢大约2.5倍:从大约8到18个周期).
我们可以缩小一点,看看更大的偏移量:
相同的基本模式,虽然我们看到性能提高(绿色区域),因为第二个存储距离越来越远(前面或后面)第一个,直到它在约1700字节的偏移处再次变坏.即使在改进的区域,我们也只能达到5.8次循环/迭代仍然比3.5的同线性能差得多.
如果添加任何类型的加载或预取指令,这些指令在3个存储区之前运行,则整体缓慢性能和"慢速模式"异常值都会消失:
您可以通过16个问题将其移回原始步幅 - 核心循环中的任何类型的预取或加载,对距离非常不敏感(即使它实际上落后),修复问题并获得2.3周期/迭代,接近2.0的最佳理想值,并且等于具有单独循环的两个商店的总和.
因此,基本规则是没有相应负载的L2存储比软件预取它们要慢得多 - 除非整个存储流以单个顺序模式访问缓存行.这与像这样的线性模式永远不会受益于SW预取的想法相反.
我真的没有一个充实的解释,但它可能包括这些因素:
McCalpin博士在英特尔论坛上发表的这些评论也非常有趣.
0大多数情况下只有在禁用L2流传输时才能实现,否则L2上的额外争用会将此速度降低到每3.5个周期约1行.
1将此与商店进行对比,其中每个负载几乎完全达到1.5个周期,每个周期的隐含带宽约为43个字节.这使得完美感:L1 < - > L2带宽为64个字节,但假设L1是要么接受来自L2的线或从核心服务加载请求每循环(但不能同时并行地)则具有3个周期对于两个负载到不同的L2线:2个周期接受L2的线路,1个周期满足两个负载指令.
2随着预取关闭.事实证明,L2预取程序在检测到流式访问时会竞争对L2缓存的访问:即使它总是找到候选行并且没有进入L3,这会减慢代码并增加可变性.结论一般都是预取的,但是一切都只是稍微慢一点(这里有预取的大量结果 - 你看到每个负载大约3.3个周期,但有很多变化).
3它甚至不需要提前 - 预取后面的几行也可以工作:我想预取/加载只是快速运行在瓶颈的商店之前,所以他们无论如何都要先行.通过这种方式,预取是一种自我修复,并且几乎可以与你所使用的任何值一起使用.
Sandy Bridge 有“L1 数据硬件预取器”。这意味着,最初当您进行存储时,CPU 必须将数据从 L2 提取到 L1;但在这种情况发生几次之后,硬件预取器会注意到良好的顺序模式,并开始为您将数据从 L2 预取到 L1 中,以便在代码执行其操作之前,数据要么在 L1 中,要么“到 L1 的一半”店铺。