我觉得这个问题很有趣。 GCC 以生成不太理想的代码而闻名,但我发现找到方法来“鼓励”它生成更好的代码(当然,仅适用于最热门/瓶颈代码),而不进行过多的微观管理是很有趣的。在这种特殊情况下,我查看了用于此类情况的三个“工具”:
-
volatile:如果内存访问以特定顺序发生很重要,那么volatile 是一个合适的工具。请注意,这可能是多余的,并且每次取消引用 volatile 指针时都会导致单独的加载。
SSE/AVX 加载/存储内在函数不能与 volatile 指针一起使用,因为它们是函数。使用_mm256_load_si256((volatile __m256i *)src); 之类的东西会隐式地将其转换为const __m256i*,从而丢失volatile 限定符。
不过,我们可以直接取消对 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 上跨多个 GCC 版本的测试表明,在循环中,最好使用索引限制而不是独立的循环变量,以获得最佳结果。
结合以上所有,我构造了以下函数(经过几次迭代):
#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,这可能会产生很差的结果。
注意:如果您对实际代码执行类似操作,请添加 cmets(尤其是 __asm__ __volatile__ ("");),并记得定期检查所有可用的编译器,以确保代码没有被任何。
看着Peter Cordes' excellent answer,我决定进一步迭代函数,只是为了好玩。
正如 Ross Ridge 在 cmets 中提到的,当使用 _mm256_load_si256() 时,指针不会被取消引用(在重新转换为对齐的 __m256i * 作为函数的参数之前),因此 volatile 在以下情况下将无济于事使用_mm256_load_si256()。在另一条评论中,Seb 提出了一种解决方法:_mm256_load_si256((__m256i []){ *(volatile __m256i *)(src) }),它通过 volatile 指针访问元素并将其转换为数组,从而为函数提供指向 src 的指针。对于简单的对齐加载,我更喜欢直接的 volatile 指针;它符合我在代码中的意图。 (我的目标是 KISS,虽然我经常只击中它愚蠢的部分。)
在 x86-64 上,内部循环的开始对齐到 16 个字节,因此函数“标头”部分中的操作数并不重要。尽管如此,避免多余的二进制 AND(以字节为单位屏蔽要复制的数量的五个最低有效位)通常肯定有用。
GCC 为此提供了两个选项。一个是内置的__builtin_assume_aligned(),它允许程序员将各种对齐信息传递给编译器。另一种是对具有额外属性的类型进行类型定义,这里是__attribute__((aligned (32))),例如可以用来传达函数参数的对齐方式。这两个都应该在 clang 中可用(虽然支持是最近的,但在 3.5 中还没有),并且可能在其他如 icc 中可用(尽管 ICC、AFAIK 使用 __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 编译为本质上(为简洁起见省略了cmets 和指令):
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 代码(它适合我的编码风格),我对-O1、-O2、-O3 处的 GCC-4.8.4 和 clang-3.5 生成的代码感到满意,和-Os,所以我认为这对我来说已经足够了。 (但是请注意,我实际上并没有对此进行任何基准测试,因为我没有相关代码。我们使用临时和非临时 (nt) 内存访问,以及缓存行为(以及缓存与周围环境的交互)代码)对于这样的事情是最重要的,所以我认为微基准测试是没有意义的。)