原文 | Stephen Toub
翻譯 | 鄭子銘
SIMD,即單指令多資料 (Single Instruction Multiple Data),是一種處理方式,其中一條指令同時適用於多條資料。你有一個數位列表,你想找到一個特定值的索引?你可以在列表中一次比較一個元素,這在功能上是沒有問題的。但是,如果在讀取和比較一個元素的相同時間內,你可以讀取和比較兩個元素,或四個元素,或32個元素呢?這就是SIMD,利用SIMD指令的藝術被親切地稱為 "向量化",其中操作同時應用於一個 "向量 "中的所有元素。
.NET長期以來一直以Vector
從.NET Core 3.0開始,.NET獲得了數以千計的新的 "硬體本徵 "方法,其中大部分是對映到這些SIMD指令之一的.NET API。這些內在因素使專家能夠編寫一個針對特定指令集的實現,如果做得好,可以獲得最好的效能,但這也要求開發者瞭解每個指令集,併為每個可能相關的指令集實現他們的演演算法,例如,如果支援AVX2實現,或支援SSE2實現,或支援ArmBase實現,等等。
.NET 7引入了一箇中間地帶。以前的版本引入了Vector128
using System.Runtime.CompilerServices;
using System.Runtime.Intrinsics;
using System.Runtime.Intrinsics.X86;
internal class Program
{
private static void Main()
{
Vector128<byte> v = Vector128.Create((byte)123);
while (true)
{
WithIntrinsics(v);
WithVector(v);
}
}
[MethodImpl(MethodImplOptions.NoInlining)]
private static int WithIntrinsics(Vector128<byte> v) => Sse2.MoveMask(v);
[MethodImpl(MethodImplOptions.NoInlining)]
private static uint WithVector(Vector128<byte> v) => v.ExtractMostSignificantBits();
}
我有兩個函數:一個直接使用Sse2.MoveMask硬體本徵,一個使用新的Vector128
; Assembly listing for method Program:WithIntrinsics(Vector128`1):int
G_M000_IG01: ;; offset=0000H
C5F877 vzeroupper
G_M000_IG02: ;; offset=0003H
C5F91001 vmovupd xmm0, xmmword ptr [rcx]
C5F9D7C0 vpmovmskb eax, xmm0
G_M000_IG03: ;; offset=000BH
C3 ret
; Total bytes of code 12
; Assembly listing for method Program:WithVector(Vector128`1):int
G_M000_IG01: ;; offset=0000H
C5F877 vzeroupper
G_M000_IG02: ;; offset=0003H
C5F91001 vmovupd xmm0, xmmword ptr [rcx]
C5F9D7C0 vpmovmskb eax, xmm0
G_M000_IG03: ;; offset=000BH
C3 ret
; Total bytes of code 12
注意到什麼了嗎?這兩種方法的程式碼是相同的,都會產生一條vpmovmskb(移動位元組掩碼 (Move Byte Mask))指令。然而,前者的程式碼只能在支援SSE2的平臺上工作,而後者的程式碼可以在任何支援128位元向量的平臺上工作,包括Arm64和WASM(以及未來任何支援SIMD的平臺);只是在這些平臺上發出的指令不同。
為了進一步探討這個問題,讓我們舉一個簡單的例子,並將其向量化。我們將實現一個包含方法,我們想在一個位元組的範圍內搜尋一個特定的值,並返回是否找到它。
static bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
for (int i = 0; i < haystack.Length; i++)
{
if (haystack[i] == needle)
{
return true;
}
}
return false;
}
我們如何用Vector
static bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
if (Vector.IsHardwareAccelerated && haystack.Length >= Vector<byte>.Count)
{
// ...
}
else
{
for (int i = 0; i < haystack.Length; i++)
{
if (haystack[i] == needle)
{
return true;
}
}
}
return false;
}
現在我們知道我們有足夠的資料,我們可以開始為我們的向量迴圈編碼了。在這個迴圈中,我們將搜尋針,這意味著我們需要一個每個元素都包含該值的向量;Vector
static unsafe bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
if (Vector.IsHardwareAccelerated && haystack.Length >= Vector<byte>.Count)
{
fixed (byte* haystackPtr = &MemoryMarshal.GetReference(haystack))
{
Vector<byte> target = new Vector<byte>(needle);
byte* current = haystackPtr;
byte* endMinusOneVector = haystackPtr + haystack.Length - Vector<byte>.Count;
do
{
if (Vector.EqualsAny(target, *(Vector<byte>*)current))
{
return true;
}
current += Vector<byte>.Count;
}
while (current < endMinusOneVector);
// ...
}
}
else
{
for (int i = 0; i < haystack.Length; i++)
{
if (haystack[i] == needle)
{
return true;
}
}
}
return false;
}
而我們幾乎已經完成了。最後要處理的問題是,我們可能在最後還有一些元素沒有搜尋到。我們有幾種方法可以處理這個問題。一種是繼續執行我們的後退方案,並逐個處理剩餘的元素。另一種方法是採用向量非同步操作時常見的技巧。我們的操作沒有改變任何東西,這意味著如果我們多次比較同一個元素也沒有關係,這意味著我們可以只對搜尋空間中的最後一個向量做最後的向量比較;這可能與我們已經看過的元素重疊,也可能不重疊,但即使重疊也不會有什麼影響。就這樣,我們的實現就完成了。
static unsafe bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
if (Vector.IsHardwareAccelerated && haystack.Length >= Vector<byte>.Count)
{
fixed (byte* haystackPtr = &MemoryMarshal.GetReference(haystack))
{
Vector<byte> target = new Vector<byte>(needle);
byte* current = haystackPtr;
byte* endMinusOneVector = haystackPtr + haystack.Length - Vector<byte>.Count;
do
{
if (Vector.EqualsAny(target, *(Vector<byte>*)current))
{
return true;
}
current += Vector<byte>.Count;
}
while (current < endMinusOneVector);
if (Vector.EqualsAny(target, *(Vector<byte>*)endMinusOneVector))
{
return true;
}
}
}
else
{
for (int i = 0; i < haystack.Length; i++)
{
if (haystack[i] == needle)
{
return true;
}
}
}
return false;
}
恭喜你,我們對這個操作進行了向量處理,而且處理得相當好。我們可以把它扔到benchmarkdotnet中,看到非常好的速度。
private byte[] _data = Enumerable.Repeat((byte)123, 999).Append((byte)42).ToArray();
[Benchmark(Baseline = true)]
[Arguments((byte)42)]
public bool Find(byte value) => Contains(_data, value); // just the fallback path in its own method
[Benchmark]
[Arguments((byte)42)]
public bool FindVectorized(byte value) => Contains_Vectorized(_data, value); // the implementation we just wrote
方法 | 平均值 | 比率 |
---|---|---|
Find | 484.05 ns | 1.00 |
FindVectorized | 20.21 ns | 0.04 |
24倍的提速! 嗚呼,勝利,你所有的表現都屬於我們!
你在你的服務中部署了這個,你看到蘄春在你的熱路徑上被呼叫,但你沒有看到你所期望的改進。你再深入研究一下,你發現雖然你是用一個有1000個元素的輸入陣列來測試的,但典型的輸入有30個元素。如果我們改變我們的基準,只有30個元素,會發生什麼?這還不足以形成一個向量,所以我們又回到了一次一個的路徑,而且我們根本沒有得到任何速度的提升。
我們現在可以做的一件事是從使用Vector
static unsafe bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
if (Vector128.IsHardwareAccelerated && haystack.Length >= Vector128<byte>.Count)
{
ref byte current = ref MemoryMarshal.GetReference(haystack);
Vector128<byte> target = Vector128.Create(needle);
ref byte endMinusOneVector = ref Unsafe.Add(ref current, haystack.Length - Vector128<byte>.Count);
do
{
if (Vector128.EqualsAny(target, Vector128.LoadUnsafe(ref current)))
{
return true;
}
current = ref Unsafe.Add(ref current, Vector128<byte>.Count);
}
while (Unsafe.IsAddressLessThan(ref current, ref endMinusOneVector));
if (Vector128.EqualsAny(target, Vector128.LoadUnsafe(ref endMinusOneVector)))
{
return true;
}
}
else
{
for (int i = 0; i < haystack.Length; i++)
{
if (haystack[i] == needle)
{
return true;
}
}
}
return false;
}
有了這一點,我們現在可以在我們較小的30個元素的資料集上試試。
private byte[] _data = Enumerable.Repeat((byte)123, 29).Append((byte)42).ToArray();
[Benchmark(Baseline = true)]
[Arguments((byte)42)]
public bool Find(byte value) => Contains(_data, value);
[Benchmark]
[Arguments((byte)42)]
public bool FindVectorized(byte value) => Contains_Vectorized(_data, value);
方法 | 平均值 | 比率 |
---|---|---|
Find | 15.388 ns | 1.00 |
FindVectorized | 1.747 ns | 0.11 |
嗚呼,勝利,你所有的表現都屬於我們......再次!
再大的資料集上呢?之前用Vector
方法 | 平均值 | 比率 |
---|---|---|
Find | 484.25 ns | 1.00 |
FindVectorized | 32.92 ns | 0.07 |
...更接近於15倍。這沒什麼好奇怪的,但它不是我們以前看到的24倍。如果我們想把蛋糕也吃了呢?讓我們也新增一個Vector256
static unsafe bool Contains(ReadOnlySpan<byte> haystack, byte needle)
{
if (Vector128.IsHardwareAccelerated && haystack.Length >= Vector128<byte>.Count)
{
ref byte current = ref MemoryMarshal.GetReference(haystack);
if (Vector256.IsHardwareAccelerated && haystack.Length >= Vector256<byte>.Count)
{
Vector256<byte> target = Vector256.Create(needle);
ref byte endMinusOneVector = ref Unsafe.Add(ref current, haystack.Length - Vector256<byte>.Count);
do
{
if (Vector256.EqualsAny(target, Vector256.LoadUnsafe(ref current)))
{
return true;
}
current = ref Unsafe.Add(ref current, Vector256<byte>.Count);
}
while (Unsafe.IsAddressLessThan(ref current, ref endMinusOneVector));
if (Vector256.EqualsAny(target, Vector256.LoadUnsafe(ref endMinusOneVector)))
{
return true;
}
}
else
{
Vector128<byte> target = Vector128.Create(needle);
ref byte endMinusOneVector = ref Unsafe.Add(ref current, haystack.Length - Vector128<byte>.Count);
do
{
if (Vector128.EqualsAny(target, Vector128.LoadUnsafe(ref current)))
{
return true;
}
current = ref Unsafe.Add(ref current, Vector128<byte>.Count);
}
while (Unsafe.IsAddressLessThan(ref current, ref endMinusOneVector));
if (Vector128.EqualsAny(target, Vector128.LoadUnsafe(ref endMinusOneVector)))
{
return true;
}
}
}
else
{
for (int i = 0; i < haystack.Length; i++)
{
if (haystack[i] == needle)
{
return true;
}
}
}
return false;
}
然後,轟隆一聲,我們回來了。
方法 | 平均值 | 比率 |
---|---|---|
Find | 484.53 ns | 1.00 |
FindVectorized | 20.08 ns | 0.04 |
我們現在有一個在任何具有128位元或256位向量指令的平臺上向量化的實現(x86、x64、Arm64、WASM等),它可以根據輸入長度使用其中之一,如果有興趣的話,它可以被包含在R2R影象中。
有很多因素會影響你走哪條路,我希望我們會有指導意見,以幫助駕馭所有的因素和方法。但是能力都在那裡,無論你選擇使用Vector
我已經提到了幾個暴露了新的跨平臺向量支援的PR,但這只是觸及了為實際啟用這些操作並使其產生高質量程式碼所做工作的表面。作為這類工作的一個例子,有一組修改是為了幫助確保零向量常數得到良好的處理,比如dotnet/runtime#63821將Vector128/256
內聯是JIT可以做的最重要的優化之一。這個概念很簡單:與其呼叫某個方法,不如從該方法中獲取程式碼並將其烘烤到呼叫位置。這有一個明顯的優勢,就是避免了方法呼叫的開銷,但是除了在非常熱的路徑上的非常小的方法,這往往是內聯帶來的較小的優勢。更大的勝利是由於被呼叫者的程式碼被暴露給呼叫者的程式碼,反之亦然。例如,如果呼叫者將一個常數作為引數傳遞給被呼叫者,如果該方法沒有被內聯,被呼叫者的編譯就不知道這個常數,但是如果被呼叫者被內聯,被呼叫者的所有程式碼就知道它的引數是一個常數,並且可以對這樣一個常數進行所有可能的優化,比如消除死程式碼、消除分支、常數摺疊和傳播,等等。當然,如果這一切都是彩虹和獨角獸,所有可能被內聯的東西都會被內聯,但這顯然不會發生。內聯帶來的代價是可能增加二進位制的大小。如果被內聯的程式碼在呼叫者中會產生與呼叫被呼叫者相同或更少的組合程式碼(如果JIT能夠快速確定這一點),那麼內聯就是一個沒有問題的事情。但是,如果被內聯的程式碼會不經意地增加被呼叫者的大小,現在JIT需要權衡程式碼大小的增加和可能帶來的吞吐量優勢。由於增加了要執行的不同指令的數量,從而給指令快取帶來了更大的壓力,程式碼大小的增加本身就可能導致吞吐量的下降。就像任何快取一樣,你需要從記憶體中讀取的次數越多,快取的效果就越差。如果你有一個被內聯到100個不同的呼叫點的函數,這些呼叫點的每一個被呼叫者的指令副本都是獨一無二的,呼叫這100個函數中的每一個最終都會使指令快取受到影響;相反,如果這100個函數都通過簡單地呼叫被呼叫者的單一範例來 "共用 "相同的指令,那麼指令快取可能會更有效,並導致更少的記憶體存取。
所有這些都說明,內聯真的很重要,重要的是 "正確 "的東西被內聯,而且不能過度內聯,因此,在最近的記憶中,每一個.NET版本都圍繞內聯進行了很好的改進。.NET 7也不例外。
圍繞內聯的一個真正有趣的改進是dotnet/runtime#64521,它可能是令人驚訝的。考慮一下Boolean.ToString方法;這裡是它的完整實現。
public override string ToString()
{
if (!m_value) return "False";
return "True";
}
很簡單,對嗎?你會期望這麼簡單的東西能被內聯。唉,在.NET 6上,這個基準。
private bool _value = true;
[Benchmark]
public int BoolStringLength() => _value.ToString().Length;
產生這個組合程式碼。
; Program.BoolStringLength()
sub rsp,28
cmp [rcx],ecx
add rcx,8
call System.Boolean.ToString()
mov eax,[rax+8]
add rsp,28
ret
; Total bytes of code 23
請注意對System.Boolean.ToString()的呼叫。其原因是,從歷史上看,JIT不能跨組合邊界內聯方法,如果這些方法包含字串字面(如Boolean.ToString實現中的 "False "和 "True")。這一限制與字串互譯有關,而且這種內聯可能會導致可見的行為差異。這些顧慮已不再有效,因此本PR刪除了這一限制。因此,在.NET 7上的同一基準測試現在產生了以下結果。
; Program.BoolStringLength()
cmp byte ptr [rcx+8],0
je short M00_L01
mov rax,1DB54800D20
mov rax,[rax]
M00_L00:
mov eax,[rax+8]
ret
M00_L01:
mov rax,1DB54800D18
mov rax,[rax]
jmp short M00_L00
; Total bytes of code 38
不再呼叫System.Boolean.ToString()。
dotnet/runtime#61408做了兩個與內聯有關的修改。首先,它教會了內聯程式如何更好地看到內聯候選程式中正在呼叫的方法,特別是當分層編譯被禁用或一個方法將繞過第0層時(例如在OSR存在之前或OSR被禁用時帶有迴圈的方法);通過了解正在呼叫的方法,它可以更好地理解方法的成本,例如,如果這些方法呼叫實際上是成本很低的硬體內含物。第二,它在更多有SIMD向量的情況下啟用CSE。
dotnet/runtime#71778也影響了內聯,特別是在typeof()可以傳播給被呼叫者的情況下(例如通過方法引數)。在以前的.NET版本中,Type上的各種成員(如IsValueType)被轉化為JIT的內在因素,這樣JIT就可以為那些可以在編譯時計算出答案的呼叫替換一個常數值。例如,這個。
[Benchmark]
public bool IsValueType() => IsValueType<int>();
private static bool IsValueType<T>() => typeof(T).IsValueType;
在.NET 6上的這個組合程式碼的結果是
; Program.IsValueType()
mov eax,1
ret
; Total bytes of code 6
然而,稍微改變一下基準。
[Benchmark]
public bool IsValueType() => IsValueType(typeof(int));
private static bool IsValueType(Type t) => t.IsValueType;
而不再是那麼簡單了。
; Program.IsValueType()
sub rsp,28
mov rcx,offset MT_System.Int32
call CORINFO_HELP_TYPEHANDLE_TO_RUNTIMETYPE
mov rcx,rax
mov rax,[7FFCA47C9560]
cmp [rcx],ecx
add rsp,28
jmp rax
; Total bytes of code 38
實際上,作為內聯的一部分,JIT失去了引數是一個常數的概念,並且未能傳播它。這個PR修復了這個問題,因此在.NET 7上,我們現在可以得到我們所期望的。
; Program.IsValueType()
mov eax,1
ret
; Total bytes of code 6
Performance Improvements in .NET 7
本作品採用知識共用署名-非商業性使用-相同方式共用 4.0 國際許可協定進行許可。
歡迎轉載、使用、重新發布,但務必保留文章署名 鄭子銘 (包含連結: http://www.cnblogs.com/MingsonZheng/ ),不得用於商業目的,基於本文修改後的作品務必以相同的許可釋出。
如有任何疑問,請與我聯絡 ([email protected])