寄存器流水线计算

时间:2018-09-11 17:07:51

标签: c++ assembly x86 cpu-architecture

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

以下是用于简单测试程序的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(只是一个随机值)。

我想问一下这个汇编代码是否会使管道停滞不前?原因是所有指令都以某种方式相关并且在同一寄存器上工作。例如,在cmp rax r9实际将值分配给 eax / rax

之前,管道需要等待指令mov eax, edx等待。

循环10000次是应该进行分支预测的地方。 jb 指令跳10000次,直到最后它会通过。这意味着分支预测器应该非常容易地预测大多数情况下会发生跳跃。但是,从我的角度来看,如果代码本身在循环内停滞,那么这种优化将毫无意义。


我的目标体系结构是Skylake i5-6400

3 个答案:

答案 0 :(得分:5)

TL; DR:

情况1:适合L1D的缓冲区。向量构造函数或对std::fill的调用会将缓冲区完全放在L1D中。在这种情况下,管道的每周期1个存储吞吐量和L1D高速缓存是瓶颈。

情况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<int> vec(10000u, 5)初始化向量会更有效。现在让我们进入循环的微体系结构分析。我只会讨论我期望在现代Intel处理器上发生的事情,特别是Haswell和Skylake 1

让我们仔细检查代码:

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

第一条指令将被解码为单个uop。第二条指令将被解码为在前端融合的两个微指令。第三条指令是寄存器到寄存器的移动,并且是在寄存器重命名阶段消除移动的候选。很难确定如果不运行代码 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指令将被宏融合到单个uop中。因此,在融合域中,微指令的总数为4,在非融合域中为5。他们之间只有一跳。因此,每个循环可以发出一个单循环迭代。

由于incmov存储之间存在依赖性,因此无法在同一周期内分派这两条指令。不过,可以使用来自先前迭代的uops来调度来自先前迭代的inc

incmov可以分派到四个端口(p0,p1,p5,p6)。预测采用的cmp/jb仅存在一个端口p6。 mov dword ptr [rcx+rax*4],5的STA uop有三个端口(p2,p3,p7),而STD uop有一个端口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字节缓存行。

存储融合微指令的总数等于元素数(在这种情况下为100万)。要获得所需的LFB数量,请除以16得到62500 LFB,这与到L2的RFO数量相同。需要另一个LFB之前将需要16个周期,因为每个周期只能调度一个存储。只要L2可以在16个周期内交付目标行,我们就永远不会阻塞LFB,并且实现的吞吐量将接近每个周期1次迭代,或者就IPC而言,每个周期5条指令。这只有在我们几乎总是及时将int 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)在the instruction mov dword ptr [rcx+rax*4],5 will get unlaminiated的Sandy Bridge和Ivy Bridge上,在融合域中每次迭代产生5 oups。因此前端可能在关键路径上。

(2)或类似的东西,取决于循环的第一次迭代的第一条指令是否获得分配器的第一个插槽。如果不是,则需要相应地更改显示的迭代次数。

(3)@PeterCordes发现,在大多数情况下,在Skylake上都确实存在消除移动的情况。我也可以在Haswell上确认这一点。

答案 1 :(得分:4)

在经典的教科书流水线意义上,是的,这似乎处于停滞状态,因为您将一个操作的结果用作下一个操作的操作数。但是,即使在教科书中,您也会看到对此的可能解决方案。

x86的实际实现有多种实现方式,不会带来面值汇编语言可能暗示的性能下降。

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

由于许多原因,我不希望您能够创建与简单噪声相比确实能看到差异的东西。首先,最重要的是,您可能正在操作系统上运行此程序,并通过代码层询问操作系统该循环的时间是几点。我不希望您能够将您要在此处执行的操作与操作系统的噪音区分开。运行DOS并禁用中断是一个开始,但是我仍然认为您不会看到处理器/系统噪音以外的任何东西。

如果您想尝试或看到这些效果,则需要选择其他处理器和系统。或者您需要研究特定芯片的intel文档(或amd)以及所用芯片的步进和固件补丁,然后您应该能够制作出与功能相同但执行不同的相同序列相比可以检测到的指令序列

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

答案 2 :(得分:1)

无序执行隐藏了inc进入商店寻址模式的延迟,如Hadi所述。

直到执行该迭代的inc之后的周期,存储才执行,但是inc在大多数uarch中只有1个周期的延迟,因此,无序的延迟不多隐藏执行。


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

C ++中的

size_t类型具有定义良好的溢出行为(环绕),编译器必须实现(不同于UB的带符号溢出)。因此,如所写,如果unsigned,则循环是无限的,并且gcc必须编写与该情况下抽象机的行为匹配的代码。这将阻止编译器自动向量化。

(而且,即使ISO C ++表示编译器不包含vec.size() > UINT_MAX,原子操作或库调用,编译器通常也不会对作为UB的无限循环持攻击态度。)

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


不过,额外的size_t i是100%冗余的。 mov eax,edx已经正确地零扩展到RDX中,因此编译器可以使用i / inc edx。无论您使用哪种编译器,这都是一个错过的优化。