Division by zero and Illegal Behavior

I want to do some numerical calculations (probably using f64, but maybe I’ll be generic later). Some of my calculation may be subject to numerical instability, and I might run into

  • division by zero
  • square root of negative number
  • sine of infinity
  • logarithm of zero
  • logarithm of negative number

Now reading the Zig documentation on Illegal Behavior, I read that

Division by Zero

is illegal behavior. I also read that:

Some Illegal Behavior is safety-checked: […] All other Illegal Behavior is unchecked, […]

Some questions:

  1. So is division by zero safety-checked or not? And how can I know?
  2. Is division by zero also illegal behavior for floating point numbers, or just for integers?
  3. If yes, does floating point division-by-zero differ from integer division-by-zero with regard to safety checking?
  4. Are there differences between comptime and runtime?

Experiments with zig version 0.15.0-dev.936+fc2c1883b on FreeBSD, all in Debug mode:

std.debug.print("{}\n", .{1.0 / 0.0});
 ⇒ error: division by zero here causes illegal behavior

But:

for (0..3) |i| {
    const f: f64 = 1 - @as(f64, @floatFromInt(i));
    std.debug.print("{}\n", .{1.0 / f});
}

Results in:

1e0
inf
-1e0

Note that the documentation of @setFloatMode says:

@setFloatMode(comptime mode: FloatMode) void

Changes the current scope’s rules about how floating point operations are defined.

  • Strict (default) - Floating point operations follow strict IEEE compliance.
  • […]

Now IEEE says that 1.0/0.0 is inf by default, right? (But maybe IEEE allows optional exceptions?)


Also I wonder: Disregarding what Zig does now, would it be good to treat floating point division-by-zero as Illegal Behavior?

If yes, then what about:

  • sqrt(-0.00001)
  • ln(0)
  • ln(-1)

And side question: Is there a reason why there is no natural logarithm (of a number x, not of 1+x or gamma(x)) in std.math?

Division by 0 is well-defined with floats. It produces either inf or -inf. It’s illegal for integers, not floats.

Not a Zig thing, IEEE 754 defines this.

If that is true, I’d be happy.

Just still wondering about:

And also, I think the documentation could maybe a bit more clear that with “division by zero”, only integer division is meant.

Edit: Apparently @as(f64, 1.0) / @as(f64, 0.0) gives inf, so maybe that’s a comptime_float thing?

I would call that a bug, personally. those are comptime_float, but I would argue they should behave like other floats in this matter.

They are comptime_floats. I would consider it a bug according to the language reference:

Float literals have type comptime_float which is guaranteed to have the same precision and operations of the largest other floating point type, which is f128.

because f128 should have defined behavior for division by zero according to IEEE 754.

1 Like

5 posts were split to a new topic: Comptime float internals

Most of your questions are answered in the same document you linked. https://ziglang.org/documentation/0.14.1/#Division-by-Zero has example program outputs that shows what happens when you divide by zero at comptime or at runtime in safety-checked builds, and the operator table lets you know that dividing floats by zero under @setFloatMode(.optimized) is illegal behavior:

Name

Syntax

Types

Remarks

Example

Division

a / b
a /= b

10 / 5 == 2

What’s not mentioned in the docs is that you’re not allowed to divide comptime_float by 0 and that this is always a compile error. comptime_float has the same precision as f128 but it’s only intended for storing finite values. You can currently store infinity/NaN inside a comptime_float with some trickery but there is an accepted proposal to disallow this.

Also relevant: @setFloatMode(.optimized) will be replaced by a set of new float types which are distinct from IEEE 754 floats like f32 and which will only allow finite values (which means that dividing values of those types by zero will be illegal behavior).

“ln” is std.math.log(). There’s also @log(), @log2() and @log10().

The behavior for most math functions is specified by IEEE 754. sqrt(-1) is NaN, log(0) is -inf and log(-1) is NaN. For the new float types I mentioned above I would assume that all non-finite results will be illegal behavior.

3 Likes

If your logic depends on float values, then it is a programming error.
If your calculations are numerically unstable, you are doomed anyway, even if their was a lossless representation of all real numbers in computers, because the unavoidable input errors lead to huge output errors.

So I don’t quite see the OP’s point.

What do you mean with “logic” in this context?

I would like to note that “Illegal Behavior” and “getting NaN” as result are quite different levels of “doom”.

The first can lead to “Undefined Behavior” in ReleaseFast mode, while the second can be caught later and displayed to the user accordingly.

2 Likes

Logic: If decisions depend on “exact” values or on NaN.
Say, you compute 1/(b-c).
The input values b and c include small measurement errors.
If they are close enough to each other, you divide by a close to zero, with a maximum error that is greater than the absolute value of a, so you don’t even know if the sign is the same as the exact value’s sign.

The result of the division might be any of division by zero error or a huge positive or negative value.

Anyway, it is not usable in a reasonable way, and the division by zero error is just a very unlikely special case. The only thing you could do is to tell the user when that’s the case. But for this, you must not rely just on the zero division error.

Comparison with infinite values is fine though, and I find them super useful for defining open ranges or as initial values for scanning a list for the min/max. The fact that inf*n == {inf,0,-inf} is also useful.

For example, consider finding the intersect between two lines: you might actually see a division by zero give you inf which could be what you actually want. It depends on context, it’s not obviously wrong or right.

Compile time floating point operations have been held up for years in Rust at this point because it is actually quite complex. Even having a plan and thinking about it at this point Zig’s development is great and encouraging.

I used NaN once in a Java program to describe missing values in a chart. However, I’m not happy with this, cause it’s a dirty hack.
In Zig, I’d rather user optionals for this and for the missing bounds of half-open intervals.

I only found examples for integer division (which result in checked Illegal Behavior, as I understand now). This is what also got me confused, because as I learned here, floating point division-by-zero is not Illegal Behavior.

Now that is interesting, but contradictory to what I find under documentation of @setFloatMode:

Optimizations are required to retain legal behavior over [NaNs / +/-Inf], but the value of the result is undefined.

To me, this reads as: If NaN or infinite values occur, then this is legal behavior, just the result may be undefined. This is somewhat contradictory to what’s in the table you cited:

As here, I would interpret “Division by Zero” as Illegal Behavior, while documentation for @setFloatMode explicitly states that it is “legal behavior”.

Do you also see the contradiction of the documentation, or did I miss or misinterpret something? What I’m not sure about is what “the result is undefined” is supposed to mean. Does it mean the result can be some arbitrary value, or is it like undefined memory. If it’s the latter, then does it mean the calculation is legal but accessing the result is then Illegal Behavior?

So for me, there are even more open questions now than before. :man_shrugging:

Here it seems to be indeed illegal behavior. But not sure about @setFloatMode(.optimized), as elaborated above.

Like I said earlier, I find “wrong results” (whether they are NaN or simply “undefined results” in the meaning of arbitrary values as result) much easier to handle than actual Illegal Behavior, especially considering that most numerical calculations MAY contain numerical surprises.

I would also like to emphasize that Illegal Behavior would, unless in Debug or ReleaseSafe mode, cause undefined behavior, which can basically result in anything (including time travel :innocent:). So I’m not really sure if having floating point arithmetic with Illegal Behavior is a good idea (but maybe it opens up optimization opportunities that are worth it).

Under some rare conditions, checking floating point numbers for an exact number can make sense. For example, integer numbers are represented precisely in f64 up to 253. Also, assuming a division-by-zero was Illegal Behavior, then I could compare with 0.0 to avoid that division.

I think there may also be contexts where you want to have something like if (isNan(x)) return NaN. Or (if you want to return even before): if (x == 0.0) return null; y /= x;

Side question: How do I even return NaN? I didn’t find it in std.math. And as I learned here, comptime_float doesn’t support division by zero.

Yes, but I could, for example, check if abs(1/(b-c)) < 1e10.

Not according to IEEE defaults. Division by zero is either positive or negative infinity or NaN (depending on the divident).

But to do that, I have to exactly do this: Check if a number is zero (or non-finite, after the operation).

Maybe you should discuss this with a numerics expert (disclaimer : I studied math, but that was > 30 years ago).
Or you give us a bit more background why you want to perform numerically unstable calculations, because those must be strictly avoided if the result needs to be meaningful.
And this actually independent of the programming language, or if your float implementation is strictly IEEE conforming, or if you use f64 or f256 or whatever.
If you want exact answers like “does a solution exist at all?”, use a computer algebra system.
If you want to calculate with integers, use integer data types.
If you want numeric calculations, assure beforehand that the calculations are numerically stable and that you know the maximum error depending on the input values and their errors.

Regarding my division example:
Maybe I should be more precise (pun intended):
I assume that b and c are measures for the unknown exact values b’ and c’, with an absolute error e, so abs(b-b’)<e , and abs(c-c’)<e.
Now if eg abs(b-c)<e, then for the exact difference b’-c’ we know that abs(b’-c’)<3e (I’m tired, forgive me when I’m wrong), but we don’t even know the sign of it, it could differ from the sign of b-c.
And thus if we divide by it, the result is meaningless, because the exactresult could be any real number, or undefined if the exact difference is 0.
This is all independent of programming languages.

Well, sometimes this depends on user input, and you don’t want to explicitly check all possible cases.

That said, I do acknowledge the importance of numerical stability and issues with floating point arithmetic in this context.

Just, to me, it’s still more important that my program doesn’t exhibit Undefined Behavior, rather than returning correct results.

I am not convinced that numerical stability is always important, for example sometimes floating point numbers are just used to render things and it either looks right or it doesn’t, as long as the numerically questionable operations don’t screw up the output in a catastrophic way it doesn’t technically matter (for example if they happen outside the camera view).

I think being able to reason about whether some computation is guaranteed to be numerically stable is a good skill set to have (I currently don’t have experience with that) and it probably can help with delivering correct results for certain problems (or maybe even essential for some problems), but I think it isn’t always something that is necessary to get useful results.

So basically I would say numerical stability is only important if it helps solve the problem.

I agree the documentation could be more clear, but

Can cause Division by Zero for floats in FloatMode.Optimized Mode.

and

[FloatMode.Optimized] Optimizations are required to retain legal behavior over [NaNs / +/-Inf], but the value of the result is undefined.

don’t mean the same thing. What they mean is that under @setfloatMode(.optimized), float division by zero is illegal behavior, but operations on NaN and infinity values obtained through other means are legal. In other words,

pub fn main() !void {
    @setFloatMode(.optimized);
    var a: f32, var b: f32 = .{ 2, 0 };
    const c = a / b;
    _ = .{ &a, &b, c };
}

will panic with panic: division by zero, but

pub fn main() !void {
    @setFloatMode(.optimized);
    var a: f32, var b: f32 = .{ 2, @import("std").math.nan(f32) };
    const c = a / b;
    _ = .{ &a, &b, c };
}

is legal.

You’re correct that the proposed real32 float types are more strict than @setFloatMode(.optimized), because those types do forbid even storing NaN/infinity values.

Are you sure? To me, that wouldn’t make much sense. Afterall it also says, “Assume the arguments and result are not NaN.”

*confused*

The point of @setFloatMode(.optimized) (or the -ffast-math compiler flag) is to inform the compiler that it is allowed to transform floating point operations in whichever ways it thinks will result in the best computational performance for the target. This could mean that something like 2.0 * NaN might result in NaN on one target where NaNs are as fast as finite values, but 0.0 on another where computations on NaNs are slow (theoretically, I don’t actually know if there are any targets where this is true).

Recommended reading: Optimizations enabled by -ffast-math – Krister Walfridsson's blog – Compilers, programming languages, etc.

  • x-x cannot be optimized to 0.0 because that is not true when x is NaN or Inf.
  • x*0.0 cannot be optimized to 0.0 because that is not true when x is NaN or Inf.

The point here is that the presence of NaN/±infinity prevents certain obvious transformations. By acting under the assumption that NaNs/infinites will never occur, the compiler is allowed to make those transformations.

1 Like