模型量化技术(上):FP32/FP16/BF16/INT8原理、量化感知训练、动态量化实战

聊到模型量化,我得先跟你掏心窝子说一句:这可能是你花最少成本,换来最大推理加速的手段。我见过太多团队,模型精度明明够用,却因为显存爆了、延迟高了,硬生生把项目拖垮。其实,很多时候只需要把精度从FP32降到FP16或者INT8,问题就迎刃而解了。

今天这一讲,咱们先把量化的“底裤”扒干净。从最基础的数值表示原理,到实战中怎么用代码做动态量化,我都会把踩过的坑和经验一并倒给你。

1. 为什么我们需要量化?

说白了,就是省钱省力。

你想想看,一个70B的大模型,用FP32存下来要280GB显存。就算用H100也得4张卡才能塞下。但如果你换成INT8呢?直接降到70GB,一张A100就搞定了。这还不算完,计算速度也能翻倍甚至翻三倍。

我在项目中遇到过最极端的情况:一个OCR模型在FP32下跑一次要800ms,客户嫌慢。我换成INT8动态量化后,直接降到120ms,精度只掉了0.3%。客户当场就拍板了。

核心收益总结:
  • 显存占用降低:FP32 → INT8,理论减少75%
  • 计算吞吐提升:低精度计算单元更多,带宽压力更小
  • 功耗下降:移动端和边缘设备尤其明显

2. 四种精度的“底细”

咱们先搞清楚这四种格式到底长什么样。我习惯用“位宽 + 指数位 + 尾数位”这个框架去理解。

格式 总位宽 指数位 尾数位 表示范围 精度
FP32 32 8 23 ~3.4e38 高(约7位有效数字)
FP16 16 5 10 ~65504 中(约3位有效数字)
BF16 16 8 7 ~3.4e38 低(约2位有效数字)
INT8 8 无 8 -128~127 离散整数

2.1 FP32:老大哥,稳但重

FP32是深度学习默认的“通用货币”。它的动态范围大,精度也够。但问题是——太占地方了。我刚开始做模型部署时,总觉得FP32是“标准答案”,后来发现很多场景根本不需要这么高的精度。

2.2 FP16:速度快,但容易“炸”

FP16在NVIDIA的Tensor Core上跑得飞快。但有个致命伤:表示范围太小。最大值才65504。你想想看,如果模型里某个权重或者激活值超过这个数,直接就变成Inf了。我在训练一个Transformer时遇到过,loss突然变成NaN,查了半天才发现是FP16下梯度溢出了。

避坑指南:FP16训练一定要配合Loss Scaling。我曾经因为忘了加这个,白白浪费了两天算力。

2.3 BF16:Google的“黑科技”

BF16是Google搞出来的。它保留了FP32的8位指数,所以动态范围跟FP32一样大。虽然尾数只有7位,精度差了点,但训练时基本够用。现在A100、H100都原生支持BF16,我个人非常推荐用它替代FP32做训练。

2.4 INT8:推理的“杀手锏”

INT8只有256个离散值。但它的计算速度极快,而且显存占用极低。为什么能用?因为神经网络的权重和激活值通常分布在一个很小的范围内。比如ReLU之后的值都在0附近。我们只需要把这个范围映射到-128~127之间就行。

3. 量化原理:从浮点到整数的“翻译”

量化的核心公式其实很简单:

Q = round( (R - zero_point) / scale )

其中:

  • R:原始浮点值
  • Q:量化后的整数值
  • scale:缩放因子(浮点)
  • zero_point:零点偏移(整数)

反量化就是反过来:

R ≈ Q * scale + zero_point

嗯,这里要注意:scale和zero_point怎么选,直接决定了量化精度。我见过有人直接用min-max映射,结果因为一个异常点,整个量化范围被拉得很大,精度掉得一塌糊涂。

我的经验:实际项目中,我更喜欢用KL散度或者均方误差最小化来选scale。NVIDIA的TensorRT就是这么干的。它能找到让量化前后分布最接近的那个阈值。

4. 量化感知训练(QAT):让模型“学会”低精度

动态量化虽然方便,但精度损失有时候还是有点大。这时候就需要量化感知训练了。

说白了,就是在训练过程中模拟量化效果。我们在前向传播时插入伪量化节点(FakeQuant),把权重和激活值先量化再反量化。这样模型就能“看到”量化带来的误差,并主动去适应它。

import torch
import torch.nn as nn

# 一个简单的QAT示例
class QuantizedConv(nn.Module):
    def __init__(self, in_ch, out_ch, kernel_size):
        super().__init__()
        self.conv = nn.Conv2d(in_ch, out_ch, kernel_size)
        # 伪量化节点
        self.quant = torch.quantization.FakeQuantize(
            observer=torch.quantization.MinMaxObserver,
            quant_min=-128, quant_max=127,
            dtype=torch.qint8
        )
    
    def forward(self, x):
        # 先量化再反量化,模拟INT8计算
        x = self.quant(x)
        x = self.conv(x)
        x = self.quant(x)
        return x

我在项目中用过一次QAT:一个语义分割模型,直接PTQ(训练后量化)掉点2.5个mIoU。用了QAT微调了3个epoch后,只掉了0.3个点。效果立竿见影。

注意:QAT训练时,伪量化节点的梯度是直通估计(STE)的。也就是说,round操作在反向传播时直接跳过梯度。这会导致训练不稳定。我建议学习率调小一点,比如原来的1/10。

4. 动态量化实战:一行代码搞定

如果你不想折腾训练,只想快速部署,那动态量化是你的不二之选。PyTorch里只需要一行代码:

import torch

# 加载一个预训练模型
model = torch.load('model_fp32.pth')

# 动态量化:只量化权重,激活值在推理时动态计算scale
quantized_model = torch.quantization.quantize_dynamic(
    model,
    {torch.nn.Linear, torch.nn.LSTM},  # 只量化这些层
    dtype=torch.qint8
)

# 保存量化模型
torch.save(quantized_model.state_dict(), 'model_int8.pth')

为什么叫“动态”?因为激活值的scale和zero_point是在推理时实时计算的。每次前向传播,都会根据当前batch的数据重新算一遍。好处是精度高,坏处是计算量稍微大一点。

我建议你优先对全连接层和LSTM层做动态量化。卷积层用动态量化收益不大,因为卷积的计算瓶颈在访存,不在计算。

实战效果:我用BERT做文本分类,FP32模型大小440MB,动态量化后变成110MB。推理速度从15ms降到6ms,准确率从92.1%降到91.8%。完全可接受。

5. 知识体系总览

为了让你更直观地理解本章的知识结构,我画了一张图:

模型量化技术(上)知识体系 为什么需要量化? 显存↓ 速度↑ 功耗↓ 四种数值精度 FP32(32位) FP16(16位) BF16(16位) INT8(8位) 量化方法 训练后量化(PTQ) 量化感知训练(QAT) 动态量化实战(PyTorch一行代码)

6. 总结与建议

好了,这一讲的内容就到这里。我最后给你三个实操建议:

  1. 先试动态量化:成本最低,效果往往不错。如果精度不够,再考虑QAT。
  2. 优先量化Linear和LSTM:收益最大。卷积层建议用静态量化或者INT8校准。
  3. 一定要做精度验证:量化后的模型,必须用验证集跑一遍。我见过有人直接上线,结果精度掉到没法用。
一个小技巧:如果你用PyTorch,可以试试torch.quantization.quantize_dynamic的qconfig_spec参数。你可以指定哪些子模块不量化,比如某些对精度特别敏感的层。

量化这条路,说白了就是用精度换效率。但只要你选对方法,精度损失完全可以控制在1%以内。下一讲,我们会深入静态量化和INT8校准的细节,到时候再聊。

想要更多资料,联系可可