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

Ch20: 后端组织——如何管理多个硬件加速器

Part 4: 核心抽象深度对比 前置章节:Ch19: 推理调度——从按需递归到图分区 后续章节:Ch21: 图优化——编译器思维在推理引擎中的应用


核心问题

一部手机上至少有三种可以跑推理的硬件:CPU(ARM 大小核)、GPU(Mali/Adreno/Apple GPU)、以及可能的 NPU/DSP(Hexagon、Neural Engine 等)。每种硬件有不同的编程接口、不同的内存空间、不同的数据格式偏好。推理引擎需要一种机制来管理这些硬件后端——让算子能够在不同硬件上执行,让数据能够在不同硬件之间传递,同时让上层代码尽可能不用关心”到底在哪个硬件上跑”。

六个框架的后端组织方式差异巨大,从 ncnn 的”编译时锁定”到 ORT 的”插件市场”,覆盖了封闭到开放的全部谱系。


工厂与分包商的类比

想象一个手机壳制造商,需要在不同的工厂里生产不同材质的手机壳。六个推理框架就像六种不同的供应链管理模式:

ncnn 像一个自营工厂——在建厂时(编译时)就决定好了产线配置,产线在建厂时固定,运行时不能新增。

MNN 像一个中央工厂 + 万能转接车间——标准化产线接口(Backend 工厂)加上 Geometry Compiler 兜底,新供应商只需要掌握 20 种基本工艺。

ORT 像一个开放的工业园区——标准入驻接口(EP),企业竞价订单(GetCapability),可通过动态库运行时入驻。

TNN 像一个连锁品牌总部——标准化产品规格(BaseLayer),各门店(AbstractDevice)本地化生产(CreateLayerAcc)。

Paddle-Lite 像一个硬件抽象层市场——极简接口(NNAdapter HAL,8 个函数指针),任何厂商通过 dlopen 动态接入。

MACE 像一个分车间管理的大工厂——总部(MaceEngine)先分流(Flow),每个车间有自己的运行时(Runtime 多态),DSP 车间整批外包(Hexagon 整图下推)。


ncnn:编译时分层注册表

ncnn 的后端组织是六个框架中最”静态”的——所有后端在编译时就确定了,运行时没有动态选择的余地。

分层覆盖机制

ncnn 通过编译时的 Layer 注册表层层覆盖来实现后端选择。每个更具体的平台实现覆盖更通用的实现:

层级 0:通用实现(C++ 标量代码)
  Convolution → convolution.cpp

层级 1:ARM 实现(NEON intrinsics)
  Convolution_arm → convolution_arm.cpp  ← 覆盖层级 0

层级 2:x86 实现
  层级 2a:AVX2
  层级 2b:AVX-512  ← 覆盖 2a

层级 3:Vulkan GPU
  Convolution_vulkan → convolution_vulkan.cpp

编译时 CMake 选项决定编译哪些层级:

option(NCNN_VULKAN "build with vulkan" OFF)
option(NCNN_AVX2 "build with avx2" ON)
option(NCNN_AVX512 "build with avx-512" OFF)

生成的注册表选择最优实现:

Layer* Convolution_final_layer_creator(void*) {
#if NCNN_VULKAN
    if (vkdev) return new Convolution_vulkan;
#endif
#if NCNN_AVX512
    return new Convolution_x86_avx512;
#elif NCNN_AVX2
    return new Convolution_x86_avx2;
#elif __ARM_NEON
    return new Convolution_arm;
#else
    return new Convolution;
#endif
}

VkMat:GPU 后端的独立数据容器

ncnn 的 Vulkan 后端使用 VkMat(而非 Mat)管理 GPU 内存,CPU 和 GPU 之间的数据传输是显式的上传/下载操作:

class VkMat {
public:
    VkBuffer buffer;
    VkDeviceMemory memory;
    size_t buffer_offset;
    int dims;
    int w, h, d, c;
    size_t elempack;
    VkAllocator* allocator;
};

设计取舍

编译时后端选择最简单——没有运行时查找和调度开销。代价是一个编译好的 ncnn 库只支持编译时选中的后端组合,且 CPU 和 Vulkan 是两个独立路径——不能在同一次推理中混合使用。


MNN:Backend 工厂 + Geometry Compiler 降低门槛

MNN 的后端组织围绕 Backend 基类和 BackendCreator 工厂构建,Geometry Compiler 大幅降低了新后端的实现门槛。

Backend 基类

class Backend {
public:
    // 创建算子实现
    virtual Execution* onCreate(const std::vector<Tensor*>& inputs,
                                 const std::vector<Tensor*>& outputs,
                                 const MNN::Op* op) = 0;
    // 内存管理
    virtual bool onAcquireBuffer(const Tensor* tensor,
                                  StorageType type) = 0;
    virtual bool onReleaseBuffer(const Tensor* tensor,
                                  StorageType type) = 0;
    // 数据拷贝(设备间传输)
    virtual void onCopyBuffer(const Tensor* src,
                               const Tensor* dst) const = 0;

    MNNForwardType type() const { return mType; }
};

BackendCreator 注册

class BackendCreator {
public:
    virtual Backend* onCreate(const Backend::Info& info) const = 0;
};

// 注册示例
static void registerBackend() {
    MNNInsertExtraBackendCreator(MNN_FORWARD_OPENCL,
                                  new OpenCLBackendCreator);
    MNNInsertExtraBackendCreator(MNN_FORWARD_VULKAN,
                                  new VulkanBackendCreator);
}

Geometry Compiler 降低门槛

Geometry Compiler 让新后端只需实现约 20 个核心算子(MatMul、Conv 1x1、Binary、Unary、Reduce、Raster 等)就能”间接支持”上百个复杂算子:

class MyNPUBackend : public Backend {
    Execution* onCreate(const std::vector<Tensor*>& inputs,
                         const std::vector<Tensor*>& outputs,
                         const MNN::Op* op) override {
        switch (op->type()) {
            case OpType_MatMul:    return new MyNPUMatMul(this);
            case OpType_BinaryOp:  return new MyNPUBinary(this);
            case OpType_UnaryOp:   return new MyNPUUnary(this);
            case OpType_Raster:    return new MyNPURaster(this);
            // ~20 个核心算子
            default:
                return nullptr; // 返回 null → 自动触发 Geometry 降级
        }
    }
};

传统方式(ncnn/TNN)要求新后端实现 100+ 个算子;MNN 方式只需 20 个核心算子,其余通过 GeometryComputer 降级组合。代价是降级算子比手写专用实现慢,但提供了”先能跑、后优化”的渐进路径。

设计取舍

MNN 的 Backend 工厂 + Geometry Compiler 是六个框架中对新后端最友好的设计。核心竞争力在于”20 个算子覆盖全部算子集”的降级兜底,大幅降低了硬件厂商的接入成本。


ONNX Runtime:Execution Provider 插件体系

ORT 的后端组织是六个框架中最”开放”的——通过 EP 插件体系,第三方可以独立开发、发布甚至运行时动态加载自己的后端。

ExecutionProvider 接口

class IExecutionProvider {
public:
    // 核心:报告自己能处理哪些子图
    virtual std::vector<std::unique_ptr<ComputeCapability>>
    GetCapability(const GraphViewer& graph,
                   const IKernelLookup& kernel_lookup) const;

    // 获取内存分配器
    virtual AllocatorPtr GetAllocator(OrtMemType mem_type) const;

    // 编译子图(TensorRT 等编译型后端使用)
    virtual common::Status Compile(
        const std::vector<FusedNodeAndGraph>& fused_nodes,
        std::vector<NodeComputeInfo>& compute_funcs);

    virtual const std::string& Type() const = 0;
};

GetCapability 竞价

每个 EP 通过 GetCapability “竞价”自己能处理的算子。不同类型的 EP 有不同的竞价策略:

// 逐节点型 EP(如 CUDA EP):逐个节点查找 kernel
CUDAExecutionProvider::GetCapability(...) {
    for (auto& node : graph.Nodes()) {
        if (kernel_lookup.LookUpKernel(node, Type())) {
            // 有 CUDA kernel → 认领单个节点
            capabilities.push_back(MakeCapability({node.Index()}));
        }
    }
}

// 编译型 EP(如 TensorRT EP):认领尽可能大的连续子图
TensorrtExecutionProvider::GetCapability(...) {
    // 子图越大,TensorRT 优化效果越好
    auto subgraph = FindLargestSupportedSubgraph(graph);
    capabilities.push_back(MakeCapability(subgraph));
}

动态库加载

ORT 支持运行时通过动态库加载 EP:

Ort::SessionOptions options;
// 内置 EP
options.AppendExecutionProvider_CUDA(cuda_opts);
// 动态库 EP
options.RegisterExecutionProviderLibrary("libmy_ep.so");

截至 2024 年 ORT 已有 30+ 个官方和第三方 EP。

CPU EP 兜底

CPU EP 内置、不可移除,作为所有其他 EP 的兜底。未被任何 EP 认领的算子自动分配给 CPU EP,确保任何有效 ONNX 模型都能运行。

设计取舍

ORT 的 EP 体系最开放——GetCapability 竞价让多 EP 在同一次推理中协作,动态库加载让后端开发完全独立于 ORT 核心。代价是 EP 间数据传输的开销、图分区质量依赖 EP 实现、以及 30+ EP 的维护成本。


TNN:AbstractDevice 设备抽象

TNN 通过 AbstractDevice 抽象类管理后端,每种硬件是一个 Device 实例。

AbstractDevice 接口

class AbstractDevice {
public:
    // 创建硬件特定算子实现
    virtual std::shared_ptr<LayerAcc> CreateLayerAcc(
        LayerType type,
        const std::vector<Blob*>& inputs,
        const std::vector<Blob*>& outputs) = 0;

    // 内存管理
    virtual Status Allocate(void** handle, MatType mat_type,
                             DimsVector dims) = 0;
    virtual Status Free(void* handle) = 0;

    // 设备间数据拷贝
    virtual Status CopyToDevice(BlobHandle* dst, const BlobHandle* src,
                                  int size, DataFormat format) = 0;
    virtual Status CopyFromDevice(BlobHandle* dst, const BlobHandle* src,
                                    int size, DataFormat format) = 0;
};

Device 注册

// ARM 设备注册
class ArmDevice : public AbstractDevice {
    std::shared_ptr<LayerAcc> CreateLayerAcc(
        LayerType type, ...) override {
        switch (type) {
            case LAYER_CONVOLUTION:
                return std::make_shared<ArmConvLayerAcc>();
            case LAYER_POOLING:
                return std::make_shared<ArmPoolingLayerAcc>();
            default:
                return nullptr; // 不支持 → 报错(没有降级兜底)
        }
    }
};

// 静态注册
static auto _register = []() {
    GetDeviceMap()[DEVICE_ARM] = new ArmDevice();
    return true;
}();

设计取舍

TNN 的 AbstractDevice 是 ncnn 编译时绑定的”运行时化”升级——允许运行时选择设备,但整个推理在单个设备上执行(不支持多后端图分区协作)。接口比 ORT 的 EP 简单得多,但缺少 Geometry Compiler 兜底——CreateLayerAcc 返回 nullptr 就直接报错。


Paddle-Lite:NNAdapter HAL + dlopen 动态加载

Paddle-Lite 的后端组织最独特之处是 NNAdapter——类似 Android HAL 的极简硬件抽象层。

NNAdapter HAL:8 个函数指针

硬件厂商只需实现 8 个 C 函数就能接入 Paddle-Lite:

typedef struct {
    int (*open_device)(const char* name, void** device);
    void (*close_device)(void* device);
    int (*create_context)(void* device, const char* properties,
                           int count, void** context);
    void (*destroy_context)(void* context);
    int (*create_compilation)(void* model, void* context,
                               void** compilation);
    void (*destroy_compilation)(void* compilation);
    int (*create_execution)(void* compilation, void** execution);
    int (*compute_execution)(void* execution);
} NNAdapterDriver;

dlopen 动态加载

NNAdapterDriver* LoadDriver(const char* driver_name) {
    std::string lib_path = "lib" + std::string(driver_name) + ".so";
    void* handle = dlopen(lib_path.c_str(), RTLD_LAZY);

    auto driver = new NNAdapterDriver;
    driver->open_device = (decltype(driver->open_device))
        dlsym(handle, "NNAdapterDevice_open");
    // ... 获取所有 8 个函数指针
    return driver;
}

MIR 子图划分

MIR 优化 pass 中的 nnadapter_subgraph_pass 负责将支持的算子合并为子图,替换为 SubgraphOp 通过 NNAdapter 驱动执行:

原始图: Conv → BN → ReLU → Pool → FC
                ↓
nnadapter_subgraph_pass 检查每个节点
                ↓
NNAdapter支持: Conv, BN, ReLU, Pool    不支持: FC
                ↓
结果: [SubgraphOp(Conv→BN→ReLU→Pool)] → FC(CPU)

设计取舍

NNAdapter HAL 是最像系统级抽象的后端组织方式。8 个函数指针的极简接口和 dlopen 加载直接借鉴了 Android HAL 设计,极大降低了硬件厂商的接入成本。在国产 NPU 生态(寒武纪、华为 Kirin NPU、瑞芯微 RKNN 等)中覆盖较好。代价是 C 函数指针间接调用的开销,以及子图编译(create_compilation)通常很慢,适合”编译一次、推理多次”的场景。


MACE:Flow 分流 + Runtime 多态

MACE 的后端组织通过 Flow 分流Runtime 多态两个机制组合实现。

Runtime 多态

class Runtime {
public:
    virtual MaceStatus AllocateBuffer(Buffer* buffer,
                                       index_t nbytes) = 0;
    virtual MaceStatus ReleaseBuffer(Buffer* buffer) = 0;
    virtual MaceStatus MapBuffer(Buffer* buffer,
                                  AllocatorMapType map_type,
                                  void** mapped_ptr) = 0;
    virtual MaceStatus UnMapBuffer(Buffer* buffer) = 0;
    virtual RuntimeType GetRuntimeType() const = 0;
};

class CpuRuntime : public Runtime {
    MaceStatus AllocateBuffer(Buffer* buffer, index_t nbytes) override {
        void* data = memalign(64, nbytes);
        static_cast<CpuBuffer*>(buffer)->SetData(data);
        return MACE_SUCCESS;
    }
};

class OpenCLRuntime : public Runtime {
    MaceStatus AllocateBuffer(Buffer* buffer, index_t nbytes) override {
        cl_mem mem = clCreateBuffer(context_, CL_MEM_READ_WRITE,
                                     nbytes, nullptr, &err);
        static_cast<OpenCLBuffer*>(buffer)->SetCLMem(mem);
        return MACE_SUCCESS;
    }
};

Flow 分流

MaceEngine 根据目标后端选择不同的 Flow(Ch19 已讲),不同 Flow 使用不同 Runtime:

class MaceEngine::Impl {
    MaceStatus Init(const NetDef* net_def, const MaceEngineConfig& config) {
        if (config.runtime_type() == RT_HEXAGON) {
            flow_.reset(new HexagonFlow(config));
        } else {
            flow_.reset(new CommonFlow(config));
        }
        return flow_->Init(net_def, ...);
    }
};

Hexagon 整图下推 vs OpenCL 分 Op

MACE 的后端协作有两种截然不同的模式。CPU 和 OpenCL 可以分 Op 执行——每个 Operation 根据自己的 runtime 类型在对应的 Runtime 上分配内存和执行计算。Hexagon DSP 则是整图下推——整个网络作为黑盒发送给 Hexagon 运行时执行:

// Hexagon 整图下推
class HexagonFlow : public BaseFlow {
    MaceStatus Run(...) override {
        hexagon_wrapper_->ExecuteGraph(input, &output);
        return MACE_SUCCESS;
    }
};

// CPU/OpenCL 分 Op 执行
class CommonFlow : public BaseFlow {
    MaceStatus Run(...) override {
        for (auto& op : net_->operators()) {
            op->Run(context); // 每个 op 在自己的 runtime 上执行
        }
        return MACE_SUCCESS;
    }
};

设计取舍

MACE 的 Flow + Runtime 组合是一种”策略模式”——不同硬件类型走完全不同的执行路径,而不是用一种统一的接口勉强适配所有硬件。好处是每种硬件用最适合自己的方式工作(Hexagon 整图下推发挥 DSP 全局优化能力,OpenCL 逐 Op 利用 GPU 并行)。坏处是代码路径分裂——不同后端的逻辑独立维护,难以共享优化策略。


横向对比

维度 ncnn MNN ORT TNN Paddle-Lite MACE
后端选择时机 编译时 运行时(工厂) 运行时(EP注册) 运行时(Device) 运行时(dlopen) 运行时(Flow分流)
多后端协作 不支持 Session级切换 图分区竞价 单后端执行 子图划分 Flow分流
新后端成本 高(所有算子) 低(~20核心算子) 中(EP接口+kernel) 高(所有LayerAcc) 低(8个函数) 中(Runtime+Op)
降级/兜底 Geometry Compiler CPU EP兜底 无(报错) 无(SubgraphOp兜底)
动态加载 不支持 不支持 动态库EP 不支持 dlopen 不支持
后端数量(2024) ~4 ~5 30+ ~5 10+ ~4
接口复杂度 无接口(编译时) Backend(~5方法) EP(~10方法) Device(~6方法) HAL(8函数指针) Runtime(~5方法)

核心取舍分析

六种后端组织方式的分歧可以归结为两个核心权衡。

第一个权衡是开放性 vs 性能。ORT 的 EP 插件体系最开放——30+ 后端、动态加载、竞价机制——但每一层抽象都有间接调用开销,图分区可能产生次优的子图切分。ncnn 的编译时锁定最封闭但最快——编译器直接内联最优实现,零运行时开销。

第二个权衡是新后端的接入成本 vs 运行效率。MNN 的 Geometry Compiler 和 Paddle-Lite 的 NNAdapter HAL 分别从两个角度降低了接入成本——MNN 通过降级减少了需要实现的算子数(从 100+ 降到 20),Paddle-Lite 通过极简接口减少了每个算子的实现复杂度(8 个函数搞定一切)。但降级算子比手写慢,NNAdapter 的 C 函数指针比直接调用慢。

对于竞赛场景,ncnn 的编译时后端组织是最直接的——你只需要关注 CPU 后端(可能加 Vulkan),编译选项决定一切。对于工业部署场景,ORT 的 EP 体系或 Paddle-Lite 的 NNAdapter 更适合——它们能适配更多硬件平台,而且新硬件的接入不需要修改引擎核心代码。对于快速原型验证,MNN 的 Geometry Compiler 兜底策略最友好——即使你的新后端只实现了几个基本算子,模型也能跑起来(虽然可能不快)。


本章小结

后端组织是推理引擎”连接硬件”的桥梁。六个框架从 ncnn 的编译时分层覆盖(最简单、最封闭)到 ORT 的 EP 插件竞价(最复杂、最开放),再到 Paddle-Lite 的 NNAdapter HAL(最系统化),覆盖了后端管理的完整设计谱系。MNN 的 Geometry Compiler 降级机制和 ORT 的 CPU EP 兜底策略,分别从”减少算子实现数”和”保底执行能力”两个角度解决了”新后端能不能跑起来”的问题。

有了后端之后,下一个问题是:模型加载进来的原始计算图通常不是最优的——它可能包含冗余操作、可融合的算子对、可预计算的常量表达式。下一章我们将看到六个框架如何用”图优化”来改善计算图的执行效率。


导读目录:README.md 上一章:Ch19: 推理调度——从按需递归到图分区 下一章:Ch21: 图优化——编译器思维在推理引擎中的应用