Metadef 这个名字在自定义算子开发圈子里说实话一开始很容易被忽略。很多人刚接触 CANN 算子开发时目光全放在 kernel 怎么实现、tiling 怎么切分上等真正动手写第一个算子才发现最卡脖子的不是计算逻辑而是“这个算子的属性该怎么描述、shape 该怎么推、数据类型怎么校验”——这些活全堆在 Metadef 这一层。我自己最早写自定义算子时就栽过跟头。照着官方样例抄了一个算子原型结果注册编译时直接报属性校验失败排查半天发现是默认值的类型和声明不一致。后来把 Metadef 这层彻底啃了一遍才算明白它为什么被称为“扩展基石”。这篇就把我理解和实践 Metadef 的过程完整梳理一遍适合正在做 CANN 自定义算子开发、或者想在算子层做深度优化的小伙伴尤其建议那些觉得“属性定义随便写写就行”的人仔细看看。1. 先搞清楚 Metadef 在 CANN 体系里的站位1.1 为什么算子开发会卡在“元信息”上一个算子在计算框架里要真正跑起来需要的远不止一份 kernel 代码。举个例子你写了一个自定义归约算子编译器在构图阶段就得知道这个算子接受几个输入输入是什么数据类型输出 shape 怎么由输入推导有哪些属性可以配置属性取值范围是多少这些问题全部要在算子“落地”之前回答清楚否则框架根本不知道该怎么把算子节点插入计算图更不用说后续的图优化、算子调度、内存分配了。这里的“回答清楚”不是靠口头约定而是要有一套机器可读、可校验的声明机制。Metadef 干的就是这件事——它提供了一套描述算子原型、属性规范、推导规则的元定义框架。所谓“元定义”意思是它不是某个具体算子的实现而是定义“算子长什么样”的规则本身。我见过不少初学者把算子原型理解成“头文件声明”觉得写不写都无所谓。这个认知偏差挺要命的。没有元定义你的 kernel 写得再漂亮框架也找不到入口把它注册进算子库。而且元定义做得不好后续编译阶段各种诡异报错会层出不穷最典型的就是 shape 推导不出来导致整图编译失败。1.2 一张“藏宝图”Metadef 与各模块的边界要理解 Metadef 的定位得先看一条完整的自定义算子开发链路。我把它分成四段算子原型定义描述算子的输入输出、属性、推导规则属于“声明层”。Tiling 策略决定数据怎么切分到不同核上属于“策略层”。Kernel 实现写实际的向量或矩阵计算逻辑属于“计算层”。图编译与运行时把算子节点接入整图分配内存并执行属于“调度层”。其中第一段“算子原型定义”就是 Metadef 的主场。它向下对接 framework 的算子注册机制向上支撑 gengine 的图编译优化。很多人搞不清楚 Metadef 和算子 kernel 的关系其实两者是先后依赖的关系——先有 Metadef 把算子“身份”定义清楚kernel 才有资格被框架调起来。我用一个类比帮大家理解Metadef 像是一份“岗位说明书”里面写清楚这个岗位需要什么技能输入、产出什么成果输出、有哪些可调的绩效指标属性、如何判断候选人是否合格推导与校验kernel 则是这个人实际干活的过程。没有岗位说明书HR框架根本不知道该招什么人、把人放哪个部门。下表是我自己整理的各层职责对比方便大家对照定位层级核心职责典型输出依赖 Metadef 程度算子原型层声明输入输出、属性、推导规则算子原型注册核心依赖Tiling 策略层计算切分维度生成 tiling 数据Tiling 参数结构体间接依赖需读取属性确定策略Kernel 实现层实现核心计算逻辑核函数代码弱依赖但 kernel 入参通常需要属性值图编译与运行时整图优化、调度、执行可执行图强依赖推导结果2. 属性定义用一把“Schema”尺子描述算子的个性2.1 属性定义的本质是“声明式约束”Metadef 里最基础也最容易被低估的部分是 AttrDef即属性定义。一个算子的属性本质上就是这个算子区别于其他算子的“旋钮”。比如卷积算子的 stride、padding归一化算子的 epsilon都是属性。属性定义要做的是把这些旋钮的类型、范围、默认值、是否必填全部声明清楚。这里有一个核心设计思想声明式描述而不是命令式检查。也就是说你不是写一大段 if-else 去判断属性值合不合理而是用一个描述性的结构告诉框架“这个属性应该长什么样”框架自动生成校验逻辑。这个思路和很多制造行业里用图纸代替人工检验的思路完全一致——把标准前置让机器来执行校验。为什么这个设计很关键因为图编译阶段对算子属性的访问频次极高每一次图优化 pass 都可能读取属性。如果是命令式校验每一处调用都得写重复的防御逻辑既容易漏又容易错。声明式定义把校验收敛到一处框架层面所有地方共用同一套校验结果。2.2 属性描述里到底能写哪些“限定”我自己在实际项目中常用的属性限定手段大致有这么几类每一类都有它的使用场景类型限定属性值必须是整数、浮点、字符串、布尔或张量形状等。这里最容易踩坑的是整数和浮点的混用定义成 float 的属性传个 int 进去某些版本会隐式转换某些版本直接报错。默认值算子实例化时如果用户没显式传属性就使用默认值。默认值设置不当的典型问题是值和类型不匹配比如声明了一个 float 属性默认值却写成整数 0注册端可能不报错但在图序列化阶段翻车。取值范围给属性划定合法区间比如轴索引必须在 [-rank, rank-1] 之间。这能帮开发者提前暴露错误而不是等 kernel 跑出 NaN 才回头查。必填与可选区分哪些属性是算子正常工作必须的哪些是锦上添花的。把核心功能属性标成可选是设计上常见的偷懒行为会导致算子在缺参时“带病运行”。互斥与联动有的属性之间是有约束关系的比如指定了 dynamic_shape 就不能同时指定静态 shape 列表。这类跨属性的校验Metadef 也支持自定义校验函数来兜底。2.3 属性约束的“力”作用在哪个环节属性定义写完之后它的约束力贯穿算子从注册到上板的多个环节。理解这一点你就明白为什么前面说“属性定义是基石”了。第一个环节是算子注册。自定义算子通过注册接口把原型挂到框架里注册过程本身就会做一次合法性检查包括属性类型是否被支持、默认值是否合法、必填项是否有默认值兜底。第二个环节是图编译。算子节点被插入计算图时框架会按原型定义校验属性是否齐全。第三个环节是算子执行前的最终校验。有些属性值要到执行时才确定比如动态 shape 场景下的轴索引这时运行前校验就非常必要。从软件工程角度说这等于把“接口契约”写在了代码能强制执行的地方而不是靠文档约束人。我写过好几年的算子最大的体会是合约前置越严格下游排错成本越低。3. 自动推导让 shape、数据类型、格式不用手动猜3.1 InferShape不写推导算子寸步难行如果只能选一个必须吃透的 Metadef 机制我一定选 InferShape。它是输入 shape 到输出 shape 的推导函数负责告诉框架给定输入张量的维度输出张量该长什么样。为什么它这么重要因为计算图在编译阶段需要把整个图的所有张量 shape 全部确定下来才能做内存规划和算子调度。你的算子如果在图中它的输出 shape 不确定下游所有算子全部跟着不确定整图编译直接卡死。InferShape 的书写逻辑本质上是对算子数学语义的维度分析。拿一个简单的自定义矩阵乘算子举例输入 A 的形状是 [M, K] 输入 B 的形状是 [K, N] 输出就是 [M, N] 。这个推理看起来简单但真实算子往往复杂得多尤其是涉及广播、轴规约、动态 shape 的场景。我给大家看一个我自己写过的归约算子的 InferShape 示例感受一下实际书写方式这里用伪代码风格展示核心逻辑实际注册方式各家版本略有差异def infer_shape(self, inputs, attrs): input_shape inputs[0].shape axis attrs[axis] keep_dims attrs[keep_dims] rank len(input_shape) # 处理负轴索引比如 -1 表示最后一维 normalized_axis [a rank if a 0 else a for a in axis] # 校验轴不能越界不能重复 for a in normalized_axis: if a 0 or a rank: raise RuntimeError(axis out of range) if len(set(normalized_axis)) ! len(normalized_axis): raise RuntimeError(duplicate axis is not allowed) # 推导输出 shape被规约的维度变成 1 或消失 output_shape [] for i, dim in enumerate(input_shape): if i in normalized_axis: if keep_dims: output_shape.append(1) else: output_shape.append(dim) return [output_shape]这段代码里最关键的习惯是“先校验后推导”。我见过不少人在 InferShape 里忽略输入的合法性检查结果框架在运行时才报错定位成本极高。3.2 InferDataType 与 InferFormat容易偷懒但必出事的环节和 InferShape 相比InferDataType 和 InferFormat 的名气小很多但翻车率一点不低。InferDataType 推导输出张量的数据类型常见规则是“输出和输入同类型”但有些算子会改变类型比如类型提升类的算子。我举个实际例子一个自定义的缩放算子输入是 int8但内部为了保持精度输出设计成 fp16。如果你不写 InferDataType框架默认按输入类型推导输出那输出就变成 int8 了精度直接被打回原形算子效果名存实亡。InferFormat 则负责输出张量的内存排布格式推导。CANN 体系里同一份数据可能以不同格式存放比如 NCHW、NHWC 或者带分形结构的特殊格式算子对格式有特定要求。如果不正确推导格式图编译阶段会插入大量格式转换算子性能损失肉眼可见更严重的是某些算子对格式有限制格式错了直接编译失败。我自己在项目里踩过一个印象很深的坑写了一个自定义轴变换算子InferShape 和 InferDataType 都写得没问题就是没写 InferFormat。结果图编译时框架默认输出格式和输入一致但该算子的实际 kernel 只支持一种特定格式于是多出来两个格式转换算子的开销整张图性能比预期慢了将近一倍。后来把 InferFormat 补齐性能立刻恢复正常。3.3 推导规则怎么“挂”到算子上定义好推导函数只完成了一半还得把它挂到算子原型上。Metadef 提供了一套注册机制把 InferShape、InferDataType、InferFormat 等作为回调函数绑定到具体的算子类型上。整个过程有点像把插件插到主板上——插槽位置对了才能跑起来。具体的注册方式在各类版本里形态略有差异但核心套路是一致的先定义算子原型对象 → 给原型对象绑定推导函数 → 将原型注册到全局算子库。这里提醒一下注册时要注意命名空间和算子类型名的唯一性同名注册不会有明显报错但会导致后续查算子时命中错误。4. 从零到一写一个带自定义属性的完整算子4.1 先做设计决策属性边界划在哪里动手写代码之前我强烈建议大家先在一张纸上回答三个问题。第一这个算子真正需要的属性有几个第二每个属性的类型和默认值是什么第三哪些属性会影响 InferShape 的逻辑这些问题看似简单却直接决定你后面要写多少代码。我自己见过的典型反面教材是明明只需要一个 axis 属性非要把所有可能的配置项全部做成属性结果 InferShape 里全是 if 分支维护起来痛不欲生。属性不是越多越好而是越精确越好。以我常用的一个自定义归一化算子为例它的属性设计就三样epsilonfloat默认 0.00001控制数值稳定性、axisint默认 -1指定归约轴、keep_dimsbool默认 True控制输出是否保留被归约的维度。这三个属性各司其职没有任何冗余。4.2 属性 Schema 的完整书写示例下面我给出一个完整的属性 Schema 示例使用 JSON 风格描述方便你看清楚每个字段的含义{ attr_defs: [ { name: epsilon, type: float, default_value: 0.00001, required: false, description: A small value added to the denominator for numerical stability. }, { name: axis, type: int, default_value: -1, required: false, description: The axis along which to normalize. Support negative indexing. }, { name: keep_dims, type: bool, default_value: true, required: false, description: If true, retains the reduced axis with length 1. } ] }注意看几个细节。required字段我全部设成了 false并且给了默认值这意味着用户在使用这个算子时可以完全不传属性算子也能正常工作。但如果你想设计一个必须由用户显式指定的属性就把required设为 true这样缺参时框架会直接报错而不是静默使用默认值从排错角度来说更友好。4.3 把推导函数和 Schema 一起注册属性 Schema 只是静态描述真正让算子活起来的是注册动作。完整流程可以拆成下面几步定义算子类型名和输入输出的个数、顺序、类型要求。定义属性 Schema并把上一步的输入输出声明关联起来。编写 InferShape、InferDataType、InferFormat 函数。调用注册接口把原型和推导函数绑定后注册进算子库。编译验证写一个小的测试用例确认属性校验和推导都按预期工作。我实际操作时习惯把第 5 步拆得更细——先用一个最简单的最小用例跑通注册再逐步增加属性组合做校验测试。因为属性组合的排列组合数量是爆炸的全量测试不现实优先覆盖边界值最值、负数、空列表和默认值场景是性价比最高的测试策略。下面是一个简化版的注册绑定过程示例重点看推导函数如何和算子原型关联def infer_type(self, inputs, attrs): # 输出数据类型和输入保持一致 return [inputs[0].dtype] def infer_format(self, inputs, attrs): # 大多数自定义算子可以直接沿用输入的格式 return [inputs[0].format] # 假想的注册流程展示了绑定关系 register_op(CustomNormV2) .attr(...) .infer_shape(infer_shape) .infer_dtype(infer_type) .infer_format(infer_format)4.4 从 Metadef 到图上板你自己的代码去了哪里很多开发者写完注册代码后对整个链路是黑盒的。我建议大家脑子里至少有一条完整链路你写的属性 Schema 和推导函数先进入算子原型库成为框架描述该算子的唯一依据当用户代码里出现该算子时框架根据原型创建算子节点调用你的 InferShape 得到输出 shape调用 InferDataType 得到输出类型这些信息汇总后参与图优化和内存分配最终 kernel 才被执行。换句话说你写的每一行 Metadef 代码都在为整张图的高效运行铺路。5. 扩展 Metadef 的几种进阶玩法和边界5.1 注册自定义属性类型什么时候值得动手Metadef 自带的属性类型基本覆盖了日常需求int、float、bool、string、list、tensor shape 等。但有些场景下默认类型确实不够用。比如你想定义一个“坐标点集合”属性每个点包含 x 和 y 两个字段用默认类型就得拆成两个 list语义上非常别扭。这时候可以考虑自定义属性类型。但我必须说一句大实话自定义属性类型是一把双刃剑。好处是语义清晰、可读性强坏处是你要额外实现序列化、反序列化、校验逻辑而且下游工具链未必认这个新类型兼容性风险不小。我的建议是除非该属性类型会被多个算子复用或者它对图编译逻辑有实质影响否则老老实实用基础类型组合就够了。真正值得扩展的情况我遇到过一种——某类算子族共用一种“张量描述结构”且该结构参与了编译期优化决策这时候抽象成自定义类型收益就远大于成本。5.2 跨算子校验单个算子没问题合起来就翻车属性校验的粒度天然是单个算子但真实计算图里算子之间是有数据依赖的。比如上一个算子输出的 dtype 是 fp16下一个算子要求输入必须是 fp32那合起来就会出问题可能额外插入转换算子也可能直接报类型不匹配的编译错误。这种情况下Metadef 层面的解法通常不是修改属性定义而是通过 InferDataType 和格式推导的返回值尽量准确地描述输出的真实约束。如果输出类型在不同条件下会有变化推导函数里要覆盖所有分支而不是只给一个笼统的结果。比如我们前面举的 int8 输入、fp16 输出的缩放算子如果在推导时只写“输出与输入同类型”下游算子拿到错误信息整图的行为就不可控了。5.3 兼容性和版本演进老模型不能因为属性变更就跑不了Metadef 的属性定义一旦对外发布就变成了接口契约的一部分。改属性类型、改默认值、增加必填项都会直接影响存量模型和已有算子代码。我见过一个非常典型的翻车案例某算子早期版本把 axis 的默认值设计成 0后续为了对齐主流框架语义把默认值改成 -1。结果所有不显式传 axis 的老模型行为全部变化模型输出对不上排查了整整一天才定位到是默认值变更导致。所以属性 Schema 的演进一定要遵循“向后兼容”原则新增属性时给默认值让老代码无需修改修改默认值时一定要做全量回归测试废弃属性时要保留一个过渡期哪怕只是打印 warning 而不是直接报错。这些经验是用实际踩坑换来的值得记下来。从兼容性角度还有一个更值得养成的习惯属性定义里尽量别用魔法数字。比如用一个 int 属性表示归一化策略取值 0 代表按层归一化1 代表按实例归一化——这种设计对后续扩展极不友好新加一种策略就得担心老值语义被误解。改成字符串或枚举类描述可读性会好很多扩展也更从容。5.4 文档里不会写的暗坑最后分享几个我在实际项目里反复踩、后来又逐一解决的暗坑希望对大家有帮助。第一个坑属性列表的顺序。Metadef 里有些机制对属性定义的顺序敏感尤其在序列化和反序列化场景下属性顺序变了二进制缓存的对齐就出问题。所以新增属性的时候尽量往列表末尾追加别在中间插入。第二个坑浮点默认值的精度表达。浮点数在二进制表示里存在精度误差定义默认值时如果写了一个无限循环小数某些场景下会出现“看起来相等但实际不等”的对比失败。建议默认值都写成简洁的十进制形式并且避免在编译期做浮点相等判断。第三个坑InferShape 里的纯函数约束。推导函数内部不要依赖全局状态或可变静态变量。因为图编译阶段同一推导函数可能被多次调用且调用顺序不确定一旦有状态依赖结果就不稳定。我习惯把所有依赖限制在输入 shape 和属性值内部这样推导是确定性的也方便单元测试。第四个坑空 Tensor 场景。很多 InferShape 的实现没处理输入 shape 中包含 0 的情况。比如归约算子的输入某个维度是 0输出 shape 应该是什么如果你的推导逻辑没覆盖编译阶段就是崩溃或静默错误。加上对空维度输入的显式处理能省掉很多线上事故。写在最后我实际用下来的几点体会Metadef 这套东西说到底是“把约束显式化、把行为契约化”的设计哲学。我刚开始写自定义算子时总觉得属性定义、推导函数是辅助工作真正厉害的是 kernel 里的计算优化。但写了一年多之后反而觉得 Metadef 才是决定一个算子能否被广泛、稳定使用的天花板。kernel 写得烂顶多性能差Metadef 定义得烂可能是编译不过、行为不可控、老模型崩掉。现在每写一个新算子我都会先把属性 Schema 单独拎出来评审一轮重点看边界条件是否覆盖、默认值是否安全、向后兼容性是否满足。养成这个习惯之后编译报错率和线上异常都肉眼可见地减少。最后再送大家一个小技巧调试 InferShape 时直接用框架的离线校验工具跑单测比反复走整图编译要快得多这是我最常用也最推荐的排错路径。