【问题标题】:Wrong gcc generated assembly ordering, results in performance hit错误的 gcc 生成的程序集排序,导致性能下降
【发布时间】:2024-04-13 03:55:09
【问题描述】:

我有以下代码,它将数据从内存复制到 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 个数据包的吞吐量。

接下来,我在 hexeditor 中手动重新排序指令。这意味着现在循环看起来如下:

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)

【问题讨论】:

  • 你选择了哪些编译时优化?
  • 与您的问题没有直接关系,但srcdest 可以重叠吗?如果没有,在两者上使用restrict 关键字可能会让编译器生成比任何一个版本都更有效的代码...
  • 好点,但是 restrict 关键字不会在像这样简单的一对一复制的情况下改变任何东西。
  • 对我来说这似乎不是一个错误,除非这会导致程序的实际行为不同......只是一个猜测,但您是否考虑过使用volatile __m256i
  • @Seb:性能错误是一类编译器错误。报告为gcc.gnu.org/bugzilla/show_bug.cgi?id=69622

标签: c performance gcc optimization assembly


【解决方案1】:

我觉得这个问题很有趣。 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 用于比较,本质上是在每次迭代时执行 jmpcmpjae,这可能会产生很差的结果。

注意:如果您对实际代码执行类似操作,请添加 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) 内存访问,以及缓存行为(以及缓存与周围环境的交互)代码)对于这样的事情是最重要的,所以我认为微基准测试是没有意义的。)

【讨论】:

  • volatile 限定符可能不起作用,因为限定符在作为参数传递给 _mm256_load_si256 时会丢失。
  • 没有对 volatile 指针的实际解引用,因此没有对 volatile 对象的实际访问。您的 volatile 强制转换无效,因为 volatile 限定符在作为参数传递给 _mm256_load_si256 时立即丢失,因为指针转换为 __m256i const *
  • @NominalAnimal 你明白你的第一个例子没有使用volatile 访问吗?因此它不起作用的原因,以及我的建议,确实使用volatile访问...
  • @Seb:我读到它是用单个成员构造一个数组,其值是通过 volatile 访问获得的,并将其作为参数传递给函数。 AIUI,存在编译器从字面上构造数组的风险(因此,多余的副本)。
  • @PeterCordes:感谢您的编辑;我认为它比原来的效果要好得多。我在这里(以及我所有其他答案)的意图不仅仅是陈述答案,而是展示我如何以及为什么得出答案。我希望volatile 起作用的原因是C11 在6.7.3p7 中说,对具有volatile 限定类型的对象的访问是由实现定义的,因此GCC 可以 将生成的AST 标记为对 volatile 对象的访问,即使函数原型没有使用这样的限定符。另一方面,6.7.3p6 说通过非易失性左值访问易失性对象是 UB..
【解决方案2】:

首先,普通人使用gcc -O3 -march=native -S,然后编辑.s来测试编译器输出的小修改。我希望你在十六进制编辑该更改时玩得开心。 :P 您还可以使用 Agner Fog 的出色 objconv 进行反汇编,然后您可以选择 NASM、YASM、MASM 或 AT&T 语法将其组装回二进制文件。


使用与 Nominal Animal 相同的一些想法,我制作了a version that compiles to similarly good asm。我对为什么它编译成好的代码很有信心,我猜想为什么排序如此重要:

CPU 只有几个(~10 个?)write-combining fill buffers for NT loads / stores

this article about copying from video memory with streaming loads, and writing to main memory with streaming stores。通过一个小缓冲区(比 L1 小得多)反弹数据实际上更快,以避免流加载和流存储竞争填充缓冲区(尤其是无序执行)。请注意,使用来自普通内存的“流式”NT 加载是没有用的。据我了解,流式加载仅对 I/O 有用(包括视频 RAM 之类的东西,它映射到不可缓存的软件写入组合 (USWC) 区域中的 CPU 地址空间)。主存 RAM 映射为 WB(Writeback),因此 CPU 可以推测性地预取并缓存它,这与 USWC 不同。无论如何,即使我链接了一篇关于使用流式加载的文章,我不建议使用流式加载。这只是为了说明填充缓冲区的争用几乎肯定是 gcc 的怪异代码导致大问题的原因,而普通的非 NT 存储则不会。

另请参阅 this thread 末尾的 John McAlpin 的评论,这是另一个确认 WC 一次存储到多个缓存行可能会大大减慢速度的消息来源。

gcc 的原始代码输出(出于某种我无法想象的脑残原因)存储了第一个缓存行的第二半,然后是第二个缓存行的两半,然后是第一个缓存行的第一半。可能有时第一个高速缓存行的写入组合缓冲区在两半都被写入之前被刷新,导致外部总线的使用效率降低。

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


无论如何,使用compiler-only barriers that stop compiler reordering 但不发出屏障指令是阻止它的一种方法。在这种情况下,这是一种打击编译器并说“愚蠢的编译器,不要那样做”的方式。我认为您通常不需要在任何地方都这样做,但显然您不能相信 gcc 与 write-combining 存储(排序真的很重要)。因此,在使用 NT 加载和/或存储时,至少使用您正在开发的编译器来查看 asm 可能是一个好主意。 I've reported this for 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 执行无论如何都会流水线化所有内容。 (请注意,Intel copy-from-video-mem 文章甚至使用mfence 来避免流式存储和流式加载之间的重叠。)

atomic_signal_fence 没有直接记录所有不同的内存排序选项对它的作用。 atomic_thread_fence 的 C++ 页面是 cppreference 上的一个地方,其中有示例等等。

这就是我没有使用 Nominal Animal 将 src 声明为指向 volatile 的指针的想法的原因。 gcc 决定保持加载顺序与存储顺序相同。


鉴于此,仅展开 2 可能不会在微基准测试中产生任何吞吐量差异,并且将节省生产中的 uop 缓存空间。每次迭代仍然会做一个完整的缓存行,这看起来不错。

SnB-family CPUs can't micro-fuse 2-reg addressing modes,因此最小化循环开销的明显方法(获取指向 src 和 dst 末尾的指针,然后将负索引向上计数为零)不起作用。商店不会微熔。不过,您会很快将填充缓冲区填充到额外的微指令无关紧要的程度。该循环可能在每个周期运行不到 4 微秒。

仍然有一种方法可以减少循环开销:使用我可笑的丑陋且不可读的 C hack 让编译器只执行一个 sub(和一个 cmp/jcc)作为循环开销,没有完全展开将产生一个 4-uop 循环,即使在 SnB 上,该循环也应该在每个时钟一次迭代中发出。 (请注意,vmovntdq 是 AVX2,而 vmovntps 只是 AVX1。Clang 已经使用 vmovaps / vmovntps 作为此代码中的 si256 内在函数!它们具有相同的对齐要求,并且不在乎什么它们存储的位。它不保存任何 insn 字节,只保存兼容性。)


请参阅第一段以获取指向此内容的神螺栓链接。

我猜你是在 Linux 内核中执行此操作的,所以我输入了适当的 #ifdefs,因此这在内核代码或为用户空间编译时应该是正确的。

#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 可以向左转一对/例如,右向量从常量转换为掩码。)

【讨论】:

  • 非常有趣!您比我更深入地研究 CPU 架构,而且您的 cmets 非常出色。这促使我安装 clang-3.5 只是为了看看我是否可以重写我的函数(但仍然保持“我的风格”,可以这么说)但在所有优化级别从 GCC 和 clang 中获得好的代码(-O0 除外,在这方面 GCC 是绝望的)。我可以看到我还有很多关于 C11 内存模型和原子的知识要学习。谢谢!
最近更新 更多