1. 那场关于 Batch Size 的争论,到底在吵什么
事情起因很简单。团队里两个同学在调 SGLang Omni 的推理服务,一个坚持把 Batch Size 拉到 256,理由是"吞吐量上不去就是浪费卡";另一个死活要压在 32,说"再往上延迟就崩了,线上根本没法用"。两个人各自跑了一组数据,都觉得自己是对的,最后把问题甩到我这里。
我看了两边的测试脚本,发现问题根本不在谁对谁错,而在于两个人测的根本不是同一个东西。一个测的是离线批量推理的峰值吞吐,一个测的是在线服务的尾延迟。这两个指标在 SGLang Omni 这种带调度器的推理框架里,压根就不是一条曲线上的事。
这件事让我意识到,很多人在做 SGLang Omni 性能验证的时候,习惯性地把它当成一个普通的推理引擎来对待——调个 Batch Size,看吞吐和延迟,画条曲线,收工。但 SGLang Omni 的核心价值恰恰在于它的调度层,Batch Size 只是调度器手里的一个旋钮,脱离调度策略单独谈 Batch Size,就像脱离路况谈车速,没有意义。
这篇内容我想把这次争论拆开讲清楚:SGLang Omni 的性能验证到底该怎么做,Batch Size 和调度器之间是什么关系,以及在真实业务场景下,调度取舍应该基于什么逻辑来决策。适合正在用或者准备用 SGLang Omni 做推理服务部署的工程师,也适合那些被"吞吐还是延迟"这个问题反复折磨的同行。
2. SGLang Omni 的调度层到底在调度什么
2.1 从请求进来到结果返回,中间经过了哪些调度决策
要理解 Batch Size 为什么不能单独调,得先搞清楚 SGLang Omni 从收到一个请求到返回结果,中间经历了哪些调度环节。
一个请求进来之后,调度器至少要做出这么几个决策:这个请求什么时候被放进执行队列、它和哪些请求被编进同一个 Batch、这个 Batch 什么时候真正被送到计算单元上执行、执行完之后 KV Cache 怎么管理、下一个 Batch 怎么组。这一连串决策里,Batch Size 只是"编 Batch"这一步的一个结果,而不是一个可以独立设定的输入参数。
我习惯用一个类比来解释:SGLang Omni 的调度器就像一个餐厅的前厅经理。Batch Size 相当于"每桌坐几个人",但前厅经理真正要管的是:现在有几桌空着、后厨出菜速度怎么样、有没有客人在催、新来的客人是拼桌还是等下一轮。你单独规定"每桌必须坐 8 个人",结果可能是后厨忙不过来,所有桌都在等菜,翻台率反而下降。
SGLang Omni 的调度器做的事情类似。它要根据当前 GPU 的显存占用、KV Cache 的碎片情况、队列里等待请求的数量和优先级、以及正在执行的 Batch 的预期完成时间,动态决定下一个 Batch 怎么组。Batch Size 在这个过程里是一个被决定的量,而不是一个被设定的量。
2.2 连续批处理与分页注意力:调度器的两个核心抓手
SGLang Omni 调度层有两个关键技术点,直接决定了 Batch Size 的弹性空间。
第一个是连续批处理(Continuous Batching)。传统静态批处理是等一批请求凑齐了再一起送进去,整批同进同出。连续批处理不一样,它允许一个 Batch 里的请求在不同时间点完成,完成的请求立刻释放资源,新请求可以随时插进来。这意味着实际运行的 Batch Size 是动态变化的,你设定的那个数字只是一个上限或者目标值,真正跑起来的时候可能一直在变。
第二个是分页注意力(Paged Attention)。这个机制把 KV Cache 切成固定大小的块来管理,就像操作系统用分页管理内存一样。它的好处是显存利用率高,不同请求的 KV Cache 可以非连续存储,碎片少。但这也意味着调度器在组 Batch 的时候,要考虑当前显存里还有多少可用的页,而不是简单地看"还能塞几个请求"。
这两个机制叠加在一起,导致一个结果:SGLang Omni 的实际 Batch Size 是调度器根据运行时状态动态决定的,你在配置里写的那个数字,更多是一个约束条件而不是一个执行参数。
我实测过一组数据,在同样的配置下,只改变请求到达的间隔,实际运行的平均 Batch Size 可以从 40 多波动到 180 多。如果你只盯着配置里的数字做性能验证,测出来的结果和线上真实表现可能差出好几倍。
2.3 调度器的"取舍"本质:没有最优解,只有当前场景下的最适解
很多人做性能验证的时候,潜意识里在找一个"最优 Batch Size"。但在 SGLang Omni 的调度框架下,这个问题本身就不成立。
调度器每时每刻都在做取舍:是把资源留给已经在跑的请求让它们尽快完成,还是腾出来给新来的请求降低排队时间?是优先保证高优先级请求的延迟,还是让所有请求公平地分享算力?这些取舍没有绝对的对错,只有适不适合当前场景。
举个具体的例子。在一个对话式应用里,用户发一条消息,等回复,然后再发下一条。这种场景下,请求是稀疏到达的,调度器应该倾向于降低单请求延迟,哪怕 Batch Size 小一点、吞吐低一点,也要让每个请求尽快返回。因为用户感知的是单次响应时间,不是系统总吞吐。
但在一个离线批量处理场景里,比如一次性要处理十万条文本做分类,请求是密集到达的,调度器就应该倾向于最大化吞吐,把 Batch Size 拉满,让 GPU 利用率尽可能高。这时候单请求延迟高一点无所谓,因为没人在线等。
所以性能验证的第一步,不是调参数,而是明确你的场景属于哪一类。这个前提不搞清楚,后面所有测试都是白做。
3. 性能验证方案:从"调参"转向"场景化压测"
3.1 先定义清楚你要验证的指标到底是什么
回到开头那场争论,两个人吵了半天,其实连指标都没对齐。做 SGLang Omni 的性能验证,至少要区分以下几类指标:
| 指标类别 | 具体指标 | 适用场景 | 测量方式 |
|---|---|---|---|
| 吞吐类 | Tokens/s、Requests/s | 离线批量、后台任务 | 固定请求总量,测总完成时间 |
| 延迟类 | TTFT、TPOT、E2E Latency | 在线交互、实时对话 | 固定并发数,测分位数 |
| 资源类 | GPU 利用率、显存占用、KV Cache 命中率 | 容量规划、成本核算 | 持续监控,取稳定段均值 |
| 调度类 | 平均 Batch Size、队列等待时间、抢占次数 | 调度策略调优 | 从调度器日志中提取 |
TTFT 是 Time To First Token,首 token 延迟,用户感知的"开始响应"时间。TPOT 是 Time Per Output Token,每个输出 token 的耗时,影响的是"回复速度"。E2E Latency 是端到端延迟,从请求发出到最后一个 token 返回的总时间。
这三个延迟指标在不同场景下的重要性完全不同。对话场景最在意 TTFT,因为用户等的是"有没有开始回";长文本生成场景更在意 TPOT,因为用户等的是"什么时候写完";而批量处理场景可能只关心 E2E 和总吞吐。
我建议在做任何压测之前,先填一张这样的表,明确你要优化的核心指标是什么、可接受的阈值是多少。这张表填不出来,说明你还没想清楚业务需求,这时候调什么参数都是瞎调。
3.2 压测工具与流量模型的搭建要点
SGLang Omni 自带的 benchmark 脚本可以用,但默认的流量模型太理想化——固定并发、固定输入长度、固定输出长度。真实场景里这些都在变。
我在实际项目中会自己搭一套流量模型,核心是模拟真实请求的到达过程和长度分布。到达过程用泊松分布来模拟随机到达,长度分布从线上日志里采样,输入和输出长度分别统计。
具体做法是:从生产环境的访问日志里导出最近一周的请求记录,提取每条请求的输入 token 数和输出 token 数,做成两个经验分布。压测的时候,请求到达间隔按泊松过程生成,每个请求的长度从经验分布里随机采样。这样测出来的结果比固定长度压测更接近真实。
压测脚本的核心逻辑大概是这样:
import numpy as np import asyncio # 从日志中采样的长度分布 input_lengths = np.load("input_length_dist.npy") output_lengths = np.load("output_length_dist.npy") # 泊松到达过程,平均每秒 arrival_rate 个请求 arrival_rate = 50 inter_arrival_times = np.random.exponential(1.0 / arrival_rate, size=num_requests) async def send_request(input_len, output_len): # 构造请求并发送到 SGLang Omni 服务 ... async def main(): tasks = [] for i in range(num_requests): await asyncio.sleep(inter_arrival_times[i]) input_len = np.random.choice(input_lengths) output_len = np.random.choice(output_lengths) tasks.append(asyncio.create_task(send_request(input_len, output_len))) await asyncio.gather(*tasks)这套流量模型搭起来比直接用固定长度压测麻烦,但测出来的数据才有参考价值。我踩过的坑是:早期用固定长度压测,调出来一个"最优 Batch Size",上线之后发现完全不是那么回事,因为线上请求长度方差很大,调度器实际组出来的 Batch 和压测时完全不一样。
3.3 如何从调度器日志中读出真实的 Batch 行为
SGLang Omni 的调度器会输出运行日志,里面包含了每个 Batch 的实际大小、组 Batch 的时间、执行时间等信息。这些日志是性能验证的金矿,但很多人不看。
我通常会从日志里提取这么几个量:实际 Batch Size 的时间序列、队列等待时间的分布、Batch 执行时间的分布、以及抢占或重调度的发生频率。把这些画出来,你就能看到调度器在真实负载下的行为模式。
比如有一次我发现,配置里写的 max batch size 是 128,但日志里实际 Batch Size 长期在 30 到 50 之间波动,从来没到过 128。查了半天原因,发现是 KV Cache 的显存限制——每个请求的 KV Cache 占用比预期大,显存根本放不下 128 个请求的 KV Cache,调度器被迫把 Batch Size 压下来了。
这个信息如果只看吞吐和延迟曲线是发现不了的,必须从调度器日志里才能读出来。所以我的建议是:性能验证一定要结合调度器日志一起看,不能只看端到端的指标。
4. Batch Size 与调度策略的联动关系拆解
4.1 为什么"调大 Batch Size 就能提吞吐"是个危险的经验
"Batch Size 越大吞吐越高"这个经验来自传统深度学习训练。训练的时候,整个数据集是固定的,Batch Size 越大,梯度估计越准,GPU 利用率越高,单位时间内处理的样本数越多。这个逻辑在训练场景下基本成立。
但推理场景不一样。推理的请求是流式到达的,Batch Size 大了之后,每个 Batch 的执行时间变长,新来的请求要等更久才能被处理。如果请求到达速率跟不上 Batch 的执行速率,GPU 就会出现"等活干"的空闲期,吞吐反而下降。
更关键的是,SGLang Omni 的连续批处理机制下,Batch Size 大了之后,KV Cache 的显存压力急剧上升。一旦显存不够,调度器要么拒绝新请求,要么触发抢占——把正在跑的请求踢出去,腾出显存给新请求。抢占的代价很高,被踢出去的请求要重新计算,浪费算力。
我实测过一组对比数据,在同样的硬件和模型下:
| 配置 Batch Size | 实际平均 Batch | 吞吐 (tokens/s) | P99 TTFT (ms) | 抢占次数 |
|---|---|---|---|---|
| 32 | 28 | 1850 | 120 | 0 |
| 64 | 52 | 2400 | 280 | 2 |
| 128 | 71 | 2650 | 890 | 17 |
| 256 | 68 | 2480 | 2100 | 43 |
可以看到,Batch Size 从 32 调到 128,吞吐确实涨了,但 P99 TTFT 从 120ms 涨到了 890ms,涨了 7 倍多。而且抢占次数从 0 涨到了 17 次。到了 256,吞吐反而下降了,因为抢占太频繁,大量算力浪费在重计算上。
这组数据说明一个问题:Batch Size 和吞吐不是单调关系,存在一个拐点,过了拐点之后吞吐不升反降。而且这个拐点的位置,取决于你的硬件配置、模型大小、请求长度分布、以及到达速率。没有一个通用的"最优值"。
4.2 调度器的抢占机制:什么时候它会"反悔"
SGLang Omni 调度器的抢占机制,是我觉得最值得深入理解的部分。它有点像操作系统的进程调度——当资源不够的时候,把一些正在运行的进程挂起,腾出资源给更重要的进程。
在 SGLang Omni 里,抢占通常发生在显存不够的时候。调度器发现新请求的 KV Cache 放不下,就会选择一些正在运行的请求,把它们的状态保存下来,释放显存,让新请求先跑。被抢占的请求等显存空出来之后再恢复执行。
这个机制的好处是提高了显存利用率和请求的公平性,但代价是被抢占的请求要重新计算已经生成的部分。如果抢占频繁发生,大量算力就浪费在重复计算上了。
我在日志里观察到,抢占的发生和 Batch Size 的关系不是线性的。Batch Size 在某个阈值以下时,几乎不发生抢占;一旦超过阈值,抢占次数会急剧上升。这个阈值取决于单个请求的平均 KV Cache 占用和总显存容量。
计算这个阈值的方法不复杂:假设模型有 L 层,每层 KV Cache 的维度是 d,那么单个 token 的 KV Cache 占用大约是 2 * L * d * 精度字节数。乘以请求的平均长度,就是单个请求的平均 KV Cache 占用。总显存减去模型权重和其他开销,剩下的除以单个请求的 KV Cache 占用,就是理论上能同时容纳的请求数。
但这个理论值往往偏乐观,因为实际 KV Cache 有碎片,而且请求长度有方差。我一般会把这个理论值打个七折作为实际的安全上限。
4.3 不同优先级请求混跑时,调度器会怎么选
真实业务场景里,请求往往不是同质的。可能有付费用户的请求和免费用户的请求混在一起,可能有实时请求和后台任务混在一起。SGLang Omni 的调度器支持给请求设置优先级,但优先级怎么影响调度决策,很多人不清楚。
我通过日志分析发现,调度器在处理优先级的时候,大致遵循这么几个原则:高优先级请求会优先被编入 Batch;当显存不够时,低优先级请求会先被抢占;在队列里等待时,高优先级请求的等待时间上限更低。
但这里有个坑:如果高优先级请求的到达速率超过了系统的处理能力,低优先级请求可能会被无限期饿死。我遇到过这种情况,一个高优先级的实时任务把 GPU 占满了,后台的批量处理任务等了几个小时都没跑完。
解决办法是给低优先级请求设置一个"保底配额",比如保证至少 10% 的算力分配给低优先级请求。SGLang Omni 本身没有直接提供这个配置,但可以通过限制高优先级请求的最大并发数来间接实现。
5. 真实场景下的调度取舍决策框架
5.1 在线对话场景:延迟优先,Batch Size 要"克制"
在线对话场景的特点是请求稀疏到达、用户对延迟敏感、单次请求的输出长度通常不长。这种场景下,调度策略应该以降低 TTFT 和 TPOT 为首要目标。
我的经验是,在线对话场景下 Batch Size 不宜设得太大,一般控制在 32 到 64 之间。原因是:对话请求的到达间隔通常比较大,Batch Size 设大了之后,调度器为了凑 Batch 会等更久,反而增加了 TTFT。而且对话场景的请求长度方差大,大 Batch 里混着长请求和短请求,短请求会被长请求拖累。
具体配置上,我会把 max batch size 设在 48 左右,同时开启请求的优先级调度,让新到达的请求能尽快被处理。另外,KV Cache 的显存预留要充足,避免因为显存不够触发抢占。
实测下来,这套配置在对话场景下能把 P99 TTFT 控制在 200ms 以内,TPOT 在 30ms 左右,用户感知的响应速度很流畅。
5.2 离线批量场景:吞吐优先,但要防"假吞吐"
离线批量场景和在线场景正好相反。请求密集到达,没人在线等结果,目标是尽快把所有请求处理完。这种场景下 Batch Size 可以拉大,但要注意一个陷阱:假吞吐。
所谓假吞吐,是指吞吐指标看起来很高,但实际上大量算力浪费在抢占和重计算上了。我见过一个案例,配置 Batch Size 设到 256,吞吐指标显示 3000 tokens/s,看起来很漂亮。但看调度器日志发现,抢占次数极高,实际有效吞吐(扣除重计算浪费的算力)只有 1800 tokens/s 左右。
识别假吞吐的方法是:对比总生成 token 数和总计算 token 数。如果后者明显大于前者,说明有大量重计算。SGLang Omni 的日志里会记录每个 Batch 的实际计算量,把一段时间内的数据加起来就能算出来。
离线场景下我的建议是:Batch Size 设在显存能容纳的请求数的 60% 到 70% 左右,留出余量给请求长度的波动。同时监控抢占次数,如果抢占次数超过总 Batch 数的 5%,就说明 Batch Size 设大了,要往回调。
5.3 混合场景:怎么给不同优先级的请求分配算力
混合场景是最复杂的,也是真实业务中最常见的。比如一个系统既要处理实时对话请求,又要跑后台的批量任务。这时候调度策略的核心是算力隔离。
我的做法是把 GPU 资源在逻辑上分成两个池:一个池专门跑实时请求,Batch Size 设小,延迟优先;另一个池跑批量任务,Batch Size 设大,吞吐优先。两个池的算力比例根据业务需求动态调整。
SGLang Omni 本身不直接支持这种资源池的划分,但可以通过部署两个独立的服务实例来实现,每个实例配置不同的调度参数,然后用一个上层的负载均衡器把请求分发到对应的实例。
这种方案的代价是需要更多的 GPU 资源,因为两个实例各自要预留显存。如果资源紧张,退而求其次的方案是在同一个实例里用优先级调度,给实时请求设高优先级,批量任务设低优先级,同时限制批量任务的最大并发数,防止它把实时请求饿死。
5.4 一个可复用的决策清单
为了方便大家在实际项目中做决策,我整理了一个清单,按顺序回答这些问题,基本就能确定调度策略的方向:
- 你的场景是在线交互还是离线批量?在线交互延迟优先,离线批量吞吐优先。
- 延迟指标里,用户最在意的是 TTFT 还是 TPOT?对话场景看 TTFT,长文本生成看 TPOT。
- 你的请求长度分布是什么样的?方差大不大?方差大意味着 Batch 内请求的完成时间差异大,调度器需要更频繁地重组 Batch。
- 你的显存能容纳多少个请求的 KV Cache?这个数字决定了 Batch Size 的物理上限。
- 你的请求到达速率是多少?到达速率低的时候,Batch Size 设大了反而增加延迟。
- 有没有多优先级请求混跑?有的话需要设计算力隔离方案。
- 你能接受的抢占频率是多少?抢占频率高说明 Batch Size 或并发数设大了。
把这七个问题的答案写下来,调度策略基本就清晰了。剩下的就是压测验证和微调。
6. 踩过的坑与实测经验
6.1 只看平均值不看分位数,被 P99 教做人
这是我早期做性能验证时犯的最大的错误。压测报告里只写了平均延迟和平均吞吐,看起来一切正常。上线之后用户投诉不断,查了才发现 P99 延迟是平均值的十几倍。
原因很简单:平均值被大量快速请求拉低了,掩盖了少量慢请求的问题。而在真实业务里,用户感知的恰恰是那些慢请求。一个用户等了 3 秒才收到回复,他不会关心其他 99 个用户只等了 100ms。
所以性能验证一定要看分位数,至少要看 P50、P95、P99 三个点。如果业务对延迟极其敏感,还要看 P99.9。SGLang Omni 的 benchmark 工具支持输出分位数,但默认可能只输出平均值,需要手动开启。
6.2 压测环境和生产环境不一致导致的"数据失真"
另一个常见的坑是压测环境和生产环境不一致。我见过最离谱的案例是:压测的时候用的是单卡,生产环境是多卡;压测的时候模型是 FP16,生产环境是 INT8 量化。这种情况下压测数据完全没有参考价值。
即使硬件和模型一致,还有一些隐性差异会影响结果:CUDA 版本、驱动版本、SGLang Omni 的版本、甚至 GPU 的温度和功耗状态。我建议压测环境尽可能和生产环境保持一致,如果做不到,至少在报告里注明差异,并且对结果保持谨慎。
还有一个容易被忽略的点:压测时的请求内容。如果压测用的请求都是短文本,而生产环境的请求有长有短,那压测结果会偏乐观。因为短文本的 KV Cache 占用小,Batch Size 可以设得更大,延迟也更低。
6.3 调度器参数调整后,为什么效果和预期相反
有一次我把 max batch size 从 64 调到 128,预期吞吐会提升,结果吞吐反而下降了。查了半天日志,发现原因是:Batch Size 调大之后,单个 Batch 的执行时间变长,调度器组 Batch 的频率降低,导致 GPU 在 Batch 之间的空闲时间增加。而且因为显存压力增大,抢占次数上升,进一步浪费了算力。
这个经历让我明白一个道理:调度器参数之间是相互影响的,不能孤立地调一个参数。调 Batch Size 的时候,要同时关注 KV Cache 的显存分配、请求队列的管理策略、以及抢占的触发条件。这些参数是一个整体,牵一发而动全身。
我的做法是:每次只调一个参数,调完之后跑一轮完整的压测,记录所有关键指标的变化。如果多个指标同时恶化,说明这个调整方向不对,要回滚。如果部分指标改善、部分恶化,就要根据业务需求判断是否值得。
6.4 关于 SGLang Omni 版本升级的注意事项
SGLang Omni 还在快速迭代中,版本升级可能会改变调度器的行为。我遇到过升级之后,同样的配置下 Batch Size 的实际分布变了,延迟指标也变了。所以升级之前一定要在测试环境跑一轮完整的回归测试,对比升级前后的关键指标。
另外,升级的时候要注意配置文件的兼容性。有些参数在新版本里可能被重命名或者废弃了,如果不注意,可能会用默认值覆盖你的配置,导致行为不符合预期。我一般会在升级后检查一遍调度器的启动日志,确认所有关键参数都按预期生效了。
7. 一些个人体会
回到开头那场争论,最后我的结论是:两个人说的都对,但都不完整。Batch Size 设 256 在离线批量场景下确实能提吞吐,但前提是显存够、抢占少;Batch Size 设 32 在在线对话场景下确实能保延迟,但吞吐上不去,成本会高。
真正重要的不是找到一个"正确"的 Batch Size,而是理解你的场景需要什么,然后让调度器围绕这个需求去工作。SGLang Omni 的调度层提供了足够的灵活性来做这件事,但前提是你得知道自己在调什么、为什么调。
我现在做性能验证的流程基本固定下来了:先明确场景和核心指标,再搭一套贴近真实的流量模型,然后结合端到端指标和调度器日志一起分析,最后根据业务需求做取舍。这套流程跑下来,虽然不能保证找到"最优解",但至少能保证不会做出错误的决策。
如果你也在用 SGLang Omni,我的建议是:多看看调度器的日志,那里面的信息比端到端指标丰富得多。很多问题的答案,其实调度器早就告诉你了,只是你没去看。