错误的gcc生成汇编代码顺序导致性能下降。

8
我有以下代码,它将数据从内存复制到DMA缓冲区:
for (; likely(l > 0); l-=128)
{
    __m256i m0 = _mm256_load_si256( (__m256i*) (src) );
    __m256i m1 = _mm256_load_si256( (__m256i*) (src+32) );
    __m256i m2 = _mm256_load_si256( (__m256i*) (src+64) );
    __m256i m3 = _mm256_load_si256( (__m256i*) (src+96) );

    _mm256_stream_si256( (__m256i *) (dst), m0 );
    _mm256_stream_si256( (__m256i *) (dst+32), m1 );
    _mm256_stream_si256( (__m256i *) (dst+64), m2 );
    _mm256_stream_si256( (__m256i *) (dst+96), m3 );

    src += 128;
    dst += 128;
}

这就是gcc的汇编输出样式:
405280:       c5 fd 6f 50 20          vmovdqa 0x20(%rax),%ymm2
405285:       c5 fd 6f 48 40          vmovdqa 0x40(%rax),%ymm1
40528a:       c5 fd 6f 40 60          vmovdqa 0x60(%rax),%ymm0
40528f:       c5 fd 6f 18             vmovdqa (%rax),%ymm3
405293:       48 83 e8 80             sub    $0xffffffffffffff80,%rax
405297:       c5 fd e7 52 20          vmovntdq %ymm2,0x20(%rdx)
40529c:       c5 fd e7 4a 40          vmovntdq %ymm1,0x40(%rdx)
4052a1:       c5 fd e7 42 60          vmovntdq %ymm0,0x60(%rdx)
4052a6:       c5 fd e7 1a             vmovntdq %ymm3,(%rdx)
4052aa:       48 83 ea 80             sub    $0xffffffffffffff80,%rdx
4052ae:       48 39 c8                cmp    %rcx,%rax
4052b1:       75 cd                   jne    405280 <sender_body+0x6e0>

请注意最后一个vmovdqavmovntdq指令的重新排序。使用上面由gcc生成的代码,我能够在我的应用程序中达到每秒约10,227,571个数据包的吞吐量。
接下来,我在十六进制编辑器中手动重新排序这些指令。这意味着现在循环的顺序如下:
405280:       c5 fd 6f 18             vmovdqa (%rax),%ymm3
405284:       c5 fd 6f 50 20          vmovdqa 0x20(%rax),%ymm2
405289:       c5 fd 6f 48 40          vmovdqa 0x40(%rax),%ymm1
40528e:       c5 fd 6f 40 60          vmovdqa 0x60(%rax),%ymm0
405293:       48 83 e8 80             sub    $0xffffffffffffff80,%rax
405297:       c5 fd e7 1a             vmovntdq %ymm3,(%rdx)
40529b:       c5 fd e7 52 20          vmovntdq %ymm2,0x20(%rdx)
4052a0:       c5 fd e7 4a 40          vmovntdq %ymm1,0x40(%rdx)
4052a5:       c5 fd e7 42 60          vmovntdq %ymm0,0x60(%rdx)
4052aa:       48 83 ea 80             sub    $0xffffffffffffff80,%rdx
4052ae:       48 39 c8                cmp    %rcx,%rax
4052b1:       75 cd                   jne    405280 <sender_body+0x6e0>

经过正确排序的指令,我可以获得大约13,668,313个数据包每秒。因此,很明显由于gcc引入的重新排序方式会降低性能。

你是否遇到过这种情况?这是已知的错误还是我需要提交一个错误报告?

编译标志:

-O3 -pipe -g -msse4.1 -mavx

我的gcc版本:

gcc version 4.6.3 (Ubuntu/Linaro 4.6.3-1ubuntu5)

你选择了哪些编译期优化? - jim mcnamara
2
不是直接与您的问题相关,但是 srcdest 是否可以重叠?如果不能,那么在两者上都使用 restrict 关键字可能会使编译器生成比任何一个版本都更有效的代码... - R.. GitHub STOP HELPING ICE
不错的观点,然而在像这样简单的一对一复制情况下,“restrict”关键字并不会改变任何东西。 - Piotr Jurkiewicz
对我来说,这似乎不是一个错误,除非它导致程序的实际行为有所不同...只是猜测,但您是否考虑使用volatile __m256i - autistic
@Seb:性能缺陷是编译器缺陷的一种。报告地址为https://gcc.gnu.org/bugzilla/show_bug.cgi?id=69622。 - Peter Cordes
2个回答

11

我认为这个问题很有趣。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 是用于比较的,这本质上是在每次迭代时执行 jmpcmpjae,这可能导致非常差的结果。

注意:如果您对真实世界的代码进行类似的操作,请添加注释(特别是对于 __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)内存访问,并且缓存行为(以及与周围代码的缓存交互)对于这类东西至关重要,因此我认为微基准测试是毫无意义的。)


2
volatile 限定符可能无法正常工作,因为当作为参数传递给 _mm256_load_si256 时,限定符会丢失。 - Ross Ridge
2
没有实际的解引用volatile指针,因此没有实际访问volatile对象。你的volatile转换没有效果,因为当作为_mm256_load_si256的参数进行转换为__m256i const *时,volatile限定符会立即丢失。 - Ross Ridge
1
@NominalAnimal 你明白你的第一个例子没有使用volatile访问吗?这就是它不起作用的原因,也是我建议的原因,我的建议确实使用了volatile访问... - autistic
1
@Seb:我理解的是构建一个只有一个成员的数组,该成员的值通过volatile访问获得,并将其作为参数传递给函数。据我所知,风险在于编译器会字面上构建数组(因此存在多余的副本)。 - Nominal Animal
1
@Seb,我很感激你的努力,但我真的不太关心语言法律和确定语义(==我不相信自己在这方面的技能,因此并不是那么在意)。这是我的一个没有根据的希望,我立即发现它在实践中行不通(甚至在发布答案之前就发现了)。我最初只保留了我的帖子,以防其他人也会沿着同样的轨道思考(并且表明该轨道不会导致解决方案)。没有必要用线索棒一直敲打我。但是,如果您对上面的示例解决方案有评论或建议,我会倾听。 - Nominal Animal
显示剩余40条评论

5
首先,普通人使用 gcc -O3 -march=native -S 命令,然后编辑 .s 文件来测试编译器输出的小修改。希望你在十六进制编辑器中玩得开心。:P 你也可以使用 Agner Fog 的优秀工具 objconv 来生成反汇编代码,然后选择 NASM、YASM、MASM 或 AT&T 语法将其重新汇编为二进制文件。
使用了一些与Nominal Animal相同的想法,我制作了一个编译成类似好的汇编代码的版本。虽然我对它为什么能够编译出良好的代码感到自信,但我猜测为什么顺序如此重要:
CPU只有几个(~10?)用于NT负载/存储的写组合填充缓冲区
请看从视频内存中使用流式加载复制数据,并使用流式存储写入主内存的这篇文章。实际上,通过一个小缓冲区(比L1小得多)来反弹数据是更快的,以避免流式加载和流式存储竞争填充缓冲区(特别是在乱序执行时)。请注意,从普通内存中使用“流式”NT加载是没有用的。据我了解,流式加载只对I/O有用(包括像映射到CPU地址空间中的Uncacheable Software-Write-Combining(USWC)区域的视频RAM等内容)。主内存RAM被映射为WB(Writeback),因此CPU可以预取并缓存它,而不像USWC那样。总之,即使我链接了一篇关于使用流式加载的文章,我不建议使用流式加载。这只是为了说明争夺填充缓冲区几乎肯定是gcc的奇怪代码引起大问题的原因,在正常的非NT存储情况下不会出现这种问题。

请参阅John McAlpin在this thread的评论,作为另一个证实WC存储到多个缓存行可能会导致严重减速的来源。

由于某种愚蠢的原因(我无法想象),gcc对您的原始代码的输出将第一缓存行的第二半部分存储,然后是第二个缓存行的两个半部分,然后是第一缓存行的第一半部分。可能有时候第一缓存行的写组合缓冲区在写入两个半部分之前被刷新,导致外部总线使用效率不高。

clang不会对我们的三个版本(我的版本、OP的版本和Nominal Animal的版本)进行任何奇怪的重新排序。


无论如何,使用只停止编译器重新排序但不发出障碍指令的仅编译器屏障是一种阻止它的方法。在这种情况下,这是一种敲打编译器并说“愚蠢的编译器,不要这样做”的方式。我认为通常不需要到处这样做,但显然你不能信任gcc的写组合存储(其中顺序真的很重要)。因此,在使用NT加载和/或存储时,至少要查看使用的编译器的汇编代码。我已经向gcc报告了此问题。Richard Biener指出-fno-schedule-insns2是一种解决方法。

Linux(内核)已经有一个barrier()宏,作为编译器内存屏障。它几乎肯定只是一个GNU asm volatile("")。在Linux之外,您可以继续使用该GNU扩展,或者您可以使用C11 stdatomic.h设施。它们基本上与C++11 std::atomic设施相同,具有AFAIK相同的语义(感谢上帝)。

我在每个存储之间放置了一个屏障,因为当没有有用的重新排序时,它们是免费的。事实证明,在循环内部仅放置一个屏障可以使一切保持良好顺序,这就是Nominal Animal答案所做的。它实际上并不禁止编译器重排序没有分隔它们的存储;编译器只是选择不这样做。这就是我在每个存储之间设置屏障的原因。


我只请求编译器提供写屏障,因为我认为仅有NT存储的顺序很重要,而不是加载。即使交替使用加载和存储指令也可能无关紧要,因为OOO执行管道会处理一切。(请注意,英特尔从视频内存复制文章甚至使用来避免流式存储和流式加载之间的重叠。) atomic_signal_fence没有直接说明所有不同的内存排序选项如何使用它。atomic_thread_fence的C ++页面是cppreference上唯一有关此类示例和更多信息的地方。
这就是我没有使用Nominal Animal将src声明为指向易失性的想法的原因。gcc决定保持与存储相同的加载顺序。
鉴于这一点,在微基准测试中仅展开2次可能不会产生任何吞吐量差异,并且在生产中将节省uop缓存空间。 每次迭代仍将完成整个高速缓存行,这似乎很好。 SnB家族的CPU不能微融合2寄存器寻址模式,因此最明显的减少循环开销的方法(获取指向src和dst结尾的指针,然后计算负索引)无法使用。 存储将不会被微融合。 但是,您很快就会填满填充缓冲区,以至于额外的uop并不重要。 这个循环可能每周期运行不到4个uops。

然而,有一种方法可以减少循环开销:使用我极其丑陋且难以阅读的 C 语言黑科技,让编译器只执行一个 sub(和一个 cmp/jcc)作为循环开销,即使不展开循环也会产生一个 4-uop 循环,在 SnB 上每个迭代应该发出一个时钟。 (请注意,vmovntdq 是 AVX2,而 vmovntps 只是 AVX1。Clang 已经在此代码中使用了 si256 内置函数的 vmovaps / vmovntps!它们具有相同的对齐要求,并且不关心存储哪些位。这并没有节省任何指令字节,只是提高了兼容性。)


请参见第一段中的godbolt链接。

我猜您正在Linux内核中进行此操作,因此我加入了适当的#ifdef,以便在内核代码或编译为用户空间时都是正确的。

#include <stdint.h>
#include <immintrin.h>

#ifdef __KERNEL__  // linux has it's own macro
//#define compiler_writebarrier()   __asm__ __volatile__ ("")
#define compiler_writebarrier()   barrier()
#else
// Use C11 instead of a GNU extension, for portability to other compilers
#include <stdatomic.h>
// unlike a single store-release, a release barrier is a StoreStore barrier.
// It stops all earlier writes from being delayed past all following stores
// Note that this is still only a compiler barrier, so no SFENCE is emitted,
// even though we're using NT stores.  So from another core's perpsective, our
// stores can become globally out of order.
#define compiler_writebarrier()   atomic_signal_fence(memory_order_release)
// this purposely *doesn't* stop load reordering.  
// In this case gcc loads in the same order it stores, regardless.  load ordering prob. makes much less difference
#endif

void copy_pjc(void *const destination, const void *const source, const size_t bytes)
{
          __m256i *dst  = destination;
    const __m256i *src  = source;
    const __m256i *dst_endp = (destination + bytes); // clang 3.7 goes berserk with intro code with this end condition
        // but with gcc it saves an AND compared to Nominal's bytes/32:

    // const __m256i *dst_endp = dst + bytes/sizeof(*dst); // force the compiler to mask to a round number


    #ifdef __KERNEL__
    kernel_fpu_begin();  // or preferably higher in the call tree, so lots of calls are inside one pair
    #endif

    // bludgeon the compiler into generating loads with two-register addressing modes like [rdi+reg], and stores to [rdi]
    // saves one sub instruction in the loop.
    //#define ADDRESSING_MODE_HACK
    //intptr_t src_offset_from_dst = (src - dst);
    // generates clunky intro code because gcc can't assume void pointers differ by a multiple of 32

    while (dst < dst_endp)  { 
#ifdef ADDRESSING_MODE_HACK
      __m256i m0 = _mm256_load_si256( (dst + src_offset_from_dst) + 0 );
      __m256i m1 = _mm256_load_si256( (dst + src_offset_from_dst) + 1 );
      __m256i m2 = _mm256_load_si256( (dst + src_offset_from_dst) + 2 );
      __m256i m3 = _mm256_load_si256( (dst + src_offset_from_dst) + 3 );
#else
      __m256i m0 = _mm256_load_si256( src + 0 );
      __m256i m1 = _mm256_load_si256( src + 1 );
      __m256i m2 = _mm256_load_si256( src + 2 );
      __m256i m3 = _mm256_load_si256( src + 3 );
#endif

      _mm256_stream_si256( dst+0, m0 );
      compiler_writebarrier();   // even one barrier is enough to stop gcc 5.3 reordering anything
      _mm256_stream_si256( dst+1, m1 );
      compiler_writebarrier();   // but they're completely free because we are sure this store ordering is already optimal
      _mm256_stream_si256( dst+2, m2 );
      compiler_writebarrier();
      _mm256_stream_si256( dst+3, m3 );
      compiler_writebarrier();

      src += 4;
      dst += 4;
    }

  #ifdef __KERNEL__
  kernel_fpu_end();
  #endif

}

它编译为(gcc 5.3.0 -O3 -march=haswell):

copy_pjc:
        # one insn shorter than Nominal Animal's: doesn't mask the count to a multiple of 32.
        add     rdx, rdi  # dst_endp, destination
        cmp     rdi, rdx  # dst, dst_endp
        jnb     .L7       #,
.L5:
        vmovdqa ymm3, YMMWORD PTR [rsi]   # MEM[base: src_30, offset: 0B], MEM[base: src_30, offset: 0B]
        vmovdqa ymm2, YMMWORD PTR [rsi+32]        # D.26928, MEM[base: src_30, offset: 32B]
        vmovdqa ymm1, YMMWORD PTR [rsi+64]        # D.26928, MEM[base: src_30, offset: 64B]
        vmovdqa ymm0, YMMWORD PTR [rsi+96]        # D.26928, MEM[base: src_30, offset: 96B]
        vmovntdq        YMMWORD PTR [rdi], ymm3 #* dst, MEM[base: src_30, offset: 0B]
        vmovntdq        YMMWORD PTR [rdi+32], ymm2      #, D.26928
        vmovntdq        YMMWORD PTR [rdi+64], ymm1      #, D.26928
        vmovntdq        YMMWORD PTR [rdi+96], ymm0      #, D.26928
        sub     rdi, -128 # dst,
        sub     rsi, -128 # src,
        cmp     rdx, rdi  # dst_endp, dst
        ja      .L5 #,
        vzeroupper
.L7:

Clang有一个非常相似的循环,但是介绍要长得多:clang不假设srcdest实际上都对齐。也许它没有利用这样的知识,即如果不是32B对齐,则加载和存储将会出错?(它知道可以使用...aps指令而不是...dqa,因此它肯定会更多地进行编译器风格的内部优化,这在gcc中更常见(其中它们更经常总是转换为相关指令)。例如,clang可以将一对左/右向量移位转换为来自常量的掩码。)


1
非常有趣!你深入研究了CPU架构,而我却没有这么深入。你的评论非常好。这促使我安装clang-3.5,只是为了看看是否可以重写我的函数(但仍保持“我的风格”,可以这么说),并获得GCC和clang在所有优化级别(除了-O0之外,在这个级别上,GCC无望)的良好代码。我可以看出,我还有很多关于C11内存模型和原子性方面需要学习。谢谢! - Nominal Animal

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接