(本文首发于公众号)
在之前的文章中提到过可以把 ReLU 合并到 Conv 中加速量化推理,当时只是用一个例子简单介绍一下过程,逻辑上存在一些漏洞。本文打算从数学上深入剖析一下其中的原理,并进一步扩展到其他激活函数,看看在网络量化中激活函数一般是怎么处理的。
为了简单起见,假设我们是量化到 uint8 的数值范围「即0~255」。回忆一下量化的基本公式 (我在之前的文章中多次强调这几个公式,它们非常重要):
r = S ( q − Z ) (1) r=S(q-Z) \tag{1} r=S(q−Z)(1)
q = c l i p ( r o u n d ( r S + Z ) , 0 , 255 ) (2) q = clip(round(\frac{r}{S}+Z),0,255) \tag{2} q=clip(round(Sr+Z),0,255)(2)
再简单重复一下符号的含义, r r r 表示实数, q q q 表示量化后的定点数, S S S 和 Z Z Z 分别是是 scale 和 zero point。
注意,这次我对 q q q 单独加了一个 clip 操作,在之前的文章中,这一步在公式中被我省略了,不过在实际量化的时候,这一步是必须的,否则会有数值溢出的危险。
现在,假设激活函数为 f ( x ) f(x) f(x),应用到实数域上是这个样子:
r 2 = f ( r 1 ) (3) r_2=f(r_1) \tag{3} r2=f(r1)(3)
那么把 (1) 式代入后可以得到量化的公式:
S 2 ( q 2 − Z 2 ) = f ( S 1 ( q 1 − Z 1 ) ) (4) S_2(q_2-Z_2)=f(S_1(q_1-Z_1)) \tag{4} S2(q2−Z2)=f(S1(q1−Z1))(4)
这就是量化时处理所有激活函数的总口诀,别看它平平无奇,但话越少,信息量越多。下面,我们就看看针对具体的激活函数,怎么运用这个公式。
ReLU 是一个非常简单的函数,它除了把小于 0 的数值截断外,甚至不做任何操作:
R e L U ( x ) = { x x > = 0 0 x < 0 (5) \begin{aligned} ReLU(x)=\begin{cases} x & x >= 0 \\ 0 & x < 0 \end{cases} \tag{5} \end{aligned} ReLU(x)={x0x>=0x<0(5)
如果把上面的函数 f f f 替换成 ReLU 的公式,就可以得到:
r 2 = { r 1 r 1 > = 0 0 r 1 < 0 (6) \begin{aligned} r_2=\begin{cases} r_1 & r_1 >= 0 \\ 0 & r_1<0 \end{cases} \tag{6} \end{aligned} r2={r10r1>=0r1<0(6)
把 (1) 式代入就变成:
S 2 ( q 2 − Z 2 ) = { S 1 ( q 1 − Z 1 ) q 1 > = Z 1 0 q 1 < Z 1 (7) S_2(q_2-Z_2)=\begin{cases} S_1(q_1-Z_1) & q_1 >= Z_1 \\ 0 & q_1 < Z_1 \end{cases} \tag{7} S2(q2−Z2)={S1(q1−Z1)0q1>=Z1q1<Z1(7)
换算一下可以得到:
q 2 = { S 1 S 2 ( q 1 − Z 1 ) + Z 2 q 1 > = Z 1 Z 2 q 1 < Z 1 (8) q_2=\begin{cases} \frac{S_1}{S_2}(q_1-Z_1)+Z_2 & q_1 >= Z_1 \\ Z_2 & q_1 < Z_1 \end{cases} \tag{8} q2={S2S1(q1−Z1)+Z2Z2q1>=Z1q1<Z1(8)
这是量化 ReLU 最通用的运算,其中 S 1 S 2 \frac{S_1}{S_2} S2S1 可以通过之前文章讲的定点数 + bitshift 来实现。
需要重点指出的是,ReLU 之后, Z 2 Z_2 Z2 永远等于 0。因为 ReLU 会把实数域上小于 0 的数全部截断为 0,此时去统计实数域的范围,可以发现是 0~a,而我们量化的数值范围是 0~255,为了保证零点对齐,因此 Z 2 Z_2 Z2 只能取 0。
当然啦,具体实现上没有必要完全按照 (8) 式来操作。一来公式内的 scale 操作过于麻烦还掉精度,二来 ReLU 本身是有明确的物理意义的,那就是把小于零点的数值截断,其余不变。这个意义在量化里面依然成立。
因此,我们其实可以用一种更简洁明了的方式来实现量化的 ReLU:
q 2 = { q 1 q 1 > = Z 1 Z 1 q 1 < Z 1 (9) q_2=\begin{cases} q_1 & q_1 >= Z_1 \\ Z_1 & q_1 < Z_1 \end{cases} \tag{9} q2={q1Z1q1>=Z1q1<Z1(9)
如果是使用这个公式,那 ReLU 前后的 scale 和 zeropoint 是要保持一致的,这一点可以从 ReLU 本身的物理含义出发得出。
tflite 里面就是用了这个简化的公式来实现 ReLU 的功能「下面这段代码参考自https://github.com/tensorflow/tensorflow/blob/r1.15/tensorflow/lite/kernels/internal/reference/reference_ops.h#L214」:
template
inline void ReluX(const tflite::ActivationParams& params,
const RuntimeShape& input_shape, const T* input_data,
const RuntimeShape& output_shape, T* output_data) {
gemmlowp::ScopedProfilingLabel label("Quantized ReluX (not fused)");
const int flat_size = MatchingFlatSize(input_shape, output_shape);
const T max_value = params.quantized_activation_max;
const T min_value = params.quantized_activation_min;
for (int i = 0; i < flat_size; ++i) {
const T val = input_data[i];
const T clamped =
val > max_value ? max_value : val < min_value ? min_value : val;
output_data[i] = clamped;
}
}
可以看出,这个量化的 ReLU 和浮点数版本的 ReLU 逻辑上几乎没有区别。
其实不止是 Conv,全连接层 FC 等也可以和 ReLU 合并。我们来看看为什么。
同样地,假设一个卷积操作为 r 3 = ∑ i N r 1 i r 2 i r_3=\sum_{i}^N r_1^i r_2^i r3=∑iNr1ir2i,按照之前文章的描述,量化后的公式为:
S 3 ( q 3 − Z 3 ) = S 1 S 2 ∑ i N ( q 1 − Z 1 ) ( q 2 − Z 2 ) (10) S_3(q_3-Z_3)=S_1S_2 \sum_{i}^N (q_1-Z_1)(q_2-Z_2) \tag{10} S3(q3−Z3)=S1S2i∑N(q1−Z1)(q2−Z2)(10)
现在, q 3 q_3 q3 进入 ReLU 进行运算得到 q 4 q_4 q4,按照上面的推算可以得出:
KaTeX parse error: No such environment: align at position 8: \begin{̲a̲l̲i̲g̲n̲}̲ S_4(q_4-Z_4)&=…
换算一下得到:
q 4 = { S 1 S 2 S 4 ∑ i N ( q 1 − Z 1 ) ( q 2 − Z 2 ) + Z 4 q 3 > = Z 3 Z 4 q 3 < Z 3 (12) q_4=\begin{cases} \frac{S_1S_2}{S_4}\sum_{i}^N (q_1-Z_1)(q_2-Z_2)+Z_4 & q_3 >= Z_3 \\ Z_4 & q_3 < Z_3 \end{cases} \tag{12} q4={S4S1S2∑iN(q1−Z1)(q2−Z2)+Z4Z4q3>=Z3q3<Z3(12)
到这里,这个式子仍然是 ReLU 的形式。换句话说,我们仍然要走两个分支来计算函数的结果。
但是,如果要把 ReLU 合并到 Conv 中,就必须得用 Conv 的运算来代替这个分支运算。换句话说, q 4 q_4 q4 无论跑哪个分支,都必须可以用 S 1 S 2 S 4 ∑ i N ( q 1 − Z 1 ) ( q 2 − Z 2 ) + Z 4 \frac{S_1S_2}{S_4}\sum_{i}^N (q_1-Z_1)(q_2-Z_2)+Z_4 S4S1S2∑iN(q1−Z1)(q2−Z2)+Z4 直接计算出来,我们才能实现 Conv 和 ReLU 的合并。
这时,就要用到量化中的 clip 操作了。上面式子 (12),其实更严格的写法应该是:
q 4 = { c l i p ( S 1 S 2 S 4 ∑ i N ( q 1 − Z 1 ) ( q 2 − Z 2 ) + Z 4 , 0 , 255 ) q 3 > = Z 3 Z 4 q 3 < Z 3 (13) q_4=\begin{cases} clip(\frac{S_1S_2}{S_4}\sum_{i}^N (q_1-Z_1)(q_2-Z_2)+Z_4, 0, 255) & q_3 >= Z_3 \\ Z_4 & q_3 < Z_3 \end{cases} \tag{13} q4={clip(S4S1S2∑iN(q1−Z1)(q2−Z2)+Z4,0,255)Z4q3>=Z3q3<Z3(13)
前面说了, Z 4 = 0 Z_4=0 Z4=0。如果 q 3 < Z 3 q_3 < Z_3 q3<Z3,那么等价地 ∑ i N ( q 1 − Z 1 ) ( q 2 − Z 2 ) < 0 \sum_{i}^N (q_1-Z_1)(q_2-Z_2)<0 ∑iN(q1−Z1)(q2−Z2)<0,此时会跑第二个分支得到 q 4 = Z 4 q_4=Z_4 q4=Z4。但是,由于有 clip 操作,在这种情况下, q 4 = c l i p ( S 1 S 2 S 4 ∑ i N ( q 1 − Z 1 ) ( q 2 − Z 2 ) + Z 4 , 0 , 255 ) = 0 = Z 4 q_4=clip(\frac{S_1S_2}{S_4}\sum_{i}^N (q_1-Z_1)(q_2-Z_2)+Z_4, 0, 255)=0=Z_4 q4=clip(S4S1S2∑iN(q1−Z1)(q2−Z2)+Z4,0,255)=0=Z4,因此,我们发现,无论跑哪个分支,最后都可以统一用下面这个式子来表示:
q 4 = c l i p ( S 1 S 2 S 4 ∑ i N ( q 1 − Z 1 ) ( q 2 − Z 2 ) + Z 4 , 0 , 255 ) (14) q_4=clip(\frac{S_1S_2}{S_4}\sum_{i}^N (q_1-Z_1)(q_2-Z_2)+Z_4, 0, 255) \tag{14} q4=clip(S4S1S2i∑N(q1−Z1)(q2−Z2)+Z4,0,255)(14)
而这个公式的意义相当于:我们计算出 ReLU 之后的 S S S 和 Z Z Z,然后把这个 S S S 和 Z Z Z 对应到 Conv 的输出,这样一来,ReLU 的运算就合并到 Conv 里面了。
正如我前面提到的,ReLU 除了做数值上的截断外,其实没有其他操作了,而量化本身自带截断操作,因此才能把 ReLU 合并到 Conv 或者 FC 等操作里面。
有读者可能觉得,ReLU 本身的操作很简单,为什么还得用 (8) 式这种绕弯路的方式说一大堆。那是因为 ReLU 本身的性质可以让我们抄近道,如果换成其他函数,这个流程就绕不过去了。
不信来看看 LeakyReLU 是怎么量化的。
LeakyReLU 的公式可以表示成:
L e a k y R e L U ( x ) = { x x > = 0 α x x < 0 (15) LeakyReLU(x)=\begin{cases}x & x >= 0 \\ \alpha x & x < 0 \end{cases} \tag{15} LeakyReLU(x)={xαxx>=0x<0(15)
这里面的 α \alpha α 是我们事先指定的数值,一般是 0~1 之间的小数。
同样地,我们按照文章最开始的总口诀,即公式 (3)(4),来逐步分析这个函数。把原来的函数 f f f 替换成 LeakyReLU,可以得到:
r 2 = { r 1 r 1 > = 0 α r 1 r 1 < 0 (16) r_2=\begin{cases} r_1 & r_1 >= 0 \\ \alpha r_1 & r_1 < 0 \end{cases} \tag{16} r2={r1αr1r1>=0r1<0(16)
把 (1) 式代入:
S 2 ( q 2 − Z 2 ) = { S 1 ( q 1 − Z 1 ) q 1 > = Z 1 α S 1 ( q 1 − Z 1 ) q 1 < Z 1 (17) S_2(q_2-Z_2)=\begin{cases}S_1(q_1-Z_1) & q_1 >= Z_1 \\ \alpha S_1(q_1-Z1) & q_1 < Z_1 \end{cases} \tag{17} S2(q2−Z2)={S1(q1−Z1)αS1(q1−Z1)q1>=Z1q1<Z1(17)
换算一下得到:
q 2 = { S 1 S 2 ( q 1 − Z 1 ) + Z 2 q 1 > = Z 1 α S 1 S 2 ( q 1 − Z 1 ) + Z 2 q 1 < Z 1 (18) q_2=\begin{cases}\frac{S_1}{S_2}(q_1-Z_1)+Z_2 & q_1 >= Z_1 \\ \frac{\alpha S_1}{S_2}(q_1-Z_1)+Z_2 & q_1 < Z_1 \end{cases} \tag{18} q2={S2S1(q1−Z1)+Z2S2αS1(q1−Z1)+Z2q1>=Z1q1<Z1(18)
此时,由于有 α \alpha α 的存在,这两个分支就无法像 ReLU 一样进行合并,自然也就无法整合到 Conv 等操作内部了。
在 tflite 中是将 α \alpha α 转换为一个定点数再计算的。具体地,假设 α q = c l i p ( r o u n d ( α S 1 + Z 1 ) , 0 , 255 ) \alpha_q=clip(round(\frac{\alpha}{S_1}+Z_1), 0, 255) αq=clip(round(S1α+Z1),0,255),可以得到:
q 2 = { S 1 S 2 ( q 1 − Z 1 ) + Z 2 q 1 > = Z 1 S 1 S 1 S 2 ( α q − Z 1 ) ( q 1 − Z 1 ) q 1 < Z 1 (19) q_2=\begin{cases}\frac{S_1}{S_2}(q_1-Z_1)+Z_2 & q_1 >= Z_1 \\ \frac{S_1S_1}{S_2}(\alpha_q-Z_1)(q_1-Z_1) & q_1 < Z_1 \end{cases} \tag{19} q2={S2S1(q1−Z1)+Z2S2S1S1(αq−Z1)(q1−Z1)q1>=Z1q1<Z1(19)
具体代码如下「参考自https://github.com/tensorflow/tensorflow/blob/r1.15/tensorflow/lite/kernels/activations.cc#L248」:
TfLiteStatus LeakyReluPrepare(TfLiteContext* context, TfLiteNode* node) {
TF_LITE_ENSURE_EQ(context, NumInputs(node), 1);
TF_LITE_ENSURE_EQ(context, NumOutputs(node), 1);
const TfLiteTensor* input = GetInput(context, node, 0);
TfLiteTensor* output = GetOutput(context, node, 0);
TF_LITE_ENSURE_EQ(context, input->type, output->type);
LeakyReluOpData* data = reinterpret_cast(node->user_data);
if (output->type == kTfLiteUInt8) {
const auto* params =
reinterpret_cast(node->builtin_data);
// Quantize the alpha with same zero-point and scale as of input
data->q_alpha = static_cast(std::max(
std::numeric_limits::min(),
std::min(std::numeric_limits::max(),
std::round(input->params.zero_point +
(params->alpha / input->params.scale)))));
double real_multiplier =
input->params.scale * input->params.scale / output->params.scale;
QuantizeMultiplierSmallerThanOneExp(
real_multiplier, &data->output_multiplier, &data->output_shift);
}
return context->ResizeTensor(context, output,
TfLiteIntArrayCopy(input->dims));
}
这段代码主要是做一些准备工作,把 α q \alpha_q αq 和 S 1 S 1 S 2 \frac{S_1S_1}{S_2} S2S1S1 等变量事先计算好。
函数本身的具体操作如下「参考自https://github.com/tensorflow/tensorflow/blob/r1.15/tensorflow/lite/kernels/internal/reference/reference_ops.h#L242」:
template
inline void QuantizeLeakyRelu(const LeakyReluParams& params, T q_alpha,
const RuntimeShape& input_shape,
const T* input_data,
const RuntimeShape& output_shape,
T* output_data) {
gemmlowp::ScopedProfilingLabel label("LeakyRelu (not fused)");
const int flat_size = MatchingFlatSize(input_shape, output_shape);
static const int32 quantized_min = std::numeric_limits::min();
static const int32 quantized_max = std::numeric_limits::max();
static const int32 alpha_value = q_alpha - params.alpha_offset;
for (int i = 0; i < flat_size; ++i) {
const int32 input_value = input_data[i] - params.input_offset;
if (input_value >= 0) {
output_data[i] = input_data[i];
} else {
const int32 unclamped_output =
params.output_offset + MultiplyByQuantizedMultiplierSmallerThanOneExp(
input_value * alpha_value,
params.output_multiplier,
params.output_shift);
const T clamped_output =
std::min(quantized_max, std::max(quantized_min, unclamped_output));
output_data[i] = static_cast(clamped_output);
}
}
}
代码里面的 input_value
就是公式 (19) 里面的 q 1 − Z 1 q_1-Z_1 q1−Z1,tflite 会根据 input_val
的数值情况分两个分支运行,这个过程和 (19) 基本一致。
眼尖的读者可能发现,为啥 q 1 > Z 1 q_1>Z_1 q1>Z1 这个分支,代码里面好像直接令 q 2 = q 1 q_2=q_1 q2=q1 了,这跟公式 (19) 描述的好像不一样啊。哈哈,这个地方我也暂时不明白,了解详情的读者请教教我,或者我之后弄懂再补充一下。
对于类 ReLU 函数来说,其实还都是分段线性的,那遇到非线性的函数「比如 sigmoid、tanh」又该怎么量化呢?从 gemmlowp 的文档来看,这些函数其实是用定点运算来近似浮点的效果。这部分内容触及到我的知识盲区,所以就不给大家做深入介绍了,感兴趣的读者可以看一下 gemmlowp 的源码进一步了解:https://github.com/google/gemmlowp/blob/master/fixedpoint/fixedpoint.h。
虽然我对里面的原理了解不多,但还是有一点点落地的经验。我曾经用高通骁龙的 SNPE 工具量化了 tanh 函数,但在 DSP 上跑定点运算的时候,发现耗时比在 GPU 上跑浮点运算满了 100 倍左右。
因此对于有落地需求的同学来说,我的建议是网络中尽量不要包含这类非线性函数。如果实在要有的话,要么尝试把网络拆成几块,一些跑定点,一些跑浮点,要么就是用一些线性函数来近似这些非线性函数的效果。
这篇文章主要讲了网络量化中如何处理激活函数,并从数学上进一步剖析为何 ReLU 可以和 Conv 等操作合并。
你可能已经发现,网络量化这个课题跟底层的实现联系非常紧密,比如涉及到 gemmlowp、neon 等底层函数库等。有读者会说:我只想老老实实研究算法,对这些底层的运算不了解也没兴趣了解啊!
对于这部分读者,其实也不用焦虑,诚然,网络量化对底层的联系相比其他深度学习算法而言更加紧密,但对于顶层的算法开发人员,只需要大概知道底层是怎么运行的就可以,而把更多的精力放在对量化算法的改进上。当然啦,如果想成为一流的网络量化专家,熟悉底层还是很有必要的,否则你怎么知道未来算法的发展趋势呢?
欢迎关注我的公众号:大白话AI,立志用大白话讲懂AI。