TLDR
- https://zig.godbolt.org
- Use
-O ReleaseFast
instead of-Doptimize=ReleaseFast
export fn
orelsepub fn main()
What is Compiler Explorer?
To quote from Compiler Explorers Readme.md: “Compiler Explorer is an interactive compiler exploration website. Edit code in […] 30+ supported languages […], and see how that code looks after being compiled in real time.”
Start with Zig on Compiler Explorer
For a quick start, head over to https://zig.godbolt.org. By default you will see two windows. On the left the default Zig example code
// Type your code here, or load an example.
export fn square(num: i32) i32 {
return num * num;
}
and on the right the resulting assembly (shortened for brevity):
square:
push rbp
mov rbp, rsp
sub rsp, 16
mov dword ptr [rbp - 4], edi
imul edi, edi
mov dword ptr [rbp - 8], edi
seto al
jo .LBB0_1
jmp .LBB0_2
.LBB0_1:
movabs rdi, offset __anon_1381
mov esi, 16
xor eax, eax
mov edx, eax
movabs rcx, offset .L__unnamed_1
call example.panic
.LBB0_2:
mov eax, dword ptr [rbp - 8]
add rsp, 16
pop rbp
ret
example.panic:
push rbp
mov rbp, rsp
sub rsp, 32
[...]
In case the default example changed since writing this, try this short URL.
Note the function name square
appearing as the label square:
right at the beginning of its assembly code.
Looking at the source you might wonder why the function is introduced with the export
keyword. Try to remove it! You will notice the assembly window becomes empty. Why is that? Our example code does not contain any use of the function square
, so it is omitted in the assembly.
Ways to make the compiler emit assembly
The export
keyword states the intent to make the function part of a C Library. The compiler will then generate assembly for it, even if it can not see any use of the function.
There is a drwaback to use the export
keyword: The function is required to have a signature compatible with C. So no slices, optionals, error unions and other Zig features may appear in exported signatures. In easy cases it is possible to use small adapter functions with C compatible signatures which in turn call the function of interest. Slice parameters can be adapted to a C Pointer and a length, like in this example:
export fn sumArrayC(array: [*]const u32, len: usize) u32 {
return sumArrayZig(array[0..len]);
}
pub fn sumArrayZig(slice: []const u32) u32 {
var sum: u32 = 0;
for (slice) |item| {
sum += item;
}
return sum;
}
If this approach becomes impractical, the next thing to do is to include a main
function. This way we can analyze whole Zig programs and not only single functions. Doing this for the previous example shows why we might want to stick to the export
approach as long as that is feasible.
pub fn main() void {
const sum = sumArrayZig(&.{1, 2, 3, 4, 5});
_ = sum;
}
pub fn sumArrayZig(slice: []const u32) u32 {
var sum: u32 = 0;
for (slice) |item| {
sum += item;
}
return sum;
}
The assembly window now contains well over 100.000 lines, including loads of infrastructure code! Luckily the usual search key combo <Ctrl> + f
comes to the rescue. Enter the name of the function of interest, here sumArrayZig
, and hit the Enter key a few times:
[...]
767 mov esi, 5
768 call example.sumArrayZig
769 mov dword ptr [rbp - 4], eax
[...]
2470
2471 example.sumArrayZig:
2472 push rbp
2473 mov rbp, rsp
[...]
On line 768 we find the call of sumArrayZig
in main
and further down on line 2471 the function we are looking for begins.
Choose different build modes
By default the Zig toolchain uses the build mode Debug
. If we want to see what assembly gets generated for other build modes, we have to explicitly tell the compiler on the command line. On Compiler Explorer we do this by entering the build mode into the text input Compiler options...
.
Let us return to our initial square
example with the default build mode set explicitly: -O Debug
. What does our square
function do again?
export fn square(num: i32) i32 {
return num * num;
}
For one, the obvious thing: one multiplication. But remember, Zig has safety checks for arithmetic overflow. Can we find both things in the assembly?
1 square:
2 push rbp
3 mov rbp, rsp
4 sub rsp, 16
5 mov dword ptr [rbp - 4], edi
6 imul edi, edi
7 mov dword ptr [rbp - 8], edi
8 seto al
9 jo .LBB0_1
10 jmp .LBB0_2
11 .LBB0_1:
12 movabs rdi, offset __anon_1381
13 mov esi, 16
14 xor eax, eax
15 mov edx, eax
16 movabs rcx, offset .L__unnamed_1
17 call example.panic
18 .LBB0_2:
19 mov eax, dword ptr [rbp - 8]
20 add rsp, 16
21 pop rbp
22 ret
23
24 example.panic:
25 push rbp
26 mov rbp, rsp
27 sub rsp, 32
28 mov qword ptr [rbp - 16], rsi
29 mov qword ptr [rbp - 24], rdi
30 mov qword ptr [rbp - 8], rdx
31 call zig_panic@PLT
[...]
38
39 __anon_1381:
40 .asciz "integer overflow"
[...]
The multiplication (imul
) is located on line 6. Then on line 9 we find a conditional jump on overflow (jo
) to .LBB0_1
which starts just below on line 12 loading something called __anon_1381
which we can find on line 39 marking the text “integer overflow”. Continuing after line 12 leads to calls to example.panic
(line 17) and ultimately zig_panic@PLT
(line 31).
Also note, that the whole assembly code is sprinkled with stack operations (use of registers rbp
and rsp
) which is common in Debug modes.
How does this change if we change the build mode to -O ReleaseSafe
enabling optimizations while keeping safety checks?
square:
imul edi, edi
jo .LBB0_2
mov eax, edi
ret
.LBB0_2:
push rax
call zig_panic@PLT
Not much is left. But the multiplication (imul
) is there, followed by the overflow check (jo
) which now quite directly leads to the call of zig_panic@PLT
. All those stack operations are gone, as well as the descriptive message why the call to zig_panic@PLT
was made.
Finally, let us look what remains if we even disable safety checks by using the build mode -O ReleaseFast
:
square:
mov eax, edi
imul eax, edi
ret
The multiplication is still there. As expected, overflows are no longer checked for and consequently no calls to zig_panic@PLT
are present anymore.
The Zig toolchain uses two different conventions to specify build modes depending on whether you use the buildsystem (zig build -Doptimize=<MODE>
) or other subcommands like zig build-exe -O <MODE>
. On Compiler Explorer we need to use the latter. Be aware though, that the use of -Doptimize=<MODE>
on Compiler Explorer will seemingly work, but have no influence on the build mode. This will instead define a C macro named optimize
and set its value to the mode.
Other platforms
Compiler Explorer runs Zig in x86_64
Linux containers. The Zig toolchain therefore autodetects the target as x86_64-linux-gnu
. If you are developing for a different platform use the -target
compiler option. See the square
example with explicit -target x86_64-linux-gnu
.
If you want to know what Zig autodetects for your system, an easy way is to run zig env
. Look for the key target
in the resulting JSON output. The computer I am writing this on gets detected as "target": "x86_64-linux.6.5...6.5-gnu.2.35",
. If you do not need it that specific, consider removing the version numbers and version ranges.
Ask questions about generated Assembly
You played around with code on Compiler Explorer and now want to ask questions or discuss your findings?
Then consider to include a short link to the code and compiler settings you used. This gives readers all details in case they need them. According to Compiler Explorer’s FAQ short links are designed to stay valid forever.
To emphasize points you want to make, repeat parts of the source code and generated assembly in your post. Aim to write posts that are understandable without following links.