Skip to content

Fix decimal to/from floating-point conversions to round correctly - #130566

Merged
tannergooding merged 5 commits into
dotnet:mainfrom
tannergooding:tannergooding-decimal-double-conversion-precision
Jul 13, 2026
Merged

Fix decimal to/from floating-point conversions to round correctly#130566
tannergooding merged 5 commits into
dotnet:mainfrom
tannergooding:tannergooding-decimal-double-conversion-precision

Conversation

@tannergooding

@tannergoodingtannergooding commented Jul 11, 2026

Copy link
Copy Markdown
Member

Fixes#72125 and Fixes#72135 — both directions of decimal ↔ floating-point conversion rounded through intermediate floating-point steps and lost precision.

  • decimal → double/float combined (double)Low64 + (double)High * 2^64 and then divided by 10^scale, rounding several times (e.g. 10000000000000.099609375m produced the wrong double).
  • double/float → decimal assumed the source carried only 15 (double) or 7 (float) significant digits and truncated the rest (e.g. (decimal)1.23 gave 1.23 instead of 1.2299999999999999822364316060).

Both are reimplemented as correctly-rounded integer algorithms so a round-trip matches decimal.Parse(value.ToString("G99")):

  • decimal → floating-point reuses the parser's own correctly-rounded fast paths — Clinger ((double)mantissa / Pow10DoubleTable[scale]) and Eisel-Lemire (Number.ComputeFloat) — falling back to an exact 128-bit division only for mantissas wider than 64 bits and the rare case Eisel-Lemire cannot decide. Number.Pow10DoubleTable was made internal to reuse it.
  • floating-point → decimal decomposes the value into an odd significand * 2^exponent and rounds significand * 5^k once to fit the 96-bit mantissa and scale.

Also fixes decimal → Half and decimal → BFloat16, which is dependent on the same work, addressing that part of #112474.

  • These previously routed through float ((Half)(float)value), which double-rounds: float's 24-bit significand only just meets the 2q + 2 bound needed for the second rounding to be innocuous (q = 11 for Half, so 24 == 2*11 + 2 with no margin), so the result was not always correctly rounded.
  • Both now route through double ((Half)(double)value), whose 53-bit significand comfortably satisfies the bound for both types, making the conversion correctly rounded. Half/BFloat16 → decimal was already exact (both are exactly representable as float) and is unchanged.

Performance

Local BenchmarkDotNet (per 16-element batch, InProcessEmitToolchain), comparing the correctly-rounded implementation against the previous (incorrect) one:

DirectionBefore fixAfter fix
decimal→double28.4 ns64.0 ns
decimal→float32.0 ns80.5 ns
double→decimal127.8 ns177.8 ns
float→decimal181.5 ns123.6 ns

The decimal → floating-point common case runs at the previous speed via the Clinger / Eisel-Lemire fast paths; the residual is entirely the wide-mantissa (>64-bit) inputs that require the exact division for correctness — the very inputs the old code rounded incorrectly. double → decimal is +39% (~+3 ns/op), a correctness-justified regression offset by float → decimal being ~32% faster.

Testing

Updated the affected expected-value test data and added round-trip / overflow regression tests, including new decimal → Half/BFloat16 conversion coverage (correctly-rounded double-rounding cases, subnormals, and overflow). All pass, bit-identical to the correctly-rounded reference:

  • System.Runtime.Tests: 69797 / 0
  • System.Runtime.Extensions.Tests: 8648 / 0
  • System.Runtime.Numerics.Tests: 3103 / 0

Two existing tests encoded the old truncated new decimal(double) result and were updated to the correctly-rounded expansion (the same category of change #72135 anticipates):

  • System.IO.Tests.StringWriterTests.TestWriteMisc
  • System.Data.Tests.SqlTypes.SqlDecimalTest.ReadWriteXmlTest

Note

This pull request was created by GitHub Copilot.

Both conversion directions rounded through intermediate floating-point steps
that lost precision:
- decimal -> double/float combined (double)Low64 + (double)High * 2^64 and
then divided by 10^scale, rounding several times (e.g.
10000000000000.099609375m produced the wrong double).
- double/float -> decimal assumed the source carried only 15 (double) or 7
(float) significant digits and truncated the rest (e.g. (decimal)1.23 gave
1.23 instead of 1.2299999999999999822364316060).
Reimplement both as correctly-rounded integer algorithms so a round-trip
matches decimal.Parse(value.ToString(G99)). decimal -> floating-point
reuses the parser's Clinger and Eisel-Lemire fast paths (Number.ComputeFloat)
for the common case and falls back to an exact 128-bit division only for
mantissas wider than 64 bits. floating-point -> decimal decomposes the value
into an odd significand and rounds significand * 5^k once to fit the 96-bit
mantissa and scale.
Fixesdotnet#72125Fixesdotnet#72135
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This pull request updates the CoreLib implementation of decimal ↔ floating-point conversions to use correctly-rounded algorithms (instead of rounding through intermediate floating-point steps), and adjusts/extends tests across libraries to reflect the new, more precise results.

Changes:

  • Reworks float/double → decimal and decimal → float/double conversion logic in Decimal.DecCalc to be correctly-rounded.
  • Makes Number.Pow10DoubleTableinternal so decimal→floating-point can reuse parser fast paths.
  • Updates and adds tests in System.Runtime / System.Runtime.Extensions / System.Runtime.Numerics for the new conversion results and edge cases (including overflow).
Show a summary per file
FileDescription
src/libraries/System.Private.CoreLib/src/System/Decimal.DecCalc.csReimplements decimal↔float/double conversions using integer-based, correctly-rounded algorithms.
src/libraries/System.Private.CoreLib/src/System/Number.NumberToFloatingPointBits.csExposes Pow10DoubleTable to enable reuse of existing parser fast paths.
src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DecimalTests.csUpdates constructor bit-pattern expectations and adds regression/overflow tests for conversions.
src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DecimalTests.GenericMath.csUpdates expected values for generic-math conversions from floating-point types.
src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Text/StringBuilderTests.csAdjusts decimal-from-double based test inputs to avoid relying on the old, incorrect rounding.
src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Convert.ToDecimal.csUpdates Convert.ToDecimal(double) test data to use exactly representable values.
src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.csFixes test coverage wiring for decimal rounding modes by introducing decimal-specific test data.
src/libraries/System.Runtime.Numerics/tests/ComplexTests.GenericMath.csUpdates expected values for Complex→decimal conversion paths.

Copilot's findings

  • Files reviewed: 8/8 changed files
  • Comments generated: 2

VarDecFromFloat stamped the sign and scale onto the result even when rounding produced a zero mantissa, yielding a non-canonical signed or scaled zero for tiny magnitudes (e.g. (decimal)(-1e-30) became a negative zero). Return the canonical 'result = default' zero in that case, restoring the historical underflow behavior. Also assert the drop invariant in DecimalToFloatingPointExact.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings July 12, 2026 00:21

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 8/8 changed files
  • Comments generated: 3

…ding
The StringBuilder and Convert.ToDecimal tests had been switched to binary-exact inputs, which no longer exercised the correctly-rounded (double -> decimal) conversion this change adjusts. Restore the inexact inputs and derive the expected values from parsing the full base-10 expansion so the behavior stays covered without hardcoding long literals.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings July 12, 2026 00:45

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 8/8 changed files
  • Comments generated: 0 new

tannergoodingand others added 2 commits July 11, 2026 19:21
…rrectly rounded
Converting decimal -> Half/BFloat16 through float double-rounds: float's 24-bit
significand only just meets the 2q+2 bound (q=11 for Half), so the subsequent
rounding to Half is not always innocuous. Route through double instead, whose
53-bit significand comfortably satisfies the bound for both types, making the
conversion correctly rounded. Half/BFloat16 -> decimal is already exact.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
…ersion
StringWriterTests.TestWriteMisc and SqlDecimalTest.ReadWriteXmlTest encoded the
old truncated-to-15-digits result of new decimal(double). With the conversion now
correctly rounded, new decimal(1234.01), new decimal(4556.89756), and
new decimal(-6445.9999) preserve their full decimal expansions, so update the
expected values to match.
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
CopilotAI review requested due to automatic review settings July 12, 2026 02:48

CopilotAI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Copilot's findings

  • Files reviewed: 14/14 changed files
  • Comments generated: 0 new

@tannergooding
tannergooding merged commit 45286f4 into dotnet:mainJul 13, 2026
145 of 147 checks passed
@tannergooding
tannergooding deleted the tannergooding-decimal-double-conversion-precision branch July 13, 2026 13:55
@dotnet-milestone-botdotnet-milestone-botBot added this to the 11.0-preview7 milestone Jul 14, 2026
eiriktsarpalis pushed a commit that referenced this pull request Jul 15, 2026
…30566)
Fixes#72125 and Fixes#72135 — both directions of decimal ↔
floating-point conversion rounded through intermediate floating-point
steps and lost precision.
- `decimal → double/float` combined `(double)Low64 + (double)High *
2^64` and then divided by `10^scale`, rounding several times (e.g.
`10000000000000.099609375m` produced the wrong double).
- `double/float → decimal` assumed the source carried only 15 (double)
or 7 (float) significant digits and truncated the rest (e.g.
`(decimal)1.23` gave `1.23` instead of
`1.2299999999999999822364316060`).
Both are reimplemented as correctly-rounded integer algorithms so a
round-trip matches `decimal.Parse(value.ToString("G99"))`:
- **`decimal → floating-point`** reuses the parser's own
correctly-rounded fast paths — Clinger (`(double)mantissa /
Pow10DoubleTable[scale]`) and Eisel-Lemire (`Number.ComputeFloat`) —
falling back to an exact 128-bit division only for mantissas wider than
64 bits and the rare case Eisel-Lemire cannot decide.
`Number.Pow10DoubleTable` was made `internal` to reuse it.
- **`floating-point → decimal`** decomposes the value into an odd
`significand * 2^exponent` and rounds `significand * 5^k` once to fit
the 96-bit mantissa and scale.
----------
Also fixes `decimal → Half` and `decimal → BFloat16`, which is dependent
on the same work, addressing that part of #112474.
- These previously routed through `float` (`(Half)(float)value`), which
double-rounds: `float`'s 24-bit significand only just meets the `2q + 2`
bound needed for the second rounding to be innocuous (`q = 11` for
`Half`, so `24 == 2*11 + 2` with no margin), so the result was not
always correctly rounded.
- Both now route through `double` (`(Half)(double)value`), whose 53-bit
significand comfortably satisfies the bound for both types, making the
conversion correctly rounded. `Half`/`BFloat16 → decimal` was already
exact (both are exactly representable as `float`) and is unchanged.
## Performance
Local BenchmarkDotNet (per 16-element batch, `InProcessEmitToolchain`),
comparing the correctly-rounded implementation against the previous
(incorrect) one:
| Direction | Before fix | After fix |
|---|---|---|
| decimal→double | 28.4 ns | 64.0 ns |
| decimal→float | 32.0 ns | 80.5 ns |
| double→decimal | 127.8 ns | 177.8 ns |
| float→decimal | 181.5 ns | 123.6 ns |
The `decimal → floating-point` common case runs at the previous speed
via the Clinger / Eisel-Lemire fast paths; the residual is entirely the
wide-mantissa (>64-bit) inputs that require the exact division for
correctness — the very inputs the old code rounded incorrectly. `double
→ decimal` is +39% (~+3 ns/op), a correctness-justified regression
offset by `float → decimal` being ~32% faster.
## Testing
Updated the affected expected-value test data and added round-trip /
overflow regression tests, including new `decimal → Half`/`BFloat16`
conversion coverage (correctly-rounded double-rounding cases,
subnormals, and overflow). All pass, bit-identical to the
correctly-rounded reference:
- System.Runtime.Tests: 69797 / 0
- System.Runtime.Extensions.Tests: 8648 / 0
- System.Runtime.Numerics.Tests: 3103 / 0
Two existing tests encoded the old truncated `new decimal(double)`
result and were updated to the correctly-rounded expansion (the same
category of change #72135 anticipates):
- `System.IO.Tests.StringWriterTests.TestWriteMisc`
- `System.Data.Tests.SqlTypes.SqlDecimalTest.ReadWriteXmlTest`
> [!NOTE]
> This pull request was created by GitHub Copilot.
---------
Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@github-actionsgithub-actionsBot locked and limited conversation to collaborators Aug 13, 2026
Sign up for freeto 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 Decimal to double conversion loses precision

3 participants

@tannergooding@eiriktsarpalis