zyl910

优化技巧、硬件体系、图像处理、图形学、游戏编程、国际化与文本信息处理。

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::

上一篇文章里,给大家讲解了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。但这个办法存在以下缺点:

  1. 没能发挥CPU的全部潜力。X86处理器如今已经普及Avx2指令了,能够完善的处理256位向量。若使用Vector128,就强制降级了。
  2. .NET 版本要求高。.NET Core 3.0才提供 Vector128类型。而很多应用程序还需使用 .NET Framework
  3. .NET 7.0之前的固定大小向量(Vector128等)还不完善。例如自动大小向量(Vector)早就支持的函数,固定大小向量(Vector128等)很晚才支持 。

于是,更好的办法是使用 自动大小向量 Vector。

  1. Vector 类型的大小不是固定的。一般来说,它是本机CPU的最大向量大小。例如是X86架构且具有Avx2指令集时,它是256位;否则它为128位。鉴于Avx512尚未普及,这个位宽是合适的。
  2. .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# 的语法检查。

于是我们一般是这样使用的:

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

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. 第1次的索引为 indices 的原始值,使 i <Count 的元素做好换位。
  2. 第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.0JIT编译结果

下图是 .NET 7.0JIT编译结果。

FlipX24_x86_net7_avx2.png

由于 .NET 7.0 不支持 Avx512,故使用了 Avx2时的算法。可参考“2.2.3 实现3向量换位(YShuffleX3Kernel)”,读懂这一段的汇编代码。

2.4.3 查看 .NET 8.0JIT编译结果

下图是 .NET 8.0JIT编译结果。

FlipX24_x86_net8_avx512.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 方法。该方法的定义如下。

// 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硬件加速向量算法

附录

posted on 2024-12-11 22:37  zyl910  阅读(35)  评论(2编辑  收藏  举报