Неправильный порядок сборки, созданный gcc, приводит к снижению производительности

Я получил следующий код, который копирует данные из памяти в буфер 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>

Обратите внимание на изменение порядка последнего vmovdqa а также vmovntdq инструкции. С 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)

2 ответа

Я нахожу эту проблему интересной. GCC известен тем, что он создает неоптимальный код, но я нахожу интересным найти способы "побудить" его к созданию лучшего кода (конечно, только для самого горячего / узкого места), без чрезмерного микроуправления. В данном конкретном случае я рассмотрел три "инструмента", которые я использую для таких ситуаций:

  • volatile: Если важно, чтобы доступ к памяти происходил в определенном порядке, то volatile это подходящий инструмент. Обратите внимание, что это может быть излишним и приведет к отдельной загрузке каждый раз, когда volatile указатель разыменовывается.

    Встроенные функции загрузки / хранения SSE/AVX не могут быть использованы с volatile указатели, потому что они являются функциями. Используя что-то вроде _mm256_load_si256((volatile __m256i *)src); неявно бросает его const __m256i* теряя volatile Классификатор.

    Однако мы можем напрямую разыменовывать изменчивые указатели. (Встроенные функции load/store нужны только тогда, когда нам нужно сообщить компилятору, что данные могут быть не выровнены или что нам нужно потоковое хранилище.)

    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;
    }
}

Компилируем это (example.c) используя GCC-4.8.4 используя

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__ ("");) и не забывайте периодически проверять все доступные компиляторы, чтобы убедиться, что код не скомпилирован слишком плохо кем-либо.


Глядя на отличный ответ Питера Кордеса, я решил еще немного повторить эту функцию, просто для удовольствия.

Как упоминает Росс Ридж в комментариях, при использовании _mm256_load_si256() указатель не разыменовывается (перед повторным приведением к выровненному __m256i * в качестве параметра к функции), таким образом, volatile не поможет при использовании _mm256_load_si256(), В другом комментарии Себ предлагает обходной путь: _mm256_load_si256((__m256i []){ *(volatile __m256i *)(src) }), который снабжает функцию указателем на src путем доступа к элементу через изменчивый указатель и приведение его к массиву. Для простой выровненной загрузки я предпочитаю прямой изменчивый указатель; это соответствует моим намерениям в коде. (Я стремлюсь к ПОЦЕЛУЮ, хотя часто поражаю только глупую его часть.)

На x86-64 начало внутреннего цикла выровнено до 16 байтов, поэтому количество операций в части заголовка функции не очень важно. Тем не менее, избегание избыточного двоичного И (маскирование пяти младших битов суммы для копирования в байтах), безусловно, полезно в целом.

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 по существу (комментарии и директивы опущены для краткости):

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) доступ к памяти, а также поведение кеша (и взаимодействие кеша с окружающим код) имеет первостепенное значение для таких вещей, так что не имеет смысла микробенчмарк это, я думаю.)

Прежде всего, нормальные люди используют gcc -O3 -march=native -S а затем отредактируйте .s проверить небольшие изменения в выводе компилятора. Я надеюсь, что вы получили удовольствие от редактирования этого изменения в шестнадцатеричном формате.:P Вы также можете использовать превосходный Агнер Фог objconv сделать разборку, которая может быть собрана обратно в двоичный файл с выбранным вами синтаксисом NASM, YASM, MASM или AT&T.


Используя некоторые из тех же идей, что и Nominal Animal, я сделал версию, которая компилируется с таким же хорошим качеством. Я уверен, почему он компилируется в хороший код, и я догадываюсь, почему порядок так важен:

Процессоры имеют только несколько (~10?) Буферов заполнения, комбинирующих запись, для загрузок / хранилищ NT.

См. Эту статью о копировании из видеопамяти с потоковой загрузкой и записи в основную память с помощью потоковых хранилищ. На самом деле быстрее перебрасывать данные через небольшой буфер (намного меньше, чем L1), чтобы избежать потоковой загрузки и потоковых хранилищ, конкурирующих за заполняющие буферы (особенно с выполнением вне порядка). Обратите внимание, что использование потоковых NT-загрузок из обычной памяти бесполезно. Насколько я понимаю, потоковые загрузки полезны только для ввода-вывода (включая такие вещи, как видеопамять, которая отображается в адресное пространство ЦП в области Uncacheable Software-Write-Combining (USWC)). Оперативная память в оперативной памяти сопоставлена ​​с WB (Writeback), поэтому ЦПУ может спекулятивно предварительно извлекать ее и кэшировать, в отличие от USWC. В любом случае, хотя я и делаю ссылку на статью о потоковой загрузке, я не предлагаю использовать потоковую загрузку. Это просто для иллюстрации того, что конкуренция за заполненные буферы почти наверняка является причиной того, что странный код gcc вызывает большую проблему, в отличие от обычных хранилищ, отличных от NT.

Также см. Комментарий Джона Макалпина в конце этого потока, поскольку другой источник, подтверждающий, что WC хранит сразу несколько строк кэша, может быть большим замедлением.

Вывод gcc для вашего исходного кода (по какой-то причине, которую я не могу себе представить) сохранил 2-ю половину первой кеш-строки, затем обе половины второй кеш-строки, затем 1-ю половину первой кеш-строки. Вероятно, иногда объединяющий записи буфер для 1-й кеш-линии очищался до того, как были записаны обе половины, что приводило к менее эффективному использованию внешних шин.

clang не делает никаких странных переупорядочений ни с одной из наших 3-х версий (моей, OP и Nominal Animal's).


В любом случае, использование барьеров только для компилятора, которые останавливают переупорядочивание компилятора, но не испускают инструкцию барьера, является одним из способов остановить это. В этом случае это способ нанести удар по компилятору и сказать "тупой компилятор, не делай этого". Я не думаю, что вам обычно нужно делать это везде, но очевидно, что вы не можете доверять gcc с хранилищами, сочетающими запись (где порядок действительно важен). Так что, вероятно, неплохо бы взглянуть на asm хотя бы с помощью компилятора, который вы разрабатываете, при использовании загрузок и / или хранилищ NT. Я сообщил об этом для GCC. Ричард Бинер отмечает, что -fno-schedule-insns2 это своего рода обходной путь.

Linux (ядро) уже имеет barrier() макрос, который действует как барьер памяти компилятора. Это почти наверняка просто GNU asm volatile(""), За пределами Linux вы можете продолжать использовать это расширение GNU, или вы можете использовать C11 stdatomic.h объекты. Они в основном такие же, как C++11 std::atomic объекты, с AFAIK идентичной семантики (слава богу).

Я ставлю барьер между каждым магазином, потому что они свободны, когда в любом случае нет никакой возможности изменить порядок. Оказывается, только один барьер внутри цикла поддерживает все в порядке, что и делает ответ Nominal Animal. Это на самом деле не запрещает компилятору переупорядочивать хранилища, у которых нет барьера, разделяющего их; компилятор просто решил не делать этого. Вот почему я заключил сделку между каждым магазином.


Я только попросил компилятор для барьера записи, потому что я ожидаю, что имеет значение только порядок хранилищ NT, а не нагрузки. Даже чередование инструкций загрузки и хранения, вероятно, не будет иметь значения, так как в любом случае выполнение OOO все конвейерно. (Обратите внимание, что статья Intel о копировании из видео-мема даже использовалась mfence чтобы избежать дублирования между потоковыми хранилищами и потоковыми загрузками.)

atomic_signal_fence непосредственно не документирует, что все различные варианты упорядочения памяти делают с этим. Страница C++ для atomic_thread_fence это единственное место на cppreference, где есть примеры и многое другое.

По этой причине я не использовал идею Номинального животного о том, чтобы объявить src указателем на volatile. gcc решает сохранить загрузки в том же порядке, что и магазины.


Учитывая это, развертывание только на 2, вероятно, не приведет к разнице в пропускной способности микробенчмарков и сэкономит место в кеше при работе. Каждая итерация будет делать полную строку кэша, что кажется хорошим.

Процессоры семейства SnB не могут микропереключать режимы 2-регистровой адресации, поэтому очевидный способ минимизировать издержки цикла (получить указатели на конец src и dst, а затем подсчитать отрицательный индекс до нуля) не работает. В магазинах не будет микроплавкого предохранителя. Вы бы очень быстро заполнили буферы заполнения до такой степени, что дополнительные мопы все равно не имеют значения. Этот цикл, вероятно, не работает нигде, около 4 моп за цикл

Тем не менее, есть способ уменьшить накладные расходы цикла: с моим нелепым безобразием и нечитаемостью в C, чтобы заставить компилятор сделать только один subcmp/jcc) в качестве издержек цикла, никакая развертка вообще не создаст цикл с 4 мопами, который должен выдавать одну итерацию за такт даже на SnB. (Обратите внимание, что vmovntdq это AVX2, пока vmovntps есть только AVX1. Clang уже использует vmovaps / vmovntps для si256 встроенные в этот код! У них одинаковое требование выравнивания, и им все равно, какие биты они хранят. Он не сохраняет байты insn, только совместимость.)


Смотрите первый абзац для ссылки на это.

Я догадался, что вы делаете это внутри ядра Linux, поэтому я вставил соответствующий #ifdef s, так что это должно быть правильно как код ядра или при компиляции для пользовательского пространства.

#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 не предполагает, что src а также dest на самом деле оба выровнены. Может быть, он не использует знания о том, что нагрузки и хранилища выйдут из строя, если не выровнены по 32B? (Он знает, что может использовать ...aps инструкции вместо ...dqa, таким образом, это, конечно, делает больше оптимизации стиля компилятора, чем у gcc (где они чаще всего превращаются в соответствующую инструкцию). например, clang может превратить пару сдвигов вектора влево / вправо в маску из константы.)

Другие вопросы по тегам