diff --git a/.github/workflows/benchmarks.yml b/.github/workflows/benchmarks.yml
new file mode 100644
index 0000000..00acda4
--- /dev/null
+++ b/.github/workflows/benchmarks.yml
@@ -0,0 +1,91 @@
+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
+
+ # 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 "$BENCHMARK_FILTER")
+ if [ "$BENCHMARK_JOB" != "default" ]; then
+ args+=(--job "$BENCHMARK_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/.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/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..cce8188
--- /dev/null
+++ b/PreciseNumber.Benchmarks/README.md
@@ -0,0 +1,69 @@
+# 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.
+
+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
+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.Test/PreciseNumberTests.cs b/PreciseNumber.Test/PreciseNumberTests.cs
index 06cc98d..e35d0c8 100644
--- a/PreciseNumber.Test/PreciseNumberTests.cs
+++ b/PreciseNumber.Test/PreciseNumberTests.cs
@@ -1984,6 +1984,153 @@ 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 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()
+ {
+ 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.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/PreciseNumber/PreciseNumber.cs b/PreciseNumber/PreciseNumber.cs
index bbfab32..912389c 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,173 @@ 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 pre-computed when the type is first used.
+ ///
+ 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.
+ ///
+ 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.
+ ///
+ ///
+ /// 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, []);
+
+ ///
+ /// 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[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;
+ }
+
+ return 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 .
+ ///
+ /// 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 .
+ ///
+ /// 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 +215,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 +332,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;
+
+ char[]? rentedBuffer = desiredAlloc > MaxStackAllocChars ? ArrayPool.Shared.Rent(desiredAlloc) : null;
+ Span stackBuffer = stackalloc char[MaxStackAllocChars];
+ Span buffer = rentedBuffer is null ? stackBuffer : rentedBuffer.AsSpan();
- return number.TryFormat(buffer, out int charsWritten, format.AsSpan(), formatProvider)
- ? buffer[..charsWritten].ToString()
- : string.Empty;
+ 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 +382,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 +454,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 +556,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 +573,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 +662,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 +702,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 +833,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;
}
///
@@ -684,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')
@@ -694,52 +949,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++)
{
+ char c = s[i];
+ if (c == '.')
+ {
+ if (hasDecimal)
+ {
+ throw new FormatException(InvalidFormatMessage);
+ }
+
+ 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(InvalidFormatMessage);
+ }
+
if (hasDecimal)
{
- throw new FormatException("Input string was not in a correct format.");
+ decimalDigits++;
}
- hasDecimal = true;
- continue;
+ digits[digitCount++] = c;
}
- if (c is 'e' or 'E')
+ if (digitCount == 0)
{
- exponent = int.Parse(s[(i + 1)..], InvariantCulture);
- break;
+ throw new FormatException(InvalidFormatMessage);
}
- if (c is < '0' or > '9')
- {
- throw new FormatException("Input string was not in a correct format.");
- }
+ BigInteger significand = BigInteger.Parse(digits[..digitCount], NumberStyles.None, InvariantCulture);
+
+ exponent -= decimalDigits;
- if (hasDecimal)
+ 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 +1063,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);
- string output = FormatOutput(provider);
+ 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();
+
+ 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 +1227,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 +1240,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 +1252,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 +1264,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 +1293,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 +1325,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 +1373,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 +1382,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 +1391,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 +1400,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 +1409,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 +1418,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 +1481,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 +1532,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 +1596,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 +1666,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;
+ }
}
}
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