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