diff --git a/.github/workflows/benchmark-history.yml b/.github/workflows/benchmark-history.yml
new file mode 100644
index 0000000..4c90f7d
--- /dev/null
+++ b/.github/workflows/benchmark-history.yml
@@ -0,0 +1,204 @@
+name: Benchmark History
+
+# Measures a small, fixed set of benchmarks once per release, appends the numbers to a committed
+# history file, and redraws the chart the README shows. Deliberately separate from benchmarks.yml,
+# which exists to get a full ad-hoc run on demand and publishes nothing.
+#
+# Two ways in:
+# * a published release, which measures that version and adds one point;
+# * a manual dispatch listing refs, which measures each of them in ONE job and backfills.
+#
+# The backfill running as a single job is the point rather than an optimisation. Separate runs land
+# on different CI hosts, and the difference between an x86-64-v3 and a v4 runner is larger than
+# most releases are, so points gathered in separate jobs are not comparable as raw times. Within
+# one job they are. Across jobs, BaselineBenchmarks is what ties them together -- see its remarks.
+
+on:
+ release:
+ types: [published]
+ workflow_dispatch:
+ inputs:
+ refs:
+ description: "Space-separated refs to backfill, oldest first (tags, branches, or SHAs)"
+ required: false
+ default: "cd9a8227793bbd6ea791be7f6c0579ae1242eec5 v2.0.0 v2.0.1 v2.0.2 v2.0.3 v2.0.4"
+ type: string
+ labels:
+ description: "Optional space-separated version labels matching refs, when a ref is not a version"
+ required: false
+ default: "1.9.0 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4"
+ type: string
+
+permissions:
+ contents: write
+
+concurrency:
+ group: benchmark-history
+ cancel-in-progress: false
+
+env:
+ DOTNET_VERSION: "10.0"
+ HISTORY: docs/benchmarks/history.json
+ # Already ignored, and ktsu.Sdk regenerates .gitignore on build so a new entry would not last.
+ RUNS: BenchmarkDotNet.Artifacts
+ CHART: docs/benchmarks/performance.svg
+ # The set drawn in the README. Name globs rather than a [BenchmarkCategory], because the backfill
+ # runs this same filter against older checkouts that predate any attribute added today.
+ HEADLINE_FILTER: >-
+ *ArithmeticBenchmarks.Add
+ *ArithmeticBenchmarks.Multiply
+ *ArithmeticBenchmarks.Divide
+ *ComparisonBenchmarks.CompareTo
+ *ConstructionBenchmarks.Sanitizing
+ *TextBenchmarks.Parse
+ *ConversionBenchmarks.ToDouble
+ # Short runs: three iterations is enough for a trend line, and a release should not tie up a
+ # runner for half an hour. benchmarks.yml is still there for a full-length run.
+ BENCHMARK_JOB: short
+
+jobs:
+ measure:
+ name: Measure and chart
+ runs-on: ubuntu-latest
+ timeout-minutes: 180
+
+ steps:
+ - name: Checkout Repository
+ uses: actions/checkout@v7
+ with:
+ # The default branch, not the released tag. The results are committed back here, and a
+ # release event would otherwise leave the checkout detached at the tag, so the push at
+ # the end would be asking the default branch to move backwards. The tag itself is
+ # measured through a worktree below, exactly as a backfill ref is.
+ ref: ${{ github.event.repository.default_branch }}
+ fetch-depth: 0
+
+ - name: Setup .NET SDK ${{ env.DOTNET_VERSION }}
+ uses: actions/setup-dotnet@v6
+ with:
+ dotnet-version: ${{ env.DOTNET_VERSION }}.x
+
+ # Measured from this checkout, and stamped onto every entry this job produces. Older refs do
+ # not carry BaselineBenchmarks, and do not need to: everything measured in this job shares
+ # one runner, so one reading of that runner describes all of them.
+ - name: Measure the reference workload
+ id: baseline
+ shell: bash
+ run: |
+ set -euo pipefail
+ dotnet run -c Release --project PreciseNumber.Benchmarks -- \
+ --filter '*BaselineBenchmarks.ReferenceWork' \
+ --job "$BENCHMARK_JOB" \
+ --artifacts "$GITHUB_WORKSPACE/$RUNS/baseline"
+ ns=$(dotnet run scripts/benchmark-history.cs -- baseline --results "$RUNS/baseline")
+ echo "Reference workload: $ns ns"
+ echo "ns=$ns" >> "$GITHUB_OUTPUT"
+
+ - name: Measure the released version
+ if: github.event_name == 'release'
+ shell: bash
+ env:
+ VERSION: ${{ github.event.release.tag_name }}
+ run: |
+ set -euo pipefail
+ version="${VERSION#v}"
+ work="${RUNNER_TEMP}/bench-$version"
+ git worktree add --detach "$work" "$VERSION"
+
+ (cd "$work" && dotnet run -c Release --project PreciseNumber.Benchmarks -- \
+ --filter $HEADLINE_FILTER \
+ --job "$BENCHMARK_JOB" \
+ --artifacts "$GITHUB_WORKSPACE/$RUNS/$version")
+
+ dotnet run scripts/benchmark-history.cs -- ingest \
+ --history "$HISTORY" \
+ --results "$RUNS/$version" \
+ --version "$version" \
+ --commit "$(git rev-parse --short "$VERSION^{commit}")" \
+ --date "$(git log -1 --format=%cs "$VERSION")" \
+ --run-id "${{ github.run_id }}" \
+ --baseline-ns "${{ steps.baseline.outputs.ns }}"
+
+ git worktree remove --force "$work"
+
+ - name: Measure each backfill ref
+ if: github.event_name == 'workflow_dispatch'
+ shell: bash
+ env:
+ REFS: ${{ inputs.refs }}
+ LABELS: ${{ inputs.labels }}
+ BASELINE_NS: ${{ steps.baseline.outputs.ns }}
+ run: |
+ set -euo pipefail
+ read -ra refs <<< "$REFS"
+ read -ra labels <<< "$LABELS"
+
+ for index in "${!refs[@]}"; do
+ ref="${refs[$index]}"
+ label="${labels[$index]:-${ref#v}}"
+ work="${RUNNER_TEMP}/bench-$label"
+
+ echo "::group::$label ($ref)"
+ rm -rf "$work"
+ git worktree add --detach "$work" "$ref"
+
+ if [ ! -f "$work/PreciseNumber.Benchmarks/PreciseNumber.Benchmarks.csproj" ]; then
+ echo "::warning::$ref has no benchmark project; skipping"
+ git worktree remove --force "$work"
+ echo "::endgroup::"
+ continue
+ fi
+
+ # Each ref is measured by its own benchmark sources. Between 2.0.0 and now those
+ # sources are unchanged, so this compares library versions rather than harnesses.
+ (cd "$work" && dotnet run -c Release --project PreciseNumber.Benchmarks -- \
+ --filter $HEADLINE_FILTER \
+ --job "$BENCHMARK_JOB" \
+ --artifacts "$GITHUB_WORKSPACE/$RUNS/$label")
+
+ dotnet run scripts/benchmark-history.cs -- ingest \
+ --history "$HISTORY" \
+ --results "$RUNS/$label" \
+ --version "$label" \
+ --commit "$(git rev-parse --short "$ref^{commit}")" \
+ --date "$(git log -1 --format=%cs "$ref")" \
+ --run-id "${{ github.run_id }}" \
+ --baseline-ns "$BASELINE_NS"
+
+ git worktree remove --force "$work"
+ echo "::endgroup::"
+ done
+
+ - name: Redraw the chart
+ shell: bash
+ run: dotnet run scripts/benchmark-history.cs -- render --history "$HISTORY" --out "$CHART"
+
+ - name: Commit the history and the chart
+ shell: bash
+ run: |
+ set -euo pipefail
+ git config user.name "github-actions[bot]"
+ git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
+ # Staged first, then compared against the index: on the first run these files are new,
+ # and `git diff` alone does not see an untracked file, so the run would push nothing and
+ # still report success.
+ git add "$HISTORY" "${CHART%.svg}"*.svg
+ if git diff --cached --quiet; then
+ echo "Nothing changed."
+ exit 0
+ fi
+ # [skip ci] so that committing results does not start the pipeline over again.
+ git commit -m "[bot][skip ci] Update benchmark history"
+ branch="${{ github.event.repository.default_branch }}"
+ # Another release may have landed while this job was measuring.
+ git pull --rebase origin "$branch"
+ git push origin "HEAD:$branch"
+
+ - name: Upload the raw reports
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: benchmark-history-${{ github.run_id }}
+ path: ${{ env.RUNS }}/
+ retention-days: 30
+ if-no-files-found: warn
diff --git a/PreciseNumber.Benchmarks/BaselineBenchmarks.cs b/PreciseNumber.Benchmarks/BaselineBenchmarks.cs
new file mode 100644
index 0000000..897d05a
--- /dev/null
+++ b/PreciseNumber.Benchmarks/BaselineBenchmarks.cs
@@ -0,0 +1,59 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+namespace ktsu.PreciseNumber.Benchmarks;
+
+using BenchmarkDotNet.Attributes;
+
+///
+/// Measures a fixed workload that touches none of this library, so that timings taken on
+/// different machines can be compared.
+///
+///
+/// Every release is benchmarked in its own CI job, and a job lands on whichever shared runner is
+/// free — an x86-64-v3 or v4 host, at whatever clock its neighbours leave it. That difference is
+/// routinely larger than the changes a release makes, so a chart of raw times across releases
+/// mostly plots the runner.
+///
+/// This benchmark is the fixed point that makes the rest comparable. It is integer arithmetic over
+/// a value the JIT cannot fold away, chosen because it has no allocation, no library code, and no
+/// dependence on anything that changes between versions — so its measured time is a reading of the
+/// machine and nothing else. Dividing a benchmark's time by this one's, taken in the same job,
+/// cancels most of the difference between hosts. `scripts/benchmark_history.py` records it on
+/// every entry and plots the ratio rather than the nanoseconds.
+///
+///
+/// It follows that this method's body must never change. Editing it silently rescales every
+/// comparison drawn against history recorded before the edit.
+///
+///
+[MemoryDiagnoser]
+public class BaselineBenchmarks
+{
+ // Read from a field rather than written as a literal, so that the loop cannot be constant
+ // folded into its own answer at JIT time.
+ private ulong seed;
+
+ ///
+ /// Sets the starting value.
+ ///
+ [GlobalSetup]
+ public void Setup() => seed = 0xcbf29ce484222325;
+
+ ///
+ /// Mixes a counter with a multiply-xor-shift step, the way a non-cryptographic hash does.
+ ///
+ /// The accumulated value, returned so that nothing here is dead code.
+ [Benchmark]
+ public ulong ReferenceWork()
+ {
+ ulong accumulator = seed;
+
+ for (int i = 0; i < 256; i++)
+ {
+ accumulator = (accumulator ^ (ulong)i) * 0x100000001b3;
+ accumulator ^= accumulator >> 29;
+ }
+
+ return accumulator;
+ }
+}
diff --git a/README.md b/README.md
index 7ba9185..fe4b64f 100644
--- a/README.md
+++ b/README.md
@@ -14,6 +14,8 @@ A high-precision numeric type for .NET that provides arbitrary precision arithme
- [Features](#features)
+- [Performance](#performance)
+
- [Getting Started](#getting-started)
- [Installation](#installation)
@@ -56,8 +58,6 @@ A high-precision numeric type for .NET that provides arbitrary precision arithme
- [Limitations](#limitations)
-- [Performance](#performance)
-
- [API Reference](#api-reference)
- [PreciseNumber Class](#precisenumber-class)
@@ -86,6 +86,41 @@ A high-precision numeric type for .NET that provides arbitrary precision arithme
- **Balanced Performance**: The design prioritizes accuracy and precision while maintaining reasonable performance. For calculations where extreme precision matters more than raw speed, PreciseNumber delivers excellent results, though built-in numeric types remain faster for standard precision needs.
+## Performance
+
+Values are immutable value types. Every operation returns a new value, but that value lives inline
+in its variable, field, or array element, so the only heap allocation is the `BigInteger` digit
+array, and a significand that fits in an `int` doesn't need one. Cost therefore tracks the number
+of significant digits rather than the magnitude of the value, and allocation matters as much as
+raw speed.
+
+
+
+
+
+
+Every release measures a fixed set of benchmarks and adds a point to the chart above; the numbers
+behind it are in [`docs/benchmarks/history.json`](docs/benchmarks/history.json).
+
+Read the two halves differently. **Allocation is exact** — the same code allocates the same bytes on
+any machine, so a step in the top row is always a real change. **Time is measured on shared CI
+runners**, where the host a job happens to land on varies more than most releases do, so each time
+is divided by a reference workload measured in the same job. That cancels most of the difference
+between machines; what is left is indicative rather than precise, and a small wobble between two
+releases is more likely the runner than the library.
+
+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.
+
# Getting Started
## Installation
@@ -394,26 +429,6 @@ three-argument overload when you want something other than that.
- Converting from `double`, `float`, or `Half` keeps the shortest digits that round-trip, so converting back gives the original value, and `0.3048` stays exactly 0.3048. The result of `0.1 + 0.2` in `double` arrives as 0.30000000000000004, because that's the value the `double` holds
-## Performance
-
-Values are immutable value types. Every operation returns a new value, but that value lives inline
-in its variable, field, or array element, so the only heap allocation is the `BigInteger` digit
-array, and a significand that fits in an `int` doesn't need one. 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
diff --git a/docs/benchmarks/history.json b/docs/benchmarks/history.json
new file mode 100644
index 0000000..2d37639
--- /dev/null
+++ b/docs/benchmarks/history.json
@@ -0,0 +1,813 @@
+{
+ "schemaVersion": 1,
+ "entries": [
+ {
+ "version": "1.8.0",
+ "commit": "b0564d7",
+ "date": "2026-09-13",
+ "cpu": "Intel Xeon Processor 2.80GHz",
+ "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)",
+ "baselineNs": 454.8992,
+ "runId": "local-seed",
+ "benchmarks": {
+ "ArithmeticBenchmarks.Add": {
+ "Digits=8": {
+ "meanNs": 123.1859,
+ "allocatedBytes": 104
+ },
+ "Digits=30": {
+ "meanNs": 164.7113,
+ "allocatedBytes": 120
+ },
+ "Digits=200": {
+ "meanNs": 402.6075,
+ "allocatedBytes": 264
+ }
+ },
+ "ArithmeticBenchmarks.Divide": {
+ "Digits=8": {
+ "meanNs": 794.4781,
+ "allocatedBytes": 264
+ },
+ "Digits=30": {
+ "meanNs": 889.3647,
+ "allocatedBytes": 312
+ },
+ "Digits=200": {
+ "meanNs": 995.529,
+ "allocatedBytes": 456
+ }
+ },
+ "ArithmeticBenchmarks.Multiply": {
+ "Digits=8": {
+ "meanNs": 81.9846,
+ "allocatedBytes": 72
+ },
+ "Digits=30": {
+ "meanNs": 184.0505,
+ "allocatedBytes": 96
+ },
+ "Digits=200": {
+ "meanNs": 1050.0351,
+ "allocatedBytes": 232
+ }
+ },
+ "ComparisonBenchmarks.CompareTo": {
+ "Digits=8": {
+ "meanNs": 4.5,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 4.7745,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 5.1859,
+ "allocatedBytes": 0
+ }
+ },
+ "ConstructionBenchmarks.Sanitizing": {
+ "Digits=8": {
+ "meanNs": 35.0957,
+ "allocatedBytes": 40
+ },
+ "Digits=30": {
+ "meanNs": 76.4736,
+ "allocatedBytes": 40
+ },
+ "Digits=200": {
+ "meanNs": 254.6939,
+ "allocatedBytes": 40
+ }
+ },
+ "ConversionBenchmarks.ToDouble": {
+ "": {
+ "meanNs": 20.149,
+ "allocatedBytes": 0
+ }
+ },
+ "TextBenchmarks.Parse": {
+ "Digits=8": {
+ "meanNs": 199.5932,
+ "allocatedBytes": 40
+ },
+ "Digits=30": {
+ "meanNs": 480.1194,
+ "allocatedBytes": 80
+ },
+ "Digits=200": {
+ "meanNs": 2534.6838,
+ "allocatedBytes": 152
+ }
+ }
+ }
+ },
+ {
+ "version": "1.9.0",
+ "commit": "cd9a822",
+ "date": "2026-09-13",
+ "cpu": "Intel Xeon Processor 2.80GHz",
+ "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)",
+ "baselineNs": 454.8992,
+ "runId": "local-seed",
+ "benchmarks": {
+ "ArithmeticBenchmarks.Add": {
+ "Digits=8": {
+ "meanNs": 114.0296,
+ "allocatedBytes": 104
+ },
+ "Digits=30": {
+ "meanNs": 156.3233,
+ "allocatedBytes": 120
+ },
+ "Digits=200": {
+ "meanNs": 428.4019,
+ "allocatedBytes": 264
+ }
+ },
+ "ArithmeticBenchmarks.Divide": {
+ "Digits=8": {
+ "meanNs": 403.9122,
+ "allocatedBytes": 192
+ },
+ "Digits=30": {
+ "meanNs": 613.6075,
+ "allocatedBytes": 240
+ },
+ "Digits=200": {
+ "meanNs": 2375.4439,
+ "allocatedBytes": 568
+ }
+ },
+ "ArithmeticBenchmarks.Multiply": {
+ "Digits=8": {
+ "meanNs": 84.6192,
+ "allocatedBytes": 72
+ },
+ "Digits=30": {
+ "meanNs": 179.2593,
+ "allocatedBytes": 96
+ },
+ "Digits=200": {
+ "meanNs": 1029.5614,
+ "allocatedBytes": 232
+ }
+ },
+ "ComparisonBenchmarks.CompareTo": {
+ "Digits=8": {
+ "meanNs": 4.55,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 4.7625,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 4.5483,
+ "allocatedBytes": 0
+ }
+ },
+ "ConstructionBenchmarks.Sanitizing": {
+ "Digits=8": {
+ "meanNs": 35.4251,
+ "allocatedBytes": 40
+ },
+ "Digits=30": {
+ "meanNs": 80.1594,
+ "allocatedBytes": 40
+ },
+ "Digits=200": {
+ "meanNs": 262.4124,
+ "allocatedBytes": 40
+ }
+ },
+ "ConversionBenchmarks.ToDouble": {
+ "": {
+ "meanNs": 20.7065,
+ "allocatedBytes": 0
+ }
+ },
+ "TextBenchmarks.Parse": {
+ "Digits=8": {
+ "meanNs": 205.5582,
+ "allocatedBytes": 40
+ },
+ "Digits=30": {
+ "meanNs": 490.8118,
+ "allocatedBytes": 80
+ },
+ "Digits=200": {
+ "meanNs": 2503.218,
+ "allocatedBytes": 152
+ }
+ }
+ }
+ },
+ {
+ "version": "2.0.0",
+ "commit": "da3aac6",
+ "date": "2026-09-13",
+ "cpu": "Intel Xeon Processor 2.80GHz",
+ "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)",
+ "baselineNs": 454.8992,
+ "runId": "local-seed",
+ "benchmarks": {
+ "ArithmeticBenchmarks.Add": {
+ "Digits=8": {
+ "meanNs": 100.4854,
+ "allocatedBytes": 64
+ },
+ "Digits=30": {
+ "meanNs": 145.0197,
+ "allocatedBytes": 80
+ },
+ "Digits=200": {
+ "meanNs": 406.8626,
+ "allocatedBytes": 224
+ }
+ },
+ "ArithmeticBenchmarks.Divide": {
+ "Digits=8": {
+ "meanNs": 421.971,
+ "allocatedBytes": 152
+ },
+ "Digits=30": {
+ "meanNs": 649.0157,
+ "allocatedBytes": 200
+ },
+ "Digits=200": {
+ "meanNs": 2501.2133,
+ "allocatedBytes": 528
+ }
+ },
+ "ArithmeticBenchmarks.Multiply": {
+ "Digits=8": {
+ "meanNs": 70.0239,
+ "allocatedBytes": 32
+ },
+ "Digits=30": {
+ "meanNs": 175.1806,
+ "allocatedBytes": 56
+ },
+ "Digits=200": {
+ "meanNs": 1025.2062,
+ "allocatedBytes": 192
+ }
+ },
+ "ComparisonBenchmarks.CompareTo": {
+ "Digits=8": {
+ "meanNs": 3.1494,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 4.2118,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 3.589,
+ "allocatedBytes": 0
+ }
+ },
+ "ConstructionBenchmarks.Sanitizing": {
+ "Digits=8": {
+ "meanNs": 19.0883,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 63.0727,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 242.1056,
+ "allocatedBytes": 0
+ }
+ },
+ "ConversionBenchmarks.ToDouble": {
+ "": {
+ "meanNs": 5.749,
+ "allocatedBytes": 0
+ }
+ },
+ "TextBenchmarks.Parse": {
+ "Digits=8": {
+ "meanNs": 187.9601,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 479.94,
+ "allocatedBytes": 40
+ },
+ "Digits=200": {
+ "meanNs": 2528.7065,
+ "allocatedBytes": 112
+ }
+ }
+ }
+ },
+ {
+ "version": "2.0.1",
+ "commit": "b2427f0",
+ "date": "2026-09-14",
+ "cpu": "Intel Xeon Processor 2.80GHz",
+ "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)",
+ "baselineNs": 454.8992,
+ "runId": "local-seed",
+ "benchmarks": {
+ "ArithmeticBenchmarks.Add": {
+ "Digits=8": {
+ "meanNs": 100.8056,
+ "allocatedBytes": 64
+ },
+ "Digits=30": {
+ "meanNs": 148.6598,
+ "allocatedBytes": 80
+ },
+ "Digits=200": {
+ "meanNs": 576.7779,
+ "allocatedBytes": 224
+ }
+ },
+ "ArithmeticBenchmarks.Divide": {
+ "Digits=8": {
+ "meanNs": 432.8415,
+ "allocatedBytes": 152
+ },
+ "Digits=30": {
+ "meanNs": 630.3228,
+ "allocatedBytes": 200
+ },
+ "Digits=200": {
+ "meanNs": 2408.9761,
+ "allocatedBytes": 528
+ }
+ },
+ "ArithmeticBenchmarks.Multiply": {
+ "Digits=8": {
+ "meanNs": 71.7227,
+ "allocatedBytes": 32
+ },
+ "Digits=30": {
+ "meanNs": 168.2777,
+ "allocatedBytes": 56
+ },
+ "Digits=200": {
+ "meanNs": 1036.3418,
+ "allocatedBytes": 192
+ }
+ },
+ "ComparisonBenchmarks.CompareTo": {
+ "Digits=8": {
+ "meanNs": 3.9038,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 4.1531,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 4.2655,
+ "allocatedBytes": 0
+ }
+ },
+ "ConstructionBenchmarks.Sanitizing": {
+ "Digits=8": {
+ "meanNs": 19.3621,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 63.2541,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 242.4946,
+ "allocatedBytes": 0
+ }
+ },
+ "ConversionBenchmarks.ToDouble": {
+ "": {
+ "meanNs": 5.0037,
+ "allocatedBytes": 0
+ }
+ },
+ "TextBenchmarks.Parse": {
+ "Digits=8": {
+ "meanNs": 186.071,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 486.0585,
+ "allocatedBytes": 40
+ },
+ "Digits=200": {
+ "meanNs": 2492.1256,
+ "allocatedBytes": 112
+ }
+ }
+ }
+ },
+ {
+ "version": "2.0.2",
+ "commit": "8bf324f",
+ "date": "2026-09-14",
+ "cpu": "Intel Xeon Processor 2.80GHz",
+ "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)",
+ "baselineNs": 454.8992,
+ "runId": "local-seed",
+ "benchmarks": {
+ "ArithmeticBenchmarks.Add": {
+ "Digits=8": {
+ "meanNs": 101.1571,
+ "allocatedBytes": 64
+ },
+ "Digits=30": {
+ "meanNs": 147.8035,
+ "allocatedBytes": 80
+ },
+ "Digits=200": {
+ "meanNs": 397.9905,
+ "allocatedBytes": 224
+ }
+ },
+ "ArithmeticBenchmarks.Divide": {
+ "Digits=8": {
+ "meanNs": 421.5628,
+ "allocatedBytes": 152
+ },
+ "Digits=30": {
+ "meanNs": 648.9157,
+ "allocatedBytes": 200
+ },
+ "Digits=200": {
+ "meanNs": 2435.5906,
+ "allocatedBytes": 528
+ }
+ },
+ "ArithmeticBenchmarks.Multiply": {
+ "Digits=8": {
+ "meanNs": 74.7106,
+ "allocatedBytes": 32
+ },
+ "Digits=30": {
+ "meanNs": 169.6895,
+ "allocatedBytes": 56
+ },
+ "Digits=200": {
+ "meanNs": 1011.6622,
+ "allocatedBytes": 192
+ }
+ },
+ "ComparisonBenchmarks.CompareTo": {
+ "Digits=8": {
+ "meanNs": 4.0939,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 3.8344,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 3.6637,
+ "allocatedBytes": 0
+ }
+ },
+ "ConstructionBenchmarks.Sanitizing": {
+ "Digits=8": {
+ "meanNs": 20.3191,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 64.0998,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 244.0778,
+ "allocatedBytes": 0
+ }
+ },
+ "ConversionBenchmarks.ToDouble": {
+ "": {
+ "meanNs": 5.3519,
+ "allocatedBytes": 0
+ }
+ },
+ "TextBenchmarks.Parse": {
+ "Digits=8": {
+ "meanNs": 185.887,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 489.6898,
+ "allocatedBytes": 40
+ },
+ "Digits=200": {
+ "meanNs": 2449.7325,
+ "allocatedBytes": 112
+ }
+ }
+ }
+ },
+ {
+ "version": "2.0.3",
+ "commit": "6722ff9",
+ "date": "2026-09-14",
+ "cpu": "Intel Xeon Processor 2.80GHz",
+ "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)",
+ "baselineNs": 454.8992,
+ "runId": "local-seed",
+ "benchmarks": {
+ "ArithmeticBenchmarks.Add": {
+ "Digits=8": {
+ "meanNs": 106.0063,
+ "allocatedBytes": 64
+ },
+ "Digits=30": {
+ "meanNs": 145.0424,
+ "allocatedBytes": 80
+ },
+ "Digits=200": {
+ "meanNs": 400.2685,
+ "allocatedBytes": 224
+ }
+ },
+ "ArithmeticBenchmarks.Divide": {
+ "Digits=8": {
+ "meanNs": 423.013,
+ "allocatedBytes": 152
+ },
+ "Digits=30": {
+ "meanNs": 641.3537,
+ "allocatedBytes": 200
+ },
+ "Digits=200": {
+ "meanNs": 3414.9285,
+ "allocatedBytes": 528
+ }
+ },
+ "ArithmeticBenchmarks.Multiply": {
+ "Digits=8": {
+ "meanNs": 67.2843,
+ "allocatedBytes": 32
+ },
+ "Digits=30": {
+ "meanNs": 168.3793,
+ "allocatedBytes": 56
+ },
+ "Digits=200": {
+ "meanNs": 1019.9757,
+ "allocatedBytes": 192
+ }
+ },
+ "ComparisonBenchmarks.CompareTo": {
+ "Digits=8": {
+ "meanNs": 4.4645,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 3.8372,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 3.5919,
+ "allocatedBytes": 0
+ }
+ },
+ "ConstructionBenchmarks.Sanitizing": {
+ "Digits=8": {
+ "meanNs": 19.6194,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 67.0253,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 247.2198,
+ "allocatedBytes": 0
+ }
+ },
+ "ConversionBenchmarks.ToDouble": {
+ "": {
+ "meanNs": 5.1391,
+ "allocatedBytes": 0
+ }
+ },
+ "TextBenchmarks.Parse": {
+ "Digits=8": {
+ "meanNs": 190.2204,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 485.4024,
+ "allocatedBytes": 40
+ },
+ "Digits=200": {
+ "meanNs": 2529.4197,
+ "allocatedBytes": 112
+ }
+ }
+ }
+ },
+ {
+ "version": "2.0.4",
+ "commit": "c265bdd",
+ "date": "2026-09-15",
+ "cpu": "Intel Xeon Processor 2.80GHz",
+ "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)",
+ "baselineNs": 454.8992,
+ "runId": "local-seed",
+ "benchmarks": {
+ "ArithmeticBenchmarks.Add": {
+ "Digits=8": {
+ "meanNs": 103.5012,
+ "allocatedBytes": 64
+ },
+ "Digits=30": {
+ "meanNs": 147.654,
+ "allocatedBytes": 80
+ },
+ "Digits=200": {
+ "meanNs": 395.7874,
+ "allocatedBytes": 224
+ }
+ },
+ "ArithmeticBenchmarks.Divide": {
+ "Digits=8": {
+ "meanNs": 419.0343,
+ "allocatedBytes": 152
+ },
+ "Digits=30": {
+ "meanNs": 627.7278,
+ "allocatedBytes": 200
+ },
+ "Digits=200": {
+ "meanNs": 2440.4735,
+ "allocatedBytes": 528
+ }
+ },
+ "ArithmeticBenchmarks.Multiply": {
+ "Digits=8": {
+ "meanNs": 69.7283,
+ "allocatedBytes": 32
+ },
+ "Digits=30": {
+ "meanNs": 165.934,
+ "allocatedBytes": 56
+ },
+ "Digits=200": {
+ "meanNs": 1027.7703,
+ "allocatedBytes": 192
+ }
+ },
+ "ComparisonBenchmarks.CompareTo": {
+ "Digits=8": {
+ "meanNs": 3.5588,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 3.5576,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 3.6354,
+ "allocatedBytes": 0
+ }
+ },
+ "ConstructionBenchmarks.Sanitizing": {
+ "Digits=8": {
+ "meanNs": 19.7243,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 63.6485,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 241.9847,
+ "allocatedBytes": 0
+ }
+ },
+ "ConversionBenchmarks.ToDouble": {
+ "": {
+ "meanNs": 5.3881,
+ "allocatedBytes": 0
+ }
+ },
+ "TextBenchmarks.Parse": {
+ "Digits=8": {
+ "meanNs": 189.778,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 477.5123,
+ "allocatedBytes": 40
+ },
+ "Digits=200": {
+ "meanNs": 2512.168,
+ "allocatedBytes": 112
+ }
+ }
+ }
+ },
+ {
+ "version": "2.0.5",
+ "commit": "3857bac",
+ "date": "2026-09-16",
+ "cpu": "Intel Xeon Processor 2.80GHz",
+ "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)",
+ "baselineNs": 454.8992,
+ "runId": "local-seed",
+ "benchmarks": {
+ "ArithmeticBenchmarks.Add": {
+ "Digits=8": {
+ "meanNs": 104.0341,
+ "allocatedBytes": 64
+ },
+ "Digits=30": {
+ "meanNs": 144.1285,
+ "allocatedBytes": 80
+ },
+ "Digits=200": {
+ "meanNs": 397.1837,
+ "allocatedBytes": 224
+ }
+ },
+ "ArithmeticBenchmarks.Divide": {
+ "Digits=8": {
+ "meanNs": 425.7019,
+ "allocatedBytes": 152
+ },
+ "Digits=30": {
+ "meanNs": 615.669,
+ "allocatedBytes": 200
+ },
+ "Digits=200": {
+ "meanNs": 3372.9288,
+ "allocatedBytes": 528
+ }
+ },
+ "ArithmeticBenchmarks.Multiply": {
+ "Digits=8": {
+ "meanNs": 68.7651,
+ "allocatedBytes": 32
+ },
+ "Digits=30": {
+ "meanNs": 167.7462,
+ "allocatedBytes": 56
+ },
+ "Digits=200": {
+ "meanNs": 1010.8013,
+ "allocatedBytes": 192
+ }
+ },
+ "ComparisonBenchmarks.CompareTo": {
+ "Digits=8": {
+ "meanNs": 4.2163,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 3.8202,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 3.5939,
+ "allocatedBytes": 0
+ }
+ },
+ "ConstructionBenchmarks.Sanitizing": {
+ "Digits=8": {
+ "meanNs": 19.1214,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 62.9138,
+ "allocatedBytes": 0
+ },
+ "Digits=200": {
+ "meanNs": 241.3521,
+ "allocatedBytes": 0
+ }
+ },
+ "ConversionBenchmarks.ToDouble": {
+ "": {
+ "meanNs": 4.8894,
+ "allocatedBytes": 0
+ }
+ },
+ "TextBenchmarks.Parse": {
+ "Digits=8": {
+ "meanNs": 194.2737,
+ "allocatedBytes": 0
+ },
+ "Digits=30": {
+ "meanNs": 476.8454,
+ "allocatedBytes": 40
+ },
+ "Digits=200": {
+ "meanNs": 2442.8716,
+ "allocatedBytes": 112
+ }
+ }
+ }
+ }
+ ]
+}
diff --git a/docs/benchmarks/performance-dark.svg b/docs/benchmarks/performance-dark.svg
new file mode 100644
index 0000000..28fc8de
--- /dev/null
+++ b/docs/benchmarks/performance-dark.svg
@@ -0,0 +1,205 @@
+
diff --git a/docs/benchmarks/performance.svg b/docs/benchmarks/performance.svg
new file mode 100644
index 0000000..b417420
--- /dev/null
+++ b/docs/benchmarks/performance.svg
@@ -0,0 +1,205 @@
+
diff --git a/scripts/benchmark-history.cs b/scripts/benchmark-history.cs
new file mode 100644
index 0000000..408895d
--- /dev/null
+++ b/scripts/benchmark-history.cs
@@ -0,0 +1,602 @@
+// Copyright (c) 2023-2026 ktsu-dev contributors
+
+// Accumulates benchmark results per release and draws them for the README.
+//
+// dotnet run scripts/benchmark-history.cs -- ingest --history --results --version
+// dotnet run scripts/benchmark-history.cs -- render --history --out ");
+ return svg.ToString();
+ }
+
+ private static void Preamble(StringBuilder svg, Theme theme, int width, int height, JsonArray entries)
+ {
+ svg.AppendLine(CultureInfo.InvariantCulture, $"""