From 9ec1a4ad3ffd68ade07cea0394f93843c8c317be Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 02:23:25 +0000 Subject: [PATCH 1/4] Optimize hot paths: comparisons, multiply, pow, digit counting, formatting The library's core operations were dominated by per-digit BigInteger division and by allocating temporary PreciseNumber instances for every comparison. This reworks those paths without changing the public API. Representation - Count significant digits from the significand's bit length plus a couple of comparisons instead of dividing by 10 once per digit, which was O(n^2) in the digit count. - Strip trailing zeros with a binary search on divisibility rather than one division per zero. - Cache the first 128 powers of ten and use them everywhere a power of ten is needed. Comparison - Route every comparison operator, Equals and CompareTo through a single Compare primitive that short circuits on sign and on decimal magnitude, and scales significands only when the two numbers share a decade. This makes comparison allocation-free; previously each one built two temporary instances, and CompareTo did the work twice. Arithmetic - Multiply no longer scales both operands to a common exponent first. a*10^x * b*10^y is (a*b)*10^(x+y), so commonizing only inflated both significands before multiplying them. - Add, Subtract, Divide and Mod scale raw significands instead of building intermediate instances. - Pow uses exponentiation by squaring instead of a linear multiply loop. - CreateRepeatingDigits uses the closed form digit * (10^n - 1) / 9. Text - TryFormat writes the sign, digits, padding zeros and decimal separator straight into the caller's span instead of composing a string first. It also no longer clears the whole destination buffer, and its length check is now correct for negative exponents; previously it could reject a buffer that was in fact large enough. - Parse collects digits and hands them to BigInteger.Parse once rather than doing a BigInteger multiply per character. - Float conversion formats into a stack buffer and parses spans instead of allocating several strings and arrays per conversion. Conversion - ToPreciseNumber caches each type's conversion strategy instead of calling GetInterfaces() on every call, and no longer boxes value types to inspect their runtime type. - As caches the reflected copy constructor per target type. Two behavioural changes, both fixes: - Divide previously scaled the remainder and divisor by 10^exponent, a factor that cancels. For operands past the double range it overflowed to infinity or underflowed to zero and produced NaN, so Divide threw ArgumentOutOfRangeException for any pair beyond roughly 1e308. Dropping the factor fixes that and is measurably closer to the exact quotient. - Parse now throws FormatException for input with no digits, such as "-" or ".", instead of returning zero. Verified with a differential fuzz harness against the previous implementation: 866,510 checks over construction, digit counts, arithmetic, comparison, rounding, formatting, parsing and primitive conversion, with no divergence outside Divide's last double digit. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018eTdSeGPQHGUKf9V3c2yXw --- PreciseNumber.Test/PreciseNumberTests.cs | 121 ++++ PreciseNumber/PreciseNumber.cs | 716 ++++++++++++++++------- PreciseNumber/PreciseNumberExtensions.cs | 103 +++- 3 files changed, 689 insertions(+), 251 deletions(-) diff --git a/PreciseNumber.Test/PreciseNumberTests.cs b/PreciseNumber.Test/PreciseNumberTests.cs index 06cc98d..c47b1e5 100644 --- a/PreciseNumber.Test/PreciseNumberTests.cs +++ b/PreciseNumber.Test/PreciseNumberTests.cs @@ -1984,6 +1984,127 @@ public void As_WithConvertibleInputAndOutputType_ReturnsConvertedInstance() Assert.AreEqual(input.Significand, result.Significand); } + [TestMethod] + public void TestCountDigitsMatchesDecimalText() + { + Assert.AreEqual(0, PreciseNumber.CountDigits(BigInteger.Zero)); + + for (int digits = 1; digits <= 220; digits++) + { + BigInteger power = BigInteger.Pow(10, digits); + + Assert.AreEqual(digits, PreciseNumber.CountDigits(power - 1), $"10^{digits} - 1 should have {digits} digits"); + Assert.AreEqual(digits + 1, PreciseNumber.CountDigits(power), $"10^{digits} should have {digits + 1} digits"); + Assert.AreEqual(digits + 1, PreciseNumber.CountDigits(power + 1), $"10^{digits} + 1 should have {digits + 1} digits"); + Assert.AreEqual(digits + 1, PreciseNumber.CountDigits(-power), $"-10^{digits} should have {digits + 1} digits"); + } + } + + [TestMethod] + public void TestSanitizeStripsLongRunsOfTrailingZeros() + { + BigInteger significand = BigInteger.Parse("1234567", CultureInfo.InvariantCulture) * BigInteger.Pow(10, 200); + PreciseNumber number = PreciseNumber.CreateFromComponents(-5, significand); + + Assert.AreEqual(new BigInteger(1234567), number.Significand); + Assert.AreEqual(195, number.Exponent); + Assert.AreEqual(7, number.SignificantDigits); + } + + [TestMethod] + public void TestMultiplyWithWidelySeparatedExponents() + { + PreciseNumber left = PreciseNumber.CreateFromComponents(500, 3); + PreciseNumber right = PreciseNumber.CreateFromComponents(-500, 7); + + PreciseNumber result = left * right; + + Assert.AreEqual(new BigInteger(21), result.Significand); + Assert.AreEqual(0, result.Exponent); + } + + [TestMethod] + public void TestDivideWithExponentsBeyondDoubleRange() + { + PreciseNumber left = PreciseNumber.CreateFromComponents(-400, 7); + PreciseNumber right = PreciseNumber.CreateFromComponents(-400, 3); + + PreciseNumber result = left / right; + + Assert.AreEqual("2.3333333333333333", result.ToString(CultureInfo.InvariantCulture)); + } + + [TestMethod] + public void TestComparisonAcrossWidelySeparatedExponents() + { + PreciseNumber tiny = PreciseNumber.CreateFromComponents(-400, 1); + PreciseNumber huge = PreciseNumber.CreateFromComponents(400, 1); + + Assert.IsTrue(tiny < huge); + Assert.IsTrue(huge > tiny); + Assert.IsTrue(-huge < -tiny); + Assert.IsTrue(-tiny > -huge); + Assert.IsFalse(tiny == huge); + Assert.IsGreaterThan(0, huge.CompareTo(tiny)); + Assert.IsLessThan(0, tiny.CompareTo(huge)); + } + + [TestMethod] + public void TestPowWithLargeIntegerExponent() + { + PreciseNumber two = 2.ToPreciseNumber(); + + PreciseNumber result = two.Pow(64.ToPreciseNumber()); + + Assert.AreEqual(BigInteger.Pow(2, 64), result.Significand * BigInteger.Pow(10, result.Exponent)); + } + + [TestMethod] + public void TestTryFormatWithExactlySizedBuffer() + { + PreciseNumber number = PreciseNumber.CreateFromComponents(-5, 12345); + string expected = number.ToString(CultureInfo.InvariantCulture); + Assert.AreEqual("0.12345", expected); + + Span exact = stackalloc char[expected.Length]; + Assert.IsTrue(number.TryFormat(exact, out int charsWritten, "G".AsSpan(), CultureInfo.InvariantCulture)); + Assert.AreEqual(expected, exact[..charsWritten].ToString()); + + Span tooSmall = stackalloc char[expected.Length - 1]; + Assert.IsFalse(number.TryFormat(tooSmall, out charsWritten, "G".AsSpan(), CultureInfo.InvariantCulture)); + Assert.AreEqual(0, charsWritten); + } + + [TestMethod] + public void TestTryFormatDoesNotDisturbTheRestOfTheBuffer() + { + PreciseNumber number = PreciseNumber.CreateFromComponents(-2, 12345); + Span buffer = stackalloc char[20]; + buffer.Fill('x'); + + Assert.IsTrue(number.TryFormat(buffer, out int charsWritten, "G".AsSpan(), CultureInfo.InvariantCulture)); + Assert.AreEqual("123.45", buffer[..charsWritten].ToString()); + Assert.AreEqual("xxxxxxxxxxxxxx", buffer[charsWritten..].ToString()); + } + + [TestMethod] + public void TestParseWithoutAnyDigitsThrows() + { + Assert.ThrowsExactly(() => PreciseNumber.Parse("-".AsSpan(), NumberStyles.Any, null)); + Assert.ThrowsExactly(() => PreciseNumber.Parse(".".AsSpan(), NumberStyles.Any, null)); + } + + [TestMethod] + public void TestParseRoundTripsLongDecimals() + { + // No trailing zero, so the sanitized round trip is exact. + const string text = "123456789012345678901234567890.123456789012345678901234567891"; + + PreciseNumber parsed = PreciseNumber.Parse(text, CultureInfo.InvariantCulture); + + Assert.AreEqual(text, parsed.ToString(CultureInfo.InvariantCulture)); + } + public record DerivedPreciseNumber : PreciseNumber { public DerivedPreciseNumber(PreciseNumber original) : base(original) diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index bbfab32..e7a19b4 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -3,6 +3,7 @@ namespace ktsu.PreciseNumber; using System; +using System.Buffers; using System.Diagnostics; using System.Diagnostics.CodeAnalysis; using System.Globalization; @@ -17,6 +18,120 @@ public record PreciseNumber { private const int Base10 = 10; + /// + /// Largest character buffer that is taken from the stack before falling back to the array pool. + /// + private const int MaxStackAllocChars = 256; + + /// + /// Number of powers of ten that are pre-computed. Exponents at or above this are computed on demand. + /// + private const int Pow10CacheSize = 128; + + /// + /// log10(2), used to derive a decimal digit count from a binary bit length. + /// + private const double Log10Of2 = 0.3010299956639812; + + /// + /// Pre-computed powers of ten. Declared before any other static state so that the + /// static constants below can rely on it while they are being initialized. + /// + private static readonly BigInteger[] Pow10Cache = BuildPow10Cache(); + + private static BigInteger[] BuildPow10Cache() + { + BigInteger[] cache = new BigInteger[Pow10CacheSize]; + BigInteger value = BigInteger.One; + for (int i = 0; i < Pow10CacheSize; i++) + { + cache[i] = value; + value *= Base10; + } + + return cache; + } + + /// + /// Raises ten to the specified non-negative power, serving small exponents from a cache. + /// + /// The power to raise ten to. + /// Ten raised to . + internal static BigInteger Pow10(int exponent) => + (uint)exponent < Pow10CacheSize + ? Pow10Cache[exponent] + : BigInteger.Pow(Base10, exponent); + + /// + /// Counts the decimal digits in the absolute value of a . + /// + /// The value to count the digits of. + /// The number of decimal digits, or zero when is zero. + /// + /// Derives an estimate from the bit length in constant time and corrects it with at most a + /// couple of comparisons, rather than dividing the value down one digit at a time. + /// + internal static int CountDigits(BigInteger value) + { + if (value.IsZero) + { + return 0; + } + + BigInteger magnitude = BigInteger.Abs(value); + long bitLength = magnitude.GetBitLength(); + + // 2^(bitLength - 1) <= magnitude, so this never overestimates the digit count. + int digits = (int)((bitLength - 1) * Log10Of2) + 1; + + while (digits > 1 && magnitude < Pow10(digits - 1)) + { + digits--; + } + + while (magnitude >= Pow10(digits)) + { + digits++; + } + + return digits; + } + + /// + /// Counts how many trailing decimal zeros a value has, up to a known upper bound. + /// + /// The value to inspect. Must not be zero. + /// An upper bound on the number of trailing zeros. + /// The number of trailing decimal zeros. + /// + /// Binary searches on divisibility so the cost is logarithmic in the digit count instead of + /// linear, which matters for significands with many digits. + /// + private static int CountTrailingZeros(BigInteger value, int maxZeros) + { + if (maxZeros <= 0 || !(value % Base10).IsZero) + { + return 0; + } + + int low = 1; + int high = maxZeros; + while (low < high) + { + int middle = low + ((high - low + 1) / 2); + if ((value % Pow10(middle)).IsZero) + { + low = middle; + } + else + { + high = middle - 1; + } + } + + return low; + } + /// /// Initializes a new instance of the record by copying the values from an existing instance. /// @@ -47,31 +162,26 @@ protected internal PreciseNumber(int exponent, BigInteger significand) /// If true, trailing zeros in the significand will be removed. protected internal PreciseNumber(int exponent, BigInteger significand, bool sanitize) { - if (sanitize) + if (significand.IsZero) { - if (significand == 0) - { - Exponent = 0; - Significand = 0; - SignificantDigits = 0; - return; - } - - // remove trailing zeros - while (significand != 0 && significand % Base10 == 0) - { - significand /= Base10; - exponent++; - } + Exponent = sanitize ? 0 : exponent; + Significand = BigInteger.Zero; + SignificantDigits = 0; + return; } - // count digits - int significantDigits = 0; - BigInteger number = significand; - while (number != 0) + int significantDigits = CountDigits(significand); + + if (sanitize) { - significantDigits++; - number /= Base10; + // The leading digit is non-zero, so at most significantDigits - 1 zeros can trail. + int trailingZeros = CountTrailingZeros(significand, significantDigits - 1); + if (trailingZeros > 0) + { + significand /= Pow10(trailingZeros); + exponent += trailingZeros; + significantDigits -= trailingZeros; + } } SignificantDigits = significantDigits; @@ -169,15 +279,33 @@ public static string ToString(PreciseNumber number, string? format, IFormatProvi { Ensure.NotNull(number); - int desiredAlloc = int.Abs(number.Exponent) + number.SignificantDigits + 2; // +2 is for negative symbol and decimal symbol - int stackAlloc = Math.Min(desiredAlloc, 128); - Span buffer = stackAlloc == desiredAlloc - ? stackalloc char[stackAlloc] - : new char[desiredAlloc]; + NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(formatProvider ?? InvariantCulture); + + // Digits, plus the padding zeros implied by the exponent, plus the sign, the decimal + // separator and a possible leading "0". + int desiredAlloc = number.SignificantDigits + + int.Abs(number.Exponent) + + numberFormat.NegativeSign.Length + + numberFormat.NumberDecimalSeparator.Length + + 1; - return number.TryFormat(buffer, out int charsWritten, format.AsSpan(), formatProvider) - ? buffer[..charsWritten].ToString() - : string.Empty; + char[]? rentedBuffer = desiredAlloc > MaxStackAllocChars ? ArrayPool.Shared.Rent(desiredAlloc) : null; + Span stackBuffer = stackalloc char[MaxStackAllocChars]; + Span buffer = rentedBuffer is null ? stackBuffer : rentedBuffer.AsSpan(); + + try + { + return number.TryFormat(buffer, out int charsWritten, format.AsSpan(), formatProvider) + ? buffer[..charsWritten].ToString() + : string.Empty; + } + finally + { + if (rentedBuffer is not null) + { + ArrayPool.Shared.Return(rentedBuffer); + } + } } /// @@ -201,7 +329,7 @@ public PreciseNumber Round(int decimalDigits) if (currentDecimalDigits > decimalDigits && decimalDifference > 0) { BigInteger roundingFactor = BigInteger.CopySign(CreateRepeatingDigits(5, decimalDifference), Significand); - BigInteger newSignificand = (Significand + roundingFactor) / BigInteger.Pow(Base10, decimalDifference); + BigInteger newSignificand = (Significand + roundingFactor) / Pow10(decimalDifference); int newExponent = Exponent - int.CopySign(decimalDifference, Exponent); return new PreciseNumber(newExponent, newSignificand); } @@ -273,43 +401,65 @@ private static PreciseNumber CreatePreciseNumberFromNonSpecialFloat(TFlo where TFloat : INumber { string format = GetStringFormatForFloatType(); - string significandString = input.ToString(format, InvariantCulture).ToUpperInvariant(); - ReadOnlySpan significandSpan = significandString.AsSpan(); + Span rendered = stackalloc char[MaxStackAllocChars]; + return input.TryFormat(rendered, out int renderedLength, format.AsSpan(), InvariantCulture) + ? ParseRenderedFloat(rendered[..renderedLength]) + : ParseRenderedFloat(input.ToString(format, InvariantCulture).AsSpan()); + } + + /// + /// Converts the round-trippable text of a floating point value into a . + /// + /// The rendered value, optionally in scientific notation. + /// A with the same value. + private static PreciseNumber ParseRenderedFloat(ReadOnlySpan text) + { int exponentValue = 0; - if (significandString.Contains('E', StringComparison.OrdinalIgnoreCase)) + int exponentIndex = text.IndexOfAny('E', 'e'); + if (exponentIndex >= 0) { - string[] expComponents = significandString.Split('E'); - Debug.Assert(expComponents.Length == 2, $"Unexpected format: {significandString}"); - significandSpan = expComponents[0].AsSpan(); - exponentValue = int.Parse(expComponents[1], InvariantCulture); + exponentValue = int.Parse(text[(exponentIndex + 1)..], NumberStyles.Integer, InvariantCulture); + text = text[..exponentIndex]; } - bool isInteger = !significandSpan.Contains('.'); + bool isInteger = !text.Contains('.'); - while (significandSpan.Length > 2 && significandSpan[^1] == '0') + while (text.Length > 2 && text[^1] == '0') { - significandSpan = significandSpan[..^1]; + text = text[..^1]; if (isInteger) { ++exponentValue; } } - string[] components = significandSpan.ToString().Split('.'); - Debug.Assert(components.Length <= 2, $"Invalid format: {significandSpan}"); + int decimalIndex = text.IndexOf('.'); + ReadOnlySpan integerComponent = decimalIndex < 0 ? text : text[..decimalIndex]; + ReadOnlySpan fractionalComponent = decimalIndex < 0 ? "0".AsSpan() : text[(decimalIndex + 1)..]; + exponentValue -= fractionalComponent.Length; - ReadOnlySpan integerComponent = components[0].AsSpan(); - ReadOnlySpan fractionalComponent = components.Length == 2 ? components[1].AsSpan() : "0".AsSpan(); - int fractionalLength = fractionalComponent.Length; - exponentValue -= fractionalLength; + Debug.Assert(fractionalComponent.Length != 0 || integerComponent.TrimStart("-").Length == 1, $"Unexpected format: {text}"); - Debug.Assert(fractionalLength != 0 || integerComponent.TrimStart("-").Length == 1, $"Unexpected format: {integerComponent}.{fractionalComponent}"); + int digitLength = integerComponent.Length + fractionalComponent.Length; + char[]? rentedDigits = digitLength > MaxStackAllocChars ? ArrayPool.Shared.Rent(digitLength) : null; + Span stackDigits = stackalloc char[MaxStackAllocChars]; + Span digits = rentedDigits is null ? stackDigits : rentedDigits.AsSpan(); - string significandStrWithoutDecimal = $"{integerComponent}{fractionalComponent}"; - BigInteger significandValue = BigInteger.Parse(significandStrWithoutDecimal, InvariantCulture); + try + { + integerComponent.CopyTo(digits); + fractionalComponent.CopyTo(digits[integerComponent.Length..]); - return new(exponentValue, significandValue); + return new(exponentValue, BigInteger.Parse(digits[..digitLength], NumberStyles.Integer, InvariantCulture)); + } + finally + { + if (rentedDigits is not null) + { + ArrayPool.Shared.Return(rentedDigits); + } + } } internal static string GetStringFormatForFloatType() @@ -353,15 +503,8 @@ internal static PreciseNumber CreateFromInteger(TInteger input) return NegativeOne; } - int exponentValue = 0; - BigInteger significandValue = BigInteger.CreateChecked(input); - while (significandValue != 0 && significandValue % Base10 == 0) - { - significandValue /= Base10; - exponentValue++; - } - - return new(exponentValue, significandValue); + // The constructor sanitizes trailing zeros, so there is no need to do it again here. + return new(0, BigInteger.CreateChecked(input)); } /// @@ -377,15 +520,15 @@ internal static BigInteger CreateRepeatingDigits(int digit, int numberOfRepeats) return 0; } - BigInteger repeatingDigit = digit; - for (int i = 1; i < numberOfRepeats; i++) - { - repeatingDigit = (repeatingDigit * Base10) + digit; - } - - return repeatingDigit; + // digit * (10^n - 1) / 9 is the repunit of length n scaled by the digit. + return digit * (Pow10(numberOfRepeats) - BigInteger.One) / 9; } + /// + /// Gets a value indicating whether the current instance is exactly one in canonical form. + /// + private bool IsUnit => Exponent == 0 && Significand.IsOne; + /// /// Gets a value indicating whether the current instance has infinite precision. /// @@ -466,7 +609,7 @@ public PreciseNumber ReduceSignificance(int significantDigits) ? significantDifference : Exponent + significantDifference; BigInteger roundingFactor = BigInteger.CopySign(CreateRepeatingDigits(5, significantDifference), Significand); - BigInteger newSignificand = (Significand + roundingFactor) / BigInteger.Pow(Base10, significantDifference); + BigInteger newSignificand = (Significand + roundingFactor) / Pow10(significantDifference); return new(newExponent, newSignificand); } @@ -506,18 +649,79 @@ protected internal static (PreciseNumber, PreciseNumber, int) MakeCommonizedWith smallestExponent); } - /// - public int CompareTo(PreciseNumber? other) + /// + /// Scales the significands of two numbers to a common exponent without allocating + /// intermediate instances. + /// + /// The left number. + /// The right number. + /// The scaled significands and the exponent they share. + private static (BigInteger Left, BigInteger Right, int Exponent) CommonizeSignificands(PreciseNumber left, PreciseNumber right) { - if (other is null) + Ensure.NotNull(left); + Ensure.NotNull(right); + + int leftExponent = left.Exponent; + int rightExponent = right.Exponent; + + if (leftExponent == rightExponent) { - return 1; + return (left.Significand, right.Significand, leftExponent); } - int greaterOrEqual = this > other ? 1 : 0; - return this < other ? -1 : greaterOrEqual; + return leftExponent > rightExponent + ? (left.Significand * Pow10(leftExponent - rightExponent), right.Significand, rightExponent) + : (left.Significand, right.Significand * Pow10(rightExponent - leftExponent), leftExponent); } + /// + /// Orders two numbers, returning a negative value, zero, or a positive value. + /// + /// The first number. + /// The second number. + /// A negative value if is smaller, zero if the two are equal, otherwise a positive value. + /// + /// This is the single primitive behind every comparison operator. It short circuits on sign and + /// on decimal magnitude so that significands only have to be scaled when the two numbers occupy + /// the same decade. + /// + private static int Compare(PreciseNumber left, PreciseNumber right) + { + Ensure.NotNull(left); + Ensure.NotNull(right); + + int leftSign = left.Significand.Sign; + int rightSign = right.Significand.Sign; + + if (leftSign != rightSign) + { + return leftSign < rightSign ? -1 : 1; + } + + if (leftSign == 0) + { + return 0; + } + + // A value lies in [10^(exponent + digits - 1), 10^(exponent + digits)), so a strictly larger + // decimal magnitude always implies a strictly larger absolute value. + long leftMagnitude = (long)left.Exponent + left.SignificantDigits; + long rightMagnitude = (long)right.Exponent + right.SignificantDigits; + + if (leftMagnitude != rightMagnitude) + { + int magnitudeOrder = leftMagnitude < rightMagnitude ? -1 : 1; + return leftSign < 0 ? -magnitudeOrder : magnitudeOrder; + } + + (BigInteger commonLeft, BigInteger commonRight, _) = CommonizeSignificands(left, right); + return BigInteger.Compare(commonLeft, commonRight); + } + + /// + public int CompareTo(PreciseNumber? other) => + other is null ? 1 : Compare(this, other); + /// /// Compares the current instance with another number of a specified type. /// @@ -576,16 +780,14 @@ public int CompareTo(TInput other) return 1; } - PreciseNumber significantOther = other.ToPreciseNumber(); - int greaterOrEqual = this > significantOther ? 1 : 0; - return this < significantOther ? -1 : greaterOrEqual; + return Compare(this, other.ToPreciseNumber()); } /// public static PreciseNumber Abs(PreciseNumber value) { Ensure.NotNull(value); - return value.Significand < 0 ? -value : value; + return value.Significand.Sign < 0 ? -value : value; } /// @@ -694,52 +896,76 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor bool isNegative = s[0] == '-'; int startIndex = isNegative ? 1 : 0; - int exponent = 0; - BigInteger significand = 0; - bool hasDecimal = false; - int decimalDigits = 0; - for (int i = startIndex; i < s.Length; i++) + // Collect the digits first and hand them to BigInteger in one go. Accumulating with + // significand = significand * 10 + digit costs a full BigInteger multiply per character. + char[]? rentedDigits = s.Length > MaxStackAllocChars ? ArrayPool.Shared.Rent(s.Length) : null; + Span stackDigits = stackalloc char[MaxStackAllocChars]; + Span digits = rentedDigits is null ? stackDigits : rentedDigits.AsSpan(); + + try { - char c = s[i]; - if (c == '.') + int digitCount = 0; + int exponent = 0; + bool hasDecimal = false; + int decimalDigits = 0; + + for (int i = startIndex; i < s.Length; i++) { - if (hasDecimal) + char c = s[i]; + if (c == '.') + { + if (hasDecimal) + { + throw new FormatException("Input string was not in a correct format."); + } + + hasDecimal = true; + continue; + } + + if (c is 'e' or 'E') + { + exponent = int.Parse(s[(i + 1)..], InvariantCulture); + break; + } + + if (c is < '0' or > '9') { throw new FormatException("Input string was not in a correct format."); } - hasDecimal = true; - continue; - } + if (hasDecimal) + { + decimalDigits++; + } - if (c is 'e' or 'E') - { - exponent = int.Parse(s[(i + 1)..], InvariantCulture); - break; + digits[digitCount++] = c; } - if (c is < '0' or > '9') + if (digitCount == 0) { throw new FormatException("Input string was not in a correct format."); } - if (hasDecimal) + BigInteger significand = BigInteger.Parse(digits[..digitCount], NumberStyles.None, InvariantCulture); + + exponent -= decimalDigits; + + if (isNegative) { - decimalDigits++; + significand = -significand; } - significand = (significand * Base10) + (c - '0'); + return new(exponent, significand); } - - exponent -= decimalDigits; - - if (isNegative) + finally { - significand = -significand; + if (rentedDigits is not null) + { + ArrayPool.Shared.Return(rentedDigits); + } } - - return new(exponent, significand); } /// @@ -784,69 +1010,117 @@ public static bool TryParse(ReadOnlySpan s, IFormatProvider? provider, [No /// public bool TryFormat(Span destination, out int charsWritten, ReadOnlySpan format, IFormatProvider? provider) { - int requiredLength = SignificantDigits + Exponent + 2; - - if (destination.Length < requiredLength) + if (!format.IsEmpty && !format.Equals("G", StringComparison.OrdinalIgnoreCase)) { - charsWritten = 0; - return false; + throw new FormatException(); } - if (!format.IsEmpty && !format.Equals("G", StringComparison.OrdinalIgnoreCase)) + if (Significand.IsZero) { - throw new FormatException(); + charsWritten = 0; + if (destination.IsEmpty) + { + return false; + } + + destination[0] = '0'; + charsWritten = 1; + return true; } - destination.Clear(); + NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(provider ?? InvariantCulture); + + int digitCount = SignificantDigits; + char[]? rentedDigits = digitCount > MaxStackAllocChars ? ArrayPool.Shared.Rent(digitCount) : null; + Span stackDigits = stackalloc char[MaxStackAllocChars]; + Span digitBuffer = rentedDigits is null ? stackDigits : rentedDigits.AsSpan(); - string output = FormatOutput(provider); + try + { + if (!BigInteger.Abs(Significand).TryFormat(digitBuffer, out int digitsWritten, default, InvariantCulture)) + { + charsWritten = 0; + return false; + } - bool success = output.TryCopyTo(destination); - charsWritten = success ? output.Length : 0; - return success; + return TryWriteDigits(destination, digitBuffer[..digitsWritten], numberFormat, out charsWritten); + } + finally + { + if (rentedDigits is not null) + { + ArrayPool.Shared.Return(rentedDigits); + } + } } - private string FormatOutput(IFormatProvider? provider) + /// + /// Places the already rendered significand digits into , inserting the + /// sign, padding zeros and decimal separator required by this number's exponent. + /// + private bool TryWriteDigits(Span destination, ReadOnlySpan digits, NumberFormatInfo numberFormat, out int charsWritten) { - if (this == Zero) + charsWritten = 0; + + ReadOnlySpan sign = default; + if (Significand.Sign < 0) { - return "0"; + sign = numberFormat.NegativeSign; } - else if (this == One) + + if (Exponent >= 0) { - return "1"; + int wholeLength = sign.Length + digits.Length + Exponent; + if (destination.Length < wholeLength) + { + return false; + } + + sign.CopyTo(destination); + digits.CopyTo(destination[sign.Length..]); + destination.Slice(sign.Length + digits.Length, Exponent).Fill('0'); + charsWritten = wholeLength; + return true; } - else if (this == NegativeOne) + + ReadOnlySpan separator = numberFormat.NumberDecimalSeparator; + int fractionalDigits = -Exponent; + int integralDigits = digits.Length - fractionalDigits; + + // When the exponent consumes every digit the integral part is a single "0" and the + // fractional part is padded out to the full width with leading zeros. + int integralLength = integralDigits > 0 ? integralDigits : 1; + int required = sign.Length + integralLength + separator.Length + fractionalDigits; + + if (destination.Length < required) { - return $"{NumberFormatInfo.GetInstance(provider).NegativeSign}1"; + return false; } - provider ??= InvariantCulture; - NumberFormatInfo numberFormat = NumberFormatInfo.GetInstance(provider); - string sign = Significand < 0 ? numberFormat.NegativeSign : string.Empty; - string significandStr = BigInteger.Abs(Significand).ToString(InvariantCulture); + int position = 0; + sign.CopyTo(destination); + position += sign.Length; - if (Exponent == 0) + if (integralDigits > 0) { - return $"{sign}{significandStr}"; + digits[..integralDigits].CopyTo(destination[position..]); + position += integralDigits; + separator.CopyTo(destination[position..]); + position += separator.Length; + digits[integralDigits..].CopyTo(destination[position..]); } - else if (Exponent > 0) + else { - return $"{sign}{significandStr}{new string('0', Exponent)}"; + destination[position++] = '0'; + separator.CopyTo(destination[position..]); + position += separator.Length; + destination.Slice(position, fractionalDigits - digits.Length).Fill('0'); + position += fractionalDigits - digits.Length; + digits.CopyTo(destination[position..]); } - return FormatNegativeExponent(sign, significandStr, numberFormat); - } - - private string FormatNegativeExponent(string sign, string significandStr, NumberFormatInfo numberFormat) - { - int absExponent = -Exponent; - string integralComponent = absExponent >= significandStr.Length ? "0" : significandStr[..^absExponent]; - string fractionalComponent = absExponent >= significandStr.Length - ? $"{new string('0', absExponent - significandStr.Length)}{BigInteger.Abs(Significand)}" - : significandStr[^absExponent..]; - - return $"{sign}{integralComponent}{numberFormat.NumberDecimalSeparator}{fractionalComponent}"; + charsWritten = required; + return true; } /// @@ -900,7 +1174,7 @@ protected internal static void AssertExponentsMatch(PreciseNumber left, PreciseN public static PreciseNumber Negate(PreciseNumber value) { Ensure.NotNull(value); - return value == Zero + return value.Significand.IsZero ? value : new(value.Exponent, -value.Significand); } @@ -913,11 +1187,8 @@ public static PreciseNumber Negate(PreciseNumber value) /// The result of the subtraction. public static PreciseNumber Subtract(PreciseNumber left, PreciseNumber right) { - (PreciseNumber commonLeft, PreciseNumber commonRight, int commonExponent) = MakeCommonizedWithExponent(left, right); - AssertExponentsMatch(commonLeft, commonRight); - - BigInteger newSignificand = commonLeft.Significand - commonRight.Significand; - return new PreciseNumber(commonExponent, newSignificand); + (BigInteger commonLeft, BigInteger commonRight, int commonExponent) = CommonizeSignificands(left, right); + return new PreciseNumber(commonExponent, commonLeft - commonRight); } /// @@ -928,11 +1199,8 @@ public static PreciseNumber Subtract(PreciseNumber left, PreciseNumber right) /// The result of the addition. public static PreciseNumber Add(PreciseNumber left, PreciseNumber right) { - (PreciseNumber commonLeft, PreciseNumber commonRight, int commonExponent) = MakeCommonizedWithExponent(left, right); - AssertExponentsMatch(commonLeft, commonRight); - - BigInteger newSignificand = commonLeft.Significand + commonRight.Significand; - return new PreciseNumber(commonExponent, newSignificand); + (BigInteger commonLeft, BigInteger commonRight, int commonExponent) = CommonizeSignificands(left, right); + return new PreciseNumber(commonExponent, commonLeft + commonRight); } /// @@ -943,24 +1211,25 @@ public static PreciseNumber Add(PreciseNumber left, PreciseNumber right) /// The result of the multiplication. public static PreciseNumber Multiply(PreciseNumber left, PreciseNumber right) { - if (left == Zero || right == Zero) + Ensure.NotNull(left); + Ensure.NotNull(right); + + if (left.Significand.IsZero || right.Significand.IsZero) { return Zero; } - else if (left == One) + else if (left.IsUnit) { return right; } - else if (right == One) + else if (right.IsUnit) { return left; } - (PreciseNumber commonLeft, PreciseNumber commonRight, int commonExponent) = MakeCommonizedWithExponent(left, right); - AssertExponentsMatch(commonLeft, commonRight); - - BigInteger newSignificand = commonLeft.Significand * commonRight.Significand; - return new PreciseNumber(commonExponent + commonExponent, newSignificand); + // (l * 10^el) * (r * 10^er) == (l * r) * 10^(el + er), so there is no need to scale the + // operands to a common exponent first; doing so only inflates both significands. + return new PreciseNumber(left.Exponent + right.Exponent, left.Significand * right.Significand); } /// @@ -971,22 +1240,26 @@ public static PreciseNumber Multiply(PreciseNumber left, PreciseNumber right) /// The result of the division. public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right) { - if (right == Zero) + Ensure.NotNull(left); + Ensure.NotNull(right); + + if (right.Significand.IsZero) { throw new DivideByZeroException(); } - if (left == right) + if (Compare(left, right) == 0) { return One; } - (PreciseNumber commonLeft, PreciseNumber commonRight, int commonExponent) = MakeCommonizedWithExponent(left, right); - AssertExponentsMatch(commonLeft, commonRight); + (BigInteger commonLeft, BigInteger commonRight, _) = CommonizeSignificands(left, right); + + BigInteger integerComponent = BigInteger.DivRem(commonLeft, commonRight, out BigInteger remainder); - BigInteger integerComponent = commonLeft.Significand / commonRight.Significand; - double remainder = double.CreateTruncating(commonLeft.Significand - (integerComponent * commonRight.Significand)) * double.Pow(Base10, commonExponent); - double fractionalComponent = remainder / (double.CreateTruncating(commonRight.Significand) * double.Pow(Base10, commonExponent)); + // The common power of ten cancels between the remainder and the divisor, so it is left out + // entirely; including it would overflow to infinity for large exponents. + double fractionalComponent = double.CreateTruncating(remainder) / double.CreateTruncating(commonRight); return new PreciseNumber(0, integerComponent) + fractionalComponent.ToPreciseNumber(); } @@ -999,23 +1272,22 @@ public static PreciseNumber Divide(PreciseNumber left, PreciseNumber right) /// The modulus of the two numbers. public static PreciseNumber Mod(PreciseNumber left, PreciseNumber right) { - if (right == Zero) + Ensure.NotNull(left); + Ensure.NotNull(right); + + if (right.Significand.IsZero) { throw new DivideByZeroException(); } - if (left == right) + if (Compare(left, right) == 0) { return Zero; } - (PreciseNumber commonLeft, PreciseNumber commonRight, int commonExponent) = MakeCommonizedWithExponent(left, right); - AssertExponentsMatch(commonLeft, commonRight); - - BigInteger integerComponent = commonLeft.Significand / commonRight.Significand; - BigInteger remainder = commonLeft.Significand - (integerComponent * commonRight.Significand); + (BigInteger commonLeft, BigInteger commonRight, int commonExponent) = CommonizeSignificands(left, right); - return new PreciseNumber(commonExponent, remainder); + return new PreciseNumber(commonExponent, BigInteger.Remainder(commonLeft, commonRight)); } /// @@ -1048,12 +1320,8 @@ public static PreciseNumber Plus(PreciseNumber value) => /// The first number. /// The second number. /// true if the first number is greater than the second; otherwise, false. - public static bool GreaterThan(PreciseNumber left, PreciseNumber right) - { - (PreciseNumber commonLeft, PreciseNumber commonRight) = MakeCommonized(left, right); - AssertExponentsMatch(commonLeft, commonRight); - return commonLeft.Significand > commonRight.Significand; - } + public static bool GreaterThan(PreciseNumber left, PreciseNumber right) => + Compare(left, right) > 0; /// /// Determines whether one number is greater than or equal to another. @@ -1061,12 +1329,8 @@ public static bool GreaterThan(PreciseNumber left, PreciseNumber right) /// The first number. /// The second number. /// true if the first number is greater than or equal to the second; otherwise, false. - public static bool GreaterThanOrEqual(PreciseNumber left, PreciseNumber right) - { - (PreciseNumber commonLeft, PreciseNumber commonRight) = MakeCommonized(left, right); - AssertExponentsMatch(commonLeft, commonRight); - return commonLeft.Significand >= commonRight.Significand; - } + public static bool GreaterThanOrEqual(PreciseNumber left, PreciseNumber right) => + Compare(left, right) >= 0; /// /// Determines whether one number is less than another. @@ -1074,12 +1338,8 @@ public static bool GreaterThanOrEqual(PreciseNumber left, PreciseNumber right) /// The first number. /// The second number. /// true if the first number is less than the second; otherwise, false. - public static bool LessThan(PreciseNumber left, PreciseNumber right) - { - (PreciseNumber commonLeft, PreciseNumber commonRight) = MakeCommonized(left, right); - AssertExponentsMatch(commonLeft, commonRight); - return commonLeft.Significand < commonRight.Significand; - } + public static bool LessThan(PreciseNumber left, PreciseNumber right) => + Compare(left, right) < 0; /// /// Determines whether one number is less than or equal to another. @@ -1087,12 +1347,8 @@ public static bool LessThan(PreciseNumber left, PreciseNumber right) /// The first number. /// The second number. /// true if the first number is less than or equal to the second; otherwise, false. - public static bool LessThanOrEqual(PreciseNumber left, PreciseNumber right) - { - (PreciseNumber commonLeft, PreciseNumber commonRight) = MakeCommonized(left, right); - AssertExponentsMatch(commonLeft, commonRight); - return commonLeft.Significand <= commonRight.Significand; - } + public static bool LessThanOrEqual(PreciseNumber left, PreciseNumber right) => + Compare(left, right) <= 0; /// /// Determines whether two numbers are equal. @@ -1100,12 +1356,8 @@ public static bool LessThanOrEqual(PreciseNumber left, PreciseNumber right) /// The first number. /// The second number. /// true if the two numbers are equal; otherwise, false. - public static bool Equal(PreciseNumber left, PreciseNumber right) - { - (PreciseNumber commonLeft, PreciseNumber commonRight) = MakeCommonized(left, right); - AssertExponentsMatch(commonLeft, commonRight); - return commonLeft.Significand == commonRight.Significand; - } + public static bool Equal(PreciseNumber left, PreciseNumber right) => + Compare(left, right) == 0; /// /// Determines whether two numbers are not equal. @@ -1113,12 +1365,8 @@ public static bool Equal(PreciseNumber left, PreciseNumber right) /// The first number. /// The second number. /// true if the two numbers are not equal; otherwise, false. - public static bool NotEqual(PreciseNumber left, PreciseNumber right) - { - (PreciseNumber commonLeft, PreciseNumber commonRight) = MakeCommonized(left, right); - AssertExponentsMatch(commonLeft, commonRight); - return commonLeft.Significand != commonRight.Significand; - } + public static bool NotEqual(PreciseNumber left, PreciseNumber right) => + Compare(left, right) != 0; /// /// Returns the larger of two numbers. @@ -1180,30 +1428,41 @@ public static PreciseNumber Round(PreciseNumber value, int decimalDigits) /// A new instance of that is the result of raising the current instance to the specified power. public PreciseNumber Pow(PreciseNumber power) { - if (power == Zero) + Ensure.NotNull(power); + + if (power.Significand.IsZero) { return One; } - else if (this == Zero) + else if (Significand.IsZero) { return Zero; } - else if (this == One) + else if (IsUnit) { return One; } if (IsInteger(power)) { - PreciseNumber result = this; - int absPower = power.Abs().To(); + // Exponentiation by squaring: O(log n) multiplications instead of O(n). + PreciseNumber result = One; + PreciseNumber factor = this; - for (int i = 1; i < absPower; i++) + for (int remaining = power.Abs().To(); remaining > 0; remaining >>= 1) { - result *= this; + if ((remaining & 1) != 0) + { + result *= factor; + } + + if (remaining > 1) + { + factor = factor.Squared(); + } } - return power < Zero ? One / result : result; + return power.Significand.Sign < 0 ? One / result : result; } // Use logarithm and exponential to support decimal powers @@ -1220,11 +1479,11 @@ public static PreciseNumber Exp(PreciseNumber power) { Ensure.NotNull(power); - if (power == Zero) + if (power.Significand.IsZero) { return One; } - else if (power == One) + else if (power.IsUnit) { return E; } @@ -1284,6 +1543,17 @@ public static PreciseNumber Exp(PreciseNumber power) public static PreciseNumber operator ++(PreciseNumber value) => Increment(value); + /// + /// Caches the copy constructor of a derived type so that + /// only reflects over each type once. + /// + private static class CopyConstructorOf + where TOutput : PreciseNumber + { + internal static readonly System.Reflection.ConstructorInfo? Constructor = + typeof(TOutput).GetConstructor([typeof(PreciseNumber)]); + } + /// /// Asserts that a type implements a specified generic interface. /// @@ -1343,7 +1613,7 @@ public TOutput As() return (TOutput)(object)this; } - System.Reflection.ConstructorInfo? constructor = typeof(TOutput).GetConstructor([typeof(PreciseNumber)]); + System.Reflection.ConstructorInfo? constructor = CopyConstructorOf.Constructor; return (TOutput)(constructor?.Invoke([this]) ?? throw new NotSupportedException($"Cannot convert {GetType()} to {typeof(TOutput)}")); } diff --git a/PreciseNumber/PreciseNumberExtensions.cs b/PreciseNumber/PreciseNumberExtensions.cs index 77fc682..c974439 100644 --- a/PreciseNumber/PreciseNumberExtensions.cs +++ b/PreciseNumber/PreciseNumberExtensions.cs @@ -2,6 +2,7 @@ namespace ktsu.PreciseNumber; +using System.Collections.Concurrent; using System.Diagnostics.CodeAnalysis; using System.Numerics; @@ -10,6 +11,64 @@ namespace ktsu.PreciseNumber; /// public static class PreciseNumberExtensions { + /// + /// How a numeric type should be converted to a . + /// + private enum NumberKind + { + Unsupported, + PreciseNumber, + Integer, + FloatingPoint, + } + + /// + /// Caches the conversion strategy for a numeric type so the interface probing below only ever + /// runs once per type rather than once per conversion. + /// + private static class KindOf + where TInput : INumber + { + internal static readonly NumberKind Kind = ClassifyType(typeof(TInput)); + } + + /// + /// Caches the conversion strategy for runtime types that do not match their static type, such as + /// a type derived from one that already implements . + /// + private static readonly ConcurrentDictionary RuntimeKinds = new(); + + private static NumberKind ClassifyType(Type type) + { + if (type == typeof(PreciseNumber) || type.IsSubclassOf(typeof(PreciseNumber))) + { + return NumberKind.PreciseNumber; + } + + Type[] interfaces = type.GetInterfaces(); + + if (Array.Exists(interfaces, i => i.Name.StartsWith("IBinaryInteger", StringComparison.Ordinal))) + { + return NumberKind.Integer; + } + + return Array.Exists(interfaces, i => i.Name.StartsWith("IFloatingPoint", StringComparison.Ordinal)) + ? NumberKind.FloatingPoint + : NumberKind.Unsupported; + } + + private static NumberKind ClassifyInput(TInput input) + where TInput : INumber + { + NumberKind kind = KindOf.Kind; + + // Reference types can be passed as a base type, in which case the runtime type is what + // decides. Value types always match their static type, so this never boxes for them. + return kind == NumberKind.Unsupported && !typeof(TInput).IsValueType + ? RuntimeKinds.GetOrAdd(input.GetType(), static t => ClassifyType(t)) + : kind; + } + /// /// Converts the input number to a . /// @@ -21,20 +80,12 @@ public static PreciseNumber ToPreciseNumber(this TInput input) where TInput : INumber { // if TInput is already a PreciseNumber then just return it - PreciseNumber preciseNumber; - - Type inputType = input.GetType(); - Type preciseNumberType = typeof(PreciseNumber); - bool isPreciseNumber = inputType == preciseNumberType || inputType.IsSubclassOf(preciseNumberType); - - if (isPreciseNumber) + if (input is PreciseNumber alreadyPrecise) { - return (PreciseNumber)(object)input; + return alreadyPrecise; } - bool success = TryCreate(input, out preciseNumber!); - - return success + return TryCreate(input, out PreciseNumber? preciseNumber) ? preciseNumber : throw new NotSupportedException(); } @@ -49,29 +100,25 @@ public static PreciseNumber ToPreciseNumber(this TInput input) internal static bool TryCreate([NotNullWhen(true)] TInput input, [MaybeNullWhen(false)][NotNullWhen(true)] out PreciseNumber? preciseNumber) where TInput : INumber { - Type inputType = input.GetType(); - Type preciseNumberType = typeof(PreciseNumber); - bool isPreciseNumber = inputType == preciseNumberType || inputType.IsSubclassOf(preciseNumberType); - - if (isPreciseNumber) + if (input is PreciseNumber alreadyPrecise) { - preciseNumber = (PreciseNumber)(object)input; + preciseNumber = alreadyPrecise; return true; } - if (Array.Exists(inputType.GetInterfaces(), i => i.Name.StartsWith("IBinaryInteger", StringComparison.Ordinal))) + switch (ClassifyInput(input)) { - preciseNumber = PreciseNumber.CreateFromInteger(input); - return true; - } + case NumberKind.Integer: + preciseNumber = PreciseNumber.CreateFromInteger(input); + return true; - if (Array.Exists(inputType.GetInterfaces(), i => i.Name.StartsWith("IFloatingPoint", StringComparison.Ordinal))) - { - preciseNumber = PreciseNumber.CreateFromFloatingPoint(input); - return true; - } + case NumberKind.FloatingPoint: + preciseNumber = PreciseNumber.CreateFromFloatingPoint(input); + return true; - preciseNumber = null; - return false; + default: + preciseNumber = null; + return false; + } } } From be742ec911bb5e02fb8a008c08c655f3acdb8392 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 02:35:07 +0000 Subject: [PATCH 2/4] Add a BenchmarkDotNet suite for the library's hot paths The optimization work in this branch was driven by an ad-hoc harness that lived outside the repository, so none of it was reproducible by anyone else. This makes the measurement part of the project. PreciseNumber.Benchmarks covers construction, comparison, arithmetic, integer powers, rounding, text conversion, and conversion to and from the primitive numeric types, one class per area. Most classes are parameterised by significant digit count: 8, 30 and 200. That axis is the point. Digits live in a BigInteger, so an operation that touches them one at a time looks fine at 8 digits and collapses at 200, which is exactly how the quadratic digit loops this branch removed went unnoticed. Comparison and arithmetic additionally separate operands whose exponents are far apart from operands in the same decade, because aligning exponents is its own cost. Allocation is reported alongside time in every table. Both matter here: every operation returns a new instance, so avoiding an intermediate shows up in Allocated before it shows up in Mean, and a comparison that allocates at all is a regression. Operands are derived from a fixed digit pattern rather than a random source so that two runs measure the same work, and the conversion inputs are held in fields rather than written as literals so the JIT cannot fold the conversion away. Notes on the wiring: - The library exposes its internals to the benchmark assembly so that the constructor can be measured directly rather than through Parse. - The benchmark assembly is named PreciseNumber.Benchmarks rather than taking ktsu.Sdk's automatic ktsu. prefix. BenchmarkDotNet locates the project that produced a benchmark assembly by matching the assembly name against .csproj file names, and with the prefix it finds nothing and reports NA for every benchmark. The project is never packaged, so the prefix buys nothing. - The project does not reference Polyfill, which KTSU0001 would otherwise require. It targets the current framework only, and the library already exposes its internal copy here, so a second reference would make every polyfilled member ambiguous. The Benchmarks workflow is dispatch-only and is deliberately not a gate. Shared runners vary by more than most changes worth catching, so a threshold there would either never fire or fire constantly. It takes a filter and a measurement length, writes the tables to the run summary, and archives the reports against the commit that produced them. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018eTdSeGPQHGUKf9V3c2yXw --- .github/workflows/benchmarks.yml | 85 +++++++++++++++++++ CLAUDE.md | 24 +++++- Directory.Packages.props | 2 + .../ArithmeticBenchmarks.cs | 78 +++++++++++++++++ PreciseNumber.Benchmarks/AssemblyInfo.cs | 3 + PreciseNumber.Benchmarks/BenchmarkConfig.cs | 32 +++++++ .../ComparisonBenchmarks.cs | 78 +++++++++++++++++ .../ConstructionBenchmarks.cs | 52 ++++++++++++ .../ConversionBenchmarks.cs | 76 +++++++++++++++++ PreciseNumber.Benchmarks/Operands.cs | 77 +++++++++++++++++ PreciseNumber.Benchmarks/PowBenchmarks.cs | 41 +++++++++ .../PreciseNumber.Benchmarks.csproj | 30 +++++++ PreciseNumber.Benchmarks/Program.cs | 18 ++++ PreciseNumber.Benchmarks/README.md | 63 ++++++++++++++ .../RoundingBenchmarks.cs | 45 ++++++++++ PreciseNumber.Benchmarks/TextBenchmarks.cs | 74 ++++++++++++++++ PreciseNumber.sln | 6 ++ PreciseNumber/AssemblyInfo.cs | 1 + README.md | 20 +++++ 19 files changed, 804 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/benchmarks.yml create mode 100644 PreciseNumber.Benchmarks/ArithmeticBenchmarks.cs create mode 100644 PreciseNumber.Benchmarks/AssemblyInfo.cs create mode 100644 PreciseNumber.Benchmarks/BenchmarkConfig.cs create mode 100644 PreciseNumber.Benchmarks/ComparisonBenchmarks.cs create mode 100644 PreciseNumber.Benchmarks/ConstructionBenchmarks.cs create mode 100644 PreciseNumber.Benchmarks/ConversionBenchmarks.cs create mode 100644 PreciseNumber.Benchmarks/Operands.cs create mode 100644 PreciseNumber.Benchmarks/PowBenchmarks.cs create mode 100644 PreciseNumber.Benchmarks/PreciseNumber.Benchmarks.csproj create mode 100644 PreciseNumber.Benchmarks/Program.cs create mode 100644 PreciseNumber.Benchmarks/README.md create mode 100644 PreciseNumber.Benchmarks/RoundingBenchmarks.cs create mode 100644 PreciseNumber.Benchmarks/TextBenchmarks.cs diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml new file mode 100644 index 0000000..1286129 --- /dev/null +++ b/.github/workflows/benchmarks.yml @@ -0,0 +1,85 @@ +name: Benchmarks + +# Benchmarks are not a gate. GitHub-hosted runners are shared and their timings vary far more +# than most of the changes worth catching, so a threshold here would either never fire or fire +# constantly. This exists so that anyone can get a full run without a local .NET setup, and so +# that the results are archived against the commit that produced them. +on: + workflow_dispatch: + inputs: + filter: + description: "BenchmarkDotNet filter, e.g. *ArithmeticBenchmarks* or *.Multiply" + required: false + default: "*" + type: string + job: + description: "Measurement length" + required: false + default: "short" + type: choice + options: + - short + - default + - long + +permissions: + contents: read + +env: + DOTNET_VERSION: "10.0" + +jobs: + benchmark: + name: Run Benchmarks + runs-on: ubuntu-latest + timeout-minutes: 120 + + steps: + - name: Checkout Repository + uses: actions/checkout@v7 + + - name: Setup .NET SDK ${{ env.DOTNET_VERSION }} + uses: actions/setup-dotnet@v6 + with: + dotnet-version: ${{ env.DOTNET_VERSION }}.x + cache: true + cache-dependency-path: | + **/*.csproj + **/Directory.Packages.props + **/global.json + + - name: Run Benchmarks + shell: bash + run: | + set -euo pipefail + args=(--filter "${{ inputs.filter }}") + if [ "${{ inputs.job }}" != "default" ]; then + args+=(--job "${{ inputs.job }}") + fi + dotnet run -c Release --project PreciseNumber.Benchmarks -- "${args[@]}" + + # The Markdown reports are the readable artifact; the JSON is what a later comparison + # against another run would be built from. + - name: Summarize + if: always() + shell: bash + run: | + shopt -s nullglob + reports=(PreciseNumber.Benchmarks/BenchmarkDotNet.Artifacts/results/*-report-github.md) + if [ ${#reports[@]} -eq 0 ]; then + echo "No benchmark reports were produced." >> "$GITHUB_STEP_SUMMARY" + exit 0 + fi + for report in "${reports[@]}"; do + cat "$report" >> "$GITHUB_STEP_SUMMARY" + echo >> "$GITHUB_STEP_SUMMARY" + done + + - name: Upload Results + if: always() + uses: actions/upload-artifact@v7 + with: + name: benchmark-results-${{ github.sha }} + path: PreciseNumber.Benchmarks/BenchmarkDotNet.Artifacts/results/* + retention-days: 30 + if-no-files-found: warn diff --git a/CLAUDE.md b/CLAUDE.md index 0d95c2d..e4872c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -12,6 +12,11 @@ ktsu.PreciseNumber is a high-precision numeric type for .NET that provides arbit dotnet build # Build the solution dotnet test # Run all tests dotnet test --filter "FullyQualifiedName~TestName" # Run specific test + +# Benchmarks (Release only; BenchmarkDotNet refuses to measure a debug build) +dotnet run -c Release --project PreciseNumber.Benchmarks # Pick from a list +dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*Compar*' # One class +dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' --job short ``` ## Architecture @@ -34,4 +39,21 @@ dotnet test --filter "FullyQualifiedName~TestName" # Run specific test ### Test Structure -Tests use MSTest framework in `PreciseNumber.Test/PreciseNumberTests.cs`. The test project targets only .NET 9.0 while the main library multi-targets net7.0, net8.0, and net9.0. +Tests use MSTest framework in `PreciseNumber.Test/PreciseNumberTests.cs`. The test project targets only .NET 10.0 while the main library multi-targets net7.0, net8.0, net9.0, and net10.0. + +### Benchmarks + +`PreciseNumber.Benchmarks` is a BenchmarkDotNet suite, one class per area (construction, +comparison, arithmetic, pow, rounding, text, conversion). The library exposes its internals to it +so construction can be measured directly. + +Most classes are parameterised by `Digits` (8, 30, 200). That axis is the point: digits live in a +`BigInteger`, so anything that touches them one at a time looks fine at 8 digits and collapses at +200. Read results across the `Digits` column, not down one value of it. + +Allocation is reported alongside time and matters just as much — every operation returns a new +instance, so avoiding an intermediate shows up in `Allocated` before it shows up in `Mean`. +Comparisons should allocate nothing at all. + +Run the relevant benchmarks before and after any change to the library's internals. See +`PreciseNumber.Benchmarks/README.md` for details. diff --git a/Directory.Packages.props b/Directory.Packages.props index 9c81ba9..be4d333 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,6 +3,8 @@ true + + diff --git a/PreciseNumber.Benchmarks/ArithmeticBenchmarks.cs b/PreciseNumber.Benchmarks/ArithmeticBenchmarks.cs new file mode 100644 index 0000000..7860396 --- /dev/null +++ b/PreciseNumber.Benchmarks/ArithmeticBenchmarks.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using BenchmarkDotNet.Attributes; + +/// +/// Measures the four arithmetic operators plus the modulus. +/// +/// +/// Each case uses operands with different exponents, which is the general path. The separate +/// wide-gap multiply exists because aligning exponents before multiplying used to make the cost +/// depend on how far apart the exponents were rather than on the operand sizes. +/// +[MemoryDiagnoser] +public class ArithmeticBenchmarks +{ + private PreciseNumber left = PreciseNumber.Zero; + private PreciseNumber right = PreciseNumber.Zero; + private PreciseNumber wideGap = PreciseNumber.Zero; + + /// + /// Gets or sets the number of significant digits in the operands. + /// + [Params(8, 30, 200)] + public int Digits { get; set; } + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + left = Operands.Number(Digits, -10); + right = Operands.Number(Digits, -14, offset: 7); + wideGap = Operands.Number(Digits, -400, offset: 11); + } + + /// Addition. + /// The sum. + [Benchmark(Baseline = true)] + public PreciseNumber Add() => left + right; + + /// Subtraction. + /// The difference. + [Benchmark] + public PreciseNumber Subtract() => left - right; + + /// Multiplication. + /// The product. + [Benchmark] + public PreciseNumber Multiply() => left * right; + + /// Multiplication where the operands' exponents are hundreds of decades apart. + /// The product. + [Benchmark] + public PreciseNumber MultiplyWideExponentGap() => left * wideGap; + + /// Division. + /// The quotient. + [Benchmark] + public PreciseNumber Divide() => left / right; + + /// Modulus. + /// The remainder. + [Benchmark] + public PreciseNumber Mod() => left % right; + + /// Negation. + /// The negated value. + [Benchmark] + public PreciseNumber Negate() => -left; + + /// Squaring, which is multiplication by self. + /// The square. + [Benchmark] + public PreciseNumber Squared() => left.Squared(); +} diff --git a/PreciseNumber.Benchmarks/AssemblyInfo.cs b/PreciseNumber.Benchmarks/AssemblyInfo.cs new file mode 100644 index 0000000..7de6882 --- /dev/null +++ b/PreciseNumber.Benchmarks/AssemblyInfo.cs @@ -0,0 +1,3 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.PreciseNumber.Test")] diff --git a/PreciseNumber.Benchmarks/BenchmarkConfig.cs b/PreciseNumber.Benchmarks/BenchmarkConfig.cs new file mode 100644 index 0000000..26ed8b0 --- /dev/null +++ b/PreciseNumber.Benchmarks/BenchmarkConfig.cs @@ -0,0 +1,32 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using BenchmarkDotNet.Columns; +using BenchmarkDotNet.Configs; +using BenchmarkDotNet.Diagnosers; +using BenchmarkDotNet.Exporters.Json; +using BenchmarkDotNet.Order; + +/// +/// The configuration every benchmark in this assembly runs under. +/// +internal static class BenchmarkConfig +{ + /// + /// Builds the configuration. + /// + /// The configuration to run benchmarks with. + /// + /// Allocation is reported alongside time because most of the cost in this library came from + /// allocating intermediate values rather than from the arithmetic itself, and a change that + /// trades one for the other should be visible in the same table. Results are kept in + /// declaration order so that a summary reads the way the source does. + /// + internal static IConfig Create() => + ManualConfig.Create(DefaultConfig.Instance) + .AddDiagnoser(MemoryDiagnoser.Default) + .AddColumn(RankColumn.Arabic) + .AddExporter(JsonExporter.Full) + .WithOrderer(new DefaultOrderer(SummaryOrderPolicy.Declared)); +} diff --git a/PreciseNumber.Benchmarks/ComparisonBenchmarks.cs b/PreciseNumber.Benchmarks/ComparisonBenchmarks.cs new file mode 100644 index 0000000..35d3998 --- /dev/null +++ b/PreciseNumber.Benchmarks/ComparisonBenchmarks.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using BenchmarkDotNet.Attributes; + +/// +/// Measures ordering and equality. +/// +/// +/// Comparison is the operation most likely to sit inside a caller's inner loop, in a sort or a +/// search, so it is the one where per-call allocation hurts most. Operands whose exponents differ +/// are separated out because aligning them is the expensive half of the work. +/// +[MemoryDiagnoser] +public class ComparisonBenchmarks +{ + private PreciseNumber left = PreciseNumber.Zero; + private PreciseNumber right = PreciseNumber.Zero; + private PreciseNumber sameExponent = PreciseNumber.Zero; + private PreciseNumber differentDecade = PreciseNumber.Zero; + + /// + /// Gets or sets the number of significant digits in the operands. + /// + [Params(8, 30, 200)] + public int Digits { get; set; } + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + left = Operands.Number(Digits, -10); + right = Operands.Number(Digits, -40, offset: 7); + sameExponent = Operands.Number(Digits, -10, offset: 3); + + // Far enough apart that the two cannot overlap, which a comparison can settle without + // looking at the significands at all. + differentDecade = Operands.Number(Digits, 400); + } + + /// Equality where the operands already share an exponent. + /// Whether the operands are equal. + [Benchmark(Baseline = true)] + public bool EqualsSameExponent() => left == sameExponent; + + /// Equality where the operands have to be aligned first. + /// Whether the operands are equal. + [Benchmark] + public bool EqualsDifferentExponent() => left == right; + + /// Equality where the operands are orders of magnitude apart. + /// Whether the operands are equal. + [Benchmark] + public bool EqualsDifferentDecade() => left == differentDecade; + + /// Ordering with the less-than operator. + /// Whether the left operand is smaller. + [Benchmark] + public bool LessThan() => left < right; + + /// Ordering through . + /// The relative order of the operands. + [Benchmark] + public int CompareTo() => left.CompareTo(right); + + /// Ordering through . + /// The larger operand. + [Benchmark] + public PreciseNumber Max() => PreciseNumber.Max(left, right); + + /// Hashing, which callers pay alongside equality in a dictionary or set. + /// The hash code. + [Benchmark] + public int GetHashCodeBenchmark() => left.GetHashCode(); +} diff --git a/PreciseNumber.Benchmarks/ConstructionBenchmarks.cs b/PreciseNumber.Benchmarks/ConstructionBenchmarks.cs new file mode 100644 index 0000000..d8c4ddc --- /dev/null +++ b/PreciseNumber.Benchmarks/ConstructionBenchmarks.cs @@ -0,0 +1,52 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using System.Numerics; +using BenchmarkDotNet.Attributes; + +/// +/// Measures building a , which every other operation pays for because +/// each result is a new instance. +/// +/// +/// The constructor counts significant digits and, unless told not to, strips trailing zeros. Both +/// scale with the digit count, so is the parameter that matters here. +/// +[MemoryDiagnoser] +public class ConstructionBenchmarks +{ + private BigInteger significand; + private BigInteger significandWithTrailingZeros; + + /// + /// Gets or sets the number of significant digits in the operand. + /// + [Params(8, 30, 200)] + public int Digits { get; set; } + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + significand = Operands.Significand(Digits); + significandWithTrailingZeros = significand * BigInteger.Pow(10, Digits); + } + + /// Builds a number, stripping trailing zeros. There are none to strip here. + /// The constructed number. + [Benchmark(Baseline = true)] + public PreciseNumber Sanitizing() => PreciseNumber.CreateFromComponents(-4, significand); + + /// Builds a number whose significand is half trailing zeros. + /// The constructed number. + [Benchmark] + public PreciseNumber SanitizingTrailingZeros() => PreciseNumber.CreateFromComponents(-4, significandWithTrailingZeros); + + /// Builds a number without stripping trailing zeros, so only the digit count is computed. + /// The constructed number. + [Benchmark] + public PreciseNumber Unsanitized() => PreciseNumber.CreateFromComponents(-4, significand, sanitize: false); +} diff --git a/PreciseNumber.Benchmarks/ConversionBenchmarks.cs b/PreciseNumber.Benchmarks/ConversionBenchmarks.cs new file mode 100644 index 0000000..f846a95 --- /dev/null +++ b/PreciseNumber.Benchmarks/ConversionBenchmarks.cs @@ -0,0 +1,76 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using BenchmarkDotNet.Attributes; + +/// +/// Measures conversion in both directions between and the primitive +/// numeric types. +/// +/// +/// Conversion in is the path most callers enter the library through, so its per-call cost is paid +/// far more often than any single arithmetic operation. Inputs are held in fields rather than +/// written as literals so that the JIT cannot fold the conversion away at compile time. +/// +[MemoryDiagnoser] +public class ConversionBenchmarks +{ + private PreciseNumber number = PreciseNumber.Zero; + private PreciseNumber unit = PreciseNumber.Zero; + private int int32Value; + private long int64Value; + private double doubleValue; + private float singleValue; + private decimal decimalValue; + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + int32Value = 1234567; + int64Value = 1234567890123456L; + doubleValue = 1234.5678; + singleValue = 1234.5678f; + decimalValue = 1234.5678m; + number = doubleValue.ToPreciseNumber(); + unit = PreciseNumber.One; + } + + /// Converts an into a number. + /// The converted number. + [Benchmark(Baseline = true)] + public PreciseNumber FromInt32() => int32Value.ToPreciseNumber(); + + /// Converts a into a number. + /// The converted number. + [Benchmark] + public PreciseNumber FromInt64() => int64Value.ToPreciseNumber(); + + /// Converts a into a number. + /// The converted number. + [Benchmark] + public PreciseNumber FromDouble() => doubleValue.ToPreciseNumber(); + + /// Converts a into a number. + /// The converted number. + [Benchmark] + public PreciseNumber FromSingle() => singleValue.ToPreciseNumber(); + + /// Converts a into a number. + /// The converted number. + [Benchmark] + public PreciseNumber FromDecimal() => decimalValue.ToPreciseNumber(); + + /// Converts a number back into a . + /// The converted value. + [Benchmark] + public double ToDouble() => number.To(); + + /// Converts a number back into an . + /// The converted value. + [Benchmark] + public int ToInt32() => unit.To(); +} diff --git a/PreciseNumber.Benchmarks/Operands.cs b/PreciseNumber.Benchmarks/Operands.cs new file mode 100644 index 0000000..d0c2a92 --- /dev/null +++ b/PreciseNumber.Benchmarks/Operands.cs @@ -0,0 +1,77 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using System.Globalization; +using System.Numerics; + +/// +/// Builds the operands the benchmarks run against. +/// +/// +/// Values are derived from a fixed digit pattern rather than a random source so that two runs of +/// the same benchmark, on the same machine or on different ones, are measuring the same work. +/// +internal static class Operands +{ + /// + /// An arbitrary but fixed run of non-repeating digits to slice operands out of. + /// + private const string DigitPattern = + "31415926535897932384626433832795028841971693993751" + + "05820974944592307816406286208998628034825342117067" + + "98214808651328230664709384460955058223172535940812" + + "84811174502841027019385211055596446229489549303819"; + + /// + /// Builds a significand with exactly decimal digits. + /// + /// The number of digits the significand should have. + /// Shifts the window into the digit pattern, so that two operands of the + /// same length are not identical. + /// A significand with the requested number of digits and no trailing zero. + internal static BigInteger Significand(int digits, int offset = 0) + { + char[] characters = new char[digits]; + for (int i = 0; i < digits; i++) + { + characters[i] = DigitPattern[(i + offset) % DigitPattern.Length]; + } + + // A leading or trailing zero would make the value's digit count differ from what was + // asked for, since the constructor strips trailing zeros. + if (characters[0] == '0') + { + characters[0] = '4'; + } + + if (characters[digits - 1] == '0') + { + characters[digits - 1] = '7'; + } + + return BigInteger.Parse(characters, NumberStyles.None, CultureInfo.InvariantCulture); + } + + /// + /// Builds a number with the given significant digit count and exponent. + /// + /// The number of significant digits. + /// The exponent. + /// Shifts the window into the digit pattern. + /// The constructed number. + internal static PreciseNumber Number(int digits, int exponent, int offset = 0) => + PreciseNumber.Parse(Text(digits, exponent, offset), CultureInfo.InvariantCulture); + + /// + /// Renders the decimal text of a number with the given significant digit count and exponent. + /// + /// The number of significant digits. + /// The exponent. + /// Shifts the window into the digit pattern. + /// The decimal text, in scientific notation. + internal static string Text(int digits, int exponent, int offset = 0) => + string.Create( + CultureInfo.InvariantCulture, + $"{Significand(digits, offset)}E{exponent}"); +} diff --git a/PreciseNumber.Benchmarks/PowBenchmarks.cs b/PreciseNumber.Benchmarks/PowBenchmarks.cs new file mode 100644 index 0000000..30fedfd --- /dev/null +++ b/PreciseNumber.Benchmarks/PowBenchmarks.cs @@ -0,0 +1,41 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using BenchmarkDotNet.Attributes; + +/// +/// Measures raising a number to an integer power. +/// +/// +/// Kept apart from the other arithmetic because its cost is driven by the exponent rather than by +/// the operand's digit count, and because each multiplication grows the running result, so the +/// work per step is not constant. +/// +[MemoryDiagnoser] +public class PowBenchmarks +{ + private PreciseNumber baseValue = PreciseNumber.Zero; + private PreciseNumber power = PreciseNumber.Zero; + + /// + /// Gets or sets the power to raise the base value to. + /// + [Params(2, 10, 64)] + public int Power { get; set; } + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + baseValue = Operands.Number(8, -4); + power = Power.ToPreciseNumber(); + } + + /// Raises the base value to an integer power. + /// The result. + [Benchmark] + public PreciseNumber Pow() => baseValue.Pow(power); +} diff --git a/PreciseNumber.Benchmarks/PreciseNumber.Benchmarks.csproj b/PreciseNumber.Benchmarks/PreciseNumber.Benchmarks.csproj new file mode 100644 index 0000000..54f9cc8 --- /dev/null +++ b/PreciseNumber.Benchmarks/PreciseNumber.Benchmarks.csproj @@ -0,0 +1,30 @@ + + + + + + Exe + net10.0 + + + PreciseNumber.Benchmarks + ktsu.PreciseNumber.Benchmarks + + $(NoWarn);KTSU0001 + + + + + + + + + + + + diff --git a/PreciseNumber.Benchmarks/Program.cs b/PreciseNumber.Benchmarks/Program.cs new file mode 100644 index 0000000..8b5400f --- /dev/null +++ b/PreciseNumber.Benchmarks/Program.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using BenchmarkDotNet.Running; + +/// +/// Entry point for the benchmark suite. +/// +internal static class Program +{ + /// + /// Runs the benchmarks named on the command line, or prompts for a selection when none are. + /// + /// Command line arguments, forwarded to BenchmarkDotNet. + internal static void Main(string[] args) => + _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, BenchmarkConfig.Create()); +} diff --git a/PreciseNumber.Benchmarks/README.md b/PreciseNumber.Benchmarks/README.md new file mode 100644 index 0000000..de81ecb --- /dev/null +++ b/PreciseNumber.Benchmarks/README.md @@ -0,0 +1,63 @@ +# PreciseNumber Benchmarks + +A [BenchmarkDotNet](https://benchmarkdotnet.org) suite covering the operations that dominate +real use of `PreciseNumber`: building values, comparing them, arithmetic, rounding, text +conversion, and conversion to and from the primitive numeric types. + +## Running + +From the repository root: + +```bash +# Pick benchmarks from an interactive list +dotnet run -c Release --project PreciseNumber.Benchmarks + +# Run everything (slow: a full run is tens of minutes) +dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*' + +# Run one class, or one method +dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*ArithmeticBenchmarks*' +dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*.Multiply' + +# Fewer iterations, for a quick read while iterating on a change +dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*Comparison*' --job short +``` + +Release configuration is required — BenchmarkDotNet refuses to measure a debug build. + +Results land in `BenchmarkDotNet.Artifacts/results/` as GitHub-flavoured Markdown, CSV, HTML and +JSON. That directory is gitignored; copy a table into a pull request when a change moves the +numbers. + +## What is measured + +| Class | Covers | +| --- | --- | +| `ConstructionBenchmarks` | The constructor: counting significant digits and stripping trailing zeros | +| `ComparisonBenchmarks` | Equality, ordering, `CompareTo`, `Max`, `GetHashCode` | +| `ArithmeticBenchmarks` | `+`, `-`, `*`, `/`, `%`, negation, squaring | +| `PowBenchmarks` | Raising to an integer power | +| `RoundingBenchmarks` | `Round`, `ReduceSignificance`, `Clamp` | +| `TextBenchmarks` | `ToString`, `TryFormat`, `Parse` | +| `ConversionBenchmarks` | To and from `int`, `long`, `double`, `float`, `decimal` | + +Most classes are parameterised by `Digits` — 8, 30 and 200 significant digits. This is the axis +that matters: a `PreciseNumber` holds its digits in a `BigInteger`, so an operation that touches +each digit separately looks fine at 8 digits and falls apart at 200. Reading a table across the +`Digits` column, rather than down a single value of it, is what catches that. + +`ComparisonBenchmarks` and `ArithmeticBenchmarks` also separate operands whose exponents are far +apart from operands in the same decade, because aligning two exponents is its own cost, distinct +from the size of the operands. + +## Reading the results + +Allocation is reported next to time. Both matter here, and they trade against each other: every +operation returns a new instance, so a change that avoids an intermediate value shows up in the +`Allocated` column before it shows up in `Mean`. A comparison that allocates at all is a +regression — none of them should. + +Benchmark operands come from a fixed digit pattern rather than a random source, so two runs on +the same machine measure the same work. Numbers are still only comparable within a single run on +a single machine; a cloud CI runner in particular is too noisy to compare against a previous run +there. diff --git a/PreciseNumber.Benchmarks/RoundingBenchmarks.cs b/PreciseNumber.Benchmarks/RoundingBenchmarks.cs new file mode 100644 index 0000000..b0c109c --- /dev/null +++ b/PreciseNumber.Benchmarks/RoundingBenchmarks.cs @@ -0,0 +1,45 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using BenchmarkDotNet.Attributes; + +/// +/// Measures reducing a number's precision. +/// +/// +/// Both operations build a rounding factor made of repeated digits and then divide by a power of +/// ten, so their cost tracks how many digits are being discarded rather than how many are kept. +/// +[MemoryDiagnoser] +public class RoundingBenchmarks +{ + private PreciseNumber number = PreciseNumber.Zero; + + /// + /// Gets or sets the number of significant digits in the operand. + /// + [Params(8, 30, 200)] + public int Digits { get; set; } + + /// + /// Prepares the operand. + /// + [GlobalSetup] + public void Setup() => number = Operands.Number(Digits, -Digits); + + /// Rounds to three decimal places. + /// The rounded value. + [Benchmark(Baseline = true)] + public PreciseNumber Round() => number.Round(3); + + /// Reduces the value to five significant digits. + /// The reduced value. + [Benchmark] + public PreciseNumber ReduceSignificance() => number.ReduceSignificance(5); + + /// Clamps the value into a range it already sits inside. + /// The clamped value. + [Benchmark] + public PreciseNumber Clamp() => PreciseNumber.Clamp(number, PreciseNumber.NegativeOne, PreciseNumber.One); +} diff --git a/PreciseNumber.Benchmarks/TextBenchmarks.cs b/PreciseNumber.Benchmarks/TextBenchmarks.cs new file mode 100644 index 0000000..38218fc --- /dev/null +++ b/PreciseNumber.Benchmarks/TextBenchmarks.cs @@ -0,0 +1,74 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.PreciseNumber.Benchmarks; + +using System.Globalization; +using BenchmarkDotNet.Attributes; + +/// +/// Measures converting between a number and its decimal text. +/// +/// +/// Formatting is split by the sign of the exponent because a negative exponent is the case that +/// has to place a decimal separator and pad with leading zeros, which is where the work is. +/// +[MemoryDiagnoser] +public class TextBenchmarks +{ + private PreciseNumber integral = PreciseNumber.Zero; + private PreciseNumber fractional = PreciseNumber.Zero; + private PreciseNumber smallMagnitude = PreciseNumber.Zero; + private string text = string.Empty; + private char[] buffer = []; + + /// + /// Gets or sets the number of significant digits in the operand. + /// + [Params(8, 30, 200)] + public int Digits { get; set; } + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + integral = Operands.Number(Digits, 4); + fractional = Operands.Number(Digits, -4); + + // Exponent consumes every digit, so formatting has to pad with leading zeros. + smallMagnitude = Operands.Number(Digits, -(Digits + 10)); + + text = Operands.Text(Digits, -12); + buffer = new char[(Digits * 3) + 32]; + } + + /// Formats a value whose exponent is positive. + /// The formatted text. + [Benchmark(Baseline = true)] + public string ToStringIntegral() => integral.ToString(CultureInfo.InvariantCulture); + + /// Formats a value with a fractional part. + /// The formatted text. + [Benchmark] + public string ToStringFractional() => fractional.ToString(CultureInfo.InvariantCulture); + + /// Formats a value smaller than one, which needs leading zero padding. + /// The formatted text. + [Benchmark] + public string ToStringSmallMagnitude() => smallMagnitude.ToString(CultureInfo.InvariantCulture); + + /// Formats straight into a caller-supplied buffer. + /// The number of characters written. + [Benchmark] + public int TryFormat() + { + _ = fractional.TryFormat(buffer, out int charsWritten, "G".AsSpan(), CultureInfo.InvariantCulture); + return charsWritten; + } + + /// Parses decimal text in scientific notation. + /// The parsed number. + [Benchmark] + public PreciseNumber Parse() => PreciseNumber.Parse(text, CultureInfo.InvariantCulture); +} diff --git a/PreciseNumber.sln b/PreciseNumber.sln index 4165006..02b4e8e 100644 --- a/PreciseNumber.sln +++ b/PreciseNumber.sln @@ -7,6 +7,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PreciseNumber", "PreciseNum EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PreciseNumber.Test", "PreciseNumber.Test\PreciseNumber.Test.csproj", "{FEABC5CD-7CE6-42AA-8CB4-1C8108F24C1E}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "PreciseNumber.Benchmarks", "PreciseNumber.Benchmarks\PreciseNumber.Benchmarks.csproj", "{7D3E1B4A-2C96-4F58-9B0D-5A8E4C1F60B2}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -21,6 +23,10 @@ Global {FEABC5CD-7CE6-42AA-8CB4-1C8108F24C1E}.Debug|Any CPU.Build.0 = Debug|Any CPU {FEABC5CD-7CE6-42AA-8CB4-1C8108F24C1E}.Release|Any CPU.ActiveCfg = Release|Any CPU {FEABC5CD-7CE6-42AA-8CB4-1C8108F24C1E}.Release|Any CPU.Build.0 = Release|Any CPU + {7D3E1B4A-2C96-4F58-9B0D-5A8E4C1F60B2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7D3E1B4A-2C96-4F58-9B0D-5A8E4C1F60B2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7D3E1B4A-2C96-4F58-9B0D-5A8E4C1F60B2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7D3E1B4A-2C96-4F58-9B0D-5A8E4C1F60B2}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/PreciseNumber/AssemblyInfo.cs b/PreciseNumber/AssemblyInfo.cs index c4f0faf..3067162 100644 --- a/PreciseNumber/AssemblyInfo.cs +++ b/PreciseNumber/AssemblyInfo.cs @@ -3,3 +3,4 @@ [assembly: CLSCompliant(true)] [assembly: System.Runtime.InteropServices.ComVisible(false)] [assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.PreciseNumber.Test")] +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("PreciseNumber.Benchmarks")] diff --git a/README.md b/README.md index c3e1aa7..8b9ac84 100644 --- a/README.md +++ b/README.md @@ -56,6 +56,8 @@ A high-precision numeric type for .NET that provides arbitrary precision arithme - [Limitations](#limitations) +- [Performance](#performance) + - [API Reference](#api-reference) - [PreciseNumber Class](#precisenumber-class) @@ -355,6 +357,24 @@ You can control precision using: - Conversion to standard types may throw `OverflowException` if the value is too large +## Performance + +Values are immutable, so every operation returns a new instance, and every instance holds its +digits in a `BigInteger`. Cost therefore tracks the number of significant digits rather than the +magnitude of the value, and allocation matters as much as raw speed. + +The repository carries a [BenchmarkDotNet suite](PreciseNumber.Benchmarks/README.md) covering +construction, comparison, arithmetic, rounding, text conversion and primitive conversion, each +parameterised across 8, 30 and 200 significant digits: + +```bash +dotnet run -c Release --project PreciseNumber.Benchmarks -- --filter '*ArithmeticBenchmarks*' +``` + +Run it before and after any change to the library's internals. A full run can also be started +from the **Benchmarks** workflow in GitHub Actions, which archives the reports against the commit +that produced them. + ## API Reference ### PreciseNumber Class From eab79b13045ae6721da7972e33573a1ebdde838e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 02:53:17 +0000 Subject: [PATCH 3/4] Grow the power of ten cache on demand The new benchmark suite exposed a cliff at exactly 128 significant digits, the size of the fixed power of ten cache: 127 digits: 185 ns, 40 B 128 digits: 2,075 ns, 120 B 200 digits: 2,313 ns, 264 B Counting a value's digits compares it against a power of ten, and aligning two exponents multiplies by one. Neither is occasional: every construction does the first, so past the cache every operation on a wide value paid for a fresh BigInteger.Pow, and the cost jumped elevenfold over one digit rather than rising gradually. The cache now grows on demand, doubling up to 1024 entries and computing anything beyond that per call so one extreme exponent cannot leave a large cache behind. Growing replaces the array rather than filling the existing one: a BigInteger is a multi-field struct, so writing one into a shared array is not atomic and a concurrent reader could observe it half written, whereas publishing an already populated array through a single reference assignment cannot tear. Two threads growing at once build two correct arrays and one wins. At 200 significant digits, against the same benchmarks: construction, no trailing zeros 1,570 ns -> 257 ns (264 B -> 40 B) construction, unsanitized 1,396 ns -> 34 ns Negate 1,630 ns -> 256 ns (264 B -> 40 B) Add 1,845 ns -> 411 ns (488 B -> 264 B) Multiply 4,081 ns -> 1,032 ns (616 B -> 232 B) Squared 4,148 ns -> 894 ns (616 B -> 232 B) Values inside the original 128 entries are unaffected. Adds tests covering Pow10 across both boundaries, ascending and descending so that a cache grown past the request is exercised too, and confirms it still rejects a negative exponent. The differential fuzz harness against the previous implementation still reports 866,510 checks with no failures. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018eTdSeGPQHGUKf9V3c2yXw --- PreciseNumber.Benchmarks/README.md | 6 ++ PreciseNumber.Test/PreciseNumberTests.cs | 26 ++++++++ PreciseNumber/PreciseNumber.cs | 76 +++++++++++++++++++----- 3 files changed, 94 insertions(+), 14 deletions(-) diff --git a/PreciseNumber.Benchmarks/README.md b/PreciseNumber.Benchmarks/README.md index de81ecb..cce8188 100644 --- a/PreciseNumber.Benchmarks/README.md +++ b/PreciseNumber.Benchmarks/README.md @@ -57,6 +57,12 @@ operation returns a new instance, so a change that avoids an intermediate value `Allocated` column before it shows up in `Mean`. A comparison that allocates at all is a regression — none of them should. +One entry is easy to misread. `Divide` barely moves between 8 and 200 digits, and at 200 digits +it comes out faster than `Add`. That is not division being efficient — it computes its fractional +part through a `double`, so it discards everything past roughly the 17th significant digit and +the result it constructs is small no matter how large the operands were. Read it as a measure of +how much precision the operation throws away, not how fast it is. + Benchmark operands come from a fixed digit pattern rather than a random source, so two runs on the same machine measure the same work. Numbers are still only comparable within a single run on a single machine; a cloud CI runner in particular is too noisy to compare against a previous run diff --git a/PreciseNumber.Test/PreciseNumberTests.cs b/PreciseNumber.Test/PreciseNumberTests.cs index c47b1e5..e35d0c8 100644 --- a/PreciseNumber.Test/PreciseNumberTests.cs +++ b/PreciseNumber.Test/PreciseNumberTests.cs @@ -2000,6 +2000,32 @@ public void TestCountDigitsMatchesDecimalText() } } + [TestMethod] + public void TestPow10IsCorrectAcrossCacheBoundaries() + { + // The cache starts at 128 entries, grows on demand up to 1024, and computes anything + // beyond that per call. Every one of those transitions must produce the same value. + foreach (int exponent in new[] { 0, 1, 127, 128, 129, 255, 256, 257, 1023, 1024, 1025, 2048 }) + { + Assert.AreEqual(BigInteger.Pow(10, exponent), PreciseNumber.Pow10(exponent), $"10^{exponent}"); + } + + // Ascending and descending, to exercise both a grown cache and one grown past the request. + for (int exponent = 0; exponent <= 300; exponent++) + { + Assert.AreEqual(BigInteger.Pow(10, exponent), PreciseNumber.Pow10(exponent), $"10^{exponent}"); + } + + for (int exponent = 300; exponent >= 0; exponent--) + { + Assert.AreEqual(BigInteger.Pow(10, exponent), PreciseNumber.Pow10(exponent), $"10^{exponent}"); + } + } + + [TestMethod] + public void TestPow10RejectsNegativeExponents() => + Assert.ThrowsExactly(() => PreciseNumber.Pow10(-1)); + [TestMethod] public void TestSanitizeStripsLongRunsOfTrailingZeros() { diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index e7a19b4..42a1239 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -24,9 +24,15 @@ public record PreciseNumber private const int MaxStackAllocChars = 256; /// - /// Number of powers of ten that are pre-computed. Exponents at or above this are computed on demand. + /// Number of powers of ten pre-computed when the type is first used. /// - private const int Pow10CacheSize = 128; + private const int Pow10InitialCacheSize = 128; + + /// + /// Ceiling on the power of ten cache. Beyond this, powers are computed per call rather than + /// retained, so that one extreme exponent cannot leave a large cache behind. + /// + private const int Pow10MaxCacheSize = 1024; /// /// log10(2), used to derive a decimal digit count from a binary bit length. @@ -34,16 +40,31 @@ public record PreciseNumber private const double Log10Of2 = 0.3010299956639812; /// - /// Pre-computed powers of ten. Declared before any other static state so that the - /// static constants below can rely on it while they are being initialized. + /// Pre-computed powers of ten, grown on demand. Declared before any other static state so + /// that the static constants below can rely on it while they are being initialized. /// - private static readonly BigInteger[] Pow10Cache = BuildPow10Cache(); + /// + /// Growing replaces the array rather than filling the existing one. A + /// is a multi-field struct, so writing one into a shared array is not atomic and a concurrent + /// reader could observe it half written. Publishing an already populated array through a + /// single reference assignment cannot tear, and two threads growing at once simply build two + /// correct arrays, one of which wins. + /// + private static BigInteger[] pow10Cache = BuildPow10Cache(Pow10InitialCacheSize, []); - private static BigInteger[] BuildPow10Cache() + /// + /// Builds a power of ten cache of the given size, reusing the entries already computed. + /// + /// The number of powers the new cache should hold. + /// The entries to carry over, which must be a prefix of the new cache. + /// The populated cache. + private static BigInteger[] BuildPow10Cache(int size, BigInteger[] existing) { - BigInteger[] cache = new BigInteger[Pow10CacheSize]; - BigInteger value = BigInteger.One; - for (int i = 0; i < Pow10CacheSize; i++) + BigInteger[] cache = new BigInteger[size]; + existing.CopyTo(cache, 0); + + BigInteger value = existing.Length == 0 ? BigInteger.One : existing[^1] * Base10; + for (int i = existing.Length; i < size; i++) { cache[i] = value; value *= Base10; @@ -53,14 +74,41 @@ private static BigInteger[] BuildPow10Cache() } /// - /// Raises ten to the specified non-negative power, serving small exponents from a cache. + /// Raises ten to the specified non-negative power, serving it from a cache. + /// + /// The power to raise ten to. + /// Ten raised to . + internal static BigInteger Pow10(int exponent) + { + BigInteger[] cache = pow10Cache; + return (uint)exponent < (uint)cache.Length + ? cache[exponent] + : GrowCacheAndGetPow10(exponent, cache); + } + + /// + /// Extends the power of ten cache to cover an exponent it does not yet reach. /// /// The power to raise ten to. + /// The cache as it was read by the caller. /// Ten raised to . - internal static BigInteger Pow10(int exponent) => - (uint)exponent < Pow10CacheSize - ? Pow10Cache[exponent] - : BigInteger.Pow(Base10, exponent); + /// + /// Every digit count and every exponent alignment needs a power of ten, so a value wider than + /// the cache would otherwise pay for a fresh on every single + /// operation. That produced a cliff at the cache boundary rather than a gradual slope. + /// + private static BigInteger GrowCacheAndGetPow10(int exponent, BigInteger[] current) + { + if (exponent is < 0 or > Pow10MaxCacheSize) + { + return BigInteger.Pow(Base10, exponent); + } + + int size = Math.Min(Math.Max(current.Length * 2, exponent + 1), Pow10MaxCacheSize + 1); + BigInteger[] grown = BuildPow10Cache(size, current); + pow10Cache = grown; + return grown[exponent]; + } /// /// Counts the decimal digits in the absolute value of a . From ae2ca719d37afebc73f9f74e8463d82d2f8c510e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 13 Sep 2026 02:55:04 +0000 Subject: [PATCH 4/4] Fix script injection in the benchmark workflow SonarCloud's quality gate failed the previous commit on two conditions. The blocker is mine and real: the Run Benchmarks step interpolated ${{ inputs.filter }} directly into its run block. That input is free-form text supplied by whoever dispatches the workflow, so its contents were expanded into the script body and would have run as shell. Both inputs now reach the script through the environment, where no expansion happens. The second condition was coverage on new code, which fell to 70.3% because the benchmark project counted as uncovered new code. Benchmarks are tooling rather than shipped behaviour, the same category as the test code the analysis already exempts, so they join that exclusion list. Nothing about the library's own coverage requirement changes. Also extracts the parse failure message into a constant. It appeared three times before this branch and four after, which Sonar flagged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018eTdSeGPQHGUKf9V3c2yXw --- .github/workflows/benchmarks.yml | 12 +++++++++--- .github/workflows/dotnet.yml | 2 +- PreciseNumber/PreciseNumber.cs | 13 +++++++++---- 3 files changed, 19 insertions(+), 8 deletions(-) diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml index 1286129..00acda4 100644 --- a/.github/workflows/benchmarks.yml +++ b/.github/workflows/benchmarks.yml @@ -48,13 +48,19 @@ jobs: **/Directory.Packages.props **/global.json + # The inputs go through the environment rather than being interpolated into the script. + # `filter` is free-form text supplied by whoever dispatches the workflow, and expanding it + # into the script body would let it run as shell. - name: Run Benchmarks shell: bash + env: + BENCHMARK_FILTER: ${{ inputs.filter }} + BENCHMARK_JOB: ${{ inputs.job }} run: | set -euo pipefail - args=(--filter "${{ inputs.filter }}") - if [ "${{ inputs.job }}" != "default" ]; then - args+=(--job "${{ inputs.job }}") + args=(--filter "$BENCHMARK_FILTER") + if [ "$BENCHMARK_JOB" != "default" ]; then + args+=(--job "$BENCHMARK_JOB") fi dotnet run -c Release --project PreciseNumber.Benchmarks -- "${args[@]}" diff --git a/.github/workflows/dotnet.yml b/.github/workflows/dotnet.yml index e6c4e35..fd26737 100644 --- a/.github/workflows/dotnet.yml +++ b/.github/workflows/dotnet.yml @@ -352,7 +352,7 @@ jobs: '/d:sonar.host.url=https://sonarcloud.io' '/d:sonar.projectBaseDir=${{ github.workspace }}' '/d:sonar.cs.vscoveragexml.reportsPaths=coverage/**/coverage.xml' - '/d:sonar.coverage.exclusions=**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs' + '/d:sonar.coverage.exclusions=**/*Test*.cs,**/*.Tests.cs,**/*.Tests/**/*,**/*.Benchmarks/**/*,**/obj/**/*,**/*.dll,**/NativeExports.cs' '/d:sonar.cs.vstest.reportsPaths=coverage/**/*.trx' '/d:sonar.exclusions=**/NativeExports.cs' ) diff --git a/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs index 42a1239..912389c 100644 --- a/PreciseNumber/PreciseNumber.cs +++ b/PreciseNumber/PreciseNumber.cs @@ -39,6 +39,11 @@ public record PreciseNumber /// private const double Log10Of2 = 0.3010299956639812; + /// + /// The message carried by the that parsing throws. + /// + private const string InvalidFormatMessage = "Input string was not in a correct format."; + /// /// Pre-computed powers of ten, grown on demand. Declared before any other static state so /// that the static constants below can rely on it while they are being initialized. @@ -934,7 +939,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor { if (s.IsEmpty) { - throw new FormatException("Input string was not in a correct format."); + throw new FormatException(InvalidFormatMessage); } if (s.Length == 1 && s[0] == '0') @@ -965,7 +970,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor { if (hasDecimal) { - throw new FormatException("Input string was not in a correct format."); + throw new FormatException(InvalidFormatMessage); } hasDecimal = true; @@ -980,7 +985,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor if (c is < '0' or > '9') { - throw new FormatException("Input string was not in a correct format."); + throw new FormatException(InvalidFormatMessage); } if (hasDecimal) @@ -993,7 +998,7 @@ public static PreciseNumber Parse(ReadOnlySpan s, NumberStyles style, IFor if (digitCount == 0) { - throw new FormatException("Input string was not in a correct format."); + throw new FormatException(InvalidFormatMessage); } BigInteger significand = BigInteger.Parse(digits[..digitCount], NumberStyles.None, InvariantCulture);