Compile Time
This is a continuation of the posts on The Path to 1.0.
In the previous post on C Interop we discussed how it is being used to provide both an escape hatch for features not available in bpftrace’s core and as a means to reduce the amount of LLVM IR that needs to be written and supported. In this post we’ll look at the other mechanisms required to reduce bpftrace’s core code footprint and create powerful, reusable features in the bpftrace language itself.
Folding and Pruning
Hopefully you’re not disappointed that this section is not about bpftrace oragami but rather compiler optimizations.
Constant folding is an age-old compiler technique that bpftrace actually started utilizing a while ago but became a critical part of this larger journey. If you’re not familiar, here is a lighting-fast tutorial because it’s very simple. Say you have a bpftrace variable assignment: $a = 1 + 2;. Is the addition on the right side something you have to wait to evaluate at runtime? Of course not. Since bpftrace is parsing and compiling on every script invocation, we know that these two integer literals can never change at runtime and can be "folded" at compile time, resulting in a new assignment: $a = 3;. This technique can work on all kinds of expressions. One of the important ones is if expressions, which unlock another age-old compiler technique: branch pruning.
Let’s say you have an if/else expression, e.g.,
if ("my_string" == "other_string") {
print("hi");
} else {
print("bye");
}
At compile time we know the conditional (comparing two string literals) will never evaluate to true meaning that the statement in the if block (print("hi");) is unreachable. We can then transform this whole if/else expression into just:
{
print("bye");
}
Neat, right. The wrapping brackets are to maintain the correct scope.
comptime
Even though bpftrace is a compiler (of sorts) and compiler law states that any optimizations, which don’t shouldn’t affect program behavior, can and will be applied, we still wanted users to be explicit about which branches should be pruned (specifically for type inference & checking) as doing this automatically can lead to some really confusing error messages. Example:
let $x;
if (false) {
$x = "hello";
}
print($x);
stdin:1:55-64: ERROR: none type passed to print() is not printable
stdin:1:61-63: WARNING: Variable used before it was assigned: $x
If we never visit the assignment to $x then $x has no type and print doesn’t know how to handle it. Note: bpftrace still enables LLVM to do its own optimizations (and possible branch pruning) but this happens after bpftrace’s lexing, parsing, and compiling pipeline.
There were several key words from various languages (like constexpr) we could’ve chosen from to indicate evaluation at compile time but we went with comptime, which is taken from Zig, as the behavior and syntax was closest to what we wanted for bpftrace.
comptime is not just about branch pruning; the contract is that the expression is required to be evaluated at compile time or else it’s an error, e.g.,
stdin:1:22-40: ERROR: Unable to resolve comptime expression.
begin { @a = 1; $b = comptime (@a == 2); }
~~~~~~~~~~~~~~~~~~
The values of bpftrace maps and variables are considered only knownable at runtime (even though more complex static analysis could probably resolve some cases) so this comptime expression can’t be evaluated.
Let’s look at how we build upon comptime.
Introspection
In order to move even the most trivial error handling into our standard library, we needed several new builtins that would allow us to introspect variables, maps, probes, and environment state. The first two examples we'll look at are fail and probetype.
fail is a function that is never executed at runtime. However, if bpftrace can verify that it’s reachable at runtime then compilation is stopped and an error is surfaced to users.
probetype is a builtin that evaluates to a string literal at compile time based on the probe where it is used. Example:
kprobe:do_nanosleep {
print(probetype); // prints "kprobe"
}
Some bpftrace functions (like signal or retval) only work for specific probe types so if we want to move the internal checks for these functions into bpftrace code itself we would put these two builtins together with comptime into something like this:
macro signal(expr) {
if comptime (probetype != "kprobe") {
fail("Signal can only be used in kprobes. Got %s", probetype);
}
}
Because probetype evaluates to a string literal and we’re comparing it against another string literal the comptime expression is valid and transforms into if (true) or if (false) at compile time. So in this program:
interval:1s { signal(1); }
The probetype is "interval" so the comptime expression is true and therefore we know fail can be reached, so it’s a compilation error. Easy peasy.
Let’s look at a few more builtins.
Since bpftrace has its own type system we needed to be able to introspect types (again, at compile time) so that we can issue errors, make branching decisions, and support limited static polymorphism, which is already part of the bpftrace language. Let’s look at another part of the signal macro.
macro signal(expr) {
let $sig = 0;
…
if comptime (is_literal(expr)) {
if comptime (is_str(expr)) {
$sig = __builtin_signal_num(expr);
} else if comptime (is_integer(expr)) {
$sig = expr;
} else {
fail("signal accepts a string literal or a positive integer");
}
}
…
}
As the fail message suggests, signal can be called with either a string literal or an integer literal, e.g. signal(1) or signal("SIGINT") (note: this is slightly different from the real signal builtin). We also see some new builtins: is_literal (hopefully self-explanatory), is_str, and is_integer. The last two are actually macros themselves that utilize another builtin typeinfo:
macro is_str(expr) {
typeinfo(expr).1 == "string"
}
macro is_integer(expr) {
typeinfo(expr).1 == "int"
}
typeinfo evaluates to a tuple, where the second item is a string literal name of the bpftrace type (SizedType). Although typeinfo is still unstable (meaning you shouldn’t use it in your own scripts yet) it is already being used in the standard library to support existing polymorphic builtins during our migration. Moving checks into bpftrace syntax helps to remove a lot of cruft and complication in bpftrace’s core and just makes everything more composable. Who knows, one-day bpftrace might be written in bpftrace! Ok, probably not.
User Value
So as a bpftrace user, why should you care about all this? For a long time now, bpftrace script portability has been a top request, e.g. "I want the same script to work across multiple kernel versions or multiple architectures". Well the above features now open the door to that. Consider the following pseudo code (as not all of this has been implemented yet):
uprobe:uprobe_test:uprobeFunction1 {
if comptime (arch == "ppc64") {
@ = usym(reg("nip"));
} else {
@ = usym(reg("ip"));
}
}
Ooooo! Now this script works on x86 and ppc64.
To re-iterate, all of this is still a work in progress and not yet released but it’s coming together fast and you should expect to be able to use these awesome features in the next bpftrace release, which will happen in early 2026.