Mojo 编译器中的 Variadics 深度解析:VariadicList、VariadicPack 与底层 MLIR 表示
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
本文是 Mojo 编译器内部实现(compiler internals)层面的变参(variadics)技术指南,核心内容源自 Mojo/docs/compiler/overviews/Variadics.md,并结合 stdlib 源码与 KGEN/LIT 方言的 MLIR 定义进行佐证。你将理解VariadicList、VariadicListMem、VariadicPack三种变参机制的异同,掌握!kgen.pack、!kgen.variadic、!lit.ref.pack等底层表示的分工,并学会区分 variadic-parameterized、variadic-referring、variadic-capturing 三类函数——这对阅读编译器代码、编写复杂泛型 API 以及调试变参相关问题时都至关重要。
阅读前提:本文面向编译器内部视角。如果只是想使用 Mojo 的变参语法,建议先阅读公开的 variadics 文档(Mojo 官方手册中关于变参参数的章节),再回到本文探究其底层原理。
三种变参形态速览(TL;DR)
Mojo 的变参并非只有一种形态,编译器内部将其划分为三类,对应三个不同的运行时表示:
VariadicList:本质是"单一可寄存器传递(register-passable)类型的运行时数组",所有元素在内存中连续排列。典型用法:def foo(*args: Int): ...。VariadicListMem:本质是"指针的运行时数组"。与VariadicList类似,但它存储的是指针,因此指向的元素也可以是 memory-only 类型(即不能放进 SSA 寄存器的类型)。典型用法:def foo(*args: Spaceship): ...。VariadicPack:本质是"指针构成的元组(tuple)",指向一组共享某个公共 trait 的异构对象。典型用法:def foo*arg_types: Stringable: ...。
一个常见的疑虑是:变参全部走间接寻址(indirection)会不会低效?比如往一个 variadic pack 里传入 Int、Float 这样的"读取"值,并不希望它们被间接引用。文档明确指出:这一层开销会在 elaboration 之后的参数约定降级(argument convention lowering)阶段被消除,最终运行时是高效直接的——这与 C++ 的实现方式不同。也就是说,编译器内部先以统一、简单的方式建模变参,再在后续 pipeline 中针对具体调用点做优化。
VariadicList 与 VariadicPack 的核心区别
从概念上讲,两者泾渭分明:
- VariadicList 是同质元素的数组:
VariadicList存寄存器可传递的值本身,VariadicListMem存指针(指向的值可以是 memory 类型)。 - VariadicPack 是异构值的元组:元素一律通过指针间接引用。
文档归纳了四条主要差异,这也是理解 Mojo 变参类型系统最重要的框架:
1. 长度何时可知
VariadicList的长度是运行时值——除非列表本身是参数(parameter),此时因为所有元素也是参数,长度在编译期(comptime)可知。VariadicPack的长度在编译期(也因此在运行期)恒可知:因为 elaborator 会针对调用方的每一组具体类型列表实例化函数(类型列表本身就是参数)。
2. 同质 vs 异构
- Elaboration 之后:
VariadicList是同质的(运行时是数组);VariadicPack是异构的(运行时是指针元组)。 - Elaboration 之前:
VariadicList同质;VariadicPack可以视作"同质"——所有元素共享一个公共 trait,从这个角度看是同质的,但概念上它们指向的是各种不同类型的对象。
3. 如何迭代
VariadicList直接用普通for循环即可(长度是运行时值,元素同质)。VariadicPack由于各元素类型可能不同,必须使用comptime for、pack.each或pack.each_idx在编译期展开迭代。
4. 如何索引
VariadicList可以在编译期或运行期索引,因为它本质就是一个运行时数组。VariadicPack只能在编译期索引,因为它本质是元组,编译器必须知道每个位置的具体类型才能生成正确的访问代码。
底层参与者:kgen.pack、lit.ref.pack、kgen.variadic
文档给出了一张"参与者清单",这些是 Mojo 编译管线中真实存在的 MLIR 类型/结构:
| 名称 | 语义 | 说明 |
|---|---|---|
!kgen.pack | 异构元素列表,元素在内存中紧挨着存放 | 类似于std::tuple,Mojo 的Tuple只是对它的包装。解析器不直接使用它,而是使用!lit.ref.pack |
!lit.ref.pack | 异构指针列表,指针在内存中紧挨着存放 | 类似"指针的std::tuple"。VariadicPack包装它,适用于变参场景 |
kgen.variadic | 同质元素列表 | 例如kgen.variadic<Int32>就是 int 列表;也可以是某个 trait 的同质列表,如kgen.variadic<AnyType>,在 elaboration 之后可以变成异构列表 |
Tuple | 包装kgen.pack | 用户可见的元组类型 |
VariadicPack | 包装lit.ref.pack | 其元素类型由编译期的kgen.variadic(类型列表)描述 |
VariadicList | 包装kgen.variadic | 仅可用于 trivial 类型,文档建议在未来废弃,改用VariadicListMem |
VariadicListMem | 也包装kgen.variadic | 因为追踪 origins,可以用于 memory 类型 |
文档特别强调了一个容易误解的点:不存在lit.pack,因为它与kgen.pack冗余。另外,VariadicPack内部并不包含运行时的kgen.variadic元素——它包含的是编译期类型列表kgen.variadic所描述的一堆指针(lit.ref.pack)。
kgen.variadic 的双重角色
kgen.variadic在文档中出现频率很高且看似神秘,其角色可以这样澄清:
- 它本质上就是一个数组,把它当作数组看待即可。
VariadicList/VariadicListMem包含一个运行时值的kgen.variadic(数组)。VariadicPack包含一个运行时的lit.ref.pack(指针元组),但使用编译期的kgen.variadic(类型数组)来描述这些值的类型。
一句话总结:VariadicList在运行时使用值的kgen.variadic;VariadicPack在编译期使用类型的kgen.variadic(描述运行时lit.ref.pack中各元素的类型)。
源码佐证:!kgen.param_list、!kgen.struct 与 !lit.ref.pack
在 Mojo/include/Mojo/KGENDialect/KGENTypes.td 中定义了!kgen.param_list类型:"a homogeneously typed list of zero or more elements",并且携带原始参数约定(owned/read/mut 等)。而!kgen.struct(同文件 L766-L812)描述的是"元素类型按声明顺序排列的结构体",其元素类型存储为TypedAttr,既可以是已解析的具体ParamListAttr,也可以是 elaboration 之前的参数化表达式(如变参参数引用);它还带有一个isParamPack布尔标志,参数约定降级阶段会据此判断何时可以展平(flatten)一个 pack。
在 Mojo/include/Mojo/LITDialect/LITTypes.td 中,!lit.ref.pack被定义为一组异构的引用(references),其运行时的语义表示正是!kgen.struct<[!lit.ref<f32, lt>, !lit.ref<String, lt>]> isParamPack>,即"一包引用组成的结构体"。这里也印证了文档的说法:解析器(parser)生成的是!lit.ref.pack,而不是直接使用!kgen.pack。
在 stdlib 侧,Mojo/stdlib/std/builtin/variadics.mojo 中VariadicPack的_mlir_type正是!lit.ref.pack<:param_list<element_trait> Ts, origin>——即用编译期类型列表Ts与公共 traitelement_trait参数化的一包引用。而同文件 L1108-L1134 中的VariadicList则持有Span[Pointer[element_type, origin], ...],即元素指针的 Span,运行时以数组形式存在,因此支持运行时下标与普通for迭代。
从 VariadicPack 加载到 SSA 值:两种读取方式
读取一个kgen.pack有两种方式,文档给出了明确的使用建议:
- 一次性整体加载:例如
VariadicPack.get_loaded_kgen_pack。把整个 pack 一次性加载为一个 SSA 值。 - 取单个元素指针再解引用:例如
Tuple.__getitem__的做法。拿到某个元素的指针,再加载该指针。
编译器内部几乎总是采用第二种方式。第一种方式仅在 FFI 场景下有用——比如把一串元素交给printf之类的 C 变参函数。原因有二:
- 如果元素类型是 memory-only 类型,整体加载操作是未定义的:memory-only 类型无法加载进 SSA 寄存器。
- 只有当所有元素都可寄存器传递时,才能做整体加载;"把 memory 类型加载进 SSA 寄存器"本身就是不可能的。
因此get_loaded_kgen_pack只在调用 C 变参函数之类的特定 hack 位置使用。在 stdlib 源码 variadics.mojo 中,get_loaded_kgen_pack的实现正是通过kgen.struct.load_indirect一次性加载所有元素,其注释明确写着"Useful for FFI, such as calling printf. Otherwise, avoid this if possible."(对 FFI 有用,如调用 printf,否则尽可能避免)。而VariadicPack.__getitem_param__(L1531-L1545)则是通过lit.ref.pack.extract提取单个元素的引用——这正是文档推荐的第二种路径。
VariadicList 的元素类型不能是 trait
VariadicList的元素类型不能是 trait,因为普通参数的类型不能是 trait 类型——Mojo 不支持存在类型(existentials),后者依赖动态分发(dynamic dispatch)和行为(behavior)。
而VariadicPack的元素类型可以是 trait:VariadicPack在 elaboration 之后可以是异构的(每个元素的具体类型各不相同,但都符合同一 trait),因此允许以 trait 作为元素约束。
Variadics 是运行时值
除开 comptime/解释器/优化器等特殊情况外,变参在运行时都有地址:
VariadicList(包装kgen.variadic)概念上类似于 Java 的变参——参数被收集进一个指针数组。正因为它运行时确实存在一个数组,它的索引才能接受运行时参数(参见VariadicListMem的公开文档)。VariadicPack(包装lit.ref.pack)也一样,但它在运行时降级为一个结构体,其__get__的索引是泛型参数(编译期常量)。
VariadicList之所以是这种形态,是因为编译器刻意不针对通过VariadicList传入的所有值做实例化——它保持为运行时数组,从而避免为每个调用点生成独立的代码。
kgen.pack 与 kgen.struct 的区别
kgen.pack比kgen.struct更通用:
kgen.struct要求元素类型列表是已知的(例如[Int, F32]);kgen.pack允许该列表是参数化的,即允许"元素"(elements)这种形式。
kgen.pack是参数化的,它以一个TypedAttr作为参数描述内容;对于Tuple而言,这个内容参数就是一个kgen.variadic。在 KGENTypes.td 的!kgen.struct描述中可以看到,其元素类型存储可以是"parametric expression (e.g., a variadic parameter reference) before elaboration",正是这种参数化能力的体现。
VariadicPack 的元素类型只能是 trait
看VariadicPack的定义(stdlib 源码中的实际版本比文档略有演进,增加了is_owned参数):
struct VariadicPack[ elt_is_mutable: Bool, origin: Origin[mut=elt_is_mutable], element_trait: type_of(AnyType), //, is_owned: Bool, *Ts: element_trait, ](Copyable where (not is_owned, ...), RegisterPassable, Sized):其中element_trait: type_of(AnyType)意味着element_trait可以是任何 trait——但不能是某个具体 struct 或 int。换句话说,VariadicPack的每个元素都必须由 trait 约束,这是它与VariadicList在类型约束上的又一关键分野。
星号(*)的三种含义
在 Mojo 变参语法中,*出现在不同位置代表完全不同的机制。文档用下面这段示例说明:
def bar(*vlist: Int): ... def foo*arg_types: MyTrait: ...*vlist: Int:产生一个VariadicList[Int](同质运行时数组)。*arg_types: MyTrait:产生一个kgen.variadic(本质是数组)的类型,每个类型都符合MyTrait——这是编译期类型列表。*args: *arg_types:产生一个VariadicPack,其元素类型由arg_types这个kgen.variadic描述,内部包含一个kgen.pack,装着这些类型的值。
三种星号分别作用于:值列表(运行时数组)、类型列表(编译期参数)、以及"以类型列表描述的一包值"。这正是 Mojo 变参区别于其他语言的关键设计:把"值的变参"和"类型的变参"统一在一个语法模型里。
变参参数化 / 变参引用 / 变参捕获函数(PPPRPCF)
文档引入了三个容易混淆的概念,并给出了对照示例:
def variadic_parameterized*arg_types: MyTrait: ... struct VariadicStruct[*arg_types: MyTrait]: def variadic_referring(*args: *arg_types): ... def variadic_capturing[ *arg_types: MyTrait, //, func: def (*args: *arg_types) -> Int, ]() -> VariadicStruct[*arg_types]: ...- variadic-parameterized(变参参数化)函数:可以接收任意参数。例如:一个
Int加一个Bool;六个Spaceship;什么都不传;甚至七十三个TurtleDove。 - variadic-referring(变参引用)函数:不能随便接收参数,只能接收与
VariadicStruct实例化时完全相同的参数集合(即复用 struct 的arg_types)。 - variadic-capturing(变参捕获)函数:可以接收任何函数参数值,这些参数的实参会被读入
arg_types参数声明。
日常所说的"变参函数"(variadic function)在这三者之间常常产生歧义。在深入任何涉及变参函数的代码之前,务必先厘清自己讨论的是哪一种。文档还提示:关于 variadic-capturing 函数的更多细节,见下一节的 IFAIAV。
将函数实参推断进变参(IFAIAV):函数类型特性
IFAIAV(Inferring Function Args Into A Variadic)描述的是:一个函数可以把传入的fn参数值的实参类型读出来,并推断进一个kgen.variadic参数声明。示例(源自测试用例 CAIASV):
def device_func(a: Int, b: Bool) -> Int: ... @value struct DeviceFunction[*arg_types: MyTrait]: def call(self, *args: *arg_types) -> Int: ... def infer_variadic[ *arg_types: MyTrait, //, # paraphrased func: def (*args: *arg_types) -> Int, ]() -> DeviceFunction[*arg_types]: return DeviceFunction[*arg_types]() def main(): var thing = infer_variadic[device_func]() var result1 = thing.call(42, True)注意:infer_variadic并不是 variadic-parameterized 函数(见上一节 PPPRPCF 的分类),它属于variadic-capturing 函数。这个例子做了三件事:
main把一个类型为def(Int, Bool)的函数作为参数值传给infer_variadic的输入参数func(其期望类型为def(*:*arg_types))。- 在传参过程中,
infer_variadic的调用点推断出arg_types = variadic(Int, Bool)。 - 调用点随之知道应当返回
DeviceFunction[variadic(Int, Bool)]。
从thing.call这一行可以看到实际效果:它强制要求用户传给call的参数类型与device_func的形参类型完全一致,从而在编译期就保证了类型安全。这个特性有时也被称为"函数类型(function types)"特性。
综合:一份编译器内部的变参速查清单
把文档与源码结合,可以归纳出以下便于记忆的要点:
- 三种用户态变参类型:
VariadicList(同质值数组,仅 trivial 类型,建议废弃)、VariadicListMem(指针数组,支持 memory 类型)、VariadicPack(异构指针元组,元素须符合公共 trait)。 - 四种底层表示:
!kgen.pack(异构元素包,Tuple的底层)、!lit.ref.pack(异构引用包,VariadicPack的底层)、kgen.variadic(同质列表,既是运行时值数组也是编译期类型数组)、!kgen.struct(已知元素类型列表的结构体,带isParamPack标志)。 - 迭代规则:
VariadicList用普通for;VariadicPack用comptime for(或each/each_idx)。 - 索引规则:
VariadicList编译期/运行期皆可索引;VariadicPack仅编译期索引。 - 读取规则:几乎总是"取元素指针再加载";只有 FFI(如
printf)场景才整体加载 pack。 - 函数分类:先分清 variadic-parameterized / variadic-referring / variadic-capturing,再讨论变参函数行为。
- 性能模型:变参的间接寻址会在 elaboration 后的参数约定降级阶段被优化消除,不必担心运行时开销。
这份清单可以直接作为阅读 Mojo/lib/Elaborator 与 Mojo/lib/KGENToLLVM 相关源码时的参照地图。理解这些底层表示与分工,是深入 Mojo 编译器变参实现、乃至参与其标准库与语言特性开发的基础。
【免费下载链接】mojoThe Modular Platform (includes MAX & Mojo)项目地址: https://gitcode.com/GitHub_Trending/mo/mojo
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考