
1. 量化的第一性原理浮点模型为什么要换成低比特表示1.1 先从一次本地部署的“显存危机”说起我在本地跑ComfyUI工作流的时候经常遇到一个让人头疼的问题加载一个5GB多的浮点模型还没开始出图显存已经爆掉了。后来有朋友提醒我可以在启动参数里把模型加载精度改成低比特比如FP8甚至INT4爆显存的问题一下子缓解了很多。也正是从那次开始我认真去研究模型量化到底在做什么为什么一个看起来“精度变低了”的模型反而在本地部署里这么吃香。模型量化说白了就是把模型里的浮点参数FP32、FP16映射到更小的数值表示上比如INT8、INT4甚至二值化的INT1。它不是一个“把文件压缩一下”的动作而是一种有损表示的转换。就好比你给别人描述一张照片原来你能说出256种颜色现在只允许你说出16种颜色信息变少了但整体轮廓还在。模型量化要解决的就是在“信息损失”和“运行效率”之间找平衡。这篇内容主要面向两类人一类是刚接触量化想搞懂原理再动手的算法同学另一类是像我们这样把模型部署到本地、边缘设备上的工程向选手。看完你至少能回答三个问题量化后的数值是怎么算出来的PTQ和QAT有什么区别int8量化后精度下降甚至输出不动到底该从哪里排查。1.2 量化的本质把连续数值塞进“有限格子里”浮点数和低比特整数最大的区别不是小数点的位置而是“表示精度”的密度。FP32能表示大约2的32次方种不同数值INT8只能表示256种。如果我们要把FP32的权重矩阵映射到INT8就必须决定两件事这个映射的“缩放比例”是多少原来的0在量化后对应哪个整数这就是量化最核心的两个参数缩放因子scale和零点zero point。它们的换算关系是q round(real / scale) zero_point real (q - zero_point) * scale你可以把scale理解成一把“刻度尺”的单位长度。浮点数值范围是10INT8的范围是256个格子那么每个格子大概代表0.04。如果scale太大格子的间距变大细小的变化就被吞掉了如果scale太小超过范围的值会被截断直接劈到边界上。量化映射分两种对称量化和非对称量化。对称量化把浮点范围看成是对称的零点固定为0公式里没有zero_point实现简单推理时少一次减法运算。非对称量化允许零点偏移能更充分利用整数的表示范围。实际工程中权重一般用对称量化因为权重的分布近似正态正负对称激活值比较喜欢用非对称量化因为很多激活函数输出的分布偏在0以上用非对称能保住更多有效信息。我用一个简单例子演示scale的计算。假设某层权重的最小值是-5.0最大值是5.0量化到INT8范围-128到127scale (max - min) / (qmax - qmin) (5.0 - (-5.0)) / (127 - (-128)) 10.0 / 255 ≈ 0.0392157对称量化则用abs的最大值除以127也就是10.0/127≈0.0787。同一份数据对称量化的scale更大误差也更大一点。这也是为什么很多框架默认对激活值做非对称量化对权重做对称量化。1.3 量化精度和位宽的关系FP16、INT8、INT4怎么选位宽每降低一点存储压力和计算量都会显著下降但代价是表示出的数字更“粗糙”。FP16是“半精度浮点”它保留了浮点的动态范围只是尾数位变少了所以很多推理框架里把它当作“无损”或近无损选项。INT8则是目前部署的主力CPU上有原生向量指令加速NPU上更是标配。INT4进一步压缩但精度损失明显通常需要配合量化感知训练来救场。我在ComfyUI里最常用的组合是基础模型用FP8加载VAE保持FP16。这样既避免了显存溢出出图质量肉眼看不出差别。但如果你直接把一个FP16模型强行压到INT4又不想做任何校准和微调那大概率会得到一张“灵魂抽象画”。选择位宽不能只看显存容不容易爆还要看你下游任务的容错率。2. 量化方案选型PTQ、QAT和动态量化到底谁适合你2.1 PTQ训练后量化的“低成本快车道”训练后量化Post-Training QuantizationPTQ是最容易上手的方式模型训练完你只需要一小部分校准数据跑一遍前向统计每一层的数值范围然后完成映射。标定完直接导出INT8模型不需要动训练流程。PTQ适合的场景很明确模型已经训好你没有精力重训业务场景可以接受少量精度下降手头有能代表真实分布的校准数据。它的优势是快、省事缺点是当权重分布特别不均匀、或者激活值有大量离群点的时候误差会横向扩散。实操里最常见的做法是用几百到一千张有代表性的样本做校准。样本不要求带标签因为只统计激活值的分布不需要计算loss。代码实现起来PyTorch里是这样import torch from torch.quantization import get_default_qconfig_mapping model.eval() model.qconfig get_default_qconfig_mapping(fbgemm).module_level_configs model_fused torch.ao.quantization.fuse_modules(model, [[conv1, bn1, relu]]) model_prepared torch.ao.quantization.prepare(model_fused, inplaceFalse) with torch.no_grad(): for images, _ in calibration_dataloader: model_prepared(images)校准完成后模型内部的Observer会记录每层激活的最小、最大值或者其他统计量最后调用convert把模型真正变成量化版本。这个过程中的“校准数据代表真实分布”这一点至关重要后面排查精度问题时会反复遇到。2.2 QAT精度敏感任务的“高级套餐”量化感知训练Quantization-Aware TrainingQAT是在训练过程中就模拟量化误差用“伪量化”算子插入网络让模型自己去适应量化带来的扰动。它比PTQ多一个训练阶段通常是在一个预训练模型的基础上用很低的学习率微调几个epoch。为什么QAT更稳因为它把量化误差当作一种“噪声”纳入到了训练里模型学会了抵抗这种噪声。这在分割、检测等像素级任务中几乎是必须的分割任务里一个小数点的偏移可能让目标边界抖动检测任务里框的位置时常对低比特噪声非常敏感。但QAT的代价是一套完整的训练pipeline需要准备数据、配置优化器、调学习率时间和算力成本都上去了。我的经验是当PTQ在验证集上精度掉点超过2%先别急着上QAT可以先把校准数据质量提上去、检查异常值裁剪等情况稳定了再做决策。2.3 动态量化为CPU推理而生的“取巧”方案动态量化的思路比静态量化更“偷懒”它不提前校准而是在推理时动态地把权重从浮点转成INT8同时把激活值在算子内部临时量化。这样省去了校准环节实现最省事速度提升也很可观。在NLP模型比如BERT、GPT系列上动态量化是Pytorch官方推荐的默认选项尤其适合CPU部署。动态量化的缺点是激活值每一层都要实时计算scale会带来额外开销。图形和连续推理的场景下性能不一定比静态量化好。但如果你的目标是快速上线不想管校准流程动态量化可以作为第一版方案。3. 手写一个最小可用的量化流程从浮点张量到INT83.1 手工实现量化的核心算子把原理变成代码比读十篇博客都有用。我写了一个最小的量化函数可以让你直观看到浮点张量是怎么变成低比特表示的import numpy as np def quantize(tensor, bits8): qmin, qmax -(2 ** (bits - 1)), 2 ** (bits - 1) - 1 min_val, max_val tensor.min(), tensor.max() scale (max_val - min_val) / (qmax - qmin) zero_point qmin - min_val / scale zero_point int(np.clip(round(zero_point), qmin, qmax)) quantized np.round(tensor / scale zero_point) quantized np.clip(quantized, qmin, qmax).astype(np.int8) return quantized, scale, zero_point def dequantize(quantized, scale, zero_point): return (quantized - zero_point) * scale if __name__ __main__: np.random.seed(42) real_tensor np.random.randn(4, 4) * 2.0 q, s, z quantize(real_tensor) reconstructed dequantize(q, s, z) error np.abs(real_tensor - reconstructed).mean() print(f原始范围: [{real_tensor.min():.4f}, {real_tensor.max():.4f}]) print(fscale: {s:.6f}, zero_point: {z}) print(f平均绝对误差: {error:.6f})这个例子中每个张量是独立的而在真实模型里权重是逐层或逐通道量化per-channel。逐通道量化是给每个输出通道单独算scale权重的分布通常更集中每个通道有自己适合的映射误差更小。逐张量量化实现更简单但遇到分布跨度大的层时误差会很大。3.2 用校准数据的分布统计替代暴力min/max在上面的代码里我用的是min和max作为浮点范围。但实际数据经常有离群点比如某个激活值突然冲到50而绝大多数值在-5到5之间。如果按min/max来定scale大部分格子都被浪费了普通值的精度被严重稀释。工程上的解法是用百分位数percentile裁剪掉极端的离群值或者用KL散度来选一个最合适的阈值范围。TensorRT和Intel的蒸馏校准工具都实现了类似算法。自己实现时可以用numpy的percentile快速验证def calibration_minmax(activations, percentile99.999): low np.percentile(activations, 100 - percentile) high np.percentile(activations, percentile) return low, high校准不是简单看“最小值最大值”而是看“大多数真实数据的使用范围”。这个认知能帮你避免很多精度翻车现场。3.3 为什么整数运算会快从计算机底层看低比特的意义很多人问我INT8比FP32少了那么多位数为什么速度快原因不只是内存变少。CPU和NPU里都用SIMD指令做向量运算INT8能在一个指令周期内并行处理更多数据。以Intel CPU为例AVX512指令集一次可以处理16个FP32数的乘加换成INT8则可以处理64个数。四倍的吞吐量。GPU上的Tensor Core也类似INT8算力单位远高于FP32。这种差异在实际部署中的体感非常明显。我之前把一个小型检测模型从FP32切到INT8CPU延迟从80毫秒降到25毫秒左右模型文件大小也从120MB降到30MB。代价就是验证集上mAP掉了0.5几乎可以忽略。4. 工程实战在PyTorch、ONNX Runtime和ComfyUI中落地量化4.1 PyTorch官方量化流程的完整拆解PyTorch从1.8之后量化API经历过几次变动现在推荐用torch.ao.quantization下的写法。以一个小型卷积网络为例完整流程可以拆成这样import torch import torch.nn as nn from torch.ao.quantization import ObserverBase, MinMaxObserver, QuantStub, DeQuantStub class SimpleCNN(nn.Module): def __init__(self): super().__init__() self.quant QuantStub() self.conv1 nn.Conv2d(3, 16, kernel_size3, padding1) self.conv2 nn.Conv2d(16, 32, kernel_size3, padding1) self.fc nn.Linear(32 * 8 * 8, 10) self.dequant DeQuantStub() def forward(self, x): x self.quant(x) x torch.relu(self.conv1(x)) x torch.max_pool2d(x, 2) x torch.relu(self.conv2(x)) x torch.max_pool2d(x, 2) x x.view(x.size(0), -1) x self.fc(x) x self.dequant(x) return x model SimpleCNN().eval() model.qconfig torch.ao.quantization.QConfig( activationMinMaxObserver.with_args(dtypetorch.quint8), weightMinMaxObserver.with_args(dtypetorch.qint8) ) model torch.ao.quantization.prepare(model) # 这里用校准数据跑几轮forward model torch.ao.quantization.convert(model)QuantStub和DeQuantStub是告诉模型“从哪一层开始量化、在哪一层结束量化”。prepare阶段模型内部还挂着Observer校准结束后Observer的统计结果被固化convert后才真正生成量化模型。很多新手在模型里加了量化相关代码却报错大多是因为忘了把模型切成eval模式或者在prepare之前就把模型包在了DataParallel里。4.2 ONNX Runtime快速导出和性能对比如果你手里的模型已经导出成ONNX那么用ONNX Runtime做量化是我的第一推荐。原因很简单API只有一行基本覆盖了常见的浮点模型from onnxruntime.quantization import quantize_dynamic, QuantType quantize_dynamic( model_inputmodel_fp32.onnx, model_outputmodel_int8.onnx, weight_typeQuantType.QUInt8, )这是动态量化如果要静态量化需要额外提供校准数据生成器。我个人建议在决定用静态量化之前先用动态量化跑一版比较精度和速度。很多场景动态量化已经足够没必要为了“更标准”多引入一倍的工作量。ONNX Runtime量化后的模型可以直接用InferenceSession加载CPU上推理速度提升明显。但我提醒一句ONNX Runtime的不同执行提供方ExecutionProvider对量化的支持不同CPUExecutionProvider支持得最好如果你切到CUDAExecutionProvider有些量化算子可能不被支持需要留意。4.3 ComfyUI本地模型的量化加载技巧回到开头提到的ComfyUI。实际上ComfyUI本身不是量化工具而是一个工作流平台。它内部的推理后端会读取你加载的模型权重如果模型原本是FP16它默认以FP16载入如果你的显存吃紧常见做法是利用它支持的FP8加载或者让工作流在后台通过diffusers的bitsandbytes把模型转成NF4精度的量化表示。我的实际做法分两步。第一步在ComfyUI的启动命令里加上FP8相关的参数例如对支持FP8的模型启用FP8加载这样载入后的基础模型会以低比特存于显存中显存占用肉眼可见下降。第二步如果某个节点只支持FP16/FP32输入再单独把精度提回去——虽然样子上多了一次转换但整体峰值显存依然低了很多。需要特别说明的是ComfyUI的很多自定义节点默认不做量化不要指望一个“量化开关”解决所有问题。最可靠的方式是在加载模型前确认模型文件的state_dict里包含量化映射关系或者直接用官方文档推荐的模型加载器去读取固定精度的checkpoint。5. 精度下降排查手记int8量化后数值不动、精度掉点的真相5.1 数值不动不是“量化成功了”而是“量化死了”热词里有一条“int8量化后精度下降数值不动”我看到后特别有感触。很多情况下量化后模型输出完全不变或所有输出变成同一个值这不是量化效果好反而说明模型已经被破坏得很严重。出现这种问题常见原因有三个。第一激活值里存在极端的离群点导致scale被拉得非常大普通的数值在经过round之后全部归零或映射到同一个整数相当于有效信息全部丢失。第二某个层在量化后输出被clip在边界一旦累计误差超过阈值后面的层全部饱和。第三你没有在量化后对模型做任何验证直接拿量化模型跑到了它有分布偏移的数据上。排查思路是从量化模型的最前端开始逐层对比激活值分布。先用一个脚本分别跑浮点模型和量化模型在每一层记录输出张量的均值、标准差、min/max找到第一个开始出现巨大偏差的层。这一步虽然费时间但远远好过盲调。5.2 RKNN回归模型“不量化正常、量化后数值不动”的典型原因RKNN是瑞芯微边缘芯片上常用的NPU工具链它有自己的量化校准和转换流程。回归模型在RKNN上发生“不量化正常、量化后输出数值不动”的情况我遇到过两次每次根因都不同。第一次是做目标框回归模型输出4个坐标量化后几乎所有预测框都挤在图像角落。排查后发现模型框架里有一个分支的数值范围特别小比如偏移量在-0.01到0.01之间量化后直接被吞成0。第二次是校准数据集用的是随机裁剪图片和真实业务场景的分布差太远结果scale定得不合理给后面所有层埋了雷。针对地解决如果是数值范围太小我会在模型输出前加一个scale放大层或者改用支持per-channel配置的量化方式如果是校准集问题就用业务数据在线增强的方式重新生成一个校准集。还有一个排查重点RKNN的量化模式下有些算子可能不被量化支持会以浮点运行此时输出的“不动”可能不是量化本身的问题而是算子的数值类型配置有误。症状可能原因排查方向全部输出变成同一个值某层激活被clip到边界或scale过大逐层对比激活分布定位首个异常层只有小数输出大数输出全为0极小数值范围被量化误差吞掉检查模型是否有数值极小的分支考虑加放大层校准集精度正常业务数据精度崩校准数据分布与真实场景偏移用业务数据重新校准检查预处理是否一致量化前正常量化后NaN出现除以0的scale或zero_point越界检查权重中是否有全0通道清理非法张量RKNN上输出总是某个固定值量化配置不支持该算子或算子回退失败查看RKNN日志中算子类型确认是否走了量化计划5.3 量化后精度掉点的常规优化路径当精度掉点不超过1%我通常先试着调校准集和统计方法不太想动模型结构。这属于“低成本修复”阶段包括增加校准集数量、使用KL散度校准、对权重使用per-channel、对激活值使用非对称量化。这些改动不需要训练只需要重新校准和转换几分钟就能看到结果。如果掉点在1%到3%之间说明模型本身对量化噪声已经比较敏感我会考虑做敏感层定位。找出那些被量化后损失最大的层保留它为FP16其他层用INT8。这种混合精度方案在工程上非常实用既能保精度又能节省大部分显存和算力。如果掉点超过3%直接切换到QAT。最初几个epoch可以直接在量化模拟环境下训练让模型自己适应。在部署侧还要检查预处理里是否有浮点转整型的隐式转换比如图像归一化的标准差是否正确这些细节往往和量化无关却会跟精度损失叠加到一起。5.4 最后一招画分布图胜过猜排障到了瓶颈我就会把浮点模型的权重分布、激活值分布、量化反量化后的重建误差分布画出来。很多时候只凭数值去猜很难定位问题看到分布图问题会瞬间清晰。当某层权重呈现长尾分布绝大多数值集中在0附近少数值在远端量化后的整数表示几乎全部变成0这种层需要先做权重的均值裁剪或范围剪枝。当激活值呈现双峰分布量化后模型很容易在某些区间反复震荡而后面的归一化层会把这个震荡放大。这些用眼睛扫一遍分布图就能定案不用反复试参数。6. 一些可以少走弯路的经验记录最后分享一些实操中总结出来的东西。量化不是一个“调好参数就一劳永逸”的事它是一个跟部署环境、数据分布密切耦合的过程。首先永远别用一个校准集分布来代表所有场景。校准数据集最好从业务真实数据里采样覆盖光照变化、噪声等级、类别平衡等细节。尤其是回归模型输入的微小分布偏移会被量化误差放大校准集的质量直接决定了下限。其次先加日志再调参数。无论用什么框架我建议都写一个对比脚本分别记录浮点模型和量化模型在每一层的输出统计量。这样出了问题你可以顺着日志一层层往里追。我在本地部署时吃过无数次“肉眼看不见、一跑就崩”的亏后来养成了习惯任何模型改动前先把这个对比脚本跑一遍。再就是合理看待精度指标。如果量化后的模型在评价指标上掉了0.3%但延迟从180ms降到60ms那这笔交易是划算的。与其执着于守住每个小数位不如明确部署目标你要的是“能跑的模型”还是“精度最好的模型”两者的路径完全不一样。ComfyUI本地部署的经验也一样先在低比特模式下跑通整条出图链路再回头挑哪几个节点需要回退到高精度。先有能用的系统再让它更准顺序对了问题就少一半。根据我的实际经验绝大多数量化项目的失败不是量化本身太难而是没做好数据校准、没保留足够的排查工具、以及对量化误差的预期不合理。先把这三件事做扎实量化这条路就走得比较顺了。