Ascend DeepEP MoE Dispatch Optimization
导言
我面对的是一个很具体、也很容易被“理论带宽”带偏的问题:Ascend DeepEP 的 MoE dispatch 吞吐只有硬件理论值的一半,128P 跨机场景尤其明显,而且系统里还有两层路由。麻烦在于,我既不熟悉 MoE dispatch 的接口与数据协议,也没写过典型 URMA/UDMA 算子,更不知道应该在哪里计时、打印和判断瓶颈。
这篇文章不从零散优化技巧出发,而是沿一条 token 的真实旅程,依次读懂 Python 接口、Host 侧 workspace/notify、Device 侧 AIV/UDMA/QP、目标 rank 的接收布局与反向 combine;再把 ascend_deepep、海思 hierarchy 和 MoonEP 的 peer 调度放进同一张拓扑图。最终目标不是立即猜出一个“神奇参数”,而是建立一条可证伪的优化路线:先区分数据面、控制面和同步面,再判断 128P 下真正撞到的是字节带宽、WQE 提交率、P 维路由扫描、拓扑扇出还是最慢 rank 的尾延迟。
问题不只是“带宽没跑满”¶
看到吞吐只有硬件理论值的一半,第一反应通常是增加 AIV、增加 QP、做双缓冲或者把通信和计算异步起来。这些方向并非错误,但它们默认了一个尚未证明的前提:当前瓶颈就是负责搬 payload 的硬件引擎。
MoE dispatch 的端到端时间更接近:
这几项不一定完全串行,实际延迟取决于依赖关系和重叠程度;多 rank 场景最终看到的又往往不是平均值,而是最慢 rank:
于是,“理论带宽的一半”至少可能来自五类不同问题:
- 数据面受限:hidden payload 真正压满了链路、UDMA 引擎或 GM 读写带宽。
- 控制面受限:单包太小,WQE 生成、提交、CQE/quiet 的固定成本占比过高。
- 路由规划受限:Top-K 很小,却仍按
[num_tokens, world_size]扫描和保存路由。 - 拓扑受限:128 个 peer 同时展开,跨机链路、交换框或某条 rail 出现热点。
- 尾延迟受限:平均 rank 不慢,但少数 expert/rank 收到更多 token,所有人最终等它完成。
这也是后续读代码时最重要的视角:不要只找“拷贝循环”,要同时找 payload 在哪里走、offset 谁算、完成由谁确认、下一阶段在等谁。
一条 token 如何走完 MoE¶
设 EP 域共有 \(P\) 个 rank,每个 rank 持有 \(E_l\) 个本地专家;输入 x 的 shape 为 [S, H],Router 为每个 token 选择 \(K\) 个专家:
一个全局专家 expert_id 对应:
如果一个 token 的 Top-2 专家分别位于 rank 0 和 rank 3,dispatch 就要把同一行 hidden 复制到两个目标 rank;目标 rank 按本地专家重排成连续输入,完成 expert FFN 后,再把两份结果发回 token owner,最后按 Top-K 权重归约。dispatch 是一趟不等长、由路由结果驱动的 All-to-AllV;combine 是携带反向索引的逆过程。
下面这张图把容易混在一起的三条线分开:蓝色是 payload,绿色是路由与 offset,紫色是完成和缓冲区复用协议。
一个正确的单边通信顺序通常是:
计算目标地址和接收 offset
→ 把 payload Put 到目标 SHMEM window
→ 确认 payload 已经对端可见
→ 再发布 ready/count/completion
→ 接收方消费并复用缓冲区
如果 flag 先于 payload 可见,接收方就可能读到旧数据;如果缓冲区在 quiet 前被复用,后续 WQE 可能读到被覆盖的源数据。因此,通信和计算能否重叠,首先是依赖图与内存可见性问题,其次才是调度技巧。
最小概念账本:rank、AIV、SHMEM、URMA、QP、WQE、quiet
- Rank:EP 通信域中的一个参与者,通常对应一张 NPU 卡上的进程或设备执行上下文。
- AIV:Ascend AI Core 的 Vector 侧执行单元。这里不只做数值计算,也负责扫描路由、搬运 GM/UB、构造和提交通信任务。
- GM / UB:GM 是设备全局内存,容量大、延迟高;UB 是核内高速缓冲,容量小,常用来做 staging、向量处理和 WQE 暂存。
- SHMEM:为每个 rank 建立对称内存窗口,使相同 offset 可以映射到不同 peer 的远端地址,适合 one-sided Put/Get。
- URMA/UDMA:URMA 更接近远端内存访问语义和资源体系;UDMA 是这里执行远端搬运的具体 DMA 路径。不要把 API 语义、Runtime 资源和硬件引擎压成同一个概念。
- QP(Queue Pair):远端通信队列。发送侧把工作提交到 QP,底层引擎异步执行。
- WQE(Work Queue Element):一次通信工作的描述符,包含源/目标地址、长度和操作类型等信息。小包很多时,瓶颈可能是每秒能处理多少 WQE,而不是每秒能搬多少字节。
- CQE / completion:某批通信工作的完成记录或完成信号。
quiet():等待此前提交到指定范围的非阻塞通信完成,并建立后续复用或发布 flag 所需的顺序边界。它不是普通函数调用开销,而可能直接暴露网络和队列尾延迟。- Barrier:所有参与者到齐后再推进。它很容易保证正确性,也很容易把一个慢 rank 的延迟放大到全局。
从 Python 接口进入真实调用链¶
本节以 ascend_deepep@18e2dd4b 为代码锚点。Python 入口位于 ascend_deepep/buffer.py::Buffer.dispatch,它先区分是否提供 handle。
if handle is not None:
(
rank_prefix_matrix,
channel_prefix_matrix,
_recv_channel_prefix_matrix,
recv_src_idx,
is_token_in_rank_from_handle,
_send_head,
) = _unpack_v1_handle(handle)
return self._native.intranode_dispatch(
x_tensor,
x_scales,
None,
None,
None,
is_token_in_rank_from_handle,
None,
num_recv_tokens,
rank_prefix_matrix,
channel_prefix_matrix,
...,
)
没有 handle 时,需要传入当前路由的统计和矩阵:
if num_tokens_per_rank is None:
raise ValueError("num_tokens_per_rank is required")
if is_token_in_rank is None:
raise ValueError("is_token_in_rank is required")
if num_tokens_per_expert is None:
raise ValueError("num_tokens_per_expert is required")
随后执行 native dispatch,并把这次生成的路由计划打包成 v1_handle:
v1_handle = (
rank_prefix_matrix,
channel_prefix_matrix,
recv_channel_prefix_matrix,
recv_src_idx,
is_token_in_rank,
send_head,
)
这段接口已经透露了一个重要优化边界:hidden 可以每轮变化,但如果路由关系不变,prefix、offset 和反向索引没有必要每轮重建。cached dispatch 就是在复用这部分控制面工作。
Fresh dispatch 是什么意思
Fresh dispatch 不是正式 API 名称,只是为了区分两条路径使用的简称:
handle is None
→ 当前路由第一次出现或发生变化
→ 重新计算 prefix、offset、src_idx、send_head
→ 返回新的 handle
handle is not None
→ 路由关系与上次一致
→ 复用 handle,只搬运新的 x
→ cached dispatch
因而“Fresh”更准确的中文是重新规划路由的 dispatch,不代表第一次调用整个程序,也不代表输入 hidden 必须是新 Tensor。
Dispatch handle 六项分别保存什么
handle 不是一个不透明的通信句柄,而是一组让 cached dispatch 和 combine 找回数据位置的 Tensor:
| 项 | 直观作用 | 后续消费者 |
|---|---|---|
rank_prefix_matrix |
各 source rank 向各 destination rank 发送后的累计边界,决定一个源 rank 的数据落在目标接收区哪一段 | cached dispatch、combine |
channel_prefix_matrix |
当前源 rank 内,各 channel 面向每个目标 rank 的累计发送边界 | cached dispatch |
recv_channel_prefix_matrix |
目标 rank 收集到的“各 source rank × channel”前缀,用于还原本地接收段和规划反向发送 | combine |
recv_src_idx |
每一行 compact 接收 token 对应源 rank 上的原始 token 下标 | combine 把 expert 结果送回原 token |
is_token_in_rank |
shape 为 [S,P] 的布尔路由矩阵,表示某 token 是否需要发到某 rank |
cached dispatch |
send_head |
shape 为 [S,P] 的发送序号/存在性表;-1 表示该 token 没有发给这个 contributor |
combine 判断需要归约哪些 rank,并找到对应槽位 |
可以把它压缩成一句话:prefix 决定“段在哪里”,recv_src_idx 决定“这一行原来是谁”,send_head 决定“combine 应该等谁”。
Host 侧先造一张通信地图¶
进入 csrc/ops/dispatch.cpp 后,Host 侧主要完成四件事:校验输入、规划 symmetric workspace、启动 notify、创建输出 view 并启动真正的 dispatch kernel。
Workspace 不是一个 Tensor,而是一块地址空间¶
代码首先解析 AIV 数量:
const int64_t resolved_num_aiv = ResolveNumAiv(num_aiv);
const int64_t num_channels = resolved_num_aiv;
随后按极端情况预留内部接收容量:
const int64_t recv_buffer_tokens = CheckedMulInt64(
num_tokens, world_size, "dispatch receive capacity");
原因是:每个 source rank 最多有 num_tokens 行可以发给当前 rank;共有 world_size 个 source rank,所以当前实现给内部接收区保留 num_tokens × world_size 行。
MakeDispatchWorkspaceLayout() 再用一个不断前移的 cursor,把一整块 symmetric buffer 切成多个互不重叠的区域:
layout.recv_channel_prefix_offset = cursor;
cursor = AppendWorkspaceRegion(cursor, ...);
layout.x_buffer_offset = cursor;
cursor = AppendWorkspaceRegion(cursor, recv_buffer_tokens * hidden_bytes, ...);
layout.src_idx_buffer_offset = cursor;
cursor = AppendWorkspaceRegion(cursor, recv_buffer_tokens * sizeof(int32_t), ...);
layout.topk_idx_buffer_offset = cursor;
...
layout.topk_weights_buffer_offset = cursor;
...
layout.x_scales_buffer_offset = cursor;
这些 offset 随后写入 tiling,Device kernel 只要拿到 symmetric buffer 基地址,就能定位 payload、反向索引、Top-K 元数据、scale 和 completion signal。
这里的 layout 到底是什么布局
它不是 NCHW、ND、TND 之类的 Tensor 维度布局,而是一块大 workspace 的内存地图:
symmetric_buffer base
├── notify workspace
├── recv_channel_prefix [P, num_channels]
├── x payload [S×P, H]
├── src_idx [S×P]
├── topk_idx [S×P, K],可选
├── topk_weights [S×P, K],可选
├── x_scales [S×P, scale_cols],可选
└── UDMA completion signals [P, 2, 512B]
offset 就是每个区域相对 symmetric_buffer base 的字节偏移。这样做可以复用一次大的对称内存分配,并保证远端 rank 使用相同 offset 找到对应区域。
Notify 先交换数量和前缀¶
Host 在 payload dispatch 前启动 notify kernel:
LaunchNotifyDispatchKernel(
*num_tokens_per_rank,
*num_tokens_per_expert,
is_token_in_rank,
rank_prefix_matrix,
channel_prefix_matrix,
notify_num_recv_tokens,
notify_num_recv_tokens_per_expert,
tiling,
symmetric_buffer,
shmem.FftsAddr());
notify 的核心任务不是提醒一句“开始接收”,而是把不规则路由转成通信双方都能使用的边界:当前 rank 实际要收多少 token、每个本地 expert 收多少、每个 source rank 和 channel 应写到接收区的什么位置。真正的 payload kernel 依靠这些 prefix,才能让多个 AIV 和多个 rank 并行写入而不互相覆盖。
Notify kernel 是不是让每个 rank 知道要接收什么
可以这样理解,但要再精确一步:它不是发送一张 token 明细表,而是在生产计数和 offset。
Router 结果
→ 每个 token 要去哪些 rank
→ 每个 source→destination 有多少行
→ 每个 channel 负责其中哪一段
→ prefix sum 得到接收起点
有了起点后,数据面可以直接执行:
所以 notify 更像 dispatch 的控制面规划与发布阶段。即使调用方提供了固定 num_worst_tokens,notify 仍然要运行;省掉的只是 Host 回读实际接收行数,而不是 prefix 计算本身。
输出长度有动态和固定两种模式¶
notify 之后,Host 决定公开输出 Tensor 的第一维:
if (cached_mode) {
num_recv_tokens = cached_num_recv_tokens;
} else if (num_worst_tokens > 0) {
num_recv_tokens = num_worst_tokens;
} else {
auto recv_tokens_cpu = notify_num_recv_tokens.cpu();
num_recv_tokens = recv_tokens_cpu.item<int32_t>();
}
然后用 SHMEM 内部地址创建非 owning view:
auto recv_x = MakeShmemTensor(
symmetric_buffer,
dispatch_workspace.x_buffer_offset,
{num_recv_tokens, hidden},
x.scalar_type());
最后才启动 LaunchV1DispatchKernel()。因此 Host 路径可以概括成:
validate
→ resolve AIV/channel
→ reserve worst internal workspace
→ launch notify
→ choose public output rows
→ create SHMEM Tensor views
→ launch dispatch payload kernel
双层解释:num_worst_tokens > 0 到底是什么
直觉层。把输出 Tensor 想成提前摆好的货架:
num_worst_tokens = 0
→ 等 notify 统计实际收到 237 行
→ Host 回读 237
→ 输出 shape = [237, H]
num_worst_tokens = 256
→ Host 不回读实际计数
→ 直接输出 shape = [256, H]
→ 前 237 行有效,后 19 行 padding
源码层。Device 端执行:
const int32_t actual_recv_tokens = ...;
const int32_t recv_tokens_to_copy =
DispatchMin(actual_recv_tokens, num_output_tokens);
所以调用方必须保证:
上界太大会增加 padding;上界小于真实值时,当前实现会只公开前 num_worst_tokens 行,存在静默截断风险。尾部 x/scales/weights 填 0,topk_idx 塄为 -1。
还要区分两种容量:内部 SHMEM 仍按 S × P 预留,num_worst_tokens 只决定公开输出长度和是否需要 Device→Host 回读。它当前不会缩小内部 workspace。
最理想的情况是 layout 阶段已经知道精确接收量,直接把精确值传进来:既没有 Host 同步,也没有 padding。只知道上界时,可以采用安全 bucket;完全不知道时先传 0 保证正确性。
Device 侧如何把 channel 变成远端 Put¶
普通 dispatch.cpp 的核心所有权模型是:一个 AIV 负责一个 channel,并维护面向所有 destination rank 的游标。
for (int32_t dst_rank = 0; dst_rank < world_size; ++dst_rank) {
const int32_t rank_base = rank > 0
? rank_prefix_gm.GetValue((rank - 1) * world_size + dst_rank)
: 0;
const int32_t channel_prefix_begin = core_idx > 0
? channel_prefix_gm.GetValue(dst_rank * num_channels + core_idx - 1)
: 0;
recv_position_ub.SetValue(
dst_rank, rank_base + channel_prefix_begin);
}
随后它遍历自己负责的 token 范围和全部目标 rank:
for (int32_t token = token_begin; token < token_end; ++token) {
for (int32_t dst_rank = 0; dst_rank < world_size; ++dst_rank) {
if (is_token_in_rank_gm.GetValue(
token * world_size + dst_rank) == 0) {
continue;
}
...
}
}
这段代码简单直接,但它也暴露了 128P 的一个结构性成本:即使 Top-K 只产生少数目标 rank,每个 token 仍可能扫描完整的 world_size 维度。
UDMA 版本在 peer 调度上又增加了两个策略:按 source rank 旋转目标顺序,以及用两个 QP 承载 channel。
for (int32_t logical_destination = destination_worker_index;
logical_destination < world_size;
logical_destination += destination_worker_count) {
int32_t dst_rank = rank + logical_destination;
if (dst_rank >= world_size) {
dst_rank -= world_size;
}
...
}
为什么要把目的 rank 加上当前 source rank
如果所有 rank 都按 0,1,2,... 的顺序访问 peer,那么某一时刻可能出现:
旋转后则变成:
它的目的只是错开起始 peer,降低同一时刻的集中冲击。它没有减少 peer 数,也没有理解 server、交换框或 rail;每个源 rank 最终仍会面对全部目标 rank。因此它是一种低成本去同步化调度,不等于拓扑感知算法。
两个 QP 采用 3:1,而不是均分¶
当前代码固定两个 QP,并按 channel 编号分配:
__aicore__ inline int32_t DispatchUdmaQpForChannel(
int32_t channel,
int32_t qp_count) {
if (qp_count == 2) {
return (channel & 3) == 3 ? 1 : 0;
}
return 0;
}
也就是:
所有 peer 的 payload 提交完成后,每个 QP 分别追加一个 Strong Order completion,再执行 qp_quiet(dst_rank, qp_slot);最后才是:
两个 QP 既然能并行,为什么不是 1:1
并行机会和负载均分是两个问题。目标是让两个 QP 尽量同时结束:
如果两个 QP 的有效速率相同,理想模型确实倾向 1:1;如果某次实验中 \(R_0 \approx 3R_1\),3:1 反而更接近同时完成。
但源码只明确说这是 MoonEP 的 two-QP performance experiment,没有记录底层原因。因此只能提出待验证假设:两个 QP 可能存在服务能力差异、共享资源竞争、CQE/queue depth 差异,或者 3:1 只对当时的 shape 和拓扑有效。不能把“QP0 就是三倍快”写成事实。
更重要的是,按 channel 计数 3:1 不等于按字节或 WQE 3:1。不同 channel 的 token 数可能高度不均。优化时应该记录每个 QP 的 WQE 数、payload 字节、提交周期和 quiet 周期,并至少比较 1QP、2QP 1:1、2QP 3:1、2QP 1:3 与 4QP round-robin。
world_size × 2 QP × 512 bytes 怎么算
completion signal 的地址计算是:
signal_slot = rank * kDispatchUdmaQpCount + qp_slot;
signal_addr = base + signal_slot * kDispatchUdmaCompletionSignalBytes;
常量是:
所以每个 symmetric workspace 需要为每个 peer、每个 QP 保留一个独立的 512B completion slot:
128P 时:
512B 是实现选定的 signal slot 间距和对齐单位,不是 uint64_t completion 值本身有 512B;真正的值仍是 8 字节,较大步长用于隔离和满足实现的缓存行/顺序要求。
128P 为什么会放大问题¶
小规模下,“每个源 rank 直接面向每个目标 rank”简单且可能足够快;扩展到 128P 后,数据量未必线性增长,控制面却很容易增长:
与此同时,每个 peer 的消息变小,固定 WQE/quiet 成本占比上升;不同源 rank 若在相近时刻访问相同拓扑区域,还会形成热点。这里要解决的已经不是单核 DataCopy 的问题,而是全系统同时有哪些通信关系处于活跃状态。
下面把三套代码的调度单位放到同一张图里。
ascend_deepep:扁平直达¶
当前 UDMA kernel 通过 rank 旋转错开起始目标,但没有显式的 server/frame/rail 分组。每个 source rank 仍会依次处理全部 destination rank;每个 peer 的两个 QP 完成后分别 quiet,最后进入全局 barrier。
这个方案的优点是地址关系直接、没有额外 relay;风险则是 128P 下 peer 扇出、路由扫描、completion/quiet 和尾延迟同时放大。若性能从 64P 到 128P 明显掉档,而 hidden size 增大后有效带宽反而改善,应优先怀疑固定控制成本与拓扑调度,而不是立即优化向量计算。
海思 hierarchy:跨机只面向 server¶
ops-transformer@8dc2fa04 的公开说明把两种算法写得很直接:
fullmesh:
token 直接通过 RDMA 发往 Top-K 专家所在 rank
hierarchy:
token 经过跨机、机内两次发送
仅不同 server 同号卡之间使用 RDMA
server 内使用 HCCS
Layered kernel 固定:
跨机目标中继 rank 的计算是:
也就是说,source rank 的 local lane 为 5 时,跨机只和目标 server 的 lane 5 通信;数据到达后,再通过 Win2Ipc()、SendTokenToIpc()、Ipc2Out() 等机内路径转发到真正持有 expert 的 rank。
举一个抽象例子:每 server 16 rank,目标 expert 在 server 2 的 rank 42,而 source rank 的 local lane 是 5。跨机阶段先发送到:
rank 37 是 server 2 的同号中继;随后 server 2 内部再把 hidden 送到 rank 42。若同一 token 在 server 2 上命中多个专家,跨机阶段还可以按 server 聚合 hidden,避免为同一 server 重复发送多份大 payload。
代价也很明确:多一次 relay、机内 copy 与控制协议。只有当减少的跨机 peer、重复 hidden 和 RDMA 下发成本,大于新增机内搬运成本时,hierarchy 才会获益。
当前公开 hierarchy 不能直接当作 128P 现成答案
固定 revision 的 README 声明:fullmesh 支持到 128P 及以上,而 hierarchy 当前只支持 16、32、64P。它仍然非常适合学习“先按 server 聚合,再机内展开”的设计,但不能据此声称这份实现已直接覆盖当前 128P 场景。要迁移思想,必须重新确认 server 数、每 server rank 数、HCCS/RDMA 路径和 tiling 约束。
MoonEP:16-rank group 与拓扑波次¶
ascend-moonep@b67c6840 为 128P 写出了明确的生产拓扑假设:
constexpr uint32_t kDispatchFrameCount = 2;
constexpr uint32_t kDispatchRanksPerFrame = 64;
constexpr uint32_t kDispatchRanksPerDirectRow = 8;
constexpr uint32_t kDispatchGroupRows = 4;
constexpr uint32_t kDispatchGroupColumns = 4;
constexpr uint32_t kMaxDispatchGroupWidth = 16;
它把 128 rank 看成两个 64P frame;每个 frame 是 8 × 8,再切成四个 4 × 4 = 16 rank 的 group。全局共有 8 个 group,因此每个 rank 用 8 个逻辑轮次覆盖所有目标 group。GetGroupDstRank() 保证每轮映射是双射,使每个目标 group 在一轮中只接收一个源 group,避免多组同时集中冲击同一目标。
这里最特殊的不是数字 16,而是 rank ID 被当成物理坐标使用:frame、row、column、group side 都由 rank 算术得到。如果真实 rank table 没有按这套物理位置编号,照搬公式可能主动制造热点。
每轮开放 16 rank,是不是只使用 16 个 AIV
不是。首先,“16 rank”指每个 source rank 当前面对的 16 个目的 peer,不是全系统只有 16 个 rank 活跃;128 个 source rank 都在同时执行自己的映射。
其次,kernel 固定启动 32 AIV,并明确分工:
const int64_t token_aiv_count =
GetDispatchGroupWidth(world_size); // 128P 时为 16
const bool token_worker = aiv_index < token_aiv_count;
const bool weight_worker = !token_worker;
128P 时:
AIV 0–15
→ 16 个 token workers
→ 每个 AIV 负责当前 group 的一个 peer
→ 搬运大块 hidden
AIV 16–31
→ 16 个 weight workers
→ 并行处理 Top-K weight 元数据
汇合后
→ 32 个 AIV 一起执行 DispatchEpilogue
如果 with_weights == 0,后 16 个 AIV 在 token 阶段可能没有有效工作;如果 weight 很小,它们也可能提前到达 barrier。于是 16:16 只是特定 workload 的静态划分,不保证所有 shape 都负载均衡。
三种策略可以用同一组问题比较,而不是只看函数名:
| 实现 | 同时面向的通信单位 | 跨机路径 | 控制协议 | 主要边界 |
|---|---|---|---|---|
ascend_deepep |
最终覆盖全部 peer;按 source rank 旋转顺序 | source → destination rank | 两个 QP、per-peer quiet、全局 barrier | 无显式 topology group |
| 海思 hierarchy | 先 destination server,再 server 内 rank | source lane → 目标 server 同 lane 中继 | RDMA + HCCS/IPC + flag | 当前公开支持范围不是 128P |
| MoonEP | 每轮一个 16-rank group,8 轮覆盖 128P | 由 2×64、8×8 坐标映射决定 | 1–4 QP、completion/credit、group schedule | 拓扑与 rank 编号假设很强 |
P 维路由为什么值得单独优化¶
当前 is_token_in_rank 是 [S,P] 布尔矩阵:
它的优点是判断简单,缺点是在 128P 下表示和扫描都按 \(P\) 扩展。假设 Top-K 为 8,而这些专家最多落在 8 个 rank 上,那么每个 token 实际只需要保存少量 destination,却要读取 128 个布尔值。
更紧凑的 plan 可以写成:
token 0 → [dst rank 3, dst rank 17]
token 1 → [dst rank 8]
token 2 → [dst rank 3, dst rank 9, dst rank 17]
destination_offsets:
rank 3 → [begin, end)
rank 8 → [begin, end)
...
每个 destination 段内再保存:
token_id、local_expert_id、topk_slot
hierarchy 模式则先压成 destination server,再在 server 内展开 rank/expert:
token
→ unique destination server list
→ server segment offset
→ destination rank / local expert list
这样既能减少 [S,P] 扫描,也能识别“同一 token 在同一 server 命中多个专家”,为跨机 hidden 去重创造条件。需要注意,plan 生成本身也有成本;如果把 planning 时间排除在 benchmark 外,就可能得到一个对端到端系统无效的优化结论。
所谓 compact plan 到底压缩了什么
原来的表示像一张 128 列考勤表,即使一个 token 只去两个 rank,也要逐列看完:
compact plan 只保存非零项:
再按 destination 聚合,就得到真正适合通信提交的连续段:
优化点不是把 bool 换成更小的 dtype,而是把工作量从“查看全部可能目标”改成“只处理真实目标”。理想情况下复杂度更接近 S × unique_dst_per_token,而不是 S × P。
先把时间和字节测对¶
在改调度前,需要建立一个同时覆盖 Host、Device、网络和多 rank 尾部的测量面。最少应该回答:
本 rank 实际发送/接收了多少 hidden 字节?
生成并提交了多少 WQE?
每个 QP 分到多少 WQE 和字节?
route scan、WQE submit、qp_quiet、final barrier、epilogue 各花多久?
最慢 rank 是谁,它的 route histogram 是否异常?
有效 payload 带宽应按真实路由计算,而不是直接拿 S × P × H 当分子:
端到端报告同时给出平均延迟和 max-rank latency。只看 rank 0 或所有 rank 平均值,可能完全看不到负载不均和拓扑热点。
Host 侧计时¶
Host 侧适合用 NPU event 包住稳定的阶段边界:
event_start
→ layout/notify/dispatch enqueue
event_end
→ 最后统一 synchronize
→ elapsed(event_start, event_end)
不要每个小阶段后立刻 Host synchronize,否则测到的是被人为串行化后的路径。正确性调试阶段可以同步定位,性能测量阶段则应连续提交多轮、丢弃 warmup,并明确是否冲刷 L2。
Device 侧计时¶
MoonEP 已经展示了一个适合借鉴的模式:用编译期 Profile 开关读取 cycle,把每个 AIV 的阶段周期和字节数写入独立 GM 槽位;Profile=false 时编译成零开销路径。
建议每个 AIV 至少记录:
route_scan_cycles
wqe_build_cycles
wqe_submit_cycles
wqe_count
payload_bytes
qp0_quiet_cycles
qp1_quiet_cycles
barrier_cycles
epilogue_cycles
记录位置必须按 [rank, aiv, metric] 或其他独占方式切开,不能为了计时再引入热点原子加。最后由 Host 一次性读取并聚合。
怎么打印,才不会把性能问题打印成另一个性能问题
printf 适合确认控制流,不适合放在 token、peer 或 WQE 热循环中。调试时可以使用三道阀门:
推荐顺序是:
- 先把计数、cycle 和关键状态写到每 AIV 独占 GM buffer。
- kernel 结束后由 Host 统一读取并格式化。
- 真正需要确认死锁位置时,再限制到一个 rank、一个 AIV、一个 peer 打印。
高频 Device printf 会改变指令调度、同步和队列时序,甚至让原本不复现的竞态消失,因此不能把带打印版本的吞吐当作性能证据。
把优化变成可证伪的实验¶
优化顺序不应是“哪个技巧听起来高级就先做哪个”,而应是每一步只验证一个瓶颈假设。
P0:建立可信基线¶
先固定:
S/H/K/P、dtype、expert 数、capacity/alignment。- rank 到 server、frame、交换路径和 local lane 的映射。
- 实际 route histogram:每个 token 的 unique destination rank/server 数,每个 rank/expert 的接收量。
- 计时是否包含 layout/notify/plan,是否使用 cached handle,是否使用
num_worst_tokens。 - 带宽分子是 hidden payload、全部元数据,还是链路物理字节。
同时做小规模分解:8P、16P、32P、64P、128P;均匀路由和倾斜路由;小 H 和大 H。只有 128P 掉档时,优先检查拓扑和 P 维控制面;所有规模都只有一半时,再检查数据路径、统计口径和底层引擎。
P1:先处理拓扑扇出¶
低风险起点是保留 rank 旋转,并把“全 128 peer”改成可调波次,例如每轮 8/16/32 peer,记录:
如果硬件拓扑确认与 MoonEP 的 2×64、8×8、4×4 一致,可以研究它的双射 group schedule;如果真实系统更接近每 server 16 rank,则可研究 hierarchy 的“同 lane 跨机 + server 内展开”。必须先建立 rank→物理位置表,再选择公式。
P2:减少 WQE 和完成等待¶
对相同 peer 的连续 token 做聚合,使一次 WQE 搬更多字节;把 quiet 放在一批 payload 之后,而不是每个小块之后,同时保证 completion flag 晚于 payload 可见。
两个 QP 的分流不要只看 channel 数。把策略做成可配置,对比:
若增加 QP 只增加了 WQE/CQE 管理而没有降低 quiet 周期,瓶颈可能在共享链路或共享引擎,不在单 QP 队列深度。
P3:压缩路由计划¶
从 Top-K expert 直接生成 unique destination rank/server list,并按 destination compact;尽量融合:
目标是避免多次读取 [S,P],也避免所有 rank 重复读取完整 source-rank 计数。计划生成要纳入端到端计时,除非业务确实能长期复用 cached handle。
P4:再做细粒度通算掩盖¶
把工作拆成真正无依赖或仅有局部依赖的阶段:
当前 peer group 的 UDMA 在途
↔ 下一 group 的 route/offset/WQE 准备
hidden token AIV
↔ weight metadata AIV
ping buffer 的 GM→UB
↔ pong buffer 的 UB→remote GM
全局 barrier 若只用于保护某个 peer buffer,可以尝试改成 per-peer/per-group completion 与 credit;但必须保留“payload 可见后才能发 ready”“consumer 完成后才能复用 slot”这两条 happens-before。
P5:最后压内存细节¶
在确认 GM/UB 搬运成为热点后,再处理:
- 让同一 peer/expert 的 token 在 GM 中连续,扩大 DataCopy 和 WQE 粒度。
- 保证 UB 起点与传输粒度对齐,避免邻近 AIV 写同一缓存行。
- 使用 ping-pong staging,把 MTE2 读、Vector 处理、MTE3/UDMA 提交重叠。
- 复用 symmetric workspace 与 cached plan,避免热路径分配和初始化。
- 针对常见
H/K/P做静态 specialization,同时保留通用 fallback。
复杂硬件上的通算掩盖,流程理解是否正确?常见几板斧是什么
你的理解方向基本正确:根据计算逻辑建立依赖图,把计算、通信、访存、原子和同步拆开,再根据 shape 精细调度,使多个硬件资源尽量同时工作。
但要补上两道门:
- 先证明独立性。单边通信中的 payload、flag、credit 和 buffer reuse 有严格顺序,不能因为代码段看起来分开就并行。
- 先证明当前上限。如果瓶颈是 WQE 提交率,增加 payload AIV 没用;如果瓶颈是最慢 expert,优化平均 GM 带宽也没用。
常见“几板斧”可以归纳为:
- 减数据:量化、去重、同 server 命中多个专家时 hidden 只跨机一次、避免重复元数据。
- 减任务:连续 token 合并成大 WQE,批量下发,降低 CQE、doorbell 和 quiet 次数。
- 懂拓扑:按 server/frame/rail 分组,限制每轮 fan-out,用双射或错峰 schedule 避免热点。
- 做流水:双缓冲,当前通信与下一批 planning/pack 重叠,hidden 与 weight 分工。
- 减同步:用局部 completion/credit 替代不必要的全局 barrier,但不破坏内存可见性。
- 压访存:连续布局、对齐、UB tiling、减少 GM 往返、避免伪共享和热点原子。
- 做均衡:按实际字节/WQE 给 AIV 与 QP 分工,关注 expert/rank skew 和 max-rank tail。
- 按 shape 特化:为常见
P/H/K/S选择 group width、AIV 比例、QP 数和 bucket,避免一套参数覆盖所有场景。
真正可复用的方法不是背下这些技巧,而是把每一项写成:
一份可以直接执行的实验表¶
| 优先级 | 假设 | 最小改动 | 必看指标 | 支持假设的现象 |
|---|---|---|---|---|
| P0 | 带宽口径或尾延迟判断有误 | 记录实际 payload 和所有 rank 延迟 | bytes、avg/p95/max rank latency | max 明显高于平均,或实际字节远小于理论分子 |
| P1 | 128P peer 扇出/拓扑是主瓶颈 | group width 扫描 8/16/32/128 | 每轮 quiet、总 latency、链路分布 | 中等 group 明显降低 max latency |
| P2 | WQE/quiet 固定成本过高 | 合并同 peer 连续 token | WQE/byte、submit/quiet cycles | WQE 数下降且有效带宽上升 |
| P2 | 3:1 QP 不适合当前 shape | 扫描 1QP、1:1、3:1、1:3、4QP | per-QP bytes/WQE/quiet | 某策略稳定降低最后完成 QP 的时间 |
| P3 | [S,P] route scan 在 128P 过重 |
compact destination list 原型 | route cycles、GM bytes、plan time | P 扩展时 route 时间不再近似线性增长 |
| P4 | 全局同步吞掉重叠 | per-group completion/credit 原型 | barrier cycles、并发在途 WQE | barrier 降低且 correctness 不变 |
| P4 | AIV 分工失衡 | 扫描 token/weight AIV 比例 | 每组 AIV 完成周期 | 两组结束时间接近,总时间下降 |
| P5 | GM/UB 路径受限 | 重排连续化与 ping-pong | MTE cycles、GM throughput | 大 H/大 batch 下收益更明显 |
每次只改一个主要变量。若同时改拓扑、QP 数、路由格式和 AIV 分工,即使性能上涨,也无法知道收益来自哪里,更无法判断换一个 shape 后为什么回退。
回到“只有理论一半”¶
现在可以把最初的问题改写得更精确:
在 128P 两层拓扑上,当前 dispatch 是受 hidden payload 带宽限制,还是受全 peer 扇出、WQE/quiet、
[S,P]规划和最慢 rank 同步限制?
源码已经能确认几件事:ascend_deepep 当前使用扁平 peer 扫描、source-rank 旋转、固定两个 QP 和 3:1 channel 分配;Host 侧始终按 S×P 预留内部接收区;num_worst_tokens 能避免 Host 回读但不会缩小这块 workspace;海思 hierarchy 展示了“按 server 跨机聚合、机内再展开”的协议;MoonEP 展示了“16-rank group、拓扑双射波次、token/weight AIV 分工和局部 completion/credit”的另一条路线。
源码还不能证明的是:3:1 为何在当前硬件上最优、MoonEP 的 2×64 拓扑是否等于实际部署、hierarchy 的 relay 收益能否覆盖当前 128P,以及理论带宽的一半究竟丢在哪个阶段。这些问题不能继续靠静态阅读裁决,只能靠分阶段 cycle、per-QP WQE/bytes、route histogram 和 max-rank latency 去收束。
因此第一版优化不必追求“大改架构”。先把计时面补齐,再做两个最小实验:QP 策略扫描和peer group width 扫描。如果前者无效、后者在 128P 明显有效,主矛盾就从“单 QP 不够并行”转向“拓扑与控制面扇出”;届时再投入 compact plan 或 hierarchy,路径会清楚得多。
源码锚点¶
本文只描述以下固定 revision 和会话中已经走读的实现,不把实验性策略升级为通用硬件结论:
ascend_deepep@18e2dd4b:PythonBuffer.dispatch、Hostcsrc/ops/dispatch.cpp、Devicekernels/dispatch.cpp与kernels/dispatch_udma.cpp。ops-transformer@8dc2fa04:README 中的fullmesh/hierarchy约束与moe_distribute_dispatch_v2_layered.h。ascend-moonep@b67c6840:moonep_dispatch_udma.cpp中的 128P group schedule、QP 策略、AIV 分工和 profiling。- AIV MoE All-to-All:dispatch/combine 的 one-sided Put、ready/count 与反向归约背景。
- DMA Communication Terminology:UDMA、IPC/MTE、SHMEM 与完成语义的概念边界。

