Do we need an @assert?

From what I read I seem to understand std.debug.assert

  • can help the compiler optimizing.
  • is not at all like C asserts, but always called.
  • but can be optimized away often.

In an earlier discussion here on the forum there were some thoughts about it and Andrew even mentioned a Post Traumatic Stress Syndrome regarding asserts.

In my code I would like to give the compiler a chance to optimize using asserts, but as a simple programmer I have no clue if the assert is actually called, if it is optimized away and if it is used to optimize code!

So I often prevent explicitely an assert call by:

if (comptime lib.is_paranoid) {
    assert(something);
    assert(something_else);
}

because some of these checks are expensive debug-only checks.

In my feeling a built-in @assert would be a very nice addition to the language if it:

  • is never executed in fast releasemode.
  • can be used to optimize.

For example I have

if (comptime lib.is_paranoid) {
    assert(input_depth >= 0);
}

where I probably deprive the compiler of a chance to optimize things that are done with input_depth (and other vars or code depening on this) in the function.

Any thoughts on this?

7 Likes

I already tried to spark a conversation about it, and I also do believe a builtin assert, would be both more convenient, and more powerful, I also think it’s not fair that we can encode some of the invariants with the @branchHint() but not more invariants with an @assert() or @excpect() builtin

2 Likes

I was thinking that assertions are always removed from fast release mode, but maybe I’m wrong? Anyway you can do:


const dbg = builtin.mode == .Debug;

// elsewhere
if (dbg) assert(something);
1 Like

I think those two thing contradict each other, at least in some situations. The stdlib assert looks like this:

pub fn assert(ok: bool) void {
    if (!ok) unreachable; // assertion failure
}

This unreachable is exactly what causes the optimization in release-fast because it promises the optimizer that this code will in fact not be reached which in turn allows further optimizations (and if it actually is reached than you’ll get undefined behaviour.

In some (many? most?) cases the compiler might already be able to remove the condition because the only result of the condition is the unreachable anyway, but only when the condition has no ā€˜side effects’.

PS: I don’t really have an opinion about an @assert() builtin, but I feel like Zig already has way too many builtins. I would not change the semantics of assert though because that would cause even more confusion. Ideally stdlib.debug.assert would be called something entirely else IMHO.

8 Likes

It isn’t, and everybody coming from other languages is thoroughly confused about this :wink:

Zig’s assert is more like __builtin_assume in gcc/clang - if that would panic in debug mode when the assumption is false.

2 Likes

As someone who spends most of the time in lesser languages, I brought along the wrong mental model as well. Turns out to be kinda common.

We discussed this at length in some previous threads on Ziggit, and a couple of posts got written up, maybe you find them helpful: < Zig asserts are not C asserts | ~/cryptocode > and https://joshuao.com/posts/zig-assert-strategies.html

4 Likes

Maybe but as stated: I don’t know what the compiler can do.

Indeed there are quite an insane amount of builtins already, certainly regarding unreadable math :slight_smile:

It would be nice to have one @assert I believe, which tries to optimize.

Another example from my code is:

if (comptime lib.is_paranoid) {
    assert(us.e == self.stm.e); // very cheap, probably some magic optimization possible.
    assert(self.pos_ok()); // very expensive, optimization completely impossible and irrelevant.
}

Wouldn’t it be nice if the compiler could figure it out?

@assert(us.e == self.stm.e); // Hey compiler, try something.
@assert(self.pos_ok()); // Compiler sees there is nothing here to try.
1 Like

I think it might be more useful to have a @hasRuntimeSafety() built-in for this. You could then easily use that in conjunction with the current std.debug.assert:

std.debug.assert(!@hasRuntimeSafety() or expensiveLogic());

or even just write a section of code that only ever runs in safe modes:

if (@hasRuntimeSafety()) {
    for (blah) |b| {
        if (b.isExpensivelyWrong())
            unreachable;
    }
}
2 Likes

I’m not sure there’s many cases where that would be useful. I’m not how sure of my reasoning is but here’s what I think:

  • If it can’t be optimized out, evaluating its argument must have side effects,
  • If the assert we’re giving the compiler depends on side-effects, it can’t properly use it for optimization

There is certainly some niche cases out there (and maybe I’m wrong and there are many non-niche cases), but either the assert is useless or optimized out.

Personally, I’ve never used much assertions outside of Zig, and it’s really natural to me to not think of it as a ā€œalways-zero cost callā€, because it’s just a function.

I’d be more interested in proper documentation and some idiomatic way to ensure the assertion doesn’t depend on side effects.

3 Likes

You can just use std.debug.runtime_safety.

I thought so, but:

Deprecated because it returns the optimization mode of the standard library, when the caller probably wants to use the optimization mode of their own module.

EDIT addendum: I would also like @hasRuntimeSafety() to be based not only on @import("builtin").mode, but on @setRuntimeSafety(bool) as well.

I do this:

pub const safety_checks = builtin.mode == .Debug or builtin.mode == .ReleaseSafe;ļæ¼

1 Like

Hate it or love it, but it’s not going to change as long as @andrewrk is in charge.

The assert statement in other languages is either macro based or breaking the ā€œnormalā€ rules of those languages: you can’t pass a boolean into a function and expect the compiler to magically pretend you didn’t. Since zig doesn’t have macros and insists on having no magic regardless of how convenient it is you will find no argument that will sway him.

His logic is undeniable correct, but it fundamentally changes the functionality of an extremely established programming trope. Honestly I’d almost prefer to not have assert in zig at all. Hopefully one day I will admit he was right on this call.

3 Likes

My two cents, as TigerBeetle developer:

pub fn assert(ok: bool) void {
    if (!ok) unreachable; // assertion failure
}

Is about perfect four our use-case, and a big improvement over your typical assert macro. It’s just a function, can’t get confused about that! Having to write if (constants.verify) assert(costly()) is a positive feature in our context.

Though, I’d probably move it to std.assert rather than std.debug.assert. Having debug in there feels weird (the same goes for std.debug.panic).

27 Likes

Yes, this is the key point to understand. Because of this, assert calls should not be removed in release-fast.

Note that assert! calls are never removed in Rust (in spite of being a macro) and that has the same benefits in Rust. There are other differences with Rust assert!, but this aspect is the same.

1 Like

Do you have a systematic way to chose when to gate expensive assertions, or just based on experience / when it shows up in profiling?

I would think of @assert as a sort of @TypeOf, analyzing during compilation but not evaluating during execution the expression it’s been given. Wouldn’t that fit?

You can use comptime assert(…) for that today.

No, because the expression might depend on runtime stuff.

If it depends on runtime values, then the following cannot be true:

PS. Unlike you’re thinking the compiler can do proofs?

1 Like