ARM 算子性能優(yōu)化上手指南
前言
做 arm 側(cè)算子開(kāi)發(fā)時(shí),不能不關(guān)心的就是性能。本文主要就是介紹 arm 算子性能優(yōu)化的常用思路,做為一個(gè)入門級(jí)的參考。文章以 ARM Cortex a55 上的 GaussianBlur 優(yōu)化為例展開(kāi),并在文末對(duì) arm 性能優(yōu)化思路做了一個(gè)總結(jié)。
GaussianBlur 的優(yōu)化
Q1: 什么是 GaussianBlur?
GaussianBlur 是一種線性平滑濾波。它的計(jì)算過(guò)程是:原圖每一個(gè)點(diǎn)都和周邊點(diǎn)進(jìn)行加權(quán)求和,得到對(duì)應(yīng)位置的輸出,權(quán)重矩陣是 kernel。以 kernel_size=3 為例,如圖:

示例代碼如下:
而 kernel 的值是由高斯公式?1?算出,并做了歸一化。比如常用的 kernel_size=3 的 kernel 為:

它可以由兩個(gè)向量相乘得到:
Q2: 如何進(jìn)行優(yōu)化?
接下來(lái),筆者將以 kernel_size=3 的 GaussianBlur 為例,介紹一些常見(jiàn)的優(yōu)化思路:
Point 1: 首先考慮算法層面的優(yōu)化--可分離濾波
根據(jù)筆者的經(jīng)驗(yàn),性能優(yōu)化的主要效益來(lái)自于算法層面的優(yōu)化,這是從根本上減少計(jì)算量,所以第一步是考慮算法層面的優(yōu)化。對(duì)于高斯濾波來(lái)說(shuō),它是一個(gè)可分離濾波, 這意味著
它的 kernel 可以拆成行方向和列方向兩個(gè)向量的乘積。即
示例代碼 1 的邏輯等效于,先做一個(gè)方向的卷積,再做另一個(gè)方向的卷積。代碼如下:
分析一下: 示例代碼 1 的時(shí)間復(fù)雜度為?, 示例代碼 2 的時(shí)間復(fù)雜度為?
,可以看到時(shí)間復(fù)雜度變小了。
簡(jiǎn)單總結(jié)一下:算子優(yōu)化首先從數(shù)學(xué)角度出發(fā),看能不能找到等效或者近似的算法來(lái)降低算法的復(fù)雜度。而類似的 GaussianBlur 的優(yōu)化思路還有'Stack Blur'?2?(用多個(gè) boxfilter 去模擬 gaussianblur); 轉(zhuǎn)換到頻域上計(jì)算等。
Point 2:考慮減少重復(fù)計(jì)算
做完 Point1 算法設(shè)計(jì)的優(yōu)化,大幅減少了計(jì)算量,但算法實(shí)現(xiàn)過(guò)程可能會(huì)有不少重復(fù)計(jì)算,所以第二步接著考慮減少重復(fù)計(jì)算。這里還是以示例代碼 2 為例,關(guān)注以下幾個(gè)點(diǎn):
通過(guò)引入 buf 并借助 IDX(n) 宏,復(fù)用了前 2 行的行內(nèi)計(jì)算結(jié)果。由原先的每做一次行間計(jì)算,需要先做 3 次行內(nèi)計(jì)算,變成了每做一次行間計(jì)算,只需要做 1 次行內(nèi)計(jì)算。從而減少了計(jì)算量
因?yàn)?code>buf[i][j] = src[i][j-1] * kx[0] + src[i][j] * kx[1] + src[i][j+1] * kx[2];里 kx[0] 和 kx[2] 都是 0.25。所以可以優(yōu)化成
buf[i][j] = (src[i][j-1] + src[i][j+1] )* kx[0] + src[i][j] * kx[1] ;
從而由 3 次乘法 2 次加法變成了 2 次乘法 2 次加法。
簡(jiǎn)單總結(jié)一下:?性能優(yōu)化過(guò)程中,需要關(guān)注哪些計(jì)算是之前做過(guò)的,進(jìn)而設(shè)計(jì)一個(gè)數(shù)據(jù)結(jié)構(gòu)去緩存復(fù)用它。同時(shí)關(guān)注算法本身的一些特性(比如高斯核是對(duì)稱的),看看能不能減少一些計(jì)算量。
Point 3: SIMD 提高數(shù)據(jù)級(jí)并行度
前 2 步基本保證運(yùn)算是必需且最少的,第三步就需要考慮提高數(shù)據(jù)級(jí)并行度。
數(shù)據(jù)級(jí)并行(Data Level Parallelism,簡(jiǎn)稱 DLP),主要手段是 SIMD/SIMT,簡(jiǎn)單理解成一條指令同時(shí)處理多個(gè)數(shù)據(jù)。ARM 上主要是使用 NEON 指令集/SVE 指令集。受限于篇幅,以下的示例代碼,只拿示例代碼 2 中?先計(jì)算前 2 行的行內(nèi)卷積?的部分做演示。
稍微總結(jié)一下:?拿到一串代碼,可以考慮是否可以進(jìn)行向量化。
NEON 指令向量化也是有局限的。比如 對(duì)于一些查表操作,分支操作不好進(jìn)行向量化。 對(duì)于 NEON 的查表,可以考慮先標(biāo)量查表,用查表結(jié)果初始化一個(gè)向量,以便后繼操作的向量化。
另外,在精度誤差允許的前提下,可以把 float?量化成 uin8_t, uint16_t 等,或者使用 float16, 從而獲得更高的并行度。(NEON 指令一次處理 128bit, 可以一次性處理 16 個(gè) uint8_t,8 個(gè) uint16_t)
Point 4: 循環(huán)展開(kāi)
這一步是前三步的補(bǔ)充,主要是利用編譯器再嘗試優(yōu)化一下。
簡(jiǎn)單理解循環(huán)展開(kāi) (unroll loop) 就是增大 for 循環(huán)的步長(zhǎng),讓每一個(gè)迭代可以多處理一些數(shù)據(jù),給編譯器提供了更多調(diào)度的空間(比如指令重排,寄存器重命名,寄存器復(fù)用等), 同時(shí)也減少了分支判斷的次數(shù),從而提升性能。操作很簡(jiǎn)單,示例代碼如下:
稍微總結(jié)一下:?unroll 次數(shù)需要根據(jù)實(shí)際情況分析測(cè)試,也可以嘗試不同的 unroll 次數(shù),進(jìn)行搜索確認(rèn)。
Point 5:考慮減少重復(fù)訪存
前面 4 步完成了計(jì)算的優(yōu)化,還需要考慮訪存的優(yōu)化,同樣是考慮減少重復(fù)的訪存。
觀察一下示例代碼 3,它的三次 vld1q_f32 分別 load 了
vload[0] : {?src[0][j-1]?,?src[0][j] ??,?src[0][j+1]??,?src[0][j+2]??}
vload[1] : {?src[0][j]??,?src[0][j+1]?,?src[0][j+2]??,??src[0][j+3] }
vload[2] : {?src[0][j+1]?,?src[0][j+2]?,?src[0][j+3]??,?src[0][j+4]??}
三個(gè)向量。 下一個(gè) iter 又 load 了vload[0] : {?src[0][j+3]? ,?src[0][j+4]?,?src[0][j+5]?,?src[0][j+6]? }
vload[1] : {?src[0][j+4]??,?src[0][j+5]?,?src[0][j+6]?,?src[0][j+7]?}
vload[2] : {?src[0][j+5]??,?src[0][j+7]?,?src[0][j+8]?,?src[0][j+9]?}
可以發(fā)現(xiàn) src[0][j+3] 這個(gè)位置被重復(fù) load 了 3 次。于是考慮引入一組向量 head, body, tail 去減少重復(fù)訪存。示例代碼如下:
通過(guò)這種方式,原先每個(gè)for(; j < width - 1 - step; j += step)
循環(huán)里需要 3 次 vld1q,現(xiàn)在只需要 1 次。代價(jià)是多了一些賦值和 vextq 的拼湊指令。
簡(jiǎn)單總結(jié)一下:?當(dāng)算子是 memory-bound 時(shí),可以考慮減少訪存次數(shù)。比如:設(shè)計(jì)數(shù)據(jù)結(jié)構(gòu)去緩存訪存結(jié)果,減少重復(fù)訪存。
那么就引出如下的兩個(gè)問(wèn)題:
Q1 : 如何知道算子是 bound 在計(jì)算上還是訪存上?
可以借助 roofline model 進(jìn)行分析。roofline model 主要是回答“在算力峰值為 A, 帶寬峰值為 B 的設(shè)備上,跑計(jì)算量為 C, 訪存量為 D 的程序能達(dá)到性能峰值 E 是多少”。具體可以參考引用?3?的論文。
Q2 : 如何知道設(shè)備的算力峰值和帶寬峰值?
測(cè)設(shè)備的算力峰值和帶寬峰值主要是通過(guò) macro-benchmark。在 github 上可以找到一些 macro-benchmark 的 repo,比如 stream、lmbench 等
Point 6:增加多線程計(jì)算
當(dāng)計(jì)算和訪存都優(yōu)化完,保證計(jì)算和訪存都是必要且最少的,之后考慮引入多線程。
在示例代碼 2 中,可以把整個(gè) height 拆成若干段,每一段執(zhí)行相同的代碼,這樣就可以開(kāi)多個(gè)線程去并行處理。
Point 7:匯編優(yōu)化
前 6 步屬于 C++/instrinsic 層面的粗調(diào),這一步是匯編層面的優(yōu)化,屬于微調(diào),性能優(yōu)化應(yīng)該遵循?“先粗調(diào)再細(xì)調(diào)”?的原則。當(dāng)在 C++層面想不到其它優(yōu)化點(diǎn)時(shí),可以考慮進(jìn)行匯編優(yōu)化。這里簡(jiǎn)單介紹一下,不過(guò)多展開(kāi)。主要有以下要點(diǎn):
使用 asm 語(yǔ)法?4,內(nèi)嵌一段匯編,替換原先的 C++代碼,并保證精度正確
結(jié)合 compiler explorer5?, 讀懂每條匯編指令?6?的意思
尋找一些多余指令,比如通過(guò)寄存器重命名或者指令重排,復(fù)用中間結(jié)果,從而減少一些指令
先去掉所有的訪存指令,保留核心計(jì)算指令。通過(guò)指令重排等手段,讓 GFLOPS 盡量達(dá)到峰值的 90%以上。
通過(guò)多發(fā)射,用計(jì)算盡可能去掩蓋訪存。
需要注意的是:
另外針對(duì)不同的平臺(tái),會(huì)有不同的優(yōu)化技巧,需要結(jié)合體系架構(gòu)相關(guān)的信息去做針對(duì)性的優(yōu)化。
用匯編優(yōu)化也是因?yàn)轫樞驁?zhí)行核心對(duì)指令順序很敏感,編譯器的重排不能保證最優(yōu)且容易受編譯器版本影響。
例子 1: 通過(guò)查閱 cortex-a55 的優(yōu)化指南?7,可以得到如下信息:
cortex-a55 是一個(gè)雙發(fā)射(有兩個(gè)發(fā)射端口,一個(gè) cycle 可以發(fā)射兩條指令,有兩套硬件單元可以同時(shí)執(zhí)行),順序執(zhí)行的核心。
不同指令的執(zhí)行 latency , 執(zhí)行 throughput, 允許發(fā)射的端口號(hào)等信息。 比如

這里的 LDR 指令 (D-form) 負(fù)責(zé)從指定地址 load 64bit 的數(shù)據(jù)到寄存器里
知道這些信息,我們可以通過(guò)選擇可以雙發(fā)射的指令組合,達(dá)到掩蓋部分指令的開(kāi)銷的目的。比如
LDR 指令 (D-form) 可以從 slot0 或 slot1 發(fā)射出去,
LDR 指令(Q-form)只能從 slot 0 發(fā)射出去 , FADD 指令 (Q-form) 也只能從 slot 0 發(fā)射出去。
于是可以用 LDR(D-form)替換 LDR(Q-form),去和 FADD(Q-form) 做雙發(fā)射,從而掩蓋了 LDR 指令的開(kāi)銷。?
Note?: Q-form 指令一次操作 128bit 的數(shù)據(jù) D-form 指令一次操作 64bit 的數(shù)據(jù)
例子 2: 查閱 cortex-a55 的優(yōu)化指南?7,可以知道 fmla,fmul,fadd 指令 (Q-form) 的 latency 都是 4 個(gè) cycle,throughput 都是 1 個(gè) cycle,發(fā)射端口都是 0, 于是用 fmla 去替換 fadd+fmul 就可以減少一條指令。
例子 3: cortex-a7 是一個(gè)單發(fā)射,順序執(zhí)行的核心。那么主要是考慮根據(jù)指令的 latency,進(jìn)行指令重排,盡可能排滿流水。
其他可能的優(yōu)化點(diǎn)
完成上述優(yōu)化步驟后,如果性能還不達(dá)標(biāo),可以再考慮如下幾點(diǎn)優(yōu)化。
調(diào)整內(nèi)存布局
這里至少涉及兩個(gè)方面。一方面是內(nèi)存地址的對(duì)齊,不同硬件設(shè)備都有一些地址對(duì)齊的要求,比如 ARM AArch64 Load/Store 指令要求訪問(wèn)的地址和所訪問(wèn)元素的大?。ū热?4 字節(jié))對(duì)齊,不然可能會(huì)觸發(fā)對(duì)齊錯(cuò)誤,帶來(lái)額外的性能損失。 另一方面是內(nèi)存布局,比如 NHCW,NCHW 等,對(duì)于同一段代碼,不同的內(nèi)存布局,訪存的連續(xù)性是不一樣的。也會(huì)有自定義的內(nèi)存布局,在一些情況下可以取得不錯(cuò)的優(yōu)化效果。
良好的 C++代碼
C++的寫法可以多關(guān)注內(nèi)聯(lián),引用,移動(dòng)語(yǔ)義等,函數(shù)接口參數(shù)盡可能使用簡(jiǎn)單的數(shù)據(jù)結(jié)構(gòu),可以提升程序性能,減少不必要的開(kāi)銷。
把一些函數(shù)形參放到模板參數(shù)里
這樣的做法可以讓編譯器在編譯鏈接時(shí)進(jìn)行一些簡(jiǎn)單的運(yùn)算,提前知道一些參數(shù)信息也有助于編譯器的優(yōu)化。比如可以把一些 if 判斷的 flag 抽離,做為模板參數(shù)。
優(yōu)化思路總結(jié)
上述通過(guò) GaussianBlur 的例子,介紹了一些可能的優(yōu)化點(diǎn),但這只是整個(gè)優(yōu)化流程的一個(gè)步驟。
性能優(yōu)化是一個(gè)不斷迭代的過(guò)程,很難追求一步到位。一般的優(yōu)化流程可以用下圖表示:

benchmark
為了得到一個(gè)正確的優(yōu)化反饋,需要做科學(xué)嚴(yán)謹(jǐn)?shù)?benchmark。筆者認(rèn)為 benchmark 至少需要考慮以下因素:
跑多次取平均值
跑多次之前,需要先額外跑幾次,做 warm up。 目的是將數(shù)據(jù)加載到 cache 中,使得后續(xù)測(cè)速速度相對(duì)穩(wěn)定
做速度對(duì)比時(shí),需要保證兩邊的各種可能影響速度的要素盡可能對(duì)齊,包括:
輸入地址是否都做了地址對(duì)齊
關(guān)鍵的編譯選項(xiàng)是否對(duì)齊
依賴的第三方庫(kù)版本是否對(duì)齊
編譯工具鏈?zhǔn)欠駥?duì)齊
算子的各種參數(shù)組合對(duì)齊等等
觀察每個(gè) iter 的速度數(shù)據(jù),如果波動(dòng)較大,則應(yīng)該舍去,重新測(cè)速
設(shè)置 cpu 親和度,進(jìn)行綁核
也可以考慮使用 google_benchmark 等 benchmark 工具。
profile
做性能優(yōu)化之前,往往需要先做一下 profile,了解程序的熱點(diǎn)(耗時(shí)最多的地方),觀察有沒(méi)有異常的開(kāi)銷(比如函數(shù)封裝的 overhead 過(guò)大)。
可以使用一些 profiling 工具,硬件廠商通常會(huì)提供自己的 profiling 工具,比如 x86 上用 intel 的 vtune,nvidia 用上 nsight compute,arm 上用 arm map, android 上用 simple_perf 等。
也可以手動(dòng)加計(jì)時(shí)函數(shù),比較核心代碼的速度和封裝后的速度,確定封裝帶來(lái)的 overhead 是否合理。
附
更多 MegEngine 信息獲取,您可以查看:
文檔:https://www.megengine.org.cn/doc/stable/zh/?
深度學(xué)習(xí)框架?MegEngine 官網(wǎng):https://www.megengine.org.cn/
GitHub 項(xiàng)目:https://github.com/MegEngine,或加入 MegEngine 用戶交流 QQ 群:1029741705
參考文獻(xiàn)
Footnotes
高斯模糊?https://zh.wikipedia.org/wiki/%E9%AB%98%E6%96%AF%E6%A8%A1%E7%B3%8A??
Stack Blur?https://medium.com/mobile-app-development-publication/blurring-image-algorithm-example-in-android-cec81911cd5e??
roofline model?https://people.eecs.berkeley.edu/~kubitron/cs252/handouts/papers/RooflineVyNoYellow.pdf???
C++內(nèi)嵌匯編?https://dmalcolm.fedorapeople.org/gcc/2015-08-31/rst-experiment/how-to-use-inline-assembly-language-in-c-code.html#outputoperands??
compiler explorer?https://godbolt.org/??
arm 匯編指令詳細(xì)介紹?https://developer.arm.com/documentation/ddi0487/ha/?lang=en??
Arm Cortex-A55 Software Optimization Guide?https://developer.arm.com/documentation/EPM128372/0300/?lang=en????2