Ответ 1
Я нахожу эту проблему интересной. GCC известен тем, что производит менее оптимальный код, но мне кажется увлекательным найти способы "поощрять" его к получению лучшего кода (например, для самого горячего/узкого места), без чрезмерного управления микроконтролем. В этом конкретном случае я рассмотрел три "инструментария", которые я использую для таких ситуаций:
-
volatile
: Если важно, чтобы обращения к памяти выполнялись в определенном порядке, тогдаvolatile
является подходящим инструментом. Обратите внимание, что это может быть чрезмерным и приведет к отдельной загрузке каждый раз, когда указательvolatile
разыменован.Нагрузочные/хранилища SSE/AVX нельзя использовать с указателями
volatile
, поскольку они являются функциями. Используя что-то вроде_mm256_load_si256((volatile __m256i *)src);
, неявно отбрасывает его наconst __m256i*
, теряя квалификаторvolatile
.Мы можем напрямую разыскивать изменчивые указатели. (load/store intrinsics нужны только тогда, когда нам нужно сообщить компилятору, что данные могут быть неровными или что мы хотим хранить потоки).
m0 = ((volatile __m256i *)src)[0]; m1 = ((volatile __m256i *)src)[1]; m2 = ((volatile __m256i *)src)[2]; m3 = ((volatile __m256i *)src)[3];
К сожалению, это не помогает в магазинах, потому что мы хотим генерировать потоковые магазины. A
*(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
и a jae
на каждой итерации, что, вероятно, дает довольно плохие результаты.
Примечание. Если вы делаете что-то похожее для кода реального мира, добавьте комментарии (особенно для __asm__ __volatile__ ("");
) и не забудьте периодически проверять все доступные компиляторы, чтобы убедиться, что код не слишком скомпилирован любой.
Глядя на отличный ответ Питера Кордеса, я решил повторить функцию немного дальше, просто для удовольствия.
Как замечает Росс Ридж в комментариях, при использовании _mm256_load_si256()
указатель не разыменован (до того, как он будет повторно выбран для выравнивания __m256i *
в качестве параметра функции), таким образом volatile
не поможет, когда используя _mm256_load_si256()
. В другом комментарии Seb предлагает обходное решение: _mm256_load_si256((__m256i []){ *(volatile __m256i *)(src) })
, который поставляет функцию указателем на src
, обращаясь к элементу с помощью летучего указателя и отбрасывая его в массив. Для простой выровненной нагрузки я предпочитаю прямой испаряемый указатель; он соответствует моему намерению в коде. (Я нацелен на KISS, хотя часто я ударяю только тупую его часть.)
На x86-64 начало внутреннего цикла выравнивается до 16 байтов, поэтому число операций в части "header" функции не очень важно. Тем не менее, избегая избыточного двоичного И (маскируя пять наименее значимых бит суммы, чтобы скопировать в байтах), безусловно, полезно вообще.
GCC предоставляет два варианта для этого. Один из них - это __builtin_assume_aligned()
, который позволяет программисту передавать всю информацию о выравнивании компилятору. Другой тип typedefing типа, который имеет дополнительные атрибуты, здесь __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) обращения к памяти и поведение кэша (и взаимодействие кеша с окружающим код) имеет первостепенное значение для таких вещей, поэтому я думаю, что это не имеет смысла для микрообнаружения.)