[翻译]relay中的表达式— tvm 0.7.dev0文档

创建时间: 2020-01-21 11:06
更新时间: 2020-06-01 22:56
来源: https://docs.tvm.ai/langref/relay_expr.html

Relay中的表达式

Relay IR是一种纯粹的面向表达式的语言。以下各节描述了Relay中的不同表达式,并提供了其语义的详细信息。

数据流和控制片段

为了将Relay与传统的基于计算图的IR进行比较,从数据流和控制片段的角度考虑Relay表达式可能会很有用。编写和表达转换时,可以将relay程序的每个部分包含仅影响数据流的表达式,将其视为传统的计算图。
数据流**片段涵盖了不涉及控制流的一组relay表达式**。也就是说,仅包含以下结构的程序的任何部分都对应于纯计算图:

控制流**表达式允许图拓扑根据先前执行的表达式的值进行更改**。Relay中的控制片段包括以下构造:

从计算图的角度来看,一个函数是一个子图,一个函数调用内联该子图,用其参数替换具有相应名称的子图中的自由变量。因此,如果函数的主体仅使用数据流构造,则对该函数的调用位于数据流片段中;相反,如果函数的主体包含控制流,则对该函数的调用不属于数据流片段。

变量(Varialbles)

受LLVM的启发,Relay在AST和文本格式中都显式区分局部变量和全局变量。在文本格式中,全局变量和局部变量由前缀或印记来区分。全局变量以@开头,局部变量以%开头。
这种明显的区别使某些优化更容易实现。例如,内联全局定义无需进行分析:只需替换定义即可。

全局变量

全局标识符以@标记为前缀,例如“ @global”。全局标识符始终引用包含在全局可见环境中的全局可见的定义,称为模块。全局标识符必须唯一。
有关其实现和文档,请参见GlobalVar

局部变量

局部标识符以%标记为前缀,例如“ %local”。局部标识符始终引用函数参数或let表达式中绑定的变量,并且范围分别限于其出现的函数或绑定它的let表达式。
在下面的代码段中,请注意%a定义了两次。与大多数函数式语言一样,这是允许的;在第二个let表达式的范围内 ,名称%a为“阴影”,这意味着%a内部范围内的所有引用均引用后面的定义,而%a外部范围内的所有引用继续引用第一个定义。
let %a = 1;
let %b = 2 %a; // %b = 2
let %a = %a + %a; // %a = 2. %a *is
shadowed
%a + %b // has value 2 + 2 = 4
(请注意,在Relay的实现中,局部变量的每个定义都会创建一个新的[Var](https://docs.tvm.ai/api/python/relay/expr.html#tvm.relay.expr.Var),因此,尽管在外部范围中与局部变量具有相同的名称,但阴影的局部变量将是一个不同的对象。 这允许通过指针标识来比较局部变量,并了解相同的局部变量对象对应于不同的绑定站点。)
有关其实现和文档,请参见Var

函数(Functions)

Relay中的函数的作用类似于其它编程语言中的过程或函数,并用来概括命名子图的概念。
函数是Relay中的第一类,这意味着它们就像变量、常量和元组一样是表达式。此外,Relay中的函数是高阶的,这意味着可以将函数作为参数传递给函数或由函数返回,因为函数表达式的计算结果为闭包(请参见Closures小节),这些闭包是张量和元组之类的值。
有关函数节点的定义和文档,请参见Function

语法(Syntax)

函数的定义至少由关键字fn,空参数集()和由花括号包含的主体表达式(Expr)组成。
fn() { body }
函数的定义可以包含任意数量的参数。例如,一个调用add算子的简单函数:
fn(%x, %y) { add(%x, %y) }
注意,在函数体内,参数是局部变量,就像在let表达式中绑定的一样。
也可以在函数上显式地注释类型。例如,我们可以将上述功能限制为仅适用于某些类型:
fn(%x : Tensor[(10, 10), float32], %y : Tensor[(10, 10), float32])
-> Tensor[(10, 10), float32] {
add(%x, %y)
}
上面的函数仅接受Tensor[(10, 10), float32]类型的参数,并返回Tensor[(10, 10), float32]类型的值。函数参数只是一个局部变量(LocalVar),可以选择用类型注释,表示为%x:Type 。
当省略类型信息时,relay将尝试为用户推断最通用的类型。此属性称为泛化 :对于没有显式注释的定义,relay尝试根据函数体和调用位置将最通用的类型分配给参数,并返回类型。
可以使用let绑定定义递归函数表达式,如下所示:
let %fact = fn(%x : Tensor[(10, 10), float32]) -> Tensor[(10, 10), float32] {
if (%x == Constant(0, (10, 10), float32)) {
Constant(1, (10, 10), float32)
} else {
%x * %fact(%x - Constant(1, (10, 10), float32))
}
};
%fact(Constant(10, (10, 10), float32))

闭包(Closures)

函数表达式的计算结果为闭包。闭包是表示为一对本地环境(用于存储在函数主体范围之外定义的所有变量的值)和函数本身的值。
例如,在下面的示例中,最终结果是一个零值的张量,因为%f的闭包在定义了%f的指针处存储%x的值。
let %g = fn() {
let %x = Constant(0, (10, 10), float32);
// %x is a free variable in the below function
fn(%y) { %y %x }
};
// the %x in %g’s body is not in scope anymore
// %f *is
a closure where %x maps to Constant(0, (10, 10), float32)
let %f = %g();
let %x = Constant(1, (10, 10), float32);
%f(%x) // evaluates to Constant(0, (10, 10), float32)

多态和类型关系(**Polymorphism and Type Relations**)

注意:文本格式尚不支持类型参数语法。
还可以为函数提供一组类型参数,这些参数可以代替调用站点上的特定类型。具有类型参数的函数是多态类型 ; 它们的返回类型或将接受的参数类型可以根据调用站点提供的类型参数而有所不同。
类型参数是按种类分类的,并且只能出现在类型签名适当的部分(例如,种类的类型参数Shape只能出现在张量类型中期望有形状的地方);有关完整讨论,请参见有关类型参数的文档
例如,可以为任何relay类型定义一个多态标识函数,如下所示:
fn(%x : t) -> t {
%x
}
以下定义也是多态的,但将其参数限制为张量类型:
fn(%x : Tensor[s, bt]) {
%x
}
请注意,返回类型被省略,并将被推断出来。
注意:文本格式尚不支持“ where”语法。
函数也可能受一个或多个类型关系的约束,例如:
fn(%x, %y) where Broadcast { add(%x, %y) }
在上面的定义中,和的类型%x%y返回类型都受该Broadcast关系的约束,这意味着所有这三个必须是张量,并且它们的形状遵循元素广播关系。与算子一样,关系的定义对Relay不是透明的,而是在外部以C++或Python实现。
与Broadcast的情况一样,关系用于表示类型(尤其是张量形状)的复杂约束。所有函数关系必须在所有调用位置都存在;因此,将类型检查视为强制解决的问题。有关类型关系及其实现的更多详细信息,请参见Relay的类型系统文档

算子(Operator)

算子是原始操作,例如addconv2d,未在relay语言中定义。算子在c++中的全局算子注册表中声明。TVM的Tensor算子清单(TOPI)支持许多常见的算子。
要注册算子,用户必须提供算子的实现、其类型以及任何其它所需元数据。算子注册表是一个基于列的存储,其中算子是键,因此任何元数据(可能由优化遍历引用)都可以注册为新列。
从relay类型系统的角度来看,算子是一个函数,因此可以像其它任何函数一样被调用,算子也具有函数类型。特别是,使用单个类型关系(请参阅有关类型关系的文档)(通常是专门针对该算子的关系)来注册算子类型。例如,add算子已注册了Broadcast关系,add参数须为张量,而返回类型为张量,其形状取决于其参数。
在漂亮地打印Relay程序时,不会显示算子(例如conv2d flatten)。算子显式包含在程序中,并且可以通过指针唯一地标识。
注意,诸如add和multiply的通用算术算子可以使用相应的算术算子以文本格式(例如+*)编写为语法糖。
有关算子节点的定义和文档,请参阅参考资料Op,以演示注册算子元数据的基础结构。op其它文件中提供了用于生成对各种预注册算子的调用的句柄。在Relay添加算子教程显示了如何在语言中添加更多算子。

ADT(**Algebraic data type**)构造函数

separate overview 中详细地描述了relay代数数据类型(ADT), 它们融入类型系统的描述请参考这里
在本节中,我们注意到ADT构造函数被提供了一个函数类型,应在函数或算子之类的call node内部使用。ADT构造函数是通过提供其构造的ADT的名称(全局类型变量)和该构造函数的预期参数的类型来定义的。
如果ADT定义包括类型变量,则这些类型变量可能会出现在构造函数中。构造函数不能包含任何其它类型变量。
让我们假设D是一个ADT,它接受类型参数 ab。如果C1是D的构造函数, 并且需要两个参数,一个为type a,另一个为type b,则 C1具有以下类型签名:fun(a, b) -> D[a, b] 。(见无论是ADT概述或ADT类型在返回类型的类型调用的说明的讨论)。如果D另一个构造函数为C2,不带任何参数,那么它具有以下类型签名:fun() -> D[a, b]; 类型参数将始终出现在返回类型中。
调用后,构造函数将生成一个ADT实例,该实例是一个容器,用于存储该构造函数的参数值以及该构造函数的名称(“标记”)。该标签将用于解构实例并在ADT Matching时检索值 。
有关定义和文档,请参见Constructor

调用(Call)

Relay中函数类型的表达式是“可调用的”,这意味着它们可以通过函数调用来调用。它们由任何评估为闭包的表达式(即函数表达式或全局函数)和Relay算子组成。
调用的语法遵循类似C语言中的语法,如下所示:
let %c = 1;
let %f = fn(%x : Tensor[(), float32], %y : Tensor[(), float32]) { %x + %y + %c };
%f(10, 11)
当调用闭包时(请参见Closures),在存储环境中(即使用自由变量的存储值)对闭包的主体进行评估,并为每个参数添加局部变量绑定。通过评估主体获得的最终值是调用的返回值。因此,在上面的示例中,该调用的求值为22。对于算子,该实现对Relay是不透明的,因此结果留给已注册的TVM实现。
注意:文本格式尚不支持类型参数。
一个类型多态的函数还可以在调用站点中引用类型参数。类型检查时,将类型参数替换为类型实参。如果函数是类型多态的,并且未提供类型实参,则类型推断将尽可能尝试推断类型实参。以下代码提供了显式和推断的类型参数的示例:
// %f : fn(a, b) -> c
let %x1 = %f(True, False);
// %x1 is of type Tensor[(), bool]
let %x2 : () = %f(%x1, %x1)
// the type arguments in the second call are inferred to be
请注意,函数类型中的所有类型关系都必须在每个调用位置都存在。具体来说,这意味着将根据给定调用站点上参数的特定类型来检查该关系。这也是多态的一种形式,因为只要满足关系,就可以有多个有效的参数类型和返回类型分配。
例如,如果我们有一个使用%f张量参数并具有Broadcast关系的函数,那么下面的调用中的参数可能具有许多可以满足类型注释的形状:
let %x : Tensor[(100, 100, 100), float32] = %f(%a, %b);
%x
有关其定义和文档,请参见
Call

模块和全局函数

relay保留称为“模块”(在其它功能编程语言中通常称为“环境”)的全局数据结构,以跟踪全局函数的定义。特别是,模块保留全局变量到它们所表示的函数表达式的映射,该映射是全局可访问的。该模块的用途是它允许全局函数递归引用自己或任何其它全局函数(例如,在相互递归中)。
注意,relay的模块类似于在基于计算图的IR中跟踪子图的数据结构。
全局函数跟 Functions中定义的函数表达式的行为一致,但在文本格式有语法糖输入函数的定义到模块。即,全局函数定义包括全局标识符,并允许在主体中递归引用该标识符,如以下示例所示:
def @ackermann(%m : Tensor[(), int32], %n : Tensor[(), int32]) -> Tensor[(), int32] {
if (%m == 0) {
%n + 1
} else if (%m > 0 && %n == 0) {
@ackermann(%m - 1, 1)
} else {
@ackermann(%m - 1, @ackermann(%m, %n - 1))
}
}
此定义将产生模块入口映射,该映射将标识符@ackermann映射到函数表达式(具有参数、返回类型和上述主体的)。代码中其它位置,对标识符@ackermann的任何引用都可以在模块中查找标识符,并根据需要替换函数的定义。
请参阅**Module**以获取模块的定义和文档。

常量

此node表示一个张量常量(有关更多详细信息,请参见Value)。一个常量表示为**NDArray**,它允许Relay利用TVM算子进行常量评估。
由于标量是带有()的张量,因此该node也可以表示标量常量。因此,在文本格式中,数字和布尔文字是这样的语法糖,它用于编码秩为零的张量类型。
有关其定义和文档,请参见Constant

元组

构造

元组node构建异构数据的有限序列(即静态已知大小)。这些元组与Python紧密匹配,并且它们的固定长度允许有效地投射其成员。
fn(%a : Tensor[(10, 10), float32], %b : float32, %c : Tensor[(100, 100), float32]) {
let %tup = (%a, %b); // type: (Tensor[(10, 10), float32], float32)
((%tup.0 + %tup.1), %c) // type: (Tensor[(10, 10), float32], Tensor[(100, 100), float32])
}
有关其定义和文档,请参见Tuple

投影

为了提取元组的特定成员,必须用整数常量对元组进行索引。投影的索引从0开始。
例如,下面的投影结果为%b
(%a, %b, %c).1
有关其定义和文档,请参见TupleGetItem

let绑定

一个let绑定是不可变的局部变量的绑定,允许用户将一个表达式绑定到一个名称。
一个let绑定包含一个局部变量,一个可选的类型注释,一个值,以及一个可引用绑定标识符的body表达式。如果省略了绑定变量上的类型注释,则Relay会尝试推断该变量允许的最通用类型。
一个let表达式中的绑定变量仅在其body范围内,除非该变量定义了函数表达式。当let表达式创建函数时,变量的值也在范围内,以允许递归定义的函数(请参见上一小节)。
let绑定的值是在评估它所依赖的绑定之后的最终表达的值。例如,在下面的示例中,整个表达式的结果是形状(10, 10)的张量,其中所有元素均为2。
let %x : Tensor[(10, 10), float32] = Constant(1, (10, 10), float32);
%x + %x
一序列let绑定可以视为数据流图,其中绑定是由绑定变量连接的一系列子图。由于这些绑定序列是纯净的,因此可以安全地对不依赖彼此的一对绑定进行重新排序。例如,下面的第一个和第二个let绑定可以按任何顺序求值,因为两者都不依赖于数据流:
let %x = %a + %b;
let %y = %c + %d;
%x * %y
有关其定义和文档,请参见Let

图绑定

一个let绑定创建一个命名变量(该变量被绑定到给定的值,且只限于随后的表达式)。相比之下,图绑定允许通过直接将表达式(graph node)绑定到不受范围限制的临时变量来在Relay程序中显式构造数据流图。每个对该变量的引用都对应于数据流图中的一条边。它有在表达式出现的位置替换表达式的语法,即使图node仅被编译程序评估一次。
这些绑定允许一种与NNVM和其它基于数据流图的输入格式已经采用的编程风格相对应的编程风格。与let 绑定相比,变量不受范围限制的事实为评估顺序提供了一定的灵活性,尽管这也会在程序中引入一些歧义(Relay IR开发者指南 包括对此细微差别的更详细讨论)。
注意:图绑定当前不能通过文本格式解析。
在Relay的文本格式中,可以按以下方式编写图形绑定(请注意,缺少 let关键字和分号):
%1 = %a + %b
%2 = %1 + %1
%2 * %2
与let绑定不同,图绑定在Relay中不表示为AST节点,而是表示为引用其AST节点值的元变量。例如,可以通过将Python变量设置为等于相应的Relay AST节点并重复使用变量,在Relay的Python前端中构建上述程序,如下所示(使用相应API绑定的C++程序可以完成相同的操作):
sum1 = relay.add(a, b)
sum2 = relay.add(sum1, sum1)
relay.multiply(sum2, sum2)
出于开发目的和实现某些优化目的,Relay包括用于在使用图绑定定义的数据流图和具有 A-normal形式的let绑定的程序之间进行转换的pass,这些函数被函数编程社区的许多编译器优化所采用(请参阅“A-Normalization:Why and How” (Matt Might),介绍了A-normal形式)。

If-Then-Else

relay具有一个简单的if-then-else表达式,该表达式使程序可以分支到单个类型为bool的值(即布尔类型的零秩张量Tensor[(), bool])。
if (%t == %u) {
%t
} else {
%u
}
由于if-then-else分支是表达式,因此它们可能在期望其它任何表达式的位置都内联显示,例如使用C语言编写的三元算子。如果条件值评估为True,则if-then-else表达式评估为“ then”分支的值;如果条件评估为False,则if-then-else表达式评估为“ else”分支的值。
有关其定义和文档,请参见If

ADT匹配

ADT概述中所述,代数数据类型(ADT)的实例 是存储传递给用于创建它们的构造函数的参数的容器,并以构造函数名称标记。
Relay中的匹配表达式允许根据其构造函数标签检索存储在ADT实例中的值(“解构”它)。match表达式的行为类似于C样式的switch语句,针对要解构的值的类型,分支到不同的可能构造函数上。关于ADT的详细信息,匹配表达式比通过构造方法简单地进行拆分更能进行一般的模式匹配:嵌套在实例中的任何ADT实例(例如列表的列表)都可以与外部实例同时解构,而实例的不同字段可以绑定到变量。(有关ADT模式匹配的详细说明,请参阅本节。)
使用输入值(一个表达式)和一个子句列表定义一个匹配表达式,每个子句由一个模式和一个表达式组成。执行时,将执行 其模式与查询值的结构匹配的第一个子句;子句表达式被求值并返回。
例如,假设我们有一个自然数的ADT:
data Nat {
Z : () -> Nat # zero
S : (Nat) -> Nat # successor (+1) to a nat
}
然后,以下函数从传递的nat中减去一个:
fn(%v: Nat[]) -> Nat[] {
match(%v) {
case Z() { Z() }
case S(%n) { %n } # the variable %n is bound in the scope of this clause
}
}
下面的函数如果其参数至少为2,则从其参数中减去2,否则,使用嵌套的构造函数模式返回该参数:
fn(%v : Nat[]) -> Nat[] {
match(%v) {
case S(S(%n)) { %n }
# wildcard pattern: matches all cases not matched already
case { %v }
}
}
如上所述,匹配子句的顺序是相关的。在下面的示例中,第一个子句将始终匹配,因此其下的子句将永远无法运行:
fn(%v : Nat[]) -> Nat[] {
match(%v) {
case
{ %v }
case S(S(%n)) { S(%n) }
case S(%n) { %n }
case Z() { S(Z()) }
}
}
有关其定义和文档,请参见Match

TempExprs

Relay中的程序转换(pass)可能需要在程序AST中插入临时状态以指导进一步的转换。为此,将TempExprnode作为实用程序提供给开发人员。继承自TempExpr的node不能直接出现在用户提供的代码中,但可以插入pass中。理想情况下,在pass中创建的所有TempExpr对象都应在pass完成之前消除,因为 TempExpr仅存储内部状态,并没有自己的语义。
有关TempExpr在pass中使用的示例,请参见src/relay/pass/alter_op_layout.cc,在pass尝试重新安排算子调用时,它使用TempExpr节点存储有关算子布局的信息。
有关其定义和文档,请参见TempExpr