跳到主要内容
TileLang 系统教程

tilelang-tutorial.huandu.me

第 16 章 算子实战 IV:MoE 与 MLA

/code/16-operators-moe-mla/

02-moe-routing/routing_sim.py8603 字节
#!/usr/bin/env python3
"""第 16 章 · MoE 路由与重排的可逆性验证(纯标准库,CPU 可跑)。

这一节把 MoE 一层的「非矩阵乘部分」走一遍完整的数据流,并证明三件事:

1. **重排是可逆的**。token 按专家重排(permute)→ 专家计算 → 按原位置聚合
   (combine),结果等价于「对每个 token 直接调用它选中的那几个专家再按
   gating 权重求和」。这是 `scatter_reduce` / `unpermute` 能成立的前提。
2. **分块表(block metadata)怎么来的**。官方 grouped GEMM / fusedmoe 用
   `batch_sizes` / `batch_offsets` / `batch_padded_offsets`(或等价的
   `block_metadata[bx] = [expert, m_start, valid_m]`)把「每个专家接到的
   token 数不齐」翻译成一个规整的 grid。这里把它复现出来。
3. **不均衡直接变成浪费**。padding 浪费与「最热专家 / 平均专家」的比值,
   都是可以提前算出来的量;它们决定了 grouped GEMM 的实际有效算力。

记账口径与 `../01-cost-ledger/decode_step_ledger.py` 一致。

用法
----

    python3 routing_sim.py
    python3 routing_sim.py --tokens 96 --experts 8 --top-k 2 --seed 7
"""

from __future__ import annotations

import argparse
import math
import random
from dataclasses import dataclass


# ---------------------------------------------------------------------------
# 1. gating:softmax + top-k
# ---------------------------------------------------------------------------


def softmax(xs: list[float]) -> list[float]:
    m = max(xs)
    es = [math.exp(x - m) for x in xs]
    s = sum(es)
    return [e / s for e in es]


def select_top_k(scores: list[float], k: int) -> list[tuple[int, float]]:
    """返回 ``[(expert_id, score), ...]``,按分数从大到小、前 k 个。

    ``-idx`` 作为次级键,保证同分时顺序稳定(可复现)。
    """
    order = sorted(range(len(scores)), key=lambda i: (-scores[i], i))
    return [(i, scores[i]) for i in order[:k]]


# ---------------------------------------------------------------------------
# 2. 重排:assignments → 按专家分组
# ---------------------------------------------------------------------------


@dataclass(frozen=True)
class RouteTables:
    """一层 MoE 的重排结果,对应官方 example 里的几张 int32 表。"""

    expert_of: list[int]        # assignments 顺序:第 a 个 (token,expert) 对属于哪个专家
    token_of: list[int]         # assignments 顺序:它来自哪个 token
    weight_of: list[float]      # assignments 顺序:它的 gating 权重
    group_sizes: list[int]      # 每个专家的 token 数(= bincount)
    group_offsets: list[int]    # 每个专家在**紧凑**(未 padding)布局里的起点
    padded_offsets: list[int]   # 每个专家在**padding 对齐**布局里的起点
    tokens_of_expert: list[list[int]]  # 每个专家实际拿到的 token id 列表


def build_route_tables(
    topk: list[list[tuple[int, float]]],
    n_experts: int,
    block_m: int,
) -> RouteTables:
    """复现官方 example 的 ``argsort → bincount → cumsum → padded_offsets`` 流程。"""
    # 官方 fusedmoe:flat_indices.argsort() 把 (token,expert) 对按 expert 排序。
    flat = [(tok, e, w) for tok, row in enumerate(topk) for e, w in row]
    order = sorted(range(len(flat)), key=lambda a: (flat[a][1], flat[a][0]))
    expert_of = [flat[a][1] for a in order]
    token_of = [flat[a][0] for a in order]
    weight_of = [flat[a][2] for a in order]

    group_sizes = [expert_of.count(e) for e in range(n_experts)]
    group_offsets: list[int] = []
    acc = 0
    for s in group_sizes:
        group_offsets.append(acc)
        acc += s

    # padding 对齐:每个专家占用 ceil(size / block_m) * block_m 行。
    padded_offsets: list[int] = []
    acc = 0
    for s in group_sizes:
        padded_offsets.append(acc)
        acc += math.ceil(s / block_m) * block_m

    tokens_of_expert: list[list[int]] = [[] for _ in range(n_experts)]
    for a in range(len(expert_of)):
        tokens_of_expert[expert_of[a]].append(token_of[a])

    return RouteTables(
        expert_of=expert_of,
        token_of=token_of,
        weight_of=weight_of,
        group_sizes=group_sizes,
        group_offsets=group_offsets,
        padded_offsets=padded_offsets,
        tokens_of_expert=tokens_of_expert,
    )


def block_metadata(tables: RouteTables, block_m: int) -> list[tuple[int, int, int]]:
    """生成 ``[expert_id, m_start, valid_m]`` 列表(昇腾 fusedmoe 的 block_metadata)。

    对应 `_vendor/tilelang-ascend-v0.1.2.000/examples/fusedmoe/example_fusedmoe.py`
    里的 ``_build_block_metadata``。
    """
    rows: list[tuple[int, int, int]] = []
    for expert, size in enumerate(tables.group_sizes):
        if size == 0:
            continue
        base = tables.padded_offsets[expert]
        for i in range(math.ceil(size / block_m)):
            m_start = base + i * block_m
            valid_m = min(block_m, size - i * block_m)
            rows.append((expert, m_start, valid_m))
    return rows


# ---------------------------------------------------------------------------
# 3. 专家计算 —— 用一个便宜的、可精确复算的线性变换代替真 GEMM
# ---------------------------------------------------------------------------


def expert_forward(x: list[float], expert: int) -> list[float]:
    """一个「专家」:``y_j = (1 + 0.5*expert) * x_j + 0.01*expert``。

    它刻意是**线性且确定**的,这样 permute/combine 的等价性可以精确断言;
    它不代表真实 MLP 的数值行为,只用来验证数据流。
    """
    scale = 1.0 + 0.5 * expert
    bias = 0.01 * expert
    return [scale * v + bias for v in x]


def reference(xs: list[list[float]], topk: list[list[tuple[int, float]]]) -> list[list[float]]:
    """参考实现:不重排,直接对每个 token 的每个选中专家算完再按权重求和。"""
    out = []
    for x, row in zip(xs, topk):
        acc = [0.0] * len(x)
        for e, w in row:
            y = expert_forward(x, e)
            acc = [a + w * v for a, v in zip(acc, y)]
        out.append(acc)
    return out


def permuted_path(xs: list[list[float]], tables: RouteTables) -> list[list[float]]:
    """重排路径:permute → 逐 assignment 算专家 → combine(scatter-reduce)。"""
    width = len(xs[0])
    # permute:按 assignments 顺序把 token 行搬到「按专家分组」的缓冲里
    permuted = [list(xs[t]) for t in tables.token_of]
    # 专家计算
    computed = [expert_forward(permuted[a], tables.expert_of[a]) for a in range(len(permuted))]
    # combine:按 token 累加(这里显式做加法,模拟 scatter_reduce 的 reduce="sum")
    acc = [[0.0] * width for _ in range(len(xs))]
    for a in range(len(computed)):
        t = tables.token_of[a]
        w = tables.weight_of[a]
        acc[t] = [a_ + w * c for a_, c in zip(acc[t], computed[a])]
    return acc


# ---------------------------------------------------------------------------
# 4. 主流程与报告
# ---------------------------------------------------------------------------


def run(tokens: int, experts: int, top_k: int, width: int, block_m: int, seed: int) -> int:
    rng = random.Random(seed)
    xs = [[rng.uniform(-1.0, 1.0) for _ in range(width)] for _ in range(tokens)]
    # 让分布不均匀:给前 2 个专家一个偏置,模拟热点专家。
    logits = [
        [rng.gauss(0.0, 1.0) + (2.0 if e < 2 else 0.0) for e in range(experts)]
        for _ in range(tokens)
    ]
    probs = [softmax(row) for row in logits]
    topk = [select_top_k(row, top_k) for row in probs]

    tables = build_route_tables(topk, experts, block_m)
    meta = block_metadata(tables, block_m)

    ref = reference(xs, topk)
    got = permuted_path(xs, tables)
    max_err = max(abs(a - b) for ra, rb in zip(ref, got) for a, b in zip(ra, rb))

    total_assignments = sum(tables.group_sizes)
    padded_rows = len(meta) * block_m
    hot = max(tables.group_sizes)
    mean = total_assignments / experts

    print(f"tokens={tokens} experts={experts} top_k={top_k} "
          f"width={width} block_M={block_m} seed={seed}")
    print(f"assignments (token,expert) 对 : {total_assignments}")
    print(f"每个专家的 token 数           : {tables.group_sizes}")
    print(f"最热专家 / 平均专家           : {hot} / {mean:.2f} "
          f"= {hot / mean:.2f}x  (不均衡)")
    print(f"padding 后总行数              : {padded_rows} "
          f"(浪费 {(padded_rows - total_assignments) / max(total_assignments, 1):.1%})")
    print(f"grid 的 M 方向块数            : {len(meta)} "
          f"(= 各专家 ceil(size/{block_m}) 之和)")
    print(f"重排 vs 参考的最大误差        : {max_err:.3e}")

    ok = max_err < 1e-12
    print("round-trip 断言              : " + ("通过" if ok else "失败"))
    if not ok:
        raise SystemExit("permute/combine 不等价——实现有 bug")

    print("\nblock_metadata 前若干行([expert, m_start, valid_m]):")
    for row in meta[:8]:
        print(f"  {list(row)}")
    if len(meta) > 8:
        print(f"  ... 共 {len(meta)} 行")
    return 0


def main() -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("--tokens", type=int, default=64)
    parser.add_argument("--experts", type=int, default=8)
    parser.add_argument("--top-k", type=int, default=2)
    parser.add_argument("--width", type=int, default=8)
    parser.add_argument("--block-m", type=int, default=32)
    parser.add_argument("--seed", type=int, default=81394)
    args = parser.parse_args()
    if args.top_k > args.experts:
        parser.error("--top-k 不能超过 --experts")
    return run(args.tokens, args.experts, args.top_k, args.width, args.block_m, args.seed)


if __name__ == "__main__":
    raise SystemExit(main())

← 浏览本章全部代码