Your first compiler with Beaver!
Mix.install([
{:beaver, "~> 0.4"}
])
What makes a compiler
Before start coding anything. Let's get some intuition about what a typical compiler is made of.
- IR
- Pass
- CodeGen
A taste of MLIR
Most of LLVM tutorial starts with AST and parser. Guess what we are building it with Elixir and it rocks. So we can start with something fun, generating the IR.
use Beaver
alias Beaver.MLIR.Dialect.{Func, Arith}
require Func
ctx = MLIR.Context.create()
ir =
mlir ctx: ctx do
module do
Func.func some_func(function_type: Type.function([Type.i32()], [Type.i32()])) do
region do
block _bb_entry(a >>> Type.i32()) do
b = Arith.constant(value: Attribute.integer(Type.i32(), 1024)) >>> Type.i32()
c = Arith.addi(a, b) >>> Type.i32()
Func.return(c) >>> []
end
end
end
end
end
ir |> Beaver.MLIR.to_string() |> IO.puts()
As you can see, with MLIR we are not working with those low level LLVM instructions you might have seen somewhere else.
Here we have this Func.func
and Func.return
thing looks like a function and return statement in a programming language.
And the Arith
stuff seems to be doing some arithmetic for us.
Now you might take some time to look at the IR printed by IO.puts
and think about it.
If you find the text full of %1
, %2
not so interesting, don't worry.
The only thing you need to know is that this is called MLIR's "textual form".
Let's carry on.
Entering the LLVM realm
The Next Big Thing™ we are going to do is to convert what we got so far to LLVM IR.
Why? Long story short, MLIR is like a cinematic universe.
Instead of having different hero franchises, we got a bunch of "dialects". The Func
, Arith
are both dialects.
There is a dialect called LLVM
we haven't seen. LLVM
is a dialect kind of magical and kind of different from others.
It is magical that if we can convert IR to LLVM
, we can generate native machine code and run it.
This is how it is done with Beaver:
import MLIR.Conversion
llvm_ir =
ir
|> convert_arith_to_llvm
|> Beaver.Composer.nested("func.func", "llvm-request-c-wrappers")
|> convert_func_to_llvm
|> Beaver.Composer.run!()
llvm_ir |> Beaver.MLIR.to_string() |> IO.puts()
Now you can see the IR seems to "grow" a little. It is called "lowering".
What we just did is running two passes convert_func_to_llvm
and convert_arith_to_llvm
on our IR.
So that the higher level abstraction like Func
and Arith
are lowered to LLVM IR,
which is a representation closer to the hardware instruction.
Remember what I told you, if we get to LLVM Dialect, basically we should get native machine code to run right? Let's do it!
Run it!
jit = MLIR.ExecutionEngine.create!(llvm_ir)
return = Beaver.Native.I32.make(0)
arguments = [Beaver.Native.I32.make(1024)]
MLIR.ExecutionEngine.invoke!(jit, "some_func", arguments, return)
|> Beaver.Native.to_term()
Recap
Congratulations! You have just built your first compiler with Beaver. There could be a lot to unpack and here is the summary:
- First we generate some MLIR with the
mlir/1
macro. - Then we convert it to LLVM IR with
convert_func_to_llvm
andconvert_arith_to_llvm
passes. - Later we create a JIT engine from the generated LLVM IR with
MLIR.ExecutionEngine.create!
and run the native function.
These three steps are corespondent to what makes a compiler
- IR
- Pass
- CodeGen
In next tutorial we are taking a more programmable approach to generate the IR, converting Elixir AST to MLIR. By doing it we will pick up concepts like region, block, operations appear here but didn't receive much attention for now. You might want to look at the full list of MLIR dialects here.