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
203 changes: 203 additions & 0 deletions .github/workflows/benchmark-history.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,203 @@
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 of ktsu.Semantics.Quantities to backfill, oldest first"
required: false
default: "3.3.1 4.0.0 4.1.0 4.2.0 4.3.2 5.0.0 5.1.0 5.2.0 5.2.4 5.3.2"
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. One operation per storage type rather than every operation at one
# storage type: a quantity is a value type over T and does almost nothing of its own, so what a
# release changes it changes per storage type.
HEADLINE_FILTER: >-
*ConstructionBenchmarks*FromNauticalMile
*UnitConversionBenchmarks*InNauticalMile
*OperatorBenchmarks*LengthTimesLength
*VectorBenchmarks*.Length
*ComparisonBenchmarks*CompareToInterface
# 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: 240

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 Semantics.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 Semantics.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 Semantics.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
8 changes: 8 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@
same way a consumer is expected to. Not referenced by any shipping project. -->
<PackageVersion Include="ktsu.RoundTripStringJsonConverter" Version="1.0.60" />
<PackageVersion Include="ktsu.PreciseNumber" Version="2.0.3" />
<!-- Benchmark-only. -->
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BenchmarkDotNet.Annotations" Version="0.15.8" />
<!-- Only Semantics.Benchmarks 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.Semantics.Quantities" Version="5.3.2" />
<PackageVersion Include="System.Memory" Version="4.6.3" />
<PackageVersion Include="System.Threading.Tasks.Extensions" Version="4.6.3" />
<PackageVersion Include="System.Numerics.Vectors" Version="4.6.1" />
Expand Down
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,19 @@ public class UserService(ISemanticStringFactory<EmailAddress> emails)
}
```

## 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 Semantics.Quantities 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 [`Semantics.Benchmarks`](Semantics.Benchmarks/README.md).

The grid is one operation per storage type rather than every operation at one storage type. A quantity is a `readonly record struct` over its `T` and does almost nothing of its own — a value is held in the SI base unit, so an operator is the storage type's arithmetic and a struct initialiser — so the same line of user code costs different things depending on the `T` it was written against, and a release changes it per `T`.

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.

## Architecture

The quantity system is metadata-driven. The single source of truth is `Semantics.SourceGenerators/Metadata/dimensions.json` (with `units.json`, `magnitudes.json`, `conversions.json`, `domains.json`, and `logarithmic.json` alongside it), and a Roslyn incremental generator emits the quantity records, unit-conversion factories, cross-dimensional operators, and physical constants. Generated output is committed to `Semantics.Quantities/Generated/` so the project compiles without first running the generator.
Expand Down
3 changes: 3 additions & 0 deletions Semantics.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.Semantics.Test")]
59 changes: 59 additions & 0 deletions Semantics.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.Semantics.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;
}
}
32 changes: 32 additions & 0 deletions Semantics.Benchmarks/BenchmarkConfig.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Benchmarks;

using BenchmarkDotNet.Columns;
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Diagnosers;
using BenchmarkDotNet.Exporters.Json;
using BenchmarkDotNet.Order;

/// <summary>
/// The configuration every benchmark in this assembly runs under.
/// </summary>
internal static class BenchmarkConfig
{
/// <summary>
/// Builds the configuration.
/// </summary>
/// <returns>The configuration to run benchmarks with.</returns>
/// <remarks>
/// Allocation is reported alongside time because a quantity is a value type over a storage
/// type, so what allocates is the storage type rather than the quantity, and a change that
/// moves work between the two should be visible in the same table. Results are kept in
/// declaration order so that a summary reads the way the source does.
/// </remarks>
internal static IConfig Create() =>
ManualConfig.Create(DefaultConfig.Instance)
.AddDiagnoser(MemoryDiagnoser.Default)
.AddColumn(RankColumn.Arabic)
.AddExporter(JsonExporter.Full)
.WithOrderer(new DefaultOrderer(SummaryOrderPolicy.Declared));
}
Loading
Loading