fsharp icon indicating copy to clipboard operation
fsharp copied to clipboard

[Draft] Fix #5647, difference in max/min in debug with floats and nans

Open abelbraaksma opened this issue 3 years ago • 8 comments

Fixes #5647

As mentioned in the issue, the Core min and max functions behave incorrect when used with float/float32 types in relation to nan/nanf. The result should always be nan/nanf, but this isn't the case in Debug mode, because optimizations aren't applied and instead, the default comparer is being used (that is, the line if e1 < e2 then e2 else e1 from the implementation is executed, as opposed to the specialized paths).

Since I can't run the tests at the moment (see #13557), I use CI to check if the tests properly fail indeed.

EDIT: this is a precursor to #13207, which deals with a slightly different bug related to Seg.min/max etc.

abelbraaksma avatar Jul 24 '22 15:07 abelbraaksma

Weird errors in fsharpqa build:

setup\Swix\Microsoft.FSharp.SDK\Microsoft.FSharp.SDK.csproj(0,0): error NU1301: (NETCORE_ENGINEERING_TELEMETRY=Restore) Failed to verify the root directory of local source 'https://pkgs.dev.azure.com/dnceng/public/_packaging/dotnet5-transport/nuget/v3/index.json'.

abelbraaksma avatar Jul 24 '22 15:07 abelbraaksma

You should also validate the behavior against negative numbers (e.g -1.0) and positive nan (well technically a nan with the inverse sign of Double.NaN/Single.NaN, but those are typically a negative NaN).

tannergooding avatar Jul 24 '22 16:07 tannergooding

@tannergooding, tx, I’ll do that. This will ultimately use Math.Min, for which I’ve seen that it does this correctly already.

abelbraaksma avatar Jul 24 '22 17:07 abelbraaksma

Hmm, CI seems broken, same error as above in the job that runs tests. 16 tests do not show up in the result.

Should be this, I think (from other, green PR):

Failed: 0 (0.00%) Passed: 37,032 (99.07%) Other: 348 (0.93%) Total: 37,380

But I got 37,016

abelbraaksma avatar Jul 24 '22 17:07 abelbraaksma

well technically a nan with the inverse sign of Double.NaN/Single.NaN, but those are typically a negative NaN

@tannergooding, adding those tests revealed a weird little bug in Math.Max. While the IEEE 754 spec allows you return either of the arguments (unless you'd support "totalOrder" definition of that spec), it looks like Math.Min returns a negative NaN (that is +Double.NaN) when there is one, Math.Max does not:

// in .NET, the BCL NaN is negative, so -NaN gives positive 
> Math.Max(nan, -nan) |> Double.IsNegative;;  // expected
val it: bool = true

> Math.Max(-nan, nan) |> Double.IsNegative;;  // not expected
val it: bool = false

> Math.Max(-nan, -nan) |> Double.IsNegative;;  // expected
val it: bool = false

> Math.Max(nan, nan) |> Double.IsNegative;;   // expected
val it: bool = true

You could argue that the code is shortcut, but then you'd expect a similar shortcut with Math.Min, but it behaves stable:

> Math.Min(nan, -nan) |> Double.IsNegative;;  // expected
val it: bool = true

> Math.Min(-nan, nan) |> Double.IsNegative;;  // expected
val it: bool = true

> Math.Min(-nan, -nan) |> Double.IsNegative;;  // expected
val it: bool = false

> Math.Min(nan, nan) |> Double.IsNegative;;  // expected
val it: bool = true

abelbraaksma avatar Jul 24 '22 19:07 abelbraaksma

From CI:

The type 'Double' does not define the field, constructor or member 'IsNegative'.

I should’ve seen that coming… these weren’t available at the time (is this Core 3.0 or Framework?). Either way, I’ll add bit test for proper comparison.

abelbraaksma avatar Jul 24 '22 20:07 abelbraaksma

adding those tests revealed a weird little bug in Math.Max.

That is possibly related to a bug that is already fixed and will be in the next patch release of .NET 6: https://github.com/dotnet/runtime/pull/70865

It's already in the latest .NET 7 public preview.

While the IEEE 754 spec allows you return either of the arguments (unless you'd support "totalOrder" definition of that spec), it looks like Math.Min returns a negative NaN (that is +Double.NaN) when there is one, Math.Max does not

Right, which of two NaNs is returned is undefined and the IEEE 754 spec even allows that NaN to be normalized (and so it might differ from either NaN input, as long as some NaN is returned). The test is more important in that Min(+NaN, -1) and Max(-NaN, +1) should each return some NaN value and shouldn't accidentally treat -1 < +NaN or +1 > -NaN due to how it checks for signs/etc.

Even with totalOrder, the IEEE 754 spec allows NaN normalization as a legal "optimization". From 10.4 Literal meaning and value-changing optimizations:

The following value-changing transformations, among others, preserve the literal meaning of the source code: ... Changing the payload or sign bit of a quiet NaN.

Ideally, most operations won't however.

Max and Min map to the IEEE 754 maximum and minimum APIs, respectively (returning the NaN one input is NaN and the other is not. It also treats -0 as less than +0) MaxNumber and MinNumber map to the IEEE 754 maximumNumber and minimumNumber APIs, respectively (returning the number one input is NaN and the other is not. It also treats -0 as less than +0)

We don't currently have any API that exposes totalOrder and takes things like NaN sign/payload into account. If we did, it would likely just be updating CompareTo, but that is "more expensive" for little practical benefit.

tannergooding avatar Jul 24 '22 22:07 tannergooding

@tannergooding, thanks for the explanation. I looked at the IEEE 754:2008 version, not the 2019 version, the latter which makes the behavior you mention explicit. I looked at the PR (I searched, but searching for Max/Min in Git is rather fruitless) and that boils down to: if both are NaN, return first arg, if either is NaN, return that value, if neither is, just compare.

I should update the tests to cover for this change in behavior, otherwise, as soon as this is patched, the current tests will fail.

My wrong assumption was that negative NaN would be ordered before positive NaN, but that isn't the case. They are treated according to the unordered sections in the spec, i.e., it shouldn't matter which is returned, as long as it is some NaN.

abelbraaksma avatar Jul 25 '22 10:07 abelbraaksma