CANN AICPU算子耗時分析及優化探索

摘要:本文以GreaterEqual作爲測試算子,該算子計算邏輯較爲簡單(output = input1 >= input2),旨在儘可能降低計算耗時,使得算子耗時儘可能以數據操作和算子調度作爲主體。

本文分享自華爲雲社區《CANN AICPU算子耗時分析及優化探索》,作者:DavilSu。

1. 分析目的

在實際開發CANN算子的過程中,常常出現算子功能正常,但性能遠低於TensorFlow對標算子的情況。針對這個問題,本文以GreaterEqual作爲測試算子,該算子計算邏輯較爲簡單(output = input1 >= input2),旨在儘可能降低計算耗時,使得算子耗時儘可能以數據操作和算子調度作爲主體。

2. 測試代碼與平臺介紹

本次測試平臺爲OpenLab提供的Ascend服務器,搭載Ascend910A,CANN Toolkit版本號爲5.0.2alpha005。

自研測試代碼參考cac625f243dfe7b04dbb2a82059cd0e4349f77d1這一commit進行修改,該commit針對廣播操作性能進行了優化。自研設置並行閾值:含廣播操作計算爲8K,不含廣播操作計算爲32K。

GreaterEqual的TensorFlow對標算子爲TensorFlow1.15版本算子,canndev對標算子commit爲d660e086717b94b8cfb3f35a8e08046ca0461772,該版本算子嘗試利用Eigen庫的broadcast操作規避canndev源碼倉Bcast性能不足的問題,但未啓用並行計算進行加速。

測試數據我設置了涉及廣播操作和不涉及廣播操作的兩批數據,涉及廣播操作的測試數據又分爲需廣播Tensor的元素個數爲1和元素個數不爲1兩種,測試了int8、int16、int32、int64、uint8、float16、float32、float64共8種TensorFlow對標算子支持的數據類型,每種數據類型分別設置了128B、256B、1K、2K、4K、8K、16K、32K、64K、128K、256K、1M、2M、8M共14個數據規模梯度,詳細數據規模與shape對應關係如下:

3. 單線程性能分析

這一部分旨在測試單線程處理數據CANN算子與TensorFlow算子性能差距。爲避免廣播操作對測試結果產生影響,本次測試數據採用不涉及廣播操作的數據批次。

圖1 單線程耗時比例

可以看出,對於數據量低於2K的小型數據規模,CANN算子相比於TensorFlow有一定性能優勢,但隨着數據量的增加,CANN算子性能出現顯著性能劣化,尤其是uint8這一數據類型,劣化程度十分嚴重,性能劣化高達6.57倍。對於非C++標準的float16這一數據類型,二者均採用Eigen庫中的half數據類型進行代替,測試結果性能較爲接近。

圖2 計算1K數據耗時

我還測試了CANN和TF單核計算16K-8M數據量時,計算1K數據所消耗的時間。

可以看出,TensorFlow隨着數據類型佔用空間的增大,耗時也成比例的相應增加。而奇怪的是,CANN的int8、uint8耗時與int16相近,這一特點同樣體現在耗時比例int8和uint8的性能劣化程度遠高於其他數據類型,猜測有可能是因爲int8和uint8是擴展至16位再進行計算。CANN在float32和float64這兩個數據類型的表現也十分奇怪,隨着數據量的增加,耗時發生了較大波動。具體情況在向量化代碼與性能分析部分嘗試進行了分析優化。

4. 自研算子與主倉已實現算子性能對比

Canndev主倉GreaterEqual算子,嘗試利用Eigen庫的broadcast操作規避canndev源碼倉廣播性能不足的問題,但未啓用並行計算進行加速。自研算子使用canndev倉中的Bcast類進行廣播,對是否需要廣播的情況進行細化與特殊化,針對不同數據規模設置並行閾值。

本部分分別測試了涉及廣播操作和不涉及廣播操作的兩批數據,旨在測試canndev提供的方法和Eigen提供的broadcast操作性能優劣,及自研算子的性能優勢。

圖3 不含廣播操作耗時比例

圖4 含廣播操作耗時比例

從結果可以看出,當不開啓廣播操作時,自研算子性能全面優於已有算子,小數據量時由於直接操作指針,並未同已有算子通過Eigen的broadcast方法檢查後再進行處理,性能有一定優勢,大數據量由於開啓多線程,性能遠優於已有算子。

但是開啓廣播操作後,由於並行閾值設定在8K,小數據量均同爲單線程處理數據,可見目前CANN的Bcast性能劣於Eigen實現的broadcast,數據量大於8K後,由於多線程的並行處理優勢,自研算子性能遠超已有算子。

TensorFlow實現的廣播操作相比於Eigen實現的broadcast和CANN實現的Bcast均有較大的性能優勢,同爲單線程領先Eigen實現的broadcast 8-26倍,領先CANN則更多。

5. 並行閾值對比

由於參考算子爲廣播優化後的Less算子,我設置了一個對照組,閾值與Less算子的閾值相同(含廣播操作計算爲2K,不含廣播操作計算爲7K),以驗證其並行閾值是否合理。爲避免廣播操作對測試結果產生影響,本次測試數據採用不涉及廣播操作的數據批次。

測試結果如下:

圖5 Less算子閾值和自研算子閾值耗時比例閾值

可見Less算子的並行閾值設置並不合理,在8K數據規模時出現了一個明顯的耗時突增,耗時主體爲並行通訊耗時而非計算,自研算子相對平緩,該閾值由二分法循環測試得出,臨界點並行加速比接近1。

6. 向量化代碼與性能分析

在進行單線程性能分析時,我注意到一個很奇怪的現象,int8與int16耗時十分接近(如圖2),這引起了我的注意,處理器在處理數據時,耗時會與處理的數據爲定點數還是浮點數、數據的位寬、處理數據調用的指令等等因素相關,在處理相同數量的int8與int16數據時,理應int16耗時高於int8。觀察TensorFlow算子執行時間,int8和uint8耗時也小於int16耗時。

現代處理器往往支持SIMD(單指令流多數據流),通過將數據打包在一個向量寄存器中,一個運算指令內執行多個數據的計算,從而實現DLP(Data Level Parallelism),達到加速數據密集型運算的效果。而GreaterEqual算子計算過程不包含分支選擇結構,計算邏輯簡單重複,適合利用SIMD進行加速。

查閱資料發現Ascend910處理器中的AICPU爲16個核心的TaiShan核心,通過系統查詢,支持AArch64指令集,其中也包含了NEON指令集。

我嘗試在C++實現代碼中嵌入彙編代碼來實現手動向量化,性能的確大幅提升。雖然理論上手工向量化能夠實現最高程度的向量化,但由於不同處理器提供的SIMD 擴展指令集各不相同,不同應用程序特徵也複雜多變,SIMD 向量化代碼的可讀性較差,可移植程度較低,並難以進行繼續優化。考慮到未來算子代碼可能需要遷移到x86-64、ARM等不同架構的CPU上,最終選擇編譯器自動生成針對目標處理器SIMD 擴展的向量程序。自動向量化程序員無需關心底層提供的SIMD 擴展部件結構和指令集等問題,只需要把程序中存在的並行性表達清楚,很大程度上解決了高性能代碼可移植性低的問題。

查詢canndev主倉代碼內容,向量化優化相關關鍵詞僅在TFPlugin中出現,檢查CmakeLists.txt的編譯選項僅進行了O2優化。由於編譯AICPU代碼的編譯器爲GCC,通過查閱GCC文檔,O2包含的編譯選項除包含了O1的優化選項外,還包含了以下選項:

可以看到表3中並未包含向量化優化的編譯選項,因此我們通過向CmakeLists.txt中添加-ftree-vectorize(包含-ftree-loop-vectorize和-ftree-slp-vectorize)這一編譯選項來開啓自動向量化,優化結果如下:

圖6 單線程向量化計算1K數據耗時

觀察圖6結果,可以看到單線程進行向量化優化的代碼性能大幅提升。同時我們還可以觀察到,相同符號類型的定點數或浮點數的計算耗時隨着數據位寬的翻倍而成比例的增加,這也對應着SIMD擴展部件的向量寄存器長度是固定的,NEON的向量寄存器長度爲128bit,因此我們設置並行閾值不應該按照元素個數進行設計,而應該按照元素數據總大小來確定。

圖7 FP16開闢臨時變量與否耗時比例

嘗試將Tensor內的half數據轉換爲float後存入臨時開闢的float數組,性能反而劣化,分析原因爲逐元素進行數據類型轉換後賦值的開銷遠大於向量化帶來的性能提升。

圖8 單線程向量化與否耗時比例

圖9 多線程向量化與否對比耗時比例

由圖9可知,經過向量化後,所有C++原生數據類型的性能均已優於TensorFlow算子。

觀察圖10,進行向量化優化後,算子性能得到有效提升,但我們可以看到某些數據類型在數據量爲128K時性能反而不如未進行優化,這裏是因爲向量化優化版代碼並行閾值是按照數據大小進行設定的,這裏可以針對不同數據類型進行更細粒度的並行閾值設定。

圖10 向量化與否含廣播操作(需廣播Tensor的元素個數爲1)耗時比例

我還測試了向量化優化後,單元素做廣播操作的特殊情況,可以看到由於沒有調用廣播操作,而是直接對單個元素指針解引用,編譯器能正確對這種情況實現向量化優化,因此性能也得到了顯著提高。

但遺憾的是,由於需要進行廣播操作時,訪問Tensor中的元素需要調用Bcast類的GetBroadcastXIndex和GetBroadcastYIndex方法來計算廣播操作後的地址偏移量,包含了較爲複雜的計算,編譯器並不能對其進行向量化優化,而開闢臨時空間並賦值的開銷遠大於向量化帶來的性能提升,因此如何優化這個過程還有待研究。

由圖11可知,開啓-ftree-vectorize編譯選項後,編譯器不僅進行了自動SIMD優化,還對循環進行了unroll操作,有利於降低循環開銷,提供指令級並行,優化指令流水線的調度。

對於float16這一數據類型,通過閱讀Eigen庫3.3.9版本源碼,可以看到當計算設備爲CPU時,絕大多數計算(除operator/外)是將其轉換爲float後再進行計算,最後將計算結果轉換爲half數據類型。代碼片段如下:

圖12 Eigen庫中half數據類型operator>=函數定義

這種實現方式涉及到兩次數據類型轉換,且由於不是調用ARM原生數據類型,不能SIMD優化,且不利於循環展開,實際計算效率遠低於其他原生數據類型。

通過查閱ARM架構官方文檔,我發現Armv8.2-A中包括了半精度浮點指令,這避免了與單精度浮點之間的轉換的需要,因此產生了更高性能的代碼。也就說明AICPU完全可以調用數據類型__fp16來實現原生支持半精度浮點數計算。當然,GCC編譯器目前對FP16的支持劣於Clang,目前只能優化類似Add這類操作基本和指令集指令相近的算子,對於GreaterEqual算子,GCC<=11.1是轉成float再進行比較,而Clang>=9.0.0可以生成對應的半精度浮點數的SIMD指令集代碼。

但__fp16是 Arm C語言擴展,在x86-64平臺上,對於FP16,只支持原生存儲,計算都需要將其轉換爲float,GCC7.3無法編譯,Clang可以進行編譯。爲保證代碼的可移植性,並不建議使用這個數據類型。

有沒有高可移植性、高性能的實現方案呢?我在翻閱Eigen更新日誌的時候,發現在2021/04/19更新的Eigen 3.4-rc1版本中,Eigen::half以ARM原生支持的__fp16實現,並且改進了所有後端的向量化支持和ARM在矩陣計算方面對NEON指令集的調度。

圖14 Eigen更新日誌

圖15 Eigen3.4.0 Half.h當架構爲ARM64時對Eigen::half的定義

通過觀察圖16反彙編代碼,可以看出編譯器已成功調用fp16的SIMD指令集指令,Eigen::half生成的代碼基本和__fp16無異,相較於未調用SIMD指令集、未啓用原生fp16的代碼更高效,不僅免去了兩次類型轉換,還提升了一次循環內的計算數據量(SIMD一次計算8個fp16數據,未啓用SIMD指令即便是進行了循環展開,只能在一次循環內計算4個數據,且指令量遠大於優化版本)。

由於個人對友商源碼熟悉程度PyTorch高於TensorFlow,因此對比對象選定爲PyTorch,他們對SIMD進行了部分手動優化,例如在目錄aten/src/ATen/cpu/vec下,封裝了Vectorized類和一系列常用計算函數,一定程度上避免了實現文件中嵌入SIMD函數導致代碼可讀性降低,同時通過一系列環境宏定義判斷目標CPU架構,啓用對應架構的SIMD函數,在自動向量化的基礎上進一步優化實際向量化表現。

圖17 PyTorch aten/src/ATen/cpu/vec/vec256目錄下文件

7. 向量化的侷限性

當然,開啓向量化是完美的麼?當然不是,向量化是有一定的侷限性的。

  1. 目前存在的SIMD擴展部件的向量寄存器長度都是固定的,如果向量寄存器長度過長而循環迭代次數或基本塊內同構語句條數較少,則程序不能被向量化。
  2. SIMD對數據地址連續與否對執行效率有很大影響,當訪存地址不在對齊的邊界上時,則需要進行額外的移位和合並操作,才能得到滿足要求的向量數據。非對齊訪存結構不僅增加了額外的訪存操作,而且增加了特殊的操作(例如移位和合並操作等),才能得到滿足 SIMD 擴展部件要求的向量數據。由於Tensor的數據邏輯地址是對齊的,對於Element-wise類算子,這個問題並沒有產生過大影響。
  3. 一些程序由於其迭代次數不足,或者基本塊內向量並行的語句不夠多,不足以爲向量寄存器提供足夠的並行,需要進行不充分SIMD向量化。
  4. 通過在算子實現代碼中內嵌手寫的彙編代碼或編譯器提供的內函數來添加SIMD指令,理論上手工向量化能夠實現最高程度的向量化,但由於不同處理器提供的SIMD擴展指令集各不相同,會導致代碼的可移植性大幅下降,並難以進行繼續優化。而自動向量化目前對代碼的優化還有一定侷限性。
  5. 循環展開會造成一定程度的代碼膨脹。
  6. ARM的NEON擴展的浮點數計算並沒有完全實現符合IEEE 754標準的浮點運算,尤其是非正則化值會被當做0來處理,爲保證計算精度,在編譯選項不啓用-funsafe-math-optimizations選項時,部分不安全浮點計算的NEON代碼GCC編譯器不會在自動向量化中實現,這也進一步限制了ARM的SIMD性能表現。

8. 總結與優化建議

總結

  1. 按照目前canndev源碼倉的編譯選項,各種數據類型的性能在4K以上數據規模時均和TensorFlow有較大性能差距,且int8和uint8耗時異常,有可能按照16bit進行計算處理。對於Float16的處理canndev和TensorFlow均採用了Eigen庫的half,性能差距在所有數據類型中最小,但是差距比例還是高達1.3x。
  2. 目前canndev源碼倉中的GreaterEqual算子未啓用多核,且未對無需廣播的情況進行特化處理,因此在無需廣播的情況下性能遠低於自研算子。而涉及非單元素的廣播操作時,由於Eigen庫的廣播性能優於canndev的Bcast,小數據量canndev源碼倉中的GreaterEqual算子性能優於自研算子,但隨着數據量增大,開啓多核後,自研算子性能超過源碼倉的算子。
  3. 自研算子參考源碼倉中的Less算子進行設計,兩個算子計算邏輯基本相同,但Less算子設計的並行閾值偏低,導致所有數據類型在8K數據規模時出現一個明顯的耗時波峯,後移並行閾值後情況改善。
  4. 目前canndev主倉的編譯選項並未啓用自動向量化,開啓自動向量化後能被正確向量化的代碼性能大幅提高,且在不啓用-funsafe-math-optimizations編譯選項時,計算精度未出現明顯變化。
  5. 從彙編指令的角度探索了算子代碼向量化情況,Eigen<3.4版本的half數據類型不是通過ARM原生支持的__fp16進行實現,因此無法進行向量化優化,Eigen 3.4-rc1以及之後的版本底層通過__fp16實現,可以正確調用SIMD指令,性能大幅提升。

優化建議

  1. 優化Less算子並行閾值,使臨界數據量並行加速比儘量接近於1。
  2. 開啓編譯器自動向量化選項-ftree-vectorize,充分提高CPU在一個時鐘週期的計算效率。
  3. 升級Eigen版本至3.4以及之後的版本,在進行交叉編譯時指定對應ARM架構,並且開啓fp16支持,如-march=armv8.2+fp16,可實現fp16在ARM平臺上的原生支持,由編譯器進行SIMD優化和循環展開,有效提升Eigen::half在ARM架構上的性能表現。
  4. 優化Bcast的實現邏輯,目前版本依賴算子開發人員進行手動判斷是否需要廣播操作,並提取三種特殊情況進行手動實現(無需Broadcast、X爲一個元素、Y爲一個元素),算子實現代碼充斥大量冗餘代碼,應把例如判斷是否需要廣播的操作進行抽象,通過統一接口對元素進行訪問。
  5. 優化Bcast需廣播情況的獲取元素索引方法的實現方式,目前倉庫中的Bcast性能遠低於TensorFlow,落後於Eigen庫的broadcast,且目前GetBroadcastXIndex方法的實現對編譯器優化不友好。

9. 結語

本文僅爲一位CANN算子開發者對AICPU算子耗時的簡單分析和優化方案探索,分析和優化思路較爲粗糙,不當之處,還請華爲專家不吝賜教,也希望能有機會和相關專家探討交流優化方案。

 

點擊關注,第一時間瞭解華爲雲新鮮技術~

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章