TL;DR:
案例 1:适合 L1D 的缓冲区。矢量构造函数或对std::fill 的调用会将缓冲区完全放在L1D 中。在这种情况下,流水线和 L1D 缓存的每周期 1 个存储的吞吐量是瓶颈。
案例 2:适合 L2 的缓冲区。矢量构造函数或对std::fill 的调用会将缓冲区完全放在L2 中。但是,L1 必须将脏行写回 L2,并且 L1D 和 L2 之间只有一个端口。此外,必须将行从 L2 提取到 L1D。 L1D 和 L2 之间的 64B/周期带宽应该能够轻松处理,可能偶尔会出现争用(有关更多详细信息,请参见下文)。因此,总体而言,瓶颈与案例 1 中的相同。您使用的特定缓冲区大小约为 40KB,不适合 Intel 的 L1D 和最近的 AMD 处理器,但适合 L2。虽然在同时多线程 (SMT) 的情况下,可能会有来自其他逻辑内核的一些额外争用。
案例 3:不适合 L2 的缓冲区。这些行需要从 L3 或内存中获取。 L2 DPL 预取器可以跟踪存储并将缓冲区预取到 L2 中,从而减轻长延迟。单个 L2 端口与 L1 写回和填充缓冲区一起成为瓶颈。这很严重,尤其是当缓冲区不适合 L3 时,互连也可能位于关键路径上。 1 存储吞吐量对于缓存子系统来说太大了,无法处理。两个最相关的性能计数器是L1D_PEND_MISS.REQUEST_FB_FULL 和RESOURCE_STALLS.SB。
首先,请注意vector 的构造函数(可能会被内联)本身通过在内部调用memset 将元素初始化为零。 memset 基本上和你的循环做同样的事情,但它是高度优化的。换句话说,就大 O 表示法而言,两者在元素数量上都是线性的,但 memset 的常数因子较小。此外,std::fill 还在内部调用memset 将所有元素设置为零(再次)。 std::fill 也可能会被内联(启用适当的优化)。因此,那段代码中确实有三个循环。使用std::vector<int> vec(10000u, 5) 初始化向量会更有效。现在让我们来进行循环的微架构分析。我将只讨论我期望在现代英特尔处理器上发生的事情,特别是 Haswell 和 Skylake1。
让我们仔细检查代码:
00007FF6A4521080 inc edx
00007FF6A4521082 mov dword ptr [rcx+rax*4],5
00007FF6A4521089 mov eax,edx
00007FF6A452108B cmp rax,r9
00007FF6A452108E jb main+80h (07FF6A4521080h)
第一条指令将被解码为单个微指令。第二条指令将被解码为两个在前端融合的微指令。第三条指令是寄存器到寄存器的移动,是寄存器重命名阶段移动消除的候选。如果不运行代码3,很难确定移动是否会被消除。但是即使没有被淘汰,也会按照如下方式发送指令2:
dispatch cycle | allocate cycle
cmp rax,r9 macro-fused | inc edx (iteration J+3)
jb main+80h (07FF6A4521080h) (iteration J) | mov dword ptr [rcx+rax*4],5 (iteration J+3)
mov dword ptr [rcx+rax*4],5 (iteration J+1)| mov eax,edx (iteration J+3)
mov eax,edx (iteration J+1)| cmp rax,r9 macro-fused
inc edx (iteration J+2)| jb main+80h (07FF6A4521080h) (iteration J+3)
---------------------------------------------------------|---------------------------------------------------------
cmp rax,r9 macro-fused | inc edx (iteration J+4)
jb main+80h (07FF6A4521080h) (iteration J+1)| mov dword ptr [rcx+rax*4],5 (iteration J+4)
mov dword ptr [rcx+rax*4],5 (iteration J+2)| mov eax,edx (iteration J+4)
mov eax,edx (iteration J+2)| cmp rax,r9 macro-fused
inc edx (iteration J+3)| jb main+80h (07FF6A4521080h) (iteration J+4)
cmp 和 jb 指令将被宏融合到一个微指令中。因此,融合域中的微指令总数为 4,未融合域中的微指令总数为 5。其中恰好有一个跳跃。因此,每个循环可以发出一个循环迭代。
由于inc和mov-store之间的依赖关系,这两条指令不能在同一个周期内调度。尽管如此,上一次迭代中的inc 可以与上一次迭代中的微指令一起分派。
inc 和 mov 可以分派到四个端口(p0、p1、p5、p6)。对于预测采用的cmp/jb,只有一个端口 p6。 mov dword ptr [rcx+rax*4],5 的 STA 微指令有三个端口(p2、p3、p7),而 STD 微指令有一个端口 p4。 (虽然 p7 无法处理指定的寻址方式。)因为每个端口只有一个,所以可以实现的最大执行吞吐量是每个周期 1 次迭代。
不幸的是,吞吐量会更差;许多商店会错过L1D。 L1D 预取器不能预取处于排他一致性状态的行,并且不跟踪存储请求。不过好在很多店都会合并。循环中的连续存储目标是虚拟地址空间中的顺序位置。由于一行的大小为 64 字节,每个存储的大小为 4 字节,因此每 16 个连续的存储都指向同一缓存行。这些存储可以在存储缓冲区中组合,但它们不会,因为一旦它们成为 ROB 的顶部,存储将尽可能早地退出。循环体非常小,因此 16 个存储中很少有几个存储在存储缓冲区中的可能性很小。但是,当合并存储请求被发出到 L1D 时,它将丢失并分配一个 LFB,这也支持合并存储。 L2 缓存 DPL 预取器能够跟踪 RFO 请求,因此希望我们几乎总能命中 L2。但是从L2到L1的线路至少需要10-15个周期。不过,RFO 可能会在存储实际提交之前提前发送。同时,很可能需要将脏行从 L1 中逐出,以便为要写入的传入行腾出空间。被驱逐的行将被写入回写缓冲区。
如果不运行代码,很难预测整体效果。两个最相关的性能计数器是L1D_PEND_MISS.REQUEST_FB_FULL 和RESOURCE_STALLS.SB。
L1D 在 Ivy Bridge、Haswell 和 Skylake 上分别只有一个 16 字节、32 字节、64 字节宽的存储端口。因此,商店将以这些粒度提交。但单个 LFB 始终可以容纳完整的 64 字节缓存行。
store fused uops 的总数等于元素的数量(在本例中为 100 万)。要获得所需的 LFB 数量,除以 16 得到 62500 个 LFB,这与 L2 的 RFO 数量相同。在需要另一个 LFB 之前需要 16 个周期,因为每个周期只能分派一个商店。只要 L2 可以在 16 个周期内交付目标行,我们就永远不会阻塞 LFB,实现的吞吐量将接近每个周期 1 次迭代,或者就 IPC 而言,每个周期 5 条指令。这只有在我们几乎总是及时击中 L2 时才有可能。缓存或内存中的任何一致延迟都会显着降低吞吐量。它可能是这样的:16 次迭代的爆发将快速执行,然后管道在 LFB 上停止一些周期。如果这个数字等于 L3 延迟(大约 48 个周期),那么吞吐量将约为每 3 个周期 1 次迭代(= 16/48)。
L1D 有数量有限(6 个?)的写回缓冲区来保存被驱逐的行。此外,L2 只有一个 64 字节端口,用于 L1D 和 L2 之间的所有通信,包括回写和 RFO。回写缓冲区的可用性也可能在关键路径上。在这种情况下,LFB 的数量也是一个瓶颈,因为在回写缓冲区可用之前,LFB 不会被写入缓存。如果没有,LFB 将很快填满,特别是如果 L2 DPL 预取器能够及时交付线路。显然,将可缓存的 WB 存储流式传输到 L1D 是非常低效的。
如果您确实运行代码,您还需要考虑对memset 的两次调用。
(1) 在 Sandy Bridge 和 Ivy Bridge 上,the instruction mov dword ptr [rcx+rax*4],5 will get unlaminiated,在融合域中每次迭代产生 5 uop。所以前端可能在关键路径上。
(2) 或者类似的东西,取决于循环的第一次迭代的第一条指令是否获得分配器的第一个槽。如果不是,则显示的迭代次数需要相应地移动。
(3) @PeterCordes 发现在 Skylake 上大部分时间确实会发生移动消除。我也可以在 Haswell 上确认这一点。