From b6db67ce0cbfaa3dfbb1abe376481b5a58f96f53 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Sat, 11 Jul 2026 14:59:40 -0700 Subject: [PATCH 01/10] Fix incorrect rounding in Math.Round and MathF.Round with digits 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> --- .../System.Private.CoreLib.Shared.projitems | 1 + .../System.Private.CoreLib/src/System/Math.cs | 24 +-- .../src/System/MathF.cs | 23 +-- .../src/System/Number.Rounding.cs | 149 ++++++++++++++++++ .../System/Math.cs | 50 ++++++ .../System/MathF.cs | 39 +++++ 6 files changed, 265 insertions(+), 21 deletions(-) create mode 100644 src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs diff --git a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems index 11aee01cb8f771..9670007e075ff4 100644 --- a/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems +++ b/src/libraries/System.Private.CoreLib/src/System.Private.CoreLib.Shared.projitems @@ -617,6 +617,7 @@ + diff --git a/src/libraries/System.Private.CoreLib/src/System/Math.cs b/src/libraries/System.Private.CoreLib/src/System/Math.cs index ef2da391b440df..127396338fa7f9 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Math.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Math.cs @@ -29,14 +29,9 @@ public static partial class Math private const int maxRoundingDigits = 15; - private const double doubleRoundLimit = 1e16d; - - // This table is required for the Round function which can specify the number of digits to round to - private static ReadOnlySpan RoundPower10Double => - [ - 1E0, 1E1, 1E2, 1E3, 1E4, 1E5, 1E6, 1E7, 1E8, - 1E9, 1E10, 1E11, 1E12, 1E13, 1E14, 1E15 - ]; + // Below this boundary a double may have a fractional portion; at or above it every + // representable value is already an integer (2^52). + private const double doubleIntegerBoundary = 4503599627370496.0; private const double SCALEB_C1 = 8.98846567431158E+307; // 0x1p1023 @@ -1411,10 +1406,17 @@ public static double Round(double value, int digits, MidpointRounding mode) ThrowHelper.ThrowArgumentOutOfRange_RoundingDigits(nameof(digits)); } - if (Abs(value) < doubleRoundLimit) + if ((uint)mode > (uint)MidpointRounding.ToPositiveInfinity) + { + ThrowHelper.ThrowArgumentException_InvalidEnumValue(mode); + } + + // Only finite values with a magnitude below the integer boundary can have a fractional + // portion to round. All other values (including NaN and Infinity) are returned unchanged; + // this comparison is naturally false for those cases. + if (Abs(value) < doubleIntegerBoundary) { - double power10 = RoundPower10Double[digits]; - value = Round(value * power10, mode) / power10; + value = Number.RoundToDecimalDigits(value, digits, mode); } return value; diff --git a/src/libraries/System.Private.CoreLib/src/System/MathF.cs b/src/libraries/System.Private.CoreLib/src/System/MathF.cs index 01f0d31f701694..5daeaa9a8eb237 100644 --- a/src/libraries/System.Private.CoreLib/src/System/MathF.cs +++ b/src/libraries/System.Private.CoreLib/src/System/MathF.cs @@ -28,13 +28,9 @@ public static partial class MathF private const int maxRoundingDigits = 6; - // This table is required for the Round function which can specify the number of digits to round to - private static ReadOnlySpan RoundPower10Single => - [ - 1e0f, 1e1f, 1e2f, 1e3f, 1e4f, 1e5f, 1e6f - ]; - - private const float singleRoundLimit = 1e8f; + // Below this boundary a float may have a fractional portion; at or above it every + // representable value is already an integer (2^23). + private const float singleIntegerBoundary = 8388608.0f; private const float SCALEB_C1 = 1.7014118E+38f; // 0x1p127f @@ -434,10 +430,17 @@ public static float Round(float x, int digits, MidpointRounding mode) ThrowHelper.ThrowArgumentOutOfRange_RoundingDigits_MathF(nameof(digits)); } - if (Abs(x) < singleRoundLimit) + if ((uint)mode > (uint)MidpointRounding.ToPositiveInfinity) + { + ThrowHelper.ThrowArgumentException_InvalidEnumValue(mode); + } + + // Only finite values with a magnitude below the integer boundary can have a fractional + // portion to round. All other values (including NaN and Infinity) are returned unchanged; + // this comparison is naturally false for those cases. + if (Abs(x) < singleIntegerBoundary) { - float power10 = RoundPower10Single[digits]; - x = Round(x * power10, mode) / power10; + x = Number.RoundToDecimalDigits(x, digits, mode); } return x; diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs new file mode 100644 index 00000000000000..ecafea669e9c7e --- /dev/null +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs @@ -0,0 +1,149 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. + +using System.Diagnostics; + +namespace System +{ + internal static partial class Number + { + // Rounds the exact value represented by `value` to `digits` fractional decimal digits + // using the specified `mode`, returning the nearest representable result. + // + // Unlike scaling the input by a power of 10 and rounding, this operates on the exact + // value of the input using arbitrary precision arithmetic and so produces the correctly + // rounded result for all finite inputs, including those that would otherwise appear to be + // a midpoint after an inexact scaling (e.g. `655.924999999999954525...` scaling to exactly + // `65592.5` when multiplied by `100`). + // + // The caller is responsible for handling values which cannot have a fractional portion at + // the requested number of digits (namely non-finite values and values whose magnitude is at + // or above the point where all representable values are integers). + public static unsafe TNumber RoundToDecimalDigits(TNumber value, int digits, MidpointRounding mode) + where TNumber : unmanaged, IBinaryFloatParseAndFormatInfo + { + Debug.Assert(TNumber.IsFinite(value)); + Debug.Assert((uint)digits <= 15); + + bool isNegative = TNumber.IsNegative(value); + + // Decompose the input into `mantissa * 2^exponent`, giving us the exact value. + ulong mantissa = ExtractFractionAndBiasedExponent(value, out int exponent); + + // We want the nearest integer to `|value| * 10^digits`, which is `numerator / denominator` + // where both are computed exactly. The `2^exponent` term stays in the numerator when the + // exponent is non-negative and moves to the denominator otherwise. + + BigInteger.SetUInt64(out BigInteger numerator, mantissa); + BigInteger denominator; + + if (exponent >= 0) + { + numerator.ShiftLeft(exponent); + numerator.MultiplyPow10((uint)digits); + BigInteger.SetUInt32(out denominator, 1); + } + else + { + numerator.MultiplyPow10((uint)digits); + BigInteger.Pow2(-exponent, out denominator); + } + + BigInteger.DivRem(ref numerator, ref denominator, out BigInteger quotient, out BigInteger remainder); + + // The fractional portion we are rounding is `remainder / denominator`. Comparing + // `2 * remainder` against `denominator` tells us whether it is below, exactly at, or + // above the midpoint without any loss of precision. + + bool hasRemainder = !remainder.IsZero(); + remainder.Multiply(2); + int midpointComparison = BigInteger.Compare(ref remainder, ref denominator); + + bool roundUp; + + switch (mode) + { + // Rounds to the nearest value; if the number falls midway, it is rounded to the + // nearest value with an even least significant digit. + case MidpointRounding.ToEven: + roundUp = (midpointComparison > 0) || ((midpointComparison == 0) && !quotient.IsZero() && ((quotient.GetBlock(0) & 1) != 0)); + break; + + // Rounds to the nearest value; if the number falls midway, it is rounded to the + // nearest value away from zero. + case MidpointRounding.AwayFromZero: + roundUp = midpointComparison >= 0; + break; + + // Directed rounding: round toward zero. + case MidpointRounding.ToZero: + roundUp = false; + break; + + // Directed rounding: round toward negative infinity. + case MidpointRounding.ToNegativeInfinity: + roundUp = isNegative && hasRemainder; + break; + + // Directed rounding: round toward positive infinity. + case MidpointRounding.ToPositiveInfinity: + roundUp = !isNegative && hasRemainder; + break; + + default: + ThrowHelper.ThrowArgumentException_InvalidEnumValue(mode); + return default; + } + + if (roundUp) + { + quotient.Add(1); + } + + // `quotient` is now the exactly rounded integer value of `|value| * 10^digits`. The final + // result is the nearest representable value to `quotient * 10^-digits`, which we obtain by + // materializing the decimal digits and letting the shared conversion perform the correctly + // rounded decimal-to-binary step. + + byte* pDigits = stackalloc byte[TNumber.NumberBufferLength]; + NumberBuffer number = new NumberBuffer(NumberBufferKind.FloatingPoint, pDigits, TNumber.NumberBufferLength); + number.IsNegative = isNegative; + + Span buffer = number.Digits; + int digitCount = 0; + + if (!quotient.IsZero()) + { + BigInteger.SetUInt32(out BigInteger ten, 10); + + // Extract the digits least-significant first. + do + { + BigInteger.DivRem(ref quotient, ref ten, out BigInteger newQuotient, out BigInteger digit); + uint digitValue = digit.IsZero() ? 0 : digit.GetBlock(0); + buffer[digitCount++] = (byte)('0' + digitValue); + BigInteger.SetValue(out quotient, ref newQuotient); + } + while (!quotient.IsZero()); + + // The decimal point sits `digits` places to the left of the least significant digit, + // so the scale (number of digits to the left of the decimal point) is `digitCount - digits`. + number.Scale = digitCount - digits; + + // Reorder to most-significant first, as expected by NumberBuffer. + buffer.Slice(0, digitCount).Reverse(); + + // Trailing zeros carry no value and are not stored in a NumberBuffer. + while ((digitCount > 0) && (buffer[digitCount - 1] == '0')) + { + digitCount--; + } + } + + buffer[digitCount] = (byte)'\0'; + number.DigitsCount = digitCount; + + return NumberToFloat(ref number); + } + } +} diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs index 316650591fd64f..b73d1bc05a9e2a 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs @@ -1248,6 +1248,56 @@ public static void Round_Double_Digits_SpecificCases() Assert.Equal(double.NegativeInfinity, Math.Round(double.NegativeInfinity, 3, MidpointRounding.AwayFromZero)); } + public static IEnumerable Round_Double_Digits_ExactValue_TestData() + { + // 655.925 is not exactly representable; the nearest double is 655.924999999999954525..., + // which is just below the 655.925 decimal midpoint. Scaling by 100 produces exactly 65592.5 + // and so the value used to incorrectly round as if it were a midpoint. + yield return new object[] { 655.925, 2, MidpointRounding.ToEven, 655.92 }; + yield return new object[] { 655.925, 2, MidpointRounding.AwayFromZero, 655.92 }; + yield return new object[] { 655.925, 2, MidpointRounding.ToZero, 655.92 }; + yield return new object[] { 655.925, 2, MidpointRounding.ToNegativeInfinity, 655.92 }; + yield return new object[] { 655.925, 2, MidpointRounding.ToPositiveInfinity, 655.93 }; + yield return new object[] { -655.925, 2, MidpointRounding.ToEven, -655.92 }; + yield return new object[] { -655.925, 2, MidpointRounding.AwayFromZero, -655.92 }; + yield return new object[] { -655.925, 2, MidpointRounding.ToZero, -655.92 }; + yield return new object[] { -655.925, 2, MidpointRounding.ToNegativeInfinity, -655.93 }; + yield return new object[] { -655.925, 2, MidpointRounding.ToPositiveInfinity, -655.92 }; + + // Large magnitudes below the integer boundary previously lost their fractional bits when + // scaled by a power of 10 (1111111111111111.5 * 10 is not exactly representable). + yield return new object[] { 1111111111111111.5, 1, MidpointRounding.ToEven, 1111111111111111.5 }; + yield return new object[] { 1111111111111111.5, 1, MidpointRounding.AwayFromZero, 1111111111111111.5 }; + + // 0.25 is an exactly representable decimal midpoint at one fractional digit. + yield return new object[] { 0.25, 1, MidpointRounding.ToEven, 0.2 }; + yield return new object[] { 0.25, 1, MidpointRounding.AwayFromZero, 0.3 }; + yield return new object[] { 0.35, 1, MidpointRounding.ToEven, 0.3 }; // 0.35 -> 0.34999999999999997... + yield return new object[] { 0.35, 1, MidpointRounding.AwayFromZero, 0.3 }; + + // Values at or above the integer boundary (2^52) are already integers and are unchanged. + yield return new object[] { 4503599627370496.0, 5, MidpointRounding.AwayFromZero, 4503599627370496.0 }; + yield return new object[] { 2e16, 3, MidpointRounding.AwayFromZero, 2e16 }; + } + + [Theory] + [MemberData(nameof(Round_Double_Digits_ExactValue_TestData))] + public static void Round_Double_Digits_ExactValue(double value, int digits, MidpointRounding mode, double expected) + { + double actual = Math.Round(value, digits, mode); + Assert.Equal(BitConverter.DoubleToInt64Bits(expected), BitConverter.DoubleToInt64Bits(actual)); + } + + [Theory] + [InlineData(0.5)] // below the integer boundary + [InlineData(9e15)] // between the integer boundary (2^52) and the old 1e16 limit + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public static void Round_Double_Digits_InvalidMidpointRounding_ThrowsArgumentException(double value) + { + AssertExtensions.Throws("mode", () => Math.Round(value, 3, (MidpointRounding)(-1))); + } + [Fact] public static void Sign_Decimal() { diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs index e5d2b224734b13..986ac4f0b016a0 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs @@ -1235,6 +1235,45 @@ public static void Round_Digits(float x, float expected, int digits, MidpointRou AssertExtensions.Equal(expected, MathF.Round(x, digits, mode), CrossPlatformMachineEpsilon * 10); } + public static IEnumerable Round_Digits_ExactValue_TestData() + { + // Cases where scaling the input by a power of 10 produced an inexact intermediate and so + // rounded incorrectly. Expected values are the correctly rounded results of the exact input. + yield return new object[] { 55392.164f, 2, MidpointRounding.AwayFromZero, 55392.16f }; + yield return new object[] { -2052.5215f, 3, MidpointRounding.AwayFromZero, -2052.521f }; + yield return new object[] { 7379.389f, 4, MidpointRounding.ToPositiveInfinity, 7379.389f }; + yield return new object[] { 87101.12f, 5, MidpointRounding.ToZero, 87101.12f }; + yield return new object[] { 20.932291f, 6, MidpointRounding.ToEven, 20.932291f }; + yield return new object[] { -422486.8f, 5, MidpointRounding.ToEven, -422486.8f }; + yield return new object[] { -122072.92f, 2, MidpointRounding.ToNegativeInfinity, -122072.93f }; + + // 0.25 is an exactly representable decimal midpoint at one fractional digit. + yield return new object[] { 0.25f, 1, MidpointRounding.ToEven, 0.2f }; + yield return new object[] { 0.25f, 1, MidpointRounding.AwayFromZero, 0.3f }; + + // Values at or above the integer boundary (2^23) are already integers and are unchanged. + yield return new object[] { 8388608.0f, 5, MidpointRounding.AwayFromZero, 8388608.0f }; + yield return new object[] { 2e7f, 3, MidpointRounding.AwayFromZero, 2e7f }; + } + + [Theory] + [MemberData(nameof(Round_Digits_ExactValue_TestData))] + public static void Round_Digits_ExactValue(float value, int digits, MidpointRounding mode, float expected) + { + float actual = MathF.Round(value, digits, mode); + Assert.Equal(BitConverter.SingleToInt32Bits(expected), BitConverter.SingleToInt32Bits(actual)); + } + + [Theory] + [InlineData(0.5f)] // below the integer boundary + [InlineData(9e6f)] // between the integer boundary (2^23) and the old 1e8 limit + [InlineData(float.NaN)] + [InlineData(float.PositiveInfinity)] + public static void Round_Digits_InvalidMidpointRounding_ThrowsArgumentException(float value) + { + AssertExtensions.Throws("mode", () => MathF.Round(value, 3, (MidpointRounding)(-1))); + } + [Theory] [InlineData(float.NegativeInfinity, unchecked((int)(0x7FFFFFFF)), float.NegativeInfinity, 0)] [InlineData(float.PositiveInfinity, unchecked((int)(0x7FFFFFFF)), float.PositiveInfinity, 0)] From 2f2089923213bc549b53fa4ad2bc92d9dab26fc5 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Sat, 11 Jul 2026 15:12:44 -0700 Subject: [PATCH 02/10] Add a fast path for Math.Round and MathF.Round with digits 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> --- .../System.Private.CoreLib/src/System/Math.cs | 6 +- .../src/System/MathF.cs | 6 +- .../src/System/Number.Rounding.cs | 274 ++++++++++++++++++ .../System/Math.cs | 8 + .../System/MathF.cs | 8 + 5 files changed, 300 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Math.cs b/src/libraries/System.Private.CoreLib/src/System/Math.cs index 127396338fa7f9..0cfa099d6ca333 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Math.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Math.cs @@ -1416,7 +1416,11 @@ public static double Round(double value, int digits, MidpointRounding mode) // this comparison is naturally false for those cases. if (Abs(value) < doubleIntegerBoundary) { - value = Number.RoundToDecimalDigits(value, digits, mode); + if (!Number.TryRoundToDecimalDigitsFast(value, digits, mode, out double rounded)) + { + rounded = Number.RoundToDecimalDigits(value, digits, mode); + } + value = rounded; } return value; diff --git a/src/libraries/System.Private.CoreLib/src/System/MathF.cs b/src/libraries/System.Private.CoreLib/src/System/MathF.cs index 5daeaa9a8eb237..4208aab0069dc6 100644 --- a/src/libraries/System.Private.CoreLib/src/System/MathF.cs +++ b/src/libraries/System.Private.CoreLib/src/System/MathF.cs @@ -440,7 +440,11 @@ public static float Round(float x, int digits, MidpointRounding mode) // this comparison is naturally false for those cases. if (Abs(x) < singleIntegerBoundary) { - x = Number.RoundToDecimalDigits(x, digits, mode); + if (!Number.TryRoundToDecimalDigitsFast(x, digits, mode, out float rounded)) + { + rounded = Number.RoundToDecimalDigits(x, digits, mode); + } + x = rounded; } return x; diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs index ecafea669e9c7e..d4ae80170af9c2 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs @@ -2,11 +2,285 @@ // The .NET Foundation licenses this file to you under the MIT license. using System.Diagnostics; +using System.Numerics; +using System.Runtime.Intrinsics.Arm; +using System.Runtime.Intrinsics.X86; namespace System { internal static partial class Number { + // Attempts to round `value` to `digits` fractional decimal digits without resorting to the + // arbitrary precision arithmetic used by `RoundToDecimalDigits`. It produces the exact same + // (correctly rounded) result as that routine when it succeeds, and returns `false` otherwise + // so the caller can fall back. + // + // The double-double `FusedMultiplyAdd` approach is fastest wherever the hardware provides a + // fused-multiply-add (FMA3 on x86, baseline on Arm64); off such hardware it degrades to slow + // software emulation, so we fall back to the exact integer approach there instead. + public static bool TryRoundToDecimalDigitsFast(double value, int digits, MidpointRounding mode, out double result) + { + Debug.Assert(double.IsFinite(value)); + Debug.Assert((uint)digits <= 15); + Debug.Assert((uint)mode <= (uint)MidpointRounding.ToPositiveInfinity); + + return (Fma.IsSupported || AdvSimd.Arm64.IsSupported) + ? TryRoundToDecimalDigitsViaFusedMultiplyAdd(value, digits, mode, out result) + : TryRoundToDecimalDigitsViaInteger(value, digits, mode, out result); + } + + /// + public static bool TryRoundToDecimalDigitsFast(float value, int digits, MidpointRounding mode, out float result) + { + Debug.Assert(float.IsFinite(value)); + Debug.Assert((uint)digits <= 6); + Debug.Assert((uint)mode <= (uint)MidpointRounding.ToPositiveInfinity); + + return (Fma.IsSupported || AdvSimd.Arm64.IsSupported) + ? TryRoundToDecimalDigitsViaFusedMultiplyAdd(value, digits, mode, out result) + : TryRoundToDecimalDigitsViaInteger(value, digits, mode, out result); + } + + // The scaled value `|value| * 10^digits` is computed as an exact `hi + lo` double-double via a + // fused-multiply-add. `10^digits` is exactly representable for the supported `digits` range, so + // the only rounding is the single one folded into `hi`, which `lo` recovers exactly. We can then + // determine the correctly rounded integer part directly, and materialize the result with a single + // correctly rounded division so long as that integer is exactly representable (which is guaranteed + // while `hi` stays below the point where every value is already an integer). + private static bool TryRoundToDecimalDigitsViaFusedMultiplyAdd(TNumber value, int digits, MidpointRounding mode, out TNumber result) + where TNumber : unmanaged, IBinaryFloatParseAndFormatInfo + { + TNumber one = TNumber.One; + + // `10^digits` is exact for every supported `digits` and comes from the shared powers-of-ten table. + TNumber pow10 = TNumber.CreateTruncating(Pow10DoubleTable[digits]); + + TNumber av = TNumber.Abs(value); + TNumber hi = av * pow10; + TNumber lo = TNumber.FusedMultiplyAdd(av, pow10, -hi); + + // `2^(NormalMantissaBits - 1)` is the point at or above which every representable value is already + // an integer. If the scaled value reaches it, the rounded integer would no longer be exactly + // representable and the final division would no longer be correctly rounded. + TNumber integerBoundary = TNumber.ScaleB(one, TNumber.NormalMantissaBits - 1); + + if (hi >= integerBoundary) + { + result = default; + return false; + } + + bool isNegative = TNumber.IsNegative(value); + TNumber zero = TNumber.Zero; + + // The nearest integer to `hi` is exactly representable, and `hi - rn` is exact (its magnitude + // is at most 1/2). The exact fractional part of the scaled value relative to `rn` is therefore + // `(hi - rn) + lo`, which we hold exactly as the double-double `s + e` via a two-sum. + TNumber rn = TNumber.Round(hi, MidpointRounding.ToEven); + TNumber diff = hi - rn; + + TNumber s = diff + lo; + TNumber bb = s - diff; + TNumber e = (diff - (s - bb)) + (lo - bb); + + int signOfResidual = (s > zero) ? 1 : (s < zero) ? -1 : (e > zero) ? 1 : (e < zero) ? -1 : 0; + bool hasRemainder = (s != zero) || (e != zero); + + TNumber half = one / (one + one); + + // `floor` of the scaled value and how its fractional part compares to the midpoint `0.5`. + TNumber floor; + int midpointComparison; + + if (signOfResidual >= 0) + { + floor = rn; + midpointComparison = CompareResidualToThreshold(s, e, half); + } + else + { + floor = rn - one; + midpointComparison = CompareResidualToThreshold(s, e, -half); + } + + bool isFloorOdd = (long.CreateTruncating(floor) & 1L) != 0L; + bool roundUp = ShouldRoundUp(mode, midpointComparison, isFloorOdd, hasRemainder, isNegative); + + TNumber quotient = roundUp ? (floor + one) : floor; + + // `quotient` and `pow10` are both exact, so this division is correctly rounded and yields the + // nearest representable value to the exactly rounded decimal result. + TNumber rounded = quotient / pow10; + result = isNegative ? -rounded : rounded; + return true; + } + + // Rounds by operating on the exact value directly. With `value = mantissa * 2^exponent`, the scaled + // value `|value| * 10^digits` is the integer `mantissa * 10^digits` right-shifted by `-exponent`, + // so the floor and the exact comparison of the discarded fraction to `1/2` are pure integer work. + // The `mantissa * 10^digits` product stays in a `ulong` for the common case and only widens to a + // `UInt128` when it overflows 64 bits, which cannot happen for `float`. + private static bool TryRoundToDecimalDigitsViaInteger(double value, int digits, MidpointRounding mode, out double result) + { + ulong mantissa = ExtractFractionAndBiasedExponent(value, out int exponent); + bool isNegative = double.IsNegative(value); + + // When `exponent + digits >= 0` the scaled value is already an integer, so `value` is an exact + // multiple of `10^-digits` and rounding leaves it unchanged. + if ((exponent + digits) >= 0) + { + result = value; + return true; + } + + int shift = -exponent; + ulong high = Math.BigMul(mantissa, (ulong)Pow10DoubleTable[digits], out ulong low); + + ulong floor; + int midpointComparison; + bool hasRemainder; + + bool inRange = (high == 0) + ? TryGetFloorAndMidpoint(low, shift, DoubleIntegerBoundaryLog2, out floor, out midpointComparison, out hasRemainder) + : TryGetFloorAndMidpoint(new UInt128(high, low), shift, DoubleIntegerBoundaryLog2, out floor, out midpointComparison, out hasRemainder); + + if (!inRange) + { + result = default; + return false; + } + + bool isFloorOdd = (floor & 1) != 0; + bool roundUp = ShouldRoundUp(mode, midpointComparison, isFloorOdd, hasRemainder, isNegative); + + ulong quotient = roundUp ? (floor + 1) : floor; + + // `quotient` (<= 2^52) and `10^digits` are both exact, so this division is correctly rounded. + double rounded = quotient / Pow10DoubleTable[digits]; + result = isNegative ? -rounded : rounded; + return true; + } + + /// + private static bool TryRoundToDecimalDigitsViaInteger(float value, int digits, MidpointRounding mode, out float result) + { + ulong mantissa = ExtractFractionAndBiasedExponent(value, out int exponent); + bool isNegative = float.IsNegative(value); + + if ((exponent + digits) >= 0) + { + result = value; + return true; + } + + int shift = -exponent; + + // `mantissa` (< 2^24) times `10^digits` (<= 10^6) is at most ~2^44, so it never overflows a ulong. + ulong scaled = mantissa * (ulong)Pow10DoubleTable[digits]; + + if (!TryGetFloorAndMidpoint(scaled, shift, SingleIntegerBoundaryLog2, out ulong floor, out int midpointComparison, out bool hasRemainder)) + { + result = default; + return false; + } + + bool isFloorOdd = (floor & 1) != 0; + bool roundUp = ShouldRoundUp(mode, midpointComparison, isFloorOdd, hasRemainder, isNegative); + + ulong quotient = roundUp ? (floor + 1) : floor; + + // `quotient` (<= 2^23) and `10^digits` are both exact in `float`, so this division is correctly rounded. + float rounded = quotient / (float)Pow10DoubleTable[digits]; + result = isNegative ? -rounded : rounded; + return true; + } + + // `2^IntegerBoundaryLog2` is the point at or above which every representable value is already an + // integer; a floor that reaches it could not be materialized exactly by the final division. + private const int DoubleIntegerBoundaryLog2 = 52; + private const int SingleIntegerBoundaryLog2 = 23; + + // Computes `floor(scaled / 2^shift)` and how the discarded fraction `(scaled mod 2^shift) / 2^shift` + // compares to the `1/2` midpoint, where `scaled` is the exact `mantissa * 10^digits`. Returns false + // when the integer part would not be exactly representable so the caller can fall back. + private static unsafe bool TryGetFloorAndMidpoint(TUInt scaled, int shift, int integerBoundaryLog2, out ulong floor, out int midpointComparison, out bool hasRemainder) + where TUInt : unmanaged, IBinaryInteger, IUnsignedNumber + { + int bitWidth = sizeof(TUInt) * 8; + + if (shift >= bitWidth) + { + // `scaled < 2^bitWidth <= 2^shift`, so the integer part is zero. The midpoint `2^(shift-1)` + // only overlaps the value's range when `shift == bitWidth`; when `shift > bitWidth` the + // fraction is strictly below `1/2`, even when `scaled` is itself zero. + floor = 0; + hasRemainder = scaled != TUInt.Zero; + + if (shift == bitWidth) + { + TUInt half = TUInt.One << (bitWidth - 1); + midpointComparison = (scaled < half) ? -1 : (scaled > half) ? 1 : 0; + } + else + { + midpointComparison = -1; + } + return true; + } + + TUInt integerPart = scaled >> shift; + + if (integerPart >= (TUInt.One << integerBoundaryLog2)) + { + floor = 0; + midpointComparison = 0; + hasRemainder = false; + return false; + } + + TUInt remainder = scaled - (integerPart << shift); + hasRemainder = remainder != TUInt.Zero; + + TUInt half2 = TUInt.One << (shift - 1); + midpointComparison = (remainder < half2) ? -1 : (remainder > half2) ? 1 : 0; + + floor = ulong.CreateTruncating(integerPart); + return true; + } + + // Resolves whether the floor should be incremented for the given rounding `mode`, using the sign of + // the fractional part relative to the `1/2` midpoint (`midpointComparison`) and whether the floor is + // odd. `mode` is validated by the caller, so the final arm is unreachable. + private static bool ShouldRoundUp(MidpointRounding mode, int midpointComparison, bool isFloorOdd, bool hasRemainder, bool isNegative) + { + return mode switch + { + MidpointRounding.ToEven => (midpointComparison > 0) || ((midpointComparison == 0) && isFloorOdd), + MidpointRounding.AwayFromZero => midpointComparison >= 0, + MidpointRounding.ToZero => false, + MidpointRounding.ToNegativeInfinity => isNegative && hasRemainder, + MidpointRounding.ToPositiveInfinity => !isNegative && hasRemainder, + _ => throw new UnreachableException(), + }; + } + + // Compares the exact residual `s + e` against `threshold` (one of `+/-0.5`), returning the sign + // of the difference. `s - threshold` is exact for these inputs, so it decides the result unless + // it is zero, in which case the low part `e` breaks the tie exactly. + private static int CompareResidualToThreshold(TNumber s, TNumber e, TNumber threshold) + where TNumber : unmanaged, IBinaryFloatParseAndFormatInfo + { + TNumber zero = TNumber.Zero; + TNumber g = s - threshold; + + if (g != zero) + { + return (g > zero) ? 1 : -1; + } + + return (e > zero) ? 1 : (e < zero) ? -1 : 0; + } + // Rounds the exact value represented by `value` to `digits` fractional decimal digits // using the specified `mode`, returning the nearest representable result. // diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs index b73d1bc05a9e2a..dd63e337b600d8 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs @@ -1278,6 +1278,14 @@ public static IEnumerable Round_Double_Digits_ExactValue_TestData() // Values at or above the integer boundary (2^52) are already integers and are unchanged. yield return new object[] { 4503599627370496.0, 5, MidpointRounding.AwayFromZero, 4503599627370496.0 }; yield return new object[] { 2e16, 3, MidpointRounding.AwayFromZero, 2e16 }; + + // Subnormals and other tiny magnitudes have an enormous binary exponent, so the fraction sits + // far below the midpoint and rounds to zero except when directed rounding forces it up. + yield return new object[] { double.Epsilon, 15, MidpointRounding.ToEven, 0.0 }; + yield return new object[] { double.Epsilon, 15, MidpointRounding.AwayFromZero, 0.0 }; + yield return new object[] { double.Epsilon, 15, MidpointRounding.ToPositiveInfinity, 1e-15 }; + yield return new object[] { -double.Epsilon, 15, MidpointRounding.ToNegativeInfinity, -1e-15 }; + yield return new object[] { 5e-320, 10, MidpointRounding.ToEven, 0.0 }; } [Theory] diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs index 986ac4f0b016a0..f499e2e9e2247b 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs @@ -1254,6 +1254,14 @@ public static IEnumerable Round_Digits_ExactValue_TestData() // Values at or above the integer boundary (2^23) are already integers and are unchanged. yield return new object[] { 8388608.0f, 5, MidpointRounding.AwayFromZero, 8388608.0f }; yield return new object[] { 2e7f, 3, MidpointRounding.AwayFromZero, 2e7f }; + + // Subnormals and other tiny magnitudes have an enormous binary exponent, so the fraction sits + // far below the midpoint and rounds to zero except when directed rounding forces it up. + yield return new object[] { float.Epsilon, 6, MidpointRounding.ToEven, 0.0f }; + yield return new object[] { float.Epsilon, 6, MidpointRounding.AwayFromZero, 0.0f }; + yield return new object[] { float.Epsilon, 6, MidpointRounding.ToPositiveInfinity, 1e-6f }; + yield return new object[] { -float.Epsilon, 6, MidpointRounding.ToNegativeInfinity, -1e-6f }; + yield return new object[] { 1.5e-40f, 6, MidpointRounding.ToEven, 0.0f }; } [Theory] From 11b4aab74bf33465e5359ede1b41916459fd7dad Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Sat, 11 Jul 2026 19:37:30 -0700 Subject: [PATCH 03/10] Fast-path zero-digit Math.Round and MathF.Round to the mode-only overload Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- src/libraries/System.Private.CoreLib/src/System/Math.cs | 7 +++++++ src/libraries/System.Private.CoreLib/src/System/MathF.cs | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/src/libraries/System.Private.CoreLib/src/System/Math.cs b/src/libraries/System.Private.CoreLib/src/System/Math.cs index 0cfa099d6ca333..6bf31f54967983 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Math.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Math.cs @@ -1411,6 +1411,13 @@ public static double Round(double value, int digits, MidpointRounding mode) ThrowHelper.ThrowArgumentException_InvalidEnumValue(mode); } + // Rounding to zero fractional digits is just rounding to an integer, which the dedicated + // overload does with a single hardware instruction on most platforms. + if (digits == 0) + { + return Round(value, mode); + } + // Only finite values with a magnitude below the integer boundary can have a fractional // portion to round. All other values (including NaN and Infinity) are returned unchanged; // this comparison is naturally false for those cases. diff --git a/src/libraries/System.Private.CoreLib/src/System/MathF.cs b/src/libraries/System.Private.CoreLib/src/System/MathF.cs index 4208aab0069dc6..a4f2c336e9857b 100644 --- a/src/libraries/System.Private.CoreLib/src/System/MathF.cs +++ b/src/libraries/System.Private.CoreLib/src/System/MathF.cs @@ -435,6 +435,13 @@ public static float Round(float x, int digits, MidpointRounding mode) ThrowHelper.ThrowArgumentException_InvalidEnumValue(mode); } + // Rounding to zero fractional digits is just rounding to an integer, which the dedicated + // overload does with a single hardware instruction on most platforms. + if (digits == 0) + { + return Round(x, mode); + } + // Only finite values with a magnitude below the integer boundary can have a fractional // portion to round. All other values (including NaN and Infinity) are returned unchanged; // this comparison is naturally false for those cases. From 0eeaa8a01b8611b7693cf6186497cb95a76ed184 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Sat, 11 Jul 2026 19:51:56 -0700 Subject: [PATCH 04/10] Cover the integer rounding fallback with hardware intrinsics disabled Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../System/Math.cs | 28 +++++++++++++++++++ .../System/MathF.cs | 28 +++++++++++++++++++ 2 files changed, 56 insertions(+) diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs index dd63e337b600d8..e49b65a26f15ae 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs @@ -3,7 +3,9 @@ using Xunit; using System.Collections.Generic; +using System.Diagnostics; using System.Runtime.CompilerServices; +using Microsoft.DotNet.RemoteExecutor; #pragma warning disable xUnit1025 // reporting duplicate test cases due to not distinguishing 0.0 from -0.0 @@ -1296,6 +1298,32 @@ public static void Round_Double_Digits_ExactValue(double value, int digits, Midp Assert.Equal(BitConverter.DoubleToInt64Bits(expected), BitConverter.DoubleToInt64Bits(actual)); } + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public static void Round_Double_Digits_ExactValue_IntegerFallback() + { + // The digit-rounding fast path uses a double-double FusedMultiplyAdd implementation where the + // hardware accelerates it (FMA3 on x86, baseline on Arm64) and an exact integer implementation + // otherwise. On accelerated hardware the theory above only covers the FusedMultiplyAdd path, so + // re-run the same vectors with hardware intrinsics disabled to deterministically exercise the + // integer fallback and confirm it produces bit-for-bit identical results. + var psi = new ProcessStartInfo(); + psi.Environment.Add("DOTNET_EnableHWIntrinsic", "0"); + + RemoteExecutor.Invoke(static () => + { + foreach (object[] testData in Round_Double_Digits_ExactValue_TestData()) + { + double value = (double)testData[0]; + int digits = (int)testData[1]; + MidpointRounding mode = (MidpointRounding)testData[2]; + double expected = (double)testData[3]; + + double actual = Math.Round(value, digits, mode); + Assert.Equal(BitConverter.DoubleToInt64Bits(expected), BitConverter.DoubleToInt64Bits(actual)); + } + }, new RemoteInvokeOptions { StartInfo = psi }).Dispose(); + } + [Theory] [InlineData(0.5)] // below the integer boundary [InlineData(9e15)] // between the integer boundary (2^52) and the old 1e16 limit diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs index f499e2e9e2247b..ee1c7358fd06ac 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs @@ -3,6 +3,8 @@ using Xunit; using System.Collections.Generic; +using System.Diagnostics; +using Microsoft.DotNet.RemoteExecutor; #pragma warning disable xUnit1025 // reporting duplicate test cases due to not distinguishing 0.0 from -0.0 @@ -1272,6 +1274,32 @@ public static void Round_Digits_ExactValue(float value, int digits, MidpointRoun Assert.Equal(BitConverter.SingleToInt32Bits(expected), BitConverter.SingleToInt32Bits(actual)); } + [ConditionalFact(typeof(RemoteExecutor), nameof(RemoteExecutor.IsSupported))] + public static void Round_Digits_ExactValue_IntegerFallback() + { + // The digit-rounding fast path uses a double-double FusedMultiplyAdd implementation where the + // hardware accelerates it (FMA3 on x86, baseline on Arm64) and an exact integer implementation + // otherwise. On accelerated hardware the theory above only covers the FusedMultiplyAdd path, so + // re-run the same vectors with hardware intrinsics disabled to deterministically exercise the + // integer fallback and confirm it produces bit-for-bit identical results. + var psi = new ProcessStartInfo(); + psi.Environment.Add("DOTNET_EnableHWIntrinsic", "0"); + + RemoteExecutor.Invoke(static () => + { + foreach (object[] testData in Round_Digits_ExactValue_TestData()) + { + float value = (float)testData[0]; + int digits = (int)testData[1]; + MidpointRounding mode = (MidpointRounding)testData[2]; + float expected = (float)testData[3]; + + float actual = MathF.Round(value, digits, mode); + Assert.Equal(BitConverter.SingleToInt32Bits(expected), BitConverter.SingleToInt32Bits(actual)); + } + }, new RemoteInvokeOptions { StartInfo = psi }).Dispose(); + } + [Theory] [InlineData(0.5f)] // below the integer boundary [InlineData(9e6f)] // between the integer boundary (2^23) and the old 1e8 limit From 8b5374d26f93b7862260fb261b847dc624051690 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 13 Jul 2026 07:20:52 -0700 Subject: [PATCH 05/10] Fall back to scalar rounding for TensorPrimitives.Round with digits 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> --- .../Tensors/netcore/TensorPrimitives.Round.cs | 106 +++--------------- 1 file changed, 13 insertions(+), 93 deletions(-) diff --git a/src/libraries/System.Numerics.Tensors/src/System/Numerics/Tensors/netcore/TensorPrimitives.Round.cs b/src/libraries/System.Numerics.Tensors/src/System/Numerics/Tensors/netcore/TensorPrimitives.Round.cs index ac210cb2d3a293..f7377f92a16ff4 100644 --- a/src/libraries/System.Numerics.Tensors/src/System/Numerics/Tensors/netcore/TensorPrimitives.Round.cs +++ b/src/libraries/System.Numerics.Tensors/src/System/Numerics/Tensors/netcore/TensorPrimitives.Round.cs @@ -113,60 +113,31 @@ public static void Round(ReadOnlySpan x, int digits, MidpointRounding mode return; } - ReadOnlySpan roundPower10; + // The digit-based rounding currently defers to the scalar `T.Round` for every element. A + // correctly-rounded vectorized implementation needs the exact (e.g. double-double or + // arbitrary precision) scaled value to match the scalar result at the midpoints, so that + // acceleration is left as a future improvement. if (typeof(T) == typeof(float)) { - ReadOnlySpan roundPower10Single = [1e0f, 1e1f, 1e2f, 1e3f, 1e4f, 1e5f, 1e6f]; - roundPower10 = Rename(roundPower10Single); + if ((uint)digits > 6) + { + throw new ArgumentOutOfRangeException(nameof(digits)); + } } else if (typeof(T) == typeof(double)) { - Debug.Assert(typeof(T) == typeof(double)); - ReadOnlySpan roundPower10Double = [1e0, 1e1, 1e2, 1e3, 1e4, 1e5, 1e6, 1e7, 1e8, 1e9, 1e10, 1e11, 1e12, 1e13, 1e14, 1e15]; - roundPower10 = Rename(roundPower10Double); - } - else - { - if ((uint)mode > (uint)MidpointRounding.ToPositiveInfinity) + if ((uint)digits > 15) { - throw new ArgumentException(SR.Format(SR.Argument_InvalidEnumValue, mode, typeof(MidpointRounding)), nameof(mode)); + throw new ArgumentOutOfRangeException(nameof(digits)); } - - InvokeSpanIntoSpan(x, new RoundFallbackOperator(digits, mode), destination); - return; } - if ((uint)digits >= (uint)roundPower10.Length) + if ((uint)mode > (uint)MidpointRounding.ToPositiveInfinity) { - throw new ArgumentOutOfRangeException(nameof(digits)); + throw new ArgumentException(SR.Format(SR.Argument_InvalidEnumValue, mode, typeof(MidpointRounding)), nameof(mode)); } - T power10 = roundPower10[digits]; - switch (mode) - { - case MidpointRounding.ToEven: - InvokeSpanIntoSpan(x, new MultiplyRoundDivideOperator>(power10), destination); - return; - - case MidpointRounding.AwayFromZero: - InvokeSpanIntoSpan(x, new MultiplyRoundDivideOperator>(power10), destination); - return; - - case MidpointRounding.ToZero: - InvokeSpanIntoSpan(x, new MultiplyRoundDivideOperator>(power10), destination); - return; - - case MidpointRounding.ToNegativeInfinity: - InvokeSpanIntoSpan(x, new MultiplyRoundDivideOperator>(power10), destination); - return; - - case MidpointRounding.ToPositiveInfinity: - InvokeSpanIntoSpan(x, new MultiplyRoundDivideOperator>(power10), destination); - return; - - default: - throw new ArgumentException(SR.Format(SR.Argument_InvalidEnumValue, mode, typeof(MidpointRounding)), nameof(mode)); - } + InvokeSpanIntoSpan(x, new RoundFallbackOperator(digits, mode), destination); } /// T.Round(x) @@ -279,57 +250,6 @@ public static Vector512 Invoke(Vector512 x) } } - /// (T.Round(x * power10, digits, mode)) / power10 - private readonly struct MultiplyRoundDivideOperator : IStatefulUnaryOperator - where T : IFloatingPoint - where TDelegatedRound : IUnaryOperator - { - private readonly T _factor; - - public MultiplyRoundDivideOperator(T factor) - { - Debug.Assert(typeof(T) == typeof(float) || typeof(T) == typeof(double)); - _factor = factor; - } - - public static bool Vectorizable => true; - - private const float Single_RoundLimit = 1e8f; - private const double Double_RoundLimit = 1e16d; - - public T Invoke(T x) - { - T limit = typeof(T) == typeof(float) ? T.CreateTruncating(Single_RoundLimit) : T.CreateTruncating(Double_RoundLimit); - return T.Abs(x) < limit ? - TDelegatedRound.Invoke(x * _factor) / _factor : - x; - } - - public Vector128 Invoke(Vector128 x) - { - Vector128 limit = Vector128.Create(typeof(T) == typeof(float) ? T.CreateTruncating(Single_RoundLimit) : T.CreateTruncating(Double_RoundLimit)); - return Vector128.ConditionalSelect(Vector128.LessThan(Vector128.Abs(x), limit), - TDelegatedRound.Invoke(x * _factor) / _factor, - x); - } - - public Vector256 Invoke(Vector256 x) - { - Vector256 limit = Vector256.Create(typeof(T) == typeof(float) ? T.CreateTruncating(Single_RoundLimit) : T.CreateTruncating(Double_RoundLimit)); - return Vector256.ConditionalSelect(Vector256.LessThan(Vector256.Abs(x), limit), - TDelegatedRound.Invoke(x * _factor) / _factor, - x); - } - - public Vector512 Invoke(Vector512 x) - { - Vector512 limit = Vector512.Create(typeof(T) == typeof(float) ? T.CreateTruncating(Single_RoundLimit) : T.CreateTruncating(Double_RoundLimit)); - return Vector512.ConditionalSelect(Vector512.LessThan(Vector512.Abs(x), limit), - TDelegatedRound.Invoke(x * _factor) / _factor, - x); - } - } - /// T.Round(x, digits, mode) private readonly struct RoundFallbackOperator(int digits, MidpointRounding mode) : IStatefulUnaryOperator where T : IFloatingPoint From 421bc2b968514da4494ccc4bdc66d6c5d5d21b4f Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 13 Jul 2026 07:20:59 -0700 Subject: [PATCH 06/10] Use a tolerance-based comparison in TimeSpan division tests 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> --- .../tests/System.Runtime.Tests/System/TimeSpanTests.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeSpanTests.cs b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeSpanTests.cs index 0de8d33c4d3565..98e0638c426c69 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeSpanTests.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Tests/System/TimeSpanTests.cs @@ -1775,7 +1775,7 @@ public static void NaNMultiplication() [Theory, MemberData(nameof(MultiplicationTestData))] public static void Division(TimeSpan timeSpan, double factor, TimeSpan expected) { - Assert.Equal(factor, expected / timeSpan, 14); + AssertExtensions.Equal(factor, expected / timeSpan, 1e-14); double divisor = 1.0 / factor; Assert.Equal(expected, timeSpan / divisor); } @@ -1818,7 +1818,7 @@ public static void NamedNaNMultiplication() [Theory, MemberData(nameof(MultiplicationTestData))] public static void NamedDivision(TimeSpan timeSpan, double factor, TimeSpan expected) { - Assert.Equal(factor, expected.Divide(timeSpan), 14); + AssertExtensions.Equal(factor, expected.Divide(timeSpan), 1e-14); double divisor = 1.0 / factor; Assert.Equal(expected, timeSpan.Divide(divisor)); } From d868f88b54ef05451bbeda529b240913a3e7a3f7 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 13 Jul 2026 09:11:10 -0700 Subject: [PATCH 07/10] Lift the digit-count restriction on Math.Round and MathF.Round 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> --- .../src/Resources/Strings.resx | 5 +--- .../System.Private.CoreLib/src/System/Math.cs | 9 ++++-- .../src/System/MathF.cs | 11 ++++--- .../src/System/Number.Rounding.cs | 12 +++++++- .../src/System/ThrowHelper.cs | 6 ---- .../System/Math.cs | 29 +++++++++++++++++++ .../System/MathF.cs | 26 +++++++++++++++++ 7 files changed, 80 insertions(+), 18 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx b/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx index d05352ecb6fa96..186eda04f629eb 100644 --- a/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx +++ b/src/libraries/System.Private.CoreLib/src/Resources/Strings.resx @@ -1935,10 +1935,7 @@ Valid values are between {0} and {1}, inclusive. - Rounding digits must be between 0 and 15, inclusive. - - - Rounding digits must be between 0 and 6, inclusive. + Rounding digits must be greater than or equal to 0. capacity was less than the current size. diff --git a/src/libraries/System.Private.CoreLib/src/System/Math.cs b/src/libraries/System.Private.CoreLib/src/System/Math.cs index 6bf31f54967983..fb377ad4fbdcdc 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Math.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Math.cs @@ -27,7 +27,8 @@ public static partial class Math public const double Tau = 6.283185307179586476925; - private const int maxRoundingDigits = 15; + // The largest digit count the fast rounding path handles; larger counts use the exact routine. + private const int maxFastRoundingDigits = 15; // Below this boundary a double may have a fractional portion; at or above it every // representable value is already an integer (2^52). @@ -1401,7 +1402,7 @@ public static double Round(double value, MidpointRounding mode) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static double Round(double value, int digits, MidpointRounding mode) { - if ((uint)digits > maxRoundingDigits) + if (digits < 0) { ThrowHelper.ThrowArgumentOutOfRange_RoundingDigits(nameof(digits)); } @@ -1423,7 +1424,9 @@ public static double Round(double value, int digits, MidpointRounding mode) // this comparison is naturally false for those cases. if (Abs(value) < doubleIntegerBoundary) { - if (!Number.TryRoundToDecimalDigitsFast(value, digits, mode, out double rounded)) + // The fast path only handles the small-digit range where `10^digits` is exactly + // representable; larger counts fall back to the exact arbitrary-precision routine. + if ((digits > maxFastRoundingDigits) || !Number.TryRoundToDecimalDigitsFast(value, digits, mode, out double rounded)) { rounded = Number.RoundToDecimalDigits(value, digits, mode); } diff --git a/src/libraries/System.Private.CoreLib/src/System/MathF.cs b/src/libraries/System.Private.CoreLib/src/System/MathF.cs index a4f2c336e9857b..202429364346e5 100644 --- a/src/libraries/System.Private.CoreLib/src/System/MathF.cs +++ b/src/libraries/System.Private.CoreLib/src/System/MathF.cs @@ -26,7 +26,8 @@ public static partial class MathF public const float Tau = 6.283185307f; - private const int maxRoundingDigits = 6; + // The largest digit count the fast rounding path handles; larger counts use the exact routine. + private const int maxFastRoundingDigits = 6; // Below this boundary a float may have a fractional portion; at or above it every // representable value is already an integer (2^23). @@ -425,9 +426,9 @@ public static float Round(float x, MidpointRounding mode) [MethodImpl(MethodImplOptions.AggressiveInlining)] public static float Round(float x, int digits, MidpointRounding mode) { - if ((uint)digits > maxRoundingDigits) + if (digits < 0) { - ThrowHelper.ThrowArgumentOutOfRange_RoundingDigits_MathF(nameof(digits)); + ThrowHelper.ThrowArgumentOutOfRange_RoundingDigits(nameof(digits)); } if ((uint)mode > (uint)MidpointRounding.ToPositiveInfinity) @@ -447,7 +448,9 @@ public static float Round(float x, int digits, MidpointRounding mode) // this comparison is naturally false for those cases. if (Abs(x) < singleIntegerBoundary) { - if (!Number.TryRoundToDecimalDigitsFast(x, digits, mode, out float rounded)) + // The fast path only handles the small-digit range where `10^digits` is exactly + // representable; larger counts fall back to the exact arbitrary-precision routine. + if ((digits > maxFastRoundingDigits) || !Number.TryRoundToDecimalDigitsFast(x, digits, mode, out float rounded)) { rounded = Number.RoundToDecimalDigits(x, digits, mode); } diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs index d4ae80170af9c2..faa89fb24ac821 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs @@ -297,13 +297,23 @@ public static unsafe TNumber RoundToDecimalDigits(TNumber value, int di where TNumber : unmanaged, IBinaryFloatParseAndFormatInfo { Debug.Assert(TNumber.IsFinite(value)); - Debug.Assert((uint)digits <= 15); + Debug.Assert(digits >= 0); bool isNegative = TNumber.IsNegative(value); // Decompose the input into `mantissa * 2^exponent`, giving us the exact value. ulong mantissa = ExtractFractionAndBiasedExponent(value, out int exponent); + // When `exponent + digits >= 0` the scaled value is already an integer, so `value` is an + // exact multiple of `10^-digits` and rounding leaves it unchanged. This also bounds the + // work below: only `digits < -exponent` reaches the arbitrary-precision arithmetic, keeping + // both the `BigInteger` and the digit buffer within their fixed capacities. The widening to + // `long` avoids overflow for pathologically large `digits`. + if (((long)exponent + digits) >= 0) + { + return value; + } + // We want the nearest integer to `|value| * 10^digits`, which is `numerator / denominator` // where both are computed exactly. The `2^exponent` term stays in the numerator when the // exponent is non-negative and moves to the denominator otherwise. diff --git a/src/libraries/System.Private.CoreLib/src/System/ThrowHelper.cs b/src/libraries/System.Private.CoreLib/src/System/ThrowHelper.cs index 79b1e6e9aba57a..a353d6b294f498 100644 --- a/src/libraries/System.Private.CoreLib/src/System/ThrowHelper.cs +++ b/src/libraries/System.Private.CoreLib/src/System/ThrowHelper.cs @@ -248,12 +248,6 @@ internal static void ThrowArgumentOutOfRange_RoundingDigits(string name) throw new ArgumentOutOfRangeException(name, SR.ArgumentOutOfRange_RoundingDigits); } - [DoesNotReturn] - internal static void ThrowArgumentOutOfRange_RoundingDigits_MathF(string name) - { - throw new ArgumentOutOfRangeException(name, SR.ArgumentOutOfRange_RoundingDigits_MathF); - } - [DoesNotReturn] internal static void ThrowArgumentOutOfRange_Range(string parameterName, T value, T minInclusive, T maxInclusive) { diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs index e49b65a26f15ae..b94bbafb814abc 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs @@ -1288,6 +1288,26 @@ public static IEnumerable Round_Double_Digits_ExactValue_TestData() yield return new object[] { double.Epsilon, 15, MidpointRounding.ToPositiveInfinity, 1e-15 }; yield return new object[] { -double.Epsilon, 15, MidpointRounding.ToNegativeInfinity, -1e-15 }; yield return new object[] { 5e-320, 10, MidpointRounding.ToEven, 0.0 }; + + // The historical 0-15 digit cap has been lifted; any non-negative digit count is now accepted. + // Rounding to a digit count at or beyond the precision needed to round-trip a double (17) is a + // no-op, since the exactly rounded decimal converts back to the original value. + yield return new object[] { 0.1, 16, MidpointRounding.ToEven, 0.1 }; + yield return new object[] { 0.1, 17, MidpointRounding.ToEven, 0.1 }; + yield return new object[] { 0.1, 20, MidpointRounding.AwayFromZero, 0.1 }; + yield return new object[] { 655.925, 30, MidpointRounding.AwayFromZero, 655.925 }; + yield return new object[] { 0.1, 1000, MidpointRounding.ToEven, 0.1 }; + + // A fractional part first appears beyond the 15th digit here, so rounding at 16 digits is + // meaningful and was unreachable under the old cap. + yield return new object[] { 2.5e-16, 16, MidpointRounding.ToEven, 3e-16 }; + yield return new object[] { 2.5e-16, 16, MidpointRounding.AwayFromZero, 3e-16 }; + + // The smallest subnormals combined with very large digit counts exercise the deepest part of the + // exact arbitrary-precision path (near its worst-case buffer size), which must stay correct. + yield return new object[] { double.Epsilon, 324, MidpointRounding.AwayFromZero, double.Epsilon }; + yield return new object[] { double.Epsilon, 323, MidpointRounding.AwayFromZero, 0.0 }; + yield return new object[] { 2.2250738585072014e-308, 1074, MidpointRounding.AwayFromZero, 2.2250738585072014e-308 }; } [Theory] @@ -1334,6 +1354,15 @@ public static void Round_Double_Digits_InvalidMidpointRounding_ThrowsArgumentExc AssertExtensions.Throws("mode", () => Math.Round(value, 3, (MidpointRounding)(-1))); } + [Theory] + [InlineData(-1)] + [InlineData(int.MinValue)] + public static void Round_Double_Digits_NegativeDigits_ThrowsArgumentOutOfRangeException(int digits) + { + AssertExtensions.Throws("digits", () => Math.Round(1.5, digits)); + AssertExtensions.Throws("digits", () => Math.Round(1.5, digits, MidpointRounding.ToEven)); + } + [Fact] public static void Sign_Decimal() { diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs index ee1c7358fd06ac..ad99483fc35f0a 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs @@ -1264,6 +1264,23 @@ public static IEnumerable Round_Digits_ExactValue_TestData() yield return new object[] { float.Epsilon, 6, MidpointRounding.ToPositiveInfinity, 1e-6f }; yield return new object[] { -float.Epsilon, 6, MidpointRounding.ToNegativeInfinity, -1e-6f }; yield return new object[] { 1.5e-40f, 6, MidpointRounding.ToEven, 0.0f }; + + // The historical 0-6 digit cap has been lifted; any non-negative digit count is now accepted. + // Rounding to a digit count at or beyond the precision needed to round-trip a float (9) is a + // no-op, since the exactly rounded decimal converts back to the original value. + yield return new object[] { 0.1f, 8, MidpointRounding.ToEven, 0.1f }; + yield return new object[] { 0.1f, 10, MidpointRounding.ToEven, 0.1f }; + yield return new object[] { 0.1f, 20, MidpointRounding.AwayFromZero, 0.1f }; + yield return new object[] { 0.1f, 100, MidpointRounding.ToEven, 0.1f }; + + // A fractional part first appears beyond the 6th digit here, so rounding at 8 digits is + // meaningful and was unreachable under the old cap. + yield return new object[] { 2.5e-8f, 8, MidpointRounding.AwayFromZero, 3e-8f }; + + // The smallest subnormals combined with very large digit counts exercise the deepest part of the + // exact arbitrary-precision path (near its worst-case buffer size), which must stay correct. + yield return new object[] { float.Epsilon, 45, MidpointRounding.AwayFromZero, float.Epsilon }; + yield return new object[] { float.Epsilon, 44, MidpointRounding.AwayFromZero, 0.0f }; } [Theory] @@ -1310,6 +1327,15 @@ public static void Round_Digits_InvalidMidpointRounding_ThrowsArgumentException( AssertExtensions.Throws("mode", () => MathF.Round(value, 3, (MidpointRounding)(-1))); } + [Theory] + [InlineData(-1)] + [InlineData(int.MinValue)] + public static void Round_Digits_NegativeDigits_ThrowsArgumentOutOfRangeException(int digits) + { + AssertExtensions.Throws("digits", () => MathF.Round(1.5f, digits)); + AssertExtensions.Throws("digits", () => MathF.Round(1.5f, digits, MidpointRounding.ToEven)); + } + [Theory] [InlineData(float.NegativeInfinity, unchecked((int)(0x7FFFFFFF)), float.NegativeInfinity, 0)] [InlineData(float.PositiveInfinity, unchecked((int)(0x7FFFFFFF)), float.PositiveInfinity, 0)] From bc916821b4e56e298c3640845f93f27ea13f7619 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 13 Jul 2026 09:41:15 -0700 Subject: [PATCH 08/10] Accept any non-negative digits in TensorPrimitives.Round 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> --- .../Tensors/netcore/TensorPrimitives.Round.cs | 22 +++++-------------- .../tests/TensorPrimitives.Generic.cs | 2 +- 2 files changed, 7 insertions(+), 17 deletions(-) diff --git a/src/libraries/System.Numerics.Tensors/src/System/Numerics/Tensors/netcore/TensorPrimitives.Round.cs b/src/libraries/System.Numerics.Tensors/src/System/Numerics/Tensors/netcore/TensorPrimitives.Round.cs index f7377f92a16ff4..78dc033bbfeee5 100644 --- a/src/libraries/System.Numerics.Tensors/src/System/Numerics/Tensors/netcore/TensorPrimitives.Round.cs +++ b/src/libraries/System.Numerics.Tensors/src/System/Numerics/Tensors/netcore/TensorPrimitives.Round.cs @@ -113,23 +113,9 @@ public static void Round(ReadOnlySpan x, int digits, MidpointRounding mode return; } - // The digit-based rounding currently defers to the scalar `T.Round` for every element. A - // correctly-rounded vectorized implementation needs the exact (e.g. double-double or - // arbitrary precision) scaled value to match the scalar result at the midpoints, so that - // acceleration is left as a future improvement. - if (typeof(T) == typeof(float)) + if (digits < 0) { - if ((uint)digits > 6) - { - throw new ArgumentOutOfRangeException(nameof(digits)); - } - } - else if (typeof(T) == typeof(double)) - { - if ((uint)digits > 15) - { - throw new ArgumentOutOfRangeException(nameof(digits)); - } + throw new ArgumentOutOfRangeException(nameof(digits)); } if ((uint)mode > (uint)MidpointRounding.ToPositiveInfinity) @@ -137,6 +123,10 @@ public static void Round(ReadOnlySpan x, int digits, MidpointRounding mode throw new ArgumentException(SR.Format(SR.Argument_InvalidEnumValue, mode, typeof(MidpointRounding)), nameof(mode)); } + // The digit-based rounding defers to the scalar `T.Round` for every element, which accepts any + // non-negative `digits` (matching the scalar API). A correctly-rounded vectorized implementation + // needs the exact (e.g. double-double or arbitrary precision) scaled value to match the scalar + // result at the midpoints, so that acceleration is left as a future improvement. InvokeSpanIntoSpan(x, new RoundFallbackOperator(digits, mode), destination); } diff --git a/src/libraries/System.Numerics.Tensors/tests/TensorPrimitives.Generic.cs b/src/libraries/System.Numerics.Tensors/tests/TensorPrimitives.Generic.cs index a9356f84faa04c..856ea2d719690a 100644 --- a/src/libraries/System.Numerics.Tensors/tests/TensorPrimitives.Generic.cs +++ b/src/libraries/System.Numerics.Tensors/tests/TensorPrimitives.Generic.cs @@ -1666,7 +1666,7 @@ public static IEnumerable RoundData() { foreach (MidpointRounding mode in Enum.GetValues(typeof(MidpointRounding))) { - foreach (int digits in new[] { 0, 1, 4 }) + foreach (int digits in new[] { 0, 1, 4, 20 }) { yield return new object[] { mode, digits }; } From 5ba58d7f453d0d563eea16feebd66006a939fd67 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 13 Jul 2026 09:51:52 -0700 Subject: [PATCH 09/10] Extend the fast rounding path to its full exact digit range 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> --- src/libraries/System.Private.CoreLib/src/System/Math.cs | 5 +++-- src/libraries/System.Private.CoreLib/src/System/MathF.cs | 5 +++-- .../System.Private.CoreLib/src/System/Number.Rounding.cs | 6 +++--- .../tests/System.Runtime.Extensions.Tests/System/Math.cs | 9 +++++++++ .../System.Runtime.Extensions.Tests/System/MathF.cs | 9 +++++++++ 5 files changed, 27 insertions(+), 7 deletions(-) diff --git a/src/libraries/System.Private.CoreLib/src/System/Math.cs b/src/libraries/System.Private.CoreLib/src/System/Math.cs index fb377ad4fbdcdc..2e7340a1c4a7d9 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Math.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Math.cs @@ -27,8 +27,9 @@ public static partial class Math public const double Tau = 6.283185307179586476925; - // The largest digit count the fast rounding path handles; larger counts use the exact routine. - private const int maxFastRoundingDigits = 15; + // The largest digit count the fast rounding path handles: `10^digits` must fit a `ulong` for the + // integer fallback (10^19 is the last that does); larger counts use the exact routine. + private const int maxFastRoundingDigits = 19; // Below this boundary a double may have a fractional portion; at or above it every // representable value is already an integer (2^52). diff --git a/src/libraries/System.Private.CoreLib/src/System/MathF.cs b/src/libraries/System.Private.CoreLib/src/System/MathF.cs index 202429364346e5..3571a40be1c8fd 100644 --- a/src/libraries/System.Private.CoreLib/src/System/MathF.cs +++ b/src/libraries/System.Private.CoreLib/src/System/MathF.cs @@ -26,8 +26,9 @@ public static partial class MathF public const float Tau = 6.283185307f; - // The largest digit count the fast rounding path handles; larger counts use the exact routine. - private const int maxFastRoundingDigits = 6; + // The largest digit count the fast rounding path handles: `10^digits` must be exactly representable + // as a `float` (10^10 is the last that is); larger counts use the exact routine. + private const int maxFastRoundingDigits = 10; // Below this boundary a float may have a fractional portion; at or above it every // representable value is already an integer (2^23). diff --git a/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs b/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs index faa89fb24ac821..c6efa31b6c5f29 100644 --- a/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs +++ b/src/libraries/System.Private.CoreLib/src/System/Number.Rounding.cs @@ -21,7 +21,7 @@ internal static partial class Number public static bool TryRoundToDecimalDigitsFast(double value, int digits, MidpointRounding mode, out double result) { Debug.Assert(double.IsFinite(value)); - Debug.Assert((uint)digits <= 15); + Debug.Assert((uint)digits <= 19); Debug.Assert((uint)mode <= (uint)MidpointRounding.ToPositiveInfinity); return (Fma.IsSupported || AdvSimd.Arm64.IsSupported) @@ -33,7 +33,7 @@ public static bool TryRoundToDecimalDigitsFast(double value, int digits, Midpoin public static bool TryRoundToDecimalDigitsFast(float value, int digits, MidpointRounding mode, out float result) { Debug.Assert(float.IsFinite(value)); - Debug.Assert((uint)digits <= 6); + Debug.Assert((uint)digits <= 10); Debug.Assert((uint)mode <= (uint)MidpointRounding.ToPositiveInfinity); return (Fma.IsSupported || AdvSimd.Arm64.IsSupported) @@ -175,7 +175,7 @@ private static bool TryRoundToDecimalDigitsViaInteger(float value, int digits, M int shift = -exponent; - // `mantissa` (< 2^24) times `10^digits` (<= 10^6) is at most ~2^44, so it never overflows a ulong. + // `mantissa` (< 2^24) times `10^digits` (<= 10^10) is at most ~2^57, so it never overflows a ulong. ulong scaled = mantissa * (ulong)Pow10DoubleTable[digits]; if (!TryGetFloorAndMidpoint(scaled, shift, SingleIntegerBoundaryLog2, out ulong floor, out int midpointComparison, out bool hasRemainder)) diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs index b94bbafb814abc..f02851f9c1e1ca 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs @@ -1303,6 +1303,15 @@ public static IEnumerable Round_Double_Digits_ExactValue_TestData() yield return new object[] { 2.5e-16, 16, MidpointRounding.ToEven, 3e-16 }; yield return new object[] { 2.5e-16, 16, MidpointRounding.AwayFromZero, 3e-16 }; + // The fast rounding path is exact through 19 fractional digits for double; exercise the boundary + // (18, 19) and confirm the exact routine takes over just beyond it (20) while staying correct. + yield return new object[] { 2.5e-18, 18, MidpointRounding.ToEven, 3e-18 }; + yield return new object[] { 2.5e-18, 18, MidpointRounding.AwayFromZero, 3e-18 }; + yield return new object[] { 2.5e-19, 19, MidpointRounding.ToEven, 3e-19 }; + yield return new object[] { 2.5e-19, 19, MidpointRounding.AwayFromZero, 3e-19 }; + yield return new object[] { 2.5e-20, 20, MidpointRounding.ToEven, 2e-20 }; + yield return new object[] { 2.5e-20, 20, MidpointRounding.AwayFromZero, 2e-20 }; + // The smallest subnormals combined with very large digit counts exercise the deepest part of the // exact arbitrary-precision path (near its worst-case buffer size), which must stay correct. yield return new object[] { double.Epsilon, 324, MidpointRounding.AwayFromZero, double.Epsilon }; diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs index ad99483fc35f0a..5a19efb27c7af0 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs @@ -1277,6 +1277,15 @@ public static IEnumerable Round_Digits_ExactValue_TestData() // meaningful and was unreachable under the old cap. yield return new object[] { 2.5e-8f, 8, MidpointRounding.AwayFromZero, 3e-8f }; + // The fast rounding path is exact through 10 fractional digits for float; exercise the boundary + // (9, 10) and confirm the exact routine takes over just beyond it (11) while staying correct. + yield return new object[] { 2.5e-9f, 9, MidpointRounding.ToEven, 2e-9f }; + yield return new object[] { 2.5e-9f, 9, MidpointRounding.AwayFromZero, 2e-9f }; + yield return new object[] { 2.5e-10f, 10, MidpointRounding.ToEven, 2e-10f }; + yield return new object[] { 2.5e-10f, 10, MidpointRounding.AwayFromZero, 2e-10f }; + yield return new object[] { 2.5e-11f, 11, MidpointRounding.ToEven, 3e-11f }; + yield return new object[] { 2.5e-11f, 11, MidpointRounding.AwayFromZero, 3e-11f }; + // The smallest subnormals combined with very large digit counts exercise the deepest part of the // exact arbitrary-precision path (near its worst-case buffer size), which must stay correct. yield return new object[] { float.Epsilon, 45, MidpointRounding.AwayFromZero, float.Epsilon }; From 9d73a6f0d8ff0decac373689cef9132b0fd8dfc1 Mon Sep 17 00:00:00 2001 From: Tanner Gooding Date: Mon, 13 Jul 2026 10:01:25 -0700 Subject: [PATCH 10/10] Harden and focus the integer-fallback rounding tests 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> --- .../System.Runtime.Extensions.Tests/System/Math.cs | 14 +++++++++++++- .../System/MathF.cs | 14 +++++++++++++- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs index f02851f9c1e1ca..c185c2baf57d87 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/Math.cs @@ -1336,10 +1336,17 @@ public static void Round_Double_Digits_ExactValue_IntegerFallback() // re-run the same vectors with hardware intrinsics disabled to deterministically exercise the // integer fallback and confirm it produces bit-for-bit identical results. var psi = new ProcessStartInfo(); - psi.Environment.Add("DOTNET_EnableHWIntrinsic", "0"); + psi.Environment["DOTNET_EnableHWIntrinsic"] = "0"; RemoteExecutor.Invoke(static () => { + // The integer fallback is only consulted for finite magnitudes below the integer boundary + // (2^52) with a digit count in the fast-path range; every other vector takes the dedicated + // integer-rounding overload or the arbitrary-precision routine regardless of the intrinsic + // switch, so skip them to keep the remote run focused and avoid redundant work. + const int MaxFastRoundingDigits = 19; + const double IntegerBoundary = 4503599627370496.0; // 2^52 + foreach (object[] testData in Round_Double_Digits_ExactValue_TestData()) { double value = (double)testData[0]; @@ -1347,6 +1354,11 @@ public static void Round_Double_Digits_ExactValue_IntegerFallback() MidpointRounding mode = (MidpointRounding)testData[2]; double expected = (double)testData[3]; + if (digits is < 1 or > MaxFastRoundingDigits || Math.Abs(value) >= IntegerBoundary) + { + continue; + } + double actual = Math.Round(value, digits, mode); Assert.Equal(BitConverter.DoubleToInt64Bits(expected), BitConverter.DoubleToInt64Bits(actual)); } diff --git a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs index 5a19efb27c7af0..a1b67b9ca66efd 100644 --- a/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs +++ b/src/libraries/System.Runtime/tests/System.Runtime.Extensions.Tests/System/MathF.cs @@ -1309,10 +1309,17 @@ public static void Round_Digits_ExactValue_IntegerFallback() // re-run the same vectors with hardware intrinsics disabled to deterministically exercise the // integer fallback and confirm it produces bit-for-bit identical results. var psi = new ProcessStartInfo(); - psi.Environment.Add("DOTNET_EnableHWIntrinsic", "0"); + psi.Environment["DOTNET_EnableHWIntrinsic"] = "0"; RemoteExecutor.Invoke(static () => { + // The integer fallback is only consulted for finite magnitudes below the integer boundary + // (2^23) with a digit count in the fast-path range; every other vector takes the dedicated + // integer-rounding overload or the arbitrary-precision routine regardless of the intrinsic + // switch, so skip them to keep the remote run focused and avoid redundant work. + const int MaxFastRoundingDigits = 10; + const float IntegerBoundary = 8388608.0f; // 2^23 + foreach (object[] testData in Round_Digits_ExactValue_TestData()) { float value = (float)testData[0]; @@ -1320,6 +1327,11 @@ public static void Round_Digits_ExactValue_IntegerFallback() MidpointRounding mode = (MidpointRounding)testData[2]; float expected = (float)testData[3]; + if (digits is < 1 or > MaxFastRoundingDigits || MathF.Abs(value) >= IntegerBoundary) + { + continue; + } + float actual = MathF.Round(value, digits, mode); Assert.Equal(BitConverter.SingleToInt32Bits(expected), BitConverter.SingleToInt32Bits(actual)); }