含代碼|支付寶如何優化移動端深度學習引擎?

含代碼|支付寶如何優化移動端深度學習引擎?

阿里妹導讀:移動端深度學習在增強體驗實時性、降低雲端計算負載、保護用戶隱私等方面具有天然的優勢,在圖像、語音、安全等領域具有越來越廣泛的業務場景。考慮到移動端資源的限制,深度學習引擎的落地面臨著性能、機型覆蓋、SDK尺寸、內存使用、模型尺寸等多個方面的嚴峻挑戰。

本文介紹如何從模型壓縮和引擎實現兩個方面的聯合優化,應對上述挑戰,最終實現技術落地,希望對大家有所啟發。

1.背景

由於移動端資源的限制,大部分深度學習引擎都部署在雲端,移動設備獲取到輸入數據,經過簡單的加工,發送給雲端,雲端服務器經過深度神經網絡推斷運算,得到結果並反饋給移動端,完成整個過程。

顯而易見,這種交互方式有很多弊端,比如依賴網絡,流量過大,延遲太長,更重要的是,雲端服務器必須有足夠大的數據吞吐能力,如果移動端請求量太大,超過負荷,容易導致服務器宕機,從而使所有移動端任務都失效。其實,現有的移動設備已經逐漸從以前的單核32位到多核64位過渡,計算能力和存儲能力有了很大的提升,將深度學習引擎部署到移動端已經成為一個必然趨勢。

然而,成功將DL引擎部署到移動端並非易事。運行速度,包大小,內存佔用,機型覆蓋,甚至功耗都是必須逾越的障礙。支付寶移動端深度學習引擎xNN是這方面的佼佼者,本文將回顧xNN移動端DL優化的方法和技術。

含代碼|支付寶如何優化移動端深度學習引擎?

2.運行速度

大部分移動端處理器都是基於ARM架構,移動端完成深度神經網絡推斷的任務,基於CPU的方案是最基礎的,也是最可靠的;基於GPU的方案存在兼容性/數據同步/overhead過高/接口不滿足等問題;DSP的方案也會存在兼容性的問題; 最近,很多手機芯片廠商開始構建AI協處理器(各種TPU/APU/NPU),但離應用還需要一定的時間。下面我們重點介紹一下在ARM平臺的優化技術。做優化有三部曲,如下:

第一部:充分評估的優化目標。如果算法原型太複雜了,花再多精力優化也是徒勞。針對DL業務,務必讓模型充分精簡,直到你覺得差不多了才開始下手,不然的話,嘿嘿。

第二部:確認運算熱點。這離不開一些timing profile工具,如XCODE instrument, GPROF, ATRACE, DS-5等,熟練地運用工具,可以事半功倍。

第三部:貼身肉搏。下面有利器若干。

2.1.基於C/C++的基本優化

編譯器很牛逼,GCC/CLANG都有運行速度的優化選項,打開這些選項大部分情況下都會幫你的程序速度提升不少,雖然這還遠遠不夠,但聊勝於無。

書寫高效的C代碼。循環展開,內聯,分支優先,避免除法,查表等等優化小技巧一定要滾瓜爛熟,信手拈來。本文將不再贅述這些基本技巧。

必須學會看得懂彙編,即便你不寫,也要知道編譯器編譯出來的彙編代碼的效率如何。這樣你可以通過調整C/C++代碼,讓編譯器生成你需要的代碼。否則,一切浮於表面。

2.2.緩存友好

基於CPU內存子系統的優化工作很大部分都是在想如何高效地利用緩存(cache),尤其是圖像視頻處理這種大量數據交換的場景。幾十年前,我們的老前輩就發明了主存,多級緩存, 寄存器用來彌補存儲器與計算單元的性能差異,直到今天這個問題還沒有解決(或許一直都不會解決,存儲器和計算單元的設計思路是不一樣的,高速ram的成本肯定是高的)。存儲層次如下圖,原理很簡單,你想跑得快,只能背得少,你想跑得快,還想裝的多,那就要多掏錢買個車。

含代碼|支付寶如何優化移動端深度學習引擎?

CPU-內存 子系統工作起來後,如果寄存器沒數了,通過指令從L1 cache拿,L1沒數了(Cachemiss),從L2拿,Cache都沒有數據了,從主存拿,從主存拿的話, 也要分時間和位置,主存(dram/DDR)不同時刻不同位置訪問的效率都是不同的。這裡分享幾個準則:

少用內存

儘量複用內存,不要隨便地申請一大塊內存。訪問一大塊內存意味著cache miss、TLB miss、dram切bank的概率都會增大,效率自然就降低。小塊的內存反覆使用,可以讓CPU更加持久地運作,CPU運作佔比越高,程序效率越高。要知道,一次cache miss導致的訪問主存,在複雜應用下,可能有幾十甚至上百個cycle的stall.

連續訪問

數據訪問一般都遵循局部性原理,位置相近的內存被重複使用的概率更高, cache的替換規則也大多給基於這個原理來設計,跳躍的訪問內存會打破這個規則,造成訪問效率的低下。

對齊訪問

主存和緩存的最小數據交換單元是cache line, 所以訪問內存的地址最好是按照cache line的大小進行,這樣可以保證最高效的數據訪問。比如某臺機器cache line大小為64Bytes,申請一個128Byte的內存區域,將它的開始地址放在非64Byte對齊的位置,如0x80000020,那麼訪問這128Byte需要3條cacheline的訪問, 若放在0x80000040, 則只需要2條cache line, 一般通過多申請一點點內存,來避免這種不對齊,比如用posix_mem_align() 函數;更近一步,為了對齊訪問,有時需要對圖像的邊界做一些padding的,比如 224x224的圖片,我們可能會存儲在256x224的內存地址中,保證在隨機訪問某一行時,地址處於對齊的位置。如:

含代碼|支付寶如何優化移動端深度學習引擎?

合併訪問

如果反覆的讀寫一段內存進行運算,效率上肯定不如“一次讀取-多次運算-一次寫入”來的更高效。比如,DL模型中,一般CONV層後面跟著RELU,BIAS層,本著內存訪問能省則省的原則,通過提前分析網絡的結構,可以將CONV層和bias合併,甚至將CONV+BIAS+RELU合併在一起進行運算,可以獲得很好的gain. 比如:

含代碼|支付寶如何優化移動端深度學習引擎?

經過合併後,原來對memory的三讀三寫,變成了三讀一寫,速度槓槓的。

顯式對齊數據加載

在ARM彙編中,可以顯式的通知CPU,加載的地址是一個對齊得較好的地址。比如

含代碼|支付寶如何優化移動端深度學習引擎?

其中,“:128” 即是通知CPU, R1存放的地址是一個128bit對齊的地址,此時,CPU在向內存發出數據請求時,可以發出更高效的信號。如果R1不是128bit對齊的,執行這樣的指令會得到一個地址異常,也就是LINUX中常見的 bus error。怎麼保證R1是128bit對齊? 程序員必須知道R1對應的數據結構,需要事先設計好地址偏移,保證該指令被正確執行。並不是所有case都可以滿足這個要求。

緩存預取

請設想,如果CPU正熱火朝天的做計算,這時我們在後臺偷偷搬些後面會使用的數據到緩存,下次使用時CPU就不用再去等數據了,效率不是就變高了嗎?是的。緩存預取可以做這個事情,如:preload [R1, #256], 可以讓CPU在繼續執行後面的指令,並開始在後臺加載 $R1+256byte位置的數據到緩存中。

但是,preload是一條指令,當你發出這樣的指令時,需要知道,至少一個cycle浪費掉了,然後再考慮你預加載進cache的數據,是不是馬上就可以接著被CPU 採納。不幸的是,在手機實時操作系統中,可能多達幾十甚至上百個線程嗷嗷待哺,完全無法保證預取的這些數據會被馬上用上,系統中有大把事件是會讓你的線程找地方歇息的,這種情況下,你預取的數據非但不能用,還可能被其他線程從cache中踢出去,白白浪費了一次主存訪問。但是夢想總是要有的,萬一實現了呢,總要試試才知道效果吧!

類似的方法可能還能舉出一些來,但宗旨只有一個,在做同樣的事情的前提下,別讓你的CPU經常停下來等數據。

2.3.多線程

手機核備競賽前幾年搞得如火如荼,最近慢慢冷下來了,但也說明多核在運算上還有很大的優勢。當然,多核的使用,會導致CPU佔比和功耗直線上升,但在可接受的條件下,多線程優化帶來的性能提升是最可觀的。多線程的實現方法推薦使用OPENMP,接口豐富,編程簡潔,用起來並不難,但需要注意一些細節。

線程開銷

OPENMP會自動為循環分配線程,但並非所有循環都適合做多線程優化,如果每次循環只做了非常少的事情,那麼使用多線程會得不嘗失。線程的創建和切換會消耗一定的系統資源,線程調度有一定的規律,操作系統在沒有高優先事件觸發的情況下(中斷,異常,信號量), 調度週期都在毫秒級別,如果每次線程執行時間沒有達到一定的量,多線程的效果就會大打折扣。實際運用中,可以通過 #pragma omp parallel for if (cond) 語句來判斷runtime過程中是否要啟用多線程。

動態調度

默認情況,OPENMP採用靜態調度機制,即將循環的次數平均分配給各個線程,不關心各個線程的執行快慢。如果某次循環運行比較慢或者循環次數不能平均分配時,容易出現負載不均衡的情況,這時就必須有動態調度的機制,動態調度可以根據線程的運行快慢,決定是否“互相幫助”。 OPENMP可以採用schedule(dynamic)來達到動態調度的效果。

含代碼|支付寶如何優化移動端深度學習引擎?

同時需要說明的是,某些機型對於OPENMP的支持並不好,或者說線程的開銷過大,這時,可能需要手動調整線程的負載和並行的方式。這些細節需要通過反覆的實驗來微調。

2.4.稀疏化

深度神經網絡是個超級黑盒,人們把神經突觸的權重找出來了,讓整個網絡可以完成特定的任務,但卻不知道每根突觸的作用是啥。實際上,其決定作用的很可能就只是那“幾根筋”。我們的實驗結果也是如此,大量的權重數據都可以是0,而整個輸出精度相差無幾。當發現網絡中有50%甚至80%的數據為0時,那麼針對稀疏的卷積和矩陣優化就顯得非常重要了。

稀疏優化的重點是設計合適的索引方案和數據存放方式,如下圖。

含代碼|支付寶如何優化移動端深度學習引擎?

稀疏方案的應用,依賴不同的運算結構,如針對1x1卷積的優化,數據組織方法和3x3卷積就可能存在不同,不同的稀疏程度,得到的提升效果也是不同,需要不斷地嘗試各種方案和參數。

2.5.定點化

大部分深度神經網絡推斷引擎,都需要用浮點精度來得到更精確的結果,這樣paper上的數據才好看。但實際上,有些DL應用並不需要這麼高的精度,即便是單精度浮點,也存在很大的冗餘運算。以Tensor的1x1的卷積為例子,實際就是一個乘累加的過程,若使用單精度浮點存放數據,每個元素需要4個字節,而如果將數據量化到8bit, 只需要1個字節,節省3/4的內存訪問量,這意味著在帶寬緊張的狀態下,性能提升會更加明顯。下圖體現了不同場景下定點化的性能提升收益(倍數)。

含代碼|支付寶如何優化移動端深度學習引擎?

2.6.NEON及彙編

NEON 是針對高級媒體和信號處理應用程序以及嵌入式處理器的 64/128 位混合 SIMD 技術。它是作為 ARM內核的一部分實現的,但有自己的執行管道和寄存器組,該寄存器組不同於ARM 核心寄存器組。NEON 支持整數、定點和單精度浮點 SIMD 運算。經過良好設計的NEON代碼,理論上可以比普通C語言版本快2-8倍。

NEON指令集分為ARMV7版本和ARMV8版本,寄存器個數和格式略有不同。寫NEON指令有兩種方式,一種是NEON Intrinsic, 一種是NEON Inline Assembly(內聯彙編)。

本文主要是分享一些用Neon/彙編優化的經驗,學習具體neon/彙編寫法可以參考:

https://community.arm.com/android-community/b/android/posts/arm-neon-programming-quick-reference

2.6.1.NEON Intrinsic vs Neon 內聯彙編

大部分情況下采用NEON Intrinsic編程就夠用了,NEON Intrinsic的好處也是非常明顯的,首先,在armv7,armv8平臺都可以跑,其次,代碼簡潔容易理解和維護,另外,編譯器還會根據不同平臺做代碼重排;但是NEON intrinsic也有一些缺點,比如沒有預取指令,分解Neon寄存器很麻煩,寄存器分配可能不高效,無法做顯式的對齊加載,編譯器可能會引進一些奇怪的指令,造成性能低下。

含代碼|支付寶如何優化移動端深度學習引擎?

如果對某個模塊的性能要求很高,編譯器的輸出不滿足要求,這時候,就需要使用內聯彙編;對於xNN中的核心模塊卷積運算,都是通過內聯彙編實現,性能比NEON Intrinsic提升10%左右。以下是ARM官方例子:

含代碼|支付寶如何優化移動端深度學習引擎?

當然還有一種方式是採用純彙編寫程序,這不是所有人都能接受的。付出得多,收穫也多,真正的高效的程序都是純彙編的。相對編譯器產生的代碼,手工純彙編的好處還是非常明顯的,比如精簡的棧內存,高效的寄存器利用,充分的流水線優化等等,有一種一切盡在掌握的快感。

但是,大部分情況下,用純彙編寫程序花去的精力,完全有機會在其它地方去彌補。所以這種方式適合追求極致的同學。

2.6.2.會寫NEON/彙編很重要,構思好的實現方案更重要

NEON指令比較豐富,實現同一個功能有多種指令組合,除了理解指令的本身的作用之後,需要合理組織數據,使用更高效的指令來實現既定功能。

舉個例子,實用實現單精度浮點矩陣乘法 sgemm中的 C = A * B + Bias,為了簡單起見,假設矩陣的維度是2的冪。如 Dims(C) = 512x512, Dims(B) =512x512, Dims(B) = 512x512, Dims(Bias) = 512x512;

代碼1 : C語言的寫法是:

含代碼|支付寶如何優化移動端深度學習引擎?

運行時間是: 1654689us (小米5 snapdragon 820,下同).

為了構造SIMD,我們將4個C的元素同時輸出,如:

含代碼|支付寶如何優化移動端深度學習引擎?

從而產生代碼2,這是初步的NEON寫法:

含代碼|支付寶如何優化移動端深度學習引擎?

運行時間:633551us

考慮到數據的重複加載,可以多取四行,一次輸出4x4的block, 這樣可以省下接近3/4的數據讀取,如

含代碼|支付寶如何優化移動端深度學習引擎?

從而得到代碼3:

含代碼|支付寶如何優化移動端深度學習引擎?

運行時間:367165us

進一步,我們發現矩陣B的取數不連續,每次只取4個float, 遠不到cache line (64Byte),相當於cache miss有3/4是有可能被優化掉的,存在很大的浪費,所以對B的數據做轉置,轉置的過程可以和實際代碼結合,減少額外內存拷貝;同時,我們將Bias的作為sum的初始化值, 減少一次寫操作。於是得到代碼4:

含代碼|支付寶如何優化移動端深度學習引擎?

運行時間是:60060us

最後,按4x4的block進行循環構造,然後用OPENMP進行多線程化,得到代碼5:

含代碼|支付寶如何優化移動端深度學習引擎?

運行時間25178us

上面四種NEON寫法的運行速度增益分別是:

含代碼|支付寶如何優化移動端深度學習引擎?

代碼5肯定還不是優化的終點,通過數據重排,4x4的轉置部分還可以省掉;cache的優化還可以更加細緻;4x4的塊是否是最優也有待論證;最後,還可以祭出彙編大殺器,性能還能再上一個臺階。

sgemm的優化版本還可以參考nnpack/eigen等開源庫。實際代碼中,需要處理各種維度的情況,代碼遠比上述要複雜。

3.包大小

移動端的資源緊張,不僅僅是指運算資源,app大小也是商用DL引擎一個重要指標,更小的包大小意味著更快的下載速度,更少的app下載流量。app大小的壓縮包括很多方面,這裡我們只針對庫文件的大小精簡,做一些經驗分享。

3.1.編譯優化

編譯器有針對大小的編譯選項,比如GCC的-Os, 相當於可以同時打開-O2的優化效果,同時精簡生成目標文件的尺寸,生成目標代碼後,鏈接成動態庫的時候,可以通過strip命令,去掉多餘的調試代碼。這些是最基本的優化。針對IOS, 可以通過XCODE下面方法做精簡:

  • BuildSettings->Optimization Level,XCODE默認設置為“Fastest ,Smallest”,保持默認即可
  • Build Settings->Linking->Dead Code Stripping 設置成 YES
  • Deployment Postprocessing 設置成YES
  • Strip Linked Product 設置成YES
  • 工程的Enable C++ Exceptions和Enable Objective-C Exceptions選項都設置為NO。
  • symbols hidden by default選項設置為YES。
  • 所有沒有使用C++動態特性的lib庫(搜索工程沒有使用dynamic_cast關鍵字) Enable C++ Runtime Types 選項設置為NO。

上述配置,在IOS設置輸出目標為release時,XCODE會幫你自動打開一部分配置。同樣的,在Android NDK編譯,在Application.mk中配置OPTIMIZE=release,NDK會幫你做絕大部分的優化工作。

針對資源緊張的嵌入式設備,ARM提供了thumb/thumb2精簡指令集, 相當於,同樣的指令,同時有 16bit 和32bit 兩套指令,使用 -MThumb選項可以讓編譯器優先編譯出16bit的指令,在執行時通過內置的譯碼器,將指令轉換成32bit的指令,這樣既可以精簡代碼,還可以保持原來32bit指令的性能。

針對動態庫的發佈,還可以通過Invisible Symbol的方式,將不需要的符號隱藏起來,省下目標庫文件中符號表的表項,如果你的代碼有大量的函數,這會是不小的提升,試試看,說不定有驚喜。具體寫法是:

含代碼|支付寶如何優化移動端深度學習引擎?

這樣就ok了,簡直是物美價廉!

3.2.代碼精簡

以上都是一些常規的縮小庫大小的方法,實際上,針對DL模型的特性還可以進一步精簡庫大小,比如包括:

  • 庫依賴簡化- 大部分開源引擎都會依賴C++ STL庫,如Caffe/Tensorflow, 如果要做到極致的精簡,需要將複雜的C++屬性去掉,這樣可以依賴更加緊湊的stl庫,甚至不依賴stl.
  • 功能裁剪 - 刪除不常用的layer,刪除不常用的代碼分支,或者Layer組件化,用時加載,都可以減少基本庫大小;

3.3.模型壓縮

深度學習模型的size,小到幾M,大到幾百M,如果不做壓縮,根本是不可想象的。模型中的大部分數據是神經網絡的突觸權重,存在有巨大的壓縮空間。比如, 支付寶xNN團隊提供的xqueeze工具,可以讓深度學習模型壓縮比例達到幾十甚至一百倍。壓縮技術包括神經元剪枝 (neuron pruning)、突觸剪枝(synapse pruning)、量化 (quantization)、網絡結構變換 (network transform)、自適應Huffman編碼(adaptive Huffman)等等。具體實現可以參考一些主流的Paper。

4.內存精簡

內存使用也需要精簡,低端機型有可能只有1G甚至512M的內存空間,再複雜的應用場景下,經常會不夠用。精簡內存,可以一定程度上緩解內存不足引起的閃退;另一方面,使用更少的內存也有利於推斷速度的提升;

具體到DL推斷過程運行時存在很大的內存冗餘,比如:

含代碼|支付寶如何優化移動端深度學習引擎?

實際上,推斷過程中,大部分輸入層在做完運算後,可以被馬上釋放,所以完全存在複用內存的可能性。

支付寶xNN 設計了一種稱為MPool的內存管理機制,結合深度學習推斷的過程,MPool 通過分析網絡結構,在內存充分複用的前提下,計算出最小的內存使用量,在開始推斷前提前申請足夠的內存。

含代碼|支付寶如何優化移動端深度學習引擎?

MPool有如下幾個優點:

  • 避免推斷過程中反覆申請釋放內存;
  • 內存申請全部集中到初始化過程,避免推斷過程中出現內存不足導致的推斷失敗,從而改善用戶體驗;
  • 充分的複用可以提高的內存訪問效率,對性能提升也有一定的幫助;

採用MPool結構的DL引擎,運行主流模型,內存佔用降低達到75%以上。

5.兼容性與可靠性

商用軟件,兼容性和可靠性是很重要的指標。舉個例子,支付寶xNN在春節掃五福活動中,機型覆蓋率達到98%以上,基本上只要是ARM based的手機,電池不要隨便爆炸,就可以支持。然而兼容性和可靠性的提升,不比開發過程順利多少,需要解決很多工程上的問題。

舉幾個例子:

為了在支付寶app中部署xNN,需要兼容app中的stl版本;

為了兼容舊版本的Android, 必須使用較舊一點的NDK版本和API, 否則會出現一些數學庫的兼容性問題;

為了保證連續多次運行內存不洩漏,需要在不同android/ios版本的上做疲勞測試;

為了多任務併發,需要做互斥;

為了兼容受損的模型文件,需要做多層次的模型校驗和兼容。

……

這樣的例子還有很多。總結一句:出來混的,遲早要還的;代碼中的任何瑕疵,最後總會被爆出來。

6.更多

基於CPU的實現方案,面對複雜場景/強實時性應用,還是力不從心的,畢竟CPU資源是有限的,永遠都無法預測用戶部署的模型的複雜度,也無法預測手機後臺運行了多少程序;異構化也是重要的優化方向,採用DSP/GPU來做推斷可以大大降低功耗,同時還可以提升推斷速度,DSP/GPU方案面臨的問題是兼容性比較差,每一個款手機都有可能不同,所以需要做大量的適配工作。

最後,模型安全也是需要考慮的方向,模型文件包含了用戶的知識產權,對模型文件適當加密和隔離運行,可以有效地阻止模型被破解和盜取的風險。


分享到:


相關文章: