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
97 changes: 52 additions & 45 deletions .github/workflows/benchmark-history.yml
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,15 @@ name: Benchmark History
# 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.
# * a published release, which measures that version from its own tag and adds one point;
# * a manual dispatch listing versions, which measures each published package in ONE job.
#
# A backfill measures packages rather than tags. Both are defensible until a benchmark is added: a
# tag predating one cannot run it, so a new measurement could only ever start from the next release,
# and AbstractionCostBenchmarks would have had no history at all. Measuring the published packages
# with today's sources gives it the same history as everything else, and times every version by
# identical code besides. ktsu.SignificantNumber and ktsu.Semantics do the same, so the three
# charts are built the same way.
#
# 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
Expand All @@ -18,15 +25,10 @@ on:
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"
versions:
description: "Space-separated released versions to backfill, oldest first"
required: false
default: "1.9.0 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4"
default: "1.8.0 1.9.0 2.0.0 2.0.1 2.0.2 2.0.3 2.0.4 2.0.5"
type: string

permissions:
Expand All @@ -52,6 +54,7 @@ env:
*ConstructionBenchmarks.Sanitizing
*TextBenchmarks.Parse
*ConversionBenchmarks.ToDouble
*AbstractionCostBenchmarks.*
# 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
Expand Down Expand Up @@ -121,51 +124,55 @@ jobs:

git worktree remove --force "$work"

- name: Measure each backfill ref
- name: Measure each backfill version
if: github.event_name == 'workflow_dispatch'
shell: bash
env:
REFS: ${{ inputs.refs }}
LABELS: ${{ inputs.labels }}
VERSIONS: ${{ inputs.versions }}
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"
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 PreciseNumber.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

# 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"
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

Expand Down
5 changes: 5 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,10 @@
<PackageVersion Include="BenchmarkDotNet" Version="0.15.8" />
<PackageVersion Include="BenchmarkDotNet.Annotations" Version="0.15.8" />
<PackageVersion Include="Polyfill" Version="11.3.0" />
<!-- 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.PreciseNumber" Version="2.0.5" />
</ItemGroup>
</Project>
160 changes: 160 additions & 0 deletions PreciseNumber.Benchmarks/AbstractionCostBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,160 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.PreciseNumber.Benchmarks;

using System.Globalization;

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;

/// <summary>
/// Measures what this type costs against the same arithmetic on a bare <see cref="double"/>.
/// </summary>
/// <remarks>
/// <para>
/// Every other class here answers "how long does this operation take", which is only readable next
/// to something. This one supplies the something: the primitive a caller would otherwise have
/// used. The same class, with the same loops and the same methodology, is in ktsu.SignificantNumber
/// and ktsu.Semantics, so the three answers are comparable with each other as well as with
/// <see cref="double"/>.
/// </para>
/// <para>
/// The bare method is the BenchmarkDotNet baseline, so the answer is the <c>Ratio</c> column rather
/// than two rows divided by hand. Unlike the wrapper types built on this one, a ratio here is not
/// expected to be 1.00 and is not a defect when it is not: arbitrary precision is a cost paid for
/// something a <see cref="double"/> cannot do at all. What the number is for is watching that cost
/// across releases.
/// </para>
/// <para>
/// <b>Why these are loops.</b> A single operation over operands that do not change is
/// loop-invariant, and the JIT hoists it out of the measurement entirely — for
/// <see cref="double"/> that leaves a method indistinguishable from an empty one, and a ratio
/// against an empty method means nothing. Here each iteration feeds the next, so there is nothing
/// to hoist and both sides are measurable.
/// </para>
/// <para>
/// <b>Which way the loop biases the answer.</b> Both sides pay the same counter and branch, and it
/// is a dependency chain, so most of that overlaps the arithmetic; whatever does not is added
/// equally to numerator and denominator and pulls the ratio toward 1.00. A ratio here is therefore
/// a floor on the real cost rather than the whole of it.
/// </para>
/// <para>
/// <b>Why the operands stay bounded, and why they are short.</b> This type carries as many digits
/// as the arithmetic produces, so a chain that compounded its operand would measure that growth
/// rather than the operation; both loops accumulate instead. The operands are also chosen to be
/// values a <see cref="double"/> can hold, so the two sides are doing the same arithmetic on the
/// same numbers rather than being handed different problems. How the cost grows with digits is a
/// different question, and <see cref="ArithmeticBenchmarks"/> answers it across its
/// <c>Digits</c> axis.
/// </para>
/// </remarks>
[MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
public class AbstractionCostBenchmarks
{
/// <summary>
/// Operations per invocation. Enough that the loop's own cost is a small share of the work,
/// few enough that the arbitrary-precision side still finishes an iteration promptly.
/// </summary>
private const int Operations = 256;

private const string SeedText = "1234.5678901234";
private const string StepText = "0.0009765625";
private const string OtherText = "3.14159265358979";

private double bareSeed;
private double bareStep;
private double bareOther;

// 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. The backfill measures
// those releases too, so the file has to compile against both shapes.
private PreciseNumber preciseSeed = default!;
private PreciseNumber preciseStep = default!;
private PreciseNumber preciseOther = default!;

/// <summary>
/// Prepares the operands, parsed from the same text on both sides.
/// </summary>
[GlobalSetup]
public void Setup()
{
bareSeed = double.Parse(SeedText, CultureInfo.InvariantCulture);
bareStep = double.Parse(StepText, CultureInfo.InvariantCulture);
bareOther = double.Parse(OtherText, CultureInfo.InvariantCulture);

preciseSeed = PreciseNumber.Parse(SeedText, CultureInfo.InvariantCulture);
preciseStep = PreciseNumber.Parse(StepText, CultureInfo.InvariantCulture);
preciseOther = PreciseNumber.Parse(OtherText, CultureInfo.InvariantCulture);
}

/// <summary>Adds along a chain, on a bare double.</summary>
/// <returns>The accumulated value.</returns>
[BenchmarkCategory("Add")]
[Benchmark(Baseline = true, OperationsPerInvoke = Operations)]
public double BareAdd()
{
double accumulator = bareSeed;

for (int i = 0; i < Operations; i++)
{
accumulator += bareStep;
}

return accumulator;
}

/// <summary>Adds along the same chain, on this type.</summary>
/// <returns>The accumulated value.</returns>
[BenchmarkCategory("Add")]
[Benchmark(OperationsPerInvoke = Operations)]
public PreciseNumber PreciseAdd()
{
PreciseNumber accumulator = preciseSeed;

for (int i = 0; i < Operations; i++)
{
accumulator += preciseStep;
}

return accumulator;
}

/// <summary>Multiplies and accumulates, on a bare double.</summary>
/// <returns>The accumulated value.</returns>
[BenchmarkCategory("Multiply")]
[Benchmark(Baseline = true, OperationsPerInvoke = Operations)]
public double BareMultiply()
{
double accumulator = 0d;
double value = bareSeed;

for (int i = 0; i < Operations; i++)
{
accumulator += value * bareOther;
value += bareStep;
}

return accumulator;
}

/// <summary>Multiplies and accumulates over the same values, on this type.</summary>
/// <returns>The accumulated value.</returns>
[BenchmarkCategory("Multiply")]
[Benchmark(OperationsPerInvoke = Operations)]
public PreciseNumber PreciseMultiply()
{
PreciseNumber accumulator = PreciseNumber.Zero;
PreciseNumber value = preciseSeed;

for (int i = 0; i < Operations; i++)
{
accumulator += value * preciseOther;
value += preciseStep;
}

return accumulator;
}
}
28 changes: 27 additions & 1 deletion PreciseNumber.Benchmarks/PreciseNumber.Benchmarks.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,34 @@
<PackageReference Include="BenchmarkDotNet.Annotations" />
</ItemGroup>

<ItemGroup>
<!-- Normally the working copy. Set BenchmarkAgainstVersion to measure a released package
instead, in the environment of the same `dotnet run` that names this project, as
`BenchmarkAgainstVersion=2.0.4 dotnet run -c Release ...`. An environment variable rather
than a `-p:` switch, because BenchmarkDotNet builds each benchmark in an autogenerated
project of its own, which a property set on the outer run never reaches. MSBuild reads
environment variables as properties in every project, including that one.

That switch is how the release history is charted, and it replaced measuring each tag from
its own worktree. Both are defensible until a benchmark is added: a tag predating it cannot
run it, so a new measurement could only ever start from the next release. Measuring the
published packages with today's sources gives the new benchmark the same history as the old
ones, and is the better comparison besides, since every version is then timed by identical
code rather than by whatever each tag happened to ship. It is also what ktsu.SignificantNumber
and ktsu.Semantics do, so the three charts are built the same way. -->
<ItemGroup Condition="'$(BenchmarkAgainstVersion)' == ''">
<ProjectReference Include="..\PreciseNumber\PreciseNumber.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(BenchmarkAgainstVersion)' != ''">
<PackageReference Include="ktsu.PreciseNumber" VersionOverride="$(BenchmarkAgainstVersion)" />
</ItemGroup>

<!-- Construction is the one area measured through an internal factory, because building a number
from its components is not otherwise reachable and is exactly what the sanitising path costs.
A published package exposes nothing, so that file cannot compile against one; it is left out
of those builds and the chart draws its panel from the working copy onward. Everything else
here is public surface and measures identically either way. -->
<ItemGroup Condition="'$(BenchmarkAgainstVersion)' != ''">
<Compile Remove="ConstructionBenchmarks.cs" />
</ItemGroup>

</Project>
33 changes: 33 additions & 0 deletions PreciseNumber.Benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,39 @@ each digit separately looks fine at 8 digits and falls apart at 200. Reading a t
apart from operands in the same decade, because aligning two exponents is its own cost, distinct
from the size of the operands.

## What this type costs against a bare double

`AbstractionCostBenchmarks` is the one benchmark here whose answer is a ratio rather than a
duration. Every other class says how long an operation takes, which is only readable beside
something; this supplies the something — the primitive a caller would otherwise have used.

The same class, with the same loops and the same methodology, is in `ktsu.SignificantNumber` and
`ktsu.Semantics`, so the three libraries answer one question the same way and their answers are
comparable with each other as well as with `double`.

| release | `Add` | `Multiply` |
|---|---|---|
| 1.8.0 | 99.7× | 293.9× |
| 1.9.0 | 99.7× | 288.2× |
| 2.0.0 | 82.8× | 248.8× |
| 2.0.5 | 83.6× | 249.8× |

Becoming a value type in 2.0 took about 15% off the price of arbitrary precision, and six releases
have held it there. **The ratio is not expected to be 1 and is not a defect for being large** — a
`double` cannot do this at all. What the chart's third section is for is noticing the day it moves.

Three things decide how the number should be read:

- **These are loops.** A single operation over operands that do not change is loop-invariant and
the JIT hoists it out, which would leave the `double` side indistinguishable from an empty method
and the ratio meaningless. Each iteration feeds the next, so there is nothing to hoist.
- **The loop's own cost biases toward 1**, being paid by both sides, so a ratio is a floor on the
real cost rather than the whole of it.
- **Both loops accumulate rather than compound**, because this type carries as many digits as the
arithmetic produces and a compounding chain would measure that growth instead of the operation.
How the cost grows with digits is a different question, and `ArithmeticBenchmarks` answers it
across the `Digits` axis.

## Reading the results

Allocation is reported next to time. Both matter here, and they trade against each other: every
Expand Down
Loading