3、训练后量化(PTQ):Calibration数据集选择与KL散度调优实战

训练后量化,说白了就是模型训练完了,我们再动手把它从FP32压缩到INT8。这活儿看着简单,但坑不少。我最早做PTQ的时候,觉得随便找几张图跑一遍校准就完事了,结果模型精度直接掉了两三个点,找了好久才发现是Calibration数据集没选对。

今天我们就聊聊PTQ里最关键的两个环节:Calibration数据集怎么选,以及KL散度怎么调。这两个点搞定了,PTQ基本就稳了。

3.1 什么是Calibration?为什么它这么重要?

Calibration,直译过来就是“校准”。在PTQ里,它的任务是:用一小批数据跑一遍模型,统计出每一层激活值的分布范围。然后量化器根据这个范围,决定怎么把FP32的数值映射到INT8的256个离散值上。

你想想看,如果校准数据选得不对,统计出来的范围就偏了。比如你拿了一堆全是白天场景的图片去校准,模型对夜晚场景的激活值就可能被截断,精度自然就崩了。

核心结论:Calibration数据集的质量,直接决定了PTQ的精度上限。数据集选得好,INT8模型精度可以接近FP32;选得不好,掉点2-3%是常事。

3.2 Calibration数据集的选择策略

我个人习惯把Calibration数据集的选择分成三个维度来考虑:代表性、多样性、数量。

维度 说明 常见误区
代表性 校准数据必须覆盖真实部署场景的分布 只用训练集做校准,忽略了部署时的数据偏移
多样性 包含不同光照、角度、背景、类别等变化 校准集全是同类样本,导致某些通道量化误差大
数量 一般500-2000张图片足够,太多反而过拟合 以为越多越好,结果校准时间翻倍,精度没提升

我在项目中遇到过一种情况:用训练集的随机子集做校准,效果还行,但一换到实际场景的测试集,精度就掉。后来我改用验证集+少量真实场景数据混合校准,问题就解决了。

小技巧:如果你不确定校准集选得好不好,可以跑一次“校准集分布 vs 真实部署分布”的对比。用直方图看一眼激活值的分布是否接近,心里就有底了。

3.3 KL散度:量化误差的“度量尺”

校准数据选好了,接下来就是怎么用这些数据去确定量化阈值。这里就要请出KL散度了。

KL散度,全称Kullback-Leibler divergence,用来衡量两个概率分布之间的差异。在量化里,我们用它来比较原始FP32激活值的分布和量化后INT8激活值的分布。KL散度越小,说明量化损失越小。

具体做法是:

  1. 用校准数据跑一遍模型,收集每一层的激活值直方图(FP32)。
  2. 尝试不同的量化阈值(比如从最大值往下砍一点),计算量化后的分布。
  3. 计算原始分布和量化后分布的KL散度。
  4. 选择KL散度最小的那个阈值。

嗯,这里要注意:KL散度不是对称的。我们关心的是量化后的分布如何“逼近”原始分布,所以方向是D_KL(原始 || 量化)。

3.4 KL散度调优实战:代码与参数

下面我贴一段核心代码,展示如何用Python实现KL散度校准。这是我在NVIDIA TensorRT源码基础上简化过的版本,逻辑是一样的。

import numpy as np

def kl_divergence(p, q):
    """计算KL散度 D_KL(p || q)"""
    p = np.asarray(p, dtype=np.float32)
    q = np.asarray(q, dtype=np.float32)
    # 避免除零和log(0)
    p = p / np.sum(p) + 1e-10
    q = q / np.sum(q) + 1e-10
    return np.sum(p * np.log(p / q))

def calibrate_threshold(hist, bin_edges, num_bins=2048):
    """
    通过KL散度寻找最优量化阈值
    hist: 激活值直方图
    bin_edges: 直方图边界
    """
    # 将直方图归一化为概率分布
    hist = hist.astype(np.float32)
    hist = hist / np.sum(hist)
    
    best_threshold = bin_edges[-1]
    min_kl = float('inf')
    
    # 从高到低尝试不同的截断位置
    for i in range(num_bins // 2, num_bins):
        # 截断直方图
        truncated_hist = hist[:i].copy()
        # 将截断部分累加到最后一个bin
        truncated_hist[-1] += np.sum(hist[i:])
        
        # 量化到128个bin(INT8正半轴)
        quantized_hist = np.zeros(128)
        bin_size = i / 128
        for j in range(128):
            start = int(j * bin_size)
            end = int((j + 1) * bin_size)
            if end > i:
                end = i
            quantized_hist[j] = np.sum(truncated_hist[start:end])
        
        # 反量化回原始bin数
        dequantized_hist = np.zeros(i)
        for j in range(128):
            start = int(j * bin_size)
            end = int((j + 1) * bin_size)
            if end > i:
                end = i
            dequantized_hist[start:end] = quantized_hist[j] / (end - start)
        
        # 计算KL散度
        kl = kl_divergence(truncated_hist, dequantized_hist)
        
        if kl < min_kl:
            min_kl = kl
            best_threshold = bin_edges[i]
    
    return best_threshold, min_kl

避坑指南:我曾经在调KL散度时忽略了一个细节——直方图的bin数量。bin太少,分布信息丢失严重,KL散度算出来不准;bin太多,计算量暴增,而且容易过拟合。我一般用2048个bin,效果比较稳定。

3.5 实战中的调优经验

光有代码还不够,实际调优时还有几个点要注意:

  • 逐层校准 vs 全局校准:我建议逐层做KL散度校准。每层的激活值分布差异很大,全局用一个阈值会损失精度。
  • 对称量化 vs 非对称量化:对于ReLU这类激活函数,输出都是非负的,用非对称量化更合适。KL散度校准在非对称量化下效果更好。
  • 校准集数量:我试过从100张到5000张图片,发现1000张左右是个甜点。太少统计不稳定,太多边际收益递减。

为什么会这样?因为KL散度本质上是在找“信息损失最小”的截断点。校准集太小,直方图稀疏,KL散度容易找到假的最优解;校准集太大,直方图过于平滑,反而模糊了关键区域的细节。

3.6 知识结构图:PTQ校准与KL散度调优流程

下面这张图总结了整个流程,方便你对照理解。

PTQ校准与KL散度调优流程 ① 选择Calibration数据集 ② 前向推理,收集激活值直方图 ③ 尝试不同量化阈值 ④ 计算KL散度 D_KL(原始 || 量化) ⑤ 选择KL散度最小的阈值 ⑥ 应用最优阈值,完成INT8量化 关键:校准集需覆盖真实部署分布,KL散度逐层计算,阈值选择以信息损失最小为目标

3.7 总结:PTQ校准的核心要点

最后,我把今天的内容浓缩成几个关键点,方便你记:

  • 校准集选不好,后面全白费。代表性、多样性、数量三者缺一不可。
  • KL散度是量化误差的度量尺。用它来找最优阈值,比简单用最大值或百分比截断靠谱得多。
  • 逐层校准,别偷懒。每层的分布不一样,统一阈值会损失精度。
  • 校准集数量1000张左右足够。太多太少都不好。

嗯,PTQ这块内容其实挺深的。今天讲的Calibration和KL散度,是PTQ里最核心的两个环节。搞懂了它们,你就能在精度和性能之间找到那个最优的平衡点。