把一个几 MB 的浮点模型放进只有 512KB SRAM 的 MCU,还要保证摄像头 30fps 不断流,这件事在嵌入式AI模型量化真正成熟之前,基本只能靠加内存、换主控硬扛。我最早做端侧图像分类时,一个参数量 4M 出头的网络,FP32 权重就占了 16MB 多,Flash 直接放不下,更别说推理时激活张量还要占掉一大块 SRAM。后来把权重和激活都压到 int8,模型体积掉到 4MB 左右,峰值内存压进 400KB,精度只掉了不到 1 个百分点,帧率翻了将近三倍,这才算真正跑通。这篇内容讲的就是这条路径:模型量化怎么在模型压缩和推理加速之间找到一个能落地的平衡点,我会把整个流程拆成可复现的 5 步,从资源账本、方案选型、PTQ 到 QAT、再到端侧算子适配和最终验收,每一步都给参数、给代码、给判断标准。适合已经训练过模型、但还没在端侧真正落地过的同学,也适合被“模型放不下、跑不动”卡住很久的嵌入式开发者。
1. 嵌入式量化到底在省什么:先把资源账算明白
1.1 三个硬约束:Flash、SRAM 和算力
很多人一上来就问“量化能掉多少精度”,我觉得顺序反了,应该先问“我的板子到底缺什么”。端侧资源永远是三个维度同时卡:Flash 存权重和常量,SRAM 放输入、激活和中间张量,算力决定单帧延迟。这三者缺一不可,而且互相牵制——你把模型压小了,可能激活峰值反而更高;你把延迟降下来了,可能精度又崩了。
拿一个典型的小型分类网络举例,参数量 4.2M。FP32 存储是 4.2M × 4 字节 ≈ 16.8MB,而多数中低端 MCU 的片内 Flash 只有 1MB 到 2MB,就算外挂 QSPI Flash 到 8MB,读取带宽也会成为新瓶颈。压到 int8 后是 4.2MB,仍不算小,所以实际项目里通常还要配合剪枝、换更轻的骨干网络。激活侧更麻烦,峰值内存大致等于“输入张量 + 当前层输出 + 复用缓冲”,以 224×224×3 的输入为例,单是输入就是 150KB(int8)或 600KB(FP32),再加上中间特征图,FP32 下很容易冲破 1MB。
算力这块,带单精度 FPU 的 Cortex-M7 跑一次 FP32 乘加大概 1 到 2 个周期,而 int8 有专门的 SIMD 指令,一条指令能打包算 4 个 8 位乘加。实测下来,同一个卷积层,int8 实现相对 FP32 通常有 3 到 4 倍的吞吐提升,这个倍率不是理论吹出来的,是访存和指令两条线同时受益的结果。所以量化在端侧的价值,从来不只是“文件变小”,而是存储、带宽、算力三件事一起改善。
1.2 量化省的第二笔账:访存和指令密度
这一点常被忽略。推理的瓶颈很多时候不在乘加次数,而在数据搬运。FP32 权重每个 4 字节,int8 每个 1 字节,同样的 cache 行能装下 4 倍的权重,cache miss 率直接下来。对于没有 cache、只有紧耦合内存的 MCU,权重访存次数不变但每次搬运的字节数变成 1/4,总线占用明显下降。
指令密度是另一半。以常见的 8 位 SIMD 乘加为例,一条指令完成 4 组乘加并累加,等于把原本 4 条指令的活压成 1 条。但这有个前提:数据必须是打包好的、对齐的。如果你的权重在内存里是散着放的,编译器生成不出一条打包指令,加速效果会大打折扣。这也是为什么后面第四步要专门讲权重重排和内存对齐——量化只是给了硬件加速的机会,真正吃到红利还得靠算子库把数据布局喂对。
还有一笔隐性成本要算进去:反量化。有些实现为了省事,每层算完就把 int8 结果转回 float,下一层再用 float 算,这种“伪量化”在端侧几乎没有加速,反而多了转换开销。正确做法是让整条链路尽量保持在定点域,只在模型入口和出口做一次转换。
1.3 哪些模型天生不适合一刀切量化
量化不是万能药,有几类结构要特别小心。第一类是通道数很少的卷积,尤其是 depthwise 卷积,每个通道单独统计量化参数,通道内样本少,动态范围容易被个别离群值带偏。第二类是注意力结构里的 softmax 输入,数值分布跨度大,8 位表示很容易在长尾处失真。第三类是检测或回归任务的头部,输出是连续值,量化误差会直接体现在坐标偏移上。
我的经验是,动手前先看一眼模型结构:如果大量使用小通道卷积、LayerNorm、逐元素相加,就要做好精度掉点的心理准备,提前规划混合精度方案——也就是大部分层用 int8,少数敏感层保留 16 位或浮点。这不是妥协,是工程上最划算的做法,因为那几层通常参数量占比很小,留着不影响整体体积。
2. 五步法总览:从浮点模型到端侧可用模型
2.1 五个步骤和每步的验收标准
整个流程我习惯拆成五步,每步都有明确的产出和验收线,不达标就不往下走,避免问题层层累积到最后无法定位。
| 步骤 | 主要动作 | 产出物 | 验收标准 |
|---|---|---|---|
| 第一步 | 模型瘦身、校准集准备、浮点基线 | 干净的浮点模型 + 基线指标 | 浮点精度达标,预处理链路固定 |
| 第二步 | 训练后量化(PTQ) | int8 模型 + 逐层量化参数 | 精度掉点可量化,能定位到具体层 |
| 第三步 | 量化感知训练(QAT) | 微调后的量化模型 | 掉点收敛到业务可接受范围 |
| 第四步 | 端侧算子适配与推理集成 | 可运行的端侧推理代码 | 无算子回退,内存对齐正确 |
| 第五步 | 端到端性能与精度验收 | 对比数据表 + 回归报告 | 延迟、内存、精度三项同时达标 |
这张表看着简单,实际执行时最容易出问题的是第一步和第四步。第一步没把数据预处理固定下来,后面所有精度对比都是假的;第四步没检查算子兼容性,模型能跑但一堆层回退到浮点,速度不升反降。
2.2 先 PTQ,不行再上 QAT
方案选型上,我的原则很明确:能用训练后量化解决的就不要碰量化感知训练。原因很实在——PTQ 只需要一个转换脚本加几百张校准图,半小时到两小时能跑完一轮;QAT 要改训练代码、插伪量化节点、重新调学习率,几小时到几天不等,而且调不好还会过拟合校准集。
判断标准我给个粗略的分档:PTQ 之后精度掉点在 1 个百分点以内,直接收;掉 1 到 3 个点,看业务场景,分类任务勉强能接受,检测和分割一般不行;掉超过 3 个点,老老实实上 QAT。还有一个信号是掉点的分布——如果只是某一两层掉得厉害,优先考虑混合精度而不是全局 QAT,成本低得多。
2.3 对称还是非对称、per-tensor 还是 per-channel
这是量化里最容易被含糊过去的一组选择,我把它拆开说清楚。权重侧,卷积核的分布通常近似零均值对称,用对称量化更合适,zero_point 固定为 0,计算时少一次加法;而且权重是离线确定的,可以逐通道统计,也就是 per-channel,每个输出通道一套 scale,能把通道间的动态范围差异吃掉。
激活侧不一样,经过 ReLU 或 ReLU6 之后,激活值范围是 [0, 6] 这种单边分布,必须用非对称量化,zero_point 不为零,否则一半的表示范围被浪费。激活值是运行时才知道的,通常只能 per-tensor,一层一套参数。
具体算一下:8 位非对称量化,scale = (max - min) / 255,zero_point = round(-min / scale)。假设某层激活范围是 [0, 6],scale = 6/255 ≈ 0.0235,zero_point = 0;如果范围是 [-2, 6],scale = 8/255 ≈ 0.0314,zero_point = round(2/0.0314) ≈ 64。反量化就是 real = (q - zero_point) × scale。这几个公式建议写在纸上贴在显示器边上,排查问题时手算一层就能判断 scale 是不是被离群值撑爆了。
3. 第一步:基线模型与校准集准备
3.1 先给模型做减法,再谈量化
量化之前该做的瘦身别省。我的习惯是先做三件事:把骨干换成更轻的结构、把训练后的 BN 融合进卷积、去掉推理用不到的分支。BN 融合是最划算的,因为它不改变数学等价性,纯粹减少计算和访存。
融合公式不复杂,卷积输出 y = w*x + b,后面接 BN:y' = gamma * (y - mean) / sqrt(var + eps) + beta。把 BN 参数吸收进卷积后,新的权重 w' = w * gamma / sqrt(var + eps),新的偏置 b' = (b - mean) * gamma / sqrt(var + eps) + beta。做完这一步,模型里就没有 BN 层了,量化时也少一类需要特殊处理的算子。这一步务必在浮点模型上做,做完重新验证一遍精度,确认无损再往下走。
3.2 校准集怎么挑:数量、分布和预处理
校准集决定了激活值的量化范围,它选得对不对,直接决定 PTQ 的成败。数量上,100 到 500 张足够,再多边际收益很低;关键是分布要覆盖真实场景——类别要均衡,光照、角度、遮挡、远近都要有代表样本。我见过最典型的翻车是校准集只用了白天清晰图,结果夜间场景一进来,激活值超出校准范围,直接截断,精度断崖式下跌。
预处理必须和训练、推理完全一致,包括 resize 的插值方式、归一化的均值和方差、通道顺序。这里有个特别隐蔽的坑:训练时用的是等比缩放加填充,推理时改成了直接拉伸,图像几何变了,激活分布也跟着变,量化参数自然偏。建议把预处理写成一个函数,训练、校准、推理三处共用同一份代码,别各写各的。
3.3 建立可信的浮点基线
所有量化对比都必须有一个可信的浮点基线,否则后面精度掉了你都不知道是量化造成的还是本来就这样。基线要在目标测试集上跑,指标固定下来:分类看 top-1 和 top-5,检测看 mAP,同时记录单帧延迟和峰值内存。
还有一点,固定随机种子和输入尺寸。有些框架在推理时对输入尺寸有隐式处理,尺寸一变,量化校准的统计就全乱了。我一般会在基线阶段就把输入张量的形状、数据类型、归一化参数写成配置项,后面所有环节都读这份配置,从源头杜绝不一致。
4. 第二步:跑通训练后量化
4.1 转换脚本的最小骨架
PTQ 的核心就三行配置:开启优化、提供校准数据、执行转换。以常见的端侧推理格式为例,转换脚本大致是这样:
import tensorflow as tf def representative_dataset(): for img in calib_images: # 已按训练预处理好的 numpy 数组 yield [img[np.newaxis, ...].astype(np.float32)] converter = tf.lite.TFLiteConverter.from_saved_model("float_model") converter.optimizations = [tf.lite.Optimize.DEFAULT] converter.representative_dataset = representative_dataset converter.target_spec.supported_ops = [tf.lite.OpsSet.TFLITE_BUILTINS_INT8] converter.inference_input_type = tf.int8 converter.inference_output_type = tf.int8 quant_model = converter.convert() open("model_int8.tflite", "wb").write(quant_model)如果是训练框架侧直接量化,思路一样,只是接口不同:开启默认量化配置、挂上校准数据加载器、导出量化模型。这里的关键不是记 API,而是理解“representative_dataset 提供的是激活值的统计样本”,它只跑前向、不反传,所以校准阶段不需要标签,速度很快。
4.2 逐层检查量化参数
转换完别急着测精度,先看量化参数。把每层的 scale 和 zero_point 打印出来,重点盯三类异常:scale 等于 0 或者极小,说明该层激活几乎全零,可能是前面某层输出被截断;zero_point 落在 0 或 255 两端,说明分布严重偏斜;某层 scale 比相邻层大一个数量级,说明有离群值把范围撑爆了。
发现离群值层,处理办法有几种:对该层单独用更细的粒度(per-channel),或者把该层保留为 16 位,再或者在校准集里补一些能触发该层大激活的样本。我一般先看这个层在模型里的位置,如果是第一层卷积,八成是输入归一化参数写错了;如果是最后几层,可能是分类头的 logits 分布本身就宽。
4.3 第一次精度评估与掉点定位
测精度要用和基线完全相同的测试集和评估脚本,只换模型文件。掉点之后定位方法我习惯用逐层对比:把浮点模型和量化模型的中间层输出都抽出来,算余弦相似度或者均方误差,相似度突然掉下去的那一层就是元凶。
经验上,最敏感的是第一层和最后一层。第一层直接吃原始输入,数值范围大且分布随场景变化;最后一层输出是分类 logits 或回归值,量化误差会直接映射到结果上。这两层保留浮点或 16 位,往往能挽回大部分精度损失,而它们参数量占比很小,对体积和速度影响可以忽略。
5. 第三步:精度不达标时的 QAT
5.1 伪量化节点和直通估计
QAT 的本质是在训练时把量化的误差“演”出来,让网络提前适应。做法是在权重和激活后面插入伪量化节点:前向时执行“量化再反量化”,也就是 round 加 clamp 再乘回 scale,模拟 int8 的精度损失;反向传播时因为 round 不可导,用直通估计,把梯度原样传过去。
用公式描述就是,前向 q = clamp(round(x / s + z), 0, 255),输出 x̂ = (q - z) × s;反向时 ∂x̂/∂x 近似为 1。这个近似看起来粗糙,但实践中效果稳定,因为量化误差本身是有界的,梯度方向不会偏太远。
5.2 插入位置与冻结策略
插入位置有讲究。卷积和全连接层的权重、激活都要插,BN 已经在第一步融合掉了,不用管。注意伪量化节点要插在激活函数之后,这样统计到的是真实输出范围。残差相加、拼接这类逐元素操作,输入侧要保证两路量化参数一致,否则相加后范围对不上。
训练策略上,我一般先冻结量化参数(scale 和 zero_point)跑几轮 warmup,让网络先适应固定的量化格点,再放开让量化参数一起学。放开太早容易震荡,尤其学习率还没降下来的时候。
5.3 超参怎么调
QAT 不是从头训,学习率要比原始训练小一个数量级,常见取值 1e-4 到 1e-5,优化器沿用原来的就行。训练轮次一般是原始训练的 10% 到 20%,太多了会过拟合校准集,表现为校准集上精度很好、真实测试集上反而变差。
batch size 别设太小,量化参数的统计需要足够样本,太小会让 scale 抖动。还有一个细节:数据增强要保留,但强度可以降一点,因为强增强会引入极端样本,把激活范围撑大,反而不利于量化。
6. 第四步:端侧算子适配与推理落地
6.1 算子兼容性清单
模型能在 PC 上转成 int8,不代表端侧推理库认得所有算子。这一步必须先做兼容性核对,常见的坑是某些激活函数、特殊的 padding 方式、自定义算子没有 int8 实现,推理时自动回退到浮点,结果是模型里一段 int8、一段浮点,中间不停做格式转换,速度比纯浮点还慢。
| 算子类型 | int8 支持情况 | 处理建议 |
|---|---|---|
| 标准卷积、深度卷积 | 普遍支持 | 直接使用 |
| 全连接 | 普遍支持 | 直接使用 |
| 最大池化、平均池化 | 普遍支持 | 注意平均池化的累加溢出 |
| 逐元素相加、相乘 | 多数支持 | 两路量化参数需对齐 |
| 特殊激活、自定义算子 | 支持不一 | 替换为等价标准算子或保留浮点 |
核对方法很直接:把量化模型丢给端侧推理框架的解析工具,看它报不报“将使用浮点回退”的警告。有警告就逐个处理,能换等价算子就换,换不了就接受它保留浮点,但要评估这段回退对整体延迟的影响。
6.2 内存布局与对齐
端侧推理库通常要求 NHWC 布局,而训练框架默认可能是 NCHW,转换时要注意。更关键的是对齐:很多 SIMD 指令要求数据地址 16 字节对齐,权重如果不是按这个规则重排的,算子库会走慢速分支。多数成熟的推理库在转换阶段会自动做权重重排,你要做的是确认这一步真的执行了,别在配置里不小心关掉。
还有张量复用。中间张量的生命周期要理清楚,能复用的缓冲区尽量复用,否则峰值内存会翻倍。有些推理框架提供内存规划工具,能输出每个张量的生命周期图,照着调整层顺序或者手动指定复用,往往能省下可观的 SRAM。
6.3 定点推理的实际调用
以常见的定点算子库为例,一次 8 位卷积的调用大致长这样:
arm_convolve_s8(&ctx, &conv_params, &quant_params, &input_dims, input_data, &filter_dims, filter_data, &bias_dims, bias_data, &output_dims, output_data, &buffer);其中 quant_params 里装的就是前面统计出来的输入、权重、输出三组 scale 和 zero_point。这里要注意偏置的位宽:int8 权重配的是 int32 偏置,scale 是输入 scale 乘权重 scale,这个换算关系错了,输出会整体偏移,表现为精度莫名下降但又不是崩掉,很难查。建议在集成阶段写个小测试,用同一份输入分别跑浮点和定点,逐层比对输出,把误差定位到具体算子。
7. 第五步:端到端验收与平衡取舍
7.1 三项指标要一起看
验收不能只看精度,也不能只看速度,得三项一起看:精度、延迟、内存。精度用业务指标,分类看 top-1,检测看 mAP,有业务自定义指标的用业务指标;延迟测端到端,包含预处理和后处理,不能只测推理那一段;内存分 Flash 占用和 SRAM 峰值,两个都要记录。
| 指标 | FP32 基线 | PTQ int8 | QAT int8 | 变化 |
|---|---|---|---|---|
| 模型体积 | 16.8MB | 4.2MB | 4.2MB | 降低约 75% |
| 峰值 SRAM | 约 1.1MB | 约 380KB | 约 380KB | 降低约 65% |
| 单帧延迟 | 120ms | 42ms | 44ms | 提升近 3 倍 |
| top-1 精度 | 92.4% | 91.1% | 92.0% | 掉点 0.4 到 1.3 个点 |
这张表是我自己项目里的大致量级,不同模型和硬件差异很大,但比例关系有参考价值:体积和内存的压缩是确定的,延迟提升取决于算子库和硬件 SIMD 能力,精度掉点取决于模型结构和校准质量。
7.2 平衡点怎么定
平衡点不是算出来的,是业务约束倒推出来的。先定一条硬线,比如“单帧延迟必须低于 50ms,精度掉点不超过 1 个点”,然后在这个约束下选方案。如果 PTQ 满足,就停;不满足,看是延迟不够还是精度不够。延迟不够一般是算子回退或者没用定点库,回去查第四步;精度不够就上 QAT 或者混合精度。
还有一个被低估的手段是输入分辨率。把输入从 224 降到 192,计算量按平方下降,精度可能只掉 0.5 个点,往往比折腾量化更划算。实际项目里我经常是分辨率和量化一起调,先定分辨率再定量化精度,顺序反了会白费功夫。
7.3 上线前的回归测试
端侧模型上线前必须做回归,重点测三类:长稳测试,连续跑几小时看有没有内存泄漏和数值漂移;分布外测试,用真实场景里的极端样本,比如过曝、全黑、遮挡严重;异常输入测试,空帧、尺寸不符的输入,看会不会越界。
我踩过一次坑,校准集和测试集都正常,但现场设备摄像头自动曝光导致整体偏暗,激活值集中在小范围内,量化分辨率不够,精度掉了将近 5 个点。后来在预处理里加了直方图均衡,问题才解决。这类问题在实验室里永远测不出来,只能靠现场数据回流。
8. 常见问题与排查技巧实录
8.1 精度崩到不可用
精度直接崩掉,九成是校准环节出的问题,按这个顺序查:校准集预处理和推理是否一致,这是最高频的原因;校准集分布是否覆盖真实场景;BN 是否在量化前融合;第一层和最后一层的量化范围是否被离群值撑爆。查完这四项,基本能定位到八九成。
还有一种情况是精度没崩但“飘”,每次跑结果略有不同。这通常是某些层回退到了浮点,或者多线程推理时的累加顺序不一致导致的。定点推理本身应该是确定性的,结果不稳定说明链路里有浮点参与。
8.2 量化后速度没变快甚至更慢
这个问题的排查逻辑和精度问题完全相反,重点在算子层。先看推理框架有没有输出回退警告,有回退就逐层处理;再看权重是否做了对齐重排,没重排会走慢速分支;最后看是否每层都在做反量化,整条链路应该在定点域内闭环。
还有一种隐蔽情况是内存带宽打满。模型压到 int8 后,权重体积小了,但如果推理时频繁在 Flash 和 SRAM 之间搬运,带宽反而成了新瓶颈。解决办法是把热点权重常驻 SRAM,或者用带 cache 的主控。
8.3 问题速查表
| 现象 | 最可能原因 | 排查动作 |
|---|---|---|
| 精度断崖式下跌 | 校准集分布不匹配 | 对比校准与推理预处理 |
| 精度缓慢掉 2 到 3 个点 | 敏感层量化误差大 | 逐层余弦相似度定位,改混合精度 |
| 推理结果每次不同 | 存在浮点回退 | 检查算子兼容性警告 |
| 速度无明显提升 | 算子回退或未对齐 | 查权重重排配置与内存对齐 |
| 输出整体偏移 | 偏置 scale 换算错误 | 核对输入 scale × 权重 scale |
| 内存溢出 | 中间张量未复用 | 用内存规划工具看生命周期 |
8.4 几条踩出来的心得
第一条,先把数据一致性做扎实再谈量化。我见过太多项目,量化背了精度的锅,实际是预处理写了三份不一样的代码。第二条,首尾层留浮点或 16 位,成本极低收益极高,几乎是我现在的默认操作。第三条,校准集一定要放难样本,宁可少放清晰图,也要把夜间、逆光、遮挡放进去,量化范围是由最难的那张图决定的。
第四条,QAT 别训太久,训到校准集精度明显高于测试集就该停了,那是过拟合的信号。第五条,端侧集成阶段一定要写逐层比对脚本,别指望一次跑通,定点算子的参数错一个,误差会顺着网络放大,没有逐层数据根本查不出来。
我个人在实际项目里的体会是,量化这件事的技术门槛其实不高,公式和接口就那么几个,真正难的是工程纪律——每一步都留可对比的基线,每一个改动都单独验证,不达标就不往下走。把这套流程跑顺了,一个模型从浮点到端侧落地,两三天就能走完一轮,剩下的时间都花在调那最后零点几个点的精度上。