













论文原文 DWDP: Distributed Weight Data Parallelism for High-Performance LLM Inference on NVL72
TensorRT-LLM PR#12136 代码
TensorRT-LLM 关于DWDP的Blog
笔者注:最简单的想法做出效果很好的优化。但是,为什么别人能够想到而自己想不到呢?有时候很佩服那些能够从简单想法中延伸出来,然后不断与他人合作修正补充最终实现完美的优化的工作的人。
类似于FSDP那样的想法,按需要加载模型参数,计算完成后再释放出去。通过利用cudaMemcpyAsync的Zero-SM占用实现计算-通信的交叠,释放同步点。
如有笔误请批评指正,笔者水平可能不足,谢谢!
人工智能辅助:代码阅读与采用了GLM-5.1. 论文阅读部分参考了GLM-5.1的回答。
我们知道现有的Moe-LLM推理中,存在着从稠密层到稀疏层的通信模式转换(Attn-Moe), 也存在的从稀疏层到稠密层的通信模式转换(Moe-Attn)。在Megatron-Moe中,这样的转换意味着通信模要从DP-EP-DP的转变。而转变的方式其实就是我们熟悉的all-to-all操作。
如上图所示,当我们的工作负载不均衡时,我们的跨rank的同步开销就成为了问题。为什么会出现这样的不均呢?是因为我们推理过程中,request的长度总是不同的,就很容易造成某些rank开销很长,某些rank开销很少。

上图左侧的阴影部分就是因为工作负载不均衡造成的开销,这就是本次重点解决的层间跨rank同步开销问题。
曾经的解决方法包括:缓存感知调度,负载感知调度,专家负载调度等。然而这些调度方法并没有完全地解决这样的同步开销。并随着负载的多元化而变得更加难以避免。
对此,文章提出了两个对应的优化策略,并分析了计算-通信交叠带来的剩余影响。

Q1: 为什么要关注专家的卸载?
A1: 因为在Moe模型中,专家模型的内存占用更大。从DeepSeek中我们可以看到,在运行过程中,参与运算的参数和不参与运算的参数达到了1:18。这大部分是因为Moe中采用TopK激活造成的。
在一个DWDP组内,注意力层将复制到每一个rank上,而专家则分配到不同的rank上。这样,每个rank就只具有自己的本地专家。剩余的专家在其他的Peer GPU上。类似FSDP,在执行到本层的l+1的时候,从远程GPU加载专家到自己的本地显存上。
需要注意的是,为了消除跨rank的同步,DWDP不再采用基于NCCL的集合通信范式(比如All gather),而是采用了 cudaMemcpyAsync 这种0-SM占用的,全部交给 asyncEngine 来执行的方式来实现计算-传输的交叠。相关的资料可以参考 异步和交叠计算与数据传输.
这样,每一个rank就可以独立执行计算,不需要等待其他的rank,消除了跨rank的同步点。
除了允许异步推理之外,DWDP还提供了专家存放的灵活性主要体现在如下的两个方面:
因此我们可以总结DWDP的两大优势:
然而,这样的新策略同时也带来了新的挑战:

分析方法也很好理解——就是分析如上图所示的时间模型,每一层执行的时间。
这样我们就可以估算计算时间了。计算开销延时就是峰值运算与吞吐的最大值。这样就有 \(T_{\text{op}}=\max{(F/P_{\text{peak}}, B/BW_{\text{mem}})}\). 其中:
\(P_{\text{peak}}\): 计算吞吐峰值
\(BW_{\text{mem}}\): 内存带宽
\(F\): 操作的运算量,单位为 Flop
\(B\): 内存占用流量。
这样我们需要关注的就是两个比率:
文章发现DEP在16K 长度的token后表现差于DWDP,因此适用于具有长序列的推理场景下。文中还提到,在batchSize增大的情况下,更小的序列长度甚至也能表现的更好(因为实验在batchSize = 1下进行)。
因为随着序列长度的增加,计算时间不断上升,因此当序列增加超过一定量时,加速比将降低,此时需要专注于计算的优化。并且,这个模型是理想化的,忽略了例如计算与权重预取的冲突,以及工作负载不均的情况。
回顾我们的挑战一节,这一节将会讲述如何实现 高效权重分离 和 异步预取造成的多对一冲突 问题。
首先文章对4-rank下的传统DP+EP与DWDP进行了分解分析。我们可以看到下表:

我们可以从表中发现DWDP带来的优劣:
cudaMemcpyAsync带来的GPU拉取的开销)。
上图通过nsight分析可以看到多对一带来的通信开销。可以看到,当多个rank同时从一个rank获取专家的时候,这个rank将会序列化执行pull请求,导致prefetch时间超出了计算时间。这样就会导致气泡的出现。
首先文章分析了越大的DWDP组越容易出现多对一的冲突。我们就不再赘述方法了。我们来看他是怎么通过 时分多路复用的复制 来实现减轻多对一通信负担的。
这里的设计其实就是利用了分块+RR调度的策略。我们简洁明了地概括如下:
chunk_i 轮询。chunk_i 传输过去。也就是文中的伪代码:
copy_plan = []
for each parameter p:
M = prefetch_size(p)
for offset = 0 to M step s:
chunk = min(s, M - offset)
for peer in round_robin(remote_peers):
src = peer_ptr(peer, p) + src_offset(peer, p) + offset
dst = local_buffer(peer, p) + offset
copy_plan.append((dst, src, chunk))
return copy_plan
很像网络中的 packet-spray 不是吗~
在这样的设置下,如果流水线引擎能够保证两个切片能够同时传输,rank级别的数据拉取就不会在冲突度为2的情况下出现降速(也就是2个远程rank拉取同一个rank的专家)。这样也可以缓解因为网络冲突带来的性能波动。
接下来我们将结合Blog来分析一下DWDP的运行时组件和运行时的数据流。
目前,DWDP使用在分离式服务的上下文服务器上。
dwdp_size: DWDP组中的GPU数量
num_groups: DWDP组数量
num_experts_per_worker: 本地专家数量
num_prefetch_experts: 拉取的远程专家的数量
DwdpLayerHandleCollector实现在 tensorrt_llm/_torch/pyexecutor/dwdp.py 中。主要用来:
register_weights函数和_register_param中。DwdpPrefetchBuffer这是一个运行时buffer,用来储存预取回的远程专家权重。他的角色主要是:
起到提供存储,stream和事件通知的作用。
cudaMemcpyAsync需要的stream)双缓冲区真的已经是高性能通信的标配了~
DwdpManager这就是整个DWDP的控制中心。他将管理整个DWDP的生命周期,并在预取过程中起到关键的协调作用。他负责:
以下就是整体的工作流程:

我就不赘述流程了。
在fused_moe_cute_dsl.py中,我们有:
@dataclass
class NvFp4WeightView:
w3_w1_weight: List[torch.Tensor]
fc1_weight_scale: List[torch.Tensor]
fc1_global_scale: List[torch.Tensor]
w2_weight: List[torch.Tensor]
fc2_weight_scale: List[torch.Tensor]
fc2_global_scale: List[torch.Tensor]
expert_size_per_partition: int
slot_start: int
# ...
当DWDP没有开启的时候,每个list都只有一个张量(也就是本地全部的Experts)。开启的时候,DWDP将具有本地的真实张量,以及分配好的远程预取buffer在张量列表中。
接着,我们可以看到,在run_moe函数中执行了
result = self.run_moe_nvfp4(
x=x,
token_selected_experts=token_selected_experts,
token_final_scales=token_final_scales,
x_sf=x_sf,
moe_output=moe_output,
enable_alltoall=enable_alltoall,
weight_view=weight_view,
)
来执行moe。通过对源码的修改,执行引擎可以通过判断前面 w3_w1_weight 中的张量个数来判断是否启用了DWDP。
is_dwdp = len(weight_view.w3_w1_weight) > 1
forward_impl = self.run_moe_nvfp4_impl_dwdp if is_dwdp else self.run_moe_nvfp4_impl
执行 CuteDslFusedMoENvfp4Runner,我们可以看到,其中的forward_impl变成了经过dwdp的代码。于是我们可以看到执行的是:
x, x_sf = torch.ops.trtllm.cute_dsl_nvfp4_gather_grouped_gemm_swiglu_blackwell_multi_b(
input=x.view(torch.float4_e2m1fn_x2),
weight=[
w.view(torch.float4_e2m1fn_x2) for w in weight_view.w3_w1_weight
],
input_scale=x_sf.view(torch.uint8),
weight_scale=[
ws.view(torch.uint8) for ws in weight_view.fc1_weight_scale
],
# ...
)
替换成了列表。相对称的finalize部分也修改为了列表。
torch.ops.trtllm.cute_dsl_nvfp4_grouped_gemm_finalize_inplace_blackwell(
input=x.view(torch.float4_e2m1fn_x2),
weight=[
w.view(torch.float4_e2m1fn_x2) for w in weight_view.w2_weight
],
input_scale=x_sf.view(torch.uint8),
weight_scale=[
ws.view(torch.uint8) for ws in weight_view.fc2_weight_scale
],
# ...
)
对应的算子实现在cute_dsl_custom_ops.py中。
对于cute_dsl_nvfp4_gather_grouped_gemm_swiglu_blackwell_multi_b,在进入函数后,注册一个新的op,具体的注册如下:
tuner.choose_one(
"trtllm::cute_dsl_nvfp4_gather_grouped_gemm_swiglu_blackwell_multi_b",
# ...
)
#...
@torch.library.register_fake(
"trtllm::cute_dsl_nvfp4_gather_grouped_gemm_swiglu_blackwell_multi_b")
def _fake_multi_b(
#...
)
然后旧有的ops直接包装:
def cute_dsl_nvfp4_gather_grouped_gemm_swiglu_blackwell(
# ...
)
return torch.ops.trtllm.cute_dsl_nvfp4_gather_grouped_gemm_swiglu_blackwell_multi_b(
#...
)
对于finalize来说这也是相同的。我们也就不再赘述了。同样也是注册函数,旧有的ops包装新的op来实现。
实现在dwdp.py的函数prefetch_layer中。
@nvtx_range("dwdp_prefetch_layer")
def prefetch_layer(self, layer_idx: int, wait_compute_layer_idx: Optional[int] = None):
with torch.cuda.stream(self.prefetch_buffer.prefetch_stream):
# 等待上一次的计算同步结束,保证不出现WAW依赖。
if wait_compute_layer_idx is not None:
self.prefetch_buffer.wait_compute_event(wait_compute_layer_idx)
# 轮询每一个rank并且将对应的块传输过去。
for peer_rank in range(self.dwdp_size):
if peer_rank == self.dwdp_rank:
continue # 跳过本地的张量
src_expert_offset = self._get_prefetch_src_offset_from_peer(peer_rank)
for param_name in param_names:
param_shape = collector.param_shapes[param_name]
param_dtype = collector.param_dtypes[param_name]
expert_size = param_shape.numel() * param_dtype.itemsize
# src_ptr 指向远端节点对应的权重起始地址。
base_ptr = collector.get_peer_ptr(peer_rank, param_name)
src_ptr = base_ptr + src_expert_offset * expert_size
# dst_tensor 本地的对应专家的存放地址。
dst_tensor = self.prefetch_buffer.buffers[buffer_idx][param_name][peer_rank]
dst_ptr = dst_tensor.data_ptr()
data_size = self.num_prefetch_experts * expert_size
# 通过cudaMemcpyAsync来实现异步拉取。
(err,) = cudart.cudaMemcpyAsync(
dst_ptr,
src_ptr,
data_size,
cudart.cudaMemcpyKind.cudaMemcpyDeviceToDevice,
self.prefetch_buffer.prefetch_stream.cuda_stream,
)
check_cuda_error(
err, f"prefetch layer {layer_idx} peer_rank {peer_rank} {param_name}"
)
self.prefetch_events和self.compute_events来记录对应的stream记录下来的event。其中compute stream是默认流,prefetch则通过独有的self.prefetch_stream来记录流事件。events在列表中如下图分布:

用于记录对应层的对应chunk块是否完成。
笔者很喜欢看这种工程上的讨论——因为他们常常深入到体系结构中。
为什么DWDP造成了计算效率的下降呢?这是因为硬件内部存在了冲突。我们可以看下图所示的通路示意图:

这张图显示的是目的端GPU通过CE拉取源端GPU的专家权重的过程图。CE专注于数据搬运,因此它无需占用SM。
然而,仔细分析数据流就会发现:数据会经过NoC,L2-Cache,DRAM,而GPU的kernel在执行并发的内存请求。这样,两者在硬件上存在着相互干扰的问题。
跨GPU传输的完整路径包括了:
这样发生干扰的硬件定位在:(1) NoC的仲裁:NoC要选择执行内核指令和CE指令,两者争抢带宽。(2) L2 Cache:命中率降低。一致性维护成本升高。(3) DRAM的带宽将会争用。但最重要的是 (4) 功耗冲突。GPU达到功耗墙导致其在达到内存带宽限制前主动降频。
DWDP和特定的硬件平台绑定很深——他要求必须是具有CE和nvlink的节点内通信。一旦跨节点采用这种方法,很有可能导致通信时延远大于计算时延,造成严重的流水线气泡——严重影响端到端延迟。因此常见的多节点集群——通过RDMA,采用的是传输token而不是传输专家权重的方式。但是这也让我们看到了,系统优化是无法和特定硬件平台解耦的,我们必须要建立好标准的流水线模型和roofline,进行认真的权衡才能实现更优的优化。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。