Почему AVX не улучшает производительность по сравнению с SSE2? - PullRequest
1 голос
/ 01 марта 2020

Я новичок в области SSE2 и AVX. Я пишу следующий код для проверки производительности SSE2 и AVX.

#include <cmath>
#include <iostream>
#include <chrono>
#include <emmintrin.h>
#include <immintrin.h>

void normal_res(float* __restrict__ a, float* __restrict__ b, float* __restrict__ c, unsigned long N) {
    for (unsigned long n = 0; n < N; n++) {
        c[n] = sqrt(a[n]) + sqrt(b[n]);
    }
}

void normal(float* a, float* b, float* c, unsigned long N) {
    for (unsigned long n = 0; n < N; n++) {
        c[n] = sqrt(a[n]) + sqrt(b[n]);
    }
}

void sse(float* a, float* b, float* c, unsigned long N) {
    __m128* a_ptr = (__m128*)a;
    __m128* b_ptr = (__m128*)b;

    for (unsigned long n = 0; n < N; n+=4, a_ptr++, b_ptr++) {
        __m128 asqrt = _mm_sqrt_ps(*a_ptr);
        __m128 bsqrt = _mm_sqrt_ps(*b_ptr);
        __m128 add_result = _mm_add_ps(asqrt, bsqrt);
        _mm_store_ps(&c[n], add_result);
    }
}

void avx(float* a, float* b, float* c, unsigned long N) {
    __m256* a_ptr = (__m256*)a;
    __m256* b_ptr = (__m256*)b;

    for (unsigned long n = 0; n < N; n+=8, a_ptr++, b_ptr++) {
        __m256 asqrt = _mm256_sqrt_ps(*a_ptr);
        __m256 bsqrt = _mm256_sqrt_ps(*b_ptr);
        __m256 add_result = _mm256_add_ps(asqrt, bsqrt);
        _mm256_store_ps(&c[n], add_result);
    }
}

int main(int argc, char** argv) {
    unsigned long N = 1 << 30;

    auto *a = static_cast<float*>(aligned_alloc(128, N*sizeof(float)));
    auto *b = static_cast<float*>(aligned_alloc(128, N*sizeof(float)));
    auto *c = static_cast<float*>(aligned_alloc(128, N*sizeof(float)));

    std::chrono::time_point<std::chrono::system_clock> start, end;
    for (unsigned long i = 0; i < N; ++i) {                                                                                                                                                                                   
        a[i] = 3141592.65358;           
        b[i] = 1234567.65358;                                                                                                                                                                            
    }

    start = std::chrono::system_clock::now();   
    for (int i = 0; i < 5; i++)                                                                                                                                                                              
        normal(a, b, c, N);                                                                                                                                                                                                                                                                                                                                                                                                            
    end = std::chrono::system_clock::now();
    std::chrono::duration<double> elapsed_seconds = end - start;
    std::cout << "normal elapsed time: " << elapsed_seconds.count() / 5 << std::endl;

    start = std::chrono::system_clock::now();     
    for (int i = 0; i < 5; i++)                                                                                                                                                                                                                                                                                                                                                                                         
        normal_res(a, b, c, N);    
    end = std::chrono::system_clock::now();
    elapsed_seconds = end - start;
    std::cout << "normal restrict elapsed time: " << elapsed_seconds.count() / 5 << std::endl;                                                                                                                                                                                 

    start = std::chrono::system_clock::now();
    for (int i = 0; i < 5; i++)                                                                                                                                                                                                                                                                                                                                                                                              
        sse(a, b, c, N);    
    end = std::chrono::system_clock::now();
    elapsed_seconds = end - start;
    std::cout << "sse elapsed time: " << elapsed_seconds.count() / 5 << std::endl;   

    start = std::chrono::system_clock::now();
    for (int i = 0; i < 5; i++)                                                                                                                                                                                                                                                                                                                                                                                              
        avx(a, b, c, N);    
    end = std::chrono::system_clock::now();
    elapsed_seconds = end - start;
    std::cout << "avx elapsed time: " << elapsed_seconds.count() / 5 << std::endl;   
    return 0;            
}

Я компилирую свою программу, используя g ++ complier, как показано ниже.

g++ -msse -msse2 -mavx -mavx512f -O2

Результаты следующие. Кажется, что при использовании более продвинутых 256-битных векторов улучшения не происходит.

normal elapsed time: 10.5311
normal restrict elapsed time: 8.00338
sse elapsed time: 0.995806
avx elapsed time: 0.973302

У меня два вопроса.

  1. Почему AVX не дает мне дальнейших улучшений? Это из-за пропускной способности памяти?
  2. Согласно моему эксперименту, SSE2 работает в 10 раз быстрее, чем наивная версия. Это почему? Я ожидаю, что SSE2 может быть только в 4 раза быстрее на основе его 128-битных векторов по отношению к плавающей запятой одинарной точности. Большое спасибо.

Ответы [ 2 ]

3 голосов
/ 01 марта 2020

Здесь есть несколько проблем ....

  1. Пропускная способность памяти очень важна для этих размеров массива - больше примечаний ниже.
  2. Пропускная способность для SSE и AVX square root инструкции могут не соответствовать вашим ожиданиям - больше примечаний ниже.
  3. Первый тест («нормальный») может быть медленнее, чем ожидалось потому что создается выходной массив (т. е. создаются виртуальные сопоставления с физическими) во время временной части теста. (Просто заполните c нулями в l oop, который инициализирует a и b, чтобы исправить это.)

Пропускная способность памяти Примечания:

  • С N = 1 << 30 и переменные с плавающей запятой, каждый массив равен 4 ГБ. </li>
  • Каждый тест читает два массива и записывает в третий массив. Этот третий массив также должен быть прочитан из памяти перед перезаписью - это называется «запись на выделение» или «чтение на владение».
  • Итак, вы читаете 12 ГиБ и пишете 4 ГиБ в каждом тесте. Поэтому тесты SSE и AVX соответствуют полосе пропускания DRAM ~ 16 ГБ / с, что близко к верхнему пределу диапазона, обычно наблюдаемого для однопоточной работы на современных процессорах.

Замечания о пропускной способности инструкций:

  • Наилучшим эталоном для задержки и пропускной способности команд на процессорах x86 является "инструкция_таблица.pdf" из https://www.agner.org/optimize/
  • Агнер определяет «взаимную пропускную способность» как среднее число циклов на выбывшую инструкцию, когда процессор получает рабочую нагрузку независимых инструкций того же типа.
  • Например, для ядра Intel Skylake пропускная способность SSE и AVX SQRT одинакова:
  • SQRTPS (xmm) 1 / throughput = 3 -> 1 инструкция каждые 3 цикла
  • VSQRTPS (ymm) 1 / пропускная способность = 6 -> 1 инструкция каждые 6 циклов
  • Ожидается, что время выполнения для квадратных корней составит (1 << 31) квадратных корней / 4 квадратных корни на команду SSE SQRT * 3 цикла на команду SSE SQRT / 3 ГГц = 0,54 секунды (случайным образом предполагая частоту процессора). </li>
  • Ожидаемая пропускная способность для случаев "normal" и "normal_res" зависит от специфики сгенерированный ассемблерный код.
2 голосов
/ 01 марта 2020

Скалярное значение в 10 раз вместо 4x медленнее:

Вы получаете ошибки страницы в c[] внутри скалярной временной области, потому что вы пишете это впервые. Если вы выполняли тесты в другом порядке, какой бы из них ни был первым, вы бы заплатили такой большой штраф. Эта часть является дубликатом этой ошибки: Почему итерация хотя `std :: vector` быстрее, чем итерация хотя `std :: array`? См. также Idiomati c способ оценки производительности?

normal оплачивает эту стоимость в первом из 5 проходов по массиву , Меньшие массивы и большее число повторов амортизируют это еще больше, но лучше сначала запомнить или иным образом заполнить пункт назначения, чтобы предварительно выполнить поиск неисправности перед временной областью.


normal_res также скаляр, но пишет в уже загрязненный c[]. Скалярный в 8 раз медленнее, чем SSE, вместо ожидаемого 4x.

Вы использовали sqrt(double) вместо sqrtf(float) или std::sqrt(float). На Skylake-X это отлично учитывает дополнительный коэффициент пропускной способности 2 . Посмотрите на вывод asm компилятора в проводнике компилятора Godbolt (G CC 7.4 при условии, что используется та же система, что и ваш последний вопрос ). Я использовал -mavx512f (что подразумевает -mavx и -msse), и не имел опций настройки, чтобы надеяться получить примерно тот же код, что и вы. main не встроенный normal_res, поэтому мы можем просто взглянуть на его отдельное определение.

normal_res(float*, float*, float*, unsigned long):
...
        vpxord  zmm2, zmm2, zmm2    # uh oh, 512-bit instruction reduces turbo clocks for the next several microseconds.  Silly compiler
                                    # more recent gcc would just use `vpxor xmm0,xmm0,xmm0`
...
.L5:                              # main loop
        vxorpd  xmm0, xmm0, xmm0
        vcvtss2sd       xmm0, xmm0, DWORD PTR [rdi+rbx*4]   # convert to double
        vucomisd        xmm2, xmm0
        vsqrtsd xmm1, xmm1, xmm0                           # scalar double sqrt
        ja      .L16
.L3:
        vxorpd  xmm0, xmm0, xmm0
        vcvtss2sd       xmm0, xmm0, DWORD PTR [rsi+rbx*4]
        vucomisd        xmm2, xmm0
        vsqrtsd xmm3, xmm3, xmm0                    # scalar double sqrt
        ja      .L17
.L4:
        vaddsd  xmm1, xmm1, xmm3                    # scalar double add
        vxorps  xmm4, xmm4, xmm4
        vcvtsd2ss       xmm4, xmm4, xmm1            # could have just converted in-place without zeroing another destination to avoid a false dependency :/
        vmovss  DWORD PTR [rdx+rbx*4], xmm4
        add     rbx, 1
        cmp     rcx, rbx
        jne     .L5

vpxord zmm сокращает турбо тактовые сигналы только на несколько миллисекунд (я думаю) в начале каждого звонка normal и normal_res. Он не использует 512-битные операции, поэтому тактовая частота может снова возрасти позже. Отчасти это может быть связано с тем, что оно не является точно 8x.

Сравнение / ja связано с тем, что вы не использовали -fno-math-errno, поэтому G CC все еще вызывает фактические sqrt для входных данных <0, чтобы установить <code>errno. Он делает if (!(0 <= tmp)) goto fallback, прыгает на 0 > tmp или неупорядочен. «К счастью», sqrt достаточно медленный, чтобы оставаться единственным узким местом. Превышение порядка c преобразования и сравнения / ветвления означает, что блок SQRT по-прежнему остается занятым ~ 100% времени.

vsqrtsd пропускная способность (6 циклов) в 2 раза медленнее, чем * Пропускная способность 1056 * (3 такта) в Skylake-X, поэтому использование двойной стоимости в скалярной пропускной способности в 2 раза больше.

Scalar sqrt в Skylake-X имеет такую ​​же пропускную способность, что и соответствующие 128-битные PSD / pd SIMD версия. Таким образом, 6 циклов на 1 число в виде double против 3 циклов на 4 поплавка в виде вектора ps полностью объясняют коэффициент 8x.

Дополнительное замедление 8x против 10x для normal было только из-за ошибок страницы.


SSE против AVX sqrt пропускная способность

128-бит sqrtps достаточно для получения полной пропускной способности SIMD div / кв.м ; если предположить, что это сервер Skylake, как ваш последний вопрос, он имеет ширину 256 бит, но не полностью конвейеризован. Процессор может поочередно отправлять 128-битный вектор в нижнюю или верхнюю половину, чтобы использовать преимущества полной аппаратной ширины, даже если вы используете только 128-битные векторы. См. Деление с плавающей запятой и умножение с плавающей запятой (FP div и sqrt выполняются на одном и том же модуле выполнения.)

См. Также значения задержки / пропускной способности для команд https://uops.info/ или https://agner.org/optimize/.

Все add / sub / mul / fma имеют ширину 512 бит и полностью конвейеризованы; используйте это (например, для оценки полинома 6-го порядка или чего-то еще), если вы хотите что-то, что может масштабироваться с векторной шириной. div / sqrt является особым случаем.

Вы ожидаете выгоды от использования 256 -битовые векторы для SQRT, только если у вас было узкое место во внешнем интерфейсе (4 / тактовая инструкция / пропускная способность uop), или если вы выполняли кучу операций add / sub / mul / fma с векторами.

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


См. Статью Джона Маккальпина Ответьте для более подробной информации о стоимости только для записи примерно так же, как чтение + запись из-за RFO

С таким небольшим количеством вычислений на доступ к памяти, вы, вероятно, близки к узким местам в пропускной способности памяти снова / снова. Даже если аппаратное обеспечение FP SQRT было шире / быстрее, на практике ваш код может не работать быстрее. Вместо этого ядро ​​будет тратить больше времени, ничего не делая, ожидая поступления данных из памяти.

Кажется, вы получаете именно ожидаемое ускорение от 128-битных векторов (2x * 4x = 8x), так что, очевидно, версия __m128 не является узким местом и для пропускной способности памяти.

2x sqrt на 4 обращения к памяти примерно такой же, как a[i] = sqrt(a[i]) (1x sqrt на загрузку + хранилище), который вы делали в опубликованном вами коде в чате , но вы не дали никаких цифр для этого. Это позволило избежать проблемы с ошибкой страницы, поскольку после инициализации он переписывал массив на месте.

В общем случае переписывание массива на месте - хорошая идея, если вы по какой-то причине продолжаете настаивать на пытаясь получить ускорение SIMD 4x / 8x / 16x, используя эти безумно огромные массивы, которые даже не помещаются в кэш-память третьего уровня. (при условии последовательного доступа, чтобы предварительные сборщики могли извлекать его непрерывно, не вычисляя следующий адрес): более быстрое вычисление не ускоряет общий прогресс. Строки кэша поступают из памяти с некоторой фиксированной максимальной пропускной способностью, причем ~ 12 строк кэша передаются за один раз (12 LFB в Skylake). Или «superqueue» L2 может отслеживать больше строк кэша, чем это (может быть, 16?), Поэтому предварительная выборка L2 будет считываться раньше, чем остановится ядро ​​ЦП.

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

(Буфер хранилища, записывающий обратно в L1d и затем удаляющий грязные строки, также происходит, но основной c идея о том, что ядро ​​ожидает память, все еще работает.)


Вы можете думать об этом, как об остановке и go traffi c в машине : a щель открывается впереди твоей машины. Сокращение этого разрыва быстрее не даст вам никакой средней скорости, это просто означает, что вы должны остановиться быстрее.


Если вы хотите увидеть преимущества AVX и AVX512 над SSE, вам потребуется меньше массивы (и более высокий счетчик повторений). Или вам потребуется много работы ALU для каждого вектора, например, полином.

Во многих реальных задачах одни и те же данные используются многократно, поэтому кеши работают. И можно разбить вашу задачу на выполнение нескольких операций с одним блоком данных, пока он горячий в кэше (или даже при загрузке в регистрах), чтобы увеличить вычислительную интенсивность, достаточную для того, чтобы воспользоваться преимуществами баланса вычислений и памяти современных процессоров. .

...