Skip to content

Make signed zero treatment deterministic in min and max (and clamp) #154061

Open

Description

@RalfJung

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."

and

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

  1. added
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Mar 18, 2026
  2. xtqqczze commented on Mar 19, 2026

    @xtqqczze
    Contributor

    This would align with .NET’s behavior, where -0.0 is considered less than +0.0 in floating-point Min and Max methods.

  3. ds84182 commented on Mar 19, 2026

    @ds84182

    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.

  4. RalfJung commented on Mar 20, 2026

    @RalfJung
    MemberAuthor

    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.

  5. ds84182 commented on Mar 20, 2026

    @ds84182

    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.

  6. added a commit that references this issue on Mar 20, 2026
    8440f8d
  7. Liberxue commented on Oct 2, 2026

    @Liberxue
    Contributor

    For a f32::min loop, rustc 1.96 emits nsz on the minimumnum and vector reduction intrinsics. I took that IR, removed nsz, and compiled the two versions with the same llc (-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

    • llc here is LLVM 21.1.0, while rustc 1.96 uses a different LLVM snapshot. There is also a later LLVM change that adds another setcc + select to the minimumnum zero fixup, so these numbers may underestimate the cost with current LLVM.

    Codegen

    target nsz deterministic delta
    x86-64 (SSE2) 9 16 +7
    x86-64-v2 7 15 +8
    x86-64-v3 (AVX2) 4 14 + branch +10
    skylake-avx512 5 12 +7
    avx10.2-512 2 2 0

    f64 and max show 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:

    benchmark min median
    fold(INFINITY, f32::min) 2.473x 2.447x
    scalar chain x = x.min(y) 2.537x 2.479x
    elementwise o[i] = a[i].min(b[i]) 1.146x 1.149x
    clamp(0, 6) 0.984x 1.009x
    f64 fold 2.479x 2.374x
    f64 scalar chain 2.511x 2.441x
    f64 elementwise 0.999x 0.996x

    The main overhead is in reductions and scalar dependency chains. Elementwise loops are much less affected, while clamp is essentially unchanged.

  8. RalfJung commented on Oct 2, 2026

    @RalfJung
    MemberAuthor

    To be fair, even today I think every computation that does not use to_bits, is_sign_positive, or is_sign_negative is deterministic. It seems to be tricky to even observe the sign of zero.

    We should probably have more clear warnings on those operations.

  9. juntyr commented on Oct 2, 2026

    @juntyr
    Contributor

    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 matters

  10. RalfJung commented on Oct 2, 2026

    @RalfJung
    MemberAuthor

    Ah! That's what I was missing. Yeah this prints a random sequence of booleans in Miri, without using any "strange" values.

    fn main() {
        for _ in 0..50 {
            let posz = 1.0 / f32::MAX / f32::MAX;
            let negz = -1.0 / f32::MAX / f32::MAX;
            let m = f32::min(posz, negz);
            dbg!(1.0 / m >= 0.0);
        }
    }

    Yeah that does seem quite unfortunate if the non-determinism can "blow up" like that. The NaN non-determinism doesn't really do that.

  11. Liberxue commented on Oct 2, 2026

    @Liberxue
    Contributor

    CI runs this on a shared ubuntu-26.04 runner, LLVM 21.1.0: https://github.com/Liberxue/minmax-nsz-bench/actions/runs/36981476191

    Instruction counts match the table above, and the job asserts them: it fails if +avx10.2-512 stops being free, or if x86-64-v3 stops costing more.

    Wall-clock on the shared runner is compressed relative to the pinned Broadwell numbers, same shape:

    shape f32 f64
    reduce 1.662x 1.555x
    chain 1.684x 1.541x
    elementwise 0.993x 1.055x
    clamp 0.998x

    aarch64

    Zero cost. For f32, f64, <8 x float> and max on generic / neoverse-n1 / apple-m1, the nsz and deterministic builds emit identical instructions (4, 4, 7, 4). fminnm already returns the correctly signed zero. Asserted in CI too, so a regression fails the job.

  12. RalfJung commented on Oct 2, 2026

    @RalfJung
    MemberAuthor

    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.

  13. Liberxue commented on Oct 2, 2026

    @Liberxue
    Contributor

    Xeon E5-2686 v4, pinned core, perf stat, n=16384. Counters, per ymm:

    Detailed results
    port5 uops cycles port5 busy
    nsz 2.03 2.48 78%
    deterministic 6.04 6.23 97%

    Repo: 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.

  14. RalfJung commented on Oct 2, 2026

    @RalfJung
    MemberAuthor

    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.

  15. Liberxue commented on Oct 6, 2026

    @Liberxue
    Contributor

    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.

    Thanks, Ralf. Yeah, sorry, got a bit carried away with formatting. Edited to note the AI use. Analysis is mine.

  16. added
    T-compilerRelevant to the compiler team, which will review and decide on the PR/issue.
    T-libsRelevant to the library team, which will review and decide on the PR/issue.
    A-floating-pointArea: Floating point numbers and arithmetic
    and removed
    needs-triageThis issue may need triage. Remove when done. See docs forge.rust-lang.org/release/issue-triaging
    on Oct 29, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    A-floating-pointArea: Floating point numbers and arithmeticT-compilerRelevant to the compiler team, which will review and decide on the PR/issue.T-libsRelevant to the library team, which will review and decide on the PR/issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions