从一次线上事故说起
去年11月,我们一个智能客服语义匹配服务上线。模型是BERT-base微调,PyTorch 1.13训练,直接拿TorchScript部署在2台8核16G的CPU机器上。压测结果:P99延迟187ms,吞吐量只有42 QPS。业务方要求P99小于100ms,差了快一倍。
资源加了一倍,延迟降到152ms,还是不够。而且每台机器CPU跑满,GC频繁。后来把TorchScript换成了ONNX Runtime,同样的机器,P99直接降到68ms。再后来加了1张T4 GPU,用TensorRT跑,P99稳定在23ms。
这篇文章记录我在这类部署优化项目里踩过的坑和验证过的方法。完整流程:PyTorch → ONNX → ONNX Runtime / TensorRT → Triton Serving。
部署瓶颈分析:到底慢在哪
PyTorch模型推理慢,通常有三个原因:
| 原因 | 说明 |
|---|---|
| Python解释器开销 | 每次forward调用都有GIL锁、Python对象创建销毁 |
| 动态图调度 | PyTorch动态图每次执行都需要重新构建计算图 |
| 算子粒度太细 | 小算子逐个调用,kernel launch开销占比高 |
# 用 PyTorch Profiler 看耗时分布
torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU,
torch.profiler.ProfilerActivity.CUDA
],
on_trace_ready=torch.profiler.tensorboard_trace_handler('./log')
) as prof:
outputs = model(input_ids, attention_mask)
print(prof.key_averages().table(sort_by="cuda_time_total"))
方案对比:ONNX Runtime vs TensorRT vs TorchScript
我们当时对比了三种部署方案,测试环境如下:
- CPU:Intel Xeon Gold 6266C @ 3.0GHz,8核16线程
- GPU:NVIDIA T4 16G,驱动版本470.82.01
- 软件:PyTorch 1.13.1,ONNX Runtime 1.15.1,TensorRT 8.5.3.1
- 模型:BERT-base-uncased微调,序列长度128,batch size=1
# 环境信息确认命令
python -c "import torch; print(torch.__version__)"
python -c "import onnxruntime; print(onnxruntime.__version__)"
/usr/src/tensorrt/bin/trtexec --version
nvidia-smi --query-gpu=name,driver_version,memory.total --format=csv
延迟和吞吐压测结果
| 方案 | P99延迟(ms) | 吞吐(QPS) | 内存占用(MB) | 部署复杂度 |
|---|---|---|---|---|
| TorchScript (CPU) | 187 | 42 | 680 | 低 |
| ONNX Runtime (CPU) | 68 | 156 | 520 | 低 |
| ONNX Runtime (GPU, fp32) | 9 | 620 | 330 | 低 |
| TensorRT (GPU, fp16) | 3.8 | 2100 | 280 | 中 |
| TorchScript (GPU, fp16) | 5.2 | 950 | 340 | 低 |
核心结论:CPU部署选ONNX Runtime,GPU部署选TensorRT。ONNX Runtime GPU版本性能不如TensorRT,但胜在部署简单、无需转换引擎文件、CPU/GPU切换只改一个参数。
选型决策表
| 场景 | 推荐方案 | 理由 |
|---|---|---|
| CPU推理,追求部署简单 | ONNX Runtime CPU EP | pip安装即用,跨平台,无NVIDIA依赖 |
| GPU推理,追求极致性能 | TensorRT | 层融合+精度校准,比ONNX Runtime GPU快2-3倍 |
| 多模型管理/动态batch | Triton Serving | 自带动态batching、模型版本管理、并发控制 |
| 快速迭代、模型频繁更新 | ONNX Runtime | 无需构建引擎,ONNX文件直接跑 |
| 和现有K8s/云原生体系集成 | Triton Serving | 原生支持K8s、Prometheus监控、gRPC/RESTful |
完整实现:从PyTorch到生产环境
第一步:导出ONNX
PyTorch导出ONNX有几个细节要注意:opset_version选11+支持更多算子;dynamic_axes如果不设,模型就固定了batch size和序列长度;do_constant_folding开启能提前算好常量子图。
# export_onnx.py
import torch
from transformers import AutoModelForSequenceClassification, AutoTokenizer
# 模型路径,这里用的是微调后的模型
model_path = "./models/bert-base-intent/checkpoint-5000"
model = AutoModelForSequenceClassification.from_pretrained(model_path, torchscript=True)
model.eval()
tokenizer = AutoTokenizer.from_pretrained("bert-base-uncased")
# 构造示例输入,这里的维度必须和实际推理时一致
dummy_input = tokenizer(
"how to pay my bill",
return_tensors="pt",
padding="max_length",
truncation=True,
max_length=128
)
# 导出ONNX,关键是dynamic_axes
torch.onnx.export(
model,
(dummy_input["input_ids"], dummy_input["attention_mask"]),
"./models/bert-intent.onnx",
input_names=["input_ids", "attention_mask"],
output_names=["logits"],
dynamic_axes={
"input_ids": {0: "batch_size", 1: "seq_len"},
"attention_mask": {0: "batch_size", 1: "seq_len"},
"logits": {0: "batch_size"}
},
opset_version=14,
do_constant_folding=True,
verbose=False
)
print("ONNX exported to ./models/bert-intent.onnx")
第二步:用ONNX Runtime推理
ONNX Runtime的API设计很简洁,初始化一个InferenceSession,然后run()就行。CPU和GPU切换只需要改providers参数。
# onnx_inference.py
import numpy as np
import onnxruntime as ort
import time
class ONNXPredictor:
def __init__(self, onnx_path, use_gpu=False):
self.session = ort.InferenceSession(
onnx_path,
providers=[
"CUDAExecutionProvider" if use_gpu else "CPUExecutionProvider",
"CPUExecutionProvider"
]
)
# 打印实际使用的provider,确认GPU是否生效
print("Using providers:", self.session.get_providers())
self.input_names = [inp.name for inp in self.session.get_inputs()]
self.output_names = [out.name for out in self.session.get_outputs()]
def predict(self, input_ids, attention_mask):
inputs = {
"input_ids": input_ids.astype(np.int64),
"attention_mask": attention_mask.astype(np.int64)
}
start = time.perf_counter()
outputs = self.session.run(self.output_names, inputs)
infer_ms = (time.perf_counter() - start) * 1000
return outputs[0], infer_ms
# 使用示例
if __name__ == "__main__":
import onnxruntime as ort
predictor = ONNXPredictor("./models/bert-intent.onnx", use_gpu=True)
# 构造输入
input_ids = np.ones((1, 128), dtype=np.int64)
attention_mask = np.ones((1, 128), dtype=np.int64)
logits, latency = predictor.predict(input_ids, attention_mask)
print(f"Latency: {latency:.2f}ms")
print(f"Logits shape: {logits.shape}")
第三步:转换为TensorRT引擎
TensorRT转换有两种方式:trtexec命令行工具和Python API。trtexec适合快速验证,Python API适合嵌入自动化流程。
# 用trtexec转ONNX为TensorRT引擎
# 关键参数:--fp16开启半精度,--maxBatchSize设batch上限
# 注意:这里生成的engine文件和GPU型号绑定,换机器需要重新生成
/usr/src/tensorrt/bin/trtexec \
--onnx=./models/bert-intent.onnx \
--saveEngine=./models/bert-intent-fp16.engine \
--fp16 \
--maxBatchSize=32 \
--workspace=1024 \
--verbose
# convert_trt.py
import tensorrt as trt
TRT_LOGGER = trt.Logger(trt.Logger.WARNING)
def build_engine(onnx_path, engine_path, fp16=True):
"""ONNX转TensorRT engine"""
builder = trt.Builder(TRT_LOGGER)
network = builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(network, TRT_LOGGER)
with open(onnx_path, "rb") as f:
if not parser.parse(f.read()):
print("Failed to parse ONNX file")
for i in range(parser.num_errors):
print(parser.get_error(i))
return None
config = builder.create_builder_config()
config.max_workspace_size = 1 << 30 # 1GB
if fp16:
config.set_flag(trt.BuilderFlag.FP16)
# 动态shape需要设置优化配置文件
profile = builder.create_optimization_profile()
profile.set_shape("input_ids", (1, 64), (1, 128), (32, 256))
profile.set_shape("attention_mask", (1, 64), (1, 128), (32, 256))
config.add_optimization_profile(profile)
engine = builder.build_engine(network, config)
if engine:
with open(engine_path, "wb") as f:
f.write(engine.serialize())
print(f"Engine saved to {engine_path}")
return engine
if __name__ == "__main__":
build_engine(
onnx_path="./models/bert-intent.onnx",
engine_path="./models/bert-intent-fp16.engine",
fp16=True
)
第四步:TensorRT Python推理
# trt_inference.py
import numpy as np
import tensorrt as trt
import pycuda.driver as cuda
import pycuda.autoinit
import time
class TRTPredictor:
def __init__(self, engine_path):
self.logger = trt.Logger(trt.Logger.WARNING)
self.runtime = trt.Runtime(self.logger)
with open(engine_path, "rb") as f:
self.engine = self.runtime.deserialize_cuda_engine(f.read())
self.context = self.engine.create_execution_context()
# 分配显存
self.allocate_buffers()
def allocate_buffers(self):
self.inputs = []
self.outputs = []
self.bindings = []
for binding in self.engine:
shape = self.engine.get_binding_shape(binding)
size = trt.volume(shape)
dtype = trt.nptype(self.engine.get_binding_dtype(binding))
host_mem = cuda.pagelocked_empty(size, dtype)
device_mem = cuda.mem_alloc(host_mem.nbytes)
self.bindings.append(int(device_mem))
if self.engine.binding_is_input(binding):
self.inputs.append((host_mem, device_mem))
else:
self.outputs.append((host_mem, device_mem))
def infer(self, input_ids, attention_mask):
# 拷贝输入到显存
np.copyto(self.inputs[0][0], input_ids.ravel())
np.copyto(self.inputs[1][0], attention_mask.ravel())
cuda.memcpy_htod(self.inputs[0][1], self.inputs[0][0])
cuda.memcpy_htod(self.inputs[1][1], self.inputs[1][0])
start = time.perf_counter()
self.context.execute_v2(bindings=self.bindings)
cuda.Context.synchronize()
infer_ms = (time.perf_counter() - start) * 1000
# 拷贝输出回内存
cuda.memcpy_dtoh(self.outputs[0][0], self.outputs[0][1])
logits = self.outputs[0][0].copy().reshape(1, -1)
return logits, infer_ms
if __name__ == "__main__":
predictor = TRTPredictor("./models/bert-intent-fp16.engine")
input_ids = np.ones((1, 128), dtype=np.int32)
attention_mask = np.ones((1, 128), dtype=np.int32)
logits, latency = predictor.infer(input_ids, attention_mask)
print(f"TensorRT latency: {latency:.2f}ms")
第五步:Triton Serving部署
Triton Serving的模型仓库有固定的目录结构,每个模型一个文件夹,里面放版本号和配置文件。配置模板如下:
# model_repository/bert_intent/1/model.onnx (实际文件)
# 把ONNX文件放在版本号目录下
# model_repository/bert_intent/config.pbtxt
name: "bert_intent"
platform: "onnxruntime_onnx"
max_batch_size: 32
input [
{
name: "input_ids"
data_type: TYPE_INT64
dims: [-1, 128]
},
{
name: "attention_mask"
data_type: TYPE_INT64
dims: [-1, 128]
}
]
output [
{
name: "logits"
data_type: TYPE_FP32
dims: [-1, 2]
}
]
dynamic_batching {
preferred_batch_size: [4, 8, 16]
max_queue_delay_microseconds: 100
}
# 启动Triton服务
# 用GPU跑需要加 --gpus 1,拉取镜像的时候注意版本
docker pull nvcr.io/nvidia/tritonserver:23.05-py3
# 挂载模型仓库并启动
docker run --gpus=1 --rm -p 8000:8000 -p 8001:8001 -p 8002:8002 \
-v /data/model_repository:/models \
nvcr.io/nvidia/tritonserver:23.05-py3 \
tritonserver --model-repository=/models \
--model-control-mode=poll \
--metrics-interval-ms=5000
Triton客户端调用
# triton_client.py
import numpy as np
import tritonclient.http as httpclient
import time
client = httpclient.InferenceServerClient(url="localhost:8000")
# 确认模型状态
print(client.get_model_repository_index())
input_ids = np.ones((1, 128), dtype=np.int64)
attention_mask = np.ones((1, 128), dtype=np.int64)
inputs = [
httpclient.InferInput("input_ids", input_ids.shape, "INT64"),
httpclient.InferInput("attention_mask", attention_mask.shape, "INT64")
]
inputs[0].set_data_from_numpy(input_ids)
inputs[1].set_data_from_numpy(attention_mask)
outputs = [httpclient.InferRequestedOutput("logits")]
# 压测100次取平均
latencies = []
for _ in range(100):
start = time.perf_counter()
result = client.infer("bert_intent", inputs=inputs, outputs=outputs)
latencies.append((time.perf_counter() - start) * 1000)
logits = result.as_numpy("logits")
print(f"Average latency: {np.mean(latencies):.2f}ms")
print(f"P99 latency: {np.percentile(latencies, 99):.2f}ms")
print(f"Logits: {logits}")
效果数据汇总
我们把同一套BERT模型分别用四种方案部署在相同规格的机器上(CPU方案用8核16线程,GPU方案用T4),压测工具用Locust模拟并发请求,压测时长10分钟,结果如下:
| 指标 | TorchScript CPU | ONNX CPU | ONNX GPU | TensorRT GPU |
|---|---|---|---|---|
| 单次推理平均耗时(ms) | 42.6 | 15.2 | 3.1 | 1.2 |
| P99延迟(ms) | 187.3 | 68.4 | 9.2 | 3.8 |
| 最大吞吐(QPS) | 42 | 156 | 620 | 2100 |
| CPU/GPU利用率(峰值) | 92% | 78% | 58% | 71% |
| 内存占用(MB) | 680 | 520 | 330 | 280 |
| 模型文件大小(MB) | 420 | 418 | 418 | 210 |
和原始TorchScript CPU方案比:ONNX Runtime CPU延迟降64%,吞吐提升3.7倍;TensorRT GPU延迟降98%,吞吐提升50倍。TensorRT的引擎文件通过FP16量化,体积也缩小了一半。
为什么TensorRT的batch越大优势越明显
我们测了不同batch size下TensorRT和ONNX GPU的吞吐对比:
| Batch Size | ONNX GPU (ms) | TensorRT (ms) | 加速比 |
|---|---|---|---|
| 1 | 3.1 | 1.2 | 2.6x |
| 4 | 7.8 | 2.1 | 3.7x |
| 8 | 13.5 | 3.4 | 4.0x |
| 16 | 25.2 | 5.8 | 4.3x |
| 32 | 48.7 | 10.6 | 4.6x |
原因在于TensorRT做了层融合。Transformer里有大量连续的小算子(Add+Bias+GeLU+LayerNorm),TensorRT把它们融合成单个kernel,减少了kernel launch次数和显存读写。batch越大,融合带来的收益越明显。
源码级原理:ONNX和TensorRT为什么快
ONNX Runtime快在哪
ONNX Runtime的核心优化是计算图优化。它拿到ONNX图后会做三件事:
- 算子融合:把小算子合并成一个大算子。比如把Conv + BatchNorm + ReLU融合成一个ConvBNRelu
- 常量折叠:提前把不需要输入的常量计算好
- 内存规划:通过分析每个tensor的生命周期,复用内存块,减少内存分配次数
此外ONNX Runtime用C++实现了算子内核,避免了Python层调度开销。PyTorch的Eager模式,一次forward要经过Python的__call__ → forward → 每个Module的__call__,每一步都有Python虚拟机的开销。ONNX Runtime把整张图编译成一个执行计划,直接在C++层顺序执行。
TensorRT快在哪
TensorRT比ONNX Runtime GPU快2-3倍,核心是三个技术:
- 层融合(Layer Fusion):把相邻的kernel融合成单个kernel。例如:卷积+偏置+ReLU融合成一个kernel,减少了kernel启动开销和中间结果的显存读写
- 精度校准(Precision Calibration):通过FP16或INT8量化。FP16在T4等GPU上有专门的Tensor Core加速,混合精度训练已经成为标配,但推理时直接用FP16往往能获得2倍以上加速
- 内核自动调优(Kernel Autotuning):TensorRT对每个算子会尝试多种kernel实现,在目标GPU上实际运行,选择最快的那个。专门针对你的GPU架构做优化
T4是我们用的GPU,TensorRT针对T4的Tensor Core做了深度优化。如果是V100、A100、L4,各自的最优kernel都不一样。这也是TensorRT引擎文件绑定了GPU型号的原因。
避坑指南
这四个月里踩了很多坑,挑了最影响的几个。
坑一:动态轴设置不当导致推理崩溃
第一次导ONNX忘了设dynamic_axes,模型被固定为batch_size=1。线上来了2条请求就直接报错。设了dynamic_axes之后,又在TensorRT的optimization profile里忘了设置对应的shape范围,导致在batch_size=16时显存溢出。
解决:dynamic_axes必须和TensorRT的set_shape配合。优化profile的min、opt、max三组shape要覆盖实际推理的所有情况。opt的值要和实际batch size接近,否则性能会打折扣。
坑二:ONNX算子支持不全,导出失败
模型里有自定义的focal loss层,导出ONNX时提示Unsupported operator: ATen::one_hot。后来发现是PyTorch 1.13和opset 14的兼容性问题。
解决:把opset_version升到15,或者手动把自定义层改用ONNX支持的算子重写。尽量别在部署模型里用太新的PyTorch算子。建议写完模型先跑一次导出脚本,提前发现问题。
坑三:TensorRT的FP16精度下降
模型在FP16下F1分数从0.912掉到0.895,对语义匹配场景影响不大。但之前有个金融场景的模型,FP16直接掉到0.87,业务方不接受。
解决:先跑FP16,用验证集测一下指标。如果掉点严重,改用INT8量化并做校准。TensorRT的INT8需要提供一个校准数据集,在构建引擎时统计激活值分布,能降低精度损失。或者退一步,用ONNX Runtime GPU的FP16,精度和速度的折中方案。
坑四:onnxruntime-gpu的CUDA版本不匹配
onnxruntime-gpu 1.15.1要求CUDA 11.8,但机器上装的是CUDA 12.0,导致运行时报错DLL load failed: The specified module could not be found。排查了半天,查了环境变量、权限、lib路径,最后发现是onnxruntime和CUDA版本不匹配。
解决:用pip list | grep onnxruntime确认版本,然后查官方文档的版本兼容性表格:onnxruntime-gpu 1.x和CUDA的对应关系。强烈建议用conda管理环境,直接创建指定CUDA版本的虚拟环境,或者用Docker镜像,把CUDA版本锁死。
坑五:Triton的dynamic batching配置参数调优
Triton默认的dynamic batching会等几毫秒攒batch,如果max_queue_delay_microseconds设太大,P99延迟会飙升;设太小又攒不够batch,吞吐上不去。
我们压测发现:max_queue_delay_microseconds=100时P99是23ms;改成500,P99涨到55ms;改成50,吞吐从2100掉到1800。这个参数必须根据实际业务流量和延迟要求来调,没有通用的推荐值。
另外要注意,Triton对每个模型默认是独立的实例,如果多个模型共用一个GPU,需要统计显存占用。我们有个模型把T4的16G显存吃满了,导致其他模型OOM。解决:instance_group配置里限制count或者gpu字段。
最终方案和收益
最后我们的生产环境用了这样的组合:
- CPU机器跑一个ONNX Runtime做影子流量验证和回退兜底
- GPU机器跑Triton + TensorRT引擎提供服务
- Triton开启dynamic batching,
max_queue_delay_microseconds设为100 - 模型版本管理:新增模型先发到影子环境跑两三天,确认稳定再切流量
上线后,原来2台8核CPU的机器缩到1台4核CPU(跑影子验证)加1台T4 GPU(跑正式流量),成本反而降了30%左右。P99从187ms降到23ms,完全满足业务方要求。
部署方案总结
| 环节 | 推荐做法 |
|---|---|
| 导出 | PyTorch 2.0+,opset_version=15,打开constant folding |
| CPU部署 | ONNX Runtime,官方文档配置好线程数和算子 |
| GPU部署 | TensorRT,FP16优先,必要时INT8+校准 |
| 服务框架 | Triton Serving,自带dynamic batching、模型仓库和监控 |
| 兜底方案 | CPU的ONNX Runtime + 熔断降级,防止GPU故障 |
不是所有模型都需要这套流程。模型单次推理在CPU上已经小于10ms了,直接用Flask+FasterTransformer够了。上了这套方案反而增加复杂度和维护成本。优化是为了解决痛点,不是赶时髦。
模型文件、完整代码和压测脚本我放在GitHub仓库 deploy_bench/ 目录下,直接运行python run_benchmark.py就能复现本文的全部数据。