Repository navigation
Description
Currently, the behavior of min and max (and clamp) is non-deterministic when signed zeros are involved. Non-determinism can be a significant footgun when it is unexpected as it often (and in particular in this case) means that behavior depends on the target and/or optimization level.
So, it seem worth considering changing that. In the words of @jyknight
I would strongly suggest that Rust should tweak the specification of these functions to eliminate the non-determinism in the sign of zero. IMHO, that was a historical mistake in C's fmin/fmax which nobody else should be copying.
The sign of zero is important for various mathematical floating-point algorithms, and having non-determinism in its sign, especially from a fundamental operation, is just weird. I don't think it can be justified other than compatibility with existing implementations. Even the C standard says these days, albeit in a non-binding footnote, "If possible, fmax is sensitive to the sign of zero, for example fmax(−0.0, +0.0) ideally returns +0."
The reason why the sign of zero is important, generally, in IEEE floating-point math is that the values are not Real numbers. So, -0 may represent "a value close to but less than zero", vs +0 may represent "a value close to but greater than zero". The distinction is useful, then, for operations that are discontiguous at zero, such as f(x) = 1/x, where in floating-point math,f(0) = Inf and f(-0) = -Inf. [...]
It's even more important in complex math, when dealing with branch cuts for a multi-valued operation such as sqrt. It turns out to be rather irritating if Complex::new(-4.0, -5.0e-324).sqrt(); ends up in a different quadrant of the complex plane than Complex::new(-4.0, -5.0e-324 / 2.0).sqrt(); (which is what would happen if the division in the latter rounded to 0.0 instead of -0.0). The widely-cited paper on all of this is William Kahan's "Branch cuts for complex elementary functions OR Much Ado About Nothing's Sign Bit".
The main reason why the treatment of signed zeros is left non-deterministic is to improve codegen on x86, which has no native support for float min/max (except for recent extensions that aren't sufficiently widely available yet). My understanding is that guaranteeing -0.0 < +0.0 for the purpose of min/max would require a few extra instructions compared to the current lowering, and t-libs-api considers that cost too high (but I haven't seen that cost actually measured). Personally, I think we should generally err on the side of defaulting to predictable portable behavior, but the performance hit of that choice needs to be tolerable (and the threshold for that is obviously subjective).
So... I think to make a case towards t-libs-api that this behavior is worth changing, someone needs to do some hacking and benchmarking to quantify the actual performance loss that reliable signed zero handling would incur on x86. I don't plan to do any follow-up work here myself, this is far outside my expertise anyway. I leave this issue as a place to track any further efforts people might want to undertake in tweaking this particular aspect of Rust. :)
Activity
xtqqczze commentedon Mar 19, 2026
This would align with .NET’s behavior, where -0.0 is considered less than +0.0 in floating-point
MinandMaxmethods.ds84182 commentedon Mar 19, 2026
Whoever writes and runs the x86 benchmarks should bench on older hardware, since the change would be more impactful on them than a recent Intel and AMD CPU.
RalfJung commentedon Mar 20, 2026
That depends on how old "older" is. Personally I don't think the performance characteristic of 10+ year old hardware is very relevant. 🤷 Anyway this is mostly a moot point until someone shows up who is willing and able to do this work.
ds84182 commentedon Mar 20, 2026
Well I do have two old netbooks (Intel Celerons, iirc) from ~2010 I can test with. But I'll have to purchase (or make) a power cable.
Enforce deterministic signed zero behavior in min/max/clamp
Liberxue commentedon Oct 2, 2026
For a
f32::minloop, rustc 1.96 emitsnszon theminimumnumand vector reduction intrinsics. I took that IR, removednsz, and compiled the two versions with the samellc(-mcpu=x86-64-v3 -O3). Both were then linked into the same Rust benchmark.So this is an A/B on the LLVM IR rather than a source-level approximation.
Benchmark example: minmax-nsz-bench
llchere is LLVM 21.1.0, while rustc 1.96 uses a different LLVM snapshot. There is also a later LLVM change that adds anothersetcc+selectto theminimumnumzero fixup, so these numbers may underestimate the cost with current LLVM.Codegen
f64andmaxshow the same pattern.For
<8 x float>, the vector form is 4 → 6 instructions on AVX2, 5 → 8 on Skylake-AVX512, and 2 → 2 on AVX10.2.Wall-clock
On the Broadwell machine, pinned to one core,
n=16384(L2-resident), 21 runs:fold(INFINITY, f32::min)x = x.min(y)o[i] = a[i].min(b[i])clamp(0, 6)f64foldf64scalar chainf64elementwiseThe main overhead is in reductions and scalar dependency chains. Elementwise loops are much less affected, while
clampis essentially unchanged.RalfJung commentedon Oct 2, 2026
To be fair, even today I think every computation that does not use
to_bits,is_sign_positive, oris_sign_negativeis deterministic. It seems to be tricky to even observe the sign of zero.We should probably have more clear warnings on those operations.
juntyr commentedon Oct 2, 2026
You can unfortunately also observe it via
1/x, which turns the mostly irrelevant difference between +-0 into +-Inf. I am cursed by having a use case where this mattersRalfJung commentedon Oct 2, 2026
Ah! That's what I was missing. Yeah this prints a random sequence of booleans in Miri, without using any "strange" values.
Yeah that does seem quite unfortunate if the non-determinism can "blow up" like that. The NaN non-determinism doesn't really do that.
Liberxue commentedon Oct 2, 2026
CI runs this on a shared
ubuntu-26.04runner, LLVM 21.1.0: https://github.com/Liberxue/minmax-nsz-bench/actions/runs/36981476191Instruction counts match the table above, and the job asserts them: it fails if
+avx10.2-512stops being free, or ifx86-64-v3stops costing more.Wall-clock on the shared runner is compressed relative to the pinned Broadwell numbers, same shape:
clampaarch64
Zero cost. For
f32,f64,<8 x float>andmaxongeneric/neoverse-n1/apple-m1, the nsz and deterministic builds emit identical instructions (4, 4, 7, 4).fminnmalready returns the correctly signed zero. Asserted in CI too, so a regression fails the job.RalfJung commentedon Oct 2, 2026
Wow so on pre-AVX10 x86 this more than doubles the number of instructions? Uff. Is that really the ideal instruction sequence?
That said, instruction count and actual execution time are not the same thing, so some cycle count or wall time measurements would also be good to have.
Liberxue commentedon Oct 2, 2026
Xeon E5-2686 v4, pinned core,
perf stat, n=16384. Counters, perymm:Detailed results
nszRepo: https://github.com/Liberxue/minmax-nsz-bench
Note: I used an LLM to help format this comment. The benchmarks, analysis, and conclusions are my own.
RalfJung commentedon Oct 2, 2026
Just a reminder that we have an AI policy. Text you post in issues is expected to be written by you (with all claims being based on your own expertise), anything AI-generated should be clearly separated and hidden by default.
If AI was not involved in your exploration, please disregard this message.
Liberxue commentedon Oct 6, 2026
Thanks, Ralf. Yeah, sorry, got a bit carried away with formatting. Edited to note the AI use. Analysis is mine.