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
205 changes: 205 additions & 0 deletions .github/workflows/benchmark-history.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,205 @@
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.
#
# Two ways in:
# * a published release, which measures that version and adds one point;
# * a manual dispatch listing versions, which measures each of them in ONE job and backfills.
#
# A backfill version is measured as a published package, through BenchmarkAgainstVersion: no tag
# before this workflow carries a benchmark project, so there is no older source to run. A release
# from now on does carry one, and is measured from its own tag through a worktree -- which is also
# what keeps the release path off a race, since the package for a tag is not necessarily on
# nuget.org yet at the moment its release is published.
#
# The backfill running as a single job still matters: separate runs land on different CI hosts,
# and that difference is larger than most releases are. Within one job the points are comparable
# as they stand; across jobs, BaselineBenchmarks is what ties them together.

on:
release:
types: [published]
workflow_dispatch:
inputs:
versions:
description: "Space-separated released versions to backfill, oldest first"
required: false
# 1.2.2 and 1.2.7 are left out: their Parse throws, which is how every operand here is
# built, so they measure nothing. The backfill would skip them; this saves it the run.
default: "1.3.0 1.4.0 1.4.20 1.4.40 2.0.0 2.0.1"
type: string

permissions:
contents: write

concurrency:
group: benchmark-history
cancel-in-progress: false

env:
DOTNET_VERSION: "10.0"
HISTORY: docs/benchmarks/history.json
CHART: docs/benchmarks/performance.svg
# Already ignored, and ktsu.Sdk regenerates .gitignore on build so a new entry would not last.
RUNS: BenchmarkDotNet.Artifacts
# The set drawn in the README.
HEADLINE_FILTER: >-
*ArithmeticBenchmarks.Add
*ArithmeticBenchmarks.Multiply
*ArithmeticBenchmarks.Divide
*ComparisonBenchmarks.CompareTo
*SignificanceBenchmarks.ReduceToThree
*TextBenchmarks.Parse
*ConversionBenchmarks.FromDouble
# Short runs: three iterations is enough for a trend line, and a release should not tie up a
# runner for half an hour.
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.
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: everything
# here shares one runner, so one reading of that runner describes all of it.
- name: Measure the reference workload
id: baseline
shell: bash
run: |
set -euo pipefail
dotnet run -c Release --project SignificantNumber.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:
TAG: ${{ github.event.release.tag_name }}
run: |
set -euo pipefail
version="${TAG#v}"
work="${RUNNER_TEMP}/bench-$version"
git worktree add --detach "$work" "$TAG"

(cd "$work" && dotnet run -c Release --project SignificantNumber.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 "$TAG^{commit}")" \
--date "$(git log -1 --format=%cs "$TAG")" \
--run-id "${{ github.run_id }}" \
--baseline-ns "${{ steps.baseline.outputs.ns }}"

git worktree remove --force "$work"

- name: Measure each backfill version
if: github.event_name == 'workflow_dispatch'
shell: bash
env:
VERSIONS: ${{ inputs.versions }}
BASELINE_NS: ${{ steps.baseline.outputs.ns }}
run: |
set -euo pipefail
read -ra versions <<< "$VERSIONS"

for version in "${versions[@]}"; do
echo "::group::$version"
# Through the environment rather than a -p: switch, because BenchmarkDotNet generates
# and builds a project of its own per run, which a property passed on the command line
# does not reach. MSBuild reads environment variables as properties in every project.
#
# A version whose API the current benchmarks cannot express is reported and skipped,
# rather than failing the whole backfill after the ones before it have been measured.
if ! BenchmarkAgainstVersion="$version" dotnet run -c Release --project SignificantNumber.Benchmarks -- \
--filter $HEADLINE_FILTER \
--job "$BENCHMARK_JOB" \
--artifacts "$GITHUB_WORKSPACE/$RUNS/$version"; then
echo "::warning::$version could not be benchmarked by the current suite; skipping"
echo "::endgroup::"
continue
fi

tag="v$version"
commit=""
date=""
if git rev-parse -q --verify "$tag^{commit}" >/dev/null; then
commit="$(git rev-parse --short "$tag^{commit}")"
date="$(git log -1 --format=%cs "$tag")"
fi

# Skipped here too, and for the same reason: a package can build against these
# benchmarks and still throw from every one of them at run time, which BenchmarkDotNet
# reports as a table of NA rather than as a failure. Ingest refuses such a run, and
# the backfill carries on to the next version.
if ! dotnet run scripts/benchmark-history.cs -- ingest \
--history "$HISTORY" \
--results "$RUNS/$version" \
--version "$version" \
--commit "$commit" \
--date "$date" \
--run-id "${{ github.run_id }}" \
--baseline-ns "$BASELINE_NS"; then
echo "::warning::$version produced no usable measurement; skipping"
fi
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 }}"
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
7 changes: 7 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
</PropertyGroup>
<ItemGroup>
<PackageVersion Include="ktsu.PreciseNumber" Version="2.0.3" />
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BenchmarkDotNet.Annotations" Version="0.15.8" />
<!-- Only the benchmark project references this, and only when BenchmarkAgainstVersion asks
it to measure a published release rather than the working copy. The version here is a
placeholder that VersionOverride replaces; central package management requires the
entry to exist before a project may override it. -->
<PackageVersion Include="ktsu.SignificantNumber" Version="2.0.1" />
<PackageVersion Include="Polyfill" Version="11.3.0" />
</ItemGroup>
</Project>
21 changes: 21 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Upgrading from 1.x? See the [2.0 migration guide](docs/migration-guide-2.0.md).

## Table of contents

- [Performance](#performance)
- [Installation](#installation)
- [Usage](#usage)
- [Creating a SignificantNumber](#creating-a-significantnumber)
Expand All @@ -44,6 +45,26 @@ Upgrading from 1.x? See the [2.0 migration guide](docs/migration-guide-2.0.md).
- [Contributing](#contributing)
- [License](#license)

## Performance

<picture>
<source media="(prefers-color-scheme: dark)" srcset="docs/benchmarks/performance-dark.svg">
<img alt="Allocated bytes per operation, and time relative to a fixed reference workload, for each SignificantNumber release" src="docs/benchmarks/performance.svg">
</picture>

Every release measures a fixed set of benchmarks and adds a point to the chart; the numbers behind
it are in [`docs/benchmarks/history.json`](docs/benchmarks/history.json), and the suite is
[`SignificantNumber.Benchmarks`](SignificantNumber.Benchmarks/README.md).

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.

The step at 2.0 is the type becoming a `readonly record struct` over `PreciseNumber` instead of
deriving from it. A 200-digit addition went from 54,688 bytes and 128 μs to 112 bytes and 340 ns.

## Installation

Install the package with the .NET CLI:
Expand Down
65 changes: 65 additions & 0 deletions SignificantNumber.Benchmarks/ArithmeticBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.SignificantNumber.Benchmarks;

using BenchmarkDotNet.Attributes;

/// <summary>
/// Measures the arithmetic operators.
/// </summary>
/// <remarks>
/// Every operation here does two things: the arithmetic itself, and then the rounding back to the
/// significance the operands justify. The second half is what separates this library from the
/// number underneath it, and it is why these cost more than the same operation on a
/// <c>PreciseNumber</c>.
/// </remarks>
[MemoryDiagnoser]
public class ArithmeticBenchmarks
{
// Assigned in GlobalSetup before anything is measured. Initialised here because this type
// was a class before 2.0, where an unassigned field is a null reference the compiler
// rejects; from 2.0 it is a struct and this is simply its default.
private SignificantNumber left = default!;
private SignificantNumber right = default!;

/// <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, -Digits);
right = Operands.Number(Digits, -Digits, offset: 7);
}

/// <summary>Adds two numbers.</summary>
/// <returns>The sum.</returns>
[Benchmark]
public SignificantNumber Add() => left + right;

/// <summary>Subtracts one number from another.</summary>
/// <returns>The difference.</returns>
[Benchmark]
public SignificantNumber Subtract() => left - right;

/// <summary>Multiplies two numbers.</summary>
/// <returns>The product.</returns>
[Benchmark]
public SignificantNumber Multiply() => left * right;

/// <summary>Divides one number by another.</summary>
/// <returns>The quotient.</returns>
[Benchmark]
public SignificantNumber Divide() => left / right;

/// <summary>Negates a number.</summary>
/// <returns>The negated number.</returns>
[Benchmark]
public SignificantNumber Negate() => -left;
}
3 changes: 3 additions & 0 deletions SignificantNumber.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.SignificantNumber.Test")]
59 changes: 59 additions & 0 deletions SignificantNumber.Benchmarks/BaselineBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.SignificantNumber.Benchmarks;

using BenchmarkDotNet.Attributes;

/// <summary>
/// Measures a fixed workload that touches none of this library, so that timings taken on
/// different machines can be compared.
/// </summary>
/// <remarks>
/// 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.
/// <para>
/// 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.cs` records it on
/// every entry and plots the ratio rather than the nanoseconds.
/// </para>
/// <para>
/// It follows that this method's body must never change. Editing it silently rescales every
/// comparison drawn against history recorded before the edit.
/// </para>
/// </remarks>
[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;

/// <summary>
/// Sets the starting value.
/// </summary>
[GlobalSetup]
public void Setup() => seed = 0xcbf29ce484222325;

/// <summary>
/// Mixes a counter with a multiply-xor-shift step, the way a non-cryptographic hash does.
/// </summary>
/// <returns>The accumulated value, returned so that nothing here is dead code.</returns>
[Benchmark]
public ulong ReferenceWork()
{
ulong accumulator = seed;

for (int i = 0; i < 256; i++)
{
accumulator = (accumulator ^ (ulong)i) * 0x100000001b3;
accumulator ^= accumulator >> 29;
}

return accumulator;
}
}
Loading