? 本文主要分享如何快速上手ARM匯編開(kāi)發(fā)的經(jīng)驗(yàn)、匯編開(kāi)發(fā)中常見(jiàn)的Bug以及Debug方法、用的Convolution Dephtwise算子的匯編實(shí)現(xiàn)相對(duì)于C++版本的加速效果三方面內(nèi)容。 ?
? 01前言
神經(jīng)網(wǎng)絡(luò)模型能夠在移動(dòng)端實(shí)現(xiàn)快速推理離不開(kāi)高性能算子,直接使用ARM匯編指令來(lái)進(jìn)行算子開(kāi)發(fā)無(wú)疑會(huì)大大提高算子的運(yùn)算性能。初次接觸匯編代碼可能會(huì)覺(jué)得其晦澀難懂然后望而卻步,但ARM匯編開(kāi)發(fā)一旦入門(mén)就會(huì)覺(jué)得語(yǔ)言?xún)?yōu)美簡(jiǎn)潔,如果再切換到ARM INTRISIC指令開(kāi)發(fā)反而覺(jué)得沒(méi)有直接寫(xiě)匯編碼來(lái)的方便。我會(huì)在第一節(jié)分享純小白如何快速上手ARM匯編開(kāi)發(fā)的經(jīng)驗(yàn),第二節(jié)會(huì)列舉在匯編開(kāi)發(fā)中常見(jiàn)的Bug以及Debug方法,第三節(jié)會(huì)展示常用的Convolution Dephtwise算子的匯編實(shí)現(xiàn)相對(duì)于C++版本的加速效果。如果你已經(jīng)能很熟練地使用ARM匯編指令進(jìn)行開(kāi)發(fā)了,可以跳過(guò)第一節(jié)。
? 02從簡(jiǎn)單函數(shù)上手
? ? 學(xué)習(xí)匯編開(kāi)發(fā)重要的一點(diǎn)是通過(guò)學(xué)習(xí)現(xiàn)有函數(shù)的匯編代碼來(lái)實(shí)現(xiàn)自己的需求
我寫(xiě)的第一個(gè)匯編算子是MaxPooling算子,算子本身的計(jì)算過(guò)程非常簡(jiǎn)單。但當(dāng)我開(kāi)始實(shí)現(xiàn)MaxPooling的匯編代碼時(shí),我不知道第一行代碼怎么寫(xiě),不知道開(kāi)頭和結(jié)尾怎么寫(xiě),不知道中間的計(jì)算邏輯怎么寫(xiě)。當(dāng)時(shí)我就在MNN庫(kù)的source文件夾下面找到了一份邏輯簡(jiǎn)單的、自己非常熟悉的Relu算子當(dāng)做參照來(lái)實(shí)現(xiàn)MaxPooling. 之所以我推薦用一個(gè)邏輯簡(jiǎn)單的、自己非常熟悉的算子當(dāng)做學(xué)習(xí)匯編的模版,是因?yàn)楫?dāng)算子的計(jì)算邏輯簡(jiǎn)單時(shí),我們才能把注意力放在匯編函數(shù)的聲明、傳參、讀取數(shù)據(jù)、存儲(chǔ)結(jié)果、返回等等這些大的流程上面,至于內(nèi)部的函數(shù)實(shí)現(xiàn)(如何計(jì)算一行數(shù)據(jù)的最大值,如何去計(jì)算一個(gè)寄存器中所有數(shù)據(jù)的累加和等等)可以暫時(shí)不去關(guān)注。學(xué)習(xí)一個(gè)新的東西時(shí),我們找的例子模版不能過(guò)于復(fù)雜,因?yàn)檫@會(huì)導(dǎo)致我們將注意力放在例子本身的實(shí)現(xiàn)細(xì)節(jié)中,而忽略了如何去入門(mén),這樣會(huì)增加我們的學(xué)習(xí)成本。 ?
匯編函數(shù)的開(kāi)頭與結(jié)尾
函數(shù)定義以asm_function開(kāi)頭,后加函數(shù)名(以MNNAvgPoolInt8 ARM64為例):
?
asm_function MNNAvgPoolInt8 // 加上函數(shù)的傳參注釋?zhuān)奖愫罄m(xù)對(duì)照使用對(duì)應(yīng)的寄存器 // void MNNAvgPoolInt8(int8_t* dst, int8_t* src, size_t outputWidth, // size_t inputWidth, size_t kernelx, size_t kernely, size_t stridesx, // ssize_t paddingx, ssize_t factor); // Auto load: x0: dst, x1: src, x2: outputWidth, x3: inputWidth, // x4: kernelx, x5: kernely, x6: stridesx, x7: paddingx // Load from sp: // w8: factor
?
傳參:ARM64 用于傳參的寄存器有8個(gè):x0-x7. 如果函數(shù)的參數(shù)大于8,就需要使用sp寄存器讀取剩余參數(shù)。例如AvgPoolInt8算子中的第9個(gè)參數(shù)factor讀?。?/p>
?
// x8寄存器存儲(chǔ)參數(shù)factor的值,不是必須使用x8寄存器,用其他寄存器也是可以的。 ldr x8, [sp, #0]? ARM寄存器使用不當(dāng)會(huì)導(dǎo)致程序crash。這里總結(jié)了ARM32和AMR64的寄存器基本使用規(guī)則。ARM32中通用寄存器和向量寄存器都有16個(gè),每個(gè)向量寄存器的最大使用長(zhǎng)度是128位。ARM32中用于傳參的寄存器有4個(gè):r0-r3。ARM32中r13寄存器就是sp寄存器,指向棧頂;r14寄存器也叫l(wèi)r寄存器,存儲(chǔ)函數(shù)的返回值地址;r15寄存器也叫pc寄存器,存儲(chǔ)將要執(zhí)行的下一條指令的地址。在進(jìn)行匯編開(kāi)發(fā)時(shí),一般不使用r13和r15寄存器來(lái)存儲(chǔ)臨時(shí)變量。r9寄存器的使用在各個(gè)平臺(tái)上可能不同,為了防止出錯(cuò),一般也不用來(lái)存儲(chǔ)臨時(shí)變量。當(dāng)不需要使用r14存儲(chǔ)返回值地址的信息時(shí),也可以使用其存儲(chǔ)臨時(shí)變量。下圖中我總結(jié)了ARM32中寄存器的基本使用規(guī)則,關(guān)于各寄存器更加詳細(xì)的介紹參考。 ?

?
ARM64中通用寄存器和向量寄存器的個(gè)數(shù)比ARM32多一倍,有32個(gè)。ARM64中向量寄存器的使用更加靈活,可以8bit,16bit,32bit,64bit使用。例如,v0表示128位的向量寄存器,d0,s0,h0分別表示v0的低64位,32位,16位。注意,d1,s1,h1表示v1寄存器的低64位,32位,16位,而不是緊接著v0的第二個(gè)相應(yīng)位。ARM64的寄存器使用見(jiàn)下圖。
? 我們可以用浮點(diǎn)操作指令把向量寄存器中的數(shù)當(dāng)做標(biāo)量來(lái)進(jìn)行計(jì)算,需要注意在ARMV8中浮點(diǎn)操作指令不支持對(duì)16bit的浮點(diǎn)數(shù)進(jìn)行計(jì)算,僅支持做16bit和32bit, 64bit之間的轉(zhuǎn)換。 ?
fadd Sd, Sn, Sm // 32bit Single precision fsub Dd, Dn, Dm // 64bit Double precision fcvt Sd, Hn // half-precision to single-precision fcvt Dd, Hn // half-precision to double-precision fcvt Hd, Sn // single-precision to half-precision fcvt Hd, Dn // double-precision to half-precision?
?
對(duì)上圖中的“用完恢復(fù)”寄存器的使用:一些復(fù)雜的函數(shù)需要的向量寄存器或者通用寄存器可能會(huì)非常多,那就需要我們?cè)陂_(kāi)頭加載這些寄存器,不然會(huì)報(bào)錯(cuò)segment fault.加載方法如下:
?
// d8-d15表示使用v8-v15這8個(gè)寄存器的64位, (2* 64)/8=16, // 這就是每次sp移位時(shí)(#16*i)中16的來(lái)源。 stp d14, d15, [sp, #(-16 * 9)]! stp d12, d13, [sp, #(16 * 1)] stp d10, d11, [sp, #(16 * 2)] stp d8, d9, [sp, #(16 * 3)] stp x27, x28, [sp, #(16 * 4)] stp x25, x26, [sp, #(16 * 5)] stp x23, x24, [sp, #(16 * 6)] stp x21, x22, [sp, #(16 * 7)] stp x19, x20, [sp, #(16 * 8)]?
?
在函數(shù)的結(jié)尾需要釋放這些寄存器:
?
ldp x19, x20, [sp, #(16 * 8)] ldp x21, x22, [sp, #(16 * 7)] ldp x23, x24, [sp, #(16 * 6)] ldp x25, x26, [sp, #(16 * 5)] ldp x27, x28, [sp, #(16 * 4)] ldp d8, d9, [sp, #(16 * 3)] ldp d10, d11, [sp, #(16 * 2)] ldp d12, d13, [sp, #(16 * 1)] ldp d14, d15, [sp], #(16 * 9) ret // 最后需加上ret返回? ARM32中寄存器的數(shù)量只有ARM64的一半,自動(dòng)傳參的寄存器僅r0-r3這四個(gè)寄存器,其他寄存器的加載方式和ARM64也不同,我們依然以MNNAvgPoolInt8為例,代碼的解釋和新手閉坑的地方我直接在下面的注釋中寫(xiě)明。
// 函數(shù)定義 asm_function MNNAvgPoolInt8 // void MNNAvgPoolInt8(int8_t* dst, int8_t* src, size_t outputWidth, // size_t inputWidth, size_t kernelx, size_t kernely, size_t stridesx, // ssize_t paddingx, ssize_t factor); // Auto load: r0: dst, r1: src, r2: outputWidth, r3: inputWidth // r4: kernelx, r5: kernely, r7: stridesx, r8: paddingx, lr: factor // 其他寄存器加載, 注意lr寄存器每次必須被push進(jìn)來(lái)(可以不使用),不然會(huì)報(bào)錯(cuò)segment fault. push {r4-r8, r10-r11, lr} // 上一行push了8個(gè)寄存器,那么sp指針會(huì)向低地址移動(dòng)(8*4=32)個(gè)字節(jié)(ARM32每個(gè)指針占4個(gè)字節(jié)), // 所以第五個(gè)參數(shù)“kernelx”加載時(shí)需要將sp的地址加(#32). // 虛擬內(nèi)存中棧是從高地址向低地址擴(kuò)展的,而函數(shù)傳參是從右往左傳去棧中的, // 所以后面的參數(shù)地址會(huì)比前面的高,即相對(duì)sp寄存器的地址增加的更多。 ldr r4, [sp, #32] // kernelx ldr r5, [sp, #36] // kernely ldr r7, [sp, #40] // stridesx ldr r8, [sp, #44] // paddingx ldr lr, [sp, #48] // factor // 加載向量寄存器一定要放在利用sp寄存器來(lái)讀取所有函數(shù)參數(shù)之后, // 否則不能正常讀取函數(shù)參數(shù) vpush?{q4-q7}
?
ARM32 結(jié)尾對(duì)寄存器的釋放
?
// 不需要pop lr寄存器,但是必須pop pc寄存器。 // ARM32結(jié)尾不需要寫(xiě) ret, 這和ARM64不同。 vpop {q4-q7} pop {r4-r8, r10-r11, pc}
?
??核心功能的實(shí)現(xiàn)
? 寫(xiě)匯編代碼之前,我們一定要先實(shí)現(xiàn)C++版本的代碼,保證C++版本的算子在ARM移動(dòng)端的計(jì)算結(jié)果是正確的。這樣做有兩個(gè)目的:第一,保證我們對(duì)算子的理解是正確并清晰的,否則寫(xiě)匯編算子就是浪費(fèi)時(shí)間;第二,為匯編算子的輸出結(jié)果提供標(biāo)準(zhǔn)答案,因?yàn)橥瑯拥?C++ 代碼在不同的平臺(tái)上的計(jì)算結(jié)果可能會(huì)略有不同(但差異不會(huì)很大),我們需要保證匯編版本的算子和C++版本的算子計(jì)算結(jié)果在ARM平臺(tái)上完全一致。 ?
匯編代碼中條件判斷和分支跳轉(zhuǎn)
? MaxPooling算子通過(guò)遍歷局部區(qū)域的所有元素,進(jìn)而找到區(qū)域內(nèi)的最大值。這就涉及到循環(huán)指令、地址跳轉(zhuǎn)指令和比較兩個(gè)向量寄存器中對(duì)應(yīng)元素。關(guān)于指令的解釋我直接在代碼注釋中寫(xiě)明。 ?
比較兩個(gè)向量寄存器中對(duì)應(yīng)元素的大小
?
/* smax, smin 比較整型數(shù)數(shù)據(jù)的大小 ARM匯編有符號(hào)整數(shù)的指令一般以s開(kāi)頭(signed int) 無(wú)符號(hào)整數(shù)的指令一般以u(píng)開(kāi)頭(unsigned int) 浮點(diǎn)數(shù)據(jù)的指令一般以f開(kāi)頭(float) */ // 比較v0和v1寄存器中的16個(gè)int8_t數(shù)據(jù), // 并將對(duì)應(yīng)位置上的較大值存儲(chǔ)在v2的相應(yīng)位置上 // b 表示以8位來(lái)讀取數(shù)據(jù),相應(yīng)的匯編中 h:16位, s:32位, d:64位 smax v2.16b, v0.16b, v1.16b smin v10.4s, v11.4s, v12.4s //比較v11和v12的4個(gè)int32_t數(shù)據(jù)的大小?
?
循環(huán)執(zhí)行某一段代碼
如果需要在ARM匯編中循環(huán)執(zhí)行一段代碼,那我們需要自定義一個(gè)符號(hào)來(lái)標(biāo)記這一段代碼。以MaxPooling算子為例,假設(shè)每一個(gè)像素點(diǎn)含有16個(gè)Channel,我們需要得到被kernel覆蓋到的9個(gè)像素點(diǎn)上對(duì)應(yīng)Channel的最大值,即重復(fù)執(zhí)行比較指令9次。例如用Loop來(lái)標(biāo)記我們需要循環(huán)的代碼段:
?
1. mov w7, #-0x80 // 給通用寄存器賦值-128,即int8_t類(lèi)型的最小值 2. dup v0.16b, w7 // 初始化v0, v0中存儲(chǔ)了16個(gè)-128 3. mov x10, #9 // 計(jì)數(shù) // 循環(huán) Loop: 3. ld1 {v1.16b}, [x0] // 從地址x0中加載16個(gè)int8的數(shù)據(jù)到v1寄存器,與v0做比較 4. smax v0.16b, v0.16b, v1.16b // 用v0記錄最終的比較結(jié)果 5. add x0, x0, #1 // 移動(dòng)像素點(diǎn)的地址,這里我們假設(shè)9個(gè)像素點(diǎn)是連續(xù)的 6. sub x10, x10, #1 // 比較完一個(gè)像素點(diǎn)的16個(gè)Channel大小后,計(jì)數(shù)減1 7. cmp x10, #0 // cmp是compare的縮寫(xiě):比較x10和0的大小 8. bgt Loop // bgt是branch greater than的縮寫(xiě),滿(mǎn)足條件就跳到分支Loop執(zhí)行 // 循環(huán)執(zhí)行結(jié)束 9. st1 {v0}, [x1] // 存儲(chǔ)寄存器v0中的16個(gè)int8_t數(shù)據(jù)到地址x1中 // ARM 匯編代碼是按照從上到下的順序來(lái)執(zhí)行的, // 所以跳出Loop不需要額外的指令來(lái)表示結(jié)束該分支 // 當(dāng)不滿(mǎn)足x10>0時(shí),會(huì)直接執(zhí)行第9行代碼? ??如何查找需要的指令 ?
靈活地運(yùn)用各種匯編指令往往能提高算子性能。
利用現(xiàn)成的匯編代碼查找指令
? 當(dāng)我們閱讀一些匯編代碼時(shí),根據(jù)匯編指令去查詢(xún)其功能是非常容易的,甚至根據(jù)指令名我們可以猜測(cè)出他的功能。但是當(dāng)我們第一次寫(xiě)匯編代碼時(shí),想知道實(shí)現(xiàn)某個(gè)功能可以使用哪些指令往往很難。此時(shí)最關(guān)鍵的一點(diǎn),需要我們思考哪個(gè)函數(shù)中會(huì)用到我將要實(shí)現(xiàn)的功能,然后去參考他的匯編實(shí)現(xiàn)過(guò)程。比如寫(xiě)Pooling算子的匯編代碼時(shí)不知道如何去進(jìn)行循環(huán)代碼段的編寫(xiě),我們就可以參考矩陣乘算子的匯編代碼去學(xué)習(xí)分支跳轉(zhuǎn),寄存器的比較等指令。當(dāng)我們不知道如何用匯編指令去實(shí)現(xiàn)浮點(diǎn)數(shù)轉(zhuǎn)整數(shù)的四舍五入時(shí),MNN中現(xiàn)成的Float2Int8函數(shù)一定會(huì)有相應(yīng)的指令實(shí)現(xiàn)這個(gè)功能。當(dāng)我們編寫(xiě)了越來(lái)越多的匯編代碼,會(huì)接觸到更多的匯編指令,解決問(wèn)題的思路和視野也更開(kāi)闊。 ?
利用關(guān)鍵詞在ARM官網(wǎng)查找指令
ARM官網(wǎng)列舉了所有匯編指令的用法,其中ARM64的指令手冊(cè)比ARM32更易查找和理解。一般ARM64的指令在ARM32系統(tǒng)都能找到對(duì)應(yīng)的等效指令。偶爾我們也需要ARM Intrisic指令來(lái)完成一些簡(jiǎn)單函數(shù)的開(kāi)發(fā),Intrisic指令可以參考。利用好功能的關(guān)鍵詞能提高查找指令的速度。例如某次編程中我需要查找哪些指令能實(shí)現(xiàn)“int8+int16->int16"的功能,顯然關(guān)鍵詞是"add". 官網(wǎng)中會(huì)列舉適用于各種場(chǎng)景的向量加法指令,很快就可以定位到"saddw v0.8h, v1.8h, v2.8b"指令。
03ARM匯編Debug方法和常見(jiàn)錯(cuò)誤列舉
?利用好“打印printf”
匯編代碼的調(diào)試一直是個(gè)難題,不能像C++代碼那樣一步步Debug查看變量的值,只能通過(guò)在函數(shù)調(diào)用的外層加打印的方式來(lái)查看匯編代碼的執(zhí)行結(jié)果。不過(guò)只要我們能利用好打印,匯編代碼的BUG排查就能簡(jiǎn)單不少!具體來(lái)說(shuō),如果我們需要查看某個(gè)中間變量的值,我們可以在代碼內(nèi)部用返回值地址來(lái)存儲(chǔ)該值,從而我們可以在匯編代碼的外部打印該地址存儲(chǔ)的內(nèi)容,這樣間接地檢查代碼執(zhí)行的邏輯是否符合預(yù)期。
??函數(shù)傳參錯(cuò)誤
函數(shù)傳參錯(cuò)誤非常容易被忽視,因?yàn)檫@個(gè)錯(cuò)誤很少會(huì)直接報(bào)錯(cuò)"segment fault",而是發(fā)現(xiàn)匯編算子的結(jié)果和C++版本不一致時(shí),經(jīng)過(guò)一步步排查才發(fā)現(xiàn)傳參就出現(xiàn)了錯(cuò)誤。畢竟我們發(fā)現(xiàn)結(jié)果錯(cuò)誤時(shí),更習(xí)慣于去檢查匯編代碼中最復(fù)雜的邏輯,不太會(huì)想到代碼開(kāi)頭的函數(shù)傳參就已經(jīng)錯(cuò)了。目前為止,我遇到過(guò)的傳參錯(cuò)誤就只有以下兩種:
1、除了整型以外的數(shù)據(jù)傳參應(yīng)該用指針傳入,而不是直接傳入?yún)?shù)值。浮點(diǎn)參數(shù)傳遞方式與編譯器及參數(shù)配置相關(guān),可能不同平臺(tái)下傳遞方式不一樣。如果直接浮點(diǎn)數(shù)值傳參,帶來(lái)的結(jié)果有可能是:浮點(diǎn)參數(shù)后面的參數(shù)數(shù)值都是前一個(gè)參數(shù)的數(shù)據(jù),也就是發(fā)生了傳參的偏移,導(dǎo)致計(jì)算結(jié)果對(duì)不上;如果恰巧你需要從某個(gè)參數(shù)中l(wèi)oad數(shù)據(jù),該參數(shù)的值受到了浮點(diǎn)參數(shù)錯(cuò)誤傳遞的影響,那有可能會(huì)報(bào)segment fault的錯(cuò)誤。
?
// 正確傳參,用指針傳遞浮點(diǎn)常數(shù)para0 void func(float* para0, float* dst) // 錯(cuò)誤傳參,直接傳入常數(shù)para0 void func(float para0, float* dst)? 2、傳參寄存器使用錯(cuò)誤
ARM64 自動(dòng)傳參的寄存器有8個(gè):x0-x7,ARM32 自動(dòng)傳參的寄存器有4個(gè): r0-r3。如果參數(shù)個(gè)數(shù)大于8(4),就需要從sp寄存器的相對(duì)位置來(lái)load參數(shù)。
asm_function MNNAvgPoolInt8 // 加上函數(shù)的傳參注釋?zhuān)奖愫罄m(xù)對(duì)照使用對(duì)應(yīng)的寄存器 // void MNNAvgPoolInt8(int8_t* dst, int8_t* src, size_t outputWidth, // size_t inputWidth, size_t kernelx, size_t kernely, size_t stridesx, // ssize_t paddingx, ssize_t factor); // Auto load: x0: dst, x1: src, x2: outputWidth, x3: inputWidth, // x4: kernelx, x5: kernely, x6: stridesx, x7: paddingx // Load from sp: // w8: factor?
?
3、整型參數(shù)建議使用ssize_t和size_t傳參
定義一個(gè)函數(shù):void func(int8_t* dst, int8_t* src, float* params0, float* params1, int width, int height, int kernelx, int kernely, int needBroadcast)
按照前面的介紹,第9個(gè)參數(shù)needBroadcast應(yīng)該由sp寄存器來(lái)加載,如:ldr x8, [sp, #0],如果我們需要比較needBroadcast和0的大小,寫(xiě)成:cmp x8, #0,無(wú)論x8是否為0,代碼的判斷結(jié)果都會(huì)是false.除非將判斷語(yǔ)句寫(xiě)成:cmp w8, #0. 出現(xiàn)這種問(wèn)題的原因在于,ssize_t和size_t這兩種類(lèi)型,ARM64和ARM32會(huì)將其分別看做是64位和32位的數(shù)據(jù),而對(duì)于int類(lèi)型的數(shù)據(jù),ARM64和ARM32上都會(huì)是32位的數(shù)據(jù),而ARM64的通用寄存器以x來(lái)使用是64位的(即x1,x2...),以w來(lái)使用才是32位的(即w1,w2...)。所以要比較x8與0的大小關(guān)系,應(yīng)是:cmp,w8,#0.
對(duì)于上述問(wèn)題的更好的解決辦法是,函數(shù)聲明時(shí)將needBroadcast參數(shù)的類(lèi)型定義成ssize_t,因?yàn)樵搮?shù)的取值可能是-1,1,0, 我們將其定義成有符號(hào)類(lèi)型。在匯編代碼中再次使用 cmp x8, #0來(lái)比較結(jié)果就是正確的了,當(dāng)然此時(shí)我們還是用w8和0比較的話(huà),結(jié)果也是正確的。
??ARM32 向量寄存器和參數(shù)加載的順序問(wèn)題
? 在匯編開(kāi)發(fā)中我遇到過(guò)這樣的問(wèn)題,定義一個(gè)函數(shù)如下:
// void MNNAvgPoolInt8(int8_t* dst, int8_t* src, size_t outputWidth, // size_t inputWidth, size_t kernelx, size_t kernely, size_t stridesx, // ssize_t paddingx, ssize_t factor); asm_function MNNAvgPoolInt8 // Auto load: r0: dst, r1: src, r2: outputWidth, r3: inputWidth // Load from sp: r4: kernelx, r5: kernely, r7: stridesx, r8: paddingx, lr: factor 2. push {r4-r8, r10-r11, lr} 3. vpush {q4-q6} 4. ldr r4, [sp, #32] 5. ldr r5, [sp, #36] 6. ldr r7, [sp, #40] 7. ldr r8, [sp, #44] 8.?ldr?lr,?[sp,?#48]???????//?lr:?factor
?
這樣可能不會(huì)出現(xiàn)報(bào)錯(cuò)segment fault,但是參數(shù)的加載結(jié)果是錯(cuò)的。原因在于第3行vpush應(yīng)該在通過(guò)sp加載完所有的函數(shù)參數(shù)之后,而不是在此之前。因?yàn)閜ush了8個(gè)通用寄存器入棧之后,再push向量寄存器入棧,那么函數(shù)參數(shù)相對(duì)于sp寄存的位置就不再是(8x4=32). 相對(duì)位置的偏移發(fā)生了變化。第3行的代碼應(yīng)該在第8行后面。 ?
??ARM64 通用寄存器的使用問(wèn)題
? 在ARM64中給通用寄存器賦整型數(shù)值
// 通用寄存器的賦值只能用32位來(lái)使用寄存器 mov w10, #0 // right mov x10, #0 // error // 后續(xù)計(jì)算中要使用x10來(lái)進(jìn)行加減乘的計(jì)算,需要將w10擴(kuò)展成x10: uxtw x10, w10 // w10中32位數(shù)據(jù)在x10的低32位中保持不變,x10的高32位填充為0.? sub, add等指令只能對(duì)整型數(shù)據(jù)操作,浮點(diǎn)類(lèi)型數(shù)據(jù)需要使用fsub, fadd等
fmov v1.4s, #1.0 fmov v2.4s, #0.2 fsub v1.4s, v1.4s, v2.4s?
?
??四舍五入的問(wèn)題
ARM32和ARM64中浮點(diǎn)數(shù)取整的方式不一樣。ARM32中浮點(diǎn)數(shù)轉(zhuǎn)換成整數(shù)的指令(vcvt.s32.f32)是向負(fù)無(wú)窮取整的,在ARM32中沒(méi)有四舍五入的取整指令。需要在ARM32中實(shí)現(xiàn)四舍五入,可以這樣做:
?
//對(duì)寄存器q3中的4個(gè)浮點(diǎn)數(shù)據(jù)做四舍五入取整 // q3: -1.4, 4.5, 1.1, -2.7 -> q3: -1, 4, 1, -3 vmov.f32 q1, #0.5 vmov.f32 q2, #-0.5 vcgt.f32 q12, q3, #0 vbsl.f32 q12, q1, q2 // bitwise select. vadd.f32 q13, q12, q3 vcvt.s32.f32 q3, q13? ARM64提供的取整指令更加靈活方便,有:
// q10: -1.4, 4.5, 1.1, -2.7 fcvtas q1, q10 // q1: -1, 5, 1, -3 就近取整 fcvtzs q2, q10 // q2: -1, 4, 1, -2 向0取整 fcvtms q3, q10 // q3: -2, 4, 1, -3 向負(fù)無(wú)窮取整 fcvtps q4, q10 // q4: -1, 5, 2, -3 向正無(wú)窮取整 fcvtns q4, q10 // q4: -2, 4, 2, -2 向最近的偶數(shù)取整?
?
??整型數(shù)據(jù)和浮點(diǎn)數(shù)據(jù)進(jìn)行數(shù)學(xué)運(yùn)算的問(wèn)題
整型數(shù)據(jù)與浮點(diǎn)數(shù)據(jù)進(jìn)行相加或相乘等數(shù)學(xué)運(yùn)算之前,一定要先將整型數(shù)據(jù)轉(zhuǎn)換成浮點(diǎn)數(shù)據(jù)再進(jìn)行數(shù)學(xué)運(yùn)算,否則計(jì)算結(jié)果會(huì)出錯(cuò)。該過(guò)程經(jīng)常出現(xiàn)在Int8量化算子的開(kāi)發(fā)中,往往是量化算子很難消除的計(jì)算負(fù)擔(dān)。用Binary multiply的Int8量化算子舉例說(shuō)明該過(guò)程:
?
// Int8 量化的乘法算子,輸入和輸出均是Int8類(lèi)型,但考慮到int8xint8會(huì)可能會(huì)導(dǎo)致越界, // 在量化算子的實(shí)現(xiàn)過(guò)程中會(huì)將兩個(gè)輸入數(shù)據(jù)分別轉(zhuǎn)換成Float32數(shù)據(jù)之后相乘, // 再將Float32的結(jié)果量化到Int8類(lèi)型. sxtl v0.8h, v0.8b // int8x8_t -> int16x8_t sxtl v1.8h, v1.8b // int8x8_t -> int16x8_t sxtl v2.4s, v0.4h // v0的低64位數(shù)據(jù):int16x4_t -> int32x4_t sxtl2 v3.4s, v0.8h // v0的高64位數(shù)據(jù):int16x4_t -> int32x4_t sxtl v4.4s, v1.4h sxtl2 v5.4s, v1.8h scvtf v2.4s, v2.4s // int32x4_t -> float32x4_t scvtf v3.4s, v3.4s scvtf v4.4s, v4.4s scvtf v5.4s, v5.4s fmul v2.4s, v2.4s, v6.4s // v6.4s: float32x4_t 量化scale參數(shù) fmul v3.4s, v3.4s, v6.4s fmul v4.4s, v4.4s, v6.4s fmul v5.4s, v5.4s, v6.4s ...? 此處有同學(xué)可能會(huì)質(zhì)疑這么麻煩還有必要開(kāi)發(fā)Int8量化的乘法算子嗎?具體原因可以參考之前關(guān)于開(kāi)發(fā)Pooling量化算子的ATA文章,開(kāi)頭有說(shuō)明原因。 ?
?
?Segment fault出現(xiàn)的可能原因總結(jié)
在這里總結(jié)目前我遇到過(guò)的程序crash情況,后續(xù)也會(huì)在此添加更多的bug。
數(shù)據(jù)加載、存儲(chǔ)時(shí),地址寄存器使用錯(cuò)誤
函數(shù)參數(shù)加載地址時(shí)是否使用了錯(cuò)誤的寄存器;
寫(xiě)代碼過(guò)程中,是否給存儲(chǔ)地址的寄存器賦值了,導(dǎo)致寄存器的內(nèi)容改變;
循環(huán)加載、存儲(chǔ)數(shù)據(jù)時(shí),原地址累加是否導(dǎo)致了越界;
寄存器開(kāi)頭和結(jié)尾是否相應(yīng)地pushpop(stpldp)
通用寄存器的加減出錯(cuò),大多由于賦值錯(cuò)誤或函數(shù)加載錯(cuò)誤而間接導(dǎo)致
通用寄存器的內(nèi)容是否符合預(yù)期,可使用Printf的辦法驗(yàn)證
ARM64和ARM32中用于自動(dòng)加載函數(shù)參數(shù)的寄存器個(gè)數(shù)分別是8個(gè)、4個(gè)
ARM64中通用寄存器賦值只能用32位,即w0,w1...根據(jù)需要決定是否使用uxtw擴(kuò)展到相應(yīng)的x0,x1...
函數(shù)參數(shù)類(lèi)型聲明錯(cuò)誤,導(dǎo)致加載錯(cuò)誤
非整型函數(shù)參數(shù)一律用指針傳遞
整型常數(shù)參數(shù)盡量使用ssize_t, size_t
是否設(shè)置了循環(huán)退出條件,比如用于計(jì)數(shù)寄存器是否每次減1,循環(huán)退出條件是否能滿(mǎn)足
有一些寄存器是否忘記push就直接使用了,參考1.1節(jié)中的圖查詢(xún)哪些寄存器需要用完恢復(fù)
? 04ARM匯編的加速效果 ?
拿ConvolutionDepthwise的Int8量化算子舉例說(shuō)明,C++版本的算子實(shí)現(xiàn)和ARM匯編版本的性能差距。測(cè)試模型中含有超過(guò)20個(gè)ConvolutionDepthwise算子。測(cè)試機(jī)我選擇了高端機(jī)華為Mate40 Pro和中端機(jī)華為P30 Pro,并使用ARM V8.2平臺(tái)的相關(guān)指令編寫(xiě)匯編算子。測(cè)試結(jié)果中顯示的時(shí)間是該模型中所有ConvolutionDepthwise算子的耗時(shí)總和,顯然在ARM V8.2 64位平臺(tái)上,匯編算子的性能提高了約4.7倍。
? | C++版本 | ARM V8.2 匯編 |
---|---|---|
華為Mate40 Pro | 11.28 ms | 1.98 ms |
華為P30 Pro | 12.83 ms | 2.22 ms |
05團(tuán)隊(duì)介紹 ?
大淘寶技術(shù)Meta Team,負(fù)責(zé)面向消費(fèi)場(chǎng)景的3D/XR基礎(chǔ)技術(shù)建設(shè)和創(chuàng)新應(yīng)用探索,通過(guò)技術(shù)和應(yīng)用創(chuàng)新找到以手機(jī)及XR 新設(shè)備為載體的消費(fèi)購(gòu)物3D/XR新體驗(yàn)。團(tuán)隊(duì)在端智能、商品三維重建、3D引擎、XR引擎等方面有深厚的技術(shù)積累。先后發(fā)布端側(cè)推理引擎MNN,端側(cè)實(shí)時(shí)視覺(jué)算法庫(kù)PixelAI,商品三維重建工具Object Drawer等技術(shù)。團(tuán)隊(duì)在OSDI、MLSys、CVPR、ICCV、NeurIPS、TPAMI等頂級(jí)學(xué)術(shù)會(huì)議和期刊上發(fā)表多篇論文。
審核編輯:湯梓紅
評(píng)論