build_dot_zig
A nice byproduct of my work on TigerBeetlex is a library
called build_dot_zig. It’s similar to elixir_make
,
but it allows you to use the Zig build system instead of make
to define your build steps and have
them automatically execute during mix compile
. You just put a build.zig
file in your project
root, add :build_dot_zig
to your Mix compilers and you can start leveraging it as a build system
for your NIFs, or in general to Do Stuff™.
Note that this works perfectly fine even if your NIF is implemented in C or C++ and doesn’t have any
Zig code. This is because inside the zig
binary there are (at least) 3 different things:
- A programming language
- A compiler which can be used as drop-in replacement for GCC or Clang
- A build system
build_dot_zig
allows you to use Zig the build system (which in turn also leverages Zig the
compiler) even if you don’t want to use Zig the language.
In this post I will briefly show some of the features which could make build_dot_zig
a nice
alternative to elixir_make
. If you want to go deeper in the Zig build system features, I suggest
looking at the Zig build system guide.
No System Toolchain
build_dot_zig
doesn’t require a system toolchain to be installed. You can compile C and C++ NIFs
without build_essential
, XCode or MSVC. When a library depends on it, it automatically downloads
and locally caches the zig
compiler (~45 MiB tarball, single binary). Alternatively, you can use
your system zig
if you already have it installed. All of this works regardless if you are on
Linux, MacOS or Windows.
This also means that your NIF will be compiled exactly by the same compiler and build system that you used for developing it, something that is all over the place when using the system toolchain or build system.
Moreover, build_dot_zig
ensures that you only download a single copy of a specific version of Zig
per project if multiple dependencies use the same build_dot_zig
version.
C NIF Mix Generator
A recent addition is the C NIF Mix generator, which is very handy to bootstrap a C NIF from scratch.
You just run
mix build_dot_zig.gen.c_nif Math math sum/2 multiply/2
and the generator will take care of creating:
- The
build.zig
file with all the correct instructions to build the NIF - The C source file with all the NIF boilerplate and function skeletons
- The Elixir module with all the function stubs and the code to load the NIF
The code is already usable just after the generation, the functions will just raise
:not_implemented
when called, so the only manual step needed is writing the implementation of the
NIFs.
Mix and Matching C and Zig
While in the introduction I said you don’t have to use Zig the language, I think it’s a nice tool to have at your disposal. If you already have a C based NIF you can add Zig code to it (or gradually rewrite it in Zig); this is made very easy by Zig’s first-class support for C interop. You are already using the Zig build system, so adding a new Zig file to your library is a breeze.
On the other hand if you just want to write your whole NIF in Zig and you don’t have to do fancy things with the build system, your safest bet is probably to use Zigler (see also this section).
Caching
I won’t go much into the details here and I suggest looking at this blogpost by Andrew
Kelley
and the linked references, but tl;dr: the caching system of the
Zig build system is more advanced than what make
uses, and it can save you from going mad
screaming “WHY IS IT PRINTING THE OLD STRING, I JUST CHANGED IT!”
CPU Features
For native targets, the Zig compiler automatically enables advanced CPU features by default
(-march=native
). This means that the code is optimized for the hardware it runs on, which is great
when you build on the same machine you’re going to use to deploy (e.g. Fly.io builders).
If, instead, you want to target the widest possible range of CPUs, you can always pass zig_cpu: "baseline"
. You can also find a middle ground, e.g. targeting reasonably modern CPUs like
TigerBeetle
does.
This allows your compiled code to be reasonably compatible without leaving too much performance on
the table, which should be a no-brainer given NIFs are mainly used for performance reasons.
Zig Package Manager
Lastly, from version 0.11.0 Zig comes with an official decentralized package manager baked into its
build system. It is still rough around the edges, especially if you come from the Elixir world and
you’re used to the mix
tooling, but it’s able to handle Zig, C and C++ dependencies.
This can be used to write a NIF referencing another C/C++ library without it being installed system-wide. The library would be fetched by the Zig package manager, built and linked directly to the NIF by the build system.
Full disclosure: I still haven’t experimented with the package manager in the context of
build_dot_zig
, but it’s certainly on my radar.
How Is This Different From Zigler?
Some of you which are already familiar with Zig and Elixir might ask “How is this different from
Zigler?”. The two projects have some kind of overlap but in general I’d say that Zigler is more
oriented towards using Zig the language while build_dot_zig
is more focused on Zig the build
system and Zig the compiler.
From a practical standpoint, build_dot_zig
is much more manual while Zigler does a lot more for
you. I wrote build_dot_zig
because for TigerBeetlex I needed to manually control the contents of
build.zig
and Zigler currently doesn’t allow that.
Example Repo
If you want to have a look at how this can be used, I’ve also created a
repo with a simple Elixir application that calls a
C NIF which gets built by build_dot_zig
. In the repo there is also a minimal GitHub workflow
showing that this compiles and runs tests on all major operating systems.
Future Plans
One area where the Zig compiler is extremely powerful is cross-compilation. I have to understand all
the implications of cross-compiling an Elixir release for another architecture to know if
build_dot_zig
can offer some advantages here too. I plan to have a look at
Nerves or
Burrito to better understand the whole story.
Moreover, I want to investigate if build_dot_zig
can make it easy to provide precompiled
NIFs, since it would allow compiling the
NIF for multiple targets using the same toolchain.