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: 图优化——编译器思维在推理引擎中的应用