用离散事件仿真评估LLM推理性能:从压测困境到容量规划
我最初接触大规模 LLM 推理服务的性能评估时一直有种“使不上劲”的感觉。线上服务不敢随便压压测环境搭起来又贵又慢最难受的是真实流量永远无法重放——同一个高峰时段的负载特征每次出现都不一样根本没法做对照实验。后来接触到 VIDUR 这样的大规模模拟框架这个问题才算真正打开了口子。简单说VIDUR 是一个专为 LLM inference 场景设计的离散事件仿真框架它把一次推理请求的生命周期、调度策略、显存占用和 token 级时间推进全部建模进模拟器里用可控、可重复、低成本的仿真实验来代替大部分真实压测。如果你和我一样既要评估现有服务容量又要给未来扩容找依据这篇实战总结应该对你有参考价值。1. 为什么 LLM 推理系统需要仿真而不是直接堆压测1.1 真实压测的三堵墙成本、不可重放、尾延迟难复现先说成本。一台主流 GPU 服务器的每小时费用并不是小数想在多台机器上跑满真实负载随便一个 24 小时实验就是大几千的开销还占用大量的人力去盯监控、捞日志。更麻烦的是压测场景一旦换一个输入分布之前的实验基本作废又得重新跑一轮。然后是“不可重放”。真实用户请求的到达时间、prompt 长度、生成长度之间的关系非常复杂高峰时段和低谷时段完全是两种特征。我们团队曾经想把线上某一天的高峰流量完整录下来拿回去压测结果光是清洗日志、对齐时间戳、拼接完整会话就花了两天。就算拼出来了也没有办法保证第二次压测时负载生成器能原样重现当时的并发抖动。没有可重放的条件做 A/B 对比就是空中楼阁。最隐蔽的是尾延迟问题。LLM 推理服务的核心指标除了平均在线人数更重要的是 P99 首 token 延迟。真实压测时稍微一点网络抖动、邻居噪音、调度毛刺都会让尾延迟失真。我们经常测到一批结果P50 很漂亮P99 却忽高忽低根本分不清是系统瓶颈还是环境噪声。1.2 模拟器的价值把“跑实验”变成“算实验”模拟框架的本质是把系统的关键行为全部抽象成离散事件。比如一个用户请求什么时候到达、占据了多大显存、需要经历多少个 decode 步骤、系统在什么时间点把它调度到 GPU 上这些在模拟器里都是以事件的形式精确排列的。这样一来实验就变成了一种“计算”而不是“运行”。我可以在十分钟内把同样的负载在三种不同的调度策略、两种不同的显存预算、甚至三种不同的请求长度分布下各跑一遍拿到完全确定的对比数据。同一场景可以跑一百次结果完全一致因为随机种子由我控制。1.3 传统 Web 压测模型为什么套不上 LLM 推理很多人一提到“模拟”就想用经典的排队论模型或者传统 Web 服务压测工具那种“请求-响应”一步式模型。但对 LLM 推理来说一个请求的“处理”不是一个原子操作它至少要经历 prefill 和 decode 两个阶段。prefill 阶段一次性处理输入 token计算密集GPU 利用率一下子拉满decode 阶段逐 token 自回归生成每个 token 走一步访存密集吞吐受显存带宽限制。再加上 KV cache 在序列长度增长时持续占用显存请求之间还会因为动态 batch 相互影响。传统压测模型完全表达不了这种时间维度上的变化。VIDUR 这类框架把请求拆分到 token 级别去推进时间才是真正贴合 LLM 推理特性的模拟方式。2. VIDUR 核心设计拆解一个请求在模拟器里是怎么走完一生的2.1 请求生命周期从到达、排队到执行的完整事件链第一次打开 VIDUR 源码时我最关心的就是它怎么表示一个请求的生命周期。如果你了解过离散事件模拟会发现它的世界是一个“事件队列”驱动的每个事件都带着一个时间戳模拟器永远处理时间戳最小的事件然后产生后续事件。一个请求的故事是这样开始的到达事件模拟器根据负载生成器的设定在某个时间点把一个请求“放进门”。排队事件请求进入调度器的等待队列此时它不带执行权只是躺在队列里记录已等待时长。调度事件调度器根据策略选中一批请求分配给某个模拟 GPU 设备。prefill 完成事件执行器根据该请求的输入长度和当前设备的 prefill 吞吐能力算出一个完成时间时间推进到那一刻。decode 步进事件每生成一个 token时间推进一个“token 步进”直到最大生成长度达到或 EOS 触发。释放事件请求结束KV cache 被释放模拟器把设备容量签名减掉。这种事件驱动的表示方式让我非常满意的一点是它天然尊重了真实系统里的并发交错。比如两个请求同时竞争显存、新请求插队、某个长请求拖住整批调度这些都会在事件的时间顺序上自然体现出来。2.2 prefill 与 decode 双阶段模型的时间推进逻辑很多初学者理解不了为什么 prefill 和 decode 要分开建模我举个例子。假设一个请求有 2000 个输入 token要生成 400 个输出 token。在一个简化模型里prefill 阶段可以看成“一次性把 2000 个 token 塞进 GPU 并行算完”耗时取决于 GPU 的 prefill 吞吐比如每秒 20k tokens那就需要 100 毫秒左右。而 decode 阶段是“一步一个 token”400 个输出 token 就是 400 个串行的解码步每一步的耗时取决于当前 batch 大小下的吞吐率。这两种阶段的耗时形态完全不同。prefill 是“宽而短”的工作decode 是“窄而长”的工作。VIDUR 在事件调度上做得很细prefill 完成时它会计算一个绝对时间戳decode 开始后每一个 token step 推进的时间间隔又由当时设备负载和 KV cache 占用情况动态决定。2.3 KV cache 与显存预算模拟器里的“物理世界边界”KV cache 是 LLM 推理在高并发下最致命的约束。简单理解KV cache 就是为每个请求的每个 token 保存的 key-value 向量序列越长缓存越大。就算你把 prefill 算得飞快若显存放不下新的 KV cache调度器仍然必须把请求挡在门外。在实际建模中KV cache 占用的计算方式可以按层数、注意力头数、每个 head 的维度、每 token 字节数来精确推算。我曾经把线上某个服务模型的参数代入层数 32 层头数 32每头维度 128每个 key 和 value 各占 2 字节半精度那么每个 token 的 KV cache 占用约为2key 和 value乘以层数乘以头数乘以每头维度乘以每字节 2算下来大概是 524KB 每 token 左右这里稍微展开计算一下avg_key_value_bytes_per_token 2 (K/V) * 32 (layers) * 32 (heads) * 128 (head_dim) * 2 (bytes) 524,288 bytes大约 512 KB 一个 token。当几百个并发请求都生成长文本时显存爆炸是非常快的事情。VIDUR 模型里把这层约束直接放进 scheduling 的前置条件当设备剩余可用 KV cache 不够任何请求都不能被调度进来哪怕算力还有剩余。这一点非常关键因为很多人在估容量时只看算力结果实际跑起来显存先爆。2.4 调度策略模块从 FCFS 到连续批处理的模拟实现调度器是模拟框架的“大脑”。VIDUR 里自带的调度器实现了几种主流策略我最常用的是三种先到先服务FCFS真实系统兜底策略短作业优先SJF预判请求生成长度来排序优先级抢占VIP 请求可以插到队头有一点需要说清楚真实的 LLM 推理服务普遍采用连续批处理continuous batching也就是某个请求提前结束它占的 slot 立刻让给新请求而不是等整批都结束再统一换一批。VIDUR 的调度事件里对连续批处理的支持和真实系统是一致的模拟时它不会傻等整批结束而是每个 token step 都会检查是否有释放事件触发调度器重新决策。3. 用 VIDUR 搭起一次完整的模拟实验从负载画像到结果解读3.1 第一步把你对负载的全部认知塞进请求生成器模拟实验的准确性一半取决于负载画像写得好不好。我第一次用 VIDUR 时图省事直接传入“每个请求平均 2000 输入 token、生成 300 token、QPS 恒定”结果模拟结果和线上真实数据差得离谱。后来我总结了一套负载建模的流程拉取线上网关日志统计一个完整业务周期的到达事件按 5 分钟粒度画出 QPS 曲线。分别统计 prompt 长度和生成长度的分布。这里不要只看平均值要关注重尾很多场景下 90 分位比均值大两三倍。检查请求之间的关联。比如某个上游故障可能导致一批请求同时重试这类突发也要写进负载配置里。3.2 模拟实验的 YAML 配置示例这里给一个 VIDUR 风格的最小配置示例字段命名参考常见实现不同版本可能有出入simulation: duration_seconds: 7200 seed: 42 model: prefill_throughput_tokens_per_sec: 50000 decode_throughput_table: # batch_size: tokens_per_sec 1: 60 8: 120 16: 180 32: 220 64: 250 kv_bytes_per_token: 524288 total_kv_cache_bytes: 34359738368 load: arrival_process: type: trace reference_file: ./trace_peak_hour.json prompt_length: distribution: lognormal mean: 1800 sigma: 1200 generation_length: distribution: generalized_extreme_value loc: 200 scale: 150 scheduler: strategy: fcfs max_batch_size: 64 request_queue_limit: 1024 metrics: output_interval_seconds: 60 log_all_requests: true3.3 输出指标哪些数字才是决策依据跑完模拟后别急着看控制台刷出来的平均数。我通常把输出分成三组来分析时延类TTFT首个 token 延迟、TPOT每输出 token 延迟、端到端时延。效率类GPU 设备平均利用率、显存峰值占用、请求排队长度随时间变化。稳定性类P95/P99 时延、超时请求占比、尾部事件的时间段分布。用表格对照看更直观指标含义我关注的原因TTFT收到请求到首个 token 输出直接影响用户体验也是排队问题的第一征兆TPOT每个输出 token 的耗时反映 decode 阶段的系统吞吐瓶颈显存峰值模拟单位时间内 KV cache 总占用判断节点是否会被显存打爆请求失败率排队超过阈值被拒绝的比例容量规划的硬上限依据3.4 一个典型实验的结论SJF 并不会解决所有问题有一次我想验证“短作业优先”能不能降低系统整体时延。模拟设置是 QPS 50prompt 长度 500 到 5000 之间随机生成长度 100 到 800。跑了 30 分钟后对比 FCFS 和 SJFSJF 让短请求的 TTFT 大幅优化P50 从 800ms 降到 500ms但长请求的端到端时延几乎翻倍最夸张的几个甚至超时整体 P99 并没有显著改善因为长请求堆积后把显存拖住了系统可用并发量下降这个结论告诉我们调度策略优化不是单纯的排序问题它必须和显存分配、响应时间目标绑定在一起考虑。模拟的价值就在这里我可以毫无成本地试一套激进策略看看它带来的副作用再决定要不要在真实系统里配置。4. 模拟不等于真实校准与验证环节才是全家桶的灵魂4.1 为什么不能用理论值直接跑模拟很多模拟框架上手容易但人们怀疑它是因为所有关键参数都是理想值。最典型的坑是 decode 吞吐率。如果你在配置里填了一个“理论峰值 300 token/s”模拟结果会显得非常乐观因为现实中 GPU 的 decode 吞吐受批处理大小、序列长度、量化方式、甚至 CPU 负责的采样环节影响会有明显波动。我把这种误差称为“模拟器的乐观偏见”。它会导致你按模拟结果规划了 10 台机器结果真实流量来了 12 台都扛不住。4.2 校准吞吐曲线的完整流程校准的办法不复杂关键是得有方法。我推荐的方式准备一个压测工具控制输入长度、输出长度、并发数打到真实服务上。固定输入长度比如 1024 token改变并发数记录实际达到的 decode 吞吐量。把不同并发下的吞吐量做成查找表填进模拟器的配置。连续跑几个不同场景把模拟输出的平均 TTFT 和实测压测的平均 TTFT 对比计算系统级误差常数。这里举一个实际校准数字某批测试中测量到的 decode 吞吐对照表如下并发数实测 decode 吞吐token/s模拟使用值155558110110161651653220520564225225填进去之后再跑模拟P50 误差能控制在 5% 以内P99 误差大一些但也基本到 10% 左右可以接受了。4.3 校准之后仍然存在的偏差来源就算校准了吞吐表模拟和真实之间还有几个灰色地带。调度器本身的延迟是一个模拟器里调度一般是一个零耗时的原子动作但真实系统的调度器是线程池在跑有队列轮转开销、锁竞争、网络通讯这部分延迟在低负载时可能只有几毫秒在高负载时会被放大到几十毫秒。另一个是 CPU 与 GPU 之间的数据搬运。真实推理要经过 tokenizer、采样、内存复制等步骤模拟器默认这些不消耗时间。遇到过最极端的案例一个 4000 token 的输入请求在真实系统里 tokenizer 就要花掉 30ms如果场景里大量超大 prompt这个开销就不可忽视了。4.4 校准不是一次性工作设备、精度、框架版本都要重新标我在某次升级推理框架后没有重新校准顺手用旧模拟配置跑了一轮容量评估结果把并发上限算高了 15%。后来追查发现是框架新版本加强了连续批处理的贪心程度实际吞吐提升了但显存释放更积极峰值占用形态也变了。所以我的习惯是硬件型号变更、量化精度变化、推理框架大版本升级后都必须重新过一遍校准流程。5. 实操里的坑与个人经验你可能也会踩到的雷区5.1 负载日志的清洗比想象中更耗时模拟器的“垃圾进垃圾出”性质极强。线上网关日志里的超时请求、客户端重试请求、被熔断的请求都会影响到达模式的统计。我踩过的坑是忘记剔除客户端超时重试导致负载生成器的 QPS 被向上抬了将近 20%那轮实验直接作废。后来我把日志清洗步骤固定成管道先去掉 5 秒后仍没收到响应的请求再去掉同一 session 在 1 秒内的连续重复请求最后按业务类型打标签。5.2 重尾分布不能只配一个均值真实场景里 prompt 长度和生成长度的分布都很胖如果只把均值写进负载生成器模拟结果会比真实世界“温柔”太多。因为长请求是显存消耗大户重尾才是真实系统被打爆的起点。我的做法是直接用 trace 文件喂给 VIDUR而不是用概率分布近似。如果拿不到完整 trace至少要用分段分布描述头部和尾部例如“95% 的请求小于 2000 token剩下 5% 在 2000 到 8000 之间均匀分布”。5.3 模拟器也可能跑得不够快模拟大规模推理集群时模拟器自身的运行时长也是问题。一个 100 节点集群、QPS 500、持续 1 小时的模拟事件数量可能在千万级。这时候有些优化技巧很管用尽量减少按 token 步进来模拟 decode改为按 batch 周期聚合事件关闭 debug 级日志IO 对性能影响巨大使用批处理参数把同样的计算合并一次处理多个请求的同阶段事件5.4 框架的下一步用法容量规划、混部评估和异常演练熟练使用之后我慢慢把 VIDUR 拓展到更多决策场景。容量规划是最直接的拿上季度峰值 QPS 乘以 1.6 冗余系数看需要多少节点。混部评估也很实用——把训练任务和推理任务放在模拟器里共用同一批 GPU 资源能看到训练稳态占用的算力如何挤压推理队列这对预算分割非常有参考价值。异常演练是我最近在不断完善的用法把某个节点在模拟中途直接宕机掉观察其它节点的调度器如何重新分布负载时延冲击多大。这个场景在真实系统里演练成本太高但在模拟器里只是删掉一个节点事件的问题。我记得第一次在团队里展示模拟结果时大家还是半信半疑的直到我用模拟器预判出“当前节点数在两周后的新版本上线时必然超时”并且后续线上监控数据基本印证了这一点同事们才真正接受这套工作流。模拟不是用来替代压测的一劳永逸工具而是帮你把空间感、时间感建立起来的一把手电筒——照清楚系统在重压下到底会发生什么。