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. + + + + Allocated bytes per operation, and time relative to a fixed reference workload, for each PreciseNumber release + + +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 @@ + + + +PreciseNumber performance by release +8 releases · newest 2.0.5 · 2026-09-16 + +Allocated bytes per operation +Deterministic: the same code allocates the same bytes on any machine. +Add (30 digits) + + + + + + + + + + +80 B +120 B +Multiply (30 digits) + + + + + + + + + + +56 B +96 B +Divide (30 digits) + + + + + + + + + + +200 B +312 B +CompareTo (30 digits) + + + + + + + + + + +0 B +0 B +Construct (30 digits) + + + + + + + + + + +0 B +40 B +Parse (30 digits) + + + + + + + + + + +40 B +80 B +ToDouble + + + + + + + + + + +0 B +0 B + +Time, as a multiple of a fixed reference workload +Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster. +Add (30 digits) + + + + + + + + + + +0.317× +0.362× +Multiply (30 digits) + + + + + + + + + + +0.369× +0.405× +Divide (30 digits) + + + + + + + + + + +1.35× +1.96× +CompareTo (30 digits) + + + + + + + + + + +0.0084× +0.0105× +Construct (30 digits) + + + + + + + + + + +0.138× +0.168× +Parse (30 digits) + + + + + + + + + + +1.05× +1.06× +ToDouble + + + + + + + + + + +0.0107× +0.0443× +releases, oldest to newest: 1.8.0 → 1.9.0 → 2.0.0 → 2.0.1 → 2.0.2 → 2.0.3 → 2.0.4 → 2.0.5 +Measured on Intel Xeon Processor 2.80GHz. Full tables: PreciseNumber.Benchmarks. + 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 @@ + + + +PreciseNumber performance by release +8 releases · newest 2.0.5 · 2026-09-16 + +Allocated bytes per operation +Deterministic: the same code allocates the same bytes on any machine. +Add (30 digits) + + + + + + + + + + +80 B +120 B +Multiply (30 digits) + + + + + + + + + + +56 B +96 B +Divide (30 digits) + + + + + + + + + + +200 B +312 B +CompareTo (30 digits) + + + + + + + + + + +0 B +0 B +Construct (30 digits) + + + + + + + + + + +0 B +40 B +Parse (30 digits) + + + + + + + + + + +40 B +80 B +ToDouble + + + + + + + + + + +0 B +0 B + +Time, as a multiple of a fixed reference workload +Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster. +Add (30 digits) + + + + + + + + + + +0.317× +0.362× +Multiply (30 digits) + + + + + + + + + + +0.369× +0.405× +Divide (30 digits) + + + + + + + + + + +1.35× +1.96× +CompareTo (30 digits) + + + + + + + + + + +0.0084× +0.0105× +Construct (30 digits) + + + + + + + + + + +0.138× +0.168× +Parse (30 digits) + + + + + + + + + + +1.05× +1.06× +ToDouble + + + + + + + + + + +0.0107× +0.0443× +releases, oldest to newest: 1.8.0 → 1.9.0 → 2.0.0 → 2.0.1 → 2.0.2 → 2.0.3 → 2.0.4 → 2.0.5 +Measured on Intel Xeon Processor 2.80GHz. Full tables: PreciseNumber.Benchmarks. + 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 +// +// A file-based app rather than a project: it is tooling, it is the same language as the library, +// and the SDK that builds the library already runs it with nothing else installed. The work still +// lives in a class rather than in top-level statements, so the analyzers judge each method. + +using System.Globalization; +using System.Text; +using System.Text.Json; +using System.Text.Json.Nodes; +using System.Text.RegularExpressions; + +return BenchmarkHistory.Run(args); + +/// Reads BenchmarkDotNet reports into a per-release history, and draws it. +internal static partial class BenchmarkHistory +{ + private const int SchemaVersion = 1; + private const string BaselineKey = "BaselineBenchmarks.ReferenceWork"; + private const int Columns = 4; + private const int CellWidth = 228; + private const int CellHeight = 132; + private const int Left = 56; + + /// The benchmarks the README draws, in order. + /// + /// Everything measured is stored; this only decides what the picture shows, so it can change + /// without re-running anything. + /// + private static readonly (string Key, string? Parameters, string Label)[] Headline = + [ + ("ArithmeticBenchmarks.Add", "30", "Add"), + ("ArithmeticBenchmarks.Multiply", "30", "Multiply"), + ("ArithmeticBenchmarks.Divide", "30", "Divide"), + ("ComparisonBenchmarks.CompareTo", "30", "CompareTo"), + ("ConstructionBenchmarks.Sanitizing", "30", "Construct"), + ("TextBenchmarks.Parse", "30", "Parse"), + ("ConversionBenchmarks.ToDouble", null, "ToDouble"), + ]; + + /// + /// Validated for colour-vision separation against both surfaces: every check passes, worst + /// adjacent pair dE 24.7 light and 26.8 dark. + /// + private static readonly Dictionary Themes = new(StringComparer.Ordinal) + { + ["light"] = new("#fcfcfb", "#0b0b0b", "#52514e", "#e4e3df", "#2a78d6", "#eb6834"), + ["dark"] = new("#1a1a19", "#ffffff", "#c3c2b7", "#333330", "#3987e5", "#d95926"), + }; + + private sealed record Theme( + string Surface, string Ink, string Muted, string Grid, string Alloc, string Time); + + internal static int Run(string[] args) + { + if (args.Length == 0) + { + Console.Error.WriteLine("Expected 'ingest', 'render', or 'baseline'."); + return 2; + } + + Dictionary options = ReadOptions(args.Skip(1)); + try + { + return args[0] switch + { + "ingest" => Ingest(options), + "render" => Render(options), + "baseline" => PrintBaseline(options), + _ => Unknown(args[0]), + }; + } + catch (InvalidOperationException problem) + { + Console.Error.WriteLine(problem.Message); + return 2; + } + } + + /// Prints the reference workload's mean, for a workflow to carry between steps. + private static int PrintBaseline(Dictionary options) + { + string directory = Required(options, "results"); + string[] reports = Directory.GetFiles(directory, "*-report-full.json", SearchOption.AllDirectories); + if (reports.Length == 0) + { + Console.Error.WriteLine($"No *-report-full.json under {directory}"); + return 1; + } + + (var measured, _, _) = ReadReports(reports); + double? baseline = Baseline(measured, ""); + if (baseline is null) + { + Console.Error.WriteLine($"No {BaselineKey} measurement under {directory}"); + return 1; + } + + Console.WriteLine(baseline.Value.ToString(CultureInfo.InvariantCulture)); + return 0; + } + + private static int Unknown(string command) + { + Console.Error.WriteLine($"Unknown command '{command}'."); + return 2; + } + + private static Dictionary ReadOptions(IEnumerable rest) + { + Dictionary found = new(StringComparer.Ordinal); + string? name = null; + foreach (string argument in rest) + { + if (argument.StartsWith("--", StringComparison.Ordinal)) + { + name = argument[2..]; + found[name] = ""; + } + else if (name is not null) + { + found[name] = argument; + name = null; + } + } + + return found; + } + + private static string Required(Dictionary options, string name) => + options.TryGetValue(name, out string? value) && value.Length > 0 + ? value + : throw new InvalidOperationException($"--{name} is required"); + + private static string Optional(Dictionary options, string name, string fallback = "") => + options.TryGetValue(name, out string? value) && value.Length > 0 ? value : fallback; + + private static int Ingest(Dictionary options) + { + string resultsDirectory = Required(options, "results"); + string[] reports = Directory.GetFiles(resultsDirectory, "*-report-full.json", SearchOption.AllDirectories); + Array.Sort(reports, StringComparer.Ordinal); + if (reports.Length == 0) + { + Console.Error.WriteLine($"No *-report-full.json under {resultsDirectory}"); + return 1; + } + + (var measured, string cpu, string runtime) = ReadReports(reports); + double? baseline = Baseline(measured, Optional(options, "baseline-ns")); + if (baseline is null) + { + Console.Error.WriteLine( + $"warning: no {BaselineKey} measurement and no --baseline-ns; " + + "this entry's times will not be comparable across runners"); + } + + string version = Required(options, "version"); + JsonObject record = new() + { + ["version"] = version, + ["commit"] = Optional(options, "commit"), + ["date"] = Optional(options, "date", DateTime.UtcNow.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture)), + ["cpu"] = cpu, + ["runtime"] = runtime, + ["baselineNs"] = baseline, + ["runId"] = Optional(options, "run-id"), + ["benchmarks"] = Benchmarks(measured), + }; + + string historyPath = Required(options, "history"); + JsonObject history = LoadHistory(historyPath); + JsonArray entries = history["entries"]!.AsArray(); + + // A version is measured once. Re-running a release replaces its entry rather than doubling it. + for (int index = entries.Count - 1; index >= 0; index--) + { + if (string.Equals(entries[index]?["version"]?.GetValue(), version, StringComparison.Ordinal)) + { + entries.RemoveAt(index); + } + } + + entries.Add((JsonNode?)record); + Reorder(entries); + + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(historyPath))!); + File.WriteAllText(historyPath, history.ToJsonString(new JsonSerializerOptions { WriteIndented = true }) + "\n"); + + Console.WriteLine( + $"ingested {version}: {record["benchmarks"]!.AsObject().Count} benchmarks, " + + $"baseline {baseline?.ToString(CultureInfo.InvariantCulture) ?? "none"} ns, " + + $"cpu {(cpu.Length > 0 ? cpu : "unknown")}"); + return 0; + } + + private static (SortedDictionary> Measured, string Cpu, string Runtime) + ReadReports(string[] reports) + { + SortedDictionary> measured = new(StringComparer.Ordinal); + string cpu = ""; + string runtime = ""; + + foreach (JsonNode document in reports.Select(report => JsonNode.Parse(File.ReadAllText(report))!)) + { + if (cpu.Length == 0 && document["HostEnvironmentInfo"] is JsonNode environment) + { + cpu = (environment["ProcessorName"]?.GetValue() ?? "").Trim(); + runtime = (environment["RuntimeVersion"]?.GetValue() ?? "").Trim(); + } + + foreach (JsonNode? entry in document["Benchmarks"]?.AsArray() ?? []) + { + if (entry?["Statistics"]?["Mean"] is not JsonNode mean) + { + continue; + } + + string key = BenchmarkKey(entry["FullName"]?.GetValue() ?? ""); + string parameters = (entry["Parameters"]?.GetValue() ?? "").Trim(); + if (!measured.TryGetValue(key, out List? cases)) + { + cases = []; + measured[key] = cases; + } + + Measurement measurement = new( + Math.Round(mean.GetValue(), 4), + entry["Memory"]?["BytesAllocatedPerOperation"]?.GetValue() ?? 0); + int existing = cases.FindIndex(one => string.Equals(one.Parameters, parameters, StringComparison.Ordinal)); + if (existing >= 0) + { + cases[existing] = new(parameters, measurement); + } + else + { + cases.Add(new(parameters, measurement)); + } + } + } + + return (measured, cpu, runtime); + } + + private sealed record Measurement(double MeanNs, long AllocatedBytes); + + private sealed record ParameterCase(string Parameters, Measurement Value); + + private static double? Baseline( + SortedDictionary> measured, string given) + { + if (measured.TryGetValue(BaselineKey, out List? cases) && cases.Count > 0) + { + return cases[0].Value.MeanNs; + } + + return double.TryParse(given, NumberStyles.Float, CultureInfo.InvariantCulture, out double parsed) + ? parsed + : null; + } + + private static JsonObject Benchmarks( + SortedDictionary> measured) + { + JsonObject benchmarks = []; + foreach ((string key, List cases) in measured) + { + if (string.Equals(key, BaselineKey, StringComparison.Ordinal)) + { + continue; + } + + JsonObject byParameters = []; + foreach (ParameterCase one in cases) + { + byParameters[one.Parameters] = new JsonObject + { + ["meanNs"] = one.Value.MeanNs, + ["allocatedBytes"] = one.Value.AllocatedBytes, + }; + } + + benchmarks[key] = byParameters; + } + + return benchmarks; + } + + private static void Reorder(JsonArray entries) + { + JsonNode[] ordered = + [ + .. entries + .Select(node => node!.DeepClone()) + .OrderBy(node => node["version"]?.GetValue() ?? "", VersionOrder.Instance), + ]; + + entries.Clear(); + foreach (JsonNode node in ordered) + { + entries.Add((JsonNode?)node); + } + } + + private static string BenchmarkKey(string fullName) + { + string bare = fullName.Split('(')[0]; + string[] parts = bare.Split('.'); + return parts.Length >= 2 ? $"{parts[^2]}.{parts[^1]}" : bare; + } + + private static JsonObject LoadHistory(string path) + { + if (!File.Exists(path)) + { + return new JsonObject { ["schemaVersion"] = SchemaVersion, ["entries"] = new JsonArray() }; + } + + JsonObject history = JsonNode.Parse(File.ReadAllText(path))!.AsObject(); + history["schemaVersion"] ??= SchemaVersion; + history["entries"] ??= new JsonArray(); + return history; + } + + /// Orders versions numerically, keeping anything unparseable first in name order. + private sealed class VersionOrder : IComparer + { + internal static readonly VersionOrder Instance = new(); + + public int Compare(string? left, string? right) + { + int[] first = Numbers(left); + int[] second = Numbers(right); + for (int index = 0; index < Math.Min(first.Length, second.Length); index++) + { + if (first[index] != second[index]) + { + return first[index].CompareTo(second[index]); + } + } + + return first.Length != second.Length + ? first.Length.CompareTo(second.Length) + : string.CompareOrdinal(left, right); + } + + private static int[] Numbers(string? text) => + [.. DigitRun().Matches(text ?? "").Select(match => int.Parse(match.Value, CultureInfo.InvariantCulture))]; + } + + [GeneratedRegex("[0-9]+")] + private static partial Regex DigitRun(); + + private static int Render(Dictionary options) + { + string historyPath = Required(options, "history"); + JsonArray entries = LoadHistory(historyPath)["entries"]!.AsArray(); + if (entries.Count == 0) + { + Console.Error.WriteLine($"{historyPath} has no entries to draw"); + return 1; + } + + string output = Required(options, "out"); + string extension = Path.GetExtension(output); + string stem = output[..^extension.Length]; + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(output))!); + + List written = []; + foreach (string name in (string[])["light", "dark"]) + { + string path = string.Equals(name, "light", StringComparison.Ordinal) + ? output + : $"{stem}-dark{extension}"; + File.WriteAllText(path, Draw(entries, Themes[name])); + written.Add(path); + } + + Console.WriteLine($"rendered {entries.Count} releases to {string.Join(", ", written)}"); + return 0; + } + + private static string Draw(JsonArray entries, Theme theme) + { + string[] labels = [.. entries.Select(entry => entry!["version"]?.GetValue() ?? "?")]; + int width = Left + (Columns * CellWidth) + 24; + int rows = (Headline.Length + Columns - 1) / Columns; + int height = 72 + (((34 + (rows * CellHeight)) * 2) + 54); + + StringBuilder svg = new(); + Preamble(svg, theme, width, height, entries); + + int y = 72; + foreach (bool isTime in (bool[])[false, true]) + { + Section(svg, theme, entries, labels.Length, y, isTime); + y += 34 + (rows * CellHeight); + } + + Footer(svg, entries, labels, y - 4); + svg.AppendLine(""); + return svg.ToString(); + } + + private static void Preamble(StringBuilder svg, Theme theme, int width, int height, JsonArray entries) + { + svg.AppendLine(CultureInfo.InvariantCulture, $""""""); + svg.AppendLine(""); + svg.AppendLine(CultureInfo.InvariantCulture, $""""""); + svg.AppendLine(CultureInfo.InvariantCulture, $"""PreciseNumber performance by release"""); + + JsonNode latest = entries[^1]!; + string date = latest["date"]?.GetValue() ?? ""; + string suffix = date.Length > 0 ? " · " + Escape(date) : ""; + svg.AppendLine(CultureInfo.InvariantCulture, $"""{entries.Count} releases · newest {Escape(latest["version"]?.GetValue() ?? "?")}{suffix}"""); + } + + private static void Section(StringBuilder svg, Theme theme, JsonArray entries, int points, int y, bool isTime) + { + string colour = isTime ? theme.Time : theme.Alloc; + string title = isTime + ? "Time, as a multiple of a fixed reference workload" + : "Allocated bytes per operation"; + string note = isTime + ? "Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster." + : "Deterministic: the same code allocates the same bytes on any machine."; + + svg.AppendLine(CultureInfo.InvariantCulture, $""""""); + svg.AppendLine(CultureInfo.InvariantCulture, $"""{Escape(title)}"""); + svg.AppendLine(CultureInfo.InvariantCulture, $"""{Escape(note)}"""); + + for (int position = 0; position < Headline.Length; position++) + { + (string key, string? parameters, string label) = Headline[position]; + double?[] values = [.. entries.Select(entry => Value(entry!, key, parameters, isTime))]; + Panel( + svg, + Left + (position % Columns * CellWidth), + y + 26 + (position / Columns * CellHeight), + label + (parameters is null ? "" : $" ({parameters} digits)"), + points, + values, + isTime, + colour, + theme); + } + } + + private static double? Value(JsonNode entry, string key, string? parameters, bool isTime) + { + if (entry["benchmarks"]?[key] is not JsonObject cases || cases.Count == 0) + { + return null; + } + + JsonNode? measurement = parameters is null + ? cases.First().Value + : cases.FirstOrDefault(pair => pair.Key.Contains(parameters, StringComparison.Ordinal)).Value; + if (measurement is null) + { + return null; + } + + if (!isTime) + { + return measurement["allocatedBytes"]!.GetValue(); + } + + double? baseline = entry["baselineNs"]?.GetValue(); + return baseline is > 0 ? measurement["meanNs"]!.GetValue() / baseline : null; + } + + private static void Footer(StringBuilder svg, JsonArray entries, string[] labels, int axisY) + { + List ticks = []; + for (int index = 0; index < labels.Length; index++) + { + // Every label while they fit. Thinning them reads as the whole list, which would say + // there were fewer releases than there were. + if (labels.Length > 12 && index > 0 && index < labels.Length - 1 && index % 2 == 1) + { + continue; + } + + ticks.Add(Escape(labels[index])); + } + + svg.AppendLine(CultureInfo.InvariantCulture, $"""releases, oldest to newest: {string.Join(" → ", ticks)}"""); + + string[] cpus = + [ + .. entries + .Select(entry => entry!["cpu"]?.GetValue() ?? "") + .Where(name => name.Length > 0) + .Distinct(StringComparer.Ordinal) + .OrderBy(name => name, StringComparer.Ordinal), + ]; + string measured = cpus.Length > 0 ? string.Join(", ", cpus) : "an unrecorded CPU"; + svg.AppendLine(CultureInfo.InvariantCulture, $"""Measured on {Escape(measured)}. Full tables: PreciseNumber.Benchmarks."""); + } + + /// One small multiple: a single series, so colour carries no identity of its own. + private static void Panel( + StringBuilder svg, int x0, int y0, string title, int points, double?[] values, + bool isTime, string colour, Theme theme) + { + double plotTop = y0 + 22; + double plotBottom = y0 + CellHeight - 12 - 20; + double plotLeft = x0 + 6; + double plotRight = x0 + CellWidth - 16 - 10; + + svg.AppendLine(CultureInfo.InvariantCulture, $"""{Escape(title)}"""); + + (int Index, double Value)[] present = + [ + .. values + .Select((value, index) => (Index: index, Value: value)) + .Where(point => point.Value.HasValue) + .Select(point => (point.Index, point.Value!.Value)), + ]; + + if (present.Length == 0) + { + svg.AppendLine(CultureInfo.InvariantCulture, $"""not measured"""); + return; + } + + // Zero-based: these are magnitudes, and a clipped axis would exaggerate every wobble. + double highest = present.Max(point => point.Value); + double top = highest > 0 ? highest * 1.25 : 1.0; + + double X(int index) => points == 1 + ? (plotLeft + plotRight) / 2 + : plotLeft + ((plotRight - plotLeft) * index / (points - 1)); + double Y(double value) => plotBottom - ((plotBottom - plotTop) * (value / top)); + + svg.AppendLine(CultureInfo.InvariantCulture, $""""""); + + if (present.Length > 1) + { + string line = string.Join(" ", present.Select(point => $"{F(X(point.Index))},{F(Y(point.Value))}")); + svg.AppendLine(CultureInfo.InvariantCulture, $""""""); + } + + foreach ((int index, double value) in present) + { + // A 2px surface ring keeps markers legible where the line passes behind them. + svg.AppendLine(CultureInfo.InvariantCulture, $""""""); + } + + (int lastIndex, double lastValue) = present[^1]; + string anchor = lastIndex == points - 1 ? "end" : "middle"; + svg.AppendLine(CultureInfo.InvariantCulture, $"""{Escape(Label(lastValue, isTime))}"""); + + (int firstIndex, double firstValue) = present[0]; + if (firstIndex != lastIndex) + { + svg.AppendLine(CultureInfo.InvariantCulture, $"""{Escape(Label(firstValue, isTime))}"""); + } + } + + /// One decimal is ample for an SVG coordinate, and keeps the committed diff small. + private static string F(double value) => value.ToString("0.0", CultureInfo.InvariantCulture); + + private static string Label(double value, bool isTime) => + isTime ? RatioLabel(value) : ByteLabel(value); + + private static string ByteLabel(double value) => + value <= 0 ? "0 B" + : value >= 1024 ? (value / 1024).ToString("0.0", CultureInfo.InvariantCulture) + " KB" + : value.ToString("0", CultureInfo.InvariantCulture) + " B"; + + /// Three significant figures, so a 0.0331x and a 15.3x are both legible. + private static string RatioLabel(double value) => + value <= 0 ? "0×" + : value >= 100 ? value.ToString("0", CultureInfo.InvariantCulture) + "×" + : value >= 10 ? value.ToString("0.0", CultureInfo.InvariantCulture) + "×" + : value >= 1 ? value.ToString("0.00", CultureInfo.InvariantCulture) + "×" + : value >= 0.1 ? value.ToString("0.000", CultureInfo.InvariantCulture) + "×" + : value.ToString("0.0000", CultureInfo.InvariantCulture) + "×"; + + private static string Escape(string text) => + text + .Replace("&", "&", StringComparison.Ordinal) + .Replace("<", "<", StringComparison.Ordinal) + .Replace(">", ">", StringComparison.Ordinal) + .Replace("\"", """, StringComparison.Ordinal); +}