Удаление нескольких _mm256_blend_ps снижает производительность, а не увеличивает ее - PullRequest
0 голосов
/ 08 марта 2020

Я пишу небольшую библиотеку шаблонов для транспонирования произвольных матриц с использованием встроенных функций AVX. Поскольку я интенсивно использую if constexpr и шаблоны, я хотел убедиться, что компилятор применяет всю ожидаемую оптимизацию и сравнивает мой код. Я наткнулся на результат, который мне не совсем понятен.

В функциях есть параметр шаблона, который управляет обработкой неиспользуемых значений регистров. Один из вариантов - взять все, что окажется там во время выполненных операций. Другой способ - записывать только записи, необходимые для сохранения результата. Я удалил все элементы шаблона и написал короткий пример для матрицы 7x4:

РЕДАКТИРОВАТЬ: этот код неправильный --- см. ОБНОВЛЕНИЕ

void Transpose7x4(__m256 in0, __m256 in1, __m256 in2, __m256 in3, __m256& out0, __m256& out1, __m256& out2,
                    __m256& out3, __m256& out4, __m256& out5, __m256& out6)
{
    __m256 tout0, tout1, tout2, tout3, tout4, tout5, tout6;
    __m256 tmp0, tmp1, tmp2, tmp3;


    __m256 tmp4 = _mm256_unpacklo_ps(in3, in0);
    __m256 tmp5 = _mm256_unpackhi_ps(in3, in0);
    __m256 tmp6 = _mm256_unpacklo_ps(in1, in2);
    __m256 tmp7 = _mm256_unpackhi_ps(in1, in2);

    tmp0 = _mm256_shuffle_ps(tmp4, tmp6, 0x44);
    tmp1 = _mm256_shuffle_ps(tmp6, tmp4, 0xee);
    tmp2 = _mm256_shuffle_ps(tmp5, tmp7, 0x44);
    tmp3 = _mm256_shuffle_ps(tmp7, tmp5, 0xee);

    tout0 = _mm256_permute2f128_ps(tmp0, tmp0, 0x00);
    tout1 = _mm256_permute2f128_ps(tmp1, tmp1, 0x00);
    tout2 = _mm256_permute2f128_ps(tmp2, tmp2, 0x00);
    tout3 = _mm256_permute2f128_ps(tmp3, tmp3, 0x00);
    tout4 = _mm256_permute2f128_ps(tmp0, tmp0, 0x44);
    tout5 = _mm256_permute2f128_ps(tmp1, tmp1, 0x44);
    tout6 = _mm256_permute2f128_ps(tmp2, tmp2, 0x44);


    // Don't care what is written to unused values
    out0 = tout0;
    out1 = tout1;
    out2 = tout2;
    out3 = tout3;
    out4 = tout4;
    out5 = tout5;
    out6 = tout6;

    // Only write to values necessary to store the result
    //out0 = _mm256_blend_ps(out0, tout0, 0xfe);
    //out1 = _mm256_blend_ps(out1, tout1, 0xfe);
    //out2 = _mm256_blend_ps(out2, tout2, 0xfe);
    //out3 = _mm256_blend_ps(out3, tout3, 0xfe);
    //out4 = _mm256_blend_ps(out4, tout4, 0xfe);
    //out5 = _mm256_blend_ps(out5, tout5, 0xfe);
    //out6 = _mm256_blend_ps(out6, tout6, 0xfe);
}

Как вы можете видеть, версия для того, чтобы не перезаписывать неиспользуемые значения, нужны дополнительные наложения, поэтому я ожидал, что это будет немного медленнее. Тем не менее, результат тестов (Clang 8.0.0 и G CC 8.3.0 на процессоре Intel Skylake) сказал мне иначе. 100 транспозиций дали мне около 430 нс для версии с наложением, в то время как другая версия заняла около 670 нс. Я проверил сборку, если происходит что-то странное, но ничего не вижу: godbolt

Сборка более или менее идентична, только в одной версии vmovaps чередуется с дополнительные vblendps (и один vperm2f128).

Я рассчитал ожидаемые тактовые циклы с учетом конвейерной обработки инструкций для _mm256_permute2f128_ps. Для кода без смешивания я придумал 17 циклов. Умножение на 100 и деление на частоту моего процессора дало 425 нс, что довольно много, что я получил для версии со смешиванием. Единственная причина, по которой я вижу, почему версия без наложения занимает больше времени, заключается в том, что конвейерная обработка инструкций для _mm256_permute2f128_ps по какой-то причине не может быть использована. Если я рассчитываю ожидаемое время исходя из предположения, что каждые _mm256_permute2f128_ps занимают 3 такта, я получаю 725 нс, что намного ближе к полученным результатам.

Итак, вопрос в том, почему версия с блендами быстрее (используя конвейерную обработку команд), чем «более простая» версия, и как я могу это исправить.

1 Ответ

1 голос
/ 15 марта 2020

Нашел решение. Комментарий Питера Кордеса подтолкнул меня в правильном направлении. Что-то с моим тестом было не так. Я использую бенчмарк Google, и вот исходный код бенчмарка, который я использовал:

#include <benchmark/benchmark.h>

#include <x86intrin.h>

#include <array>



class FixtureBenchmark_m256 : public benchmark::Fixture
{
public:
    std::array<std::array<__m256, 8>, 10000> in;
    std::array<std::array<__m256, 8>, 10000> out;

    FixtureBenchmark_m256()
    {
        __m256 tmp0 = _mm256_setr_ps(1, 2, 3, 4, 5, 6, 7, 8);
        for (std::size_t i = 0; i < 1000; ++i)
            for (std::size_t j = 0; j < 8; ++j)
            {
                __m256 tmp1 = _mm256_set1_ps(i * 8 + j);
                in[i][j] = _mm256_mul_ps(tmp0, tmp1);
            }
    }
};



void T7x4_assign(__m256 in0, __m256 in1, __m256 in2, __m256 in3, __m256& out0, __m256& out1, __m256& out2, __m256& out3,
                 __m256& out4, __m256& out5, __m256& out6)
{
    __m256 tout0, tout1, tout2, tout3, tout4, tout5, tout6;
    __m256 tmp0, tmp1, tmp2, tmp3;


    __m256 tmp4 = _mm256_unpacklo_ps(in3, in0);
    __m256 tmp5 = _mm256_unpackhi_ps(in3, in0);
    __m256 tmp6 = _mm256_unpacklo_ps(in1, in2);
    __m256 tmp7 = _mm256_unpackhi_ps(in1, in2);

    tmp0 = _mm256_shuffle_ps(tmp4, tmp6, 0x44);
    tmp1 = _mm256_shuffle_ps(tmp6, tmp4, 0xee);
    tmp2 = _mm256_shuffle_ps(tmp5, tmp7, 0x44);
    tmp3 = _mm256_shuffle_ps(tmp7, tmp5, 0xee);

    tout0 = _mm256_permute2f128_ps(tmp0, tmp0, 0x00);
    tout1 = _mm256_permute2f128_ps(tmp1, tmp1, 0x00);
    tout2 = _mm256_permute2f128_ps(tmp2, tmp2, 0x00);
    tout3 = _mm256_permute2f128_ps(tmp3, tmp3, 0x00);
    tout4 = _mm256_permute2f128_ps(tmp0, tmp0, 0x44);
    tout5 = _mm256_permute2f128_ps(tmp1, tmp1, 0x44);
    tout6 = _mm256_permute2f128_ps(tmp2, tmp2, 0x44);

    out0 = tout0;
    out1 = tout1;
    out2 = tout2;
    out3 = tout3;
    out4 = tout4;
    out5 = tout5;
    out6 = tout6;
}


void T7x4_blend(__m256 in0, __m256 in1, __m256 in2, __m256 in3, __m256& out0, __m256& out1, __m256& out2, __m256& out3,
                __m256& out4, __m256& out5, __m256& out6)
{
    __m256 tout0, tout1, tout2, tout3, tout4, tout5, tout6;
    __m256 tmp0, tmp1, tmp2, tmp3;

    __m256 tmp4 = _mm256_unpacklo_ps(in3, in0);
    __m256 tmp5 = _mm256_unpackhi_ps(in3, in0);
    __m256 tmp6 = _mm256_unpacklo_ps(in1, in2);
    __m256 tmp7 = _mm256_unpackhi_ps(in1, in2);

    tmp0 = _mm256_shuffle_ps(tmp4, tmp6, 0x44);
    tmp1 = _mm256_shuffle_ps(tmp6, tmp4, 0xee);
    tmp2 = _mm256_shuffle_ps(tmp5, tmp7, 0x44);
    tmp3 = _mm256_shuffle_ps(tmp7, tmp5, 0xee);

    tout0 = _mm256_permute2f128_ps(tmp0, tmp0, 0x00);
    tout1 = _mm256_permute2f128_ps(tmp1, tmp1, 0x00);
    tout2 = _mm256_permute2f128_ps(tmp2, tmp2, 0x00);
    tout3 = _mm256_permute2f128_ps(tmp3, tmp3, 0x00);
    tout4 = _mm256_permute2f128_ps(tmp0, tmp0, 0x44);
    tout5 = _mm256_permute2f128_ps(tmp1, tmp1, 0x44);
    tout6 = _mm256_permute2f128_ps(tmp2, tmp2, 0x44);

    out0 = _mm256_blend_ps(out0, tout0, 0xfe);
    out1 = _mm256_blend_ps(out1, tout1, 0xfe);
    out2 = _mm256_blend_ps(out2, tout2, 0xfe);
    out3 = _mm256_blend_ps(out3, tout3, 0xfe);
    out4 = _mm256_blend_ps(out4, tout4, 0xfe);
    out5 = _mm256_blend_ps(out5, tout5, 0xfe);
    out6 = _mm256_blend_ps(out6, tout6, 0xfe);
}



BENCHMARK_F(FixtureBenchmark_m256, 7x4_assign)(benchmark::State& state)
{
    for (auto _ : state)
    {
        for (std::size_t i = 0; i < 100; ++i)
        {
            T7x4_assign(in[i][0], in[i][1], in[i][2], in[i][3], out[i][0], out[i][1], out[i][2], out[i][3], out[i][4],
                        out[i][5], out[i][6]);
            benchmark::ClobberMemory();
        }
    }
}

BENCHMARK_F(FixtureBenchmark_m256, 7x4_blend)(benchmark::State& state)
{
    for (auto _ : state)
    {
        for (std::size_t i = 0; i < 100; ++i)
        {
            T7x4_blend(in[i][0], in[i][1], in[i][2], in[i][3], out[i][0], out[i][1], out[i][2], out[i][3], out[i][4],
                       out[i][5], out[i][6]);
            benchmark::ClobberMemory();
        }
    }
}

BENCHMARK_MAIN();

Это дало вывод:

---------------------------------------------------------------------------
Benchmark                                 Time             CPU   Iterations
---------------------------------------------------------------------------
FixtureBenchmark_m256/7x4_assign        646 ns          646 ns      1081509
FixtureBenchmark_m256/7x4_blend         380 ns          380 ns      1847485

Проблема здесь в l oop. Я не могу точно сказать, что именно происходит, может быть, кеширование пропало или какие-то странные оптимизации l oop, но удаление l oop дает ожидаемое время:

---------------------------------------------------------------------------
Benchmark                                 Time             CPU   Iterations
---------------------------------------------------------------------------
FixtureBenchmark_m256/7x4_assign       3.27 ns         3.27 ns    214698649
FixtureBenchmark_m256/7x4_blend        4.15 ns         4.14 ns    168642478

Так почему же циклы в первое место? Это произошло из-за установки google benchmark в Ubuntu с использованием sudo apt-get install libbenchmark-dev. Проблема в том, что это отладочная сборка, и в этой версии округляются тайминги наносекунд. Таким образом, я не мог видеть никакой разницы для одного выполнения и синхронизировал несколько вызовов функций с помощью al oop. Однако после ручной сборки и установки версии выпуска я получил более точные тайминги и смог удалить l oop, что негативно сказалось на тесте.

Дополнительное замечание: я также неправильно рассчитал ожидаемые циклы ЦП. Я использовал не оптимизированную сборку, а встроенные. Итак, я придумал 8 нормальных перетасовок и 7 перестановок между рядами, которые дают 15. Добавление неизбежной задержки последней перестановки между рядами (2 дополнительных цикла) дало 17. Однако компилятор оптимизирует 3 _mm256_permute2f128_ps, что дает 14 (12 тасует - как сказал Питер Кордес - плюс 2 цикла задержки). Деление на частоту процессора 4,2 дает 3,33, что довольно близко к результату теста.

ОБНОВЛЕНИЕ

Мне было интересно, почему компилятор оптимизировал 3 _mm256_permute2f128_ps звонки. В моей библиотеке встроенные функции обобщены, чтобы легко поменять тип регистра. Кроме того, все маски рассчитываются автоматически. Поэтому я сделал несколько ошибок, когда заменил все вызовы библиотеки. Вот правильный код:

void Transpose7x4(__m256 in0, __m256 in1, __m256 in2, __m256 in3, __m256& out0, __m256& out1, __m256& out2,
                    __m256& out3, __m256& out4, __m256& out5, __m256& out6)
{
__m256 tout0, tout1, tout2, tout3, tout4, tout5, tout6;
    __m256 tmp0, tmp1, tmp2, tmp3;


    __m256 tmp4 = _mm256_unpacklo_ps(in3, in0);
    __m256 tmp5 = _mm256_unpackhi_ps(in3, in0);
    __m256 tmp6 = _mm256_unpacklo_ps(in1, in2);
    __m256 tmp7 = _mm256_unpackhi_ps(in1, in2);

    tmp0 = _mm256_shuffle_ps(tmp4, tmp6, 0x44);
    tmp1 = _mm256_shuffle_ps(tmp4, tmp6, 0xee);
    tmp2 = _mm256_shuffle_ps(tmp5, tmp7, 0x44);
    tmp3 = _mm256_shuffle_ps(tmp5, tmp7, 0xee);


    tout0 = _mm256_permute2f128_ps(tmp0, tmp0, 0x00);
    tout1 = _mm256_permute2f128_ps(tmp1, tmp1, 0x00);
    tout2 = _mm256_permute2f128_ps(tmp2, tmp2, 0x00);
    tout3 = _mm256_permute2f128_ps(tmp3, tmp3, 0x00);
    tout4 = _mm256_permute2f128_ps(tmp0, tmp0, 0x33);
    tout5 = _mm256_permute2f128_ps(tmp1, tmp1, 0x33);
    tout6 = _mm256_permute2f128_ps(tmp2, tmp2, 0x33);


    out0 = tout0;
    out1 = tout1;
    out2 = tout2;
    out3 = tout3;
    out4 = tout4;
    out5 = tout5;
    out6 = tout6;

    //out0 = _mm256_blend_ps(out0, tout0, 0xfe);
    //out1 = _mm256_blend_ps(out1, tout1, 0xfe);
    //out2 = _mm256_blend_ps(out2, tout2, 0xfe);
    //out3 = _mm256_blend_ps(out3, tout3, 0xfe);
    //out4 = _mm256_blend_ps(out4, tout4, 0xfe);
    //out5 = _mm256_blend_ps(out5, tout5, 0xfe);
    //out6 = _mm256_blend_ps(out6, tout6, 0xfe);
}

Теперь все инструкции (8 перемешиваний и 7 перемешиваний между рядами) появляются в сборке, как и ожидалось:

Godbolt

...