Skip to content

[Breaking change]: Decimal and BigInteger floating-point conversions are correctly rounded #55743

Description

@tannergooding

Description

Starting in .NET 11 Preview 7, conversions between decimal and binary floating-point types, and conversions from BigInteger to binary floating-point types, produce correctly rounded results. Previously, these conversions could truncate, round through intermediate values, or discard significant digits.

The change affects conversions between decimal and float or double, and conversions from decimal or BigInteger to Half or BFloat16. It was introduced by dotnet/runtime#130565 and dotnet/runtime#130566.

The change can also affect compile-time constant folding. For example, a C# compiler running on the .NET 11 Preview 7 SDK can emit a different decimal value for a constant conversion from a double literal, even when the project targets an earlier version of .NET.

Version

.NET 11 Preview 7

Previous behavior

Conversions from float and double to decimal retained only 7 and 15 significant decimal digits, respectively. This could hide the difference between a binary floating-point literal and a decimal literal:

using System.Globalization;

double value = 1.23;
decimal converted = (decimal)value;

Console.WriteLine(converted.ToString("G29", CultureInfo.InvariantCulture));

The output was:

1.23

Conversions from decimal to float or double could round more than once. For example:

using System.Globalization;

decimal value = 10000000000000.099609375m;
double converted = (double)value;

Console.WriteLine(converted.ToString("G99", CultureInfo.InvariantCulture));

The output was:

10000000000000.09765625

Conversions from decimal to Half or BFloat16 first converted the value to float, which could also produce an incorrectly rounded result.

Conversions from BigInteger to double truncated discarded bits instead of rounding to the nearest representable value. For example:

using System.Globalization;
using System.Numerics;

BigInteger value = long.MaxValue / 2;
double converted = (double)value;

Console.WriteLine(converted.ToString("G17", CultureInfo.InvariantCulture));

The output was:

4.6116860184273874E+18

Conversions from BigInteger to float, Half, or BFloat16 first converted the value to double, which could round twice and produce a result one unit in the last place away from the nearest representable value.

New behavior

Conversions round the exact source value once to the nearest representable destination value.

For the first example, the double value is not exactly 1.23. Its exact value is approximately 1.229999999999999982236431605997..., so the converted decimal value is now:

1.229999999999999982236431606

For the second example, the converted double value is now:

10000000000000.099609375

Conversions from decimal to Half or BFloat16 are also correctly rounded and can produce a different destination bit pattern than in previous versions.

For the BigInteger example, the converted double value is now:

4.6116860184273879E+18

Conversions from BigInteger to float, Half, or BFloat16 are also correctly rounded.

When a conversion is evaluated as a compile-time constant, rebuilding with a compiler hosted by the .NET 11 Preview 7 SDK or a later SDK can embed the new result in the output assembly regardless of the project's target framework.

Type of breaking change

  • Binary incompatible: Existing binaries might encounter a breaking change in behavior, such as failure to load or execute, and if so, require recompilation.
  • Source incompatible: When recompiled using the new SDK or component or to target the new runtime, existing source code might require source changes to compile successfully.
  • Behavioral change: Existing binaries might behave differently at run time.

Reason for change

The previous conversion algorithms lost information that the destination type could represent and sometimes selected a value other than the nearest representable result. This caused precision errors in both directions of decimal conversion and in conversions from BigInteger. The new algorithms follow the expected floating-point rule of computing the conversion as if with exact intermediate precision and then rounding once to the destination type.

Recommended action

Do not assume that a binary floating-point literal and a decimal literal with the same source text represent the same value. If a value is intended to be decimal, use a decimal literal:

decimal value = 123.4567m;

instead of converting a double literal:

decimal value = (decimal)123.4567;

To generally restore the previous result when converting to decimal, round the converted value to 7 significant decimal digits for a float source or 15 significant decimal digits for a double source. The previous conversion rounded to nearest with ties to even; it did not truncate. Positive and negative values were handled symmetrically.

For example, formatting the source value with the G7 or G15 standard numeric format string and parsing the result performs the corresponding significant-digit rounding for arbitrary finite values:

using System.Globalization;

static decimal ConvertToDecimalLikePrevious(float value)
{
    Span<char> text = stackalloc char[32];
    value.TryFormat(text, out int length, "G7", CultureInfo.InvariantCulture);
    return decimal.Parse(text[..length], NumberStyles.Float, CultureInfo.InvariantCulture);
}

static decimal ConvertToDecimalLikePrevious(double value)
{
    Span<char> text = stackalloc char[32];
    value.TryFormat(text, out int length, "G15", CultureInfo.InvariantCulture);
    return decimal.Parse(text[..length], NumberStyles.Float, CultureInfo.InvariantCulture);
}

decimal fromFloat = ConvertToDecimalLikePrevious(14.1f);          // 14.1
decimal fromDouble = ConvertToDecimalLikePrevious(-123.4567);     // -123.4567

The span-based implementation does not allocate. Values outside the range of decimal continue to throw an exception during parsing, as they did during conversion.

Update tests and serialized expected values that encoded the previous, incorrectly rounded result. This includes code that depended on BigInteger conversion truncating toward zero. If an application requires a specific legacy float, double, Half, or BFloat16 bit pattern for a protocol or file format, encode that bit pattern explicitly rather than reproducing it through a numeric conversion.

There is no compatibility switch to restore the previous conversion algorithms.

Feature area

Core .NET libraries

Affected APIs

  • System.Decimal.Decimal(float)
  • System.Decimal.Decimal(double)
  • Explicit conversions from System.Single and System.Double to System.Decimal
  • Explicit conversions from System.Decimal to System.Single and System.Double
  • System.Decimal.ToSingle(decimal)
  • System.Decimal.ToDouble(decimal)
  • System.Convert.ToDecimal(float)
  • System.Convert.ToDecimal(double)
  • System.Convert.ToSingle(decimal)
  • System.Convert.ToDouble(decimal)
  • System.Decimal.CreateChecked<TOther>(TOther), System.Decimal.CreateSaturating<TOther>(TOther), and System.Decimal.CreateTruncating<TOther>(TOther) when converting to or from System.Single or System.Double
  • Explicit conversion from System.Decimal to System.Half
  • Explicit conversion from System.Decimal to System.Numerics.BFloat16
  • Explicit conversions from System.Numerics.BigInteger to System.Double, System.Single, System.Half, and System.Numerics.BFloat16
  • Equivalent conversions performed through System.IConvertible or the generic math interfaces

Note

This issue was drafted with AI assistance from GitHub Copilot.


Associated WorkItem - 628410

Metadata

Metadata

Labels

📌 seQUESTeredIdentifies that an issue has been imported into Quest.breaking-changeIndicates a .NET Core breaking change

Type

No type

Projects

Status
👀 In review

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions