使用.NET Hardware Intrinsics API加速機器學習場景
ML.NET 0.6版本剛剛發布不久,我們知道ML.NET代碼已經依賴於使用本機代碼庫的性能矢量化。這是一個重新實現托管代碼中現有代碼庫的機會,使用.NET Hardware Intrinsics進行矢量化,並比較結果。
什麽是矢量化,什麽是SIMD,SSE和AVX?
矢量化是用於同時將相同操作應用於陣列的多個元素的名稱。在x86 / x64平臺上,可以通過使用單指令多數據(SIMD)CPU指令在類似陣列的對象上操作來實現矢量化。
SSE(Streaming SIMD Extensions)和AVX(Advanced Vector Extensions)是x86架構的SIMD指令集擴展的名稱。
.NET Core 3.0將SIMD指令公開為可直接用於托管代碼的API,從而無需使用本機代碼來訪問它們。
基於ARM的CPU確實提供了類似的內在函數,但.NET Core尚不支持它們(盡管工作正在進行中)。因此,當AVX和SSE都不可用時,必須使用軟件回退代碼路徑。JIT使得以非常有效的方式執行此回退成為可能。當.NET Core確實公開ARM內在函數時,代碼可以利用它們,此時軟件回退很少需要。
項目目標
- 通過使用軟件回退創建單個托管程序集,增加ML.NET平臺範圍(x86,x64,ARM32,ARM64等)
- 通過在可用的情況下使用AVX指令來提高ML.NET性能
- 驗證.NET硬件內在函數API 並演示性能與本機代碼相當
原本可以通過簡單地更新本機代碼來使用AVX指令來實現第二個目標,但是同時轉移到托管代碼我可以消除為每個目標架構構建和發布單獨二進制文件的需要 - 它通常也更容易維護托管代碼。
挑戰
首先要熟悉C#和.NET,然後我的工作包括:
- 用於C#中CPU數學運算的基層實現。如果你不熟悉,請參閱這篇偉大的MSDN雜誌文章C# - All About Span:探索新的.NET Mainstay
- 根據可用性,啟用AVX,SSE和軟件實現之間的切換。
- 正確處理托管代碼中的指針,並刪除某些現有代碼所做的對齊假設
- 使用multitargeting允許ML.NET繼續在沒有.NET Hardware Intrinsics API的平臺上運行。
多目標
.NET Hardware Intrinsics將在.NET Core 3.0中提供,目前正在開發中。ML.NET還需要在.NET Standard 2.0兼容平臺上運行 - 例如.NET Framework 4.7.2和.NET Core 2.1。為了支持這兩者,我選擇使用多目標創建一個同時針對.NET Standard 2.0和.NET Core 3.0的.csproj
文件。
- 在.NET Standard 2.0上,系統將使用具有SSE硬件內在函數的原始本機實現
- 在.NET Core 3.0上,系統將使用帶有AVX硬件內在函數的新托管實現。
代碼之初
在原始代碼中,機器學習中使用的每個培訓師,學習者和轉換最終都稱為包裝器方法,對輸入數組執行CPU數學運算,例如 SseUtils
MatMulDense
,它將兩個密集數組的矩陣乘法解釋為矩陣,和SdcaL1UpdateSparse
,執行稀疏數組的隨機雙坐標上升的更新步驟。
這些包裝器方法假定優先選擇SSE指令,並在另一個類中調用相應的方法,該方法用作托管代碼和本機代碼之間的接口,並包含直接調用其本機等效項的方法。文件中的這些本機方法依次使用包含SSE硬件內在函數的循環實現CPU數學運算。
打破托管代碼路徑
對於這段代碼,我為在.NET Core 3.0上變為活動的CPU數學運算添加了一個新的獨立代碼路徑,並保持原始代碼路徑在.NET Standard 2.0上運行。以前所有方法的調用站點現在都稱為相同名稱的方法,保持CPU數學運算的API簽名相同。 SseUtils
CpuMathUtils
CpuMathUtils
是一個新的分部類,它包含表示CPU數學運算的每個公共API的兩個定義,其中一個僅在.NET Standard 2.0上編譯,而另一個僅在.NET Core 3.0上編譯。此條件編譯功能為方法創建兩個獨立的代碼路徑。在.NET Standard 2.0上編譯的那些函數定義直接調用它們的對應物,它們基本上遵循原始的本機代碼路徑。
用software fallback編寫代碼
另一方面,在.NET Core 3.0上編譯的其他函數定義根據運行時的可用性切換到同一CPU數學運算的三個實現之一:
- 一個用包含AVX硬件內在函數的循環實現操作的方法,
AvxIntrinsics
- 一個用包含SSE硬件內在函數的循環實現操作的方法
SseIntrinsics
- 軟件後備,以防AVX和SSE都不受支持。
每當代碼使用.NET硬件內在函數時,您通常會看到此模式 - 例如,這是向向量添加標量的代碼:
如果支持AVX,則首選,否則使用SSE(如果可用),否則使用軟件回退路徑。在運行時,JIT實際上只為這三個塊中的一個生成代碼,適用於它自己發現的平臺。
為了給你一個想法,這裏的AVX實現看起來像上面的方法調用:
您將註意到它使用AVX以8為一組進行操作,然後使用SSE對任何4組進行操作,最後為任何剩余的進行軟件循環。
由於托管代碼中的和方法直接實現類似於最初在文件中的本機方法的CPU數學運算,因此代碼更改不僅消除了本機依賴性,還簡化了公共API和基礎層硬件內在函數之間的抽象級別。
在進行此替換後,我能夠使用ML.NET執行任務,例如具有隨機雙坐標上升的列車模型,進行超參數調整,以及在Raspberry Pi上執行交叉驗證,此時ML.NET需要x86 CPU。
這就是現在架構的樣子(圖1):
性能改進
那麽這對性能有何不同?
我使用Benchmark.NET編寫測試來收集測量數據。
首先,我禁用了AVX代碼路徑,以便在使用相同的SSE指令時公平地比較本機和托管實現。如圖2所示,性能具有可比性:在測試運行的大型向量上,托管代碼添加的開銷並不顯著。
圖2
其次,我啟用了AVX支持。圖3顯示微基準測試的平均性能增益比單獨的SSE高約20%。
圖3
將兩者結合起來 - 從本機代碼中的SSE實現升級到托管代碼中的AVX實現 - 我測量了微基準測試的18%的改進。有些操作的速度提高了42%,而其他一些涉及稀疏輸入的操作則有進一步優化的潛力。
最重要的當然是真實場景的表現。在.NET Core 3.0上,K-means聚類和邏輯回歸的訓練模型加快了約14%(圖4)。
圖4
我希望這已經證明了.NET硬件內在函數的強大功能,並且我鼓勵您在.NET Core 3.0預覽可用時考慮在自己的項目中使用它們的機會。
使用.NET Hardware Intrinsics API加速機器學習場景