我是指令优化方面的新手。
我对一个简单的函数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;
}
我在网络testp上使用了Agner Fog提供的测试框架。
在这种情况下使用的数组是对齐的:
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,重复= 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微指令)与分析一致。
在端口0,端口1,端口5,端口6中运行的uops数量与分析结果相似。我认为也许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,解决了我的许多语法错误。
================================================== ===============
从彼得的答复中,我知道只有“读和写”寄存器才是依赖关系,“仅写者”寄存器就不是依赖关系。
因此,我尝试减少循环中使用的寄存器,并尝试将其展开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%,它比urolling 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
最佳答案
再次查看循环: movss xmm1, src
不依赖于xmm1
的旧值,因为其目标是只写的。每次迭代的mulss
是独立的。乱序执行可以并且确实利用了指令级并行性,因此您绝对不会在mulss
延迟上遇到瓶颈。
可选阅读:用计算机体系结构术语来说:寄存器重命名避免了WAR anti-dependency data hazard重用同一体系结构寄存器。 (在寄存器重命名之前,某些流水线+依赖项跟踪方案无法解决所有问题,因此计算机体系结构 Realm 在解决各种数据危害方面大有作为。
用Tomasulo's algorithm重命名寄存器会使所有事情都消失了,除了真正的真实依赖关系(写后读取),因此,目标不是源寄存器的任何指令都不会与依赖关系链发生交互,涉及该寄存器的旧值。 (除了错误的依赖项(例如 popcnt
on Intel CPUs)之外,只写寄存器的一部分而不清除其余部分(例如mov al, 5
或sqrtss xmm2, xmm1
。)。Related: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
延迟上。这是预料之中的:点积中的序列依赖性是单个和(即归约)的加法,而不是 vector 元素之间的乘积。
尽管存在各种较小的效率低下,但到目前为止,这是此循环的主要瓶颈:
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会将cmp
和jg
彼此相邻放置,以进行宏融合。我不确定为什么在表上有
*
和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添加延迟的瓶颈,比任何uop吞吐量限制都慢得多。没有
-ffast-math
,编译器将无能为力。使用-ffast-math
,clang通常会在多个累加器中展开,并且会自动矢量化,因此它们将成为 vector 累加器。因此,如果您命中L1D缓存,则可以使Haswell的吞吐量限制(每个时钟增加1个 vector 或标量FP)达到饱和。如果FMA在Haswell上的延迟为5c,吞吐量为0.5c,则需要10个累加器才能使10个FMA处于运行状态,并通过使p0 / p1保持FMA饱和来最大程度地提高FMA的吞吐量。 (Skylake将FMA延迟减少到4个周期,并在FMA单元上运行乘法,加法和FMA。因此,它的添加延迟实际上比Haswell高。)
(您遇到了瓶颈,因为每个FMA都需要两个负载。在其他情况下,实际上可以通过用1.0的FMA替换一些
vaddps
指令来获得增加的吞吐量。这意味着要隐藏更多的延迟,因此最好是使用更复杂的算法,在该算法中,您首先要添加的内容不在关键路径上。)回复:每个端口uops :
是的,类似的东西。
这些微指令不是随机分配的,也不是它们可以在其上运行的每个端口上均匀分配的。您假设
add
和cmp
uops将在p0156上均匀分布,但事实并非如此。发行阶段根据已经等待该端口多少个微指令来将微指令分配给端口。由于
addss
只能在p1上运行(这是循环瓶颈),因此通常会有很多p1 uops发出但没有执行。因此,几乎没有其他uops将被调度到port1。 (这包括mulss
:大多数mulss
uops最终将排定到端口0。)分支分支只能在端口6上运行。端口5在此循环中没有任何uops,只能在那儿运行,因此最终吸引了许多多端口uops。
调度程序(从预留站中选择未融合域的指令)不够智能,无法运行关键路径优先,因此,此分配算法可减少资源冲突延迟(其他uot在
addss
可能具有周期的情况下窃取了port1跑)。如果您遇到给定端口的吞吐量瓶颈的情况,这也很有用。据我了解,对已分配的微指令进行调度通常是最早的。这个简单的算法不足为奇,因为它必须在每个时钟周期从a 60-entry RS中为每个端口准备好其输入的uop,而不会使CPU崩溃。发现和利用the ILP的乱序机制是现代CPU中可观的电力成本之一,与执行实际工作的执行单元相当。
相关/更多详细信息:How are x86 uops scheduled, exactly?
更多性能分析资料:
除了高速缓存未命中/分支预测错误之外,CPU绑定(bind)循环的三个主要可能瓶颈是:
循环体或简短的代码块可以大致由以下三点来表征:融合域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。
另请参见x86标签Wiki中的许多性能和引用链接。
调整FMA循环:
是的,Haswell上的点积将仅以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个 vector 累加器来隐藏其延迟。 8是很好的,因此依赖链中有很多松弛,可以让它们在因意外延迟或争夺p0 / p1而产生的任何延迟后赶上来。 7或什至6也可以:展开系数不必为2的幂。
展开精确到5表示您也正处于依赖链的瓶颈。任何时候FMA没有以确切的周期运行时,其输入准备就绪就意味着该依赖关系链中丢失了一个周期。如果加载速度很慢(例如,它在L1缓存中未命中而必须等待L2),或者如果加载未按顺序完成并且来自另一个依赖链的FMA窃取了此FMA预定的端口,则可能发生这种情况。 (请记住,调度是在发布时进行的,因此调度程序中的对象是port0 FMA或port1 FMA,而不是可以占用任何空闲端口的FMA)。
如果您在依赖关系链中留下一些懈怠,那么乱序执行可能会“赶上” FMA,因为它们不会在吞吐量或延迟方面造成瓶颈,只需等待负载结果即可。 @Forward发现(在问题的更新中),对于此循环,展开5会使性能从L1D吞吐量的93%降低到89.5%。
我的猜测是在这里可以展开6(比隐藏延迟的最小值多一个),并且获得与展开8相同的性能。如果我们更接近最大化FMA吞吐量(而不仅仅是瓶颈)吞吐量),超过最小值可能还不够。
更新:@Forward的实验测试表明我的猜测是错误。 unroll5和unroll6之间没有太大区别。同样,unroll15的大小是unroll8的两倍,是每个时钟2x 256b负载的理论最大吞吐量的两倍。仅使用循环中的独立负载进行测量,或者使用独立负载和仅寄存器的FMA进行测量,将告诉我们其中有多少是由于与FMA依赖关系链的交互作用所致。即使是最好的情况,也仅由于测量错误和计时器中断而导致无法获得理想的100%吞吐量。 (Linux
perf
除非您以 super 用户身份运行,否则它仅测量用户空间周期,但是时间仍然包括中断处理程序所花费的时间。这就是为什么以非 super 用户身份运行时,CPU频率报告为3.87GHz,而运行时为3.900GHz的原因。作为根并测量cycles
而不是cycles:u
。)我们没有瓶颈,但可以通过避免非
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个 vector 的18个融合域uops。您的每个vmovaps + vfmaddps对需要3个融合域uops,而不是2,因为索引寻址模式没有分层。当然,它们每对仍然具有2个未融合域的负载uops(端口2/3),因此这仍然是瓶颈。
较少的融合域微指令可使乱序执行提前看到更多迭代,从而有可能帮助它更好地吸收缓存丢失。但是,即使没有高速缓存未命中,当我们遇到执行单元(在这种情况下,加载uops)成为瓶颈时,这也是一件小事。但是,使用超线程,除非其他线程停止运行,否则您只能获得前端发布带宽的所有其他周期。如果在负载和p0 / 1上的竞争不是太激烈,则较少的融合域uops将使此循环在共享内核时运行得更快。 (例如,另一个超线程可能正在运行许多port5 / port6并存储uops吗?)
由于取消分层发生在uop缓存之后,因此您的版本不会在uop缓存中占用额外的空间。每个uop的disp32都可以,并且不会占用额外的空间。但是更大的代码大小意味着uop缓存不太可能有效打包,因为您会在uop缓存行越来越满之前达到32B边界。 (实际上,较小的代码也不能保证更好。较小的指令可能会导致填充uop缓存行,并且在越过32B边界之前需要在另一行中输入一个条目。)这个小循环可以从回送缓冲区(LSD)运行,因此幸运的是,uop缓存不是一个因素。
然后循环:有效清理是小型阵列有效矢量化的难点,小型阵列可能不是展开因子的倍数,尤其不是 vector 宽度的倍数
...
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随机播放甚至都不需要立即控制字节,因此与更明显的
shufps
相比,它节省了2个字节的代码大小。 (以及4个字节的代码大小与vpermilps
的比较,因为该操作码始终需要一个3字节的VEX前缀和一个立即数)。与SSE相比,AVX 3-operand的东西非常好,尤其是在用内在函数用C编写时,因此您不那么容易选择将冷寄存器写入movhlps
。