先说问题: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
其中 scale 和 zero_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 推理(基线) | 180ms | 420MB | 1.8GB | 0.912(基线) |
| B. ONNX Runtime FP32 | 92ms | 420MB | 1.1GB | 0.911 |
| C. ONNX Runtime INT8 动态量化 | 71ms | 168MB | 680MB | 0.905 |
| D. OpenVINO INT8 静态量化 | 8ms | 128MB | 460MB | 0.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 CPU | ONNX Runtime FP32 | OpenVINO INT8 |
|---|---|---|---|
| P50 延迟 | 180ms | 92ms | 8ms |
| P99 延迟 | 362ms | 141ms | 19ms |
| 吞吐量(单 worker) | 5.3 req/s | 10.8 req/s | 96 req/s |
| 模型大小 | 420MB | 420MB | 128MB |
| 内存峰值 | 1.8GB | 1.1GB | 460MB |
| F1 分数(1000条测试集) | 0.912 | 0.911 | 0.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,那量化带来的收益会被框架本身的调度开销吃掉一大半,建议换框架优先于换硬件。