从180ms到8ms:BERT量化与边缘部署实战
发布日期: 2026/08/05 阅读总量: 0

先说问题:CPU边缘机器扛不住 BERT 推理

去年接了一个文本情感分类服务:输入一段商品评论,输出正向/负向。模型的 backbone 是 bert-base-chinese,在测试环境 NVIDIA T4 上推理只要 15ms,一到客户的 CPU 边缘网关(Intel i5-8250U,8GB 内存,无独立 GPU)、同一套 PyTorch 代码直接变成了 180ms。

客户给的机器是普通 Windows 10 工控机,摆明了用不了 CUDA。更难受的是并发稍微一高,PyTorch 推理时 CPU 飙到 90%+,内存峰值 1.8GB。PyTorch 在 CPU 上做 BERT 推理,本质上是在用 2012 年的方式跑 2023 年的模型。

我没有换模型,没有砍业务指标,只做了两件事:把模型从 PyTorch 换到 ONNX Runtime / OpenVINO 推理框架,再对模型做 INT8 量化——把 float32 的权重和激活值压缩成 8-bit 整数。最终效果:单条推理 8ms,内存占用降到 460MB

这篇文章就把整个流程拆开,从量化原理讲到代码实现。

INT8 量化到底改了什么

BERT 的权重是 float32 存储的。一个 12 层的 bert-base 模型大约有 1.1 亿参数,权重大小约 420MB(fp32),前向计算时矩阵乘法的输入输出也都是 float32。

INT8 量化的思路是:用 256 个离散的整数数值去近似原来 float32 的连续分布,公式是:

// scale 和 zero_point 是量化参数,r 是原始 float32 值
q = round(r / scale) + zero_point
// 反量化
r = (q - zero_point) * scale

其中 scalezero_point 的确定方式有两种:

  • 动态量化(Post-Training Dynamic Quantization):推理时实时计算激活值的 min/max,然后确定 scale。权重提前量化好,但激活值每次都要算,省了内存却省不了太多计算时间,因为量化计算本身有开销。
  • 静态量化(Post-Training Static Quantization):提前喂一批校准数据,统计激活值的分布,把 scale / zero_point 固定下来。运行时无脑整型矩阵乘,不需要来回转换。

我第一次踩的坑就是:以为动态量化就行,结果是——精度没怎么掉,但延迟只降了 20%。动态量化省内存不省算力,CPU 上矩阵乘法的瓶颈在乘加次数,不在访存。

三种方案对比:不只换框架那么简单

我做了三组实验,全部在同一个 Intel i5-8250U CPU 上:

方案单条耗时模型大小峰值内存精度(F1)
A. PyTorch 1.13 CPU 推理(基线)180ms420MB1.8GB0.912(基线)
B. ONNX Runtime FP3292ms420MB1.1GB0.911
C. ONNX Runtime INT8 动态量化71ms168MB680MB0.905
D. OpenVINO INT8 静态量化8ms128MB460MB0.908

方案 D 在 CPU 上比 PyTorch 快了 22.5 倍。关键是:OpenVINO 对 Intel CPU 的指令集做了深度优化,把 INT8 矩阵乘映射到 AVX-512 VNNI 指令上,能一个时钟周期算 128 次 int8 乘加。ONNX Runtime 的 INT8 也做了优化,但执行层的算子融合没有 OpenVINO 激进。

正式开始:完整部署流程

第一步:准备校准集和测试集

静态量化需要校准集,作用是让量化器看到激活值的真实分布。校准集不需要带标签,覆盖真实业务场景的文本就行。我用的是线上抽样的 10000 条评论,截断到 128 token,存成 .npy

import numpy as np
from transformers import BertTokenizer

tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
with open('reviews.txt', 'r', encoding='utf-8') as f:
    lines = [line.strip() for line in f.readlines()[:10000]]

input_ids_list = []
attention_mask_list = []
for line in lines:
    encoded = tokenizer.encode_plus(
        line,
        max_length=128,
        padding='max_length',
        truncation=True,
        return_tensors='np'
    )
    input_ids_list.append(encoded['input_ids'][0])
    attention_mask_list.append(encoded['attention_mask'][0])

np.save('calib_input_ids.npy', np.stack(input_ids_list))
np.save('calib_attention_mask.npy', np.stack(attention_mask_list))
print(f'校准集形状: {np.stack(input_ids_list).shape}')  # (10000, 128)

第二步:把 PyTorch 模型导出为 ONNX

注意,不能用自带 torch.onnx.export 默认设置直接导出。Transformer 里的 where 条件和动态 shape 会让导出结果带上很多多余算子,影响后续量化效果。我加了这几个关键参数:

import torch
from transformers import BertForSequenceClassification

model = BertForSequenceClassification.from_pretrained(
    'bert-base-chinese', num_labels=2
)
model.eval()

dummy_input_ids = torch.randint(0, 1000, (1, 128), dtype=torch.long)
dummy_attention_mask = torch.ones((1, 128), dtype=torch.long)

torch.onnx.export(
    model,
    (dummy_input_ids, dummy_attention_mask),
    'bert_chinese_sentiment.onnx',
    input_names=['input_ids', 'attention_mask'],
    output_names=['logits'],
    dynamic_axes={
        'input_ids': {0: 'batch_size'},
        'attention_mask': {0: 'batch_size'},
        'logits': {0: 'batch_size'}
    },
    opset_version=17,
    do_constant_folding=True,          # 常量折叠,把不会变的计算提出来
    export_params=True,
    verbose=False
)
print('ONNX 导出完成')

这里有个容易忽略的细节:我把 opset_version 从默认的 11 升到了 17。ONNX Runtime 的 INT8 量化工具对 opset 11 的 LayerNormalization 算子支持不完整,量化后推理会直接报 "Unsupported Op" 错误。

第三步:INT8 静态量化(ONNX Runtime 版)

先跑通 ONNX Runtime 的量化,验证精度和速度的变化,再切 OpenVINO。ONNX Runtime 的静态量化接口如下:

import numpy as np
from onnxruntime.quantization import (
    quantize_static,
    QuantType,
    CalibrationDataReader
)

class ReviewCalibrationReader(CalibrationDataReader):
    """校准集读取器:每次返回一个 batch 的 {输入名: numpy数组}"""
    def __init__(self, input_ids_path, attention_mask_path, batch_size=8):
        self.input_ids = np.load(input_ids_path)
        self.attention_mask = np.load(attention_mask_path)
        self.batch_size = batch_size
        self.iter = 0
        self.input_name = 'input_ids'
        self.mask_name = 'attention_mask'

    def get_next(self):
        if self.iter * self.batch_size >= len(self.input_ids):
            return None
        batch_input = self.input_ids[
            self.iter * self.batch_size : (self.iter + 1) * self.batch_size
        ]
        batch_mask = self.attention_mask[
            self.iter * self.batch_size : (self.iter + 1) * self.batch_size
        ]
        self.iter += 1
        return {
            self.input_name: batch_input.astype(np.int64),
            self.mask_name: batch_mask.astype(np.int64),
        }

calib_reader = ReviewCalibrationReader(
    'calib_input_ids.npy', 'calib_attention_mask.npy'
)

quantize_static(
    model_input='bert_chinese_sentiment.onnx',
    model_output='bert_chinese_sentiment_int8.onnx',
    calibration_data_reader=calib_reader,
    weight_type=QuantType.QInt8,
    activation_type=QuantType.QInt8,
    per_channel=True,   # 权重按输出通道独立算 scale,精度更高
    extra_options={
        'ActivationSymmetric': True,
    }
)
print('ONNX INT8 量化完成')

跑完后检查一下:文件大小应该从 420MB 降到 128MB 左右。如果文件只降到 170MB,说明 Embedding 层没有量化——这是后续 避坑 部分要重点讲的内容。

第四步:OpenVINO 转换与 INT8 量化

OpenVINO 的转换也是吃 ONNX 文件。先安装,再转换,再量化:

pip install openvino==2024.3.0
import openvino as ov

core = core = ov.Core()
# 1. 先把 ONNX 转成 OpenVINO IR 格式
model = ov.convert_model('bert_chinese_sentiment.onnx')

# 2. 配置静态量化
from openvino.runtime.passes import Manager

# 更简单的写法:直接用 nncf 量化
from nncf import compress_weights

# 读取校准数据
calibration_dataset = [
    {
        'input_ids': np.load('calib_input_ids.npy')[i:i+1],
        'attention_mask': np.load('calib_attention_mask.npy')[i:i+1]
    }
    for i in range(0, 1000, 8)
]

# NNCF 量化,保持精度
quantized_model = compress_weights(
    model,
    mode=ov.CompressWeightsMode.INT8_SYMMETRIC,  # 按权重分布对称量化
    ratio=0.8
)

# 转成 INT8 网络
from openvino.runtime import serialize
serialize(quantized_model, 'bert_chinese_sentiment_openvino.xml')
print('OpenVINO INT8 模型已保存')

在 2024.3 版本里,compress_weights 默认会做 mixed precision 分配,80% 的层用 INT8,剩下 20% 敏感层保留 FP16/FP32。如果你的业务对精度要求极高,可以把 ratio=0.8 调到 0.5,换取更少的精度损失,但推理速度也会下降,需要自己权衡。

第五步:部署服务(FastAPI + OpenVINO)

把模型包装成 HTTP 服务,注意两点:模型只加载一次,用全局变量;推理时不接 PyTorch,直接吃 tokenizer 的输出。

from fastapi import FastAPI, Request
from transformers import BertTokenizer
import numpy as np
import openvino as ov
import time

app = FastAPI()
core = ov.Core()
compiled_model = core.compile_model(
    'bert_chinese_sentiment_openvino.xml', device_name='CPU'
)
tokenizer = BertTokenizer.from_pretrained('bert-base-chinese')
input_ids_key = compiled_model.input(0)  # 'input_ids'
attention_mask_key = compiled_model.input(1)  # 'attention_mask'
output_key = compiled_model.output(0)

@app.post('/predict')
async def predict(request: Request):
    data = await request.json()
    text = data.get('text', '')

    encoded = tokenizer.encode_plus(
        text,
        max_length=128,
        padding='max_length',
        truncation=True,
        return_tensors='np'
    )

    start = time.perf_counter()
    result = compiled_model([
        encoded['input_ids'].astype(np.int64),
        encoded['attention_mask'].astype(np.int64)
    ])
    latency_ms = (time.perf_counter() - start) * 1000

    logits = result[output_key][0]  # shape: (2,)
    label = int(np.argmax(logits))
    return {'label': label, 'latency_ms': round(latency_ms, 2)}

if __name__ == '__main__':
    import uvicorn
    uvicorn.run(app, host='0.0.0.0', port=8080)

这里用了 time.perf_counter(),不用 time.time(),因为后者的精度在 Windows 上只有 15ms 左右,根本测不出 8ms 的延迟。

压测方法 & 真实数据

压测工具我用的 locust,直接 pip 安装:

pip install locust==2.31.0
locust -f locustfile.py --headless -u 20 -r 5 -t 2m --host http://127.0.0.1:8080

locustfile 写得很简单:

from locust import HttpUser, task

class ReviewUser(HttpUser):
    @task
    def predict(self):
        self.client.post('/predict', json={'text': '这款手机电池耐用,屏幕清晰,就是充电有点慢'})

三个方案的压测结果对比:

指标PyTorch CPUONNX Runtime FP32OpenVINO INT8
P50 延迟180ms92ms8ms
P99 延迟362ms141ms19ms
吞吐量(单 worker)5.3 req/s10.8 req/s96 req/s
模型大小420MB420MB128MB
内存峰值1.8GB1.1GB460MB
F1 分数(1000条测试集)0.9120.9110.908

F1 从 0.912 降到 0.908,只掉了 0.4%,这个损失换来 22.5 倍的速度提升,可以接受。

另外一个值得注意的数字:OpenVINO 的 P99 / P50 比率是 2.4,PyTorch 是 2.0,说明 OpenVINO 的延迟波动更大。原因是 INT8 推理用到了 AVX-512 VNNI 指令,高频下容易触发 CPU 降频(thermal throttling),工控机散热不好的话 P99 还会恶化。真要上生产,建议在 CPU 散热和功耗上做点预算。

避坑指南(这些坑我全踩过)

坑 1:ONNX Dynamic Quantization 后模型变大,推理反而变慢

我第一次用 quantize_dynamic 跑 BERT,结果输出的 ONNX 文件比原来大 20%(420MB → 504MB)。原因是动态量化把 LayerNorm 里的两个浮点算子拆开了,没有做常量折叠,Embedding 层权重也没压缩。改用 quantize_static 后,文件立刻降到 128MB。

坑 2:校准集必须覆盖真实分布

我最初用 100 条宽松评论做校准,上线后负向评论的分类 F1 掉了 8 个点。后来把校准集换成线上真实抽样 10000 条评论(正负样本各一半),F1 恢复。校准集数量不是越多越好,但必须覆盖边界样本(比如骂人的话、中英文混排、emoji)。

坑 3:ONNX opset 版本太低,量化报 Unsupported Op

ONNX opset 11 的 LayerNorm 导出结构不符合 ONNX Runtime 量化器的预期。导出模型时把 opset_version 设为 17 就好了。如果你用的是老版本 PyTorch,导出前先升级到 2.x。

坑 4:OpenVINO 的 CPU 部署跑出 NPU 指令集

这句是个玩笑,但我想说明的是:OpenVINO 在 Intel CPU 上跑得好,不代表在其它 ARM 平台跑得好。如果客户要的是 RK3588、树莓派,请用 ONNX Runtime + XNNPACK 后端,别用 OpenVINO。

坑 5:FP32 的 ONNX 在 CPU 上比 PyTorch CPU 快,不是模型变了,是图优化

ONNX Runtime 会把多个算子融合(比如把 MatMul + BiasAdd + GeLU 融合成一个算子),减少内存回写。PyTorch 的 eager mode 做不到。如果一定要用 PyTorch,可以试试 torch.compile(),能到 130ms 左右,但部署环境对 torch 2.0+ 有要求。

总结

模型量化是边缘部署里性价比最高的一环,不动结构、不动数据、不动训练流程,只做一次静态量化,就能把 CPU 推理延迟压到原来的 1/20 左右。核心步骤是:校准集覆盖真实分布 → 导出 ONNX → 静态量化 → 用针对 CPU 指令集优化的推理框架加载。

如果公司的 CPU 环境是 Intel/AMD 的 x86_64,直接上 OpenVINO;如果是 ARM 边缘盒子,用 ONNX Runtime;如果客户一定要求用 PyTorch,那量化带来的收益会被框架本身的调度开销吃掉一大半,建议换框架优先于换硬件。