在上一篇文章里,给大家讲解了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);
它看上去与 Ssse3.Shuffle
差不多,仅是向量大小变为了256位。但该指令的索引(第2个参数mask)的定义域并没有扩展到256位,而仍然是128位。它可以看作是2个128位的Shuffle指令组合而成。下面摘录了《Intel® Intrinsics Guide》手册里该指令的介绍。
__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[i+7] == 1
dst[i+7:i] := 0
ELSE
index[3:0] := b[i+3:i]
dst[i+7:i] := a[index*8+7:index*8]
FI
IF b[128+i+7] == 1
dst[128+i+7:128+i] := 0
ELSE
index[3:0] := b[128+i+3:128+i]
dst[128+i+7:128+i] := a[128+index*8+7:128+index*8]
FI
ENDFOR
dst[MAX:256] := 0
注意描述(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://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)));
}
该办法很巧妙,利用了加法对索引里的值进行转换,使其能很好利用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
。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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))
);
}
先前的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指令使用该常数时,会将 “[X, Y, Z, W]”给重排为“[Z, W, X, Y]”,于是实现了 高128位与低128位的互换。
K0、K1这样的常数由于经常使用,于是将它们放进了 Vector256Constants 这个静态类中。
2.2.1.3 进一步优化
上面的代码虽然有效,但是早期的 .NET JIT 在编译本机代码时,不会做指令排序等优化,导致性能没达到预期。于是可以手动对各个向量指令调整顺序,尽可能提高这段代码的运行时效率。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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
}
上面这段代码,还参考了 Intel的手册里IceLake的指标,估算了一下延迟与吞吐率。总延迟(total latency)为8个时钟周期,总吞吐率(total throughput CPI)为3。
2.2.2 实现2向量换位(YShuffleX2Kernel)
根据先前128位时的处理经验,组合使用2个单向量的Shuffle方法,就能实现2向量换位的方法 YShuffleX2Kernel。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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[i]>indices[i] ==> indices[i]<vCount[i].
Vector256<byte> rt1 = YShuffleKernel_ByteAdd2(vector1, indices1);
Vector256<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
return rt;
}
同样的,可以手动对各个向量指令调整顺序,尽可能提高这段代码的运行时效率。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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[i]>indices[i] ==> indices[i]<vCount[i].
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
}
上面这段代码,还参考了 Intel的手册里IceLake的指标,估算了一下延迟与吞吐率。总延迟(total latency)为21个时钟周期,总吞吐率(total throughput CPI)为7.83。
2.2.3 实现3向量换位(YShuffleX3Kernel)
根据先前128位时的处理经验,组合使用3个单向量的Shuffle方法,就能实现3向量换位的方法 YShuffleX2Kernel。源代码如下。
/// <inheritdoc cref="IWVectorTraits256.YShuffleX3Kernel(Vector256{byte}, Vector256{byte}, Vector256{byte}, Vector256{byte})"/>
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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[i]>indices[i] ==> indices[i]<vCount2[i].
Vector256<byte> rt1 = YShuffleKernel_ByteAdd2(vector2, indices1);
Vector256<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
return rt;
}
2.3 用Avx512系列指令集改进256位向量的操作
.NET 8.0
新增了对 Avx512系列指令集的支持。Avx512不仅提供了“跨小道重排指令”(_mm_permutexvar_epi8
),且提供了“2向量的跨小道重排指令”(_mm_permutex2var_epi8
)。这些指令能有效的改进多向量换位方法。
Avx512系列指令集有多个子集,其中Avx512VL就是负责处理128~256位向量的指令集。随后它可以与Avx512BW、Avx512DQ、Avx512F、Avx512Vbmi 等子集进行配合,能处理元素大小为 8~64位的数据。例如 Avx512Vbmi 里含有上面所说的 “跨小道重排指令”,且提供了“2向量的跨小道重排指令”。于是 .NET中可以通过 Avx512Vbmi.VL
类 来处理 128~256位向量的这些重排指令。
其实 VectorTraits 也支持 512位向量的多向量换位,源代码详见 WVectorTraits512Avx512.YS.cs
,有兴趣的读者可以自行翻阅。由于目前自动大小向量Vertor最大为256位,于是本文将重点放在用Avx512系列指令集改进256位向量的操作上。
2.3.1 实现单向量换位(YShuffleKernel)
使用Avx512Vbmi所提供 “跨小道重排指令”(_mm256_permutexvar_epi8
),可以直接实现全256位的换位。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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);
}
- 若当前CPU支持Avx512系列指令集,便使用它提供的高效指令。保障了性能。从上面代码中的注释可以看出,Sapphire Rapids时该指令的延迟为5个时钟周期,吞吐率为1。
- 若当前CPU不支持Avx512系列指令集,便回退为Avx2的实现。保障了兼容性。YShuffleKernel_ByteAdd2 方法的总延迟为8个时钟周期,总吞吐率为3。
这样便实现了“自动使用当前处理器最佳指令”的效果。
2.3.2 实现2向量换位(YShuffleX2Kernel)
使用Avx512Vbmi所提供 “2向量的跨小道重排指令”(_mm256_permutex2var_epi8
),可以方便的实现 YShuffleX2Kernel方法。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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);
}
摘录一下 Intel手册对 _mm256_permutex2var_epi8
指令的说明。
__m256i _mm256_permutex2var_epi8 (__m256i a, __m256i idx, __m256i b)
#include <immintrin.h>
Instruction: vpermi2b ymm, ymm, ymm
CPUID Flags: AVX512_VBMI + AVX512VL
Description
Shuffle 8-bit integers in a and b across lanes using the corresponding selector and index in idx, and store the results in dst.
Latency and Throughput
Architecture Latency Throughput (CPI)
Icelake Intel Core - 2
Icelake Xeon - 2
Sapphire Rapids 4 2
- 若当前CPU支持Avx512系列指令集,便使用它提供的高效指令。保障了性能。从上面代码中的注释可以看出,Sapphire Rapids时该指令的延迟为4个时钟周期,吞吐率为2。
- 若当前CPU不支持Avx512系列指令集,便回退为Avx2的实现。保障了兼容性。YShuffleX2Kernel_Combine3 方法的总延迟为21个时钟周期,总吞吐率为7.83。
2.3.3 实现3向量换位(YShuffleX3Kernel)
使用Avx512Vbmi所提供 “跨小道重排指令”、“2向量的跨小道重排指令”,可以方便的实现 YShuffleX3Kernel方法。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector256<byte> YShuffleX3Kernel_Permute(Vector256<byte> vector0, Vector256<byte> vector1, Vector256<byte> vector2, Vector256<byte> indices) {
if (!Avx512Vbmi.VL.IsSupported) VectorMessageFormats.ThrowNewUnsupported("Avx512Vbmi, Avx512VL");
Vector256<byte> vCount2 = Vector256.Create((byte)(Vector256<byte>.Count * 2));
Vector256<byte> indices1 = Avx2.Subtract(indices, vCount2);
Vector256<byte> mask = Avx2.CompareGreaterThan(vCount2.AsSByte(), indices.AsSByte()).AsByte(); // vCount2[i]>indices[i] ==> indices[i]<vCount2[i].
Vector256<byte> rt0 = Avx512Vbmi.VL.PermuteVar32x8x2(vector0, indices, vector1);
Vector256<byte> rt1 = Avx512Vbmi.VL.PermuteVar32x8(vector2, indices1);
Vector256<byte> rt = ConditionalSelect_Relaxed(mask, rt0, rt1);
return rt;
}
2.3.4 利用512位向量,进一步优化3向量换位(YShuffleX3Kernel)
还有没有办法进一步优化呢?
有办法,就是利用512位向量的长度。
既然已经支持 Avx512Vbmi了,那么应该是支持512位向量的。1个512位向量,可以放下2个256位向量。于是对1个512位向量进行重排,就相当于对2个256位进行“2向量换位”。
且对 2个512位向量进行重排,相当于对4个256位进行“4向量换位”。目前我们仅需3个256位就行了,于是可以减少一个输入参数。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
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();
}
ToVector512Unsafe().WithUpper
,就是将2个256位向量,组合成1个512位向量。
而单独使用 ToVector512Unsafe
,是将 1个256位向量,重新解释成1个512位向量,高256位不管。它利用了Avx512指令集中“zmm的低256位就是ymm寄存器”的特点,实际上不用生成额外的转换指令。最后的 GetLower
也利用了这一点。
仅 WithUpper 需靠 _mm512_inserti64x4
(vinserti64x4)指令来实现。
随后为了能“自动使用当前处理器最佳指令”,故也写上指令集判断。
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);
}
Shuffle_Use_Longer 是我定义的一个常量(const),用于对比各种办法的性能。由于它是常量,于是JIT编译时会自动剪裁的,不会带来额外的分支跳转开销。
最后摘录一下 Intel手册对 _mm512_permutex2var_epi8
指令的说明。
__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
从信息里可以看出,Sapphire Rapids时该指令的延迟为6个时钟周期,吞吐率为2。它比起先前组合实现的办法,性能又能提升很多。
2.4 对运行时JIT编译的本机代码进行反汇编查看
本节探讨更专业的内容,适合熟悉向量指令汇编代码的专业人士。若你觉得过于艰深,可以跳过此节。
观察运行时的汇编代码,能够更清晰的分析代码的性能瓶颈。
2.4.1 反汇编查看的办法
若想对运行时JIT编译的本机代码进行反汇编查看,最简单的办法是使用Visual Studio的“Disassembly”功能。详细说明见 C# 使用SIMD向量类型加速浮点数组求和运算(5):如何查看Release程序运行时汇编代码,本节简单说明一下。
具体办法是:在Visual Studio里打开程序的解决方案,并设置断点。按“F5”运行程序直至遇到断点,然后点击菜单栏里的“DEBUG”(调试)->“Windows”(窗口)->“Disassembly”(反汇编),便会打开“Disassembly”(反汇编)窗口。
此时需注意“分层编译”的影响。为了提高程序的启动速度,.NET 推出了“分层编译”技术。即在程序启动时,几乎不进行编译优化,而是以最简单、最快的办法进行即时编译(JIT),使程序能在很短的时间内启动。随后JIT会监控程序的热点代码, 对热点代码进行 慢速的、复杂的二次编译,此时才会使用多种编译优化手段。例如改为内联使用内在函数,而不是函数调用。
为了查看编译优化后的汇编代码,最好在热点代码运行后才触发断点。其实 ImageFlipXOn24bitBenchmark.cs
里已有准备。将 Setup 方法末尾的allowDebugBreak相关的代码取消注释,并将 allowDebugBreak 赋值为 true。即将代码改为下面这样。
// Debug break.
bool allowDebugBreak = true;
if (allowDebugBreak) {
for (int i = 0; i < 10000; ++i) {
UseVectors();
}
Debugger.Break();
UseVectors();
}
按F5运行程序。不久后,程序会因断点而暂停,停在 Debugger.Break
后面的 UseVectors
语句上。此时通过主菜单,打开“Disassembly”(反汇编)窗口。随后在“Disassembly”窗口内按多次 单步运行的快捷键(一般是 F11),使程序运行到 UseVectorsDoBatch 方法内。于是便可以观察 UseVectorsDoBatch 的汇编代码了。
2.4.2 查看 .NET 7.0
JIT编译结果
下图是 .NET 7.0
JIT编译结果。
由于 .NET 7.0
不支持 Avx512,故使用了 Avx2时的算法。可参考“2.2.3 实现3向量换位(YShuffleX3Kernel)”,读懂这一段的汇编代码。
2.4.3 查看 .NET 8.0
JIT编译结果
下图是 .NET 8.0
JIT编译结果。
比起 .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
方法。该方法的定义如下。
// 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);
使用该方法,便能实现单向量换位的方法 YShuffleKernel。源码在 WVectorTraits128AdvSimdB64.YS.cs
。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector128<byte> YShuffleKernel(Vector128<byte> vector, Vector128<byte> indices) {
var rt = AdvSimd.Arm64.VectorTableLookup(vector, indices);
return rt;
}
3.1.2 实现2向量换位(YShuffleX2Kernel)
根据先前Sse时的处理经验,组合使用2个单向量的Shuffle方法,就能实现2向量换位的方法 YShuffleX2Kernel。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector128<byte> YShuffleX2Kernel_Combine(Vector128<byte> vector0, Vector128<byte> vector1, Vector128<byte> indices) {
Vector128<byte> vCount = Vector128.Create((byte)Vector128<byte>.Count);
Vector128<byte> indices1 = AdvSimd.Subtract(indices, vCount);
Vector128<byte> rt0 = YShuffleKernel(vector0, indices);
Vector128<byte> rt1 = YShuffleKernel(vector1, indices1);
Vector128<byte> rt = AdvSimd.Or(rt0, rt1);
return rt;
}
Arm的查表指令有一个特点,索引一旦超过范围就会被清零。而X86的shuffle指令,仅在最高位为1时才会清零,于是需要手动判断索引是否超过范围,比较繁琐。
根据Arm的这个特点,便可以利用减法来调整索引,最后用 或(Or)运算将2次查表的结果给合并。所以上面的代码对比Sse的,看来更清爽一些。
3.1.3 实现3向量换位(YShuffleX3Kernel)
根据先前的处理经验,组合使用3个单向量的Shuffle方法,就能实现3向量换位的方法 YShuffleX3Kernel。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector128<byte> YShuffleX3Kernel_Combine(Vector128<byte> vector0, Vector128<byte> vector1, Vector128<byte> vector2, Vector128<byte> indices) {
Vector128<byte> vCount = Vector128.Create((byte)Vector128<byte>.Count);
Vector128<byte> indices1 = AdvSimd.Subtract(indices, vCount);
Vector128<byte> indices2 = AdvSimd.Subtract(indices1, vCount);
Vector128<byte> rt0 = YShuffleKernel(vector0, indices);
Vector128<byte> rt1 = YShuffleKernel(vector1, indices1);
rt0 = AdvSimd.Or(rt0, rt1);
Vector128<byte> rt2 = YShuffleKernel(vector2, indices2);
rt0 = AdvSimd.Or(rt0, rt2);
return rt0;
}
3.2 用32位的AdvSimd指令集操作128位向量
3.2.1 实现单向量换位(YShuffleKernel)
32位的Arm架构也提供了 TableLookup(查表) 指令,它对应了 AdvSimd.VectorTableLookup
方法。该方法的定义如下。
// https://learn.microsoft.com/zh-cn/dotnet/api/system.runtime.intrinsics.arm.advsimd.vectortablelookup?view=net-8.0
// uint8x8_t vqvtbl1_u8(uint8x16_t t, uint8x8_t idx)
// A32:VTBL Dd、{Dn, Dn+1}、Dm
// A64:TBL Vd.8B、{Vn.16B}、Vm.8B
public static Vector64<byte> VectorTableLookup (Vector128<byte> table, Vector64<byte> byteIndexes);
可以注意到,它的索引(byteIndexes)及返回值,都只有64位(Vector64)。
若需要128位的结果,就得调用2次VectorTableLookup。源码在 WVectorTraits128AdvSimd.YS.cs
。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector128<byte> YShuffleKernel(Vector128<byte> vector, Vector128<byte> indices) {
var lower = AdvSimd.VectorTableLookup(vector, indices.GetLower());
var upper = AdvSimd.VectorTableLookup(vector, indices.GetUpper());
var rt = lower.ToVector128Unsafe().WithUpper(upper); //Vector128.Create(lower, upper);
return rt;
}
GetLower 方法,可以获取向量的下半部分。GetUpper 方法,可以获取向量的上半部分。故可以分别传递给 VectorTableLookup,得到2个64位的结果。
最后将这2个64位的结果,组合成1个128位,便完成了处理。
注意在早期版本 .NET 中,Vector128.Create
的性能比较低,因它是借助内存来组合向量的。可以改为用 ToVector128Unsafe与WithUpper,这便在寄存器内完成了向量的组合。
3.2.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) {
Vector128<byte> vCount = Vector128.Create((byte)Vector128<byte>.Count);
Vector128<byte> indices1 = AdvSimd.Subtract(indices, vCount);
Vector128<byte> rt0 = YShuffleKernel(vector0, indices);
Vector128<byte> rt1 = YShuffleKernel(vector1, indices1);
Vector128<byte> rt = AdvSimd.Or(rt0, rt1);
return rt;
}
代码与64位架构时完全一致。由于在不同的类中,于是调用了各自的 YShuffleKernel 方法。而 YShuffleKernel 封装了底层不一致的细节(32位架构仅返回64位),使上层代码能够完全一致。
3.2.3 实现3向量换位(YShuffleX3Kernel)
根据先前的处理经验,组合使用3个单向量的Shuffle方法,就能实现3向量换位的方法 YShuffleX3Kernel。源代码如下。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector128<byte> YShuffleX3Kernel_Combine(Vector128<byte> vector0, Vector128<byte> vector1, Vector128<byte> vector2, Vector128<byte> indices) {
Vector128<byte> vCount = Vector128.Create((byte)Vector128<byte>.Count);
Vector128<byte> indices1 = AdvSimd.Subtract(indices, vCount);
Vector128<byte> indices2 = AdvSimd.Subtract(indices1, vCount);
Vector128<byte> rt0 = YShuffleKernel(vector0, indices);
Vector128<byte> rt1 = YShuffleKernel(vector1, indices1);
rt0 = AdvSimd.Or(rt0, rt1);
Vector128<byte> rt2 = YShuffleKernel(vector2, indices2);
rt0 = AdvSimd.Or(rt0, rt2);
return rt0;
}
3.3 使用 .NET 8.0
新增的多向量查表指令
3.3.1 简介
对于AdvSimd.Arm64.VectorTableLookup
方法,.NET 5.0 的文档是只有2个重载方法。
VectorTableLookup(Vector128<SByte>, Vector128<SByte>) // int8x16_t vqvtbl1q_s8(int8x16_t t, uint8x16_t idx)
VectorTableLookup(Vector128<Byte>, Vector128<Byte>) // uint8x16_t vqvtbl1q_u8(uint8x16_t t, uint8x16_t idx)
到了.NET 8.0 ,文档多了6个重载方法。
VectorTableLookup(ValueTuple<Vector128<Byte>,Vector128<Byte>,Vector128<Byte>,Vector128<Byte>>, Vector128<Byte>) // uint8x16_t vqtbl4q_u8 (uint8x16x4_t t、uint8x16_t idx)
VectorTableLookup(ValueTuple<Vector128<Byte>,Vector128<Byte>,Vector128<Byte>>, Vector128<Byte>) // uint8x16_t vqtbl3q_u8 (uint8x16x3_t t、uint8x16_t idx)
VectorTableLookup(ValueTuple<Vector128<Byte>,Vector128<Byte>>, Vector128<Byte>) // uint8x16_t vqtbl2q_u8 (uint8x16x2_t t、uint8x16_t idx)
VectorTableLookup(ValueTuple<Vector128<SByte>,Vector128<SByte>,Vector128<SByte>,Vector128<SByte>>, Vector128<SByte>) // int8x16_t vqtbl4q_s8 (int8x16x4_t t、uint8x16_t idx)
VectorTableLookup(ValueTuple<Vector128<SByte>,Vector128<SByte>,Vector128<SByte>>, Vector128<SByte>) // int8x16_t vqtbl3q_s8 (int8x16x3_t t、uint8x16_t idx)
VectorTableLookup(ValueTuple<Vector128<SByte>,Vector128<SByte>>, Vector128<SByte>) // int8x16_t vqtbl2q_s8 (int8x16x2_t t、uint8x16_t idx)
可见,2、3、4个向量的查表功能都加上了了。随后再区分一下 Byte/SByte 这2种类型,于是共增加了 3*2=6 个重载方法。
3.3.2 实现2向量换位(YShuffleX2Kernel)
有了2向量查表的指令后,便能轻松的实现YShuffleX2Kernel方法。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector128<byte> YShuffleX2Kernel(Vector128<byte> vector0, Vector128<byte> vector1, Vector128<byte> indices) {
#if ARM_ALLOW_LOOKUP_X
var rt = AdvSimd.Arm64.VectorTableLookup((vector0, vector1), indices);
return rt;
#else
return YShuffleX2Kernel_Combine(vector0, vector1, indices);
#endif
}
由于经常需要判断是否支持多向量换位,于是在源文件顶部,专门定义了它的条件编译符号 ARM_ALLOW_LOOKUP_X。
#if NET8_0_OR_GREATER
#define ARM_ALLOW_LOOKUP_X
#endif // NET8_0_OR_GREATER
3.3.3 实现3向量换位(YShuffleX3Kernel)
有了3向量查表的指令后,便能轻松的实现YShuffleX3Kernel方法。
[MethodImpl(MethodImplOptions.AggressiveInlining)]
public static Vector128<byte> YShuffleX3Kernel(Vector128<byte> vector0, Vector128<byte> vector1, Vector128<byte> vector2, Vector128<byte> indices) {
#if ARM_ALLOW_LOOKUP_X
var rt = AdvSimd.Arm64.VectorTableLookup((vector0, vector1, vector2), indices);
return rt;
#else
return YShuffleX3Kernel_Combine(vector0, vector1, vector2, indices);
#endif
}
四、结语
弄懂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库是提供了性能更好的专用方法的,范例见 [C#] 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<T>
,及C++程序对比 - C# 使用SIMD向量类型加速浮点数组求和运算(5):如何查看Release程序运行时汇编代码