Зацикливание массивов со встроенной сборкой

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

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

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("");
}

3 ответа

Решение

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


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

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

Встроенный синтаксис asm GNU C не предполагает, что вы читаете или записываете память, указанную операндами указателя. (например, может быть, вы используете inline-asm and на значение указателя). Так что вам нужно сделать что-то с "memory" клоббер или операнды ввода / вывода памяти, чтобы сообщить, какую память вы модифицируете. "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
        );
    }
}

Вывод asm проводника Godbolt для этой и нескольких версий ниже.

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

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

В этом случае мы получаем ужасные результаты: gcc5.x фактически увеличивает 3 дополнительных указателя, потому что он решает использовать [reg] режимы адресации вместо индексированных. Он не знает, что встроенный 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) из ответа @David Wohlferd на 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)
        );
    }
}

Это дает нам тот же внутренний цикл, который мы получили с "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's 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 операнд ввода / вывода был бы проще, но его написание таким образом вносит меньшие изменения, чтобы раскомментировать загрузку во встроенном ассемблере, вместо того, чтобы позволить компилятору получить одно значение в регистры для нас.

Когда я компилирую ваш код 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

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

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);
    }
}

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

.L28:
        movaps  (%rdi,%rax), %xmm0
        addps   (%rsi,%rax), %xmm0
        movaps  %xmm0, (%rdx,%rax)
        addq    $16, %rax
        cmpq    %rcx, %rax
        jne     .L28
Другие вопросы по тегам