为什么mulss在Haswell上只用了3个周期,与Agner的指令表不同?

时间:2017-07-15 01:14:58

标签: c assembly optimization sse micro-optimization

我是指导优化的新手。

我对一个简单的函数dotp进行了简单的分析,该函数用于获取两个浮点数组的点积。

C代码如下:

float dotp(               
    const float  x[],   
    const float  y[],     
    const short  n      
)
{
    short i;
    float suma;
    suma = 0.0f;

    for(i=0; i<n; i++) 
    {    
        suma += x[i] * y[i];
    } 
    return suma;
}

我在网上使用Agner Fog提供的测试框架testp

在这种情况下使用的数组是对齐的:

int n = 2048;
float* z2 = (float*)_mm_malloc(sizeof(float)*n, 64);
char *mem = (char*)_mm_malloc(1<<18,4096);
char *a = mem;
char *b = a+n*sizeof(float);
char *c = b+n*sizeof(float);

float *x = (float*)a;
float *y = (float*)b;
float *z = (float*)c;

然后我调用函数dotp,n = 2048,repeat = 100000:

 for (i = 0; i < repeat; i++)
 {
     sum = dotp(x,y,n);
 }

我用gcc 4.8.3编译它,使用编译选项-O3。

我在不支持FMA指令的计算机上编译此应用程序,因此您可以看到只有SSE指令。

汇编代码:

.L13:
        movss   xmm1, DWORD PTR [rdi+rax*4]  
        mulss   xmm1, DWORD PTR [rsi+rax*4]   
        add     rax, 1                       
        cmp     cx, ax
        addss   xmm0, xmm1
        jg      .L13

我做了一些分析:

          μops-fused  la    0    1    2    3    4    5    6    7    
movss       1          3             0.5  0.5
mulss       1          5   0.5  0.5  0.5  0.5
add         1          1   0.25 0.25               0.25   0.25 
cmp         1          1   0.25 0.25               0.25   0.25
addss       1          3         1              
jg          1          1                                   1                                                   -----------------------------------------------------------------------------
total       6          5    1    2     1     1      0.5   1.5

跑完后,我们得到结果:

   Clock  |  Core cyc |  Instruct |   BrTaken | uop p0   | uop p1      
--------------------------------------------------------------------
542177906 |609942404  |1230100389 |205000027  |261069369 |205511063 
--------------------------------------------------------------------  
   2.64   |  2.97     | 6.00      |     1     | 1.27     |  1.00   

   uop p2   |    uop p3   |  uop p4 |    uop p5  |  uop p6    |  uop p7       
-----------------------------------------------------------------------   
 205185258  |  205188997  | 100833  |  245370353 |  313581694 |  844  
-----------------------------------------------------------------------          
    1.00    |   1.00      | 0.00    |   1.19     |  1.52      |  0.00           

第二行是从英特尔寄存器读取的值;第三行除以分支编号“BrTaken”。

所以我们可以看到,在循环中有6条指令,7个uop,与分析一致。

在port0 port1 port 5 port6中运行的uop数与分析中的相似。我想也许uops调度程序会这样做,它可能会尝试平衡端口上的负载,我是对的吗?

我绝对不明白为什么每个循环只有大约3个循环。根据Agner的instruction table,指令mulss的延迟为5,并且循环之间存在依赖关系,因此,据我所知,每个循环应该至少需要5个周期。

有人可以提供一些见解吗?

=============================================== ===================

我尝试在nasm中编写此函数的优化版本,将循环展开8倍并使用vfmadd231ps指令:

.L2:
    vmovaps         ymm1, [rdi+rax]             
    vfmadd231ps     ymm0, ymm1, [rsi+rax]       

    vmovaps         ymm2, [rdi+rax+32]          
    vfmadd231ps     ymm3, ymm2, [rsi+rax+32]    

    vmovaps         ymm4, [rdi+rax+64]          
    vfmadd231ps     ymm5, ymm4, [rsi+rax+64]    

    vmovaps         ymm6, [rdi+rax+96]          
    vfmadd231ps     ymm7, ymm6, [rsi+rax+96]   

    vmovaps         ymm8, [rdi+rax+128]         
    vfmadd231ps     ymm9, ymm8, [rsi+rax+128]  

    vmovaps         ymm10, [rdi+rax+160]               
    vfmadd231ps     ymm11, ymm10, [rsi+rax+160] 

    vmovaps         ymm12, [rdi+rax+192]                
    vfmadd231ps     ymm13, ymm12, [rsi+rax+192] 

    vmovaps         ymm14, [rdi+rax+224]                
    vfmadd231ps     ymm15, ymm14, [rsi+rax+224] 
    add             rax, 256                    
    jne             .L2

结果:

  Clock   | Core cyc |  Instruct  |  BrTaken  |  uop p0   |   uop p1  
------------------------------------------------------------------------
 24371315 |  27477805|   59400061 |   3200001 |  14679543 |  11011601  
------------------------------------------------------------------------
    7.62  |     8.59 |  18.56     |     1     | 4.59      |     3.44


   uop p2  | uop p3  |  uop p4  |   uop p5  |   uop p6   |  uop p7  
-------------------------------------------------------------------------
 25960380  |26000252 |  47      |  537      |   3301043  |  10          
------------------------------------------------------------------------------
    8.11   |8.13     |  0.00    |   0.00    |   1.03     |  0.00        

因此我们可以看到L1数据缓存达到2 * 256bit / 8.59,非常接近峰值2 * 256/8,使用率约为93%,FMA单位仅使用8 / 8.59,峰值为2 * 8/8,使用率为47%。

所以我认为我已经达到了Peter Cordes所期望的L1D瓶颈。

=============================================== ===================

特别感谢Boann,在我的问题中解决了很多语法错误。

=============================================== ==================

从Peter的回复中,我得知只有“读写”寄存器才是依赖,“仅限编写”的寄存器不会是依赖。

所以我尝试减少循环中使用的寄存器,我尝试展开5,如果一切正常,我应该遇到同样的瓶颈,L1D。

.L2:
    vmovaps         ymm0, [rdi+rax]    
    vfmadd231ps     ymm1, ymm0, [rsi+rax]    

    vmovaps         ymm0, [rdi+rax+32]    
    vfmadd231ps     ymm2, ymm0, [rsi+rax+32]   

    vmovaps         ymm0, [rdi+rax+64]    
    vfmadd231ps     ymm3, ymm0, [rsi+rax+64]   

    vmovaps         ymm0, [rdi+rax+96]    
    vfmadd231ps     ymm4, ymm0, [rsi+rax+96]   

    vmovaps         ymm0, [rdi+rax+128]    
    vfmadd231ps     ymm5, ymm0, [rsi+rax+128]   

    add             rax, 160                    ;n = n+32
    jne             .L2 

结果:

    Clock  | Core cyc  | Instruct  |  BrTaken |    uop p0  |   uop p1  
------------------------------------------------------------------------  
  25332590 |  28547345 |  63700051 |  5100001 |   14951738 |  10549694   
------------------------------------------------------------------------
    4.97   |  5.60     | 12.49     |    1     |     2.93   |    2.07    

    uop p2  |uop p3   | uop p4 | uop p5 |uop p6   |  uop p7 
------------------------------------------------------------------------------  
  25900132  |25900132 |   50   |  683   | 5400909 |     9  
-------------------------------------------------------------------------------     
    5.08    |5.08     |  0.00  |  0.00  |1.06     |     0.00    

我们可以看到5 / 5.60 = 89.45%,它比8岁的小一点小,有什么不对吗?

=============================================== ==================

我尝试按6,7和15展开循环,以查看结果。 我也再次按5和8展开,以重复确认结果。

结果如下,我们可以看到这次结果比以前好多了。

虽然结果不稳定,但展开因子更大,结果更好。

            | L1D bandwidth     |  CodeMiss | L1D Miss | L2 Miss 
----------------------------------------------------------------------------
  unroll5   | 91.86% ~ 91.94%   |   3~33    | 272~888  | 17~223
--------------------------------------------------------------------------
  unroll6   | 92.93% ~ 93.00%   |   4~30    | 481~1432 | 26~213
--------------------------------------------------------------------------
  unroll7   | 92.29% ~ 92.65%   |   5~28    | 336~1736 | 14~257
--------------------------------------------------------------------------
  unroll8   | 95.10% ~ 97.68%   |   4~23    | 363~780  | 42~132
--------------------------------------------------------------------------
  unroll15  | 97.95% ~ 98.16%   |   5~28    | 651~1295 | 29~68

=============================================== ======================

我尝试在网络“https://gcc.godbolt.org

中使用gcc 7.1编译该函数

编译选项是“-O3 -march = haswell -mtune = intel”,类似于gcc 4.8.3。

.L3:
        vmovss  xmm1, DWORD PTR [rdi+rax]
        vfmadd231ss     xmm0, xmm1, DWORD PTR [rsi+rax]
        add     rax, 4
        cmp     rdx, rax
        jne     .L3
        ret

1 个答案:

答案 0 :(得分:28)

再次查看您的循环: movss xmm1, srcxmm1的旧值无关,因为其目标是只写。每次迭代mulss都是独立的。乱序执行可以并且确实利用了指令级并行性,因此您绝对不会遇到mulss延迟的瓶颈。

可选读物:在计算机体系结构术语中:寄存器重命名避免了重用相同架构寄存器的WAR anti-dependency data hazard。 (注册表重命名之前的一些流水线+依赖关系跟踪方案并没有解决所有问题,因此计算机体系结构领域对各种数据危害都有很大帮助。

使用Tomasulo's algorithm重命名寄存器会使一切都消失,除了实际的真正依赖关系(写入后读取),因此目标不是源寄存器的任何指令都不会与涉及旧值的依赖关系链相互作用。注册。 (除了popcnt on Intel CPUs之类的错误依赖,并且只写入部分寄存器而不清除其余部分(如mov al, 5sqrtss xmm2, xmm1)。相关:Why do most x64 instructions zero the upper part of a 32 bit register)。

返回您的代码:

.L13:
    movss   xmm1, DWORD PTR [rdi+rax*4]  
    mulss   xmm1, DWORD PTR [rsi+rax*4]   
    add     rax, 1                       
    cmp     cx, ax
    addss   xmm0, xmm1
    jg      .L13

循环携带的依赖关系(从一次迭代到下一次迭代)各自:

  • xmm0,由 addss xmm0, xmm1 读取和写入,在Haswell上有3个周期延迟。
  • rax,由add rax, 1读写。 1c延迟,因此它不是关键路径。

看起来您正确测量了执行时间/周期数,因为3c addss延迟的循环瓶颈。

这是预期的:点积中的序列依赖是单个和(也就是减少)的加法,而不是向量元素之间的乘法。

这是迄今为止这个循环的主要瓶颈,尽管存在各种轻微的低效率:

short i产生了愚蠢的cmp cx, ax,它采用了额外的操作数大小前缀。幸运的是,gcc设法避免实际执行add ax, 1,因为签名溢出是C中的未定义行为。So the optimizer can assume it doesn't happen。 (更新:integer promotion rules make it different for short,所以UB没有进入它,但gcc仍然可以合法优化。非常古怪的东西。)

如果您使用-mtune=intel或更好的-march=haswell进行汇编,gcc会将cmpjg放在彼此相邻的位置 - 保险丝。

我不确定为什么您的*cmp指令中有add表。 (更新:我纯粹猜测你使用的是IACA之类的符号,但显然你不是。它们都没有融合。发生的唯一融合是mulss xmm1, [rsi+rax*4]的微融合。

由于它是具有读 - 修改 - 写目的地寄存器的2操作数ALU指令,因此即使在Haswell上的ROB中也保持宏融合。 (Sandybridge会在发布时解开它。)Note that vmulss xmm1, xmm1, [rsi+rax*4] would un-laminate on Haswell, too

这一切都不重要,因为你只是完全没有FP-add延迟的瓶颈,比任何uop吞吐量限制慢得多。没有-ffast-math,编译器就无法做到。对于-ffast-math,clang通常会使用多个累加器进行展开,并且它将自动向量化,因此它们将是向量累加器。因此,如果您使用L1D缓存,则可能会使Haswell的吞吐量限制为每个时钟1个向量或标量FP加入。

由于FMA在Haswell上具有5c延迟和0.5c吞吐量,因此需要10个累加器来保持10个FMA在飞行中并通过保持p0 / p1饱和FMA来最大化FMA吞吐量。 (Skylake将FMA延迟减少到4个周期,并在FMA单元上运行乘法,加法和FMA。因此它实际上具有比Haswell更高的添加延迟。)

(您在负载上遇到瓶颈,因为每个FMA需要两个负载。在其他情况下,您可以通过用乘数为1.0的FMA替换一些vaddps指令来实际获得增加吞吐量这意味着隐藏更多的延迟,因此在一个更复杂的算法中它是最好的,在这个算法中,你首先没有关键路径上的添加。)

Re:每个端口uops

  

端口5中的每个循环有1.19个uop,远远超过预期的0.5,是否uops调度程序尝试在每个端口上进行微操作

是的,就像那样。

uop不是随机分配的,或者以某种方式均匀分布在可以运行的每个端口上。您认为addcmp uops将在p0156上均匀分配,但事实并非如此。

问题阶段根据已经等待该端口的uop数量将uops分配给端口。由于addss只能在p1上运行(并且它是循环瓶颈),因此通常会发出大量p1 uop但未执行。所以很少有其他的uop将被安排到port1。 (这包括mulss:大多数mulss微博将最终安排到端口0。)

Taken-branches只能在端口6上运行。端口5在此循环中没有任何uop,只能 在那里运行,所以它最终吸引了很多人 - 端口uops。

调度程序(从预留站中选择未融合的域uops)不够智能,无法运行关键路径优先,因此这是分配算法减少资源冲突延迟(其他uops在周期中窃取port1)当addss可以运行时)。在您遇到给定端口吞吐量的情况下,它也很有用。

根据我的理解,已经分配的uops的调度通常是最早的。这个简单的算法并不令人惊讶,因为它必须在每个时钟周期从a 60-entry RS为每个端口准备好输入,而不会使CPU熔化。发现和利用the ILP的无序机制是现代CPU中的重要电力成本之一,与执行实际工作的执行单位相当。

相关/更多详情:How are x86 uops scheduled, exactly?

更多性能分析:

除了缓存未命中/分支错误预测之外,CPU绑定循环的三个主要可能瓶颈是:

  • 依赖链(如本案例中)
  • 前端吞吐量(Haswell每个时钟最多发出4个融合域uop)
  • 执行端口瓶颈,就像许多uops需要p0 / p1或p2 / p3一样,就像在展开的循环中一样。计算特定端口的未融合域uops。通常情况下,您可以假设最佳情况分发,使用可以在其他端口上运行的uops,而不是频繁地窃取繁忙端口,但确实会发生一些。

循环体或短代码块可以用3种方法近似表征:融合域uop计数,可以运行的执行单元的未融合域计数,以及假设最佳情况调度的总关键路径延迟它的关键路径。 (或从输入A / B / C到输出的每个延迟......)

例如,要比较几个短序列,请完成所有三个,请参阅What is the efficient way to count set bits at a position or lower?

上的答案

对于短循环,现代CPU具有足够的无序执行资源(物理寄存器文件大小,因此重命名不会耗尽寄存器,ROB大小),以便有足够的循环迭代来查找所有的并行性。但随着循环内的依赖链变长,最终它们会耗尽。有关当CPU用完重命名的寄存器时会发生什么情况的详细信息,请参阅Measuring Reorder Buffer Capacity

另请参阅代码wiki中的大量性能和参考链接。

调整您的FMA循环:

是的,Haswell上的dot-product将仅在FMA单元的吞吐量的一半时成为L1D吞吐量的瓶颈,因为每次乘法+加载需要两个负载。

如果您正在执行B[i] = x * A[i] + y;sum(A[i]^2),则可能会使FMA吞吐量饱和。

看起来您仍在尝试避免注册表重用,即使在只写vmovaps加载目标的情况下也是如此,因此在展开8之后用完了寄存器。这很好,但对其他情况可能很重要。

此外,使用ymm8-15可能会略微增加代码大小,如果它意味着需要3字节的VEX前缀而不是2字节。有趣的事实:vpxor ymm7,ymm7,ymm8需要一个3字节的VEX,而vpxor ymm8,ymm8,ymm7只需要一个2字节的VEX前缀。对于可交换操作,将源序列从高到低排序。

我们的负载瓶颈意味着最佳情况下FMA吞吐量是最大值的一半,因此我们需要至少5个向量累加器来隐藏其延迟。 8很好,所以依赖链中有很多松弛,让它们在意外延迟或竞争p0 / p1后出现任何延迟。 7或者甚至6也没关系:你的展开因子不一定是2的幂。

完全展开5意味着您也正确处于依赖链的瓶颈。只要FMA没有在精确周期中运行,其输入就绪意味着该依赖链中的丢失周期。如果负载很慢(例如,它在L1缓存中未命中并且必须等待L2),或者如果负载完成无序并且来自另一个依赖链的FMA窃取了此FMA被安排的端口,则会发生这种情况。 (请记住,调度发生在发布时,因此位于调度程序中的uop是port0 FMA或port1 FMA,而不是可以占用任何端口空闲的FMA。)

如果你在依赖链中留下一些松懈,乱序执行就可以“赶上”#34;在FMA上,因为他们不会在吞吐量或延迟方面遇到瓶颈,只是等待负载结果。 @Forward发现(在问题的更新中),从这个循环中,将第3次表示从L1D吞吐量的93%降低到89.5%。

我的猜测是,展开6(比隐藏延迟的最小值多一个)就可以了,并获得与展开8相同的性能。如果我们接近最大化FMA吞吐量(而不仅仅是在负载吞吐量方面存在瓶颈),超过最小值可能还不够。

更新:@ Forward的实验测试显示我猜错了。 unroll5和unroll6之间没有很大的区别。此外,unroll15是unroll8的两倍,理论最大吞吐量为每时钟2x 256b负载。仅使用循环中的独立负载或使用独立负载和仅寄存器FMA进行测量,将告诉我们有多少是由于与FMA依赖链的交互。即使是最好的情况也不会获得完美的100%吞吐量,只是因为测量错误和定时器中断导致的中断。 (Linux perf仅测量用户空间周期,除非您以root用户身份运行它,但时间仍然包括在中断处理程序中花费的时间。这就是为什么当以非root用户身份运行时,CPU频率可能会报告为3.87GHz,但是以root身份运行并测量cycles而不是cycles:u时为3.900GHz。)

我们在前端吞吐量方面没有瓶颈,但我们可以通过避免非mov指令的索引寻址模式来减少融合域uop计数。当用不同于此的东西共享核心时,越少越好并且使其更加超线程友好

简单的方法就是在循环中做两个指针递增。复杂的方法是将一个数组相对于另一个数组编入索引的巧妙技巧:

;; input pointers for x[] and y[] in rdi and rsi
;; size_t n  in rdx

    ;;; zero ymm1..8, or load+vmulps into them

    add             rdx, rsi             ; end_y
    ; lea rdx, [rdx+rsi-252]  to break out of the unrolled loop before going off the end, with odd n

    sub             rdi, rsi             ; index x[] relative to y[], saving one pointer increment

.unroll8:
    vmovaps         ymm0, [rdi+rsi]            ; *px, actually py[xy_offset]
    vfmadd231ps     ymm1, ymm0, [rsi]          ; *py

    vmovaps         ymm0,       [rdi+rsi+32]   ; write-only reuse of ymm0
    vfmadd231ps     ymm2, ymm0, [rsi+32]

    vmovaps         ymm0,       [rdi+rsi+64]
    vfmadd231ps     ymm3, ymm0, [rsi+64]

    vmovaps         ymm0,       [rdi+rsi+96]
    vfmadd231ps     ymm4, ymm0, [rsi+96]

    add             rsi, 256       ; pointer-increment here
                                   ; so the following instructions can still use disp8 in their addressing modes: [-128 .. +127] instead of disp32
                                   ; smaller code-size helps in the big picture, but not for a micro-benchmark

    vmovaps         ymm0,       [rdi+rsi+128-256]  ; be pedantic in the source about compensating for the pointer-increment
    vfmadd231ps     ymm5, ymm0, [rsi+128-256]
    vmovaps         ymm0,       [rdi+rsi+160-256]
    vfmadd231ps     ymm6, ymm0, [rsi+160-256]
    vmovaps         ymm0,       [rdi+rsi-64]       ; or not
    vfmadd231ps     ymm7, ymm0, [rsi-64]
    vmovaps         ymm0,       [rdi+rsi-32]
    vfmadd231ps     ymm8, ymm0, [rsi-32]

    cmp             rsi, rdx
    jb              .unroll8                 ; } while(py < endy);

使用非索引寻址模式作为vfmaddps的内存操作数,可以使其在无序内核中保持微融合,而不是在问题上进行非层叠。 Micro fusion and addressing modes

所以我的循环是8个向量的18个融合域uops。由于索引寻址模式没有分层,因此每个vmovaps + vfmaddps对需要3个融合域uop,而不是2。它们当然每对仍然有2个未融合域加载uops(port2 / 3),因此仍然是瓶颈。

更少的融合域uops使得无序执行可以看到更多的迭代,可能有助于它更好地吸收缓存未命中。尽管如此,即使没有缓存未命中,当我们在执行单元(在这种情况下加载uops)上遇到瓶颈时,这也是一个小问题。但是对于超线程,除非另一个线程停滞,否则只能获得前端发布带宽的每个其他周期。如果它没有为load和p0 / 1竞争太多,那么融合域uops的数量会减少,这样在共享内核时这个循环运行得更快。 (例如,其他超线程可能正在运行很多port5 / port6并存储uops?)

由于在uop-cache之后发生了取消分层,因此你的版本在uop缓存中没有占用额外的空间。每个uop的disp32都可以,并且不占用额外的空间。但是体积较大的代码大小意味着uop-cache不太可能有效地打包,因为在uop缓存行更频繁地填满之前你将达到32B边界。 (实际上,较小的代码也不能保证更好。较小的指令可能导致填充uop缓存行,并且在跨越32B边界之前需要在另一行中输入一个条目。)这个小循环可以从环回缓冲区(LSD)运行,幸运的是,uop-cache不是因素。

然后循环之后:高效清理是小数组高效矢量化的难点,可能不是展开因子的倍数,尤其是矢量宽度

    ...
    jb

    ;; If `n` might not be a multiple of 4x 8 floats, put cleanup code here
    ;; to do the last few ymm or xmm vectors, then scalar or an unaligned last vector + mask.

    ; reduce down to a single vector, with a tree of dependencies
    vaddps          ymm1, ymm2, ymm1
    vaddps          ymm3, ymm4, ymm3
    vaddps          ymm5, ymm6, ymm5
    vaddps          ymm7, ymm8, ymm7

    vaddps          ymm0, ymm3, ymm1
    vaddps          ymm1, ymm7, ymm5

    vaddps          ymm0, ymm1, ymm0

    ; horizontal within that vector, low_half += high_half until we're down to 1
    vextractf128    xmm1, ymm0, 1
    vaddps          xmm0, xmm0, xmm1
    vmovhlps        xmm1, xmm0, xmm0        
    vaddps          xmm0, xmm0, xmm1
    vmovshdup       xmm1, xmm0
    vaddss          xmm0, xmm1
    ; this is faster than 2x vhaddps

    vzeroupper    ; important if returning to non-AVX-aware code after using ymm regs.
    ret           ; with the scalar result in xmm0

有关末尾水平和的更多信息,请参阅Fastest way to do horizontal float vector sum on x86。我使用的两个128b shuffle甚至不需要立即控制字节,因此它节省了2个字节的代码大小而不是更明显的shufps。 (还有4个字节的代码大小与vpermilps,因为该操作码总是需要一个3字节的VEX前缀以及一个立即的前缀)。与SSE相比,AVX 3操作数的东西非常很好,特别是当用C语言写入内部函数时,你不能轻易地选择冷注册到movhlps