犀牛鸟 2026 研究笔记 estelledc.github.io

Ch27: 跨赛道 YOLO 到端侧——MoE 导出与引擎选型

Part 5: 实战与竞赛 前置章节:Ch26: 开源竞赛贡献路径 后续章节:Ch28: 十大核心技术难点与三层矛盾(Part 6 开篇)


本章目标

这是本系列导读的最后一章。前面的章节依次覆盖了推理引擎的基础概念(Part 1-2)、六大框架的架构精讲(Part 3)、核心抽象对比(Part 4)、以及编译部署实战(Part 5 前半段)。这一章处理一个跨赛道的实战问题:如何把一个可能包含 MoE(Mixture-of-Experts)结构的 YOLO 检测模型部署到手机端。

这个问题之所以值得单独用一章来讲,是因为它把前面所有章节的知识串在了一起:模型导出(Ch23)、引擎选型(Ch22)、量化(Ch10/Ch23)、端侧约束(Ch05)、算子支持(Ch18/Ch21)——而 MoE 结构还引入了一个在前面章节中只是简单提及的新挑战:动态路由。

读完这一章,你应该能:


前置准备


MoE 遇上端侧:为什么这是一个系统性难题

MoE 的核心机制回顾

Mixture-of-Experts 的核心思想是”分工”:不让一个大模型处理所有情况,而是训练多个小型”专家”(Expert),用一个”路由器”(Router)根据输入数据决定激活哪些专家。

输入 x
  │
  ├──→ Router(x) → [0.7, 0.2, 0.05, 0.05]  ← 每个 Expert 的权重
  │
  ├──→ Expert 1(x) × 0.7  ─┐
  ├──→ Expert 2(x) × 0.2  ─┤
  ├──→ Expert 3(x) × 0.05 ─┤ → 加权求和 → 输出
  └──→ Expert 4(x) × 0.05 ─┘

在训练和服务器推理中,这个机制工作得很好。但它撞上端侧推理引擎时,产生了三个根本性的冲突。

冲突 1: 动态路由 vs 静态图

端侧推理引擎的核心假设是静态计算图——所有算子的连接关系和执行顺序在模型加载时就确定了,推理时只是在固定的图上跑数据。

MoE 的 Router 打破了这个假设:Router 的输出决定了激活哪些 Expert,这意味着不同的输入会触发不同的计算路径。用代码表达就是:

# MoE 的 forward——本质上是数据依赖的控制流
def forward(self, x):
    router_output = self.router(x)          # [batch, num_experts]
    top_k_indices = router_output.topk(k=2)  # 选择 Top-2 Expert

    output = torch.zeros_like(x)
    for idx in top_k_indices:
        output += self.experts[idx](x) * router_output[idx]
    return output

这段代码中的 topkfor idx in top_k_indices 都是数据依赖的——静态图无法表达”运行时才知道选哪个 Expert”。

冲突 2: 稀疏激活 vs 全量执行

MoE 的效率来自稀疏激活——8 个 Expert 只激活 2 个,计算量只有全量的 25%。但端侧引擎的静态图要求要么全部执行、要么全部不执行,没有”选择性执行部分子图”的能力。

如果把所有 Expert 都放进静态图全部执行,就失去了 MoE 的效率优势——你付出了 8 个 Expert 的参数量(模型大小)和计算量,却没有得到稀疏激活带来的速度收益。

冲突 3: 实例级差异 vs 批处理

在目标检测(YOLO)中,MoE 可能在不同空间位置或不同检测框上激活不同的 Expert——同一张图片的不同区域走不同的专家路径。这种”实例级差异”在端侧引擎中更难处理,因为引擎通常假设同一个 batch 内所有样本走同样的计算路径。

小结:三重冲突

MoE 特征 端侧引擎假设 冲突
动态路由(Router 输出决定路径) 静态计算图(编译期确定路径) 根本矛盾
稀疏激活(只跑部分 Expert) 全量执行(所有算子都跑) 效率损失
实例级差异(不同输入不同路径) 批处理一致性(同 batch 同路径) 语义冲突

理解了这三重冲突,就能理解为什么”把 MoE 模型直接导出到端侧”不是一个”转换工具不支持”的小问题,而是一个需要改变模型架构的系统性挑战。


三条可行路径

面对这个系统性挑战,有三条路径可以走。它们在”工程复杂度”和”模型保真度”之间做了不同的取舍。

路径 A: 单专家蒸馏(推荐)

核心思想: 不把 MoE 部署到端侧,而是训练一个不含 MoE 的”学生模型”来模仿 MoE “老师模型”的行为。

具体步骤:

  1. 统计 Expert 激活频率: 在验证集上跑 MoE 模型,记录每个 Expert 被 Router 选中的频率
  2. 选择 Top Expert: 保留激活频率最高的 1-2 个 Expert
  3. 去除 Router: 把保留的 Expert 直接串联到模型中,去掉 Router 的选择逻辑
  4. 微调: 用原始训练数据对简化后的模型做少量 fine-tuning,恢复精度
# pruning.py — Expert 激活频率统计与裁剪

import torch
import torch.nn as nn
from collections import defaultdict

class ExpertActivationTracker:
    """统计 MoE 模型中每个 Expert 的激活频率"""

    def __init__(self, model):
        self.model = model
        self.activation_counts = defaultdict(lambda: defaultdict(int))
        self.total_samples = 0
        self._register_hooks()

    def _register_hooks(self):
        """为每个 MoE 层注册 forward hook"""
        for name, module in self.model.named_modules():
            if hasattr(module, 'router') and hasattr(module, 'experts'):
                module._tracker_name = name
                module.register_forward_hook(self._hook_fn)

    def _hook_fn(self, module, input, output):
        """记录 Router 的 top-k 选择"""
        router_output = module.router(input[0])
        # 假设 Top-2 路由
        top_k = router_output.topk(2, dim=-1).indices
        name = module._tracker_name

        for expert_idx in top_k.flatten().tolist():
            self.activation_counts[name][expert_idx] += 1

    def run_calibration(self, dataloader, num_batches=100):
        """在校准数据上统计激活频率"""
        self.model.eval()
        with torch.no_grad():
            for i, (images, _) in enumerate(dataloader):
                if i >= num_batches:
                    break
                self.model(images)
                self.total_samples += images.size(0)

    def get_top_experts(self, k=1):
        """返回每个 MoE 层中激活频率最高的 k 个 Expert"""
        result = {}
        for layer_name, counts in self.activation_counts.items():
            sorted_experts = sorted(
                counts.items(),
                key=lambda x: x[1],
                reverse=True
            )
            result[layer_name] = [idx for idx, count in sorted_experts[:k]]
            print(f"{layer_name}:")
            for idx, count in sorted_experts:
                freq = count / self.total_samples * 100
                print(f"  Expert {idx}: {freq:.1f}% ({count}/{self.total_samples})")
        return result


def prune_moe_to_single_expert(model, top_experts):
    """
    把 MoE 层替换为单个 Expert,去除 Router。

    Args:
        model: 包含 MoE 层的模型
        top_experts: dict, {layer_name: [top_expert_indices]}

    Returns:
        简化后的模型(不含 MoE 结构)
    """
    for name, module in model.named_modules():
        if name in top_experts:
            # 获取最常被激活的 Expert
            best_expert_idx = top_experts[name][0]
            best_expert = module.experts[best_expert_idx]

            # 用单个 Expert 替换整个 MoE 层
            # 具体实现取决于 MoE 层的接口
            parent_name = ".".join(name.split(".")[:-1])
            child_name = name.split(".")[-1]
            parent = dict(model.named_modules())[parent_name] if parent_name else model
            setattr(parent, child_name, best_expert)

            print(f"Replaced MoE layer '{name}' with Expert {best_expert_idx}")

    return model


# 使用示例
if __name__ == "__main__":
    # 1. 加载 MoE-YOLO 模型
    model = load_moe_yolo("moe_yolov8.pt")

    # 2. 统计 Expert 激活频率
    tracker = ExpertActivationTracker(model)
    tracker.run_calibration(val_dataloader, num_batches=200)
    top_experts = tracker.get_top_experts(k=1)

    # 输出类似:
    # backbone.stage3.moe:
    #   Expert 0: 45.2% (9040/20000)
    #   Expert 1: 38.1% (7620/20000)
    #   Expert 2: 10.5% (2100/20000)
    #   Expert 3: 6.2% (1240/20000)

    # 3. 裁剪为单 Expert 模型
    pruned_model = prune_moe_to_single_expert(model, top_experts)

    # 4. 验证精度
    pruned_acc = evaluate(pruned_model, val_dataloader)
    print(f"Pruned model mAP: {pruned_acc:.4f}")

    # 5. 可选:Fine-tuning 恢复精度
    # fine_tune(pruned_model, train_dataloader, epochs=10)

    # 6. 导出
    pruned_model.eval()
    dummy = torch.randn(1, 3, 640, 640)
    torch.onnx.export(pruned_model, dummy, "yolo_pruned.onnx", opset_version=13)

这个路径的优势: 裁剪后的模型是一个标准的 CNN/Transformer,可以无障碍地走 Ch23 讲的标准部署流水线(ONNX → ncnn/MNN → 量化 → 部署)。不需要引擎做任何特殊支持。

这个路径的代价: 丢失了 MoE 的”专家分工”能力——模型在某些场景下的精度可能不如原始 MoE 模型。但根据经验,Top-1 Expert 通常能覆盖 40-50% 的样本,fine-tuning 后精度损失通常在 1-3% mAP 以内。

路径 B: 标准 YOLOv8 替代

核心思想: 如果 MoE 结构的精度提升不大(比如只有 0.5-1% mAP),不如直接用标准 YOLOv8 模型,省去所有 MoE 相关的麻烦。

# 标准 YOLOv8 的端侧部署——完整流程
pip install ultralytics

# 训练标准 YOLOv8(不含 MoE)
yolo train model=yolov8n.pt data=my_dataset.yaml epochs=100

# 一键导出到 ncnn
yolo export model=runs/detect/train/weights/best.pt format=ncnn

# 验证
yolo predict model=runs/detect/train/weights/best_ncnn_model source=test_images/

这条路径适用于两种情况:

  1. MoE 的精度提升不显著: 如果标准 YOLOv8n 在你的任务上已经达到了可接受的精度(比如 mAP@0.5 > 0.85),那引入 MoE 的复杂度可能不值得
  2. 部署时间紧迫: 路径 A 的 Expert 裁剪 + fine-tuning 需要额外的开发和训练时间,如果时间不够,用标准模型先上线是务实的选择

YOLOv8 各尺寸模型在端侧的性能参考:

模型 参数量 FLOPs 端侧延迟* mAP@0.5 (COCO)
YOLOv8n 3.2M 8.7G ~12ms 37.3
YOLOv8s 11.2M 28.6G ~25ms 44.9
YOLOv8m 25.9M 78.9G ~55ms 50.2
YOLOv8l 43.7M 165.2G ~95ms 52.9

*延迟数据基于 Snapdragon 888, ncnn, 4 线程大核, 640x640 输入,仅供参考

对于实时应用(30fps),需要单帧推理时间 < 33ms,YOLOv8n 和 YOLOv8s 是可行的选择。

路径 C: 全 MoE 导出(实验性)

核心思想: 尝试把完整的 MoE 结构导出到端侧,包括 Router 和所有 Expert。

这条路径目前是实验性的,成功率取决于具体的 MoE 实现方式和目标引擎的能力。基本思路是:

  1. 把 Router 的 top-k 选择改写为不含动态索引的等价计算
  2. 让所有 Expert 都执行,用 Router 权重做加权求和
  3. 用 ONNX 的 Where/Gather 算子表达选择逻辑
# 把动态路由改写为"全量执行 + 加权求和"
class MoELayerExportFriendly(nn.Module):
    def __init__(self, original_moe):
        super().__init__()
        self.router = original_moe.router
        self.experts = nn.ModuleList(original_moe.experts)

    def forward(self, x):
        # Router 输出权重(所有 Expert 的权重,不做 top-k 选择)
        weights = torch.softmax(self.router(x), dim=-1)

        # 全部 Expert 都执行
        expert_outputs = []
        for expert in self.experts:
            expert_outputs.append(expert(x))

        # 用 Router 权重做加权求和
        # 这是一个纯粹的算术运算,静态图可以表达
        output = torch.zeros_like(expert_outputs[0])
        for i, expert_out in enumerate(expert_outputs):
            output = output + expert_out * weights[:, i:i+1, None, None]

        return output

这条路径的问题:

实际上,这条路径只在 Expert 数量很少(2-3 个)、每个 Expert 很轻量的情况下可行。如果有 8 个 Expert,全量执行的计算量会让端侧延迟完全不可接受。

路径选择决策树

你的模型有 MoE 结构吗?
├── 否 → 标准部署流程(Ch23)
└── 是 → MoE 对精度提升显著吗?(对比有/无 MoE 的 mAP)
    ├── 否(< 1% mAP 提升) → 路径 B: 用标准 YOLOv8 替代
    └── 是(>= 1% mAP 提升)→ Expert 数量多少?
        ├── 2-3 个且轻量 → 路径 C: 尝试全量导出
        └── 4+ 个或计算密集 → 路径 A: 单专家蒸馏

大多数实际场景会走路径 A 或路径 B。路径 C 作为实验性选择,在你有充足时间探索且 Expert 数量少时可以尝试。


引擎选型:ncnn vs MNN vs ORT Mobile

确定了模型结构后(裁剪后的标准模型 or 原始标准 YOLO),下一个问题是选哪个引擎。Ch22 做了全面的横向对比,这里聚焦于 YOLO 端侧部署这个具体场景。

ncnn

包体积: 极小,arm64 下 libncnn.a 约 700KB-1MB。

YOLO 支持: 通过 PNNX 或 Ultralytics yolo export format=ncnn 直接支持。ncnn 社区有大量 YOLO 部署的示例代码和经验分享。

GPU 加速: Vulkan 后端,在支持 Vulkan 的设备上可以获得 GPU 加速。对中大型模型(YOLOv8s 及以上)有明显收益。

适合场景:

# ncnn 的 YOLO 部署一条龙
yolo export model=yolov8n.pt format=ncnn
# 输出的 .param + .bin 直接可用

MNN

包体积: 中等,libMNN.so 约 5MB。

YOLO 支持: 通过 ONNX → MNNConvert 路径支持。MNN 的 Express API 对 YOLO 后处理(NMS、box decoding)的支持比 ncnn 更灵活。

GPU 加速: OpenCL(Android)、Metal(iOS)。OpenCL 在部分高通芯片上性能优于 Vulkan。

适合场景:

# MNN 的 YOLO 部署
# 1. PyTorch → ONNX
python export_onnx.py

# 2. ONNX → MNN
./MNNConvert -f ONNX --modelFile yolov8n.onnx --MNNModel yolov8n.mnn

# 3. 量化(可选)
./MNNConvert -f ONNX --modelFile yolov8n.onnx --MNNModel yolov8n_w8.mnn --weightQuantBits 8

ORT Mobile

包体积: 较大,约 10MB+。

YOLO 支持: 直接加载 ONNX 格式,不需要额外的转换步骤。API 和服务器端 ONNX Runtime 一致,迁移成本极低。

GPU 加速: 通过 NNAPI(Android)、CoreML(iOS)等 EP 支持硬件加速。

适合场景:

# ORT Mobile 的 Python 推理示例(API 和服务器端完全一致)
import onnxruntime as ort
import numpy as np

# 和服务器端一模一样的代码
session = ort.InferenceSession("yolov8n.onnx")
input_name = session.get_inputs()[0].name
output = session.run(None, {input_name: input_data})

选型对照表

维度 ncnn MNN ORT Mobile
包体积 ~1MB ~5MB ~10MB+
YOLO 部署成熟度 最高
CPU 性能(轻量模型) 最优
GPU 加速 Vulkan OpenCL/Metal NNAPI/CoreML
API 标准化 自有 自有 ONNX 标准
Transformer/LLM 支持
社区生态 中文社区活跃 阿里系生态 微软生态
学习曲线 低(API 简单) 中(多种接口) 低(标准 API)
动态 shape 支持 有限
适合的 YOLO 型号 n/s n/s/m n/s/m/l

我的推荐

如果你在竞赛场景中需要快速部署 YOLO 到端侧,第一选择是 ncnn——原因很简单:Ultralytics 的 yolo export format=ncnn 一条命令搞定,社区有最多的 YOLO+ncnn 的示例代码和 troubleshooting 经验,遇到问题时能找到参考。

如果你需要在同一个 App 中同时跑 YOLO 检测和 LLM 对话(比如”拍照识别 + 自然语言解读”),选 MNN——它是唯一同时在 CNN 和 LLM 推理上都有竞争力的端侧引擎。

如果你的团队已经在服务器端大量使用 ONNX Runtime,选 ORT Mobile——一套 API 从云到端,减少维护成本。


完整技术方案模板

把前面的所有内容串起来,一个 YOLO 端侧部署项目的完整技术方案模板如下:

## 技术方案:[项目名] YOLO 端侧部署

### 1. 模型选型
- 基线模型:YOLOv8n(3.2M 参数)
- 输入尺寸:640x640
- 是否包含 MoE:[是/否]
  - 如果是:采用路径 [A/B/C],理由:[xxx]

### 2. 引擎选型
- 选用引擎:[ncnn/MNN/ORT Mobile]
- 选型理由:[包体积/性能/生态/团队经验]
- GPU 加速:[是/否],后端:[Vulkan/OpenCL/Metal]

### 3. 部署流水线
- 导出:[Ultralytics yolo export / torch.onnx.export / PNNX]
- 转换:[onnx2ncnn / MNNConvert / 直接 ONNX]
- 量化:[无 / fp16 / INT8 PTQ]
  - 校准集:[xxx]
  - 预期精度损失:[< x% mAP]

### 4. 性能目标
- 目标设备:[具体芯片/手机型号]
- 延迟要求:[< xx ms / > xx fps]
- 内存限制:[< xx MB]
- 模型大小限制:[< xx MB]

### 5. 验证计划
- 精度验证:在 [测试集] 上计算 mAP@0.5
- 性能验证:benchncnn / MNN benchmark / 自定义计时
- 稳定性验证:连续推理 1000 帧无崩溃

常见问题排查

Q: YOLOv8 导出到 ncnn 后推理结果偏差很大?

A: 最常见的原因是后处理不一致。YOLO 的后处理包括 anchor-free 解码、NMS、坐标还原——Ultralytics 的 yolo export format=ncnn 会把后处理包含在导出的模型中,但如果你手动导出 ONNX 再转 ncnn,后处理可能不在模型图中,需要自己实现。确认你的后处理逻辑和 Ultralytics 的 Python 推理结果一致。

Q: INT8 量化后 YOLO 的小物体检测精度掉很多?

A: 小物体检测对特征精度更敏感。几个可以尝试的方向:(1)对 backbone 的前几层(处理高分辨率特征图的层)保持 FP32 不量化;(2)增加校准集中小物体的比例;(3)从 YOLOv8n 换到 YOLOv8s——更大的模型对量化的耐受性更好。

Q: ncnn 上 YOLOv8 的 NMS 怎么做?

A: ncnn 没有内置 NMS 算子。需要在 C++ 侧自己实现。标准做法:

// 简化版 NMS
struct Detection {
    float x1, y1, x2, y2, score;
    int class_id;
};

float iou(const Detection& a, const Detection& b) {
    float inter_x1 = std::max(a.x1, b.x1);
    float inter_y1 = std::max(a.y1, b.y1);
    float inter_x2 = std::min(a.x2, b.x2);
    float inter_y2 = std::min(a.y2, b.y2);

    float inter_area = std::max(0.f, inter_x2 - inter_x1) *
                       std::max(0.f, inter_y2 - inter_y1);
    float union_area = (a.x2 - a.x1) * (a.y2 - a.y1) +
                       (b.x2 - b.x1) * (b.y2 - b.y1) - inter_area;

    return inter_area / (union_area + 1e-6f);
}

std::vector<Detection> nms(std::vector<Detection>& dets, float iou_threshold) {
    std::sort(dets.begin(), dets.end(),
              [](const Detection& a, const Detection& b) {
                  return a.score > b.score;
              });

    std::vector<Detection> result;
    std::vector<bool> suppressed(dets.size(), false);

    for (int i = 0; i < (int)dets.size(); i++) {
        if (suppressed[i]) continue;
        result.push_back(dets[i]);
        for (int j = i + 1; j < (int)dets.size(); j++) {
            if (!suppressed[j] && iou(dets[i], dets[j]) > iou_threshold) {
                suppressed[j] = true;
            }
        }
    }
    return result;
}

Q: 把 MoE Expert 裁剪为 1 个后,模型参数量和标准 YOLOv8 一样大吗?

A: 不一定。裁剪后的参数量取决于被保留的 Expert 的大小和原始 MoE 层在模型中的占比。如果 MoE 只用在 backbone 的最后一个 stage,裁剪后的参数量会非常接近标准 YOLOv8。


本章小结

MoE 模型到端侧是一个系统性挑战,根源在于 MoE 的动态路由、稀疏激活、实例级差异和端侧引擎的静态图假设之间的根本矛盾。

三条路径中,单专家蒸馏(路径 A)是最可靠的选择——它把问题转化为”训练一个标准模型”,从而可以利用整个标准部署流水线。标准 YOLOv8 替代(路径 B)是最务实的选择——如果 MoE 的收益不显著,不如简单直接。全 MoE 导出(路径 C)是实验性的,仅在 Expert 数量极少时可行。

引擎选型上,ncnn 是 YOLO 端侧部署的默认选择(最小包体积 + 最成熟的社区支持);MNN 适合需要同时跑 CNN + LLM 的混合场景;ORT Mobile 适合已有 ONNX 生态的团队。

至此,本系列 27 章的内容全部结束。从 Ch01 的”为什么需要端侧推理”到这一章的”把 YOLO 部署到手机上”,我们走过了推理引擎的基础概念、硬件约束、六大框架的架构设计、核心抽象的横向对比、以及从编译到部署的完整实操流程。希望这些内容能帮助你在实际项目中做出更好的技术决策。


下一步建议


导读目录:README.md 上一章:Ch26: 开源竞赛贡献路径——如何向 ncnn 提交第一个 PR 下一章:Ch28: 十大核心技术难点与三层矛盾