Uh oh!
There was an error while loading. Please reload this page.
Fix decimal to/from floating-point conversions to round correctly - #130566
Merged
tannergooding merged 5 commits intoJul 13, 2026
Merged
Conversation
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>
Contributor
There was a problem hiding this comment.
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 → decimalanddecimal → float/doubleconversion logic inDecimal.DecCalcto be correctly-rounded. - Makes
Number.Pow10DoubleTableinternalso 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
| File | Description |
|---|---|
| src/libraries/System.Private.CoreLib/src/System/Decimal.DecCalc.cs | Reimplements decimal↔float/double conversions using integer-based, correctly-rounded algorithms. |
| src/libraries/System.Private.CoreLib/src/System/Number.NumberToFloatingPointBits.cs | Exposes Pow10DoubleTable to enable reuse of existing parser fast paths. |
| src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DecimalTests.cs | Updates constructor bit-pattern expectations and adds regression/overflow tests for conversions. |
| src/libraries/System.Runtime/tests/System.Runtime.Tests/System/DecimalTests.GenericMath.cs | Updates expected values for generic-math conversions from floating-point types. |
| src/libraries/System.Runtime/tests/System.Runtime.Tests/System/Text/StringBuilderTests.cs | Adjusts 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.cs | Updates Convert.ToDecimal(double) test data to use exactly representable values. |
| src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs | Fixes test coverage wiring for decimal rounding modes by introducing decimal-specific test data. |
| src/libraries/System.Runtime.Numerics/tests/ComplexTests.GenericMath.cs | Updates expected values for Complex→decimal conversion paths. |
Copilot's findings
- Files reviewed: 8/8 changed files
- Comments generated: 2
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
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>
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
…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>
…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>
eiriktsarpalis
approved these changes
Jul 13, 2026
Uh oh!
There was an error while loading. Please reload this page.
tannergooding
deleted the
tannergooding-decimal-double-conversion-precision
branch
July 13, 2026 13:55
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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for freeto subscribe to this conversation on GitHub.
Already have an account?
Sign in.
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Fixes#72125 and Fixes#72135 — both directions of decimal ↔ floating-point conversion rounded through intermediate floating-point steps and lost precision.
decimal → double/floatcombined(double)Low64 + (double)High * 2^64and then divided by10^scale, rounding several times (e.g.10000000000000.099609375mproduced the wrong double).double/float → decimalassumed the source carried only 15 (double) or 7 (float) significant digits and truncated the rest (e.g.(decimal)1.23gave1.23instead of1.2299999999999999822364316060).Both are reimplemented as correctly-rounded integer algorithms so a round-trip matches
decimal.Parse(value.ToString("G99")):decimal → floating-pointreuses 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.Pow10DoubleTablewas madeinternalto reuse it.floating-point → decimaldecomposes the value into an oddsignificand * 2^exponentand roundssignificand * 5^konce to fit the 96-bit mantissa and scale.Also fixes
decimal → Halfanddecimal → BFloat16, which is dependent on the same work, addressing that part of #112474.float((Half)(float)value), which double-rounds:float's 24-bit significand only just meets the2q + 2bound needed for the second rounding to be innocuous (q = 11forHalf, so24 == 2*11 + 2with no margin), so the result was not always correctly rounded.double((Half)(double)value), whose 53-bit significand comfortably satisfies the bound for both types, making the conversion correctly rounded.Half/BFloat16 → decimalwas already exact (both are exactly representable asfloat) and is unchanged.Performance
Local BenchmarkDotNet (per 16-element batch,
InProcessEmitToolchain), comparing the correctly-rounded implementation against the previous (incorrect) one:The
decimal → floating-pointcommon 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 → decimalis +39% (~+3 ns/op), a correctness-justified regression offset byfloat → decimalbeing ~32% faster.Testing
Updated the affected expected-value test data and added round-trip / overflow regression tests, including new
decimal → Half/BFloat16conversion coverage (correctly-rounded double-rounding cases, subnormals, and overflow). All pass, bit-identical to the correctly-rounded reference: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.TestWriteMiscSystem.Data.Tests.SqlTypes.SqlDecimalTest.ReadWriteXmlTestNote
This pull request was created by GitHub Copilot.