PRelu算子調優經歷-函數優化策略
上一篇小編和大家分享了在運行客戶的一個模型時遇到了一個PRelu算子,在利用TFLm自帶的PRelu參考實現的代碼,其中PRelu竟然拋出了188ms的天文數字...因此小編開始準備PRelu算子的優化工作。
分析了參考實現后,發現了兩個優化方向,其一是PRelu中alpha參數的特殊性所帶來的內存訪問優化;以及量化模型所帶來的反量化問題。
本期小編就和大家一起來看下對于反量化問題的優化細節。在開始前,再來回顧一下小編所特殊定制的模型:
(資料圖片)
這是一個具有5個節點的小巧的深度神經網絡,輸入時128*128*3,模型推理時間(采用KeilIDE,ofast優化):
跳過PRelu算子,模型推理時間:
這樣我們就可以得出PRelu算子的執行時間為13ms,接下來就將以此為基礎進行算法優化,TFLm算法實現:
output_value = MultiplyByQuantizedMultiplier( input_value, params.output_multiplier_1, params.output_shift_1);output_value = MultiplyByQuantizedMultiplier( input_value * alpha_value, params.output_multiplier_2, params.output_shift_2);
上一篇小編給大家解釋了為何需要進行反量化操作以及其必要性。所謂反量化操作的本質,就是要用int8類型的中間結果來準確表達浮點結果。那么具體來說需要怎么操作呢?下面就是嚴謹的推公式環節,請讀友們不要眨眼:
首先是整數環節,我們假設輸入為input, 輸出為output,參數alpha;其參數類型均為int8。而想要將其反量化為浮點數,需要為其設定對應的量化參數,分別為scale以及zero_point。這樣一來,變量的浮點數表示即為:v_fp=scale* (v_i8+zero_point)
為了分析簡單,我們假設zero_point為0,那么上式可被簡化為,當然實際計算式,只需要將輸入值提前加上其zero_point再進行操作即可:
v_fp=scale* v_i8接下來我們根據輸入數據的符號進行區分,當輸入為正時,其輸出結果為,
scale_o* output=scale_i* v_i8output=scale_i / scale_0* v_i8
這樣我們就可以根據輸入直接獲取int8類型的輸出結果。
當輸入為負時:
scale_o* output=(scale_a*alpha)*(scale_i* v_i8)output=((scale_a* scale_i)/scale_0)* 〖alpha*v〗_i8)
這樣也就獲得了相對應的負數輸入所對應的輸出結果。不過,征程還沒有結束,TFLm的參考實現會將這兩組浮點數代表的scale參數轉換為指數形式,并以mul+shift的形式保存為:正數output_multipiler_1和output_shift_1, 負數output_multipiler_2和output_shift_2。
知道了結果是如何進行反量化操作的,回過頭我們看看TFLm的實現:inline std::int16_t SaturatingRoundingDoublingHighMul(std::int16_t a, std::int16_t b) { bool overflow = a == b && a == std::numeric_limits::min(); std::int32_t a_32(a); std::int32_t b_32(b); std::int32_t ab_32 = a_32 * b_32; std::int16_t nudge = ab_32 >= 0 ? (1 << 14) : (1 - (1 << 14)); std::int16_t ab_x2_high16 = static_cast((ab_32 + nudge) / (1 << 15)); return overflow ? std::numeric_limits::max() : ab_x2_high16;}inline int32_t MultiplyByQuantizedMultiplier(int32_t x, int32_t quantized_multiplier, int shift) { using gemmlowp::RoundingDivideByPOT; using gemmlowp::SaturatingRoundingDoublingHighMul; int left_shift = shift > 0 ? shift : 0; int right_shift = shift > 0 ? 0 : -shift; return RoundingDivideByPOT(SaturatingRoundingDoublingHighMul( x * (1 << left_shift), quantized_multiplier), right_shift);}
首先arm的cmsis-nn庫是兼容這種量化方式的,那么他也一定有一個這樣的實現,功夫不負有心人,這個函數叫做arm_nn_requantize,直接替換MultiplyByQuantizedMultiplier函數讓我們先看一下速度:
嗯,不錯,有效果,44ms->42ms,相當于PRelu算子執行速度從13ms->11ms; 還可以,無痛漲點。翻看arm_nn_requantize函數,其中也不乏一些手撕浮點數的神秘操作。考慮到我們的RT1170本身兼備一個FPU單元,為啥不直接用浮點數計算呢?這次我們不對scale參數進行指數化轉換,而是直接將其作為浮點數參與運算,公式就是上面我們推導的:
// init the float mul, shift float real_multiplier_1 = (input->params.scale) / (output->params.scale); float real_multiplier_2 = (input->params.scale) * (alpha->params.scale) / (output->params.scale);
計算方式重新定義為:
output_value = MultiplyByQuantizedMultiplierFP32( input_value, multiplier_pos);static inline int32_t MultiplyByQuantizedMultiplierFP32(int32_t x, float mul){ return roundf(x * mul);
是不是看著非常清爽?讓我們看下時間:
額。。。有點尷尬,竟然沒有長點,而且和TFLm的原始實現速度一樣。小編才提到的內存優化不是還沒有上?浮點運算這邊還有小插曲,讓我們繼續前行:
首先讓我們先看下浮點操作再如何進行優化,由于我們的代碼由于采用了Ofast優化策略,因此代碼的可閱讀性變得很差。為了進行代碼優化,小編需要特殊編寫一組浮點運算代碼以供優化參考,因為我們最終實現的是一個int32數據與浮點數相乘:
static inline int32_t MultiplyByQuantizedMultiplierFP32(int32_t x, float mul){ return roundf(x * mul);}
編寫代碼如下:
int32_t v1 = (float)SysTick->VAL; float v2 = SysTick->VAL * 0.0001f; int32_t v3 = (v1 * v2); PRINTF("%d", v3);
其所生成的匯編代碼為:
int32_t v1 = (float)SysTick->VAL; 800040DCLDR R2, [R0] 800040DE STRD R2, R1, [SP] 800040E2 VLDR D0, [SP] 800040E8 VSUB.F64 D0, D0, D1 800040F0 VCVT.F32.F64 S0, D0 800040F8 VCVT.S32.F32 S0, S0 800040FE VMOV R0, S0 float v2 = SysTick->VAL * 0.0001f; 800040E6 LDR R0, [R0] 800040EC STRD R0, R1, [SP, #16] 800040F4 VLDR D2, [SP, #16] 80004102 VSUB.F64 D0, D2, D1 80004106 VLDR D2, =0x4330000080000000 80004110 VCVT.F32.F64 S0, D0 80004122 VMUL.F32 S0, S0, S4 int32_t v3 = (v1 * v2); 800040FC STR R1, [SP, #12] 8000410A EORR0, R0, #0x80000000 8000410E STR R0, [SP, #8] 80004116 VLDR D1, [SP, #8] 8000411A VSUB.F64 D1, D1, D2 8000411E VLDR S4, =0x38D1B717 80004126 VCVT.F32.F64 S2, D1 8000412A VMUL.F32 S0, S2, S0
到這里,小伙伴們可能已經看到了端倪,小編也特意為大家標紅了幾條匯編代碼。那小編就先拋出疑問:我們明明定義的浮點型, 咋還用上double類型了呢?相同的代碼用GCC編譯會是什么樣的呢?int32_t v1 = (float)SysTick->VAL;300030f2: mov.w r3, #3758153728 ; 0xe000e000300030f6: vldr s15, [r3, #24]71 float v2 = SysTick->VAL * 0.0001f;300030fa: vldr s14, [r3, #24]300030fe: vcvt.f32.u32 s14, s1430003102: vldr s13, [pc, #92] ; 0x30003160 +148>30003106: vmul.f32 s14, s14, s1372 int32_t v3 = __builtin_roundf(v1 * v2);3000310a: vcvt.f32.s32 s15, s153000310e: vmul.f32 s15, s15, s1430003112: vrinta.f32 s15, s15
看似正常,沒有使用double類型寄存器;那問題出在哪呢?難道Keil對于浮點數的支持不太行?翻閱了一萬件資料之后,小編在編譯時使用一個叫做-ffp-mode = full的參數,這個參數的意思是:
同時還有兩個參數,是-fp-mode=fast和-fp-mode=std,簡單來講就是full會保證轉換精度,因此會出現使用double類型的情況。而fast可能會丟失一點精度,而std介于兩者之間。那么我們定義-fp-mode=std試試?
代碼如下:
int32_t v1 = (float)SysTick->VAL; 800040D4 VLDR S0, [R0] 800040E2 VCVT.F32.U32 S0, S0 float v2 = SysTick->VAL * 0.0001f; 800040D8 VLDR S2, [R0] 800040DC VCVT.F32.U32 S2, S2 800040E6 VMUL.F32 S2, S2, S4 int32_t v3 = (v1 * v2); 800040EA VRINTZ.F32 S0, S0 800040EE VMUL.F32 S0, S2, S0
嗯,優雅,就是這么簡單。指令條數減少了很多啊,讓我們再來看看時間:
這樣一來就和arm提供的方式一致了,相比實現就清爽了很多。
接下來小編還有一個殺手锏,內存優化,不過此處的內存優化是有個前提,我們知道PRelu的alpha參數是按通道的,這里要做個特殊的假設,假設輸入維度為 h w c,而且alpha參數是按h w共享的,即只有最后一維參數,維度為11 c:
if((alpha_shape.Dims(0) == 1) && (alpha_shape.Dims(1) == 1))
這樣我們就可以按c通道進行展開,并進行順序訪問;
其次,輸入數據為int8類型,原始實現方式中每次只取一個數據進行計算:const int32_t input_value = params.input_offset + input_data[input_index];
這樣編譯器會將起編譯為LDRB指令,即每次只獲取一個字節的數據。對此進行優化,每次讀取4個字節的數據,這樣可以編譯為LDR指令,并放置于寄存器中,減少訪存次數:
uint32_t steps = alpha_shape.Dims(2);uint32_t total_size = input_shape.Dims(0) * input_shape.Dims(1) * input_shape.Dims(2) * input_shape.Dims(3);for(int value_index=0;value_index T *alpha = (T *)alpha_data; // each 4, calc the time_tick uint32_t inner_loop = steps >> 2; int8_t *input_data_ptr = (int8_t*)input_data + value_index; int8_t *output_data_ptr = (int8_t*)output_data + value_index; while(inner_loop --){ int32_t input_data_32 = *((int32_t*)(input_data_ptr)); input_data_ptr += 4; uint32_t count = 4; while(count--){ int8_t input_data_8 = input_data_32 & 0xFF; input_data_32 >>= 8; 。。。。;value_index+=steps){>
這樣一來,就可以順序取數據,并且每次讀取4個字節,看下時間:
Nice!~
PRelu的時間變為37ms – 31ms = 6ms。經過兩步優化,將PRelu的執行時間降低了7ms。用客戶的模型測試一下,PRelu算子運行時間從之前的188ms降低到了51ms。Perfect!
不過,小編精益求精,還有一些微小的優化空間,后續將會進一步優化。
歡迎朋友們持續關注~
關鍵詞:
相關文章
精彩推送
港股異動 | 康臣藥業(01681)績后漲超7% 上半年歸母溢利同比增長17.24% 尿毒清顆粒維持市場領先地位
智通財經APP獲悉,康臣藥業(01681)績后漲超7%,截至發稿,漲6 16%,報5
港股異動 | 康師傅控股(00322)午后漲超6% 上半年歸母溢利同比增長30.66% 兩大業務板塊保持增長態勢
智通財經APP獲悉康師傅控股00322午后漲超6截至發稿漲568報1154港元成交