我是一名指令优化的新手。
我对一个简单的函数 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个uops,与分析结果一致。
在端口0、端口1、端口5和端口6上运行的uops数量与分析报告中说的相似。我认为这可能是由于uops调度程序的作用,它试图平衡端口上的负载,我想我是对的吗?
我绝对不明白为什么每个循环只需要大约3个周期。根据Agner的指令表,指令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 * 256位/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 类似。请注意保留 html 标签。
.L3:
vmovss xmm1, DWORD PTR [rdi+rax]
vfmadd231ss xmm0, xmm1, DWORD PTR [rsi+rax]
add rax, 4
cmp rdx, rax
jne .L3
ret
-march=native
的调整选项,并修复一些可能只在AVX2存在一段时间后才会注意到的使代码变慢的问题。虽然我认为很多人使用旧的编译器也能得到不错的结果。也许我太过于重视它了,但是当我查看编译器汇编输出时,新版本的gcc通常表现更好。尽管在整体上并不会有太大影响。 - Peter Cordes