正在获取所在地区…
正在读取天气
音乐
暂未播放
DeepInfer 源码拆解(二十八):userbuffer,四类通信缓冲与对称内存
从 InputMetadata 的伏笔说起
前文讲到调度元数据如何被拼成注意力内核的输入张量,结尾留下一句伏笔:一次前向里有相当一部分延迟并不来自算术,而来自 rank 之间的搬运——MoE 的 dispatch/combine、CP 的 all-gather、Engram 的远程查表、MegaMoE 的对称内存交换。这些搬运都有一个共同前提:通信缓冲区必须先于任何一次前向被分配好,固定下来,此后不再变动。
固定下来的“地方”,就是 deepinfer/model_executor/parallel_utils/userbuffer.py 里的 userbuffer 单例。整个文件只有二百四十行,却定义了 EP、CP、Engram 与 MegaMoE 四类缓冲的全部尺寸推导、分配时机与销毁顺序。它在并行拓扑一篇里只露了个脸(userbuffer.allocate_cp、with userbuffer.cp_session()),在视觉编码器与 Engram 两篇里也没有正面展开,但它是 DeepInfer 通信层的地基。
放在其他框架的语境里对照一下,这件事会更容易理解:
- 在 vLLM 里,EP 通信走的是 DeepEP 的
Buffer,通常在 Worker 初始化时分配,CP/TP 通信走 NCCL 进程组; - 在 SGLang 里,DeepEP buffer 由
DeepEPConfig的 SM 预算决定大小,DP Attention 的同步则完全交给 NCCL; - 在 PyTorch 原生
torch.distributed里,通信缓冲是库内实现的,用户看不到也管不着。
DeepInfer 选择了另一种组织方式:把四类性质完全不同的通信资源收进同一个进程级对象,用同一套“按模型参数推导尺寸、在固定时间点分配、在关闭时逆序销毁”的逻辑统一管理。 本文要回答四个问题:
- 为什么不能依赖 NCCL 自带的通信缓冲,而要用 DeepEP/DeepGEMM 的“用户态缓冲”?
userbuffer里四类缓冲分别解决什么问题,尺寸各自由哪些参数决定?allocate_ep里的 SM 预算推导、allocate_cp里的对齐计算、allocate_engram里的 QP 数,背后是什么工程权衡?- SymmBuffer 的“首个规格必须最大、后续是缓存视图”这条约束,为什么会成为 DeepInfer 对称内存组织方式的硬性前提?
先把结论摆出来:
userbuffer是一个进程级单例,而不是每个 worker、每个模型层的私有对象。 原因是 DeepInfer 的生产拓扑里每个 GPU 只跑一个引擎(DP Attention + Wide-EP),一个进程只有一个模型,一个通信缓冲集合就够用;而且多个 MoE 层共享同一个 EPBuffer,可以避免每层一份缓冲带来的显存翻倍。- EPBuffer 的尺寸由 DeepEP 的 C 侧函数
calculate_ep_buffer_size计算,返回值已经按 2 MB 对齐;SM 数与 QP 数按理论带宽模型推导,并受“是否与计算重叠”这一开关约束。 DeepInfer 不直接调 DeepEP 的默认值,而是把allow_hybrid_mode=True与带有条件判断的allow_multiple_reduction一起交给构造器,把通信行为绑定到确定性配置上。 - CP 缓冲必须手动对齐。 DeepEP 的
get_num_allocation_alignment()要求按 RDMA 对齐分配,所以allocate_cp算出会话上限后,再向上取整到对齐边界,并把max_num_ags_per_session条会话分配的额外对齐字节一起算进去。 - Engram 缓冲是“GPU 接收区 + CPU/GPU 远端存储”两层结构。
get_storage_size_hint一次返回两个数字:GPU 侧的接收区与本 rank 的 FP8 scale 因子分片,以及每个 rank 注册的 RDMA 存储大小;get_theoretical_config再按 NIC 每秒读请求数推导 QP 数、队列深度与是否限制读原子。 - SymmBuffer 的缓存语义隐含了一个顺序约束。
_sym_buffers以规格为键缓存;当新规格出现时,base取已有缓冲中的第一个。因此第一次分配的规格必须是最大的,否则后续更大的规格会视图越界——这不是性能提示,而是正确性前提。

(图源:DeepInfer 官方仓库 doc/images/architecture_overview.png。图中的 Decode 实例内部是 Wide-EP 多卡组织;本文拆的 userbuffer 正是支撑这条 Decode 主轴的通信地基。)
为什么要“用户态”缓冲
在动手看代码之前,值得先把一个容易被跳过的问题讲清楚:NCCL 本身就有通信缓冲,为什么 DeepInfer 要在 userbuffer 这个名字下再建一层?
NCCL 的抽象:集合通信 + 内部缓冲
torch.distributed 与 NCCL 的组合,面向的是“语义清晰的集合操作”:all_reduce、all_gather、reduce_scatter、all_to_all。你给它一组张量,它内部决定切块、分阶段、走哪条路径(Ring、Tree、NVLS),并自己管理收发缓冲。这套抽象极大降低了写分布式代码的门槛,但它有几个隐含假设:
- 通信是独立于计算的一类操作。 NCCL 内核需要 SM 才能跑,但它不知道你的 GEMM 正在用多少 SM,也无法主动让自己的内核“只占 16 个 SM”去和计算重叠;
- 缓冲区是库的内部细节。 你看不到、也改不了收发缓冲的布局,因此无法让“对端写入的位置”与“本地 GEMM 的输入指针”在物理上重合;
- 一次调用一次同步。 多个张量的
all_gather需要多次调用,或者自己拼成一个大张量再调用。
DeepInfer 面对的场景把这三点都推到了极限。
DeepInfer 的通信形态
回到 DeepInfer 实际的工作负载,它的通信集中在四个地方:
| 通信点 | 语义 | 延迟敏感度 | 是否可与计算重叠 |
|---|---|---|---|
| MoE dispatch / combine | 按 token 路由做 All-to-All | 每层一次,累计显著 | 是,且必须重叠 |
| CP all-gather | 序列维度的分批集合通信 | 首尾与注意力内部 | 视阶段而定 |
| Engram 远程查表 | 按 hash 索引做 RDMA 读 | 首层一次 | 是 |
| MegaMoE 的对称内存交换 | gemm 与通信融进一个内核 | 与 GEMM 同频 | 完全融合 |
这四类需求的共同点是:它们的性能上限由“SM 与带宽如何共享、收发缓冲的物理位置、是否能零拷贝”决定,而不是由“集合操作的语义”决定。
第一类(MoE)要的是“通信只占一部分 SM、把缓冲切成小 buckets 与计算流水”。第二类(CP)要的是“多个小张量一次会话完成,输出直接是注册缓冲里的视图”。第三类(Engram)要的是“远端存储注册成可被 RDMA 直接读的区域”。第四类(MegaMoE)甚至没有独立的通信内核——dispatch、GEMM、combine 是同一个 kernel,rank 之间通过对称内存指针读写。
NCCL 的通用集合通信抽象覆盖不了这四种形态,这就是 userbuffer 出现的原因。它借用了 DeepEP 与 DeepGEMM 提供的底层原语(EPBuffer、BucketBuffer、EngramBuffer、SymmBuffer),把“尺寸推导、分配时机、生命周期”这三件跨框架通用的事情收拢到一处。

(图源:DeepEP 官方仓库 figures/normal.png。DeepInfer 的 EPBuffer 正是这幅图里两类内核的统一入口——同一个缓冲对象,可以按调用参数切换高吞吐与低延迟形态。)
userbuffer 的骨架:一个单例,四个槽位
打开 userbuffer.py,最先看到的是一个用 NamedTuple 描述的对称缓冲规格,和它在 _UserBufferManager.__init__ 里的初始化:
1class _SymBufferSpec(NamedTuple):2 """Identity of one SymmBuffer view; fields are exactly the deep_gemm.SymmBuffer kwargs."""3
4 group: dist.ProcessGroup5 num_experts: int6 num_max_tokens_per_rank: int7 num_topk: int8 hidden: int9 intermediate_hidden: int10 num_shared_experts: int11 mma_type: str12 activation: str13
14
15class _UserBufferManager:16 """Owns all process-wide communication buffers used by one loaded model."""17
18 def __init__(self) -> None:19 self.ep: EPBuffer | None = None20 self.cp: CPManager | None = None21 self.engram: EngramBuffer | None = None22 self._sym_buffers: dict[_SymBufferSpec, deep_gemm.SymmBuffer] = {}23
24
25userbuffer = _UserBufferManager()这段代码不到二十行,却定下了整个模块的三条规则:
规则一:一个模型一份缓冲。 userbuffer 是模块级的全局实例,DeepseekForCausalLM 在模型构造的最后调用 self.allocate_userbuffer(),它遍历所有 MoE 层逐层 allocate_*,而每层拿到的是同一个对象。这一点在 allocate_ep 里写得很明确:
1if self.ep is not None:2 assert self.ep.num_bytes >= EPBuffer.get_buffer_size_hint(**ep_kwargs)3 return self.ep第二次调用不会重新分配,而是断言已分配的大小不小于新规格的需求,然后复用。对 decode 来说,所有 MoE 层的峰值 token 数基本一致(受 max_num_tokens 约束),所以复用是安全的;如果某层的规格更大,断言会直接失败,而不是悄悄截断。
规则二:对称缓冲按“规格”缓存。 _sym_buffers 以 _SymBufferSpec 为键。_SymBufferSpec 的字段恰好就是 deep_gemm.SymmBuffer 的关键字参数,规格相同则返回同一实例;规格不同则创建新视图,并把第一个已有缓冲作为 base 传入。这一点后文单独展开。
规则三:销毁是逆序的、显式的。 close() 先销毁 ep/cp/engram,再逆序销毁 _sym_buffers 里的所有视图,最后清空字典。每个缓冲构造时都传了 explicitly_destroy=True,意味着析构函数不会替你释放——必须显式调用 destroy()。这是为了避开 Python 垃圾回收时机不确定、以及进程退出时 os._exit 跳过析构函数带来的 NCCL 通信句柄泄漏。
分配发生在什么时候
分配时机在这里至关重要。deepseek.py 里相关代码的先后顺序是:
1# Allocate userbuffer.2torch.cuda.empty_cache()3self.allocate_userbuffer()torch.cuda.empty_cache() 先执行,把 CUDA caching allocator 里所有空闲块还给驱动,再开始分配。这不是仪式感——EPBuffer 与 EngramBuffer 内部的收发区是普通 CUDA 分配,而 SymmBuffer 依赖 DeepGEMM 的对称内存机制;如果先分配再 empty_cache(),这些区域不会被回收(它们是活跃分配),但其他临时张量的空洞会占住地址空间,导致对称内存对齐或总显存估算出现偏差。
在 allocate_userbuffer() 内部,顺序也是固定的(下面是精简后的关键结构):
1def allocate_userbuffer(self) -> None:2 max_ag_bytes_per_token = self.hidden_size * 2 # hidden_state in sample3
4 if envs.DEEPINFER_YOCO_PREFILL_TRUNCATION_MODE == "compact" and self.model.yoco_boundary_layer_idx is not None:5 # yoco compact 开 mirrorcp 需要传 HC residual(mask 模式不做边界 gather,无此需求)6 max_ag_bytes_per_token = max(max_ag_bytes_per_token, getattr(self.cfg, "hc_mult", 1) * self.hidden_size * 2)7
8 decoder_layers = list(self.model.layers)9 if self.num_mtp_moe_layers > 0:10 decoder_layers += self.MTP_module.moe_layer_list()11 for decoder_layer in decoder_layers:12 if isinstance(decoder_layer.mlp, DeepseekMoE):13 decoder_layer.mlp.allocate_userbuffer()14 if isinstance(decoder_layer.self_attn, V4AttentionBase):15 max_ag_bytes_per_token = max(max_ag_bytes_per_token, decoder_layer.self_attn.get_max_ag_bytes_per_token())16
17 userbuffer.allocate_cp(18 PG.context_parallel_group(),19 max_num_tokens=self.cfg.max_num_prefilling_tokens,20 max_ag_bytes_per_token=max_ag_bytes_per_token,21 )22
23 # Engram24 if self.model.engram_runtime is not None:25 self.model.engram_runtime.initialize()三件事按顺序发生:
- MoE 层逐层分配 EP 或 SymmBuffer。
DeepseekMoE.allocate_userbuffer根据use_mega_moe二选一:MegaMoE 走allocate_sym_buffer,否则走allocate_ep。MTP 层的 MoE 也被拉进来——投机解码的草稿头如果在同一进程里跑,它的 MoE 前向同样需要 dispatcher,这一行避免了一个很隐蔽的问题:MTP 层的allocate_userbuffer被漏掉,前向时才报“EP buffer 未初始化”。 - 注意力层聚合 CP 的 all-gather 字节数。 每层调用
get_max_ag_bytes_per_token(),取最大值,并且以一个“输出侧 hidden 必须 gather”的基线hidden_size * 2(BF16 的两个字节)起步。为什么是最大值而不是总和?因为 CP 的 all-gather 是逐层发生的,同一时刻只有一层的张量在缓冲里,所以缓冲按“单层最大需求 × token 数 × CP 大小”估算即可。 - CP 缓冲一次性分配,Engram 随后初始化。 Engram 的
initialize()内部会调用到userbuffer.allocate_engram:GPU 表建立零拷贝视图后交给EngramBuffer,这里的allocate_engram正是完成这一步的地方。
这个基线值本身也值得一说。CP 的最后一步是注意力输出之后的 hidden_states all-gather(下文的 deepseek.py 第 242 行),它要 gather 的是每 token hidden_size × 2 字节的 BF16 隐状态;而各注意力层内部 gather 的 K/V 分片可能更大也可能更小,取决于 qk_head_dim 与 o_groups 的配置。基线保证了即使所有层内部的聚合字节都不大,输出侧的 gather 也不会因为缓冲不够而越界;各层的 get_max_ag_bytes_per_token() 再在这个基线上取最大。

(本站绘制,依据 userbuffer.py 与 deepseek.py 的调用顺序。图中“模型层调用点”对应 DeepseekMoE.allocate_userbuffer() 与 V4AttentionBase.get_max_ag_bytes_per_token();下方“生命周期”一栏的第六步 close() 在 worker/base.py 的 stop() 里被调用,不在模型内部。)
这张图把四类缓冲并列摆在一起,容易让人误以为它们是“一个东西的四种模式”。实际上它们的性质差别很大,下一节逐个拆。
EPBuffer:一个接口,两套内核,三笔预算
allocate_ep 是四类缓冲里参数最多、推导链最长的一个:
1def allocate_ep(2 self,3 ep_group: dist.ProcessGroup,4 max_num_tokens: int,5 hidden_size: int,6 num_topk: int,7 num_experts: int,8 use_fp8_dispatch: bool,9) -> EPBuffer:10 ep_kwargs = dict(11 group=ep_group,12 num_max_tokens_per_rank=max_num_tokens,13 hidden=hidden_size,14 num_topk=num_topk,15 use_fp8_dispatch=use_fp8_dispatch,16 allow_hybrid_mode=True,17 allow_multiple_reduction=not (envs.DEEPINFER_BATCH_INVARIANT and envs.DEEPINFER_EXPERT_REARRANGEMENT_STEP_INTERVAL > 0),18 )19 if self.ep is not None:20 assert self.ep.num_bytes >= EPBuffer.get_buffer_size_hint(**ep_kwargs)21 return self.ep22
23 self.ep = EPBuffer(**ep_kwargs, explicitly_destroy=True)24 if envs.DEEPINFER_USERBUFFER_COMM_SMS == 0:25 num_sms = self.ep.get_theoretical_num_sms(num_experts=num_experts, num_topk=num_topk)26 device_num_sms = torch.cuda.get_device_properties().multi_processor_count27 compute_num_sms = min(32, device_num_sms // 4)28 envs.DEEPINFER_USERBUFFER_COMM_SMS = min(num_sms, device_num_sms - compute_num_sms)29 ...30 return self.ep六个入参分别决定三笔预算:显存预算(num_max_tokens_per_rank、hidden、num_topk、use_fp8_dispatch)、SM 预算(num_experts、num_topk,以及设备 SM 数)、QP 预算(由 allow_hybrid_mode 与 RDMA 原子能力决定)。下面逐笔拆。
显存预算:C 侧算出来的一个数
EPBuffer 的构造并不在 Python 里逐字段累加,而是把参数交给 C++:
1# Calculate buffer size (already 2 MB-aligned from hint functions / calculate_ep_buffer_size)2if num_bytes is None:3 num_bytes = _C.calculate_ep_buffer_size(4 self.nccl_comm_handle.get(),5 num_max_tokens_per_rank, hidden, num_topk, use_fp8_dispatch,6 allow_hybrid_mode, allow_multiple_reduction)calculate_ep_buffer_size 在 DeepEP 的 C++ 侧实现,返回值已经按 2 MB 对齐(get_buffer_size_hint 复用同一个函数,所以复用断言比较的也是同一口径)。它的输入里没有“层数”“序列长度”“并发请求数”,只有每 rank 的最大 token 数、隐维、topk、是否 FP8,再加上模式开关。这说明一件事:EPBuffer 是 scratch,不是按“模型总吞吐”预留的容量池。 它只需要容纳一次 dispatch/combine 的最大瞬时数据量,具体多少层、多少请求都共用这一份。
num_max_tokens_per_rank 从哪来?mlp.py 里:
1self.max_num_tokens = self.max_num_decoding_tokens if self.engine_type == EngineType.COMPLETION else self.max_num_prefilling_tokens而 model_loader.py 里这两个值的推导是:
1model_cfg.gpt.max_num_decoding_tokens = model_cfg.gpt.max_num_seqs * (1 + envs.DEEPINFER_USE_NEXTN_DECODE)2model_cfg.gpt.max_num_prefilling_tokens = model_cfg.gpt.max_num_batched_tokens // PG.context_parallel_group().size()把它们连起来看,含义很清晰:
- Decode 引擎(
COMPLETION)按max_num_seqs × (1 + 投机深度)分配。 多出来的1 + N因子是给投机解码草稿 token 留的位置——每个请求一步可能带 1 个目标 token 加 N 个草稿 token 进入 MoE dispatch,如果缓冲只按max_num_seqs算,投机路径会把 buffer 写穿。这一条与投机解码一篇直接对应。 - Prefill 引擎按
max_num_batched_tokens // cp_size分配。 因为 prefill 的 token 会先被 CP 切成cp_size份,每个 rank 只看自己那一段,所以单 rank 的 MoE token 上界要除以 CP 大小。这个除法如果漏掉,CP 开启时会多分配cp_size倍显存。 - 一个进程只跑一种引擎类型。 DeepInfer 的 Prefill/Decode 是分离部署的,所以
engine_type在进程启动时就固定,缓冲尺寸也只按一种形态算。这与“同一个进程里 prefill 和 decode 混跑”的框架不同,后者必须按两者的最大值分配。
use_fp8_dispatch 则直接来自 MLP 的 fp8_mlp 配置:
1self.use_fp8_act = cfg.get("fp8_mlp", False)开启后,dispatch 发送的是 FP8 张量加 scale factor:接收端拿到的是 (FP8 tensor, scale factors) 这个二元组,数据字节数大约减半,但缓冲里要额外留 scale 的空间。这就是为什么 C 侧函数把它作为独立开关,而不是简单按 hidden 乘一个系数。
SM 预算:通信让出多少计算资源
EPBuffer 的 SM 数不是显式参数,而是 get_theoretical_num_sms(num_experts, num_topk) 算出来的。DeepInfer 又在这个结果上做了一次“保留计算 SM”的裁剪:
1device_num_sms = torch.cuda.get_device_properties().multi_processor_count2compute_num_sms = min(32, device_num_sms // 4)3envs.DEEPINFER_USERBUFFER_COMM_SMS = min(num_sms, device_num_sms - compute_num_sms)以 H800 的 132 个 SM 为例:compute_num_sms = min(32, 33) = 32,所以通信最多用 100 个 SM。为什么是“32 或四分之一,取小”?这是两个约束的交集:
- 四分之一:给计算留足至少 25% 的 SM,保证 decode 的 GEMM 不会因为通信抢占而排队;
- 32:在 SM 数较少的卡(如 80 SM、96 SM 的卡)上,四分之一也会缩小到二十来个 SM,这时用 32 作上限只是给“大卡”封顶,保证计算侧不会被压得过低。它是一个保守的常数,而不是根据 GEMM 形状推出来的理论值。
这个结果写进环境变量 DEEPINFER_USERBUFFER_COMM_SMS,后续每次 dispatch/combine 都显式把它作为 num_sms 传入:
1x=hidden_states,2num_sms=envs.DEEPINFER_USERBUFFER_COMM_SMS, # TODO: Use all SMs when DeepEP support different #SM for dispatch and combine.那行 TODO 值得注意:dispatch 与 combine 的带宽模型并不相同,但当前 DeepEP 只接受一个 num_sms,所以 DeepInfer 用一个保守值同时服务两者。 dispatch 是“读本地 token、写远端缓冲”,combine 是“读远端结果、累加回本地”,两者对 SM 与 QP 的需求不同。统一值意味着其中一个阶段一定不是最优——这是工程上的已知折衷,而不是设计目标。
一个容易忽略的细节:get_theoretical_num_sms() 在 DeepEP 内部有缓存,get_theoretical_num_qps() 则是一个纯函数式的推导。更重要的是,SM 数只在 DEEPINFER_USERBUFFER_COMM_SMS == 0(即用户没有手动指定)且第一次分配时被推导一次,之后所有 MoE 层、包括后续可能的第二次 allocate_ep 调用,都复用这个环境变量。这符合 DeepEP 的约束:一个 EPBuffer 里的通信内核在构造时就绑定了 SM 与 QP 数,运行时每个 dispatch 调用的 num_sms 只是在“预分配的网络上下文”里选择用多少个,而不能超过构造时准备的 QP 上限。
理论 SM 数是怎么推出来的
get_theoretical_num_sms 的推导不算短,但逻辑是清晰的。它要回答:给定专家数、topk、带宽,通信内核最少需要多少 SM 才能不成为瓶颈?
第一步是估算“一个 token 的 topk 选择平均会落到几个不同的 rank 上”。对均衡 gate,公式是:
E[Ghit]=G(1−(kE)(kE−E/G))其中 E 是专家总数,k 是 topk,G 是分组数(在 EP 语境里就是 rank 数,每个 rank 负责 E/G 个专家)。这个式子的含义是:先算“一个 token 的 k 个选择全部避开某个固定组”的概率,再用 1 减去它,得到“至少命中该组一个专家”的概率;乘以组数,就是命中的不同组数的期望。对 DeepSeek 的 group-limited gate,这个公式只是近似,DeepEP 的注释也明确写了“请勿用于 V3.0 的 group-limited gate”,但作为一阶估算足够。
第二步是把流量拆成四类,全部按“每 token、每 hidden 字节”归一化:
sm_read:dispatch 阶段从 HBM 读 token 的量;sm_write:dispatch 阶段写 send buffer 与发出 NVLink 的量;nvlink_traffic:走 scale-up 域(NVLink)的字节量;rdma_traffic:走 scale-out 域(RDMA)的字节量。
对单节点的纯 NVLink EP(num_scaleout_ranks == 1),代码里的累加是:
1# Write send buffer2if self.num_rdma_ranks > 1:3 sm_write += 1 / num_expected_topk4
5# Issue NVLink6sm_write += self.num_nvlink_ranks / self.num_ranks7
8# NVLink and RDMA traffic9nvlink_traffic += self.num_nvlink_ranks / self.num_ranks * (1 - 1 / self.num_nvlink_ranks) # Except local bypass10rdma_traffic += (self.num_ranks - self.num_nvlink_ranks) / self.num_ranks1 / num_expected_topk 这一项对应“每个 token 平均被复制到几个 rank”,即写放大;(1 - 1/R_nvlink) 对应“除本地 rank 外,其余 NVLink rank 都要收一份”,即扇出。对跨节点 EP,还会有 num_expected_scaleout_topk 与转发路径的额外项。
第三步是找瓶颈域:比较 rdma_traffic / rdma_gbs 与 nvlink_traffic / nvlink_gbs,取较大的那个作为 bounded_traffic 与 bounded_gbs。这相当于“哪条链路的归一化时间更长,就以它为瓶颈”。
第四步是算 SM:
1num_sms = max(2 # Dispatch3 bounded_gbs / bounded_traffic * sm_read / sm_read_gbs,4 bounded_gbs / bounded_traffic * sm_write / sm_write_gbs,5 # Combine6 bounded_gbs / bounded_traffic * (sm_write - nvlink_traffic) / sm_read_gbs,7 bounded_gbs / bounded_traffic * (sm_read + nvlink_traffic) / sm_write_gbs,8)9num_sms = align(max(4, math.ceil(num_sms * 1.3)), 4)10num_sms = num_sms if self.prefer_overlap_with_compute else max(num_sms, 64)11num_sms = min(num_sms, num_device_sms // 2 * 2)四个比值分别对应 dispatch 的读/写与 combine 的读/写;乘以 1.3 是工程裕量,向上取整到 4 的倍数是为了让 SM 分配能均分到偶数个 channel;max(num_sms, 64) 只在“不需要与计算重叠”时生效,因为独占通信时多给 SM 能更快跑完;最后把上限压到设备 SM 数的一半,避免通信把整卡吃满。
DeepInfer 调用时没有传 prefer_overlap_with_compute,所以走 EPBuffer 的默认值 True,不会触发 64 的下限;随后又被 device_num_sms - compute_num_sms 二次裁剪。两层裁剪叠加后,DeepInfer 实际使用的是“理论值、设备 SM 减保留计算 SM”两者的最小值(理论值本身已经不超过设备 SM 的一半)。 这就是为什么在 132 SM 的卡上,通信通常只拿到 64–100 个 SM,而不是理论公式给出的上限。

(图源:DeepEP 官方仓库 figures/low-latency.png。DeepInfer 的 EPBuffer 在 decode 阶段更接近这幅图:低延迟模式把收发槽位固定下来,用 RDMA 直接写对端缓冲;而 prefill 的大批量 token 更适合 Normal 模式的转发路径。)
QP 预算:一条 channel 一条队列
RDMA 的 QP(Queue Pair)数决定能同时有多少个未完成的 RDMA 操作。EPBuffer 在构造时先定一个上限:
1if num_allocated_qps == 0:2 # Hybrid mode will consume more QPs3 # The extra QP is for notify warps4 if self.allow_hybrid_mode:5 num_allocated_qps = 65 if check_fast_rdma_atomic_support() else 1296 else:7 num_allocated_qps = 17为什么是 65 和 129?混合模式下,每个 channel 需要一条数据 QP,另外还要一条 notify QP 用来发完成通知;如果网卡支持快速 RDMA atomic,可以用一条 QP 同时承担数据与原子操作,于是 64 个 channel + 1 条 notify = 65;不支持时要把数据与 atomic 拆开,变成 128 + 1 = 129。非混合模式只有 16 个 channel,所以是 16 + 1 = 17。
实际使用的 QP 数又由 SM 数推导:
1# For direct mode, we encourage less QPs to reduce DB ringing overhead2num_qps = min(num_sms, 8 + 1)3
4# For hybrid mode, we encourage every channel (and notify) to have an independent QP5if self.allow_hybrid_mode:6 num_qps = num_sms * 16 + 17
8return min(num_qps, self.num_allocated_qps)混合模式下每个 SM 展开 16 条 channel,所以 num_sms * 16 + 1;再被 num_allocated_qps 截断。这个设计把“SM 数”和“QP 数”绑在一起:多给 SM 不只是多给计算单元,还会多开 QP,从而增加网卡队列压力。 反过来说,如果网卡 QP 上限较低,SM 数也不能无限加,num_allocated_qps 就是那道天花板。dispatch/combine 调用时若传入的 num_qps 超过 num_allocated_qps,DeepEP 会直接 assert num_qps <= self.num_allocated_qps 失败——所以这不是软限制,而是硬约束。
一个容易忽略的互斥:batch-invariant 与 EPLB
allow_multiple_reduction 的取值不是常量,而是一个条件表达式:
1allow_multiple_reduction=not (2 envs.DEEPINFER_BATCH_INVARIANT3 and envs.DEEPINFER_EXPERT_REARRANGEMENT_STEP_INTERVAL > 04)这三个符号分别是什么意思?
DEEPINFER_BATCH_INVARIANT:要求同一请求在不同 batch 组成下产生逐位相同的结果。batch-invariant 那一篇讲过,DeepInfer 为此固定了归约顺序、禁用了会改变累加顺序的优化。DEEPINFER_EXPERT_REARRANGEMENT_STEP_INTERVAL > 0:EPLB 在线重排专家,每隔若干步就会改变“哪个 rank 持有哪个专家”。allow_multiple_reduction:DeepEP 的 combine 是否允许分多次归约。允许时可以利用更多并行度,但归约顺序会随 rank 拓扑变化;关闭时只在 combine 的 epilogue 里做一次归约,精度最好,代价是传输量可能变大。
三者同时成立时,如果允许多次归约,EPLB 重排后的归约顺序可能与重排前不同,同一请求的输出就不再逐位一致。所以 DeepInfer 把 allow_multiple_reduction 关掉:宁可 combine 慢一点,也要保证 batch-invariant 的承诺。这是一条典型的“正确性优先于吞吐”的约束,也解释了为什么 userbuffer.py 里会出现一个看似与缓冲无关的布尔表达式。
CPManager:会话、零拷贝与对齐
讲并行拓扑的那一节把 CP 称为“代码里存在、生产暂不使用”的第五条轴,只展示了 with userbuffer.cp_session(): userbuffer.cp.all_gather(x)() 这种调用形态,没有展开缓冲是怎么铺的。这一节补上。
BucketBuffer:一块连续对称存储 + 会话语义
CPManager 本身很薄,真正的实现在 DeepEP 的 BucketBuffer 里:
1class CPManager:2 """Manage CP communication storage, sessions, and all-gather operations.3
4 Wait callbacks must be consumed within the session. Gathered tensors alias5 the buffer storage, which is reused by the next session.6 """7
8 def __init__(self, group: dist.ProcessGroup, num_bytes: int) -> None:9 self._buffer = BucketBuffer(group, num_bytes, explicitly_destroy=True)10 self._rank = group.rank()11 self._size = group.size()12 self._session: BucketSession | None = None那段 docstring 是整段代码里信息密度最高的地方,它说了两件必须同时成立的约束:
- 等待回调必须在会话内消费。
all_gather返回的是一个 handle,handle.wait()之后才拿到结果;但会话退出时缓冲的“借用”关系会被撤销。如果会话外还握着 handle 再调用wait(),拿到的可能是下一次会话已经写过的数据。 - 聚合结果是缓冲的别名。
wait()返回的张量没有复制,直接是把注册存储里的那一段view出来。这意味着使用方必须在会话内把结果读完,或者自己复制出去;否则下一层 CP 通信会把这块区域覆盖。
BucketBuffer 的设计目标由此清晰:它是一块按“会话”复用的共享存储,而不是按张量分配的内存池。 传统做法是每次 all-gather 都 torch.empty 一块接收区,NCCL 从发送缓冲拷进去;BucketBuffer 则要求发送方与接收方都指向同一块注册存储的不同切片,通信内核直接写这块存储,省掉一次复制。
尺寸怎么算
allocate_cp 的逻辑用十几行代码就能读完:
1def allocate_cp(2 self,3 cp_group: dist.ProcessGroup,4 max_num_tokens: int,5 max_ag_bytes_per_token: int,6 max_num_ags_per_session: int = 8,7) -> CPManager | None:8 if cp_group.size() <= 1:9 assert self.cp is None, "cp buffer was already allocated for a multi-rank CP group"10 return None11 max_ag_bytes_per_session = max_num_tokens * max_ag_bytes_per_token * cp_group.size()12 max_ag_bytes_per_session += max_num_ags_per_session * deep_ep.get_num_rdma_alignment()13 alignment = deep_ep.get_num_allocation_alignment()14 num_bytes = ((max_ag_bytes_per_session + alignment - 1) // alignment) * alignment15 if self.cp is not None:16 assert self.cp.num_bytes >= num_bytes17 return self.cp18
19 self.cp = CPManager(cp_group, num_bytes)20 return self.cp先看尺寸公式:
S=一次 all-gather 的聚合量Tmax⋅Btoken⋅Ncp+会话内对齐的额外字节Nags⋅Ardma各符号的含义:
- Tmax:单 rank 的最大 token 数,传的是
max_num_prefilling_tokens(注意不是 CP 切分前的总 token 数); - Btoken:每个 token 参与 CP all-gather 的字节数,由各层注意力
get_max_ag_bytes_per_token()与输出侧基线hidden_size * 2的最大值给出; - Ncp:CP 组大小,因为聚合后在每个 rank 上都要放完整的 Tmax×Ncp 个 token;
- Nags:一个会话里最多有多少次分配的额外对齐,默认 8;
- Ardma:一次 RDMA 对齐单位(
get_num_rdma_alignment())。
为什么要乘以 CP 大小? 因为 all_gather 的输出是“所有 rank 的本地分片拼接”,每个 rank 最终持有 Tmax×Ncp 个 token 的数据。如果只按本地 token 数分配,输出区根本放不下。
为什么要加对齐字节? BucketSession._allocate 每次分配都会把字节数向上对齐到 get_num_rdma_alignment();一个会话里最多 8 次分配,就会有最多 8 次对齐浪费。预留 8 个对齐单位,是为了保证会话内 8 次分配不会因为对齐向上取整而超出总大小。这个细节在代码里没有注释,只能从 _allocate 的实现反推:
1def _allocate(self, num_bytes: int, dtype: torch.dtype = torch.uint8) -> torch.Tensor:2 assert num_bytes > 03 allocated_bytes = align(num_bytes, self.alignment)4 offset = self.num_allocated_bytes5 if offset + allocated_bytes > self.buffer.num_bytes:6 raise RuntimeError(f'BucketBuffer session out of storage, which has only {self.buffer.num_bytes}')7 self.num_allocated_bytes += allocated_bytes8 return self.buffer.storage.narrow(0, offset, num_bytes).view(dtype)注意 offset + allocated_bytes > self.buffer.num_bytes 这个判断用的是对齐后的 allocated_bytes,所以真正越界的是对齐后的大小。allocate_cp 里预留的 N_ags × alignment 恰好覆盖了这段对齐增量。
最后一次向上取整到 get_num_allocation_alignment():这是 DeepEP 对缓冲存储起始地址与总大小的要求,因为 RDMA 注册与 BucketBuffer 的对称切分都按这个粒度为前提。
max_ag_bytes_per_token 从哪来
这个值由两部分取最大:输出侧基线,与各注意力层的估计。输出侧基线在 allocate_userbuffer 里是 self.hidden_size * 2,对应 CP 最后一步把完整隐状态 gather 回来的长度;注意力层的估计则来自 V4AttentionBase.get_max_ag_bytes_per_token():
1def get_max_ag_bytes_per_token(self) -> int:2 return self.qk_head_dim * torch.bfloat16.itemsizeqk_head_dim 是 MLA 压缩后的 KV 隐维(DeepSeek 系模型里是 576,即 512 的 latent 加 64 维 RoPE),乘 BF16 的 2 字节得到每 token 的 KV 分片字节数。这一层要 gather 的是“投影后、RoPE 前”的 KV——注意 _prepare_swa_kv 里 fused_norm_rope_hadamard_cast_store_kvcache 的 out_args 直接指向 userbuffer.cp.get_inplace_tensor(...) 返回的注册切片,也就是说投影、归一化、RoPE、量化存储这一整条融合 kernel 的“输出”并不是在写 KV Cache,而是在把 RoPE 前的结果写进 CP 的注册存储,等 all-gather 之后再统一落盘。
get_max_ag_bytes_per_token() 在整个 V4AttentionBase 里只返回这一个常数,说明 CP 的 all-gather 在注意力层里只针对主 KV 分片这一种数据;不同层取最大值得到的其实是同一个数。但缓冲的尺寸仍然按“最大”而不是“总和”算——因为同一时刻只有一层在会话里。
这也解释了一个容易被误解的行为:如果 cp_group.size() <= 1,allocate_cp 返回 None,整个 CP 缓冲不分配,cp_session() 直接空转。 这行断言 assert self.cp is None 不是多余的——它防止“先按多卡跑了一次、再改成单卡”的进程里残留旧值;由于 userbuffer 是单例,跨模型实例复用时这类残留是真实存在的。
会话如何把“未注册张量”变成“注册切片”
BucketSession 最巧妙的部分,是它在会话期间临时替换 Buffer 的集合通信方法:
1def __enter__(self) -> 'BucketSession':2 # NOTES: Users must ensure themselves all the allocated bytes in the buffer can be3 # safely reused before entering this session.4 assert not any(name in self.buffer.__dict__ for name in self.collective_methods), \5 'Nested sessions are not supported'6 self.num_allocated_bytes = 07 self.context_idx = None8 for name in self.collective_methods:9 method = partial(self._collective_func, name)10 setattr(self.buffer, name, method)11 return self进入会话时,all_gather/reduce_scatter/all_reduce 三个方法被替换成 _collective_func 的偏函数;退出时再删除这些实例属性,恢复成类上的原始方法。于是:
- 会话外调用
buffer.all_gather(srcs)走原生路径,要求srcs必须属于这块缓冲(已经是注册切片); - 会话内调用同样的方法,则先检查
srcs是否已注册;如果没注册,就从会话游标处分配一块注册切片、torch._foreach_copy_把数据拷进去,再调用原生方法。
这就是 _collective_func 里那段 is_src_registered 判断的意义:
1is_src_registered = storage_begin <= src.data_ptr() < storage_end2...3if not is_src_registered and not accept_unregistered_src:4 copy_tasks.append((src_buffer, src))5...6if copy_tasks:7 torch._foreach_copy_(*zip(*copy_tasks))torch._foreach_copy_ 是批量拷贝,把多个小张量一次提交,减少 Python 侧循环与 kernel 启动开销。accept_unregistered_src 的取值是 name == 'all_gather' and num_rdma_ranks == 1,即只在纯 NVLink、且只做 all-gather 时为真——此时 DeepEP 可以直接从任意 CUDA 张量读,不需要先拷进注册区;其余情况(RDMA、reduce-scatter、all-reduce)都必须先落到注册存储里。
_wrap_handle 则处理“结果落在哪里”的问题:如果调用方给了 dsts,而 dsts 不在缓冲里,会话会把聚合结果先写进临时注册切片,等 wait() 后再批量拷回 dsts:
1def hook_after_wait():2 dst_buffers = hook()3 copy_pairs = [(dst, dst_buffer) for dst_buffer, dst in zip(dst_buffers, dsts, strict=True)4 if dst.data_ptr() != dst_buffer.data_ptr()]5 if copy_pairs:6 torch._foreach_copy_([dst.view(-1) for dst, _ in copy_pairs],7 [dst_buffer.view(dst.dtype) for dst_buffer, dst in copy_pairs])8 return resultdst.data_ptr() != dst_buffer.data_ptr() 这一项是为了避免自拷贝:如果调用方本来就是用注册缓冲做输出,就不要再拷一遍。
把这条链路连起来,一次会话内的 cp.all_gather(x) 完整流程是:
- 检查
x是否已注册;未注册则从游标分配一块,并拷贝进去; - 调用
buffer.all_gather(src_buffers),结果同样落在游标分配的注册区里; - 返回 handle,
wait()时把聚合结果的 rank 分片按列拼成 Ncp×T 再flatten(0, 1); - 若调用方给了外部
dsts,在hook_after_wait里拷回。
这与并行拓扑一篇里“gathered_hidden_states = userbuffer.cp.all_gather(hidden_states)()”的调用一一对应:外面看到的是一个同步调用,里面是三段式:拷贝、通信、回填。
DeepInfer 在哪些地方开 CP 会话
CP 会话在 DeepInfer 里有五处调用点,基本覆盖了 prefill 阶段所有“需要序列维度完整视野”的通信:
| 调用点 | 文件与函数 | gather 的内容 | 用途 |
|---|---|---|---|
| 主 KV 分片 | attn.py::_prepare_swa_kv | RoPE 前、归一化后的 kv_swa | 拼接完整序列的 KV,供注意力计算 |
| 主 K 缓存更新 | attn.py::update_main_cache | main_k.unsqueeze(1) | 把各 rank 的 K 拼成完整序列后按镜像索引落盘 |
| 压缩器输入 | attn.py 第 782 行附近 | projected_kv | 先发通信,与 Q 投影重叠 |
| YoCo 边界 | yoco.py::yoco_boundary_compact | contrib(截断后仍保留的 token 值) | compact 模式下跨 rank 完成截断与重排 |
| 最终隐状态 | deepseek.py 第 242 行 | hidden_states | CP 输出汇总,交给采样层 |
其中 update_main_cache 和 _prepare_swa_kv 展示了两种不同的“写入方式”:
1def update_main_cache(self, main_k, kv_cache, main_positions, slot_mapping_store_swa, mirror_cp_reverse_index) -> None:2 use_cp = mirror_cp_reverse_index is not None3 with userbuffer.cp_session(enabled=use_cp):4 if use_cp:5 main_k = self.kv_norm(6 main_k,7 out=userbuffer.cp.get_inplace_tensor(main_k.shape, main_k.dtype),8 )9 wait_main_k = userbuffer.cp.all_gather(main_k.unsqueeze(1))10 main_k = gather(wait_main_k(), mirror_cp_reverse_index)11
12 fused_norm_rope_hadamard_cast_store_kvcache(13 main_k,14 norm_args=None if use_cp else NormArgs(self.kv_norm.weight, self.kv_norm.variance_epsilon),15 ...这里 get_inplace_tensor 是 CPManager 提供的“预分配”接口:它在会话里按 (N_cp, *shape) 分配一整块聚合区,返回本 rank 对应的那一片。于是一个很值钱的优化出现了:归一化 kernel 的输出直接写进注册区,省掉一次“算到临时张量、再拷进缓冲”的往返;随后 all_gather 看到源已注册,也就不再拷贝。注意 norm_args=None if use_cp else ...:CP 开启时归一化已经在 kv_norm(..., out=...) 里做过了,融合 kernel 不再重复做归一化。
而 _prepare_swa_kv 走的是另一条路:直接让 fused_norm_rope_hadamard_cast_store_kvcache 的 out_args 指向 get_inplace_tensor() 的返回切片。这条路径上的 CastArgs 带 cast_back=True,out_stage 保持默认的 transform:内核按 e4m3、每通道元素数 swa_cache_num_per_channels 做一遍量化-反量化的往返,输出的是“经过 RoPE、Hadamard 变换、并按缓存精度舍入过”的 BF16 张量,写进 CP 注册区。之所以要先舍入再聚合,是为了让聚合后的 kv_swa 与直接写进 SWA 缓存的那份在数值上同一口径——prefill 之后 store_kv_cache(kv_swa, kv_cache.swa_cache, ...) 用的就是这个聚合结果,后续层读缓存时看到的也就是它,两边如果精度不一致,同一段上下文会在不同层得到不同的 K。
这里有一个容易看反的地方:CP 开启时 out_args 是 CP 的注册切片,kv_cache 并不直接参与写入,cast_back 的意义不在“写缓存”,而在“让聚合前后的数值口径对齐”。两条路径的共同点是:通信的源是注册缓冲,不是普通张量;差别只在于谁负责往里写、写的是哪一个阶段的结果。
会话本身的开销也要算清楚。cp_session() 是一个上下文管理器,进入时替换三个实例属性、退出时删除;每次进出的 Python 开销很小,但它约束了“会话内所有集合调用必须用同一组”(_check_group),也禁止嵌套。DeepInfer 的调用点都是“一次会话、一到两次集合调用”,所以这个约束不会带来实际麻烦。

(本站绘制,依据 BucketSession 的游标分配与 hook_after_wait 回填逻辑。图中左侧是本 rank 的源张量,中间是会话游标分配的注册区域——每次分配按 RDMA 对齐向上取整,右侧是聚合结果在 flatten(0, 1) 之后的形状。会话结束时游标归零,下一层可以复用同一块区域。)
EngramBuffer:GPU 接收区 + 远端存储的两层缓冲
Engram 是 DeepSeek 的“条件记忆”机制,Engram 的推理路径是这样:N-gram 哈希命中后,从一张按层组织的大表里取 embedding,再与前向中的局部计算合并。那张表往往比 GPU 显存装得下的大得多,所以核心问题是:如何让一次哈希命中以尽量少的开销读到远端数据?
答案是 RDMA 读。allocate_engram 就是为这件事准备缓冲:
1def allocate_engram(2 self,3 engram_group: dist.ProcessGroup,4 num_entries_per_layer: list[int],5 hidden_size: int,6 max_num_tokens: int,7 num_entries_per_token: int,8 dtype: torch.dtype,9 num_sf_packs: int = 0,10) -> EngramBuffer:11 assert self.engram is None, "allocate_engram can only be called once"12 num_gpu_bytes, num_rdma_storage_size = EngramBuffer.get_storage_size_hint(13 engram_group,14 num_entries_per_layer,15 hidden_size,16 max_num_tokens,17 num_entries_per_token,18 dtype,19 num_sf_packs,20 )21 use_cpu_rdma_storage = not envs.DEEPINFER_ENGRAM_FORCE_GPU_STORAGE22 num_qps, qp_depth, restrict_rd_atomic = EngramBuffer.get_theoretical_config(23 engram_group,24 len(num_entries_per_layer),25 max_num_tokens,26 num_entries_per_token,27 )28 # EP 通信走 nvlink,此处 Engram sl 保持默认 sl3(sl2 留给 kv cache 流量隔离)29 engram_kwargs = dict(30 group=engram_group,31 num_gpu_bytes=num_gpu_bytes,32 num_rdma_storage_size=num_rdma_storage_size,33 use_cpu_rdma_storage=use_cpu_rdma_storage,34 num_allocated_qps=num_qps,35 allow_hybrid_mode=True,36 explicitly_destroy=True,37 qp_depth=qp_depth,38 restrict_rd_atomic=restrict_rd_atomic,39 )40 self.engram = EngramBuffer(**engram_kwargs)41 self.engram.set_config(42 num_entries_per_layer,43 hidden_size,44 max_num_tokens,45 num_entries_per_token,46 dtype,47 num_sf_packs,48 )49 return self.engram这段代码里可以拆出五个设计决策,每一个都值得单独讲。
决策一:为什么只允许一次
assert self.engram is None, "allocate_engram can only be called once"。
与 EP 缓冲“多层共享”不同,Engram 缓冲只分配一次,并且不能被复用。原因在于 EngramBuffer 的状态不只是存储,还包括 set_config 写入的表结构:层数、每层条目数、每 token 取几条、dtype、scale 打包方式。这些一旦写入,fetch(indices) 的索引张量形状就固定了——它的索引是 [num_layers, num_tokens, num_entries_per_token];如果复用旧缓冲去装另一个结构的表,索引会静默错位。
DeepInfer 的 Engram 是所有层共用一张表族(num_entries_per_layer 是列表,长度等于层数),所以一个缓冲就够;这个断言防止的是“模型里意外出现两个 Engram 运行时”这种情形。
决策二:两个尺寸,两种存储
get_storage_size_hint 返回的是 (num_gpu_bytes, num_rdma_storage_size),它本身只是把参数转交 C 侧的 calculate_storage_size:
1return _C.EngramBuffer.calculate_storage_size(2 comm.get_nccl_comm_handle(group).get(),3 num_entries_per_layer, hidden, dtype.itemsize,4 num_max_tokens, num_entries_per_token, num_sf_packs)docstring 把两个数的含义写得很清楚:
num_gpu_bytes:GPU 侧的“fetch 接收区 + 本 rank 的那一份 FP8 scale 因子表”。接收区要能同时容纳一次fetch返回的所有层的结果,所以它随max_tokens × entries_per_token × hidden × itemsize × num_layers增长;num_rdma_storage_size:每个 rank 注册的存储大小,也就是这个 rank 负责的那份表分片的字节数。
后者默认放在 CPU 内存里:
1use_cpu_rdma_storage = not envs.DEEPINFER_ENGRAM_FORCE_GPU_STORAGE这个默认值是整个 Engram 设计的关键。表的总量按“层数 × 条目数 × 隐维 × dtype”算,FP8 下也已经远超单卡显存;放在 CPU 里,通过 RDMA 直接读,意味着不需要把表复制到 GPU 再读。代价是网络延迟:一次 fetch 要走 PCIe + 网卡,延迟远高于 HBM 访问;但 Engram 的访问模式是一次性、大粒度的 gather,延迟可以被批量掩盖。
EngramBuffer 构造时还有一个处理注册大小的细节:
1num_max_local_ranks = int(os.getenv('EP_NUM_MAX_LOCAL_RANKS', 16)) if allow_hybrid_mode else 12num_registered_bytes = num_gpu_bytes + num_rdma_storage_bytes * num_max_local_ranks + (1 << 32)3num_total_gpu_bytes = torch.cuda.get_device_properties('cuda').total_memory4if num_registered_bytes > num_total_gpu_bytes:5 os.environ['NCCL_WIN_STRIDE'] = str(align(num_registered_bytes, 1 << 32))混合模式下,NCCL 的 window 需要把“本节点上所有 rank 的注册存储”都映射进一个地址空间;按 16 个本地 rank 预留,再加上 4 GB 的余量,是为了容纳 GPU 接收区、各 rank 的存储、以及 NCCL 自身的簿记。如果这个总量超过设备总显存,就调大 NCCL_WIN_STRIDE,让 window 的虚拟地址跨度足以容纳它——这是一个“先按最坏情形设地址空间、再允许真实分配超过显存”的技巧,因为注册存储本身多半在 CPU,真正的 GPU 占用只有 num_gpu_bytes。如果本地 rank 数确实不是 16,EP_NUM_MAX_LOCAL_RANKS 可以覆盖这个假设。
决策三:QP 数与队列深度按 NIC 读能力推
get_theoretical_config 返回三个值,背后是一个很直接的估算:
1nic_read_mpps = 90 if check_fast_rdma_atomic_support() else 452return _C.EngramBuffer.get_theoretical_config(3 comm.get_nccl_comm_handle(group).get(),4 num_layers, max_num_tokens, num_entries_per_token,5 nic_read_mpps, num_max_read_requests_per_qp)nic_read_mpps 是网卡每秒能完成的读请求数(单位是百万次,Mpps),90 与 45 对应新一代与老一代网卡的能力;num_max_read_requests_per_qp 默认 16,即每个 QP 上同时挂多少个未完成的读。C 侧的估算逻辑可以写成:
其中 L 是层数,T 是最大 token 数,kper token 是每 token 取几条条目(num_entries_per_token),qp_depth 是每个 QP 上并发的读请求上限。
这个推导的含义是:QP 数不是拍脑袋定的,而是“要让所有层、所有 token 的读请求能在单步内发出”所需的最小并发度。 如果 QP 太少,读请求会排队,fetch 会退化成串行;如果 QP 太多,网卡的连接表与内存开销会变大。get_theoretical_config 上还挂了 functools.lru_cache,说明这个推导按参数只需要算一次。
restrict_rd_atomic 是第三个返回值:它被传给构造器,再以环境变量的形式传给 NCCL:
1with EnvOverride([2 ('NCCL_GIN_TYPE', '3', True),3 # TODO: Configure RD atomics through NCCL devComm config when supported.4 ('NCCL_GIN_GDAKI_MAX_QP_RD_ATOMIC', '8', restrict_rd_atomic),5]):6 self.nccl_comm_handle = comm.get_nccl_comm_handle(group, force_new_comm=True)NCCL_GIN_TYPE=3 指定使用 GDAKI 后端(GPU 直接访问网卡)以加速初始化,并在构造期间临时覆盖环境变量,构造结束后恢复。这是为了让 Engram 的初始化走“快速路径”,而不受进程其他部分环境配置的影响;force_new_comm=True 意味着它不复用 EP 的 NCCL 通信器,而是单独建一个——两种流量的 QP 配额、超时、后端参数本来就不同。
决策四:RDMA service level 的流量隔离
allocate_engram 里有一行注释:
1# EP 通信走 nvlink,此处 Engram sl 保持默认 sl3(sl2 留给 kv cache 流量隔离)InfiniBand/RoCE 的 service level(SL)决定流量进入哪个虚拟通道,不同 SL 在网卡上有不同的缓冲与优先级。DeepInfer 把 SL2 留给 KV Cache 的跨实例传输(P/D 分离、HiCache 之类的场景),把 Engram 的读请求留在默认的 SL3。这是流量工程,而不是性能调优:如果把 Engram 的大量小读请求与 KV Cache 的大块搬运放在同一个 SL,队列会互相干扰,P/D 分离的延迟会出现毛刺。
决策五:set_config 不是可选的
分配完 EngramBuffer 之后立刻调用 set_config:
1self.engram.set_config(2 num_entries_per_layer,3 hidden_size,4 max_num_tokens,5 num_entries_per_token,6 dtype,7 num_sf_packs,8)EngramBuffer 的构造参数只告诉 C++“多大内存、多少 QP、多久超时”,而 set_config 告诉它“这些内存怎么解释”:每层条目数、条目宽度、一次最多取多少、元素字节数、scale 因子怎么打包。没有这一步,fetch(indices) 无法把索引张量翻译成 RDMA 目标地址。
write(storages, sfs) 则把表内容写进注册存储。在 DeepInfer 的装载流程里,这一步发生在 initialize() 内部:先把 HF 权重里的表整理成 CPU/GPU 上的分片,再调用 write 写进注册存储,最后释放原始张量。CP 组对象广播那一篇已经提到,这类跨 rank 的初始化需要在所有 rank 同时进行。这也解释了为什么 Engram 的加载在 userbuffer 之后——它需要一个已经能接收 RDMA 写的内存区域,而这个区域是 allocate_engram 才建出来的。
fetch 的返回则是一个 per-layer 的等待回调列表:索引一次发出、所有层的 RDMA 读同时进行,调用方逐层 wait() 再取结果。这与 CP 的 all_gather 返回 handle 的设计是同一种思路——先把通信发出去,把等待推迟到真正要用数据的那一刻,从而让通信与前向的其他计算重叠。
通信 SM 的占用与释放:limit_num_sms
userbuffer 里还有一个很容易被跳过的上下文管理器,它不在四类缓冲的分配路径上,却决定了这些缓冲在运行时“拿多少 SM”:
1@contextmanager2def limit_num_sms(num_sms: int, enabled: bool = True) -> Generator[None]:3 if not enabled:4 yield5 return6 prev_sms = deep_gemm.get_num_sms()7 tile_kernels.set_num_sms(prev_sms - num_sms)8 deep_gemm.set_num_sms(prev_sms - num_sms)9 try:10 yield11 finally:12 tile_kernels.set_num_sms(prev_sms)13 deep_gemm.set_num_sms(prev_sms)它做的事情可以用一句话概括:在 with 块内,把后续所有 DeepGEMM / TileLang kernel 可用的 SM 数减去 num_sms,块结束后恢复。 被减掉的这部分 SM,就是留给通信内核的。
这里有一个容易被误解的点:limit_num_sms 本身并不启动任何通信,它只是重新写了两套 kernel 库的“全局 SM 预算”。真正让通信内核只跑在剩余 SM 上的是 dispatch/combine 调用时传入的 num_sms=envs.DEEPINFER_USERBUFFER_COMM_SMS。两者合起来描述的是同一个分区:
其中 Nextdevice 是设备 SM 数(H800 为 132),Nextcomm 就是前面 allocate_ep 推导并裁剪后写进环境变量的值(前面算过,通常是 64–100)。limit_num_sms 把计算侧的上限压到 Nextcompute,通信内核则被显式限制在 Nextcomm 内——两边的边界加起来恰好是整卡 SM 数,不会互相越界抢占。
用在哪里
mlp.py 里只有一处调用,在 MoE 的 combine 之后、共享专家前向之前:
1# Combine2combine_pre_evt = self.ep_buffer.capture()3with limit_num_sms(envs.DEEPINFER_USERBUFFER_COMM_SMS, enabled=PLATFORM.is_gpu):4 shared_out = self.shared_forward(hidden_states)5event_overlap = self.combine(6 moe_out_hidden,7 shared_out,8 handle,9 combine_pre_evt,10)11moe_combined_hidden, _combined_topk_weights = event_overlap.current_stream_wait()看一下时序:combine 先 capture() 一个事件,然后在共享专家前向期间把计算用的 SM 压小,最后 combine() 把通信与共享专家前向一起发出去,用事件在流上等待两者都完成。这是一个典型的“通信与计算重叠”切片:combine 在跑的时候,共享专家的 GEMM 也在跑,但后者被限制在 Nextcompute 个 SM 内,保证两者不会因为争抢 SM 而互相拖慢。
如果只看 limit_num_sms 的名字,很容易以为它“给通信让出 SM”;更准确的说法是“给计算限配额”。通信侧的 num_sms 是请求值,计算侧的 SM 上限是保护值;capture() 加 current_stream_wait() 则保证两个任务在同一个流上的完成顺序正确——只有两者都结束,才把合并后的 hidden 交给下一层。
enabled=PLATFORM.is_gpu 说明在非 GPU 平台(例如 Ascend)上这个分区不启用:那里的通信与计算资源划分由平台自己的调度器负责,Python 侧改 SM 预算没有意义。
一个限制:GPU 上的全局口径
num_sms 是 DeepGEMM 与 TileLang 的进程级全局设置,不是 per-stream、per-thread 的。这意味着:
- 在
with块内,任何并发执行的 DeepGEMM kernel 都会看到缩小后的 SM 数; - 块内有 Python 异常时,
finally保证恢复。但若同样的写法出现在 CUDA Graph 捕获过程中,图里就会永久写死缩小后的 SM 配置(捕获只记录当时的状态,finally恢复的只是捕获之后的新状态)——这也是这类块只应出现在捕获范围之外的原因; - 多个 worker 线程共享这个设置,所以要保证同一时刻只有一个地方在改它。DeepInfer 的做法是只在 MLP 前向的单一位置调用,天然避免了竞争。
读到这里可以回头看 allocate_ep 里的 SM 推导:它给出的是一个“单步内通信能用多少 SM”的静态估计,而 limit_num_sms 是在执行期把这个估计施加到计算侧。前者决定缓冲区尺寸与 QP 数,后者决定这两笔预算在时间轴上的实际占用。
SymmBuffer:对称内存视图与“首个必须最大”的约束
四类缓冲里最反直觉的是 allocate_sym_buffer。它不直接分配一块新的、独立的缓冲,而是把已分配的第一个缓冲当作 base,创建同一块物理存储上的不同视图。
1def allocate_sym_buffer(2 self,3 ep_group: dist.ProcessGroup,4 num_experts: int,5 max_num_tokens: int,6 num_topk: int,7 hidden_size: int,8 moe_intermediate_size: int,9 num_shared_experts: int,10 mma_type: str,11 activation: str,12) -> deep_gemm.SymmBuffer:13 """The first spec allocated must be the largest: it owns the backing storage, later specs are cached views of it.14
15 Callers passing equal specs share one instance; that is safe since SymmBuffer never mutates after construction.16 """17 spec = _SymBufferSpec(18 group=ep_group,19 num_experts=num_experts,20 num_max_tokens_per_rank=max_num_tokens,21 num_topk=num_topk,22 hidden=hidden_size,23 intermediate_hidden=moe_intermediate_size,24 num_shared_experts=num_shared_experts,25 mma_type=mma_type,26 activation=activation,27 )28 buffer = self._sym_buffers.get(spec)29 if buffer is not None:30 return buffer31
32 base = next(iter(self._sym_buffers.values()), None) # get the base buffer33 if base is None and PLATFORM.is_gpu:34 # Newer DeepGEMM probes and caches SM locality on first use, which synchronizes.35 # Initialize it here, before guarded forward and CUDA graph capture.36 locality_init = getattr(getattr(deep_gemm, "locality_domain", None), "get_balanced_sm_locality_domains", None)37 if locality_init is not None:38 locality_init()39 buffer = deep_gemm.SymmBuffer(**spec._asdict(), base=base)40 if base is None:41 logger.info(f"Created DeepGEMM {_gib(buffer.buffer.nbytes)} SymmetricBuffer, (EP {ep_group.rank()}/{ep_group.size()}), {spec=}")42 self._sym_buffers[spec] = buffer43 return buffer为什么要“视图”
MegaMoE 把 gate、dispatch、grouped GEMM、combine 融进一个内核,rank 之间通过对称内存指针交换数据(并行拓扑一篇讲到过 deep_gemm.fp8_fp4_mega_moe)。DeepGEMM 的 SymmBuffer 需要一组固定的参数:专家数、每 rank 最大 token、topk、隐维、中间维、共享专家、MMA 类型、激活类型。不同模型层这些参数通常相同,所以按规格缓存是自然的。
但 DeepInfer 里有两类层会用到它:主干 MoE 层与 MTP 层。它们的专家数或中间维可能相同,也可能不同——MTP 的草稿头往往是一个更小的 MoE。如果每层都独立分配,显存会随层数线性翻倍;SymmBuffer 的“base 视图”机制恰好解决了这一点:物理存储只分配一次,其他规格只是在这块存储上按不同形状重新切。
use_mega_moe 的判定也决定了它与 allocate_ep 是二选一:
1self.use_mega_moe = (2 (PLATFORM.is_gpu or self.mma_type == "fp8xfp4")3 and self.mma_type in ("fp8xfp4", "fp8xfp8", "bf16xbf16")4 and PG.is_within_nvl_domain(PG.expert_parallel_group())5)也就是说,MegaMoE 要求 EP 组落在同一个 NVLink 域内(is_within_nvl_domain)。一旦 EP 跨节点,对称内存指针就不再指向同一地址空间里的对端,MegaMoE 无法工作,只能退回 DeepEP 的 EPBuffer。这个判定解释了生产拓扑里的一个隐含约束:Wide-EP 跨节点部署时,MoE 前向走的是 EPBuffer 路径,userbuffer.sym_buffers 大概率是空的。
“首个必须最大”为什么是正确性约束
base = next(iter(self._sym_buffers.values()), None) 取的是字典里第一个插入的缓冲,而不是“当前最大”的缓冲。这意味着:
- 如果第一次分配的是
num_experts已包含冗余专家、hidden与intermediate都是主干规格的层,后续 MTP 层的规格更小,视图形状都能被包含在 base 的存储里; - 如果第一次分配的是 MTP 的小规格,后续主干层视图需要更大的存储,就会越界——而
SymmBuffer的越界不一定抛异常,可能是读到相邻 rank 的数据,表现为“结果偶发错误”。
因此 docstring 里那句“第一个规格必须最大”不是建议,而是分配顺序的硬约束。它的实现依赖调用顺序,而 allocate_userbuffer 的遍历顺序恰好保证了这一点:decoder_layers 先列 self.model.layers 的全部主干层,之后才 += MTP_module.moe_layer_list()。 如果哪天有人调整了遍历顺序,比如先处理 MTP 再处理主干,这条约束就会被悄悄破坏,且不会有断言拦住。
这是一类典型的“文档约束 + 顺序依赖”的隐患,也提示了一个通用原则:当缓存键与生命周期绑定到“首个实例”时,顺序就是接口的一部分。
为什么要先做 SM locality 初始化
base is None and PLATFORM.is_gpu 分支里的这段代码看起来与缓冲无关:
1locality_init = getattr(getattr(deep_gemm, "locality_domain", None), "get_balanced_sm_locality_domains", None)2if locality_init is not None:3 locality_init()注释已经把原因写清楚了:新版 DeepGEMM 会在首次使用时探测并缓存 SM locality,这个探测会同步。 如果它发生在 guarded forward 或 CUDA Graph 捕获期间,就会导致捕获失败——图里不能出现同步点。所以 userbuffer 在分配第一个 SymmBuffer 之前主动触发一次,把同步行为挪到“一切都还没开始”的时刻。
这是一个很值得记住的工程模式:任何“首次调用会同步/分配/初始化”的库函数,都必须在 CUDA Graph 捕获之前预热。 CUDA Graph 捕获那一篇提到过类似的预热逻辑,这里是从缓冲分配侧又补了一处。
getattr(...) 的双层防御式写法则说明 DeepInfer 要兼容不同版本的 DeepGEMM:老版本没有 locality_domain,就用 getattr 返回 None 跳过;新版本有则调用。这种“可选能力探测”在多后端框架里很常见(讲 TileLang 双平台时也说过同样的思路)。
_asdict() 与“fail fast”
deep_gemm.SymmBuffer(**spec._asdict(), base=base) 这一行把 NamedTuple 直接展开成关键字参数。这样做的好处是规格与构造参数一一对应,不会漏传或多传——_SymBufferSpec 的 docstring 也明确写了“fields are exactly the deep_gemm.SymmBuffer kwargs”。缺点是一旦 DeepGEMM 改了构造签名,这里会直接 TypeError,而不是静默行为变化。这是刻意的“fail fast”设计:通信缓冲的规格错配如果静默发生,后果是显存越界或数据错位,比启动失败更难排查。
SymmBuffer 构造时还会在 EP 组内做一次 barrier(它和 EPBuffer、EngramBuffer 一样,都在构造末尾 torch.cuda.synchronize() 后 group.barrier())。所以首次分配 SymmBuffer 也是一个跨 rank 的同步点——它必须在所有 rank 都会走到的代码路径里调用,不能出现在“某个 rank 恰好分支不同”的地方。
与 allocate_ep 的二选一
DeepseekMoE.allocate_userbuffer 里:
1def allocate_userbuffer(self) -> None:2 if self.use_mega_moe:3 self.sym_buffer = userbuffer.allocate_sym_buffer(...)4 else:5 self.ep_buffer = userbuffer.allocate_ep(...)一个 MLP 层只持有其中一种。但 userbuffer 里 ep 与 _sym_buffers 可以并存:如果同一个模型里只有部分层满足 MegaMoE 条件,另一部分层走 EPBuffer,那么两者都会被分配,显存预算要按两者之和算。实践中 use_mega_moe 依赖的是 EP 组是否在同一 NVLink 域,这是拓扑级属性,对同一组的所有层都成立,所以正常部署里并存的概率不高——但代码并没有对此做断言,不能默认二者互斥。
close() 的顺序也反映了这一点:先销毁 ep/cp/engram,再逆序销毁 sym 视图。先销毁 base 再销毁视图会出错,因为视图引用了 base 的存储;逆序(后分配的视图先销毁)恰好保证 base 最后销毁。vars(buffer).clear() 在销毁后显式清空对象字典,是为了让 Python 层立刻丢掉对底层句柄的引用,不再依赖 GC 时机。
生命周期:从 empty_cache 到两侧 barrier 之间的 close
前面按“槽位”拆完,再把它们放回时间里,看一次模型装载与退出中 userbuffer 经历的几个阶段。
阶段一:构造之前
Worker.init_model 调用 DeepseekForCausalLM.__init__,在其末尾分配 userbuffer,而这一时刻前面还有几件事已经完成:
- HF
config.json读入后,finalize_model_config补全max_num_decoding_tokens、max_num_prefilling_tokens与专家冗余布局; - 权重装载完成,专家按 EP rank 物化(见权重装载一篇),所以每层都知道自己的
num_physical_experts与num_physical_topk; - EPLB 初始化完成(见 EPLB 一篇),冗余专家数已确定。
也就是说,缓冲尺寸依赖的不是“配置里写了什么”,而是“物化之后每个 rank 实际持有多少专家”。 如果权重装载阶段因为 EP 冗余把每个 rank 的物理专家数从“总专家数 / EP”变成了更大的数,allocate_sym_buffer 和 allocate_ep 里的 num_experts 就必须是这个物化后的数,否则 SymmBuffer 的视图会少切一列、EPBuffer 的尺寸估算也会偏小。这一依赖关系解释了为什么 allocate_userbuffer 必须放在模型构造的最后,而不能放在 __init__ 开头。
阶段二:分配
torch.cuda.empty_cache() 之后按 “MoE 层 → CP → Engram” 的顺序分配。这个顺序不是随意的:
- MoE 在前:EP/SymmBuffer 的峰值显存最大,先分配给它们,避免被其他分配切碎地址空间;
- CP 在中:它依赖各注意力层的
get_max_ag_bytes_per_token(),需要遍历所有层; - Engram 在后:Engram 表需要
set_config+write,而且它的存储默认在 CPU,分配的是 RDMA 注册,与 GPU 地址空间的关系最小。
如果顺序反过来(先 Engram),Engram 的 NCCL window 会先占掉一段很大的虚拟地址空间,后续 EP 的对称内存对齐可能被挤压。
阶段三:捕获与重放
CUDA Graph 捕获时(见 CUDA Graph 一篇),userbuffer 里的缓冲已经存在且尺寸固定。这是图能被捕获的前提:图里每一次通信内核的指针、大小、offset 都在捕获时被写死。运行时如果重新分配缓冲,图重放会写到旧地址。所以 userbuffer 的分配时机与 CUDA Graph 是硬耦合的——先有缓冲,后有图;缓冲变了,图就作废。
阶段四:关闭
worker/base.py 的 Worker.stop 里,关闭顺序写得很讲究:
1def stop(self) -> None:2 # `os._exit(0)` below skips destructors, which would leave the driver worker's UB3 # stuck at its destructor barrier, so close userbuffer explicitly between barriers.4 dist.barrier()5 userbuffer.close()6 dist.barrier()7
8 # NOTE necessary to avoid destroy process group hangs.9 # The reason is that when using NCCL inside CUDA graphs, NCCL allocates some extra space (a kind of dependency).10 # When destroying the process group, NCCL checks whether all dependencies have been released.11 # SEE https://github.com/pytorch/pytorch/issues/11538812 ...注释里那句“os._exit(0) 跳过了析构函数,所以 driver worker 的 UB 会卡在它的析构 barrier 上”值得展开:
- 为什么是 barrier 夹住 close?
userbuffer.close()会调用每个 DeepEP/DeepGEMM 缓冲的destroy(),而destroy()内部涉及跨 rank 的协调(例如 NCCL 的 window 注销需要所有 rank 参与)。如果某个 rank 先销毁、另一个 rank 还在跑,就会挂起。两侧的dist.barrier()保证所有 rank 同时进入、同时退出。 - 为什么不能依赖析构函数? 进程退出时(
os._exit(0))Python 不会跑__del__,C 侧的析构器也不会按预期顺序执行;DeepEP 缓冲的 C++ 对象析构时可能等一个跨 rank 的 barrier,若某个 rank 已经退出,就会永久阻塞。显式 close 把“同步销毁”挪到一个所有 rank 都还活着的时刻。 - 为什么先关 userbuffer,再销毁 CUDA Graph? 注释紧接着说明了原因:CUDA Graph 里用了 NCCL 时,NCCL 会分配“图依赖”的额外空间,
destroy_process_group会检查这些依赖是否已释放(PyTorch issue 115388)。userbuffer 的销毁本身不需要图存在,放在销毁图之前是安全的,也更早释放跨 rank 资源。
这条顺序可以概括为:barrier → close → barrier → 销毁图 → 清理 shm 广播 → 销毁进程组。
阶段五:cleanup_shm_broadcast
PG.cleanup_shm_broadcast() 会主动关闭并 unlink MessageQueue 的共享内存:
1def cleanup_shm_broadcast(self) -> None:2 """Explicitly close and unlink shared memory used by MessageQueue,3 so that the resource_tracker won't report leaked shared_memory warnings."""4 if self.mq is not None:5 if self.mq.buffer is not None:6 self.mq.buffer.shared_memory.close()7 if self.mq.buffer.is_creator:8 self.mq.buffer.shared_memory.unlink()9 self.mq = None这与 userbuffer 不是同一个对象,但属于同一类问题:跨 rank 的共享资源必须在进程退出前显式析构,否则要么泄漏,要么挂起。 CP 组对象广播一篇拆过这个 MessageQueue,这里只是补上它的收尾。
一次装载的显存账:四类缓冲各占多少
把四类缓冲的尺寸公式放在一起,可以估算一次装载的总量。以 DeepInfer 文档里的典型部署(H800、Decode 引擎、Wide-EP)为例,量级大致如下:
| 缓冲 | 主要尺寸因子 | 量级 | 备注 |
|---|---|---|---|
| EPBuffer(DeepEP) | max_num_seqs × (1 + N_nextn) × hidden × topk | 数百 MB | 由 calculate_ep_buffer_size 按 2 MB 对齐给出 |
| SymmBuffer(MegaMoE) | num_experts × max_tokens × topk × hidden | 与 EPBuffer 同量级 | 只在 MegaMoE 且 EP 不跨域时使用 |
| CP BucketBuffer | max_tokens × ag_bytes × cp_size | 0(CP=1 时不分配) | 长上下文 prefill 才有量 |
| EngramBuffer(GPU 侧) | max_tokens × entries_per_token × hidden × layers | 数十 MB–数百 MB | 表本体在 CPU,GPU 只是接收区 |
几个容易误判的点:
- EP 缓冲与 SymmBuffer 在正常部署里不会叠加,但在代码层面并非互斥。 同一个 MLP 层只会用其中一种,且这个选择由拓扑级属性决定,通常所有层一致;如果出现了混合,总占用就要相加。
- Engram 的 GPU 侧很小,大头在 CPU 的 RDMA 存储。 评估“这个部署需要多少 CPU 内存”时,要看
num_rdma_storage_size × 本地 rank 数,而不是 GPU 侧那个数。 - CP 缓冲在
cp_size=1时根本不分配,所以标准部署的内存账里看不到它;开启 CP 做长上下文时,它的占比会随prefill tokens × cp_size上升。 - 这些缓冲都是“一次性”的。 它们不在 decode 步内分配或释放,所以不会造成显存碎片;代价是峰值需求在启动时就要满足。
- 启动显存 profiling 的峰值与这几块缓冲有交集。 启动显存那一篇拆过
profile_run:峰值测量发生在权重与 userbuffer 都已分配之后,所以“剩余的块数”天然扣掉了这些通信缓冲——这正是为什么 DeepInfer 的 KV 预算能算得比较准。
与其他框架对照
把 userbuffer 的做法放到主流框架里比较,差异会更清楚。
vLLM
vLLM 的 EP 通信同样基于 DeepEP,但 buffer 的组织方式不同:
- 按 Worker 持有,按模型层共享。 DeepEP 的封装在 Worker 初始化时创建 buffer,所有 MoE 层复用;
- SM 数可以通过配置显式给出。 配置里通常有
num_sms或num_max_dispatch_tokens_per_rank之类的字段,不够时直接报错,而不是像userbuffer那样做“理论值 → 保留计算 SM → 设备一半”的三层裁剪; - 没有 CP 的 BucketBuffer:CP 在 vLLM 里主要由 NCCL 进程组承担,没有会话式零拷贝缓冲。
差异的根源在于拓扑:vLLM 支持 TP,CP 的通信与 TP 的通信在同一个 NCCL 进程组体系里,没必要单独为 CP 造一层缓冲。
SGLang
SGLang 的 DeepEPConfig 更接近 DeepInfer 的思路:它按带宽模型算 SM 数,也按 max_num_tokens 与 hidden 算 buffer 大小。差别在三点:
- SGLang 把 DeepEP 配置做成独立类,而 DeepInfer 把它塞进
userbuffer的一个方法,尺寸推导与模型结构强耦合(依赖物化后的物理专家数); - SGLang 没有 Engram 缓冲,也没有
SymmBuffer的“视图缓存”机制;它的对称内存用法更多是 DeepGEMM 的普通调用,而不是按规格缓存的视图管理; - SGLang 的弹性 EP 需要支持动态扩容,通信缓冲要能在“世界变大”后重建;DeepInfer 的
userbuffer是“一次分配、不复用更大规格”,更适合固定规模的集群。
TensorRT-LLM
TensorRT-LLM 的 BufferManager 与 userbuffer 在职责上最接近:它统一管理 KV 池、workspace 与 CUDA Graph 缓冲,按预算分配(见 BufferManager 一篇)。区别在于:
- TensorRT-LLM 的通信走 NCCL/Mpi,
BufferManager主要管显存,不管通信缓冲; - DeepInfer 把通信缓冲也纳入预算,因为它的通信不走通用 NCCL,而是 DeepEP/DeepGEMM 的专用原语,缓冲的尺寸直接决定通信性能。
llama.cpp
llama.cpp 不涉及多卡 EP 缓冲,但在 CP 的思路上有一个可对照的点:它的 server 用 slot 复用一个 KV 缓存,等价于“会话式复用”的低配版。DeepInfer 的 BucketSession 比它多了一层“通信期间的临时分配游标”,本质相同:所有临时资源走同一块区域,退出即失效。
读 userbuffer 容易踩的坑
这一节列出读这份代码时最容易误判的几处。
- 以为
allocate_ep会按层分配。 它只分配一次,后续调用是断言复用。如果你看到某个模型有几十层 MoE,却在日志里只看到一条Created DeepEP-V2 ... EPBuffer,那是正确的。 - 以为
num_max_tokens_per_rank是序列长度。 它是“每 rank 的单步 token 上界”,decode 时由max_num_seqs × (1 + N)给出,prefill 时由max_num_batched_tokens / cp_size给出。 - 以为
allocate_cp的max_num_ags_per_session是“并发会话数”。 它指的是一个会话内最多几次分配的对齐浪费,会话本身不嵌套。 - 以为
SymmBuffer会自己分配。 首个规格分配底层存储,后续是视图;如果顺序错了,越界可能不报错。 - 以为 Engram 的
num_gpu_bytes是表的大小。 它只是 GPU 侧接收区与 scale 分片的和;表本体在num_rdma_storage_size里。 - 以为
userbuffer.close()是进程退出的常规析构。 它是Worker.stop里显式调用的跨 rank 协调点,两侧有 barrier;漏掉它会导致 driver worker 挂起。 - 以为 SM 数可以每层单独调。
DEEPINFER_USERBUFFER_COMM_SMS是一次性推导并全局写入的环境变量;运行时num_sms只能调低,不能超过构造时准备的 QP 上限。 - 以为
allow_multiple_reduction只是性能开关。 在 batch-invariant 与 EPLB 同时开启时,它决定的是“输出是否逐位一致”。
小结
这一篇把 DeepInfer 的 userbuffer 从并行拓扑一篇里一闪而过的 cp_session 扩展成了完整的通信资源管理层。核心结论可以压缩成五句话:
userbuffer是进程级单例,所有 MoE 层共享一个 EPBuffer 或一组 SymmBuffer 视图;分配发生在empty_cache()之后、CUDA Graph 捕获之前,尺寸依赖物化后的物理专家数。- EPBuffer 的显存预算由 C 侧算,SM 与 QP 预算由带宽模型算,DeepInfer 再按“保留 32 或四分之一 SM”裁剪一次。
allow_multiple_reduction与 batch-invariant、EPLB 的互斥,是正确性约束而不是性能开关。 - CP 的 BucketBuffer 是会话式零拷贝:尺寸按“聚合量 + 对齐浪费”算,会话内未注册张量先拷进游标分配区,结果在
hook_after_wait里回填;DeepInfer 在注意力层用get_inplace_tensor让归一化与融合 kernel 直接写注册区。 - Engram 是两层结构:GPU 接收区与 CPU 上的 RDMA 存储;QP 数与队列深度按 NIC 读能力推导,SL 留给 KV Cache 隔离。
- SymmBuffer 的“首个必须最大”是正确性前提,且它依赖
allocate_userbuffer的遍历顺序;任何调整顺序的改动都要同步检查这条约束。
通信层到这里已经闭合:分配、尺寸、会话、销毁都有了确定的位置。下一步转向配置层:EngineArgs 与 environ.py 里的并行度、显存比例与环境变量,如何决定前面这些缓冲与 KV 池最终长成什么形状,以及同一份配置在不同引擎类型下为什么会长出不同的运行形态。
参考资料
文章分享
如果这篇文章对你有帮助,欢迎分享给更多人!
评论区
分享你的想法,与大家交流讨论
正在获取所在地区…
正在读取天气
音乐
暂未播放



