PRelu算子调优经历-函数优化策略

2023-08-25 02:42:01来源:NXP

上一篇小编和大家分享了在运行客户的一个模型时遇到了一个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!

不过,小编精益求精,还有一些微小的优化空间,后续将会进一步优化。

欢迎朋友们持续关注~

标签:

今日热门
More
供应
返回顶部