Почему при выравнивании доступа к памяти mmap иногда возникает ошибка на AMD64?
У меня есть этот кусок кода, который segfaults при запуске на Ubuntu 14.04 на AMD64-совместимом процессоре:
#include <inttypes.h>
#include <stdlib.h>
#include <sys/mman.h>
int main()
{
uint32_t sum = 0;
uint8_t *buffer = mmap(NULL, 1<<18, PROT_READ,
MAP_PRIVATE|MAP_ANONYMOUS, -1, 0);
uint16_t *p = (buffer + 1);
int i;
for (i=0;i<14;++i) {
//printf("%d\n", i);
sum += p[i];
}
return sum;
}
Это только segfaults, если память выделена с использованием mmap
, Если я использую malloc
буфер в стеке или глобальная переменная, в которой он не находится.
Если я уменьшу количество итераций цикла до значения меньше 14, он больше не будет сбоем. И если я выведу индекс массива из цикла, он также больше не будет иметь ошибки.
Почему невыполненная память обращается к segfault на процессоре, который может получить доступ к невыровненным адресам, и почему только при таких конкретных обстоятельствах?
1 ответ
gcc4.8 создает пролог, который пытается достичь границы выравнивания, но предполагает, что uint16_t *p
выровнен на 2 байта, т.е. некоторое количество скалярных итераций сделает выровненный указатель на 16 байтов.
Я не думаю, что gcc когда-либо намеревался поддерживать неправильно выровненные указатели на x86, просто он работал для неатомарных типов без автоматической векторизации. Это определенно неопределенное поведение в ISO C, чтобы использовать указатель на uint16_t
менее чем alignof(uint16_t)=2
выравнивание. GCC не предупреждает, когда видит, что вы нарушаете правило во время компиляции, и фактически делает рабочий код (для malloc
где он знает минимальное выравнивание возвращаемого значения), но это, по- видимому, просто случайность внутренних компонентов gcc, и его не следует воспринимать как указание на "поддержку".
Попробуй с -O3 -fno-tree-vectorize
или же -O2
, Если мои объяснения верны, то это не будет segfault, потому что он будет использовать только скалярные нагрузки (которые, как вы говорите на x86, не имеют никаких требований к выравниванию).
GCC знает malloc
возвращает 16-байтовую выровненную память для этой цели (x86-64 Linux, где maxalign_t
имеет ширину 16 байт, потому что long double
имеет заполнение до 16 байтов в x86-64 System V ABI). Он видит, что вы делаете, и использует movdqu
,
Но gcc не лечит mmap
как встроенный, поэтому он не знает, что он возвращает выровненную память страницы, и применяет свою обычную стратегию автоматической векторизации, которая, очевидно, предполагает, что uint16_t *p
выровнен по 2 байта, поэтому он может использовать movdqa
после обработки смещения. Ваш указатель смещен и нарушает это предположение.
(Интересно, используют ли новые заголовки glibc __attribute__((assume_aligned(4096)))
помечать mmap
возвращаемое значение как выровненный. Это было бы хорошей идеей и, вероятно, дало бы вам примерно тот же код, что и для malloc
, За исключением того, что это не будет работать, потому что это будет нарушать проверку ошибок для mmap != (void*)-1
, как указывает @Alcaro с примером на Godbolt: https://gcc.godbolt.org/z/gVrLWT)
на процессоре, который может получить доступ к невыровненным
SSE2 movdqa
segfaults для unaligned, и ваши элементы сами не выровнены, так что у вас есть необычная ситуация, когда ни один элемент массива не начинается с 16-байтовой границы.
SSE2 является базовым для x86-64, поэтому gcc использует его.
Ubuntu 14.04LTS использует gcc4.8.2 (не по теме: он старый и устаревший, во многих случаях он хуже, чем gcc5.4 или gcc6.4, особенно при автоматической векторизации. Он даже не распознает -march=haswell
.)
14 - это минимальное пороговое значение для эвристики gcc, чтобы решить автоматически векторизовать ваш цикл в этой функции, с -O3
и нет -march
или же -mtune
опции.
Я положил твой код на Godbolt, и это важная часть main
:
call mmap #
lea rdi, [rax+1] # p,
mov rdx, rax # buffer,
mov rax, rdi # D.2507, p
and eax, 15 # D.2507,
shr rax ##### rax>>=1 discards the low byte, assuming it's zero
neg rax # D.2507
mov esi, eax # prolog_loop_niters.7, D.2507
and esi, 7 # prolog_loop_niters.7,
je .L2
# .L2 leads directly to a MOVDQA xmm2, [rdx+1]
Он вычисляет (с этим блоком кода), сколько скалярных итераций нужно выполнить до достижения MOVDQA, но ни один из путей кода не приводит к циклу MOVDQU. то есть gcc не имеет пути к коду для обработки случая, когда p
странно
Но генератор кода для malloc выглядит так:
call malloc #
movzx edx, WORD PTR [rax+17] # D.2497, MEM[(uint16_t *)buffer_5 + 17B]
movzx ecx, WORD PTR [rax+27] # D.2497, MEM[(uint16_t *)buffer_5 + 27B]
movdqu xmm2, XMMWORD PTR [rax+1] # tmp91, MEM[(uint16_t *)buffer_5 + 1B]
Обратите внимание на использование movdqu
, Есть еще несколько скалярных movzx
нагрузки смешаны: 8 из 14 полных итераций выполняются SIMD, а остальные 6 - скалярными. Это пропущенная оптимизация: она может легко сделать еще 4 с movq
загрузить, особенно потому, что он заполняет вектор XMM после распаковки нулями, чтобы получить элементы uint32_t перед добавлением.
(Существуют различные другие пропущенные оптимизации, например, возможно, с использованием pmaddwd
с множителем 1
добавить горизонтальные пары слов в элементы dword.)
Безопасный код с невыровненными указателями:
Если вы хотите написать код, который использует невыровненные указатели, вы можете сделать это правильно в ISO C, используя memcpy
, Для целей с эффективной поддержкой выравнивания нагрузки (например, x86) современные компиляторы все равно будут просто использовать простую скалярную загрузку в регистр, точно так же, как разыменование указателя. Но при автоматической векторизации gcc не будет предполагать, что выровненный указатель выровнен с границами элементов и будет использовать невыровненные нагрузки.
memcpy
это то, как вы выражаете невыровненную загрузку / хранение в ISO C / C++.
#include <string.h>
int sum(int *p) {
int sum=0;
for (int i=0 ; i<10001 ; i++) {
// sum += p[i];
int tmp;
#ifdef USE_ALIGNED
tmp = p[i]; // normal dereference
#else
memcpy(&tmp, &p[i], sizeof(tmp)); // unaligned load
#endif
sum += tmp;
}
return sum;
}
С gcc7.2 -O3 -DUSE_ALIGNED
мы получаем обычный скаляр до границы выравнивания, затем векторный цикл:( проводник компилятора Godbolt)
.L4: # gcc7.2 normal dereference
add eax, 1
paddd xmm0, XMMWORD PTR [rdx]
add rdx, 16
cmp ecx, eax
ja .L4
Но с memcpy
мы получаем автоматическую векторизацию с невыровненной загрузкой (без intro/outro для обработки выравнивания), в отличие от обычного предпочтения gcc:
.L2: # gcc7.2 memcpy for an unaligned pointer
movdqu xmm2, XMMWORD PTR [rdi]
add rdi, 16
cmp rax, rdi # end_pointer != pointer
paddd xmm0, xmm2
jne .L2 # -mtune=generic still doesn't optimize for macro-fusion of cmp/jcc :(
# hsum into EAX, then the final odd scalar element:
add eax, DWORD PTR [rdi+40000] # this is how memcpy compiles for normal scalar code, too.
В случае с OP лучше организовать выравнивание указателей. Это позволяет избежать расщепления строк кэша для скалярного кода (или для векторизации, как это делает gcc). Это не требует больших затрат дополнительной памяти или места, а расположение данных в памяти не фиксируется.
Но иногда это не вариант. memcpy
довольно надежно полностью оптимизируется с помощью современного gcc / clang, когда вы копируете все байты примитивного типа. т. е. просто загрузка или сохранение, отсутствие вызова функции и отскока к дополнительной ячейке памяти. Даже в -O0
это просто memcpy
встроенные без вызова функции, но, конечно, tmp
не оптимизирует прочь
В любом случае, проверьте сгенерированный компилятором asm, если вы беспокоитесь, что он может не оптимизироваться в более сложном случае или с другими компиляторами. Например, ICC18 не выполняет автоматическую векторизацию версии с использованием memcpy.
uint64_t tmp=0;
а затем memcpy над младшими 3 байтами компилируется в фактическую копию в память и перезагружается, так что это не очень хороший способ выразить, например, нулевое расширение типов нечетного размера.