第3章 模型量化基础:FP32、FP16、INT8、INT4精度介绍与误差分析

量化,说白了就是给模型“减肥”。

一个训练好的神经网络,参数都是FP32的浮点数。每个数占4字节。一个7B的模型,光权重就要28GB。这在服务器上都够呛,更别说手机、摄像头这些端侧设备了。

我刚开始做端侧推理时,第一个想法就是:能不能把这些数用更少的位来表示?能。这就是量化。

3.1 四种精度的“身世”

先看看我们常用的几种数值精度。它们就像不同刻度的尺子——刻度越细,量得越准,但尺子也越重。

精度类型 位宽 表示范围 相对FP32存储节省 典型场景
FP32 32位 ~±3.4×10³⁸ 1x(基准) 训练、高精度推理
FP16 16位 ~±6.5×10⁴ 2x 训练加速、GPU推理
INT8 8位 [-128, 127] 4x 端侧推理、边缘计算
INT4 4位 [-8, 7] 或 [0, 15] 8x 极致压缩、特定硬件

FP32是“全精度”。每个数用1位符号、8位指数、23位尾数。它能表示非常大和非常小的数,精度也够。训练时几乎都用它。

FP16是“半精度”。指数和尾数都砍了一半。范围小了,精度也低了。但好处是显存占用减半,计算速度翻倍。我在训练大模型时,经常用混合精度——前向用FP16算,反向用FP32更新权重。又快又稳。

INT8和INT4就更有意思了。它们不是浮点数,是整数。没有指数,只有符号位和数值位。范围非常有限。但好处是——存储直接降到1/4和1/8,而且整数运算在CPU、DSP、NPU上快得飞起。

关键认知:量化不是简单的“砍位宽”,而是把浮点数映射到整数网格上。映射得好,精度损失极小;映射得不好,模型直接崩掉。

3.2 量化到底在做什么?

量化本质上是一个映射函数。把FP32的值域 [r_min, r_max] 映射到整数的值域 [q_min, q_max]。

最常见的映射是对称量化:

// 对称量化公式
scale = (r_max - r_min) / (q_max - q_min)
zero_point = 0  // 对称量化零点为0

// 量化:浮点 -> 整数
q = round(r / scale)

// 反量化:整数 -> 浮点
r = q * scale

举个例子。假设一组权重在 [-1.0, 1.0] 之间。要量化到INT8 [-128, 127]:

scale = (1.0 - (-1.0)) / (127 - (-128))
      = 2.0 / 255
      ≈ 0.00784

// 浮点数 0.5 量化后:
q = round(0.5 / 0.00784) = round(63.7) = 64

// 反量化回来:
r = 64 * 0.00784 = 0.5018  // 有微小误差

你看,0.5变成了0.5018。这就是量化误差的源头——舍入误差。

我的经验:对称量化对权重效果不错,因为训练好的权重通常关于0对称。但对激活值(比如ReLU后的输出)就不太行了——它们全是正数。这时候用非对称量化(zero_point不为0)效果更好。

3.3 量化误差从哪来?

量化误差不是一种,是三种。我踩过的坑,基本都跟它们有关。

3.3.1 舍入误差

这是最直接的。浮点数映射到整数网格,必然有舍入。就像你用毫米尺量头发丝,总会有±0.5mm的误差。

单个舍入误差很小。但模型里动辄上亿个参数,误差累积起来就不可忽视了。尤其是某些层对精度特别敏感——比如BatchNorm的gamma参数,稍微偏一点,输出就飘了。

3.3.2 截断误差

这是最要命的。浮点数的范围比整数大得多。一旦某个值超出了量化范围,就会被“截断”到边界值。

举个例子。INT8的范围是[-128, 127]。如果某个权重是128.5,量化后变成127。反量化回来是127 * scale,跟128.5差了一大截。

我遇到过这种情况:一个模型量化后直接不工作了。查了半天,发现是某一层的权重有少量异常大值,把整个scale撑大了,导致大部分正常值被压缩到很小的区间,精度全丢了。

避坑指南:量化前一定要做“异常值裁剪”。把权重分布两端0.1%的极端值直接砍掉,反而能提升量化精度。我曾经因为不舍得裁剪,模型精度掉了5个点。裁剪后只掉了0.3个点。

3.3.3 跨层误差传播

这个最隐蔽。量化误差在层与层之间会传播、放大。

比如第一层量化后,输出有0.1%的误差。这个误差作为第二层的输入,经过计算后可能变成1%的误差。到了第五层,可能就变成10%了。

尤其是那些有“捷径连接”的结构(比如ResNet),量化误差会在不同路径上累积,最后在合并点爆发。

3.4 不同精度的“脾气”

FP16量化,我一般叫它“温柔量化”。范围够大,精度也还行。大部分模型直接转FP16,精度几乎不掉。我做过一个实验:ResNet-50从FP32转FP16,Top-1准确率只掉了0.02%。基本可以忽略。

INT8量化,是“主力量化”。存储省4倍,速度提升2-4倍。但需要做校准——拿一批数据跑一遍,统计激活值的分布,找到最优的scale和zero_point。我习惯用1000张左右的校准集,太少统计不准,太多浪费时间。

INT4量化,是“极限量化”。存储省8倍,但精度损失明显。一般只在特定场景用——比如语音唤醒、关键词检测这类对精度要求不高的任务。或者配合混合精度——敏感层用INT8,不敏感层用INT4。

我的建议:从FP16开始,精度达标就停。不达标再上INT8。INT4是最后的选择。别一上来就追求极致压缩,精度崩了再回头调,更浪费时间。

3.5 量化误差的“体检报告”

怎么知道量化后模型精度掉了多少?我一般看三个指标:

  1. 余弦相似度:量化前后,每一层输出的余弦相似度。低于0.99就要警惕了。
  2. 信噪比(SNR):量化噪声与原始信号的比值。SNR低于30dB,说明噪声太大了。
  3. 最终任务指标:比如分类准确率、检测mAP。这是最终标准。前两个指标再好看,任务指标掉了也是白搭。

我习惯在量化后先跑一遍余弦相似度。如果某层低于0.95,我会单独看这层的权重分布——是不是有异常值?是不是量化参数没选对?

3.6 一张图看懂量化流程

下面这张图,是我自己总结的量化决策流程。每次做量化前,我都会过一遍。

量化精度选择与误差诊断流程 FP32 原始模型 尝试 FP16 量化 精度达标? 直接使用 尝试 INT8 量化 + 校准 诊断:余弦相似度 / SNR / 异常值裁剪 精度达标? 部署 INT8 考虑 INT4 或混合精度 说明: FP16:几乎不掉精度 INT8:需校准,掉0.5-2% INT4:掉3-10%,慎用

嗯,这张图基本概括了我的量化思路。先试FP16,不行再试INT8。INT8还不行,就做诊断——看看是哪层出了问题。是异常值?还是分布不均匀?对症下药。

3.7 量化后的“体检”怎么做?

我分享一个实用的小脚本。量化后,我会跑一遍每层的余弦相似度:

import torch

def check_quantization_quality(model_fp32, model_quant, calib_loader):
    """
    检查量化后每层的余弦相似度
    """
    similarities = {}
    
    # 注册hook,获取每层输出
    hooks_fp32 = []
    hooks_quant = []
    
    def hook_fn(name, storage):
        def fn(module, input, output):
            storage[name] = output.detach()
        return fn
    
    fp32_outputs = {}
    quant_outputs = {}
    
    for name, module in model_fp32.named_modules():
        if isinstance(module, (torch.nn.Conv2d, torch.nn.Linear)):
            hooks_fp32.append(
                module.register_forward_hook(hook_fn(name, fp32_outputs))
            )
    
    for name, module in model_quant.named_modules():
        if isinstance(module, (torch.nn.Conv2d, torch.nn.Linear)):
            hooks_quant.append(
                module.register_forward_hook(hook_fn(name, quant_outputs))
            )
    
    # 跑一遍校准数据
    with torch.no_grad():
        for batch in calib_loader:
            model_fp32(batch)
            model_quant(batch)
            break  # 只跑一个batch
    
    # 计算余弦相似度
    for name in fp32_outputs:
        if name in quant_outputs:
            a = fp32_outputs[name].flatten()
            b = quant_outputs[name].flatten()
            cos_sim = torch.nn.functional.cosine_similarity(
                a.unsqueeze(0), b.unsqueeze(0)
            )
            similarities[name] = cos_sim.item()
    
    # 清理hook
    for h in hooks_fp32 + hooks_quant:
        h.remove()
    
    return similarities

这个脚本会输出每层的余弦相似度。我一般这样判断:

  • ≥0.99:优秀,放心部署
  • 0.95-0.99:还行,但建议关注
  • <0.95:有问题,需要排查

小技巧:如果某层相似度特别低,试试把这层单独保留为FP32。混合精度往往能解决大部分问题。我有个项目,INT8量化后准确率掉了3%。最后只把第一层和最后一层保留为FP16,准确率就只掉了0.3%。

3.8 量化不是银弹

说了这么多,我得泼点冷水。量化不是万能的。

有些模型天生对量化不友好。比如MobileNet、EfficientNet这些轻量级模型,参数量本来就少,量化后精度损失更明显。反而是大模型(ResNet-50以上)量化效果更好——参数冗余多,砍掉一些精度也不怎么掉。

还有,量化对激活值的敏感度远高于权重。我见过一个案例:权重量化到INT8几乎不掉点,但激活值一量化,直接掉了5个点。原因就是激活值的分布太宽,量化网格不够细。

所以,我的建议是:量化前先做“敏感性分析”。逐层量化,看看哪层最敏感。敏感层保留高精度,不敏感层使劲压缩。这才是工程上的最优解。

最后提醒:量化后的模型一定要在目标硬件上测试。模拟量化(fake quantization)和实际硬件量化(hardware quantization)可能有差异。我吃过这个亏——PC上模拟跑得好好的,部署到手机上就崩了。原因就是硬件的INT8乘法器对溢出处理跟软件不一样。