NEP 38 — 使用 SIMD 最佳化指令提升效能#

作者:

Sayed Adel, Matti Picus, Ralf Gommers

狀態:

最終

類型:

標準

建立日期:

2019-11-25

決議:

https://mail.python.org/archives/list/numpy-discussion@python.org/thread/PVWJ74UVBRZ5ZWF6MDU7EUSJXVNILAQB/#PVWJ74UVBRZ5ZWF6MDU7EUSJXVNILAQB

摘要#

雖然編譯器在利用硬體特定的常式來最佳化程式碼方面越來越出色,但有時它們無法產生最佳結果。此外,我們希望能夠將二進位最佳化的 C 擴充模組從一部機器複製到另一部具有相同基礎架構 (x86、ARM 或 PowerPC) 但功能不同的機器,而無需重新編譯。

我們在 ufunc 機制中有一種機制可以建置由 CPU 功能名稱索引的替代迴圈。在匯入時(在 InitOperators 中),會從候選者中選擇與執行階段 CPU 資訊相符的迴圈函數。此 NEP 提出了一種機制,以在此基礎上針對更多功能和架構進行建置。提議的步驟如下:

  • 建立一組定義完善、與架構無關的通用內建函數,以捕捉跨架構可用的功能。

  • 將這些通用內建函數擷取到一組 C 巨集中,並使用這些巨集來建置程式碼路徑,以涵蓋從基準到該架構上可用的最大功能集的功能集。將這些作為數量有限的已編譯替代程式碼路徑提供。

  • 在執行階段,探索哪些 CPU 功能可用,並相應地從可能的程式碼路徑中選擇。

動機與範疇#

傳統上,NumPy 依賴編譯器來產生專門針對目標架構的最佳程式碼。然而,如今很少有使用者在本機為其機器編譯 NumPy。大多數人使用二進位套件,這些套件必須為最低公分母 CPU 架構提供執行階段支援。因此,NumPy 無法利用其 CPU 處理器的更進階功能,因為這些功能可能並非在所有使用者的系統上都可用。

傳統上,CPU 功能是透過內建函數公開的,這些內建函數是編譯器特定的指令,直接對應到組合語言指令。最近,有人討論新增更多內建函數的有效性(例如,gh-11113 針對浮點數的 AVX 最佳化)。過去,NumPy 中新增了針對快速 avx512 常式的架構特定程式碼,用於各種 ufunc 中,並使用上述機制來選擇最適合該架構的迴圈。然而,該程式碼不通用,也無法推廣到其他架構。

最近,OpenCV 轉向使用硬體抽象層 (HAL) 中的通用內建函數,這為常見的共用單指令多資料 (SIMD) 建構提供了良好的抽象化。此 NEP 針對 NumPy 提出了類似的機制。使用此機制分為三個階段:

  • 程式碼中為抽象內建函數提供基礎架構。ufunc 機制將使用這些抽象內建函數集進行擴充,以便將單個 ufunc 表示為一組迴圈,從最小到最大可能可用的內建函數集。

  • 在編譯時,編譯器巨集和 CPU 偵測用於將抽象內建函數轉換為具體內建函數呼叫。平台上不可用的任何內建函數,無論是因為 CPU 不支援它們(因此無法測試)還是因為抽象內建函數在平台上沒有平行的具體內建函數,都不會產生錯誤,而是不會產生相應的迴圈並將其新增到可能性集中。

  • 在執行階段,CPU 偵測程式碼將進一步限制可用的迴圈集,並為 ufunc 選擇最佳迴圈。

目前的 NEP 僅建議將執行階段功能偵測和最佳迴圈選擇機制用於 ufunc。未來的 NEP 可能會針對提議的解決方案提出其他用途。

ufunc 機制已經能夠在執行階段為特定可用的 CPU 功能選擇最佳迴圈,目前用於 avx2fmaavx512f 迴圈(在產生的 __umath_generated.c 檔案中);通用內建函數將擴充產生的程式碼,以包含更多迴圈變體。

使用方式與影響#

最終使用者將能夠取得適用於其平台和編譯器的內建函數清單。或者,使用者可以指定將使用執行階段可用的哪些迴圈,或許可以透過環境變數來啟用基準測試不同迴圈的影響。對於不熟悉的最終使用者來說,應該不會有直接的影響,所有迴圈的結果都應該在一個小數字 (1-3?) ULP 內相同。另一方面,擁有更強大機器的使用者應該會注意到效能顯著提升。

二進位發行版本 - PyPI 上的 wheel 和 conda 套件#

此流程發行的二進位檔案將會更大,因為它們包含該架構的所有可能迴圈。某些封裝者可能更喜歡限制迴圈數量,以限制二進位檔案的大小,我們希望他們仍然支援廣泛的架構系列。請注意,Intel MKL 產品中已經存在此問題,其中二進位套件包含大量適用於各種 CPU 替代方案的替代共用物件 (DLL)。

原始碼建置#

請參閱下方的「詳細描述」。在封裝者知道目標機器的詳細資訊的原始碼建置中,理論上可以透過選擇僅編譯目標所需的迴圈,透過命令列引數產生更小的二進位檔案。

如何執行基準測試以評估效能優勢#

新增更多使用內建函數的程式碼會使程式碼更難維護。因此,只有在產生顯著的效能優勢時,才應新增此類程式碼。評估此效能優勢可能並非易事。為了協助實現這一點,此 NEP 的實作將新增一種方法,可以透過環境變數在執行階段選擇可以使用哪些指令集。(名稱待定)。此功能對於 CI 程式碼驗證至關重要。

診斷#

新的字典 __cpu_features__ 將可供 python 使用。鍵是可用的功能,值是布林值,指示該功能是否可用。各種新的私有 C 函數將在內部用於查詢可用功能。這些可能會透過特定的 c 擴充模組公開以進行測試。

新增新的 CPU 架構特定最佳化的工作流程#

對於任何可能是 SIMD 向量化的候選程式碼,NumPy 將始終具有基準 C 實作。如果貢獻者想要為某些架構(通常是他們最感興趣的架構)新增 SIMD 支援,則此註解是關於如何執行此操作的教學課程的開始:numpy/numpy#13516

目前,NumPy 針對許多 ufunc 具有許多 avx512favx2fma SIMD 迴圈。這些可能是第一個要移植到通用內建函數的候選對象。預期新的實作可能會導致基準測試中的效能衰退,但不會增加二進位檔案的大小。如果效能衰退並非微不足道,我們可能會選擇保留該平台的 X86 特定程式碼,並針對其他平台使用通用內建函數程式碼。

任何使用內建函數實作 ufunc 的新 PR 都應預期使用通用內建函數。如果可以證明使用通用內建函數過於笨拙或效能不足,則也可以接受平台特定程式碼。在極少數情況下,可能會接受僅限單一平台的 PR,但必須在偏好使用通用內建函數的解決方案的框架內對其進行審查。

接受新迴圈的主觀標準是

  • 正確性:即使在演算法的邊緣點,新程式碼的準確性也不得降低超過 1-3 個 ULP。

  • 程式碼膨脹:原始碼大小和已編譯 wheel 的二進位檔案大小。

  • 可維護性:程式碼的可讀性

  • 效能:基準測試必須顯示顯著的效能提升

新增新的內建函數#

如果貢獻者想要使用平台特定的 SIMD 指令,但該指令尚不支援作為通用內建函數,則

  1. 應將其新增為所有平台的通用內建函數

  2. 如果它在其他平台上沒有等效的指令(例如,AVX512 中的 _mm512_mask_i32gather_ps),則不應新增通用內建函數,而應改為編寫平台特定的 ufunc 或簡短的輔助函數。如果使用此類輔助函數,則必須使用功能巨集將其包裝起來,並預設使用合理的非內建函數後備方案。

我們預期 (2) 會是例外情況。貢獻者和維護者應考慮與使用最佳可用通用內建函數實作相比,該單一平台內建函數是否值得。

其他專案的重複使用#

如果 SciPy 或 Astropy 等也建置 ufunc 的其他程式庫可以使用通用內建函數,那就太好了,但這並非此 NEP 首次實作的明確目標。

向後相容性#

應該不會對向後相容性產生影響。

詳細描述#

CPU 特定的內建函數會對應到通用內建函數,這些通用內建函數對於所有 x86 SIMD 變體、ARM SIMD 變體等都是相似的。例如,NumPy 通用內建函數 npyv_load_u32 對應到

  • 適用於 ARM 型 NEON 的 vld1q_u32

  • 適用於 x86 型 AVX2 的 _mm256_loadu_si256

  • 適用於 x86 型 AVX-512 的 _mm512_loadu_si512

任何編寫 SIMD 迴圈的人都將使用 npyv_load_u32 巨集,而不是架構特定的內建函數。程式碼還提供用於編譯和執行階段的防護巨集,以便可以選擇適當的迴圈。

runtests.pysetup.py 提供兩個新的建置選項:--cpu-baseline--cpu-dispatch--cpu-baseline 定義了編譯所需的絕對最低功能。例如,在 x86_64 上,這預設為 SSE3。如果編譯器支援,則將啟用最低功能。--cpu-dispatch 設定了可以偵測到並用作調度需求集的其他內建函數集。例如,在 x86_64 上,這預設為 [SSSE3, SSE41, POPCNT, SSE42, AVX, F16C, XOP, FMA4, FMA3, AVX2, AVX512F, AVX512CD, AVX512_KNL, AVX512_KNM, AVX512_SKX, AVX512_CLX, AVX512_CNL, AVX512_ICL]。這些功能都對應到 c 語言層級的布林陣列 npy__cpu_have,而 c 語言層級的便利函數 npy_cpu_have(int feature_id) 查詢此陣列,並且結果會儲存在執行階段的 __cpu_features__ 中。

匯入 ufunc 時,可用的已編譯迴圈的所需功能會與偵測到的功能相符。最佳匹配的迴圈會被標記為由 ufunc 呼叫。

實作#

目前的 PR

編譯時和執行階段程式碼基礎架構由第一個 PR 提供。第二個新增了基礎架構在迴圈中使用的示範。一旦 NEP 獲得批准,就需要更多工作來使用 NEP 提供的機制編寫迴圈。

替代方案#

gh-13516 中提出的一種替代方案是手動為每個 CPU 架構單獨實作迴圈,而不嘗試抽象化 SIMD 內建函數中的常見模式(例如,具有 loops.avx512.c.srcloops.avx2.c.srcloops.sse.c.srcloops.vsx.c.srcloops.neon.c.src 等)。這更類似於 PIXMAX 的做法。儘管如此,這裡還是有很多重複,而且手動程式碼重複需要一位擁護者,他將專注於實作和維護該平台的迴圈程式碼。

討論#

大多數討論發生在接受此 NEP 的 PR gh-15228 上。郵件清單上的討論提到了 VOLK,已將其新增到相關工作章節中。可維護性問題也在郵件清單和 gh-15228 中提出,並按如下方式解決

  • 如果貢獻者想要利用特定的 SIMD 指令,他們是否也需要為所有其他架構新增此指令的軟體實作?(請參閱工作流程的新內建函數部分)。

  • 驗證所有架構的程式碼和基準測試的負擔落在誰身上?如果新增通用 ufunc 以取代架構特定程式碼有助於一個架構,但損害了另一個架構的效能,會發生什麼情況?(在工作流程的取捨部分中回答)。

參考文獻與註腳#