Fix rounding of BigInteger conversions to floating-point types - #130565
#130565Merged
tannergooding merged 1 commit intoJul 13, 2026
dotnet:maindotnet/runtime:mainfrom
tannergooding:tannergooding-sturdy-carnivaltannergooding/runtime:tannergooding-sturdy-carnivalCopy head branch name to clipboard
Merged
Fix rounding of BigInteger conversions to floating-point types#130565tannergooding merged 1 commit intodotnet:maindotnet/runtime:mainfrom tannergooding:tannergooding-sturdy-carnivaltannergooding/runtime:tannergooding-sturdy-carnivalCopy head branch name to clipboard
tannergooding merged 1 commit into
dotnet:maindotnet/runtime:mainfrom
tannergooding:tannergooding-sturdy-carnivaltannergooding/runtime:tannergooding-sturdy-carnivalCopy head branch name to clipboard
Conversation
The `explicit operator double(BigInteger)` truncated toward zero instead of rounding, so values such as `long.MaxValue / 2` produced a result 512 below the correctly rounded double that `(double)(long)` yields. Rewrite the conversion to perform IEEE round-to-nearest, ties-to-even using the top 64 significant bits, a round bit, and a sticky bit gathered from the uncaptured lower bits and limbs, handling the rounding carry and overflow to infinity. Remove the now-unused `NumericsHelpers.GetDoubleFromParts`. The `float`, `Half`, and `BFloat16` operators previously converted via `double` as an intermediate, which double-rounds and can be off by 1 ULP. Route them through a shared `ConvertToDouble` helper that rounds to odd at 53 bits, which is innocuous when re-rounded to the narrower type, giving a correctly rounded result. Fixes dotnet#49611 Fixes dotnet#125837 Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Contributor
|
Tagging subscribers to this area: @dotnet/area-system-numerics |
Contributor
There was a problem hiding this comment.
Pull request overview
This PR updates System.Numerics.BigInteger floating-point conversion operators to produce correctly-rounded results, fixing truncation in (double)BigInteger and avoiding double-rounding errors in (float), (Half), and (BFloat16) conversions.
Changes:
- Reworked
(double)BigIntegerconversion to implement IEEE 754 round-to-nearest, ties-to-even using a 64-bit significand window plus a sticky-bit scan of lower limbs. - Routed
(float),(Half), and(BFloat16)conversions through a sharedConvertToDouble(..., roundToOdd: true)path intended to prevent downstream double-rounding when narrowing. - Added new rounding-focused tests covering tie-breaking, deep-limb sticky bits, overflow-to-infinity, and float/BFloat16 double-rounding-sensitive cases.
Show a summary per file
| File | Description |
|---|---|
| src/libraries/System.Runtime.Numerics/src/System/Numerics/BigInteger.cs | Replaces double/float/Half/BFloat16 conversion logic with a shared ConvertToDouble implementation that accounts for rounding and sticky bits. |
| src/libraries/System.Runtime.Numerics/src/System/Numerics/NumericsHelpers.cs | Removes the now-unused GetDoubleFromParts helper. |
| src/libraries/System.Runtime.Numerics/tests/BigInteger/cast_from.cs | Adds regression and boundary tests validating correct rounding behavior and previously failing double-rounding scenarios. |
Copilot's findings
- Files reviewed: 3/3 changed files
- Comments generated: 0
This was referenced Jul 12, 2026
eiriktsarpalis
approved these changes
Jul 13, 2026
eiriktsarpalis
pushed a commit
that referenced
this pull request
Jul 15, 2026
Fixes #49611 Fixes #125837 ## `(double)BigInteger` The `explicit operator double(BigInteger)` truncated toward zero rather than rounding. It gathered the top mantissa bits and passed them to `NumericsHelpers.GetDoubleFromParts`, which masked off the low bits (truncation) and never observed the lower limbs (no sticky bit). So `(double)(BigInteger)(long.MaxValue / 2)` gave `4611686018427387392` where the direct `(double)(long)(long.MaxValue / 2)` correctly gives `4611686018427387904`. The conversion now performs IEEE round-to-nearest, ties-to-even: it builds the top 64 significant bits (`man`, MSB at bit 63), the top-bit position, and a `sticky` flag from any uncaptured lower bits/limbs, then keeps 53 bits using the round bit + sticky, handling the rounding carry and overflow to infinity. Both the 64-bit and 32-bit limb paths are covered. A `BigInteger` backed by a `bits[]` array is always a normal double magnitude, so there is no denormal/zero handling. ---------- ## `(float)`, `(Half)`, `(BFloat16)` These operators previously converted via `double` as an intermediate, which double-rounds and can be off by 1 ULP. In a sweep, `float` had hundreds of such errors; `BFloat16` shares `float`'s exponent range and is affected the same way. `Half` is not (every finite `Half`-range integer is exact in `double`), but is routed through the shared path anyway for uniformity. The fix routes all three through a shared `ConvertToDouble(value, roundToOdd: true)` helper that rounds to odd at 53 bits. A round-to-odd intermediate is always innocuous when re-rounded to a narrower type, so the narrow result is correctly rounded. This resolves the `BigInteger` portion of #112474; the `Integer -> Half`, `Decimal -> FP`, and `Decimal32/64/128 -> FP` items there remain out of scope. ## Tests Added rounding coverage to `cast_from.cs`: `double` ties-to-even boundaries, a deep lower-limb sticky bit, carry overflow to infinity, and `float`/`Half`/`BFloat16` cases (the `float`/`BFloat16` ones fail without the round-to-odd routing). > [!NOTE] > This PR description was drafted with the assistance of GitHub Copilot. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
tannergooding
added a commit
that referenced
this pull request
Jul 16, 2026
A performance follow-up to #130565 (which fixed the *correctness* of `BigInteger` -> floating-point conversions). No behavioral change -- the results are identical, this only makes them faster. Validated against a 2M-value oracle sweep vs `double.Parse`/`float.Parse` (0 mismatches) plus the existing rounding tests. ## `ulong` hardware fast path Magnitudes that fit in 64 bits are by far the common case. Rather than run them through the general limb scan, `operator double`, `operator float`, and `operator BFloat16` now reconstruct a `ulong` (`ToUInt64`) and use the correctly rounded `(T)(ulong)` conversion the hardware already provides. `(double)(ulong)`, `(float)(ulong)`, and `BFloat16`'s direct integer rounding are all correctly single-rounded, so this is exact. `Half` has no fast path because every `bits[]`-backed magnitude (>= 2^31) overflows it to infinity anyway. ---------- ## Round directly at the target width The narrow-type operators previously rounded to odd at 53 bits and then re-rounded via the narrowing cast, to dodge double rounding. Instead, `ConvertToDouble` now takes the target `precision` (53 for `double`, 24 for `float`, 11 for `Half`, 8 for `BFloat16`) and rounds a single time at that width using round-to-nearest, ties-to-even. The resulting `double` already lies exactly on the target type's grid, so the narrowing cast is exact and no double rounding is possible. Round-to-odd is gone entirely. This is valid precisely because every `bits[]`-backed `BigInteger` magnitude is at least 2^31, comfortably in the normal range of all four targets, so a target subnormal (the only case that would need extra intermediate precision) never occurs. A side benefit: the narrow types now share `double`'s sticky-scan gate. ---------- ## Lazy sticky scan The lower limbs only affect the result when the top bits sit exactly on the halfway point (the round bit is set) and nothing above already made the value sticky. `ConvertToDouble` now scans the lower limbs (an O(n) walk) only in that case, instead of always. Since `Half`/`float`/`BFloat16` now use the same round-to-nearest gate as `double`, they benefit too. ## Numbers Local A/B (Release corerun, identical CoreLib/JIT, base = current main, this = PR). ns/op: | Case (magnitude) | double base -> PR | float base -> PR | | --- | --- | --- | | Small (<= 64-bit) | 4.23 -> 1.57 (0.37x) | 4.40 -> 1.57 (0.36x) | | 65-bit / ~128-bit | ~neutral | ~neutral | | Large (~1024-bit) | 7.15 -> 4.09 (0.57x) | 7.22 -> 4.08 (0.57x) | The common <= 64-bit case is ~2.7x faster for both, and large-magnitude `float` is now at parity with `double` (was 7.2 vs 4.1ns). ## Tests Added wide (> 64-bit) rounding cases for `double`, `float`, and `BFloat16` to `cast_from.cs` so the general limb-scan path stays covered now that the <= 64-bit killer values hit the fast path. > [!NOTE] > This PR description was drafted with the assistance of GitHub Copilot. --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> Co-authored-by: Copilot Autofix powered by AI <175728472+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 free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
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 #49611
Fixes #125837
(double)BigIntegerThe
explicit operator double(BigInteger)truncated toward zero rather than rounding. It gathered the top mantissa bits and passed them toNumericsHelpers.GetDoubleFromParts, which masked off the low bits (truncation) and never observed the lower limbs (no sticky bit). So(double)(BigInteger)(long.MaxValue / 2)gave4611686018427387392where the direct(double)(long)(long.MaxValue / 2)correctly gives4611686018427387904.The conversion now performs IEEE round-to-nearest, ties-to-even: it builds the top 64 significant bits (
man, MSB at bit 63), the top-bit position, and astickyflag from any uncaptured lower bits/limbs, then keeps 53 bits using the round bit + sticky, handling the rounding carry and overflow to infinity. Both the 64-bit and 32-bit limb paths are covered. ABigIntegerbacked by abits[]array is always a normal double magnitude, so there is no denormal/zero handling.(float),(Half),(BFloat16)These operators previously converted via
doubleas an intermediate, which double-rounds and can be off by 1 ULP. In a sweep,floathad hundreds of such errors;BFloat16sharesfloat's exponent range and is affected the same way.Halfis not (every finiteHalf-range integer is exact indouble), but is routed through the shared path anyway for uniformity.The fix routes all three through a shared
ConvertToDouble(value, roundToOdd: true)helper that rounds to odd at 53 bits. A round-to-odd intermediate is always innocuous when re-rounded to a narrower type, so the narrow result is correctly rounded.This resolves the
BigIntegerportion of #112474; theInteger -> Half,Decimal -> FP, andDecimal32/64/128 -> FPitems there remain out of scope.Tests
Added rounding coverage to
cast_from.cs:doubleties-to-even boundaries, a deep lower-limb sticky bit, carry overflow to infinity, andfloat/Half/BFloat16cases (thefloat/BFloat16ones fail without the round-to-odd routing).Note
This PR description was drafted with the assistance of GitHub Copilot.