blob: fb589527768f60a51b6dc99d24afaf4ceda2e403 [file] [log] [blame] [view] [edit]
# Intermediate Representation
As Tint has grown the number of transforms on the AST has grown. This
growth has lead to several issues:
1. Transforms rebuild the AST and SEM which causes slowness
1. Transforming in AST can be difficult as the AST is hard to work with
In order to address these goals, an IR is being introduced into Tint.
The IR is mutable, it holds the needed state in order to be transformed.
The IR is also translatable back into AST. It will be possible to
generate an AST, convert to IR, transform, and then rebuild a new AST.
This round-trip ability provides a few features:
1. Easy to integrate into current system by replacing AST transforms
piecemeal
1. Easier to test as the resulting AST can be emitted as WGSL and
compared.
The IR helps with the complexity of the AST transforms by limiting the
representations seen in the IR form. For example, instead of `for`,
`while` and `loop` constructs there is a single `loop` construct.
`alias` and `const_assert` nodes are not emitted into IR. Dead code is
eliminated during the IR construction.
As the IR can convert into AST, we could potentially simplify the
SPIRV-Reader by generating IR directly. The IR is closer to what SPIR-V
looks like, so maybe a simpler transform.
## Design
The IR breaks down into two fundamental pieces, the control flow and the
expression lists. While these can be thought of as separate pieces they
are linked in that the control flow blocks contain the expression lists.
A control flow block may use the result of an expression as the
condition.
The IR works together with the AST/SEM. There is an underlying
assumption that the source `Program` will live as long as the IR. The IR
holds pointers to data from the `Program`. This includes things like SEM
types, variables, statements, etc.
Transforming from AST to IR and back to AST is a lossy operation.
The resulting AST when converting back will not be the same as the
AST being provided. (e.g. all `for`, `while` and `loop` constructs coming
in will become `while` loops going out). This is intentional as it
greatly simplifies the number of things to consider in the IR. For
instance:
* No `alias` nodes
* No `const_assert` nodes
* All loops become `while` loops
* `if` statements may all become `if/else`
### Code Structure
The code is contained in the `src/tint/ir` folder and is broken down
into several classes. Note, the IR is a Tint _internal_ representation
and these files should _never_ appear in the public API.
#### Builder
The `Builder` class provides useful helper routines for creating IR
content. The Builder owns an `ir::Module`, it can be created with an
existing Module by moving it into the builder. The Module is moved from
the builder when it is complete.
#### Module
The top level of the IR is the `Module`. The module stores a list of
`functions`, `entry_points`, allocators and various other bits of
information needed by the IR. The `Module` also contains a pointer to
the `Program` which the IR was created from. The `Program` must outlive
the `Module`.
The `Module` provides two methods from moving two and from a `Program`.
The `Module::FromProgram` static method will take a `Program` and
construct an `ir::Module` from the contents. The resulting module class
then has a `ToProgram` method which will construct a new `Program` from
the `Module` contents.
#### BuilderImpl
The `BuilderImpl` is internally used by the `Module` to do the
conversion from a `Program` to a `Module`. This class should not be used
outside the `src/tint/ir` folder.
### Transforms
Similar to the AST a transform system is available for IR. The transform
has the same setup as the AST (and inherits from the same base transform
class.)
Note, not written yet.
### Scoping
The IR flattens scopes. This also means that the IR will rename shadow
variables to be uniquely named in the larger scoped block.
For an example of flattening:
```
{
var x = 1;
{
var y = 2;
}
}
```
becomes:
```
{
var x = 1;
var y = 2;
}
```
For an example of shadowing:
```
{
var x = 1;
if (true) {
var x = 2;
}
}
```
becomes:
```
{
var x = 1;
if true {
var x_1 = 2;
}
}
```
### Control Flow Blocks
At the top level, the AST is broken into a series of control flow nodes.
There are a limited set of flow nodes as compared to AST:
1. Block
1. Function
1. If statement
1. Loop statement
1. Switch statement
1. Terminator
As the IR is built a stack of control flow blocks is maintained. The
stack contains `function`, `loop`, `if` and `switch` control flow
blocks. A `function` is always the bottom element in the flow control
stack.
The current instruction block is tracked. The tracking is reset to
`nullptr` when a branch happens. This is used in the statement processing
in order to eliminate dead code. If the current block does not exist, or
has a branch target, then no further instructions can be added, which
means all control flow has branched and any subsequent statements can be
disregarded.
Note, this does have the effect that the inspector _must_ be run to
retrieve the module interface before converting to IR. This is because
phony assignments in dead code add variables into the interface.
```
var<storage> b;
fn a() {
return;
_ = b; // This pulls b into the module interface but would be
// dropped due to dead code removal.
}
```
#### Control Flow Block
A block is the simplest control flow node. It contains the instruction
lists for a given linear section of codes. A block only has one branch
statement which always happens at the end of the block. Note, the branch
statement is implicit, it doesn't show up in the expression list but is
encoded in the `branch_target`.
In almost every case a block does not branch to another block. It will
always branch to another control flow node. The exception to this rule
is blocks branching to the function end block.
#### Control Flow Function
A function control flow block has two targets associated with it, the
`start_target` and the `end_target`. Function flow starts at the
`start_target` and ends just before the `end_target`. The `end_target`
is always a terminator, it just marks the end of the function
(a return is a branch to the function `end_target`).
#### Control Flow If
The if flow node is an `if-else` structure. There are no `else-if`
entries, they get moved into the `else` of the `if`. The if control flow
node has three targets, the `true_target`, `false_target` and possibly a
`merge_target`.
The `merge_target` is possibly `nullptr`. This can happen if both
branches of the `if` call `return` for instance as the internal branches
would jump to the function `end_target`.
In all cases, the if node will have a `true_target` and a
`false_target`, the target block maybe just a branch to the
`merge_target` in the case where that branch of the if was empty.
#### Control Flow Loop
All of the loop structures in AST merge down to a single loop control
flow node. The loop contains the `start_target`, `continuing_target` and
a `merge_target`.
In the case of a loop, the `merge_target` always exists, but may
actually not exist in the control flow. The target is created in order
to have a branch for `continue` to branch too, but if the loop body does
a `return` then control flow may jump over that block completely.
The chain of blocks from the `start_target`, as long as it does not
`break` or `return` will branch to the `continuing_target`. The
`continuing_target` will possibly branch to the `merge_target` and will
branch to the `start_target` for the loop.
A while loop is decomposed as listed in the WGSL spec:
```
while (a < b) {
c += 1;
}
```
becomes:
```
loop {
if (!(a < b)) {
break;
}
c += 1;
}
```
A for loop is decomposed as listed in the WGSL spec:
```
for (var i = 0; i < 10; i++) {
c += 1;
}
```
becomes:
```
var i = 0;
loop {
if (!(i < 10)) {
break;
}
c += 1;
continuing {
i++;
}
}
```
#### Control Flow Switch
The switch control flow has a target block for each of the
`case/default` labels along with a `merge_target`. The `merge_target`
while existing, maybe outside the control flow if all of the `case`
branches `return`. The target exists in order to provide a `break`
target.
#### Control Flow Terminator
The terminator control flow is only used as the `end_target` of a
function. It does not contain instructions and is only used as a marker
for the exit of a function.
### Expression Lists.
Note, this section isn't fully formed as this has not been written at
this point.
The expression lists are all in SSA form. The SSA variables will keep
pointers back to the source AST variables in order for us to not require
PHI nodes and to make it easier to move back out of SSA form.
#### Expressions
All expressions in IR are single operations. There are no complex
expressions. Any complex expression in the AST is broke apart into the
simpler single operation components.
```
var a = b + c - (4 * k);
```
becomes:
```
%t0 = b + c
%t1 = 4 * k
%v0 = %t0 - %t1
```
This also means that many of the short forms `i += 1`, `i++` get
expanded into the longer form of `i = i + 1`.
##### Short-Circuit Expressions
The short-circuit expressions (e.g. `a && b`) will be convert into an
`if` structure control flow.
```
let c = a() && b()
```
becomes
```
let c = a();
if (c) {
c = b();
}
```
#### Registers
There are several types of registers used in the SSA form.
1. Constant Register
1. Temporary Register
1. Variable Register
1. Return Register
1. Function Argument Register
##### Constant Register
The constant register `%c` holds a constant value. All values in IR are
concrete, there are no abstract values as materialization has already
happened. Each constant register holds a single constant value (e.g.
`3.14`) and a pointee to the type (maybe? If needed.)
##### Temporary Register
The temporary register `%t` hold the results of a simple operation. The
temporaries are created as complex expressions are broken down into
pieces. The temporary register tracks the usage count for the register.
This allows a portion of a calculation to be pulled out when rebuilding
AST as a common calculation. If the temporary is used once it can be
re-combine back into a large expression.
##### Variable Register
The variable register `%v` potentially holds a pointer back to source
variables. So, while each value is written only once, if the pointer
back to an AST variable exists we can rebuild the variable that value
was originally created from and can assign back when converting to AST.
##### Return Register
Each function has a return register `%r` where the return value will be
stored before the final block branches to the `end_target`.
##### Function Argument Register
The function argument registers `%a` are used to store the values being
passed into a function call.
#### Type Information
The IR shares type information with the SEM. The types are the same, but
they may exist in different block allocations. The SEM types will be
re-used if they exist, but if the IR needs to create a new type it will
be created in the IRs type block allocator.
#### Loads / Stores and Deref
Note, have not thought about this. We should probably have explicit
load/store operations injected in the right spot, but don't know yet.
## Alternatives
Instead of going to a custom IR there are several possible other roads
that could be travelled.
### Mutable AST
Tint originally contained a mutable AST. This was converted to immutable
in order to allow processing over multiple threads and for safety
properties. Those desires still hold, the AST is public API, and we want
it to be as safe as possible, so keeping it immutable provides that
guarantee.
### Multiple Transforms With One Program Builder
Instead of generating an immutable AST after each transform, running
multiple transforms on the single program builder would remove some of
the performance penalties of going to and from immutable AST. While this
is true, the transforms use a combination of AST and SEM information.
When they transform they _do not_ create new SEM information. That
means, after a given transform, the SEM is out of date. In order to
re-generate the SEM the resolver needs to be rerun. Supporting this
would require being very careful on what transforms run together and
how they modify the AST.
### Adopt An Existing IR
There are already several IRs in the while, Mesa has NIR, LLVM has
LLVM IR. There are others, adopting one of those would remove the
requirements of writing and maintaining our own IR. While that is true,
there are several downsides to this re-use. The IRs are internal to the
library, so the API isn't public, LLVM IR changes with each iteration of
LLVM. This would require us to adapt the AST -> IR -> AST transform for
each modification of the IR.
They also end up being lower level then is strictly useful for us. While
the IR in Tint is a simplified form, we still have to be able to go back
to the high level structured form in order to emit the resulting HLSL,
MSL, GLSL, etc. (Only SPIR-V is a good match for the lowered IR form).
This transformation back is not a direction other IRs maybe interested
in so may have lost information, or require re-determining (determining
variables from SSA and PHI nodes for example).
Other technical reasons are the maintenance of BUILD.gn and CMake files
in order to integrate into our build systems, along with resulting
binary size questions from pulling in external systems.