ONNX/TensorRT部署实测:延迟降85%的完整方案
发布日期: 2026/08/09 阅读总量: 1

从一次线上事故说起

去年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)18742680
ONNX Runtime (CPU)68156520
ONNX Runtime (GPU, fp32)9620330
TensorRT (GPU, fp16)3.82100280
TorchScript (GPU, fp16)5.2950340

核心结论:CPU部署选ONNX Runtime,GPU部署选TensorRT。ONNX Runtime GPU版本性能不如TensorRT,但胜在部署简单、无需转换引擎文件、CPU/GPU切换只改一个参数。

选型决策表

场景推荐方案理由
CPU推理,追求部署简单ONNX Runtime CPU EPpip安装即用,跨平台,无NVIDIA依赖
GPU推理,追求极致性能TensorRT层融合+精度校准,比ONNX Runtime GPU快2-3倍
多模型管理/动态batchTriton 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 CPUONNX CPUONNX GPUTensorRT GPU
单次推理平均耗时(ms)42.615.23.11.2
P99延迟(ms)187.368.49.23.8
最大吞吐(QPS)421566202100
CPU/GPU利用率(峰值)92%78%58%71%
内存占用(MB)680520330280
模型文件大小(MB)420418418210

和原始TorchScript CPU方案比:ONNX Runtime CPU延迟降64%,吞吐提升3.7倍;TensorRT GPU延迟降98%,吞吐提升50倍。TensorRT的引擎文件通过FP16量化,体积也缩小了一半。

为什么TensorRT的batch越大优势越明显

我们测了不同batch size下TensorRT和ONNX GPU的吞吐对比:

Batch SizeONNX GPU (ms)TensorRT (ms)加速比
13.11.22.6x
47.82.13.7x
813.53.44.0x
1625.25.84.3x
3248.710.64.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的minoptmax三组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就能复现本文的全部数据。