跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 38 章一次真实开源的完整拆解40 分钟14 习题8 自测题

DeepEP-Ascend:专家并行通信与计算重叠

MoE 的专家并行会引入 all-to-all,而通信很容易把算力省下的东西吃回去。本章讲清 EP dispatch/combine 的语义、FP8 dispatch 与延迟 epilogue、HCCL/UBMEM/URMA 的分工、DeepJIT 为什么要在运行时编译,以及 EP8–EP128 的带宽数字怎么读。

本章进度状态 未读完习题 0 / 14自测 未作答

学习目标

读完本章,你应当能够:

  1. 用第 16 章的账本口径算出 MoE 一层 dispatch + combine 的通信字节数, 说清它为什么随专家并行(EP)规模变成核心成本,以及 FP8 dispatch 把这个数字 压掉了多少。
  2. 逐行解释 资料库索引 里那段 EPBuffer 用法:构造参数、 get_buffer_size_hint()、destroy()、缓存 handle(cached handle)、 expert padding、defer_epilogue 与 async_with_compute_stream 各自表达什么。
  3. 说清 FP8 dispatch 为什么省带宽、row-major scale 的粒度与含义, 并与第 27 章的 block scaling 口径对齐;能指出它和第 35 章 SF 打包的异同。
  4. 画出 HCCL / HCOMM / UBMEM / URMA 在这个库里的分工,说明为什么昇腾超节点 用 UBMEM + URMA 而不是纯 RDMA。
  5. 逐项对照第 26 章 TileLang 自己的 JIT / ABI 与 DeepJIT 在职责上的异同, 并说出 DeepJIT 对使用者的四个实际影响(首次调用编译、共享缓存、 ASCEND_HOME_PATH 与 Bisheng、EP_JIT_* / DJ_JIT_*)。
  6. 会读 128 卡超节点的带宽表:说出测量条件,解释为什么 EP 越大越慢、 为什么 combine 比 dispatch 慢,以及「可持续 dispatch 达到物理 payload 带宽上限的 90–95%」这句话该怎么读。
  7. 说清 TileLang 这条主线在本章的两处落点:通信库自己一行 TileLang 也没有, 但它两端的算子(MoE 路由、量化)是 TileLang 写的。

前置知识

  • 第 16 章:MoE 的四段结构(router / dispatch / grouped GEMM / combine)、 bytes = 2·T·k·d·b 这套字节账,以及「跨卡时每 token 每方向 k·d·b 字节」的 结论。本章的 §1 直接在这张账本上继续算,不再重复定义。
  • 第 26 章:TileLang 自己的运行时与 JIT——@tilelang.jit 的生命周期、 三层缓存、host packed ABI、adapter 与框架的转接。§6 会把这条链与 DeepJIT 逐项摆在一起。
  • 第 27 章:低精度与 block scaling。本章只用到两个结论:scale 的粒度、 以及 scale 的布局(行主序 / 分组主序)会直接改变数值结果, 以及 E8M0 的语义(只存指数,只能是 2 的整数次幂)。
  • 第 35 章:MegaMoE 把 dispatch → grouped GEMM → SwiGLU → combine 融进一个 kernel。§9 会把「独立通信库 + 外部 GEMM」与「全融合」两条路摆在一起比。
  • 第 32 章:本次开源的六个组件与版本核验方式。

不需要你会写 Ascend C,也不需要你有昇腾硬件。本章有两段 C++ 需要读 (§4 的 dispatch 主循环、§6 的 JIT 调用点),都可以只靠源码读懂。


本章的验证边界(务必先读)

写作环境是 macOS (Apple Silicon)、Python 3.11,没有 NVIDIA GPU,也没有昇腾 NPU (见 版本锁定)。因此本章遵守四条硬规矩:

  1. 所有代码结论一律标注「静态阅读」。 引用的 DeepEP-Ascend 源码逐行读过, 行号与文件内容一致;但没有一行 Ascend C 被编译过、没有被任何 NPU 执行过。
  2. 性能数字只有一个来源:README 的性能表(资料库索引)。 本书没有任何实测数字。凡是「本书复算」的地方,都出自 code/ 下的纯 Python 程序,能在写作机上跑出结果,正文会明确标注。
  3. 通信栈的头文件不在 corpus 里。 hccl/hccl.h、hcomm/hcomm.h、 kernel_operator.h、c_api/*、simt_api/* 都由 CANN 提供, 快照里只有 #include 和调用点。所以 §5 只根据 README 的措辞与 DeepEP-Ascend 源码里怎么用它们来描述分工,不对 CANN 的内部实现下结论。
  4. 硬件规格缺失的地方要留白。 本章会反推一个「物理 payload 带宽上限」的量级, 但 corpus 里没有 Ascend 950 UBMEM 链路的官方带宽规格, 这个反推写进 本章引用 的「待补资料」,并标注为区间估计。

三个记号先约定好:

记号 指什么
DeepEP-Ascend/… corpus 里的 DeepEP-Ascend/;站点会把它换成上游 GitHub 链接(commit 3b25377d04b2)
DeepEP/… NVIDIA 侧对照物 …/raw/DeepEP/…(commit 93eb6eb23812)
DeepJIT/… corpus 里的 …/raw/DeepJIT/…(服务器端主分支快照,见 资料库索引)

本章与第 35 章的关系是镜像的:第 35 章讲「昇腾上怎么把 GEMM 压到极限」, 落脚在 hc_prenorm.py 这个 TileLang kernel;本章讲「昇腾上怎么把 all-to-all 压到极限」,落脚在一个不用 TileLang 的通信库。两章合起来回答同一个问题: 一个生产级栈会在哪里用 TileLang,在哪里绕开它。


1. 问题的由来:省下来的算力,被 all-to-all 吃回去

1.1 一句话定位

第 16 章说过:MoE 用「每个 token 只激活 k 个专家」把 FLOP 降下来,代价是引入 两段零 FLOP 的搬运——dispatch(把 token 送到专家所在的卡)和 combine (把结果送回原卡)。当专家被切到不同 rank 上(专家并行,EP),这两段搬运 就变成了跨设备的 all-to-all。

这就是本章要处理的东西。DeepEP-Ascend 的自我定位只有一句话 (资料库索引):

面向华为昇腾 NPU 的高性能机器学习训练与推理通信库,提供 MoE dispatch/combine 的专家并行(EP)all-to-all 操作,支持 FP8 dispatch 和延迟 epilogue。

请注意这份定位里没有出现的词:没有「TileLang」。这是一条重要的线索, §9 会回到它。

1.2 先把账算出来

用第 16 章的口径:一层 MoE,一个 token 被路由到 k 个专家,hidden 维是 d, 每元素 b 字节。跨卡时每 token 每方向 k·d·b 字节(第 16 章 §2.6 第 3 条)。 现在把它放到本次发布所用的规模上:

量 取值 出处
每 rank token 容量 T 16384 资料库索引
hidden d 7168 同上
top-k 6 同上
总专家数 256 同上
EP 规模 8 / 16 / 32 / 64 / 128 资料库索引

把 k·d·b 代进去,一个 token 在 dispatch 方向上传出去的有效载荷是:

FP8  dispatch:6 × 7168 × 1          = 43008 字节 ≈ 42.0 KB
BF16 dispatch:6 × 7168 × 2          = 86016 字节 ≈ 84.0 KB
BF16 combine :6 × 7168 × 2 + 6×4    = 86040 字节 ≈ 84.0 KB   (含路由权重)

再乘上容量 16384 个 token,一层的字节量就到 GB 级别。而同一层的 grouped GEMM 在默认形状下只有 ~2.9 TFLOP(第 16 章 §2.4 的账):在一个平衡点 333 FLOP/Byte 的机器上,2.9 TFLOP 对应的「算力等价访存」约 8.7 GB。 也就是说,dispatch + combine 的通信量和这一层 GEMM 的访存是同一量级。

1.3 为什么这个成本随 EP 规模「越来越不划算」

上表的数字还不完整,因为它假设「一个 token 恰好去 k 个不同的 rank」。 真实的 EP 里有两件事会改变这个数字:

  1. 去重。如果同一个 token 的 k 个专家里有几个落在同一个 rank,它对这个 rank 只传一次。所以一个 token 实际覆盖的不同目标 rank 数是 R·(1-(1-1/R)^k)(R 个 rank、均匀路由的期望),随 R 增大而增大。
  2. 本地专家不占网络。落在本 rank 的专家走本地拷贝,不占 URMA 带宽 (DeepEP-Ascend/deep_ep/include/deep_ep/impls/ep/dispatch.hpp:436-445 的元数据本地拷贝与 :806-841 的 hidden 本地拷贝流水)。

把这两条合起来看,EP 越大,一个 token 越可能被拆到更多 rank 上, 跨设备字节量越大;同时每个 rank 上的专家越少(128 卡、256 专家时只有 2 个), 每张卡的算力摊薄,通信占比反而更高。这正是 128 卡超节点上 「算力省下来了、通信吃回去了」的具体形态。code/02-comm-ledger/ 会把 这两条算成一张表。

1.4 本章要回答的六个问题

  1. 接口:EPBuffer 长什么样?为什么构造它要指定 token 容量、hidden、top-k?
  2. 数据流:一次 dispatch / combine 在设备上到底发生了什么?
  3. 精度:为什么 dispatch 可以用 FP8,combine 不能?scale 放在哪、怎么排?
  4. 重叠:defer_epilogue 与 async_with_compute_stream 表达的是什么?
  5. 底座:HCCL / HCOMM / UBMEM / URMA 各管什么?
  6. 现状:哪些能力现在能用,哪些还没实现——以及没实现对使用者意味着什么?

2. 概念与模型:一次 dispatch / combine 到底发生了什么

一个 token 的 dispatch / combine 数据流本地计算网络 all-to-allURMA 单边写URMA 单边写本 rank 的 tokenx: [num_tokens, hidden]路由topk_idx / topk_weightsdispatch按 topk 复制到专家所在 rank目标 rank 的展开布局每 token 每槽位一行 + 来源表专家 grouped GEMM外部库(如 DeepGEMM-Ascend)combine按 top-k 权重归约回原 rank原 rank 的输出combined_x: bf16
dispatch 把 token 复制到专家所在的 rank(带来源表),combine 再把 k 份专家输出按路由权重归约回原 rank;反向传播时两者互换。
  • 同一 token 的多个专家落在同一 rank 时只传一次(去重)。
  • 来源表 = topk_idx + topk_weights + 源 token 全局下标,随 token 一起传。
  • combine 的累加在 FP32 里做,最后转回 BF16。
训练一步里通信的位置attention / MLP 前向①发起 dispatch(async + defer)②共享专家 / 其它计算(重叠)③wait:拿到 recv_x / weights / handle④专家 grouped GEMM⑤发起 combine(bias 可融合共享专家)⑥其它计算(重叠)⑦wait:拿到 combined_x⑧
dispatch 先发起、后等待,中间塞共享专家等独立计算;更外层的重叠交给 DualPipe。三层重叠可以叠加。
  • 不重叠时,dispatch → GEMM → combine 之间串行等待,等待时间由最慢的 rank 决定。
  • 调用级重叠靠 defer_epilogue + async_with_compute_stream。
  • 流水线级重叠靠 DualPipe 把前后向 chunk 的计算-通信错开。
EPBuffer 的生命周期1. ① 规划容量get_buffer_size_hint(),返回 2 MB 对齐字节数2. ② 构造注册对称内存 [ workspace | buffer ],建信道与 Jetty,barrier 对齐3. ③ 使用(可循环)dispatch → 专家 GEMM → combine;handle 可缓存4. ④ 销毁destroy() → 注销 workspace → finalize,然后才拆进程组
一个 EP group 只建一个长生命周期 buffer;容量必须所有 rank 一致;用完显式 destroy() 之后才能拆进程组。
  • 每层建一个 buffer 是反模式:一个 EP group 只应有一个。
  • 改容量/对齐/topk 之前必须等在途操作全部结束。
  • explicitly_destroy=True 且忘记 destroy() 会打印泄漏警告并真的泄漏。

2.1 一次 all-to-all 的两半

MoE 的前向里,通信出现两次(图 1):

  • dispatch:每个 rank 持有自己的 token,按路由结果把 token 的行复制到 专家所在的 rank。目标端收集到的 token 是「按专家分组」的,叫 展开布局(expanded layout)。
  • combine:专家算完之后,把每个 token 的 k 份结果归约回它原来的 rank, 乘上路由权重。

(源描述见 图:01-dispatch-combine-flow; SVG 规格 src/tutorial/figures/38-deepep-ascend/01-dispatch-combine-flow.json。)

图里有一个容易被忽略的细节:dispatch 传的不只是 token 数据。 接收端要能把结果送回去,所以每个 token 还要带一份「来源信息」:

  • topk_idx:这个 token 选中了哪些专家([num_tokens, num_topk],int64);
  • topk_weights:对应的路由权重(float32);
  • 源 token 的全局下标(src_token_global_idx)。

这三样加起来的字节数写在每 token 的布局(token layout)里, §4.2 会逐字段拆开。先把结论放这里:在 hidden=7168 时它们是 ~300 字节, 相对 7168 字节的 hidden 只是零头,但它们决定了 combine 能否正确还原—— 第 16 章 §2.5 说的「来源表本身也要存、也要传」,指的就是它。

2.2 展开布局与 expert padding

接收端拿到的 token 不是 [T, d],而是「每个 token 的每个 top-k 槽位一行」。 设这个 rank 从所有来源 rank 一共收到 num_recv_tokens 个 token(去重后 按 rank 计),那么展开后的行数是

num_expanded_tokens = Σ_e ceil(num_recv_tokens_per_expert[e] / expert_alignment)
                                                       × expert_alignment

为什么要 padding 对齐? 因为下一步是 grouped GEMM,它的 M 必须是 block_M 的整数倍。第 16 章 §2.3 第 2 条讲过同一件事(官方例子用 padding_M = 128)。DeepEP-Ascend 把它做成一个参数 expert_alignment, README 的注释是「Match the grouped GEMM's token alignment」 (资料库索引)。

两个后果:

  1. padding 是浪费的显存与 grid,但换来的是 GEMM 的规整 grid。 这是第 16 章 §2.6 第 4 条「不均衡与 varlen 直接变成浪费」的具体实现。
  2. padding 槽位里是脏数据还是零,取决于 do_zero_padding。 do_zero_padding=True 才会把对齐空隙清零(资料库索引); 在 do_cpu_sync=False 的模式下,尾部未用的容量不会被清零, README 明确提醒「does not make unused output capacity valid data」 (资料库索引)。

2.3 combine 是 dispatch 的逆;两个反向正好互换

MoE 的前向是

dispatch → 专家 GEMM → combine

反向传播时,梯度按相反顺序穿过这段链(资料库索引):

  • dispatch 的反向是一次 combine(把发出去的梯度收回来);
  • combine 的反向是一次 dispatch(把回来的梯度再发出去)。

所以 EPBuffer 的两个方法互为反向,而且复用同一个 handle: 前向 dispatch 产生的路由信息(谁去了哪)保存下来,反向直接拿来用, 不需要重新算一遍路由。这就是 handle 存在的第一个理由。

2.4 handle 是什么,为什么可以缓存

EPHandle 是 dispatch 返回的一包路由结果(deep_ep/buffers/ep.py:21-51), 里面装着:

  • 去重后的每 rank 接收计数与前缀和(psum_num_recv_tokens_per_rank);
  • 每个本地专家的接收计数、对齐后的前缀和(psum_num_recv_tokens_per_expert);
  • 每个 token 每个 top-k 槽位在目标 buffer 里的槽位下标 (dst_buffer_slot_idx)与 SGE 下标(dst_gsge_idx);
  • 回传元数据 recv_src_metadata([num_recv_tokens, num_topk + 2])。

一旦有了它,下一次 dispatch 只要输入形状不变,就可以跳过布局计算, 直接复用这套槽位分配——README 把这个模式叫 cached handle (资料库索引、资料库索引)。代码里的入口是 dispatch(..., handle=handle)(deep_ep/buffers/ep.py:327), 走的是 cached_mode 分支(csrc/buffers/ep.hpp:119-132)。

cached handle 有一个前提:所有 rank 必须用同样的容量、同样的对齐、 同样的 top-k;deep_ep/buffers/ep.py:412-414 会断言 (num_experts, expert_alignment, num_max_tokens_per_rank) 与 handle 一致。

还有一个坑:handle 按引用持有 topk_idx,且不允许在 handle 存活时修改它。 EPHandle.topk_idx 这个属性会在读取时检查 PyTorch 的版本计数器 (deep_ep/buffers/ep.py:94-98),但通过 .data 或裸指针的写入检查不到。

2.5 训练一步里,通信站在哪里

把 dispatch / combine 放回训练一步里看(图 2):

attention / MLP 前向 ──▶ dispatch ──▶ 专家 GEMM ──▶ combine ──▶ 输出
                                ▲
                                └── 这一段可以和「共享专家(shared experts)」
                                    或其它独立计算重叠

(源描述见 图:02-train-step-timeline; SVG 规格 src/tutorial/figures/38-deepep-ascend/02-train-step-timeline.json。)

为什么重叠是必须的?因为 dispatch 是「同步阻塞」的:专家 GEMM 要等 token 到齐才能开始,而 token 到齐需要等最慢的 rank。第 16 章 §2.6 第 3 条 已经点过这条:最慢的 rank 决定整步时间。如果不把通信藏到别的计算后面, 这段等待就是纯粹的流水线气泡。

DeepEP 这一代人给出的答案是「先发起、后等待」:把 dispatch 立刻下发, 然后去算不依赖它的东西(例如共享专家),需要结果时再 .wait() (资料库索引)。这个模式在训练侧还有一个更激进的版本—— DeepSeek-V3 的 DualPipe,把前向与反向的计算-通信整体错开 (资料库索引:full overlap of forward and backward computation-communication phases)。§9.3 会把它与本库的关系讲清楚。

2.6 buffer 的生命周期

图 3 把 EPBuffer 的一生画出来。要点有四条,都来自 README 的 Buffer initialization 一节(资料库索引):

  1. 每个 EP group 只建一个长生命周期 buffer,跨层、跨 micro-batch 复用;
  2. 所有 rank 必须对 token 容量达成一致;
  3. 增删改 buffer 之前必须等所有在途操作完成;
  4. 用完之后显式 destroy(),再拆进程组。

(源描述见 图:03-buffer-lifecycle; SVG 规格 src/tutorial/figures/38-deepep-ascend/03-buffer-lifecycle.json。)


3. 用法:把 README 的样例读成一段能落地的代码

这一节把 README 里的三段代码逐行读一遍。它们需要真实的多卡昇腾环境, 本机跑不了(资料库索引 写明验证级别是「仅静态核对」)。

3.1 构造 EPBuffer:参数逐个说清

README 的初始化样例(资料库索引)是:

ep_group = dist.group.WORLD
max_tokens_per_rank = 4096
hidden = 7168
num_topk = 6
num_experts = 256

buffer = EPBuffer(
    ep_group,
    num_max_tokens_per_rank=max_tokens_per_rank,
    hidden=hidden,
    num_topk=num_topk,
    use_fp8_dispatch=False,  # Reserve enough storage for BF16 and FP8 dispatch.
    explicitly_destroy=True,
)

对应到源码,签名其实更长(deep_ep/buffers/ep.py:175-191)。这里按 「读者必须先懂的那几个」分组:

参数 语义 为什么需要它
group 通信进程组 决定 num_ranks;buffer 的所有容量计算都按这个规模来
num_max_tokens_per_rank 每 rank 的 token 容量上限 决定接收 buffer 的大小;所有 rank 必须一致(资料库索引)
hidden token 的 hidden 维 决定每 token 的字节数
num_topk 每 token 选几个专家 决定展开行数与元数据大小
use_fp8_dispatch 是否为 FP8 dispatch 预留空间 False 时按 BF16 预留,已经足够同时容纳 FP8(样例注释)
explicitly_destroy 是否要求手动 destroy() True 时析构不自动清理,忘写 destroy() 会打印泄漏警告并直接泄漏(csrc/buffers/base.hpp:19-29)
deterministic 是否使用确定性布局 打开后 dispatch 的输出按源 token 排序(deep_ep/buffers/ep.py:100-160)
num_bytes 手动指定 buffer 字节数 不填就用 get_buffer_size_hint() 算;必须 2 MB 对齐

构造时不在设备上分配,而是拼一个 comm::Context(csrc/buffers/ep.hpp:44-58): 它先在 HCCL 里注册一块对称内存(symmetric memory),再申请一块 host workspace 用于 CPU 侧同步。构造结尾有两个 synchronize() 夹一个 dist.barrier(group)(deep_ep/buffers/ep.py:242-244)——保证所有 rank 的 buffer 都建好了,任何一个 rank 才能开始通信。

3.2 get_buffer_size_hint():先算容量,再决定分配

当你要管一个 buffer 池时,第一个问题总是「要留多大」。库给的入口是 EPBuffer.get_buffer_size_hint(...)(deep_ep/buffers/ep.py:251-275), 它不构造 buffer,只算一遍布局并返回对齐到 2 MB的字节数。

它对内部做的事在 csrc/buffers/ep.hpp:581-605:分别算

  • dispatch 布局:BufferLayout(TokenLayout(hidden, sf, topk), num_ranks, num_max_tokens_per_rank);
  • combine 布局:BufferLayout(combine_token_layout, min(num_topk, num_ranks), num_max_tokens_per_rank);

取两者较大值,再向上对齐到 kNumAllocationAlignmentBytes(2 MB, deep_ep/include/deep_ep/common/compiled.hpp:48)。

一个容易误读的细节:size-hint 里 SF 的 pack 数用的是 ceil_div(hidden, 32)(csrc/buffers/ep.hpp:594),而实际运行时 num_sf_packs = ceil_div(hidden, 64)(一个 int16 装两个 UE8M0, 见 DeepEP-Ascend/tests/ep/test_ep.py:91-93 的 sf.view(torch.int16))。 也就是说这个 hint 为 SF 预留的是实际用量的两倍,源码注释也写明了 「approximate the SF pack count」(:593)。规划容量时可以放心按它来, 但不要把它的数字当成实际流量。code/03-buffer-size/ 会把这段公式复算一遍。

3.3 dispatch / combine 的四个 helper

README 给训练 / 推理各写了一个 helper 对(资料库索引)。 把它们摊平看,真正的调用只有两个方法:

# dispatch:把 token 送到专家所在 rank
recv_x, _, recv_weights, handle = buffer.dispatch(
    x,                       # bf16,或 (fp8_data, sf) 元组
    topk_idx=topk_idx,       # [num_tokens, num_topk], int64
    topk_weights=topk_weights,
    num_experts=num_experts,
    expert_alignment=expert_alignment,
    do_cpu_sync=do_cpu_sync,
    do_expand=True,          # 昇腾只支持 True
    do_zero_padding=True,
    async_with_compute_stream=True,
    defer_epilogue=True,
)

# combine:把 k 份专家输出归约回原 rank
combined_x, combined_weights = buffer.combine(
    expert_output,           # bf16
    handle=handle,
    bias=bias,               # 可选:融合残差 / 共享专家输出
    async_with_compute_stream=True,
    defer_epilogue=True,
)

逐项解释必须懂的五个参数:

参数 语义 为什么这么设计
do_expand=True 使用「每个 token 每个专家槽位一行」的展开布局 昇腾后端只支持这个模式(csrc/buffers/ep.hpp:116);NVIDIA 侧还有非展开的 low-latency 模式,昇腾没有
expert_alignment 每个本地专家的接收数向上对齐到这个值 让 grouped GEMM 的 M 严格对齐;测试用 128(资料库索引)
do_cpu_sync 是否同步回 CPU 拿到精确的接收计数 训练 / prefill 常开(要精确形状);decode 关掉,用设备侧计数,省掉一次 CPU 往返(资料库索引)
do_zero_padding 是否清零对齐空隙 给 GEMM 一个干净输入;关掉会读到脏数据
bias combine 阶段额外加上的一块 bf16 把「共享专家输出」或残差融进 combine,省一次单独的 elementwise kernel(资料库索引)

再补两个必须保持默认的参数:

参数 约束 出处
do_handle_copy 必须 False,handle 复制已不支持 资料库索引;deep_ep/buffers/ep.py:392
sl_idx / num_allocated_qps 必须保持 None / 0,显式 RDMA 服务等级与 QP 数不支持 资料库索引;deep_ep/buffers/ep.py:205-206

num_sms 这个参数沿用了 NVIDIA 的名字,但在昇腾上选的是 AI core 数 (资料库索引):0 表示走默认值 get_theoretical_num_sms(), 而它在昇腾上直接返回 get_num_ai_cores()(deep_ep/buffers/ep.py:305-309)。 注意 cached dispatch 与 combine 一律复用 handle 里存的核数, 传别的值不起作用(deep_ep/buffers/ep.py:409、:566)。

3.4 重叠:defer_epilogue + async_with_compute_stream

这两个参数一起用,表达的是**「先把通信发出去,先不取结果」**:

  • async_with_compute_stream=True:这次调用返回一个 EventOverlap 对象 (而不是结果张量),表示「当前 stream 上的工作」;
  • defer_epilogue=True:把收尾(epilogue)也推迟——调用立刻返回, 但真正的「等接收计数 / 拷贝数据」要等到 .wait() 才执行。

README 给出的骨架(资料库索引):

pending = dispatch_forward(buffer, x, topk_idx, topk_weights, num_experts, ...)

# 通信在跑,同时算别的(这里:共享专家)
shared_output = shared_experts(x)

recv_x, _, recv_weights, handle = pending.wait()   # 这时才真正取结果
expert_output, expert_ctx = local_experts_forward(recv_x, recv_weights, handle)

pending = combine_forward(buffer, expert_output, handle, bias=shared_output)
# ... 再算点别的 ...
output, _ = pending.wait()

代码侧的对应关系:defer_epilogue=True 时,C++ 侧不立即跑 epilogue, 而是把它包成一个 hook 返回(csrc/buffers/ep.hpp:396-402), Python 侧在 .wait() 时回调(deep_ep/buffers/ep.py:500-504)。

两个硬约束:

  1. defer_epilogue=True 必须搭配 async_with_compute_stream=True, C++ 侧直接断言(csrc/buffers/ep.hpp:398);
  2. 如果通信之前已经排了别的 kernel,要用 buffer.capture() 先把 「输入就绪」的事件记下来,作为 previous_event 传进去。

代价:延迟 epilogue 意味着「结果在第一次 .wait() 时产生」, 拿到之后要自己留住;如果不重叠、立即 .wait(),反而可能比不延迟更慢。 README 的原话是「Without other work to overlap, call .wait() immediately」 (资料库索引)。

3.5 确定性布局(deterministic=True)

默认情况下,dispatch 的输出顺序由设备上 SIMT 线程的原子操作顺序决定, 同一份输入两次运行的行顺序可能不同。打开 deterministic=True 后, EPHandle.deterministic_sort()(deep_ep/buffers/ep.py:100-160) 会在 epilogue 之后做一次按源 token 的稳定排序:

  • 排序键 = expert_idx * 1e10 + 源 token 全局下标(:125-130);
  • 只排「展开后的数据张量」,再改写元数据槽位(:145-160);
  • cached dispatch 走 cached_recv_src_metadata_before_sort 复用上一次的确定性布局 (:116-121)。

这是第 16 章「一致性(确定性)」那条工程约束的具体实现:用一次额外的 排序换「逐位可复现」。代价写在 deep_ep/buffers/ep.py:145-160 里—— 被排序的数据量是全部展开 token,k 路路由下是 T·k 行。

3.6 FP8 dispatch 的 scale:粒度、排布与两个方向

§3.2 留下了一个问题没讲透:FP8 dispatch 的 scale 到底长什么样。这一节补齐, 并把第 27 章的数值口径接上。

(1)为什么要 FP8。 dispatch 是纯搬运,字节数就是成本。把 hidden 从 BF16 换成 FP8,hidden 部分直接省一半(§1.2 的账:84 KB → 42 KB)。 代价是精度:FP8 必须带 scale。库把这件事做成一个显式的模式开关 use_fp8_dispatch——注意它管的是容量预留(资料库索引 的注释), FP8 的实际数据由调用方按约定传进来。

(2)scale 的粒度。 输入 x 在 FP8 模式下是一个二元组 (x_data, sf)(deep_ep/buffers/ep.py:341-345 的文档字符串):

  • x_data:[num_tokens, hidden],torch.float8_e4m3fn;
  • sf:[num_tokens, num_sf_packs],每个元素是一个 int16 (sf_pack_t,common/compiled.hpp:33)。

每 128 个 hidden 元素一个 fp32 scale(deep_ep/utils/math.py:30-38 的 per_token_cast_to_fp8 用 view(m, -1, 128) 分组)。所以 num_sf_packs = ceil(hidden / 128) × 2:那个 ×2 来自 sf.view(torch.int16) 的字节重解释(一个 fp32 变两个 int16), 不是「两个 scale 合成一个 int16」。

(3)排布:row-major 还是 column-major。 dispatch 有一个参数 use_tma_aligned_col_major_sf(ep.py:372-373), 默认 False 表示 row-major:

row-major   : sf.stride() == (num_sf_packs, 1)        # 同一 token 的 scale 连续
col-major   : sf.stride() == (1, num_expanded_tokens) # 同一 scale 位置跨 token 连续

测试里对这两种排布都有断言(tests/ep/test_ep.py:143-145); host 侧从 row-major 转 col-major 就是把输出转置再转回来 (csrc/buffers/ep.hpp:380-381)。文档字符串还写明昇腾不会在 token 维度上额外补 padding(ep.py:372-373)。

(4)与第 27 章对齐。 第 27 章 §2.3 讲过 block-scaled 的三要素 (块粒度、E8M0 的语义、打包);§6.2 讲过分组 GEMM 的 scale 布局最容易写错, §7 第 1 条给了一个具体教训:行主序的 bug 会「藏起来」—— 当 K 不超过 4 个粒度块时,两种索引恰好等价。 这里的 use_tma_aligned_col_major_sf 就是同一类「布局开关」, 只不过它作用在 dispatch 的输出上:如果接收侧按 row-major 读、 而发送侧写了 col-major,得到的 scale 会整体错位。 它比第 27 章的 MX 打包简单(没有「一个 word 装 4 个 E8M0」那种位运算), 但踩坑的方式是同一种。

(5)为什么 combine 不能也走 FP8。 combine 要做的是一次加权求和, 而且是 k 份结果的归约;在低精度下累加会引入额外的舍入。库的选择是 combine 输入固定 BF16(csrc/buffers/ep.hpp:434 断言 x.scalar_type() == torch::kBFloat16),归约在 FP32 里做(§4.5), 最后转回 BF16。这是「通信省一半」与「归约要准」之间的取舍。


4. 实现机制:buffer、layout 与两个 kernel

这一节读代码。目标是能回答「一次 dispatch 在设备上分几步」「每一步动数据了吗」。

4.1 两块内存:workspace 与 buffer

EPBuffer 在 HCCL 里注册的是一整块对称内存,但内部切成两段 (csrc/comm/api.hpp:48-63):

symmetric memory  =  [ workspace | buffer ]
                        ↑            ↑
                    信号量/计数    收发数据
  • workspace:放 EPSignals,也就是各种直方图计数器、前缀和、 host 侧通知位(deep_ep/include/deep_ep/layout/ep/workspace.hpp:8-18);
  • buffer:放收发的 token 数据(dispatch 用发送缓冲,combine 用接收环)。

「对称」这个词在这里是字面意思:每个 rank 都按同样的公式算出同一段偏移, 于是 rank i 想写到 rank j 的某个位置时,它只需要知道 j 这块内存的 基地址,剩下的偏移两边一致。基地址表存在 CommonSignals.peer_ptrs 里 (deep_ep/include/deep_ep/layout/common/signals.hpp:9-17), 取对端地址的辅助函数是 get_sym_ptr()(同文件 :19-24)。

4.2 每 token 的布局

TokenLayout(deep_ep/include/deep_ep/layout/ep/token.hpp:9-83) 定义了「一个 token 在 buffer 里占的字节」:

[ hidden bytes ][ SF packs(32B 对齐)][ topk_idx(int64×k,32B 对齐)]
                [ topk_weights(float32×k)][ src_token_global_idx(int32)]

对齐单位是 32 字节(kNumUbAlignmentBytes = 32, common/compiled.hpp:38)。两个入口分别对应 dispatch(带 SF 和元数据) 与 combine(不带 SF、不带元数据,csrc/kernels/ep/combine.hpp:14-16)。

4.3 dispatch kernel:三段

dispatch_impl(deep_ep/include/deep_ep/impls/ep/dispatch.hpp:559-865) 在 num_vec_cores 个 AIV 上跑,每个 AIV 负责一段连续的 token 区间。三段是:

第一段:SIMT 并行做路由(:86-549)。 这一段跑在 SIMT 模拟的 AIV 线程上(__simt_vf__),做四件事:

  1. 每个 token 的 k 个专家做直方图统计(asc_atomic_add 到 expert_histogram,:179);
  2. 用 simt::deduplicate 把「同一 token 的多个专家落在同一 rank」去重, 一个目标 rank 只记一次(:183);
  3. 分配槽位:对每个目标 rank 做前缀和,给每个 entry 定一个 dst_slot_idx(:260-294);
  4. 把跨 rank 的接收数汇总到 workspace,算出 psum_num_recv_tokens_per_rank / _per_expert(:451-548)。

这一段还有两个进度标记:local_copy_ready / metadata_ready (:297-298、:431、:801),后面的搬运流水会等它。

第二段:写 URMA 的 SQ(:327-429)。 每个 warp 负责一批 SQE: 把「源地址 + 目标 rank + 目标槽位」写进一个 64 字节的 WQEBB (kNumWQEBBBytes = 64,comm/handle.hpp:19)。这里有一个细节值得记: 一次 WRITE 的 SQE 里,token 数据与元数据是两个 SGE (源码注释 :360-369),所以网络上一次搬运就带上了「数据 + 来源表」, 不需要第二趟。最后一条物理 SQE 会带 fence + CQE 标记 (kWriteSQEFinalFlags,comm/handle.hpp:31-32), 保证顺序与完成通知。

第三段:本地拷贝 + 收尾(:806-865)。 落在本 rank 的 token 不占网络,走 asc_copy_gm2ub_align → asc_copy_ub2gm_align (:818-838)搬进接收 buffer。最后 ring_doorbell() 通知硬件 (comm/handle.hpp:346-355),一次 dispatch 的发起侧就结束了。

注意顺序:SIMT 先算好路由并写好 SQE,硬件再去搬数据, 所以「通信」与「路由计算」不重叠;真正能重叠的是这次 dispatch 与 调用方后续的独立计算。

4.4 dispatch 的 epilogue:为什么可以推迟

接收端的收尾在 dispatch_copy_epilogue_impl 里:等所有 rank 的通知到齐、 拿到精确的接收计数、再按槽位把数据从 buffer 拷到用户张量 (csrc/buffers/ep.hpp:353-377 是 host 侧的调用)。

它被单独拆成一个 kernel,正好配合 defer_epilogue: 发起(issue)与收尾(epilogue)分成两次 launch, 中间那段空白就可以塞进别的计算(资料库索引)。

4.5 combine kernel:环形归约

combine 的正向流程是「把 k 份结果送回原 rank,再加起来」。设备侧 combine_impl(deep_ep/include/deep_ep/impls/ep/combine.hpp:162-...) 的做法是:

  1. 根据 src_metadata 算出每个 token 该发到哪、放到接收环的哪个槽 (load_meta_issue_wqe,:59-151);
  2. 一个 token 只落在一个 rank 上(master)时,直接发原始数据; 落在多个 rank 上时,先复制到 send_buffer 再发(:138-140);
  3. 接收侧用 RingReducer(combine_utils.hpp:14-...)把 k 份 BF16 加载、展宽成 FP32 累加、再转回 BF16(:56-90)。

第 3 步是理解 combine 成本的关键:

  • 累加在 FP32 里做(acc_regs 是 vector_float,:61), 只是最后 asc_float2bfloat16_rn 转回 BF16(:88-89)—— 与第 16 章 §2.5 的「k 路求和的顺序决定结果」是同一件事;
  • 参与归约的数据都先落到 UB 的接收环里,所以 combine 比 dispatch 多了一次「写环 + 读环」的本地流量——这正是 README 说的 「additional local reduction overhead」(资料库索引)。

4.6 同步:barrier 与信号

device 侧的 barrier 是 comm::scalar::barrier(impls/ep/dispatch.hpp:651), 它用 CommonSignals 里的 barrier_counter / barrier_signals (layout/common/signals.hpp:14-16)在 rank 之间对齐时间。 源码注释给了两个数:一次 barrier 在 8 rank 时约 4 µs(:643), 一次 kernel launch + InitSocState() 约 1.9 µs(:573)—— 这两个数字是源码里写的观察,不是本书实测。

还有一条容易忽略的:EP_DISABLE_BARRIER_PROFILING 关掉的是 基准测试用的额外同步,不影响正常调用的 barrier(资料库索引)。


5. 底座:HCCL / HCOMM / UBMEM / URMA 各管什么

README 用一句话交代了底座(资料库索引、:5):

Ascend C kernels use HCCL/HCOMM, UBMEM and URMA.

一句话里有四个名字,它们的分工可以从源码的调用点反推出来。先看图 4。

(源描述见 图:04-comm-stack-layers; SVG 规格 src/tutorial/figures/38-deepep-ascend/04-comm-stack-layers.json。)

5.1 HCCL:进程组、拓扑与内存注册

HCCL 是昇腾的集合通信库(对标 NCCL)。在这个库里它承担四件事 (csrc/kernels/comm/hccl.hpp):

  1. 拿到通信域句柄:按名字取 HcclComm (HcclCommGetHandleWithName,:76);
  2. 注册对称内存:HcclCommMemReg(comm, buffer_uid, &mem, &mem_handle) (:72-77)。注册之后这块内存在对端可见;
  3. 查拓扑:HcclRankGraphGetLayers 拿网络层列表,然后固定用 layers[1](:87-92),注释写明「Use net layer 1 for UBMEM channels and Jetties」。README 的性能表也写着「All measurements use netlayer 1, the supernode's external Clos network」(资料库索引);
  4. 拿对端地址:HcclChannelGetRemoteMems 返回对端注册过的内存, 取最后一段(就是用户 buffer)填进 peer_ptrs (:184-193)。

还有一个重要的硬约束:这个后端要求每个 peer 之间都有直连的 UBMEM 链路;get_physical_domain_size() 会逐个 peer 检查链路协议里 有没有 COMM_PROTOCOL_UB_MEM,没有就断言失败 (csrc/comm/handle.hpp:54-64 的注释与断言: 「Ascend currently requires direct UBMEM connectivity to every peer」)。

5.2 HCOMM:端点、信道与 Jetty(URMA 的队列)

HCOMM 是更底层的通信引擎接口。这个库用它做三件事 (csrc/kernels/comm/hccl.hpp:196-281):

  1. 建端点:HcommEndpointCreate,每个 AIV 一个 (num_jetties == num_vec_cores,:224-229);
  2. 建信道:HcommChannelCreateWithConfig,用来做 rank 间的 连接建立与端口交换;低 rank 当 server、高 rank 当 client (:263),信道名字必须 ≤ HCOMM_CHANNEL_NAME_MAX_LEN (:255);
  3. 拿 Jetty:设备侧的 handle::HcommJetty (deep_ep/include/deep_ep/comm/handle.hpp:167-...)把一条 URMA 队列(SQ/CQ)的地址、深度、门铃地址读进 UB,然后在 kernel 里 自己写 SQE、自己敲门铃。

这就是「Ascend C kernel 直接操作 URMA 队列」的全部含义: 连接建立走 HCOMM 的 host API,数据搬运走 kernel 里的裸 SQE。

5.3 UBMEM 与 URMA:为什么不是纯 RDMA

这两个词要分开讲:

  • UBMEM 是「统一总线内存」——超节点里各 rank 的显存可以被对端 直接按地址访问,所以本例里每对 rank 都是直连(§5.1 的硬约束)。 在这个模型下,rank j 的 buffer 有确定的基地址,要写的地址就是 「本地偏移 + 对端基址」,这正是对称内存能成立的原因。
  • URMA 是昇腾的单边内存访问指令族。kernel 里构造的是 WRITE 和 WRITE_WITH_NOTIFY 两种 opcode (comm/handle.hpp:257-258),也就是「我把数据写到你那儿, 可选地顺手给你一个通知」。没有任何一次 READ 或双边握手在数据路径上。

为什么不是纯 RDMA? README 的 Requirements 里写明了硬件前提 (资料库索引):需要 Ascend 950 (A5) 的 UBMEM 连通性, 以及 rank 之间的 UBC_CTP / URMA 信道。从这一点可以合理推断: 这套实现要的就是超节点内的 UB 内存语义,而不是通用 RDMA—— 它本来就是为超节点内部设计的 (DeepSeek 开源昇腾基础组件(官方发布说明) 第 13 行 的「基于昇腾 950 的 128 卡超节点方案」)。

两个「域」的定义:get_physical_domain_size() 与 get_logical_domain_size() 都返回 {1, num_ranks} (csrc/kernels/comm/api.hpp:82-89)。换成 NVIDIA 的词就是 「rdma_ranks = 1、nvlink_ranks = num_ranks」——整个 EP group 被当成单个 UB 域。所以 EPBuffer.__init__ 里 num_scaleout_ranks = 1(deep_ep/buffers/ep.py:238-241)。

5.4 DualPipe 与 EPLB 在这张图上的位置

这两个仓库不在 DeepEP-Ascend 里,但它们是理解「为什么要做重叠」和 「为什么接口里有个 LB 区」的关键。

DualPipe(资料库索引)是 DeepSeek-V3 技术报告里的 双向流水并行调度:它把前向和反向的计算-通信阶段完全重叠, 顺带压低流水线气泡。它的表格(资料库索引)用 (PP/2-1)(F&B+B-3W) 的公式把气泡写清楚——注意气泡里出现的是 F&B(两个方向互相重叠的 chunk),这是 DualPipe 相对 1F1B 的核心区别。

EPLB(资料库索引)解决另一个问题:不同专家的负载不一样。 它的做法是冗余专家(redundant experts)——把热点专家复制多份, 再启发式地摊到各卡上;分两个策略:分层(先把专家组摊到节点、 再在节点内复制)与全局(无视专家组,全局复制摊牌)。

这两件事最终都落到 DeepEP 的接口上:EPLB 产生的「冗余专家」需要 把权重提前搬到副本所在的卡,反向再把副本的梯度收回原专家—— 对应 EPBuffer.lb_prefetch_weights / lb_reduce_grads (资料库索引)。但这两个能力在昇腾后端还没实现: Python 侧接口已经按 V2.5 对齐,通信 kernel 还没写 (资料库索引;C++ 侧直接 EP_HOST_UNREACHABLE, csrc/buffers/ep.hpp:75-86)。§8 会把它归进「现在别用」的清单。


6. DeepJIT:device kernel 为什么在运行时编译

6.1 一句话定位

README 说(资料库索引):

Installation builds the host extension; device kernels are compiled on first use. Keep the CANN toolkit and Bisheng available at runtime.

也就是:安装时只编 host 侧扩展,device kernel 第一次用到才编。 干这件事的是 DeepJIT(资料库索引): 一个 header-only 的 C++20 JIT 运行时,同时支持 CUDA 与昇腾两个后端。

6.2 调用形态

DeepEP-Ascend 用它的方式是「拼一段源码字符串,交给 JIT 编译」。 以 dispatch 为例(csrc/kernels/ep/dispatch.hpp:51-68):

const auto kernel = jit->compile("dispatch", std::format(R"(
#include <deep_ep/impls/ep/dispatch.hpp>
using namespace deep_ep;
static void __instantiate_kernel() {{
    auto ptr = reinterpret_cast<void*>(&dispatch_impl<{}, {}, {}, ...>);
}}
)", num_ranks, num_experts, num_topk, ...));

jit->launch(kernel, {.num_blocks = num_vec_cores, .num_ubuf_bytes = num_ub_bytes}, ...);

读这段要注意两点:

  1. 模板参数就是 shape 与规模。num_ranks、num_experts、num_topk、 expert_alignment、num_hidden_bytes、num_sf_packs 全都进模板参数—— 它们直接决定 UB 布局的大小(UBLayoutWithBuffer, impls/ep/dispatch.hpp:56-75)。换一个 shape 就等于换一份 kernel 源码。
  2. host 参数与 device 参数分开。num_blocks / num_ubuf_bytes 是启动 配置,其余是 kernel 实参;指针参数里还夹着 jetty_ptrs(§5.2 的队列表)。

refresh runtime 侧的 Device 查询也来自 DeepJIT + ACL: runtime->get_num_aicore_cores() / get_num_vec_cores() / get_num_ubuf_bytes_per_vec_core()(csrc/runtime/jit.hpp:33-59), 这些值直接喂给 UB 布局计算。

6.3 缓存与环境变量

DeepJIT 的缓存是「内存 + 磁盘」两层(资料库索引):

  • 磁盘缓存:默认 $HOME/.dj,可以用冒号分隔的多个根; 按顺序查找,miss 只写第一个根(资料库索引、:71-79)。 多个进程/节点可以共享一个只读查找缓存(:63-69);
  • 共享缓存的三个前提:同一文件系统内可原子 rename、目录 fsync 可用、目录可信(:45-58)。

DeepEP 把前缀设成 EP(csrc/runtime/jit.hpp:77: deep_jit::Config(library_root, "EP", {}, {include_dir}, {"deep_ep/"})), 所以它的变量是 EP_JIT_*,回落顺序是 EP_JIT_* → DJ_JIT_* → 内置默认(资料库索引)。 README 里列了七个(资料库索引),日常最有用的是:

变量 默认 用途
EP_JIT_CACHE_DIR $HOME/.dj 缓存根,或冒号分隔的多个根
EP_JIT_DEBUG 0 打印编译命令、二进制路径、加载耗时
EP_JIT_DUMP_ASM 0 cache miss 时把昇腾汇编存到 asm/
EP_JIT_PRINT_COMPILER_COMMAND 0 打印 Bisheng 与链接器命令
EP_JIT_LAUNCH_TIMEOUT 300 kernel launch 超时秒数,0 关闭

还有一条来自 README Requirements 的硬要求(资料库索引、:37): 运行时必须能拿到 CANN toolkit 与 Bisheng,并且 ASCEND_HOME_PATH(或 ASCEND_TOOLKIT_HOME)要指向当前的 CANN 安装。 DeepJIT 的 Ascend 后端需要的正是 bin/bisheng、bin/ld.lld 与 asc/include/adv_api(资料库索引 的 Ascend 一节)。

6.4 与第 26 章 TileLang 的 JIT / ABI 逐项对照

第 26 章把 TileLang 的 JIT 拆成四段:装饰 → 取 TIR → 缓存查询 → lower/codegen → 加载/调用,出口是一个 packed host function。 把 DeepJIT 摆在同一张表上:

维度 TileLang(第 26 章) DeepJIT(本章)
编译什么 由 TileLang DSL 经 TIR/pass 生成的 kernel 源码 作者手写的 Ascend C 源码字符串
谁决定编译期常量 TileLang 的 JIT 参数 + tensor shape C++ 模板参数,作者自己拼
缓存键 源码 + TIR + pass 配置 + 目标等(kernel_cache.py) 源码 + 被跟踪的 include 树 + 编译器版本 + 生效选项 + 调用方给的依赖签名(DeepJIT/README.md:37-...)
磁盘缓存 <cache>/<version>/<platform>/<key>/ <cache-root>/cache/<tag>.<digest>/(资料库索引 的 Cache-key 一节)
共享缓存 支持,靠 TILELANG_CACHE_DIR 支持多根查找 + 只读共享根(资料库索引)
host 侧 ABI packed function:形状/stride/device 检查、输出分配都在里面(make_packed_api.cc) 没有生成 host wrapper;调用方自己传指针和启动参数
框架适配 adapter 层(torch / cython / tvm_ffi) 用当前 torch_npu stream,其余由调用方负责
语言/后端 一套 DSL,两个后端(第 25 章) 两个后端共享运行时,源码各不相同

一句话总结它们的分工差异:

TileLang 的 JIT 把「从 DSL 到可调用函数」整条链包住, 所以它必须管 ABI、管 adapter、管输出分配; DeepJIT 只管「把一段 C++ 源码变成一次可发起的 launch」, host 侧的参数校验、张量分配、stream 管理都留给调用方。

这不意味着谁更好,而是它们解决的是不同层次的问题: DeepEP 这类库的 host 侧本来就有一大堆依赖 shape 的逻辑 (EPBuffer.dispatch 里几十行断言),再多一层自动 ABI 反而碍事; 而 TileLang 的用户是「写算子的研发」,必须要有个能直接调用的函数。

6.5 对使用者的四个实际影响

  1. 首次调用慢。第一次用到某个 shape 的 dispatch/combine,要现场 调 Bisheng 编一遍(资料库索引)。生产部署要预热, 或者把磁盘缓存挂成共享目录(资料库索引)。
  2. 缓存目录要共享。多节点/多进程场景下,把所有 rank 指向同一个 EP_JIT_CACHE_DIR,才能避免每个 rank 各编一遍。
  3. 环境要完整。ASCEND_HOME_PATH / ASCEND_TOOLKIT_HOME 必须 指对,bin/bisheng 与 bin/ld.lld 必须可执行;容器里缺 CANN 工具链会直接在第一次调用时失败(资料库索引)。
  4. 调参要会看汇编。EP_JIT_DUMP_ASM=1 把昇腾汇编存下来, EP_JIT_PRINT_COMPILER_COMMAND=1 打印 Bisheng 命令—— 这是排查「为什么这次编译慢 / 为什么会 trap」的第一手材料。

7. 性能表怎么读:128 卡超节点的带宽数字

7.1 先把表贴出来,连同它的条件

(资料库索引,逐字抄录)

EP size Dispatch (GB/s) Combine (GB/s)
EP8 373–375 345–347
EP16 348–352 338–341
EP32 335–340 320–324
EP64 323–327 294–298
EP128 313–320 272–278

测量条件(同一个 README 的 Performance 一节,:9-11、:21):

条件 取值
硬件 Ascend 950DT NPU,手工配置的 PoC HDK(非公开版本)
软件栈 CANN 9.2.0
网络 netlayer 1,即超节点的 external Clos 网络
每 rank token 容量 16,384
hidden 7,168
路由 top-6 / 256 experts
核数 32 AI cores / 64 AIVs
expert alignment 128,无 bias
dispatch 精度 FP8 + row-major scale
combine 精度 BF16
迭代 10 warmup + 50 samples,刷 cache
口径 计时含 issue 与 drain,不含最后的 epilogue;带宽范围覆盖所有 rank

这张表是「本书唯一引用的性能数字」。要提醒的是它的两个不确定性:

  • HDK 是 PoC(proof-of-concept)版本,带人工配置,不是公开版本; README 在 Recommended HDK and firmware 一节写明商用 Q3 HDK 计划在 2026-10-15 前后发布,并明确「these measurements are not results from that unreleased commercial version」(:41-47)。所以这张表不能当成 你现在能买到的硬件的成绩。
  • README 也写明「Kernel support on other Ascend generations or CANN versions has not been established by these measurements」(:35)。

7.2 为什么 EP 越大越慢

两条单调下降都能从 §1.3 和源码里找到解释:

  1. 去重率下降、跨设备字节量上升。EP8 时 256/8 = 32 个专家在本 rank, 一个 token 的 6 个专家更容易落回本地;EP128 时只有 2 个本地专家, 几乎每个 top-k 专家都在别的 rank 上。每 rank 的有效接收 token 数上升, 而有线带宽固定,于是单位时间能完成的「逻辑 token 数」下降。 依据:impls/ep/dispatch.hpp:183 的去重、以及正文 §1.3 的 R·(1-(1-1/R)^k)。
  2. 同步与启动的固定开销随 rank 数上升。源码注释里写着 barrier「this costs 4 us (8 ranks)」(impls/ep/dispatch.hpp:643)、 kernel launch 与 SoC 初始化「cost 1.9 us, which is slow」(:573)。 这些开销与 payload 大小无关;EP 越大,它们相对有效 payload 的占比越高, 而分母(时间)里包含了它们。
  3. 上游还没压到底。README 自己也说 「Larger EP sizes and combine remain under optimization」(:21)—— 所以这张表的下降里,有一部分是当前实现的余量,不是物理必然。

7.3 为什么 combine 比 dispatch 慢

同样是 EP8,dispatch 373–375 GB/s,combine 只有 345–347。三条原因:

  1. combine 的 payload 更大。README 写明 dispatch 走 FP8、 combine 走 BF16。同样一个 token,combine 的 hidden 部分是 dispatch 的 2 倍字节(§7.1 的账:42 KB → 84 KB)。
  2. 额外的本地归约开销。README 原话是「combine has additional local reduction overhead」(:21)。设备侧对应 §4.5 的 RingReducer: 数据要先写进 UB 接收环、再读出来做 FP32 累加、再转回 BF16, 这一段不占网络带宽但占流水线时间。
  3. 与 HBM 抢带宽。README 接着说「HBM contention with URMA」(:21)—— URMA 的数据落地要写显存,而本地归约也要读显存, 两者在 HBM 上互相争抢。README 说华为固件改进计划就是针对这条 (「Huawei firmware improvements are also planned to reduce this contention」)。

7.4 「90–95% 的物理 payload 带宽上限」怎么读

README 的那句是(:21):

Sustained dispatch reaches roughly 90–95% of the physical payload bandwidth limit for EP sizes up to 32.

三个词要拆开:

  • physical payload:真正在链路上搬的有效字节。测试代码里这个量是 num_dispatch_urma_bytes = num_recv_tokens × (每 token 的逻辑字节数), 其中「每 token 的逻辑字节数」= count_bytes(x, topk_idx, topk_weights) / num_tokens (tests/ep/test_ep.py:404-406)。注意它不含 padding、不含 barrier。
  • bandwidth limit:这条链路(UBMEM/URMA)能搬的字节率上限。
  • 90–95%:实测 GB/s ÷ 上限。所以这句话等价于 「dispatch 已经把链路吃掉了九成以上」。

本书复算:把表里的数字除以 0.90 与 0.95,可以反推出一个量级:

EP8  : 373–375 ÷ (0.90…0.95) ≈ 393–417 GB/s
EP16 : 348–352 ÷ (0.90…0.95) ≈ 366–391 GB/s
EP32 : 335–340 ÷ (0.90…0.95) ≈ 353–378 GB/s

也就是量级在 350–420 GB/s/rank 之间。注意三件事:

  1. 这是一个区间估计,不是实测;README 自己是「roughly」的说法;
  2. 它随 EP 规模略降,符合 §7.2 的解释(规模越大、固定开销占比越高);
  3. corpus 里没有 Ascend 950 UBMEM 链路的官方带宽规格, 所以这个反推不能当成硬件手册上的数字。它只用来回答一个定性问题: 瓶颈现在在链路上,不在算法上——这也是为什么 README 说 「Larger EP sizes and combine remain under optimization」。

code/04-bandwidth-normalize/ 会把这张表连同口径一起复算成 「相对上限」的百分比,并明确标注假设。

(源描述见 图:05-bandwidth-vs-ep; SVG 规格 src/tutorial/figures/38-deepep-ascend/05-bandwidth-vs-ep.json。 数据源只有 资料库索引 一张表。)


8. 状态的诚实标注:现在能用什么,别用什么

这一节按「能用 / 实验性 / 不支持」三档把整个库排一遍。判据全部来自 README 的 Features and API compatibility(:60-74)与 Ongoing(:76-84), 以及源码里的断言。图 6 是速查版。

(源描述见 图:06-capability-status; SVG 规格 src/tutorial/figures/38-deepep-ascend/06-capability-status.json。)

8.1 现在可以用(EP 主线)

能力 状态 依据
EPBuffer 的 BF16 / FP8 dispatch 可用,展开布局(do_expand=True) 资料库索引、:72
combine(BF16 输入,FP32 累加) 可用 资料库索引;impls/ep/combine_utils.hpp:56-90
cached handle(复用布局) 可用;do_handle_copy 必须 False 资料库索引、:72
确定性布局(deterministic=True) 可用,代价是一次排序 deep_ep/buffers/ep.py:100-160
expert padding / do_zero_padding 可用 资料库索引
延迟 epilogue(defer_epilogue) 可用,必须配 async_with_compute_stream csrc/buffers/ep.hpp:398
训练与推理共用同一接口 可用(prefill / decode 用 do_cpu_sync 区分) 资料库索引、:120
专家接收计数统计(cumulative_local_expert_recv_stats) 可用,可做在线负载监控 deep_ep/buffers/ep.py:351-352

一句话:如果你要做的是「标准的 MoE 专家并行 dispatch/combine」, 昇腾后端这条路已经通了。资料库索引 给了两条可直接复现的 8 卡命令(bf16 与 fp8 各一条)。

8.2 实验性(接口在,验证未完成)

能力 状态 依据
PPBuffer(相邻流水 rank 的 send/recv) experimental,正确性与性能验证进行中 资料库索引、:82
EngramBuffer(NPU 侧 BF16/FP8 表 + 每层 fetch hook) experimental 资料库索引、:82
BucketBuffer 的 batched all-gather 可用但归入 experimental;用前必须设 EP_AVOID_RECORD_STREAM=1 资料库索引、:80、:234
BufferAllocator / BufferBase / deep_ep.comm 可用(分配规划、生命周期、通信域查询、barrier、stream) 资料库索引

Bucket 还有一组容易踩的约束(资料库索引):所有 rank 的 shard 必须等长; bucket 必须足够大,session 不会自动扩容;离开 session 时输出必须属于 bucket; 批量最多 64 个 tensor;多 group 时用 group= 选;session 退出不加 barrier。

8.3 不支持(现在别用)

能力 状态 依据
Bucket 的 reduce-scatter / all-reduce 昇腾 kernel 仍在做 资料库索引
lb_prefetch_weights / lb_reduce_grads 接口已对齐,昇腾通信 kernel 未实现 资料库索引;csrc/buffers/ep.hpp:79、:85(EP_HOST_UNREACHABLE)
混合通信(hybrid) 不支持 资料库索引;deep_ep/buffers/ep.py:194-196
CPU-backed Engram 存储 不支持(NVIDIA 侧支持) 资料库索引
graph capture(把 EP 入图) 不支持 资料库索引
显式 RDMA 服务等级 / QP 数 不支持(sl_idx=None、QP 用默认) 资料库索引
do_handle_copy=True 不支持 资料库索引
非展开布局(low-latency 模式) 不支持(do_expand 必须 True) csrc/buffers/ep.hpp:116
奇数 rank 数 多卡通信要求 rank 数为偶数 资料库索引;csrc/kernels/comm/hccl.hpp:85

8.4 这些状态对读者的实际影响

把上面对号入座,能得出四条可执行的结论:

  1. 训练侧的重叠只能靠 DualPipe + defer_epilogue 这一层。 想要「像 NVIDIA 那样把 EP 入 graph capture 降下发开销」—— 现在做不到(资料库索引)。decode 的小 batch 场景要特别小心 每次 launch 的固定开销。
  2. 动态冗余专家(EPLB)暂时不能端到端用。EPLB 本身有算法、 有 rebalance_experts 接口(资料库索引),但把权重搬来搬去的 lb_prefetch_weights 在昇腾上还没实现。所以「在线负载均衡」这条路 目前只有规划、没有搬运。
  3. Bucket 只能 all-gather。CP/DP 用 all-gather 没问题; 要用 reduce-scatter / all-reduce 就得先自己在 all-gather 上叠。
  4. hybrid 模式没有。NVIDIA 侧那套「scale-up + scale-out 混跑」 在昇腾上退化成单域(§5.3 的 {1, num_ranks}), 所以容量规划只能按「一个 UB 域」来。

9. TileLang 这条线:通信库不用它,它服务的两端用

9.1 分工线在哪里

回到 §1.1 的那句话:DeepEP-Ascend 的自我定位里没有 TileLang。 这不是遗漏——deep_ep/ 下一行 TileLang 也没有(grep 无命中), kernel 全部是 Ascend C(__global__ __vector__ __aicore__, #include <kernel_operator.h>、c_api/*、simt_api/*, 见 impls/ep/dispatch.hpp:1-20)。

但它服务的两端是 TileLang:

          TileKernels(TileLang)                DeepEP-Ascend(Ascend C)
   ┌────────────────────────────┐        ┌────────────────────────────┐
   │ moe_topk_gate / topk_gate  │───────▶│  EPBuffer.dispatch         │
   │ per_token_cast(量化到 FP8)│───────▶│  EPBuffer.combine          │
   └────────────────────────────┘        └────────────────────────────┘
             产生 topk_idx                    消费 topk_idx
             产生 fp8 x + sf                  消费 fp8 x + sf
  • 进 dispatch 的 topk_idx 由 tile_kernels.moe.topk_gate / moe_topk_gate_forward 产出(TileKernels/tile_kernels/moe/topk_gate_kernel.py:7-43, 实现分别在 topk_gate_cuda.py 与 topk_gate_asc.py)。输出的 [num_tokens, num_topk] int64 正是 EPBuffer.dispatch 要的类型。
  • 进 dispatch 的 FP8 数据与 scale 由 tile_kernels.quant.per_token_cast_impl 产出(TileKernels/tile_kernels/quant/per_token_cast_kernel.py:9-60), 它同时有 CUDA 与 Ascend 两个 TileLang 实现。

为什么通信库不用 TileLang? 因为 dispatch/combine 的核心不是 「在 AIV 上算」,而是「直接写 URMA 队列、操作 Jetty、按字节拼 SQE」 (§5.2、§4.3)。这类代码需要的是「能拿到队列地址、能逐 lane 组装 64 位字、 能在 SIMT 与 SIMD 之间互操作」——这是 Ascend C 的领域,不是 「tile 级数据流」的领域。第 35 章的结论在这里有一个镜像的表述: 当瓶颈在通信协议、不在算术上时,TileLang 的抽象层次帮不上忙。

9.2 与 MegaMoE 的两种做法

第 35 章的 MegaMoE 走了相反的方向:它把 dispatch、两个 grouped GEMM、 SwiGLU、combine 全部融进一个 kernel (DeepGEMM-Ascend/deep_gemm/include/deep_gemm/layout/mega_moe.hpp:26-33 的四个进度阶段 kDispatched → kLinear1Ready → kLinear2Ready → kCombineSent), 中间结果不再往返 HBM,通信与计算在同一批核上交错。

两种做法摆在一起:

维度 DeepEP-Ascend(独立通信库) MegaMoE(融合 kernel)
谁写通信 DeepEP 的 Ascend C kernel DeepGEMM-Ascend 的 Ascend C kernel
谁写 GEMM 外部库(如 DeepGEMM-Ascend) 同一个 kernel
中间结果 展开布局的 token,落在 HBM 不落 HBM,留在片上/对称缓冲
通信与 GEMM 的关系 分两次 launch,靠「先发起后等待」重叠 同一批核内按进度阶段交错
可复用性 高:任意 MoE 结构都能接 低:形状/专家数/核数都写死
约束 需要对称缓冲区、rank 数偶数 核数固定 32、形状提前注册、每次 launch 清 progress 区

适用场景的判断很简单:

  • 算法还在动、要换模型结构、要复用到不同层 → 用 DeepEP-Ascend。 它把「通信」做成一个可组合的库,代价是中间结果过一遍 HBM。
  • 形状已经冻结、要把通信开销压到最低 → 走 MegaMoE 那条路。 代价是灵活性:第 35 章 §4.5 记着 DJ_HOST_ASSERT(runtime->get_num_sms() == kNumAICores), 核数写成 32。

9.3 与 DualPipe 的关系(再补一句)

§5.4 提到 DualPipe 做的是训练侧的流水线级重叠。把它和本章的 defer_epilogue 区分开:

层次 重叠的是什么 谁负责
kernel 内 dispatch 的 issue 与本地拷贝;combine 的发送与归约 DeepEP-Ascend 自己
调用级 「这次 dispatch」与「调用方后续的独立计算」 使用者的代码(defer_epilogue)
流水线级 前向 chunk 与反向 chunk 的计算-通信 DualPipe 的调度

三者可以叠加:DualPipe 提供一个调度骨架,骨架里的每个 MoE 层 用 defer_epilogue 把本层的通信藏起来,而 DeepEP 的 kernel 内部 再做一层细粒度重叠。本章只证明第一、二层能用;第三层需要真实的 多机环境,留给第 39 章。

9.4 一句话总结这条主线

TileLang 负责「算」,DeepEP-Ascend 负责「搬」。 两者的接口是三个张量:topk_idx(int64)、topk_weights(float32)、 x(bf16 或 fp8+sf)。只要这三个张量的类型与布局对得上, 一边是 TileLang、一边是 Ascend C,也能拼成一条流水线。


本章小结

一页纸复习:

  1. 通信量从哪来:MoE 的 dispatch 把 token 送到专家所在卡、combine 收回, 跨越 rank 时是 all-to-all。用第 16 章的账本,每 token 每方向 k·d·b 字节; 在 16384 tokens/rank、hidden 7168、top-6 的规模下,一层就是 GB 级。 EP 越大,token 被拆到越多 rank 上、本地专家越少,通信占比越高。
  2. 接口:EPBuffer(group, num_max_tokens_per_rank, hidden, num_topk, use_fp8_dispatch, explicitly_destroy, ...);用 get_buffer_size_hint() 规划容量(对齐 2 MB);dispatch / combine 互为反向; handle 缓存路由、按引用持有 topk_idx。
  3. 精度:dispatch 用 FP8(hidden 只占 1 字节)+ row-major scale, combine 必须是 BF16(要精确归约)。scale 的粒度与布局影响数值, 与第 27 章的 block scaling 是同一套语言。
  4. 重叠:defer_epilogue=True + async_with_compute_stream=True 把「发起」与「收尾」拆开,中间塞独立计算;训练侧的整体重叠交给 DualPipe。
  5. 底座:HCCL 管通信域、拓扑与内存注册(固定用 netlayer 1); HCOMM 管端点、信道与 Jetty;UBMEM 提供对称内存地址; kernel 直接写 URMA 的 WRITE / WRITE_WITH_NOTIFY。 整个 EP group 是一个 UB 域({1, num_ranks})。
  6. DeepJIT:device kernel 首次使用时才编;缓存可共享; 调用形态是「拼一段 C++ 源码 → compile → launch」。 与 TileLang 的 JIT 相比,DeepJIT 不管 host ABI 与 adapter, 只管「把源码变成一次 launch」。
  7. 性能表:EP8–EP128,dispatch 373→313 GB/s、combine 345→272 GB/s; 条件是 Ascend 950DT + CANN 9.2.0 + PoC HDK + netlayer 1; dispatch 在 EP≤32 时达到链路物理上限的 90–95%。 combine 更慢是因为 payload 翻倍、多一次本地归约、且与 HBM 抢带宽。
  8. 现状:EP 主线可用;Bucket 只有 all-gather;PP / Engram 实验性; reduce-scatter / all-reduce、LB 通信、hybrid、CPU Engram、 graph capture 都不支持。选型前先过一遍 §8 的三张表。
  9. TileLang 主线:通信库自己不用 TileLang(它要直接操作 URMA 队列), 但它消费的 topk_idx 与 fp8 x + sf 来自 TileLang 算子; MegaMoE 走的是把通信融进 GEMM kernel 的另一条路—— 一条可组合、一条极致融合。

延伸阅读

  • [R1] DeepEP-Ascend 自述:资料库索引 —— 定位、Requirements、已验证组合、HDK 说明、性能表、接口兼容表、 Ongoing 清单、三段 EP 用法代码、环境变量。
  • [R2] Python 接口与 handle:DeepEP-Ascend/deep_ep/buffers/ep.py —— EPHandle 的字段、EPBuffer 的构造与断言、dispatch / combine 的参数与文档字符串、deterministic_sort、LB 两个入口。
  • [R3] host 侧 launch 与 buffer 尺寸:DeepEP-Ascend/csrc/buffers/ep.hpp 与 csrc/kernels/ep/{dispatch,combine}.hpp —— epilogue 的拆分、容量公式、模板参数是怎么拼出来的。
  • [R4] device 侧 kernel:deep_ep/include/deep_ep/impls/ep/dispatch.hpp、 .../combine.hpp、.../combine_utils.hpp、.../dispatch_copy_epilogue.hpp —— §4 的全部依据。
  • [R5] URMA 队列与 SQE 模板:deep_ep/include/deep_ep/comm/handle.hpp —— HcommJetty、HcommPeerInfo、WRITE / WRITE_WITH_NOTIFY 的字段布局。
  • [R6] 通信栈:DeepEP-Ascend/csrc/kernels/comm/hccl.hpp、 csrc/kernels/comm/symmetric.hpp、csrc/comm/api.hpp —— §5 的全部依据。
  • [R7] JIT 运行时:资料库索引(缓存、共享缓存、Ascend 后端) 与 DeepEP-Ascend/csrc/runtime/jit.hpp(Config 与设备查询)。
  • [R8] 测试与口径:DeepEP-Ascend/tests/ep/test_ep.py —— 测量循环、count_bytes 的口径、bench_msprof 的 warmup/sample/flush。
  • [R9] NVIDIA 对照物:资料库索引 —— V2.5 的接口约定、cached handle、deferred epilogue、 expert load balancing 的完整语义(昇腾缺失的部分在这里可以看到原貌)。
  • [R10] 配套调度与均衡:资料库索引、资料库索引。
  • [R11] TileLang 侧两端:TileKernels/tile_kernels/moe/topk_gate_kernel.py 与 tile_kernels/quant/per_token_cast_kernel.py。
  • [R12] 前置章节:第 16 章(MoE 四段结构与字节账)、 第 26 章(TileLang 的 JIT 与 ABI)、第 27 章(低精度与 block scaling)、 第 35 章(MegaMoE 融合)。

完整的引用清单(含核验状态与上游链接)见 本章引用; 每个代码示例的运行前提与验证级别见 资料库索引。

本章图示

这些图没有在源文件里标注所属小节,集中列在这里,可在读完正文后回看。

DeepEP-Ascend 的通信栈分层Python 接口EPBuffer.dispatch / combine调用方device kernel(Ascend C)dispatch_impl / combine_impl,自己写 SQE、敲门铃DeepEP-AscendURMA单边内存访问:WRITE / WRITE_WITH_NOTIFY数据路径UBMEM统一总线内存,对端显存可按地址访问硬件HCOMMEndpoint / Channel / Jetty(SQ / CQ)host 侧HCCL通信域、rank graph(netlayer 1)、内存注册、对端地址host 侧
host 侧用 HCCL 建通信域与注册内存、用 HCOMM 建端点与信道;device kernel 直接写 URMA 队列;UBMEM 提供对称内存地址。
  • 整个 EP group 是一个 UB 域:物理域与逻辑域都是 {1, num_ranks}。
  • 要求每对 peer 之间都有直连 UBMEM 链路(否则构造时断言失败)。
  • HCCL / HCOMM 的头文件由 CANN 提供,不在 corpus 快照里;本图只描述分工。
带宽随 EP 规模变化EP8 dispatch375 GB/sEP8 combine347 GB/sEP16 dispatch352 GB/sEP16 combine341 GB/sEP32 dispatch340 GB/sEP32 combine324 GB/sEP64 dispatch327 GB/sEP64 combine298 GB/sEP128 dispatch320 GB/sEP128 combine278 GB/s
数据照抄 DeepEP-Ascend README 的性能表(每条取区间上限):dispatch 从 EP8 的 375 GB/s 降到 EP128 的 320 GB/s,combine 从 347 降到 278。
  • 条件:Ascend 950DT + CANN 9.2.0 + PoC HDK,netlayer 1;16384 tokens/rank、hidden 7168、top-6/256 experts、expert alignment 128;dispatch FP8 + row-major scale,combine BF16。
  • 柱值取区间上限,仅用于看趋势;精确区间见 README。
  • EP≤32 时 dispatch 达到物理 payload 带宽上限的 90–95%。
能力状态清单能力状态依据EP dispatch(BF16 / FP8)可用README:66,72EP combine(BF16,FP32 累加)可用README:66cached handle / deterministic / padding可用README:66延迟 epilogue可用(需 async)ep.hpp:398Bucket batched all-gather可用(实验性)README:69,80PPBuffer / EngramBuffer实验性README:67,68,82Bucket reduce-scatter / all-reduce未实现README:80lb_prefetch_weights / lb_reduce_grads未实现README:81混合通信 hybrid不支持README:84CPU-backed Engram 存储不支持README:84graph capture(EP 入图)不支持README:84显式 RDMA 服务等级 / QP 数不支持README:74奇数 rank 数不支持(须偶数)README:29
只有 EP 主线(dispatch/combine 与其三个可选特性)是可用状态;其余按实验性、未实现、不支持三档如实标注。
  • 判据全部来自 DeepEP-Ascend README 的 Features 表与 Ongoing 清单,以及源码断言。
  • 「未实现」指接口已在、通信 kernel 还没写;「不支持」指这条路当前走不通。
  • 选型前先过一遍这张表。

习题

14 题 · 答案在提交后展开

先在纸上或输入框里写下你的答案,再展开参考答案对照。客观判断请自己打勾:这三颗按钮 只影响本机保存的学习进度。

E1综合难度 ★无需硬件通信量从哪来

设一层 MoE:T = 8192 tokens/rank、hidden = 7168、top-6、num_experts = 256、 EP = 8。token 数据用 BF16。

  1. 按第 16 章的账本(跨卡时每 token 每方向 k·d·b 字节),写出 dispatch 与 combine 各自的上界字节数(假设 k 个专家都落在不同 rank)。
  2. 若改用 FP8 dispatch、combine 仍为 BF16,dispatch 的上界变成多少?
  3. 用一句话说明:为什么这个数字「不随 GEMM 调优而变小」。
E2综合难度 ★无需硬件三个参数各自在防什么坑

分别解释 EPBuffer 的这三个参数各自防的是哪一类错误:

  1. num_max_tokens_per_rank 为什么要求所有 rank 一致?
  2. expert_alignment 为什么必须和后面 grouped GEMM 的 M 对齐一致?
  3. do_zero_padding 在 do_cpu_sync=False 时不能保证什么?
E3综合难度 ★★无需硬件读 `get_buffer_size_hint`

读 csrc/buffers/ep.hpp:581-605 与 deep_ep/include/deep_ep/layout/ep/token.hpp:9-83, 回答:

  1. get_buffer_size_hint 在 dispatch 布局与 combine 布局之间取什么值?
  2. 它用什么常量做最终对齐?这个常量是多少字节?
  3. SF 的 pack 数在 hint 里是 ceil_div(hidden, 32),而实际运行时是 ceil_div(hidden, 64)。这会造成什么偏差?会给出偏小还是偏大的容量?
E4综合难度 ★★无需硬件FP8 dispatch 的 payload

测试里造 FP8 输入的方式是:

x_data, sf = per_token_cast_to_fp8(x)      # sf 形状 (num_tokens, hidden/128),fp32
sf = sf.view(torch.int16)                  # 形状 (num_tokens, hidden/64),int16

(DeepEP-Ascend/tests/ep/test_ep.py:89-99)

  1. hidden = 7168 时,num_sf_packs 是多少?
  2. 一个 token 的 SF 占多少字节?
  3. .view(torch.int16) 在这里是「数值转换」还是「字节重解释」?请给出一条 能证明你答案的判据(提示:对比字节总数)。
E5综合难度 ★★★无需硬件为什么 EP 越大越慢

README 的表格里 dispatch 从 EP8 的 373–375 GB/s 降到 EP128 的 313–320 GB/s。 给出至少三条理由,并说明每一条能在源码或 README 里指到哪里。

E6综合难度 ★★★无需硬件为什么 combine 比 dispatch 慢

同一个 EP8:dispatch 373–375 GB/s,combine 345–347 GB/s。给出三条原因, 并说明其中哪一条是本地开销、哪一条与 HBM 有关。

E7综合难度 ★★无需硬件读 dispatch 的三段

读 deep_ep/include/deep_ep/impls/ep/dispatch.hpp:86-549、:327-429、:806-865, 回答:

  1. 第一段(SIMT)里,「去重」是针对什么去重?用什么原语做的?
  2. 第二段写 SQE 时,一个 token 的数据和它的元数据分别落在哪里? 这带来什么好处?
  3. 第三段里,落在本 rank 的 token 走什么路径?它占 URMA 带宽吗?
E8综合难度 ★★无需硬件defer_epilogue 的两个硬约束
  1. defer_epilogue=True 时必须同时打开哪个参数?在源码哪一行断言?
  2. 如果通信之前已经排了别的 kernel,为什么还要用 buffer.capture()?
  3. 在什么情况下「延迟 epilogue」反而比不延迟更慢?
E9综合难度 ★★无需硬件核心数与 handle 的关系
  1. num_sms 在昇腾上到底选的是什么?
  2. cached dispatch 与 combine 时,传 num_sms=8 但 handle 里存的是 32, 实际会用几个核?源码依据在哪?
  3. num_sms=0 在昇腾上会返回什么?
E10综合难度 ★★★无需硬件画通信栈

不看 §5 的图,自己画一张 HCCL / HCOMM / UBMEM / URMA 的分层图,并回答:

  1. 「连接建立」走哪一层?「数据搬运」走哪一层?
  2. 为什么 get_physical_domain_size() 返回 {1, num_ranks} 而不是 {num_ranks, 1} 或别的组合?
  3. 这个库对「每对 peer 之间必须有 UBMEM 直连」的检查写在哪?断言什么?
E11综合难度 ★★★无需硬件DeepJIT 与 TileLang JIT 的职责

把第 26 章 TileLang 的 JIT 与本章的 DeepJIT 逐项对照,至少填写下面四格:

维度 TileLang DeepJIT
编译的输入是什么 ? ?
缓存键里包含什么 ? ?
host 侧 ABI 谁生成 ? ?
使用者要自己管什么 ? ?

然后用一句话总结:为什么 DeepEP 这类库不需要 TileLang 那套 host ABI?

E12综合难度 ★★无需硬件选型题

下面四个需求,各自在 DeepEP-Ascend 上现在能不能做?不能的话给出替代方案。

  1. 用 Bucket 做 CP 的 all-reduce。
  2. 在线检测到热点专家后,动态把权重搬到冗余副本上,并在反向把梯度收回。
  3. 把一次次 decode step 的 EP 通信 capture 进图,减少下发开销。
  4. 用 7 张卡跑 EP(num_ranks = 7)。
E13综合难度 ★★★无需硬件复算「90–95%」的含义
  1. 按测试代码的口径,性能表里的 GB/s 是哪些字节除以时间? (它包含 padding 吗?包含 barrier 吗?)
  2. 把 EP8、EP16、EP32 的 dispatch 数字分别除以 0.90 与 0.95, 得到三个区间的并集,写出这个反推出来的「物理 payload 带宽上限」量级。
  3. 为什么这个反推不能当成硬件规格?请说出两条理由。
E14综合难度 ★★无需硬件TileLang 主线
  1. 进 EPBuffer.dispatch 的三个张量(topk_idx、topk_weights、x) 分别由哪个 TileLang 算子产出?给出文件名。
  2. 为什么 DeepEP-Ascend 自己不用 TileLang?(提示:看 §4.3 与 §5.2 里它 到底在操作什么。)
  3. MegaMoE 走了另一条路:它把什么融进了一个 kernel?两条路各自的 适用场景是什么?

章节自测

8 题 · 提交后本地评分

单选题。提交后立即给出分数与逐题解析;重做会清空选择,但会保留上一次的成绩记录。

  1. 1. 为什么说 MoE 的 dispatch / combine 是专家并行(EP)的核心成本?

  2. 2. 关于 `expert_alignment` 与 `do_zero_padding`,下面哪个说法正确?

  3. 3. FP8 dispatch 里,一个 token 的 hidden = 7168 时,scale 部分的字节数是多少、怎么来的?

  4. 4. 使用 `defer_epilogue=True` 时,下面哪条是必须满足的?

  5. 5. 在 DeepEP-Ascend 的通信栈里,「连接建立」与「数据搬运」分别走哪一层?

  6. 6. 与第 26 章 TileLang 的 JIT 相比,DeepJIT 在职责上的区别是什么?

  7. 7. 下面哪一项在当前的 DeepEP-Ascend 昇腾后端是「接口存在但功能未实现」?

  8. 8. README 说 EP≤32 时可持续 dispatch 达到「物理 payload 带宽上限的 90–95%」。关于这句话,哪个理解正确?

本章代码

9 个文件

正文里提到的每个示例都能在这里直接阅读(带语法高亮),也可以整章浏览。

浏览本章全部代码 →

本章引用

源码链接指向锁定 tag

本章写到的每个源码位置与外部资料都在下面列出:源码链接指向锁定 tag 的 GitHub 文件, 资料链接指向原文。查看全部资料索引 →

展开引用清单

第 38 章 参考资料

本章所有结论都基于下表的本地资料或锁定版本源码。版本以 版本锁定 为准:TileLang v0.1.15 (commit a35f8ddf45eba16c21211ec8822d56ce5363036f)、TileLang-Ascend TileLang-ascend-v0.1.2.000-release(commit 89a9364e47a341fffdd65ed94786b1d4f9b6a78c)。

第五部分引用的 DeepSeek 仓库不是 _vendor/ 里的锁定源码,而是 corpus 里 2026-10-01 抓取的快照;每个仓库的 upstream 与 commit 记在 资料库索引。 DeepEP-Ascend 的快照 commit 是 3b25377d04b2(2026-09-29,Initial public release), NVIDIA 侧 DeepEP 是 93eb6eb23812(2026-09-30)。凡引用这些仓库的文件, 行号都是那次快照里的行号;仓库一更新,行号就可能漂移。

写作环境:macOS (Apple Silicon)、Python 3.11;没有 NVIDIA GPU,也没有昇腾 NPU。 因此本章(以及整个第五部分)的示例最高验证级别是「仅静态核对」或 「CPU 逻辑验证」,没有任何一条性能数字来自本机实测。


一手材料:DeepEP-Ascend

id 引用位置 用途 核验方式与状态
R1 资料库索引 本章主依据:定位(:3、:5)、性能表与测量条件(:9-21)、Requirements(:27-35)、安装(:37-63)、Features 与 API 兼容(:60-74)、Ongoing(:76-84)、EP 用法与代码样例(:86-196)、Bucket / PP / Engram(:198-226)、环境变量(:228-248)、复现命令(:250-281) 已核验(2026-10-01):全文 300 行逐行读
R2 .../DeepEP-Ascend/deep_ep/buffers/ep.py EPHandle 字段与 topk_idx 版本检查(:21-98)、deterministic_sort(:100-160)、EPBuffer 构造与断言(:175-249)、get_buffer_size_hint(:251-275)、dispatch(:314-508)、combine(:521-592)、LB 两个入口(:594-649) 已核验(2026-10-01):逐行读
R3 .../DeepEP-Ascend/csrc/buffers/ep.hpp 对称内存注册与 host workspace(:36-98)、LB 未实现(:75-86)、dispatch 的 host 逻辑与 epilogue 拆分(:88-409)、combine(:411-558)、容量公式(:560-605) 已核验(2026-10-01):逐行读
R4 .../DeepEP-Ascend/deep_ep/include/deep_ep/impls/ep/dispatch.hpp device 侧 dispatch:UB 布局(:26-75)、SIMT 路由与去重(:86-549)、SQE 写入(:327-429)、本地拷贝(:806-865) 已核验(2026-10-01):逐行读
R5 .../DeepEP-Ascend/deep_ep/include/deep_ep/impls/ep/combine.hpp 与 combine_utils.hpp combine 的设备侧实现:任务与 UB 布局(:20-55)、metadata/WQE(:59-151)、主流程(:162-...);FP32 环形归约(combine_utils.hpp:14-90) 已核验(2026-10-01):逐行读
R6 .../DeepEP-Ascend/deep_ep/include/deep_ep/impls/ep/dispatch_copy_epilogue.hpp、combine_reduce_epilogue.hpp 两个 epilogue kernel(被 defer_epilogue 单独 launch) 已核验(2026-10-01):读函数入口与 host 调用点
R7 .../DeepEP-Ascend/deep_ep/include/deep_ep/layout/ep/token.hpp TokenLayout 与 BufferLayout(:9-130)——§4.2、§3.2 的依据 已核验(2026-10-01):逐行读
R8 .../DeepEP-Ascend/deep_ep/include/deep_ep/layout/ep/workspace.hpp、.../layout/common/signals.hpp EPSignals 的字段与 peer_ptrs / get_sym_ptr——§4.1、§4.6 的依据 已核验(2026-10-01):逐行读
R9 .../DeepEP-Ascend/csrc/kernels/ep/dispatch.hpp、combine.hpp host 侧 launch:拼模板参数、jit->compile、jit->launch、num_blocks / num_ubuf_bytes 已核验(2026-10-01):逐行读
R10 .../DeepEP-Ascend/csrc/kernels/comm/hccl.hpp HCCL 通信域、netlayer 1、UBMEM 信道、Jetty、内存注册与对端地址(:48-98、:124-299) 已核验(2026-10-01):逐行读
R11 .../DeepEP-Ascend/csrc/kernels/comm/symmetric.hpp、csrc/comm/api.hpp、csrc/comm/handle.hpp、csrc/comm/barrier.hpp 对称内存(aclrtMalloc)、Context(workspace / buffer 切分、物理/逻辑域)、通信域 API、barrier 已核验(2026-10-01):逐行读
R12 .../DeepEP-Ascend/deep_ep/include/deep_ep/comm/handle.hpp HcommJettyInfo / HcommPeerInfo(:34-131)、HcommJetty 与 SQE 模板(:167-454)、WRITE / WRITE_WITH_NOTIFY(:257-258、:378-395) 已核验(2026-10-01):逐行读
R13 .../DeepEP-Ascend/deep_ep/include/deep_ep/common/compiled.hpp、common/reduction.hpp 常量(kNumUbAlignmentBytes=32、kNumAllocationAlignmentBytes=2 MB、kNumMaxRanks=256、kNumMaxExperts=2048、kNumMaxVecCores=64、sf_pack_t=int16)与归约状态编码 已核验(2026-10-01):逐行读
R14 .../DeepEP-Ascend/csrc/runtime/jit.hpp DeepJIT 的 Config(库根、前缀 "EP"、include 目录与前缀)、设备查询(AI core / vec core / UB 大小)、set_npu_arch / set_barrier_in_prologue 已核验(2026-10-01):逐行读
R15 .../DeepEP-Ascend/tests/ep/test_ep.py 用法与口径:模式枚举(:25-30)、FP8 造数(:89-100)、cached dispatch(:330-388)、性能口径 count_bytes 与 bench_msprof(:390-504)、命令行默认值(:553-590) 已核验(2026-10-01):逐行读
R16 .../DeepEP-Ascend/deep_ep/utils/math.py per_token_cast_to_fp8(:30-38,128 元素一个 fp32 scale)、count_bytes(:57-64)——payload 口径的依据 已核验(2026-10-01):逐行读
R17 .../DeepEP-Ascend/deep_ep/utils/testing.py bench_msprof(:297-...):warmup / samples / flush_l2 / barrier profiling;KernelProfile.gbps(:125-126) 已核验(2026-10-01):读函数与参数
R18 .../DeepEP-Ascend/deep_ep/utils/envs.py、deep_ep/__init__.py init_dist(HCCL backend、WORLD_SIZE/RANK 语义)、init_jit 在 import 时执行 已核验(2026-10-01):逐行读

配套仓库(NVIDIA 对照、调度、均衡、JIT、算子)

id 引用位置 用途 核验状态
D1 资料库索引 NVIDIA 侧对照:V2.5 拆分(:11-20)、接口与例子(:103-289)、deferred epilogues(:283-289)、expert load balancing 完整语义(:330-380) 已核验(2026-10-01):逐行读到 :400
D2 资料库索引 JIT 运行时:双后端、缓存与共享缓存(:39-79)、调用示例(CUDA / Ascend)、环境变量表、Ascend 后端要求(bin/bisheng、bin/ld.lld、adv_api)、缓存键组成 已核验(2026-10-01):逐节读
D3 资料库索引 双向流水并行:full overlap 的定位(:3)、气泡对比表(:26-31) 已核验(2026-10-01):逐行读(69 行)
D4 资料库索引 专家负载均衡:冗余专家策略(:5-8)、两种策略(:19-31)、rebalance_experts 接口与例子(:33-57) 已核验(2026-10-01):逐行读(67 行)
D5 .../TileKernels/tile_kernels/moe/topk_gate_kernel.py、topk_gate_cuda.py、topk_gate_asc.py TileLang 侧产出 topk_idx:入口与 dispatch 的类型约定;CUDA/Ascend 两份实现 已核验(2026-10-01):读入口与 CUDA 实现
D6 .../TileKernels/tile_kernels/moe/moe_topk_gate_forward_kernel.py TileLang 侧 MoE 路由:topk_idx / topk_weights 的产出与形状约定 已核验(2026-10-01):读签名与注释
D7 .../TileKernels/tile_kernels/quant/per_token_cast_kernel.py TileLang 侧 per-token 量化:CUDA / Ascend 双实现,产出入 dispatch 的 fp8 + sf 已核验(2026-10-01):读入口与断言
D8 .../DeepGEMM-Ascend/deep_gemm/include/deep_gemm/layout/mega_moe.hpp:26-33 MegaMoE 的四个进度阶段(§9.2 的对照) 已核验(2026-10-01):读枚举;另见第 35 章
D9 .../deepseek-article-20260930.md:9-13 官方发布文章:组件定位(:12)、「昇腾 950 的 128 卡超节点」(:13) 已核验(2026-10-01):逐字读
D10 资料库索引 13 个仓库的 upstream、commit、日期 已核验(2026-10-01):逐行读

TileLang 侧对照(锁定源码 _vendor/)

本章只在 §6.4 做「TileLang JIT / ABI vs DeepJIT」的对照,以及 §9 说明分工线, 因此只需要第 26 章的结论,不新增源码引用。需要复核时回到:

这三处的逐行分析见第 26 章,本章不重复。


关于性能数字的说明

本章不提供任何本机实测数字。 本章出现的性能相关数字只有三类:

数字 来源 条件
EP8–EP128 的 dispatch / combine GB/s R1(资料库索引) Ascend 950DT + CANN 9.2.0 + 手工配置的 PoC HDK;netlayer 1;16384 tokens/rank、hidden 7168、top-6/256 experts、32 AI cores / 64 AIVs、expert alignment 128;dispatch FP8 + row-major scale,combine BF16;10 warmup + 50 samples,刷 cache;作者未复现
「物理 payload 带宽上限的 90–95%」 R1(资料库索引) 原文是 "roughly";本书反推出 350–420 GB/s/rank 的区间估计,见 code/04-bandwidth-normalize/
一层 MoE 的 dispatch / combine 字节数 本书 code/02-comm-ledger/ 复算 均匀路由假设下的上界量级,不是实测流量

另外,源码里有三个作者观察(不是实测):barrier 在 8 rank 时约 4 µs (R4 :643)、kernel launch + InitSocState() 约 1.9 µs(R4 :573)、 VF 调用约 0.5 µs(R4 :670)。它们只是注释,本章引用时都写成了「源码里写的观察」。


待补资料

  1. 没有 Ascend 950 UBMEM / URMA 链路的官方带宽规格。 §7.4 的反推(350–420 GB/s/rank)缺一个可以对照的硬件上限。 建议来源:华为 Atlas 850E 或 Ascend 950 的产品/部署文档, 或在真机上跑一个纯 URMA 带宽 microbenchmark。
  2. PoC HDK 与商用 Q3 HDK 的差异没有量化。 README 说商用版本「includes the configuration required for full-bandwidth operation」(资料库索引),但没有给两组数字的对比。 2026-10-15 前后商用版本发布后,建议补一份对照。
  3. CANN 侧的头文件不在 corpus 里。 hccl/hccl.h、hcomm/hcomm.h、kernel_operator.h、c_api/*、simt_api/* 的语义(尤其 HcommUrmaSqeCtx 的字段布局、kSQDepth 的硬件含义、 notifyNum = 3 这个「magic number」)只能从调用点反推。 §5 已明确写成「按调用点描述分工」,未对 CANN 内部实现下结论。
  4. EP_JIT_* 与 DeepJIT 的 DJ_JIT_* 的完整回落表分散在两处。 本章只列了 README 的六个变量;DeepJIT README 的完整表更长。 ASCEND_HOME_PATH 的读取时机(import 时还是首次 launch 时)本章未核实。
  5. deterministic_sort 的开销没有数据。 源码只说它排 T·k 行;没有它在真实形状下的耗时。 建议补一份「开/关 deterministic 的 dispatch 耗时对比」。
  6. combine 的「本地归约 + HBM 争抢」缺一个可分解的数字。 README 说 combine 更慢有这两个原因,但没有给出行内拆解。 建议用 msProf 把 combine 拆成「URMA 写」「UB 归约」「HBM 读写」三段。
  7. Bucket 的 session 语义与 all-gather 之外的能力缺材料。 资料库索引 给了几条约束(等长 shard、不自动扩容、退出不加 barrier), 但 reduce-scatter / all-reduce 的实现状态只有一句「still being built」。
  8. 与 TileKernels / DeepGEMM-Ascend 的接口契约没有正式文档。 §9.1 的三张量约定(topk_idx / topk_weights / x)是从两边的签名 反推出来的,上游没有一份「谁产谁消」的对接说明。 建议向上游确认这份契约是否有意固定。

本章共 14 个二级小节;右上角目录在桌面端会跟随滚动高亮。