神经网络量化入门--激活函数
本文首发于公众号
在之前的文章中提到过可以把 ReLU 合并到 Conv 中加速量化推理,当时只是用一个例子简单介绍一下过程,逻辑上存在一些漏洞。本文打算从数学上深入剖析一下其中的原理,并进一步扩展到其他激活函数,看看在网络量化中激活函数一般是怎么处理的。
温故知新
为了简单起见,假设我们是量化到 uint8 的数值范围「即0~255」。回忆一下量化的基本公式「我在之前的文章中多次强调这几个公式,它们非常重要」
再简单重复一下符号的含义, 表示实数, 表示量化后的定点数, 和 分别是是 scale 和 zero point。
注意,这次我对 单独加了一个 clip 操作,在之前的文章中,这一步在公式中被我省略了,不过在实际量化的时候,这一步是必须的,否则会有数值溢出的危险。
现在,假设激活函数为 ,应用到实数域上是这个样子:
那么把 (1) 式代入后可以得到量化的公式:
这就是量化时处理所有激活函数的总口诀,别看它平平无奇,但话越少,信息量越多。下面,我们就看看针对具体的激活函数,怎么运用这个公式。
ReLU
ReLU 是一个非常简单的函数,它除了把小于 0 的数值截断外,甚至不做任何操作:
如果把上面的函数 替换成 ReLU 的公式,就可以得到:
把 (1) 式代入就变成:
换算一下可以得到:
这是量化 ReLU 最通用的运算,其中 可以通过之前文章讲的定点数 + bitshift 来实现。
需要重点指出的是,ReLU 之后, 永远等于 0。因为 ReLU 会把实数域上小于 0 的数全部截断为 0,此时去统计实数域的范围,可以发现是 0~a,而我们量化的数值范围是 0~255,为了保证零点对齐,因此 只能取 0。
当然啦,具体实现上没有必要完全按照 (8) 式来操作。一来公式内的 scale 操作过于麻烦还掉精度,二来 ReLU 本身是有明确的物理意义的,那就是把小于零点的数值截断,其余不变。这个意义在量化里面依然成立。
因此,我们其实可以用一种更简洁明了的方式来实现量化的 ReLU:
如果是使用这个公式,那 ReLU 前后的 scale 和 zeropoint 是要保持一致的,这一点可以从 ReLU 本身的物理含义出发得出。
tflite 里面就是用了这个简化的公式来实现 ReLU 的功能「下面这段代码参考自https://github.com/tensorflow/tensorflow/blob/r1.15/tensorflow/lite/kernels/internal/reference/reference_ops.h#L214」:
template <typename T> 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 逻辑上几乎没有区别。
ReLU如何勾搭上Conv
其实不止是 Conv,全连接层 FC 等也可以和 ReLU 合并。我们来看看为什么。
同样地,假设一个卷积操作为 ,按照之前文章的描述,量化后的公式为:
现在, 进入 ReLU 进行运算得到 ,按照上面的推算可以得出:
换算一下得到:
到这里,这个式子仍然是 ReLU 的形式。换句话说,我们仍然要走两个分支来计算函数的结果。
但是,如果要把 ReLU 合并到 Conv 中,就必须得用 Conv 的运算来代替这个分支运算。换句话说, 无论跑哪个分支,都必须可以用 直接计算出来,我们才能实现 Conv 和 ReLU 的合并。
这时,就要用到量化中的 clip 操作了。上面式子 (12),其实更严格的写法应该是:
前面说了,。如果 ,那么等价地 ,此时会跑第二个分支得到 。但是,由于有 clip 操作,在这种情况下,,因此,我们发现,无论跑哪个分支,最后都可以统一用下面这个式子来表示:
而这个公式的意义相当于:我们计算出 ReLU 之后的 和 ,然后把这个 和 对应到 Conv 的输出,这样一来,ReLU 的运算就合并到 Conv 里面了。
正如我前面提到的,ReLU 除了做数值上的截断外,其实没有其他操作了,而量化本身自带截断操作,因此才能把 ReLU 合并到 Conv 或者 FC 等操作里面。
LeakyReLU
有读者可能觉得,ReLU 本身的操作很简单,为什么还得用 (8) 式这种绕弯路的方式说一大堆。那是因为 ReLU 本身的性质可以让我们抄近道,如果换成其他函数,这个流程就绕不过去了。
不信来看看 LeakyReLU 是怎么量化的。
LeakyReLU 的公式可以表示成:
这里面的 是我们事先指定的数值,一般是 0~1 之间的小数。
同样地,我们按照文章最开始的总口诀,即公式 (3)(4),来逐步分析这个函数。把原来的函数 替换成 LeakyReLU,可以得到:
把 (1) 式代入:
换算一下得到:
此时,由于有 的存在,这两个分支就无法像 ReLU 一样进行合并,自然也就无法整合到 Conv 等操作内部了。
在 tflite 中是将 转换为一个定点数再计算的。具体地,假设 ,可以得到:
具体代码如下「参考自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<LeakyReluOpData*>(node->user_data); if (output->type == kTfLiteUInt8) { const auto* params = reinterpret_cast<TfLiteLeakyReluParams*>(node->builtin_data); // Quantize the alpha with same zero-point and scale as of input data->q_alpha = static_cast<uint8_t>(std::max<float>( std::numeric_limits<uint8_t>::min(), std::min<float>(std::numeric_limits<uint8_t>::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)); }
这段代码主要是做一些准备工作,把 和 等变量事先计算好。
函数本身的具体操作如下「参考自https://github.com/tensorflow/tensorflow/blob/r1.15/tensorflow/lite/kernels/internal/reference/reference_ops.h#L242」:
template <typename T> 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<T>::min(); static const int32 quantized_max = std::numeric_limits<T>::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<uint8>(clamped_output); } } }
代码里面的 input_value
就是公式 (19) 里面的 ,tflite 会根据 input_val
的数值情况分两个分支运行,这个过程和 (19) 基本一致。
眼尖的读者可能发现,为啥 这个分支,代码里面好像直接令 了,这跟公式 (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

【推荐】国内首个AI IDE,深度理解中文开发场景,立即下载体验Trae
【推荐】编程新体验,更懂你的AI,立即体验豆包MarsCode编程助手
【推荐】抖音旗下AI助手豆包,你的智能百科全书,全免费不限次数
【推荐】轻量又高性能的 SSH 工具 IShell:AI 加持,快人一步
· 从 HTTP 原因短语缺失研究 HTTP/2 和 HTTP/3 的设计差异
· AI与.NET技术实操系列:向量存储与相似性搜索在 .NET 中的实现
· 基于Microsoft.Extensions.AI核心库实现RAG应用
· Linux系列:如何用heaptrack跟踪.NET程序的非托管内存泄露
· 开发者必知的日志记录最佳实践
· TypeScript + Deepseek 打造卜卦网站:技术与玄学的结合
· Manus的开源复刻OpenManus初探
· AI 智能体引爆开源社区「GitHub 热点速览」
· C#/.NET/.NET Core技术前沿周刊 | 第 29 期(2025年3.1-3.9)
· 从HTTP原因短语缺失研究HTTP/2和HTTP/3的设计差异