Repository navigation
Proposal: Make floats non-NaN by default #11234
Description
Introduction
The idea behind this proposal comes from observing that NaN has some surprising commonalities with null pointers:
- It represents an invalid value using a specialized bit sequence
- Some functions expect to receive this invalid value, others assume they do not (for optimal performance)
In combination with the arithmetic/comparison behavior of NaNs, these troubles lead to a number of footguns in real-life code.
Examples include sorting algorithms failing for NaN data, every NaN colliding in a hash map, NaNs propagating virally in streaming outputs, invalid image filtering when operating on NaNs, nan values persisting after filtering, parsers failing on NaN inputs, and formatting/display unintentionally exposing NaN to the user.
Footguns abound when there is disagreement about whether NaN needs to be handled correctly.
Proposal
Option A: Replace f32 with error{NaN}!f32
- All floating point operations (
+-*/%) yielderror{NaN}!f32 - Arithmetic is overloaded on
error{NaN}!f32 error{NaN}!f32can be unwrapped withtry,catch, andiflike any other error union- Comparison of
f32yieldsbool. Comparison oferror{NaN}!f32yieldserror{NaNOperand}!bool
Other error unions, such as error{Foo}!f32 are not treated specially (no arithmetic, no special layout, etc.).
"NaN-boxing" is to be supported via getNaNPayload and setNaNPayload
Option B: Make comparisons of floats return error{NaNOperand}!bool
This is a minimal change to the language that would force users to explicitly account for NaN in floating point comparisons, which is the central oversight in above-mentioned bugs.
API Impacts
This means that "nan-safe" functions can be given a type that reflects their special handling of NaN. Meanwhile, highly-optimized routines that don't handle NaN correctly can be given a type that reflects their assumptions:
// Returns median, ignoring any NaN values
pub fn median(in: []const error{NaN}!f32) f32 { ... }
// Assumes that inputs do not include NaN
pub fn convolve(a: []const f32, b: []const f32, out: []f32) { ... }Example
/// Insertion sort. NaN values are sorted to the end
fn sort_inplace(vec: []error{NaN}!f32) void {
for (vec) |maybe_key, i| {
if (maybe_key) |key| { // If maybe_key is NaN, treat it as greater than everything (i.e. don't move it)
var j = i;
// `error{NaN}!f32` forces us to explicitly handle the NaN case here
while ((vec[j - 1] > key) catch true) { // If vec[j - 1] is NaN, treat it as greater
vec[j] = vec[j - 1];
j = j - 1;
if (j == 0) break;
}
vec[j] = key;
}
}
}Meanwhile, the code for a non-NaN-safe version of this function would look exactly like it does today.
Supplemental Ideas
These related ideas can be accepted/rejected separately from the main proposal:
-
Size Optimization for
?f32: Define?f32to be stored in a typical float, by assigning it a NaN payload with a special value. This is similar to R's "NA" value, except that?f32would not support arithmetic or comparison (except withnull), meaning that NA/NaN propagation is not an issue. It behaves like any other optional. -
@assertFinite/@assertNonNaNbuilt-ins: The UB-introducing@setFloatMode(.Optimize)assumptions are that inputs/outputs are non-Inf and non-NaN. All other fast-math optimization flags make a different performance/accuracy trade-off, but do not directly introducepoison/undefinedinto the program.@assertFinitewould allow the programmer to make these dangerous assumptions explicit in their code where it's obvious exactly which operands it affects.
(1) can be particularly important for performance when operating on large, structured data, since it affects how many values can fit into a cache line. This is why it's common to see in statistical software, including R and Pandas.
Edit: Updated 4/11 to use error{NaN}!f32 instead of ?f32 + add supplemental ideas
Activity
andrewrk commentedon Mar 19, 2022
Can you address the following operations of IEEE 754 floats?
-Infinity + Infinityproduces NaNInfinity - Infinityproduces NaNInfinity * 0produces NaN0 / 0produces NaN(Thanks @rtfeldman for pointing these out to me.) How do these operations fit into your vision?
It looks like you are saying:
What might that look like in machine code for each of these operations? Can we estimate whether such safety checks in debug builds will be reasonable, as they are with overflow arithmetic, or whether they might be debilitatingly slow?
mrakh commentedon Mar 20, 2022
One possibility would be to always return an optional float for all floating-point operations, whether the operands themselves are optional or not. Then, a coercion from
?fNtofNcould invoke safety-checked undefined behavior when the value is NaN.x64 and arm64 will appropriately set a flag register if a floating-point compare operand is NaN. From there, you should be able to conditionally branch on that flag, for a total of two instructions for a NaN check. It looks like x64's
ucomisshas ~7 clock cycles of latency on modern AMD chips and ~3 on modern Intel chips, and that arm64'sfcmphas 2 clock cycles of latency on the Apple M1 Firestorm. My guess is that Broadcom ARM chips will have similar latencies.MasonRemaley commentedon Mar 20, 2022
I like the idea of encoding NaNs in the type system!
I have a reservation to do with UB--in something very dynamic like a physics engine for a game, it's very tough to be 100% certain that there's no way to push the system past its limits and get a NaN (likely by first getting an infinity.) Due to how NaNs propagate, this kind of glitch tends to be contained to a single object in the game, but if getting a NaN unexpectedly is UB then it becomes a much more serious concern.
I'd propose having debug builds check
fXs for NaNs, but release builds treatfXand?fXidentically.topolarity commentedon Mar 20, 2022
Great question.
My original thinking was that "responsible code" would check for these cases pre-emptively. However, I'm starting to think panics upon producing NaN is the wrong way to go.
Here's why: It can be more performant to propagate invalid errors throughout a computation and check the result at the end, rather than to pre-emptively avoid generating invalid values. For example:
According to the original proposal, this would panic if vec contained an "Infinity" followed by "-Infinity". Adding an
if (...)inside the hot loop to avoid generating a NaN would impact performance for the well-behaved case. But leaving it out means allowing NaN to leak into non-NaNf32values. The user is also too unlikely to hit this panic in the first place.The right choice is to make accum an
?f32and then handle the invalid value at the end (or return the?f32directly), and I believe this is the pattern intended by IEEE 754.Amendment
I'd like to amend the proposal so that all floating point operations return
?f32, as @mrakh suggested. For ergonomics, we'd also want to consider supporting the standard comparison operators on?f32, which would return a?bool(null when either argument is null). Finally, conversion from?f32tof32should be explicit, like any other optional.This would achieve what the original proposal intended: it prevents invalid comparisons with NaN and unintentional storage of NaN, which are the primary footguns responsible for the unexpected behavior mentioned in the introduction.
Finally, it's still worth adding safety panics upon generating NaN or Inf in blocks with
@setFloatMode(.Optimized), since this mode implies-ffinite-math-only. This can be done by checking the FPU status bits as described above, or alternatively by enabling traps for floating point exceptions (these are typically masked by default)topolarity commentedon Mar 20, 2022
@MasonRemaley I believe your concern is addressed by the latest amendment, since floating point optionals would have the same safety checks as other optionals in Zig (only
.?panics and only in safe builds) - Let me know if you have any concerns for the new designtopolarity commentedon Mar 21, 2022
Another thought occurred to me:
.?coercions can be used to mark operations as nnan for LLVM optimizations, indicating that the operation can be assumed not to yield NaN. If we add another built-in@assertFinite(?f32) f32, this can be used to mark operations as bothninfandnnanin Release builds.For example:
could be lowered to the following LLVM IR:
With these assumptions made explicit,
@setFloatModecould be used just to enable/disable the other fast-math flags, which do not make assumptions about the range of operands or results or introduce LLVMpoisonto the program.Existing
-ffast-math-like optimization would be achieved by liberal usage of@assertFiniteand@setFloatMode(.Optimized)SpexGuy commentedon Mar 21, 2022
I think encoding nan-ability in the type system is an interesting idea, but making it the null value of an optional feels very wrong to me. For one, there are many NaN values, and IEEE 754 defines rules for how the bits which can vary propagate through operations. This can be used in a technique called "NaN packing" or "NaN boxing" to associate extra data with NaN values, which can be used to indicate where they were first discovered or other attributes. Additionally, many CPUs designate one of these bits to indicate "signaling NaN" values, which will generate a CPU exception when used. None of that fits into this model.
So that would instead leave us with the
fXXfamily of IEEE 754 floats, and an additional family ofeXXorxXX"fast finite floats". Those would obey the following rules:eXXvalues cannot take on NaNeXXvalue is created from a representation that would give it a non-finite value, it instead becomesundefinedeXXcoerces tofXXfXXcan be converted toeXXwith the@assertFinitebuiltinrohlem commentedon Mar 21, 2022
I think the original post gives good arguments why
NaNshould be considered in function signatures.I also think we should consider the full potential scope of this.
Maybe obvious/unnecessary, but to distinguish floating-point number types, here's everything one _could_ expect from them:
I think it's reasonable to understand a floating-point number as a union of these states (let me know if there's more still). Constructing and destructuring could be provided by a library, though since floats are language-provided,
stdor compiler builtins would also be reasonable places imo.Edit: Adding this paragraph because I'm unsure whether my main point came across well enough:
I'd advocate to have distinct floating-point types that:
If we expose all of these combinations of options in
std.builtin.TypeInfo.Float(for querying via@typeInfoand construction via@Type), users can already make full use of them - as arguments to basic operators, and for asserting properties via@floatCastto the respective type. (->std.meta/math.assertFiniteis implementable via@floatCast.)Using
NaNas anull-encoding is an opportune size optimization. If the compiler understands floating-point types well enough, any unused bit pattern is fit for this purpose: If it is always a number, useNaNrepresentation, if it is always finite but may beNaN, use an infinity, etc. .Except for ergonomics, the first half would already make the language feature-complete in this regard.
If that is an acceptable design, the remaining questions are:
fXX-family?rXX/eXX)+,-,*,/)+%,+|)(my thoughts)
+/- 0, andNaNwith payload.(Maybe obvious, I don't like the idea of changing this depending on target platform.)
const r32 = std.math.FiniteRealNumber(32);is also readable, so it's really about which are fundamentally important enough to reserve letter-prefix syntax for.Note that it does help discoverability a bit, but then again this is a rather advanced topic already.
+/- inforNaNin some cases, so evena/bin finite numbers needs to either yield a "full" float, or imply an assertion.+|for saturating finite float addition - if we want to support that - would be perfectly intuitive imo.+<would signal assert finite, as in "has a limit"?(A bit iffy with single-letter-identifiers, but at least it would be a parsing error if you forget or add a space.)
+rfor "real" (non-NaN and finite)+ffor "finite"+nfor "numerical / a number -> non-NaN" (not 100% sure, but wiktionary defines infinity to be a number)topolarity commentedon Mar 22, 2022
I appreciate the detailed thoughts! Your alternative sounds a lot like tracking general bounds for floats (similar to what was proposed for integers in #3806), and I absolutely agree that would solve this problem and potentially bring other benefits, if the ergonomic and other challenges involved can be resolved 🙂
FWIW, Inf-able and NaN-able are the subsets critical for safety/optimization. These are the only subsets that interact
-ffast-mathto introduce undefined behavior, and NaN is the only value which is generally dangerous/incorrect to allow in comparisons (><=!)gwenzek commentedon Mar 22, 2022
So type systems help prevent bugs.
What kind of bug is this proposal preventing?
Looking at an example average function:
The first implementation can overflow while the second can't. I think your proposition will force me to add explicit Nan checking to both implementation but doesn't help me write the correct implementation.
rohlem commentedon Mar 22, 2022
@gwenzek I think "[helping someone] write the correct implementation" is a rather general statement of rather high expectations. Does an easy way to add an assertion meet your criterion?
The original post proposes arithmetic operators to be overloaded for
?f32, meaning you need no checks around operations where you want to accept NaN as arguments and results, as you would in status-quo.You would only check to convert from NaN-able
?f32to non-NaN-ablef32.Your particular example is about overflow though, which doesn't intersect with the original proposal at all.
If you use my comment above as basis, since overflow results in positive infinity, this would be your transformed example:
If we added an operator family, say
+<,-<,*<,/<to represent "float arithmetic that asserts results to be finite", then you could write it as:Edit: Also note that while
avg1may overflow,avg2is more likely to involve subnormal numbers (a performance penalty), and truncate precision of smallest-possible epsilon numbers towards 0. Neither approach is perfect for all scenarios.InKryption commentedon Mar 22, 2022
(Tried stitching together some thoughts into a coherent set of paragraphs, apologies if this comes across like rambling, feel free to dismiss it if you believe it lacks substance).
I think this proposal follows a trend I've seen in a couple of proposals that tend to run counter to the actual momentum of zig's design, in particular #7512 which, while not rejected, is pretty barren in terms of discussion, and has a comment under it by Andrew stating that it is unlikely to be accepted.
The actual direction zig seems to be going in doesn't necessarily reject the introduction of new types for particular use cases, but it appears to have a preference for the addition of new operations on existing types to achieve particular semantics (wrapping and saturating arithmetic operators,
@divFloorvs@divTruncvs@divExact). This thought process even goes as far for it to apparently be reasonable to propose removing volatile from the type system, replacing it special operations.This is all to say, I think that it would make more sense to add operators/builtins that allow one to produce the desired behavior, as is suggested by @rohlem, rather than overloading the usage of
?fXXor adding new type variants offXXwith specific behaviors.I would loosely draw a comparison between this, and the usages of RAII (Rust, C++, ...) vs
defer(Zig, Go, ...): where the former requires a program's state to operate and evolve in terms of its types (and the operations that are associated with them), the latter allows a program's state to operate almost exclusively in terms of the desired behaviors. Not saying that either is better or worse, but I would argue that the latter is quite a bit more aligned with the philosophy of Zig.topolarity commentedon Mar 22, 2022
Comparison with nan is the bug I'm trying to avoid:
When given "{ 1.5, 0.5, 3.5, nan, 0.25, 6.0, 1.5 }" this returns "{ 0.5, 1.5, 3.5, nan, 0.25, 1.5, 6 }"
Under this proposal, the function signature of
sort_inplacealready makes it clear that this function is not written to handle NaN correctly. If you wanted it to handle NaN you'd write it like this.This also intends to improve support for generic implementations.
A function that accepts
?Twill generally correctly handle?f32too. This extends to hash maps:AutoHashMapcurrently refuses to acceptf32as a key, because it might be NaN. But with this change, it can supportf32and even?f32correctly.3 remaining items
ominitay commentedon Mar 23, 2022
Uhh, doesn't changing floats from pretending to be an optional to pretending to be an error union undermine your key advantage here of improved compatibility with generics?
topolarity commentedon Mar 23, 2022
Hashmap would work just fine, for example. Can you think of an example that's broken?
ominitay commentedon Mar 23, 2022
Oh,,, I think I see what you mean now
topolarity commentedon Apr 11, 2022
Updated proposal to use
error{NaN}!fXinstead of?fX.Also factored out two ideas that don't depend on the main proposal:
@assertFinite/@assertNonNaNbuilt-ins (for explicit fastmath assumptions)?f32encoded using NaN payloads, as a size/performance optimizationdvmason commentedon Aug 30, 2022
I'm using NaN boxing in a (dynamic) language runtime I'm building, but I wouldn't let the floating-point unit get ahold of any NaN values that I use to encode other things, so I don't think this proposal has any impact on NaN boxing.... or at least I can't see how it would.
andrewrk commentedon Apr 24, 2026
Consolidating into #23173
@setFloatMode#23173