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

Double to Decimal Conversion Refactor (WIP) #70602

Closed
wants to merge 32 commits into from

Conversation

dakersnar
Copy link
Contributor

@dakersnar dakersnar commented Jun 11, 2022

Fixes #72135

This is unfinished, but I'm leaving for vacation, so I wanted to share the current state of this effort.

Problems with previous code

The previous conversion code is buggy.

  • It relies on false assumptions
    • "Round the input to a 15-digit integer. The R8 format has only 15 digits of precision, and we want to keep garbage digits out of the Decimal were making." This is an incorrect assumption, and the cause of the linked bug.
  • It seems to have solved an off-by-one error in a hacky way
    • const uint DBLBIAS = 1022; is conceptually incorrect; the exponent bias for double is 1023
  • It generally doesn't take advantage of a lot of functionality we now expose in double and decimal

Summary of approach

This PR is a general refactor of the conversion code. The primary goal is to solve the reported bug, but secondarily make this process more efficient by reusing fast code we have written elsewhere. To achieve this, @tannergooding and I brainstormed reusing a section of the Dragon4 algorithm for double to string conversion, as printing a double already involves converting it to base 10.

To do this, I split up the code for the Dragon4 algorithm into two parts and added an additional API that allows Decimal.DecCalc.cs access to the first half of Dragon4. This API is structured to return everything Decimal.DecCalc.cs needs to construct a decimal representation of the original double.

Status of the work

The rough draft of the approach is complete, and the original bug reported in #68042 is now fixed. Unfortunately, the logic does not seem to be holding up for all code paths, as there are a number of failing test cases in DecimalTests.cs. For example:

image

I haven't had enough time to dive deep into what is causing these issues, but I'm adding GitHub comments to the logic that I think is shaky.

What is left to do

  • Fix the logic to handle all the failing edge cases
  • Ensure we have proper test coverage
  • (Possibly) perf analysis to see if borrowing from Dragon4 is giving the performance we are expecting
  • If we determine this Dragon4 method is barking up the wrong tree, use another method @tannergooding and I have brainstormed. In my opinion, correctness and maintainability is more important than performance here.
  • Backport to previous versions of .NET?

@ghost
Copy link

ghost commented Jun 11, 2022

Tagging subscribers to this area: @dotnet/area-system-numerics
See info in area-owners.md if you want to be subscribed.

Issue Details

Fixes #68042

This is unfinished, but I'm leaving for vacation, so I wanted to share the current state of this effort.

Problems with previous code

The previous conversion code is buggy.

  • It relies on false assumptions
    • "Round the input to a 15-digit integer. The R8 format has only 15 digits of precision, and we want to keep garbage digits out of the Decimal were making." This is an incorrect assumption, and the cause of the linked bug.
  • It seems to have solved an off-by-one error in a hacky way
    • const uint DBLBIAS = 1022; is conceptually incorrect; the exponent bias for double is 1023
  • It generally doesn't take advantage of a lot of functionality we now expose in double and decimal

Summary of approach

This PR is a general refactor of the conversion code. The primary goal is to solve the reported bug, but secondarily make this process more efficient by reusing fast code we have written elsewhere. To achieve this, @tannergooding and I brainstormed reusing a section of the Dragon4 algorithm for double to string conversion, as printing a double already involves converting it to base 10.

To do this, I split up the code for the Dragon4 algorithm into two parts and added an additional API that allows Decimal.DecCalc.cs access to the first half of Dragon4. This API is structured to return everything Decimal.DecCalc.cs needs to construct a decimal representation of the original double.

Status of the work

The rough draft of the approach is complete, and the original bug reported in #68042 is now fixed. Unfortunately, the logic does not seem to be holding up for all code paths, as there are a number of failing test cases in DecimalTests.cs. For example:

image

I haven't had enough time to dive deep into what is causing these issues, but I'm adding GitHub comments to the logic that I think is shaky.

What is left to do

  • Fix the logic to handle all the failing edge cases
  • Ensure we have proper test coverage
  • (Possibly) perf analysis to see if borrowing from Dragon4 is giving the performance we are expecting
  • If we determine this Dragon4 method is barking up the wrong tree, use another method @tannergooding and I have brainstormed. In my opinion, correctness and maintainability is more important than performance here.
  • Backport to previous versions of .NET?
Author: dakersnar
Assignees: -
Labels:

area-System.Numerics

Milestone: -

//
const uint DBLBIAS = 1022;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As mentioned, this is conceptually incorrect, as possibly even logically incorrect

Comment on lines -1713 to -1715
// Round the input to a 15-digit integer. The R8 format has
// only 15 digits of precision, and we want to keep garbage digits
// out of the Decimal were making.
Copy link
Contributor Author

Choose a reason for hiding this comment

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

As mentioned, this is incorrect, and the cause of the original bug

// power is between -14 and 43

if (power >= 0)
if (input.Exponent < -94)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

I'm using the Exponent exposed on double now. I think this is more correct than the original comparison.

if (X86.Sse41.IsSupported)
mant = (ulong)(long)Math.Round(dbl);
else
if (input.Exponent >= 96)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This should prevent all overflowing doubles from making it to Dragon4.

// / 10e, where m is an integer such that
// -296 <; m <; 296, and e is an integer
// / 10^e, where m is an integer such that
// -2^96 <; m <; 2^96, and e is an integer
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Suggested change
// -2^96 <; m <; 2^96, and e is an integer
// -2^96 <= m <= 2^96, and e is an integer

Is there something I'm missing about the use of ; here?

Copy link
Member

Choose a reason for hiding this comment

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

This comment got corrupted in June 2002. Originally it looked this way:

     * <p>The finite set of values of type <i>Decimal</i> are of the form <i>m</i>
     * / 10<sup><i>e</i></sup>, where <i>m</i> is an integer such that
     * -2<sup>96</sup> &lt; <i>m</i> &lt; 2<sup>96</sup>, and <i>e</i> is an integer
     * between 0 and 28 inclusive.

You should use <, not <=. Also please move / 10^e to the previous line.

// require that the DoubleToNumber handle zero itself.
Debug.Assert(mantissa != 0);

Dragon4State state = Dragon4GetScaleValueMargin(mantissa, exponent, mantissaHighBitIdx, hasUnequalMargins, cutoffNumber, isSignificantDigits);
Copy link
Contributor Author

Choose a reason for hiding this comment

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

First half of Dragon4 is now hoisted to this helper function.

@@ -537,5 +458,171 @@ private static unsafe uint Dragon4(ulong mantissa, int exponent, uint mantissaHi
Debug.Assert(outputLen <= buffer.Length);
return outputLen;
}

private static unsafe Dragon4State Dragon4GetScaleValueMargin(ulong mantissa, int exponent, uint mantissaHighBitIdx, bool hasUnequalMargins, int cutoffNumber, bool isSignificantDigits)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Here is the hoisted first half of Dragon4

@@ -224,11 +224,48 @@ public void Ctor_Double(double value, int[] bits)
[InlineData(double.MinValue)]
[InlineData(double.PositiveInfinity)]
[InlineData(double.NegativeInfinity)]
[InlineData(79228162514264337593543950335.0)]
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This number is decimal.MaxValue, which is actually stored as 79228162514264337593543950336.0 as a double, which has an exponent of 96 and should overflow when cast to a decimal.

Comment on lines 233 to 251
[Fact]
public void Ctor_LargeDouble_RoundtripCastSucceeds()
{
// Decrementing Decimal's MaxValue to get a number that shouldn't lose precision when cast back and forth
double x = Math.BitDecrement(79228162514264337593543950335.0);

// Cast to a decimal
decimal y = new decimal(x);

// Use strings to compare values of double and decimal, ensuring no precision loss
string x_string = x.ToString("G99");
string y_string = y.ToString("G99");
Assert.Equal(x_string, y_string);

// Cast back to double, ensuring no precision loss
double z = (double)y;
Assert.Equal(x, z);

}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is the original bug and is passing now.

Comment on lines 253 to 267
[Fact]
public void Ctor_SmallDoubleRoundsUp()
{
// Create a double with decimal's smallest non-zero value
double x = .0000000000000000000000000001;

// Cast to a decimal
decimal y = new decimal(x);

Assert.NotEqual(decimal.Zero, y);

// Use strings to ensure decimal is correct
string y_string = y.ToString("G99");
Assert.Equal(".0000000000000000000000000001", y_string);
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

This is meant to test conversion to the smallest non-zero decimal value. WIP.

@dakersnar
Copy link
Contributor Author

The code is working and complete but needs some cleanup. I will either make a fresh PR or update this one tomorrow.

@dakersnar dakersnar closed this Jul 14, 2022
@ghost ghost locked as resolved and limited conversation to collaborators Aug 13, 2022
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Double to decimal conversion loses precision
2 participants