blog-static/content/blog/08_compiler_llvm.md
Danila Fedorin 2130b00752 Narrow some of the tags
Signed-off-by: Danila Fedorin <danila.fedorin@gmail.com>
2024-03-13 15:59:46 -07:00

582 lines
23 KiB
Markdown

---
title: Compiling a Functional Language Using C++, Part 8 - LLVM
date: 2019-10-30T22:16:22-07:00
tags: ["C++", "Functional Languages", "Compilers"]
series: "Compiling a Functional Language using C++"
description: "In this post, we enable our compiler to convert G-machine instructions to LLVM IR, which finally allows us to generate working executables."
---
We don't want a compiler that can only generate code for a single
platform. Our language should work on macOS, Windows, and Linux,
on x86\_64, ARM, and maybe some other architectures. We also
don't want to manually implement the compiler for each platform,
dealing with the specifics of each architecture and operating
system.
This is where LLVM comes in. LLVM (which stands for __Low Level Virtual Machine__),
is a project which presents us with a kind of generic assembly language,
an __Intermediate Representation__ (IR). It also provides tooling to compile the
IR into platform-specific instructions, as well as to apply a host of various
optimizations. We can thus translate our G-machine instructions to LLVM,
and then use LLVM to generate machine code, which gets us to our ultimate
goal of compiling our language.
We start with adding LLVM to our CMake project.
{{< codelines "CMake" "compiler/08/CMakeLists.txt" 7 7 >}}
LLVM is a huge project, and has many components. We don't need
most of them. We do need the core libraries, the x86 assembly
generator, and x86 assembly parser. I'm
not sure why we need the last one, but I ran into linking
errors without them. We find the required link targets
for these components using this CMake command:
{{< codelines "CMake" "compiler/08/CMakeLists.txt" 19 20 >}}
Finally, we add the new include directories, link targets,
and definitions to our compiler executable:
{{< codelines "CMake" "compiler/08/CMakeLists.txt" 39 41 >}}
Great, we have the infrastructure updated to work with LLVM. It's
now time to start using the LLVM API to compile our G-machine instructions
into assembly. We start with `LLVMContext`. The LLVM documentation states:
> This is an important class for using LLVM in a threaded context.
> It (opaquely) owns and manages the core "global" data of LLVM's core infrastructure, including the type and constant uniquing tables.
We will have exactly one instance of such a class in our program.
Additionally, we want an `IRBuilder`, which will help us generate IR instructions,
placing them into basic blocks (more on that in a bit). Also, we want
a `Module` object, which represents some collection of code and declarations
(perhaps like a C++ source file). Let's keep these things in our own
`llvm_context` class. Here's what that looks like:
{{< codeblock "C++" "compiler/08/llvm_context.hpp" >}}
We include the LLVM context, builder, and module as members
of the context struct. Since the builder and the module need
the context, we initialize them in the constructor, where they
can safely reference it.
Besides these fields, we added
a few others, namely the `functions` and `struct_types` maps,
and the various `llvm::Type` subclasses such as `stack_type`.
We did this because we want to be able to call our runtime
functions (and use our runtime structs) from LLVM. To generate
a function call from LLVM, we need to have access to an
`llvm::Function` object. We thus want to have an `llvm::Function`
object for each runtime function we want to call. We could declare
a member variable in our `llvm_context` for each runtime function,
but it's easier to leave this to be an implementation
detail, and only have a dynamically created map between runtime
function names and their corresponding `llvm::Function` objects.
We populate the maps and other type-related variables in the
two methods, `create_functions()` and `create_types()`. To
create an `llvm::Function`, we must provide an `llvm::FunctionType`,
an `llvm::LinkageType`, the name of the function, and the module
in which the function is declared. Since we only have one
module (the one we initialized in the constructor) that's
the module we pass in. The name of the function is the same
as its name in the runtime. The linkage type is a little
more complicated - it tells LLVM the "visibility" of a function.
"Private" or "Internal" would hide this function from the linker
(like `static` functions in C). However, we want to do the opposite: our
generated functions should be accessible from other code.
Thus, our linkage type is "External".
The only remaining parameter is the `llvm::FunctionType`, which
is created using code like:
```C++
llvm::FunctionType::get(return_type, {param_type_1, param_type_2, ...}, is_variadic)
```
Declaring all the functions and types in our runtime is mostly
just tedious. Here are a few lines from `create_functions()`, which
give a very good idea of the rest of that method:
{{< codelines "C++" "compiler/08/llvm_context.cpp" 47 60 >}}
Similarly, here are a few lines from `create_types()`, from
which you can extrapolate the rest:
{{< codelines "C++" "compiler/08/llvm_context.cpp" 7 11 >}}
We also tell LLVM the contents of our structs, so that
we may later reference specific fields. This is just like
forward declaration - we can forward declare a struct
in C/C++, but unless we also declare its contents,
we can't access what's inside. Below is the code
for specifying the body of `node_base` and `node_app`.
{{< codelines "C++" "compiler/08/llvm_context.cpp" 19 26 >}}
There's still more functionality packed into `llvm_context`.
Let's next take a look into `custom_function`, and
the `create_custom_function` method. Why do we need
these? To highlight the need for the custom class,
let's take a look at `instruction_pushglobal` which
occurs at the G-machine level, and then at `alloc_global`,
which will be a function call generated as part of
the PushGlobal instruction. `instruction_pushglobal`'s
only member variable is `name`, which stands for
the name of the global function it's referencing. However,
`alloc_global` requires an arity argument! We can
try to get this information from the `llvm::Function`
corresponding to the global we're trying to reference,
but this doesn't get us anywhere: as far as LLVM
is concerned, any global function only takes one
parameter, the stack. The rest of the parameters
are given through that stack, and their number cannot
be easily deduced from the function alone.
Instead, we decide to store global functions together
with their arity. We thus create a class to combine
these two things (`custom_function`), define
a map from global function names to instances
of `custom_function`, and add a convenience method
(`create_custom_function`) that takes care of
constructing an `llvm::Function` object, creating
a `custom_function`, and storing it in the map.
The implementation for `custom_function` is
straightforward:
{{< codelines "C++" "compiler/08/llvm_context.cpp" 234 252 >}}
We create a function type, then a function, and finally
initialize a `custom_function`. There's one thing
we haven't seen yet in this function, which is the
`BasicBlock` class. We'll get to what basic blocks
are shortly, but for now it's sufficient to
know that the basic block gives us a place to
insert code.
This isn't the end of our `llvm_context` class: it also
has a variety of other `create_*` methods! Let's take a look
at their signatures. Most return either `void`,
`llvm::ConstantInt*`, or `llvm::Value*`. Since
`llvm::ConstantInt*` is a subclass of `llvm::Value*`, let's
just treat it as simply an `llvm::Value*` while trying
to understand these methods.
So, what is `llvm::Value`? To answer this question, let's
first understand how the LLVM IR works.
### LLVM IR
An important property of LLVM IR is that it is in __Single Static Assignment__
(SSA) form. This means that each variable can only be assigned to once. For instance,
if we use `<-` to represent assignment, the following program is valid:
```
x <- 1
y <- 2
z <- x + y
```
However, the following program is __not__ valid:
```
x <- 1
x <- x + 1
```
But what if we __do__ want to modify a variable `x`?
We can declare another "version" of `x` every time we modify it.
For instance, if we wanted to increment `x` twice, we'd do this:
```
x <- 1
x1 <- x + 1
x2 <- x1 + 1
```
In practice, LLVM's C++ API can take care of versioning variables on its own, by
auto-incrementing numbers associated with each variable we use.
Assigned to each variable is `llvm::Value`. The LLVM documentation states:
> It is the base class of all values computed by a program that may be used as operands to other values.
It's important to understand that `llvm::Value` __does not store the result of the computation__.
It rather represents how something may be computed. 1 is a value because it computed by
just returning 1. `x + 1` is a value because it is computed by adding the value inside of
`x` to 1. Since we cannot modify a variable once we've declared it, we will
keep assigning intermediate results to new variables, constructing new values
out of values that we've already specified.
This somewhat elucidates what the `create_*` functions do: `create_i8` creates an 8-bit integer
value, and `create_pop` creates a value that is computed by calling
our runtime `stack_pop` function.
Before we move on to look at the implementations of these functions,
we need to understand another concept from the world of compiler design:
__basic blocks__. A basic block is a sequence of instructions that
are guaranteed to be executed one after another. This means that a
basic block cannot have an if/else, jump, or any other type of control flow anywhere
except at the end. If control flow could appear inside the basic block,
there would be opporunity for execution of some, but not all,
instructions in the block, violating the definition. Every time
we add an IR instruction in LLVM, we add it to a basic block.
Writing control flow involves creating several blocks, with each
block serving as the destination of a potential jump. We will
see this used to compile the Jump instruction.
### Generating LLVM IR
Now that we understand what `llvm::Value` is, and have a vague
understanding of how LLVM is structured, let's take a look at
the implementations of the `create_*` functions. The simplest
is `create_i8`:
{{< codelines "C++" "compiler/08/llvm_context.cpp" 150 152 >}}
Not much to see here. We create an instance of the `llvm::ConstantInt` class,
from the actual integer given to the method. As we said before,
`llvm::ConstantInt` is a subclass of `llvm::Value`. Next up, let's look
at `create_pop`:
{{< codelines "C++" "compiler/08/llvm_context.cpp" 160 163 >}}
We first retrieve an `llvm::Function` associated with `stack_pop`
from our map, and then use `llvm::IRBuilder::CreateCall` to insert
a value that represents a function call into the currently
selected basic block (the builder's state is what
dictates what the "selected basic block" is). `CreateCall`
takes as parameters the function we want to call (`stack_pop`,
which we store into the `pop_f` variable), as well as the arguments
to the function (for which we pass `f->arg_begin()`).
Hold on. What the heck is `arg_begin()`? Why do we take a function
as a paramter to this method? The answer is fairly simple: this
method is used when we are
generating a function with signature `void f_(struct stack* s)`
(we discussed the signature in the previous post). The
parameter that we give to `create_pop` is this function we're
generating, and `arg_begin()` gets the value that represents
the first parameter to our function - `s`! Since `stack_pop`
takes a stack, we need to give it the stack we're working on,
and so we use `f->arg_begin()` to access it.
Most of the other functions follow this exact pattern, with small
deviations. However, another function uses a more complicated LLVM
instruction:
{{< codelines "C++" "compiler/08/llvm_context.cpp" 202 209 >}}
`unwrap_num` is used to cast a given node pointer to a pointer
to a number node, and then return the integer value from
that number node. It starts fairly innocently: we ask
LLVM for the type of a pointer to a `node_num` struct,
and then use `CreatePointerCast` to create a value
that is the same node pointer we're given, but now interpreted
as a number node pointer. We now have to access
the `value` field of our node. `CreateGEP` helps us with
this: given a pointer to a node, and two offsets
`n` and `k`, it effectively performs the following:
```C++
&(num_pointer[n]->kth_field)
```
The first offset, then, gives an index into the "array"
represented by the pointer, while the second offset
gives the index of the field we want to access. We
want to dereference the pointer (`num_pointer[0]`),
and we want the second field (`1`, when counting from 0).
Thus, we call `CreateGEP` with these offsets and our pointers.
This still leaves us with a pointer to a number, rather
than the number itself. To dereference the pointer, we use
`CreateLoad`. This gives us the value of the number node,
which we promptly return.
This concludes our implementation of the `llvm_context` -
it's time to move on to the G-machine instructions.
### G-machine Instructions to LLVM IR
Let's now envision a `gen_llvm` method on the `instruction` struct,
which will turn the still-abstract G-machine instruction
into tangible, close-to-metal LLVM IR. As we've seen
in our implementation of `llvm_context`, to access the stack, we need access to the first
argument of the function we're generating. Thus, we need this method
to accept the function whose instructions are
being converted to LLVM. We also pass in the
`llvm_context`, since it contains the LLVM builder,
context, module, and a map of globally declared functions.
With these things in mind, here's the signature for `gen_llvm`:
```C++
virtual void gen_llvm(llvm_context&, llvm::Function*) const;
```
Let's get right to it! `instruction_pushint` gives us an easy
start:
{{< codelines "C++" "compiler/08/instruction.cpp" 17 19 >}}
We create an LLVM integer constant with the value of
our integer, and push it onto the stack.
`instruction_push` is equally terse:
{{< codelines "C++" "compiler/08/instruction.cpp" 37 39 >}}
We simply peek at the value of the stack at the given
offset (an integer of the same size as `size_t`, which
we create using `create_size`). Once we have the
result of the peek, we push it onto the stack.
`instruction_pushglobal` is more involved. Let's take a look:
{{< codelines "C++" "compiler/08/instruction.cpp" 26 30 >}}
First, we retrive the `custom_function` associated with
the given global name. We then create an LLVM integer
constant representing the arity of the function,
and then push onto the stack the result of `alloc_global`,
giving it the function and arity just like it expects.
`instruction_pop` is also short, and doesn't require much
further explanation:
{{< codelines "C++" "compiler/08/instruction.cpp" 46 48 >}}
Some other instructions, such as `instruction_update`,
`instruction_pack`, `instruction_split`, `instruction_slide`,
`instruction_alloc` and `instruction_eval` are equally as simple,
and we omit them for the purpose of brevity.
What remains are two "meaty" functions, `instruction_jump` and
`instruction_binop`. Let's start with the former:
{{< codelines "C++" "compiler/08/instruction.cpp" 101 123 >}}
This is the one and only function in which we have to take
care of control flow. Conceptually, depending on the tag
of the `node_data` at the top of the stack, we want
to pick one of many branches and jump to it.
As we discussed, a basic block has to be executed in
its entirety; since the branches of a case expression
are mutually exclusive (only one of them is executed in any given case),
we have to create a separate basic block for each branch.
Given these blocks, we then want to branch to the correct one
using the tag of the node on top of the stack.
This is exactly what we do in this function. We first peek
at the node on top of the stack, and use `CreateGEP` through
`unwrap_data_tag` to get access to its tag. What we then
need is LLVM's switch instruction, created using `CreateSwitch`.
We must provide the switch with a "default" case in case
the tag value is something we don't recognize. To do this,
we create a "safety" `BasicBlock`. With this new safety
block in hand, we're able to call `CreateSwitch`, giving it
the tag value to switch on, the safety block to default to,
and the expected number of branches (to optimize memory allocation).
Next, we create a vector of blocks, and for each branch,
we append to it a corresponding block `branch_block`, into
which we insert the LLVM IR corresponding to the
instructions of the branch. No matter the branch we take,
we eventually want to come back to the same basic block,
which will perform the usual function cleanup via Update and Slide.
We re-use the safety block for this, and use `CreateBr` at the
end of each `branch_block` to perform an unconditional jump.
After we create each of the blocks, we use the `tag_mappings`
to add cases to the switch instruction, using `addCase`. Finally,
we set the builder's insertion point to the safety block,
meaning that the next instructions will insert their
LLVM IR into that block. Since we have all branches
jump to the safety block at the end, this means that
no matter which branch we take in the case expression,
we will still execute the subsequent instructions as expected.
Let's now look at `instruction_binop`:
{{< codelines "C++" "compiler/08/instruction.cpp" 139 150 >}}
In this instruction, we pop and unwrap two integers from
the stack (assuming they are integers). Depending on
the type of operation the instruction is set to, we
then push the result of the corresponding LLVM
instruction. `PLUS` calls LLVM's `CreateAdd` to insert
addition, `MINUS` calls `CreateSub`, and so on. No matter
what the operation was, we push the result onto the stack.
That's all for our instructions! We're so very close now. Let's
move on to compiling definitions.
### Definitions to LLVM IR
As with typechecking, to allow for mutually recursive functions,
we need to be able each global function from any other function.
We then take the same approah as before, going in two passes.
This leads to two new methods for `definition`:
```C++
virtual void gen_llvm_first(llvm_context& ctx) = 0;
virtual void gen_llvm_second(llvm_context& ctx) = 0;
```
The first pass is intended to register all functions into
the `llvm_context`, making them visible to other functions.
The second pass is used to actually generate the code for
each function, now having access to all the other global
functions. Let's see the implementation for `gen_llvm_first`
for `definition_defn`:
{{< codelines "C++" "compiler/08/definition.cpp" 58 60 >}}
Since `create_custom_function` already creates a function
__and__ registers it with `llvm_context`, this is
all we need. Note that we created a new member variable
for `definition_defn` which stores this newly created
function. In the second pass, we will populate this
function with LLVM IR from the definition's instructions.
We actually create functions for each of the constructors
of data types, but they're quite special: all they do is
pack their arguments! Since they don't need access to
the other global functions, we might as well create
their bodies then and there:
{{< codelines "C++" "compiler/08/definition.cpp" 101 112 >}}
Like in `definition_defn`, we use `create_custom_function`.
However, we then use `SetInsertPoint` to configure our builder to insert code into
the newly created function (which already has a `BasicBlock`,
thanks to that one previously unexplained line in `create_custom_function`!).
Since we decided to only include the Pack instruction, we generate
a call to it directly using `create_pack`. We follow this
up with `CreateRetVoid`, which tells LLVM that this is
the end of the function, and that it is now safe to return
from it.
Great! We now implement the second pass of `gen_llvm`. In
the case of `definition_defn`, we do almost exactly
what we did in the first pass of `definition_data`:
{{< codelines "C++" "compiler/08/definition.cpp" 62 68 >}}
As for `definition_data`, we have nothing to do in the
second pass. We're done!
### Getting Results
We're almost there. Two things remain. The first: our implementation
of `ast_binop`, implement each binary operation as simply a function call:
`+` calls `f_plus`, and so on. But so far, we have not implemented
`f_plus`, or any other binary operator function. We do this
in `main.cpp`, creating a function `gen_llvm_internal_op`:
{{< codelines "C++" "compiler/08/main.cpp" 70 83 >}}
We create a simple function body. We then append G-machine
instructions that take each argument, evaluate it,
and then perform the corresponding binary operation.
With these instructions in the body, we insert
them into a new function, just like we did in our code
for `definition_defn` and `definition_data`.
Finally, we write our `gen_llvm` function that we will
call from `main`:
{{< codelines "C++" "compiler/08/main.cpp" 125 141 >}}
It first creates the functions for
`+`, `-`, `*`, and `/`. Then, it calls the first
pass of `gen_llvm` on all definitions, followed
by the second pass. Lastly, it uses LLVM's built-in
functionality to print out the generated IR in
our module, and then uses a function `output_llvm`
to create an object file ready for linking.
To be very honest, I took the `output_llvm` function
almost entirely from instructional material for my university's
compilers course. The gist of it, though, is: we determine
the target architecture and platform, specify a "generic" CPU,
create a default set of options, and then generate an object file.
Here it is:
{{< codelines "C++" "compiler/08/main.cpp" 85 123 >}}
We now add a `generate_llvm` call to `main`.
Are we there?
Let's try to compile our first example, `works1.txt`. The
file:
{{< rawblock "compiler/08/examples/works1.txt" >}}
We run the following commands in our build directory:
```
./compiler < ../examples/work1.txt
gcc -no-pie ../runtime.c program.o
./a.out
```
Nothing happens. How anticlimactic! Our runtime has no way of
printing out the result of the evaluation. Let's change that:
{{< codelines "C++" "compiler/08/runtime.c" 157 183 >}}
Rerunning our commands, we get:
```
Result: 326
```
The correct result! Let's try it with `works2.txt`:
{{< rawblock "compiler/08/examples/works2.txt" >}}
And again, we get the right answer:
```
Result: 326
```
This is child's play, though. Let's try with something
more complicated, like `works3.txt`:
{{< rawblock "compiler/08/examples/works3.txt" >}}
Once again, our program does exactly what we intended:
```
Result: 3
```
Alright, this is neat, but we haven't yet confirmed that
lazy evaluation works. How about we try it with
`works5.txt`:
{{< rawblock "compiler/08/examples/works5.txt" >}}
Yet again, the program works:
```
Result: 9
```
At last, we have a working compiler!
While this is a major victory, we are not yet
finished with the compiler altogether. While
we allocate nodes whenever we need them, we
have not once uttered the phrase `free` in our
runtime. Our language works, but we have no way
of comparing numbers, no lambdas, no `let/in`.
In the next several posts, we will improve
our compiler to properly free unused memory
usign a __garbage collector__, implement
lambda functions using __lambda lifting__,
and use our Alloc instruction to implement `let/in` expressions.
We get started on the first of these tasks in
[Part 9 - Garbage Collection]({{< relref "09_compiler_garbage_collection.md" >}}).