Hot-code reloading, or hot-code swapping, is the ability of the compiler to allow you, the developer, to make
changes to your program appear instantaneously while the program is already running. Typically, compilers
utilise the idea of dynamic library hot swapping (for native targets such as macOS) where your program (logic) is
compiled and linked down to a dynamic library (shared object) which is then managed and reloaded by
a small loader program running side-by-side 1. Note that both programs, the dynamic library and the loader,
do not share the same address space.
In Zig 2, we would like to try something else. Instead of having your program managed by a running side-by-side
loader program, what if the compiler would “simply” update the memory of the running process? You will most
inevitably think I have gone completely crazy, which given the current state of global affairs is not unthinkable,
but hear me out.
Here’s my claim. We can pull it off using the self-hosted Zig compiler (also known as stage2 compiler). We can pull
it off on Linux 3 and macOS (Windows coming soon TM), and here’s how. The self-hosted Zig compiler is a
little bit special since it couples very tighly with our in-house linker 4. This gives it special powers which
effectively mean we can completely bypass the idea of creating relocatable object files for Zig modules in favour
of writing the already-relocated declarations/symbols directly into the final binary. I will refer to this concept
as incremental compilation but some prefer to call it in-place binary patching, or incremental linking.
Either way, the point is, the compiler does not generate any intermediate relocatable object files.
From incremental compilation…
Hmm, OK, Jakub, whatever you mean. How about an example to illustrate what you mean? Sure thing, just one more
sentence of explanation before we dive into the problem of incremental compilation. The granularity at which the compiler
works is scoped down to single declaration, aka decl or simply symbol, which is then incrementally allocated space
in the virtual memory and a file offset, and written to the binary file (there’s a couple more things that actually happen here
such as resolving relocations, if any, but you get the point). This allows us to update only those symbols that
actually changed in an incremental fashion.
OK, example time! Consider the following Zig code
// example.zig
pub fn main() void {
var x: u32 = 1;
_ = bar(); // This is so that we force the compiler to generate bar before addToBar in the address space.
const y = addToBar(x);
assert(y == 11);
}
fn addToBar(x: u32) u32 {
const y = bar();
return x + y;
}
fn bar() u32 {
return 10;
}
fn assert(ok: bool) void {
if (!ok) unreachable;
}
In order to put Zig into incremental compilation mode, we will use a special flag --watch
like so
$ zig build-exe example.zig --watch
(zig)
By this point, the compiler created a fully functional binary that we can run from disk. However, since
--watch
flag puts the compiler in the REPL mode, we can update-and-run directly from the REPL loop
(zig) update-and-run
(zig)
In this case, no output is good news as this means we didn’t hit the assert. Let’s tweak the assert
to something false though just to test that everything is working as expected
// ...
assert(y == 12);
// ...
and then retry update-and-run in the REPL
(zig) update-and-run
warning: process aborted abnormally
(zig)
Hmm, right, so assertion was correctly triggered in this case, and the compiler reported that the binary
did not exit cleanly, just as we expected.
OK, but how any of this lend itself towards hot-code reloading in Zig? Right, let’s do another tweak to the
source where we change the definition of bar
to something longer so that the linker will be forced to move
the symbol to a new location in the file and virtual memory
fn bar() u32 {
assert(true);
assert(true);
assert(true);
assert(true);
return 10;
}
Let’s update-and-run
(zig) update-and-run
(zig)
So far so good. If we now analyse the before and after of the update-and-run step, we will note that bar
was moved
from its initial address in virtual memory of 0x1000010c0
to 0x100001178
since it grew too big to be accomodated
in its original place. I will stop here for a second and pull up a “printout” from a debugging tool 5 I wrote to
aid in visualising changes to the binary between incremental updates
There are two columns in the picture: the left hand side depicts the contents of the virtual memory before the next
incremental update, and the right hand side depicts the contents after the incremental update. I have purposefully
highlighted the symbol bar
which, as predicted, has been moved in memory from 0x1000010c0
to 0x100001178
since it grew too big to fit its current placeholder (NB Zig’s incremental MachO linker does insert some
padding between symbols so that they can grow without necessitating the move, however in this case, we purposefully
grew the contents of bar
enough to trigger the move and reallocation in virtual memory).
But what about any caller of bar
? Did any symbol calling bar
need a full rewrite? The short answer is no. Why,
you ask? Let’s pull up another view of the changes to the virtual memory contents of the file between updates, and
in particular, let us zoom in on addToBar
The contents of the highlighted addToBar
depicts any relocation to any other symbol within the binary image.
Note that addToBar
doesn’t make a direct reference to bar
; instead, it references a mysterious cell in the
global offset table (GOT) denoted here as section __DATA_CONST,__got
. The cell is located at an address 0x100054028
.
Let’s pull up its contents in both views
Note that both cells in both views still point to bar
but the cell on the left hand side points to bar
at its original address of 0x1000010c0
, while the cell on the right hand side to its new address after the move,
0x100001178
. In other words, in order to preserve the integrity of the calls, all the linker had to tweak
was to update the target address of bar
in its GOT cell. There was no need to touch any other symbol which
called bar
as every reference to it is done via the GOT table. This mechanism lends itself really well
to hot-code reloading as it minimises the number of changes the linker has to do to the binary, and it will
be the cornerstone for our hot-code reloading solution. Let’s get right to it then!
…to hot-code reloading…
Before we go on, I will point out that in the rest of this post, I will mainly focus on Mach and macOS specific
bits to get the ball rolling with respect to hot-code reloading with the Zig compiler. One additional bit
required to actually get it all pieced together into a working solution is to roll out some mechanism for communicating
with the compiler while in the hot-code reloading mode as communicating via stdio will be unavailable as we will be
piping the output of the hot-code reloaded child process (our binary) via the compiler. Therefore, one could for
instance com