diff --git a/.github/workflows/benchmark-history.yml b/.github/workflows/benchmark-history.yml new file mode 100644 index 0000000..57841c2 --- /dev/null +++ b/.github/workflows/benchmark-history.yml @@ -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 diff --git a/Directory.Packages.props b/Directory.Packages.props index 732ea1e..42b6a60 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -11,6 +11,14 @@ same way a consumer is expected to. Not referenced by any shipping project. --> + + + + + diff --git a/README.md b/README.md index 597b074..31ee730 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,19 @@ public class UserService(ISemanticStringFactory emails) } ``` +## Performance + + + + Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Quantities release + + +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. diff --git a/Semantics.Benchmarks/AssemblyInfo.cs b/Semantics.Benchmarks/AssemblyInfo.cs new file mode 100644 index 0000000..7b1596d --- /dev/null +++ b/Semantics.Benchmarks/AssemblyInfo.cs @@ -0,0 +1,3 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ktsu.Semantics.Test")] diff --git a/Semantics.Benchmarks/BaselineBenchmarks.cs b/Semantics.Benchmarks/BaselineBenchmarks.cs new file mode 100644 index 0000000..b205a68 --- /dev/null +++ b/Semantics.Benchmarks/BaselineBenchmarks.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.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.cs` 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/Semantics.Benchmarks/BenchmarkConfig.cs b/Semantics.Benchmarks/BenchmarkConfig.cs new file mode 100644 index 0000000..f5b8164 --- /dev/null +++ b/Semantics.Benchmarks/BenchmarkConfig.cs @@ -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; + +/// +/// The configuration every benchmark in this assembly runs under. +/// +internal static class BenchmarkConfig +{ + /// + /// Builds the configuration. + /// + /// The configuration to run benchmarks with. + /// + /// 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. + /// + internal static IConfig Create() => + ManualConfig.Create(DefaultConfig.Instance) + .AddDiagnoser(MemoryDiagnoser.Default) + .AddColumn(RankColumn.Arabic) + .AddExporter(JsonExporter.Full) + .WithOrderer(new DefaultOrderer(SummaryOrderPolicy.Declared)); +} diff --git a/Semantics.Benchmarks/ComparisonBenchmarks.cs b/Semantics.Benchmarks/ComparisonBenchmarks.cs new file mode 100644 index 0000000..5c079ee --- /dev/null +++ b/Semantics.Benchmarks/ComparisonBenchmarks.cs @@ -0,0 +1,68 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks; + +using System.Numerics; + +using BenchmarkDotNet.Attributes; + +using ktsu.PreciseNumber; +using ktsu.Semantics.Quantities; + +/// +/// Measures comparing and equating quantities, by each of the two routes available. +/// +/// +/// A quantity carries two comparisons, and they do not cost the same. The operators are the +/// storage type's own, on a struct, and should allocate nothing. +/// and go through PhysicalQuantityCore, which takes an +/// IPhysicalQuantity<T> — an interface, so the argument is boxed at the call, and the +/// dimensions are checked before the values are. That is a deliberate difference rather than an +/// oversight: the interface route is the one that can refuse to compare a length with a mass, and +/// this class is what says what it costs. +/// +/// The storage type. +[MemoryDiagnoser] +[GenericTypeArguments(typeof(double))] +[GenericTypeArguments(typeof(float))] +[GenericTypeArguments(typeof(decimal))] +[GenericTypeArguments(typeof(PreciseNumber))] +public class ComparisonBenchmarks + where T : struct, INumber +{ + // Assigned in GlobalSetup before anything is measured. Initialised here because a + // quantity was a class before 4.0, where an unassigned field is a null reference the + // compiler rejects; from 4.0 it is a record struct and this is simply its default. + private Length left = default!; + private Length right = default!; + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + left = Length.Create(Operands.Of("1234.5678901234567890123456")); + right = Length.Create(Operands.Of("1234.5678901234567890123457")); + } + + /// Compares with the less-than operator. + /// Whether the left sorts before the right. + [Benchmark] + public bool LessThan() => left < right; + + /// Compares through the dimension-checking interface. + /// The comparison. + [Benchmark] + public int CompareToInterface() => left.CompareTo(right); + + /// Equates through the record's generated equality. + /// Whether the two are equal. + [Benchmark] + public bool EqualsRecord() => left.Equals(right); + + /// Equates through the dimension-checking interface. + /// Whether the two are equal. + [Benchmark] + public bool EqualsInterface() => left.Equals((IPhysicalQuantity)right); +} diff --git a/Semantics.Benchmarks/ConstructionBenchmarks.cs b/Semantics.Benchmarks/ConstructionBenchmarks.cs new file mode 100644 index 0000000..5c1b654 --- /dev/null +++ b/Semantics.Benchmarks/ConstructionBenchmarks.cs @@ -0,0 +1,67 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks; + +using System.Numerics; + +using BenchmarkDotNet.Attributes; + +using ktsu.PreciseNumber; +using ktsu.Semantics.Quantities; + +/// +/// Measures building a quantity, through each of the three routes a unit factory can take. +/// +/// +/// +/// is the floor: a struct initialiser and nothing else. Every other route +/// here is that plus the work a unit costs, so the difference between them is the measurement. +/// +/// +/// adds only the magnitude guard, because a metre already is the SI base +/// unit. multiplies by a metric magnitude, and +/// by a conversion constant; both read a Values<T> +/// property that materialises the factor into the storage type once per closed generic and holds +/// it. That holder is what 5.2.0 added, and it is why the storage-type axis is the interesting one +/// here: for the factor was already a and nothing +/// changed, while for it went from a 15-digit conversion of a double to the +/// literal parsed at the type's own precision. +/// +/// +/// The storage type. +[MemoryDiagnoser] +[GenericTypeArguments(typeof(double))] +[GenericTypeArguments(typeof(float))] +[GenericTypeArguments(typeof(decimal))] +[GenericTypeArguments(typeof(PreciseNumber))] +public class ConstructionBenchmarks + where T : struct, INumber +{ + private T value; + + /// + /// Prepares the operand. + /// + [GlobalSetup] + public void Setup() => value = Operands.Of("1234.5678901234567890123456"); + + /// Builds a quantity from a value already in the SI base unit. + /// The quantity. + [Benchmark] + public Length Create() => Length.Create(value); + + /// Builds a quantity through the base unit's factory, which is the guard alone. + /// The quantity. + [Benchmark] + public Length FromMeter() => Length.FromMeter(value); + + /// Builds a quantity through a metric magnitude. + /// The quantity. + [Benchmark] + public Length FromKilometer() => Length.FromKilometer(value); + + /// Builds a quantity through a conversion constant that is not a power of ten. + /// The quantity. + [Benchmark] + public Length FromNauticalMile() => Length.FromNauticalMile(value); +} diff --git a/Semantics.Benchmarks/Operands.cs b/Semantics.Benchmarks/Operands.cs new file mode 100644 index 0000000..a2ba3e1 --- /dev/null +++ b/Semantics.Benchmarks/Operands.cs @@ -0,0 +1,28 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks; + +using System.Globalization; +using System.Numerics; + +/// +/// Builds the storage-type values the benchmarks run against. +/// +/// +/// Every value is parsed from text rather than converted from a , so that a +/// storage type wide enough to hold more than a double's 15 digits actually receives them. A +/// or a PreciseNumber seeded through a double would be measured +/// carrying a double's worth of digits, which is the opposite of why those types are here. +/// +internal static class Operands +{ + /// + /// Parses a value into the storage type. + /// + /// The storage type. + /// The decimal text to parse. + /// The parsed value. + internal static T Of(string text) + where T : struct, INumber => + T.Parse(text, NumberStyles.Float, CultureInfo.InvariantCulture); +} diff --git a/Semantics.Benchmarks/OperatorBenchmarks.cs b/Semantics.Benchmarks/OperatorBenchmarks.cs new file mode 100644 index 0000000..84eca61 --- /dev/null +++ b/Semantics.Benchmarks/OperatorBenchmarks.cs @@ -0,0 +1,95 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks; + +using System.Numerics; + +using BenchmarkDotNet.Attributes; + +using ktsu.PreciseNumber; +using ktsu.Semantics.Quantities; + +/// +/// Measures the generated physics relationships. +/// +/// +/// +/// Every value is held in its SI base unit, so a relationship operator is the storage type's own +/// arithmetic and a struct initialiser — there is no conversion in the middle of an equation. That +/// is the claim this class exists to keep honest: an operator should cost what the same arithmetic +/// costs on bare , and is here as the comparison, +/// being the one operation that changes no dimension. +/// +/// +/// For and that claim comes back as a non-answer, and the +/// non-answer is the point. The operands do not change between iterations, so a product of two of +/// them is loop-invariant and the JIT hoists it clean out; BenchmarkDotNet then reports a +/// ZeroMeasurement warning — the method is indistinguishable from an empty one. Nothing here can +/// fix that without measuring the fix instead of the operator: an array of operands adds a load, +/// a mutated field adds a store, and either would swamp the single instruction being asked about. +/// Read those two rows as "below what the harness resolves", not as a number, and read the +/// and PreciseNumber rows, which are far enough above the floor to +/// mean something. The release chart draws none of these for the same reason. +/// +/// +/// The storage type. +[MemoryDiagnoser] +[GenericTypeArguments(typeof(double))] +[GenericTypeArguments(typeof(float))] +[GenericTypeArguments(typeof(decimal))] +[GenericTypeArguments(typeof(PreciseNumber))] +public class OperatorBenchmarks + where T : struct, INumber +{ + // Assigned in GlobalSetup before anything is measured. Initialised here because a + // quantity was a class before 4.0, where an unassigned field is a null reference the + // compiler rejects; from 4.0 it is a record struct and this is simply its default. + private Length length = default!; + private Length otherLength = default!; + private Force1D force = default!; + private Duration duration = default!; + private Velocity3D velocity = default!; + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + length = Length.Create(Operands.Of("1234.5678901234567890123456")); + otherLength = Length.Create(Operands.Of("8765.4321098765432109876543")); + force = Force1D.Create(Operands.Of("-98.76543210987654321")); + duration = Duration.Create(Operands.Of("12.3456789012345678")); + velocity = new() + { + X = Operands.Of("3.14159265358979323846"), + Y = Operands.Of("-2.71828182845904523536"), + Z = Operands.Of("1.41421356237309504880"), + }; + } + + /// Adds two quantities of the same dimension. + /// The sum. + [Benchmark] + public Length Add() => length + otherLength; + + /// Multiplies two lengths, which lands on a different dimension. + /// The area. + [Benchmark] + public Area LengthTimesLength() => length * otherLength; + + /// Multiplies a signed scalar quantity by a duration. + /// The momentum. + [Benchmark] + public Momentum1D ForceTimesDuration() => force * duration; + + /// Multiplies a three-component quantity by a duration, componentwise. + /// The displacement. + [Benchmark] + public Displacement3D VelocityTimesDuration() => velocity * duration; + + /// Divides one quantity by another of the same dimension, giving a bare ratio. + /// The ratio. + [Benchmark] + public T LengthOverLength() => length / otherLength; +} diff --git a/Semantics.Benchmarks/Program.cs b/Semantics.Benchmarks/Program.cs new file mode 100644 index 0000000..3cf93f1 --- /dev/null +++ b/Semantics.Benchmarks/Program.cs @@ -0,0 +1,18 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks; + +using BenchmarkDotNet.Running; + +/// +/// Entry point for the benchmark suite. +/// +internal static class Program +{ + /// + /// Runs the benchmarks named on the command line, or prompts for a selection when none are. + /// + /// Command line arguments, forwarded to BenchmarkDotNet. + internal static void Main(string[] args) => + _ = BenchmarkSwitcher.FromAssembly(typeof(Program).Assembly).Run(args, BenchmarkConfig.Create()); +} diff --git a/Semantics.Benchmarks/README.md b/Semantics.Benchmarks/README.md new file mode 100644 index 0000000..1e8bc48 --- /dev/null +++ b/Semantics.Benchmarks/README.md @@ -0,0 +1,97 @@ +# Semantics Benchmarks + +A [BenchmarkDotNet](https://benchmarkdotnet.org) suite covering the quantity system: building a +quantity from a unit, reading it back out in one, the generated physics operators, the +componentwise vector operations, and comparison. + +## The axis that matters here is the storage type + +Every generated quantity is a `readonly record struct` over a storage type — `Length`, +`Length`, `Length` — and it does almost nothing of its own. A value is held +in the dimension's SI base unit, so an operator is the storage type's arithmetic and a struct +initialiser, and a unit factory is that plus one multiplication. What a quantity costs is therefore +mostly what its `T` costs, and a change to this library shows up differently per `T`. + +So every class here is generic and carries `[GenericTypeArguments]` for `double`, `float`, +`decimal` and `PreciseNumber`, and the summary is read **across** those four rather than down one +of them. The four are chosen to span the interesting ground: two binary floating point types the +hardware does in a register, one decimal type the runtime does in software, and one arbitrary +precision type from another package. + +Operands are **parsed from text**, never converted from a `double`. A `decimal` or a +`PreciseNumber` seeded through a double would be measured carrying a double's worth of digits, +which is the opposite of why those types are in the list. + +## Running + +From the repository root: + +```bash +# Pick benchmarks from an interactive list +dotnet run -c Release --project Semantics.Benchmarks + +# Run everything +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*' + +# One class across all four storage types, or one storage type across all classes +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*VectorBenchmarks*' +dotnet run -c Release --project Semantics.Benchmarks -- --filter '**' +``` + +## Measuring a published release + +Set `BenchmarkAgainstVersion` and the suite measures that package instead of the working copy: + +```bash +BenchmarkAgainstVersion=5.2.0 dotnet run -c Release --project Semantics.Benchmarks -- --filter '**' +``` + +Set it **in the environment, not with `-p:`**. BenchmarkDotNet generates and builds a project of +its own for each run, and a property passed on the command line does not reach that project — it +would build the benchmark assembly against the version you asked for and the harness against the +one pinned centrally, which fails to compile if a type changed shape between them. MSBuild reads +environment variables as properties in every project, so the environment form reaches both. + +This switch is how `docs/benchmarks/` is filled. No tag in this repository carries a benchmark +project, so there is no older source to check out and run; and measuring packages is the better +comparison anyway, because every version is timed by identical benchmark code rather than by +whatever each tag happened to ship. A version whose API the current benchmarks cannot express is +reported and skipped rather than failing the backfill — 4.0 made every quantity a record struct and +5.0 removed four operators, so reaching back far enough eventually finds a version this suite +cannot ask. + +It is also why nothing here touches an internal member: the `InternalsVisibleTo` that would expose +one names the test assembly, and a benchmark built on internals could only ever measure the +working copy. + +## What each class is for + +| Class | What it isolates | +|---|---| +| `ConstructionBenchmarks` | `Create` is the floor — a struct initialiser. `FromMeter` adds the magnitude guard alone. `FromKilometer` and `FromNauticalMile` add a factor read from a `Values` holder, which is what 5.2.0 introduced and what gives a `decimal` quantity its full precision. | +| `UnitConversionBenchmarks` | The way back out, through `In(unit)`. `InCelsius` is the one affine case, carrying an offset as well as a factor. | +| `OperatorBenchmarks` | The generated physics relationships. `Add` is the control, being the one operation that changes no dimension. | +| `VectorBenchmarks` | Componentwise arithmetic, and the square root two of the operations need. `LengthSquared` is `Length` without the root, so the gap between them is the root alone — which for `decimal` and `PreciseNumber` is a Newton refinement rather than a hardware instruction. | +| `ComparisonBenchmarks` | The two comparison routes. The operators are the storage type's own; `CompareTo` and the `IPhysicalQuantity` overload of `Equals` box the argument and check dimensions first, which is what buys the ability to refuse a length against a mass. | +| `BaselineBenchmarks` | Touches none of this library. It exists so that timings taken in different CI jobs can be compared; see its remarks, and do not edit its body. | + +### An operator on a `double` is below the floor + +`OperatorBenchmarks` over `double` and `float` comes back with a ZeroMeasurement warning: the +method is indistinguishable from an empty one. That is not a broken benchmark, it is the answer. +A relationship operator on a binary floating point type is one machine instruction over operands +the loop does not change, so the JIT hoists it out entirely. + +It cannot be fixed without measuring the fix instead of the operator — an array of operands adds a +load, a mutated field adds a store, and either would swamp the instruction being asked about. So +those rows are read as "below what the harness resolves" rather than as numbers, the `decimal` and +`PreciseNumber` rows in the same table are the ones that mean something, and the release chart +draws no operator panel at all. + +## Reading the results + +Allocation is reported alongside time and matters just as much. A quantity is a value type, so +anything allocated was allocated by the storage type or by boxing — a `double` or `decimal` +quantity should show 0 B for arithmetic and comparison, a `PreciseNumber` one should show its +`BigInteger` digit arrays, and the interface comparison routes should show a box on every storage +type. diff --git a/Semantics.Benchmarks/Semantics.Benchmarks.csproj b/Semantics.Benchmarks/Semantics.Benchmarks.csproj new file mode 100644 index 0000000..d76a90e --- /dev/null +++ b/Semantics.Benchmarks/Semantics.Benchmarks.csproj @@ -0,0 +1,50 @@ + + + + + + Exe + net10.0 + + + Semantics.Benchmarks + ktsu.Semantics.Benchmarks + + true + + + + + + + + + + + + + + + + + + + diff --git a/Semantics.Benchmarks/UnitConversionBenchmarks.cs b/Semantics.Benchmarks/UnitConversionBenchmarks.cs new file mode 100644 index 0000000..eb2b7ee --- /dev/null +++ b/Semantics.Benchmarks/UnitConversionBenchmarks.cs @@ -0,0 +1,78 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks; + +using System.Numerics; + +using BenchmarkDotNet.Attributes; + +using ktsu.PreciseNumber; +using ktsu.Semantics.Quantities; +using ktsu.Semantics.Quantities.Units; + +/// +/// Measures reading a quantity back out in a unit. +/// +/// +/// +/// This is the other half of the boundary measures: a +/// value goes in through a factory and comes back out through In(unit), and between the two +/// it is held in the SI base unit. Anything that uses the library at all crosses this pair, which +/// is why both are measured rather than only the arithmetic between them. +/// +/// +/// is the affine case. Every other conversion here is a multiplication; +/// a temperature also carries an offset, so it reads ToBaseOffsetAs as well and is the one +/// unit family where the second half of the affine conversion is not free. +/// +/// +/// The storage type. +[MemoryDiagnoser] +[GenericTypeArguments(typeof(double))] +[GenericTypeArguments(typeof(float))] +[GenericTypeArguments(typeof(decimal))] +[GenericTypeArguments(typeof(PreciseNumber))] +public class UnitConversionBenchmarks + where T : struct, INumber +{ + private static readonly Meter Meter = new(); + private static readonly Kilometer Kilometer = new(); + private static readonly NauticalMile NauticalMile = new(); + private static readonly Celsius Celsius = new(); + + // Assigned in GlobalSetup before anything is measured. Initialised here because a + // quantity was a class before 4.0, where an unassigned field is a null reference the + // compiler rejects; from 4.0 it is a record struct and this is simply its default. + private Length length = default!; + private Temperature temperature = default!; + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + length = Length.Create(Operands.Of("1234.5678901234567890123456")); + temperature = Temperature.Create(Operands.Of("293.15")); + } + + /// Reads the value back in the base unit, where the factor is one. + /// The value in metres. + [Benchmark] + public T InMeter() => length.In(Meter); + + /// Reads the value back through a metric magnitude. + /// The value in kilometres. + [Benchmark] + public T InKilometer() => length.In(Kilometer); + + /// Reads the value back through a conversion constant. + /// The value in nautical miles. + [Benchmark] + public T InNauticalMile() => length.In(NauticalMile); + + /// Reads a temperature back through a conversion carrying an offset. + /// The value in degrees Celsius. + [Benchmark] + public T InCelsius() => temperature.In(Celsius); +} diff --git a/Semantics.Benchmarks/VectorBenchmarks.cs b/Semantics.Benchmarks/VectorBenchmarks.cs new file mode 100644 index 0000000..7ff35fb --- /dev/null +++ b/Semantics.Benchmarks/VectorBenchmarks.cs @@ -0,0 +1,88 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks; + +using System.Numerics; + +using BenchmarkDotNet.Attributes; + +using ktsu.PreciseNumber; +using ktsu.Semantics.Quantities; + +/// +/// Measures the componentwise vector operations, and the square root two of them need. +/// +/// +/// +/// and are written-out arithmetic and nothing else, so they +/// are the control. and are the same arithmetic plus +/// StorageMath.Sqrt, and that is the whole point of the class: a binary floating point type +/// takes the Math.Sqrt round trip, and anything else is seeded from that root and refined +/// with Newton steps in its own arithmetic until it settles. The gap between the two rows is the +/// cost of those steps, and it is a per-storage-type answer rather than a single number. +/// +/// +/// is here to separate the two halves: it is +/// without the root, so the difference between them is the root alone. +/// +/// +/// The storage type. +[MemoryDiagnoser] +[GenericTypeArguments(typeof(double))] +[GenericTypeArguments(typeof(float))] +[GenericTypeArguments(typeof(decimal))] +[GenericTypeArguments(typeof(PreciseNumber))] +public class VectorBenchmarks + where T : struct, INumber +{ + // Assigned in GlobalSetup before anything is measured. Initialised here because a + // quantity was a class before 4.0, where an unassigned field is a null reference the + // compiler rejects; from 4.0 it is a record struct and this is simply its default. + private Displacement3D left = default!; + private Displacement3D right = default!; + + /// + /// Prepares the operands. + /// + [GlobalSetup] + public void Setup() + { + left = new() + { + X = Operands.Of("3.14159265358979323846"), + Y = Operands.Of("-2.71828182845904523536"), + Z = Operands.Of("1.41421356237309504880"), + }; + right = new() + { + X = Operands.Of("1.61803398874989484820"), + Y = Operands.Of("0.57721566490153286060"), + Z = Operands.Of("-1.73205080756887729352"), + }; + } + + /// Sums the squares of the components, without taking a root. + /// The squared length. + [Benchmark] + public T LengthSquared() => left.LengthSquared(); + + /// Takes the length, which is the squared length and a root. + /// The length. + [Benchmark] + public T Length() => left.Length(); + + /// Takes the distance between two vectors, which is a difference and a root. + /// The distance. + [Benchmark] + public T Distance() => left.Distance(right); + + /// Takes the dot product, which is written-out arithmetic alone. + /// The dot product. + [Benchmark] + public T Dot() => left.Dot(right); + + /// Takes the cross product, which is written-out arithmetic alone. + /// The cross product. + [Benchmark] + public Displacement3D Cross() => left.Cross(right); +} diff --git a/Semantics.sln b/Semantics.sln index b51638b..1742535 100644 --- a/Semantics.sln +++ b/Semantics.sln @@ -31,6 +31,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Cpp.Test", "Seman EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Quantities.Precise", "Semantics.Quantities.Precise\Semantics.Quantities.Precise.csproj", "{5A67DFC3-E2D2-41B9-9E42-0B4727AF0C7D}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Semantics.Benchmarks", "Semantics.Benchmarks\Semantics.Benchmarks.csproj", "{7F2A4C61-8D35-4B90-A6E2-1C0F5D3B8E47}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -209,6 +211,10 @@ Global {5A67DFC3-E2D2-41B9-9E42-0B4727AF0C7D}.Release|x64.Build.0 = Release|Any CPU {5A67DFC3-E2D2-41B9-9E42-0B4727AF0C7D}.Release|x86.ActiveCfg = Release|Any CPU {5A67DFC3-E2D2-41B9-9E42-0B4727AF0C7D}.Release|x86.Build.0 = Release|Any CPU + {7F2A4C61-8D35-4B90-A6E2-1C0F5D3B8E47}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {7F2A4C61-8D35-4B90-A6E2-1C0F5D3B8E47}.Debug|Any CPU.Build.0 = Debug|Any CPU + {7F2A4C61-8D35-4B90-A6E2-1C0F5D3B8E47}.Release|Any CPU.ActiveCfg = Release|Any CPU + {7F2A4C61-8D35-4B90-A6E2-1C0F5D3B8E47}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/docs/benchmarks/history.json b/docs/benchmarks/history.json new file mode 100644 index 0000000..ec8ff53 --- /dev/null +++ b/docs/benchmarks/history.json @@ -0,0 +1,1315 @@ +{ + "schemaVersion": 1, + "entries": [ + { + "version": "3.3.1", + "commit": "fffa086", + "date": "2026-09-09", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 8.6608, + "allocatedBytes": 0 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 4.7739, + "allocatedBytes": 0 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 11.5986, + "allocatedBytes": 0 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 5.6374, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 43.4281, + "allocatedBytes": 64 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 27.3741, + "allocatedBytes": 48 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 519.2756, + "allocatedBytes": 120 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 28.3839, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 81.1407, + "allocatedBytes": 64 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 35.0934, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 231.1584, + "allocatedBytes": 128 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 28.2983, + "allocatedBytes": 48 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 71.2753, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 7.3582, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 989.1301, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 7.1397, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 163.2781, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 1.002, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 1827.4723, + "allocatedBytes": 312 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 0.8253, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "4.0.0", + "commit": "496d895", + "date": "2026-09-10", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 20.3958, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 14.2212, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 34.4273, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 13.928, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 9.117, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 3.364, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 443.2553, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 0.9414, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 49.7154, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0007, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 169.1601, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 70.1256, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 8.4243, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 1045.9718, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 7.1489, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 162.9532, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 0.2999, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 1791.7621, + "allocatedBytes": 312 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 0.7062, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "4.1.0", + "commit": "fa79094", + "date": "2026-09-11", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 19.4354, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 14.2071, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 32.335, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 13.1068, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 9.0262, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 3.3646, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 458.7913, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 1.1227, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 49.6151, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.021, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 169.2628, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0038, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 70.5234, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 7.5685, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 999.187, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 7.9879, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 165.9375, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 0.3614, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 1799.8658, + "allocatedBytes": 312 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 0.7097, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "4.2.0", + "commit": "5e19d32", + "date": "2026-09-11", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 19.3255, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 13.9245, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 34.1165, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 13.375, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 9.3505, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 3.3425, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 444.181, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 1.0971, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 49.8869, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.5551, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 171.1742, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0061, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 73.8065, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 7.9266, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 1027.7254, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 7.0378, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 164.2486, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 0.4013, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 1808.3943, + "allocatedBytes": 312 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 0.772, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "4.3.2", + "commit": "256def6", + "date": "2026-09-12", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 19.7476, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 14.1942, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 31.7734, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 14.6629, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 9.1464, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 3.3107, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 439.0509, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 1.0845, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 49.6864, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0064, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 153.0474, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0215, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 73.5654, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 6.9238, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 999.566, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 7.4833, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 164.8533, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 0.4858, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 1791.8562, + "allocatedBytes": 312 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 0.83, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "5.0.0", + "commit": "bb69de0", + "date": "2026-09-12", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 17.0531, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 14.1983, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 27.5663, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 12.3376, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 9.1429, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 2.1024, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 440.8756, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 1.1355, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 48.7206, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.001, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 167.6413, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0.5091, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 76.0165, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 6.8443, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 1014.094, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 7.7742, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 161.0405, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 0.4173, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 1778.1463, + "allocatedBytes": 312 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 0.6495, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "5.1.0", + "commit": "4c71e14", + "date": "2026-09-13", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 17.2642, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 14.6504, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 28.4428, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 14.593, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 9.0442, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 2.1058, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 443.6043, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 1.047, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 49.4833, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0023, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 168.0931, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0017, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 70.3008, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 6.8747, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 997.1396, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 8.6266, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 169.2199, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 0.3754, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 1780.1261, + "allocatedBytes": 312 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 0.7037, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "5.2.0", + "commit": "eda9fc8", + "date": "2026-09-13", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 19.0621, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 13.6742, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 28.8963, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 13.1918, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 7.6477, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 2.035, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 106.7759, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 0.8308, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 49.7014, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0023, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 175.7197, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0.5194, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 79.1666, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 22.796, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 637.7155, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 19.5547, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 506.5822, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 0.9443, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 6588.3068, + "allocatedBytes": 1760 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 0.9528, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "5.2.4", + "commit": "cbc914c", + "date": "2026-09-14", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 19.0401, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 13.9626, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 30.4436, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 15.0078, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 15.3305, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 3.3693, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 119.6968, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 2.0236, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 48.7248, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0535, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 168.3563, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0685, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 81.7809, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 20.0893, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 634.1803, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 17.9519, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 515.3732, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 0.8839, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 6395.2321, + "allocatedBytes": 1760 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 1.0075, + "allocatedBytes": 0 + } + } + } + }, + { + "version": "5.3.2", + "commit": "2a3e53b", + "date": "2026-09-16", + "cpu": "Intel Xeon Processor 2.80GHz", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 454.7423, + "runId": "local-seed", + "benchmarks": { + "ComparisonBenchmarks\u003CDecimal\u003E.CompareToInterface": { + "": { + "meanNs": 18.7366, + "allocatedBytes": 32 + } + }, + "ComparisonBenchmarks\u003CDouble\u003E.CompareToInterface": { + "": { + "meanNs": 13.152, + "allocatedBytes": 24 + } + }, + "ComparisonBenchmarks\u003CPreciseNumber\u003E.CompareToInterface": { + "": { + "meanNs": 28.5365, + "allocatedBytes": 40 + } + }, + "ComparisonBenchmarks\u003CSingle\u003E.CompareToInterface": { + "": { + "meanNs": 13.502, + "allocatedBytes": 24 + } + }, + "ConstructionBenchmarks\u003CDecimal\u003E.FromNauticalMile": { + "": { + "meanNs": 15.5335, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CDouble\u003E.FromNauticalMile": { + "": { + "meanNs": 3.3758, + "allocatedBytes": 0 + } + }, + "ConstructionBenchmarks\u003CPreciseNumber\u003E.FromNauticalMile": { + "": { + "meanNs": 119.0305, + "allocatedBytes": 40 + } + }, + "ConstructionBenchmarks\u003CSingle\u003E.FromNauticalMile": { + "": { + "meanNs": 2.0081, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDecimal\u003E.LengthTimesLength": { + "": { + "meanNs": 48.593, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CDouble\u003E.LengthTimesLength": { + "": { + "meanNs": 0.5216, + "allocatedBytes": 0 + } + }, + "OperatorBenchmarks\u003CPreciseNumber\u003E.LengthTimesLength": { + "": { + "meanNs": 169.8841, + "allocatedBytes": 48 + } + }, + "OperatorBenchmarks\u003CSingle\u003E.LengthTimesLength": { + "": { + "meanNs": 0.0431, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDecimal\u003E.InNauticalMile": { + "": { + "meanNs": 82.7255, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CDouble\u003E.InNauticalMile": { + "": { + "meanNs": 17.6686, + "allocatedBytes": 0 + } + }, + "UnitConversionBenchmarks\u003CPreciseNumber\u003E.InNauticalMile": { + "": { + "meanNs": 631.3755, + "allocatedBytes": 232 + } + }, + "UnitConversionBenchmarks\u003CSingle\u003E.InNauticalMile": { + "": { + "meanNs": 18.8093, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDecimal\u003E.Length": { + "": { + "meanNs": 512.9149, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CDouble\u003E.Length": { + "": { + "meanNs": 1.0452, + "allocatedBytes": 0 + } + }, + "VectorBenchmarks\u003CPreciseNumber\u003E.Length": { + "": { + "meanNs": 6547.0307, + "allocatedBytes": 1760 + } + }, + "VectorBenchmarks\u003CSingle\u003E.Length": { + "": { + "meanNs": 1.0098, + "allocatedBytes": 0 + } + } + } + } + ] +} diff --git a/docs/benchmarks/performance-dark.svg b/docs/benchmarks/performance-dark.svg new file mode 100644 index 0000000..406755d --- /dev/null +++ b/docs/benchmarks/performance-dark.svg @@ -0,0 +1,263 @@ + + + +Semantics.Quantities performance by release +10 releases · newest 5.3.2 · 2026-09-16 + +Allocated bytes per operation +Deterministic: the same code allocates the same bytes on any machine. +Construct (double) + + + + + + + + + + + + +0 B +48 B +Construct (float) + + + + + + + + + + + + +0 B +48 B +Construct (decimal) + + + + + + + + + + + + +0 B +64 B +Construct (precise) + + + + + + + + + + + + +40 B +120 B +Read back (decimal) + + + + + + + + + + + + +0 B +0 B +Read back (precise) + + + + + + + + + + + + +232 B +232 B +Vector length (decimal) + + + + + + + + + + + + +0 B +0 B +CompareTo (double) + + + + + + + + + + + + +24 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. +Construct (double) + + + + + + + + + + + + +0.0074× +0.0602× +Construct (float) + + + + + + + + + + + + +0.0044× +0.0624× +Construct (decimal) + + + + + + + + + + + + +0.0342× +0.0955× +Construct (precise) + + + + + + + + + + + + +0.262× +1.14× +Read back (decimal) + + + + + + + + + + + + +0.182× +0.157× +Read back (precise) + + + + + + + + + + + + +1.39× +2.18× +Vector length (decimal) + + + + + + + + + + + + +1.13× +0.359× +CompareTo (double) + + + + + + + + + + + + +0.0289× +0.0105× +releases, oldest to newest: 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 +Measured on Intel Xeon Processor 2.80GHz. Full tables: Semantics.Benchmarks. + diff --git a/docs/benchmarks/performance.svg b/docs/benchmarks/performance.svg new file mode 100644 index 0000000..90a9ea9 --- /dev/null +++ b/docs/benchmarks/performance.svg @@ -0,0 +1,263 @@ + + + +Semantics.Quantities performance by release +10 releases · newest 5.3.2 · 2026-09-16 + +Allocated bytes per operation +Deterministic: the same code allocates the same bytes on any machine. +Construct (double) + + + + + + + + + + + + +0 B +48 B +Construct (float) + + + + + + + + + + + + +0 B +48 B +Construct (decimal) + + + + + + + + + + + + +0 B +64 B +Construct (precise) + + + + + + + + + + + + +40 B +120 B +Read back (decimal) + + + + + + + + + + + + +0 B +0 B +Read back (precise) + + + + + + + + + + + + +232 B +232 B +Vector length (decimal) + + + + + + + + + + + + +0 B +0 B +CompareTo (double) + + + + + + + + + + + + +24 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. +Construct (double) + + + + + + + + + + + + +0.0074× +0.0602× +Construct (float) + + + + + + + + + + + + +0.0044× +0.0624× +Construct (decimal) + + + + + + + + + + + + +0.0342× +0.0955× +Construct (precise) + + + + + + + + + + + + +0.262× +1.14× +Read back (decimal) + + + + + + + + + + + + +0.182× +0.157× +Read back (precise) + + + + + + + + + + + + +1.39× +2.18× +Vector length (decimal) + + + + + + + + + + + + +1.13× +0.359× +CompareTo (double) + + + + + + + + + + + + +0.0289× +0.0105× +releases, oldest to newest: 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 +Measured on Intel Xeon Processor 2.80GHz. Full tables: Semantics.Benchmarks. + diff --git a/scripts/benchmark-history.cs b/scripts/benchmark-history.cs new file mode 100644 index 0000000..c1161f0 --- /dev/null +++ b/scripts/benchmark-history.cs @@ -0,0 +1,634 @@ +// 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. + /// + /// + /// Drawn as a grid of one operation per storage type rather than of 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 — and the same line of user code costs + /// four different things depending on the T it was written against. The top row is one + /// construction across the four, so that row reads as the comparison it is. + /// + /// + /// None of the eight is a bare operator, although the suite measures those too. A relationship + /// operator over a binary floating point type is a single machine instruction on values the + /// loop does not change, so the JIT hoists it out and BenchmarkDotNet reports it as + /// indistinguishable from an empty method. That is a true answer about the library and a + /// useless one to plot: a panel of it would chart the harness's resolution rather than any + /// release. What is drawn instead is the work around an operator — building a quantity from a + /// unit, reading it back out in one, a vector length, a comparison — all of which are far + /// enough above that floor to move when the library does. + /// + /// + private static readonly (string Key, string? Parameters, string Label)[] Headline = + [ + ("ConstructionBenchmarks.FromNauticalMile", null, "Construct (double)"), + ("ConstructionBenchmarks.FromNauticalMile", null, "Construct (float)"), + ("ConstructionBenchmarks.FromNauticalMile", null, "Construct (decimal)"), + ("ConstructionBenchmarks.FromNauticalMile", null, "Construct (precise)"), + ("UnitConversionBenchmarks.InNauticalMile", null, "Read back (decimal)"), + ("UnitConversionBenchmarks.InNauticalMile", null, "Read back (precise)"), + ("VectorBenchmarks.Length", null, "Vector length (decimal)"), + ("ComparisonBenchmarks.CompareToInterface", null, "CompareTo (double)"), + ]; + + /// + /// 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 benchmarks = Benchmarks(measured); + if (benchmarks.Count == 0) + { + // Reports with every row reading NA: the harness built and ran, and each benchmark + // threw. An older package whose Parse is a NotSupportedException does exactly this. + // Recording it would put a release on the axis with nothing under it, which reads as + // a release that was measured and found to cost nothing. + Console.Error.WriteLine( + $"No benchmark in {resultsDirectory} produced a measurement; {version} not recorded"); + return 1; + } + + 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, + }; + + 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, $"""Semantics.Quantities 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: Semantics.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); +}