CANN AICPU算子耗時分析及優化探索
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對應關系如下:
不涉及廣播操作
數據規模
Shapes
128B
[1,128], [1,128], [1,128]
256B
[1,256], [1,256], [1,256]
1K
[1,1024], [1,1024], [1,1024]
2K
[2,1024], [2,1024], [2,1024]
4K
[4,1024], [4,1024], [4,1024]
8K
[8,1024], [8,1024], [8,1024]
16K
[16,1024], [16,1024], [16,1024]
32K
[32,1024], [32,1024], [32,1024]
64K
[64,1024], [64,1024], [64,1024]
128K
[128,1024], [128,1024], [128,1024]
256K
[256,1024], [256,1024], [256,1024]
1M
[1,1024,1024], [1,1024,1024], [1,1024,1024]
2M
[2,1024,1024], [2,1024,1024], [2,1024,1024]
8M
[8,1024,1024], [8,1024,1024], [8,1024,1024]
表1? 不涉及廣播操作數據規模與shape對應列表
涉及廣播操作
數據規模
Shapes
128B
[1,128], [128], [1,128]
256B
[1,256], [256], [1,256]
1K
[1,1024], [1024], [1,1024]
2K
[2,1024], [1024], [2,1024]
4K
[4,1024], [1024], [4,1024]
8K
[8,1024], [1024], [8,1024]
16K
[16,1024], [1024], [16,1024]
32K
[32,1024], [1024], [32,1024]
64K
[64,1024], [1024], [64,1024]
128K
[128,1024], [1024], [128,1024]
256K
[256,1024], [1024], [256,1024]
1M
[1,1024,1024], [1024], [1,1024,1024]
2M
[2,1024,1024], [1024], [2,1024,1024]
8M
[8,1024,1024], [1024], [8,1024,1024]
表2? 涉及廣播操作數據規模與shape對應列表
涉及廣播操作(需廣播Tensor的元素個數為1)
數據規模
Shapes
128B
[1,128], [1], [1,128]
256B
[1,256], [1], [1,256]
1K
[1,1024], [1], [1,1024]
2K
[2,1024], [1], [2,1024]
4K
[4,1024], [1], [4,1024]
8K
[8,1024], [1], [8,1024]
16K
[16,1024], [1], [16,1024]
32K
[32,1024], [1], [32,1024]
64K
[64,1024], [1], [64,1024]
128K
[128,1024], [1], [128,1024]
256K
[256,1024], [1], [256,1024]
1M
[1,1024,1024], [1], [1,1024,1024]
2M
[2,1024,1024], [1], [2,1024,1024]
8M
[8,1024,1024], [1], [8,1024,1024]
表3? 涉及廣播操作數據規模與shape對應列表(需廣播Tensor的元素個數為1)
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的優化選項外,還包含了以下選項:
-fthread-jumps
-fisolate-erroneous-paths-dereference
-falign-functions? -falign-jumps
-flra-remat
-falign-loops? -falign-labels
-foptimize-sibling-calls
-fcaller-saves
-foptimize-strlen
-fcrossjumping
-fpartial-inlining
-fcse-follow-jumps? -fcse-skip-blocks
-fpeephole2
-fdelete-null-pointer-checks
-freorder-blocks-algorithm=stc
-fdevirtualize -fdevirtualize-speculatively
-freorder-blocks-and-partition -freorder-functions
-fexpensive-optimizations
-frerun-cse-after-loop
-fgcse? -fgcse-lm
-fsched-interblock? -fsched-spec
-fhoist-adjacent-loads
-fschedule-insns? -fschedule-insns2
-finline-small-functions
-fstore-merging
-findirect-inlining
-fstrict-aliasing -fstrict-overflow
-fipa-cp
-ftree-builtin-call-dce
-fipa-bit-cp
-ftree-switch-conversion -ftree-tail-merge
-fipa-vrp
-fcode-hoisting
-fipa-sra
-ftree-pre
-fipa-icf
-ftree-vrp
-fipa-ra
表4? g++ O2優化包含優化選項
可以看到表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前后反匯編代碼對比
由圖11可知,開啟-ftree-vectorize編譯選項后,編譯器不僅進行了自動SIMD優化,還對循環進行了unroll操作,有利于降低循環開銷,提供指令級并行,優化指令流水線的調度。
對于float16這一數據類型,通過閱讀Eigen庫3.3.9版本源碼,可以看到當計算設備為CPU時,絕大多數計算(除operator/外)是將其轉換為float后再進行計算,最后將計算結果轉換為half數據類型。代碼片段如下:
圖12? Eigen庫中half數據類型operator>=函數定義
這種實現方式涉及到兩次數據類型轉換,且由于不是調用ARM原生數據類型,不能SIMD優化,且不利于循環展開,實際計算效率遠低于其他原生數據類型。
圖13 反匯編代碼,左為GCC11.1,右為Clang9.0.0
通過查閱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? Add算子反匯編代碼(左為__fp16,中為3.4.0版本Eigen::half,右為3.3.9版本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.??? 向量化的局限性
當然,開啟向量化是完美的么?當然不是,向量化是有一定的局限性的。
目前存在的SIMD擴展部件的向量寄存器長度都是固定的,如果向量寄存器長度過長而循環迭代次數或基本塊內同構語句條數較少,則程序不能被向量化。
SIMD對數據地址連續與否對執行效率有很大影響,當訪存地址不在對齊的邊界上時,則需要進行額外的移位和合并操作,才能得到滿足要求的向量數據。非對齊訪存結構不僅增加了額外的訪存操作,而且增加了特殊的操作(例如移位和合并操作等),才能得到滿足 SIMD 擴展部件要求的向量數據。由于Tensor的數據邏輯地址是對齊的,對于Element-wise類算子,這個問題并沒有產生過大影響。
一些程序由于其迭代次數不足,或者基本塊內向量并行的語句不夠多,不足以為向量寄存器提供足夠的并行,需要進行不充分SIMD向量化。
通過在算子實現代碼中內嵌手寫的匯編代碼或編譯器提供的內函數來添加SIMD指令,理論上手工向量化能夠實現最高程度的向量化,但由于不同處理器提供的SIMD擴展指令集各不相同,會導致代碼的可移植性大幅下降,并難以進行繼續優化。而自動向量化目前對代碼的優化還有一定局限性。
循環展開會造成一定程度的代碼膨脹。
ARM的NEON擴展的浮點數計算并沒有完全實現符合IEEE 754標準的浮點運算,尤其是非正則化值會被當做0來處理,為保證計算精度,在編譯選項不啟用-funsafe-math-optimizations選項時,部分不安全浮點計算的NEON代碼GCC編譯器不會在自動向量化中實現,這也進一步限制了ARM的SIMD性能表現。
8.??? 總結與優化建議
總結
按照目前canndev源碼倉的編譯選項,各種數據類型的性能在4K以上數據規模時均和TensorFlow有較大性能差距,且int8和uint8耗時異常,有可能按照16bit進行計算處理。對于Float16的處理canndev和TensorFlow均采用了Eigen庫的half,性能差距在所有數據類型中最小,但是差距比例還是高達1.3x。
目前canndev源碼倉中的GreaterEqual算子未啟用多核,且未對無需廣播的情況進行特化處理,因此在無需廣播的情況下性能遠低于自研算子。而涉及非單元素的廣播操作時,由于Eigen庫的廣播性能優于canndev的Bcast,小數據量canndev源碼倉中的GreaterEqual算子性能優于自研算子,但隨著數據量增大,開啟多核后,自研算子性能超過源碼倉的算子。
自研算子參考源碼倉中的Less算子進行設計,兩個算子計算邏輯基本相同,但Less算子設計的并行閾值偏低,導致所有數據類型在8K數據規模時出現一個明顯的耗時波峰,后移并行閾值后情況改善。
目前canndev主倉的編譯選項并未啟用自動向量化,開啟自動向量化后能被正確向量化的代碼性能大幅提高,且在不啟用-funsafe-math-optimizations編譯選項時,計算精度未出現明顯變化。
從匯編指令的角度探索了算子代碼向量化情況,Eigen<3.4版本的half數據類型不是通過ARM原生支持的__fp16進行實現,因此無法進行向量化優化,Eigen 3.4-rc1以及之后的版本底層通過__fp16實現,可以正確調用SIMD指令,性能大幅提升。
優化建議
優化Less算子并行閾值,使臨界數據量并行加速比盡量接近于1。
開啟編譯器自動向量化選項-ftree-vectorize,充分提高CPU在一個時鐘周期的計算效率。
升級Eigen版本至3.4以及之后的版本,在進行交叉編譯時指定對應ARM架構,并且開啟fp16支持,如-march=armv8.2+fp16,可實現fp16在ARM平臺上的原生支持,由編譯器進行SIMD優化和循環展開,有效提升Eigen::half在ARM架構上的性能表現。
優化Bcast的實現邏輯,目前版本依賴算子開發人員進行手動判斷是否需要廣播操作,并提取三種特殊情況進行手動實現(無需Broadcast、X為一個元素、Y為一個元素),算子實現代碼充斥大量冗余代碼,應把例如判斷是否需要廣播的操作進行抽象,通過統一接口對元素進行訪問。
優化Bcast需廣播情況的獲取元素索引方法的實現方式,目前倉庫中的Bcast性能遠低于TensorFlow,落后于Eigen庫的broadcast,且目前GetBroadcastXIndex方法的實現對編譯器優化不友好。
9.??? 結語
本文僅為一位CANN算子開發者對AICPU算子耗時的簡單分析和優化方案探索,分析和優化思路較為粗糙,不當之處,還請華為專家不吝賜教,也希望能有機會和相關專家探討交流優化方案。
昇騰
版權聲明:本文內容由網絡用戶投稿,版權歸原作者所有,本站不擁有其著作權,亦不承擔相應法律責任。如果您發現本站中有涉嫌抄襲或描述失實的內容,請聯系我們jiasou666@gmail.com 處理,核實后本網站將在24小時內刪除侵權內容。