- 人工智能
- 大模型
- 模型推理服务
- Ascend
- CANN
【免费下载链接】vllm-ascend
Community maintained hardware plugin for vLLM on Huawei Ascend
KVPP(KV Layer Parallelism)是 vllm-ascend 为 MLA/SFA 类模型设计的一种 KV 缓存并行化方案:它不再让每个 TP rank 复制完整的持久化历史 KV,而是按层(layer)把缓存分片到 TP 组内的不同 rank 上,计算到某层时再通过整层广播取回。本文围绕 kvpp.md 的设计文档展开,结合仓库内 ascend_config.py、kv_cache_placement.py、kvpp.py 等源码,讲清它的背景动机、内存预算公式、广播预取机制、支持边界,以及如何通过一行additional_config开启。
读完本文,你将掌握:KVPP 解决了什么问题、为什么用"整层广播 + 双 scratch buffer + 预取一层"就能成倍扩大长上下文容量、每 rank 的物理内存预算如何计算、哪些模型与并行组合可用、哪些配置会直接报错,以及完整的启动命令示例。
1. 背景:为什么 MLA/SFA 的持久 KV 缓存会被"复制"
在张量并行(Tensor Parallel,TP)下,MLA(Multi-head Latent Attention)与 SFA(Sparse Flash Attention)类模型的历史 KV 缓存会在 TP rank 之间复制一份(replicate)。以 TP=2 为例,同一段历史上下文在每个 rank 上都保留一份完整缓存,直接带来两方面的代价:
- 长上下文容量受限:KV 缓存按 TP 倍数重复占用显存,可容纳的 token 数被压缩;
- 并发请求受限:缓存副本越多,留给并发请求 batch 的空间越小。
KVPP 的核心思路与常规 KV 分片不同:它**按层(by layer)在 TP 组内分配持久缓存的所有权,而不是按 head 或按序列分片。某个 rank 只持久保存"属于自己"的那些层的主 KV 缓存;当模型执行到某一层、需要该层历史缓存参与注意力计算时,拥有该层的 rank 把整层缓存广播(broadcast)**给组内其他 rank,计算完成后再由 owner 保留更新后的缓存,供下一轮 forward 继续使用。
从实现上看,这一策略在 KVPPConfig.from_vllm_config 中被触发:只有additional_config["enable_kvpp"]为true时,KVPP 的组规模才被计算为tensor_parallel_size * prefill_context_parallel_size(在不启用 DCP 时,MLA 缓存在 PCP 的 KV gather 之后同样被复制,因此层归属要覆盖整个复制域)。
值得强调的是:KVPP只改变 KV 缓存的物理存放位置与传输方式,不改变模型的执行拓扑。模型执行仍然保留原有的 TP/EP/PP 配置;调度器(scheduler)仍然持有完整、逻辑的缓存规格(cache specification)与 block 编号。也就是说,上层逻辑缓存管理完全不变,变的只是底层物理存储的布局和广播时机——这保证了与 vLLM 原有缓存管理、前缀缓存(prefix caching)、chunked prefill 等机制的兼容。
2. 总体设计:层归属 + 双 scratch buffer + 预取一层
KVPP 的设计由三个可叠加的机制组成:
- 层归属(Layer Ownership):每个 PP stage 把本地的目标层(target layer)按层号排序后,切分成连续、均衡的分区,分配给 TP 组内的 owner rank;
- 两个可复用的 scratch buffer:每个 rank 只为"自己拥有的层"分配持久存储,处理其他层时复用两个 scratch 缓冲;
- 预取一层(prefetch one layer ahead):计算当前层的同时,用独立的传输流预先广播下一层。
设计文档给出的拓扑示意如下:
TP group within one PP stage Owner rank: persistent main KV + indexer KV for its layers | +-- Full-layer broadcast --> Other ranks: scratch buffer | +-- Wait --> Compute current layer Prefetch next layer从源码可以验证"双 scratch"的取值并非随意:在 kv_cache_placement.py 中明确定义了
# One buffer for the current layer and one for the next layer's prefetch. KVPP_SCRATCH_BUFFER_COUNT = 2目标层执行序号(execution ordinal)交替选择两个 scratch buffer,保证"当前层正在被计算"与"下一层正在被预取写入"可以分别使用独立的存储,互不覆盖。
3. 层归属(Layer Ownership):如何给层找主人
3.1 分区规则
每个 Pipeline Parallel(PP)stage 先收集本 stage 负责的本地目标层,按层号排序,然后连续、均衡地分给 KVPP 组内的 owner rank。一层的主 KV、indexer KV 以及量化(quantization)组件被捆绑为一个整体(bundle),只有一个 owner,不允许把同一层的不同组件拆给不同 rank。
对应源码是 map_kvpp_layers_to_owners:
- 层按
extract_layer_index分桶并排序; - 用
divmod(len(layer_indices), kvpp_size)得到base, remainder; - 前
remainder个 rank 多分 1 层,即partition_size = base + int(owner_rank < remainder); - 每个 rank 拿到的是一段连续的层区间,同 index 下的所有 cache 名(主 KV、indexer 等)一起归属同一个 owner。
之所以要"连续分区"而非轮询打散,是为了让广播的传输模式更规整(同一 rank 连续拥有相邻层,且各 rank 的层归属在两个 rank 上保持一致),同时便于每 rank 的持久分配按层形成连续内存段。
3.2 MTP 缓存单独处理
MTP(Multi-Token Prediction)等 draft 层的缓存不属于目标层分区:它们仍在每个需要它们的 rank 上独立分配,因此 draft 层不会出现在layer_owner_ranks映射中。这一点在 map_kvpp_layers_to_owners 里通过find_draft_layers(...)显式排除,并在 register_kvpp_draft_layers 中从 loader 处读取真实的 draft 层名(而非靠层号推断),同时校验 draft 层不得与目标层共享 KV 缓存(KVPP does not support draft layers sharing target KV caches.)。
3.3 每 rank 的存储角色
- owner rank:为该层分配持久存储(persistent main KV + indexer KV),forward 结束后保留更新后的缓存;
- 其他 rank:把收到的整层广播写入对应 scratch buffer,仅用于本次计算,计算结束后该 scratch 可被下一层复用。
在 allocate_kvpp_cache 中可以看到:plan.layer_owner_ranks.get(name)为None或等于当前 rank 时分配独立持久 buffer,否则按target_index % KVPP_SCRATCH_BUFFER_COUNT交替复用两个 scratch buffer;scratch 的尺寸取所有目标层 bundle 中的最大值(scratch_size = max(...)),并按 2 MiB 对齐(KVPP_BUFFER_ALIGNMENT = 2 * 1024 * 1024,见 kvpp_cache.py)。
4. 物理布局与内存预算
4.1 物理布局
bundle 内部的各组件(主 KV、indexer 数据、scale 等)在物理上连续存放,每个组件包含该层的全部逻辑 block。KVPP 不要求所有层连成一个全局大块——只有"一个广播 bundle"必须连续,这正是广播能直接走一块连续内存的前提。
布局严格跟随实际缓存规格(cache specification),覆盖主 KV、indexer 数据、scale/量化分量,以及 LI-C8、SFA-C8 等稀疏缓存布局。对应实现是 build_kvpp_layer_layout:按 bundle 内 cache 名顺序、按tensor_sizes中每个分量的"每 block 字节数 × num_blocks"推进游标(cursor),得到每个分量的(offset, size)区间;build_kvpp_buffer_sizes 则根据AscendSFAIndexerCacheSpec、AscendMLAAttentionSpec、量化 split factor 等逐分量计算每 block 大小。
4.2 每 rank 的物理内存预算
设计文档给出了核心公式:
Physical bytes per block = Sum of owned target-bundle bytes per block + Local MTP-cache bytes per block + 2 * Largest target-bundle bytes per block Available blocks = floor(Available KV memory / Physical bytes per block)逐项解读:
- owned target-bundle bytes per block:本 rank 拥有的所有目标层 bundle 每 block 字节数之和——这是唯一的"持久"开销;
- Local MTP-cache bytes per block:本地 MTP/draft 缓存,每个需要的 rank 都要放一份;
- 2 × Largest target-bundle bytes per block:两个 scratch buffer,各按"最大目标层 bundle"尺寸预留。
最终Available blocks取整后即为 KVPP 物理分配所能容纳的 block 数。worker 再把该 block 容量换算成逻辑内存预算交给 planner:逻辑缓存管理(调度、block 编号、前缀缓存)完全不变,物理存储则按 KVPP 布局分配。
源码中的对应实现是 KVPPPhysicalCachePlan.get_num_blocks:
for name, bundle in self.layer_bundles.items(): _, size = build_kvpp_layer_layout(bundle, self.tensor_sizes, num_blocks=1) owner = self.layer_owner_ranks.get(name) if owner is None or owner == self.kvpp_rank: persistent_bytes += size if owner is not None: scratch_bytes = max(scratch_bytes, size) bytes_per_block = persistent_bytes + KVPP_SCRATCH_BUFFER_COUNT * scratch_bytes return available_bytes // bytes_per_block if bytes_per_block else 0与文档公式一一对应:persistent_bytes累加"owned bundle + 本地 MTP(owner 为 None 的 draft 层)",scratch_bytes取目标层中的最大值并乘以 2。
从该公式可以直观看出收益来源:设 TP 组规模为 N、层数为 L,原本每个 rank 要存放全部 L 层的完整缓存副本,KVPP 之后每 rank 只需持久保存约 L/N 层的 bundle,其余层用两块 scratch 轮转承载。层数 L 越大、TP 规模 N 越大、单层缓存越大,节省的显存越可观。worker 侧对预算的实际换算在 worker.py 的_apply_kvpp_memory_budget中完成。
5. 整层广播与预取(Full-Layer Broadcast and Prefetch)
5.1 执行时序
带历史缓存的真实请求(forward pass 中包含此前已计算过的 token)的执行顺序如下:
- forward 开始前:立即预取第一层(第一个 attention 层)的缓存;
- 每个 attention 层:在 projection 之后、第一次访问/写入本层 KV 缓存之前,等待本层广播完成;
- 等待完成后,立刻发起下一层的预取,使下一层广播与当前层计算重叠。
无历史缓存的 forward(首次 prefill)、dummy run(warmup)以及 profiling 路径不做历史缓存广播——它们没有可广播的历史数据,这是设计文档明确给出的优化。
5.2 广播的语义
- 每次广播只在当前 PP stage 的 KVPP 组内进行,不会跨 PP stage 或跨 DP 副本;
- owner 将完整 bundle一次性广播:包含所有组件、所有已分配的逻辑 block;
- 其他 rank 把接收内容写入对应的 scratch buffer;
- 计算结束后,owner 保留更新后的缓存,用于后续 forward。
值得注意的一个开销特征:广播载荷不做请求级或 active-block 级过滤——只要 block 已分配,就整层整块传输,广播流量随分配的 block 总数增长,而不是随实际命中的 token 数增长。这在长上下文 + 高并发时会成为主要的通信开销,需要在容量收益与通信开销之间权衡。
5.3 传输流与事件同步(源码级机制)
预取使用独立的传输流(transfer stream),通过事件(event)建立严格的数据依赖与完成保证。核心实现在 BroadcastKVPPTransport.prefetch:
def prefetch(self, layer_name, cache_ready, transfer_stream): with torch.npu.stream(transfer_stream): transfer_stream.wait_event(cache_ready) # 依赖:等当前流就绪 work = dist.broadcast( self._layer_buffers[layer_name], src=self._owner_global_ranks[layer_name], group=self._device_group, async_op=True, ) work.wait() # 等待广播调用完成 done = torch.npu.Event() done.record(transfer_stream) # 在传输流上记录完成事件 # A Future must cover device completion, including the owner's source # reads, before attention can overwrite the persistent cache. done.synchronize() # 设备级完成后再返回要点:
cache_ready事件在当前计算流上记录,传输流wait_event(cache_ready)保证广播不会早于缓存数据就绪;dist.broadcast(..., async_op=True)在独立线程/流上发起,work.wait()只保证调用层面完成;done.record(transfer_stream)之后done.synchronize()保证设备传输真正结束(包括 owner 侧源数据读取完毕)才从 wait 返回——避免 attention 在广播尚未落盘时就去覆写持久缓存。
5.4 预取调度器
预取由 KVPPScheduler 驱动:
schedule_forward(has_history):has_history为真时立即start_layer_prefetch(第一个 attention 层);- 预取任务提交到单线程
ThreadPoolExecutor(kvpp-prefetch线程),在线程内torch.npu.set_device后走传输流; wait_for_layer(layer_name):当前层广播完成后,_next_attention_layer_index += 1,若还有下一层则立即发起下一层预取——这就是"计算当前层与预取下一层重叠"的直接实现;complete_forward():清空_has_history与层游标,等待下一轮 forward。
执行侧接入在 KVPPRuntime:create_from_kv_cache依据KVPPConfig.from_vllm_config判断config.size > 1才构建运行时,通过static_forward_context[name].impl.layerwise_kv_cache_hook = scheduler把逐层钩子挂到各 attention 层;Model Runner V1 中则在 model_runner_v1.py 用num_computed_tokens > 0判定has_history并调用prepare_forward,forward 结束调用complete_forward。
5.5 KVPP 通信组如何建立
KVPP 组在 parallel_state.py 中建立:所有 rank 按[ExternalDP, DP, PP, PCP, TP]张量化后,flatten(-2)把 PCP 与 TP 维度合并,再按kvpp_size切分出组;每个 DP 副本、每个 PP stage 各有一个独立的 KVPP 组,组的规模恒等于pcp_size * tp_size。这样层归属的复制域与 MLA 缓存实际被复制的域一致(DCP 关闭时,PCP 的 prefill gather 也会复制 MLA KV)。
6. 支持范围与约束(Support and Constraints)
设计文档给出了完整的支持矩阵:
| 维度 | 范围 |
|---|---|
| 模型与执行 | 非混合(non-hybrid)MLA/SFA 模型,eager 模式;Model Runner V1 与 V2 |
| 支持的组合 | TP、EP、PP、chunked prefill、prefix caching(前缀缓存)、异步调度(async scheduling)、固定步长 MTP(fixed-step MTP) |
| 缓存布局 | 按实际缓存规格分配,包括 LI-C8 与 SFA-C8 |
| 不支持 | Graph 执行(CUDA Graph 类)、PCP、DCP、prefill/decode 分离(disaggregated)、变步长 MTP(variable-step MTP) |
源码层面的校验逻辑位于 KVPPConfig.validate,会在配置阶段直接抛错,值得逐一对照:
- DCP 互斥:
decode_context_parallel_size != 1时报"KVPP and DCP cannot be enabled at the same time."; - PD 分离限制:KVPP 的 PD 分离仅允许
MooncakeConnectorV2连接器,且 decode-only 节点(kv_consumer)上必须关闭 KVPP("KVPP must be disabled on the decode-only node."); - 执行模式:仅支持 eager,或 CUDA Graph 模式为
PIECEWISE("KVPP supports eager execution or PIECEWISE only."); - 模型类型:仅支持非混合 MLA 模型(
"KVPP currently supports only non-hybrid MLA models."); - 投机解码:仅支持
method='mtp'或method='dspark',且投机 token 数必须固定("KVPP currently supports only a fixed number of speculative tokens.");DSpark 自适应校验(adaptive verification)与动态投机长度均被拒绝。
这些校验意味着:只要配置不满足上述条件,服务会启动失败并给出明确原因,而不是静默降级,便于用户在部署前快速发现不兼容组合。
通信换容量的本质
KVPP 的本质是用通信换持久缓存容量:每个 rank 仍然需要两块按最大目标层尺寸预留的 scratch buffer,外加独立的 MTP 缓存;持久缓存则从"全部层"缩减为"本地拥有的 L/N 层"。因此:
- 内存收益取决于层数、TP 规模、每层缓存尺寸三者的组合;
- 通信代价取决于分配的 block 总数(广播载荷不按请求/active block 过滤)。
层数越多、TP 规模越大,每 rank 的持久份额越小,收益越明显;而 block 数越大,每层广播的载荷越重,对互连带宽的要求越高。
7. 启用方式(Usage)
KVPP 通过模型启动参数开启,开关为additional_config中的enable_kvpp,默认false。设计文档给出的最小示例:
vllm serve <model-path> \ --tensor-parallel-size 2 \ --enforce-eager \ --additional-config '{"enable_kvpp": true}'要点说明:
--enforce-eager必须显式给出:如第 6 节所述,Graph 执行(除 PIECEWISE 模式外)不被支持,不满足时配置校验会直接报错;--tensor-parallel-size 2:KVPP 组规模默认跟随 TP 规模(更精确地说,源码中为tensor_parallel_size * prefill_context_parallel_size,即 MLA 缓存被复制的复制域;不启用 PCP 时即为 TP 规模)。组内各 rank 各自持有部分层的持久缓存,其余层通过广播取用;- 开启 PP 时:每个 PP stage 在自己的 TP 组内独立分配 owner 并广播,跨 stage 之间互不干扰;
- 开关解析:
enable_kvpp通过 validate_additional_config_bool 按 pydantic 的 bool 规则解析(字符串"false"/"true"也会被正确转换),随后由 KVPPConfig.from_vllm_config 读取。
开启后,日志侧可在ascend_config的kvpp_config.size字段确认生效(size > 1表示 KVPP 已激活,见 ascend_config.py 中kvpp_config: KVPPConfig的注入)。若需与投机解码(MTP/DSpark)联用,请确保投机 token 数为固定值,并注意 MTP 缓存会在各所需 rank 独立分配、不参与层分区。
8. 小结
KVPP 为 vllm-ascend 上的 MLA/SFA 长上下文场景提供了一个清晰的显存-通信权衡杠杆:
- 架构上:保持逻辑缓存管理与模型执行拓扑不变,仅把持久 KV 按层分散到 TP 组各 rank,配以"整层广播 + 双 scratch buffer + 预取一层"的运行时机制;
- 内存上:每 rank 的持久开销从"全部层"降为"本地层 + 两块最大层 scratch + 本地 MTP",公式与源码
get_num_blocks一一对应; - 约束上:eager 或 PIECEWISE、非混合 MLA、固定步长投机是硬性前提,PCP/DCP/PD 分离/变步长 MTP 会被配置校验直接拒绝;
- 使用上:一条
--additional-config '{"enable_kvpp": true}'即可开启,配合--enforce-eager使用。
如果你正在为 DeepSeek 等 MLA 架构的长上下文 + 高并发部署头疼缓存容量,KVPP 是把"复制 N 份的历史缓存"改为"每 rank 持有 L/N 层 + 整层广播"的务实选项——具体收益建议结合你的层数、TP 规模与单层缓存尺寸,按第 4 节的公式先行估算,再评估互连带宽能否承受随 block 数线性增长的广播流量。
- 人工智能
- 大模型
- 模型推理服务
- Ascend
- CANN
【免费下载链接】vllm-ascend
Community maintained hardware plugin for vLLM on Huawei Ascend
相关推荐
跨层 KV 共享(Cross-Layer KV Sharing)实战:从 LLMs-from-scratch 仓库理解 KV 缓存的内存优化
跨层 KV 共享(Cross Layer KV Sharing)实战:从 LLMs from scratch 仓库理解 KV 缓存的内存优化 本篇以 LLMs
示例工程大模型人工智能vLLM-Ascend 分层与稀疏 KV Cache 卸载:Prefill/Decode 分离部署的设计与实践
vLLM Ascend 分层与稀疏 KV Cache 卸载:Prefill/Decode 分离部署的设计与实践 本文围绕 vLLM Ascend 的分层(Lay
人工智能大模型模型推理服务AscendCANNSGLang 分层 KV 缓存接入 AIBrix KVCache 实战指南:以 AIBrixKVCache 作为 L3 KV 缓存
SGLang 分层 KV 缓存接入 AIBrix KVCache 实战指南:以 AIBrixKVCache 作为 L3 KV 缓存 本文以仓库文档 python
模型推理服务推理引擎人工智能大模型本地部署多模态
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考