SIMD via C#
簡(jiǎn)介 TL;DR
我們?yōu)镃#(準(zhǔn)確地說(shuō)是.NET Core)引入了一套全新的機(jī)制,使得C# 以后可以像C/C++ 一樣直接使用intrinsic functions 來(lái)直接操作Intel CPU 的大多數(shù)SIMD 指令了(從SSE 到AVX2)。
(注意是以后!這個(gè)項(xiàng)目還沒(méi)有完成!)
Vectors in .NET
在最開始我想先說(shuō)一說(shuō)SIMD 編程在C#/.NET 中的現(xiàn)狀,以及為什么我們要引入這套全新的intrinsic。
微軟在之前的.NET Framework 和.NET Core 中引入了一個(gè)新的庫(kù):?System.Numerics.Vectors?,其中包含幾個(gè)重要的值類型(Vector<T>,?Vector2,?Vector3, 等等)和操作它們的一些靜態(tài)方法。程序員可以用這個(gè)庫(kù)在.NET 環(huán)境中編寫SIMD 程序。以下我假定大家都大概知道SIMD 編程的概念,來(lái)具體講講這個(gè)庫(kù) 的設(shè)計(jì)與實(shí)現(xiàn)。
System.Numerics.Vectors?庫(kù)中的這些靜態(tài)方法的實(shí)際功能不能用C# 等.NET managed language 直接寫出來(lái)(雖然它們都有一份C# 的實(shí)現(xiàn)),而是由編譯器特殊對(duì)待從而生成特殊代碼(SSE, AVX, AVX2, 等指令集的指令),我們稱這些方法(函數(shù))為intrinsic。這些intrinsic 大部分都操作在上面說(shuō)到的這些值類型上(Vector<T>,?Vector2,?Vector3, 等等),這些類型的實(shí)例也會(huì)被編譯器特殊對(duì)待。其中最主要的是Vector<T>,這個(gè)類型的設(shè)計(jì)不同于傳統(tǒng)C/C++ intrinsic?中的vector 類型:
泛型:.NET 中的這個(gè)vector類型采用了泛型設(shè)計(jì),泛型Vector<T>的類型參數(shù)只接受numeric types,即C# 的基礎(chǔ)數(shù)字類型(byte, sbyte, short, ushort, long, ulong, float, double)。如果試圖創(chuàng)建一個(gè)Vector<UserDefinedStruct>的實(shí)例,運(yùn)行時(shí)會(huì)拋出異常(一大波來(lái)自Haskell 的鄙視正在路上……)。
長(zhǎng)度可變(length-agnostic):大家都知道隨著微處理器歷史的發(fā)展SIMD 計(jì)算單元和寄存器的長(zhǎng)度也在不斷地進(jìn)化,Intel 從最初MMX 的64-bit 寄存器到后來(lái)SSE 系列128-bit 寄存器,再到AVX 擴(kuò)展為256-bit,最新的AVX-512 已經(jīng)有了512-bit 的SIMD 寄存器。C/C++ intrinsic 使用不同長(zhǎng)度的vector 類型來(lái)抽象這些SIMD 寄存器,比如__m128,?__m256d。然而借助.NET 的JIT 編譯,Vector<T>?的長(zhǎng)度可以隨著程序運(yùn)行的硬件環(huán)境的不同而改變,例如一個(gè)使用了System.Numerics.Vectors?來(lái)加速的程序在Sandy Bridge 等稍微老一點(diǎn)的CPU 上看到Vector<byte>?的長(zhǎng)度為16,而同一個(gè)程序運(yùn)行在Haswell 以上的新CPU 上看到的Vector<byte>?的長(zhǎng)度為32,但程序行為保持不變,并且開發(fā)者也不需要重新編譯他們的源碼就可以得到更多的提速。這個(gè)設(shè)計(jì)乍一看起來(lái)非常酷,但是也為這個(gè)庫(kù)的命運(yùn)埋下了巨大的隱患。
System.Numerics.Vectors 的缺陷
System.Numerics.Vectors?庫(kù)的設(shè)計(jì)初衷是要做一個(gè)跨平臺(tái)的通用的SIMD 編程庫(kù)。可以看出它的最終目標(biāo)是要在統(tǒng)一的API 下支持不同的硬件指令集(SSE, AVX, NEON, etc.),雖然現(xiàn)在只做了x86/x64 平臺(tái)的支持,但一些設(shè)計(jì)缺陷已經(jīng)暴露出來(lái)了。
當(dāng)『通用』成為設(shè)計(jì)目的時(shí),『可用』成了重中之重。眾所周知,SIMD 編程或者叫向量化編程相對(duì)來(lái)說(shuō)是比較困難的,當(dāng)一個(gè)程序想使用SIMD 來(lái)加速時(shí)開發(fā)者關(guān)注的第一點(diǎn)肯定是『性能』。然而這個(gè)『通用』和『可用』的設(shè)計(jì)目的并不能保證『性能』。舉個(gè)最簡(jiǎn)單的例子,不同硬件提供的指令集一般在功能上是不會(huì)完全重合的,當(dāng)一個(gè)指令在Intel CPU 上存在而在ARM CPU 上不存在的時(shí)候,通用SIMD 庫(kù)就要想辦法繞著彎來(lái)在不直接提供支持的硬件上實(shí)現(xiàn)這個(gè)API。然而這個(gè)『彎兒』一旦開始繞了,性能提升就不能保證了(在一些極端情況下不繞彎都不能保證)。試想一個(gè)程序員發(fā)現(xiàn)一個(gè)函數(shù)foo在他的程序中調(diào)用非常頻繁,并且可以被向量化,于是欣喜地使用Vectors<T>?重寫了。然后他發(fā)現(xiàn)整個(gè)程序在他裝備了Skylake CPU的 Macbook Pro 上性能提升了50%,但在發(fā)布新版本幾天后所有ARM 用戶全來(lái)罵娘了(這只是個(gè)例子,性能退化在所有硬件平臺(tái)之間都有可能出現(xiàn),不是針對(duì)某些硬件架構(gòu))。以下列出的其他缺陷都或多或少來(lái)自這一條設(shè)計(jì)原則。
可變長(zhǎng)度的Vector<T>?上無(wú)法抽象某些硬件指令的語(yǔ)義。比如很重要的shuffle?這族指令就沒(méi)法抽象到變長(zhǎng)Vector<T>, Github 已經(jīng)有人多次要求提供這些API,但最終都沒(méi)有很好的解決方案。再比如,對(duì)于AVX/AVX2 來(lái)說(shuō),很多時(shí)候我們需要同時(shí)操作YMM 和XMM 寄存器,但這在Vector<T>?的設(shè)計(jì)中不被允許。
System.Numerics.Vectors?中的類型和函數(shù)在JIT 編譯器不支持生成SIMD 指令的環(huán)境下會(huì)退回到C# 的軟件實(shí)現(xiàn)。這點(diǎn)對(duì)性能是很致命的,尤其是有些時(shí)候這種『不支持生成SIMD 指令的環(huán)境』是不可避免的,比如反射調(diào)用。
還有很多細(xì)節(jié)性的缺點(diǎn)我就不一一列舉了,比較這篇文章重點(diǎn)不在System.Numerics.Vectors。有興趣的同學(xué)可以去CoreCLR 和CoreFX 的GitHub repo 翻一翻相關(guān)的issue。
Intel Hardware Intrinsic
說(shuō)了那么多終于進(jìn)入正題了。為了探索一個(gè)新的SIMD 方案,我代表牙膏廠為.NET 提供了API Proposal: Add Intel hardware intrinsic functions and namespace #22940。總體的設(shè)計(jì)都在這份API proposal 里了,我簡(jiǎn)單總結(jié)一下:
加入兩個(gè)新的namespace:System.Runtime.Intrinsics和System.Runtime.Intrinsics.X86。其中System.Runtime.Intrinsics只包含跨平臺(tái)類型,目前有兩個(gè)新的值類型Vector128<T>和Vector256<T>?來(lái)抽象SIMD 寄存器。每個(gè)硬件平臺(tái)提供各自平臺(tái)相關(guān)的類型和方法用來(lái)操作Vector128<T>和Vector256<T>,比如x86 平臺(tái)的所有intrinsic 都在System.Runtime.Intrinsics.X86namespace 下,它提供了在managed language 中直接訪問(wèn)以下指令集的能力:SSE, SSE2, SSE3, SSSE3, SSE4.1, SSE4.2, AVX, AVX2, FMA, AES, BMI1, BMI2, LZCNT, POPCNT, PCLMULQDQ。
每一個(gè)指令集封裝成一個(gè)static class(例如Sse,Aes,?Avx2, 等.),每個(gè)class 都有一個(gè)IsSupported?方法用來(lái)檢測(cè)當(dāng)前硬件,從而為不同的硬件提供不同的優(yōu)化方案。
| | if (Avx2.IsSupported) { // 為AVX2 CPU 優(yōu)化的算法 ? } else if (Sse41.IsSupported) { // 為SSE4.1 CPU 優(yōu)化的算法 } else if (Neon.IsSupported) { // 為ARM NEON CPU 優(yōu)化的算法 } else { // software-fallback } |
要求一個(gè)新的C# 語(yǔ)言特性,const?參數(shù)。因?yàn)镮ntel hardware intrinsic 直接通過(guò)C# 代碼來(lái)控制最終的代碼生成,而一些SIMD 指令明確要求立即數(shù)操作數(shù)。比如shufpd?對(duì)應(yīng)的C# intrinsic 是
| 1 | public static Vector128<double> Shuffle(Vector128<double> left, Vector128<double> right, byte control); |
參數(shù)control?對(duì)應(yīng)shufpd?的imm8?操作數(shù),它必須是編譯時(shí)確定的,如果用戶傳入一個(gè)『變量』可能導(dǎo)致程序無(wú)法編譯。所以我們向C# 語(yǔ)言特性的開發(fā)組請(qǐng)求了一個(gè)新的語(yǔ)言特性:將const?關(guān)鍵字用于方法的形式參數(shù)。最終Shuffle?的方法簽名為:
| 1 | public static Vector128<double> Shuffle(Vector128<double> left, Vector128<double> right, const byte control); |
這樣C# 編譯器(Roslyn)就只允許byte 字面量值流入control?參數(shù)。
Intel hardware intrinsic 在.NET Core 中所有環(huán)境下都會(huì)被編譯為直接對(duì)應(yīng)的硬指令,比如JIT編譯、AOT編譯(Crossgen)、MSCorlib 內(nèi)部調(diào)用(比如用來(lái)優(yōu)化String)、Debugging 調(diào)用、反射調(diào)用等等。而相對(duì)的System.Numerics.Vectors?只能在第三方JIT 編譯的普通調(diào)用中才會(huì)生成SIMD 指令。
具體的API 請(qǐng)移步?https://github.com/dotnet/coreclr/tree/master/src/mscorlib/src/System/Runtime/Intrinsics
.NET Managed Intrinsic 與C/C++ Native Intrinsic
如果有SIMD 編程經(jīng)驗(yàn)的讀者看到這里一定會(huì)覺(jué)得我們做的這套新的intrinsic 和Intel C/C++ intrinsic?很相似。對(duì),這套新的hardware intrinsic 是比原先System.Numerics.Vectors?更偏底層的一套intrinsic 機(jī)制,我們希望可以通過(guò)managed language (目前只有C#)來(lái)直接對(duì)應(yīng)編譯器的代碼生成。然而,他還是有一些區(qū)別于C/C++ intrinsic 的地方。
-
.NET Core 的JIT 編譯為hardware intrinsic 的使用和實(shí)現(xiàn)提供了更大的便利。因?yàn)镃/C++ 都是AOT 編譯的,所以一般在編譯SIMD 程序時(shí)開發(fā)者需要選用不同的編譯器選項(xiàng)來(lái)編譯多分二進(jìn)制分發(fā)文件來(lái)保證各個(gè)在硬件平臺(tái)都達(dá)到最優(yōu)性能。然而JIT 編譯就不會(huì)有這份顧慮,JIT 編譯器會(huì)在啟動(dòng)前自動(dòng)探知當(dāng)前的硬件參數(shù),來(lái)自動(dòng)生成最有性能的代碼。也許有人會(huì)說(shuō).NET Core 也有AOT 編譯啊!可是.NET Core 的AOT 編譯器(Crossgen)依然可以從JIT 編譯器中獲利,比如我們可以AOT 編譯一個(gè)程序的大部分,但留下硬件相關(guān)的代碼,待到運(yùn)行時(shí)再JIT 編譯這些代碼(intrinsic)然后動(dòng)態(tài)插入到原先AOT 編譯好的程序中。
-
當(dāng)然.NET Core 的hardware intrinsic 相比C/C++ 也有劣勢(shì)。一般SIMD 計(jì)算對(duì)內(nèi)存數(shù)據(jù)都有對(duì)齊要求,CoreCLR 卻沒(méi)有提供完整的對(duì)齊內(nèi)存的接口給用戶。但是這一點(diǎn)可以通過(guò)unsafe?代碼(目前所有和內(nèi)存交互的intrinsic 都是操作指針)和后續(xù)的值類型對(duì)齊機(jī)制來(lái)逐漸解決。還有一點(diǎn)就是managed language 對(duì)底層硬件的控制不如native language 靈活。舉個(gè)例子,在C/C++ 中我們可以這么寫代碼來(lái)節(jié)省Load和Store:
1
2
3
// __m256 a, float* b
__m256* v = (__m256*)b;
__m256 result = _mm256_add_ps(a, *v); // vaddps ymm0, ymm0, ymmword ptr [rdi]
上面這段兩行代碼可以只生成一條memory-flavor 的指令,但在C# 中我們不能持有一個(gè)泛型struct 的指針,所以我們必須寫成:
1
2
3
// Vector<float> a, float* b
Vector<float> v = Avx.Load(b);
Vector<float> result = Avx.Add(a, v);
直覺(jué)上這個(gè)程序是兩條指令,但可以被編譯器優(yōu)化折疊為和上面C/C++ 程序一樣的編譯結(jié)果。
小福利
能看到這兒還沒(méi)有關(guān)掉文章的一定是對(duì)SIMD 計(jì)算和編譯器實(shí)現(xiàn)都很有興趣的同學(xué),那我順便放點(diǎn)編譯器實(shí)現(xiàn)的細(xì)節(jié)在這作為堅(jiān)持到最后的獎(jiǎng)勵(lì)。
如果你點(diǎn)進(jìn)了我上面給出的API 連接就會(huì)發(fā)現(xiàn),所有的hardware intrinsic 有一個(gè)C# 的實(shí)現(xiàn):
| | /// <summary> /// __m256 _mm256_add_ps (__m256 a, __m256 b) /// </summary> public static Vector256<float> Add(Vector256<float> left, Vector256<float> right) => Add(left, right); /// <summary> /// __m256d _mm256_add_pd (__m256d a, __m256d b) /// </summary> public static Vector256<double> Add(Vector256<double> left, Vector256<double> right) => Add(left, right); |
每個(gè)intrinsic 在C# API 中都是一個(gè)直接遞歸方法。這是為什么呢?
原因是我們需要intrinsic 在某些環(huán)境下既是intrinsic 又是function(method)。
首先我們可以將在intrinsic 理解為必須內(nèi)聯(lián)的函數(shù)(方法),對(duì)它的調(diào)用會(huì)被直接替換為一條或多條匯編指令,而不遵循普通函數(shù)/方法的調(diào)用約定(calling convention)。然而這一定義在某些情況下是無(wú)法工作的,比如deugger 和反射。例如.NET 在反射機(jī)制中提供了『方法調(diào)用』卻沒(méi)有提供『intrinsic調(diào)用』,那么typeof(Avx).GetMethod("Add").Invoke(null, args)?是無(wú)法工作的。但是我們可以這么做:
在某些環(huán)境中編譯器看到用戶調(diào)用Avx.Add(a, b)?時(shí)不對(duì)其進(jìn)行特殊處理,而只當(dāng)成是普通的函數(shù)調(diào)用。
編譯器如果看到Avx.Add(a, b)?是被自身調(diào)用的(遞歸),則強(qiáng)制將其編譯為相應(yīng)的匯編指令。
這樣,我們就完美解決了intrinsic 既是intrinsic (遞歸調(diào)用)又是function(用戶調(diào)用)的問(wèn)題。
最后
如果大家對(duì)這項(xiàng)功能感興趣,我會(huì)在這里持續(xù)更新項(xiàng)目進(jìn)展,也請(qǐng)大家耐心等候!
原文地址:http://fiigii.com/2017/09/29/SIMD-via-C/
.NET社區(qū)新聞,深度好文,微信中搜索dotNET跨平臺(tái)或掃描二維碼關(guān)注
總結(jié)
以上是生活随笔為你收集整理的SIMD via C#的全部?jī)?nèi)容,希望文章能夠幫你解決所遇到的問(wèn)題。
- 上一篇: C#使用Xamarin开发可移植移动应用
- 下一篇: ASP.NET Core 处理 404