Motiejus Jakštys Public Record

Zig interpreter

2026-11-10

Hello Ziggy

I wrote1 an interpreter for a subset of Zig and named it zig1. Here is a demo:

$ wget -O zig1.c https://m.jakstys.lt/2026/zig-interpreter/stage1-0.17.1-1.c
$ cc -O3 zig1.c -o zig1 -lm

$ cat <<EOF > hello.zig
pub fn main() void {
    @import("std").debug.print("Hello Ziggy!\n", .{});
}
EOF

$ ./zig1 --run --zig-lib-dir ../lib hello.zig
Hello Ziggy!

real    0m0.337s
user    0m0.233s
sys     0m0.104s

Why a Zig subset, you don’t ask? Because it only works with valid Zig: an invalid Zig program is undefined behavior. It will also sometimes crash, or just run incorrectly, on correct Zig programs. How cheeky! It is also slow – hundreds to thousands of times slower than a Zig-compiled binary, depending on the program:

$ hyperfine -Np 'zig build-exe hello.zig' './hello' './zig1 --run --zig-lib-dir ../lib hello.zig'
Benchmark 1: ./hello
  Time (mean ± σ):     767.9 µs ± 158.2 µs    [User: 407.3 µs, System: 207.8 µs]
  Range (min … max):   583.9 µs … 1101.5 µs    10 runs

Benchmark 2: ./zig1 --run --zig-lib-dir ../lib hello.zig
  Time (mean ± σ):     246.9 ms ±   3.9 ms    [User: 173.3 ms, System: 70.7 ms]
  Range (min … max):   241.0 ms … 252.9 ms    10 runs

Summary
  ./hello ran
  321.52 ± 66.44 times faster than ./zig1 --run --zig-lib-dir ../lib hello.zig

It’s a really shitty interpreter.

Now for the fun stuff

Now that we got the intro out of the way, we can do fun stuff with it. For example, use real Zig through interpreter to compile that hello ziggy:

$ git clone -b 0.17.1 https://codeberg.org/ziglang/zig; cd zig
$ mv $OLDPWD/zig1 .
$ ./zig1 --run --zig-lib-dir lib --bootstrap-profile \
  --dep build_options -Mroot=src/main.zig \
  -Mbuild_options=<(./zig1 --bootstrap-build-options | sed 's/= \.bootstrap;/= .@"x86_64-linux";/') \
  -- build-exe -fno-llvm -fno-lld -target x86_64-linux -OReleaseSmall \
     --name hello -femit-bin=hello.small -Mroot=hello.zig
$ file hello.small
hello.small: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped
$ hyperfine -N ./hello.small
  Time (mean ± σ):     779.1 µs ± 149.5 µs    [User: 414.9 µs, System: 249.4 µs]
  Range (min … max):   554.1 µs … 1594.5 µs    2810 runs

This can take upwards an hour on cold caches (compared to milliseconds with zig build-exe), but the result is identical to:

zig build-exe -fno-llvm -fno-lld -target x86_64-linux -OReleaseSmall \
     --name hello -femit-bin=hello.small -Mroot=hello.zig

… except we didn’t have zig in the first place.

Compile zig itself

Since zig is just another Zig program, we can compile it with itself directly, just like we did with hello ziggy:

$ cat <<EOF > config.zig
pub const have_llvm = false;
pub const llvm_has_m68k = false;
pub const llvm_has_csky = false;
pub const llvm_has_arc = false;
pub const llvm_has_xtensa = false;
pub const version: [:0]const u8 = "0.17.1-bootstrap";
pub const semver = @import("std").SemanticVersion.parse(version) catch unreachable;
pub const enable_debug_extensions = false;
pub const enable_logging = false;
pub const enable_link_snapshots = false;
pub const enable_tracy = false;
pub const value_tracing = false;
pub const skip_non_native = false;
pub const debug_gpa = false;
pub const dev = .core;
pub const io_mode: enum { threaded, evented } = .threaded;
pub const value_interpret_mode = .direct;
EOF
$ ./zig1 --run --zig-lib-dir lib --bootstrap-profile \
  --dep build_options -Mroot=src/main.zig \
  -Mbuild_options=<(./zig1 --bootstrap-build-options | sed 's/= \.bootstrap;/= .@"x86_64-linux";/') \
  -- build-exe -fno-llvm -fno-lld -target x86_64-linux -OReleaseSmall \
     --name zig2 -femit-bin=zig2 \
     --dep build_options -Mroot=src/main.zig -Mbuild_options=config.zig
$ file zig2
zig2: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), statically linked, not stripped

Bootstrapping

You may remember Andrew removing Zig C++ implementation (stage1) in favor of zig1.wasm, and leaving it for the void to implement an alternative:

I openly acknowledge that this cost is being paid, however, I strongly believe that it is worth it in the end. I suspect that combined with an official language specification and a growing popularity of Zig, we will see a third-party project start an alternate Zig implementation in C, similar to how mrustc exists for Rust (despite not yet having a spec). This would fill the necessary role to solve O(1) source bootstrapping again.

So let’s replicate the bootstrap, but instead of a checked-in Zig implementation in zig1.wasm, use the interpreter:

./zig1 lib build-exe -ofmt=c -lc -OReleaseSmall --name zig2 \
  -femit-bin=zig2.c -target x86_64-linux \
  --dep build_options -Mroot=src/main.zig \
  -Mbuild_options=config.zig

./zig1 lib build-obj -ofmt=c -lc -OReleaseSmall --name compiler_rt \
   -femit-bin=compiler_rt.c -target x86_64-linux \
  -Mroot=lib/compiler_rt.zig

And there you have it after a few hours — zig2.c and compiler_rt.c, byte-identical just like how zig1.wasm would compile them. Resume the standard bootstrap to compile them to zig2 and eventually zig3.

About zig1.c

zig1.c is ~120k lines of C, about the same amount Andrew removed in #13560. zig1.c includes lexer, parser, AstGen, Sema and AIR interpreter2. When Zig implementation was available (i.e. everything but interpreter), I tried to write a direct port, except for error handling.

Test harness, lexer, and 5% of parser were hand crafted by yours truly in early 2025, then I shelved it. Once powerful-enough LLMs came about, I resumed, and now, after lots of tokens and micro-management, it is complete.

For me, zig1.c concludes something I started 2 years ago to make Zig bootstrapping easier. I did it; maybe someone will find it useful, even if upstream won’t. Zig1 shares the zig license and obviously builds on top of it. It can bootstrap Zig, it is mostly LLM-written, is licensed the same way as Zig, and I do not promise anything about its maintenance. Full code with git history can be found in stage1-0.17.x branch.

If you are hand-crafting another Zig implementation for bootstrapping purposes, learning that LLM wrote it first may be deeply sad. I am truly sorry. If not for LLMs, it is quite likely I would have picked it up continued to grind for the next few years for the fun of it and for myself and everyone’s benefit. Maybe you will be inspired and hand-craft a true, piece-of-art, artisanal, bootstrap-worthy, much smaller implementation?

Final thoughts

Since upstream is against LLMs (rightfully so), I added a disclaimer to the top of the README:

This is an LLM-tainted fork. Zig community is against any LLM use, please be respectful where you share & discuss it.

Extending my thought from the previous post:

Therefore, finishing a project with LLMs feels like eating a cake solo. Not only it’s unhealthy, it’s not-fun and wrong — cakes are meant to be eaten with others in celebration, just like Open Source was meant to be created, shared and enjoyed together. The world is very different now.

Now that some of us have dayjobs that expect LLM-enhanced project delivery, the act of writing code by hand feels akin to long hikes: set aside motor transportation, bed and kitchen. Instead, use a backpack, tent and a stove. Comfortable? Not really. Fun and deeply rewarding? Hell yes. Can be done for 250-365 days/year? Yes, but only by a few.

We went from slow, fun and collaborative to fast and depressingly lonely. If not for LLMs, I most likely would have picked this up and grinded it out in a few years, learning about compilers a bunch and, in procecss, bringing value to the community. Now I feel like I ate a bunch of cake.

Appendix

Hardware (for timings):

  • CPU: AMD Ryzen 7 8745HS
  • Linux 7.2.0

Tested with the following compilers:

  • gcc 15.3.0
  • clang 22.1.8
  • tcc 22.1.8
  • x86_64-w64-mingw32-gcc (GCC) 14-win32
$ /run/current-system/sw/bin/time -v ./zig1 lib build-obj -ofmt=c -lc -OReleaseSmall --name compiler_rt \
   -femit-bin=compiler_rt.c -target x86_64-linux \
  -Mroot=lib/compiler_rt.zig
        Command being timed: "./zig1 lib build-obj -ofmt=c -lc -OReleaseSmall --name compiler_rt -femit-bin=compiler_rt.c -target x86_64-linux -Mroot=lib/compiler_rt.zig"
        User time (seconds): 286.84
        System time (seconds): 0.75
        Percent of CPU this job got: 57%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 8:20.85
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 438496
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 311163
        Voluntary context switches: 426
        Involuntary context switches: 1649
        Swaps: 0
        File system inputs: 488
        File system outputs: 39136
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0
$ /run/current-system/sw/bin/time -v ./zig1 --run --zig-lib-dir lib --bootstrap-profile \
    --dep build_options -Mroot=src/main.zig  \
    -Mbuild_options=<(./zig1 --bootstrap-build-options | sed 's/= \.bootstrap;/= .@"x86_64-linux";/') \
    -- build-exe -fno-llvm -fno-lld -target x86_64-linux -OReleaseSmall \
    --name zig2 -femit-bin=zig2 \
    --dep build_options -Mroot=src/main.zig -Mbuild_options=config.zig
        Command being timed: "./zig1 --run --zig-lib-dir lib --bootstrap-profile --dep build_options -Mroot=src/main.zig -Mbuild_options=/dev/fd/63 -- build-exe -fno-llvm -fno-lld -target x86_64-linux -OReleaseSmall --name zig2 -femit-bin=zig2 --dep build_options -Mroot=src/main.zig -Mbuild_options=config.zig"
        User time (seconds): 15669.04
        System time (seconds): 14.09
        Percent of CPU this job got: 96%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 4:31:15
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 1722732
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 4115356
        Voluntary context switches: 1397
        Involuntary context switches: 73168
        Swaps: 0
        File system inputs: 46560
        File system outputs: 455840
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0
$ /run/current-system/sw/bin/time -v ./zig1 lib build-exe -ofmt=c -lc -OReleaseSmall --name zig2 \
  -femit-bin=zig2.c -target x86_64-linux \
  --dep build_options -Mroot=src/main.zig \
  -Mbuild_options=config.zig
        Command being timed: "./zig1 lib build-exe -ofmt=c -lc -OReleaseSmall --name zig2 -femit-bin=zig2.c -target x86_64-linux --dep build_options -Mroot=src/main.zig -Mbuild_options=config.zig"
        User time (seconds): 16751.33
        System time (seconds): 7.14
        Percent of CPU this job got: 97%
        Elapsed (wall clock) time (h:mm:ss or m:ss): 4:46:01
        Average shared text size (kbytes): 0
        Average unshared data size (kbytes): 0
        Average stack size (kbytes): 0
        Average total size (kbytes): 0
        Maximum resident set size (kbytes): 2245828
        Average resident set size (kbytes): 0
        Major (requiring I/O) page faults: 0
        Minor (reclaiming a frame) page faults: 763145
        Voluntary context switches: 692
        Involuntary context switches: 74872
        Swaps: 0
        File system inputs: 11848
        File system outputs: 577816
        Socket messages sent: 0
        Socket messages received: 0
        Signals delivered: 0
        Page size (bytes): 4096
        Exit status: 0

  1. LLM disclaimer applies. ↩︎

  2. For a better description what those words mean, start here. ↩︎