☰
端侧AI部署利器:INT8模型量化原理与实战技巧
2026/9/26 13:29:47 网站建设 项目流程

聊端侧AI,绕不开一个词:模型量化。这一篇是端侧AI入门笔记系列的第四篇,就从浮点格式和INT8的差别讲起。如果你做过端侧AI硬件部署,一定遇到过类似画面:PC上跑得好好的FP32模型,挪到手机、开发板、智能摄像头这类小设备上,要么内存不够直接加载崩溃,要么推理慢到没法用;可换成INT8量化版本之后,模型体积缩到四分之一,延迟和功耗也都跟着降。模型量化就是在这样的背景下,成了端侧部署最关键的临门一脚。这篇文章我打算把浮点格式本身的位布局、INT8量化的映射逻辑、实操工具链以及精度补救手段一起讲透,适合刚接触端侧AI、准备把手头模型部署到小设备上的朋友,也是我自己这段踩坑经历的一份整理。

1. 先看账本:INT8换来的空间与速度到底有多可观

1.1 一个7B模型,四档精度四档体积

先说最直观的账。一个模型占多大内存,基本等于参数量乘以每个参数的字节数。按7B参数规模的大语言模型来算,FP32是4字节,FP16/BF16是2字节,INT8是1字节,INT4只需要0.5字节,于是最终体积是这样的:

精度单参数字节数7B模型近似占用
FP32428 GB
FP16/BF16214 GB
INT817 GB
INT40.53.5 GB

28GB什么概念?现在新一代旗舰手机内存普遍是12GB到16GB,绝大多数开发板更是只有4GB到8GB,FP32模型连装都装不下,更别提运行。而INT8版本直接把峰值内存需求压到7GB,虽然单看还是很大,但对7B这个级别的模型来说,至少让"在有限内存设备上跑大模型"从不可能变成了可尝试。我在实际部署时发现,很多端侧场景真正卡住的不是FLOPS不够,而是内存根本不够分,INT8这一步换来的空间收益是最直接、最确定的。

1.2 端侧算力卡在哪:内存带宽比FLOPS更致命

很多人一开始只关注算力,觉得设备每秒多少TOPS够跑模型了,但真正部署过就知道,端侧推理的瓶颈往往是内存带宽。原因很简单:计算单元要不停地从内存里把权重和中间激活值搬进寄存器或Cache,搬运的速度跟不上,算力再高也在空转。

举一个更具体的例子。假设同一份权重数据,FP32需要搬4个字节,INT8只需要搬1个字节,带宽占用直接降为四分之一。在内存带宽有限的端侧芯片上,这个差距会非常明显地反映在推理延迟上,尤其像大模型的decode阶段,每个token都需要从头读取一遍全部权重,这阶段基本就是跑在内存带宽上。实测下来,权重量化到INT8之后,decode的吞吐往往能提升两倍甚至更多,靠的不是算力变强了,而是"每次要搬的数据变少了"。

1.3 为什么很多自带NPU只认整型

还有一个更现实的原因:大量端侧芯片自带的NPU或DSP,原生计算单元只支持整型运算,对浮点支持要么需要额外硬件单元,要么效率极低。你用FP32模型跑这种NPU,要么直接不被支持,要么被软件层转换成模拟浮点,速度惨不忍睹。

我自己用过几款常见的端侧推理芯片,它们的SDK里都明确要求模型必须量化成INT8才能走硬件加速路径。也就是说,在某些平台上,量化不是"可选项",而是"能不能用上NPU"的门票。这也是为什么每轮端侧模型评测里,INT8的模型的部署帧率总是比FP32高出一大截——因为它们的FP32根本没走加速单元,或者走了也打不到理想频率。

2. 浮点格式的底牌:FP64、FP32、FP16和BF16各站各的位

2.1 IEEE 754里的三分天下

要理解量化到底改变了什么,先得把浮点格式的家底翻出来。现代计算机里的浮点数基本都遵循IEEE 754标准,一个浮点数由三部分组成:符号位、指数位、尾数位。符号位决定正负,指数位决定数值范围,尾数位决定小数精度。

举个例子,FP32是1位符号、8位指数、23位尾数,总共32位。FP64则是1位符号、11位指数、52位尾数,总共64位。FP16是1位符号、5位指数、10位尾数。后面还有个BF16,1位符号、8位指数、7位尾数。这几者的区别不仅仅是占用字节数,更关键的是它们能表示的数值范围和精度完全不同:

格式总位数符号位指数位尾数位最大有限值最小正规数机器精度
FP646411152约1.80e308约2.23e-308约2.22e-16
FP32321823约3.40e38约1.18e-38约1.19e-7
FP1616151065504约6.10e-5约9.77e-4
BF1616187约3.40e38约1.18e-38约7.81e-3

注意看FP16,它的最大有限值只有65504,最小正规数约6.10e-5。一旦数值超过65504就会变成无穷大,一旦小于约6.10e-5并且不是次正规数,就会被刷新成0。这就是为什么FP16在训练中经常出现"loss直接跑飞"的问题——梯度或激活值很容易就超出这个范围。BF16的情况则刚好相反,它保留了FP32的8位指数,所以动态范围和FP32一致,不会动不动溢出;但它的尾数只有7位,精度更低。简单来说,FP16牺牲范围换精度,BF16牺牲精度保范围。

2.2 FP16的"近0灾难"和BF16的"取舍"

举个实操中会遇到的例子。如果你用FP16去存一个神经网络的权重,比如某个矩阵元素真实值是0.1,FP16能表示的最接近0.1的数约是0.0999756,误差已经到万分之一数量级。对于大多数推理场景,这个误差没问题,但如果你在做梯度累积或者某些对极小数值敏感的计算,FP16就会吃大亏。

BF16更夸张,它的机器精度约是2^-7,也就是0.0078125。用BF16存0.1,实际可能存成0.1015625或0.09765625。这个误差肉眼可见,但如果只是用来做训练前向或梯度回传的粗略表示,配合FP32的Master Weight,效果也可以接受。

问题在于,很多人把FP16和BF16混为一谈,以为都是"2字节省钱",结果在端侧部署时发现数值范围对不上,模型输出异常。实际上选择哪种格式,取决于你的模型数值是"容易溢出"还是"需要精度"。激活函数、softmax前面、注意力分数这类容易产生大数值的地方,优先考虑BF16或者干脆保留FP32;对精度要求高又不是极端范围的部分,FP16更合适。

2.3 训练与推理对精度诉求不一样

为什么训练时候用的是FP32、FP16混合精度,到了推理阶段反而敢用INT8?核心原因是训练和推理面对的任务性质完全不同。训练要反向传播,梯度在每一层之间反复传递,任何一个精度损失都可能被后续计算放大,而且还要跨很多step累积更新,所以对数值精度极其敏感。

推理则只是做一次前向计算,每一层的输入和输出都局限在有限范围内。我们不需要精确还原每一个原始浮点值,只需要让最终输出保持可用精度。用生活类比来说,训练像是在完整抄写一本书,每一个字都不能歪;推理像是做摘要,只要核心意思在,个别错字不影响阅读。这个认知差异是理解量化的关键,也是为什么INT8在训练上基本行不通,但推理却能用得很好的根本原因。

3. 从浮点到INT8:映射、刻度、零点,一个都不能少

3.1 量化的本质不是取整,而是定标

很多人潜意识里觉得,INT8量化就是把浮点小数四舍五入成整数,把小数点"砍掉"。这样理解会错过最重要的逻辑。真正量化做的是:把一个浮点区间,映射到有限的整数值集合上,同时记录一个合适的刻度尺(scale),让整数能够尽量准确地被"翻译"回浮点数。

公式很简单:

  • 量化:q = round(r / scale) + zero_point
  • 反量化:r = (q - zero_point) * scale

这里的scale本质上就是"一个INT8刻度代表多少浮点数",zero_point则是浮点0对应的整数偏移。只要这两个值定得准,量化看起来像丢弃精度,实际上是给数据换了一套更稀疏的编码方式。

拿一组浮点数据举例:[0.2, -0.5, 1.3, 4.0]。如果采用对称量化,scale按最大绝对值4.0来定,即scale = 4.0 / 127 ≈ 0.031496。量化后得到[6, -16, 41, 127],反量化后是[0.189, -0.504, 1.291, 4.0]。可以看到4.0这种极值被完美还原,但0.2这种小值却丢了约5%的精度。原因很简单,整个可表示区间都让给了一个大值,小值只分配到很少的几个整数刻点。

3.2 对称与非对称:差一个零点

上面说的是对称量化,也就是zero_point固定为0,整数范围取[-128, 127]。这种方案简单、部署效率高,因为不需要额外处理零点偏移。但它的代价是,如果数据分布正负不对称,比如全是正数或者负值很少,就会浪费大量整数编码空间。

非对称量化则把整数范围用到[0, 255],允许zero_point取非零值。同样拿[0.2, -0.5, 1.3, 4.0]来算,rmin=-0.5,rmax=4.0,scale = (4.0 - (-0.5)) / 255 ≈ 0.017647,zero_point = round(-(-0.5) / 0.017647) ≈ 28。量化后结果接近[39, 0, 102, 255],反量化后误差都在0.006以内,比对称量化均匀得多。

所以在实际部署时,激活值往往是非对称分布的,比如ReLU之后的特征图全是非负值,这时候用非对称量化可以极大提高编码效率。权重则因为正负都有且相对对称,用对称量化通常就够了。这也是为什么很多推理引擎默认权重走对称、激活走非对称的原因。

3.3 校准数据集怎么选,scale才算得准

上面这些scale和zero_point,不是凭空算的,而是要拿一小批真实数据跑到模型里,统计每一层的激活值范围,这个步骤叫校准(Calibration)。很多人校准时偷懒,随便拿几十张图就过了,结果部署后精度崩得厉害。

关键在于校准数据要和真实部署场景的数据分布保持一致。比如你的模型是做人脸检测的,校准数据就不能全用风景图;做语音唤醒的,校准数据里的音频长度、噪声环境也要贴近实际。我自己习惯选100到500个样本,来源尽可能覆盖各类典型输入,而不是只选最好识别的那一批。样本太少,统计出的min/max不稳定;样本太杂且和场景无关,scale照样会偏。

另外,校准方法也有讲究。常见的MinMax直接取数据里的最小最大值,简单但容易被离群值带偏;Entropy会选择一个让信息损失最小的截断位置,对视觉模型通常更稳;Percentile则会人为掐掉最极端的几个百分点。我一般先试Percentile 99.9,如果精度不够再切Entropy,很少一上来就用MinMax。

3.4 per-tensor与per-channel:权重值得更精细的刻度

再往深一层,scale可以作用在一个完整的张量上(per-tensor),也可以作用在每个输出通道上(per-channel)。per-tensor实现简单,计算量小,但遇到不同通道数值范围差异很大的权重时,表现很差。比如某个卷积核的一个通道权重最大到0.8,另一个通道最大只有0.01,共用同一个scale会导致第二个通道大量有效值被压缩进极少整数格点里。

per-channel则给每个输出通道单独算scale,更精细,权重量化误差通常能降低一个数量级。我第一次把模型从per-tensor切到per-channel后,同一个INT8模型的精度直接涨了1到2个百分点,代价只是稍多的存储开销和略微复杂的部署逻辑。对这个收益来说,很值。不过要注意,有些老旧的推理引擎或底层算子实现不一定支持per-channel量化,动手前先查一下SDK文档,别等部署阶段才发现平台不支持。

4. 实操:把模型压成INT8并跑上端侧

4.1 工具链怎么选:ONNX Runtime、TFLite、OpenVINO

先说结论:工具链的选择,取决于你的模型最终要部署到什么平台。

如果目标是Android或者嵌入式Linux上的通用端侧硬件,ONNX Runtime + QDQ格式是个不错的起点,兼容性好,而且能够充分发挥NPU加速。如果目标是移动端App,并且用的是TensorFlow生态,那TFLite的INT8量化会更顺滑,转换工具链成熟,还有现成的delegate可以接NPU。如果模型最终要在Intel平台或者部分边缘盒子上跑,OpenVINO自带量化工具,和它的推理运行时绑定很深。TensorRT则适合NVIDIA平台,对INT8/TensorCore优化得最极致。

我自己最常用的路线是PyTorch训练 -> 导出ONNX -> ONNX Runtime静态量化 -> QDQ INT8模型 -> 端侧推理引擎转换。原因有两个:第一,PyTorch和ONNX之间转换成熟,中途可以快速检查精度;第二,ONNX模型靠近一个统一中间表示,后面再接TFLite或其他平台的转换器都比较方便。

4.2 ONNX Runtime静态量化完整流程

这一步是重头戏。静态量化(static quantization)需要先跑一遍校准数据,收集激活值范围,然后把权重和激活都量化成INT8,适合追求极致提速的场景。动态量化(dynamic quantization)只量化权重,激活在运行时动态算scale,省事但提速有限,更适合处理NLP这类激活分布不稳定的模型。

下面是一个可以直接跑的ONNX Runtime静态量化示例:

import numpy as np from onnxruntime.quantization import ( quantize_static, QuantType, QuantFormat, CalibrationMethod, ) from onnxruntime.quantization.shape_inference import quant_pre_process # 第1步:导出并优化模型。这一步会补全ONNX节点的shape信息,避免后续量化的坑。 quant_pre_process("model_fp32.onnx", "model_opt.onnx") # 第2步:写一个校准数据读取器,每次返回一个batch的输入,key是模型输入名。 class CalibReader: def __init__(self, data_list, input_name): self.data_list = data_list self.input_name = input_name self.idx = 0 def get_next(self): if self.idx < len(self.data_list): x = self.data_list[self.idx] self.idx += 1 return {self.input_name: x} return None def rewind(self): self.idx = 0 # 第3步:执行静态量化。这里用了QDQ格式,兼容性更好。 quantize_static( model_input="model_opt.onnx", model_output="model_int8.onnx", calibration_data_reader=CalibReader(calib_data_100, "input"), quant_format=QuantFormat.QDQ, per_channel=True, activation_type=QuantType.QInt8, weight_type=QuantType.QInt8, calibrate_method=CalibrationMethod.Percentile, )

跑完之后,用Netron或者简单打印模型节点看一眼,确认里面已经出现了QuantizeLinear/DequantizeLinear节点,说明量化算子已经插进去了。下一步就是加载量化的ONNX模型,在真实设备上做推理与精度验证。

如果你暂时不想收集校准数据,也可以先用动态量化快速看效果:

from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( "model_fp32.onnx", "model_dyn_int8.onnx", weight_type=QuantType.QInt8, )

动态量化不需要校准数据集,但通常只对Transformer、LSTM这类"权重占比高、激活偏动态"的模型提升明显,对CNN效果不如静态量化。

4.3 量化前后实测对比

实操之后一定别忘了做对比记录。我自己有一个固定模板,每次量化完都会填一张表:

指标FP32原模型INT8量化后变化幅度
模型文件大小128 MB32 MB下降75%
峰值内存占用468 MB152 MB下降67%
单帧推理耗时42 ms16 ms提速2.6倍
mAP / Top-10.8120.796下降1.6%

像这种情况,精度只掉了不到两个点,速度和内存却大幅改善,端侧部署基本可以接受。如果精度掉得超过两到三个点,就不建议直接上线了,需要按照第5章的方法去排查和修复。

对比时还有一点要注意,别只看模型大小,一定要跑真实设备上的端到端延迟。有时候INT8模型在PC上看着比FP32快不了多少,但到了低带宽的端侧设备上,速度差距会进一步拉大,因为内存带宽瓶颈在低端设备上更明显。

4.4 部署时容易踩的算子坑

量化完之后,模型可能在自己的开发机上跑得好好的,布置到端侧就报错。常见的坑有这么几个:

  • 模型里含有自定义算子或太新的算子(比如某些注意力变体),底层推理引擎不认QDQ模式。
  • 部分端侧NPU只支持非对称量化,不支持对称量化,或者反过来,导致模型转换后精度归零。
  • 有的平台不支持per-channel量化,转换工具会在日志里警告,如果忽略了,后续精度下降找不到原因。
  • 动态shape问题,比如batch维度是?,某些NPU需要固定batch才能用INT8 kernel。

遇到这种情况,我的排查顺序是:先打印量化后的模型结构,找到报错节点,再尝试把这些节点单独保持为FP16或FP32精度,也就是混合精度法,通常能绕开绝大多数兼容性问题。

5. 精度去哪儿了,以及怎么找回来

5.1 离群值和激活抖动:误差的两个主要来源

很多人以为INT8精度下降是"小数被四舍五入"导致的,但真正的原因往往更隐蔽。第一类元凶是权重或激活值里的离群值,某个通道突然出现一个极大值,比如激活值跑到30,但剩下99%的值都在0到1之间。这时候按max=30去定scale,正常值的编码区间被压到不足十分之一,等于大量精度被浪费在了一个异常值上。这种情况用Percentile校准或加一个clip操作,把尾巴掐掉,往往能立刻找回不少精度。

第二类元凶是激活值分布在不同输入下波动很大。校准数据集只覆盖了一部分分布,真实部署时遇到更极端输入,激活会超出校准统计范围,scale就失效了。所以前面才反复强调校准数据要贴近真实场景。另外,如果模型有多个不同输入源(比如多模态模型里的文本和图像),要保证校准数据覆盖到所有输入源,而不是只挑其中一个。

5.2 先做层敏感性分析,别盲目全量化

不是所有层都适合量化成INT8,这是很多入门同学最容易忽略的地方。有些层,比如第一个卷积层、最后的分类层,或者某些残差连接频繁合并的层,对量化误差特别敏感。如果全模型一刀切量化,哪怕整体精度只掉两个点,也往往是把难点的精度损失隐藏在了大多数普通层里。

我会在动手量化前先做一个简单的敏感性分析:用一个脚本逐层替换成INT8,或者逐层加入模拟量化误差,观察最终精度变化。这个方法不需要完整跑整个量化流程,但能帮我快速找出"千万要保持浮点"的层。实际经验是,图像分类模型通常第一层和最后一层喜欢浮点,检测模型里则经常是输出头的几个卷积不能动。把这些层标记为不可量化或者混合精度处理后,整体精度损失往往能控制在1%以内。

5.3 QAT是个保底手段,但不是首选

后训练量化(PTQ)不动模型权重,只是把训练好的模型数值重新编码,快而且方便,大多数情况下够用。但如果PTQ试了几种校准方法,精度还是掉太多,就该考虑量化感知训练(QAT)了。QAT的核心思想是,在训练过程中就模拟量化误差,让模型的前向计算带上"假量化"的噪声,从而让权重适应量化后的数值分布,等训练完了再转INT8,精度损失会小很多。

代价也很明显:需要训练数据和原始训练流程,时间成本高,调参经验要求也高。我之前在某个检测模型上,PTQ掉了3.8个点,怎么校准都拉不回来,最后对最后三层做了QAT,就把精度损失压到了1.1个点。但这个过程花了我差不多两周时间,所以我的建议是:先用PTQ并配合per-channel和混合精度,实在不行再请QAT出山。

5.4 混合精度和Clip策略:我的个人实操顺序

最后分享一个我在多个模型上都验证过的实操顺序,你可以直接拿去试:

  1. 先用per-channel权重量化 + 非对称激活量化,跑一遍PTQ,看精度损失。
  2. 精度损失在2%以内,收工,部署。
  3. 损失超过2%,换Percentile校准方法,尝试不同截断比例(99.9%、99.5%、99%)。
  4. 还没改善,做层敏感性分析,把最敏感的5%到10%的层保持FP16或FP32。
  5. 还不行,对特定敏感层做QAT,注意训练时加量化模拟,别等训练完再转。
  6. 如果模型里有明显离群值,考虑手动clip权重或激活范围,再重复上述步骤。

这套流程看起来很笨,但胜在可复现,每走一步都能看到数字变化,不会"凭感觉优化"。尤其是第4步,很多人跳过敏感性分析直接QAT,结果时间花了,效果却不理想。

最后说一个我自己实际操作中的体会。量化的本质,不是把模型里的浮点数变成整数那么简单,而是把一个对端侧设备来说过于奢侈的连续表示,换成一种更克制、更紧凑、也更贴近硬件底层的存储方式。它改变的是内存占用、推理速度、功耗表现,以及在有限硬件上能不能跑起来的"可能性"。希望这篇笔记能帮你在INT8这条路上少踩几个坑,也欢迎你在评论区聊聊自己遇到的量化问题,我基本都是实战派,大家交流一下真实数据比什么都强。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询