Неправильный порядок сборки, созданный 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, чтобы заставить компилятор сделать только один sub
(и cmp/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 может превратить пару сдвигов вектора влево / вправо в маску из константы.)