英特尔Skylake上的商店循环出乎意料的糟糕和奇怪的双峰性能

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你需要的变体nasmyasm程序集4 - 如果你没有,你可以尝试C++版本.

消除了可能性

以下是我考虑并在很大程度上消除的一些可能性.很多可能性都被简单的事实所消除,你可以在基准测试循环的中间随机看到性能转换,当许多事情根本没有改变时(例如,如果它与输出数组对齐有关,它就不能因为整个时间使用相同的缓冲区,所以在运行中间进行更改.我将在下面将其称为默认消除(即使对于默认消除的事物,通常还会有另一个参数).

  • 对齐因子:输出数组是16字节对齐,我尝试了2MB对齐而没有改变.也通过默认消除消除.
  • 与机器上的其他过程争用:在空闲机器上甚至在负载较重的机器上(或者使用stress -vm 4),或多或少地观察到效果.基准测试本身应该完全是核心本地的,因为它适合L2,并且perf确认每次迭代很少有L2未命中(每300-400次迭代大约1次错过,可能与printf代码有关).
  • TurboBoost:完全禁用TurboBoost,由三个不同的MHz读数确认.
  • 省电的东西:性能调控器intel_pstate处于performance模式状态.在测试期间没有观察到频率变化(CPU基本上保持锁定在2.59GHz).
  • TLB效果:即使输出缓冲区位于2 MB大页面中,也会出现效果.在任何情况下,64个4k TLB条目都覆盖了128K输出缓冲区.perf没有报告任何特别奇怪的TLB行为.
  • 4k别名:这个基准测试的较旧,更复杂的版本确实显示了一些4k别名,但由于基准测试中没有负载(它的负载可能会错误地为早期存储设置别名),因此已经消除了这种情况.也通过默认消除消除.
  • L2关联性冲突:通过默认消除消除,并且即使使用2MB页面也不会消失,我们可以确保输出缓冲区在物理内存中线性布局.
  • 超线程效应:HT被禁用.
  • 预取:此处只能涉及两个预取程序("DCU",又称L1 < - > L2预取程序),因为所有数据都存在于L1或L2中,但所有预取程序启用或全部禁用时性能相同.
  • 中断:中断计数和慢速模式之间没有相关性.总中断数量有限,主要是时钟周期.

toplev.py

我使用了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

到目前为止我发现了什么.遗憾的是它并没有真正提供了表现不佳的解释,而不是在所有的双峰分布,但更多的是因为当你可能会看到它减轻了性能和笔记一套规则:

  • L2中的存储吞吐量似乎是每三个周期0最多一个64字节高速缓存行,每个周期的存储吞吐量上限约为21个字节.换句话说,在L1中错过并且在L2中命中的一系列商店将每个所触摸的高速缓存行中花费至少三个周期.
  • 在该基线之上,当在L2中命中的商店与商店交织不同的高速缓存行时(无论这些商店是否在L1或L2中命中),存在显着的惩罚.
  • 对于附近的商店(但仍然不在同一缓存行中),惩罚显然略大.
  • 双峰性能至少表面上与上述效应相关,因为在非交错情况下它似乎不会发生,尽管我没有进一步的解释.
  • 如果确保缓存行在存储之前已经在L1中,则通过预取或虚拟加载,缓慢的性能消失,性能不再是双峰的.

细节和图片

64字节的步幅

原始问题任意使用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)

我们尝试的所有值FIRSTSECOND从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个周期).

我们可以缩小一点,看看更大的偏移量:

抵消高达2048年

相同的基本模式,虽然我们看到性能提高(绿色区域),因为第二个存储距离越来越远(前面或后面)第一个,直到它在约1700字节的偏移处再次变坏.即使在改进的区域,我们也只能达到5.8次循环/迭代仍然比3.5的同线性能差得多.

如果添加任何类型的加载或预取指令,这些指令在3个存储区之前运行,则整体缓慢性能和"慢速模式"异常值都会消失:

都好

您可以通过16个问题将其移回原始步幅 - 核心循环中的任何类型的预取或加载,对距离非常不敏感(即使它实际上落后),修复问题并获得2.3周期/迭代,接近2.0的最佳理想值,并且等于具有单独循环的两个商店的总和.

因此,基本规则是没有相应负载的L2存储比软件预取它们要慢得多 - 除非整个存储流以单个顺序模式访问缓存行.这与像这样的线性模式永远不会受益于SW预取的想法相反.

我真的没有一个充实的解释,但它可能包括这些因素:

  • 在存储缓冲区中具有其他存储可能会降低发送到L2的请求的并发性.目前尚不清楚在L1中将要错过的商店何时分配商店缓冲区,但也许它恰好在商店即将退休并且存储缓冲区中存在一定数量的"查找头"以将位置带入L1,因此在L1中不会遗漏的额外存储会损害并发性,因为前瞻不能看到会丢失的许多请求.
  • 也许存在L1和L2资源(如读取和写入端口,缓存间带宽)的冲突,这些存储模式更糟糕.例如,当存储到不同行的存储时,它们可能无法从存储队列中快速消耗(参见上面的情况,在某些情况下,每个周期可能会消耗多个存储).

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它甚至不需要提前 - 预取后面的几行也可以工作:我想预取/加载只是快速运行在瓶颈的商店之前,所以他们无论如何都要先行.通过这种方式,预取是一种自我修复,并且几乎可以与你所使用的任何值一起使用.


Bre*_*dan 0

Sandy Bridge 有“L1 数据硬件预取器”。这意味着,最初当您进行存储时,CPU 必须将数据从 L2 提取到 L1;但在这种情况发生几次之后,硬件预取器会注意到良好的顺序模式,并开始为您将数据从 L2 预取到 L1 中,以便在代码执行其操作之前,数据要么在 L1 中,要么“到 L1 的一半”店铺。