Медленно изучаю 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
}
}
Но я, похоже, не могу понять, как выразить в инструкциях то, что я хочу, и даже является ли это правильным подходом.
Как бы вы это сделали?
Вероятно, вы захотите изменить только один цвет. Поэтому оставьте r и g постоянными, а затем возьмите шкалу серого и используйте значение для b.
@DanielA.White, который просто интерпретирует данные по-новому, мне действительно нужно будет их переместить
Широковещательная загрузка qword (felixcloutier.com/x86/…) может передать vpshufb
, используя управляющий вектор, например _mm256_setr_epi32(0x00000000, 0x01010101, 0x02020202, ...)
. (Неважно, какую копию байта вы индексируете; каждая половина вектора содержит две копии каждого входного байта, поэтому один и тот же индекс получает один и тот же байт независимо от того, находится ли он в младшей или старшей 128-битной полосе.) данные доступны для дальнейшей обработки. Старший байт shuffle vec может быть равен 0xFF для очистки альфа-канала, или вы можете ИЛИ что-то еще в него.
Или вы могли бы vpmovzxbd
просто расширить каждый байт до нуля до u32, если это более удобно для того, что вы хотите сделать дальше. IIRC, C# имеет встроенную функцию, которая принимает указатель на источник, в отличие от встроенного API Intel C/C++, где есть только один с источником __m128i
, и компилятор должен сложить 8-байтовую внутреннюю загрузку с нулевым расширением в источник памяти операнд для vpmovzx
.
@PeterCordes Я попробовал перемешать, и это работает для первой половины значения 256, но ко второй половине к индексу неявно добавлено 16, что означает, что вторая половина смещена intel.com/content/www/us/ en/docs/cpp-compiler/…
@creativergk: Вот почему вы сначала выполняете широковещательную загрузку, поэтому исходный элемент i+16
совпадает с элементом i
в широковещательном результате, поэтому каждый байт результата может найти копию нужного байта в той же 128-битной полосе. (На самом деле, только i%8
имеет значение, если вы делаете 8-байтовый vpbroadcastq
. Или вы можете сделать 16-байтовый VBROADCASTI128
и перетасовать его двумя разными способами, чтобы получить два 32-байтовых вектора из 16 исходных байтов. Кстати, предыдущая ссылка, которую я включил, была случайно для версии инструкции AVX-512 я имел в виду ссылку felixcloutier.com/x86/vpbroadcast для vpbroadcastq/vbroadcasti128.)
Ааа, ок, значит, он будет обрабатывать 16 байт за раз, а не 32?
Кстати, вы можете попытаться выполнить умножение с 16-битными элементами, 32-битные — это просто излишество, а (v)pmullw
(или vpmulhuw
, если удобно) (на Intel) более эффективно, чем (v)pmulld
, и вы получите вдвое больше умножений за инструкцию. Это может потребовать дополнительной перетасовки или чего-то еще, вероятно, это не просто бесплатное обновление.
@creativergk: не сразу увидел твой ответ, так как ты @ меня не уведомил. Да, каждые 8 байт ввода превращаются в 32 байта вывода при переходе от 8-битной шкалы серого к RGBA. Самый простой способ — загружать по 8 байт за раз (так, чтобы это было удобно для последующего перетасовывания, например, широковещательная загрузка), но широковещательная загрузка по 16 байт за раз для перетасовки двумя способами и создания двух векторов вывода, вероятно, является более простым способом. эффективный. На Intel (и, я думаю, на AMD) широковещательная загрузка обходится так же дешево, как и обычная загрузка (uops.info), поэтому сделайте предварительную перетасовку бесплатно, чтобы настроить ее по дешевке vpshufb
.
Поскольку вы хотите умножить каждый ввод, вам может потребоваться расширить байты до нуля в 16-битные элементы, возможно, выровняв их внутри элемента u32, в который он в конечном итоге войдет, но в векторе с элементами R и B и другом векторе с G и Элементы? Может быть, вы можете использовать vpmaddubsw
, чтобы добавить результаты? Но нет, чтобы получить правильную конечную позицию, один множитель должен быть масштабирован на 256, поэтому он не поместится в 8-битный ввод. Итак, просто vpshufb
с 0x00FF00FF, 0x01FF01FF
и т. д., чтобы очистить нечетные элементы, реплицируя их на элементы 0 и 2 каждого u32, чтобы вы могли выполнить два 16-битных умножения внутри каждого элемента.
@PeterCordes немного заблудился. Не могли бы вы показать пример? Кроме того, я не могу найти оболочку C# для LoadBroadcast, поэтому не уверен, что она поддерживается в .Net Learn.microsoft.com/en-us/dotnet/api/…
Я бы сделал это так.
Основная идея — загрузка 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
результата.)
@PeterCordes Кажется, на моем компьютере производительность одинаковая. Тем не менее, обновил ответ, потому что кажется, что это не повредит производительности AMD и улучшит производительность Intel.
@Soonts, не могли бы вы объяснить код в makeMultipliers
, пожалуйста? может в комментариях?
@creativergk Конечно, смотри обновление