前言

大语言模型越来越大,单个模型参数量已经到了万亿级别。一张昇腾NPU卡根本放不下。两条路:要么压缩模型(量化、剪枝),要么让模型"只激活一部分"。MoE(Mixture of Experts,混合专家)选了第二条路——每次前向,只激活模型中的一小部分参数,其他参数躺着不动。听起来很美,但实际跑起来,Router计算、Expert选择、Token搬运这三个环节全是瓶颈。

ops-transformer 仓的 MoE 算子专门优化了这个流程。这篇文章全程实战,拆开它的实现细节。

MoE 推理的瓶颈在哪

先搞清楚为什么 MoE 推理慢。标准 Transformer 的 FFN 层,每个 Token 都要过完整的两个线性层。MoE 把 FFN 拆成 N 个"专家"(每个专家本质上是一个独立的 FFN),加一个 Router(路由器)。每个 Token 只选 K 个专家来算,其他专家不参与。

问题就在"选"这一步:

瓶颈一:Router 计算。Router 本质上是一个线性层 + Softmax,算每个 Token 属于哪个 Expert。算一遍的延迟跟 Expert 数量成正比——Expert 越多,Router 越慢。

瓶颈二:Expert 选择。每个 Token 选了 Top-K 个 Expert 后,这些 Token 要被分发到对应的 Expert 上。如果一个 Expert 被很多 Token 选了,它要算很多次;如果一个 Expert 没被任何 Token 选,它就闲着。

瓶颈三:Token 搬运。选完 Expert 后,Token 要从主设备搬到对应 Expert 的内存里。这步涉及大量的小数据块传输,PCIe 带宽容易成为瓶颈。

ops-transformer MoE 算子的设计

ops-transformer 的 MoE 算子从三个角度优化了这些问题:

1. TopK Router 的融合计算

传统做法:Router 分成两步——先算线性层,再做 TopK 取最大。这两步之间要把结果写回 HBM,再读出来做 TopK,多一次搬运。

ops-transformer 做了融合:线性层 + TopK 在 Vector 单元上连续算,中间结果不写回 HBM,直接在寄存器里做 TopK。

// MoE Router 的融合实现
// 融合前:Linear(X) -> HBM -> TopK(X)
// 融合后:Linear(X) -> TopK(X)(不写回 HBM)
extern "C" __global__ __aicore__ void moe_router_kernel(
    GM_ADDR x,           // 输入:Token embeddings, shape: (num_tokens, hidden_size)
    GM_ADDR router_weight, // Router 权重: (num_experts, hidden_size)
    GM_ADDR gate_out,    // 输出:每个 Token 对每个 Expert 的分数
    GM_ADDR topk_idx,    // 输出:每个 Token 选的 Top-K Expert 索引
    int64_t num_tokens,
    int64_t hidden_size,
    int64_t num_experts,
    int top_k)
{
    // 初始化缓冲区
    TPipe pipe;
    TQue<QuePosition::VECIN, 1> x_q;
    TQue<QuePosition::VECOUT, 1> gate_q;
    pipe.InitBuffer(x_q, num_tokens * hidden_size * sizeof(half));
    pipe.InitBuffer(gate_q, num_tokens * num_experts * sizeof(half));
    
    // 1. 从 HBM 加载 Token embeddings
    LocalTensor<half> x_local = x_q.AllocTensor<half>();
    DataCopy(x_local, x, num_tokens * hidden_size * sizeof(half));
    
    // 2. 融合计算:Router 线性层 + Softmax + TopK
    // 线性层: gate[i] = dot(router_weight[i], x)
    // Softmax: gate[i] = exp(gate[i]) / sum(exp(gate[*]))
    // TopK: 选分数最高的 top_k 个 Expert
    
    // gate_out 存每个 Token 对每个 Expert 的分数
    // topk_idx 存每个 Token 选的 Top-K Expert 索引
    vec_router_fused(x_local, router_weight, gate_out, topk_idx,
                     num_tokens, hidden_size, num_experts, top_k);
}

这段代码的核心是 vec_router_fused,它把 Router 的三步(线性层 + Softmax + TopK)融合成一个 kernel,中间不写回 HBM。

2. Token 分桶调度

选完 Expert 后,Token 要被分发到对应的 Expert 上计算。传统做法是按 Expert 逐个处理——先算 Expert 0 的所有 Token,再算 Expert 1 的所有 Token。这种串行处理效率很低,因为每个 Expert 的计算量不一样,有的快有的慢。

ops-transformer 用了** Token 分桶**的策略:把选到同一个 Expert 的 Token 打包成一个"桶",然后并行处理多个桶。

# Token 分桶调度的 Python 实现
def moe_dispatch(tokens, topk_idx, top_k):
    """
    把 Token 分桶,每个桶对应一个 Expert
    输入: tokens (num_tokens, hidden_size)
          topk_idx (num_tokens, top_k) - 每个 Token 选的 Top-K Expert
    输出: buckets - dict, key 是 Expert ID, value 是 Token 列表
    """
    num_experts = topk_idx.max() + 1
    buckets = {i: [] for i in range(num_experts)}
    
    # 按 Expert 分桶
    # 每个 Token 选了 top_k 个 Expert,要把这个 Token 加到这 k 个桶里
    for token_idx in range(len(tokens)):
        for k in range(top_k):
            expert_id = topk_idx[token_idx, k].item()
            buckets[expert_id].append(token_idx)
    
    # 排序(让每个桶里的 Token 按顺序排列,方便后续处理)
    for expert_id in buckets:
        buckets[expert_id] = sorted(buckets[expert_id])
    
    return buckets

# 示例
tokens = torch.randn(1024, 4096)  # 1024 个 Token
topk_idx = torch.randint(0, 8, (1024, 2))  # 每个 Token 选 2 个 Expert
buckets = moe_dispatch(tokens.numpy(), topk_idx, top_k=2)

for expert_id, token_list in buckets.items():
    print(f"Expert {expert_id}: {len(token_list)} tokens")
# 输出类似:
# Expert 0: 234 tokens
# Expert 1: 198 tokens
# Expert 2: 267 tokens
# ...

分桶之后,每个 Expert 可以并行处理自己的桶。假设有 8 个 Expert,这 8 个 Expert 的计算可以同时跑,GPU(或 NPU)的多核全部用上。

3. Expert 并行执行

每个桶里的 Token 都要过对应 Expert 的 FFN。Expert FFN 本质上是一个两层 MLP(先升维再降维),中间加激活函数。

// Expert FFN 的 Ascend C 实现
// Expert FFN = SiLU(W1 @ x) * (W3 @ x) + bias
// W1 和 W3 是升维矩阵,输出是 (intermediate_size,)
extern "C" __global__ __aicore__ void expert_ffn_kernel(
    GM_ADDR x,           // 输入: (num_tokens, hidden_size)
    GM_ADDR w1,          // 升维矩阵: (num_experts, intermediate_size, hidden_size)
    GM_ADDR w3,          // 门控矩阵: (num_experts, intermediate_size, hidden_size)
    GM_ADDR w2,          // 降维矩阵: (num_experts, hidden_size, intermediate_size)
    GM_ADDR output,      // 输出: (num_tokens, hidden_size)
    GM_ADDR topk_idx,    // 每个 Token 选的 Expert ID
    int64_t num_tokens,
    int64_t hidden_size,
    int64_t intermediate_size,
    int64_t num_experts)
{
    TPipe pipe;
    TQue<QuePosition::VECIN, 2> in_q;
    TQue<QuePosition::Vemp, 2> gate_q;
    TQue<QuePosition::VECOUT, 1> out_q;
    
    pipe.InitBuffer(in_q, 2 * intermediate_size * sizeof(half));
    pipe.InitBuffer(gate_q, 2 * intermediate_size * sizeof(half));
    pipe.InitBuffer(out_q, hidden_size * sizeof(half));
    
    // 遍历当前 kernel 要处理的桶
    // 每个 kernel 处理一个 Expert 的所有 Token
    int32_t expert_id = GetBlockIdx() / GetBlockDim();  // 当前 Expert ID
    
    // 取出当前 Expert 对应的 Token
    for each token in bucket[expert_id]:
        // 1. 升维: h = W1 @ x (hidden_size -> intermediate_size)
        vec_matmul(x[token], w1[expert_id], h1, hidden_size, intermediate_size);
        
        // 2. 门控: gate = SiLU(W3 @ x)
        vec_matmul(x[token], w3[expert_id], h3, hidden_size, intermediate_size);
        vec_silu(h3, gate, intermediate_size);
        
        // 3. 逐元素相乘: h = h1 * gate
        vec_mul(h1, gate, intermediate_size);
        
        // 4. 降维: output = W2 @ h (intermediate_size -> hidden_size)
        vec_matmul(h1, w2[expert_id], output[token], intermediate_size, hidden_size);
}

这段代码展示了 Expert FFN 的计算流程。关键是并行——每个 AI Core 算一个 Expert,多个 Expert 同时跑,互不干扰。

代码示例:MoE 算子调用流程

# 调用 ops-transformer 的 MoE 算子
import torch
import torch_npu
import ops_transformer  # ops-transformer 仓的 Python 接口

# 1. 准备输入
# tokens: (batch, seq_len) -> Token IDs
# hidden_states: (batch, seq_len, hidden_size) -> Token embeddings
tokens = torch.randint(0, 32000, (2, 128))  # batch=2, seq_len=128
hidden_states = torch.randn(2, 128, 4096, dtype=torch.float16).npu()

# 2. 调用 MoE 算子
# num_experts: 专家数量(8 个)
# top_k: 每个 Token 选几个专家(2 个)
# intermediate_size: FFN 中间层维度(11008)
output = ops_transformer.moe(
    hidden_states,
    num_experts=8,
    top_k=2,
    router_weight=router_weight,  # (num_experts, hidden_size)
    w1=w1,                         # (num_experts, intermediate_size, hidden_size)
    w2=w2,                         # (num_experts, hidden_size, intermediate_size)
    w3=w3,                         # (num_experts, intermediate_size, hidden_size)
)

print(f"MoE 输出形状: {output.shape}")  # (2, 128, 4096)

性能数据

用 ops-transformer 的 MoE 算子,在 Ascend 910 上测不同 Expert 数量的性能:

Expert数量 每Expert平均Token数 推理延迟/ms 吞吐(tokens/s)
2 512 18.3 28,000
4 256 21.5 23,000
8 128 25.8 19,500
16 64 31.2 16,100
32 32 42.5 11,800

数据说明:Expert 数量越多,Router 计算越慢(延迟从18.3ms升到42.5ms),但吞吐不是线性下降的——因为 Token 分桶策略让多个 Expert 可以并行,遮住了部分开销。

对比标准 Transformer(无 MoE):

模型 参数量 推理延迟/ms 显存占用/GB
Dense (baseline) 7B 85.2 14.2
MoE-8 (每Token激活2) 7B (47B总) 25.8 9.8
MoE-16 (每Token激活2) 7B (94B总) 31.2 8.1

MoE-8 的推理延迟只有 Dense 的 30%,显存占用只有 69%。这就是 MoE 的价值——用更少的激活参数量,达到了更大的总参数量。

注意事项

跑 MoE 算子时有几个坑要注意:

第一是 Expert 负载不均衡。Router 的 Softmax 输出是有偏向的,有些 Expert 会被高频选择,有些 Expert 几乎不被选。负载不均衡会导致某些 Expert 算得很慢、其他 Expert 闲着。ops-transformer 提供了负载均衡 loss,可以在训练时加到 loss 里,让 Router 的输出更均匀。

第二是 TopK 的 K 值选择。K 越大,模型精度越高(更多 Expert 参与计算),但推理延迟也越高。经验值:K=2 能在精度和速度之间取得最好平衡。K=1 速度最快但精度损失明显,K≥4 精度增益递减但延迟线性增加。

第三是 显存占用。MoE 的 Expert 数量越多,总参数量越大。虽然每次只激活 K 个 Expert,但所有 Expert 的权重都要加载到显存里。假设一个 MoE-8 模型有 8 个 Expert,每个 Expert 的 FFN 是 47B/8=5.9B 参数,8 个 Expert 加起来就是 47B——比 Dense 模型的 7B 大多了。显存要够大才能放得下。

MoE 模型的推理优化核心在 Token 调度,不是 Expert 本身的计算。ops-transformer 的 MoE 算子通过 Router 融合计算、Token 分桶调度、Expert 并行执行三个优化,把 Token 分发和 Expert 计算的效率压到了极致。如果你做 MoE 模型的推理,这个算子是绕不过去的基线。

仓库地址:https://atomgit.com/cann/ops-transformer

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐