姊妹篇说明:本文是《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 长上下文,特别花时间。”

这句话里其实藏着三个不同的问题,需要分开解决:

  1. 淘汰快 → KV 池容量 / 并发参数问题(本文第 3、4 节)
  2. 重算慢 → 需要 CPU Offload 承接淘汰的 KV(本文第 5 节)
  3. 看着显存不够 → 启动时的 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

排查动作

  1. 不挂任何 KV 连接器裸跑 baseline → 用启动日志反算 bytes_per_token
  2. 与社区参考值(V4-Flash 压缩后约 6~8 bytes/token 量级)对照:
    • 量级一致 → hybrid 压缩 KV 工作正常,之前"显存不够"只是 max-model-len 设太大导致的预留误报
    • 显著偏大(数倍甚至一个数量级) → 排查是否误加了 --disable-hybrid-kv-cache-manager(禁掉它会让压缩失效、KV 变大),或版本 / 连接器存在异常
  3. 不要拿启动提示当真实占用 —— 一切以启动日志反算为准

小结:把 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-seqsmax-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
调大的三个副作用
  1. 激活显存暴增(最致命):token 数翻倍 → 激活显存近似翻倍 → 可能 OOM
  2. TTFT 反而变差:调度器会凑满 batch,请求要等整个 chunk 算完
  3. 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-prefills less than max-num-partial-prefills will 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 混部开会启动失败
  • RecomputeCPUOffloadConnectorkv_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.85
  • max-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 级闸门跑通后再写。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐