一、先搞懂为啥要做模型量化

你花半个月调的AI模型,在自己电脑上跑着挺准,一部署到服务器或者边缘设备(比如摄像头、小盒子)上,要么慢得像蜗牛,要么卡得动不了?大概率是没做模型量化。

简单说,模型量化就是把模型里原来用“大尺寸数字”(比如32位浮点数,就像手机里的“高清视频”)存的参数,改成“小尺寸数字”(比如8位整数,就像压缩后的“标清视频”)。这么一改,模型体积能缩到原来的1/4甚至更小,跑起来速度能快好几倍,对硬件的要求也低很多——毕竟设备处理小数字比处理大数字快太多了。

但问题来了:压缩视频会丢细节,量化模型也会丢精度,严重的时候准确率能掉十几个百分点,完全没法用。这也是很多人头疼的点:量化完不准,改了半天不知道问题出在哪。

二、TensorRT量化的核心:INT8校准集

TensorRT是英伟达出的专门给模型加速的工具,它做INT8量化的时候,最关键的一步是“校准”——简单说就是让TensorRT知道,原来的模型在处理真实数据时,每个数字大概的范围是多少,这样量化的时候才不会把有用的信息弄丢。

而校准的前提,就是要有一个“校准集”。这个校准集不是随便找一堆数据就行,有严格的要求:

  1. 必须和模型要处理的真实数据“长得一样”:比如你模型是用来识别猫的,校准集就得是各种各样的猫的图片,不能拿汽车图片凑数;
  2. 数量不能太少:一般得有几百张,太少了TensorRT摸不准数字的范围;
  3. 不能有标注:校准只是用来摸数据分布的,不需要告诉模型哪张是猫哪张是狗。

2.1 怎么搭一个合格的校准集?

我们用图像分类模型来举个例子,假设你要把一个识别猫的模型(原来用32位浮点数)转成TensorRT的INT8模型。

首先得准备校准集的图片:从你的训练数据里挑出200张猫的图片(不能是测试集的,不然相当于用测试集调参),或者从真实场景里找200张猫的图片(比如摄像头拍的)。

然后得告诉TensorRT怎么读取这些图片,这就需要写一个“校准器”的代码。TensorRT提供了专门的API来写校准器,我们用Python来写(因为TensorRT的Python API比较好上手)。

先明确技术栈:Python 3.8 + TensorRT 8.6.1 + OpenCV 4.8.0

下面是一个完整的校准器代码,带详细注释:

import tensorrt as trt
import cv2
import numpy as np
import os

# 1. 定义校准器类,继承TensorRT的IInt8EntropyCalibrator2(这是最常用的校准器)
class MyCalibrator(trt.IInt8EntropyCalibrator2):
    def __init__(self, calibration_data_path, input_shape, cache_file):
        """
        初始化校准器
        :param calibration_data_path: 校准集图片的文件夹路径
        :param input_shape: 模型的输入形状,比如(1, 3, 224, 224)(batch, 通道, 高, 宽)
        :param cache_file: 校准缓存文件的路径,第一次校准会生成,之后可以直接用
        """
        super().__init__()
        self.calib_files = os.listdir(calibration_data_path)  # 读取校准集所有图片的文件名
        self.input_shape = input_shape
        self.cache_file = cache_file
        self.batch_size = input_shape[0]  # 模型的batch大小,这里假设输入形状的第一个维度是batch
        self.current_idx = 0  # 记录当前处理到第几张图片
        # 分配一块连续的内存,用来存当前batch的图片数据(TensorRT需要)
        self.device_input = trt.cuda.alloc(trt.volume(input_shape) * trt.float32.itemsize)

    def get_batch_size(self):
        # 返回每个batch的大小,TensorRT需要知道
        return self.batch_size

    def get_batch(self, names):
        # 读取一个batch的校准图片,处理后传给TensorRT
        if self.current_idx + self.batch_size > len(self.calib_files):
            # 所有校准图片都处理完了,返回None
            return None
        
        # 读取当前batch的图片路径
        batch_files = self.calib_files[self.current_idx : self.current_idx + self.batch_size]
        # 处理图片:转成模型需要的格式(比如通道在前、归一化)
        batch_data = []
        for img_path in batch_files:
            # 用OpenCV读图片,默认是BGR格式,转成RGB
            img = cv2.imread(os.path.join(self.calibration_data_path, img_path))
            img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
            # 调整图片大小到模型需要的尺寸
            img = cv2.resize(img, (self.input_shape[3], self.input_shape[2]))
            # 归一化:比如把像素值从0-255转到0-1之间,或者减去均值除以标准差(和训练时的预处理一致)
            img = img.astype(np.float32) / 255.0
            # 转成通道在前的格式:原来的形状是(高, 宽, 通道),转成(通道, 高, 宽)
            img = img.transpose(2, 0, 1)
            batch_data.append(img)
        
        # 把batch的图片转成TensorRT需要的格式,存到之前分配的内存里
        batch_data = np.array(batch_data, dtype=np.float32)
        trt.cuda.memcpy(self.device_input, batch_data.ctypes.data, batch_data.nbytes)
        
        # 更新当前处理的位置
        self.current_idx += self.batch_size
        # 返回设备内存的指针,TensorRT会从这里拿数据
        return [int(self.device_input)]

    def read_calibration_cache(self):
        # 读取之前生成的校准缓存文件,如果存在就不用重新校准了
        if os.path.exists(self.cache_file):
            with open(self.cache_file, "rb") as f:
                return f.read()
        return None

    def write_calibration_cache(self, cache):
        # 把本次校准的结果存到缓存文件里,下次可以直接用
        with open(self.cache_file, "wb") as f:
            f.write(cache)

# 2. 测试校准器(可选,用来验证校准器能不能正常工作)
if __name__ == "__main__":
    # 假设校准集图片存在./calib_data文件夹,模型输入是(1, 3, 224, 224),缓存文件叫calib.cache
    calib = MyCalibrator("./calib_data", (1, 3, 224, 224), "calib.cache")
    # 测试读取一个batch
    batch = calib.get_batch(["input"])
    if batch is not None:
        print("校准器工作正常")
    else:
        print("校准器工作异常")

这个校准器的核心是“和训练时的预处理完全一致”——比如训练时你把图片的像素值从0-255减去[0.485, 0.456, 0.406]再除以[0.229, 0.224, 0.225](这是ImageNet数据集的均值和标准差),那校准的时候也必须这么做,不能只除以255。如果预处理不一致,TensorRT拿到的数字范围和模型实际处理的范围不一样,量化后肯定不准。

2.2 校准集的常见坑

很多人校准后精度掉,其实是校准集出了问题:

  • 坑1:校准集用了测试集的数据。比如你训练模型时用了1000张猫的图片,测试时用了100张,结果校准的时候又用了这100张测试集的,这就相当于用测试集调参,量化后在真实场景下的精度还是会掉。
  • 坑2:校准集的场景太单一。比如你模型是用来识别室外的猫,结果校准集全是室内的猫,TensorRT摸不准室外猫的数字范围,量化后识别室外猫的精度就会掉。
  • 坑3:校准集数量太少。比如只拿了10张图片做校准,TensorRT根本摸不准数字的分布,量化的时候容易把有用的信息当成“噪音”删掉。

三、量化后精度掉了?学会逐层找问题

如果校准集没问题,量化后精度还是掉,那就要一层一层找问题了——就像手机卡了,你得先看是微信卡,还是抖音卡,还是整个系统卡。

TensorRT量化的时候,会把模型的每一层(比如卷积层、全连接层)都转成INT8,每一层转完都会有一个“量化误差”——简单说就是这一层量化后的输出和原来32位浮点数的输出差了多少。如果某一层的误差特别大,那问题大概率就出在这一层。

3.1 怎么看每一层的量化误差?

TensorRT提供了一个API,可以导出量化后的模型每一层的输入输出,我们可以和原来32位模型的对应层的输入输出对比,算出误差。

还是用上面的图像分类模型来举例子,步骤是:

  1. 先跑原来的32位模型,把每一层的输入输出存下来;
  2. 再跑TensorRT量化后的INT8模型,把每一层的输入输出存下来;
  3. 对比对应层的输出,算出误差(比如用“平均绝对误差”,就是两个输出数组对应位置的差的绝对值的平均值)。

下面是一个对比每一层误差的代码,技术栈还是Python 3.8 + TensorRT 8.6.1 + NumPy:

import tensorrt as trt
import numpy as np

# 1. 定义一个类,用来导出模型每一层的输入输出
class LayerOutputProfiler(trt.IProfiler):
    def __init__(self, layer_outputs):
        super().__init__()
        self.layer_outputs = layer_outputs  # 用来存每一层的输出

    def report_layer_time(self, layer_name, ms):
        # 这个方法是TensorRT调用的,我们不需要时间,只需要输出
        pass

    def get_layer_output(self, layer_name, output):
        # 把当前层的输出存下来
        # output是一个trt.DeviceMemory对象,转成numpy数组
        output_np = np.frombuffer(output, dtype=np.float32)
        self.layer_outputs[layer_name] = output_np

# 2. 加载原来的32位模型(假设是ONNX格式),导出每一层的输出
def get_original_layer_outputs(onnx_path, input_data):
    # 用TensorRT加载ONNX模型,转成32位的引擎
    logger = trt.Logger(trt.Logger.WARNING)
    builder = trt.Builder(logger)
    network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
    parser = trt.OnnxParser(network, logger)
    with open(onnx_path, "rb") as f:
        parser.parse(f.read())
    # 配置引擎,不用量化
    config = builder.create_builder_config()
    config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 1 << 30)  # 1GB工作空间
    # 生成引擎
    engine = builder.build_engine(network, config)
    context = engine.create_execution_context()
    # 给每一层注册输出
    layer_outputs = {}
    profiler = LayerOutputProfiler(layer_outputs)
    context.profiler = profiler
    # 运行模型,得到每一层的输出
    input_device = trt.cuda.alloc(input_data.nbytes)
    trt.cuda.memcpy(input_device, input_data.ctypes.data, input_data.nbytes)
    # 找到输入的名字(假设输入名字是input)
    input_idx = engine.get_binding_index("input")
    context.set_binding_shape(input_idx, input_data.shape)
    # 运行
    context.execute_v2([int(input_device)])
    # 把输出转成numpy数组
    for layer_name in layer_outputs:
        # 这里需要注意:TensorRT的层名字可能和ONNX的不一样,需要自己对应
        pass
    return layer_outputs

# 3. 加载量化后的INT8模型,导出每一层的输出
def get_int8_layer_outputs(int8_engine_path, input_data):
    logger = trt.Logger(trt.Logger.WARNING)
    # 加载INT8引擎
    with open(int8_engine_path, "rb") as f:
        engine = trt.Runtime(logger).deserialize_cuda_engine(f.read())
    context = engine.create_execution_context()
    layer_outputs = {}
    profiler = LayerOutputProfiler(layer_outputs)
    context.profiler = profiler
    # 运行模型
    input_device = trt.cuda.alloc(input_data.nbytes)
    trt.cuda.memcpy(input_device, input_data.ctypes.data, input_data.nbytes)
    input_idx = engine.get_binding_index("input")
    context.set_binding_shape(input_idx, input_data.shape)
    context.execute_v2([int(input_device)])
    return layer_outputs

# 4. 对比每一层的误差
def compare_layer_errors(original_outputs, int8_outputs):
    errors = {}
    for layer_name in original_outputs:
        # 找到INT8模型中对应层的输出
        int8_output = int8_outputs.get(layer_name, None)
        if int8_output is None:
            continue
        # 计算平均绝对误差
        mae = np.mean(np.abs(original_outputs[layer_name] - int8_output))
        errors[layer_name] = mae
    # 按误差从大到小排序
    sorted_errors = sorted(errors.items(), key=lambda x: x[1], reverse=True)
    print("误差最大的前5层:")
    for layer_name, mae in sorted_errors[:5]:
        print(f"层名:{layer_name},平均绝对误差:{mae:.4f}")
    return sorted_errors

# 5. 测试对比(假设输入数据是一个预处理好的图片,形状是(1, 3, 224, 224))
if __name__ == "__main__":
    # 准备输入数据(这里只是示例,实际要自己生成)
    input_data = np.random.randn(1, 3, 224, 224).astype(np.float32)
    # 得到原来32位模型的每一层输出
    original_outputs = get_original_layer_outputs("original_model.onnx", input_data)
    # 得到INT8模型的每一层输出
    int8_outputs = get_int8_layer_outputs("int8_model.engine", input_data)
    # 对比误差
    compare_layer_errors(original_outputs, int8_outputs)

这个代码的核心是“找到对应层”——因为TensorRT在转模型的时候,会把ONNX的层拆成自己的层,名字可能会变,比如ONNX里的conv_1,TensorRT里可能叫conv_1_0,这时候需要自己对应(可以看TensorRT生成的日志,或者用Netron工具打开ONNX和TensorRT的引擎,对比层的结构)。

3.2 误差大的层怎么改?

如果对比后发现某一层的误差特别大,比如平均绝对误差超过了0.1(这个阈值可以自己调),那就要针对这一层做优化:

  • 优化1:给这一层单独加校准数据。比如这一层是用来提取猫的耳朵的特征,那校准集里可以多放一些有清晰耳朵的猫的图片,让TensorRT摸准这一层的数字范围。
  • 优化2:把这一层改成16位量化。TensorRT支持混合精度量化,就是一部分层用INT8,一部分层用FP16(16位浮点数)。如果某一层用INT8误差太大,就把它改成FP16,这样精度损失会小很多,速度也比原来的32位快。
  • 优化3:检查这一层的权重。比如这一层的权重数字范围特别大,那量化的时候很容易丢精度,这时候可以调整权重的范围(比如用权重衰减或者正则化),让权重的数字范围变小。

四、量化的应用场景、优缺点和注意事项

4.1 应用场景

模型量化主要用在对速度和体积要求高的场景:

  • 边缘设备部署:比如摄像头、智能音箱、无人机上的AI模型,这些设备的算力和内存都有限,量化后才能跑起来;
  • 高并发服务:比如云服务器上的AI推理服务,量化后每个模型的速度变快,能处理更多的请求;
  • 移动端部署:比如手机上的AI拍照、AI翻译,量化后模型体积小,用户下载的时候快,跑起来也不卡。

4.2 优缺点

  • 优点:速度快(一般能快2-4倍)、体积小(能缩到原来的1/4)、能耗低(处理小数字耗电少);
  • 缺点:会损失精度(如果处理不好,精度掉得厉害)、需要额外的校准步骤(比直接转模型麻烦)。

4.3 注意事项

  • 校准集一定要和真实场景一致:这是量化后精度能不能保住的关键;
  • 预处理一定要和训练时一致:哪怕差一点,都可能导致量化后精度掉;
  • 不要盲目追求全INT8:如果某一层的精度损失太大,就改成FP16,混合精度量化是很常用的方案;
  • 校准缓存文件要妥善保存:第一次校准会花不少时间,之后直接用缓存文件可以省时间。

五、总结

模型量化后精度掉,本质上是“数字范围摸不准”和“层的误差没控制好”两个问题。只要做好校准集(和真实场景一致、预处理一致),再学会逐层找误差,大部分精度掉的问题都能解决。

最后给大家一个小建议:如果是第一次做量化,不要一开始就追求全INT8,可以先试试混合精度量化,先保证精度,再慢慢优化速度。