多彩编程 多彩编程MZPH · CODE BLOG
ARTICLE DETAIL

文章详情

深耕前端与后端开发技术的一线实战笔记与踩坑复盘。

先分清 prefill 和 decode 两块开销,再谈并发能开多大

先分清 prefill 和 decode 两块开销,再谈并发能开多大 版权与内容来源声明本文为原创整理。文中涉及官方文档、开源仓库、论文与公开报道的内容均在附表 A 中标注来源引用官方原文保持原样不作改写。文中命令、版本号与界面截图以本文成文时的实测/核验结果为准标注「待验证」的部分请以你本地环境实际输出为判断依据。本文不推荐任何不合规的软件获取方式也不对任何收益结果作承诺。转载请注明出处。有人这样估算过并发一张 80GB 显存的卡模型权重占 16GB那剩下 64GB「都拿来开并发」于是把max_num_seqs直接开到 64。真机上第二十几路请求进来的那一刻服务开始拒绝新请求日志里显存占用逼近上限先前跑得好好的那几路也一起变慢。问题不在他把上限调高了而在于他把两种完全不同的开销当成了同一笔账模型权重是一次性占住的KV Cache 是随并发和序列长度一起长的。这篇文章就把这两笔账分开算先给一个能套你自己模型的估算等式再谈并发到底能开到几路。一、prefill 和 decode 是两笔账显存够不代表并发能加一次请求的推理被切成两个阶段它们的瓶颈根本不在同一个地方。这个区分不是我的发挥vLLM 官方文档在讲分块预填充时写得很直接分块预填充「better balancing compute-bound (prefill) and memory-bound (decode) operations」也就是把算力受限的 prefill和显存带宽受限的 decode分开对待。把资源账混着算就是从这一步开始错的。prefill 阶段卡在算力上prefill 是「一口气把整段输入 token 读完」每一层都要对整段序列做注意力矩阵运算计算量按输入长度的平方量级增长。它读取的数据量相对固定但需要反复做大规模的矩阵乘考验的是卡的浮点算力。判断方法很简单如果你把输入 prompt 从 512 token 拉到 4096 token首字延迟显著变大而并发路数不变那瓶颈就在 prefill 这一侧。decode 阶段卡在显存带宽上decode 是「每步只生成一个 token」。每生成一个 token模型都要把权重完整读一遍再把这条序列已经攒下的 KV Cache 读一遍。这两件事都是读显存算力用不满卡在的是显存能吐多快数据。所以 decode 的耗时几乎不看你算得快不快看你读得快不快。这也解释了为什么加显存带宽或换更高带宽的卡对 decode 提升明显对纯算力加码反而不明显。混在一张卡上先看谁先见底同一张卡上两拨请求的这两种开销是叠在一起跑的。vLLM 文档里那句「locating compute-bound (prefill) and memory-bound (decode) requests to the same batch」说的就是当一批请求里既有正在 prefill 的、又有正在 decode 的算力模块和带宽模块可以同时忙起来卡的使用率更高。但这有一个前提——显存得装得下。如果 KV Cache 已经吃满调度器连把新请求放进批次的空间都没有混批的收益无从谈起。所以顺序永远是先算显存够不够再谈算力用没用满。 实测环境数据实测 —— 来源为 vLLM 官方文档与 PagedAttention 论文取数日期截至 2026-10-08口径为「该阶段的耗时主要由哪类资源决定」不含具体卡型与型号。阶段受限资源影响因素应对出处截至 2026-10-08prefill算力浮点运算吞吐输入 token 数、批内 prefill token 预算分块预填充与 decode 混批vLLM 官方文档 Optimization and Tuning 中「compute-bound (prefill)」原句decode显存带宽读取吞吐KV 总量 并发路数 × 序列长度 × 每 token 字节分页管理、降低 KV 头数、缩短序列上限同上「memory-bound (decode)」arXiv:2309.06180这张表就是整篇文章的索引凡是启动慢、首字慢往 prefill 那行找凡是并发加不上去、字间隔变长往 decode 那行找。二、KV Cache 怎么估一行等式套你自己模型的 config能开多少并发答案基本由一行乘法决定每 token 的 KV 字节数 2 × 层数 × KV 头数 × head_dim × 字节宽。这行等式不是我发明的口径它是从模型 config 的几个字段直接推出来的下面把每一步的依据标出来。先说清为什么非得按 KV 算PagedAttention 论文摘要讲得很直白每个请求的 KV cache 显存「huge and grows and shrinks dynamically」管理不善就会被碎片和冗余副本浪费掉「limiting the batch size」——也就是说批大小并发的上限是被 KV 显存卡住的不是被算力卡住的。每 token KV 字节 2 × num_hidden_layers × num_key_value_heads × head_dim × dtype 字节宽 └┬┘ └────────┬────────┘ └─────────┬─────────┘ └──┬──┘ K 与 V 两份 模型层数 每层的 KV 头数 单个数值占几字节这行等式怎么来的依据拆成四步每一步都能回溯到模型 config 的字段定义。Hugging Facetransformers的配置文档里num_hidden_layers的中文口径是「The number of blocks in the model.」num_attention_heads是「The number of attention heads used in the multi-head attention layers of the model.」hidden_size是「The hidden size of the model.」。而num_key_value_heads的说明是「the number of key_value heads that should be used to implement Grouped Query Attention…… If it is not specified, will default tonum_attention_heads.」head_dim的说明是「The attention head dimension. If None, it will default tohidden_size // num_attention_heads」。把这几条串起来就是那行等式乘 2是因为每个 token 在每层都要存一份 Key 和一份 Value两份乘层数是因为每一层都有自己的 KV乘 KV 头数是因为这一层里真正参与注意力读取的头数就是 KV 头数分组查询注意力下它小于注意力头数乘 head_dim是因为每个头存head_dim个数乘字节宽是把「个数」换成「字节」BF16 是 2 字节FP32 是 4 字节。注意num_key_value_heads和head_dim在 config 里可能是空值空值时要按上面两条默认规则回退别直接当 0 用。真跑一遍20 GiB 预算下能开几路 实测环境Python 3.13.12 / macOS / numpy 2.5.3脚本仅用标准库乘法numpy 只用于末尾校验打印下面这段脚本把参数换成你自己的值就能用输入层数、KV 头数、head_dim、dtype 字节宽、序列长度和可用显存输出「单条序列 KV 占用」和「给预算下能开的并发路数」。importnumpyasnp LAYERS,KV_HEADS,HEAD_DIM,DTYPE_BYTES32,8,128,2# 假设值32 层 / 8 个 KV 头 / head_dim 128 / BF16defkv_bytes_per_token(layers,kv_heads,head_dim,dtype_bytes):return2*layers*kv_heads*head_dim*dtype_bytes# 2 K 与 V 各一份defmax_seqs(budget_gib,seq_len,per_token):returnint((budget_gib*1024**3)//(per_token*seq_len))per_tokenkv_bytes_per_token(LAYERS,KV_HEADS,HEAD_DIM,DTYPE_BYTES)print(f每 token KV 字节 2 x{LAYERS}x{KV_HEADS}x{HEAD_DIM}x{DTYPE_BYTES}{per_token}B)print(f可用 KV 预算 20 GiB)forseqin(1024,2048,4096,8192,16384):per_seq_mibper_token*seq/1024**2nmax_seqs(20,seq,per_token)print(fseq{seq:6d}单条{per_seq_mib:8.2f}MiB 可开并发{n:4d}路 单路显存{per_seq_mib/1024:5.2f}GiB)print(numpy check:,np.round(np.array([per_token,per_token*4096/1024**2]),1))真跑的原始输出如下一字未改每 token KV 字节 2 x 32 x 8 x 128 x 2 131072 B 可用 KV 预算 20 GiB seq 1024 单条 128.00 MiB 可开并发 160 路 单路显存 0.12 GiB seq 2048 单条 256.00 MiB 可开并发 80 路 单路显存 0.25 GiB seq 4096 单条 512.00 MiB 可开并发 40 路 单路显存 0.50 GiB seq 8192 单条 1024.00 MiB 可开并发 20 路 单路显存 1.00 GiB seq 16384 单条 2048.00 MiB 可开并发 10 路 单路显存 2.00 GiB numpy check: [131072. 512.]读法很直接序列长度翻一倍单条占用翻一倍能开的并发路数就砍一半。所以「并发能开多大」这个问题一半的答案在你设max_model_len的时候就定了剩下那一半才是显存预算。哪些是官方给的、哪些是我举例的假设值口径要分清否则复现不出来。来自官方字段定义的部分等式本身num_hidden_layers/num_key_value_heads/head_dim/ dtype 的乘法和乘 2 依据出处是上面引的transformers配置文档。我自己举例的假设值32 层、8 个 KV 头、head_dim 128、BF162 字节、序列长度 4096、KV 预算 20 GiB——这六个数字是为了演示口径而设的假设值不对应任何一个具体模型你要跑自己的账请从模型目录里的config.json取真实字段值并把「显存预算」按「总显存 − 权重 − 激活 − 框架预留」实际折算。下面这张表把要填的输入项列清楚方便你按行替换。参数在 config / 框架里的字段取值口径本文假设值层数num_hidden_layersconfig 直接给出32KV 头数num_key_value_heads空值时回退为num_attention_heads8head 维度head_dim空值时 hidden_size // num_attention_heads128dtype 字节宽加载精度BF16 2 字节FP32 4 字节2单条序列长度你设的上限越长占用越大线性增长4096KV 可用显存总显存扣除后的余量权重、激活、预留都要扣掉20 GiB三、1 路到 4 路吞吐涨得最快再往上就变慢并发不是线性收益前几路最划算。但在谈收益之前得先有一个前提请求能被动态塞进同一个批次。这个前提来自 Orca 在 OSDI 22 提出的 iteration-level scheduling——把调度粒度从「整个请求」降到「一次迭代」先跑完的请求可以先返回新到的请求随时插进批次而不用等整批跑完。没有这层调度后面谈的「加并发」根本无从谈起。原因藏在 decode 每步要读的数据里每生成一个 token卡要读一遍模型权重再读一遍这一批里所有序列的 KV。权重那份的大小和并发路数无关KV 那份和并发路数成正比。加并发真正多花的是什么设权重读取量为 W、每条序列的 KV 读取量为 S×pS 是当前序列长度p 是每 token KV 字节、显存带宽为 BW。一步的耗时约等于 (W N×S×p) / BW而这一步产出 N 个 token。于是聚合吞吐大约是吞吐 ≈ N × BW / (W N × S × p)这个式子的形状解释了整件事N 很小时分母里 W 占主导吞吐约等于 N×BW/W和并发路数成正比加一路就多一路的量所以 1 路到 4 路涨得最快N 变大后N×S×p 这一项追上来分母被 KV 主导吞吐趋近于上限 BW / (S×p)再加并发也只是逼近这个天花板。拐点出现在哪拐点就在「W 和 N×S×p 相当」的位置。把式子改写成 N×S×p W 解出的 N就是权重那份和 KV 那份打平的地方越过它加并发换来的增量收益开始明显衰减。同时另一头在付代价单路 decode 速度 ≈ BW/(W N×S×p)N 越大单路越慢用户的字与字之间间隔变长、首字延迟也更容易被抢。所以「吞吐涨」和「单路变慢」是同一件事的两面只盯总吞吐会把体验调坏。什么时候该停手而不是继续加并发三个停手信号加并发后总吞吐几乎不动了单路延迟已经越过你的体验预算显存占用逼近gpu_memory_utilization的软上限、开始出现拒绝新请求。到这一步该做的不是继续调大max_num_seqs而是动 KV 那份的量换 KV 头数更少的结构分组查询注意力、缩短max_model_len、或对 KV 做量化。这三条都在直接压小 p 或 S是在抬天花板而不是在天花板下面挤。同一个旋钮还有第二层取舍方向正好相反。分块预填充在 vLLM V1 里默认开启max_num_batched_tokens调小会让字间隔更好prefill 更少地打断 decode调大则让首字延迟更好一步能处理的 prefill token 更多官方文档为吞吐建议把它设到 8192 以上尤其针对显存较大的卡上跑小模型。这个参数本质上就是在「总吞吐」和「单路延迟」之间选边站和上面那条拐点判断是同一件事的两面。四、按现象排障现象 → 原因 → 处置排障的关键是先判断现象落在 prefill 侧还是 decode 侧再决定动哪个旋钮。下面这张表的每一行都能从前面两节推出来。现象原因落在哪块开销处置加一路并发就显存告警、开始拒请求KV Cache 超预算decode 侧显存用第二章等式反算降max_num_seqs或降max_model_len长 prompt 一来整批都卡一下长 prefill 占满一步的算力prefill 侧算力开分块预填充限制单次处理的 prefill token 预算并发加上去单路变慢、字间隔变长KV 读取量随并发线性增长decode 侧带宽停止加并发转去压小每 token KV 字节吞吐加到某档后几乎不再涨已逼近 BW/(S×p) 上限decode 侧带宽停手换更高带宽的卡或降 KV 量化位宽同一配置白天好夜里差峰值并发超过了拐点两侧叠加设定并发上限 排队把峰值削平vLLM 的分页管理在这里帮了大忙论文摘要里说它做到「near-zero waste in KV cache memory」意思是 KV Cache 的显存浪费被压到接近零于是同一份预算能装下更多序列前面那张表里的「可开并发路数」才接近理论上限。同一篇摘要也给出代价对比在同等延迟水平下吞吐比当时的主流系统提升 2-4×。反过来说如果你的框架还在用预留整块连续显存的老做法实测能开的并发通常低于等式算出的值——这时候差的不是算力是浪费在碎片上的显存。《LangChain LangGraph MCP 智能体开发实战》视频课里面有一节专门讲本地推理服务的部署与并发参数和本章的排障思路能对上——把上面这些旋钮一个个调过来。放在资料包里扫码即可获取五、不同场景的并发档位什么条件下加什么条件下停手这一节把前面的账落成可执行的档位。原则只有一句先用等式算出上限再把实际并发停在拐点附近剩下的靠排队削峰。交互式对话优先保单路延迟交互场景的第一指标是首字延迟和字间隔不是总吞吐。这类服务把max_num_seqs设在能装下的位置但不追求拉满通常留出三分之一左右的显存余量给突发流量分块预填充打开让长 prompt 不至于整批卡顿。什么时候停手单路延迟超过你定的体验预算就到此为止。批处理与 Agent 离线优先保总吞吐离线批处理可以接受单路慢要的是单位时间出得多。这类服务把并发推到拐点右侧、贴近吞吐上限的位置先跑一轮小流量确定拐点再固定下来。什么时候停手总吞吐不再涨、或者显存占用触到软上限。到这一步抬吞吐要靠上一节说的三条——压小每 token KV 字节。先看清你的模型是哪种 KV 结构再定档位还有一个前置动作填等式之前先确认num_key_value_heads到底是多少。如果模型用的是分组查询注意力KV 头数小于注意力头数每 token KV 字节会成比例缩小能开的并发相应放大如果模型没写这个字段按官方默认回退到注意力头数那 KV 就会明显更大档位要往保守一侧取。这一步花一分钟看 config比在线上反复试参数省事得多。真实并发能不能再加判据是显存预算够不够、拐点过没过、单路延迟忍不忍得住——三个条件里有一个不成立就停手别拿算力当借口。大模型学习路线图如果你正准备把本地推理服务从单路跑通推到多路并发路线图里「部署与推理优化」那一段把这些前置概念排好了顺序。放在资料包里扫码即可获取附表 A本文引用事实与出处对照表事实出处文档名 发布方 链接本文位置KV cache 每个请求的占用很大且动态增减管理不善会因碎片与重复副本被浪费从而限制批大小《Efficient Memory Management for Large Language Model Serving with PagedAttention》arXiv:2309.06180 摘要页https://arxiv.org/abs/2309.06180第二章PagedAttention 做到「near-zero waste in KV cache memory」同等延迟下吞吐提升 2-4×同上arXiv:2309.06180 摘要页SOSP 2023提交于 2023-09-12第四章分块预填充「better balancing compute-bound (prefill) and memory-bound (decode) operations」混批可提升利用率《Optimization and Tuning》vLLM 官方文档https://docs.vllm.ai/en/latest/configuration/optimization.html页面标注 August 20, 2026截至 2026-10-08 核到第一章分块预填充在 V1 默认开启max_num_batched_tokens调小改善字间隔、调大改善首字延迟为吞吐建议设大于 8192同上vLLM 官方文档 Optimization and Tuning第三章Orca 提出 iteration-level scheduling按迭代粒度调度而非按请求解决批次里请求互相等待的问题《Orca: A Distributed Serving System for Transformer-Based Generative Models》USENIX OSDI 22https://www.usenix.org/conference/osdi22/presentation/yu第三章num_hidden_layers/num_attention_heads/hidden_size字段定义《Configuration》Hugging Face Transformers 文档https://huggingface.co/docs/transformers/main/en/main_classes/configuration第二章num_key_value_heads未指定时回退为num_attention_heads用于分组查询注意力与head_dim未指定时 hidden_size // num_attention_heads字段定义《Llama 2》Hugging Face Transformers 文档https://huggingface.co/docs/transformers/main/en/model_doc/llama2第二章每 token KV 字节等式与并发路数的实测输出表内 32/8/128/2/4096/20GiB 为本文假设值非任何具体模型本文实跑脚本Python 3.13.12 / numpy 2.5.3第二章写在最后这篇用到的资料写这篇文章时把相关的官方文档和源码又翻了一遍顺手也整理了几份配套的东西大模型学习路线图从零基础到能自己动手做 Agent按阶段说明每一步该学什么、哪些可以先跳过《LangChain LangGraph MCP 智能体开发实战》视频课7 个模块从私有化部署、EmbeddingRAG 到 MCPAgent 全流程AI 大模型知识库在线可查Agent Skills 从入门到落地、Claude Skills 完全指南等专题按目录浏览即可640 套 AI 大模型行业报告 经典 PDF 书籍看行业落地案例和别人怎么做的时候用得上大模型零基础到精通教学视频跟着敲一遍比只读文档快得多资料是我自己整理的放在下面这个码上扫码即可获取添加时备注「AI」优先通过。资料按「先路线、再动手、最后查漏」的顺序整理好了建议先看学习路线那一份照着它挑一条适合自己当前基础的路径再往下看。
返回列表