姊妹篇说明:本文是《DeepSeek-V4-Flash × 昇腾 PD 分离 × Mooncake 外部 KV 池乱码排障实录》的续篇。上一篇解决"外部共享 KV 池能不能用"(结论:对 hybrid V4-Flash 未验证、实测乱码);本篇解决**“不用外部池时,怎么把本地 KV Cache 用到极致”**。
一句话结论:DeepSeek-V4-Flash 在昇腾 PD 分离下,真正的性能瓶颈往往不在"容量不够",而在
max-num-batched-tokens太小导致 P 节点并发上不去;此外启动时看到的“一个请求要 11GB”这类提示只是按 max-model-len 算的理论预留预算(vLLM 启动估算偏保守,不代表真实占用),别被它吓到。本文给出从原理到容量估算再到参数调优的完整方法论。
1. 引言:从"缓存丢失快"说起
典型诉求:
“P 节点并发一上来,本地 KV Cache 淘汰就特别快,缓存一丢就要重新 prefill 长上下文,特别花时间。”
这句话里其实藏着三个不同的问题,需要分开解决:
- 淘汰快→ KV 池容量 / 并发参数问题(本文第 3、4 节)
- 重算慢→ 需要 CPU Offload 承接淘汰的 KV(本文第 5 节)
- 看着显存不够→ 启动时的 11GB 提示多为理论预留误报,需用启动日志反算真实每 token KV(本文第 3.3 节)
混淆这三者,就会陷入"疯狂调参但没效果"的死循环。
2. P/D 节点 KV Cache 原理对比
2.1 先纠正一个词:传的不是"序列",是"KV 张量"
- 序列(tokens):token ID 列表,如
[1, 234, 567] - KV Cache:attention 计算产生的Key / Value 张量
P 节点做的是:拿 prompt tokens → 前向计算 → 每步产生对应的 K、V 张量 → 把这些 KV 张量传给 D 节点。
传的是 KV 张量,不是 token 序列。D 节点收到后不需要重算 attention,直接拿来做 decode。
2.2 数据流全景
┌─────────────────────────────────────────────────────────┐ │ P 节点(Prefill)— "工厂" │ │ │ │ Prompt: [A,B,C,D,E] │ │ ↓ Prefill 计算 │ │ KV Cache: {K_A,V_A}, {K_B,V_B}, ..., {K_E,V_E} │ │ ↓ MooncakeConnector P2P 传输(传的是 KV 张量) │ │ 同时本地保留(prefix caching,可被 LRU 淘汰 / CPU Offload)│ └──────────────────────────┬──────────────────────────────┘ ↓ KV 张量传输(NPU Direct) ┌─────────────────────────────────────────────────────────┐ │ D 节点(Decode)— "施工现场" │ │ │ │ 接收 KV: {K_A,V_A}, ..., {K_E,V_E} │ │ ↓ 加载到 D 的 KV Cache │ │ KV Cache: [A][B][C][D][E] ← 来自 P │ │ 生成 F → KV Cache: [A][B][C][D][E][F] ← F 是自己的 │ │ 生成 G → KV Cache: [A][B][C][D][E][F][G] ← G 是自己的 │ │ ... 只增不减,直到请求结束才整体释放 │ └─────────────────────────────────────────────────────────┘2.3 P 节点 vs D 节点:本质相同,角色不同
| 维度 | P 节点(工厂) | D 节点(工地) |
|---|---|---|
| 角色 | 生产者(算 KV) | 消费者(接收 KV)+ 生产者(生成新 KV) |
| KV 内容 | 多个请求的前缀 KV(可复用) | 当前活跃请求完整 KV(prompt + 已生成) |
| 能复用吗 | ✅ 能(相同前缀命中) | ❌ 不能(每请求独立) |
| 增长模式 | 不增长(前缀长度固定) | 只增不减(每生成一个 token 就涨) |
| 释放时机 | LRU 慢慢淘汰 | 请求结束才释放 |
| 并发数 | 低(如 4~8 prefill) | 高(如 48~128 decode) |
| CPU Offload | OffloadingConnector(前缀换页) | RecomputeCPUOffloadConnector(抢占保护) |
2.4 误区澄清:P 的 KV 真的比 D 小吗?
常见误解:
“D 节点不存历史信息、生成完就销毁,所以 D 可以复用的空间更多,压力更小。”
恰恰相反。正确理解:
- P 的 KV 小,是因为:每个前缀短 + 可以 LRU 淘汰 + 并发低(4~8)
- D 的 KV 大,是因为:每个请求只增不减 + 并发高(48~128)+ 不能共享
算一笔账(prompt 10K、生成 2K 的业务):
| P 节点(并发 4) | D 节点(并发 48) | |
|---|---|---|
| 单请求 KV | ~10K tokens(前缀,可复用) | ~11K tokens(prompt + 已生成,只增不减) |
| KV 总 tokens | ~50K(唯一前缀,可淘汰) | ~528K(活跃,不可淘汰) |
| 显存压力 | 小 | 大 10 倍+ |
类比:P 像图书馆(存很多薄书/prefix,可借给多人复用,不用的放仓库);D 像48 个画家同时作画(每人一张画布,只加不减,画完才收走)。画室的压力远大于图书馆。
所以:D 节点才是 KV Cache 压力的主战场,P 节点的压力来自"前缀数量多"而非"单个前缀大"。
3. KV Cache 容量估算方法论
3.1 每 token KV 字节数:一切估算的基石
所有容量计算都依赖一个必须自己实测的量:每 token KV 字节数。
公式:
单请求 KV ≈ max_model_len × bytes_per_token这个值定不准,后面所有参数都是空中楼阁。
3.2 实测方法:从启动日志反算
启动 P 节点后,抓日志里这两行:
# GPU KV cache size: YYYYY tokens # Available KV cache memory: Z GiB计算:
bytes_per_token=(Z *1024^3)/ Y3.3 启动时"一个请求要 11GB"是理论预留,不是真实占用
启动 P 节点时你可能见过类似提示:
“一个完整请求需要 11GB 显存,显存不够,请调低 max-model-len”
先别慌 —— 这大概率只是 vLLM 按max-model-len算出来的理论预留最大值,而不是运行时真实占用。原因在于:
- vLLM 启动时会做一次
profile_run,再按max-model-len × 每 token KV 大小预留"单请求最坏情况"的 KV 空间 - 这个启动估算偏保守:它可能没有充分计入 DeepSeek-V4-Flash 的MLA 压缩 / 混合注意力带来的 KV 缩减,且默认按"请求一定跑满
max-model-len"的最坏情况留预算 - DeepSeek-V4-Flash 采用压缩注意力,每 token 的 KV 远小于传统 GQA 模型(社区实测约 6~8 bytes/token 量级),所以真实占用通常远低于启动提示
看个例子:max-model-len=215000时 vLLM 按它预留,于是提示"约 11GB";但业务请求实际只有 10K tokens 时,真实 KV 只占其中极小一部分,按压缩后的每 token KV 算大约只有几百 MB 量级。
怎么拿到真实数字?唯一可信的方法是上一节的启动日志反算:
bytes_per_token=Available KV cache memory / GPU KV cache size排查动作:
- 不挂任何 KV 连接器裸跑 baseline → 用启动日志反算
bytes_per_token - 与社区参考值(V4-Flash 压缩后约6~8 bytes/token量级)对照:
- 量级一致→ hybrid 压缩 KV 工作正常,之前"显存不够"只是
max-model-len设太大导致的预留误报 - 显著偏大(数倍甚至一个数量级)→ 排查是否误加了
--disable-hybrid-kv-cache-manager(禁掉它会让压缩失效、KV 变大),或版本 / 连接器存在异常
- 量级一致→ hybrid 压缩 KV 工作正常,之前"显存不够"只是
- 不要拿启动提示当真实占用—— 一切以启动日志反算为准
小结:把
max-model-len从拍脑袋的 215000 降到业务真实 P99(如 32768),这类"显存不够"的误报会自然消失,同时把大量 HBM 还给并发。
3.4 910B3 容量测算实例
硬件:910B3,单卡 64GB,单机 8 卡 =512 GB HBM
按gpu-memory-utilization=0.85、KV cache 约占预算 60% 估算:
整机可用 KV 总量 ≈ 512 GB × 0.85 × 0.6 ≈ 261 GB若 P 节点是DP2 × TP4(8 卡分 2 个 DP 组):
每 DP 组 KV 容量 ≈ 130 GB⚠️ 关键:
max-num-seqs和max-num-batched-tokens都是"每 DP 组"的限制。总并发 = 每 DP 组 × DP size。
按 V4-Flash 压缩后每 token KV 约6~8 bytes量级估算(以实测bytes_per_token为准),每 DP 组 130 GB 可容纳千万级 tokens的前缀 —— 容量极其充裕。所以缓存丢失快通常不是总容量不够,而是并发参数 / 淘汰策略问题。
4. 核心参数调优
4.1 gpu-memory-utilization:为什么 0.91 是"悬崖边跳舞"
直觉上"显存利用率越高越不浪费",但生产环境不建议 ≥0.90,原因:
| 风险 | 说明 |
|---|---|
| 激活显存波动 | profile_run只测单请求峰值,突发长请求激活可能超预估 → 直接 OOM |
| Ascend Graph 开销 | 图捕获会额外占显存且锁住不释放,0.91 可能"启动正常、跑着跑着崩" |
| 启动预留误报 / 激活波动 | vLLM 按max-model-len保守预留,叠加突发长请求激活超预估,0.91 没余量兜底 |
| D 节点逐步增长 | decode 每步 KV 只增不减,顶满会导致"生成到一半被抢占" |
建议值:
| 场景 | 建议值 |
|---|---|
| 生产环境(推荐) | 0.85 |
| 压测极限性能 | 0.90 |
| 调试观察 | 0.80 |
| 绝对不要 | ≥0.95 |
核心认知:有了 CPU Offload 后,HBM 角色从"主力仓库"变成"热数据缓存"。让换页机制工作,比硬塞更有效率——频繁抢占/换页的开销会吃掉硬挤出来的收益。
4.2 max-num-batched-tokens:从 8192 到 32768(最关键的调优)
问题现象
max-num-seqs=32设了,但观察到“只有 1 个在处理,其余排队”。
根因:两个独立的天花板
总并发能力 = max-num-seqs ×>Chunked Prefill 机制当 prompt >max-num-batched-tokens时,vLLM 会自动切块:
200K tokens prompt,max-num-batched-tokens=32768 → Chunk 1: tokens[0~32767] → 算 KV → 追加 → Chunk 2: tokens[32768~65535] → 算 KV → 追加(能看到 Chunk1 的 KV) → ... 共约 7 个 Chunk → 全部完成后,完整 KV 传给 D 节点
关键点:
- 处理结果数值上等价于一次性处理(后面 chunk 的 attention 能看到前面所有 KV)
- 不是每算完一个 chunk 就传给 D,D 要等所有 chunk 算完
- 代价:调度开销让总 prefill 时间略增,但避免了 OOM
调大的三个副作用
- 激活显存暴增(最致命):token 数翻倍 → 激活显存近似翻倍 → 可能 OOM
- TTFT 反而变差:调度器会凑满 batch,请求要等整个 chunk 算完
- D 节点 ITL 变差:若 D 也设大,单步延迟增加(你的 D 用小值 144,没问题)
甜点值建议(910B3,TP4)
平均 prompt 长度 建议值 每步能塞下 ≤ 4K 16384 ~4 个 8K 16384~24576 ~2-3 个 16K(常见) 32768 ~2 个 32K 49152~65536 ~2 个
原则:让max-num-batched-tokens ≥ 2 × 平均 prompt,单步至少能同时 prefill 2 个请求。
安全线:910B3 单卡 64GB 在 TP4 下,32768 是甜点,49152 是上限,65536 是危险区。
4.3 max-num-seqs:每 DP 组的并发上限
官方定义:每个 DP 组允许处理的最大请求数。
总并发能力 = max-num-seqs ×>4.4 max-num-partial-prefills:长短请求的公平性默认值是 1,这会导致队头阻塞:
默认 max-num-partial-prefills=1: 请求A [200K] ──chunk1──► ──chunk2──► ... 占满 100 步 请求B [300 token] 到达 → 本可 1 步完成 → 但必须等 A 全部跑完! 请求B 的 TTFT 变得和 200K 请求一样长 😱
解决方案(配套三个参数):
--max-num-partial-prefills2\--max-long-partial-prefills1\--long-prefill-token-threshold8192
效果:
- 同时最多 2 个请求在做 partial prefill
- 但长 prompt 只能占 1 个名额
- 超过 8K tokens 算"长 prompt"
- 短请求可以插队到长请求之间,TTFT 大幅降低,长 prompt 吞吐基本不受影响
官方原文:“Settingmax-long-partial-prefillsless thanmax-num-partial-prefillswill allow shorter prompts to jump the queue in front of longer prompts.”
什么时候调:压测发现长 prompt 场景下短请求 TTFT 异常高时才调。它是"公平性优化开关",不是必改项。
4.5 block-size 与其他
--block-size:V4-Flash 官方推荐32(对应VLLM_PREFIX_CACHE_RETENTION_INTERVAL=4096,即 32×128=4096)。若用 128 则 retention 需设为 16384--max-model-len:降到业务真实 P99(如 32768),别拍脑袋填 215000--enforce-eager:调试期开,稳定后可视情况关--no-disable-hybrid-kv-cache-manager:必须保留。V4-Flash 依赖它做压缩 / 混合注意力的 KV 管理,误加--disable-hybrid-kv-cache-manager会让压缩失效、KV 占用变大
4.6 参数对照总表
参数 原值 建议值 原因 --max-num-batched-tokens8192 32768 解决"只有1个在跑" --gpu-memory-utilization0.91 0.85 防 OOM / 激活显存波动缓冲 --max-model-len215000 业务 P99(如 32768) 省显存,消除 11GB 误报 --max-num-seqs32 32(起步) 每 DP 组并发上限 --max-num-partial-prefills1(默认) 2(按需) 解决长短请求队头阻塞 --block-size128 32 V4 官方推荐 RETENTION_INTERVAL16384 4096(配 block32) 128 倍关系
5. CPU Offload:HBM 之外的第二层
外部共享池不可靠时,用本机 CPU 内存做 HBM 的下一层。两个连接器职责完全不同,别混淆。
5.1 OffloadingConnector(P 节点:前缀换页)
作用:P 节点 HBM 满了 → 不活跃前缀 KV 换页到 CPU → 下次同前缀请求进来,从 CPU 通过 PCIe 搬回 NPU,避免重算 prefix。
{"kv_connector":"OffloadingConnector","kv_role":"kv_both","kv_connector_extra_config":{"cpu_bytes_to_use":214748364800,"blocks_per_chunk":8,"spec_name":"NPUOffloadingSpec","spec_module_path":"vllm_ascend.distributed.kv_transfer.kv_pool.kv_offload.native.npu"}}
5.2 RecomputeCPUOffloadConnector(D 节点:抢占保护)
作用:D 节点 HBM 满触发 RecomputeScheduler 抢占时,把被抢占请求的 KV 暂存 CPU,恢复时拷回,避免回流 P 节点重算 prefill。
--additional-config'{"scheduler_config":{"recompute_scheduler_enable":true}}'\--kv-transfer-config'{ "kv_connector": "MultiConnector", "kv_role": "kv_consumer", "engine_id": "1", "kv_connector_extra_config": { "connectors": [ {"kv_connector": "MooncakeConnectorV1", "kv_role": "kv_consumer", "kv_port": "30100", "kv_connector_extra_config": {"prefill": {"dp_size": 2, "tp_size": 4}, "decode": {"dp_size": 2, "tp_size": 4}}}, {"kv_connector": "RecomputeCPUOffloadConnector", "kv_role": "kv_consumer", "kv_connector_extra_config": {"cpu_bytes_to_use_per_rank": 26843545600}} ] } }'
铁律:
recompute_scheduler_enable只在 D 节点开,P 节点或 PD 混部开会启动失败RecomputeCPUOffloadConnector的kv_role必须是kv_consumer- 它不是"让 D 的 KV 普遍变大",只在抢占瞬间起作用
5.3 两者区别与协同
维度 OffloadingConnector(P) RecomputeCPUOffloadConnector(D) 挂载节点 P 节点 D 节点 触发时机 HBM 满了,LRU 淘汰不活跃前缀 HBM 满了,抢占正在 decode 的请求 保护对象 历史前缀 KV(可复用) 被抢占请求的 KV(避免回流 P) 是否跨请求共享 是(前缀 hash 命中) 否(仅该请求自身) 是否解决新请求命中 ✅ 是 ❌ 否 kv_rolekv_bothkv_consumer配套开关 无 recompute_scheduler_enable:true
两者互补不冲突:P 用 Offloading 管"前缀换页",D 用 Recompute 管"抢占保护"。
5.4 200GB 配置建议
换算:
200 GB = 200 × 1024^3 = 214,748,364,800 bytes
- P 节点:
cpu_bytes_to_use: 214748364800(注意确认是全局还是 per-rank,保守可先除以卡数) - D 节点:
cpu_bytes_to_use_per_rank: 26843545600(25 GB/卡,8 卡共 200GB) - Docker 必须加:
--shm-size=256g(否则大容量固定内存分配失败) - 主机 RAM 预留:200GB offload + 系统 + vLLM 基础,建议总 RAM ≥ 512GB
预期收益(诚实评估):
场景 无 Offload 200GB Offload P 前缀未命中 + CPU 有 重算(秒~十秒级) H2D 搬回(100K≈300MB,PCIe 64GB/s≈5ms) D 抢占恢复 回流 P 重算(极慢) CPU 暂存恢复(秒级) 新请求、CPU 也无 重算 重算(无改善)
⚠️ Offload不会让新请求凭空变快。它把"HBM 淘汰→重算"替换成"HBM 淘汰→CPU 命中→H2D 搬回"。在长上下文 + 高并发 + 多轮对话场景收益巨大;短请求 + 低复用场景收益有限。
6. 调优行动清单(可勾选)
6.1 第一步:基线测量(最重要)
pip show vllm vllm-ascend+git rev-parse HEAD锁定真实版本- 不挂任何 KV 连接器裸跑 baseline
- 抓启动日志:
GPU KV cache size+Available KV cache memory - 计算
bytes_per_token = (Z * 1024^3) / Y - 判定:与社区参考值(V4-Flash 压缩后约6~8 bytes/token量级)对照;量级一致即正常,显著偏大则排查是否误禁用 hybrid KV manager / 连接器异常
6.2 第二步:参数调优(一次只改一个变量)
max-num-batched-tokens: 8192 →32768gpu-memory-utilization: 0.91 →0.85max-model-len: 215000 →业务真实 P99- 压测观察
vllm:num_requests_running(应从 1 涨到 2~4) - 观察
vllm:num_preemptions(应为 0) - 对比 TTFT 与吞吐
6.3 第三步:CPU Offload(基线稳定后)
- P 节点挂
OffloadingConnector(200GB) - D 节点挂
RecomputeCPUOffloadConnector+recompute_scheduler_enable - Docker
--shm-size=256g - 过 token 级闸门验证正确性
6.4 踩坑预警
信号 含义 动作 P 节点 OOM batch tokens 太大 退回 16384 / 24576 TTFT 反而变长 batch 太大导致排队 适当降低 num_preemptions > 0KV 真的不够 降并发 / 加 Offload kv_cache_usage_perc持续 >0.9KV 池见底 加 Offload 或降max-model-len 外部池 external>0 就乱 hybrid 未验证路径 回退本地方案(见排障篇)
7. 结语
调优的本质不是"把参数拉满",而是理解每个参数背后的物理约束,在相互冲突的目标间找平衡点:
gpu-memory-utilization:容量 vs 稳定性 → 选 0.85max-num-batched-tokens:吞吐 vs 激活显存/延迟 → 选 32768 甜点max-num-partial-prefills:长 prompt 吞吐 vs 短请求延迟 → 让短的插队- CPU Offload:HBM 容量 vs PCIe 带宽 → 用 200GB 换"不重算"
最重要的那条建议:别信任何估算(包括本文的数字),用你自己机器的启动日志反算bytes_per_token,用你真实 prompt 分布跑 benchmark。数据出来了,参数自然就定了。
下一篇(如果有)会是《PD 分离 + 外部共享 KV 池的验证之路》——等升级到 vLLM-Ascend v0.23.0 nightly-main 并用 token 级闸门跑通后再写。