文章目录
在上一篇文章里,给大家讲解了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<T>
、Vector128<T>
、Vector256<T>
、Vector512<T>
,及辅助的静态类 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。
[MethodImpl(MethodImplOptions.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的。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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
。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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[i]>indices[i] ==> indices[i]<vCount[i].
Vector128<byte> rt1 = Ssse3.Shuffle(vector1, indices1);
Vector128<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
return rt;
}
方法内的第1行,是判断指令集指令集是否支持,若不支持便会调用ThrowNewUnsupported抛出异常。
随后2次调用 Shuffle方法,传递了不同的索引。
- 第1次的索引为 indices 的原始值,使
i <Count
的元素做好换位。 - 第2次的索引(
indices1
)为(indices-Count)
的值,使i >= Count
的元素做好换位。
然后计算好掩码 mask,便能使用条件选择(ConditionalSelect_Relaxed),将这2次Shuffle的结果进行合并。ConditionalSelect_Relaxed的用途,与 Vector128.ConditionalSelect
是相同的,它还会利用 Sse41指令集进行优化(Sse41提供了条件选择的单条指令 Sse41.BlendVariable
)。
由于 YShuffleX2Kernel 这样带Kernel后缀的方法要求索引必须在范围内,故在满足这个前提的情况下,上面的代码是正常工作的。若索引超过范围,上面的代码的结果会不正确。此时应该改为使用 YShuffleX2或YShuffleX2Insert 方法,它们会判断索引范围而进行相应的清零或插入的处理,故运算量会多一些。
2.1.3 实现3向量换位(YShuffleX3Kernel)
组合使用3个单向量的Shuffle方法,就能实现3向量换位的方法 YShuffleX2Kernel。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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[i]>indices[i] ==> indices[i]<vCount2[i].
Vector128<byte> rt1 = Ssse3.Shuffle(vector2, indices1);
Vector128<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
return rt;
}
它调用了上面的 YShuffleX2Kernel_Combine来简化代码,用 YShuffleX2Kernel_Combine 来处理 i < Count*2
范围内的索引。
随后该方法自己用Shuffle指令来处理 i >= Count*2
范围内的索引,最后将结果合并。
2.2 用Avx2指令集操作256位向量
2.2.1 实现单向量换位(YShuffleKernel)
2.2.1.1 指令介绍
Avx2指令集,提供了Byte的shuffle指令。它对应了 Avx2.Shuffle
方法。该方法的定义如下。
// 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);</