推理引擎 运行时调度:图优化、continuous batching 与 prefill/decode 两阶段
更新时间:2026-09-25。训练框架(PyTorch)关心的是「几十轮迭代怎么把梯度算对」,而推理引擎(TensorRT、ONNX Runtime、vLLM、TensorRT-LLM)关心的是另一个问题:模型已经固定,怎么让每一次请求在 GPU 上跑得又快又省。这一篇讲推理引擎在模型和硬件之间到底做了哪些事,以及服务端 LLM 推理最关键的调度技术——continuous batching。底层的异步执行机制可以对照 CUDA stream 异步:多流流水线,硬件前提(HBM 带宽、算力规格)可以对照数据中心 AI 卡对比。
本文要回答的问题
- 模型从 PyTorch 导出之后,推理引擎还对它做了什么?算子融合为什么能同时省时间和省显存?
- kernel autotune 「按形状选最快的 kernel」是什么意思,为什么同一个算子在不同 batch 下最优实现完全不同?
- 静态 batch、动态 batch、continuous batching 三者区别在哪?为什么 continuous batching 是 LLM 服务提吞吐的关键?
- prefill 和 decode 两个阶段为什么一个吃算力、一个吃带宽?调度器怎么利用这个差异?
- 拼大 batch 提吞吐的代价是什么?典型场景能拿到多少倍数字?
一、推理引擎在模型和硬件之间做什么
先看一个朴素的事实:训练保存下来的模型,是一张计算图——几千个算子(卷积、矩阵乘、逐元素加、激活、归一化)连成的有向图。如果直接按图逐个算子执行,每个算子都启动一次 kernel,GPU 会在两种浪费之间反复横跳:kernel 启动本身有几微秒固定开销,算子之间的中间结果要写回显存再被下一个算子读回。小算子多时,启动开销和访存开销能占掉一半以上时间。
推理引擎拿到计算图后做的第一件事是图优化,常见手段包括:
- 常量折叠(constant folding):不依赖输入的子图(比如 reshape 的形状参数、Conv 后面挂的加法偏置预处理)在编译期直接算成常量,运行时不再执行。
- 算子融合(operator fusion):把多个小算子拼成一个 kernel。最典型的是
Conv + Bias + ReLU融成一个 kernel——卷积输出在寄存器里就地加偏置、过 ReLU,再写回显存,省掉两次全局访存和两次 kernel 启动。LLM 里的融合点更多,比如注意力内部的 softmax、RoPE 位置编码都可以融进矩阵乘前后的 kernel。 - 消冗余与改布局:去掉恒等算子、合并相同张量的多次拷贝,把数据布局从 NCHW 改成硬件更友好的格式(TensorRT 的做法),让矩阵乘能走张量核(tensor core)的最快路径。
- 精度校准与量化:FP32 转 FP16/BF16 通常免费拿到约 2 倍速度;INT8 量化需要在校准数据集上统计激活分布,决定哪些层值得降精度而不损伤结果。
融合为什么有效,可以从算术强度角度理解:矩阵乘本来就是计算密集型,融合不改变它的算力消耗;但 ReLU、逐元素缩放这类访存密集型算子单独执行时,时间几乎全花在把张量读出再写回 HBM 上。融合之后这份数据根本不离开片上存储,等于免费执行。算子融合的系统做法(TVM、MLIR 这条编译器路线)在算子融合与深度学习编译器里展开。
这里顺带纠正一个常见误解:推理快不是单一技术的功劳,而是一连串收益叠乘的结果。FP16 让张量核吞吐翻倍大约贡献 2 倍,几个关键算子融合各省掉一次中间张量的读写往返,kernel 挑选避开慢实现,最后服务端拼批把空闲槽位填满——四件事各管一段,缺一件最终数字都会明显变差。这也是为什么「同样的模型、同样的卡,不同服务框架吞吐能差出几倍」:差距往往不在模型本身,而在这一串工程环节的完成度。
第二件事是 kernel autotune,下一节单独讲。第三件事是内存管理:推理服务是长期运行的进程,如果每次请求都 cudaMalloc / cudaFree,不仅分配本身慢(毫秒级),还会把显存割碎。引擎在初始化时预申请一大块显存池(memory pool),所有中间张量、KV cache 都从池里切块复用,请求结束时整块归还而不还给驱动。vLLM 的 PagedAttention 把这个思路推到更细:把 KV cache 像操作系统虚拟内存一样按固定大小的 block 分页管理,不同请求的显存不必连续,碎片率从动辄 60%~80% 降到 4% 以下,同样一张卡能同时容纳的请求数因此成倍增加。
二、kernel autotune:同一个 GEMM,按形状选最快的实现
写 CUDA 的人都知道,一个矩阵乘(GEMM)可以有几十种写法:tile 取多大、每个 block 算多少输出、用不用 shared memory 双缓冲、张量核指令怎么拼。没有一种写法在所有形状下都最快——瘦长矩阵和方正矩阵的最优参数完全不同,batch=1 和 batch=32 也不同。
kernel autotune 的做法简单直接:在目标显卡上把候选实现全部实测一遍,按输入形状记下最快的那个。TensorRT 在构建引擎时要求你指定允许的输入形状范围(profile),它会在这个范围的若干代表性形状上跑 benchmark;cuDNN/cuBLAS 内部也维护着按问题尺寸索引的启发式表。ONNX Runtime 的相关机制叫 graph transformation + kernel registry,每个算子注册多个实现,运行时按形状和数据类型挑选。
这件事解释了推理引擎一个反直觉的使用方式:引擎是要提前「构建」的。TensorRT 的 build 阶段可能跑几分钟到几十分钟,因为它在真卡上反复实测成百上千个 kernel 配置。构建产物(plan/engine 文件)与具体 GPU 型号、CUDA 版本绑定,换卡必须重建。收益是运行时零试错:每个形状拿到的都是这块卡上实测最快的 kernel。生产中常见的做法是离线构建好几种典型形状的引擎缓存,请求按形状命中,缓存未命中时再现场构建或回退到通用实现。
代价则是对形状变化的态度:传统静态引擎希望你提前告知 batch 和序列长度的上下界;形状越界,要么拒绝、要么触发昂贵的重新优化。这也正是后来动态形状支持和服务端调度要解决的矛盾——在线请求的形状天然不可预测。
三、静态 batch、动态 batch 与 continuous batching
先把三种批处理方式放在一张时间线上对比。假设每个请求生成长度不同(LLM 自回归生成:一个请求要连续跑几十到几百个 decode step,每个 step 都是一次前向),色块代表一个请求:
静态 batch(static batching):服务启动时固定批大小,一批请求一起进、一起出。在 CNN 这类单轮前向的场景问题不大,但在 LLM 自回归生成下有个致命缺陷——一个 batch 里生成长度不同,短请求早早算完却必须陪着长请求跑完全程(归一化时还要用 padding 补齐),它占的那部分算力和 KV cache 全浪费了,而且全程不能插入新请求。
动态 batch(dynamic batching):Triton Inference Server 一类框架的默认策略——请求先入队,凑够设定的 batch 大小、或等待超时到了就凑一批发车。它对单轮前向模型很有效,本质是用一点排队延迟换吞吐;但对 LLM 仍然没有解决「同批请求长度不同、互相等死」的问题,因为一旦发车,这批成员在整个生成期间不变。
continuous batching(连续批处理,也叫 in-flight batching) 由 vLLM、TensorRT-LLM 等 LLM 推理引擎普及,关键改动只有一句话:批的组成不是在发车时定死,而是在每一个 decode step 结束后都重新调整。某个请求这一步生成了结束符(EOS),立刻把它移出、释放它的 KV cache 槽位;等待队列里的新请求下一步就填进来。于是 GPU 看到的批次永远是接近满员的,没有「陪跑」空位。注意「连续」二字不是指请求连续不断地进来——任何服务都有流量高峰低谷——而是指 GPU 上的批次槽位在每一步都被重新审视、随时保持尽可能满。这一机制依赖两个底层条件:分页 KV cache 让槽位可以零碎地分配与回收(上一节的 PagedAttention),以及引擎能够以 iteration 为粒度调度而不是以整个请求为粒度——这正是CUDA stream 多流流水线里「CPU 不断往队列排未来工作计划」思路在服务端的版本。
三种方式的对比:
| 对比维度 | 静态 batch | 动态 batch | continuous batching |
|---|---|---|---|
| 批次何时确定 | 服务配置时写死 | 凑批发车时确定 | 每个 decode step 后重排 |
| 短请求是否陪长请求跑完 | 是,全程陪跑 | 是 | 否,结束即移出 |
| 新请求何时能进入 | 上一批整体结束后 | 下一趟发车 | 下一个 step(毫秒级) |
| KV/显存利用 | 固定预留,碎片多 | 同左 | 分页回收,碎片约 4% |
| 适用模型 | 固定形状单轮前向 | CNN、BERT 类在线服务 | 自回归 LLM 生成 |
| 延迟特征 | 吞吐稳定但排队差 | 用少量排队延迟换吞吐 | 高吞吐下尾延迟(P99)也更稳 |
四、prefill 与 decode:一个吃算力,一个吃带宽
LLM 推理在请求内部还分成性质完全不同的两个阶段,调度器必须区别对待:
- prefill(预填充):处理用户输入的整段 prompt,把所有 token 一次性并行喂进去,算出每层的 K、V 写入 KV cache,并给出第一个输出 token。每个 token 都参与完整的矩阵乘,序列长度为 $S$ 时计算量约正比于 $S$,算术强度高,是计算密集型,GPU 的张量核基本能喂饱。
- decode(解码):此后每次只生成一个新 token,但每一步都要把这个 token 的 Q 和缓存里全部历史 token 的 K、V 做注意力。每步的矩阵乘退化成「极小的 M × 巨大的 N」——新激活只有一行,权重矩阵却要整张读一遍,算术强度极低,是显存带宽密集型。
这导致一个常被引用的估算:一个 7B 参数模型,FP16 权重约 14 GB;单路 decode 每个 token 至少要把权重完整读一遍。在 2 TB/s 带宽的 A100 上,单请求 decode 的理论上限约 140 token/s,在 3 TB/s 的 H100 上约 200 token/s——这个数字与张量核算力几乎无关,纯粹被 HBM 带宽卡住。而把 batch 拼到几十路之后,权重读一遍同时喂几十行激活,算术强度被拉上来,总算力开始发挥作用,这就是 batching 对 decode 提速如此显著的根本原因。
下图把一次服务的两阶段调度画出来:prefill 是大块、decode 是细密的小步,现代引擎用 chunked prefill 把长 prompt 切成小块、与 decode 请求混在同一个 step 里调度,避免一个长 prefill 霸占 GPU 几十毫秒、把所有正在 decode 的用户卡住:
顺带说明两个服务端最常用的延迟指标:**TTFT(time to first token,首 token 延迟)**主要由排队时间加 prefill 时间决定,是用户感受到的「反应快不快」;**TPOT(time per output token,每输出 token 时间)**主要由 decode batch 的 kernel 时间决定,是用户感受到的「打字流不流畅」。两者的瓶颈手段不同:降 TTFT 靠控制排队和 prefill 切块粒度,降 TPOT 靠给 decode 选高效 kernel、控制并发批次不要越过甜区。
混批的含义是:一个 step 内,SM 同时服务于「prefill 块(贡献高算术强度的负载)」和「若干 decode 请求(贡献带宽型负载)」,两类负载互补,GPU 的算力和带宽都比纯 decode 时更接近满载。再进一步是prefill/decode 分离部署(disaggregated serving):两类阶段既然资源画像完全不同,干脆把它们放到不同 GPU 实例上——prefill 实例配高算力、短连接、快速处理完通过高速互联把 KV cache 传给 decode 实例;decode 实例配大显存、长期持有大量连接。这样两池各自按自己的甜区配置,不用在同一个混批比例上互相妥协,代价是 KV cache 跨节点传输的带宽压力和更复杂的路由,目前在大规模集群上开始落地。
优先级策略上,调度器一般给 decode 更高权重——因为用户已经在等逐字输出,prefill 晚几十毫秒用户感知弱;也可以给首 token 延迟(TTFT)设 SLA 上限,防止 chunked prefill 被无限推迟。vLLM、TensorRT-LLM 里的 max_num_batched_tokens、chunked prefill 开关调的就是这组配比。
五、延迟与吞吐的权衡:典型数字
所有调度技术最终都在同一条权衡曲线上移动:batch 越大,单位时间产出的 token/图片越多,但单个请求的延迟越高——排队等入批的时间变长,拼批后的 kernel 执行时间也随 batch 亚线性增长。给一组有代表性的量级数字(具体数值随模型、显卡、版本变化,只看量级):
- CNN/单轮前向模型:ResNet 一类模型 batch 从 1 加到 32,单批延迟通常只增长 2~4 倍,吞吐却能提升约 8~15 倍,直到 GPU 算力或显存打满;再大的 batch 收益递减,且排队延迟开始主导。
- LLM 服务端:vLLM 论文报告 continuous batching 相对朴素的静态批处理实现吞吐提升约 2~4 倍(公开评测中对比更老的实现可见更高倍数),核心不是单个 kernel 变快,而是 GPU 空闲槽位被填满、短请求不再陪跑。
- 单卡并发路数:同样跑 13B 量级模型,朴素显存分配下同时服务 4~8 路就因 KV cache 碎片触顶,分页管理后可并发几十路上百路——这是同样硬件下服务容量的直接差异。
- decode 批大小的甜区:从 batch=1 加到几十,吞吐近似线性增长、单 token 延迟增长缓慢;超过某个点(常见在 batch 64~128 一档,取决于显存和权重规模)后 kernel 时间明显变长,TTFT 和 token 间延迟(TPOT)双双恶化,进入负收益区。
还要注意一个反直觉的现象:GPU 利用率打满不等于服务配置最优。如果调度器允许无限拼批,监控面板上显存和算力都接近 100%,看起来很漂亮,但此时任何突发流量都只能纯排队,P99 延迟会急剧恶化。成熟的线上配置普遍刻意留出一两成余量,用来吸收流量毛刺和优先级插队;利用率曲线「稳态八九成、短时可冲满」比「常年 100%」健康得多。
工程落地时的决策顺序是:先量两个数——prefill 阶段的算力利用率和 decode 阶段的带宽利用率,确认瓶颈在哪一档;再定 SLA(TTFT 上限、TPOT 上限、P99 延迟),在满足 SLA 的前提下把 batch 和并发路数往上顶;开启分页 KV cache、continuous batching、chunked prefill 三项服务端调度;最后用真实流量混合(长短 prompt 比例)做压测,因为纯长 prompt 或纯短 prompt 测出来的甜区都不代表线上。值得一提的是,这些调度机制与显卡架构强相关——显存带宽、张量核算力、卡间互联的差异会改变甜区位置,选型时参考数据中心 AI 卡对比。
这一篇在知识体系里的位置
这一篇属于「AI 芯片软件栈」专题。向下看,调度器排出来的工作最终落在 stream、kernel 与拷贝引擎上,见 CUDA stream 异步;图优化与算子融合的编译器原理见 算子融合与深度学习编译器。向行业视角看,推理引擎之争(vLLM、TensorRT-LLM 的生态位)在 推理引擎之争,调度效率提升如何反映到 token 价格上见 LLM 推理成本趋势。硬件侧的带宽与算力前提则在数据中心 AI 卡对比。
总结
推理引擎的运行时调度做三层事:编译期用图优化、算子融合和量化砍掉冗余计算与访存,构建期用 kernel autotune 按形状为每个算子选出实测最快的实现,运行期用显存池和分页 KV cache 让显存可碎块复用;服务端的关键调度是 continuous batching——每个 decode step 结束都可移出完成请求、插入新请求,配合 chunked prefill 混排计算密集的 prefill 与带宽密集的 decode,在满足 TTFT/TPOT 延迟约束的前提下把 GPU 算力和带宽同时喂饱,LLM 服务相对静态批处理典型拿到约 2~4 倍吞吐,单轮前向模型 batch 化也有约 8~15 倍的吞吐空间。