Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
91 changes: 91 additions & 0 deletions .github/workflows/benchmarks.yml
Original file line number Diff line number Diff line change
@@ -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
2 changes: 1 addition & 1 deletion .github/workflows/dotnet.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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'
)
Expand Down
24 changes: 23 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
2 changes: 2 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BenchmarkDotNet.Annotations" Version="0.15.8" />
<PackageVersion Include="Polyfill" Version="11.3.0" />
</ItemGroup>
</Project>
78 changes: 78 additions & 0 deletions PreciseNumber.Benchmarks/ArithmeticBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.PreciseNumber.Benchmarks;

using BenchmarkDotNet.Attributes;

/// <summary>
/// Measures the four arithmetic operators plus the modulus.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[MemoryDiagnoser]
public class ArithmeticBenchmarks
{
private PreciseNumber left = PreciseNumber.Zero;
private PreciseNumber right = PreciseNumber.Zero;
private PreciseNumber wideGap = PreciseNumber.Zero;

/// <summary>
/// Gets or sets the number of significant digits in the operands.
/// </summary>
[Params(8, 30, 200)]
public int Digits { get; set; }

/// <summary>
/// Prepares the operands.
/// </summary>
[GlobalSetup]
public void Setup()
{
left = Operands.Number(Digits, -10);
right = Operands.Number(Digits, -14, offset: 7);
wideGap = Operands.Number(Digits, -400, offset: 11);
}

/// <summary>Addition.</summary>
/// <returns>The sum.</returns>
[Benchmark(Baseline = true)]
public PreciseNumber Add() => left + right;

/// <summary>Subtraction.</summary>
/// <returns>The difference.</returns>
[Benchmark]
public PreciseNumber Subtract() => left - right;

/// <summary>Multiplication.</summary>
/// <returns>The product.</returns>
[Benchmark]
public PreciseNumber Multiply() => left * right;

/// <summary>Multiplication where the operands' exponents are hundreds of decades apart.</summary>
/// <returns>The product.</returns>
[Benchmark]
public PreciseNumber MultiplyWideExponentGap() => left * wideGap;

/// <summary>Division.</summary>
/// <returns>The quotient.</returns>
[Benchmark]
public PreciseNumber Divide() => left / right;

/// <summary>Modulus.</summary>
/// <returns>The remainder.</returns>
[Benchmark]
public PreciseNumber Mod() => left % right;

/// <summary>Negation.</summary>
/// <returns>The negated value.</returns>
[Benchmark]
public PreciseNumber Negate() => -left;

/// <summary>Squaring, which is multiplication by self.</summary>
/// <returns>The square.</returns>
[Benchmark]
public PreciseNumber Squared() => left.Squared();
}
3 changes: 3 additions & 0 deletions PreciseNumber.Benchmarks/AssemblyInfo.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.PreciseNumber.Test")]
32 changes: 32 additions & 0 deletions PreciseNumber.Benchmarks/BenchmarkConfig.cs
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// The configuration every benchmark in this assembly runs under.
/// </summary>
internal static class BenchmarkConfig
{
/// <summary>
/// Builds the configuration.
/// </summary>
/// <returns>The configuration to run benchmarks with.</returns>
/// <remarks>
/// 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.
/// </remarks>
internal static IConfig Create() =>
ManualConfig.Create(DefaultConfig.Instance)
.AddDiagnoser(MemoryDiagnoser.Default)
.AddColumn(RankColumn.Arabic)
.AddExporter(JsonExporter.Full)
.WithOrderer(new DefaultOrderer(SummaryOrderPolicy.Declared));
}
78 changes: 78 additions & 0 deletions PreciseNumber.Benchmarks/ComparisonBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.PreciseNumber.Benchmarks;

using BenchmarkDotNet.Attributes;

/// <summary>
/// Measures ordering and equality.
/// </summary>
/// <remarks>
/// 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.
/// </remarks>
[MemoryDiagnoser]
public class ComparisonBenchmarks
{
private PreciseNumber left = PreciseNumber.Zero;
private PreciseNumber right = PreciseNumber.Zero;
private PreciseNumber sameExponent = PreciseNumber.Zero;
private PreciseNumber differentDecade = PreciseNumber.Zero;

/// <summary>
/// Gets or sets the number of significant digits in the operands.
/// </summary>
[Params(8, 30, 200)]
public int Digits { get; set; }

/// <summary>
/// Prepares the operands.
/// </summary>
[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);
}

/// <summary>Equality where the operands already share an exponent.</summary>
/// <returns>Whether the operands are equal.</returns>
[Benchmark(Baseline = true)]
public bool EqualsSameExponent() => left == sameExponent;

/// <summary>Equality where the operands have to be aligned first.</summary>
/// <returns>Whether the operands are equal.</returns>
[Benchmark]
public bool EqualsDifferentExponent() => left == right;

/// <summary>Equality where the operands are orders of magnitude apart.</summary>
/// <returns>Whether the operands are equal.</returns>
[Benchmark]
public bool EqualsDifferentDecade() => left == differentDecade;

/// <summary>Ordering with the less-than operator.</summary>
/// <returns>Whether the left operand is smaller.</returns>
[Benchmark]
public bool LessThan() => left < right;

/// <summary>Ordering through <see cref="IComparable{T}.CompareTo(T)"/>.</summary>
/// <returns>The relative order of the operands.</returns>
[Benchmark]
public int CompareTo() => left.CompareTo(right);

/// <summary>Ordering through <see cref="PreciseNumber.Max"/>.</summary>
/// <returns>The larger operand.</returns>
[Benchmark]
public PreciseNumber Max() => PreciseNumber.Max(left, right);

/// <summary>Hashing, which callers pay alongside equality in a dictionary or set.</summary>
/// <returns>The hash code.</returns>
[Benchmark]
public int GetHashCodeBenchmark() => left.GetHashCode();
}
52 changes: 52 additions & 0 deletions PreciseNumber.Benchmarks/ConstructionBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.PreciseNumber.Benchmarks;

using System.Numerics;
using BenchmarkDotNet.Attributes;

/// <summary>
/// Measures building a <see cref="PreciseNumber"/>, which every other operation pays for because
/// each result is a new instance.
/// </summary>
/// <remarks>
/// The constructor counts significant digits and, unless told not to, strips trailing zeros. Both
/// scale with the digit count, so <see cref="Digits"/> is the parameter that matters here.
/// </remarks>
[MemoryDiagnoser]
public class ConstructionBenchmarks
{
private BigInteger significand;
private BigInteger significandWithTrailingZeros;

/// <summary>
/// Gets or sets the number of significant digits in the operand.
/// </summary>
[Params(8, 30, 200)]
public int Digits { get; set; }

/// <summary>
/// Prepares the operands.
/// </summary>
[GlobalSetup]
public void Setup()
{
significand = Operands.Significand(Digits);
significandWithTrailingZeros = significand * BigInteger.Pow(10, Digits);
}

/// <summary>Builds a number, stripping trailing zeros. There are none to strip here.</summary>
/// <returns>The constructed number.</returns>
[Benchmark(Baseline = true)]
public PreciseNumber Sanitizing() => PreciseNumber.CreateFromComponents(-4, significand);

/// <summary>Builds a number whose significand is half trailing zeros.</summary>
/// <returns>The constructed number.</returns>
[Benchmark]
public PreciseNumber SanitizingTrailingZeros() => PreciseNumber.CreateFromComponents(-4, significandWithTrailingZeros);

/// <summary>Builds a number without stripping trailing zeros, so only the digit count is computed.</summary>
/// <returns>The constructed number.</returns>
[Benchmark]
public PreciseNumber Unsanitized() => PreciseNumber.CreateFromComponents(-4, significand, sanitize: false);
}
Loading