3. TensorRT核心工作流程
在上一节中我们已经明确,TensorRT 的价值在于把训练框架产出的模型,转换成一张针对特定 GPU、特定精度、特定输入尺寸深度定制过的推理引擎。本节要回答的核心问题是:这张引擎到底是怎么一步步“造”出来的?整个过程中 Builder、Network、Engine、Context 各自扮演什么角色?理解这条链路,是后续所有低延迟优化手段的落脚点——因为你每一次调优,本质上都是在改变这条链路上某个环节的输入或配置。
一、四个核心角色:谁负责什么
TensorRT 的构建流程围绕四个对象展开,它们的关系可以用“设计—施工—成品—运行”来类比,但更准确地说,是一条从模型描述到运行时状态的递进链:
- Builder(构建器):整个流程的入口和总调度。它负责创建 Network、设置构建期参数(如最大工作空间、精度标志),并最终调用 build 系列接口产出 Engine。Builder 是“一次性”的,构建完成后通常即可释放。
- Network(网络定义):模型的中间表示。它由一组 Layer 和 Tensor 组成,描述“计算图长什么样”。当你解析 ONNX 时,解析器就是把 ONNX 的节点翻译成 Network 中的层。
- Engine(引擎):构建的最终产物,是经过层融合、精度选择、内核自动调优后的可执行计划。Engine 与具体 GPU 架构强绑定,序列化后就是那个 .engine 或 .plan 文件。
- Context(执行上下文):Engine 的运行时实例。真正做推理时,你操作的是 Context,而不是 Engine。一个 Engine 可以创建多个 Context,用于多流并发。
需要特别强调的是:Engine 是只读的、可共享的,Context 才持有推理时的中间状态。这一点在多线程、多流场景下非常关键,也是很多初学者踩坑的地方——把 Engine 当成可并发调用的对象,结果出现数据竞争。
二、构建期的关键动作:解析、定义、配置
构建期的第一步是解析 ONNX。TensorRT 提供 ONNX Parser(C++ 中为 nvonnxparser,Python 中为 trt.OnnxParser),它把 ONNX 的算子图逐节点翻译成 Network 中的层。这一步并非总是“无损”的:如果 ONNX 中使用了 TensorRT 不支持的算子,解析会失败并给出具体节点名。实践中常见的处理方式是先在 PyTorch 侧把不支持的算子替换为等价的可支持组合,或者使用插件(Plugin)自行实现。
解析完成后,你拿到的是一个 Network 对象。此时可以做的操作包括:标记输入输出张量、修改某些层的精度、插入自定义插件层、调整输入维度(尤其是动态 shape 场景下设置 optimization profile)。Network 是构建期唯一可编辑的模型表示,一旦进入 build,它就被固化成 Engine,不再可改。
接下来是优化配置,这是决定最终延迟和精度的关键环节。Builder 上需要设置的典型参数包括:
- 最大工作空间(workspace size):影响内核自动调优时能尝试的算法范围。设得太小,可能选不到最优内核;设得太大,占用显存。示例中常设为 1~4 GB,具体取决于模型规模。
- 精度标志:BuilderFlag 中可开启 FP16 或 INT8。开启 FP16 通常能带来接近翻倍的吞吐提升,精度损失一般很小;INT8 则需要校准集来生成量化尺度。
- 优化 profile:动态 shape 模型必须设置,指定最小、最优、最大三组输入维度,TensorRT 会针对最优维度做内核调优。
下面是一段 Python 构建示例,展示了从 ONNX 到序列化引擎的完整过程:
import tensorrt as trt
logger = trt.Logger(trt.Logger.WARNING)
builder = trt.Builder(logger)
network = builder.create_network(
1 << int(trt.NetworkDefinitionCreationFlag.EXPLICIT_BATCH))
parser = trt.OnnxParser(network, logger)
with open("model.onnx", "rb") as f:
if not parser.parse(f.read()):
for i in range(parser.num_errors):
print(parser.get_error(i))
raise RuntimeError("ONNX 解析失败")
config = builder.create_builder_config()
config.set_memory_pool_limit(trt.MemoryPoolType.WORKSPACE, 2 << 30) # 2 GB
config.set_flag(trt.BuilderFlag.FP16)
# 动态 shape 场景:设置 optimization profile
profile = builder.create_optimization_profile()
profile.set_shape("input", (1, 3, 224, 224),
(8, 3, 224, 224), (16, 3, 224, 224))
config.add_optimization_profile(profile)
serialized = builder.build_serialized_network(network, config)
with open("model.engine", "wb") as f:
f.write(serialized)
这段代码里,build_serialized_network 直接返回序列化后的字节流,省去了先建 Engine 再序列化的中间步骤。注意 EXPLICIT_BATCH 标志:现代 TensorRT 中显式 batch 是推荐做法,它让 batch 维度成为输入 shape 的一部分,便于动态 shape 处理。
三、运行期:Engine 与 Context 的分工
构建完成后,运行期的操作对象就切换到了 Engine 和 Context。典型流程是:反序列化 Engine → 创建 Context → 分配输入输出显存 → 绑定地址 → 调用 enqueueV3 异步执行 → 同步流并读取输出。
这里有一个容易被忽视的细节:Context 持有推理时的中间激活内存和状态。如果你在同一个 Context 上连续调用两次推理,第二次会复用第一次的内存;但如果你在多条流上并发推理,就必须为每条流创建独立的 Context,否则会出现数据覆盖。这也是为什么在服务化部署中,通常采用“一个 Engine + N 个 Context”的池化结构。
四、构建流程中的常见判断点
在实际操作中,有几个判断点直接决定构建是否成功、引擎是否可用:
| 判断点 | 观察方式 | 处理方向 |
|---|---|---|
| ONNX 算子是否全部支持 | parser.parse 返回 False,打印 num_errors | 替换算子或实现 Plugin |
| 显存是否足够 | build 阶段抛出 out of memory | 减小 workspace 或 batch 上限 |
| 精度是否达标 | 对比 FP32 与 FP16/INT8 输出误差 | 调整校准集或回退部分层精度 |
| 动态 shape 是否覆盖 | 运行时报 shape 越界 | 检查 optimization profile 的 min/opt/max |