我认为这个问题很有趣。GCC 以生产次优代码而闻名,但是我发现让它在不过于微观管理的情况下“鼓励”其生成更好的代码(仅针对最热/瓶颈代码)非常有趣。 在这种情况下,我查看了三个我用于此类情况的“工具”:
volatile
:如果重要的是内存访问按特定顺序发生,则volatile
是一个合适的工具。请注意,它可能过度,每次解除volatile
指针引用时都会导致单独的加载。
SSE / AVX装载/存储指令不能与volatile
指针一起使用,因为它们是函数。 使用类似_mm256_load_si256((volatile __m256i *)src);
的东西会将其隐式转换为const __m256i *
,从而丢失volatile
限定符。
但是,我们可以直接引用易失性指针。(只有在需要告诉编译器数据可能未对齐或者我们想要流式存储时,才需要使用装载/存储指令。)
m0 = ((volatile __m256i *)src)[0];
m1 = ((volatile __m256i *)src)[1];
m2 = ((volatile __m256i *)src)[2];
m3 = ((volatile __m256i *)src)[3];
很不幸,这对于存储并没有帮助,因为我们想要发出流式存储。 *(volatile...)dst = tmp;
不会给我们想要的结果。
__asm__ __volatile__ ("");
是编译器重排序屏障的GNU C写法。它可以停止编译时重新排序,而不会发出实际的栅栏指令(例如 mfence
)。它阻止编译器在该语句中跨越内存访问进行重新排序。
使用索引限制来进行循环结构。
GCC以寄存器利用率较低而闻名。早期版本在寄存器之间进行了许多不必要的移动,尽管现在已经相当少了。然而,在许多版本的x86-64上进行测试表明,在循环中,最好使用索引限制而不是独立的循环变量以获得最佳效果。
将所有这些组合起来,我构建了以下函数(经过几次迭代):
#include <stdlib.h>
#include <immintrin.h>
#define likely(x) __builtin_expect((x), 1)
#define unlikely(x) __builtin_expect((x), 0)
void copy(void *const destination, const void *const source, const size_t bytes)
{
__m256i *dst = (__m256i *)destination;
const __m256i *src = (const __m256i *)source;
const __m256i *end = (const __m256i *)source + bytes / sizeof (__m256i);
while (likely(src < end)) {
const __m256i m0 = ((volatile const __m256i *)src)[0];
const __m256i m1 = ((volatile const __m256i *)src)[1];
const __m256i m2 = ((volatile const __m256i *)src)[2];
const __m256i m3 = ((volatile const __m256i *)src)[3];
_mm256_stream_si256( dst, m0 );
_mm256_stream_si256( dst + 1, m1 );
_mm256_stream_si256( dst + 2, m2 );
_mm256_stream_si256( dst + 3, m3 );
__asm__ __volatile__ ("");
src += 4;
dst += 4;
}
}
使用 GCC-4.8.4 编译 (example.c
)
gcc -std=c99 -mavx2 -march=x86-64 -mtune=generic -O2 -S example.c
产出结果(example.s
):
.file "example.c"
.text
.p2align 4,,15
.globl copy
.type copy, @function
copy:
.LFB993:
.cfi_startproc
andq $-32, %rdx
leaq (%rsi,%rdx), %rcx
cmpq %rcx, %rsi
jnb .L5
movq %rsi, %rax
movq %rdi, %rdx
.p2align 4,,10
.p2align 3
.L4:
vmovdqa (%rax), %ymm3
vmovdqa 32(%rax), %ymm2
vmovdqa 64(%rax), %ymm1
vmovdqa 96(%rax), %ymm0
vmovntdq %ymm3, (%rdx)
vmovntdq %ymm2, 32(%rdx)
vmovntdq %ymm1, 64(%rdx)
vmovntdq %ymm0, 96(%rdx)
subq $-128, %rax
subq $-128, %rdx
cmpq %rax, %rcx
ja .L4
vzeroupper
.L5:
ret
.cfi_endproc
.LFE993:
.size copy, .-copy
.ident "GCC: (Ubuntu 4.8.4-2ubuntu1~14.04) 4.8.4"
.section .note.GNU-stack,"",@progbits
编译后的实际代码(使用-c
而非-S
)的反汇编是:
0000000000000000 <copy>:
0: 48 83 e2 e0 and $0xffffffffffffffe0,%rdx
4: 48 8d 0c 16 lea (%rsi,%rdx,1),%rcx
8: 48 39 ce cmp %rcx,%rsi
b: 73 41 jae 4e <copy+0x4e>
d: 48 89 f0 mov %rsi,%rax
10: 48 89 fa mov %rdi,%rdx
13: 0f 1f 44 00 00 nopl 0x0(%rax,%rax,1)
18: c5 fd 6f 18 vmovdqa (%rax),%ymm3
1c: c5 fd 6f 50 20 vmovdqa 0x20(%rax),%ymm2
21: c5 fd 6f 48 40 vmovdqa 0x40(%rax),%ymm1
26: c5 fd 6f 40 60 vmovdqa 0x60(%rax),%ymm0
2b: c5 fd e7 1a vmovntdq %ymm3,(%rdx)
2f: c5 fd e7 52 20 vmovntdq %ymm2,0x20(%rdx)
34: c5 fd e7 4a 40 vmovntdq %ymm1,0x40(%rdx)
39: c5 fd e7 42 60 vmovntdq %ymm0,0x60(%rdx)
3e: 48 83 e8 80 sub $0xffffffffffffff80,%rax
42: 48 83 ea 80 sub $0xffffffffffffff80,%rdx
46: 48 39 c1 cmp %rax,%rcx
49: 77 cd ja 18 <copy+0x18>
4b: c5 f8 77 vzeroupper
4e: c3 retq
没有任何优化的话,这段代码完全令人不快,充满了不必要的移动操作,因此需要进行一些优化。(上面使用了通常我使用的 -O2
优化级别。)
如果为了减小代码体积进行优化(-Os
),这段代码乍一看起来很好,
0000000000000000 <copy>:
0: 48 83 e2 e0 and $0xffffffffffffffe0,%rdx
4: 48 01 f2 add %rsi,%rdx
7: 48 39 d6 cmp %rdx,%rsi
a: 73 30 jae 3c <copy+0x3c>
c: c5 fd 6f 1e vmovdqa (%rsi),%ymm3
10: c5 fd 6f 56 20 vmovdqa 0x20(%rsi),%ymm2
15: c5 fd 6f 4e 40 vmovdqa 0x40(%rsi),%ymm1
1a: c5 fd 6f 46 60 vmovdqa 0x60(%rsi),%ymm0
1f: c5 fd e7 1f vmovntdq %ymm3,(%rdi)
23: c5 fd e7 57 20 vmovntdq %ymm2,0x20(%rdi)
28: c5 fd e7 4f 40 vmovntdq %ymm1,0x40(%rdi)
2d: c5 fd e7 47 60 vmovntdq %ymm0,0x60(%rdi)
32: 48 83 ee 80 sub $0xffffffffffffff80,%rsi
36: 48 83 ef 80 sub $0xffffffffffffff80,%rdi
3a: eb cb jmp 7 <copy+0x7>
3c: c3 retq
直到你注意到最后一个 jmp
是用于比较的,这本质上是在每次迭代时执行 jmp
、cmp
和 jae
,这可能导致非常差的结果。
注意:如果您对真实世界的代码进行类似的操作,请添加注释(特别是对于 __asm__ __volatile__("");
),并定期检查所有可用的编译器,以确保代码没有被任何编译器编译得太糟糕。
在查看Peter Cordes 的优秀答案后,我决定进一步迭代该函数,只是为了好玩。
正如Ross Ridge在评论中提到的那样,当使用_mm256_load_si256()
时,指针不会被解除引用(在被重新转换为对齐的__m256i *
作为函数参数之前),因此在使用_mm256_load_si256()
时,volatile
并不起作用。在另一个评论中,Seb提出了一个解决方法:_mm256_load_si256((__m256i []){ *(volatile __m256i *)(src) })
,它通过通过使用易失性指针访问元素并将其强制转换为数组,向函数提供了一个指向src
的指针。对于简单的对齐加载,我更喜欢直接使用易失性指针;它与我的代码意图匹配。(虽然我打算保持KISS,但经常只达到其中愚蠢的部分。)
在x86-64上,内部循环的开始对齐到16个字节,因此函数“头”部分的操作数量实际上并不重要。但是,避免多余的二进制AND(用于屏蔽要复制的字节数的五个最低位)在一般情况下肯定是有用的。
GCC为此提供了两个选项。一个是内置的__builtin_assume_aligned()
,它允许程序员向编译器传达各种对齐信息。另一个是typedef'ing一个具有额外属性的类型,在这里是__attribute__((aligned (32)))
,可用于传达函数参数的对齐方式。这两者都应该在clang中可用(虽然支持是最近的,尚未在3.5中),并且可能在其他编译器(例如icc)中可用(尽管据我所知,ICC使用__assume_aligned()
)。
缓解GCC进行的寄存器重排的一种方法是使用辅助函数。经过进一步迭代后,我得到了这个another.c
:
#include <stdlib.h>
#include <immintrin.h>
#define likely(x) __builtin_expect((x), 1)
#define unlikely(x) __builtin_expect((x), 0)
#if (__clang_major__+0 >= 3)
#define IS_ALIGNED(x, n) ((void *)(x))
#elif (__GNUC__+0 >= 4)
#define IS_ALIGNED(x, n) __builtin_assume_aligned((x), (n))
#else
#define IS_ALIGNED(x, n) ((void *)(x))
#endif
typedef __m256i __m256i_aligned __attribute__((aligned (32)));
void do_copy(register __m256i_aligned *dst,
register volatile __m256i_aligned *src,
register __m256i_aligned *end)
{
do {
register const __m256i m0 = src[0];
register const __m256i m1 = src[1];
register const __m256i m2 = src[2];
register const __m256i m3 = src[3];
__asm__ __volatile__ ("");
_mm256_stream_si256( dst, m0 );
_mm256_stream_si256( dst + 1, m1 );
_mm256_stream_si256( dst + 2, m2 );
_mm256_stream_si256( dst + 3, m3 );
__asm__ __volatile__ ("");
src += 4;
dst += 4;
} while (likely(src < end));
}
void copy(void *dst, const void *src, const size_t bytes)
{
if (bytes < 128)
return;
do_copy(IS_ALIGNED(dst, 32),
IS_ALIGNED(src, 32),
IS_ALIGNED((void *)((char *)src + bytes), 32));
}
使用 gcc -march=x86-64 -mtune=generic -mavx2 -O2 -S another.c
编译后,可以得到以下代码 (为了简洁省略了注释和指令):
do_copy:
.L3:
vmovdqa (%rsi), %ymm3
vmovdqa 32(%rsi), %ymm2
vmovdqa 64(%rsi), %ymm1
vmovdqa 96(%rsi), %ymm0
vmovntdq %ymm3, (%rdi)
vmovntdq %ymm2, 32(%rdi)
vmovntdq %ymm1, 64(%rdi)
vmovntdq %ymm0, 96(%rdi)
subq $-128, %rsi
subq $-128, %rdi
cmpq %rdx, %rsi
jb .L3
vzeroupper
ret
copy:
cmpq $127, %rdx
ja .L8
rep ret
.L8:
addq %rsi, %rdx
jmp do_copy
进一步优化在-O3
级别只是将辅助函数内联。
do_copy:
.L3:
vmovdqa (%rsi), %ymm3
vmovdqa 32(%rsi), %ymm2
vmovdqa 64(%rsi), %ymm1
vmovdqa 96(%rsi), %ymm0
vmovntdq %ymm3, (%rdi)
vmovntdq %ymm2, 32(%rdi)
vmovntdq %ymm1, 64(%rdi)
vmovntdq %ymm0, 96(%rdi)
subq $-128, %rsi
subq $-128, %rdi
cmpq %rdx, %rsi
jb .L3
vzeroupper
ret
copy:
cmpq $127, %rdx
ja .L10
rep ret
.L10:
leaq (%rsi,%rdx), %rax
.L8:
vmovdqa (%rsi), %ymm3
vmovdqa 32(%rsi), %ymm2
vmovdqa 64(%rsi), %ymm1
vmovdqa 96(%rsi), %ymm0
vmovntdq %ymm3, (%rdi)
vmovntdq %ymm2, 32(%rdi)
vmovntdq %ymm1, 64(%rdi)
vmovntdq %ymm0, 96(%rdi)
subq $-128, %rsi
subq $-128, %rdi
cmpq %rsi, %rax
ja .L8
vzeroupper
ret
即使使用-Os
,生成的代码也非常出色。
do_copy:
.L3:
vmovdqa (%rsi), %ymm3
vmovdqa 32(%rsi), %ymm2
vmovdqa 64(%rsi), %ymm1
vmovdqa 96(%rsi), %ymm0
vmovntdq %ymm3, (%rdi)
vmovntdq %ymm2, 32(%rdi)
vmovntdq %ymm1, 64(%rdi)
vmovntdq %ymm0, 96(%rdi)
subq $-128, %rsi
subq $-128, %rdi
cmpq %rdx, %rsi
jb .L3
ret
copy:
cmpq $127, %rdx
jbe .L5
addq %rsi, %rdx
jmp do_copy
.L5:
ret
当然,如果没有优化,GCC-4.8.4生成的代码仍然相当糟糕。使用clang-3.5 -march=x86-64 -mtune=generic -mavx2 -O2
和-Os
进行优化后,我们基本上得到了
do_copy:
.LBB0_1:
vmovaps (%rsi), %ymm0
vmovaps 32(%rsi), %ymm1
vmovaps 64(%rsi), %ymm2
vmovaps 96(%rsi), %ymm3
vmovntps %ymm0, (%rdi)
vmovntps %ymm1, 32(%rdi)
vmovntps %ymm2, 64(%rdi)
vmovntps %ymm3, 96(%rdi)
subq $-128, %rsi
subq $-128, %rdi
cmpq %rdx, %rsi
jb .LBB0_1
vzeroupper
retq
copy:
cmpq $128, %rdx
jb .LBB1_3
addq %rsi, %rdx
.LBB1_2:
vmovaps (%rsi), %ymm0
vmovaps 32(%rsi), %ymm1
vmovaps 64(%rsi), %ymm2
vmovaps 96(%rsi), %ymm3
vmovntps %ymm0, (%rdi)
vmovntps %ymm1, 32(%rdi)
vmovntps %ymm2, 64(%rdi)
vmovntps %ymm3, 96(%rdi)
subq $-128, %rsi
subq $-128, %rdi
cmpq %rdx, %rsi
jb .LBB1_2
.LBB1_3:
vzeroupper
retq
我喜欢another.c
代码(它符合我的编码风格),而且我对由GCC-4.8.4和clang-3.5在-O1
、-O2
、-O3
和 -Os
下生成的代码感到满意,所以我认为这对我来说已经足够好了。(但需要注意的是,我实际上没有对任何内容进行基准测试,因为我没有相关的代码。我们使用时间和非时间(nt)内存访问,并且缓存行为(以及与周围代码的缓存交互)对于这类东西至关重要,因此我认为微基准测试是毫无意义的。)
src
和dest
是否可以重叠?如果不能,那么在两者上都使用restrict
关键字可能会使编译器生成比任何一个版本都更有效的代码... - R.. GitHub STOP HELPING ICEvolatile __m256i
? - autistic