Я новичок в 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>();
}
}
есть ли способ повысить производительность с помощью байтовой версии?
Да, действительно, используя правильные инструкции 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 );
}
}
}
Обратите внимание, что вы можете использовать var v = Vector256.Create<byte>(_bytes.AsSpan(i, offset));
, чтобы избежать небезопасного кода.
Да, подтверждено. Хорошую иллюстрацию смотрите здесь
вау, огромное увеличение скорости, как бы это выглядело, если бы вы действительно хотели использовать умножение? например, если бы умножение было параметром, который мог бы быть любым байтовым значением? ... Я пробовал это проверить, но, похоже, хотелось использовать хотя бы короткое?
Я подозреваю, что вам может понадобиться распаковать с помощью _mm_cvtepu8_epi16
, а затем умножить на _mm_mullo_epi16
и _mm_packus_epi16
, чтобы преобразовать обратно в байты. Хотя я не особо разбираюсь в SIMD...
@creativergk Возможно, но дороже, смотрите обновление
@Soonts Я думаю, PackUnsignedSaturate
подойдет _mm_packus_epi16
, который упаковывает u16 обратно в u8 и одновременно насыщает?
Есть ли у вас какие-либо рекомендации, где этому научиться? в идеале по отношению к C#?
Документы C# для этого совершенно бесполезны. Изучите его, используя встроенные функции C (это каноническая ссылка) и найдите на github.com/dotnet/runtime комментарий, содержащий соответствующее внутреннее имя, например. здесь
@canton7 Код перед этим разделяет дорожки на четные/нечетные байты, однако беззнаковое насыщение не чередуется, поэтому вы получите порядок выходных байтов, например ACEGBDFH вместо ABCDEFGH. А с векторами AVX (в отличие от векторов SSE) порядок вывода насыщения пакета становится еще более странным, поскольку он выполняется независимо для 16-байтовых фрагментов векторов.
@canton7 Тем не менее, это хорошая идея. Уменьшите 4 инструкции min/min/shift/или всего до 2 vpackuswb/vbshufb, что на самом деле может стать немного быстрее.
@creativergk Эти инструкции SIMD не зависят от языка IMO. Такие языки, как C, C++, C# и в меньшей степени Rust, не пытаются абстрагировать реальные аппаратные инструкции, что означает, что вам нужно изучать встроенные функции SIMD только один раз для каждого набора инструкций. В моей статье вы найдете введение о SSE/AVX ISA, но с упором на C++ const.me/articles/simd/simd.pdf
Почему бы не использовать Vector256.LoadUnsafe
с ref
, а не напрямую с небезопасными указателями?
@Charlieface Или версию, которая требует Span
, см. мой предыдущий комментарий
В ходе тестирования я обнаружил, что fix работает немного быстрее, чем Span.
Блин, я только что пришел к такому же выводу. Я не уверен, почему явное использование
Avx2.AddSaturate
происходит намного быстрее, поскольку операцииVector256
должны быть встроенными. Также обязательно установите флажокAxv2.IsSupported
и укажите запасной путь для машин, на которых нет AVX2.