Do we need an @assert?

In this case, since unreachable is actually reachable, the programmer is at fault in the first place. It’s UB in RealeaseFast so the compiler isn’t expected to optimize it away, or anything.

A more pertinent example would be looking at the assembly of a snippet that does contain unreachable but never reach it. If the compiler isn’t able to optimize anything despite no side effects, that would be an example of a hypothetical @hasNoSideEffect being misleading.

2 Likes

This is from the official docs:

In ReleaseFast and ReleaseSmall mode, the optimizer uses the assumption that unreachable code will never be hit to perform optimizations.

It just means that the compiler assumes unreachable is never reached and can choose faster implementations based on that assumption. I’m not very knowledgeable about this stuff and I’m sure there are better examples, but consider the following code:

fn divideByPowerOfTwo(x: u32, y: u32) u32 {
    if (!isPowerOfTwo(y)) unreachable;
    return x / y;
}

Instead of doing an actual division, the compiler could do a bit shift because dividing by power of two can be represented as a bit shift and the compiler knows that y is a power of two. I’m not saying that this is an actual optimization that a compiler would make, but it’s certainly one a compiler could make and it’s only because of that unreachable there. Also note that it doesn’t matter if isPowerOfTwo has side effects or not. That’s a completely separate topic.

It’s also worth noting that C compilers do the exact same thing with undefined behavior (Undefined behavior - Wikipedia).

4 Likes

Thanks for the beautiful example! But please note that my question was rhetorical, I am familiar with such optimizations.

1 Like

AH! Thank you. This highlights a potential huge flaw in my understanding.

First, My code has two instances - in the first, the unreachable is, in fact, not reached (the comparison fails); in the second, unreachable IS reached. My interest was in whether either of these would be optimized away.

Why did I think they probably would, when compiled ReleaseFast? Well, in the absence of side-effects, I interpreted the statement “the optimizer uses the assumption that unreachable code will never be hit to perform optimizations” to mean that it indeed makes that assumption, outright. That is, it says: “well THIS will never be reached, the programmer has stated so, so there’s no need for me to actually compile in the code in the if() parens, since it doesn’t matter what the result of that code is - the programmer has surely run this in debug mode under a hundred conditions, and it’s never been met there, because presumably it would be a programming error if it got to the unreachable, so, now that we’re in release mode, let’s just elide the check and “do nothing” here, regardless - that’ll make this compilation faster than the debug one, in which that expensive loop was actually run.” Yeah… something very formal like that. :slight_smile:

Note that I agree: this is contrived to actually be ‘reachable’, but in normal if (min > max) unreachable; kinds of tests, the normal use will certainly be to expose program(mer) fault, NOT errors encountered in the running of the program. So, when all is right, excepting side-effect scenarios, under my (mis)understanding, ReleaseFast could elide tons of code liberally, under the “assumption”, and unreachable would share some of the familiarity of (other languages’) assert() concept. But this is all moot as my understanding seems to be off….

While I’m relatively new to Zig, I felt it could be useful to write out my current understanding of the unreachable keyword and related compiler behavior:

if unreachable is reached, the program is already running in an invalid state due to programmer error (this is why it uses panic instead of returning an error).

Safe release modes assume the program can get itself into an invalid state, and inserts runtime checks to make sure it exits as soon as an invalid state is detected.

Fast and small release modes mostly take you at your word that we won’t get into an invalid state. The compiler is free to optimize out any checks that don’t seem to affect the expected output of the program, and optimize other parts of the program on the assumption that we are running in a valid state.

The official list of side effects are the scenarios where it would be impossible for the compiler to know whether something changes the expected behavior of the program or not, and as such, assumes that it does.

Aside from those, it’s possible for the compiler to determine which of the following categories the conditional statement itself falls into:

  • Definitely doesn’t change the expected output, and can safely be skipped if we assume the conditional resolves the way we expect it to.
  • Might change the expected output, even if we assume the conditional resolves the way we expect it to, and is therefore unsafe to remove.
  • In a few extra contrived scenarios, the compiler may determine that unreachable is impossible not to reach.

Because of the latter two categories, the compiler may, at its own discretion, preserve the check in order to keep test and production behavior in sync.

1 Like

I think my understanding is very close to yours, with a couple of details of importance at the end. But first…

This highlights another nuance I might have very wrong. I assumed that if the compiler decided to elide if (max < min) unreachable, then it could elide the WHOLE thing, and that’s why the behavior could be undefined in ReleaseFast/Small modes, because there’s no unreachable “hit”, NO panic at that point, where it would have occurred in Debug - the code just keeps executing, but is in a bad state because somehow max < min (which will likely lead to a crash or something), which should be impossible, but, in Release, the check was skipped… because it’s Release, after all. Do I have a flaw in this layman description of what could happen?

Agreed: the “official list of side effects are the scenarios where it would be impossible for the compiler to know whether something changes the expected behavior of the program” - so there we know that there’s NO difference between Debug and Release. This is perhaps one of the only well-known, predictable scenarios.

Indeed, “at its own discretion” is what I’m looking to get confirmation on, and you seem to support it. In other words, it’s no surprise that my code might retain its potentially-expensive “check”, even in ReleaseFast, because, at its own discretion, even in the absence of side-effect code, the compiler might decide to leave that code in place. The programmer cannot know for sure. And so, back to an earlier theme, programmers should if-guard code that is truly intended for debug-only, especially if it’s doing expensive stuff. Right?

1 Like

Yes! Compiler optimization is not there to remove code you don’t want to run. It’s just there to do its magic to make your code run faster. If you certainly don’t want some code to run, then you have to exclude it.

1 Like

You are not entirely wrong, what you’re missing is optimisations are not allowed to change the result of your code.

unreachable does cause the ifs to be removed, but it doesn’t remove the call to add1 as that would change the result of your code.

Thanks each of you; I think clarity is finally at hand, especially when it comes to major takeaways. Honestly, I haven’t really “moved”, despite enhanced understanding, as I’m inclined to if-check anything significant that’s intended for debug-only, anyway, and I don’t tend to put anything but simple checks with if(..) unreachable (or, within an assert). But the conversation highlighted concerns with things like code being elided away unexpectedly, and so I became more curious about how this could happen. It seems like, regardless of side-effect-nature of code, no code will ever really be elided away except maybe the simple boolean test itself, so nobody would need to be concerned about that, and, furthermore, certainly nobody should expect that (as some might be tempted to do based on prior experience with assert() macros, for instance). unreachable facilitates more nuanced optimization (maybe like the bit-shifting rather than dividing example) which may make ReleaseFast faster or ReleaseSmall smaller, but it will not simply assume that since the unreachable branch is certainly unreachable (trusting the programmer), that therefore it can feel free to not compile the code that happened to be in the if(), at all. (No hidden magic, again, I suppose.) Again, thanks. I think the case is closed.

2 Likes

I disagree with the previous responder. You shouldn’t remove it in non-debug modes. Not because of anything to do with the check, but because you’ll get slower code in what follows the assert.

Let’s go back to the earlier example.

If you remove the asset (if ... unreachable) the compiler has no option but do the full divide instead of the bit-shift version. The operation will go from a few cycles to a few tens of cycles.

You appear to be worried that the compiler will leave an expensive check in place, even in release. The return value of the check is obviously discarded, so the only reason the check would be preserved is because the compiler is unable to prove there aren’t side-effects to whatever functions you are calling. To make it easy on the compiler, you can make sure you’re using const correctly to show that there are no side effects. Once it knows that there are no side-effects, and that the return value is discarded, it is a trivial decision to throw the call away. I suspect it may even happen at comptime as it’s extremely similar to conditional compilation which we all rely on.

3 Likes

This is exactly why I started this thread… Not knowing if the compiler will optimize or we have an ‘expensive’ check for nothing.

Oh, I certainly agree here! I would certainly NOT if-guard that if (!isPowerOfTwo(y)) unreachable; because it’s a truth statement that is always valuable. And I think that’s the theme: assert things that are meaningful “always” assertions, without regard for build mode… because that’s going to be helpful in all ways: checking the that it really is true (in debug mode) and giving hints to the compiler (in all modes). My comment was about if-guarding assertions that are MERELY in place for debug purposes, and especially if they run costly code that you don’t want run in release modes. This would be use of assert that is a little more like most think of for other languages. In zig, don’t expect a release mode to elide anything, because you really can’t know if it will (and probably shouldn’t, the more I think about it). Now, it’s possible that a programmer thinks a given assert is only valuable for program-correctness checking, and would have no other advantage, and this programmer could be wrong, and should be encouraged to NOT if-guard the given assert, but this is where it becomes a matter of experience with the language and general programming concepts. This challenge can’t be magically overcome. Even the hoped-for @hasSideEffects, though I agree it could be useful, is of more limited corner-case use, perhaps, if assert/unreachable are used with these things in mind. (That may still be debatable, but it’s pretty settled in my mind now, I think.) In my example code, I had an “expensive check” that could be easily proven (in context) to have had no impact on my “main program”, but it also didn’t have side effects, and yet it was still retained in the release code. So… it does happen. There are cases where if-guarding, either for build-mode or by some more granular scheme, will be the only way to ensure that certain expensive code is not executed. Again, you, the developer, have to decide that that’s really just code for profiling or something, and really doesn’t belong in release modes at all.

Easily half of my repos have a dbgassert function in them. It’s so quick to dash off I don’t bother finding the last one and copying it.

I also ubiquitously have something like this:

pub const is_debug = builtin.mode == .Debug;
pub const is_safe = is_debug or builtin.mode == .ReleaseSafe;

Then use these with if statements to gate anything I don’t want happening except under those conditions. This takes care of any consideration of ‘side effects’ and what have you, so I don’t see the need for an additional mechanism.

The expectation that assert will be ‘special’ in that the text between the ( and ) will be deleted in certain modes, is just a carryover from other languages. It doesn’t represent a missing facility in Zig, we just say if (comptime condition) { ... } to get the same effect[1].

Note that empirically, Ghostty has found that std.debug assert is not consistently optimized away, that link contains a workaround for the issue. The only reason I would support a @assert builtin is if it were a practical way of solving that problem and ensuring that it doesn’t recur.

Maybe? Probably not. This is a fairly minor optimization regression, I don’t see why it couldn’t be fixed without deeper changes. “Ensuring it doesn’t recur” is harder, but the stdlib could also just adopt the “Ghostty style” assert and solve it that way.


  1. I like to explicitly use comptime for this pattern, as documentation (and in case I’m wrong about the condition being comptime-known), but as used here it’s an assertion of something which would happen anyway. ↩︎

4 Likes

Different ways you can do assertions:

const std = @import("std");
const builtin = @import("builtin");
const assert = std.debug.assert;

const safe = builtin.mode == .Debug or builtin.mode == .ReleaseSafe;

/// Causes panic instead of UB in any release mode.
fn assertOrPanic(ok: bool) void {
    if (!ok) @panic("assertion failed");
}

fn maybe() bool {
    return true;
}

pub fn main() !void {
    assert(true); // optimized away if proven true
    if (safe) assert(maybe()); // maybe() called only in safe modes
    assertOrPanic(true); // better than UB on unsafe releases imho
}

At least it’s how I see it, if you want to optimize stuff you can already by gating assertions behind if (mode), but the real problem for me is that assert causes UB in unsafe modes, so sometimes not only you don’t want it to be optimized away, you want even stronger guarantees.

1 Like

Sometimes, I want a test alike keyword for debug things, like

debug {
   ... // code only run in debug mode, including assertions etc.
}

(edit: looks like a shortform of if (@import(“builtin”).mode == .Debug) {… })

1 Like

Declare an is_debug constant as shown in a reply above and do if (is_debug) {…}.

3 Likes

Finally, I’ve collected the takeaways I’ve gleaned from this (and elsewhere) into an article

https://codeberg.org/jmcaine/zig-notes/src/branch/main/assert-and-unreachable.md

This is my first attempt at such; please critique liberally (I’d be honored). I reference official doc and std doc heavily, and cite (and thank) the key semantics thread on assert, cryptocode’s article on assert in zig, joshuao’s article on assert in zig, and this thread itself.

This will be useful to me… and maybe others.

2 Likes

Reacting on the first line of the article:

“Embrace unreachable” is in “hard defined cases” a good thing to do yes.

But in certain situations it slows down. Don’t know why. For example I originally had something like:

io.interface.print(...) catch unreachable.

When I changed it into

io.interface.print(...) catch @panic().

it was faster (quite a lot).
Sidenote: I use that ‘pattern’ to not have to make all my functions returning !void.

that is odd, in safe modes there should be little difference, and in unsafe modes it should be slightly faster.

You should, not necessarilly right now ofc, investigate this or at least make an issue.

True, it is strange. I had it confirmed by another zig-chess programmer.
In the wild-west-code of our chessprograms we both noticed a slowdown. Don’t know if I can reproduce a wild-west example :slight_smile: