在上一篇文章里,給大家講解了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.0JIT編譯結果
下圖是 .NET 7.0JIT編譯結果。

由于 .NET 7.0 不支持 Avx512,故使用了 Avx2時的算法。可參考“2.2.3 實現3向量換位(YShuffleX3Kernel)”,讀懂這一段的匯編代碼。
2.4.3 查看 .NET 8.0JIT編譯結果
下圖是 .NET 8.0JIT編譯結果。

比起 .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程序運行時匯編代碼

浙公網安備 33010602011771號