Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Printf fixes around handling of -0.0 (negative zero) #18147

Open
wants to merge 12 commits into
base: main
Choose a base branch
from

Conversation

jwosty
Copy link
Contributor

@jwosty jwosty commented Dec 14, 2024

Description

Fixes #15557 and #15558

Checklist

  • Test cases added

  • Release notes entry updated:

    Please make sure to add an entry with short succinct description of the change as well as link to this pull request to the respective release notes file, if applicable.

    Release notes files:

    • If anything under src/Compiler has been changed, please make sure to make an entry in docs/release-notes/.FSharp.Compiler.Service/<version>.md, where <version> is usually "highest" one, e.g. 42.8.200
    • If language feature was added (i.e. LanguageFeatures.fsi was changed), please add it to docs/release-notes/.Language/preview.md
    • If a change to FSharp.Core was made, please make sure to edit docs/release-notes/.FSharp.Core/<version>.md where version is "highest" one, e.g. 8.0.200.

    Information about the release notes entries format can be found in the documentation.
    Example:

    If you believe that release notes are not necessary for this PR, please add NO_RELEASE_NOTES label to the pull request.

@jwosty jwosty requested a review from a team as a code owner December 14, 2024 22:22
Copy link
Contributor

github-actions bot commented Dec 14, 2024

❗ Release notes required


✅ Found changes and release notes in following paths:

Change path Release notes path Description
src/FSharp.Core docs/release-notes/.FSharp.Core/9.0.200.md

@@ -852,11 +872,19 @@ module internal PrintfImpl =

module FloatAndDecimal =

let fixupDecimalSign (n: decimal) (nStr: string) =
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decimal not including the negative sign for 0 is an intentional choice for this type

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@vzarytovskii we should probably just follow the same behavior as .NET here then, right? i.e. make -0 decimals format the same as +0 decimals

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would ping @T-Gro for that, let the team decide what's desired and whether suggestion is needed.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is a change we could take - but it should be documented on the .NET breaking changes and happen on a major version update.


let isPositive (n: obj) =

let inline doubleIsPositive (n: double) =
Copy link
Member

@tannergooding tannergooding Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you just use double.IsPositive(n), or does F# need to differ?

.NET Framework notably has different behavior than .NET (Core), which is also intentional as .NET Framework has a much higher backwards compatibility bar

Comment on lines 663 to 666
n >= 0.0
// Ensure -0.0 is treated as negative (see https://github.com/dotnet/fsharp/issues/15557)
// and use bitwise comparison because floating point comparison treats +0.0 as equal to -0.0
&& (BitConverter.DoubleToInt64Bits n <> BitConverter.DoubleToInt64Bits -0.0)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this going to do the right thing for NaN? What is the behavior F# expects to have there, particularly with the existance of both +NaN and -NaN at the bitwise level?

&& (BitConverter.DoubleToInt64Bits n <> BitConverter.DoubleToInt64Bits -0.0)

let inline singleIsPositive (n: single) =
doubleIsPositive (float n)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Related to the question of NaN, this has a chance of causing normalization and doing the "wrong thing". It should likely be explicitly handled instead, potentially using single.IsPositive

doubleIsPositive (float n)

let decimalSignBit (n: decimal) =
// Unfortunately it's impossible to avoid this array allocation without either targeting .NET 5+ or relying on knowledge about decimal's internal representation
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

decimal's representation is notably fixed and while it technically isn't strictly documented, it isn't really "internal".

decimal is a C# primitive type that must strictly be 16-bytes in size (a hard assumption exists here), but also is an interchange type for the Win32 DECIMAL type and it's layout must be ABI compatible with this: https://learn.microsoft.com/en-us/windows/win32/api/wtypes/ns-wtypes-decimal-r1

Copy link
Member

@tannergooding tannergooding Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could likely explicitly document this if really needed, but it's generally safe to depend on.

There is a decimal.IsPositive API or you could notably also use NativePtr.stackalloc to avoid the allocation if the library was multitargeted

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The library is netstandard2.0/2.1, which I think rules out the IsPositive/IsNegative methods. And ._flags field itself is private, I am not sure there is a safe way to read it?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@T-Gro we could write an equivalent struct type and use magic like Unsafe.As to bitwise cast it, but that feels evil to me

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

FWIW, I would vote against doing it that way

bits[3] >>> 31

let inline decimalIsNegativeZero (n: decimal) =
decimalSignBit n <> 0
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is notably checking IsNegative, not IsNegativeZero and would also return true for -1.0m, as an example.

let inline decimalIsNegativeZero (n: decimal) =
decimalSignBit n <> 0

let inline decimalIsPositive (n: decimal) =
Copy link
Member

@tannergooding tannergooding Dec 16, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's much cheaper to just check value._flags >= 0: https://source.dot.net/#System.Private.CoreLib/src/libraries/System.Private.CoreLib/src/System/Decimal.cs,1443, which is what the official decimal.IsPositive API does

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same as above, this has to build with netstandard2.0.

@jwosty
Copy link
Contributor Author

jwosty commented Dec 16, 2024

OK so after reading some of the review comments here, I went and checked the output of the currently released sprintf against both .NET 9 and .NET framework.

The code:

printfn "-- floats --"
printfn "%%f +0.0f => %f" +0.0f
printfn "%%f -0.0f => %f" -0.0f

printfn "%%+f +0.0f => %+f" +0.0f
printfn "%%+f -0.0f => %+f" -0.0f

printfn "%%010.3f +0.0f => %010.3f" +0.0f
printfn "%%010.3f -0.0f => %010.3f" -0.0f

printfn "-- decimals --"
printfn "%%f +0.0m => %f" +0.0m
printfn "%%f -0.0m => %f" -0.0m

printfn "%%+f +0.0m => %+f" +0.0m
printfn "%%+f -0.0m => %+f" -0.0m

printfn "%%010.3f +0.0m => %010.3f" +0.0m
printfn "%%010.3f -0.0m => %010.3f" -0.0m

Output for .NET 9:

-- floats --
%f +0.0f => 0.000000
%f -0.0f => -0.000000
%+f +0.0f => +0.000000
%+f -0.0f => +-0.000000
%010.3f +0.0f => 000000.000
%010.3f -0.0f => 0000-0.000
-- decimals --
%f +0.0m => 0.000000
%f -0.0m => 0.000000
%+f +0.0m => +0.000000
%+f -0.0m => +0.000000
%010.3f +0.0m => 000000.000
%010.3f -0.0m => 000000.000

Output for .NET 4.8:

-- floats --
%f +0.0f => 0.000000
%f -0.0f => 0.000000
%+f +0.0f => +0.000000
%+f -0.0f => +0.000000
%010.3f +0.0f => 000000.000
%010.3f -0.0f => 000000.000
-- decimals --
%f +0.0m => 0.000000
%f -0.0m => 0.000000
%+f +0.0m => +0.000000
%+f -0.0m => +0.000000
%010.3f +0.0m => 000000.000
%010.3f -0.0m => 000000.000

As you can see, neither #15557 nor #15558 are present in the .NET framework run (which makes some sense given @tannergooding's comments). Furthermore, the .NET framework version always treats (decimal and float) negative zero as positive zero for formatting purposes (which I didn't realize before since I didn't think to also test against Framework).

Therefore I will amend this PR to preserve the .NET framework behavior in both cases. I personally don't particularly care how -0.0f and -0.0m print, just as long as it's consistent (my original purpose in this PR was just to fix the bugs; that's all). So I'm not going to file a proposal for any breaking changes, but if anyone else feels strongly about it, don't let me stop you from doing so yourself.

Additionally, it looks like decimal behavior is consistent and therefore needs no bug-fixing-changes after all. So I will be reverting those.

@jwosty
Copy link
Contributor Author

jwosty commented Dec 16, 2024

Actually there is still one scenario that I'd argue is a bug under .NET framework, too:

sprintf "%+f" -0.0000001

Under .NET framework this prints "0.000000" which is definitely a bug (+ flag should always cause output to have some sign on it). Now the question is, should the sign be - or +? My gut tells me that it should behave the same as -0 (meaning it should print +0.000000), but that will complicate things a little bit since it breaks the current assumption that a negative number will always have a negative sign. The implementation would have to start caring about whether the number rounds to zero, which I fear could fall prey to precision errors or other weird floating point stuff... Formatting this kind of input with a negative sign would be the much simpler solution, but that also feels wrong (since it would now be possible to get printf to print a negative zero, but not when the number is actually negative zero - might lead to a very strange experience for anyone who stumbles across this behavior).

Thoughts?

@tannergooding
Copy link
Member

tannergooding commented Dec 16, 2024

Under .NET framework this prints "0.000000" which is definitely a bug

.NET Framework has many bugs that will never be fixed due to its much stronger back-compat bar. Other bugs around IEEE 754 floating-point include things like double.Parse(x.ToString("R")) not always roundtripping, x.ToString() silently resulting in loss of precision in many scenarios (including Math.PI), not correctly preserving negative zero, etc. Much of this is discussed in https://devblogs.microsoft.com/dotnet/floating-point-parsing-and-formatting-improvements-in-net-core-3-0/

Typically this much stronger backwards compatibility bar means that the behavior should be preserved "as is" on .NET Framework and fixes should only be taken on .NET [Core] where relevant. F# could decide differently, but that itself comes with its own risks and potential for new problems due to it differing from what users may expect and differing from what they'd experience using the regular BCL APIs.

but that will complicate things a little bit since it breaks the current assumption that a negative number will always have a negative sign

This assumption is already broken (on .NET Framework) by negative zero. Negative zero itself exists due to there being negative non-zero results which round towards zero due to the precision limitations of the underlying format, for scientific and other mathematical domains this information is often relevant and so is pertinent to display and preserve (which is different from decimal which is designed as a currency type and where corresponding features are lacking that prevent or hinder its usage in such domains).

If you're printing with a limited number of digits, then today this functions (in both F# and the BCL) by functionally rounding to that many digits; thus if you print to 2 fractional digits then 0.005 becomes 0.01 and 0.004 becomes 0.00. Under the same premise, -0.004 becomes -0.00 and that then displays (using the BCL APIs) as negative zero would, without the sign on .NET Framework and with the sign on .NET Core.

Edit: Noting that "rounding to that many digits" is meant to account for the exact underlying represented value, not strictly the literal the user visualizes, thus 0.005 is actually 0.005000000000000000104083408558608425664715468883514404296875 and that's why it rounds "up" to 0.01 instead of down towards the non-odd value (as per the default rounding mode "to nearest; ties to even", since it isn't actually a tied value).

@jwosty
Copy link
Contributor Author

jwosty commented Dec 16, 2024

Under .NET framework this prints "0.000000" which is definitely a bug

.NET Framework has many bugs that will never be fixed due to its much stronger back-compat bar.

I should correct myself; I meant it's that sprintf "%+f" -0.0000001 printing "0.0000001" is most certainly a bug in FSharp.Core's sprintf implementation even when running under .NET Framework. I think F# has been okay with "breaking" backwards compatibility with regard to longstanding bugs (in contrast with the .NET Framework), though I of course defer to the F# team's wisdom.

but that will complicate things a little bit since it breaks the current assumption that a negative number will always have a negative sign

This assumption is already broken (on .NET Framework) by negative zero.

Again, let me refine that statement:

but that [making sprintf "%+f" -0.0000001 print "+0.000000"] will complicate things a little bit since it breaks the sprintf user's current assumption that a negative number will nonzero negative number should always have a negative sign


EDIT:

That being said, given this comment:

If you're printing with a limited number of digits, then today this functions (in both F# and the BCL) by functionally rounding to that many digits; thus if you print to 2 fractional digits then 0.005 becomes 0.01 and 0.004 becomes 0.00. Under the same premise, -0.004 becomes -0.00 and that then displays (using the BCL APIs) as negative zero would, without the sign on .NET Framework and with the sign on .NET Core.

If we were to decide in FSharp.Core to fix the bug by making sprintf "%+f" -0.0000001 print "+0.000000", would it be a sane approach to actually just do some kind of rounding calculation internally (to some number of decimal points), and just use that number to decide what sign to use for the number? Effectively changing the logic (in pseudocode) from:

let determineSign n = if n >= 0.0 then "+" else "-"

to something like:

let determineSign n nDecimalPlaces = if (roundTo n nDecimalPlaces) >= 0.0 then "+" else "-"

My feeling was that there would still be room for some floating-point weirdness, but perhaps I was wrong; would that actually be a reasonable approach to take?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
Status: New
Development

Successfully merging this pull request may close these issues.

%+f printf format specifier adds both plus and minus for negative zero (-0) on .NET Core
4 participants