万有斥力 发表于 2024-12-11 22:52:53

[C#] 24位图像水平翻转的跨平台SIMD硬件加速向量算法的关键——YShuffleX3K

在上一篇文章里,给大家讲解了24位图像水平翻转(FlipX)算法,其中用到了一个关键方法——YShuffleX3Kernel。一些读者对它背后的原理感兴趣——为什么它在跨平台时运行也能获得SIMD硬件加速, 各种向量指令集的情况下具体怎样实现的?于是本文便具体解答一下。
一、为什么它在跨平台时运行也能获得SIMD硬件加速

1.1 历史

最初,只有汇编语言能使用SIMD(Single Instruction Multiple Data,单指令多数据流)指令,故只能用它来编写向量化算法。汇编语言的编程难度是很大的,且不同的CPU架构得专门去编码,可移植性为零。
后来,C/C++ 等编程语言增加了内在函数(Intrinsics Functions)机制,能通过内在函数调用SIMD硬件加速指令,使编程门槛大为降低。此时遇到了可移植性的瓶颈——固然用 C/C++ 能开发跨平台程序,但一旦使用了SIMD指令,因它与本机指令集密切相干,就难以跨平台了。
像 simde、vectorclass、xsimd 等向量算法库,为 C/C++进步了向量算法的可移植性。使用它们,可实现源代码级别的可移植性——同一份源代码,拿到不同的平台上时,只要调整好编译参数,便能编译出该平台的能执行的向量算法。
但上述办法,还是需要去每个平台编译一遍,使用繁琐。
最理想的景象是——程序只需编译一遍,随后在各个平台上不但能正常运行,且可以或许自动使用最佳的SIMD指令。
C/C++里有一个办法来尽可能逼近这个理想——在程序运行时检测本机支持哪些指令集,然后切换为对应指令的算法。但该方法工作量大、编码难度大,且分支过多也会影响程序性能。而且该办法有一个致命弱点,它顶多只能实现同一种CPU架构时的自动使用最佳SIMD指令,CPU架构不同时还是得重新编译。
.NET Core 3.0 增加了对内在函数的支持,给这个理想带来了一线曙光。由于 .NET 程序不是一次性编译的,而是先编译为IL(中间语言)代码,随后程序在目标平台上运行时,才会被JIT(即时编译器)编译为本地代码并执行。关键点在于,是JIT支持内在函数,于是在不同平台运行时,JIT可使用该平台的SIMD指令集。
只是 .NET 更关注底层能力,有自己的发展目标,对于向量算法的关注还不够多。目前Vector等向量类型所提供的方法,方法数量还很少,缺少很多向量算法所需的方法。且部分方法长期使用标量回退算法,导致它们没有SIMD硬件加速(如 Vector128.Shuffle)。
为相识决这些缺点,我开发了VectorTraits库,为向量类型补充了不少方法,且这些方法在多个平台都是有SIMD硬件加速的。从而实现了这个理想——程序只需编译一遍,随后在各个平台上不但能正常运行,且可以或许自动使用最佳的SIMD指令。
1.2 .NET 中如何使用各种指令集的内在函数

对于固定大小的向量,它位于这个命名空间。

[*]System.Runtime.Intrinsics:用于提供各种位宽的向量类型,如 只读结构体 Vector64、Vector128、Vector256、Vector512,及辅助的静态类 Vector64、Vector128、Vector256、Vector512。官方文档阐明:包含用于创建和通报各种大小和格式的寄存器状态的类型,用于指令集扩展。有关操作这些寄存器的阐明,请参阅 System.Runtime.Intrinsics.X86 和 System.Runtime.Intrinsics.Arm。
对于各种架构的指令集,位于下面这些命名空间。

[*]System.Runtime.Intrinsics.X86:用于提供x86架构各种指令集的类,如Avx等。官方文档阐明:公开 x86 和 x64 系统的 select 指令集扩展。 对于每个扩展,这些指令集表现为单独的类。 可以通过查询相应类型上的 IsSupported 属性来确定是否支持当前情况中的任何扩展。
[*]System.Runtime.Intrinsics.Arm:用于提供Arm架构各种指令集的类,如AdvSimd 等。官方文档阐明:公开 ARM 系统的 select 指令集扩展。 对于每个扩展,这些指令集表现为单独的类。 可以通过查询相应类型上的 IsSupported 属性来确定是否支持当前情况中的任何扩展。
[*]System.Runtime.Intrinsics.Wasm:用于提供Wasm架构各种指令集的类,如PackedSimd 等。官方文档阐明:公开 Wasm 系统的 select 指令集扩展。 对于每个扩展,这些指令集表现为单独的类。 可以通过查询相应类型上的 IsSupported 属性来确定是否支持当前情况中的任何扩展。
简单来说,“System.Runtime.Intrinsics”用于定义通用的向量类型,随后它的各种子命名空间,以CPU架构来命名。子命名空间里,包含各个内在函数类,每个类对应一套指令集。类中的各个静态方法就是内在函数,对应指令集内的各条指令。
对于每一个内在函数类,都提供静态属性 IsSupported,用于检查当前运行情况是否支持该指令集。例如“Avx.IsSupported”,是用于检测是否支持Avx指令集。
观察子命名空间里的内在函数类,发现有些类的后缀是“64”(如Avx.X64,及Arm里的AdvSimd.Arm64),这些是64位模式下特有的指令集,它们的指令一样平常比力少。平时应尽量使用后缀不是“64”的类,由于这些它们是 32位或64位 情况都能工作的类。
1.3 判定指令集

上面提到了每一个内在函数类,都提供静态属性 IsSupported,用于检查当前运行情况是否支持该指令集。
于是可以用if语句写分支代码,先检测该指令集的 IsSupported 属性是否为 true,随后在分支内使用该指令集。例如。
if (Avx.IsSupported) {
c = Avx.Add(a, b);
...
}等一等,以前很多资料上说了分支语句会影响性能吗?它造成CPU流水线失效啊。
实在呢,固然外貌上这里仍是用 if关键字,但它与常规的分支语句不同。由于是JIT负责将程序编译为目标平台的本地代码并执行,此时本机支持的指令集是已经确定的,于是对应类的IsSupported属性实在是运行时的常量。于是JIT在编译这个if语句时,仅会对有效的分支进行编译,而其他分支会被忽略。也就说,在JIT生成的本地代码里,并没有“分支跳转指令”,只存在有效分支内的代码。
这种工作机制,类似 C++ 2017 尺度里增加的 “constexpr if”机制。用于在编译时根据常量表达式的值,选择执行对应的代码分支。它允许在编译时进行条件编译,从而进步代码的灵活性和性能。
1.4 使用内联来避免函数调用开销

若方法内的代码比力短小时,此时函数调用开销会非常突出。函数调用在执行时,首先要在栈中为形参和局部变量分配存储空间,然后还要将实参的值复制给形参,接下来还要将函数的返回地址(该地址指明了函数执行竣事后,程序应该回到哪里继续执行)放入栈中,最后才跳转到函数内部执行。这个过程是要耗费时间的。别的,函数执行 return 语句返回时,需要从栈中回收形参和局部变量占用的存储空间,然后从栈中取出返回地址,再跳转到该地址继续执行,这个过程也要耗费时间。
对于向量类型,函数调用开销会更加严重。不但是由于向量类型的字节数比力多,而且还要做清空向量寄存器等操作。
于是对于短小的方法,应标志为“内联”的。如许JIT会将该方法内的代码,尽量与调用者的代码内联在一起进行编译。从而避免了函数调用开销。
具体办法是给方法增加MethodImpl特性,标志 AggressiveInlining。
1.6 使用自动大小向量Vector

在 X86架构上,通过Sse系列指令集可以使用128位向量,通过Avx系列指令集可以使用256位向量等。这些向量长度,对应了 .NET 中的 Vector128、 Vector256类型。
在 Arm架构上,通过AdvSimd(NEON)系列指令集可以使用128位向量。
为了可以或许使向量算法的代码可以或许跨平台,一种办法是使用 各架构的最小集,即Vector128。但这个办法存在以下缺点:

[*]没能发挥CPU的全部潜力。X86处置惩罚器现在已经普及Avx2指令了,可以或许完善的处置惩罚256位向量。若使用Vector128,就强制降级了。
[*].NET 版本要求高。.NET Core 3.0才提供 Vector128类型。而很多应用程序还需使用 .NET Framework。
[*].NET 7.0之前的固定大小向量(Vector128等)还不完善。例如自动大小向量(Vector)早就支持的函数,固定大小向量(Vector128等)很晚才支持 。
于是,更好的办法是使用 自动大小向量 Vector。

[*]Vector 类型的大小不是固定的。一样平常来说,它是本机CPU的最大向量大小。例如是X86架构且具有Avx2指令集时,它是256位;否则它为128位。鉴于Avx512尚未普及,这个位宽是合适的。
[*].NET 版本需求低。自 .NET Core 1.0 起,便原生支持该类型。使用 nuget 安装了 System.Numerics.Vectors 包后,从 .NET Framework 4.5开始便能使用自动大小向量 Vector。
固定大小向量(Vector128等)都提供了一个扩展方法 AsVector,可以将它们重新解释为 自动大小向量 (Vector)。同样的,自动大小向量具有AsVector128、AsVector256 扩展方法 ,可以重新解释为固定大小向量。
例如VectorTraits的源代码中,YShuffleX3Kernel是如许将Vector128重新解释为Vector的。

public static Vector<byte> YShuffleX3Kernel(Vector<byte> vector0, Vector<byte> vector1, Vector<byte> vector2, Vector<byte> indices) {
    return WStatics.YShuffleX3Kernel(vector0.AsVector128(), vector1.AsVector128(), vector2.AsVector128(), indices.AsVector128()).AsVector();
}注意,它是重新解释,而不是类型转换,所以没有类型转换的开销。但是需要注意,只有向量位长雷同时,才能安全的进行转换。具体来说,通过观察程序运行时汇编代码,会发现无论是原来的Vector128,还是重新解释后的Vector,仍是使用同一个向量寄存器,没有任何其他操作。AsVector等扩展方法,只是为了能通过 C# 的语法检查。
于是我们一样平常是如许使用的:

[*]首先使用固定大小向量(Vector128等),通过 Sse、Avx、AdvSimd等指令集,编写好算法实现。
[*]随后为了方便外层代码的调用,将固定大小向量重新解释为自动大小向量 (Vector)。
[*]最后对于算法的公共方法,会检测向量位长、指令集支持性等信息,选择最适合的“算法实现”进行调用。
1.5 小结

VectorTraits 就是使用了上述办法,从而实现了跨平台时运行也能获得SIMD硬件加速。
Vectors等类所提供的方法,就是算法公共方法。这些公共方法,分别有着Sse、Avx、AdvSimd等指令集编写的算法实现。且 Vector128s、Vector256s也提供了同样的向量方法,用于需使用固定大小向量的场合。
跨平台库的使用起来很简单方便,可要将它开发出来,就没那么轻松了。需要为不同处置惩罚器架构的各种指令集,分别编写算法实现。工作繁重,是一个体力活。
接下来的章节,会具体讲解Byte类型的YShuffleX3Kernel方法,在各个平台的各种指令集上是如何实现的。
实在 YShuffleX3Kernel 方法不但支持 Byte 类型,它的重载方法还支持 Int16、Int32、Int64 等类型。这就使不同位宽的数据,也能按照同样的办法去处置惩罚。为了避免文章篇幅过长,于是文本仅讲解了 Byte 类型。有兴趣的读者可以参考本文,查看源代码里的其他数据类型是怎么处置惩罚。
二、X86架构

X86架构提供了 shuffle(换位) 指令,可以用它来实现向量内的换位。
接下来先介绍用Sse等指令集操作128位向量,随后介绍用Avx2指令集操作256位向量。最后介绍如何使用Avx512系列指令集做优化。
2.1 用Sse等指令集操作128位向量

2.1.1 实现单向量换位(YShuffleKernel)

Ssse3指令集,提供了Byte的shuffle指令。它对应了 Ssse3.Shuffle 方法。该方法的定义如下。
// https://learn.microsoft.com/zh-cn/dotnet/api/system.runtime.intrinsics.x86.ssse3.shuffle?view=net-8.0
// __m128i _mm_shuffle_epi8 (__m128i a, __m128i b)
// PSHUFB xmm, xmm/m128
public static Vector128<byte> Shuffle (Vector128<byte> value, Vector128<byte> mask);使用该方法,便能实现单向量换位的方法 YShuffleKernel。源码在 WVectorTraits128Sse.YS.cs。

public static Vector128<byte> YShuffleKernel(Vector128<byte> vector, Vector128<byte> indices) {
    if (Ssse3.IsSupported) {
      return Ssse3.Shuffle(vector, indices);
    } else {
      return SuperStatics.YShuffleKernel(vector, indices);
    }
}当 Ssse3.IsSupported 为true时,使用 Ssse3.Shuffle;当不支持该指令集时,便回退为SuperStatics的标量算法。
2.1.2 实现2向量换位(YShuffleX2Kernel)

组合使用2个单向量的Shuffle方法,就能实现2向量换位的方法 YShuffleX2Kernel。源代码如下。

public static Vector128<byte> YShuffleX2Kernel_Combine(Vector128<byte> vector0, Vector128<byte> vector1, Vector128<byte> indices) {
    if (!Ssse3.IsSupported) VectorMessageFormats.ThrowNewUnsupported("Ssse3");
    Vector128<byte> vCount = Vector128.Create((byte)Vector128<byte>.Count);
    Vector128<byte> indices1 = Sse2.Subtract(indices, vCount);
    Vector128<byte> rt0 = Ssse3.Shuffle(vector0, indices);
    Vector128<byte> mask = Sse2.CompareGreaterThan(vCount.AsSByte(), indices.AsSByte()).AsByte(); // vCount>indices ==> indices<vCount.
    Vector128<byte> rt1 = Ssse3.Shuffle(vector1, indices1);
    Vector128<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
    return rt;
}它看上去与 Ssse3.Shuffle差不多,仅是向量大小变为了256位。但该指令的索引(第2个参数mask)的定义域并没有扩展到256位,而仍然是128位。它可以看作是2个128位的Shuffle指令组合而成。下面摘录了《Intel® Intrinsics Guide》手册里该指令的介绍。

public static Vector128<byte> YShuffleX3Kernel_Combine(Vector128<byte> vector0, Vector128<byte> vector1, Vector128<byte> vector2, Vector128<byte> indices) {
    if (!Ssse3.IsSupported) VectorMessageFormats.ThrowNewUnsupported("Ssse3");
    Vector128<byte> vCount2 = Vector128.Create((byte)(Vector128<byte>.Count * 2));
    Vector128<byte> indices1 = Sse2.Subtract(indices, vCount2);
    Vector128<byte> rt0 = YShuffleX2Kernel_Combine(vector0, vector1, indices);
    Vector128<byte> mask = Sse2.CompareGreaterThan(vCount2.AsSByte(), indices.AsSByte()).AsByte(); // vCount2>indices ==> indices<vCount2.
    Vector128<byte> rt1 = Ssse3.Shuffle(vector2, indices1);
    Vector128<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
    return rt;
}注意形貌(Description)中提到的“128-bit lanes”,表现它是按每个128位小道,分别处置惩罚的。在Avx、Avx2 等256位指令会集,有不少指令是如许从128位扩展到256的,导致用起来比力困难,这一类困难一样平常被简称为“跨lane难题”(跨128位小道的难题)。
别的还可以发现,若索引向量里元素的最高位为1,则结果向量里对应元素的值会被清零。这个特性很有用,下面的章节将会用到。
2.2.1.2 解决跨lane难题

借助permute(重排)和blend(条件选择)指令,可以解决跨lane难题。
这里介绍一下,X86向量指令的名称一样平常是如许约定的——shuffle(换位)是128位lane内的操作,而permute(重排)是跨lane的操作。由于lane内操作可以按每128位并行处置惩罚,指令性能是非常高的,保举使用。而permute等跨lane的操作会相对慢一些,应仅在必须使用时才用。
借助permute和blend指令的帮忙,全256位索引的换位有了思绪——因AVX2的shuffle指令的索引是128位的,要想实现全256位索引的换位,需要调用2次shuffle指令。第一次对低128位索引进行换位;另一次先使用permute指令将高、低128位进行交换,再执行shuffle指令对别的128位索引进行换位。最后盘算好掩码,使用blend指令对2次shuffle指令的结果进行合并。
上述思绪确实可以或许工作,只是步骤比力多,拖累了性能。有没有更好的办法呢?
在stackoverflow网站上,ErmIg给出了一种办法,服从更高。这段代码是C语言的。
// https://learn.microsoft.com/zh-cn/dotnet/api/system.runtime.intrinsics.x86.avx2?view=net-8.0
// __m256i _mm256_shuffle_epi8 (__m256i a, __m256i b)
// VPSHUFB ymm, ymm, ymm/m256
public static Vector256<byte> Shuffle (Vector256<byte> value, Vector256<byte> mask);该办法很奇妙,利用了加法对索引里的值进行转换,使其能很好利用shuffle指令的“最高位为1时清零”特性。

[*]加上K0,能使低128位数据里 低128范围的索引有效(最高位不为0),而高128位范围的索引因最高位为1,会被清零。且高128位数据的效果反之。
[*]加上K1,能使低128位数据里 高128范围的索引有效(最高位不为0),而低128位范围的索引因最高位为1,会被清零。且高128位数据的效果反之。
再利用permute指令对源值进行重排,以及使用 or 指令将2个shuffle的结果进行合并,于是完成了全256位索引的换位。它不需要blend指令,并节流了掩码盘算的开销,服从非常高。
将上述办法翻译为 C# 语言的代码,便能实现单向量换位的方法 YShuffleKernel。源码在 WVectorTraits256Avx2.YS.cs。
__m256i _mm256_shuffle_epi8 (__m256i a, __m256i b)
#include <immintrin.h>
Instruction: vpshufb ymm, ymm, ymm
CPUID Flags: AVX2
Description
Shuffle 8-bit integers in a within 128-bit lanes according to shuffle control mask in the corresponding 8-bit element of b, and store the results in dst.
Operation
FOR j := 0 to 15
        i := j*8
        IF b == 1
                dst := 0
        ELSE
                index := b
                dst := a
        FI
        IF b == 1
                dst := 0
        ELSE
                index := b
                dst := a
        FI
ENDFOR
dst := 0先前的C语言代码在使用permute指令进行重排时,使用了一个魔法数字 0x4E。魔法数字会造成理解困难,于是我改为使用枚举类型ShuffleControlG4来形貌。它的成员的命名规则,参考了 HLSL(High-level shader language。DirectX里的着色语言)/GLSL(OpenGL Shading Language。OpenGL里的着色语言)里swizzle语句的写法,用 X/Y/Z/W 这4字母来代表偏移量0~3,随后“4个字母的组合”表达了“4个元素的偏移量”。
ShuffleControlG4.ZWXY 相称于HLSL(或GLSL)里的 result = source.zwxy。即permute指令使用该常数时,会将 “”给重排为“”,于是实现了 高128位与低128位的交换。
K0、K1如许的常数由于经常使用,于是将它们放进了 Vector256Constants 这个静态类中。
2.2.1.3 进一步优化

上面的代码固然有效,但是早期的.NET JIT 在编译本机代码时,不会做指令排序等优化,导致性能没到达预期。于是可以手动对各个向量指令调整顺序,尽可能进步这段代码的运行时服从。
// https://stackoverflow.com/questions/30669556/shuffle-elements-of-m256i-vector
const __m256i K0 = _mm256_setr_epi8(
    0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70,
    0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0);

const __m256i K1 = _mm256_setr_epi8(
    0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0, 0xF0,
    0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70, 0x70);

inline const __m256i Shuffle(const __m256i & value, const __m256i & shuffle)
{
    return _mm256_or_si256(_mm256_shuffle_epi8(value, _mm256_add_epi8(shuffle, K0)),
      _mm256_shuffle_epi8(_mm256_permute4x64_epi64(value, 0x4E), _mm256_add_epi8(shuffle, K1)));
}上面这段代码,还参考了 Intel的手册里IceLake的指标,估算了一下延迟与吞吐率。总延迟(total latency)为8个时钟周期,总吞吐率(total throughput CPI)为3。
2.2.2 实现2向量换位(YShuffleX2Kernel)

根据先前128位时的处置惩罚经验,组合使用2个单向量的Shuffle方法,就能实现2向量换位的方法 YShuffleX2Kernel。源代码如下。

public static Vector256<byte> YShuffleKernel_ByteAdd(Vector256<byte> vector, Vector256<byte> indices) {
    return Avx2.Or(
      Avx2.Shuffle(Avx2.Permute4x64(vector.AsInt64(), (byte)ShuffleControlG4.ZWXY).AsByte(), Avx2.Add(indices, Vector256Constants.Shuffle_Byte_LaneAdd_K1))
      , Avx2.Shuffle(vector, Avx2.Add(indices, Vector256Constants.Shuffle_Byte_LaneAdd_K0))
    );
}按F5运行程序。不久后,程序会因断点而停息,停在 Debugger.Break 后面的 UseVectors语句上。此时通过主菜单,打开“Disassembly”(反汇编)窗口。随后在“Disassembly”窗口内按多次 单步运行的快捷键(一样平常是 F11),使程序运行到 UseVectorsDoBatch 方法内。于是便可以观察UseVectorsDoBatch 的汇编代码了。
2.4.2 查看 .NET 7.0JIT编译结果

下图是 .NET 7.0JIT编译结果。
https://img2024.cnblogs.com/blog/169212/202412/169212-20241211232438444-540783499.png
由于 .NET 7.0 不支持 Avx512,故使用了 Avx2时的算法。可参考“2.2.3 实现3向量换位(YShuffleX3Kernel)”,读懂这一段的汇编代码。
2.4.3 查看 .NET 8.0JIT编译结果

下图是 .NET 8.0JIT编译结果。
https://img2024.cnblogs.com/blog/169212/202412/169212-20241211232501440-172065572.png
比起 .NET 7.0的编译结果,内循环的要简短很多,且出现了Avx512的zmm寄存器。这是由于处置惩罚器支持 Avx512,于是便使用了 Avx512的算法。可参考“2.3.4 利用512位向量,进一步优化3向量换位(YShuffleX3Kernel)”,读懂这一段的汇编代码。
从上图可以看出,JIT编译的本机代码的质量很高。这几点处置惩罚的比力好:

[*]将 indices0、indices1、indices的加载,挪至循环前(ymm0、ymm1、ymm2)。
[*]对3次 YShuffleX3Kernel 中的雷同操作进行了合并,所以仅有1条 vinserti64x4 指令。
[*]充分利用了 “zmm的低256位就是ymm寄存器”的特点,没有多余的寄存器 mov操作。
三、Arm架构

Arm架构提供了 TableLookup(查表) 指令,可以用它来实现向量内的换位。
32位时,该指令的返回值只有64位,用起来不太方便。而64位时,该指令能返回完整的128位结果。于是先从64位指令介绍起。
.NET 8.0 新增了对 AdvSimd指令集里的“2-4向量查表”指令的支持。这能给我们的方法带来进一步的性能提升,最后会来讲解它。
3.1 用64位的AdvSimd指令集操作128位向量

3.1.1 实现单向量换位(YShuffleKernel)

64位的Arm架构提供了 TableLookup(查表) 指令,它对应了 AdvSimd.Arm64.VectorTableLookup 方法。该方法的定义如下。

public static Vector256<byte> YShuffleKernel_ByteAdd2(Vector256<byte> vector, Vector256<byte> indices) {
    // Format: Code; //Latency, Throughput(references IceLake)
    Vector256<byte> vector1 = Avx2.Permute4x64(vector.AsInt64(), (byte)ShuffleControlG4.ZWXY).AsByte(); // 3,1
    Vector256<byte> indices0 = Avx2.Add(indices, Vector256Constants.Shuffle_Byte_LaneAdd_K0); // 1,0.33
    Vector256<byte> indices1 = Avx2.Add(indices, Vector256Constants.Shuffle_Byte_LaneAdd_K1); // 1,0.33
    Vector256<byte> v0 = Avx2.Shuffle(vector, indices0); // 1,0.5
    Vector256<byte> v1 = Avx2.Shuffle(vector1, indices1); // 1,0.5
    Vector256<byte> rt = Avx2.Or(v0, v1); // 1,0.33
    return rt; //total latency: 8, total throughput CPI: 3
}使用该方法,便能实现单向量换位的方法 YShuffleKernel。源码在 WVectorTraits128AdvSimdB64.YS.cs。

public static Vector256<byte> YShuffleX2Kernel_Combine(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> indices) {
    Vector256<byte> vCount = Vector256.Create((byte)Vector256<byte>.Count);
    Vector256<byte> indices1 = Avx2.Subtract(indices, vCount);
    Vector256<byte> rt0 = YShuffleKernel_ByteAdd2(vector0, indices);
    Vector256<byte> mask = Avx2.CompareGreaterThan(vCount.AsSByte(), indices.AsSByte()).AsByte(); // vCount>indices ==> indices<vCount.
    Vector256<byte> rt1 = YShuffleKernel_ByteAdd2(vector1, indices1);
    Vector256<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
    return rt;
}3.1.2 实现2向量换位(YShuffleX2Kernel)

根据先前Sse时的处置惩罚经验,组合使用2个单向量的Shuffle方法,就能实现2向量换位的方法 YShuffleX2Kernel。源代码如下。

public static Vector256<byte> YShuffleX2Kernel_Combine3(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> indices) {
    Vector256<byte> vCount = Vector256.Create((byte)Vector256<byte>.Count);
    // Format: Code; //Latency, Throughput(references IceLake)
    Vector256<byte> vector0B = Avx2.Permute4x64(vector0.AsInt64(), (byte)ShuffleControlG4.ZWXY).AsByte(); // 3,1
    Vector256<byte> vector1B = Avx2.Permute4x64(vector1.AsInt64(), (byte)ShuffleControlG4.ZWXY).AsByte(); // 3,1
    Vector256<byte> indices1 = Avx2.Subtract(indices, vCount); // 1,0.33
    Vector256<byte> indices0A = Avx2.Add(indices, Vector256Constants.Shuffle_Byte_LaneAdd_K0); // 1,0.33
    Vector256<byte> indices0B = Avx2.Add(indices, Vector256Constants.Shuffle_Byte_LaneAdd_K1); // 1,0.33
    Vector256<byte> indices1A = Avx2.Add(indices1, Vector256Constants.Shuffle_Byte_LaneAdd_K0); // 1,0.33
    Vector256<byte> indices1B = Avx2.Add(indices1, Vector256Constants.Shuffle_Byte_LaneAdd_K1); // 1,0.33
    Vector256<byte> rt0A = Avx2.Shuffle(vector0, indices0A); // 1,0.5
    Vector256<byte> rt0B = Avx2.Shuffle(vector0B, indices0B); // 1,0.5
    Vector256<byte> rt1A = Avx2.Shuffle(vector1, indices1A); // 1,0.5
    Vector256<byte> rt1B = Avx2.Shuffle(vector1B, indices1B); // 1,0.5
    Vector256<byte> mask = Avx2.CompareGreaterThan(vCount.AsSByte(), indices.AsSByte()).AsByte(); // 1,0.5. vCount>indices ==> indices<vCount.
    Vector256<byte> rt0 = Avx2.Or(rt0A, rt0B); // 1,0.33
    Vector256<byte> rt1 = Avx2.Or(rt1A, rt1B); // 1,0.33
    Vector256<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1); // 3,1
    return rt; //total latency: 21, total throughput CPI: 7.83
}Arm的查表指令有一个特点,索引一旦超过范围就会被清零。而X86的shuffle指令,仅在最高位为1时才会清零,于是需要手动判定索引是否超过范围,比力繁琐。
根据Arm的这个特点,便可以利用减法来调整索引,最后用 或(Or)运算将2次查表的结果给合并。所以上面的代码对比Sse的,看来更清爽一些。
3.1.3 实现3向量换位(YShuffleX3Kernel)

根据先前的处置惩罚经验,组合使用3个单向量的Shuffle方法,就能实现3向量换位的方法 YShuffleX3Kernel。源代码如下。
/// <inheritdoc cref="IWVectorTraits256.YShuffleX3Kernel(Vector256{byte}, Vector256{byte}, Vector256{byte}, Vector256{byte})"/>

public static Vector256<byte> YShuffleX3Kernel_Combine(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> vector2, Vector256<byte> indices) {
    Vector256<byte> vCount2 = Vector256.Create((byte)(Vector256<byte>.Count * 2));
    Vector256<byte> indices1 = Avx2.Subtract(indices, vCount2);
    Vector256<byte> rt0 = YShuffleX2Kernel_Combine3(vector0, vector1, indices);
    Vector256<byte> mask = Avx2.CompareGreaterThan(vCount2.AsSByte(), indices.AsSByte()).AsByte(); // vCount2>indices ==> indices<vCount2.
    Vector256<byte> rt1 = YShuffleKernel_ByteAdd2(vector2, indices1);
    Vector256<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
    return rt;
}3.2 用32位的AdvSimd指令集操作128位向量

3.2.1 实现单向量换位(YShuffleKernel)

32位的Arm架构也提供了 TableLookup(查表) 指令,它对应了 AdvSimd.VectorTableLookup 方法。该方法的定义如下。

public static Vector256<byte> YShuffleKernel(Vector256<byte> vector, Vector256<byte> indices) {
#if NET8_0_OR_GREATER
    if (Avx512Vbmi.VL.IsSupported) {
      return Avx512Vbmi.VL.PermuteVar32x8(vector, indices);
      //__m256i _mm256_permutexvar_epi8 (__m256i idx, __m256i a)
      //#include <immintrin.h>
      //Instruction: vpermb ymm, ymm, ymm
      //CPUID Flags: AVX512_VBMI + AVX512VL
      //Latency and Throughput
      //Architecture        Latency        Throughput (CPI)
      //Icelake Intel Core        -        1
      //Icelake Xeon        3        1
      //Sapphire Rapids        5        1
    }
#endif // NET8_0_OR_GREATER
    return YShuffleKernel_ByteAdd2(vector, indices);
}可以注意到,它的索引(byteIndexes)及返回值,都只有64位(Vector64)。
若需要128位的结果,就得调用2次VectorTableLookup。源码在 WVectorTraits128AdvSimd.YS.cs。

public static Vector256<byte> YShuffleX2Kernel(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> indices) {
#if NET8_0_OR_GREATER
    if (Avx512Vbmi.VL.IsSupported) {
      return Avx512Vbmi.VL.PermuteVar32x8x2(vector0, indices, vector1);
    }
#endif // NET8_0_OR_GREATER
    return YShuffleX2Kernel_Combine3(vector0, vector1, indices);
}GetLower 方法,可以获取向量的下半部分。GetUpper 方法,可以获取向量的上半部分。故可以分别通报给 VectorTableLookup,得到2个64位的结果。
最后将这2个64位的结果,组合成1个128位,便完成了处置惩罚。
注意在早期版本 .NET 中,Vector128.Create 的性能比力低,因它是借助内存来组合向量的。可以改为用 ToVector128Unsafe与WithUpper,这便在寄存器内完成了向量的组合。
3.2.2 实现2向量换位(YShuffleX2Kernel)

根据先前的处置惩罚经验,组合使用2个单向量的Shuffle方法,就能实现2向量换位的方法 YShuffleX2Kernel。源代码如下。

public static Vector256<byte> YShuffleX2Kernel_Combine3(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> indices) {
    Vector256<byte> vCount = Vector256.Create((byte)Vector256<byte>.Count);
    // Format: Code; //Latency, Throughput(references IceLake)
    Vector256<byte> vector0B = Avx2.Permute4x64(vector0.AsInt64(), (byte)ShuffleControlG4.ZWXY).AsByte(); // 3,1
    Vector256<byte> vector1B = Avx2.Permute4x64(vector1.AsInt64(), (byte)ShuffleControlG4.ZWXY).AsByte(); // 3,1
    Vector256<byte> indices1 = Avx2.Subtract(indices, vCount); // 1,0.33
    Vector256<byte> indices0A = Avx2.Add(indices, Vector256Constants.Shuffle_Byte_LaneAdd_K0); // 1,0.33
    Vector256<byte> indices0B = Avx2.Add(indices, Vector256Constants.Shuffle_Byte_LaneAdd_K1); // 1,0.33
    Vector256<byte> indices1A = Avx2.Add(indices1, Vector256Constants.Shuffle_Byte_LaneAdd_K0); // 1,0.33
    Vector256<byte> indices1B = Avx2.Add(indices1, Vector256Constants.Shuffle_Byte_LaneAdd_K1); // 1,0.33
    Vector256<byte> rt0A = Avx2.Shuffle(vector0, indices0A); // 1,0.5
    Vector256<byte> rt0B = Avx2.Shuffle(vector0B, indices0B); // 1,0.5
    Vector256<byte> rt1A = Avx2.Shuffle(vector1, indices1A); // 1,0.5
    Vector256<byte> rt1B = Avx2.Shuffle(vector1B, indices1B); // 1,0.5
    Vector256<byte> mask = Avx2.CompareGreaterThan(vCount.AsSByte(), indices.AsSByte()).AsByte(); // 1,0.5. vCount>indices ==> indices<vCount.
    Vector256<byte> rt0 = Avx2.Or(rt0A, rt0B); // 1,0.33
    Vector256<byte> rt1 = Avx2.Or(rt1A, rt1B); // 1,0.33
    Vector256<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1); // 3,1
    return rt; //total latency: 21, total throughput CPI: 7.83
}代码与64位架构时完全一致。由于在不同的类中,于是调用了各自的 YShuffleKernel 方法。而 YShuffleKernel 封装了底层不一致的细节(32位架构仅返回64位),使上层代码可以或许完全一致。
3.2.3 实现3向量换位(YShuffleX3Kernel)

根据先前的处置惩罚经验,组合使用3个单向量的Shuffle方法,就能实现3向量换位的方法 YShuffleX3Kernel。源代码如下。
/// <inheritdoc cref="IWVectorTraits256.YShuffleX3Kernel(Vector256{byte}, Vector256{byte}, Vector256{byte}, Vector256{byte})"/>

public static Vector256<byte> YShuffleX3Kernel_Combine(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> vector2, Vector256<byte> indices) {
    Vector256<byte> vCount2 = Vector256.Create((byte)(Vector256<byte>.Count * 2));
    Vector256<byte> indices1 = Avx2.Subtract(indices, vCount2);
    Vector256<byte> rt0 = YShuffleX2Kernel_Combine3(vector0, vector1, indices);
    Vector256<byte> mask = Avx2.CompareGreaterThan(vCount2.AsSByte(), indices.AsSByte()).AsByte(); // vCount2>indices ==> indices<vCount2.
    Vector256<byte> rt1 = YShuffleKernel_ByteAdd2(vector2, indices1);
    Vector256<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
    return rt;
}3.3 使用 .NET 8.0新增的多向量查表指令

3.3.1 简介

对于AdvSimd.Arm64.VectorTableLookup 方法,.NET 5.0 的文档是只有2个重载方法。

public static Vector256<byte> YShuffleX3Kernel_PermuteLonger(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> vector2, Vector256<byte> indices) {
    if (!Avx512Vbmi.IsSupported) VectorMessageFormats.ThrowNewUnsupported("Avx512Vbmi");
    Vector512<byte> l = vector0.ToVector512Unsafe().WithUpper(vector1);
    Vector512<byte> u = vector2.ToVector512Unsafe();
    return Avx512Vbmi.PermuteVar64x8x2(l, indices.ToVector512Unsafe(), u).GetLower();
}到了.NET 8.0 ,文档多了6个重载方法。
public static Vector256<byte> YShuffleX3Kernel(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> vector2, Vector256<byte> indices) {
#if NET8_0_OR_GREATER
    if (Shuffle_Use_Longer && Avx512Vbmi.IsSupported) {
      return YShuffleX3Kernel_PermuteLonger(vector0, vector1, vector2, indices);
    } else if (Avx512Vbmi.VL.IsSupported) {
      return YShuffleX3Kernel_Permute(vector0, vector1, vector2, indices);
    }
#endif // NET8_0_OR_GREATER
    return YShuffleX3Kernel_Combine(vector0, vector1, vector2, indices);
}可见,2、3、4个向量的查表功能都加上了了。随后再区分一下 Byte/SByte 这2种类型,于是共增加了 3*2=6 个重载方法。
3.3.2 实现2向量换位(YShuffleX2Kernel)

有了2向量查表的指令后,便能轻松的实现YShuffleX2Kernel方法。
__m512i _mm512_permutex2var_epi8 (__m512i a, __m512i idx, __m512i b)
#include <immintrin.h>
Instruction: vpermi2b zmm, zmm, zmm
CPUID Flags: AVX512_VBMI

Latency and Throughput
Architecture        Latency        Throughput (CPI)
Icelake Intel Core        -        2
Icelake Xeon        -        2
Sapphire Rapids        6        2由于经常需要判定是否支持多向量换位,于是在源文件顶部,专门定义了它的条件编译符号 ARM_ALLOW_LOOKUP_X。
// Debug break.
bool allowDebugBreak = true;
if (allowDebugBreak) {
    for (int i = 0; i < 10000; ++i) {
      UseVectors();
    }
    Debugger.Break();
    UseVectors();
}3.3.3 实现3向量换位(YShuffleX3Kernel)

有了3向量查表的指令后,便能轻松的实现YShuffleX3Kernel方法。
// https://learn.microsoft.com/zh-cn/dotnet/api/system.runtime.intrinsics.arm.advsimd.arm64.vectortablelookup?view=net-8.0
// uint8x16_t vqvtbl1q_u8(uint8x16_t t,uint8x16_t idx)
// A64:TBL Vd.16B、{Vn.16B}、Vm.16B
public static Vector128<byte> VectorTableLookup (Vector128<byte> table, Vector128<byte> byteIndexes);四、结语

弄懂YShuffleX3Kernel的算法原理后,便可以在各个场景使用它了。
对于图像的垂直翻转,向量算法险些和标量算法一样简单。而对于图像的水平翻转,标量算法仅需改造地址盘算便实现了,可向量算法一下子变得很复杂,这是为什么呢?
这是由于标量算法是以字节(Byte)为单位进行操作的,地址盘算可以灵活定位到每一个字节。而对于向量算法,向量类型的大小一样平常是128位(16字节)或是更长,颗粒度很大。而且大多数向量方法是在垂直方向工作的,例如 Vector.Add。这种工作方式,比力适合处置惩罚一维数组。
但是图像是2维的,有X、Y这2种坐标分量。垂直翻转仅需翻转Y坐标,X坐标没有变,于是处置惩罚起来很简单。而水平翻转需要翻转X坐标,这就涉及到向量内的字节级别地址定位了。
为相识决向量内的字节级别地址定位难题,就需要使用换位类别的指令。例如 X86的 shuffle(换位)、permute(重排)指令,Arm的 TableLookup(查表)指令。
32位像素固然内部有R、G、B通道的区分,但在水平翻转时可当做一个团体来处置惩罚,即当做32位整数。于是使用单向量的换位(YShuffleKernel),便能编写出水平翻转的向量算法。
对于 24位像素,它是3个字节一组。而向量大小是16字节的整数倍,无法被3整除。此时连单向量换位都难以处置惩罚,于是需要使用3向量换位。
高色彩深度的像素也可以按照同样的办法来处置惩罚,例如 64位像素(R16G16B16A16)用单向量换位,48位像素(R16G16B16)用3向量换位。
实在大多数扳连X坐标的图像处置惩罚,都可以使用 YShuffleKernel 等方法。有更佳的专用方法时除外,例如对于像素数据的交织与解交织,VectorTraits库是提供了性能更好的专用方法的,范例见 Bgr24彩色位图转为Gray8灰度位图的跨平台SIMD硬件加速向量算法。
附录


[*]YShuffleX3Kernel 的文档: https://zyl910.github.io/VectorTraits_doc/api/Zyl.VectorTraits.Vectors.YShuffleX3Kernel.html
[*]VectorTraits 的NuGet包: https://www.nuget.org/packages/VectorTraits
[*]VectorTraits 的在线文档: https://zyl910.github.io/VectorTraits_doc/
[*]VectorTraits 源代码: https://github.com/zyl910/VectorTraits
[*]微软文档-Ssse3.Shuffle 方法: https://learn.microsoft.com/zh-cn/dotnet/api/system.runtime.intrinsics.x86.ssse3.shuffle?view=net-8.0
[*]微软文档-AdvSimd.Arm64.VectorTableLookup 方法: https://learn.microsoft.com/en-us/dotnet/api/system.runtime.intrinsics.arm.advsimd.arm64.vectortablelookup?view=net-8.0
[*]Intel《Intel® Intrinsics Guide》
[*]Arm《intrinsics》
[*]C# 使用SIMD向量类型加速浮点数组求和运算(2):C#通过Intrinsic直接使用Avx指令集操作 Vector256,及C++程序对比
[*]C# 使用SIMD向量类型加速浮点数组求和运算(5):如何查看Release程序运行时汇编代码
    出处:http://www.cnblogs.com/zyl910/    版权声明:自由转载-非商用-非衍生-保持署名 | Creative Commons BY-NC-ND 3.0.
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!更多信息从访问主页:qidao123.com:ToB企服之家,中国第一个企服评测及商务社交产业平台。
页: [1]
查看完整版本: [C#] 24位图像水平翻转的跨平台SIMD硬件加速向量算法的关键——YShuffleX3K