AVX2 потребляет байты и производит uints?

Медленно изучаю SIMD, но все еще есть некоторые аспекты, которые я не могу уяснить, пытаясь найти SIMD-решение проблемы. Один из них — когда входной сигнал меньше выходного.

В качестве примера предположим, что у меня есть 8-битное изображение в оттенках серого. то есть каждый пиксель представляет собой байт в диапазоне 0-255. И теперь я хочу преобразовать это в предварительно умноженное альфа-изображение с указанным цветом. Таким образом, входные данные представляют собой 8-битный массив (8 бит на пиксель), а выходные данные — 32-битный массив (32 бита на пиксель RGBA_8888).

Таким образом, массивы не являются один к одному. Один байт в массиве оттенков серого будет преобразован в 4 байта в массиве цветов.

В скалярной форме это будет выглядеть так:

 public class Test
 {
     const int ImageSize = 2048;
     const int ImageLength = ImageSize * ImageSize;
     private byte[] _bytesGray = new byte[ImageLength];
     private uint[] _pixelsRGBA = new uint[ImageLength];

     private const byte _colorR = 0xFF;
     private const byte _colorG = 0x01;
     private const byte _colorB = 0x02;
     private const byte _colorA = 0xFF;

     [GlobalSetup]
     public void Setup()
     {
         for (int i = 0; i < ImageLength; i++)
         {
             _bytesGray[i] = (byte)(i + 1);
             _pixelsRGBA[i] = 0;
         }
     }

     [Benchmark]
     public unsafe void GrayscaleToColor_Scalar()
     {
         fixed (byte* bytePtr = _bytesGray)
         fixed (uint* pixelPtr = _pixelsRGBA)
         {
             for (int i = 0; i < ImageLength; ++i)
             {
                 byte value = bytePtr[i];
                 byte r = (byte)((value * _colorR) >> 8);
                 byte g = (byte)((value * _colorG) >> 8);
                 byte b = (byte)((value * _colorB) >> 8);
                 byte a = (byte)((value * _colorA) >> 8);

                 pixelPtr[i] = (uint)(r << 24 | g << 16 | b << 8 | a);
             }
         }
     }
}

Чтобы обработать это в форме SIMD, я думаю, что мне нужно обработать массив _bytesGray, чтобы я мог в полной мере воспользоваться преимуществами векторного регистра 256.

fixed (byte* valueBytes = _bytesGray)
{
    for (int i = 0; i < ImageLength; i += 32)
    {
        ...
    }
}

Но поскольку входной пиксель — это byte, а выходной — uint, я думаю, тогда мне понадобится 4x вектора. Где каждый вектор содержит 8 из 32 байтов. Но тогда каждый байт будет дублироваться 4 раза в каждом uint. В этот момент я мог бы выполнить умножение

fixed (byte* valueBytes = _bytesGray)
{
    for (int i = 0; i < ImageLength; i += 32)
    {
        Vector256<byte> bytes = Avx2.LoadVector256(valueBytes  + i);

        Vector256<uint> _0_8_grayBytes = ... // get 0-8 bytes and splat each byte to fill uint
        Vector256<uint> _8_16_grayBytes =  ... // get 8-16 bytes and splat each byte to fill uint
        Vector256<uint> _16_24_grayBytes =  ... // get 16-24 bytes and splat each byte to fill uint 
        Vector256<uint> _24_32_grayBytes =  ... // get 24-32 bytes and splat each byte to fill uint
    }
}

Но я, похоже, не могу понять, как выразить в инструкциях то, что я хочу, и даже является ли это правильным подходом.

Как бы вы это сделали?

Learn.microsoft.com/en-us/dotnet/api/…
Daniel A. White 14.07.2024 15:46

Вероятно, вы захотите изменить только один цвет. Поэтому оставьте r и g постоянными, а затем возьмите шкалу серого и используйте значение для b.

jdweng 14.07.2024 15:54

@DanielA.White, который просто интерпретирует данные по-новому, мне действительно нужно будет их переместить

creativergk 14.07.2024 15:58

Широковещательная загрузка qword (felixcloutier.com/x86/…) может передать vpshufb, используя управляющий вектор, например _mm256_setr_epi32(0x00000000, 0x01010101, 0x02020202, ...) . (Неважно, какую копию байта вы индексируете; каждая половина вектора содержит две копии каждого входного байта, поэтому один и тот же индекс получает один и тот же байт независимо от того, находится ли он в младшей или старшей 128-битной полосе.) данные доступны для дальнейшей обработки. Старший байт shuffle vec может быть равен 0xFF для очистки альфа-канала, или вы можете ИЛИ что-то еще в него.

Peter Cordes 14.07.2024 16:38

Или вы могли бы vpmovzxbd просто расширить каждый байт до нуля до u32, если это более удобно для того, что вы хотите сделать дальше. IIRC, C# имеет встроенную функцию, которая принимает указатель на источник, в отличие от встроенного API Intel C/C++, где есть только один с источником __m128i, и компилятор должен сложить 8-байтовую внутреннюю загрузку с нулевым расширением в источник памяти операнд для vpmovzx.

Peter Cordes 14.07.2024 16:41

@PeterCordes Я попробовал перемешать, и это работает для первой половины значения 256, но ко второй половине к индексу неявно добавлено 16, что означает, что вторая половина смещена intel.com/content/www/us/ en/docs/cpp-compiler/…

creativergk 14.07.2024 17:50

@creativergk: Вот почему вы сначала выполняете широковещательную загрузку, поэтому исходный элемент i+16 совпадает с элементом i в широковещательном результате, поэтому каждый байт результата может найти копию нужного байта в той же 128-битной полосе. (На самом деле, только i%8 имеет значение, если вы делаете 8-байтовый vpbroadcastq. Или вы можете сделать 16-байтовый VBROADCASTI128 и перетасовать его двумя разными способами, чтобы получить два 32-байтовых вектора из 16 исходных байтов. Кстати, предыдущая ссылка, которую я включил, была случайно для версии инструкции AVX-512 я имел в виду ссылку felixcloutier.com/x86/vpbroadcast для vpbroadcastq/vbroadcasti128.)

Peter Cordes 14.07.2024 19:35

Ааа, ок, значит, он будет обрабатывать 16 байт за раз, а не 32?

creativergk 14.07.2024 20:22

Кстати, вы можете попытаться выполнить умножение с 16-битными элементами, 32-битные — это просто излишество, а (v)pmullw (или vpmulhuw, если удобно) (на Intel) более эффективно, чем (v)pmulld, и вы получите вдвое больше умножений за инструкцию. Это может потребовать дополнительной перетасовки или чего-то еще, вероятно, это не просто бесплатное обновление.

user555045 14.07.2024 20:35

@creativergk: не сразу увидел твой ответ, так как ты @ меня не уведомил. Да, каждые 8 ​​байт ввода превращаются в 32 байта вывода при переходе от 8-битной шкалы серого к RGBA. Самый простой способ — загружать по 8 байт за раз (так, чтобы это было удобно для последующего перетасовывания, например, широковещательная загрузка), но широковещательная загрузка по 16 байт за раз для перетасовки двумя способами и создания двух векторов вывода, вероятно, является более простым способом. эффективный. На Intel (и, я думаю, на AMD) широковещательная загрузка обходится так же дешево, как и обычная загрузка (uops.info), поэтому сделайте предварительную перетасовку бесплатно, чтобы настроить ее по дешевке vpshufb.

Peter Cordes 15.07.2024 00:57

Поскольку вы хотите умножить каждый ввод, вам может потребоваться расширить байты до нуля в 16-битные элементы, возможно, выровняв их внутри элемента u32, в который он в конечном итоге войдет, но в векторе с элементами R и B и другом векторе с G и Элементы? Может быть, вы можете использовать vpmaddubsw, чтобы добавить результаты? Но нет, чтобы получить правильную конечную позицию, один множитель должен быть масштабирован на 256, поэтому он не поместится в 8-битный ввод. Итак, просто vpshufb с 0x00FF00FF, 0x01FF01FF и т. д., чтобы очистить нечетные элементы, реплицируя их на элементы 0 и 2 каждого u32, чтобы вы могли выполнить два 16-битных умножения внутри каждого элемента.

Peter Cordes 15.07.2024 01:05

@PeterCordes немного заблудился. Не могли бы вы показать пример? Кроме того, я не могу найти оболочку C# для LoadBroadcast, поэтому не уверен, что она поддерживается в .Net Learn.microsoft.com/en-us/dotnet/api/…

creativergk 15.07.2024 13:23
Стоит ли изучать 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
12
142
1
Перейти к ответу Данный вопрос помечен как решенный

Ответы 1

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

Я бы сделал это так.

Основная идея — загрузка 8 входных байтов за раз с использованием инструкции широковещательной загрузки. Затем выборочно переместите и обнулите байты, чтобы расширить эти 8 байтов до 16 коротких чисел, причем числа масштабируются на 0x100 и дублируются. Затем используйте умножение 16-битных целых чисел в двух векторах, наконец объедините их в 32-байтовый вектор и сохраните.

/// <summary>Create a pair of multipliers for _mm256_mulhi_epu16 instruction</summary>
static Vector256<ushort> makeMultipliers( byte low, byte high )
{
    // Compute multipliers with uint division
    uint a = low;
    uint b = high;
    a = ( a * 0x8000u + 254u ) / 255u;
    b = ( b * 0x8000u + 254u ) / 255u;
    // Create the pattern as uint scalar
    uint s = a | ( b << 16 );
    // Broadcast the uint, and bit cast the vector to short lanes
    return Vector256.Create( s ).AsUInt16();
}

// Permutation table which transform 4 bytes into 0A0A0B0B0C0C0D0D sequence of 16 bytes
static ReadOnlySpan<byte> permBytes => new byte[ 32 ]
{
    // First 16 byte slice uses bytes [ 0 .. 3 ]
    0xFF, 0, 0xFF, 0,  0xFF, 1, 0xFF, 1,  0xFF, 2, 0xFF, 2,  0xFF, 3, 0xFF, 3,
    // The second slice uses bytes [ 4 .. 7 ]
    0xFF, 4, 0xFF, 4,  0xFF, 5, 0xFF, 5,  0xFF, 6, 0xFF, 6,  0xFF, 7, 0xFF, 7,
};

[Benchmark]
public unsafe void GrayscaleToColor_Simd()
{
    Vector256<ushort> mul0 = makeMultipliers( _colorA, _colorG );
    Vector256<ushort> mul1 = makeMultipliers( _colorB, _colorR );

    Vector256<byte> perm;
    fixed( byte* ptr = permBytes )
        perm = Avx2.LoadVector256( ptr );
    Vector256<byte> blendMask = Vector256.Create( (ushort)0xFF00 ).AsByte();

    fixed( byte* bytePtr = _bytesGray )
    fixed( uint* pixelPtr = _pixelsRGBA )
    {
        byte* bytePtrEnd = bytePtr + ImageLength;
        byte* rsi = bytePtr;
        uint* rdi = pixelPtr;
        while( rsi < bytePtrEnd )
        {
            // Broadcast 8 bytes from memory
            Vector256<byte> grey32 = Avx2.BroadcastScalarToVector256( (ulong*)rsi ).AsByte();
            rsi += 8;
            // Duplicate bytes, scaling ushort numbers by the factor of 0x100
            Vector256<ushort> grey16 = Avx2.Shuffle( grey32, perm ).AsUInt16();

            // Multiply 16-bit numbers, keeping the higher 16 bits of the products
            Vector256<ushort> low = Avx2.MultiplyHigh( grey16, mul0 );
            Vector256<ushort> high = Avx2.MultiplyHigh( grey16, mul1 );
            // Shift bits into correct location,
            // which is low byte for even lanes, high byte for odd ones
            low = Avx2.ShiftRightLogical( low, 7 );
            high = Avx2.ShiftLeftLogical( high, 1 );

            // Combine into a single vector, and store
            Avx2.BlendVariable( low.AsByte(), high.AsByte(), blendMask )
                .AsUInt32().Store( rdi );
            rdi += 8;
        }
    }
}

Кстати, у вас в формуле есть интересный числовой вопрос. Когда вы это сделаете (byte)((value * _colorR) >> 8);, вы никогда не получите на выходе 255. Даже если оба входных байта равны 255, в результате вы получите только 254.

Вот почему я использовал там другую математику. Округление в моей версии далеко не идеальное, потому что оптимальное потребовало бы в два раза больше умножений в цикле: сначала для применения входного масштабирования, еще одного для деления на 255. Тем не менее, по крайней мере, входное масштабирование 0xFF, похоже, приводит к исходному результату. цвет.

Обновление: Идея алгоритма следующая. Полное выражение, которое нам нужно вычислить для этих чисел, — это grey * c / 255, которое можно рассматривать как grey * mul, где mul — дробное число ≤ 1,0. Вместо математических вычислений с плавающей запятой моя реализация вычисляет это выражение с помощью нескольких дешевых целочисленных математических инструкций.

Инструкция _mm256_mulhi_epu16 вычисляет следующее выражение для каждой пары чисел ushort: ( a * b ) >> 16

Обратите внимание, что инструкция не может масштабировать входные числа на 100%, потому что для этого потребуется, чтобы b было 0x10000, однако это число не помещается в ushort, для него требуется как минимум 17 бит. Следующий лучший вариант — масштабирование до 50 %, потому что для масштабирования 50 % потребуется b = 0x8000, который прекрасно вписывается в ushort дорожки, а затем использование битов [7 .. 14] выходных ushort полос.

Функция makeMultipliers вычисляет два из этих коэффициентов масштабирования и создает вектор из [ c0, c1 ] коротких чисел, реплицируемых по всему вектору. Мы вызываем его дважды, потому что нам нужно 4 из них, по одному на каждый канал выходного изображения.

В целом моя версия выполняет следующие математические операции с этими байтами:

static byte computeColor( byte grey, byte component )
{
    // Computed by makeMultipliers outside of the loop
    uint c32 = component;
    c32 = ( c32 * 0x8000u + 254u ) / 255u;
    ushort c16 = (ushort)c32;

    // Computed by moving bytes by 1 with Avx2.Shuffle, which inserts zero bytes
    ushort grey16 = (ushort)( (ushort)grey << 8 );

    // Computed by Avx2.MultiplyHigh
    ushort res = (ushort)( ( (uint)c16 * grey16 ) >> 16 );

    // Computed by bitwise shifts and byte extracts
    return (byte)( res >> 7 );
}

Вы загружаете vpmovzxbd, а затем тасуете vpshufb? Было бы более эффективно vpbroadcastq/vpshufb получить тот же grey16 результат (с некоторыми 0x80 байтами в маске перемешивания для создания нулей), потому что 4/8/16-байтовые широковещательные загрузки - это всего лишь один моп для порта загрузки, в отличие от vpmovzxbd, который представляет собой нагрузку + ALU и даже не имеет микропредохранителя на Intel, поэтому 2 микропроцессора для внешнего интерфейса. (Или загрузите vbroadcasti128 и перетасуйте два разных способа, чтобы получить два grey16 результата.)

Peter Cordes 15.07.2024 22:41

@PeterCordes Кажется, на моем компьютере производительность одинаковая. Тем не менее, обновил ответ, потому что кажется, что это не повредит производительности AMD и улучшит производительность Intel.

Soonts 16.07.2024 15:30

@Soonts, не могли бы вы объяснить код в makeMultipliers, пожалуйста? может в комментариях?

creativergk 16.07.2024 16:01

@creativergk Конечно, смотри обновление

Soonts 16.07.2024 17:46

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