AVX2 вычисление массива байтов

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

Я хотел выполнить простое умножение 8-битного изображения в оттенках серого, т.е. массив byte[].

Я реализовал как версию масштабатора, так и версию SIMD, и был удивлен, увидев результаты. Версия SIMD на самом деле немного медленнее.

Поэтому я подозревал, что это может быть проблема с тем, что байт не является родным для инструкций AVX, поскольку я считаю, что они работают намного быстрее с 32-битными значениями? float, int и т. д.? поэтому я также реализовал версию, используя int. Это действительно показывает лучшую производительность, но не намного.

Кроме того, учитывая, что изображения обычно хранятся в форме byte, например, 8-битная градация серого или 32-битная 8888 RGBA, не имеет смысла конвертировать изображение в ints, поскольку все, что вы получаете от ускорения, будет потеряно при преобразовании в/из byteопять. В моем конкретном случае вывод должен быть байтовым.

Итак, есть ли способ повысить производительность с помощью версии byte? И второй вопрос: как бы вы эффективно справились с проблемой переполнения байтов? то есть есть ли способ эффективно зажать его до 255 вместо того, чтобы переворачивать?

| Method       | Mean     | Error     | StdDev    | Median   |
|------------- |---------:|----------:|----------:|---------:|
| Scalar_Bytes | 3.835 ms | 0.0766 ms | 0.1565 ms | 3.830 ms |
| Vector_Bytes | 5.351 ms | 0.0970 ms | 0.1227 ms | 5.324 ms |
| Scalar_Ints  | 3.210 ms | 0.0641 ms | 0.0811 ms | 3.200 ms |
| Vector_Ints  | 1.298 ms | 0.0259 ms | 0.0706 ms | 1.277 ms |
public class Tests
{
    const int Count = 2048 * 2048;
    private byte[] _bytes = new byte[Count];
    private int[] _ints = new int[Count];

    [GlobalSetup]
    public void Setup()
    {
        _bytes = new byte[Count];

        for (int i = 0; i < Count; i++)
        {
            _bytes[i] = (byte)i;
        }

        _ints = new int[Count];

        for (int i = 0; i < Count; i++)
        {
            _ints[i] = i;
        }
    }

    [Benchmark]
    public void Scalar_Bytes()
    {
        for (int i = 0; i < Count; i++)
        {
            _bytes[i] = (byte)((_bytes[i] * 2) + 26);
        }
    }

    [Benchmark]
    public unsafe void Vector_Bytes()
    {
        int offset = Vector256<byte>.Count;
        fixed (byte* ptr = _bytes)
        {
            var add = Vector256.Create<byte>(26);
            for (int i = 0; i < Count; i += offset)
            {
                var v = Vector256.Load<byte>(ptr + i);
                v *= 2;
                v += add;

                Vector256.Store(v, ptr + i);
            }
        }
    }

    [Benchmark]
    public void Scalar_Ints()
    {
        for (int i = 0; i < Count; i++)
        {
            _ints[i] = ((_ints[i] * 2) + 26);
        }
    }

    [Benchmark]
    public unsafe void Vector_Ints()
    {
        int offset = Vector256<int>.Count;
        fixed (int* ptr = _ints)
        {
            var add = Vector256.Create<int>(26);
            for (int i = 0; i < Count; i += offset)
            {
                var v = Vector256.Load<int>(ptr + i);
                v *= 2;
                v += add;

                Vector256.Store(v, ptr + i);
            }
        }
    }
}

internal class Program
{
    static void Main(string[] args)
    {
        BenchmarkRunner.Run<Tests>();
    }
}
Стоит ли изучать PHP в 2023-2024 годах?
Стоит ли изучать PHP в 2023-2024 годах?
Привет всем, сегодня я хочу высказать свои соображения по поводу вопроса, который я уже много раз получал в своем сообществе: "Стоит ли изучать PHP в...
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
Поведение ключевого слова "this" в стрелочной функции в сравнении с нормальной функцией
В JavaScript одним из самых запутанных понятий является поведение ключевого слова "this" в стрелочной и обычной функциях.
Приемы CSS-макетирования - floats и Flexbox
Приемы CSS-макетирования - floats и Flexbox
Здравствуйте, друзья-студенты! Готовы совершенствовать свои навыки веб-дизайна? Сегодня в нашем путешествии мы рассмотрим приемы CSS-верстки - в...
Тестирование функциональных ngrx-эффектов в Angular 16 с помощью Jest
В системе управления состояниями ngrx, совместимой с Angular 16, появились функциональные эффекты. Это здорово и делает код определенно легче для...
Концепция локализации и ее применение в приложениях React ⚡️
Концепция локализации и ее применение в приложениях React ⚡️
Локализация - это процесс адаптации приложения к различным языкам и культурным требованиям. Это позволяет пользователям получить опыт, соответствующий...
Пользовательский скаляр GraphQL
Пользовательский скаляр GraphQL
Листовые узлы системы типов GraphQL называются скалярами. Достигнув скалярного типа, невозможно спуститься дальше по иерархии типов. Скалярный тип...
0
0
99
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

Ответ принят как подходящий

есть ли способ повысить производительность с помощью байтовой версии?

Да, действительно, используя правильные инструкции AVX2 вместо определенных им операторов struct Vector256<byte>

Попробуйте следующую версию:

/// <summary>AVX2 optimized version of <see cref = "Vector_Bytes" /></summary>
[Benchmark]
public unsafe void vectorBytesOpt()
{
    Vector256<byte> add = Vector256.Create( (byte)26 );
    fixed( byte* ptr = _bytes )
    {
        byte* rsiEnd = ptr + Count;
        for( byte* rsi = ptr; rsi < rsiEnd; rsi += 32 )
        {
            // Load 32 bytes
            Vector256<byte> v = Vector256.Load( rsi );
            // Multiplication by 2 is equal to adding with itself
            v = Avx2.Add( v, v );
            // Add that extra number
            v = Avx2.Add( v, add );
            // Store 32 bytes
            v.Store( rsi );
        }
    }
}

как бы вы эффективно справились с проблемой переполнения байтов?

К счастью для вас, в наборе AVX2 есть еще одна инструкция, которая добавляет байты с помощью насыщения. Вот еще одна версия, которая ограничивает ваши байты до 255 вместо переноса.

/// <summary>Another version which uses saturation</summary>
[Benchmark]
public unsafe void vectorBytesOptSat()
{
    Vector256<byte> add = Vector256.Create( (byte)26 );
    fixed( byte* ptr = _bytes )
    {
        byte* rsiEnd = ptr + Count;
        for( byte* rsi = ptr; rsi < rsiEnd; rsi += 32 )
        {
            Vector256<byte> v = Vector256.Load( rsi );
            v = Avx2.AddSaturate( v, v );
            v = Avx2.AddSaturate( v, add );
            v.Store( rsi );
        }
    }
}

Вот результаты вашего теста на моем компьютере с процессором Ryzen 7 8700G при использовании среды выполнения .NET 8.0.

| Method            | Mean        | Error     | StdDev    |
|------------------ |------------:|----------:|----------:|
| Scalar_Bytes      | 1,722.81 us | 32.646 us | 30.537 us |
| Vector_Bytes      | 3,570.63 us | 26.369 us | 24.666 us |
| vectorBytesOpt    |    43.72 us |  0.434 us |  0.406 us |
| vectorBytesOptSat |    44.18 us |  0.238 us |  0.223 us |
| Scalar_Ints       | 1,728.04 us | 16.708 us | 15.629 us |
| Vector_Ints       |   256.91 us |  7.591 us | 22.023 us |

Обновление: правильное умножение возможно, но требует гораздо больше вычислительных затрат. Вот еще одна версия, которая делает это: на моем компьютере она занимает около 63,5 мкс.

[Benchmark]
public unsafe void vectorBytesMulSat()
{
    // This number needs to be < 128
    sbyte multiplier = 2;

    // Create a pair of constant vectors for _mm256_maddubs_epi16
    ushort mulLowScalar = (ushort)( multiplier );
    ushort mulHighScalar = (ushort)( mulLowScalar << 8 );
    Vector256<sbyte> mulLow = Vector256.Create( mulLowScalar ).As<ushort, sbyte>();
    Vector256<sbyte> mulHigh = Vector256.Create( mulHighScalar ).As<ushort, sbyte>();
    // Another constant to implement saturation of these products
    Vector256<ushort> maxProduct = Vector256.Create( (ushort)0xFF );
    // Final addition
    Vector256<byte> add = Vector256.Create( (byte)26 );

    fixed( byte* ptr = _bytes )
    {
        byte* rsiEnd = ptr + Count;
        for( byte* rsi = ptr; rsi < rsiEnd; rsi += 32 )
        {
            // Load 32 bytes
            Vector256<byte> v = Vector256.Load( rsi );
            // Multiply byte * sbyte, separately for even / odd bytes
            Vector256<ushort> low = Avx2.MultiplyAddAdjacent( v, mulLow ).As<short, ushort>();
            Vector256<ushort> high = Avx2.MultiplyAddAdjacent( v, mulHigh ).As<short, ushort>();
            // Saturate these products
            low = Avx2.Min( low, maxProduct );
            high = Avx2.Min( high, maxProduct );
            // Combine back into bytes
            high = Avx2.ShiftLeftLogical( high, 8 );
            v = Avx2.Or( low, high ).As<ushort, byte>();
            // Add the final constant using saturation, and store
            v = Avx2.AddSaturate( v, add );
            v.Store( rsi );
        }
    }
}

Обновление 2: вот еще одна версия, которая также выполняет правильное умножение и немного быстрее на моем компьютере, около 58,9 мкс.

// Permutation table to fix order of bytes after _mm256_packus_epi16( even, odd )
static ReadOnlySpan<byte> permuteBytes => new byte[ 16 ]
{
    0, 8, 1, 9, 2, 10, 3, 11, 4, 12, 5, 13, 6, 14, 7, 15
};

[Benchmark]
public unsafe void vectorBytesMulSat()
{
    // This number needs to be < 128
    sbyte multiplier = 2;

    // Create a pair of constant vectors for _mm256_maddubs_epi16
    ushort mulLowScalar = (ushort)( multiplier );
    ushort mulHighScalar = (ushort)( mulLowScalar << 8 );
    Vector256<sbyte> mulLow = Vector256.Create( mulLowScalar ).As<ushort, sbyte>();
    Vector256<sbyte> mulHigh = Vector256.Create( mulHighScalar ).As<ushort, sbyte>();
    // Create a vector to permute bytes after saturation
    Vector256<byte> perm;
    fixed( byte* ptr = permuteBytes )
        perm = Avx2.BroadcastVector128ToVector256( ptr );
    // Final addition
    Vector256<byte> add = Vector256.Create( (byte)26 );

    fixed( byte* ptr = _bytes )
    {
        byte* rsiEnd = ptr + Count;
        for( byte* rsi = ptr; rsi < rsiEnd; rsi += 32 )
        {
            // Load 32 bytes
            Vector256<byte> v = Vector256.Load( rsi );
            // Multiply byte * sbyte, separately for even / odd bytes
            Vector256<short> low = Avx2.MultiplyAddAdjacent( v, mulLow );
            Vector256<short> high = Avx2.MultiplyAddAdjacent( v, mulHigh );
            // Pack and saturate these products
            v = Avx2.PackUnsignedSaturate( low, high );
            // Fix order of bytes after the packing
            v = Avx2.Shuffle( v, perm );
            // Add the final constant using saturation, and store
            v = Avx2.AddSaturate( v, add );
            v.Store( rsi );
        }
    }
}

Блин, я только что пришел к такому же выводу. Я не уверен, почему явное использование Avx2.AddSaturate происходит намного быстрее, поскольку операции Vector256 должны быть встроенными. Также обязательно установите флажок Axv2.IsSupported и укажите запасной путь для машин, на которых нет AVX2.

canton7 12.07.2024 11:58

Обратите внимание, что вы можете использовать var v = Vector256.Create<byte>(_bytes.AsSpan(i, offset));, чтобы избежать небезопасного кода.

canton7 12.07.2024 11:59

Да, подтверждено. Хорошую иллюстрацию смотрите здесь

canton7 12.07.2024 11:59

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

creativergk 12.07.2024 12:00

Я подозреваю, что вам может понадобиться распаковать с помощью _mm_cvtepu8_epi16 , а затем умножить на _mm_mullo_epi16 и _mm_packus_epi16, чтобы преобразовать обратно в байты. Хотя я не особо разбираюсь в SIMD...

canton7 12.07.2024 12:21

@creativergk Возможно, но дороже, смотрите обновление

Soonts 12.07.2024 12:29

@Soonts Я думаю, PackUnsignedSaturate подойдет _mm_packus_epi16, который упаковывает u16 обратно в u8 и одновременно насыщает?

canton7 12.07.2024 12:36

Есть ли у вас какие-либо рекомендации, где этому научиться? в идеале по отношению к C#?

creativergk 12.07.2024 12:40

Документы C# для этого совершенно бесполезны. Изучите его, используя встроенные функции C (это каноническая ссылка) и найдите на github.com/dotnet/runtime комментарий, содержащий соответствующее внутреннее имя, например. здесь

canton7 12.07.2024 12:43

@canton7 Код перед этим разделяет дорожки на четные/нечетные байты, однако беззнаковое насыщение не чередуется, поэтому вы получите порядок выходных байтов, например ACEGBDFH вместо ABCDEFGH. А с векторами AVX (в отличие от векторов SSE) порядок вывода насыщения пакета становится еще более странным, поскольку он выполняется независимо для 16-байтовых фрагментов векторов.

Soonts 12.07.2024 12:43

@canton7 Тем не менее, это хорошая идея. Уменьшите 4 инструкции min/min/shift/или всего до 2 vpackuswb/vbshufb, что на самом деле может стать немного быстрее.

Soonts 12.07.2024 12:47

@creativergk Эти инструкции SIMD не зависят от языка IMO. Такие языки, как C, C++, C# и в меньшей степени Rust, не пытаются абстрагировать реальные аппаратные инструкции, что означает, что вам нужно изучать встроенные функции SIMD только один раз для каждого набора инструкций. В моей статье вы найдете введение о SSE/AVX ISA, но с упором на C++ const.me/articles/simd/simd.pdf

Soonts 12.07.2024 13:23

Почему бы не использовать Vector256.LoadUnsafe с ref, а не напрямую с небезопасными указателями?

Charlieface 12.07.2024 13:54

@Charlieface Или версию, которая требует Span, см. мой предыдущий комментарий

canton7 12.07.2024 14:00

В ходе тестирования я обнаружил, что fix работает немного быстрее, чем Span.

creativergk 12.07.2024 17:23

Другие вопросы по теме