原文 | Stephen Toub
翻譯 | 鄭子銘
矢量化 (Vectorization)
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
內聯 (Inlining)
內聯是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])