ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

AI系统性能工程实战:从推理引擎到成本优化的全链路拆解

AI系统性能工程实战:从推理引擎到成本优化的全链路拆解 1. AI 系统性能工程的核心命题拆解1.1 为什么“性能”在 AI 系统里是个完全不同的物种传统后端系统的性能工程核心指标无非是 QPS、P99 延迟、CPU 利用率、内存占用这几样调优手段也相对成熟——加缓存、拆服务、异步化、连接池调参一套组合拳打下来基本能解决八成问题。但 AI 系统一上来就把这套经验打碎了。你面对的不再是一个确定性执行的函数而是一个概率性的推理过程同一个 prompt 两次调用输出长度可能差三倍耗时可能差五倍显存占用会因为 KV Cache 的动态增长而持续漂移。这意味着你没法用“压测出固定 QPS 然后按这个容量规划”的老思路来做。我在实际项目里踩过最典型的一个坑早期做推理服务容量评估时按平均输出 200 token 去算显存和吞吐结果上线后遇到长文本摘要场景单请求输出飙到 2000 tokenKV Cache 直接把显存打爆服务开始疯狂 OOM 重启。这件事让我彻底意识到AI 系统性能工程的第一性原则是面向分布而非面向均值。你必须关注 P50、P95、P99 的 token 长度分布、请求到达的突发性、以及显存随并发数的非线性增长曲线。所以这一篇我想聊的不是某个具体框架的调参技巧而是把 AI 系统性能工程拆成几个可以独立攻克的层面推理引擎层、服务编排层、资源调度层、以及观测与压测层。每一层都有它独特的性能瓶颈和优化逻辑混在一起谈只会越谈越乱。1.2 推理引擎层性能的第一战场推理引擎是整个 AI 系统性能的地基。不管你上层用的是什么服务框架最终 token 是一个一个从引擎里吐出来的。这一层的核心矛盾是计算密度与显存带宽的博弈。大模型推理分两个阶段Prefill预填充和 Decode解码。Prefill 阶段是把整个输入 prompt 一次性过一遍模型计算密集GPU 算力吃满属于 compute-boundDecode 阶段是逐 token 生成每生成一个 token 都要把整个模型的权重读一遍属于 memory-bound。这两个阶段的性能特征完全不同优化手段也完全不同。我见过很多团队在优化时犯的一个错误拿 Prefill 的吞吐数据去推算 Decode 的性能或者反过来。这两个阶段的耗时占比会随着输入输出长度比例剧烈变化。短输入长输出比如聊天以 Decode 为主长输入短输出比如分类、抽取以 Prefill 为主。你得先搞清楚自己的业务落在哪个区间才知道该往哪个方向使劲。具体到引擎选型目前主流的有几条路线vLLM 的 PagedAttention、TensorRT-LLM 的 kernel 融合、SGLang 的 RadixAttention 前缀缓存。它们解决的核心问题不一样。PagedAttention 解决的是 KV Cache 的显存碎片化问题把 KV Cache 按页管理显存利用率能从 60% 出头拉到 90% 以上RadixAttention 解决的是多请求共享前缀的重复计算问题在 system prompt 很长或者多轮对话场景下收益巨大。选哪个不是看谁 benchmark 分数高而是看你的业务请求有没有共享前缀、并发模式是长连接还是短请求。1.3 服务编排层被低估的性能杀手很多人把模型部署起来就以为完事了实际上服务编排层的开销在中小规模下可能比推理本身还大。我做过一次 profiling一个看似简单的请求链路——网关鉴权、prompt 模板拼接、tokenize、排队、推理、detokenize、流式返回——其中 tokenize 和 detokenize 在某些实现下能占到端到端延迟的 15% 到 20%。尤其是当你的 tokenizer 是 Python 实现且没有做批处理时CPU 会成为隐形瓶颈。还有一个更隐蔽的问题请求排队策略。默认的 FIFO 队列在 AI 场景下是灾难。因为 AI 请求的耗时差异极大一个长请求堵在前面后面所有短请求都得等P99 延迟直接爆炸。正确的做法是引入**连续批处理Continuous Batching**配合优先级调度让短请求能插队长请求在后台慢慢跑。vLLM 和 TensorRT-LLM 都内置了 continuous batching但调度策略需要你自己根据业务调。流式返回也是个容易出问题的地方。SSEServer-Sent Events连接如果没做好背压控制客户端消费慢的时候服务端会积压大量未发送的 token内存持续上涨。我建议在流式链路上加一个带缓冲上限的 channel超过阈值就暂停从引擎拉取形成反压。2. 显存管理与 KV Cache 的深度优化2.1 显存到底被谁吃掉了做 AI 系统性能优化第一件事是把显存账算清楚。一张 80G 的 A100跑一个 70B 的模型FP16权重本身就占 140G根本放不下所以必须做量化或者张量并行。就算跑 7B 模型权重占 14G剩下的显存要分给 KV Cache、激活值、CUDA context、通信 buffer。很多人算容量时只算了权重结果一上线就发现 KV Cache 根本不够用。KV Cache 的大小是可以精确计算的。公式是2 × num_layers × num_kv_heads × head_dim × seq_len × batch_size × dtype_bytes。注意这里的num_kv_heads在 GQAGrouped Query Attention架构下远小于num_attention_heads这是 LLaMA 2 70B、Qwen 等模型能省显存的关键。以 LLaMA 2 7B 为例32 层32 个 KV headhead_dim 128FP16 存储那么每个 token 的 KV Cache 是2 × 32 × 32 × 128 × 2 512KB。一个 4096 长度的请求就要占 2G 显存。如果你并发 32 个这样的请求KV Cache 就要 64G比模型权重还大。这个计算说明一个道理在长上下文高并发场景下KV Cache 才是显存的主要消费者而不是模型权重。所以优化方向就很明确了——要么压缩 KV Cache要么提高它的复用率。2.2 PagedAttention 与显存碎片在没有 PagedAttention 之前KV Cache 是按请求预分配的连续显存块。问题是每个请求的实际生成长度不可预知你只能按最大长度预留造成大量内部碎片。实测下来传统方案的显存有效利用率只有 50% 到 60%一半显存被浪费在预留但没用上的空间里。PagedAttention 的思路借鉴了操作系统的虚拟内存分页把 KV Cache 切成固定大小的 block比如 16 个 token 一块用 block table 做逻辑到物理的映射。这样请求之间可以共享物理块前缀相同的请求直接指向同一块物理显存不用复制。显存利用率能拉到 90% 以上而且支持了前缀共享和 beam search 的显存复用。但 PagedAttention 不是没有代价的。block 的粒度选择是个权衡block 太小block table 本身占显存且寻址开销大block 太大内部碎片又回来了。vLLM 默认 16实测在大多数场景下是个合理的平衡点。如果你的请求长度普遍很短比如 128 以内可以调到 8如果普遍很长调到 32 能减少 block table 开销。2.3 前缀缓存多轮对话的性能救星多轮对话场景有个特点每一轮请求都包含之前所有轮次的完整历史。如果每轮都重新计算整个历史的 KV那计算量随轮次线性增长延迟越来越慢。前缀缓存Prefix Caching就是解决这个问题的——把之前算过的 KV 按前缀 hash 缓存起来新请求来了先查缓存命中的部分直接复用。SGLang 的 RadixAttention 用基数树管理前缀缓存支持任意长度的公共前缀匹配不只是完整请求级别。实测在多轮对话场景下首 token 延迟能降低 60% 到 80%。但这里有个坑缓存淘汰策略。如果缓存无限增长显存会被吃光如果淘汰太激进命中率又上不去。我一般用 LRU 配合一个显存水位线超过 85% 就开始淘汰最久未用的前缀块。注意前缀缓存对 prompt 的格式非常敏感。如果你的 system prompt 里带了时间戳、随机 ID 这类每次都变的内容缓存永远命中不了。做缓存优化前先把 prompt 里的动态部分挪到末尾让公共前缀尽可能长。3. 批处理策略与吞吐延迟的平衡术3.1 静态批处理为什么不够用最早的推理服务用的是静态批处理攒够 N 个请求或者等 M 毫秒凑一批一起送进模型等这批全部生成完再返回。这个方案的问题在于批内请求的生成长度不一样短的早就生成完了但必须等最长的那个结束才能释放资源。GPU 在等待期间大量空转利用率可能只有 30% 到 40%。更糟的是延迟。一个短请求如果运气不好跟一个长请求分到同一批它的延迟会被长请求拖累。在聊天场景下用户等 10 秒才看到第一个字体验直接崩了。所以静态批处理只适合离线批量推理不适合在线服务。3.2 连续批处理的调度逻辑连续批处理Continuous Batching也叫 iteration-level batching的核心思想是批的粒度从“请求级”降到“迭代级”。每一步 decode 时调度器检查哪些请求已经生成完了遇到 EOS 或达到 max_tokens把它们踢出批同时把队列里等待的新请求加进来。这样 GPU 每一步都在处理一个满批利用率能拉到 80% 以上。但连续批处理引入了一个新问题批内请求的 Prefill 和 Decode 混在一起。新加入的请求需要做 Prefill而已在批内的请求在做 Decode两者的计算特征不同混在一起会互相干扰。vLLM 的处理方式是把 Prefill 和 Decode 分到不同的 step或者用 chunked prefill 把长 Prefill 切成小块穿插在 Decode 之间。这个策略的选择对延迟影响很大优先 Prefill 能降低首 token 延迟优先 Decode 能提高整体吞吐。我的经验是交互式场景优先 Prefill离线批处理优先 Decode。3.3 调度参数的实战调优连续批处理的性能高度依赖几个关键参数我列一个实战中总结的对照表参数作用调大影响调小影响推荐起点max_num_seqs单批最大请求数吞吐升显存压力大吞吐降延迟低显存的 70% 能容纳的请求数max_num_batched_tokens单批最大 token 数Prefill 吞吐升首 token 延迟降2048 到 4096block_sizeKV Cache 块大小寻址开销降碎片增碎片降开销增16gpu_memory_utilization显存使用上限缓存多命中率高安全但浪费0.90调这些参数没有银弹必须结合你的实际流量做 A/B 测试。我一般先用默认值跑一轮压测拿到基线然后每次只调一个参数观察 P50、P99 延迟和吞吐的变化找到拐点。记住一个原则吞吐和延迟在大多数情况下是矛盾的你要先明确业务更在乎哪个。在线聊天在乎 P99 延迟离线数据处理在乎吞吐两者的最优参数完全不同。4. 性能观测与压测体系搭建4.1 该观测哪些指标AI 系统的可观测性和传统服务有本质区别。传统服务看 CPU、内存、QPS 就够了AI 系统必须深入到 token 级别。我建议至少采集以下几类指标第一类是请求级指标端到端延迟、首 token 延迟TTFT、每 token 输出延迟TPOT、输入 token 数、输出 token 数。TTFT 和 TPOT 要分开看因为它们对应不同的瓶颈——TTFT 高通常是 Prefill 或排队问题TPOT 高通常是 Decode 或显存带宽问题。第二类是引擎级指标GPU 利用率、显存占用、KV Cache 使用率、批大小、队列长度、Prefill 和 Decode 的耗时占比。这些指标决定了你的容量水位。第三类是业务级指标不同接口的调用量、token 消耗分布、错误率、超时率。这些指标帮你把性能问题和业务变化关联起来。4.2 压测怎么做才真实AI 系统的压测比传统服务难得多因为请求本身是变长的。用固定长度的请求去压测得到的结论会严重偏离真实情况。我的做法是从生产流量里采样真实的 prompt 长度分布和输出长度分布然后按这个分布生成压测请求。具体步骤先从日志里统计输入 token 的 P50、P90、P99输出 token 的 P50、P90、P99以及请求到达的间隔分布。然后用这些分布参数驱动压测工具生成请求。压测工具我推荐用 locust 或 k6 做请求编排配合自定义的 token 长度采样逻辑。不要用那些只会发固定请求的简单工具测出来的数据没有参考价值。压测时还要注意预热。模型第一次加载、CUDA kernel 第一次编译、缓存第一次填充这些都会让初始阶段的性能数据失真。我一般先跑 5 分钟预热流量等各项指标稳定后再开始正式采集。4.3 一个真实的性能问题排查案例说个我实际遇到的案例。某次上线后监控显示 P99 延迟从 2 秒涨到了 8 秒但 GPU 利用率反而从 75% 降到了 50%。这个组合很反直觉——利用率降了延迟反而涨了说明瓶颈不在 GPU 计算上。排查思路是这样的先看队列长度发现队列在涨说明请求进得来出不去再看 TTFT 和 TPOT发现 TTFT 正常但 TPOT 暴涨说明 Prefill 没问题Decode 阶段卡住了最后看 KV Cache 使用率发现接近 100%触发了频繁的缓存淘汰和重计算。根因是那段时间业务侧上线了一个长上下文功能平均输出长度翻倍KV Cache 需求激增显存不够导致频繁换出换入。解决方案分两步短期把gpu_memory_utilization从 0.85 提到 0.92同时把max_num_seqs降下来牺牲一点并发换显存余量长期引入前缀缓存把多轮对话的重复计算消掉。调整后 P99 回到 2.5 秒GPU 利用率回到 70%。这个案例的教训是AI 系统的性能问题往往不是单点故障而是资源配比失衡。GPU 利用率低不代表没瓶颈可能是显存先成了瓶颈导致 GPU 在等数据。5. 常见性能陷阱与排查速查5.1 那些年踩过的坑第一个坑是tokenizer 成为瓶颈。HuggingFace 的 Python tokenizer 在单线程下每秒只能处理几千个 token高并发时 CPU 直接打满。解决方案是用 Rust 实现的 tokenizer比如 tokenizers 库的 fast 版本或者把 tokenize 做成批处理一次处理一批请求。第二个坑是日志和监控本身拖慢服务。有些团队把每个 token 的生成都打一条日志或者每次推理都同步上报监控IO 开销比推理还大。正确做法是异步批量上报日志采样关键路径上不做任何同步 IO。第三个坑是模型加载和卸载的开销被忽略。在多模型场景下如果频繁切换模型加载时间可能比推理时间还长。解决方案是用模型池预热或者用 LoRA 这种轻量适配器避免全量加载。第四个坑是网络传输成为隐形瓶颈。流式返回时如果每个 token 都单独发一个 SSE 事件网络包数量巨大TCP 开销显著。应该做小批量聚合比如攒 4 到 8 个 token 发一次。5.2 排查速查表现象可能原因排查方向解决手段TTFT 高TPOT 正常Prefill 慢或排队久看队列长度、Prefill 耗时加 chunked prefill、调调度优先级TTFT 正常TPOT 高Decode 慢或显存带宽瓶颈看 KV Cache 使用率、GPU 带宽降并发、开前缀缓存、量化 KVGPU 利用率低但延迟高显存或 CPU 瓶颈看显存占用、CPU 利用率调显存配比、优化 tokenizer延迟随并发线性增长资源不足看各项资源水位扩容或降级延迟突然跳变缓存淘汰或 GC看缓存命中率、GC 日志调缓存策略、优化内存分配吞吐上不去批大小受限看 max_num_seqs、显存调批参数、量化模型5.3 几个反直觉的经验经验一量化不一定能提速。INT8 量化能省显存但在某些 GPU 上反量化开销可能抵消掉省显存带来的收益。实测在 A100 上FP16 到 INT8 的吞吐提升只有 20% 到 30%远低于理论值。量化前一定要做 benchmark。经验二更大的批不一定更快。批大小超过某个阈值后GPU 计算单元饱和延迟线性增长但吞吐不再提升。这个阈值取决于模型大小和 GPU 型号必须实测。经验三多卡并行不是免费的。张量并行能放下更大的模型但卡间通信开销随卡数增加。2 卡并行的通信开销可能只占 5%8 卡可能占到 30%。如果单卡能放下就别用多卡。6. 从性能工程到成本工程6.1 性能优化的终点是成本做性能工程做到最后你会发现所有优化最终都指向同一个问题单位 token 的成本。吞吐提升一倍意味着同样的硬件能服务两倍的流量成本减半。延迟降低一半意味着可以用更少的实例支撑同样的并发。所以性能指标和成本指标是同一枚硬币的两面。我习惯用一个综合指标来衡量优化效果每美元每小时的 token 产出量。这个指标把吞吐、硬件成本、利用率全揉在一起比单纯的 QPS 或延迟更能反映真实价值。优化前先算这个基线优化后再算一次提升幅度一目了然。6.2 成本优化的几个杠杆最大的杠杆是提高 GPU 利用率。一块 A100 满载和半载成本是一样的但产出差一倍。把利用率从 40% 拉到 80%等于成本直接减半。手段就是前面说的连续批处理、前缀缓存、显存优化。第二个杠杆是选对硬件。不是所有场景都需要 A100/H100。7B 以下的模型在 L4、A10 这类卡上跑性价比可能更高。推理场景对显存带宽敏感对算力相对不敏感选卡时优先看显存带宽和容量而不是算力峰值。第三个杠杆是弹性伸缩。AI 流量往往有明显的波峰波谷按峰值配置资源意味着低谷期大量浪费。用 K8s 的 HPA 配合自定义指标比如队列长度做弹性伸缩能把平均利用率再拉高 20% 到 30%。但要注意冷启动时间模型加载慢的话伸缩跟不上流量变化需要保留一定的预热实例。6.3 一个成本优化的实际账本拿一个真实项目举例。原来用 8 张 A100 跑一个 13B 模型的推理服务日均处理 500 万次请求平均输入 300 token输出 150 token。优化前 GPU 平均利用率 45%P99 延迟 3.2 秒。做了三件事一是上 vLLM 替换原来的静态批处理方案利用率拉到 72%二是开前缀缓存因为 system prompt 很长且固定命中率 65%TTFT 降了 40%三是把 KV Cache 量化到 INT8显存省了 30%并发能力提升。最终结果GPU 从 8 张减到 5 张P99 延迟降到 1.8 秒日均处理能力反而提升到 700 万次。按 A100 每小时 3 美元算每月省下 2160 美元一年两万六。这个账算下来性能优化的投入产出比非常可观。7. 写在最后的一些个人体会做 AI 系统性能工程这两年我最大的感受是这行没有放之四海皆准的最优解。同一个模型同样的硬件换个业务场景最优配置可能完全不一样。所以比起记住某个参数的最优值更重要的是建立一套自己的性能分析和调优方法论先观测再定位后优化最后验证。每一步都要有数据支撑不能凭感觉。另外一点是不要过早优化。我见过太多团队在流量还没起来的时候就开始折腾各种高级特性结果复杂度上去了收益没看到。正确的节奏是先让系统跑起来用最简单的方案满足需求等性能真的成为瓶颈了再针对性地优化。性能工程是解决问题的工程不是炫技的工程。最后分享一个我常用的排查习惯遇到性能问题先问三个问题——瓶颈在计算、显存还是通信瓶颈在单请求内部还是请求之间瓶颈是稳态的还是突发的把这三个问题回答清楚问题的范围就缩小了一大半剩下的就是按图索骥。这个习惯帮我省下了大量瞎试的时间希望对你有用。
返回列表