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

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