Mojo 编译器泛型处理内幕:解析器中泛型的四个非显而易见机制(IRAIDAI / DCRTODS / PSTIAIRAID / STCHDDDOS)
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
本文聚焦 Mojo 编译器源码仓库中
Mojo/docs/compiler/arcana/Generics.md所记录的泛型解析核心机制:IndexRefAttrInterface(基于索引的参数引用)、深度(depth)与索引(index)语义、ParameterScopeTypeInterface对深度的作用,以及"同型不同深度"带来的相等性比较陷阱。读完本文,你将理解 Mojo 编译器如何在解析阶段用"相对位置"代替参数名来描述泛型引用,掌握编写或阅读编译器泛型相关代码(如IndexParameterReplacer、ParameterReplacer、ParameterEvaluator)时必备的深度感知(depth-aware)思维,并清楚为何 MLIR 内置的.walk不能用于此类遍历。
引言:为什么泛型处理在解析器中如此"非显而易见"
Mojo 是一门支持强参数多态(parametric polymorphism)的语言,其泛型参数(如T: AnyType、N: Int)在编译器前端解析、KGEN 方言生成、求值等各阶段都被大量引用。而泛型处理的难点在于:同一个类型或表达式里引用的参数,可能来自不同层级的嵌套签名,且这些引用需要在不依赖参数名的情况下保持语义一致、可判等。
Generics.md是 Mojo 编译器"秘辛"(arcana)系列文档之一,它用四个缩略词概括了解析器处理泛型时的四个非显而易见之处:
- IRAIDAI:
IndexRefAttrInterface、深度与索引(Depths and Indexes) - DCRTODS:深度不能引用 Op 声明的签名(Depths Cannot Refer To Op-Declared Signatures)
- PSTIAIRAID:
ParameterScopeTypeInterface影响IndexRefAttrInterface的深度 - STCHDDDOS:同一类型在不同作用域可以有不同的深度(Same Type Can Have Different Depths Depending On Scope)
本文将以这四个机制为骨架,结合仓库中 KGENDialect 与 MojoParser 的源码实现(Mojo/include/Mojo/KGENDialect/KGENAttrInterfaces.td、Mojo/include/Mojo/KGENDialect/KGENAttrs.td、Mojo/include/Mojo/KGENDialect/KGENTypeInterfaces.td、Mojo/include/Mojo/KGENDialect/ParameterReplacer.h)逐一展开。
IRAIDAI:IndexRefAttrInterface、深度与索引
两个等价的别名
考虑以下两个 Mojo 别名(alias),它们的类型是完全相等的:
alias A: defT: AnyType->None = ... alias B: defY: AnyType->None = ...直觉上我们知道A和B是同一个函数类型——参数名T与Y只是占位符,实际语义没有差别。但要让编译器"知道"它们相等,一个简洁的办法是:把名字擦除,改用相对位置来描述参数引用。于是上面的两个别名可以写成:
alias A: def_: AnyType)->None = ... alias B: def_: AnyType)->None = ...这里*(0,0)就是一个"索引式参数引用"(index-based parameter reference),其中第一个分量是深度(depth),第二个分量是索引(index)。
IndexRefAttrInterface的两个字段
在 KGENDialect 的 TableGen 接口定义中(Mojo/include/Mojo/KGENDialect/KGENAttrInterfaces.td),IndexRefAttrInterface的官方描述指出:这是一种用一对整数、完全不涉及名字的相对参数引用方案,其价值在于"即便参数名不同,也能判定两个类型是否相等":
"Index-based parameter references are a relative parameter referencing scheme that uses a pair of integers to reference parameters in a way that doesn't involve names. This is useful for later knowing if two types are equal, even if they have different parameter names."
IndexRefAttrInterface的所有实现都包含两个字段:
| 字段 | 含义 | 取值范围 |
|---|---|---|
depth | 目标参数声明位于"第几层外层签名"(ParameterScopeTypeInterface/ParameterScopeAttrInterface)中。0表示最近的包含签名,1表示再往上一层 | 非负整数 |
index | 该参数声明在目标签名中的序号(第几个输入参数) | 非负整数 |
接口还暴露了三个方法(对应 TableGen 中的InterfaceMethod):getDepth()返回深度、getIndex()返回索引、replace()在不改变被引用参数语义的前提下修改 depth/index 并携带新的子元素。
对应到具体属性实现(Mojo/include/Mojo/KGENDialect/KGENAttrs.td),#kgen.param.index.ref的 MLIR 文本形式即#kgen.param.index.ref<depth, index> : type,例如:
// 最近签名中的第二个输入参数 #kgen.param.index.ref<0, 1> : index // 下一层外层签名的第一个输入参数 #kgen.param.index.ref<1, 0> : !lit.struct<@Int>非零深度示例:SIMD[N, D]中的N
文档给出了一个体现非零深度的典型例子:
alias bar: def D: DType, N: Int, f: def[Y: AnyType->None ](...) = ...这里SIMD[N, D]中的N是一个#kgen.param.index.ref<1, 0> : !lit.struct<@Int>(有时写成*(1,0))。原因在于:N并没有引用"最近的包含签名"(即f所在的那个def[Y: AnyType]),而是引用它的外层签名(bar自身的签名)。在参数引用与参数声明之间隔着1 层签名,所以其深度为1,索引为0(N是bar签名的第 2 个参数,从 0 起算)。
原文档特别提醒:计算depth时要格外小心,很容易算错。
并非所有参数引用都用索引
需要澄清的是:不是所有参数引用都采用索引与深度。还有一种传统的ParamDeclRefAttr(#kgen.param.decl.ref),它仍然按名字引用参数。其定义见Mojo/include/Mojo/KGENDialect/KGENAttrs.td:
// 对名为 "p"、类型为 i1 的参数的引用 #kgen.param.decl.ref<"p"> : i1ParamDeclRefAttr是一个带类型的属性,包含被引用参数的类型和名字,其类型必须始终与被引用参数的类型一致。至于什么时候必须用哪种引用,就是下一节 DCRTODS 的主题。
DCRTODS:深度不能引用 Op 声明的签名
一个反直觉的例子
考虑下面的代码:
def fooX: AnyType: alias bar: defY: AnyType = ...按照 IRAIDAI 的直觉,你可能会认为bar的类型是defAnyType, *(0,0))——因为"类型一般不会包含ParamDeclRefAttr"。但这个推断是错误的。
bar的真实类型是:
defAnyType)也就是说,Y的引用被替换成了*(0,0),而X的引用保持为名字X不变。为什么?
规则:看参数声明由谁发出
关键规则只有一条:
- 当引用的是某个 Op 的参数声明时,使用
ParamDeclRefAttr(按名字)。 - 当引用的是某个 Type 或 Attr 的参数声明时,使用
ParamIndexRefAttr(按深度+索引)。
回到例子,把涉及的参数声明逐一归类:
| 参数声明 | 由谁声明 | 引用方式 |
|---|---|---|
X | def fooOp | ParamDeclRefAttr(保留名字X) |
bar | alias barOp | ParamDeclRefAttr |
Y: AnyType | fnType | ParamIndexRefAttr(*(0,0)) |
X和bar都是由 Op 声明的,因此用ParamDeclRefAttr按名字引用;Y是由函数类型声明的,因此用ParamIndexRefAttr按索引引用。
在Mojo/include/Mojo/KGENDialect/KGENAttrs.td中,ParamIndexRefAttr的注释也给出了同样的反例(comptime zork版本),并明确标注了禁止出现的写法:
def fooX: AnyType: comptime zork: def...( # 不允许: #kgen.param.index.ref<1, 0> : !lit.struct<@Int> )->None = ...经验法则
原文档给出的经验法则非常简洁:深度(depth)只能指向包含它的GeneratorType和GeneratorAttr;对于其他一切(比如X这样的 Op 参数),使用ParamDeclRefAttr。
这条规则也是后续所有深度处理代码(PSTIAIRAID、STCHDDDOS)的前提。
PSTIAIRAID:ParameterScopeTypeInterface影响IndexRefAttrInterface的深度
深度的两种情形
回顾 IRAIDAI:depth == 0表示引用最近的外层签名,例如:
alias A: def[T: AnyType->None = ...其中x: T的T就是*(0,0)(深度 0,索引 0)。
depth == 1表示引用再外层一层的签名,例如前面SIMD[N, D]中的N:
alias bar: def D: DType, N: Int, f: def[Y: AnyType->None ](...) = ...机制:所有签名都继承ParameterScopeTypeInterface
为了管理深度,编译器让所有"会声明参数的签名类型"统一继承ParameterScopeTypeInterface(Mojo/include/Mojo/KGENDialect/KGENTypeInterfaces.td),代码在处理深度时专门监视该接口。
该接口的官方描述用一个更完整的例子说明了其语义:
def foo[T: AnyType](): comptime bork: def T: AnyType, inner_f: def[Y: AnyType -> None ] -> None = ...bork:之后的def是一个kgen.generator,它实现了ParameterScopeTypeInterface。描述中还明确指出:
ParameterScopeTypeInterface也会导致其内部(即便是间接)包含的ParamIndexRefAttr/ImplicitOriginRefAttr等的depth字段变大,见 PSTIAIRAID。
也就是说,每进入一层签名,内部引用的深度就加 1——这正是"深度感知"遍历的基础。同理,属性一侧对应ParameterScopeAttrInterface(Mojo/include/Mojo/KGENDialect/KGENAttrInterfaces.td),它让属性内部的ParamIndexRefAttr可以引用所在作用域声明的参数。
深度感知搜索(depth-aware searching)
文档给出的例子:
alias bar: def[ T: AnyType, L: List[T], f: defY: AnyType->None ](...) = ...它被解释为:
alias bar: def[ _: AnyType, _: List[*(0,0)], _: def_: AnyType, List[*(1,0)])->None ](...) = ...如果我们要查找代码中所有对T的引用,不能简单地在整棵树上找*(0,0)——那样会同时命中第一个T(正确)和内层签名中Y的引用(错误)。正确做法是:在递归搜索过程中记录当前处于第几层签名。在外层签名中找*(0,0),一旦搜索下潜进内层签名,就改为找*(1,0)。
这正是文档所说的"深度感知搜索"(depth-aware searching),它在处理任何"可能(哪怕间接)包含签名"的对象时都至关重要。
结论:慎用AttrTypeReplacer与AttrTypeWalker,提防 MLIR 内置.walk
文档给出两条极具实战价值的警告:
- 三思而后用
AttrTypeReplacer或AttrTypeWalker——它们不是深度感知的。应当优先考虑IndexParameterReplacer、ParameterReplacer、ParameterEvaluator、ParserParameterEvaluator、IndexRefRemapper等深度感知工具。 - MLIR 的内置
.walk方法不是深度感知的!(原文用了加粗强调)——直接用它遍历带签名嵌套的结构会算错深度。
以IndexParameterReplacer为例(Mojo/include/Mojo/KGENDialect/ParameterReplacer.h),其类注释与实现印证了上述设计:
"把这个
depth传给replaceImpl是这个类的核心目的,它让replaceImpl的实现知道在递归遍历中目前深入签名作用域多少层。例如,它们可以检查depth == 0判断是否在原始作用域,或检查indexRef.getDepth() == depth判断该 indexRef 是否引用原始作用域的参数声明。"
其doReplace的递归实现展示了深度递增的关键逻辑:
// Increment depth when looking inside signatures, see PSTIAIRAID. if constexpr (std::is_base_of_v<Attribute, T>) if (isa<ParameterScopeAttrInterface>(value)) ++depth; if constexpr (std::is_base_of_v<Type, T>) if (isa<ParameterScopeTypeInterface>(value)) ++depth;即:每当递归进入一个实现了ParameterScopeAttrInterface/ParameterScopeTypeInterface的子对象,就把当前深度加一,再向下传递。这与 PSTIAIRAID 描述的语义完全吻合,也解释了为什么这些 replacer 能正确处理嵌套签名而 MLIR 原生的.walk不能。
STCHDDDOS:同一类型在不同作用域可有不同深度
同型不同 depth
继续沿用 PSTIAIRAID 的例子:
alias bar: def[ T: AnyType, L: List[T], f: defY: AnyType->None ](...) = ...解释后:
alias bar: def[ _: AnyType, _: List[*(0,0)], _: def_: AnyType, List[*(1,0)])->None ](...) = ...注意List[T]现在出现了两个不同的类型表示:
List[*(0,0)](外层签名中的T)List[*(1,0)](内层签名中引用的外层T)
尽管它们语义上是同一个类型。
结论:相等的类型不总是"指针可比较"的
文档指出:
这意味着相等的类型并不总是指针可比较的。
这颇具讽刺意味——因为引入深度的初衷(按 IRAIDAI)恰恰是为了"便于判定两个类型是否相等"(源码接口描述中的 "This is useful for later knowing if two types are equal")。于是编译器需要在多处对这种"同型不同深度"做特殊处理。
比较前的两种调整策略
当需要比较List[*(0,0)]与List[*(1,0)]是否相等时,必须先统一深度,二选一:
- 递减更深签名里引用的深度:把
List[*(1,0)]中的深度从 1 减为 0; - 递增外层签名里引用的深度:把
List[*(0,0)]中的深度从 0 增为 1。
只有完成这样的深度统一之后,才能安全地比较两个类型是否相等。
典型触发场景:实例化参数化值(STCHDDDOS-A / STCHDDDOS-B)
文档给出了两个必须警惕的关键场景:
场景 A:实例化一个参数化生成器值(generator value)。例如给定一个生成器值<index> *(0,0) + 1(对任意索引类型参数,返回其加 1),可以把外层作用域的索引引用绑定进去,如bind_params(<index> *(0,0) + 1, *(0,1))。对bind_params算子做折叠,会得到一个新的、输入参数为 0 个的生成器值<> *(1,1) + 1。注意它仍然是一个生成器值;必须再执行一次显式的"实例化"步骤来解开生成器,而这次解包需要对生成器体内的所有索引引用做一次深度递减——因为此时生成器体处于少一层的生成器作用域之下。
场景 B:apply算子作用于已实例化的参数化函数。当被调方是一个已实例化的参数化函数时,产生的函数类型同样需要对其中的索引引用做深度递减(文档中称之为"up-binding")。这个操作与场景 A 是同一类问题在另一个算子上的体现。
这两个场景的共同点在于:任何跨越签名边界"解包/折叠"参数化结构的操作,都必须同步调整内部索引引用的深度,否则会产生悬挂的、语义错误的深度值。
实战总结:处理泛型深度时需要记住的清单
综合原文档与仓库实现,处理 Mojo 解析器 / KGEN 方言中的泛型时,建议按以下清单自查:
- 先分清引用种类:引用 Op 声明的参数用
ParamDeclRefAttr(按名字),引用 Type/Attr 声明的参数用ParamIndexRefAttr(按depth+index)。深度只能指向包含它的GeneratorType/GeneratorAttr(DCRTODS)。 - 算深度时数清签名层数:
0是最近的签名,每向外一层 +1;计算时容易出错(IRAIDAI)。 - 遍历优先用深度感知工具:如
IndexParameterReplacer、ParameterReplacer、ParameterEvaluator、ParserParameterEvaluator、IndexRefRemapper;避免AttrTypeReplacer/AttrTypeWalker,绝不要用 MLIR 内置.walk(PSTIAIRAID)。 - 比较相等性前先统一深度:要么递减深层引用,要么递增外层引用,然后再判等;要留意
bind_params折叠、apply实例化等会改变深度层次的操作(STCHDDDOS-A/B)。
这些机制的最终目标,是在不依赖参数名的前提下让"形异义同"的泛型类型可以判等、替换与求值——理解depth/index这对相对坐标,是读懂 Mojo 编译器泛型前端(MojoParser 与 KGENDialect)代码的一把钥匙。感兴趣的读者可以继续在仓库中阅读Mojo/include/Mojo/KGENDialect/ParameterEvaluator.h(其中第 2 条规则专门描述ParamIndexRefAttr的替换规则)、Mojo/include/Mojo/KGENDialect/KGENParameters.h(递归遍历并替换ParamIndexRefAttr、以及ParamIndexRefAttrFinder检测器),以及Mojo/test/mojo-parser/parameters.mojo中对应的解析器测试用例来加深理解。
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考