方言
简介
方言(Dialects)是参与并扩展MLIR(多级中间表示,多级中间语言)生态系统的机制。它们允许定义新的操作、属性和类型。每个方言都有一个唯一的命名空间,这个命名空间会作为前缀添加到每个定义的属性、操作和类型前。例如,Affine方言定义的命名空间是:affine。
MLIR允许多个方言共存,即使这些方言不在主树结构内,也可以在一个模块内共同存在。方言由特定的传递过程产生和消费。MLIR提供了一个框架,可以在不同的方言之间进行转换,也可以在同一个方言内进行转换。
操作
MLIR引入了操作的概念,用于描述不同级别的抽象和计算。操作可以具有特定于应用程序的语义,并且是完全可扩展的,也就是说没有固定的操作列表。
每个操作由一个唯一的字符串标识来识别,例如"dim"、“tf.Conv2d”、“x86.repmovsb”、"ppc.eieio"等。操作可以返回零个或多个结果,接受零个或多个操作数。它还可以存储属性、具有属性的字典、具有零个或多个后继操作以及零个或多个封闭区域。
操作的内部表示相对简单,通常以字面形式包含所有这些元素。为了指示结果和操作数的类型,它还包括一个函数类型。
块
一个块(Block)是一个操作列表。在SSA CFG(静态单赋值形式控制流图)区域中,每个块代表一个编译器基本块,其中块内的指令按顺序执行,终结操作(terminator operations)实现基本块之间的控制流分支。
块中的最后一个操作必须是终结操作。一个只有单个块的区域可以通过在封闭操作上附加NoTerminator来免除这一要求。顶级的ModuleOp就是一个定义这种特性且其块体没有终结操作的操作的例子。
MLIR中的块接受一个块参数列表,表示方式类似于函数。块参数与由个别操作语义指定的值绑定。区域入口块的块参数也是区域的参数,这些参数的值由包含操作的语义决定。其他块的块参数由终结操作的语义决定,例如将该块作为后继者的分支。在具有控制流的区域中,MLIR利用这种结构隐式地表示控制流依赖值的传递,而无需传统SSA表示中的PHI节点的复杂细节。注意,不依赖控制流的值可以直接引用,不需要通过块参数传递。
以下是一个简单的函数示例,展示了分支、返回和块参数:
func.func @simple(i64, i1) -> i64 {
^bb0(%a: i64, %cond: i1): // 由^bb0支配的代码可以引用%a
cf.cond_br %cond, ^bb1, ^bb2
^bb1:
cf.br ^bb3(%a: i64) // 分支传递%a作为参数
^bb2:
%b = arith.addi %a, %a : i64
cf.br ^bb3(%b: i64) // 分支传递%b作为参数
// ^bb3从前驱接收一个参数,命名为%c,并将其与%a一起传递给bb4。
// %a直接从其定义操作引用,不通过^bb3的参数传递。
^bb3(%c: i64):
cf.br ^bb4(%c, %a : i64, i64)
^bb4(%d : i64, %e : i64):
%0 = arith.addi %d, %e : i64
return %0 : i64 // 返回也是一个终结操作。
}
区域
区域是MLIR(多级中间表示)块的有序列表。区域内部的语义并不是由IR(中间表示)强加的,而是由包含该区域的操作定义的。MLIR当前定义了两种区域:SSACFG区域,用于描述块之间的控制流,以及Graph区域,不需要块之间的控制流。操作中的区域类型通过RegionKindInterface描述。
区域没有名称或地址,只有区域中包含的块有。区域必须包含在操作内,并且没有类型或属性。区域中的第一个块是一个特殊的块,称为“入口块”。入口块的参数也是区域本身的参数。入口块不能被列为任何其他块的后继块。
值范围
- 区域的层次结构:
区域提供了一种层次化的封装方式,这意味着你不能从一个区域跳转(branch)到另一个区域。例如,如果你有一个代码块在区域A,那么你不能直接跳转到区域B的代码块。
值的作用范围 - 区域自然地限制了值的可见性:
在一个区域中定义的值不会逃逸到外部的区域。比如,在内部区域定义的变量,在外部区域是不可见的。 - 区域内的操作:
在一个区域内的操作可以引用外部区域定义的值,但前提是这些值在包含该区域的操作中是合法的。比如,如果外部区域允许使用某个变量,那么这个变量在内部区域也是可以使用的。 - 限制引用的特性:
可以使用一些特性(traits)来限制引用,例如 OpTrait::IsolatedFromAbove,或者使用自定义验证器来控制这些规则。
示例解释:
"any_op"(%a) ({ // 如果 %a 在包含的区域中是可见的…
// 那么 %a 在这里也是可见的。
%new_value = "another_op"(%a) : (i64) -> (i64)
}) : (i64) -> (i64)
在这个例子中,如果 %a 在外部区域中是可见的,那么它在内部区域中也是可见的。
- MLIR中的层次支配概念:
MLIR(多级中间表示)定义了一种广义的“层次支配”概念,用来确定一个值是否“在作用范围内”以及是否可以被某个操作使用。
在同一个区域内,值是否可以被另一个操作使用,取决于该区域的类型。
如果一个值在一个区域中定义,那么只有当一个操作的父操作在同一区域且可以使用该值时,这个值才能被使用。
一个区域的参数定义的值,可以被该区域内的任何操作使用。
一个区域中定义的值,永远不能在该区域外部使用。
Control Flow and SSACFG Regions
在MLIR中,有一种叫做SSACFG的区域,这个区域的操作就像我们写代码那样,是按顺序执行的。简单来说:
- 操作顺序执行:在一个操作执行前,它需要的所有数据(操作数)已经准备好并且有明确的值。操作执行后,这些数据的值保持不变,同时生成的结果也有明确的值。
- 操作之间的控制流:操作一个接一个地执行,直到执行到块(代码段)末尾的“终止操作”。然后,控制流会根据终止操作的指示,跳到其他地方继续执行。
控制流的进入和退出
- 进入区域:控制流总是从区域的第一个块(入口块)开始。
- 退出区域:控制流可以通过任何带有合适终止操作的块退出区域。比如,某个块的终止操作指示跳回外部操作(像函数的返回)。
实例
func.func @accelerator_compute(i64, i1) -> i64 { // 一个SSACFG区域
^bb0(%a: i64, %cond: i1): // 被 ^bb0 支配的代码可以引用 %a
cf.cond_br %cond, ^bb1, ^bb2
^bb1:
// 这里定义的 %value 不支配 ^bb2
%value = "op.convert"(%a) : (i64) -> i64
cf.br ^bb3(%a: i64) // 分支传递 %a 作为参数
^bb2:
accelerator.launch() { // 一个SSACFG区域
^bb0:
// 嵌套在 "accelerator.launch" 下的代码区域,它可以引用 %a 但不能引用 %value。
%new_value = "accelerator.do_something"(%a) : (i64) -> ()
}
// %new_value 不能在区域外引用
^bb3:
...
}
说明
- 支配关系和变量引用
支配关系(Domination)在编译原理中指一个基本块B1支配另一个基本块B2,如果每次控制流进入B2之前必定会经过B1。这个概念帮助我们理解变量的可见性和生命周期。
块^bb0
- 支配关系:bb0是函数的入口块,所以它支配所有其他块(bb1、bb2和bb3)。
- 变量引用:bb0中的变量%a和%cond在bb0、bb1、bb2和bb3中都可以被引用,因为这些块都被bb0支配。
块^bb1
- 支配关系:bb1不是其他任何块的支配块,因为从bb0到bb3可以通过bb2而不经过^bb1。
- 变量引用:bb1定义了变量%value,但由于bb1不支配bb2和bb3,%value不能在bb2和bb3中引用。然而,%value可以在^bb1内部引用。
块^bb2
- 支配关系:^bb2同样不支配其他任何块。
- 变量引用:bb2中的加速器启动区域(accelerator.launch)是一个新的嵌套区域,虽然它可以引用来自外部块(bb0)的变量%a,但不能引用来自bb1的变量%value,因为bb1不支配^bb2。
加速器启动区域(accelerator.launch 内部的 ^bb0)
- 支配关系:这个区域是独立的SSACFG区域,有它自己的控制流和支配关系。区域内的块^bb0支配区域内的所有操作。
- 变量引用:区域内的操作可以引用外部区域的变量%a,但不能引用%value,因为%value的定义在当前区域的控制流之外(即不在这个区域内的支配链上)。
块^bb3
- 支配关系:bb3既不支配也不被bb1或^bb2支配。
- 变量引用:bb3只能引用在bb0中定义并且被传递下来的变量%a,但不能引用bb1中定义的%value,因为%value的作用范围仅限于bb1
综上所述
- 入口块^bb0支配所有其他块,因此它定义的变量%a和%cond在整个函数中都是可见的。
**bb1定义的变量%value**只能在bb1内部引用,不能在bb2和bb3中引用,因为bb1不支配bb2和^bb3。
**加速器启动区域(accelerator.launch)**可以引用外部块(bb0)的变量%a,但不能引用来自bb1的变量%value。
bb3只能引用bb0中定义并传递下来的变量%a,但不能引用^bb1中的%value。
操作与多区域(Operations with Multiple Regions)
概念解释:
在编程中,操作(operation)可以包含多个区域(region)。区域就像是操作内部的小块代码或逻辑。当控制流到达一个操作时,这个操作可以选择将控制权传递给它内部的任何一个区域。当控制流从一个区域返回时,操作可以继续将控制权传递给其他区域。一个操作可以同时管理多个区域,甚至可以调用其他操作中的区域。实际例子:
假设我们有一个主操作 mainOp,它包含两个子区域 regionA 和 regionB。当 mainOp 被执行时,它首先将控制权传递给 regionA。当 regionA 完成后,mainOp 将控制权传递给 regionB。
"mainOp"() ({
// regionA
"opA"() : () -> ()
// regionB
"opB"() : () -> ()
}) : () -> ()
在这个例子中,mainOp 包含了 regionA 和 regionB 两个区域,并按顺序执行它们。
闭包(Closure)
概念解释:
闭包是一种将代码块和其环境打包成一个整体的技术。区域允许我们定义创建闭包的操作,将区域的主体“打包”成一个值。闭包可以在以后执行,具体执行的方式由操作定义。如果一个操作是异步执行的,调用方需要确保等待操作完成,以保证所用的值依然有效。实际例子:
假设我们有一个操作 createClosure,它将一个区域打包成一个闭包,并返回一个函数值。
"createClosure"() ({
// The region to be packed as a closure
%result = "opInClosure"() : () -> (i32)
}) : () -> (function<i32()>)
在这个例子中,createClosure 将 opInClosure 操作打包成一个闭包,并返回一个函数值,可以在以后调用。
图形区域(Graph Regions)
在MLIR(多级中间表示)中,图形区域(graph region)的概念用于表示图状语义,即没有控制流但可能存在并发语义或通用有向图数据结构的情况。图形区域非常适合表示没有基本顺序的循环关系或耦合值之间的关系。例如,图形区域中的操作可以代表独立的控制线程,而值可以代表数据流。
图形区域有以下几个关键点:
单一基本块:目前,图形区域被限制为只能包含一个基本块(entry block)。虽然这种限制没有特定的语义原因,但它被添加的目的是为了简化通过的基础设施,确保处理图形区域的各种传递(passes)能够正确处理反馈循环。未来,如果有需求,可能会允许多基本块的图形区域。
操作和值的表示:在图形区域中,MLIR操作代表图中的节点,而每个MLIR值代表一个多边连接,即一个源节点和多个目标节点的连接。区域内定义的所有值都在区域内的作用域内,并且可以被区域内的其他操作访问。
操作的顺序无关性:在图形区域中,基本块内操作的顺序和区域内基本块的顺序在语义上没有意义,非终止操作可以自由重排,例如通过规范化(canonicalization)进行重排。
循环的可能性:在图形区域中,循环(cycles)可以发生在单个基本块内,也可以发生在基本块之间。
参数和结果(Arguments and Results)
概念解释:
一个区域的第一个块的参数被视为区域的参数。参数的来源由父操作的语义决定,可能对应操作本身使用的一些值。区域会生成一个(可能为空的)值列表,操作的语义定义了区域结果与操作结果之间的关系。实际例子:
假设我们有一个操作 funcOp,它包含一个区域 funcRegion,区域的参数为 %arg1 和 %arg2。
module {
func @main(%arg0: i32, %arg1: i32) -> i32 {
%0 = "myFuncOp"(%arg0, %arg1) : (i32, i32) -> (i32)
return %0 : i32
}
"myFuncOp"(%input1: i32, %input2: i32) -> (i32) {
^entry(%arg1: i32, %arg2: i32):
%result = addi %arg1, %arg2 : i32
return %result : i32
}
}
关系解释
父操作 myFuncOp:
myFuncOp 是父操作,它包含一个区域。
myFuncOp 接收两个输入参数 %input1 和 %input2,类型为 i32。
区域 funcRegion:funcRegion 是 myFuncOp 的区域。
funcRegion 的第一个基本块 ^entry 接收两个参数 %arg1 和 %arg2,这些参数直接对应父操作 myFuncOp 的输入 %input1 和 %input2。
区域参数:^entry 基本块的参数 %arg1 和 %arg2 被视为整个区域 funcRegion 的参数。
这些参数的来源是父操作 myFuncOp 的输入 %input1 和 %input2。
区域结果:在 ^entry 基本块内,我们执行一个加法操作 addi,计算 %arg1 和 %arg2 的和,并将结果存储在 %result。
最后,区域返回计算结果 %result,这个结果成为操作 myFuncOp 的输出。
"test.graph_region"() ({ // 一个图形区域
%1 = "op1"(%1, %3) : (i32, i32) -> (i32) // 这是允许的,%1 和 %3 都在作用域内
%2 = "test.ssacfg_region"() ({
%5 = "op2"(%1, %2, %3, %4) : (i32, i32, i32, i32) -> (i32) // 这是允许的,%1, %2, %3, %4 都定义在包含的区域内
}) : () -> (i32)
%3 = "op2"(%1, %4) : (i32, i32) -> (i32) // 这是允许的,%4 在作用域内
%4 = "op3"(%1) : (i32) -> (i32)
}) : () -> ()
属性
类型系统
在编程中,每个数据都有一个类型,比如整数、浮点数、字符串等等。MLIR也是这样,但它有一个更灵活的类型系统,允许我们定义自己的类型。
在MLIR中,类型系统是开放的,这意味着你可以定义任何你需要的类型,没有一个预先固定的类型列表。这对于不同的应用程序来说非常有用,因为你可以创建特定的类型来满足你的需求。
类型的基本构成
在MLIR中,类型可以分为几种:
- 类型别名(type-alias):一个类型的替代名字。
- 方言类型(dialect-type):为特定应用定义的类型。
- 内建类型(builtin-type):系统预定义的一些基本类型。
类型列表有两种表示方式:
- 不带括号的类型列表:多个类型用逗号分隔,比如 int, float。
- 带括号的类型列表:可以是空的括号(),也可以是多个类型用逗号分隔并包含在括号内,比如 (int, float)。
当我们使用一个带有特定类型的值时,通常用这样的形式表示:值: 类型。
函数类型
函数类型用一个箭头->连接输入类型和输出类型。比如,一个函数接受一个整数并返回一个浮点数,可以表示为:int -> float。如果有多个输入或输出类型,可以用括号括起来,比如:(int, float) -> (string, bool)。
类型别名
类型别名就像给一个复杂类型起了一个简短的名字。比如,!avx_m128 = vector<4 x f32> 这句话定义了一个别名!avx_m128,它相当于vector<4 x f32>。以后在代码中你可以用!avx_m128来代替vector<4 x f32>,这样代码会更简洁和易读。
!avx_m128 = vector<4 x f32>
// Using the original type.
"foo"(%x) : vector<4 x f32> -> ()
// Using the type alias.
"foo"(%x) : !avx_m128 -> ()
方言类型
方言类型是一种自定义的数据类型,它可以扩展现有的类型系统。就像编程语言允许你创建自定义的类和结构体一样,方言类型允许你在特定的命名空间内创建自定义的类型。
方言类型的两种表示方式
不透明类型(Opaque Type):
用尖括号 <> 包裹的详细内容。
例如:!tf 表示一个 TensorFlow 的字符串类型。
“不透明”指的是类型的具体内部结构或实现细节对外部系统或用户不可见。这意味着外部系统不需要知道或理解类型的具体实现,只需要知道这个类型存在并能够使用它。简洁类型(Pretty Type):
省略了一些冗长的符号,使其更易读。
例如:!tf.string 也是表示一个 TensorFlow 的字符串类型,但更简洁。
内建类型
内建类型就是MLIR(多级中间表示)提供的一些基础数据类型。就像编程语言里我们常见的整型、浮点型和函数类型一样,这些类型在MLIR中也是直接可以使用的,并且其他任何自定义扩展(叫做方言)都可以利用这些基础类型。
属性
属性是附加在某个操作上的额外信息。就像你给一个文件夹贴上标签一样,这些属性为操作提供了更多的背景信息或特性。这些信息可以是关于操作自身的特定数据,并且可以通过特定的方法进行访问和使用。
假设你有一个“加法操作”,你可以为这个操作添加一些属性,比如“这两个数字相加的结果是否需要四舍五入”。这个属性就存储在“加法操作”上,具体值可能是 true 或 false。你可以通过特定的方法读取这个属性并决定是否执行四舍五入。
%result = addi %a, %b : i32 { rounding = true }
属性
在编程中,属性(Attributes)是一种为操作(operation)添加额外信息的方式。想象一下,你在写一个食谱,每个步骤(操作)可能有一些特定的要求或注释(属性),这些要求或注释不能被改变,只能作为参考。
如何确定属性类型
- 文档和规范:通常,MLIR操作的文档和规范会明确指出哪些属性是必需的,哪些是可选的。
- 操作定义:在MLIR操作定义文件(.td文件)中,属性的定义通常会表明其重要性和必要性。
- 上下文理解:通过理解操作的上下文和行为,判断属性是否是执行该操作所必需的。
方言属性
方言属性可以看作是给你的MLIR代码添加一些自定义的标签或者注释,这些标签可以携带特定的信息。就像你给你的代码打上“重要”、“需要优化”这样的标签一样,方言属性可以携带特定的信息供后续使用。
- 假设你有一个自定义方言,命名空间为foo,你想要给某个操作添加一个字符串属性和一个复杂属性。
// 定义一个字符串属性
#foo<string<"example_string">>
// 定义一个复杂属性
#foo<"a123^^^" + bar>
// 在MLIR代码中使用这些属性
func @example() {
%0 = "foo.operation"() { attr = #foo<string<"example_string">> } : () -> ()
%1 = "foo.operation"() { complex_attr = #foo<"a123^^^" + bar> } : () -> ()
return
}
在这个例子中,foo.operation操作使用了两个自定义的方言属性,一个是字符串属性,另一个是复杂属性。这些属性可以在后续的编译、优化或者代码生成过程中被利用。
内置方言
内置方言就像是MLIR系统提供的一些基础设施,这些基础设施包含了一些基本的工具和数据类型,所有人都可以直接使用,而不需要自己重新发明轮子。内置方言提供了一些通用的属性值和类型,这些属性和类型可以被任何方言直接使用,方便了不同方言之间的互操作。
- 假设你需要使用一些基本的整数和浮点数属性,这些属性是MLIR系统内置的。
func @example() {
// 使用内置的整数属性
%0 = "builtin.operation"() { int_attr = 42 : i32 } : () -> ()
// 使用内置的浮点数属性
%1 = "builtin.operation"() { float_attr = 3.14 : f32 } : () -> ()
return
}
在这个例子中,builtin.operation操作使用了内置的整数属性和浮点数属性。因为这些属性是内置的,所以任何方言都可以直接使用它们,而不需要自己定义。