一个部署工程师的困惑#
几年前我在做计算机视觉模型的部署,用的是 TensorRT 4 和 5。每次把训练好的模型转成 TensorRT engine,都绕不开一个参数:maxBatchSize。
于是我就去问研究员:你这个模型 batch size 用多少?
对方一般会愣一下。他们觉得 batch size 是训练时的超参数,和推理部署有什么关系?我也觉得别扭——这个参数本质上取决于线上请求量、延迟目标和请求合并策略,凭什么要在转模型的时候定下来?
需要说清楚一件事:maxBatchSize 不是说每次推理都必须以这个 batch 运行。它是构建 engine 时设定的上限,运行时可以用更小的 batch。但在当时的隐式 batch(Implicit Batch)模式下,batch 维度被当作一个特殊的、独立于张量形状之外的维度来处理——模型定义里看不到它,构建器单独管理它,其他所有维度在构建时就固定了。
这意味着什么?假设我有一个图像分类模型,训练时输入是 (batch, 3, 224, 224)。转成 TensorRT engine 后,3, 224, 224 是写死的,只有 batch 可以在 [1, maxBatchSize] 之间变化。如果线上来了一张 320×320 的图,要么我在前面加 resize,要么重新构建一个 engine。
我当时没有深究这个设计为什么是这样。直到后来重新看 TensorRT 的演进,才发现这个"batch size 设多少"的问题,其实串联着 TensorRT 后续几乎所有重要的架构变化。
Batch 不该是特殊维度#
先回到隐式 batch 模式的问题上。
这个模式的核心假设是:batch 是唯一需要在运行时变化的维度,其他维度在构建时就能确定。这在早期的 CV 部署场景里基本成立——图片经过预处理后,空间分辨率和通道数是固定的,唯一变化的就是一次送多少张。
但随着应用场景变多,这个假设撑不住了。NLP 模型的序列长度天然不固定;图像模型有时也需要处理不同分辨率的输入;多模态场景下各路输入的形状各不相同。更普遍地说:真实推理请求的各个维度都可能变化,不只是 batch。
TensorRT 从第 6 版开始引入了显式 batch(Explicit Batch)模式和 Dynamic Shape 支持。核心变化是:batch 回到了普通的张量维度,不再享受特殊待遇;同时,任意维度都可以被标记为动态的。
回到前面那个图像分类模型的例子。在 Dynamic Shape 下,我可以把输入形状声明为 (batch, 3, height, width),其中 batch、height、width 都是动态维度。运行时来什么形状都行,只要落在我事先声明的范围内。
这听起来很理想,但立刻带来一个实际问题:TensorRT 的核心卖点是构建时的深度优化——内核选择、内存预分配、层融合,这些都依赖于对输入形状的了解。如果构建时不知道具体形状,优化该怎么做?
告诉构建器"什么最常来"#
Dynamic Shape 把灵活性给了运行时,但构建器不能对着一个完全未知的形状空间做优化。Optimization Profile(优化配置)就是解决这个矛盾的。
一个 Optimization Profile 需要为每个动态维度指定三个值:min、opt、max。
容易误解的地方是:这不是三个孤立的 shape,而是描述一个连续范围和一个优化重点。min 和 max 定义了运行时允许的形状范围——超出这个范围的输入会被拒绝。opt 则告诉构建器:在这个范围里,这个形状最值得优化。
构建器会围绕 opt 来选择 CUDA 内核、调整 tiling 策略和分配临时内存。其他落在 [min, max] 范围内的形状也能跑,但性能可能不如 opt 附近的。
继续前面的例子。假设我的图像分类服务大部分请求是 batch=4、分辨率 224×224,偶尔会来 batch=1 的单张图,或者 batch=8 的批量请求,分辨率偶尔是 320×320。我可以这样设 profile:
1(示意值,非真实项目数据)
2input: min=(1, 3, 224, 224) opt=(4, 3, 224, 224) max=(8, 3, 320, 320)这等于告诉构建器:batch 1 到 8 都可能来,分辨率 224 到 320 都可能来,但 (4, 3, 224, 224) 是最常见的,请在这个形状上花最多的调优精力。
如果不同场景的形状分布差异很大,还可以给同一个 engine 设置多个 profile。构建器会为每个 profile 的 opt 分别做内核选择。运行时根据实际请求选择最合适的 profile。
到这里,构建阶段的问题解决了:构建器知道了形状的范围和优化重点,运行时的灵活性也保住了。 但接下来的问题是:一个 engine 编译好了,线上同时来了多个请求,每个请求的形状可能不同——这些请求怎么并发执行?
共享编译结果,隔离执行状态#
先想想不做任何设计会怎样。一个 engine 是构建阶段的产物,包含了编译好的内核和优化策略。如果每个请求都重新构建一个 engine,那构建开销(几秒到几十秒)根本不可接受。所以 engine 必须是共享的。
但执行过程不能共享。每个请求可能绑定不同的输入形状,有自己的输入输出 buffer,执行到哪一步也各不相同。如果多个请求共用同一份执行状态,就会互相覆盖。
TensorRT 的做法是把"编译结果"和"执行状态"分开:
- Engine 是共享的编译产物。它是只读的,可以被多个执行实例同时使用。
- Execution Context 是每路请求的执行状态。每个 context 绑定一个具体的输入形状,持有自己的临时内存,在独立的 CUDA stream 上提交 GPU 命令。
回到那个图像分类的例子:
- Engine 在服务启动时从磁盘加载一次(或构建一次),之后常驻内存。
- 请求 A 来了 batch=4、224×224 的输入,创建(或复用)一个 context,绑定这个形状,在 stream 0 上执行。
- 请求 B 紧接着来了 batch=1、320×320 的输入,用另一个 context,绑定另一个形状,在 stream 1 上执行。
- 两个请求共享同一份编译好的内核,但各自的执行互不干扰。
这套设计的关键在于职责划分:构建开销只付一次(Engine),每路请求只承担自己的执行状态管理开销(Context + Stream + Memory)。 这也是 TensorRT 能在高并发场景下工作的基础。
不过,当并发请求跑起来之后,我开始注意到另一层开销。
每次推理的 CPU 端提交开销#
GPU 上的计算很快,但每一轮推理并不只是 GPU 在算。CPU 端需要做一系列准备工作:设置输入输出绑定、向 CUDA stream 提交一连串 kernel launch、同步等待结果。这些操作本身是有开销的。
对于大模型或者计算量很大的推理任务,这些 CPU 端开销相对于 GPU 计算时间来说可以忽略。但如果模型很小、推理很快,或者请求频率很高,CPU 反复提交 GPU 工作的开销就变得显著了。
更具体地说:同一个形状的请求反复到来,每次都让 CPU 走一遍完整的 kernel launch 流程,但提交的 GPU 命令序列每次都一样。这就是典型的重复劳动。
CUDA Graph 就是针对这种场景的:把一次完整的 GPU 执行流程捕获成一个静态的命令图,后续重放这个图就行,跳过 CPU 端逐个 kernel launch 的开销。
这里需要理解一个前提:CUDA Graph 捕获的是一次具体执行的完整命令序列,包括每个 kernel 的参数、内存地址等。这意味着它绑定在一个特定的输入形状上。如果输入形状变了,之前捕获的 graph 就不能直接重放,需要为新形状重新捕获。
乍一看,这和 Dynamic Shape 是矛盾的——Dynamic Shape 允许形状变化,CUDA Graph 要求形状固定。但仔细想想,它们解决的是不同层面的问题。Dynamic Shape 让系统有能力接受各种形状的请求。CUDA Graph 则观察到:虽然请求形状可以变化,但实际线上流量中,绝大多数请求集中在少数几个常见形状上。对这些反复出现的热点形状,把执行路径捕获下来复用,就能省掉大量重复的 CPU 提交开销。
回到图像分类的例子。假设线上 80% 的请求是 batch=4、224×224,15% 是 batch=1、224×224,剩下 5% 是零散的形状。那我可以为前两个热点形状各捕获一个 CUDA Graph。这两个形状的推理直接重放 graph,跳过 CPU 提交。偶尔来的零散形状走正常的 kernel launch 路径,性能差一点,但不影响大局。
CUDA Graph 也有它的适用边界。它要求捕获期间的执行流是确定的——如果模型中有依赖输入数据的控制流分支,就不适合捕获。另外,如果推理时间主要花在大型计算或者数据传输上,CPU 提交开销本身占比就很小,CUDA Graph 带来的收益也就有限。
确定性的分配#
回头看这条演进线,我觉得最值得记住的一个判断是:
动态推理不是放弃静态优化。它是把确定性分配到了不同阶段。
构建阶段,通过 Optimization Profile 确定形状的允许范围和优化重点。编译器在这个阶段把能做的优化都做了——内核选择、层融合、内存规划——围绕 opt 形状深度调优。
请求阶段,实际的输入形状和执行实例才确定下来。每路请求绑定一个 Context,分配自己的执行状态和内存,在独立的 CUDA stream 上运行。Engine 的编译结果被共享,执行状态被隔离。
而对那些反复出现的热点路径,CUDA Graph 再做一层固定——不是固定形状范围或执行策略,而是固定 CPU 到 GPU 的命令提交本身,消除重复劳动。
每一层"固定"解决的问题不同,作用的阶段也不同。它们之间不是替代关系,而是叠加关系。
今天如果再有人问我"batch size 应该设多少",我大概不会直接给一个数字。我会先问:线上请求是怎么到达的?延迟和吞吐目标是什么?哪几个输入形状最常见?
然后根据这些信息去决定:profile 的 min/opt/max 怎么设,需要几个 profile,是否值得为热点形状启用 CUDA Graph。
这不是一个研究员应该回答的模型超参数问题,而是一个部署工程师需要根据线上场景做出的系统设计决策。