Ch18: 算子抽象——设计哲学的分水岭
Part 4: 核心抽象深度对比 前置章节:Ch17: 数据容器——同一个问题的六种回答 后续章节:Ch19: 推理调度——从按需递归到图分区
核心问题
推理引擎的工作是执行神经网络中的每一层计算——卷积、矩阵乘法、激活函数、归一化等等。每种计算在引擎内部被抽象为一个”算子”(Operator)。但”算子”到底应该是什么?它应该只负责计算,还是也负责推导输出形状?一个算子应该是一个整体,还是应该分成”描述”和”实现”两部分?当同一个算子有 CPU float32、CPU int8、GPU OpenCL 三种实现时,怎么组织这些实现的关系?
这些问题的回答定义了一个框架的”灵魂”——算子抽象是整个推理引擎中最能体现设计哲学的地方。六个框架在这个问题上的分歧之大,甚至可以说它们根本不是在做”同一件事”。
餐厅菜单的类比
想象六家不同风格的餐厅,它们都提供”番茄炒蛋”这道菜,但在菜单设计、后厨组织和点单流程上截然不同:
ncnn 像一家家庭小餐馆——每道菜由一位厨师从头到尾完成,从估算用料到炒菜出锅都是他一个人(shape 推导和 compute 耦合在 Layer 类中)。菜单是开店前就印好的固定菜单(编译时注册表),不支持运营中新增菜品。
MNN 像一家米其林餐厅的厨房——有三条并行的工作线:一条是前台菜单(Op,用 FlatBuffers 声明),一条是真正做菜的厨师团队(Execution,分 onResize 备菜和 onExecute 炒菜两步),还有一条是”万能替补”——如果某个厨师不会做某道特殊菜,替补可以用基本技法组合出来(GeometryComputer 降级)。三层架构让这家餐厅能应对几乎任何菜品需求。
ORT 像一个美食广场的管理方——它不雇佣任何厨师,而是制定了一套标准的摊位规格和点单流程(OpKernel + KernelRegistry)。每个入驻的商户(Execution Provider)按照统一接口注册自己的菜品实现。而且菜单有版本号(opset 版本化注册),去年的”番茄炒蛋 v1”和今年改良过的”番茄炒蛋 v13”可以同时存在。
TNN 像一家连锁餐厅——采用总部和门店分离的管理模式。总部(BaseLayer)负责菜品标准化、用料估算(shape 推导),各门店(LayerAcc)负责按当地口味(CPU/GPU/DSP 等不同硬件)实际烹饪(compute)。开新门店时由”区域经理”(Device::CreateLayerAcc)来指派厨师。
Paddle-Lite 像一家自助餐厅——食材台上的每道菜都贴着三个标签:来自哪个产地(Target)、什么级别的用料(Precision)、什么摆盘风格(Layout)。顾客点菜时,系统按这三个维度自动匹配最合适的那份(三维 kernel 派发)。
MACE 像一家融合料理餐厅——主厨(Operation)不直接上手,而是带着一群”专项助手”(Delegator),每个助手精通特定风格的烹饪。当需要做一道菜时,主厨根据五个维度(运行时、数据类型、通道数等)从助手库中惰性选择最合适的那位。但如果菜品是”整桌宴席”(Hexagon 整图下推),就绕过助手体系直接外包给宴会厅。
类比到此为止,让我们看看每个框架的实际代码结构。
ncnn:Layer 单类继承,简单到极致
ncnn 的算子抽象是六个框架中最简单的——只有一个类 Layer,所有具体算子都直接继承它。没有 Op/Kernel 分离,没有工厂模式,没有注册表查找——一个类搞定一切。
Layer 基类
// ncnn/src/layer.h(简化版)
class Layer {
public:
// 加载算子的参数和权重
virtual int load_param(const ParamDict& pd);
virtual int load_model(const ModelBin& mb);
// 形状推导 + 计算耦合在一起
virtual int forward(const Mat& bottom_blob, Mat& top_blob,
const Option& opt) const;
virtual int forward_inplace(Mat& bottom_top_blob,
const Option& opt) const;
virtual int forward(const std::vector<Mat>& bottom_blobs,
std::vector<Mat>& top_blobs,
const Option& opt) const;
std::string type;
int typeindex;
int one_blob_only;
int support_inplace;
};
注意关键设计决策:shape 推导和 compute 是耦合的。forward 方法既负责确定输出的大小(通过直接创建 top_blob),也负责执行实际计算。没有单独的”推导输出形状”阶段。
具体算子与平台特化
// ncnn/src/layer/convolution.h
class Convolution : public Layer {
public:
int load_param(const ParamDict& pd) override;
int load_model(const ModelBin& mb) override;
int forward(const Mat& bottom_blob, Mat& top_blob,
const Option& opt) const override;
int num_output;
int kernel_w, kernel_h;
int stride_w, stride_h;
int pad_left, pad_right, pad_top, pad_bottom;
Mat weight_data;
Mat bias_data;
};
// ARM 平台特化版本:继承通用版,覆盖 forward
// ncnn/src/layer/arm/convolution_arm.h
class Convolution_arm : public Convolution {
public:
int forward(const Mat& bottom_blob, Mat& top_blob,
const Option& opt) const override;
Mat weight_data_tm; // ARM NEON 预打包权重
};
编译时注册表
ncnn 的算子注册在编译时完成——通过宏定义生成一个静态的创建函数数组:
// 编译时生成的注册代码(简化版)
static const layer_registry_entry layer_registry[] = {
{"Convolution", Convolution_final_layer_creator},
{"ReLU", ReLU_final_layer_creator},
{"Pooling", Pooling_final_layer_creator},
};
// _final_layer_creator 根据编译选项选择最优实现
// 编译了 ARM NEON → Convolution_arm
// 编译了 AVX-512 → Convolution_x86_avx512
// 啥也没有 → Convolution(通用版)
覆盖链是:通用 → ARM/x86 → AVX2/AVX-512/NEON → Vulkan。编译时就确定了一条固定的覆盖路径。
设计取舍
ncnn 的算子抽象是六个框架中最简单的,也是最”死板”的。简单意味着代码路径清晰、开发者容易理解、编译优化友好。死板意味着:不能在运行时动态选择后端(编译时就定了),不能轻松添加新硬件后端(需要修改注册表生成逻辑),shape 推导和 compute 耦合(不能单独做形状推导而不执行计算)。
MNN:三层架构的精密分工
MNN 的算子抽象是六个框架中最精细的——分为 Op 声明层、Execution 执行层、GeometryComputer 降级层三个层次。
第一层:Op(FlatBuffers 声明)
MNN 的算子元数据用 FlatBuffers schema 定义——意味着算子参数可以直接从模型文件中零拷贝读取:
// MNN/schema/default/MNN.fbs(简化版)
table Convolution2DCommon {
padX: int;
padY: int;
kernelX: int = 1;
kernelY: int = 1;
strideX: int = 1;
strideY: int = 1;
dilateX: int = 1;
dilateY: int = 1;
outputCount: int;
group: int = 1;
padMode: PadMode = CAFFE;
relu: bool = false;
relu6: bool = false;
}
Op 层只是”声明”——描述算子类型和参数,不包含计算逻辑。
第二层:Execution(onResize + onExecute)
Execution 是实际执行计算的类,核心在于把”准备”和”执行”分成两步:
// MNN/source/core/Execution.hpp(简化版)
class Execution {
public:
Execution(Backend* backend) : mBackend(backend) {}
// 第一步:根据输入形状做准备(分配工作空间、重排权重等)
virtual ErrorCode onResize(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs);
// 第二步:执行实际计算
virtual ErrorCode onExecute(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs);
protected:
Backend* mBackend;
};
onResize 和 onExecute 的分离支持动态形状推理。当输入形状变化时只需重新 onResize,不变时可跳过——连续推理相同尺寸输入时节省大量初始化开销。
// CPU Convolution 实现示例
class CPUConvolution : public Execution {
public:
ErrorCode onResize(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs) override {
auto input = inputs[0];
// 根据输入形状选最优算法:Im2Col+GEMM? Winograd? 直接卷积?
mImpl = selectBestImpl(input->shape(), mCommon);
// 分配工作空间
mWorkspace.reset(Tensor::createDevice<float>(
{mImpl->workspaceSize()}));
mBackend->onAcquireBuffer(mWorkspace.get(), Backend::DYNAMIC);
return NO_ERROR;
}
ErrorCode onExecute(const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs) override {
return mImpl->execute(inputs[0], outputs[0], mWorkspace.get());
}
private:
std::shared_ptr<ConvolutionImpl> mImpl;
std::shared_ptr<Tensor> mWorkspace;
};
第三层:GeometryComputer(降级)
GeometryComputer 是 MNN 最独特的设计——把复杂算子”降级”成若干基本算子的组合:
// 例:LayerNorm 的降级实现(简化版)
class GeometryLayerNorm : public GeometryComputer {
bool onCompute(const Op* op,
const std::vector<Tensor*>& inputs,
const std::vector<Tensor*>& outputs,
Context& context,
CommandBuffer& buffer) const override {
// LayerNorm = Mean + Sub + Square + Mean + Add(eps) + Sqrt + Div + Mul + Add
// 每一步都是基本算子,后端只要支持这些基本算子就行
// 中间张量用 MEMORY_VIRTUAL 避免内存分配
return true;
}
};
GeometryComputer 的意义在于:当一个新后端只实现了 20 个基本算子时,通过降级就能”间接支持”上百个复杂算子。新后端不需要为每个算子都写专门实现。
三层协作流程
引擎执行一个算子时的查找顺序:
- 当前 Backend 有该算子的 Execution 实现?有则直接用(最高性能)
- 没有,GeometryComputer 有该算子的降级规则?有则降级成基本算子组合
- 都没有,报错
这种”三层兜底”机制让常用算子有手写的高性能实现,罕见算子通过降级来支持。
ONNX Runtime:OpKernel 与 opset 版本化
ORT 的算子抽象围绕两个核心概念构建:OpKernel 和 KernelRegistry,并引入了opset 版本化这个独特概念。
OpKernel 基类
// onnxruntime/core/framework/op_kernel.h(简化版)
class OpKernel {
public:
explicit OpKernel(const OpKernelInfo& info) : info_(info) {}
// 唯一的核心方法:执行计算
virtual Status Compute(OpKernelContext* context) const = 0;
const OpKernelInfo& Info() const { return info_; }
protected:
OpKernelInfo info_;
};
和 ncnn Layer 对比:OpKernel 只有一个 Compute 方法,参数加载的职责交给了 OpKernelInfo,OpKernel 自身只关注计算。
opset 版本化注册
ONNX 标准中同一个算子在不同版本的规范中可能有不同语义。ORT 通过版本化注册来管理:
// opset 5 到 13 的 Reshape
ONNX_CPU_OPERATOR_VERSIONED_KERNEL(
Reshape, 5, 13,
KernelDefBuilder()
.TypeConstraint("T", DataTypeImpl::AllFixedSizeTensorTypes()),
Reshape_5_13
);
// opset 14 及以后的 Reshape
ONNX_CPU_OPERATOR_KERNEL(
Reshape, 14,
KernelDefBuilder()
.TypeConstraint("T", DataTypeImpl::AllFixedSizeTensorTypes())
.InputMemoryType(OrtMemTypeCPUInput, 1),
Reshape_14
);
版本化注册是 ORT 独有的——其他五个框架都没有 opset 版本概念。好处是完美兼容新旧模型:opset 7 和 opset 18 的模型可以用同一个引擎运行。
KernelRegistry:分层查找
class KernelRegistry {
public:
Status Register(KernelCreateInfo&& create_info);
// 根据节点信息查找匹配的 kernel
Status TryFindKernel(const Node& node,
ProviderType exec_provider,
const KernelCreateInfo** out) const;
private:
// key: (op_name, domain, exec_provider)
KernelCreateMap kernel_creator_fn_map_;
};
每个 ExecutionProvider 有自己的注册表,查找时依次搜索:EP 注册表 → 全局注册表。
具体 kernel 实现
class Reshape_14 : public OpKernel {
public:
explicit Reshape_14(const OpKernelInfo& info) : OpKernel(info) {}
Status Compute(OpKernelContext* context) const override {
const Tensor* input = context->Input<Tensor>(0);
const Tensor* shape_tensor = context->Input<Tensor>(1);
auto shape_data = shape_tensor->Data<int64_t>();
TensorShape output_shape(shape_data, shape_tensor->Shape().Size());
Tensor* output = context->Output(0, output_shape);
if (input->DataRaw() != output->MutableDataRaw()) {
memcpy(output->MutableDataRaw(), input->DataRaw(),
input->SizeInBytes());
}
return Status::OK();
}
};
设计取舍
ORT 的算子抽象是最”标准化”的。opset 版本化确保向后兼容性,分层 KernelRegistry 让第三方后端独立注册 kernel。代价是间接层较多(版本匹配 + 类型约束匹配 + EP 查找),以及同一算子多版本实现的代码膨胀。
TNN:BaseLayer + LayerAcc 双层分离
TNN 的算子抽象介于 ncnn 的极简和 MNN 的精细之间——把算子分成 BaseLayer(shape 推导)和 LayerAcc(硬件计算)两层。
BaseLayer:硬件无关的形状推导
// TNN/source/tnn/layer/base_layer.h(简化版)
class BaseLayer {
public:
virtual Status InferOutputShape(bool ignore_error = false);
virtual Status InferOutputDataType();
virtual Status Init(Context* context, LayerParam* param,
LayerResource* resource,
std::vector<Blob*>& inputs,
std::vector<Blob*>& outputs,
AbstractDevice* device);
virtual Status Forward();
protected:
LayerParam* param_ = nullptr;
LayerResource* resource_ = nullptr;
std::vector<Blob*> input_blobs_;
std::vector<Blob*> output_blobs_;
std::shared_ptr<LayerAcc> layer_acc_; // 指向硬件特定实现
};
LayerAcc:硬件特定的计算
class LayerAcc {
public:
virtual Status Init(Context* context, LayerParam* param,
LayerResource* resource,
const std::vector<Blob*>& inputs,
const std::vector<Blob*>& outputs);
virtual Status Reshape(const std::vector<Blob*>& inputs,
const std::vector<Blob*>& outputs);
virtual Status Forward(const std::vector<Blob*>& inputs,
const std::vector<Blob*>& outputs);
};
Device::CreateLayerAcc 工厂方法
BaseLayer 初始化时通过 Device 工厂创建对应硬件的 LayerAcc:
Status BaseLayer::Init(Context* context, ..., AbstractDevice* device) {
// 1. 推导输出形状(硬件无关,只写一次)
InferOutputShape();
// 2. 让 Device 创建硬件特定的 LayerAcc
layer_acc_ = device->CreateLayerAcc(param_->type,
input_blobs_, output_blobs_);
// 3. 初始化 LayerAcc
layer_acc_->Init(context, param_, resource_,
input_blobs_, output_blobs_);
return TNN_OK;
}
// 前向计算直接委托
Status BaseLayer::Forward() {
return layer_acc_->Forward(input_blobs_, output_blobs_);
}
和 ncnn 的对比
ncnn 中 Convolution 和 Convolution_arm 通过继承连接——如果要同时支持 ARM、x86、OpenCL 三个后端,需要建继承树,shape 推导代码可能在基类和子类间重复。TNN 把 shape 推导提取到 BaseLayer 中(只写一次),硬件计算放到 LayerAcc 中(每个后端各写一套),通过工厂方法在运行时组装。添加新后端更清晰——写一套 LayerAcc,注册到 Device。
设计取舍
TNN 的双层设计是 ncnn 单层设计的”结构化升级”——解决了 shape/compute 耦合问题,多后端支持更有条理。但相比 MNN 三层架构,缺少 GeometryComputer 这样的万能降级机制——如果某个后端缺少某个算子的 LayerAcc,就直接报错,没有自动降级的兜底。
Paddle-Lite:OpLite + KernelLite 三维派发
Paddle-Lite 的算子抽象最大的特色是三维 kernel 派发——每个 kernel 都在 Target(硬件)、Precision(精度)、Layout(布局) 三个维度上精确标定位置。
OpLite:算子声明
// paddle-lite/lite/core/op_lite.h(简化版)
class OpLite {
public:
// 初始化:从 Scope 中获取输入输出变量
virtual bool CheckShape() const;
virtual bool InferShapeImpl() const;
// 关键:附着到特定 kernel
virtual bool AttachImpl(const cpp::OpDesc& op_desc,
lite::Scope* scope);
// 获取所有可选 kernel 的 Place 列表
virtual std::vector<Place> GetKernelPlaces() const;
protected:
std::shared_ptr<KernelLite> kernel_; // 被选中的 kernel
lite::Scope* scope_;
};
KernelLite:三维坐标标定
// paddle-lite/lite/core/kernel.h(简化版)
class KernelLite {
public:
virtual void Run() = 0;
// 这个 kernel 的"坐标":属于哪个硬件、什么精度、什么布局
Place place() const { return place_; }
// kernel 的别名(用于调试和选择)
virtual std::string name() const = 0;
protected:
Place place_; // Place = {TargetType, PrecisionType, DataLayoutType}
};
// Place 结构体:三维坐标
struct Place {
TargetType target; // Host / X86 / ARM / OpenCL / Metal / ...
PrecisionType precision; // Float / Int8 / FP16 / Int32 / ...
DataLayoutType layout; // NCHW / NHWC / NC4HW4 / Any / ...
};
三维派发机制
当调度器需要为一个 Op 选择 kernel 时,它会根据当前的”有效 Place”列表进行匹配:
// 简化版的 kernel 选择逻辑
KernelLite* SelectBestKernel(OpLite* op,
const std::vector<Place>& valid_places) {
auto candidates = op->GetKernelPlaces();
// 按优先级匹配
for (const auto& place : valid_places) {
for (auto& kernel : candidates) {
if (kernel.place().target == place.target &&
kernel.place().precision == place.precision &&
kernel.place().layout == place.layout) {
return &kernel; // 精确匹配
}
}
}
// 降级匹配:放宽 layout 要求
for (const auto& place : valid_places) {
for (auto& kernel : candidates) {
if (kernel.place().target == place.target &&
kernel.place().precision == place.precision) {
return &kernel; // 忽略 layout 差异
}
}
}
return nullptr; // 没找到
}
注册宏
// 注册一个 ARM float32 NCHW 的 Convolution kernel
REGISTER_LITE_KERNEL(
conv2d, // 算子名
kARM, // Target
kFloat, // Precision
kNCHW, // Layout
arm::ConvCompute<PRECISION(kFloat)>, // 实现类
def // kernel 别名
)
.BindInput("Input", {LiteType::GetTensorTy(TARGET(kARM),
PRECISION(kFloat), DATALAYOUT(kNCHW))})
.BindInput("Filter", {LiteType::GetTensorTy(TARGET(kARM),
PRECISION(kFloat), DATALAYOUT(kNCHW))})
.BindOutput("Output", {LiteType::GetTensorTy(TARGET(kARM),
PRECISION(kFloat), DATALAYOUT(kNCHW))})
.Finalize();
// 同一个算子,ARM int8 的实现
REGISTER_LITE_KERNEL(
conv2d, kARM, kInt8, kNCHW,
arm::ConvCompute<PRECISION(kInt8)>,
int8_out
)
// ...
.Finalize();
MIR pass 中的自动类型转换
Paddle-Lite 的三维派发不是孤立的——它和 MIR(Model Intermediate Representation)优化 pass 紧密配合。当输入 Tensor 的 Place 和选中 kernel 要求的 Place 不匹配时,MIR 会自动插入类型转换算子(比如 layout_cast、precision_cast)来桥接差异。这个过程在 Ch21 图优化中会详细讲。
设计取舍
Paddle-Lite 的三维派发是六个框架中最”显式”的 kernel 选择机制。每个 kernel 的能力边界被精确标定在三维空间中的一个点上,调度器的工作就是在这个空间中找到最佳匹配点。好处是清晰可调试——你可以精确知道某个 Op 在某个硬件+精度+布局组合下会用哪个 kernel。代价是注册的代码量巨大——每个算子在每个硬件×精度×布局组合下都需要一条注册宏,算子数和后端数的笛卡尔积让注册表膨胀很快。
MACE:Operation + Delegator 五维惰性选择
MACE 的算子抽象有一个独特的设计——Delegator 体系。Operation(算子)不直接包含计算逻辑,而是把实际计算委托给 Delegator,Delegator 的选择基于五个维度,而且是惰性的——直到第一次执行时才确定用哪个 Delegator。
Operation 基类
// mace/core/ops/operation.h(简化版)
class Operation {
public:
explicit Operation(OpConstructContext* context);
virtual ~Operation() = default;
// 推导输出形状
virtual MaceStatus InferOutputShape(
const std::vector<const Tensor*>& inputs,
std::vector<Tensor*>* outputs) = 0;
// 初始化(分配资源)
virtual MaceStatus Init(OpInitContext* context);
// 前向计算
virtual MaceStatus Run(OpContext* context) = 0;
protected:
// Delegator:实际计算的委托对象
std::unique_ptr<delegator::DelegatorBase> delegator_;
OpConditionContext condition_context_;
};
Delegator:五维惰性选择
Delegator 的选择基于五个维度:
// mace/ops/delegator/delegator_registry.h(简化版)
struct DelegatorParam {
RuntimeType runtime_type; // CPU / GPU / APU / ...
DataType data_type; // FLOAT / HALF / INT8 / ...
ImplType impl_type; // REF / NEON / ...
// 加上算子特定的维度,如:
int input_channels; // 输入通道数(决定用哪种卷积实现)
int kernel_size; // 卷积核大小
};
// 注册 Delegator 的宏
MACE_REGISTER_DELEGATOR(
registry,
Conv2dDelegator, // Delegator 类
delegator::Conv2dParam, // 参数类型
MACE_DELEGATOR_KEY( // 五维 key
Conv2d, // 算子名
RuntimeType::RT_CPU, // 运行时
float, // 数据类型
ImplType::NEON // 实现方式
)
);
“惰性选择”意味着 Delegator 不在 Operation 构造时创建,而是在第一次 Run 时才根据输入的实际特征(通道数、核大小等)查注册表选择最优实现:
MaceStatus Conv2dOp::Run(OpContext* context) {
if (delegator_ == nullptr) {
// 第一次执行:根据实际输入选择 Delegator
DelegatorParam param;
param.runtime_type = context->runtime_type();
param.data_type = inputs[0]->dtype();
param.input_channels = inputs[0]->dim(1);
param.kernel_size = filter->dim(2);
delegator_ = DelegatorRegistry::Global()->Create(param);
}
return delegator_->Compute(inputs, outputs);
}
OpenCL 不走 Delegator
MACE 的 Delegator 体系有一个重要的例外——OpenCL 后端不走 Delegator。OpenCL 的算子实现直接在 Operation 子类中完成:
// mace/ops/opencl/conv_2d.h
class OpenCLConv2dKernel : public OpenCLConv2dBase {
public:
MaceStatus Compute(OpContext* context,
const Tensor* input,
const Tensor* filter,
const Tensor* bias,
Tensor* output) override;
private:
cl::Kernel kernel_; // OpenCL kernel
uint32_t kwg_size_; // kernel work group size
std::vector<index_t> input_shape_; // 缓存的输入形状
};
原因是 OpenCL kernel 的选择逻辑和 CPU 完全不同——CPU 上可以根据通道数在 Winograd、Im2Col+GEMM、直接卷积之间选择,而 OpenCL 上更多是根据 work group size、local memory 大小、Image vs Buffer 等 GPU 特定因素来选择,这些因素不适合用 Delegator 的五维 key 来表达。
Hexagon 整图下推
MACE 的另一个例外是 Hexagon DSP 后端——它不是逐算子执行,而是把整个图(或图的一部分)作为一个整体下推到 Hexagon 的 HVX 运行时:
// mace/runtimes/hexagon/hexagon_hta_wrapper.h
class HexagonHTAWrapper {
public:
// 把整个网络设置进 Hexagon
bool SetupGraph(const NetDef& net_def);
// 整图执行
bool ExecuteGraph(const Tensor& input, Tensor* output);
// 不是逐算子调用——是整图作为一个黑盒执行
};
这种”整图下推”和 Delegator 的”逐算子委托”是完全不同的执行范式——Hexagon 的算子实现对 MACE 来说是不可见的,MACE 只负责把数据传进去、拿结果出来。
设计取舍
MACE 的 Operation + Delegator 设计是六个框架中最”多态”的。惰性选择让 Delegator 的决策延迟到运行时——可以根据输入的实际特征做最优选择,而不是编译时或初始化时就定死。但 OpenCL 不走 Delegator、Hexagon 整图下推这两个例外,暴露出 Delegator 体系的局限性——它为 CPU 上的分支选择设计得很好,但面对根本不同的执行范式(GPU shader、DSP 整图)时就力不从心了,需要走完全不同的代码路径。
横向对比
| 维度 | ncnn | MNN | ORT | TNN | Paddle-Lite | MACE |
|---|---|---|---|---|---|---|
| 层数 | 1层(Layer) | 3层(Op-Execution-Geometry) | 2层(OpKernel+Registry) | 2层(BaseLayer+LayerAcc) | 2层(OpLite+KernelLite) | 2层(Operation+Delegator) |
| shape/compute分离 | 耦合 | 分离(onResize/onExecute) | 分离(shape inference独立) | 分离(BaseLayer推导) | 分离(InferShape独立) | 分离(InferOutputShape独立) |
| 后端选择时机 | 编译时 | 运行时 | 运行时(EP注册) | 运行时(Device工厂) | MIR优化时 | 运行时(惰性) |
| 版本管理 | 无 | 无 | opset版本化 | 无 | 无 | 无 |
| 降级/兜底 | 无 | GeometryComputer | 无(EP自己兜底) | 无 | 无 | 无 |
| kernel选择维度 | 1维(平台) | 1维(Backend) | 2维(EP+opset) | 1维(Device) | 3维(Target+Precision+Layout) | 5维(Runtime+DType+Impl+…) |
| 动态形状支持 | 弱(每次重跑forward) | 强(onResize可选) | 强 | 中等(Reshape方法) | 中等 | 中等 |
核心取舍分析
六种算子抽象反映了三个根本性的设计取舍。
第一个取舍是层数 vs 简单性。ncnn 一层搞定一切,代码路径最短、学习成本最低,但灵活性差——shape 推导和计算绑在一起,添加新后端要改继承树。MNN 三层架构灵活性最高——Op 声明、Execution 执行、Geometry 降级各司其职——但认知复杂度也最高。TNN、ORT、Paddle-Lite、MACE 各自取了不同的中间点。
第二个取舍是编译时确定 vs 运行时决策。ncnn 在编译时就确定了每个算子的实现(通用 → ARM → NEON 的覆盖链),优点是零运行时开销,缺点是一个二进制文件只能服务一种硬件配置。MACE 的 Delegator 走到另一个极端——运行时才根据输入特征选择实现,优点是能做最优选择,缺点是第一次执行有额外的查找开销。
第三个取舍是算子覆盖率 vs 实现成本。没有 GeometryComputer 的框架(ncnn、TNN、Paddle-Lite、MACE),每支持一个新算子就需要在每个后端都写一份实现——算子数 x 后端数 = 总实现数。MNN 的 GeometryComputer 打破了这个线性关系——新后端只需实现核心基本算子,复杂算子通过降级自动获得支持。ORT 虽然没有降级机制,但它的 EP 插件模型允许后端只”认领”自己能高效执行的算子,其余算子自动 fallback 到 CPU EP——这是一种不同形式的兜底。
对于竞赛选手来说,ncnn 的单层 Layer 设计是最容易上手的——你只需要继承 Layer、实现 forward 方法就能添加一个新算子。理解了 ncnn 的设计后,再去看 MNN 的三层架构就能体会到”为什么需要这个复杂性”——因为当你需要支持几十个后端和上千个算子时,ncnn 的简单设计会变成维护噩梦。
本章小结
算子抽象是六个框架设计哲学分歧最大的地方。ncnn 用一个 Layer 类搞定一切,MNN 用三层架构(Op + Execution + GeometryComputer)做精密分工,ORT 用 OpKernel + opset 版本化注册实现标准化,TNN 用 BaseLayer + LayerAcc 做 shape/compute 分离,Paddle-Lite 用三维坐标(Target + Precision + Layout)精确标定每个 kernel,MACE 用 Delegator 做五维惰性选择但 OpenCL/Hexagon 走例外路径。
这些算子抽象并不是孤立运作的——它们需要被”调度”起来,按照正确的顺序执行。下一章我们将看到,当 ncnn 选择”从输出反向按需递归”、MNN 选择”四步流水线”、ORT 选择”图分区 + 执行计划”时,调度策略如何和算子抽象紧密咬合。
导读目录:README.md 上一章:Ch17: 数据容器——同一个问题的六种回答 下一章:Ch19: 推理调度——从按需递归到图分区