05 · 卸载换显存的边界
0. 结论
- 权重 CPU 卸载不可行 :粒度是整个
nn.Parameter,每 MoE 层每步都要搬 256 个专家的全部权重。 - 「省显存」与「藏时延」严格零和 :净省 =(N−p)×S,要藏住拷贝就得
p≈N,效益为0。 - 1 GiB 显存 ≈ 19,668 token 的 KV;最激进档(卸 18 层、净省 9.94 GiB/rank)只换池 ×1.58,代价是 batch=1 慢 14~21 倍。
- KV 卸载是驱逐层,不是显存扩展 :不能提高
max_model_len、也不能提高同时运行的长请求数;本模型上主机内存只能当 1/8 用(426.6 KiB/token = 54,596 B × 8)。 - 1.03× 池的档位实测零收益 :store 侧写了 581 GiB,但 CPU→GPU 换入 0 次、前缀命中增量 0;要起作用得开到 2.5~4×,而那会挤掉权重页缓存(重启 430 s → ~1200 s)。
- DCP / PCP 搁置 :DCP 的容量 ×DCP 是代码事实,但当前后端启动即断言失败,可用化 15~25 人日;PCP 不增加分片数且被 MTP + full CUDA graph 双重阻断。
- 提升大上下文的正确方案是
PP+fp8KV + 稀疏注意力 ,不是卸载(当前TP=4/PP=2+41,37+ MTP k=3 +fp8_ds_mla+ 512K,池729,280)。
1. 先把两个基础量定下来:汇率和粒度
后面所有"显存能换多少 token"的推算,都依赖这两个量,所以先讲清楚。
汇率:1 GiB 显存 ≈ 多少 token 的 KV?
拿一次基线跑的配置来说(gmu 0.95、fp8_ds_mla、TP=8/PP=1、block_size=64),日志里写了两个数:
- 可用 KV 显存:17.26 GiB
- 对应 KV cache 容量:338,688 tokens
拿这两个数反推,每个 token 在每个 rank 上占的显存是:
bash
主 attention 层 : 79 层 × 656 B = 51,824 B
dsa indexer cache : 21 层 × 132 B = 2,772 B
─────────────────────────────────────────────
每 token / 每 rank = 54,596 B
- 验算:338,688 × 54,596 B ≈ 17.22 GiB,和日志的 17.26 GiB 只差 0.22%,不到一个 block 的量,对得上。
容易忽略的点:indexer cache 和主 attention 共用同一个显存池。如果只算 79 × 656,结果会偏差 5%,跟日志对不上。所以必须把 indexer 的 2,772 B 加进去。
- 最终结论:1 GiB 显存 ≈ 19,668 token(= 1 GiB ÷ 54,596 B)。换算到每个 block(64 token),每个 rank 占 3.33 MiB。
粒度:卸载的内容
- vLLM 的权重卸载,最小单位是一整层 decoder layer,不是单个张量、也不是单个专家。具体机制是这样的(简化描述):
- 模型构建时,
make_layers()把每一层的参数注册给 offloader; - 运行时,prefetch 后端按"参数名段"做白名单匹配(比如你写了
--offload-params experts,它就找名字里含.experts.的参数); - 命中的参数整张量从 CPU 搬到 GPU,没有按行、按专家切片的逻辑;
- 至于 MoE 的路由(top-k 选哪几个专家),发生在层 forward 内部 ,等 offloader 看到时权重已经在显存里了,offloader 对路由完全不可见。
还有一个细节:MoE 的专家权重在运行时已经是一张合并好的大张量(dim0 = 256),不是 256 个小张量。逐专家拆分只存在于 checkpoint 文件里,加载时就merge。由此推出两个很实用的结论:
| 结论 | 含义 |
|---|---|
| 每步 PCIe 搬移量 = 卸载层数 × 0.5845 GiB/rank | 跟 batch 大小、跟请求实际路由到哪几个专家完全无关,是个常数 |
| MTP 头(第 79 层)卸不掉 | 它不走 make_layers,是单独构造的,那 ~0.58 GiB/rank 是固定下限 |
两个"静默失效"的坑
- 这两个坑的共同特点是:不会报错、不会警告,你只觉得"卸载好像没生效",但日志里干干净净。
坑 1:参数名对不上,offload 整个跳过。
bash
# ❌ 这样写不行
--offload-params w13_weight
# ✅ 应该写
--offload-params experts
- 原因:实际参数名是
w13_weight_packed(带_packed后缀)。匹配逻辑是".w13_weight." in ".w13_weight_packed.",结果为False,白名单为空,所有层被静默跳过。
坑 2:多参数的传法。 --offload-params 是 nargs='+',意思是空格分隔多个词:
bash
# ✅ 正确
--offload-params experts w2
# ❌ 错误(会被当成一个字面字符串)
--offload-params '["experts", "w2"]'
- 写完之后建议先看一眼日志里有没有出现
Offloading N parameters...之类的行,确认真的生效。
2. 权重卸载:为什么粒度决定了它不可行
2.1 成本模型:一层一次 28.5 ms,而一层计算只有零点几毫秒
- 取 A800 PCIe 4.0 x16 的 H2D 有效带宽 22 GB/s(A800 常见区间 20~25 GB/s,是本机假设值、未实测),按上面的粒度算:
| 卸几层 | 净省显存/rank | 每步多搬 | 每步新增时延 | batch=1 步时 25~40 ms 下的倍数 |
|---|---|---|---|---|
| 1 层 | 0.55 GiB | 0.58 GiB | 28.5 ms | ≈1.7~2.1× |
| 9 层 | 4.96 GiB | 5.26 GiB | 257 ms | ≈7~11× |
| 18 层 | 9.94 GiB | 10.5 GiB | 513 ms | 14~21× |
| 18 层(batch=64) | 9.94 GiB | 10.5 GiB | 513 ms | 2~4× |
- 用第 1 节的汇率换算:卸 18 层净省 9.94 GiB/rank ⇒ KV +195K token(×1.58),代价是 batch=1 慢 14~21 倍、batch=64 慢 2~4 倍 。反过来问「新增时延要小于步时的 10% 需要卸几层」:batch=1(步时约 25 ms)要求 ΔT < 2.5 ms ⇒ N < 0.09 层,无可行区间 ;即使按极重的 batch=64(步时约 200 ms)算,也只允许 N < 0.7 层,比一层还少,而一层只省 0.58 GiB/rank。
2.2 零和:prefetch_step 同时是「预取深度」和「buffer 份数」
- 这是这条路线最致命、也最容易被误读的性质。prefetch 后端建的是一个静态 buffer 池,
slot_capacity = prefetch_step,第idx个被卸载层用idx % prefetch_step号 slot:
python
self.buffer_pool = StaticBufferPool(
param_infos=param_infos, slot_capacity=self.prefetch_step, device=device,
)
for idx, offloader in enumerate(self.module_offloaders):
slot_idx = idx % self.prefetch_step
offloader.assign_buffer_slot(self.buffer_pool, slot_idx)
⇒ GPU 净省 = (N − p) × S 。而能不能重叠取决于 fork 语义:拷贝在一个独立的 torch.cuda.Stream() 上,start_onload_to_static() 先在本层 forward 的 kernel 全部入队之后 才 record_event(fork_event),copy stream 等这个 event 才开搬;next_index = index + prefetch_step 又决定了这一层发起的是哪一层的拷贝。于是:
prefetch_step = 1:搬的是紧邻的下一层,而下一层马上就在wait_prefetch上阻塞 ⇒ 零重叠,28.5 ms 是纯串行新增时延;prefetch_step = p:最大可重叠的计算量 = (p − 1) 层 ,而 1 层的传输 = 28.5 ms、batch=1 时 1 层 compute 只有 0.3~0.5 ms ⇒ 要藏住 1 层传输需要约 60~90 层的 lookahead;- 取
p = N时传输确实全被藏住,但此时 GPU 里仍留着全部 N 份权重,净省 = 0。
所以「把时延藏住」和「省出显存」不可兼得。 日志里 [PrefetchOffloader] Initialized {N} modules. Total GPU memory saved: {X} GB, Static buffer pool: {Y} GB 那行的 X 是搬到 CPU 的总量、不是净省 ,净省 ≈ X − Y,读日志时必须自己减。
2.3 UVA 后端
-
auto的选路逻辑就两行:offload_group_size > 0→ prefetch,cpu_offload_gb > 0→ uva,都没有 → 什么都不做。 -
UVA 的思路是把权重放 pinned host memory,给 kernel 一个零拷贝 CUDA 视图,省掉了显式 H2D 拷贝。听起来 绕开了 PCIe 瓶颈,实际上更差:
-
MoE 的 grouped GEMM 按 tile 读权重,同一张权重会被反复读。一个 token 命中 8 个专家,和 tile 重叠后 PCIe 读放大约 2~5×。按 2× 保守估,一层每步 ≥ 57 ms------比 prefetch 的 28.5 ms 还慢。
-
另有一个退化路径(
VLLM_WEIGHT_OFFLOADING_DISABLE_UVA=1)会 patchforward,每次前向把整层state_dict()重新.to(device)。对 753B 模型来说等于每层每步全量 H2D,直接不可用。
唯一还有理论价值的用法:--cpu-offload-gb 只卸一两个极少访问的 bf16 小张量。收益几百 MB/rank,相比"至少要省出 1 整层"的门槛,杯水车薪,而且读放大不可控。
2.4 开 offload ≠ 关编译 / 关图
- 一个常见误解:"卸载只能跑 eager。" 代码和实测都不是这样。
- Offloader 显式适配了 CUDA Graph:它注册了
wait_prefetch/start_prefetch两个自定义算子来造数据依赖,捕获和回放前后还会同步 copy stream。config 里也没有"设了 offload 就关图"的分支。
真正的副作用在编译缓存 :OffloadConfig 的所有字段都参与 compile hash 计算。这意味着------
- 开 / 关 / 改 offload 参数 → hash 变 → 旧的
torch_compile_cache不能复用 - 首启会多跑一次 inductor 编译(约 6~8 min)
- 做实验时务必用独立的
CACHE_ROOT,别污染现网
小结(1/3)
权重卸载在"整层、无专家级选择、无 token 感知"这套粒度下,❌ 不可行 。不是"稍微降速"的问题:PCIe 带宽比 HBM 慢两个数量级,每步搬移量被层数锁死,收益和代价被 prefetch_step 严格绑定,不存在可行区间。除非上游支持"按 token 路由只取 active 专家",否则这条路对 GLM-5.3 是死路。
3. KV 卸载:机制成立,但它是驱逐层不是显存扩展
3.1 KV卸载相关解答
- vLLM原生
OffloadingConnector+CPUOffloadingSpec,一个--kv-transfer-configJSON 就够。先把预期纠正掉:
| 问题 | 答案 | 依据 |
|---|---|---|
能把 --max-model-len 提到 GPU 装不下的长度吗? |
不能 | 启动检查 _check_enough_kv_cache_memory,完全不读 kv_transfer_config,仍要求 max_model_len ≤ GPU pool |
| 运行中的请求,KV 可以「住在 CPU」吗? | 不可以 | 命中只按 block hash 查前缀(offloading/scheduler.py:936-991),命中后要生成 GPULoadStoreSpec 拷回 GPU,请求挂到 WAITING_FOR_REMOTE_KVS 等 finished_recving |
| 那它到底有什么用? | ① prefix cache 有效容量扩大 ② 被抢占后少重算 | 同上;store 会对 preempted_req_ids 做 flush |
| 能提高「同时运行的长请求数」吗? | 不能 | 每个运行中的长请求仍要独占其全部已算 KV 的 GPU block |
⇒ 一句话:它是「驱逐层 / 更大的前缀缓存」,与「提升单请求上下文」无关 。两个默认值也要注意:offload_prompt_only 默认 True(只卸 prompt block),多轮场景要显式设 false 才能把 decode 阶段的 KV 纳入;本模型 speculative_config.method == "mtp" 时 use_eagle() 为真,而没有任何 group 被标注 is_eagle_group ⇒ offloading scheduler 会把所有 group 当成 eagle group,解码阶段每个请求的尾部 chunk 不入 offload。
3.2 特殊折扣:主机内存只能按 1/8 算
一句话结论:GLM-5.3 的主机 KV offload,N GB 只能买到 N/8 GB 的有效容量。
原因:vLLM 有一个"TP 组内共用一份 KV 副本"的去重优化,但它要求整个 KV pool 是纯 MLA 且 spec 完全一致 。GLM-5.3 的 DSA indexer cache 也混在同一个 pool 里(head_size=132 vs 主 attention 的 head_size=576),spec 不统一 ⇒ 去重条件不满足 ⇒ 8 个 rank 各存一份完全相同的副本。
bash
有效 KV = 主机内存 ÷ 8(world_size)
canonical_layout = true 解决不了------num_copies 仍由 replicated_layout 决定,而且默认配置根本没把这个参数传到 worker,属于半实现状态。
按 426.6 KiB/token(= 54,596 B × 8)折算:
| 主机内存 | 有效 token | 对比 GPU 池 (338,688) |
|---|---|---|
| 100 GB | 228,928 | 0.68× |
| 150 GB | 343,360 | 1.01× |
| 300 GB | 686,784 | 2.03× ← 推荐起点 |
| 700 GB | 1,602,560 | 4.73× |
| (若去重生效) | 12,807,360 | 37.8× ← 被 indexer 挡掉了 |
📌 磁盘 tier 更不划算。 可以把 KV 落本地盘,但 FS tier 每个 block 写整行(含空槽)且文件名带
_r{rank},实际放大 8×8 = 64×。一块 2.5 TB 盘只换约 57 万 token(1.7× GPU 池)。此条为代码推导,未实测。
3.3 实测:1.03×池的档位,换入0次
- 测量设计用的是共享长前缀负载:48 个互不相同 的 8,192-token 长前缀(合计 393K token)+ 各自的短问题;第 1 轮顺序铺满(把最前面的挤出 GPU 池),第 2 轮只重发前 8 个 (对照组里它们全部 已被挤出)。A/B 只差
--kv-transfer-config:
| 判据 | OFF(基线) | ON(cpu_bytes_to_use=150 GB) |
|---|---|---|
| 服务能否起来 | --- | ✅ Creating offloading spec with name: CPUOffloadingSpec;KV offloading: EAGLE/MTP draft attention groups [0] detected |
/dev/shm/vllm_offload_*.mmap |
--- | 149,994,999,808 B ≈ 150.0 GB(预期 149,979,648,000 B ⇒ 容量模型实测吻合) |
| 卸载容量(token) | --- | 343,360 (= 150 GB ÷ 426.6 KiB/token)⇒ ÷8 实测坐实 |
GPU KV cache size |
338,688 | 334,464 (−1.2%,profiling 差异)⇒ 卸载不改变 GPU 池 |
| 第 2 轮被挤出前缀 TTFT | 8/8 全部重算,≈15.4 s | 8/8 仍然重算,≈15.6 s ⇒ 没有收益 |
vllm:kv_offload_store_bytes_total(GPU→CPU) |
无此指标 | 624,358,039,552 B = 581 GiB(1,792 次、98.9 s)⇒ 确实在写 |
...{transfer_type="CPU_to_GPU"} |
--- | 0 ⇒ 一次都没有换入过 |
prefix_cache_hits_total 增量 |
25,280(含上一次探针残量) | 0 |
根因:
text
CPU 层容量 = 343,360 token
GPU 池容量 = 334,464 token
比值 = 1.03×
CPU 层装不下比 GPU 池更多的历史 。而两边的驱逐都是 LRU:「被 GPU 挤出的 block」恰好就是「被 CPU 层最早挤出的那批」,不存在「GPU 没有、CPU 却有」的窗口 ⇒ 换入次数恒为 0。这与 vLLM 官方 KV Offloading 文档的 Tuning Tips 逐字一致("a smaller CPU tier just mirrors what the GPU already holds and adds no hit rate")。
- 修正后的选型规则 :CPU 层要起作用,必须 ≥ 2.5~4× GPU 池的 token 数 。按 426.6 KiB/token 折算,现实天花板是
2~3×(300~450 GB),再往上就会与「启动快」直接冲突:
| 目标倍率 | cpu_bytes_to_use |
宿主 RAM 占用 | 与权重页缓存的关系 |
|---|---|---|---|
| 1.0×(本次) | 150 GB | 140 GiB(pinned、不可回收) | 还能放下 390 GB 权重页缓存 |
| 2.0× | 300 GB | 280 GiB | 页缓存剩约 670 GB,够 |
| 4.0× | 600 GB | 560 GiB | 页缓存只剩约 390 GB ⇒ 刚好卡在权重工作集上,重启会变慢 |
| 8.0× | 1.2 TB | --- | ❌ 超过参考机的 1007 GB 物理内存 |
还有一个必须算进容量的放大系数:store 是重复发生的 。581 GiB ÷ 426.6 KiB = 1.43M token 当量 ,而两轮逻辑 token 只有约 460K ⇒ 约 3.1× 重复写 (解码推进 / 分块会反复落同一批 block)。用 store_threshold ≥ 2 可以过滤掉只用一次的块。
3.4 落地的代价与开关
| 项 | 值 |
|---|---|
| 每 block 每次搬运(单 rank) | 3,494,144 B(3.33 MiB);走 C++ ops.swap_blocks_batch(DMA) |
| 128K 前缀换入 | 2,048 block × 3.33 MiB = 7.16 GB/rank ,8 rank 合计 57.2 GB ⇒ 单 rank ≈0.29~0.36 s,整机 0.3~0.6 s |
| 这段时延的位置 | 在该请求的 TTFT 关键路径上 (等 finished_recving),但与其它请求的计算重叠 |
| 主机内存 | /dev/shm 里的 mmap,启动时 cudaHostRegister 成 pinned + MADV_POPULATE_WRITE ⇒ 直接吃 RAM、不可回收 ,会挤压权重页缓存(下次重启变慢) |
| 启动开销 | 分配 + pin 数百 GB 会明显拖慢启动;cudaHostRegister 失败只 warning(退化成 unpinned DMA,更慢) |
| 容器要求 | --ipc=host(本部署已是;否则 /dev/shm 只有 32/64 MB,直接 RuntimeError) |
| 与分配器的互斥 | PYTORCH_CUDA_ALLOC_CONF=expandable_segments:True 与任何 KV connector 互斥(vllm/config/vllm.py:1005-1021),命中即 ValueError、容器 Exited(1)。配了 KV_TRANSFER_CONFIG 时脚本自动去掉该环境变量;要保留就 CUMEM_ALLOCATOR=1 走 --enable-cumem-allocator |
落地形态是一个环境变量:KV_TRANSFER_CONFIG 直接透传 --kv-transfer-config 的 JSON 原文,留空即关闭(deploy.sh 的注释里给了 150 GB 的示例;常开建议 cpu_bytes_to_use = 300000000000 + offload_prompt_only=false + 视情况 store_threshold=2)。
小结(三分之二) :KV 卸载到主机内存 ⚠️ 有条件可行 ------「并发 / 多轮前缀复用」能提升,「单请求上下文」提升不了;条件是把 CPU 层开到 2.5~4× GPU 池,并接受主机内存 ÷8 与页缓存被挤压。
4. DCP / PCP:理论容量增益存在,当前不可用
4.1 容量乘数(代码事实)
--decode-context-parallel-size 在 v0.28 已存在(v0.29 新增 --dcp-q-replicate,v0.30 强制声明)。DCP 复用 TP rank(config/parallel.py:342),受 tp % dcp == 0 约束。
容量增益的机理:每请求 block 数与 max_model_len 除以 DCP(kv_cache_interface.py:267-306),池大小不变 ⇒ 有效容量 ×DCP。本质是收回 TP 组内 MLA latent KV 的复制份,不新增显存。上限由 TP 决定(TP=4 时最大 DCP=4)。
4.2 当前后端不可用(四条阻塞)
| # | 阻塞点 | 位置 |
|---|---|---|
| 1 | 启动断言要求 return_lse_for_decode,本后端 can_return_lse_for_decode = False |
cp_utils.py:45;backend.py:873 |
| 2 | 即使绕过断言,mla_attention.py:953 断言 lse is not None,overlay 显式返回 None |
mla_attention.py:953 |
| 3 | DCP 需全局→本地 index 映射(triton_filter_and_convert_dcp_index),本后端 kernel 中 DCP 常量硬编码为 no-op(sparse_utils.py:257 注释 # DCP disabled)⇒ 静默读错 KV(不崩、无异常、结果错误) |
sparse_utils.py:257-260 |
| 4 | Indexer DCP 合并依赖 CuteDSL,面向 Blackwell/Hopper,sm80 未验证 | sparse_attn_indexer.py:53-77 |
4.3 若强行启用:代价与风险
- 改造量 3~4 处:
triton_mla_sparse_attention输出 LSE(split 路径已计算,未写出);- overlay 设
can_return_lse_for_decode = True,返回(attn_out, lse); - index 路径改走
triton_filter_and_convert_dcp_index; - 验证/实现 sm80 下 top-k 合并。
附加约束:头数不可截断;reorder_batch_threshold=1;autotune 按 gathered 头数预热。
搁置理由(四条):
- ROI 不成立。 纯 flag 的
PP=4约 0.5~1.5 人日即可将单请求顶至模型上限。DCP 仅增加聚合容量(≈3.17M vs ≈1.19M,均为外推值,未实测),15~25 人日换此增量不划算。 - 存在静默错误路径。 强制 MQA 下 prefill 走
forward_mqa,但 DCP combine 仅传 decode 的seq_lens(mla_attention.py:959)⇒ prefill 行 LSE 被置-inf⇒ combine 内核将输出写为 0(common.py:121)。无 NaN、无异常,内容错误。修复需改上游mla_attention.py,验收须覆盖 mixed prefill+decode 批。 - 必须并入
fp8_kv补丁链。deploy.sh每路径仅一次 bind mount,triton_mla_sparse_kernel.py已被 fp8 overlay 占用,不能独立开patches/dcp/。 - 上游基准不支持零代价假设。 即便在可运行硬件上,sparse-MLA DCP/PCP decode 回退 0.66×(dcp4)、0.22×(pcp4+dcp4 PIECEWISE)、0.57×(FULL+MoE dedup)。本质是用 decode 带宽换容量。
PCP 更早排除 :不增加 KV 分片数;被 MTP + full CUDA graph 双重阻断(pcp_manager.py:125);需额外 GPU。
--block-size 加大为容量中性(分子分母抵消,须 64 倍数);--num-gpu-blocks-override 不造显存(超出即 OOM);--kv-cache-memory-bytes 仍受物理显存上限。
4.4 其他"省显存"手段排除
| 手段 | 结论 |
|---|---|
gmu 0.95→0.973 |
池 +7.4%~10.1%,单流≈无损;仅短 prefill 成立(余量 ~82 MiB),长 prefill OOM |
max_model_len 327K→352K |
单请求 +7.4%;不可由池容量倒推(600K prefill 瞬态激活超标 → OOM → 容器退出) |
| CUDA graph 捕获 9 档→4 档 | 池仅 +4.0%,单流 −13%,conc16 −19%,拒绝 |
| int4 KV(656→296 B/层) | ×2.22 理论增益,有损(RoPE 64 维亦压 4-bit),精度未验证 |
DP / 双 TP=4 实例 |
需 16 卡;单卡权重 97.5 GiB > 80 GiB,装不下 |
5. 正解:PP + fp8 KV + 稀疏注意力
- 容量提升来自三条改变显存侧粒度的手段,而非改变数据搬运时机:
| 手段 | 作用对象 | 机制 | 实测收益 | 代价 |
|---|---|---|---|---|
| PP | 本 rank 层数 | num_layers 变为本 rank 层数 |
PP=2 ×2.34;PP=4 ×3.52(token 口径) |
流水线气泡;与投机解码的交互 |
| fp8 KV | 每 token 每层字节 | 656 B vs bf16 1152 B,RoPE 64 维不量化 | 池 ×1.71(TP8/PP1 口径) | 高并发有代价,单流≈0 |
| 稀疏注意力 (DSA) | 每 token 存多少 KV | indexer 保留 top-k 索引(132 B/token/层) | 与 fp8 叠加,每 token 每 rank 压至 54,596 B | 内核 overlay 工作量 |
| 权重卸载 | 权重搬运时机 | 整层重传 | 净省 = 0(延迟零和) | batch=1 慢 14~21× |
| KV 卸载 | KV 存储位置 | block 级 LRU,命中拷回 | prefix cache 有效容量 ×2.0/×4.7;不提高 max-model-len | 主机内存 ÷8;挤压页缓存 |
| DCP/PCP | KV 分片数 | 收回 TP 复制 | 理论 ×DCP(推断);当前不可用 | 15~25 人日 + 静默错误风险 |
核心区分:PP / fp8 / DSA 改变"显存里要放多少";卸载改变"何时从主机搬入"。前者换容量,后者(在 PCIe 带宽与整层/整块粒度下)只能换 prefix cache 命中率,换不到上限。
当前定稿配置 :TP=4 / PP=2 + PP_LAYER_PARTITION=41,37 + MTP k=3 + fp8_ds_mla + max_model_len 524288。
| 指标 | 值 |
|---|---|
| KV 池 | 729,280 token |
| 单流吞吐 | 80.0 tok/s(自由)/ 71.9(受控中位) |
| conc16 吞吐 | 297~310 tok/s |
| 128K 并发 | 5 条 |
| 退化率 | 0/200 |
其中 PP_LAYER_PARTITION=41,37(将 2 个 MoE 层从 PP1 挪至 PP0)本身贡献 +22% KV 并将最紧余量抬高 38%(余 2,479 MiB),依据见 02、03。
红线:1M 上下文当前不可用。 必须走 PP=4,而 2026-10-02 实测 PP=4 即使关闭投机解码仍有 20% 退化(开 MTP k=3 时 45%),失败形态为首 token 即多语言乱码。根因:DSA 跨层索引共享修复仅覆盖 PP=2 边界。deploy.sh 中 check_pp_dsa_gate() 已硬拦截。启用 1M 需将该修复扩展至 PP>2,属工程改动,非参数调整。
6. 踩坑与证否
| 我们以为 | 实际 | 为什么错 | 现在的规则 |
|---|---|---|---|
| KV 卸载能换更长上下文 | 它是驱逐层,请求 KV 必须全在 GPU 上;只增 prefix cache 命中,不提高 max_model_len |
把"有效容量"(前缀历史)和"上限"(max_model_len)混为一谈 |
容量结论只认两条:GPU KV cache size 日志 + 真发一个请求 |
| 卸载池开大点总没坏处(选了 150 GB) | 150 GB ≈ 1.03× 池 ⇒ 换入 0 次,store 写了 581 GiB 全是白写 | 没意识到 LRU 在两层上重复;容量不是单调收益 | CPU 层 ≥ 2.5~4× GPU 池才算起点;A/B 必须同时看 store_bytes 与 CPU→GPU 换入两个方向 |
| 8 个 TP rank 共用一份主机副本 | num_copies = 8,N GB 主机内存只买到 N/8 GB 有效 KV |
本模型 DSA indexer(132 B/page)与主 KV(656 B/page)同池但 page size 不同 ⇒ 退化成 UniformTypeKVCacheSpecs,replicated_layout 不生效 |
看 /dev/shm mmap 文件大小,直接除以每 token 字节数验证 |
| 权重卸载 = 用一点速度换显存 | 严格零和:event 在计算入队后才记录 ⇒ p=1 零重叠;要重叠则 p≈N,净省为 0 |
只看到"有独立 copy stream",没看 event 记录时机与 slot_capacity 绑定 |
不再评估此路线;仅"模型装不下"时当容量手段;有效写法是 --offload-params experts(写 w13_weight 会静默不生效) |
| 开 offload 会拖累 CUDA Graph 捕获 | 不需要 --enforce-eager;真正副作用是 compile hash 变了 ⇒ 存量编译缓存作废 |
--- | 实验必须用独立 CACHE_ROOT |
| DCP 理论 ×8 是既有能力 | 上限由 TP 决定(TP=4 ⇒ 最大 DCP=4);且当前后端启动即断言,可用化 15~25 人日,含一条会静默把 prefill 输出置 0 的上游修复 |
把"公式是代码事实"当成"今天就能用" | 仅在需求真超 1.05M 单请求 / 1.19M 聚合时考虑;第一步先在单卡 spike dcp_indexer_cutedsl 能否在 sm80 编译 |
方法学教训 :凡"卸载 / 缓存 / 复用"类实验,结论必须同时看写入侧与读取侧 。本轮 KV 卸载 A/B 之所以可信,是因为四条判据同时成立:store 在写、换入为 0、TTFT 没变、命中计数为 0。如果只看"服务起来了 + 日志打了 Creating offloading spec",会得出完全相反的结论。
7. 复现命令
bash
# ── A. 观察当前缺省形态(关掉卸载)的容量基线 ─────────────────────────
# 当前是 TP=4/PP=2 + 41,37 + MTP k=3 + fp8_ds_mla + 512K;池子应为 729,280
./deploy.sh start
./deploy.sh logs | grep -E "Available KV cache memory|GPU KV cache size"
# ── B. KV 卸载 A/B
# B1. 关闭(对照组)
KV_TRANSFER_CONFIG='' CACHE_ROOT=deploy/.cache-offload PORT=8022 \
./deploy.sh start
# B2. 打开(150 GB ≈ 1.03× GPU 池;这是"零收益"那一档,用来复现换入 0 次)
KV_TRANSFER_CONFIG='{"kv_connector":"OffloadingConnector","kv_role":"kv_both","kv_connector_extra_config":{"spec_name":"CPUOffloadingSpec","cpu_bytes_to_use":150000000000,"offload_prompt_only":false}}' \
CACHE_ROOT=deploy/.cache-offload PORT=8022 \
./deploy.sh start
# 启动校验(三个关键字都要出现)
./deploy.sh logs | grep -E "Creating offloading spec|Created mmap file|EAGLE/MTP draft attention groups"
# 容量自检:mmap 大小应等于 num_blocks × 27,955,200 B(= 3,494,144 B × 8 副本)
ls -l /dev/shm/vllm_offload_*.mmap
# 150 GB 档预期 ≈ 149,994,999,808 B;token 数 = 150 GB ÷ 426.6 KiB ≈ 343,360
# ── C. 判据:写入侧与读取侧必须分开看 ────────────────────────────────
curl -s localhost:8022/metrics | grep -E 'kv_offload_(store|load)_bytes_total'
# 实测:store = 624,358,039,552 B(581 GiB);transfer_type="CPU_to_GPU" = 0 ← 换入 0 次
curl -s localhost:8022/metrics | grep -E 'prefix_cache_hits_total'
# ── D. 前缀复用 A/B 探针(判据是"被挤出的前缀能否免重算")────────────
# 默认 338688 是 gpu-pool-tokens 缺省,换到当前形态时按实际池子传
python3 scripts/bench_prefix_reuse.py --prefixes 48 --prefix-tokens 8192 \
--round2-first 8 --gpu-pool-tokens 334464 --base-url http://localhost:8022/v1 \
--label offload-on --out /tmp/prefix_reuse_on.json
# 对照:把同一个探针指向未开卸载的实例(--label offload-off)
# ── E. 权重卸载:只做静态检查,不要在生产上跑 ────────────────────────
# flag 全集(help 需绕开 device 推断)
docker run --rm --entrypoint bash -e VLLM_TARGET_DEVICE=cpu \
vllm/vllm-openai:v0.28.0-glm53-sm80 -lc 'vllm serve --help=all' \
| sed -n '/^OffloadConfig:/,/^MultiModalConfig:/p'
# ⚠️ 反例:--offload-params w13_weight 匹配不到 w13_weight_packed;
# 日志里 [PrefetchOffloader] 的 "Total GPU memory saved" 是搬到 CPU 的总量,
# 净省 ≈ 该值 − Static buffer pool 的大小。
# ── F. 顺带验证"主机内存 ÷8"的判据(无 GPU,纯 Python)──────────────
docker run --rm --entrypoint bash vllm/vllm-openai:v0.28.0-glm53-sm80 -lc 'python3 - <<PY
import torch
from vllm.v1.kv_cache_interface import MLAAttentionSpec, UniformTypeKVCacheSpecs
main = MLAAttentionSpec(block_size=64, num_kv_heads=1, head_size=576, dtype=torch.uint8, cache_dtype_str="fp8_ds_mla")
idx = MLAAttentionSpec(block_size=64, num_kv_heads=1, head_size=132, dtype=torch.uint8)
specs = {f"layer.{i}.attn": main for i in range(79)}
for i in [0,1,2,6,10,14,18,22,26,30,34,38,42,46,50,54,58,62,66,70,74]:
specs[f"layer.{i}.indexer.k_cache"] = idx
u = UniformTypeKVCacheSpecs.from_specs(specs)
print(type(u).__name__, u.page_size_bytes, len(u.kv_cache_specs))
PY'
# 预期:UniformTypeKVCacheSpecs 3089664 100 ⇒ type(...) is MLAAttentionSpec 为 False
# ⇒ replicated_layout=False ⇒ num_copies = world_size = 8