AVX2 SIMD XOR не дает улучшения производительности в .NET

Я играю с новой поддержкой аппаратных встроенных функций в .NET Core 3.0 в пространстве имен System.Runtime.Intrinsics.

У меня есть код, в котором я выполняю 4 операции XOR в цикле — ниже приведен упрощенный пример (я не писал это в IDE, поэтому не обращайте внимания на любые синтаксические ошибки:

private static unsafe ulong WyHashCore(byte[] array)
{
    fixed (byte* pData = array)
    {
        byte* ptr = pData;

        // Consume 32-byte chunks
        for (int i = 0; i < array.Length; i += 32)
        {
            ulong a = Read64(ptr, i);
            ulong b = Read64(ptr, i + 8);
            ulong c = Read64(ptr, i + 16);
            ulong d = Read64(ptr, i + 24);

            // XOR them with some constants
            ulong xor1 = a ^ SOME_CONSTANT1;
            ulong xor2 = b ^ SOME_CONSTANT2;
            ulong xor3 = c ^ SOME_CONSTANT3;
            ulong xor4 = d ^ SOME_CONSTANT4;

            // Use the resulting values
        }
    }
}

Метод Read64 выглядит так:

[MethodImpl(MethodImplOptions.AggressiveInlining)]
internal static unsafe ulong Read64(byte* ptr, int start)
    => *(ulong*)(ptr + start);

Я попытался заменить 4 строки XOR на:

byte[] array; // An array from elsewhere

private static unsafe ulong WyHashCore(byte[] array)
{
    var bVector = Vector256.Create(SOME_CONSTANT1, SOME_CONSTANT2, SOME_CONSTANT3, SOME_CONSTANT4);

    fixed (byte* pData = array)
    {
        byte* ptr = pData;

        // Consume 32-byte chunks
        for (int i = 0; i < array.Length; i += 32)
        {
            ulong a = Read64(ptr, i);
            ulong b = Read64(ptr, i + 8);
            ulong c = Read64(ptr, i + 16);
            ulong d = Read64(ptr, i + 24);

            // Create a 256-bit vector from the 4 64-bit integers
            var aVector = Vector256.Create(a, b, c, d);

            // XOR the 2 vectors
            var res = Avx2.Xor(aVector, bVector);

            // Get the resulting values out of the result vector
            ulong xor1 = res.GetElement(0);
            ulong xor2 = res.GetElement(1);
            ulong xor3 = res.GetElement(2);
            ulong xor4 = res.GetElement(3);

            // Use the resulting values
        }
    }
}

Это дает ожидаемые результаты, но в 15 раз медленнее, чем простое умножение скаляров!

Я где-то ошибаюсь или неправильно использую SIMD?

** Обновление ** Я обновил код, чтобы использовать «правильные» способы загрузки и выгрузки данных в/из вектора, и теперь он примерно в 3,75 раза быстрее, чем скалярный код!

byte[] array; // An array from elsewhere
private static readonly Vector256<ulong> PrimeVector = Vector256.Create(SOME_CONSTANT1, SOME_CONSTANT2, SOME_CONSTANT3, SOME_CONSTANT4);

private static unsafe ulong WyHashCore(byte[] array)
{
    // Create space on the stack to hold XOR results
    var xorResult = stackalloc ulong[4];

    fixed (byte* pData = array)
    {
        byte* ptr = pData;

        // Consume 32-byte chunks
        for (int i = 0; i < array.Length; i += 32)
        {
            // Create a 256-bit vector from the 4 64-bit integers
            var vector = Avx.LoadVector256((ulong*)(ptr + i));

            // XOR the 2 vectors
            var res = Avx2.Xor(vector, PrimeVector);

            // Store the resulting vector in memory
            Avx2.Store(xorResult, res);

            // Get the resulting values out of the result vector
            var xor1 = *xorResult;
            var xor2 = *(xorResult + 1);
            var xor3 = *(xorResult + 2);
            var xor4 = *(xorResult + 3);

            // Use the resulting values
        }
    }
}

person Cocowalla    schedule 08.05.2019    source источник
comment
Можете ли вы опубликовать более полный код. Цикл во втором примере было бы важно увидеть. Я думаю, вы должны читать байты непосредственно из памяти, а не использовать Vector256.Create(a, b, c, d). Тоже надо так хранить, я думаю. Эта поэлементная загрузка и сохранение имеют высокие накладные расходы.   -  person usr    schedule 08.05.2019
comment
@usr Моя цель здесь состояла в том, чтобы сохранить удобочитаемость, сосредоточиться на проблеме, но я вижу, насколько важны детали - я обновил примеры кода, чтобы они лучше соответствовали реальности.   -  person Cocowalla    schedule 08.05.2019
comment
@usr Я только что попытался загрузить вектор в цикл с помощью Avx2.LoadVector256((ulong*)(ptr + p)); - это действительно намного быстрее, чем использование Vector256.Create! Это дает мне примерно паритет производительности со скалярной версией. Наверняка можно быстрее...   -  person Cocowalla    schedule 08.05.2019
comment
Пожалуйста, всегда публикуйте лучший код, который у вас есть. Обязательно сохраните результаты непосредственно в памяти. Также, пожалуйста, опубликуйте лист разборки. И, конечно же, вы работаете в режиме Release без подключенного отладчика, верно?   -  person usr    schedule 08.05.2019
comment
@usr Да, я проверяю производительность с помощью Benchmark.NET в режиме выпуска без отладчика. Как я могу сохранить результаты непосредственно в памяти? (Я новичок в С# Vector). Я создал суть, показывающую C#, IL и ASM, сгенерированные JIT.   -  person Cocowalla    schedule 08.05.2019
comment
Я не могу сказать вам, как правильно это сделать, потому что я не эксперт в этом. Но наверняка существует инструкция по сохранению векторов, верно? Насколько я понимаю, лучше даже не использовать указатели, а использовать ref и, возможно, Unsafe.Add для арифметики указателей. Таким образом, вы используете управляемые указатели, которые проще в использовании, чем неуправляемые указатели. Может быть, они также генерируют лучший код, кто знает.   -  person usr    schedule 08.05.2019
comment
@usr Раньше я проводил несколько тестов микрооптимизации, и Unsafe.Add в основном так же с точки зрения производительности, как и небезопасные указатели. Я лично нахожу указатели менее подробными и более удобными для чтения, чем использование Unsafe, но я думаю, что это дело вкуса. Есть метод Avx.Store - я его изучу!   -  person Cocowalla    schedule 08.05.2019
comment
Использование Avx.Store оказалось очень успешным — версия SIMD теперь в 3,75 раза быстрее, чем скалярная версия! Я напишу ответ с обновленным кодом - спасибо за помощь @usr :)   -  person Cocowalla    schedule 08.05.2019
comment
Похоже, что .NET API не поддерживает Avx2.Store. Может быть, в вашем коде есть опечатка, и вы действительно использовали Avx.Store?   -  person Dragos    schedule 14.08.2019


Ответы (1)


TL;DR Внутренние функции AVX2 HW используются неправильно, что приводит к генерации очень неэффективного SIMD-кода.

Ошибка заключается в том, как инструкции загружают, обрабатывают и сохраняют данные в буфере. Операция должна выполняться с использованием AVX/AVX2 Avx2.X или встроенных компонентов с памятью, что ускорит время загрузки в 4 раза и вернет Vector256. С другой стороны, это сделало бы вызов Vector256.Create избыточным и еще больше ускорило бы выполнение. Наконец, данные должны храниться в массиве с помощью встроенной функции Avx2.Store(). Это снова ускорит код примерно в 4 раза.

Последняя оптимизация, которую следует применить, — это использование параллелизма на уровне инструкций ЦП. Обычно инструкции SIMD выполняются в течение предопределенного количества циклов ЦП с задержкой, которая может превышать 1 такт ЦП. Эти параметры зависят от ЦП и могут быть найдены в:

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

Пример кода, который вы используете, представляет собой простой цикл, изменяющий данные в шагах с четырьмя беззнаковыми словами, и является идеальным кандидатом для автовекторизации путем оптимизации компиляторов. Когда идентичный цикл C++ оптимизируется GCC 9.1 с параметрами -O3 -march=haswell, результирующий машинный код показывает все стандартные оптимизации, примененные к циклу:

#include <cstdint>
void hash(uint64_t* buffer, uint64_t length) {

    uint64_t* pBuffer = buffer;
    const uint64_t CONST1 = 0x6753ul;
    const uint64_t CONST2 = 0x7753ul;
    const uint64_t CONST3 = 0x8753ul;
    const uint64_t CONST4 = 0x9753ul;

    for(uint64_t i = 0; i < length; i += 4)
    {
        *pBuffer ^= CONST1;
        *(pBuffer + 1) ^= CONST2;
        *(pBuffer + 2) ^= CONST3;
        *(pBuffer + 3) ^= CONST4;
    }
}

результат компилятора Godbolt GCC 9.1

    test    rsi, rsi
    je      .L11
    cmp     rsi, -4
    ja      .L6
    lea     rdx, [rsi-1]
    vmovdqa ymm1, YMMWORD PTR .LC0[rip]
    xor     eax, eax
    shr     rdx, 2
    inc     rdx
.L5:
    vpxor   ymm0, ymm1, YMMWORD PTR [rdi]
    inc     rax
    add     rdi, 32
    vmovdqu YMMWORD PTR [rdi-32], ymm0
    cmp     rax, rdx
    jb      .L5
    vzeroupper
.L11:
    ret
.L6:
    vmovdqa ymm1, YMMWORD PTR .LC0[rip]
    xor     eax, eax
.L3:
    vpxor   ymm0, ymm1, YMMWORD PTR [rdi]
    add     rax, 4
    add     rdi, 32
    vmovdqu YMMWORD PTR [rdi-32], ymm0
    cmp     rsi, rax
    ja      .L3
    vzeroupper
    jmp     .L11
.LC0:
    .quad   26451
    .quad   30547
    .quad   34643
    .quad   38739

результат компилятора Godbolt Clang 8.0

 .LCPI0_0:
    .quad   26451                   # 0x6753
    .quad   30547                   # 0x7753
    .quad   34643                   # 0x8753
    .quad   38739                   # 0x9753
 hash(unsigned long*, unsigned long):                             # @hash(unsigned long*, unsigned long)
    test    rsi, rsi
    je      .LBB0_3
    xor     eax, eax
    vmovaps ymm0, ymmword ptr [rip + .LCPI0_0] # ymm0 = [26451,30547,34643,38739]
 .LBB0_2:                                # =>This Inner Loop Header: Depth=1
    vxorps  ymm1, ymm0, ymmword ptr [rdi + 8*rax]
    vmovups ymmword ptr [rdi + 8*rax], ymm1
    add     rax, 4
    cmp     rax, rsi
    jb      .LBB0_2
 .LBB0_3:
    vzeroupper
    ret
person Jacek Blaszczynski    schedule 08.05.2019
comment
К сожалению, .NET не выполняет автоматическую векторизацию :( Очень хочется узнать, что вы имеете в виду под параллелизмом на уровне инструкций ЦП! - person Cocowalla; 08.05.2019
comment
@Cocowalla: создайте ILP для использования ЦП, например, суммируя массив float с 8 переменными sum0, sum1, sum2, ..., чтобы было 8 цепочек зависимостей sum0 += arr[i]. (Но вместо скалярных переменных используйте векторы SIMD в качестве аккумуляторов, чтобы ваш код мог иметь 4 или 8 дополнений vector одновременно, чтобы скрыть задержку добавления FP). См. задержка и пропускная способность во встроенных функциях Intel и Почему mulss занимает всего 3 циклы на Haswell, отличные от таблиц инструкций Agner? - person Peter Cordes; 09.05.2019
comment
Также для ознакомления с тем, как процессоры используют ILP, см. этот ответ. например 4 скалярные операции xor могут выполняться параллельно, поэтому, если вам нужно делать какие-то не векторизуемые вещи с результатами XOR, вам, вероятно, лучше просто написать их скалярно. - person Peter Cordes; 09.05.2019
comment
@Cocowalla: извлечение из SIMD-векторов в целые числа требует таких же или больших затрат, как просто выполнение скалярных загрузок и скалярного исключающего ИЛИ. Хотя, возможно, если вы выполняете SIMD-загрузку, SIMD XOR, затем сохраняете результат в 32-байтовый массив и перезагружаете оттуда, вы можете выиграть в пропускной способности и нуждаться только в 1 векторной константе вместо 4 скалярных констант в целочисленных регистрах или операндах источника памяти. , если они не помещаются в 32-битные расширенные знаки. Не задержка, но это не слишком важно, если предположить, что OoO exec может поддерживать несколько итераций вашего цикла в полете (если он не слишком велик). - person Peter Cordes; 09.05.2019
comment
@ Peter-Cordes ответ, на который вы указали, действительно помог лучше понять, почему выигрыш SIMD может быть не таким, как ожидалось, спасибо! - person Cocowalla; 09.05.2019