Почему код SIMD длины вектора медленнее, чем простой C - PullRequest
2 голосов
/ 17 июня 2019

Почему моя функция длины SIMD vector4 в 3 раза медленнее, чем метод длины простого вектора?

Функция длины SIMD vector4:

__extern_always_inline float vec4_len(const float *v) {
    __m128 vec1 = _mm_load_ps(v);
    __m128 xmm1 = _mm_mul_ps(vec1, vec1);
    __m128 xmm2 = _mm_hadd_ps(xmm1, xmm1);
    __m128 xmm3 = _mm_hadd_ps(xmm2, xmm2);
    return sqrtf(_mm_cvtss_f32(xmm3));
}

Наивная реализация:

sqrtf(V[0] * V[0] + V[1] * V[1] + V[2] * V[2] + V[3] * V[3])

SIMD-версия потребовала 16110 мсек для повторения 1000000000 раз.Наивная версия была в ~ 3 раза быстрее, она занимает всего 4746 мс.

#include <math.h>
#include <time.h>
#include <stdint.h>
#include <stdio.h>
#include <x86intrin.h>

static float vec4_len(const float *v) {
    __m128 vec1 = _mm_load_ps(v);
    __m128 xmm1 = _mm_mul_ps(vec1, vec1);
    __m128 xmm2 = _mm_hadd_ps(xmm1, xmm1);
    __m128 xmm3 = _mm_hadd_ps(xmm2, xmm2);
    return sqrtf(_mm_cvtss_f32(xmm3));
}

int main() {
    float A[4] __attribute__((aligned(16))) = {3, 4, 0, 0};

    struct timespec t0 = {};
    clock_gettime(CLOCK_MONOTONIC, &t0);

    double sum_len = 0;
    for (uint64_t k = 0; k < 1000000000; ++k) {
        A[3] = k;
        sum_len += vec4_len(A);
//        sum_len += sqrtf(A[0] * A[0] + A[1] * A[1] + A[2] * A[2] + A[3] * A[3]);
    }
    struct timespec t1 = {};
    clock_gettime(CLOCK_MONOTONIC, &t1);

    fprintf(stdout, "%f\n", sum_len);

    fprintf(stdout, "%ldms\n", (((t1.tv_sec - t0.tv_sec) * 1000000000) + (t1.tv_nsec - t0.tv_nsec)) / 1000000);

    return 0;
}

Я запускаю следующую команду на процессоре Intel (R) Core (TM) i7-8550U.Сначала с версией vec4_len, затем с простой C.

Я компилирую с помощью GCC (Ubuntu 7.4.0-1ubuntu1 ~ 18.04.1) 7.4.0:

gcc -Wall -Wextra -O3 -msse -msse3 sse.c -lm && ./a.out

SSE версиявывод:

499999999500000128.000000
13458ms

Версия для простого C:

499999999500000128.000000
4441ms

1 Ответ

5 голосов
/ 17 июня 2019

Наиболее очевидной проблемой является использование неэффективного точечного продукта (с haddps, который стоит 2x случайных мопов + 1x add uop) вместо случайных + add. См. Самый быстрый способ сделать горизонтальную векторную сумму с плавающей запятой на x86 , чтобы узнать, что делать после _mm_mul_ps, который не так уж и плох. Но все же это не то, что x86 может сделать очень эффективно.

Но, в любом случае, настоящая проблема - ваш тестовый цикл. A[3] = k; и затем использование _mm_load_ps(A) создает стойку пересылки магазина , если она компилируется наивно вместо векторного тасования. Store + reload может быть эффективно перенаправлен с ~ 5 циклами задержки, если загрузка загружает данные только из одной инструкции хранилища, и никаких данных вне этого. В противном случае необходимо выполнить более медленное сканирование всего буфера хранилища, чтобы собрать байты. Это добавляет около 10 циклов задержки к пересылке в магазин.

Я не уверен, какое влияние это окажет на пропускную способность, но этого может быть достаточно, чтобы не допустить, чтобы exec-of-exec перекрывал достаточно итераций цикла, чтобы скрыть задержку и только узкое место при sqrtss случайной пропускной способности.

(Ваш процессор Coffee Lake имеет пропускную способность 1 на 3 цикла sqrtss, поэтому на удивление пропускная способность SQRT составляет , а не ваше узкое место. 1 Вместо этого это будет произвольная пропускная способность или что-то еще. )

См. Руководство по микроарху Agner Fog и / или руководство по оптимизации.


Плюс вы еще больше смещаете это по отношению к SSE на , позволяя компилятору поднять вычисление V[0] * V[0] + V[1] * V[1] + V[2] * V[2] из цикла .

Эта часть выражения является инвариантной к циклу, поэтому компилятору нужно только (float)k возводить в квадрат, добавлять и скалярный квадрат для каждой итерации цикла. (И преобразовать это в double, чтобы добавить к вашему аккумулятору).

(удаленный ответ @ StaceyGirl указал на это; просмотр кода внутренних циклов в нем был отличным началом написания этого ответа.)


Дополнительная неэффективность в A [3] = k в векторной версии

Внутренний цикл GCC9.1 из Связь Камиля с Годболтом выглядит ужасно и, кажется, включает в себя перенос / перенос с циклом для объединения нового A[3] в 8-байтовую пару A[2..3], далее ограничение способности процессора перекрывать несколько итераций.

Я не уверен, почему gcc подумал, что это хорошая идея. Это может помочь на процессорах, которые разделяют векторные нагрузки на 8-байтовые половинки (например, Pentium M или Bobcat), чтобы избежать задержек при пересылке из магазина. Но это не нормальная настройка для «универсальных» современных процессоров x86-64.

.L18:
        pxor    xmm4, xmm4
        mov     rdx, QWORD PTR [rsp+8]     ; reload A[2..3]
        cvtsi2ss        xmm4, rbx
        mov     edx, edx                   ; truncate RDX to 32-bit
        movd    eax, xmm4                  ; float bit-pattern of (float)k
        sal     rax, 32
        or      rdx, rax                   ; merge the float bit-pattern into A[3]
        mov     QWORD PTR [rsp+8], rdx     ; store A[2..3] again

        movaps  xmm0, XMMWORD PTR [rsp]    ; vector load: store-forwarding stall
        mulps   xmm0, xmm0
        haddps  xmm0, xmm0
        haddps  xmm0, xmm0
        ucomiss xmm3, xmm0
        movaps  xmm1, xmm0
        sqrtss  xmm1, xmm1
        ja      .L21             ; call sqrtf to set errno if needed; flags set by ucomiss.
.L17:

        add     rbx, 1
        cvtss2sd        xmm1, xmm1
        addsd   xmm2, xmm1            ; total += (double)sqrtf
        cmp     rbx, 1000000000
        jne     .L18                ; }while(k<1000000000);

Это безумие отсутствует в скалярной версии.

В любом случае, gcc удалось избежать неэффективности полного преобразования uint64_t -> float (которого у x86 не было аппаратно до AVX512). Предположительно удалось доказать, что использование 64-битного преобразования с плавающей запятой со знаком всегда будет работать, поскольку старший бит не может быть установлен.


Сноска 1 : Но sqrtps имеет такую ​​же пропускную способность 1 на 3 цикла, что и скалярная, так что вы получаете только 1/4 от пропускной способности sqrt вашего ЦП, выполняя по 1 вектору по горизонтали вместо того, чтобы делать 4 длины для 4 векторов параллельно.

...