You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
Commit 8d67bbd
Browse filesBrowse the repository at this point in the historyBrowse files
Fix decimal to/from floating-point conversions to round correctly (#130566)
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 <[email protected]>
0 commit comments