第 5 章 引擎与调度:同一张卡,吞吐为什么能差几倍
第 4 章把硬件的上限算清楚了:一张卡的算力、带宽、容量是固定的,7B 模型在 A100 上逐字生成的理论下限是每 token 6.9 毫秒。但真实的推理服务里,同样的卡、同样的模型,吞吐可以从每秒几个 token 到每秒几千个 token,差三个数量级。这个差距不来自硬件,来自软件层怎么组织计算。
这一章讲这个组织过程。先说清楚三个基础词,再看软件栈的四层,最后落到引擎怎么调度请求。
先垫三块砖:批处理、调度、队列
这三个词后面每一章都会用,值得单独说清。
批处理的意思是:不一次只算一条请求,而是把多条请求拼在一起算。这样做之所以有效,是因为逐字生成每一步都要把全部权重读一遍,读一次的成本是固定的;如果这一次读能同时服务三十二条请求,那么每条请求分摊到的读取成本就只有三十二分之一。权重没变,卡没变,吞吐涨了三十多倍,靠的就是"一次读、多次用"。
调度是决定"这一批里放谁"的规则。请求到达有先后,长度有长短,有的快到期了,有的可以等。调度器要在这个约束下决定顺序和分组,目标可能是吞吐最大,也可能是延迟最低。这两个目标经常冲突:把长请求和小请求混在一批里,吞吐高但小请求被拖慢;按长度分开批,延迟稳但硬件利用率下降。
队列是请求等待的地方。服务的处理能力有上限,超过上限的请求不能直接丢掉,只能排队。队列长度是一个非常有用的信号:它短,说明容量够;它持续变长,说明已经过载,再多的优化也救不了,只能加机器或者限流。第 11 章讲评测时会回到这个指标。
软件栈的四层
Inference Engineering 把推理软件分成四层,从下往上。
最底层是 CUDA,直接和 GPU 打交道,控制每一次计算和每一块显存。往上是深度学习框架,PyTorch 是事实标准,它把运算组织成张量和层,让 Python 代码能驱动 GPU。再往上是推理引擎,vLLM、SGLang、TensorRT-LLM 属于这一层,它们处理批处理、缓存、调度这些服务端问题。最上面是编排层,负责把很多个引擎实例组织成一个服务。
大部分推理工程师的工作在最上面两层:配置引擎、部署服务。但下面两层决定了你配置的东西为什么有效,所以不能完全当黑盒。
kernel 与融合:少读写一次很值
kernel 是在 GPU 上执行的一段计算程序。一个模型的一次前向会被拆成很多个 kernel:矩阵乘一个、激活一个、归一化一个、注意力一个。每个 kernel 都要从显存读数据、算完写回去。
问题在于,写回去的数据马上又要被下一个 kernel 读出来。这一写一读完全是浪费,而在逐字生成这种带宽受限的阶段,浪费带宽就是直接浪费速度。
kernel 融合就是把这些步骤合并成一个 kernel,中间结果留在片上,不落显存。FlashAttention 是这条路线上最著名的例子:注意力的数学定义几十行就能写完,但为了把中间矩阵留在片上、避免反复读写显存,实现要几万行。
这件事说明一个规律:在带宽受限的阶段,性能优化的大头不是"算得更快",而是"少搬几次"。第 7 章讲量化、第 6 章讲缓存,本质上都在减少搬运。
引擎在做什么
一个推理引擎的核心工作可以概括成三件:把请求攒成批、给每条请求分配缓存、在合适的时机把批送进 GPU。
最关键的机制叫连续批处理。早期的做法是等一批请求凑齐,整批算完再换下一批;问题是批里最短的请求早就生成完了,位置还占着,直到最长的那个结束。连续批处理改成了流水线式的:每一步结束后,完成的请求立刻退出,等待的请求立刻补进来。批一直是满的,硬件的利用率就上去了。
这个机制听起来简单,实现起来要解决一堆问题:新请求的缓存要能插进已有的布局,不同长度的序列要能拼成一个矩阵,退出请求占的显存要能立刻回收。第 6 章的缓存管理,主要就是在解决这些。
批多大才划算
批处理有上限,这个上限可以算出来。
以第 1 章的条件为例:7B 模型,FP16,A100。每一步读 14 GB 权重,按 2039 GB/s 算是 6.9 毫秒。这是批大小为 1 时每一步的时间。
每多一条请求,多出的计算量是两倍参数量,也就是 14 GFLOP。A100 的 FP16 算力是 312 TFLOPS,算一条请求需要 45 微秒。
于是可以算出交叉点:当批大小乘 45 微秒接近 6.9 毫秒时,计算时间追上了读取时间。解出来是 153。
这个数字不是巧合。6.9 毫秒除以 45 微秒,正好是 312 TFLOPS 除以 2039 GB/s 的比值,也就是第 2 章那个算术强度平衡点。换句话说,一批能塞多少条请求而不变慢,等于硬件的算力带宽比。
在 153 以内,加请求几乎不增加每步时间,吞吐线性上涨。超过 153,每一步的计算时间开始超过读取时间,延迟随批量线性上升。这就是为什么引擎不会把批开到几百上千:再往上,吞吐还在涨,但每个用户的等待时间也在涨,体验开始崩。
一次请求在引擎里的旅程
把前面的机制串成一条时间线,可以看到引擎在每个环节插手。
请求到达,先做分词,把文本变成 token 编号。这一步在 CPU 上做,很快,但高并发时也会成为瓶颈,因为分词器是单线程的。
然后进队列。调度器看当前有哪些请求在跑、缓存还剩多少、这一批能放几条。放不下就继续等,队列长度被记录下来,成为扩容的判断依据。
轮到时先做预填充。长输入会被切成块,每块和正在解码的请求拼成一批,一起送进 GPU。预填充算完,这条请求的 KV cache 就建好了,首字可以返回。
之后进入解码循环。每一步,调度器把当前所有活跃请求拼成一批,前向一次,每条各出一个 token。有请求生成到结束符,就退出,它占的块被回收;队列里有新请求,就补进来。
每生成一个 token,引擎把编号转回文本,通过流式接口发出去。这一步看起来简单,实际上要处理断句:一个汉字可能由多个 token 组成,不能发半个字。
最后请求结束,块表释放,缓存的引用计数减一。如果这些块是被多条请求共享的前缀,要等计数归零才真正回收。
这条链上,任何一环卡住,用户看到的都是"慢"。分词慢和 GPU 慢,在客户端看来没有区别。所以排查要先分段计时,而不是直接怀疑模型。
调度:先来后到不够用
批大小定了上限,剩下的问题是怎么在有限的位置里分配。
最简单的策略是先来先到。它公平,但会遇到队头阻塞:一条超长请求排在前面,后面所有短请求都要等它。用户感知到的不是平均延迟,而是自己那一条的延迟,所以队头阻塞的代价很高。
常见的改法是按长度分桶,或者给短请求优先权,或者给已经等很久的请求提权。每种改法都有代价:分桶会降低批的填充率,优先权会让长请求饿死。
还有一个维度是预填充和解码的优先级。预填充决定首字延迟,解码决定出字速度。同一张卡上,两者抢资源:预填充算得久,解码就卡顿;解码优先,新请求的首字就慢。多数引擎用分块预填充来折中——把长预填充切成小块,夹在解码步骤之间做,这样解码的节奏不被打断,首字延迟也不会失控。
调度没有最优解,只有针对负载的取舍。判断依据是第 2 章那对坐标:ISL 和 OSL 的分布。短输入长输出的负载,解码优先;长输入短输出的负载,预填充优先。
三个引擎的取舍
市面上主流的开源引擎有三个,取舍很清楚。
| 引擎 | 性能 | 上手 | 模型支持 | 硬件 |
|---|---|---|---|---|
| vLLM | 好 | 容易 | 最广 | GPU、TPU |
| SGLang | 好 | 容易 | 广 | NVIDIA、AMD |
| TensorRT-LLM | 最好 | 难 | 较少 | 仅 NVIDIA |
vLLM 的优势是覆盖面:硬件多、模型多、社区大,遇到冷门模型时往往是唯一能跑的选择。SGLang 的优势在前后端配合,对中文开源模型和 MoE 大集群的支持投入更多。TensorRT-LLM 的性能通常最好,代价是要为特定模型做编译,换模型、换卡都要重新走一遍流程。
三者在功能上已经趋同:连续批处理、量化、投机解码、前缀缓存、并行、预填充解码拆解,都是标配。选型时真正的差别不在功能列表,而在"你的模型它支不支持"和"出问题时你能不能改"。
编排层:引擎之上还有一层
一个引擎跑在一个节点上。当一个节点不够时,需要一层东西来协调多个引擎实例,这就是编排层。
编排层做三件事。第一是路由:请求该发到哪个副本。如果某个副本已经缓存了这段前缀,发过去就能省掉重复的预填充,首字延迟立刻下降,这叫缓存感知路由。第二是拆解:把预填充和解码放到不同的机器池里,各自独立扩缩,因为两者的资源需求完全不同。第三是跨节点协调:几百张卡、多个副本、多个区域时,谁来分配、谁来回滚。
NVIDIA Dynamo 是这一层里比较有代表性的开源方案,vLLM 和其他引擎都能接。再往上还有更轻的路由工具,比如 LiteLLM 和 nginx,负责应用层的转发和限流。
记住一个区分:引擎负责让一个模型在一个节点上跑得快,编排层负责让很多个引擎像一个服务一样工作。前者的问题在 kernel 和批处理,后者的问题在路由和容量。
一次调参的判断顺序
参数很多,但判断有顺序。
先看瓶颈在哪一段。首字慢、出字正常,问题在预填充或排队;出字慢、首字正常,问题在带宽或批太小;两个都慢,先看队列长度,多半是过载。
再看批大小。批太小,权重读取摊不薄,吞吐上不去;批太大,延迟崩。找到那个交叉点附近的区间,通常就是最优区间。
然后看缓存。缓存命中率低,说明前缀复用没做好,或者路由没做缓存感知,预填充被重复执行了很多次。
最后才看模型层面:量化、投机解码、并行。这些手段的收益都很大,但前提是前面三层没有明显短板。第 1 章说的"优化错层",大多发生在这个顺序上:一上来就换量化,结果瓶颈在排队。