再看看你的循环:movss xmm1, src
的旧值不依赖于xmm1
,因为它的目的地是只写的。..每次迭代mulss
是独立的。无序执行可以并且确实利用指令级的并行性,所以您绝对不会将其作为瓶颈。mulss
延迟。
可选阅读:在计算机体系结构术语中:寄存器重命名避免了战争反依赖数据危险重复使用相同的架构寄存器。(在寄存器重命名之前的一些流水线+依赖跟踪方案并没有解决所有的问题,因此计算机体系结构领域对不同类型的数据危害做了很大的贡献。
注册重命名Tomasulo算法除了实际的真正依赖项(写入后读取)之外,一切都会消失,因此,目标也不是源寄存器的任何指令都不会与涉及该寄存器的旧值的依赖链进行交互。(除了假依赖项,如popcnt
英特尔CPU,并且只在没有清除其余部分的情况下写入寄存器的一部分(如mov al, 5
或sqrtss xmm2, xmm1
)。有关:为什么大多数x64指令对32位寄存器的上部为零?).
回到您的代码:
.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中未定义的行为。所以优化器可以假设它不会发生..(最新情况:整数提升规则使其不同于short
所以UB没有介入,但是GCC仍然可以在法律上进行优化。(相当古怪的东西。)
如果你用-mtune=intel
或者更好-march=haswell
GCC会把cmp
和jg
相邻的地方,他们可以宏观融合。
我不知道你为什么*
在你的桌子上cmp
和add
指示。(更新:我纯粹是猜测你用的是一种符号,如IACA是的,但很明显你没有)。他们俩都没有融合。唯一的融合发生在mulss xmm1, [rsi+rax*4]
.
由于它是一个带有读-修改-写目标寄存器的2操作数ALU指令,即使在Haswell上的Rob中,它也保持宏融合。(沙桥将在发布时间不对其进行分层处理。)请注意vmulss xmm1, xmm1, [rsi+rax*4]
也会在哈斯韦尔身上层叠吗?.
所有这些都不重要,因为您只是在fp添加延迟上完全瓶颈,比任何uop吞吐量限制都慢得多。无-ffast-math
编译器无能为力。带着-ffast-math
,Clang通常会使用多个累加器展开,并且它将自动矢量化,因此它们将是向量累加器。因此,如果您在L1D缓存中命中,您可能可以满足Haswell的每时钟1矢量或标量FP添加的吞吐量限制。
在Haswell上FMA为5c延迟和0.5c吞吐量时,您将需要10个累加器来保持10个FMA在飞行中,并通过保持P0/p1饱和于FMAs而使FMA吞吐量达到最大值。(Skylake将FMA延迟降低到4个周期,并在FMA单元上运行乘法、加法和FMA。因此,它实际上比Haswell具有更高的添加延迟。)
(你在负载上遇到了瓶颈,因为每个FMA都需要两个负载。在其他情况下,您实际上可以通过替换某个vaddps
使用乘法器为1.0的FMA指令。这意味着需要隐藏更多的延迟,所以在一个更复杂的算法中最好是先添加一个不处于关键路径的Add。)
Re:每个端口的OOPS:
在端口5中,每个循环有1.19个uop,比预期的0.5要多,难道uops调度程序试图在每个端口上使uop相同吗?
是的,差不多。
uop不是随机分配的,也不是以某种方式均匀地分布在它们的每个端口上。能继续跑。你以为add
和cmp
uops会均匀分布在p 0156上,但事实并非如此。
问题阶段根据已经等待该端口的uop数量将uop分配给端口。自addss
只能在p1上运行(这是循环瓶颈),通常有很多p1 uop发出,但没有执行。很少有其他uop会被安排到端口1上。(这包括mulss
*大多数mulss
(uop最终将被安排到端口0。)
获取的分支只能在端口6上运行。端口5在这个循环中没有任何可以运行的uop。只跑到那里去,结果吸引了很多港口的人。
调度程序(从预订站中提取未融合域uops)不够聪明,无法首先运行关键路径,因此这是一种减少资源冲突延迟的分配算法(当addss
本可以逃跑的)。在瓶颈影响给定端口吞吐量的情况下,它也很有用。
按照我的理解,已经分配的uop的调度通常是最古老的。这个简单的算法并不令人惊讶,因为它必须从一辆60进场的RS每一个时钟周期,不熔化你的CPU。发现和利用的坏机器ILP是现代CPU中重要的电源成本之一,可与实际工作的执行单元相媲美。
有关/更多详情:确切地说,x86 uop是如何安排的?
更多的性能分析资料:
除了缓存丢失/分支错误预测之外,CPU绑定循环的三个主要瓶颈可能是:
- 依赖链(如本例中所示)
- 前端吞吐量(在Haswell上每个时钟最多发出4个融合域uop)
- 执行端口瓶颈,比如很多uop需要P0/p1或p2/p3,就像在展开循环中一样。计算特定端口的未融合域uop。通常,您可以假设最佳情况分布,可以在其他端口上运行的uop不会经常占用繁忙的端口,但确实会发生一些情况。
一个循环体或短代码块可以大致由三件事来描述:融合域uop计数,它可以运行的执行单元的未融合域计数,以及假定其关键路径的最佳情况调度的全部关键路径延迟。(或每个输入A/B/C到输出的延迟.)
例如,做所有这三种方法来比较几个简短的序列,请参阅我对以下内容的回答:在某一位置或更低的位置计数SET位的有效方法是什么?
对于短循环,现代CPU有足够的无序执行资源(物理寄存器文件大小,以便重命名不会耗尽寄存器、rob大小),以便在运行中有足够的循环迭代来查找所有的并行性。但是,随着循环中的依赖链越来越长,它们最终会耗尽。看见测重序缓冲容量有关CPU耗尽要重命名的寄存器时发生的情况的一些详细信息。
还请参阅x86标记wiki。
调整FMA循环:
是的,在Haswell上的点积将瓶颈的L1D吞吐量只有一半的FMA单位,因为它需要两个负载每乘+加。
如果你在做B[i] = x * A[i] + y;
或sum(A[i]^2)
,您可以饱和FMA吞吐量。
看起来,您仍然试图避免注册重用,即使在只写的情况下也是如此,比如vmovaps
加载,所以在展开8之后就没有寄存器了。..这很好,但可能会影响到其他案件。
此外,使用ymm8-15
如果它意味着需要一个3字节的VEX前缀而不是2字节,就可以稍微增加代码大小.有趣的事实:vpxor ymm7,ymm7,ymm8
需要一个3字节的VEXvpxor ymm8,ymm8,ymm7
只需要一个2字节的VEX前缀。对于交换操作,从高到低对源规则进行排序。
我们的负载瓶颈意味着最佳情况下的fma吞吐量是最大的一半,所以我们至少需要5个向量累加器来隐藏它们的延迟。8很好,因此在依赖链中有大量的空闲,可以让它们在由于意外延迟或对P0/p1的竞争而出现的任何延迟之后迎头赶上。7,甚至6也可以:你的展开因子不一定是2的幂。
完全按5展开将意味着您也正处于依赖链的瓶颈..当FMA没有在确切的周期中运行时,它的输入就准备好了,这意味着在该依赖链中丢失了一个周期。如果加载速度慢(例如,它在L1缓存中丢失并必须等待L2),或者加载完全无序,而来自另一个依赖链的FMA抢占该FMA原计划的端口,则可能发生这种情况。(请记住,调度发生在发布时,所以调度程序中的uop要么是端口0 fma,要么是port1 fma,而不是可以采取哪个端口空闲的fma)。
如果在依赖链中留下一些空白,无序执行可以“赶上”FMA,因为它们不会在吞吐量或延迟上遇到瓶颈,只是等待加载结果。@Forward发现(在对问题的更新中),该循环的性能从L1D吞吐量的93%降至89.5%。
我的猜测是,将FMA吞吐量增加6(比隐藏延迟的最小值多一个)在这里是可以的,并且获得与展开8相同的性能。如果我们更接近最大化FMA吞吐量(而不是仅仅是负载吞吐量的瓶颈),超过最小可能是不够的。
更新:@Forward的实验测试显示我的猜测是错误的..在unroll 5和unroll 6之间没有太大的区别。另外,unroll 15是unroll 8的两倍,接近理论上每个时钟2x256 b负载的最大吞吐量。使用循环中的独立负载或独立负载和仅注册的FMA进行测量,将告诉我们其中有多少是由于与FMA依赖链的交互而产生的。即使是最好的情况也不能获得完美的100%的吞吐量,如果只是因为测量误差和由于计时器中断而造成的中断。(Linux)perf
除非以根用户身份运行,否则只测量用户空间周期,但时间仍然包括中断处理程序所花费的时间。这就是为什么您的CPU频率在非根运行时可能报告为3.87GHz,而在作为根运行和测量时为3.900GHz。cycles
而不是cycles:u
.)
我们并不是前端吞吐量的瓶颈,但是我们可以通过避免非索引的寻址模式来减少融合域uop的数量。mov
指示。越少越好,越好。超线程友好当与其他东西共享一个核心时。
简单的方法就是在循环中执行两个指针增量。复杂的方法是将一个数组相对于另一个数组索引的巧妙技巧:
;; 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
让它保持微型熔合在无序的核心,而不是在争论中被层叠。微融合寻址模式
所以我的回路是8个向量的18个融合域uop。您的每个vmovaps+vfmaddps对采用3个融合域uop,而不是2,因为索引寻址模式没有分层。当然,它们每对仍有2个未融合域加载uop(port2/3),所以这仍然是瓶颈。
较少的融合域uop允许无序执行看到更多的迭代,这可能有助于它更好地吸收缓存缺失。但是,当我们在执行单元上遇到瓶颈(在本例中是Load uops)时,即使没有缓存丢失,这也是次要的事情。但是使用超线程时,除非另一个线程被停止,否则只会得到前端问题带宽的每一个其他周期。如果它没有为Load和P0/1竞争太多,那么融合域uops就会让这个循环在共享内核的同时运行得更快。(例如,可能另一个超级线程正在运行大量的port 5/port 6和存储uops?)
由于不分层发生在uop缓存之后,您的版本在uop缓存中不会占用额外的空间。每个uop都可以,不需要额外的空间。但是,更大的代码大小意味着uop缓存不太可能高效地打包,因为在uop缓存行更频繁的情况下,您将到达32B边界。(实际上,较小的代码也不能保证更好。较小的指令可能会导致填充uop缓存线,并且在穿越32B边界之前需要在另一行中输入一个条目。)这个小循环可以从回环缓冲区(LSD)运行,因此幸运的是uop缓存不是一个因素。
然后在循环之后:高效清除是小数组高效矢量化的困难部分,这些小数组可能不是展开因子的倍数,尤其是矢量宽度。
...
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
有关尾端水平和的更多信息,请参见在x86上实现水平浮点向量和的最快方法..我使用的两次128 B洗牌甚至不需要立即控制字节,因此它节省了2字节的代码大小,而更明显的是shufps
..(代码大小为4字节。vpermilps
,因为操作码总是需要一个3字节的VEX前缀以及一个直接前缀)。AVX 3-操作数是非常比较好的SSE,特别是在用C编写内部代码时,所以您不能很容易地选择一个冷寄存器movhlps
进入。