The Relay IR is a pure, expression-oriented language. The below sections
describe the different expressions in Relay and give details of their semantics.
Dataflow and Control Fragments
==============================
For the purposes of comparing Relay to traditional computational graph-based IRs, it
can be useful to consider Relay exrpessions in terms of dataflow and control fragments.
Each portion of a Relay program containing expressions that only affect the dataflow can
be viewed as a traditional comptuation graph when writing and expressing transformations.
The dataflow fragment covers the set of Relay expressions that do not involve
control flow. That is, any portion of a program containing only the following
constructs corresponds to a pure computation graph:
- `Variables`_
- Tuple `Construction`_ and `Projection`_
- `Let Bindings`_
- `Graph Bindings`_
- Calls to `Operators`_
Control flow expressions allow the graph topology to change
based on the value of previously executed expressions. The control
fragment in Relay includes the following constructs:
- `If-Then-Else`_ Expressions
- Recursive Calls in Functions
From the point of view of a computation graph, a function is a subgraph and a function call inlines the subgraph, substituting its arguments for the free variables in the subgraph with corresponding names.
Thus if a function's body uses only dataflow constructs
, a call to that function is in the dataflow fragment; conversely, if the
function's body contains control flow, a call to that function is not part of the dataflow fragment.
Variables
=========
Inspired by LLVM, Relay explicitly distinguishes between local and
global variables both in the AST and in the text format. In the text format,
global and local variables are distinguished by prefixes, or *sigils*.
Global variables are prefixed with :code:`@` and local variables with :code:`%`.
This explicit distinction makes certain optimizations easier to implement.
For example, inlining a global definition requires no analysis: simply
substituting the definition suffices.
Global Variable
~~~~~~~~~~~~~~~~~~
Global identifiers are prefixed by the :code:`@` sigil, such as ":code:`@global`".
A global identifier always references a globally visible definition contained in the
globally visible environment, known as the `module`__.
Global identifiers must be unique.
__ `Module and Global Functions`_
See :py:class:`~tvm.relay.expr.GlobalVar` for its implementation
and documentation.
Local Variable
~~~~~~~~~~~~~~
Local identifiers are prefixed by the :code:`%` sigil,
such as ":code:`%local`". A local identifier always references
a function argument or a variable bound in a :code:`let` expression,
and will be scoped to the function where it appears or the :code:`let`
expression where it is bound, respectively.
In the below code segment, notice that :code:`%a` is defined twice. This is
permitted, as in most functional languages; in the scope of the second
:code:`let` expression, the name :code:`%a` is "shadowed," meaning all
references to :code:`%a` in the inner scope refer to the later defintion, while
references to :code:`%a` in the outer scope continue to refer to
the first one.
.. code-block:: python
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
(Note that in Relay's implementation, each definition of a local variable
creates a new :py:class:`~tvm.relay.expr.Var`, so a shadowed local variable,
despite having the same name as one in an outer scope, will be a different
object. This allows for comparing local variables by pointer identity with the
knowledge that the same local variable object corresponds to a different binding site.)
See :py:class:`~tvm.relay.expr.Var` for its implementation
and documentation.
Functions
=========
Functions in Relay act similarly to procedures or functions in
other programming languages and serve to generalize the concept
of a named subgraph.
Functions are first class in Relay, which means they are expressions just like variables, constants, and tuples.
Additionally, functions in Relay are higher-order, which means that a function can be passed as an argument to a
function or returned by a function, as function expressions evaluate to closures (see the `Closures`_ subsection),
which are values like tensors and tuples.
See :py:class:`~tvm.relay.expr.Function` for the definition and documentation of function nodes.
Syntax
~~~~~~
A definition minimally consists of the keyword :code:`fn`, an empty set of
parameters, and a body expression (:py:class:`~tvm.relay.expr.Expr`)
contained by curly braces.
.. code-block:: python
fn() { body }
A definition may contain any number of parameters. For example, a
simple function that invokes the :code:`add` operator:
.. code-block:: python
fn(%x, %y) { add(%x, %y) }
Notice that within the function's body, the parameters are local
variables, just like those bound in a :code:`let` expression.
One may also annotate explicit types on functions.
For example, we can restrict the above function to only work
We briefly introduced types while detailing Relay's expression language
, but have not yet described its type system. Relay is
a statically typed and type-inferred language, allowing programs to
be fully typed while requiring just a few explicit type annotations.
Static types are useful when performing compiler optimizations because they
communicate properties about the data a program manipulates, such as runtime
shape, data layout, and storage, without needing to run the program.
Relay's type system features a form of *dependent typing* for shapes. That is, its type system keeps track of the shapes of tensors in a Relay program. Treating tensor
shapes as types allows Relay to perform more powerful reasoning at compile time;
in particular, Relay can statically reason about operations whose output shapes
vary based on the input shapes in complex ways. Casting shape inference as a type
inference problem allows Relay to infer the shapes of all tensors at compile time,
including in programs that use branching and function calls.
Statically reasoning about shapes in this manner allows
Relay to be ahead-of-time compiled and provides much more information about
tensors for optimizations further in the compilation pipeline. Such optimizations
can be implemented as passes, which are Relay-to-Relay AST transformations, and
may use the inferred types (e.g., shape information) for making decisions about
program transformations. For instance, :code:`src/relay/pass/fuse_ops.cc` gives
an implementation of a pass that uses inferred tensor shapes to replace invocations
of operators in a Relay program with fused operator implementations.
Reasoning about tensor types in Relay is encoded using *type relations*, which means
that the bulk of type checking in Relay is constraint solving (ensuring that all
type relations are satisfied at call sites). Type relations offer a flexible and
relatively simple way of making the power of dependent typing available in Relay
without greatly increasing the complexity of its type system.
Types
=====
Below we detail the language of types in Relay and how they are assigned to Relay expressions.
Type
~~~~
The base type for all Relay types. All Relay types are sub-classes of this base type.
See :py:class:`~tvm.relay.ty.Type` for its definition and documentation.
Tensor Type
~~~~~~~~~~~
A concrete tensor type in Relay.
Tensors are typed according to data type and shape. At present, these use TVM's
data types and shapes, but in the future, Relay may include a separate AST for
shapes. In particular, data types include :code:`bool`, :code:`float32`, :code:`int8` and various
other bit widths and numbers of lanes. Shapes are given as tuples of dimensions (TVM :code:`IndexExpr`),
such as :code:`(5, 5)`; scalars are also given tuple types and have a shape of :code:`()`.
Note, though, that TVM shapes can also include variables and arithmetic expressions
including variables, so Relay's constraint solving phase will attempt to find
assignments to all shape variables to ensure all shapes will be concrete before
running a program.
For example, here is a simple concrete tensor type corresponding to a 10-by-10 tensor of 32-bit floats:
.. code-block:: python
Tensor[(10, 10), float32]
See :py:class:`~tvm.relay.ty.TensorType` for its definition and documentation.
Tuple Type
~~~~~~~~~~
A type of a tuple in Relay.
Just as a tuple is simply a sequence of values of statically known length, the type
of a tuple consists of a sequence of the types corresponding to each member of the tuple.
Because a tuple type is of statically known size, the type of a tuple projection
is simply the corresponding index into the tuple type.
For example, in the below code, :code:`%t` is of type
`(Tensor[(), bool], Tensor[(10, 10), float32])`
and :code:`%c` is of type `Tensor[(10, 10), float32]`.
.. code-block:: python
let %t = (False, Constant(1, (10, 10), float32));
let %c = %t.1;
%c
See :py:class:`~tvm.relay.ty.TupleType` for its definition and documentation.
Type Parameter
~~~~~~~~~~~~~~
Type parameters represent placeholder types used for polymorphism in functions.
Type parameters are specified according to *kind*, corresponding to the types
those parameters are allowed to replace:
- :code:`Type`, corresponding to top-level Relay types like tensor types, tuple types, and function types
- :code:`BaseType`, corresponding to the base type of a tensor (e.g., :code:`float32`, :code:`bool`)
- :code:`Shape`, corresponding to a tensor shape
- :code:`ShapeVar`, corresponding to variables within a tensor shape
Relay's type system enforces that type parameters are only allowed to appear where their kind permits them,
so if type variable :code:`t` is of kind :code:`Type`, :code:`Tensor[t, float32]` is not a valid type.
.. *Note: At present, only type parameters of kind :code:`Type` are supported.*
Like normal parameters, concrete arguments must be given for type parameters at call sites.
.. *Note: type parameter syntax is not yet supported in the text format.*
For example, :code:`s` below is a type parameter of kind :code:`Shape` and it will
be substituted with :code:`(10, 10)` at the call site below: