Перемещение по массивам с помощью встроенной сборки

При переходе по массиву с встроенной сборкой следует использовать модификатор регистра "r" или модификатор памяти "m" ?

Рассмотрим пример, который добавляет два массива с плавающей запятой x и y и записывает результаты в z. Обычно я бы использовал intrinsics, чтобы сделать это, как это

for(int i=0; i<n/4; i++) {
    __m128 x4 = _mm_load_ps(&x[4*i]);
    __m128 y4 = _mm_load_ps(&y[4*i]);
    __m128 s = _mm_add_ps(x4,y4);
    _mm_store_ps(&z[4*i], s);
}

Вот встроенное решение сборки, которое я придумал с помощью модификатора регистра "r"

void add_asm1(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   (%1,%%rax,4), %%xmm0\n"
            "addps    (%2,%%rax,4), %%xmm0\n"
            "movaps   %%xmm0, (%0,%%rax,4)\n"
            :
            : "r" (z), "r" (y), "r" (x), "a" (i)
            :
        );
    }
}

Это создает аналогичную сборку для GCC. Основное различие заключается в том, что GCC добавляет 16 в индексный регистр и использует шкалу 1, тогда как встроенное решение сборки добавляет 4 в индексный регистр и использует шкалу из 4.

Я не смог использовать общий регистр для итератора. Я должен был указать тот, который в этом случае был rax. Есть ли причина для этого?

Вот решение, которое я придумал с помощью модификатора памяти "m"

void add_asm2(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   %1, %%xmm0\n"
            "addps    %2, %%xmm0\n"
            "movaps   %%xmm0, %0\n"
            : "=m" (z[i])
            : "m" (y[i]), "m" (x[i])
            :
            );
    }
}

Это менее эффективно, так как он не использует индексный регистр и вместо этого должен добавить 16 в базовый регистр каждого массива. Сгенерированная сборка (gcc (Ubuntu 5.2.1-22ubuntu2) с gcc -O3 -S asmtest.c):

.L22
    movaps   (%rsi), %xmm0
    addps    (%rdi), %xmm0
    movaps   %xmm0, (%rdx)
    addl    $4, %eax
    addq    $16, %rdx
    addq    $16, %rsi
    addq    $16, %rdi
    cmpl    %eax, %ecx
    ja      .L22

Есть ли лучшее решение, использующее модификатор памяти "m" ? Есть ли способ заставить его использовать индексный регистр? Причина, по которой я спросил, это то, что мне было более логично использовать память "m" памяти, так как я читаю и записываю память. Кроме того, с модификатором регистра "r" я никогда не использую список выходных операндов, который сначала казался мне странным.

Может быть, есть лучшее решение, чем использование "r" или "m" ?

Вот полный код, который я использовал для проверки этого

#include <stdio.h>
#include <x86intrin.h>

#define N 64

void add_intrin(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __m128 x4 = _mm_load_ps(&x[i]);
        __m128 y4 = _mm_load_ps(&y[i]);
        __m128 s = _mm_add_ps(x4,y4);
        _mm_store_ps(&z[i], s);
    }
}

void add_intrin2(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n/4; i++) {
        __m128 x4 = _mm_load_ps(&x[4*i]);
        __m128 y4 = _mm_load_ps(&y[4*i]);
        __m128 s = _mm_add_ps(x4,y4);
        _mm_store_ps(&z[4*i], s);
    }
}

void add_asm1(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   (%1,%%rax,4), %%xmm0\n"
            "addps    (%2,%%rax,4), %%xmm0\n"
            "movaps   %%xmm0, (%0,%%rax,4)\n"
            :
            : "r" (z), "r" (y), "r" (x), "a" (i)
            :
        );
    }
}

void add_asm2(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   %1, %%xmm0\n"
            "addps    %2, %%xmm0\n"
            "movaps   %%xmm0, %0\n"
            : "=m" (z[i])
            : "m" (y[i]), "m" (x[i])
            :
            );
    }
}

int main(void) {
    float x[N], y[N], z1[N], z2[N], z3[N];
    for(int i=0; i<N; i++) x[i] = 1.0f, y[i] = 2.0f;
    add_intrin2(x,y,z1,N);
    add_asm1(x,y,z2,N);
    add_asm2(x,y,z3,N);
    for(int i=0; i<N; i++) printf("%.0f ", z1[i]); puts("");
    for(int i=0; i<N; i++) printf("%.0f ", z2[i]); puts("");
    for(int i=0; i<N; i++) printf("%.0f ", z3[i]); puts("");
}

Ответ 1

Избегайте inline asm, когда это возможно: https://gcc.gnu.org/wiki/DontUseInlineAsm. Он блокирует множество оптимизаций. Но если вы действительно не можете удержать компилятор в создании asm, который вы хотите, вы должны, вероятно, написать весь цикл в asm, чтобы вы могли развернуть и настроить его вручную, вместо того чтобы делать такие вещи.


Вы можете использовать ограничение r для индекса. Используйте модификатор q, чтобы получить имя 64-битного регистра, чтобы вы могли использовать его в режиме адресации. При компиляции для 32-битных целей модификатор q выбирает имя 32-битного регистра, поэтому тот же код все еще работает.

Если вы хотите выбрать, какой режим адресации используется, вам нужно сделать это самостоятельно, используя операнды указателя с ограничениями r.

Синтаксис inline asm GNU C не предполагает, что вы читаете или записываете память, на которую указывают операнды указателя. (например, возможно, вы используете inline-asm and по значению указателя). Поэтому вам нужно что-то сделать с помощью "memory" clobber или операндов ввода/вывода памяти, чтобы он знал, какую память вы изменяете. A "memory" clobber прост, но заставляет все, кроме локальных, проливать/перезагружать. См. Раздел Clobbers в документах для примера использования фиктивного входного операнда.


Еще одно огромное преимущество для ограничения m заключается в том, что -funroll-loops может работать, генерируя адреса с постоянными смещениями. Выполнение адресации не позволяет компилятору выполнить один приращение каждые 4 итерации или что-то еще, потому что каждое значение исходного уровня i должно появиться в регистре.


Здесь моя версия, с некоторыми настройками, как указано в комментариях.

#include <immintrin.h>
void add_asm1_memclobber(float *x, float *y, float *z, unsigned n) {
    __m128 vectmp;  // let the compiler choose a scratch register
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   (%[y],%q[idx],4), %[vectmp]\n\t"  // q modifier: 64bit version of a GP reg
            "addps    (%[x],%q[idx],4), %[vectmp]\n\t"
            "movaps   %[vectmp], (%[z],%q[idx],4)\n\t"
            : [vectmp] "=x" (vectmp)  // "=m" (z[i])  // gives worse code if the compiler prepares a reg we don't use
            : [z] "r" (z), [y] "r" (y), [x] "r" (x),
              [idx] "r" (i) // unrolling is impossible this way (without an insn for every increment by 4)
            : "memory"
          // you can avoid a "memory" clobber with dummy input/output operands
        );
    }
}

Godbolt compiler explorer asm для этого и пару версий ниже.

Ваша версия должна объявить %xmm0 как clobbered, или у вас будет плохое время, когда это будет включено. Моя версия использует временную переменную как операнд только для вывода, который никогда не использовался. Это дает компилятору полную свободу для размещения регистров.

Если вы хотите избежать "клонирования" памяти, вы можете использовать операнды ввода/вывода логической памяти, такие как "m" (*(const __m128*)&x[i]), чтобы сообщить компилятору, какая память читается и записывается вашей функцией. Это необходимо для обеспечения правильного генерации кода, если вы сделали что-то вроде x[4] = 1.0; прямо перед запуском этого цикла. (И даже если вы не пишете то, что простое, вложение и постоянное распространение могут сводиться к этому.) А также чтобы убедиться, что компилятор не читает с z[] до того, как цикл будет запущен.

В этом случае мы получаем ужасные результаты: gcc5.x фактически увеличивает 3 дополнительных указателя, потому что он решает использовать режимы адресации [reg] вместо индексирования. Он не знает, что inline asm никогда не ссылается на эти операнды памяти, используя режим адресации, созданный ограничением!

# gcc5.4 with dummy constraints like "=m" (*(__m128*)&z[i]) instead of "memory" clobber
.L11:
    movaps   (%rsi,%rax,4), %xmm0   # y, i, vectmp
    addps    (%rdi,%rax,4), %xmm0   # x, i, vectmp
    movaps   %xmm0, (%rdx,%rax,4)   # vectmp, z, i

    addl    $4, %eax        #, i
    addq    $16, %r10       #, ivtmp.19
    addq    $16, %r9        #, ivtmp.21
    addq    $16, %r8        #, ivtmp.22
    cmpl    %eax, %ecx      # i, n
    ja      .L11        #,

r8, r9 и r10 - дополнительные указатели, которые не используют встроенный блок asm.

Вы можете использовать ограничение, которое сообщает gcc, что весь массив произвольной длины является входом или выходом: "m" (*(const struct {char a; char x[];} *) pStr) from @Ответ Дэвида Вольферда на asm strlen. Поскольку мы хотим использовать индексированные режимы адресации, у нас будет базовый адрес всех трех массивов в регистрах, и эта форма ограничения запрашивает базовый адрес как операнд, а не указатель на текущую память, на которой работает.

Это фактически работает без дополнительных приращений счетчика внутри цикла:

void add_asm1_dummy_whole_array(const float *restrict x, const float *restrict y,
                             float *restrict z, unsigned n) {
    __m128 vectmp;  // let the compiler choose a scratch register
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
            "movaps   (%[y],%q[idx],4), %[vectmp]\n\t"  // q modifier: 64bit version of a GP reg
            "addps    (%[x],%q[idx],4), %[vectmp]\n\t"
            "movaps   %[vectmp], (%[z],%q[idx],4)\n\t"
            : [vectmp] "=x" (vectmp)  // "=m" (z[i])  // gives worse code if the compiler prepares a reg we don't use
             , "=m" (*(struct {float a; float x[];} *) z)
            : [z] "r" (z), [y] "r" (y), [x] "r" (x),
              [idx] "r" (i) // unrolling is impossible this way (without an insn for every increment by 4)
              , "m" (*(const struct {float a; float x[];} *) x),
                "m" (*(const struct {float a; float x[];} *) y)
        );
    }
}

Это дает нам тот же внутренний цикл, который мы получили с clobber "memory":

.L19:   # with clobbers like "m" (*(const struct {float a; float x[];} *) y)
    movaps   (%rsi,%rax,4), %xmm0   # y, i, vectmp
    addps    (%rdi,%rax,4), %xmm0   # x, i, vectmp
    movaps   %xmm0, (%rdx,%rax,4)   # vectmp, z, i

    addl    $4, %eax        #, i
    cmpl    %eax, %ecx      # i, n
    ja      .L19        #,

Он сообщает компилятору, что каждый блок asm считывает или записывает все массивы, поэтому он может необоснованно останавливать его от чередования с другим кодом (например, после полной развертки с низким числом итераций). Он не останавливает разворачивание, но требование иметь каждое значение индекса в регистре делает его менее эффективным.


Версия с ограничениями m, что gcc может развернуть:

#include <immintrin.h>
void add_asm1(float *x, float *y, float *z, unsigned n) {
    __m128 vectmp;  // let the compiler choose a scratch register
    for(int i=0; i<n; i+=4) {
        __asm__ __volatile__ (
           // "movaps   %[yi], %[vectmp]\n\t"
            "addps    %[xi], %[vectmp]\n\t"  // We requested that the %[yi] input be in the same register as the [vectmp] dummy output
            "movaps   %[vectmp], %[zi]\n\t"
          // ugly ugly type-punning casts; __m128 is a may_alias type so it safe.
            : [vectmp] "=x" (vectmp), [zi] "=m" (*(__m128*)&z[i])
            : [yi] "0"  (*(__m128*)&y[i])  // or [yi] "xm" (*(__m128*)&y[i]), and uncomment the movaps load
            , [xi] "xm" (*(__m128*)&x[i])
            :  // memory clobber not needed
        );
    }
}

Использование [yi] в качестве операнда ввода/вывода +x было бы проще, но запись этого способа делает меньшее изменение для раскомментации нагрузки в встроенном asm вместо того, чтобы позволить компилятору получить одно значение для регистров для нас.

Ответ 2

Когда я компилирую ваш код add_asm2 с gcc (4.9.2), я получаю:

add_asm2:
.LFB0:
        .cfi_startproc
        xorl        %eax, %eax
        xorl        %r8d, %r8d
        testl       %ecx, %ecx
        je  .L1
        .p2align 4,,10
        .p2align 3
.L5:
#APP
# 3 "add_asm2.c" 1
        movaps   (%rsi,%rax), %xmm0
addps    (%rdi,%rax), %xmm0
movaps   %xmm0, (%rdx,%rax)

# 0 "" 2
#NO_APP
        addl        $4, %r8d
        addq        $16, %rax
        cmpl        %r8d, %ecx
        ja  .L5
.L1:
        rep; ret
        .cfi_endproc

поэтому он не идеален (он использует резервный регистр), но использует индексированные нагрузки...

Ответ 3

gcc также имеет встроенные векторные расширения, которые являются даже кросс-платформенными:

typedef float v4sf __attribute__((vector_size(16)));
void add_vector(float *x, float *y, float *z, unsigned n) {
    for(int i=0; i<n/4; i+=1) {
        *(v4sf*)(z + 4*i) = *(v4sf*)(x + 4*i) + *(v4sf*)(y + 4*i);
    }
}

В моей версии gcc версии 4.7.2 сгенерированная сборка:

.L28:
        movaps  (%rdi,%rax), %xmm0
        addps   (%rsi,%rax), %xmm0
        movaps  %xmm0, (%rdx,%rax)
        addq    $16, %rax
        cmpq    %rcx, %rax
        jne     .L28