昇腾 910B 上 DeepSeek-V4-Flash PD 分离:KV Cache 原理、容量估算与参数调优实战
姊妹篇说明:本文是《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) / Y
3.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 × data-parallel-size (请求数上限)
实际并发 = min(请求数上限, token 预算上限 ÷ 平均 prompt 长度)
max-num-batched-tokens 限制的是单步处理的 token 总数。当你设 8192、而 prompt 平均 16K 时:
1 个请求就要 16384 tokens > 8192 预算
→ 单个请求都填不满一个 batch
→ 只能 1 个在跑,其余全 WAITING
这就是"只有 1 个在跑"的真正原因——不是 max-num-seqs 限制你,是 token 预算卡住了。
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 × data-parallel-size
你的 DP2 × TP4:max-num-seqs=32 → 整机上限 = 32 × 2 = 64 并发。
定多少不 OOM:
max-num-seqs ≈ (每 DP 组可用 KV tokens) / (业务最长单请求 prompt tokens)
起步 32,压测监控 vllm:kv_cache_usage_perc:
- 稳定 < 0.7 且
num_requests_running常打满 → 调到 48、64 - 一调大就 OOM → 说明激活显存是瓶颈,退回
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-prefills 2 \
--max-long-partial-prefills 1 \
--long-prefill-token-threshold 8192
效果:
- 同时最多 2 个请求在做 partial prefill
- 但长 prompt 只能占 1 个名额
- 超过 8K tokens 算"长 prompt"
- 短请求可以插队到长请求之间,TTFT 大幅降低,长 prompt 吞吐基本不受影响
官方原文:“Setting
max-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-tokens |
8192 | 32768 | 解决"只有1个在跑" |
--gpu-memory-utilization |
0.91 | 0.85 | 防 OOM / 激活显存波动缓冲 |
--max-model-len |
215000 | 业务 P99(如 32768) | 省显存,消除 11GB 误报 |
--max-num-seqs |
32 | 32(起步) | 每 DP 组并发上限 |
--max-num-partial-prefills |
1(默认) | 2(按需) | 解决长短请求队头阻塞 |
--block-size |
128 | 32 | V4 官方推荐 |
RETENTION_INTERVAL |
16384 | 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_role |
kv_both |
kv_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 → 32768 -
gpu-memory-utilization: 0.91 → 0.85 -
max-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 > 0 |
KV 真的不够 | 降并发 / 加 Offload |
kv_cache_usage_perc 持续 >0.9 |
KV 池见底 | 加 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 级闸门跑通后再写。
更多推荐




所有评论(0)