首页 / 资讯中心 / 文章详情

大模型推理TTFT优化:从Prefill到KV Cache的工程路线图

大模型推理TTFT优化:从Prefill到KV Cache的工程路线图 ★ FEATURED ARTICLE
在生产环境里跑过大模型推理的人基本都遇到过这种场景用户丢进来一份几百行甚至几千行的上下文服务端的GPU风扇瞬间拉满可结果迟迟不出来页面上的第一个token愣是等了好几秒甚至几十秒。有人说这不就是模型运算太多嘛忍忍就好但忍完就是用户流失。这个延迟有个专门的名词——TTFTTime To First Token也就是从请求到达服务到返回第一个token的时间。而TTFT的命门恰恰就在Prompt Prefill预填充阶段模型要把整段提示词一次性过一遍Transformer把中间状态全部算好才能开始逐个吐token。这篇文章想把Prefill慢这件事彻底讲清楚。我会先从Prefill和Decode两个阶段的计算特性差异说起拆出TTFT拖慢的真正根源再给出一套从算子优化、KV Cache管理到调度策略的工程优化路线图。最后会有可落地的排查工具箱和调参顺序适合正在做在线推理服务的人、想优化自家大模型应用延迟的工程师以及被长上下文折磨得够呛的算法同学参考。1. 先看懂模型推理的两个阶段Prefill与Decode的底层差异1.1 为什么TTFT是用户体验的生死线用户体验对延迟的敏感程度比大多数技术指标都要残酷。对一个问答助手或对话式应用来说用户敲下回车后的第一秒钟里如果页面没有任何反应体感就是“卡了”。这不是网络波动那种偶发而是每次请求都会撞上的确定性延迟。我常把TTFT比作餐厅上菜前的等待哪怕你后面每道菜都是飞快端上来只要第一道菜让人等了十分钟客人对整家餐厅的评价就已经定型了。在线推理服务的核心体验很大程度上由首token决定。业界比较常见的服务目标一般是指标参考目标TTFT P50300 - 800msTTFT P951 - 2sTTFT P992 - 3sDecode吞吐越高越好但优先级低于TTFT SLO这里有个容易混淆的点TTFT不等于模型计算时间。TTFT是端到端指标它包含了请求排队等待、网络传输、输入长度校验、Prefill计算、kernel调度开销、以及把第一个token通过反序列化推回给调用方的全部时间。模型计算只是其中一段但这往往是最长的一段。所以优化TTFT表面上是在压Prefill耗时实际是在同时解决调度、排队、计算和IO协同的问题。1.2 Prefill为什么比Decode慢一个数量级很多人刚开始做推理优化时会有一个疑问模型不是一样的吗为什么处理整段提示词反而比逐个生成token还慢那么多这不合理。合理的地方在于两者根本不是同一种计算形态。Prefill阶段输入是一整段prompt假设有S个token模型要并行计算所有token的注意力、前馈网络把每一层的K和V写入KV Cache。这个阶段有一整个大方块矩阵乘等着计算计算强度高模型能用满GPU的FP16/BF16算力。从FLOPs角度看处理一个token的前向计算量大约是2倍模型参数量。举个例子一个13B参数量的模型处理单个token的前向计算大约是26 GFLOPs。当prompt长度是4096个token时Prefill一次要算 26 × 4096 ≈ 106 TFLOPs。就算用一张BF16算力约300 TFLOPs的GPU理论时间也接近0.35秒实际打上折扣加上注意力计算和各种kernel开销上秒很正常。而Decode阶段是逐token生成的每一步只算当前这一个token的注意力、MLP但需要反复读取完整的KV Cache。模型权重和KV Cache都得从显存搬到计算单元里参数读取量远超计算量这是一个彻底的访存密集型过程。我见过很多人只盯着Decode速度优化却忽略了一个事实如果Prefill占掉了整体请求延迟的60%甚至70%那Decode再快用户的等待体验也不会好。尤其当prompt越来越长几万token的上下文已经开始出现在一些文档分析、代码库理解场景中Prefill耗时会被极度放大TTFT的优化优先级正在被推到和Decode吞吐同等的位置。1.3 两个阶段的计算特性对比我用一个表格把这层区别说透维度PrefillDecode输入形态整段prompt并行处理逐token串行生成计算类型计算密集compute-bound访存密集memory-bound核心矩阵大矩阵乘shape规整每次一个小向量乘大矩阵注意力矩阵[batch, heads, S, S]S是输入长度[batch, heads, 1, S]只有一个queryKV Cache写入第一次写入全部K、V每次追加一行GPU利用率高但一次耗时很长低但持续占用对TTFT贡献直接决定首token来得快慢影响后续token间隔对TTFT影响小从这表能看出来Prefill和Decode对资源的需求完全不同。一个拿手电筒和一个拿探照灯的人站在一起谁先被看到取决于调度怎么分配光线。如果调度策略把两者混在一个通道里不用巧劲就会出现“一个长prompt请求把后面所有短请求都堵死”的惨剧。2. Prefill性能瓶颈的三层拆解计算、访存与调度2.1 计算层注意力矩阵的O(S²)陷阱自注意力的核心是 QK^T。对于长度为S的输入序列这个矩阵乘的结果是一个 [S, S] 的矩阵意味着计算量和显存占用都随序列长度平方增长。S4096 时注意力矩阵包含约1600万个元素这还好说。但S32768时元素数量超过10亿单是这个分数矩阵在FP16下就有2GB。如果不做融合直接把完整的注意力分数写进显存再读出来做softmax长序列Prefill会被直接打穿显存。这也是为什么长上下文场景下Preflfill的耗时曲线不是线性上涨而是带一个明显的平方翘尾。模型参数规模决定了一个基本耗时基准但序列长度决定了注意力部分会不会成为新的主导项。还有一个理解关键注意力矩阵的计算量并不因为用了高效的kernel就消失。FlashAttention这类高效实现能减少访存、避免实例化完整S×S矩阵但FLOPs依然在那里。所以处理长序列时计算量的天花板始终是存在的。2.2 访存层KV Cache与中间激活的读写放大Prefill阶段要做的第一件大事就是把每一层的K、V写入KV Cache。这听起来简单但KV Cache的大小很容易超出直觉。我算一个具体的数。假设一个13B模型40层Transformer层隐藏维度5120采用GQA让KV头数为8每个KV头维度128序列长度4096batch size为1FP16精度KV Cache大小 2份K和V× 40层 × 4096序列长度 × 8头 × 128维度 × 1 batch × 2字节 ≈ 671MB。如果一个请求就要吃掉671MB并发8个请求就是5.4GB。如果没有GQAKV头数还是32个这个数字直接翻四倍变成26.8GB很多GPU直接就爆了。KV Cache的分配、写入、读取、释放每一步都在跟显存带宽较劲。再补一刀Prefill阶段也存在中间激活的放大问题。虽然推理不需要保存反向传播的梯度但每层前向计算过程中产生的中间结果还是要往下一层传而注意力分数、Softmax输出这些如果算子没做融合就会在显存和计算单元之间来回拷。我经常用一个类比Prefill像在食堂开饭前把上万份饭一次性做出来光是把这些饭从厨房端到窗口就要累死好几个传输带Decode则像是开饭后的零星加餐但每一次都要把整个仓库的食材重新翻一遍。传输带宽的问题两个阶段都存在只是放大方式不同。2.3 调度层Batch策略与GPU空转很多人的第一反应是既然Prefill计算量大那就把多个请求捆成一个batch把GPU塞满不就行了理论上是这样实际却很难。长prompt请求的Prefill计算时间可能是短请求的十倍以上。如果按传统方式调度一个请求完整算完再调度下一个那么排在长请求后面的短请求TTFT会变得不可接受。而如果为了照顾短请求频繁打断大请求又会造成大请求的Prefill迟迟做不完GPU在切换时也有一层切换损耗。更深层的问题是Prefill和Decode混跑时的资源分配。GPU的算力是有限的Prefill疯狂吃算力时Decode的token生成速度会明显下降反过来如果一堆decode请求占住了显存带宽Prefill虽然拥有巨大计算量也未必能跑出理论峰值。调度层优化的核心目标是在“让大请求快点算完”和“不让小请求等太久”之间找平衡点。这个平衡点固定不下来因为模型、GPU型号、prompt长度分布、请求实时负载全都在变。所以后续的工程优化本质上是把这个问题拆成可配置的策略然后针对实际流量做调参。3. 工程化优化的四条主线3.1 算子融合与高效注意力实现算子融合是降低Prefill耗时最直接、也最成熟的手段。思路很简单不要每一步都把中间结果写进显存而是把一连串操作塞进一个kernel里让数据留在寄存器里多转几圈。举个例子标准的注意力计算流程是算QK^T、除以缩放因子、做Softmax、乘以V。如果不融合这四步就有四次显存读写。融合之后整个注意力计算可以在kernel内部完成S×S的注意力矩阵从头到尾都不落显存。我在实际项目里测过单是这一步长序列的Prefill耗时通常能下降20%到30%。但这还不够因为计算量没有被削减只是访存开销变小了。要真正压计算量有几个方向稀疏化注意力窗口注意力只让每个token看附近一部分token再加几个全局token做信息锚点。长序列下的计算量直接从O(S²)降到O(S·w)。MQA/GQA机制让多个query头共享少数KV头KV Cache大小和注意力计算量都能降。前面算过KV头数从32降到8KV Cache就降为原来的四分之一。滑动窗口加分层下采样本质上就是让网络在长序列上放弃“全局精确连接”转而用局部的密集注意力和全局的稀疏注意力配合。这些方案各有代价稀疏注意力可能影响长距离依赖的建模MQA可能让模型效果轻微下降。工程上一般不会在推理阶段直接改这些结构而是希望在训练阶段就把带GQA和稀疏注意力的模型训好。如果你拿到的模型本身没有GQA那么KV Cache膨胀是绕不过去的坎。3.2 存储层KV Cache的分配、复用与量化压缩既然KV Cache这么大第一件事就是别浪费显存。传统的做法是按一个请求的最大可能长度预留KV Cache这会浪费大量显存。更精细的做法是按块的动态分配策略把KV Cache切成固定大小的block需要时按需分配不用的block可以被重新拿回来。这个思路跟操作系统的分页内存管理类似碎片少了并发请求多了显存利用率明显上升。但动态分配解决的是“怎么放”缓存复用解决的是“怎么省”。在大模型实际使用场景中很多请求是共享前缀的。比如一个聊天机器人系统提示词可能很长每个用户请求都带着同一段开头。如果没有前缀缓存这段公共前缀会在每个请求的Prefill阶段被反复计算。我见过一个文档问答类项目系统提示词加一些固定指令占了2000个token请求量一上来这几千token的重复开销直接把GPU打满。Prefix Caching的工程实现也不算复杂把Prefill计算好的KV Cache以“前缀token序列”为key存下来后续请求来做匹配命中公共前缀就直接复制或者引用对应的KV Cache块只对新增部分重新计算。匹配算法上通常用前缀树来管理缓存块这样能最大程度复用祖先链上的缓存。这里有个关键点缓存的KV Cache是显存换时间。如果缓存了太多不同前缀占用的显存反过来会挤压正常请求的KV Cache空间导致并发度下降。所以生产环境一般要给前缀缓存设置显存上限再配一个淘汰策略按最近最少使用这类规则把不活跃的前缀踢掉。KV Cache量化是另一条有效路径。用INT8精度存储KV Cache显存占用直接减半用INT4可以再减半。但量化不是白给的低比特KV Cache会引入精度损失在长上下文、需要高精度检索信息的场景中效果下降可能明显。我见过有人把KV量化一刀切打开结果长文档问答的准确率掉了好几个点。正确做法是先做效果对比测试再决定哪一层量化、量化到什么程度。我个人倾向的生产配置是FP8或INT8作为默认选项INT4只对不敏感的辅助模型开主模型想压到INT4必须过完整的效果评估集。3.3 调度层连续批处理与分块Prefill调度层的优化往往是整个优化链条里收益最快、也最隐蔽的一环。先讲连续批处理。传统批处理以请求为单位一批请求必须全部结束后才能调度下一批。这会让早到的短请求被同批的长请求拖住。连续批处理的粒度更细以一次模型迭代为单位每次迭代结束系统就去检查有没有已经准备好的请求可以插入空位。一个长Prefill请求不会独占GPU一整轮短请求可以在它计算的间隙插进来。我举个简化例子假设模型一次decode迭代要50ms一次长Prefill要500ms。传统批处理下排在后边的短请求要等500ms才能开始连续批处理下Prefill被拆成多个可抢占的片段每50ms就有一次调度机会短请求的TTFT大幅缩短。然后是分块Prefill。连续批处理解决的是“调度机会”分块Prefill解决的是“长Prefill本身的原子性”问题。如果不拆分一个长Prefill在处理过程中会持续占用GPU的计算单元期间decode请求即使能插队计算资源也竞争不过Prefill。分块Prefill把一个大请求的prefill切成长度合适的chunk每算完一个chunk就插入一些decode步骤。这样做的副作用是模型权重需要被反复读取多次因为每个chunk都要从头过一遍所有层。chunk到底切多大是个需要实测的平衡点chunk太大长Prefill依然会长时间霸占GPU短请求的TTFT改善有限。chunk太小权重重复读取的开销会被放大GPU整体吞吐下降所有请求一起变慢。我在实际调优时一般会把chunk大小从256、512、1024、2048扫一遍观察TTFT的P50/P99和整体吞吐再结合平均prompt长度选一个折中值。对大多数对话场景512到1024是比较稳的起步区间长文档场景则可能需要调大。调度层还有一个容易被忽视的细节优先级。对在线业务来说不是所有请求都值得同等对待。我希望短请求优先返回首token而长prefill请求愿意多等。通过给请求设置优先级让短请求在调度队列里插到前面TTFT的P99优化效果会非常明显。代价是长请求的尾部延迟有所上升但换来的是整体用户体验提升这笔账通常划算。3.4 模型与推理框架协同从输入压缩到并行计算如果模型本身太大或者prompt太长单靠推理引擎的调度和算子优化天花板还是会被顶到。这时候得从模型和系统设计上再想想办法。输入压缩是最容易立竿见影的手段。现在很多产品为了让模型理解“全程对话”会把历史对话、检索片段、系统指令一股脑拼进prompt。几千甚至上万token的输入对Prefill来说是巨大的负担。把不太重要的历史记录做摘要、把检索片段截断到真正的关键段落、把固定指令精简都能直接缩短prompt长度Prefill耗时差不多是线性下降的。投机采样是另一种被反复提及的优化。它的核心思路是用一个小模型先草稿出几个token让大模型一次性验证。这个技巧主要收益在Decode阶段能让整体token生成速度明显提升。但如果首token本身就慢投机采样救不了Prefill。所以我的建议是先压Prefill和TTFT再把投机采样作为Decode吞吐的补充手段来用。并行策略上也有一笔账。Tensor Parallel把模型权重切到多张卡上Prefill的计算量可以被多卡并行消化实测中Prefill耗时接近线性下降。比如8卡并行处理一个超大batch的Prefill理论上比单卡快好几倍。但每切一刀都会引入通信开销卡数增加之后通信占比会上升可能从接近线性退化成增量收益很小。千万不能无脑堆卡而是要实测通信占比。最后是精度选择。FP8训练还不算特别普及但FP8推理已经被不少框架支持。对某些模型来说FP8能减少显存占用并提高计算吞吐Prefill延迟确实能降一截。同时也要注意FP8的数值范围不如FP16宽个别层的输出可能溢出。稳妥做法是先跑一个量化前后的效果对拍让评估集告诉你能不能用。4. 实操TTFT排查工具箱与参数调优实录4.1 第一步先量化现状TTFT到底慢在哪没有数据的优化都是瞎忙。拿到任何优化需求我第一件事永远是先把TTFT拆开。拆的方法很直接在请求进入服务的地方打一个时间戳Prefill开始前打一个Prefill结束后打一个发出第一个token时再打一个。这样就得到了排队时间、Prefill计算时间、其余开销三个分段。多数时候你会发现Prefill本身占了50%以上这种时候别浪费时间调网络或者序列化直接打Prefill就行了。算子级别的问题要用性能分析工具看。主流的深度学习框架自带Profiler能告诉你每个算子的耗时、显存占用、kernel启动次数。GPU厂商也提供更底层的分析工具可以看GPU利用率、显存带宽利用率、算子之间的空档。有一次我排查一个TTFT异常从业务指标看一切正常但用底层工具一测发现某个版本的融合算子没有生效注意力那一步居然在跑未融合的版本Prefill直接翻倍。这种问题不 profiling 是根本找不出来的。我每次调优都会做一张基准表记录以下内容基线GPU型号和数量模型规模和精度平均/最大prompt长度batch size和并发配置连续批处理和分块Prefill开关TTFT的P50、P95、P99Decode阶段的token生成速度GPU利用率和显存占用拿这张表去对比每一次改动前后的差异比凭感觉调参可靠一万倍。4.2 分块Prefill参数怎么定分块Prefill的chunk大小和调度窗口这两个参数值得单独拿出来说因为它俩直接决定了系统在“计算大块”和“响应小块”之间的姿态。chunk size的实操方法我前面提过扫参数。我一般这样操作固定其他配置不变用一组带真实长度分布的prompt流量回放分别测 chunk 256、512、1024、2048记录TTFT P50/P99和总吞吐举例来说在某13B模型的实验里我测到这样的趋势chunk从256调到512时TTFT P99从1.9s降到1.3s吞吐只掉了3%继续调到1024时TTFT P99进一步降到1.1s但吞吐掉到了基线的88%。这种情况下512或1024都可选取决于业务更在意首token还是整体吞吐。max_num_seqs和max_batch_tokens这两个调度上限也要一起看。它们决定了GPU的一次前向最多容纳多少请求和多少token。如果max_batch_tokens设得太低大prompt请求会被被动拆成多轮Prefill变慢设得太高极端情况下一个大请求就能占满整个batch其他请求全部堵塞。建议让它服从一个经验值max_batch_tokens ≈ 平均prompt长度 × 期望并发请求数 × 0.6到0.8再实测微调。4.3 可复现的调优顺序建议调优不要同时开很多开关改完一个就测一轮才能知道每个改动到底带来了什么。我给出一个我自己比较推荐的顺序打开连续批处理。这一步通常零风险收益明显让调度器具备插队能力。打开前缀缓存先设置一个保守的显存上限观察命中率和TTFT变化。检查算子融合情况。确认当前框架里注意力、LayerNorm、激活函数这些算子是不是真的融合了没有融合的版本先升版本或手动开启。评估KV Cache量化。先做效果对拍再决定量化层级和精度。调整分块Prefill。配合前几步的结果扫chunk size找最佳点。优先级调度。如果业务场景对短请求有明显偏好这一步能显著拉低P99。最后考虑并行策略和精度调整。这是改动成本最大的一步能不动就不动。每走一步都要跟基线表对比。如果某一步的收益低于噪声水平果断回滚不要因为“别人说这个有效”就硬留着。5. 常见问题与排查技巧实录5.1 问题速查表现象可能原因排查手段长prompt一进来GPU利用率瞬间飙高但TTFT依然很高Prefill计算量本身太大算子可能未融合Profiler看attention算子耗时确认是否走融合kernel短请求被严重拖慢P99飙升长Prefill请求占满GPU计算单元无抢占机制开启连续批处理和分块Prefill调小chunk开启某些优化后TTFT反而变差chunk太小导致权重重复读或KV量化精度问题引发重试扫chunk size量化效果对拍并发一到峰值就显存OOMKV Cache分配碎片化或前缀缓存占用过多检查KV Cache block管理和前缀缓存上限Prefill阶段GPU利用率有但显存带宽打满访存瓶颈可能存在未融合的中间结果写读Profiler检查显存带宽利用率找未融合算子长文档回答问题效果下降KV Cache量化把关键信息丢了关掉KV量化或改回INT8/FP8重新对拍效果5.2 几个易踩的坑指标口径不统一是最容易被忽略的坑。有些人统计TTFT把排队时间刨掉有些人把网络传输算进去结果两个团队对同一个系统得出的结论截然相反。在做优化之前先定清楚TTFT的起止点从服务端收到请求到调用方收到第一个token。所有优化对比都按这一个口径走。第二个坑是只调参数不验证业务效果。有的优化把TTFT压下来了但下游任务准确率少了三四个点。这种“快了但蠢了”的优化在线上是事故。KV量化、稀疏注意力一类的改动一定要搭配效果评估集来做回归。第三个坑是忽略请求长度分布。有些优化方案在平均prompt 1k的场景里表现优秀到了平均prompt 8k的场景直接垮掉。因为长序列下注意力复杂度的平方项会重新变成主导甚至带动GPU显存分配策略失效。选方案前先看看你线上真实的prompt长度分布别拿别人家的经验直接抄。第四个坑是调优过度。所有优化项全开之后系统的复杂度和不稳定因素都在增加。实际项目里我见过因为前缀缓存和分块Prefill同时开且缓存块管理有bug导致某些前缀的KV Cache被错误引用输出质量突然下降。优化要留一点余地不要把所有开关都怼到极限。写在最后的一点体会做Prefill和TTFT优化这么久我最大的感受是大多数人的第一反应是调模型、换框架、上并行但真正的问题往往出在“不知道慢在哪”。没有一份清晰的TTFT分段数据谈优化就是猜。而一旦把请求拆到“排队、Prefill、Decode、传输”这四段90%的情况会立刻锁定到一个方向上接下来就是拿着工具一点点啃。另一个心得是性能优化要懂取舍。TTFT、吞吐、显存、效果这四个维度互相牵制。短请求的P99被压得很漂亮时长请求的尾延迟可能已经惨不忍睹。系统的姿态由业务决定C端交互产品优先保首token批处理任务则优先保吞吐。想清楚自己的核心目标再去做工程手段才不会被一堆好看的中间指标带偏。最后分享一个小技巧线上版本一定要留一个全局开关能一键回退所有可选的优化项。我曾经在灰度一个分块Prefill改动时发现效果对拍到了晚上就异常直接把开关拉回去线上立刻恢复。这种“保命”机制比任何精妙的优化都重要。
阅读完成 · 觉得有帮助?
咨询建站