模型推理慢是部署阶段最让人头疼的问题之一。即便训练出来的模型精度很高,如果在生产环境中每帧需要几百毫秒甚至更长,用户体验和系统吞吐都会受到严重影响。很多团队直接在 PyTorch 或 TensorFlow 中调用模型推理,结果 GPU 利用率常年徘徊在 30% 以下,延迟却居高不下。这并非显卡性能不够,而是推理路径没有经过针对性优化。TensorRT 可以在不改变模型精度的前提下,通过算子融合、低精度计算、内核自动调优等方式大幅降低前向耗时。而批处理则把多条样本合并成一次计算,让 GPU 中的数千个 CUDA 核心同时工作,进一步提升吞吐。本文围绕这两个方向展开,结合 Windows 平台说明具体操作。

TensorRT 加速的底层机制
TensorRT 并不是一个训练框架,而是一个运行在 NVIDIA GPU 上的推理优化器。它的优化手段主要集中在三个方面。第一种是层融合,也就是把模型中相邻的卷积、偏置、激活函数等算子合并成一个更大的计算内核。例如在常见卷积网络中,Conv2D、BatchNorm 和 ReLU 经常连续出现。训练框架会把它们拆成三个独立的算子依次执行,每次都涉及一次显存读写。TensorRT 可以把这三者融合成一个 CBR 模块,中间结果不再需要来回搬运,显存带宽占用明显下降。对于带宽受限的小模型,这一项优化往往就能带来 20% 到 40% 的延迟下降。
第二种是精度优化。TensorRT 支持 FP16 和 INT8 两种低精度推理方式。FP16 通常能在几乎不损失精度的情况下把计算速度提升一倍,因为 NVIDIA 显卡从 Volta 架构开始就提供了专门的 FP16 计算单元。INT8 则需要先做校准,TensorRT 会分析模型各层的数值范围,通过最小化量化误差来生成缩放因子。校准过程需要准备一批有代表性的样本数据,校准完成后,推理时的矩阵乘法和卷积都可以走 INT8 张量核心,速度可以达到 FP32 的三到四倍。对于容忍一定精度损失的场景,如视频监控中的目标检测,INT8 是非常划算的选择。
第三种是内核自动调优。同一个算子在不同输入形状、不同 GPU 架构下可能存在多种实现方式,TensorRT 会对候选内核逐一进行短时间基准测试,从中选出当前硬件上最快的那一个。这个调优过程发生在构建引擎阶段,一旦引擎序列化保存,后续推理就固定使用最优内核,不再引入额外开销。三种机制叠加起来,配合显式批处理设定,能显著缩短模型前向时间。
Windows 下 TensorRT 环境配置与引擎构建
在 Windows 上配置 TensorRT 需要先确认显卡驱动、CUDA 工具包和 cuDNN 的版本匹配关系。TensorRT 解压后通常放在独立目录,例如 C:\TensorRT\,其 lib 子目录中包含 nvinfer.dll、nvinfer_plugin.dll 等核心库。为了让 Python 接口正常工作,需要把 C:\TensorRT\lib 添加到系统环境变量 Path 中,确保进程能定位到这些动态链接库。同时,C:\TensorRT\python 下有对应的 whl 安装包,可以用 pip 直接安装。如果用 C++ 开发,则需要把 C:\TensorRT\include 加入附加包含目录,把 C:\TensorRT\lib 加入附加库目录。
构建引擎的典型路径是先把 PyTorch、TensorFlow 或其它框架的模型导出成 ONNX 格式,再由 TensorRT 解析 ONNX 并生成引擎。下面是一段 Python 示例,演示如何从 ONNX 文件构建 TensorRT 引擎并保存为 plan 文件。构建时需要指定最大 batch size 和工作空间大小,前者直接影响后续批处理的灵活性。
import tensorrt as trt
TRT_LOGGER = trt.Logger(trt.Logger.WARNING)
def build_engine(onnx_path, engine_path, max_batch_size=1):
# 创建 builder 和 network,显式设置 batch 维度
with trt.Builder(TRT_LOGGER) as builder, \
builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) as network, \
trt.OnnxParser(network, TRT_LOGGER) as parser:
builder.max_batch_size = max_batch_size
config = builder.create_builder_config()
config.max_workspace_size = 1 << 30 # 1GB 工作空间
with open(onnx_path, 'rb') as f:
if not parser.parse(f.read()):
for idx in range(parser.num_errors):
print(parser.get_error(idx))
return False
engine = builder.build_engine(network, config)
if engine is None:
return False
with open(engine_path, 'wb') as f:
f.write(engine.serialize())
return True
这段代码中,EXPLICIT_BATCH 标志表示网络会显式处理 batch 维度,而不是依赖 builder 的隐式设置。如果模型导出的 ONNX 已经包含动态 batch 维度,那么构建出来的引擎可以接受不同大小的 batch 输入。如果 ONNX 是固定 batch 的静态图,即使在 Python 中设置 max_batch_size 也无法改变输入形状,需要回到导出环节重新处理。引擎构建完成后,后续推理直接加载 plan 文件即可,不用再重复耗时的优化过程。
对于 C++ 开发者,构建流程类似,但需要手动管理一些资源。下面是一个极简的 C++ 推理入口,重点展示如何加载引擎并执行前向计算。需要注意的是代码中所有尖括号都经过转义,实际编写时直接写 < 和 > 即可。
#include <fstream>
#include <vector>
#include <NvInfer.h>
#include <cuda_runtime_api.h>
int main() {
char* trtModelStream = nullptr;
size_t size = 0;
std::ifstream file("model.plan", std::ios::binary);
if (file.good()) {
file.seekg(0, file.end);
size = file.tellg();
file.seekg(0, file.beg);
trtModelStream = new char[size];
file.read(trtModelStream, size);
file.close();
}
nvinfer1::IRuntime* runtime = nvinfer1::createInferRuntime(sample::gLogger);
nvinfer1::ICudaEngine* engine = runtime->deserializeCudaEngine(trtModelStream, size);
nvinfer1::IExecutionContext* context = engine->createExecutionContext();
// 后续分配输入输出缓冲区并执行 context->enqueueV2 即可
return 0;
}
Windows 下常见的一个坑是 DLL 加载失败。如果 Python 提示 ImportError 或找不到 nvinfer.dll,多半是 Path 环境变量设置后没有重启终端或 IDE。另一个问题是构建引擎时提示显存不足,这通常不是显卡显存太小,而是 max_workspace_size 设置过大,适当调低该值即可。建议从 512MB 开始逐步增加。
批处理策略:静态与动态的选择
批处理的核心目标是通过一次内核启动处理多条样本,摊薄固定开销并提高 GPU 计算单元利用率。TensorRT 支持两种批处理模式。静态批处理在构建引擎时固定 batch size,例如设置为 8,之后每次推理都必须传入 8 条样本。这种方式实现简单,对于输入数量稳定的服务(比如固定路数的视频流分析)非常合适。但如果客户端请求数量波动较大,静态批处理要么被迫填充无效数据,要么无法立即响应不足一批的请求,灵活性较差。
动态批处理则允许在推理时动态改变 batch 维度。它依赖 ONNX 模型中的动态轴,以及 TensorRT 引擎的优化配置文件。在构建引擎时,可以为输入指定一个最小、最常见和最大形状的组合,TensorRT 会针对这些形状生成多个优化内核。下面是一段设置动态输入形状的 Python 代码,它把 batch 维度设为从 1 到 16 可变,同时保持图像尺寸固定。
import tensorrt as trt
def build_dynamic_engine(onnx_path, engine_path):
with trt.Builder(TRT_LOGGER) as builder, \
builder.create_network(1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH)) as network, \
trt.OnnxParser(network, TRT_LOGGER) as parser:
config = builder.create_builder_config()
profile = builder.create_optimization_profile()
profile.set_shape('input', (1, 3, 224, 224), (4, 3, 224, 224), (16, 3, 224, 224))
config.add_optimization_profile(profile)
config.max_workspace_size = 1 << 30
with open(onnx_path, 'rb') as f:
parser.parse(f.read())
engine = builder.build_engine(network, config)
with open(engine_path, 'wb') as f:
f.write(engine.serialize())
执行动态批处理推理时,输入缓冲区的形状要与当前批次的实际样本数保持一致。下面示例展示了如何在运行时根据请求数量调整 batch 大小,并提交给 TensorRT 执行。注意内存分配必须显式复制到 GPU,且输入数据要按 NCHW 顺序排好。
import numpy as np
import pycuda.driver as cuda
import pycuda.autoinit
import tensorrt as trt
def infer_dynamic(engine, input_data):
# input_data 形状为 (batch, 3, 224, 224)
batch = input_data.shape[0]
context = engine.create_execution_context()
context.set_binding_shape(0, input_data.shape)
stream = cuda.Stream()
d_input = cuda.mem_alloc(input_data.nbytes)
d_output = cuda.mem_alloc(batch * 1000 * np.float32().nbytes)
cuda.memcpy_htod_async(d_input, np.ascontiguousarray(input_data), stream)
bindings = [int(d_input), int(d_output)]
context.execute_async_v2(bindings=bindings, stream_handle=stream.handle)
output = np.empty((batch, 1000), dtype=np.float32)
cuda.memcpy_dtoh_async(output, d_output, stream)
stream.synchronize()
return output
选择静态还是动态批处理,主要看业务形态。如果请求可以被服务端聚合成固定大小的队列,静态批处理能获得更稳定的峰值性能。如果需要低延迟实时响应,动态批处理配合请求合并等待时间(例如等待 2 毫秒再处理当前积攒的所有请求)是更常见的选择。也可以在客户端和服务端分别做一层小批量缓存,把并发请求尽量凑成较大 batch,进一步提高吞吐。
性能对比与调优建议
为了直观理解批处理带来的收益,可以做一个简单实验。以 ResNet-50 为例,在 RTX 3060 上分别使用 batch size 1、4、8、16 进行推理,记录单帧平均延迟和每秒处理帧数。下表展示了典型结果。batch 较小时延迟低但吞吐受限,因为 GPU 每个 SM 都吃不饱;batch 增大后总延迟上升,但摊到每帧的延迟反而下降,吞吐大幅提高。
| Batch Size | 平均延迟(ms) | 吞吐(frames/s) |
|---|---|---|
| 1 | 2.3 | 435 |
| 4 | 5.1 | 784 |
| 8 | 8.7 | 920 |
| 16 | 15.6 | 1025 |
从表中可以看出,batch size 从 1 增加到 4 时吞吐几乎翻倍,这是最值得优化的区间。继续增加到 16 时提升幅度变小,而且单帧延迟已经接近 16 毫秒,对于某些实时交互场景可能不可接受。因此需要根据自身业务对延迟和吞吐的容忍度找到平衡点。如果单帧延迟要求小于 10 毫秒,batch size 最好控制在 4 以下。
调优时还有几个实用技巧。第一是尽量使用 CUDA 流进行异步推理,让数据拷贝和计算重叠执行,减少 CPU 等待时间。第二是复用执行上下文和显存缓冲区,避免每次推理都重新分配内存。第三是如果模型中有大量逐元素操作,可以尝试启用 TensorRT 的 layer fusion 选项,让更多算子在构建阶段就被合并。第四是关注输入数据是否为连续内存布局,非连续的 numpy 数组会触发额外拷贝,影响整体吞吐。最后,在 Windows 上可以通过任务管理器或 Nsight Systems 观察 GPU 利用率,如果批处理下利用率仍然低于 80%,很可能存在 CPU 侧的数据预处理瓶颈,应提前把图像缩放、归一化等操作放进 GPU 预处理管线。
综合来看,TensorRT 加速与批处理并不是二选一的关系,而是互相配合的两个维度。TensorRT 压缩了单次前向的时间下限,批处理则把硬件利用率推到更高水平。把两者结合好,很多原本需要多块显卡才能扛住的推理服务,单卡就能稳定运行。