Skip to content

Fix incorrect rounding in Math.Round and MathF.Round with digits#130574

Merged
tannergooding merged 10 commits into
dotnet:mainfrom
tannergooding:tannergooding-cuddly-robot
Jul 14, 2026
Merged

Fix incorrect rounding in Math.Round and MathF.Round with digits#130574
tannergooding merged 10 commits into
dotnet:mainfrom
tannergooding:tannergooding-cuddly-robot

Conversation

@tannergooding

Copy link
Copy Markdown
Member

Fixes #1643

Math.Round(double, digits, mode) and MathF.Round(float, digits, mode) computed Round(value * 10^digits, mode) / 10^digits. Because value * 10^digits is generally not exactly representable, values slightly below (or above) a decimal midpoint could be scaled into an exact midpoint (or across a rounding boundary), producing an incorrectly rounded result. Large magnitudes could also lose their fractional bits entirely during the scaling step. Roughly 5% of random inputs across the supported digits range differed from the correctly rounded result.

Math.Round(655.925, 2, MidpointRounding.AwayFromZero);            // was 655.93, now 655.92
Math.Round(1111111111111111.5, 1, MidpointRounding.AwayFromZero); // was ...1.6, now ...1.5

(655.925 is stored as 655.924999999999954525…, which is below the 655.925 midpoint, so the correct result is 655.92.)


Commit 1 — correctness. The result is now computed from the exact value of the input and is the nearest representable value to the correctly rounded decimal result, matching what value.ToString("F{digits}") already produced. The reference path uses arbitrary precision arithmetic over the exact mantissa * 2^exponent decomposition. Mode validation was also tightened.

Commit 2 — fast path. A fast path that avoids the arbitrary precision arithmetic while producing the exact same correctly rounded result, falling back to the reference path when it cannot guarantee exactness.

  • Where the hardware has a fused-multiply-add (FMA3 on x86, baseline on Arm64), |value| * 10^digits is formed as an exact hi + lo double-double via FusedMultiplyAdd; the correctly rounded integer part is then determined directly and materialized with a single correctly rounded division while the scaled value stays below the point where every value is already an integer. This path is shared generically by both float and double.
  • Off such hardware, where a scalar FusedMultiplyAdd degrades to slow software emulation, the fast path instead uses exact integer arithmetic (ulong/UInt128 for double, ulong for float).

The Fma.IsSupported || AdvSimd.Arm64.IsSupported guard is referenced directly so it folds to a JIT constant at its single use site.

This is a behavioral break; a breaking-change doc will be filed against dotnet/docs. Tests were added covering the corrected results, subnormal/large-shift inputs, and invalid MidpointRounding values; all Math/MathF tests pass on both the FMA and integer-fallback paths (the latter validated with DOTNET_EnableAVX2=0).

Note

This PR description was drafted by GitHub Copilot on @tannergooding's behalf.

Math.Round(value, digits, mode) previously scaled the input by 10^digits,
rounded, then unscaled. The scaling step is inexact, so values just below a
decimal midpoint (e.g. 655.924999999999954525... for the literal 655.925) were
turned into an exact midpoint and rounded the wrong way, and large magnitudes
lost their fractional bits entirely before rounding.

Round the exact value of the input instead, using the internal Number.BigInteger
machinery, and convert the correctly rounded decimal result back to the nearest
representable value via NumberToFloat. This matches the value already produced by
the F{digits} format and is correct for all finite inputs.

The threshold below which a fractional portion is possible is now the exact
integer boundary (2^52 for double, 2^23 for float); at or above it every
representable value is already an integer.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 12, 2026 01:59
@tannergooding tannergooding added the breaking-change Issue or PR that represents a breaking API or functional change over a previous release. label Jul 12, 2026
@dotnet-policy-service dotnet-policy-service Bot added the needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet label Jul 12, 2026

Copilot AI 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 PR changes the implementation of Math.Round(double, digits, mode) and MathF.Round(float, digits, mode) to compute results based on the exact IEEE-754 input value (rather than rounding after an inexact value * 10^digits scaling step), and adds a fast path intended to match the reference result while avoiding arbitrary-precision work in common cases.

Changes:

  • Replace the Round(value * 10^digits) / 10^digits approach with Number.TryRoundToDecimalDigitsFast and a Number.RoundToDecimalDigits fallback to ensure correctly rounded results for the exact input.
  • Tighten MidpointRounding validation for the digits overloads and add targeted tests that assert exact bitwise results for corrected cases.
  • Add a new CoreLib implementation file (System/Number.Rounding.cs) and wire it into the shared CoreLib project items.
Show a summary per file
File Description
src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs Adds exact-value rounding test vectors and invalid MidpointRounding validation tests for MathF.Round(..., digits, mode).
src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs Adds exact-value rounding test vectors and invalid MidpointRounding validation tests for Math.Round(..., digits, mode).
src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs Introduces reference rounding via exact decomposition + BigInteger and a fast path (FMA / integer) intended to match the reference results.
src/libraries/System.Private.CoreLib/src/System/MathF.cs Updates MathF.Round(float, digits, mode) to validate mode, apply the integer boundary check, and call the new Number helpers.
src/libraries/System.Private.CoreLib/src/System/Math.cs Updates Math.Round(double, digits, mode) similarly to use the new rounding helpers and integer boundary check.
src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems Adds the new System\\Number.Rounding.cs file to the shared CoreLib compilation inputs.

Copilot's findings

  • Files reviewed: 6/6 changed files
  • Comments generated: 4

Comment thread src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs Outdated
Comment thread src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs Outdated
Comment thread src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs Outdated
Comment thread src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs Outdated
The exact BigInteger-based routine is only needed when scaling the input by
10^digits cannot be done exactly. Compute |value| * 10^digits as an exact
hi + lo double-double via a fused-multiply-add; 10^digits is exactly
representable across the supported digits range, so lo recovers the single
rounding folded into hi exactly.

From that exact scaled value we determine the correctly rounded integer part
directly (using an exact two-sum residual to compare against the midpoint) and
materialize the result with a single correctly rounded division, so long as the
rounded integer is exactly representable. Fall back to RoundToDecimalDigits
otherwise (namely once the scaled value reaches the integer boundary).

This produces bit-identical results to the exact routine (validated against a
BigInteger reference over 20M+ inputs across all digits and modes) while
avoiding the arbitrary precision arithmetic for the common in-range cases.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 12, 2026 02:19
@tannergooding
tannergooding force-pushed the tannergooding-cuddly-robot branch from db3d4de to 2f20899 Compare July 12, 2026 02:19

Copilot AI 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: 6/6 changed files
  • Comments generated: 2

Comment thread src/libraries/System.Private.CoreLib/src/System/Math.cs Outdated
Comment thread src/libraries/System.Private.CoreLib/src/System/MathF.cs Outdated
…load

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 12, 2026 02:37

Copilot AI 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: 6/6 changed files
  • Comments generated: 1

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 12, 2026 02:52

Copilot AI 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: 6/6 changed files
  • Comments generated: 2

tannergooding and others added 3 commits July 13, 2026 07:20
The vectorized scale-round-unscale path produced results that diverged from
the now-corrected scalar T.Round at the decimal midpoints. Defer to the scalar
implementation for every element so the two agree on all hardware; a correctly
rounded vectorized path is left as a future improvement.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Assert.Equal(double, double, precision) rounds both operands via Math.Round,
which is now exact, so two values that differ by a single ulp can straddle a
14th-decimal boundary and round differently. Compare against an absolute
variance instead, which is equivalent in strictness without the rounding-boundary
fragility.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The 0-15 (double) / 0-6 (float) cap was an artifact of the old
scale-by-10^digits approach. The exact routine is correct for any digit
count, so accept any non-negative digits and only throw for negative
values. Counts the fast path can't handle route straight to the exact
RoundToDecimalDigits, which gains a no-op gate that returns the value
unchanged once it is an exact multiple of 10^-digits -- this also bounds
the arbitrary-precision work so the BigInteger and digit buffer stay
within their fixed capacities.

Consolidate the now-identical MathF throw helper and resource string into
the shared non-negative variant.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 13, 2026 16:18

Copilot AI 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: 10/10 changed files
  • Comments generated: 3

@jeffhandley

Copy link
Copy Markdown
Member

I am supportive of this change in .NET 11, favoring correctness going forward over bugward compatibility of incorrect math. We generally have the position of making fixes to math operations for improved correctness.

tannergooding and others added 3 commits July 13, 2026 09:41
The scalar T.Round(x, digits, mode) no longer caps digits, so the
float/double-specific 6/15 limits here diverged from the documented
contract that this overload computes T.Round element-wise. Since the
digit path already falls back to the scalar operator for every element,
drop the type-specific caps in favor of a single up-front non-negative
check that applies to all T, matching the mode validation.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
The fast-path gate was carried over from the old public cap (15 double /
6 float), but the FMA and integer paths stay correctly rounded as long as
10^digits is exact. That holds through 19 digits for double -- bounded by
the integer fallback scaling by (ulong)10^digits -- and 10 for float,
bounded by 10^digits being exactly representable as a float. Raise the
gate to those true maxima so digits 16-19 (double) and 7-10 (float) take
the fast path instead of the arbitrary-precision routine; larger counts
still fall back to it.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Set DOTNET_EnableHWIntrinsic through the environment indexer rather than
Add, which throws if the variable is already present in the parent
environment. Skip vectors that can never reach the integer fast path
(digits == 0, magnitudes at or above the integer boundary, or digit
counts beyond the fast-path range) so the remote run stays focused on the
fallback and avoids redundant arbitrary-precision work.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
Copilot AI review requested due to automatic review settings July 13, 2026 17:01

Copilot AI 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: 11/11 changed files
  • Comments generated: 0 new

@jeffhandley jeffhandley left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good with me, but I'd prefer to get a re-review from @eiriktsarpalis before we merge as I don't have enough context for the new commits today.

@tannergooding

Copy link
Copy Markdown
Member Author

/ba-g unrelated failure already fixed on main

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area-System.Numerics breaking-change Issue or PR that represents a breaking API or functional change over a previous release. needs-breaking-change-doc-created Breaking changes need an issue opened with https://github.com/dotnet/docs/issues/new?template=dotnet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Math.Round(value, digits, mode) does not return the correct result

4 participants