如何要求 GCC 完全展开这个循环(即剥离这个循环)?
Posted
技术标签:
【中文标题】如何要求 GCC 完全展开这个循环(即剥离这个循环)?【英文标题】:How to ask GCC to completely unroll this loop (i.e., peel this loop)? 【发布时间】:2016-07-06 18:07:02 【问题描述】:有没有办法指示 GCC(我正在使用 4.8.4)展开底部函数中的 while
循环完全,即剥离此循环?循环的迭代次数在编译时已知:58。
让我先解释一下我的尝试。
通过检查 GAS 输出:
gcc -fpic -O2 -S GEPDOT.c
使用了 12 个寄存器 XMM0 - XMM11。如果我将标志 -funroll-loops
传递给 gcc:
gcc -fpic -O2 -funroll-loops -S GEPDOT.c
循环仅展开两次。我检查了 GCC 优化选项。 GCC 说-funroll-loops
也将打开-frename-registers
,所以当GCC 展开循环时,它对寄存器分配的优先选择是使用“剩余”寄存器。但是 XMM12 - XMM15 只剩下 4 个,所以 GCC 最多只能展开 2 次。如果有 48 个而不是 16 个 XMM 寄存器可用,GCC 将毫无问题地展开 while 循环 4 次。
但我又做了一个实验。我首先手动展开了两次while循环,获得了一个函数GEPDOT_2
。那么
gcc -fpic -O2 -S GEPDOT_2.c
和
gcc -fpic -O2 -funroll-loops -S GEPDOT_2.c
由于GEPDOT_2
已用完所有寄存器,因此不执行展开。
GCC 确实注册了重命名以避免引入 潜在 错误依赖。但我确信我的GEPDOT
不会有这样的潜力;即使有,也不重要。我自己尝试展开循环,展开 4 次比展开 2 次快,比不展开快。当然,我可以手动展开更多次,但这很乏味。 GCC 可以为我做这件事吗?谢谢。
// C file "GEPDOT.c"
#include <emmintrin.h>
void GEPDOT (double *A, double *B, double *C)
__m128d A1_vec = _mm_load_pd(A); A += 2;
__m128d B_vec = _mm_load1_pd(B); B++;
__m128d C1_vec = A1_vec * B_vec;
__m128d A2_vec = _mm_load_pd(A); A += 2;
__m128d C2_vec = A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
__m128d C3_vec = A1_vec * B_vec;
__m128d C4_vec = A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
__m128d C5_vec = A1_vec * B_vec;
__m128d C6_vec = A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
__m128d C7_vec = A1_vec * B_vec;
A1_vec = _mm_load_pd(A); A += 2;
__m128d C8_vec = A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
int k = 58;
/* can compiler unroll the loop completely (i.e., peel this loop)? */
while (k--)
C1_vec += A1_vec * B_vec;
A2_vec = _mm_load_pd(A); A += 2;
C2_vec += A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
C3_vec += A1_vec * B_vec;
C4_vec += A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
C5_vec += A1_vec * B_vec;
C6_vec += A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
C7_vec += A1_vec * B_vec;
A1_vec = _mm_load_pd(A); A += 2;
C8_vec += A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
C1_vec += A1_vec * B_vec;
A2_vec = _mm_load_pd(A);
C2_vec += A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
C3_vec += A1_vec * B_vec;
C4_vec += A2_vec * B_vec;
B_vec = _mm_load1_pd(B); B++;
C5_vec += A1_vec * B_vec;
C6_vec += A2_vec * B_vec;
B_vec = _mm_load1_pd(B);
C7_vec += A1_vec * B_vec;
C8_vec += A2_vec * B_vec;
/* [write-back] */
A1_vec = _mm_load_pd(C); C1_vec = A1_vec - C1_vec;
A2_vec = _mm_load_pd(C + 2); C2_vec = A2_vec - C2_vec;
A1_vec = _mm_load_pd(C + 4); C3_vec = A1_vec - C3_vec;
A2_vec = _mm_load_pd(C + 6); C4_vec = A2_vec - C4_vec;
A1_vec = _mm_load_pd(C + 8); C5_vec = A1_vec - C5_vec;
A2_vec = _mm_load_pd(C + 10); C6_vec = A2_vec - C6_vec;
A1_vec = _mm_load_pd(C + 12); C7_vec = A1_vec - C7_vec;
A2_vec = _mm_load_pd(C + 14); C8_vec = A2_vec - C8_vec;
_mm_store_pd(C,C1_vec); _mm_store_pd(C + 2,C2_vec);
_mm_store_pd(C + 4,C3_vec); _mm_store_pd(C + 6,C4_vec);
_mm_store_pd(C + 8,C5_vec); _mm_store_pd(C + 10,C6_vec);
_mm_store_pd(C + 12,C7_vec); _mm_store_pd(C + 14,C8_vec);
更新 1
感谢@user3386109 的评论,我想稍微扩展一下这个问题。 @user3386109 提出了一个非常好的问题。实际上,当有这么多并行指令要调度时,我确实对编译器优化寄存器分配的能力有些怀疑。
我个人认为一种可靠的方法是首先在 asm 内联汇编中编写循环体(这是 HPC 的关键),然后根据需要多次复制它。今年早些时候我有一个不受欢迎的帖子:inline assembly。代码有点不同,因为循环迭代的次数 j 是一个函数参数,因此在编译时是未知的。在那种情况下,我无法完全展开循环,所以我只复制了两次汇编代码,并将循环转换为标签并跳转。结果表明,我编写的程序集产生的性能比编译器生成的程序集高约 5%,这可能表明编译器未能以我们预期的最佳方式分配寄存器。
我曾经(现在仍然)是汇编编码方面的一个婴儿,所以这是一个很好的案例研究,可以让我学习一点 x86 汇编。但从长远来看,我不倾向于将代码GEPDOT
用于组装的比例很大。主要有三个原因:
-
asm 内联汇编因不可移植而受到批评。虽然我不明白为什么。也许是因为不同的机器有不同的寄存器被破坏?
编译器也越来越好。所以我还是更喜欢算法优化和更好的 C 编码习惯来帮助编译器生成好的输出;
最后一个原因更重要。迭代次数可能并不总是 58。我正在开发一个高性能的矩阵分解子程序。对于缓存阻塞因子
nb
,迭代次数为nb-2
。我不会像在之前的帖子中那样将nb
作为函数参数。这是一个机器特定的参数,将被定义为一个宏。因此迭代次数在编译时是已知的,但可能因机器而异。猜猜我在为各种nb
手动展开循环时需要做多少繁琐的工作。因此,如果有一种方法可以简单地指示编译器剥离循环,那就太好了。
如果您也能分享一些制作高性能且可移植的库的经验,我将不胜感激。
【问题讨论】:
你试过-funroll-all-loops
吗?
因此,如果您手动复制该循环的主体 58 次,GCC 在管理寄存器使用方面是否做得不错?我问是因为编写一个展开循环的预处理器似乎很简单。例如,将while
替换为preproc__repeat(58)
。然后编写一个预处理器,搜索preproc__repeat
,提取数字,并将正文复制指定的次数。
1) 不同的处理器不只是破坏不同的寄存器。他们甚至没有 相同的寄存器。而且它们没有相同的指令(尽管 _mm_load1_pd 已经在某种程度上是特定于处理器的)。此外,不同的编译器对内联汇编指令的处理方式也不同。在一个编译器上工作的内联汇编可以编译,但在另一个编译器上不能产生正确的结果。
关闭。大多数 c 编译器似乎都有某种形式的 asm 内联。但是,它们的语义没有标准。为了安全起见,一个编译器可能会在调用 asm 后破坏 all 寄存器。 Gcc 的基本汇编不会破坏任何内容。 gcc 也不会为基本 asm 执行 memory clobber,但其他编译器会这样做。这可能会在编译器之间引入细微的差异。我不知道你所说的“主导架构”是什么意思。 x86 在今天非常普遍,但 ARM 也是如此。如果英特尔向 i8 添加更多 xmm regs,你的 asm 会发生什么?
为什么你认为完全展开这个循环会有好处?如果完全展开代码可能会因为存在微操作缓存而变慢。
【参考方案1】:
这不是答案,但其他尝试使用 GCC 向量化矩阵乘法的人可能会感兴趣。
下面,我假设 c 是一个 4×4 矩阵,以行为主,a 是一个 4 行,n-列矩阵(转置),b是一个4列,n行矩阵,计算的操作是c = a × b + c,其中×表示矩阵乘法。
实现这一点的天真的功能是
void slow_4(double *c,
const double *a,
const double *b,
size_t n)
size_t row, col, i;
for (row = 0; row < 4; row++)
for (col = 0; col < 4; col++)
for (i = 0; i < n; i++)
c[4*row+col] += a[4*i+row] * b[4*i+col];
GCC 使用
为 SSE2/SSE3 生成了相当不错的代码#if defined(__SSE2__) || defined(__SSE3__)
typedef double vec2d __attribute__((vector_size (2 * sizeof (double))));
void fast_4(vec2d *c,
const vec2d *a,
const vec2d *b,
size_t n)
const vec2d *const b_end = b + 2L * n;
vec2d s00 = c[0];
vec2d s02 = c[1];
vec2d s10 = c[2];
vec2d s12 = c[3];
vec2d s20 = c[4];
vec2d s22 = c[5];
vec2d s30 = c[6];
vec2d s32 = c[7];
while (b < b_end)
const vec2d b0 = b[0];
const vec2d b2 = b[1];
const vec2d a0 = a[0][0], a[0][0] ;
const vec2d a1 = a[0][1], a[0][1] ;
const vec2d a2 = a[1][0], a[1][0] ;
const vec2d a3 = a[1][1], a[1][1] ;
s00 += a0 * b0;
s10 += a1 * b0;
s20 += a2 * b0;
s30 += a3 * b0;
s02 += a0 * b2;
s12 += a1 * b2;
s22 += a2 * b2;
s32 += a3 * b2;
b += 2;
a += 2;
c[0] = s00;
c[1] = s02;
c[2] = s10;
c[3] = s12;
c[4] = s20;
c[5] = s22;
c[6] = s30;
c[7] = s32;
#endif
对于 AVX,GCC 可以做得更好
#if defined(__AVX__) || defined(__AVX2__)
typedef double vec4d __attribute__((vector_size (4 * sizeof (double))));
void fast_4(vec4d *c,
const vec4d *a,
const vec4d *b,
size_t n)
const vec4d *const b_end = b + n;
vec4d s0 = c[0];
vec4d s1 = c[1];
vec4d s2 = c[2];
vec4d s3 = c[3];
while (b < b_end)
const vec4d bc = *(b++);
const vec4d ac = *(a++);
const vec4d a0 = ac[0], ac[0], ac[0], ac[0] ;
const vec4d a1 = ac[1], ac[1], ac[1], ac[1] ;
const vec4d a2 = ac[2], ac[2], ac[2], ac[2] ;
const vec4d a3 = ac[3], ac[3], ac[3], ac[3] ;
s0 += a0 * bc;
s1 += a1 * bc;
s2 += a2 * bc;
s3 += a3 * bc;
c[0] = s0;
c[1] = s1;
c[2] = s2;
c[3] = s3;
#endif
使用 gcc-4.8.4 (-O2 -march=x86-64 -mtune=generic -msse3
) 生成的程序集的 SSE3 版本本质上是
fast_4:
salq $5, %rcx
movapd (%rdi), %xmm13
addq %rdx, %rcx
cmpq %rcx, %rdx
movapd 16(%rdi), %xmm12
movapd 32(%rdi), %xmm11
movapd 48(%rdi), %xmm10
movapd 64(%rdi), %xmm9
movapd 80(%rdi), %xmm8
movapd 96(%rdi), %xmm7
movapd 112(%rdi), %xmm6
jnb .L2
.L3:
movddup (%rsi), %xmm5
addq $32, %rdx
movapd -32(%rdx), %xmm1
addq $32, %rsi
movddup -24(%rsi), %xmm4
movapd %xmm5, %xmm14
movddup -16(%rsi), %xmm3
movddup -8(%rsi), %xmm2
mulpd %xmm1, %xmm14
movapd -16(%rdx), %xmm0
cmpq %rdx, %rcx
mulpd %xmm0, %xmm5
addpd %xmm14, %xmm13
movapd %xmm4, %xmm14
mulpd %xmm0, %xmm4
addpd %xmm5, %xmm12
mulpd %xmm1, %xmm14
addpd %xmm4, %xmm10
addpd %xmm14, %xmm11
movapd %xmm3, %xmm14
mulpd %xmm0, %xmm3
mulpd %xmm1, %xmm14
mulpd %xmm2, %xmm0
addpd %xmm3, %xmm8
mulpd %xmm2, %xmm1
addpd %xmm14, %xmm9
addpd %xmm0, %xmm6
addpd %xmm1, %xmm7
ja .L3
.L2:
movapd %xmm13, (%rdi)
movapd %xmm12, 16(%rdi)
movapd %xmm11, 32(%rdi)
movapd %xmm10, 48(%rdi)
movapd %xmm9, 64(%rdi)
movapd %xmm8, 80(%rdi)
movapd %xmm7, 96(%rdi)
movapd %xmm6, 112(%rdi)
ret
生成的程序集 (-O2 -march=x86-64 -mtune=generic -mavx
) 的 AVX 版本本质上是
fast_4:
salq $5, %rcx
vmovapd (%rdi), %ymm5
addq %rdx, %rcx
vmovapd 32(%rdi), %ymm4
cmpq %rcx, %rdx
vmovapd 64(%rdi), %ymm3
vmovapd 96(%rdi), %ymm2
jnb .L2
.L3:
addq $32, %rsi
vmovapd -32(%rsi), %ymm1
addq $32, %rdx
vmovapd -32(%rdx), %ymm0
cmpq %rdx, %rcx
vpermilpd $0, %ymm1, %ymm6
vperm2f128 $0, %ymm6, %ymm6, %ymm6
vmulpd %ymm0, %ymm6, %ymm6
vaddpd %ymm6, %ymm5, %ymm5
vpermilpd $15, %ymm1, %ymm6
vperm2f128 $0, %ymm6, %ymm6, %ymm6
vmulpd %ymm0, %ymm6, %ymm6
vaddpd %ymm6, %ymm4, %ymm4
vpermilpd $0, %ymm1, %ymm6
vpermilpd $15, %ymm1, %ymm1
vperm2f128 $17, %ymm6, %ymm6, %ymm6
vperm2f128 $17, %ymm1, %ymm1, %ymm1
vmulpd %ymm0, %ymm6, %ymm6
vmulpd %ymm0, %ymm1, %ymm0
vaddpd %ymm6, %ymm3, %ymm3
vaddpd %ymm0, %ymm2, %ymm2
ja .L3
.L2:
vmovapd %ymm5, (%rdi)
vmovapd %ymm4, 32(%rdi)
vmovapd %ymm3, 64(%rdi)
vmovapd %ymm2, 96(%rdi)
vzeroupper
ret
我猜,寄存器调度不是最优的,但看起来也不是很糟糕。我个人对上述内容感到满意,此时并未尝试手动优化。
在 Core i5-4200U 处理器(支持 AVX2)上,上述函数的快速版本在 SSE3 的 1843 个 CPU 周期(中位数)和 AVX2 的 1248 个周期内计算两个 4×256 矩阵的乘积。这归结为每个矩阵条目 1.8 和 1.22 个周期。相比之下,未矢量化的慢速版本每个矩阵条目大约需要 11 个周期。
(循环计数是中值,即一半的测试更快。我只进行了大约 10 万次重复的粗略基准测试,所以请对这些数字持保留态度。)
在这个 CPU 上,缓存效果是这样的,在 4×512 矩阵大小时,AVX2 仍然是每个条目 1.2 个周期,但在 4×1024 时,它下降到 1.4,在 4×4096 到 1.5,在 4×8192 到1.8,每次输入 4×65536 到 2.2 个周期。 SSE3 版本保持在每个条目 1.8 个周期,最高 4×3072,此时它开始减速;在 4×65536 时,每个条目也大约是 2.2 个周期。我确实相信这个(笔记本电脑!)CPU 在这一点上受到缓存带宽的限制。
【讨论】:
@AlphaBetaGamma: :D 方法有点不同,它依赖于 GCC 的向量类型,而不是标准的 Intel 内在函数(其他 C 编译器也很好地支持)。 @AlphaBetaGamma:不需要;如果它有用且内容丰富,我很高兴。【参考方案2】:尝试调整优化器参数:
gcc -O3 -funroll-loops --param max-completely-peeled-insns=1000 --param max-completely-peel-times=100
这应该可以解决问题。
【讨论】:
@AlphaBetaGamma 您可以尝试使用这些标志。如果我没记错的话,至少需要-O1
才能使-funroll-loops
工作。当我使用-mavx
编译时,寄存器分配要好得多。如果你用内联汇编替换它,它仍然应该展开,但我不是 gcc 工作原理的专家。
@AlphaBetaGamma 使用-mavx
,编译器发出三操作数指令而不是二操作数指令。这消除了所有移动指令。我假设当没有移动时,寄存器分配是最佳的。
@AlphaBetaGamma AVX 定义了一个具有三个操作数的新指令编码。这也适用于 xmm 寄存器,但不向后兼容,因此必须显式启用。
@AlphaBetaGamma µop 缓存缓存解码后的指令,解码器是 CPU 中最慢的部分,所以跳过它很好。它最适用于非常小的循环(27 µops 或更少),循环展开会破坏它,因为循环变得太大而无法放入缓存中。 cmp/jnz 对非常便宜,不会对性能产生太大影响。它甚至可能是免费的。但一如既往:有疑问时进行基准测试。
@AlphaBetaGamma 不!正确预测的跳转不会刷新管道,并且循环中的条件指令将被正确预测(这就是优化分支预测的目的)。 µop 缓存缓存微码,即已经解码的指令。这就是它如此有用的原因。虽然它非常小。循环展开不像 20 年前那样在今天作为优化有用。以上是关于如何要求 GCC 完全展开这个循环(即剥离这个循环)?的主要内容,如果未能解决你的问题,请参考以下文章