Finish draft of part 5 of compiler series.

This commit is contained in:
Danila Fedorin 2019-09-02 23:38:27 -07:00
parent a1244f201a
commit 216e9e89b4

View File

@ -187,11 +187,11 @@ We don't have to include every node that we've defined as a subclass of
them. We will also include nodes that we didn't need for to represent expressions.
Here's the list of nodes types we'll have:
* NInt - represents an integer.
* NApp - represents an application (has two children).
* NGlobal - represents a global function (like the `f` in `f x`).
* NInd - an "indrection" node that points to another node. This will help with "replacing" a node.
* NData - a "packed" node that will represent a constructor with all the arguments.
* `NInt` - represents an integer.
* `NApp` - represents an application (has two children).
* `NGlobal` - represents a global function (like the `f` in `f x`).
* `NInd` - an "indrection" node that points to another node. This will help with "replacing" a node.
* `NData` - a "packed" node that will represent a constructor with all the arguments.
With these nodes in mind, let's try defining some instructions for the G-machine.
We start with instructions we'll use to assemble new version of function body trees as we discussed above.
@ -266,28 +266,302 @@ the thing we apply it to. We then create a new node on the heap
that is an `NApp` node, with its two children being the nodes we popped off.
Finally, we push it onto the stack.
Let's try use these instructions to get a feel for it. To save some space,
let's assume that \\(m\\) contains \\(\\text{double} : a\_{\\text{double}}\\) and \\(\\text{halve} : a\_{\\text{halve}} \\).
For the same reason, let's also use
Let's try use these instructions to get a feel for it.
{{< todo >}}Add an example, probably without notation.{{< /todo >}}
* \\(\\text{G}\\) for \\(\\text{PushGlobal}\\)
* \\(\\text{I}\\) for \\(\\text{PushInt}\\)
* \\(\\text{P}\\) for \\(\\text{Push}\\)
* \\(\\text{A}\\) for \\(\\text{MakeApp}\\)
Having defined instructions to __build__ graphs, it's now time
to move on to instructions to __reduce__ graphs - after all,
we're performing graph reduction. A crucial instruction for the
G-machine is __Unwind__. What Unwind does depends on what
nodes are on the stack. Its name comes from how it behaves
when the top of the stack is an `NApp` node that is at
the top of a potentially long chain of applications: given
an application node, it pushes its left hand side onto the stack.
It then __continues to run Unwind__. This is effectively a while loop:
applications nodes continue to be expanded this way until the left
hand side of an application is finally something
that __isn't__ an application. Let's write this rule as follows:
Let's say we want to construct a graph for the expression `double 326`.
The sequence of instructions \\(\\text{I} \; 326, \\text{G} \; \\text{double},
\\text{A}\\) will do the trick. Let's
step through them:
{{< gmachine "Unwind-App" >}}
{{< gmachine_inner "Before">}}
\( \text{Unwind} : i \quad a : s \quad h[a : \text{NApp} \; a_0 \; a_1] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( \text{Unwind} : i \quad a_0, a : s \quad h[ a : \text{NApp} \; a_0 \; a_1] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Unwind an application by pushing its left node.
{{< /gmachine_inner >}}
{{< /gmachine >}}
$$
\\begin{align}
[\\text{I} \; 326, \\text{G} \; \\text{double}, \\text{A}] & \\quad s \\quad & h \\quad & m \\\\\\
[\\text{G} \; \\text{double},\\text{A} ] & \\quad a\_0 : s \\quad & h[a\_0 : \\text{NInt} \; 326] \\quad & m \\\\\\
[\\text{A}] & \\quad a\_{\\text{double}}, a\_0 : s \\quad & h[a\_0 : \\text{NInt} \; 326] \\quad & m \\\\\\
[] & \\quad a\_1: s \\quad & h[\; \\begin{aligned} a\_0 & : \\text{NInt} \; 326 \\\ a\_1 & : \\text{NApp} \; a\_{\\text{double}} \; a\_0 \\end{aligned} ] \\quad & m \\\\\\
\\end{align}
$$
Let's talk about what happens when Unwind hits a node that isn't an application. Of all nodes
we have described, `NGlobal` seems to be the most likely to be on top of the stack after
an application chain has finished unwinding. In this case we want to run the instructions
for building the referenced global function. Naturally, these instructions
may reference the arguments of the application. We can find the first argument
by looking at offset 1 on the stack, which will be an `NApp` node, and then going
to its right child. The same can be done for the second and third arguments, if
they exist. But this doesn't feel right - we don't want to constantly be looking
at the right child of a node on the stack. Instead, we replace each application
node on the stack with its right child. Once that's done, we run the actual
code for the global function:
We end up with a node, \\(a\_1\\), on top of the stack, which represents the application of `double` to `326`. You can see
how the notation gets unwieldy very quickly, so I'll try to steer clear of more examples like this.
{{< gmachine "Unwind-Global" >}}
{{< gmachine_inner "Before">}}
\( \text{Unwind} : i \quad a, a_0, a_1, ..., a_n : s \quad h[\substack{a : \text{NGlobal} \; n \; c \\ a_k : \text{NApp} \; a_{k-1} \; a_k'}] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( c \quad a_0', a_1', ..., a_n', a_n : s \quad h[\substack{a : \text{NGlobal} \; n \; c \\ a_k : \text{NApp} \; a_{k-1} \; a_k'}] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Call a global function.
{{< /gmachine_inner >}}
{{< /gmachine >}}
In this rule, we used a general rule for \\(a\_k\\), in which \\(k\\) is any number
between 0 and \\(n\\). We also expect the `NGlobal` node to contain two parameters,
\\(n\\) and \\(c\\). \\(n\\) is the arity of the function (the number of arguments
it expects), and \\(c\\) are the instructions to construct the function's tree.
The attentive reader will have noticed a catch: we kept \\(a\_n\\) on the stack!
This once again goes back to replacing a node in-place. \\(a\_n\\) is the address of the "root" of the
whole expression we're simplifying. Thus, to replace the value at this address, we need to keep
the address until we have something to replace it with.
There's one more thing that can be found at the leftmost end of a tree of applications: `NInd`.
We simply replace `NInd` with the node it points to, and resume Unwind:
{{< gmachine "Unwind-Ind" >}}
{{< gmachine_inner "Before">}}
\( \text{Unwind} : i \quad a : s \quad h[a : \text{NInd} \; a' ] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( \text{Unwind} : i \quad a' : s \quad h[a : \text{NInd} \; a' ] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Replace indirection node with its target.
{{< /gmachine_inner >}}
{{< /gmachine >}}
We've talked about replacing a node, and we've talked about indirection, but we
haven't yet an instruction to perform these actions. Let's do so now:
{{< gmachine "Update" >}}
{{< gmachine_inner "Before">}}
\( \text{Update} \; n : i \quad a,a_0,a_1,...a_n : s \quad h \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( i \quad a_0,a_1,...,a_n : s \quad h[a_n : \text{NInd} \; a ] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Transform node at offset into an indirection.
{{< /gmachine_inner >}}
{{< /gmachine >}}
This instruction pops an address from the top of the stack, and replaces
a node at the given offset with an indirection to the popped node. After
we evaluate a function call, we will use `update` to make sure it's
not evaluated again.
Now, let's talk about data structures. We have mentioned an `NData` node,
but we've given no explanation of how it will work. Obviously, we need
to distinguish values of a type created by different constructors:
If we have a value of type `List`, it could have been created either
using `Nil` or `Cons`. Depending on which constructor was used to
create a value of a type, we might treat it differently. Furthermore,
it's not always possible to know what constructor was used to
create what value at compile time. So, we need a way to know,
at runtime, how the value was constructed. We do this using
a __tag__. A tag is an integer value that will be contained in
the `NData` node. We assign a tag number to each constructor,
and when we create a node with that constructor, we set
the node's tag accordingly. This way, we can easily
tell if a `List` value is a `Nil` or a `Cons`, or
if a `Tree` value is a `Node` or a `Leaf`.
To operate on `NData` nodes, we will need two primitive operations: __Pack__ and __Split__.
Pack will create an `NData` node with a tag from some number of nodes
on the stack. These nodes will be placed into a dynamically
allocated array:
{{< gmachine "Pack" >}}
{{< gmachine_inner "Before">}}
\( \text{Pack} \; t \; n : i \quad a_1,a_2,...a_n : s \quad h \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( i \quad a : s \quad h[a : \text{NData} \; t \; [a_1, a_2, ..., a_n] ] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Pack \(n\) nodes from the stack into a node with tag \(t\).
{{< /gmachine_inner >}}
{{< /gmachine >}}
Split will do the opposite, by popping
of an `NData` node and moving the contents of its
array onto the stack:
{{< gmachine "Split" >}}
{{< gmachine_inner "Before">}}
\( \text{Split} : i \quad a : s \quad h[a : \text{NData} \; t \; [a_1, a_2, ..., a_n] ] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( i \quad a_1, a_2, ...,a_n : s \quad h[a : \text{NData} \; t \; [a_1, a_2, ..., a_n] ] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Unpack a data node on top of the stack.
{{< /gmachine_inner >}}
{{< /gmachine >}}
These two instructions are a good start, but we're missing something
fairly big: case analysis. After we've constructed a data type,
to perform operations on it, we want to figure out what
constructor and values which were used to create it. In order
to implement patterns and case expressions, we'll need another
instruction that's capable of making a decision based on
the tag of an `NData` node. We'll call this instruction __Jump__,
and define it to contain a mapping from tags to instructions
to be executed for a value of that tag. For instance,
if the constructor `Nil` has tag 0, and `Cons` has tag 1,
the mapping for the case expression of a length function
could be written as \\([0 \\rightarrow [\\text{PushInt} \; 0], 1 \\rightarrow [\\text{PushGlobal} \; \\text{length}, ...] ]\\).
Let's define the rule for it:
{{< gmachine "Jump" >}}
{{< gmachine_inner "Before">}}
\( \text{Jump} [..., t \rightarrow i_t, ...] : i \quad a : s \quad h[a : \text{NData} \; t \; as ] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( i_t, i \quad a : s \quad h[a : \text{NData} \; t \; as ] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Execute instructions corresponding to a tag.
{{< /gmachine_inner >}}
{{< /gmachine >}}
Alright, we've made it through the interesting instructions,
but there's still a few that are needed, but less shiny and cool.
For instance: imagine we've made a function call. As per the
rules for Unwind, we've placed the right hand sides of all applications
on the stack, and ran the instructions provided by the function,
creating a final graph. We then continue to reduce this final
graph. But we've left the function parameters on the stack!
This is untidy. We define a __Slide__ instruction,
which keeps the address at the top of the stack, but gets
rid of the next \\(n\\) addresses:
{{< gmachine "Slide" >}}
{{< gmachine_inner "Before">}}
\( \text{Slide} \; n : i \quad a_0, a_1, ..., a_n : s \quad h \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( i \quad a_0 : s \quad h \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Remove \(n\) addresses after the top from the stack.
{{< /gmachine_inner >}}
{{< /gmachine >}}
Just a few more. Next up, we observe that we have not
defined any way for our G-machine to perform arithmetic,
or indeed, any primitive operations. Since we've
not defined any built-in type for booleans,
let's avoid talking about operations like `<`, `==`,
and so on (in fact, we've omitted them from the grammar so far).
So instead, let's talk about the [closed](https://en.wikipedia.org/wiki/Closure_(mathematics)) operations,
namely `+`, `-`, `*`, and `/`. We'll define a special instruction for
them, called __BinOp__:
{{< gmachine "BinOp" >}}
{{< gmachine_inner "Before">}}
\( \text{BinOp} \; \text{op} : i \quad a_0, a_1 : s \quad h[\substack{a_0 : \text{NInt} \; n \\ a_1 : \text{NInt} \; m}] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( i \quad a : s \quad h[\substack{a_0 : \text{NInt} \; n \\ a_1 : \text{NInt} \; m \\ a : \text{NInt} \; (\text{op} \; n \; m)}] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Apply a binary operator on integers.
{{< /gmachine_inner >}}
{{< /gmachine >}}
Nothing should be particularly surprising here:
the instruction pops two integers off the stack, applies the given
binary operation to them, and places the result on the stack.
We're not yet done with primitive operations, though.
We have a lazy graph reduction machine, which means
something like the expression `3*(2+6)` might not
be a binary operator applied to two `NInt` nodes.
We keep around graphs until they __really__ need to
be reduced. So now we need an instruction to trigger
reducing a graph, to say, "we need this value now".
We call this instruction __Eval__. This is where
the dump finally comes in!
{{< todo >}}Actually show the dump in the previous evaluasion rules.{{< /todo >}}
When we execute Eval, another graph becomes our "focus", and we switch
to a new stack. We obviously want to return from this once we've finished
evaluating what we "focused" on, so we must store the program state somewhere -
on the dump. Here's the rule:
{{< gmachine "Eval" >}}
{{< gmachine_inner "Before">}}
\( \text{Eval} : i \quad a : s \quad d \quad h \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( [\text{Unwind}] \quad [a] \quad \langle i, s\rangle : d \quad h \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Evaluate graph to its normal form.
{{< /gmachine_inner >}}
{{< /gmachine >}}
We store the current set of instructions and the current stack on the dump,
and start with only Unwind and the value we want to evaluate.
That does the job, but we're missing one thing - a way to return to
the state we placed onto the dump. To do this, we add __another__
rule to Unwind:
{{< gmachine "Unwind-Return" >}}
{{< gmachine_inner "Before">}}
\( \text{Unwind} : i \quad a : s \quad \langle i', s'\rangle : d \quad h[a : \text{NInt} \; n] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( i' \quad a : s' \quad d \quad h[a : \text{NInt} \; n] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Return from Eval instruction.
{{< /gmachine_inner >}}
{{< /gmachine >}}
Just one more! Sometimes, it's possible for a tree node to reference itself.
For instance, Haskell defines the
[fixpoint combinator](https://en.wikipedia.org/wiki/Fixed-point_combinator)
as follows:
```Haskell
fix f = let x = f x in x
```
In order to do this, an address that references a node must be present
while the node is being constructed. We define an instruction,
__Alloc__, which helps with that:
{{< gmachine "Alloc" >}}
{{< gmachine_inner "Before">}}
\( \text{Alloc} \; n : i \quad s \quad d \quad h \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "After" >}}
\( i \quad s \quad d \quad h[a_k : \text{NInd} \; \text{null}] \quad m \)
{{< /gmachine_inner >}}
{{< gmachine_inner "Description" >}}
Allocate indirection nodes.
{{< /gmachine_inner >}}
{{< /gmachine >}}
We can allocate an indirection on the stack, and call Update on it when
we've constructed a node. While we're constructing the tree, we can
refer to the indirection when a self-reference is required.
That's it for the instructions. Next up, we have to convert our expression
trees into such instructions. However, this has already gotten pretty long,
so we'll do it in the next post.