大模型上线之后,最容易被用户体感抓住的问题往往不是生成内容的质量,而是“半天蹦不出第一个字”。不管你的模型参数多大、效果多好,只要首Token延迟(Time To First Token, TTFT)一高,用户就会觉得“卡了”“挂了”“是不是死循环了”。而TTFT的瓶颈,绝大多数情况下不在Decode阶段,而在Prompt Prefill阶段。
这篇文章想聊的就是Prefill阶段为什么慢、慢在哪儿,以及我在多个落地项目里实际用过的优化手段。内容以工程实践为主,原理会讲,但更侧重可复现的操作路径和避坑经验。适合正在做大模型在线推理服务、做推理引擎优化,或者被用户投诉“响应太慢”的开发者参考。
1. Prefill阶段为什么慢:先搞清楚对手是谁
1.1 Prefill和Decode在本质上就是两种负载
先把概念对齐。大模型生成一次回答,不是像流水一样一个词一个词匀速吐出来的,而是分两个截然不同的阶段:
- Prefill阶段:把用户输入的Prompt所有Token一次性喂给模型,做一次完整的前向计算,生成每个Token对应的Key/Value向量,写入KV Cache。这一阶段只算一次,但计算量巨大。
- Decode阶段:根据最后的Token和已有的KV Cache,逐Token生成新内容。每步只处理一个新Token,计算量小,但需要反复读取KV Cache。
用一个粗糙的比例感受一下:假设输入Prompt有500个Token,输出有500个Token。Prefill阶段要做一次500 Token长度的前向计算;Decode阶段要做500次长度为1的前向计算。前者的单次计算量是后者的500倍。
所以,Decode阶段的瓶颈是“内存带宽”——GPU大部分时间在搬运KV Cache数据,计算单元反而闲着;而Prefill阶段的瓶颈是“计算吞吐”——要在极短的时间内完成大量矩阵乘法,把GPU的计算单元吃满。这两个阶段根本不是一个物种,用同一套优化思路去处理,一定会出问题。
1.2 TTFT的构成拆解:别只盯着模型本身
TTFT不是“模型算完Prefill的时间”这一个值。从用户发送请求到收到第一个Token,中间的时间大致由几段组成:
- 网络开销:请求从客户端到服务端的传输时间,以及响应首Token回传的时间。
- 调度等待:请求进入队列后,等待被调度到GPU上的时间。这是最容易被低估的耗时项。
- 输入预处理:Tokenization、Padding对齐、RoPE位置编码计算等。
- Prefill计算:真正在GPU上跑前向计算的时间。
- 首Token生成与传输:Decoder生成第一个Token并将其通过流式接口发出去的时间。
做过性能分析就会发现,很多所谓“Prefill慢”的case,实际等调度就等了几百毫秒。如果不拆解就直接一头扎进Kernel优化,方向就错了。
1.3 Prefill阶段的计算特征与资源消耗
Prefill阶段的计算密集程度有多高?拿一个中等规模的模型举例:7B参数的模型,处理512个Token的Prompt,一次前向计算的运算量大约在万亿次级别(视具体层数和头数有差异)。即便在A100级别的GPU上,也需要几百毫秒量级完成。
同时,Prefill阶段生成的KV Cache会占掉大量显存。比如一个7B模型,如果使用FP16存储KV Cache,512个Token的Prompt产生的KV Cache大约是几十MB量级,看起来不大。但当输入的Prompt很长(比如几千Token),或者并发请求较多时,KV Cache的显存占用会迅速攀升,变成显存压力的大头。
这就引出了Prefill优化的一个核心矛盾:既要减少计算时间,又要控制KV Cache的显存占用。很多方案在这两者之间摇摆,下面讲的具体做法也都是在找平衡点。
2. 定位Prefill瓶颈:先量化,再动手
2.1 建立性能画像:从三个时间指标入手
拿到一个Prefill慢的问题,我习惯先做三件事,把数据采全,而不是瞎猜:
- 采集Prefill阶段耗时分布:按Prompt长度分桶(比如0-64、64-256、256-1024、1024+),分别统计P50/P95/P99的Prefill耗时。分桶很重要,不同长度区间的优化策略完全不同。
- 采集GPU利用率曲线:看Prefill前后GPU的利用率波形。典型的Prefill阶段应该出现一个高计算利用率尖峰。如果尖峰很矮,说明Kernel没吃满硬件;如果尖峰很宽,说明计算量确实大。
- 采集队列等待时间:对比请求到达时间和真正开始计算的时间差。如果这个差值占了TTFT的50%以上,问题出在调度,不是Prefill计算本身。
这里推荐一个常用的组合:用Prometheus + Grafana搭建基础监控,加上推理框架自带的metrics接口,可以拿到每个请求的时间戳明细。要改到毫秒级,不要只看平均耗时,长尾才是真实体验。
2.2 Kernel级分析:找到真正的计算瓶颈
如果确认瓶颈在Prefill计算本身,就需要下钻到Kernel级别。GPU上跑Prefill时,绝大部分时间花在两类操作上:
- GEMM(通用矩阵乘法):Attention中的QKV投影、FFN层的上下投影,都属于GEMM。
- Attention计算:Q和K的点积、Softmax、与V的加权求和。
用NVIDIA的Nsight Compute或类似工具采集一次Prefill各Kernel的耗时占比,通常会发现Attention计算和FFN层的GEMM是绝对大头。具体哪个占比更高,取决于序列长度。序列越长,Attention的计算复杂度越高(O(n^2)),GEMM的占比相对下降;序列短的时候,GEMM的占比更高。
这个判断直接决定了优化方向:序列偏长的场景,优先优化Attention;序列偏短的场景,优先优化GEMM和算子融合。
2.3 常见瓶颈模式:识别你踩的是哪种坑
根据我实际处理过的案例,Prefill慢大致可以归为几种典型模式:
- 计算吞吐不足:GPU利用率高,但绝对耗时依然很长。典型原因是模型太大、Prompt太长,单机单卡的算力天花板就在那里。
- Kernel效率低下:GPU利用率低,SM(流式多处理器)大量空转。典型原因是Attention实现低效、算子未融合、显存反复读写。
- 调度饿死:GPU一会儿忙一会儿闲,很多请求挤在队列里,GPU利用率曲线呈锯齿状。典型原因是框架的调度策略不适合长Prompt请求。
- 显存带宽限制:Kernel不重,但KV Cache和中间结果在显存里反复倒腾。典型原因是Varlen Attention未启用、KV Cache自然布局不合理。
每种模式的处理方式天差地别。你是不是也遇到过这种情况:本地单测Perf看起来不错,一发线上流量就慢成狗,大概率就是调度或显存问题。
3. Prefill优化的工程实践:从算子到架构
3.1 算子级优化:FlashAttention与更快的Attention实现
Prefill阶段最值得投入的是Attention计算的优化。我们项目在优化初期,把Kernel从原始PyTorch实现切换到FlashAttention系列的高效实现,Prefill耗时直接下降了40%以上,效果非常显著。
FlashAttention的核心思路其实不复杂:传统的Attention计算需要把完整的注意力分数矩阵写回显存,再读出来做Softmax,产生了大量显存读写。FlashAttention通过分块(Tiling)计算和在线Softmax,避免了中间矩阵的落盘,把显存访问量从O(n^2)降到了O(n)。对于长序列场景,这个差距会被急剧放大。
工程上需要注意几点:
- 不允许暴力替换所有Attention。要检查你的模型实现中是否有自定义的Mask逻辑、Alibi位置编码、Multi-Query Attention等变体,这些会影响Kernel兼容性。
- 关注Varlen Attention的支持。推理时请求是动态变长的,需要按实际长度做Packing。如果框架要求先Padding到等长再Attention,会有大量无效计算,等于白白浪费了几倍的算力。
- FP8/FP16的Kernel选择。不同版本的FlashAttention对不同精度的支持情况有差异,FP8理论上能带来接近翻倍的吞吐,但有空精度损失风险,需要先做离线评测。
通用Attention实现的收益空间已经没有秘密了,关键是把适合自己模型的Kernel选对、集成好。
3.2 调度级优化:Chunked Prefill与Continuous Batching
提高GPU利用率、减少调度等待,是现代推理框架的主流方向。在多个项目里,我应用了两种调度层面的优化:
Chunked Prefill(拆分Prefill):把一个长Prompt的Prefill切成多个Chunk,与Decode阶段的请求混跑。这样做的核心收益是:GPU在等待某个长请求的Prefill时,可以间隙插入其他请求的处理,避免算力闲置。
具体怎么切片?通常以KV Cache的语义完整性为准,比如每一切片处理固定数量的Token(如128或256个),处理完一个Chunk就生成对应的KV Cache,再交给下一个Chunk。代价是Attention计算被拆分后会有少量重复计算,但因为整体利用率提升了,收益远超损耗。
Continuous Batching(连续批处理):调度器不再等待整个Batch全部完成才回收资源,而是“谁算完谁走,新请求立即补位”。配合Chunked Prefill,能让GPU始终处于满载状态。对于在线服务,显著降低TTFT和吞吐波动。
当时我们做压力测试,纯Prefill负载下,加了连续批处理后GPU利用率从60%上升到90%以上,P95 TTFT下降了约30%。
3.3 架构级优化:PD分离部署
如果业务是典型的“大Prompt、短输出”模式(比如代码补全、文档总结、语义检索问答),Prefill和Decode的资源需求差异格外明显。这时候可以考虑把PreFill和Decode拆到不同GPU上:负责Prefill的实例专门跑长Prompt计算,产出KV Cache后传给Decode实例。
这样做有三个好处:
- 专卡专用:Prefill实例用计算型GPU(计算能力强),Decode实例用访存型GPU(显存带宽大或显存大),各取所长。
- 避免互相干扰:长Prompt的Prefill不会阻塞正在进行的Decode请求,反之亦然。
- 按需扩缩容:如果业务是早上Prefill重、晚上Decode重,可以分别扩容,不用整条链路跟着一起扛。
PD分离的代价也很明显:KV Cache需要在不同GPU之间传输,增加了一次网络传输耗时;部署架构变复杂,需要两个服务实例联动。建议先用Profiling确认两类请求确实存在资源需求差异,再决定是否上这层架构。如果Prompt短、输出也短,PD分离的收益有限。
3.4 KV Cache优化:量化与复用
Prefill阶段的产物就是KV Cache,它决定了Decode阶段的速度,也决定了显存能被多少并发请求填满。
最基础的优化是动态分配显存,而非提前锁定最大可能长度。很多框架默认按最大序列长度给每个请求预留KV Cache,请求一多,显存就爆。改用按需分配、按桶分配,显存利用率直接翻一倍都不稀奇。
在此基础上,还有两个值得做的:
KV Cache量化:把KV Cache从FP16量化到INT8甚至INT4。显存占用直接减半或减到1/4,需要验证精度损失是否可接受。生成类任务通常对KV Cache量化比较宽容,但检索类、数学类任务要求高,必须做离线评测。
Prefix Caching(前缀复用):“查了你就不会再查一遍”。如果大量请求的Prompt前缀相同(比如系统提示词、固定的Few-shot示例、文档上下文),可以复用这些前缀的KV Cache,Prefill阶段直接跳过这部分计算。在Chat应用里,系统提示词前缀几乎一样,开启Prefix Caching后TTFT的降幅相当惊人。我实测过,普通对话场景降20%-40%都很正常。有些框架还能做到跨请求复用公共前缀,同样的长Prompt不用一遍遍重新算。
实现前缀缓存时要注意,它依赖哈希命中,Ke Token的序列完全一致才能复用。加了时间戳、随机数之类的动态内容会导致缓存失效,这点和HTTP缓存是同一个道理。
3.5 并行策略:张量并行与数据并行的取舍
当单个模型的规模超出单卡显存时,必须做模型并行。在Prefill优化语境下,最常用的是张量并行(Tensor Parallelism)和流水线并行(Pipeline Parallelism)。
- 张量并行:把每一层的参数切成多份放在多张卡上,计算时分块并行。Prefill阶段的计算密集程度高,张量并行的加速比非常理想。但有通信开销,且显存带宽瓶颈会放大通信等待。
- 流水线并行:按层切分模型,每张卡负责一段网络层。这种方式对Prefill的加速不明显,因为Prefill阶段本身的流水线气泡比较多。但配合PD分离,它可以应用于Decode实例的扩展。
我的建议是:单机多卡场景优先用张量并行,它能同时加快Prefill和Decode;跨机多卡场景必须仔细评估通信开销,网络带宽不足时,张量并行的收益会被严重侵蚀。
4. 实操记录:从卡顿到丝滑的一次优化复现
4.1 场景描述与性能基线
为了把上述方法串起来,我模拟一个典型项目实例。项目背景是一个企业知识库问答助手,用户的Prompt平均在800-1500 Token之间(包含文档片段),输出平均在300-500 Token。模型规模约7B,部署在单台8卡A100(40GB显存)的节点上。
优化前的基线数据(P95):
- TTFT:约2.8秒
- Prefill阶段耗时:约2.1秒
- Decode阶段耗时:约1.2秒
- GPU平均利用率:35%(波动很大)
用户反馈“回答很慢,有时感觉卡死了”,体感确实不好受。
4.2 瓶颈定位:一次Profile抓出的问题
第一步是常规性能画像。在采集了不同长度区间的耗时分布后,发现一个明显异常:512-1024 Token区间的Prefill耗时曲线比256-512 Token区间陡增了接近三倍,而不是预期中的线性增长。
用NVIDIA的Nsight分析后发现,Attention Kernel的执行时间从短序列占比30%跃升到60%以上,而FFN相关的GEMM Kernel没有明显变化。这说明问题出在Attention实现上——注意力分数矩阵的显存读写量在序列变长时呈平方级增长,而框架默认的Attention没有做FlashAttention那样的分块与在线Softmax优化。
同时发现GPU利用率曲线呈锯齿状,低峰期大量计算单元空闲。进一步查调度日志发现,框架对长Prompt请求会“独占”GPU直至Prefill完成,期间其他请求全部排队。这解释了为什么P95那么难看——一个长请求进来,后面所有请求全部陪跑。
4.3 优化动作与效果对比
针对定位结果,按顺序做了四个优化动作:
优化1:替换Attention Kernel为FlashAttention兼容实现。
替换后Prefill耗时的P95从2.1秒直接降到1.3秒左右。这是单体收益最大的一步,同时也把短序列的Attention占比砍到了10%以下。
这里要注意,模型代码里可能自带一些自定义Attention Mask逻辑,需要等价改写,不能直接硬替换,否则结果集会变。
优化2:开启Chunked Prefill + Continuous Batching。
调度器配置了Chunk大小为256 Token(即每次最多计算256个Token的Prefill,然后允许插入Decode步骤)。GPU利用率曲线从锯齿状变成了连续高负载,平均利用率提升到70%以上。TTFT的P95降到了1.6秒。
有个细节值得提一下:ChunkSize不是越小越好。Chunk越小,CPU侧调度开销在总耗时里占比越高,而且Attention的重复计算比例也在增加。256这个值是试出来的,小模型可以试128,大模型可以试512,每个环境的甜点都不一样。
优化3:打开Prefix Caching。
这个项目的系统提示词和知识库文档块是跨请求复用的,很多请求的前缀完全一致。开启前缀缓存后,命中缓存的请求可以直接跳过约40%的Prefill计算。TTFT的P50降到0.8秒,P95降到1.2秒。
唯一要注意的是,Prefix Caching依赖哈希匹配,涉及动态内容的请求(比如带时间戳或随机ID)不会命中缓存,正常。
优化4:KV Cache按实际长度动态分配。
之前框架默认按最大上下文长度给每个请求预留KV Cache空间。改成按长度分桶动态分配后,同样的显存容量能支撑的并发请求数从十几个涨到了几十个,吞吐量有非常明显的提升,也不那么容易触顶OOM了。
最终数据:
- TTFT P95:从2.8秒降到了1.2秒,降幅约57%
- Prefill阶段P95:从2.1秒降到0.8秒
- GPU平均利用率:从35%提升到75%左右
- 并发请求承载力:提升2倍以上
这个复现路径覆盖了算子、调度、缓存、显存四个层面。如果你的场景类似,强烈建议按这个顺序来,先算子,后调度,再架构,收益递增且风险递减。
5. 做Prefill优化容易踩的坑:全踩一遍给你看
5.1 盲目追新Kernel导致精度漂移
很多高效Kernel是用FP8或混合精度实现的。切换Kernel之前,级别先跑一遍足够量的离线评测,对比新旧实现的输出。我的标准是:至少用100个代表性Prompt,计算新老模型输出的相似度(如ROUGE-L或向量余弦相似度),并人工抽检10%以上的case。
踩过一次FP8 Attention带来了大概1%的输出质量波动,平时任务没感觉,一到代码生成任务就出现语法错误。后来加了离线回归流程,此后再没因为Kernel替换翻过车。
5.2 Chunked Prefill的调度冲突没处理好
Chunked Prefill有一个隐藏问题:当Prefill和Decode混跑时,GPU的计算资源分配如果没有优先级控制,Decode请求会抢走Prefill的算力,导致长Prompt请求的Prefill延迟反而更差。
解决方式是在调度器里配置优先级。通常建议:预留一部分算力配额优先保障Prefill的连续执行,其余算力用来穿插Decode;或者按请求类型设置不同的优先级,让长Prompt请求的Prefill尽量连续执行完,而不是被切成碎块。
这里分享一个实测经验:Decode虽然每步计算量小,但如果并发度高,累积起来对GPU计算单元的占用并不小。在7B模型场景,如果Decode的并行请求数超过几十,Prefill的Kernel会被严重抢占。遇到这种情况,可以适当下调Decode的Batch大小上限,给Prefill留出空间。
5.3 显存碎片与扩容焦虑
KV Cache动态分配后,显存碎片会成为一个头痛问题。请求来时,需要一块连续的显存空间来存放新生成的KV Cache。如果显存里东一块西一块都是碎片,明明总量够,却分配不出连续空间,反而触发OOM或者强制Clean Up,造成延迟尖刺。
处理方式有两个:
- 开启显存整理/池化能力,让框架在后台做块合并(类似内存池的compact功能),许多推理框架内置了这个能力,默认开启即可。
- 预先分配固定大小的显存池,按桶预留好内存块备用。这个方案在高并发场景下更稳定,但会牺牲一定的内存弹性。
实际项目里,我通常先开池化,如果出现频繁的OOM或Clean Up延迟尖刺,再切换为固定桶预留。显存池的块大小统计可以通过观测每次请求的KV Cache用量来定。
5.4 监控指标选错:P50掩盖了长尾问题
优化过程中,只看P50是很容易被误导的。做过一轮基于FlashAttention+调度优化的调整后,P50看起来已经很漂亮了,但P95还是高。后来拉出耗时分布才发现,高的是“调度等待时间”——某些请求在前一个长请求的Prefill排空期间一直干等。
建议至少同时追踪以下指标:
- P50/P95/P99的TTFT
- Prefill阶段P95耗时
- 调度等待时间P95
- GPU利用率(按1分钟粒度)
- 显存分配失败次数
这些指标放在同一张Dashboard上,定位问题时一看就知道是计算慢、调度等多,还是显存不够。
5.5 首Token输出机制的影响:流式与缓冲
一个容易被忽略的细节:即使Prefill计算很快,如果框架把首Token生成后的数据先缓冲到固定大小(比如攒满多少字节再发送),用户感知的TTFT也会变差。
排查时,检查你的流式接口是“生成即推送”还是“攒批推送”。生成即推送模式下,用户能在极短时间内看到首Token。很多框架默认开启了缓冲,改掉这个配置后,TTFT的用户体感能继续下降0.2-0.5秒,跟优化Prefill同级别。这个优化几乎零成本,建议第一时间做。
另外,有些网关层会把SSE流式响应暂存在代理里,等源端完全处理完再转发,这会让流式接口失效。如果你用的是流式传输,务必确认从推理服务到用户浏览器之间的每一层都是实时透传。
5.6 长Prompt的另一个盲区:输入预处理
对于超长Prompt(比如上万Token),输入预处理也可能成为主要耗时点。Tokenization如果使用的是慢实现、或者Padding逻辑每次都把序列对齐到最大长度,会白白引入几百毫秒的延迟。
一个比较实用的优化:把Tokenize后的结果缓存起来。对于频繁出现的固定Prompt前缀,直接复用Token ID序列。另外,对于Padding逻辑,只对Batch内当前最大长度对齐即可,不要对齐到固定上限,可以省掉大量无效计算。
6. 效果评估与上线流程:怎么验证优化是否值得
6.1 离线评测先行
任何优化上线前,先跑离线评测确认两个指标没有退化:输出质量和生成速度。输出质量建议采用领域相关的自动化指标,外加一定比例的人工测评;生成速度则用标准数据集跑出TTFT和吞吐量指标。
离线评测的环境要和线上尽可能一致,包括CUDA版本、框架版本、推理脚本。很多人离线是Pytorch原生环境,线上是优化后的推理引擎,对比出来的数据不对等,评估就没有意义。
6.2 灰度发布与A/B对比
上线时强烈建议做灰度:先切10%的流量到优化后实例,观测至少几个小时的TTFT、报错率和用户反馈,确认指标稳定后再全量放量。
A/B对比时注意流量切割的均匀性,不能把高负载流量全打到某一侧。最好用随机哈希或基于用户ID的分散策略。
有个场上踩过的教训:灰度组放量后,因为配置了更激进的Chunked Prefill,短Prompt场景的TTFT反而变差了(调度开销占比上升)。通过灰度期的逐长度分桶对比,及时发现了问题,缩回调整后重新上线。没有灰度直接把全量切过去,影响面会大很多。
6.3 持续监控与调参闭环
优化不是一次性的。线上流量模型会随着业务迭代漂移,例如Prompt长度分布、并发量都会变。建议每月或每季度回顾一次耗时分桶数据,确认当前的ChunkSize、并行策略、显存池配置仍合理。
有些团队的推理服务参数调完就没人维护了,三个月后一个业务功能改版,Prompt长度中位数翻了一倍,性能直线下降却没被发现。做成定时巡检任务,定期自动输出核心性能指标变化趋势,能提前发现问题。
7. 不同场景的差异化策略
Prefill优化的最优解和你的业务形态强相关。结合几种常见场景给一些选型建议,帮助你快速判断有哪些方向优先投入:
- 长文总结类(大Prompt,小输出):优先做PD分离,其次做Chunked Prefill和FlashAttention。这类场景Prefill的算力消耗占比极高,专用化收益最大。
- 交互对话类(中Prompt,中输出):优先做Prefix Caching + 动态KV Cache分配。对话的系统提示词是天然的可复用前缀,能在不影响生成质量的前提下大幅降低TTFT。
- 代码补全类(中Prompt,实时要求高):优先优化调度策略 + 流式输出机制。用户对毫秒级的首Token延迟非常敏感,调度等待时长必须压到极致。
- 离线批处理(长Prompt,吞吐优先):优先优化并行策略和量化方案,KV Cache的极致压缩也可以大胆尝试。离线场景不需要太低延迟,目标是单位时间内处理更多请求。
再强调一个基本但常被忽略的原则:先量化瓶颈再选方案。没有Profile数据的AutoTune行为,大概率是拿生产流量做实验。
我个人在实际操作中的体会是,Prefill优化的核心价值不在于某一个“杀手级”技巧,而在于把计算、调度、显存、传输四个环节一层层理顺。每个环节都有对应的成熟方案,多数场景甚至不需要自己写Kernel,先把框架的开关配置和调度策略调对,已经能吃掉大半的优化红利。真正需要动手改代码的地方,往往是模型结构比较特殊、没直接适配某个高效实现的时候,那种情况下,理解FlashAttention和调度器的工作原理,会比找现成代码重要得多。