【问题标题】:Pipeline on Registers calculation寄存器计算管道
【发布时间】:2019-02-16 06:08:14
【问题描述】:

我最近在阅读有关管道优化的信息。我想问我是否正确理解处理器如何处理流水线。

这里是简单测试程序的 C++ 代码:

#include <vector>

int main()
{
    std::vector<int> vec(10000u);
    std::fill(vec.begin(), vec.end(), 0);
    for (unsigned i = 0u; i < vec.size(); ++i)
    {
        vec[i] = 5;
    }

    return 0;
}

以及for循环产生的部分汇编代码:

...
00007FF6A4521080  inc         edx  
    {
        vec[i] = 5;
00007FF6A4521082  mov         dword ptr [rcx+rax*4],5  
00007FF6A4521089  mov         eax,edx  
00007FF6A452108B  cmp         rax,r9  
00007FF6A452108E  jb          main+80h (07FF6A4521080h)  
    }
...

在程序中,向量“vec”以固定大小分配并用零填充。重要的“工作”发生在 for 循环中,其中所有向量变量都分配给 5(只是一个随机值)。

我想问一下这个汇编代码是否会在管道中造成一些停顿?原因是所有指令都以某种方式相关并且在相同的寄存器上工作。例如,在mov eax, edx 实际为eax/rax赋值之前,管道需要等待指令cmp rax r9

循环 10000 次是分支预测应该发挥作用的地方。 jb 指令跳转 10000 次,最后才会通过。这意味着分支预测器应该很容易预测大部分时间会发生跳转。但是,从我的角度来看,如果代码本身在循环中停止,这种优化将毫无意义。


我的目标架构是 Skylake i5-6400

【问题讨论】:

  • 但这来自教科书的方法,这并不意味着它是无效或不相关的,当然不是,但是这个 x86 所以你现在可能在基本管道上获得了几十年的性能改进,而且它是微编码的,所以您有在 x86 ISA 级别看不到的细微差别。另外,您无需指定特定的芯片、步进和补丁来了解该处理器的行为方式(如果有一些假设无论如何都是可能的)。
  • 寄存器名称并不严格绑定到现代 x86 芯片上的特定门。没有什么可以阻止 x86 指令集计算机同时使用十几个寄存器作为 eax,只要它跟踪读取和写入它们之间的依赖关系。 (我不知道现有的硬件是否多达十几个)
  • 这里没有摊位。
  • @Yakk-AdamNevraumont:Haswell 的物理寄存器文件有 168 个整数条目和 168 个向量条目。 (realworldtech.com/haswell-cpu/3)。一打是零钱。 :P 每个架构寄存器都需要一个条目,但其余的都可以在不同的指令后用于同一个架构寄存器。您可能可以通过重复独立写入 EAX 的 imul eax, ecx, 3 / imul eax, ecx, 5 / 等序列来最大化它。 (add eax, eax 也可以,但依赖意味着 reg 重命名并不能帮助您避免任何停顿。)

标签: c++ assembly x86 cpu-architecture


【解决方案1】:

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_FULLRESOURCE_STALLS.SB


首先,请注意vector 的构造函数(可能会被内联)本身通过在内部调用memset 将元素初始化为零。 memset 基本上和你的循环做同样的事情,但它是高度优化的。换句话说,就大 O 表示法而言,两者在元素数量上都是线性的,但 memset 的常数因子较小。此外,std::fill 还在内部调用memset 将所有元素设置为零(再次)。 std::fill 也可能会被内联(启用适当的优化)。因此,那段代码中确实有三个循环。使用std::vector&lt;int&gt; 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)

cmpjb 指令将被宏融合到一个微指令中。因此,融合域中的微指令总数为 4,未融合域中的微指令总数为 5。其中恰好有一个跳跃。因此,每个循环可以发出一个循环迭代。

由于incmov-store之间的依赖关系,这两条指令不能在同一个周期内调度。尽管如此,上一次迭代中的inc 可以与上一次迭代中的微指令一起分派。

incmov 可以分派到四个端口(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_FULLRESOURCE_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 上确认这一点。

【讨论】:

  • 不对任何DV发表评论,但我仍然认为案例2不正确(可能也是案例3,但我将专注于2)。 L2 写端口、L1 写回和 FB 不是这里的瓶颈。当源代码在 L2 中时,此代码以大约 1 个周期迭代执行,这意味着每 16 个周期只填充一个 64 字节行。所以你提到的所有基础设施都有一个悠闲的 16 个周期移动从 L2 到 L1 一行,从 L1 到 L2 写回一行。实际上,它可以在短短 2 个周期内完成此操作,因此不存在瓶颈。瓶颈或多或少是一样的……
  • ... 作为 L1 案例,这就是为什么它的性能或多或少相同的原因(提示:如果您对两个场景的瓶颈有两种完全不同的解释,则运行相同的代码,但性能大致相同,您最好认真考虑两种情况下瓶颈相同的想法)。
  • @BeeOnRope 我想我同意。不要犹豫,告诉我您对案例 3 的看法。在这种情况下,还有其他方法可以解释高 L1D_PEND_MISS.REQUEST_FB_FULL 计数吗?如果这意味着可以通过增加它来提高性能,我们只能将每个周期 1 个存储的吞吐量视为瓶颈。现在我在考虑案例 1,每个周期只能分配一次存储,因此瓶颈似乎也包括分配宽度。
  • 感谢您仔细研究在 CPU 中生效的优化的答案。我的误解是管道长度真的很长(14 个阶段?)所以每个停顿都意味着 14 个等待周期。同时,最多有1个周期的停顿。
  • 关于(案例 3)我不完全确定,这取决于测量结果。您是否看到每 1.3 个周期就有 1 个商店?因此,它仍然“接近”每 1.0 个周期 1 个的理论最优值,并且尚不清楚存储限制(存储端口或 L1D 写入端口)是否仍然是主要瓶颈以及有时会发生的另一个瓶颈,或者如果其他东西一直是稳定状态下的瓶颈,那么它可以达到 1.3 个周期。您可以根据已知值计算预期性能...
【解决方案2】:

在经典的教科书管道意义上,是的,这似乎是一种停顿情况,因为您将一个操作的结果用作下一个操作的操作数。但即使在教科书中,您也会看到可能的解决方案。

并且以多种方式实际实现 x86 不会对表面值汇编语言可能暗示的性能造成影响。

这个循环的分支预测也是如此。分支预测可以同时以不同的形式出现。您首先会想到的是逻辑以某种方式预先计算结果,以便可以尽早开始提取(这就是所有分支预测所做的就是抛出一个额外的提取,顺便说一句,这可能会产生负面影响,一些时钟比正常获取早的周期)。或者不打扰预先计算,只是为了以防万一而简单地为该替代路径折腾提取并允许正常提取以覆盖不满足条件的情况。您可以/将会看到实现的另一种解决方案是简单的缓存,无论是深缓存还是短缓存。我记得上次我在 00007FF6A452108E 附近时,这是一个分支指令,让我们放弃了一个早期的取指,而不必费心等待条件是否通过。有些人可能只记得最后几个分支,有些人可能记得更多,对于像这样的简单循环运行 10 次或 100 亿次,您不一定会看到分支预测。

出于多种原因,我不希望您能够创建一些您可以真正看到与简单噪音相比差异的东西。首先,您可能正在操作系统上运行它,并通过代码层询问操作系统该循环的时间是什么时候。我不希望您能够将您在这里尝试做的事情与操作系统的噪音隔离开来。运行 DOS 并禁用中断将是一个开始,但我仍然认为除了处理器/系统的噪音之外你会看到任何东西。

如果您想试验或查看这些效果,您将需要选择不同的处理器和系统。或者您需要研究特定芯片的英特尔文档(或 AMD)以及您正在使用的芯片的步进和固件补丁,然后您应该能够制作一些您可以检测到的指令序列,与功能相同但执行不同的序列相比.

要使代码在 x86 上表现得相当好,需要做很多工作,这就是高成本和功耗的全部原因。许多经典的性能陷阱已被消除,最终发现它们的位置在 x86 ISA 视图中不一定很明显(如果有陷阱,您必须在实现级别查看它)。

【讨论】:

  • 现代 x86 CPU 具有比 ISA 公开的更大的寄存器文件 + 寄存器重命名这一事实是否也会影响这一点?
  • 是的,属于“实现”的细节太多,无法列出,而且从历史上看,随着时间的推移,不同英特尔实现的列表很长,尽管 OP 确实使用了 RAX,从而缩短了列表。是的结果是下一个指令教科书的操作数,但是x86离教科书很远,看看别处。
【解决方案3】:

正如 Hadi 解释的那样,乱序执行隐藏了 inc 馈送存储寻址模式的延迟。

在该迭代的inc 之后的循环执行之前,商店无法执行,但inc 在大多数uarches 上只有1 个循环延迟,因此无序执行隐藏的延迟并不多。


编译器发出带有额外 mov eax,edx 的低效循环的原因是您使用了带有 64 位 size_t 上限的 unsigned(32 位)循环计数器。

C++ 中的unsigned 类型具有编译器必须实现的明确定义的溢出行为(环绕)(与有符号溢出不同的是 UB)。如所写,如果vec.size() &gt; UINT_MAX,则循环是无限的,并且gcc必须针对这种情况编写与抽象机器的行为相匹配的代码。这会阻止您的编译器自动矢量化。

(而且编译器通常不会对无限循环作为 UB 采取积极的态度,即使 ISO C++ 表示如果它们不包含 volatile 或原子操作或库调用,它们也是如此。)

如果您使用int i,就不会遇到这个问题。有符号溢出是 UB,因此编译器可以假设它不会发生并将 i 提升到 size_t 和指针的宽度。 或者更好,使用size_t i,这就是它的用途。无论哪种方式,希望编译器可以将循环转换为指针增量并使用简单的寻址模式,并使用 SSE 或 AVX 自动矢量化进行 16 或 32 字节的存储。


不过,额外的 mov eax,edx 是 100% 冗余的。 i 已经正确地零扩展为 RDX,因此编译器可以使用 inc edx / cmp rdx, r9。在您使用的任何编译器中,这都是一个错过的优化。

【讨论】:

    猜你喜欢
    • 2019-07-03
    • 2014-11-07
    • 1970-01-01
    • 1970-01-01
    • 2012-12-19
    • 2018-01-14
    • 2013-01-11
    • 1970-01-01
    • 2014-03-05
    相关资源
    最近更新 更多