mini-infer系统实战-23-Control Plane:最难的不是统计出来,而是先量对

很多人第一次看 MoE 推理里的 Expert Parallelism,会觉得 control plane 只是一个“顺手补充说明”的东西:

packed path 已经比 padded 快了,exact bytes 也已经对了,那再补一个 control_plane_ms 不就结束了吗?

如果只是做概念讲解,这句话没什么问题。
但真正把它做成一轮可以落盘、可以复盘、可以拿去面试讲清楚的工程实现时,你会发现这件事远比“多打一行指标”复杂。

在 mini-infer 的 Phase 20,我做的是一版 EP Control Plane 收敛。最终正式 benchmark 平均结果是:

  • dense:22610.50 tok/s
  • ep_padded43392.45 tok/s
  • ep_packed52229.29 tok/s
  • EP packed / dense = 2.310x
  • EP packed / EP padded = 1.204x
  • ep_packed_control_plane = 0.0239 ms/run
  • ep_packed_control_plane_share = 0.019443,约 1.94%
  • max_abs_diff_packed = 0.000000

这些数字都来自该阶段的正式 benchmark,不是估算值。

这篇文章想讲的不是“control plane 是什么”,而是更实际的问题:

为什么 Phase 20 真正难的不是把 control-plane 指标打印出来,而是先确保你量到的真的是 control plane。

背景:Phase 19 已经证明 packed 更快,但还不能回答“快在哪里”

Phase 19 已经把 synthetic MoE 上的 dense / ep_padded / ep_packed 通信闭环跑通了。正式 benchmark 两次复跑平均结果是:

  • dense:22552.86 tok/s
  • ep_padded43046.93 tok/s
  • ep_packed52400.26 tok/s
  • EP packed / dense = 2.323x
  • EP packed / EP padded = 1.217x
  • ep_packed_bytes_per_layer = ep_ideal_bytes_per_layer = 262144
  • max_abs_diff_packed = 0.000000

这已经说明两件事:

  1. packed path 的 hidden-state payload bytes 已经收回到 ideal
  2. packed path 在当前 synthetic workload 下确实比 padded 更快

但 Phase 19 还留着一个很明确的 production gap:

  • packed path 仍依赖 host-side split-size Python list
  • benchmark 只能说明“packed 更快”
  • 却不能说明“packed 现在还慢在哪里、control plane 还占多少”

这就是我把 Phase 20 定成 EP Control Plane 收敛 的原因。

问题定义:你不是要“给系统多加一个指标”,你是在重新定义 benchmark 的解释力

Phase 20 的核心目标不是再造一条新数据通路,而是把 packed 路径里原本混在一起的几类成本分开:

  • source-rank router
  • dispatch layout 构建
  • token packing
  • split-size control plane
  • 真正的 all-to-all payload

如果这些东西混在一个吞吐数字里,你当然还是能说:

  • ep_packed 更快
  • ep_packed_bytes_per_layer == ep_ideal_bytes_per_layer

但你没法回答更像大厂推理组会问的问题:

  • packed path 现在还慢在哪里?
  • control plane 到底是不是主要瓶颈?
  • 当前是不是已经值得去做 grouped GEMM / overlap?

所以 Phase 20 的真正交付,不是把 benchmark 表从 8 列变成 10 列,而是让 benchmark 本身变得更有解释力。

方案:把 packed control plane 从“默认存在的杂项”收敛成显式 contract

我这轮改的核心文件是三个:

总体思路是:

  1. EPMoELayer 继续保留 comm_mode="padded"comm_mode="packed" 两条路径
  2. packed 的 split-size 组装不回到 per-layer hot path
  3. worker 侧显式构造 PackedControlPlane
  4. benchmark 正式输出:
    • control_plane_ms
    • control_plane_share
    • control_plane_note

这意味着 Phase 20 的目标不是去掉所有 Python control plane,而是先把它收拢、命名、计量、解释

实现细节:真正新增的不是一个字段,而是一层职责边界

1. PackedControlPlane

moe_layer.py 里,我新增了 PackedControlPlanebuild_packed_control_plane()

它的职责很明确:把 packed 路径的 split-size 相关信息收敛成单独的控制面对象,而不是散在 distributed forward 里临时组装。

这个对象包含的不是 payload,而是 worker 在 all_to_all_single 前后真正需要的 split 信息:

  • sender_splits
  • receiver_splits
  • return_sender_splits
  • return_receiver_splits
  • recv_count
  • recv_back_count

这个动作的意义不是“代码更优雅”,而是让你能非常明确地说:

哪些是 data plane,哪些是 control plane。

2. _prepare_packed_control_plane()

ep_engine.py 里,worker 侧新增了 _prepare_packed_control_plane()

1
2
3
4
5
6
7
def _prepare_packed_control_plane(...):
if device is not None:
torch.cuda.synchronize(device)
start_time = time.perf_counter()
packed_send_counts_cpu = packed_send_counts.cpu().tolist()
control_plane = build_packed_control_plane(...)
return control_plane, time.perf_counter() - start_time

这里有两个关键点:

  1. packed 的 split-size control plane 现在在 worker 侧统一构造
  2. 它的计时边界被单独定义出来了

你可以把这个 helper 理解成“Phase 20 真正的主角”。因为这轮不是在改 MoE 数学,而是在改 benchmark 解释力。

3. benchmark contract 扩展

benchmark_moe.py 里,Phase 20 不再只输出:

  • dense / padded / packed throughput
  • ideal / padded / packed bytes

而是额外输出:

  • control_plane_ms
  • control_plane_share
  • control_plane_note

并且这些 note 是显式写进结果里的,不靠“默认理解”:

  • ep_packed_impl_note
  • ep_packed_control_plane_note

这件事很像真实 infra 项目里的一个常见原则:

如果一个重要指标需要靠口头解释才能成立,那它还不算真正稳定的系统指标。

这轮真正踩到的坑:第一次我量出来的根本不是 control plane

Phase 20 最有价值的部分,不是实现本身,而是中途踩到的 benchmark 口径坑。

错误版本发生了什么

第一版 control_plane_ms/share 看起来是“有值”的。
worker 里大致是这么做的:

  1. source rank 先做 prepare_packed_source_context()
  2. broadcast(send_counts)
  3. 然后开始计时
  4. 再做:
    • packed_send_counts.cpu().tolist()
    • build_packed_control_plane()

表面上看,这好像已经是“只测 control plane”了。

但问题在于:

  • 前面刚在同一 CUDA stream 上做完 source-rank router / dispatch
  • 你一旦执行 packed_send_counts.cpu().tolist()
  • 这个 D2H copy 会把前面尚未完成的 GPU 工作一起等完

也就是说,你以为自己在测:

  • send-count 读回
  • Python split-size helper

实际上你测到的还包含:

  • source-rank router
  • dispatch layout
  • token packing 的 GPU 完成等待

错误信号有多明显

最小 2-GPU compare 一度得到:

  • ep_packed_control_plane_share = 0.481655

这意味着 packed path 里将近一半时间都在 control plane 上。
如果这是真的,Phase 19 的 ep_packed 根本不可能在官方 workload 下稳定比 ep_padded 快。

所以这个值本身就在告诉你:

不是系统坏了,而是指标口径坏了。

这就是 Phase 20 最关键的工程经验:

先怀疑你量的东西是不是你以为的那个东西,再去解释结果。

修正方式:先把前序 GPU 工作收口,再开始 control-plane 计时

最终修正方式非常直接,但它的意义比代码量大得多。

我在 _prepare_packed_control_plane() 开始前显式加了:

1
torch.cuda.synchronize(device)

这个同步的作用不是为了“保守一点”,而是为了明确切开两个时间段:

  1. source-rank router / dispatch / broadcast 已经结束
  2. 现在开始计量:
    • packed_send_counts 的 GPU -> CPU 读回
    • PackedControlPlane helper 本身

修完后,最小 compare 的结果变成:

  • ep_packed_control_plane_ms = 0.1108
  • ep_packed_control_plane_share = 0.000257

这两个数字才第一次像“单独的 helper 成本”。

到了官方 workload,最终稳定在:

  • ep_packed_control_plane = 0.0239 ms/run
  • ep_packed_control_plane_share = 0.019443,约 1.94%

这组数字是可信的,因为它已经不再混着前序 GPU 工作。

正式实验:Phase 20 到底交付了什么

正式 workload 是:

  • batch_size=4
  • seq_len=16
  • hidden_size=512
  • intermediate_size=1024
  • num_experts=8
  • top_k=2
  • dtype=float16
  • warmup=2
  • runs=5
  • src_rank=1

同配置跑了 2 次,平均结果是:

指标 结果
Dense throughput 22610.50 tok/s
EP padded throughput 43392.45 tok/s
EP packed throughput 52229.29 tok/s
EP packed / dense 2.310x
EP packed / EP padded 1.204x
ep_packed_control_plane 0.0239 ms/run
ep_packed_control_plane_share 0.019443(约 1.94%
max_abs_diff_padded 0.000000
max_abs_diff_packed 0.000000
shard_ratio 0.5002
ep_packed_bytes_per_layer 262144
ep_ideal_bytes_per_layer 262144

这些数据说明了三件事:

  1. Phase 19 的主线结果没有回退
    packed 仍然保持 exact bytes、数值等价和更高吞吐。

  2. control plane 已经被量化,而且占比不高
    它现在不是 packed path 的主要瓶颈。

  3. Phase 20 的核心交付不是“更快了多少”,而是 benchmark 终于能解释“为什么现在不是它在拖后腿”。

真正的结论:Phase 20 解决的不是 Python list,而是 benchmark 的解释权

如果只从功能角度看,Phase 20 好像只是:

  • 新增一个 helper
  • 多了两个 benchmark 字段
  • 写了一些 note

但这恰好会误导你低估这轮工作的价值。

Phase 20 真正解决的是:

  • control_plane_ms/share 变成可信指标
  • 让 packed path 的剩余性能问题不再停留在猜测
  • 让下一阶段的优化方向变得明确

现在我可以非常明确地说:

  • packed split-size helper 仍然存在
  • 它仍然依赖 Python list
  • 但在当前官方 workload 下,它只占 1.94%

这句话对后续阶段的价值远高于一句“packed 更快”。

因为它直接把下一步范围缩小了:

  • 不是继续盲改 control plane
  • 而是该看 grouped GEMM / overlap
  • 或者把 synthetic EP 路径接到更完整的 serving / 生成链路

总结

Phase 20 给我的一个很强的经验是:

在推理系统里,很多时候最难的不是把东西做出来,而是确保你测到的真的是你以为自己在测的东西。

Phase 19 证明了 packed communication 是更好的数据通路。
Phase 20 则进一步证明了:

  • control plane 可以被显式量化
  • 当前 packed path 的 control plane 已经不是主要瓶颈
  • benchmark 的解释力本身也是系统工程的一部分

如果只做功能实现,你会得到一个“能跑”的 EP 原型。
如果把 benchmark 口径、失败点和计时边界一起收住,你得到的才是一个可以拿去讲清楚、拿去对比、拿去继续规划下一阶段的推理系统阶段成果。


系列导航