4、训练后量化(PTQ):校准数据集的选择,KL散度与MinMax校准器,量化粒度(per-tensor vs per-channel),我在项目中踩过的坑

训练后量化,圈里人常叫它PTQ。说白了,就是模型训练完了,我们拿它做一次“瘦身”。不需要重新训练,成本低,见效快。但这里面的门道,比你想象的多得多。

我最早接触PTQ时,觉得这玩意儿简单——跑一遍校准数据,算个scale和zero_point,完事。结果呢?模型精度直接掉了两三个点,推理结果惨不忍睹。后来我才明白,PTQ不是“一键瘦身”,而是“精准手术”。

4.1 校准数据集的选择:别随便拿数据糊弄

校准数据集,就是用来统计激活值分布的那批数据。很多人图省事,随便从训练集里抽几百张图就开干。嗯,我踩过这个坑。

核心原则:校准集必须代表真实推理场景的分布。

举个例子。你训练了一个分类模型,训练集里猫狗各一半。但实际部署时,80%的输入都是猫。如果你校准集里猫狗各一半,那算出来的量化参数,在真实场景下可能就不准了。

我个人习惯的做法是:

  • 从验证集中采样,而不是训练集。验证集更接近真实分布。
  • 样本量控制在500-2000张。太少统计不准,太多浪费时间。
  • 覆盖所有类别。尤其是长尾类别,别漏掉。
  • 考虑数据增强后的分布。如果部署时会有光照变化、旋转等,校准集里最好也包含这些情况。
注意: 校准集不能太小。我曾经用100张图做校准,结果量化后的模型在某个类别上直接崩了。后来排查发现,那100张图里恰好没有那个类别的样本,导致该通道的统计信息完全失真。

4.2 KL散度与MinMax校准器:两种思路,两种命运

校准器,就是计算scale和zero_point的算法。最常用的两种:MinMax和KL散度。

4.2.1 MinMax校准器

思路很简单:找到激活值的最大值和最小值,然后映射到量化范围。

# 伪代码
scale = (max_val - min_val) / (quant_max - quant_min)
zero_point = quant_min - min_val / scale

优点:实现简单,计算快。缺点:对异常值极其敏感。如果激活值里有一个离群点,整个量化范围都会被拉宽,精度损失惨重。

我在项目中遇到过这种情况:某个卷积层的输出,99.9%的值都在[-1, 1]之间,但偶尔会蹦出一个3.5的异常值。用MinMax一算,scale直接翻倍,结果就是大部分有效值都被压缩到了很窄的量化区间里,精度掉得厉害。

4.2.2 KL散度校准器

KL散度的思路更聪明:它不直接取最大最小值,而是找一个阈值,使得量化前后的分布差异最小。

说白了,就是“牺牲掉那些不重要的离群点,保住大部分有效值的精度”。

# 伪代码
def kl_calibrator(hist, bin_edges):
    best_threshold = None
    min_kl = float('inf')
    for threshold in candidate_thresholds:
        # 截断分布
        clipped_hist = hist[bin_edges < threshold]
        # 量化到目标bit数
        quantized_hist = quantize(clipped_hist, num_bits)
        # 计算KL散度
        kl = compute_kl(clipped_hist, quantized_hist)
        if kl < min_kl:
            min_kl = kl
            best_threshold = threshold
    return best_threshold

KL散度对异常值更鲁棒。但代价是计算量大,需要先统计直方图,然后遍历候选阈值。

我的建议:

  • 如果模型对精度要求极高,且激活值分布比较干净,用MinMax。
  • 如果模型有离群点,或者你对精度损失容忍度低,用KL散度。
  • 如果追求速度,MinMax。如果追求精度,KL散度。

4.3 量化粒度:per-tensor vs per-channel

量化粒度,说白了就是“scale是共享的还是各算各的”。

4.3.1 Per-tensor量化

整个张量共享一个scale和zero_point。实现简单,硬件友好。但问题也很明显:如果不同通道的数值范围差异很大,那精度损失就大了。

举个例子。一个卷积层有64个输出通道。其中63个通道的值都在[-0.5, 0.5]之间,但第64个通道的值在[-2, 2]之间。用per-tensor量化,scale会被第64个通道拉大,其他63个通道的精度全被牺牲了。

4.3.2 Per-channel量化

每个通道独立计算scale和zero_point。精度更高,但计算量也更大,硬件实现更复杂。

我个人的经验是:

  • 权重用per-channel。权重的分布通常差异较大,per-channel能显著提升精度。
  • 激活值用per-tensor。激活值的分布相对均匀,per-tensor够用,而且硬件实现简单。
  • 如果精度不够,再考虑激活值也用per-channel。但要做好性能下降的准备。
量化粒度 精度 计算量 硬件友好度 适用场景
Per-tensor 资源受限、精度要求不高
Per-channel 精度敏感、硬件支持

4.4 我在项目中踩过的坑

做PTQ这几年,踩过的坑能写一本书。挑几个典型的说说。

坑一:校准集和验证集分布不一致

我曾经做过一个OCR模型,校准集用的是印刷体,验证集里混了手写体。结果量化后,手写体的识别率直接掉到60%。后来我把手写体也加入校准集,问题才解决。

教训: 校准集必须覆盖所有部署场景。

坑二:KL散度阈值选得太激进

有一次我用KL散度做校准,为了追求更小的量化范围,把阈值设得很低。结果很多有效信息被截断了,模型精度反而下降。

教训: KL散度的阈值不是越小越好。要平衡截断误差和量化误差。

坑三:per-channel量化后,推理速度反而变慢

我在一个移动端项目里用了per-channel量化,精度确实提升了。但推理速度比per-tensor慢了30%。原因是硬件对per-channel的支持不够好,每次计算都要重新加载scale。

教训: 量化粒度不是越细越好。要考虑硬件实现。

坑四:忽略Batch Normalization的融合

很多人做PTQ时,忘了把BN层融合到卷积层里。结果量化后的模型,BN层的统计信息被单独量化,精度损失很大。

教训: 量化前,先把BN融合掉。这是基本功。

嗯,PTQ看起来简单,但要做好,真得花功夫。校准集、校准器、量化粒度,每一个环节都可能成为瓶颈。我的建议是:先跑一遍默认配置,看看精度损失。如果损失大,再逐一排查。别一上来就追求极致,容易翻车。