From d3284eab750acbed7a55824d3c7a86eec1710603 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 06:05:58 +0000 Subject: [PATCH 1/2] Chart performance per release, and show it in the README [patch] Adds a BenchmarkDotNet suite over the quantity system, and a workflow that measures a fixed subset of it once per release, appends the numbers to a committed history file, and redraws the chart the README shows. Every class is generic and carries GenericTypeArguments for double, float, decimal and PreciseNumber, because that is the axis this library varies along. A quantity is a readonly record struct over its T holding a value in the SI base unit, so an operator is the storage type's arithmetic and a struct initialiser, and a factory is that plus one multiplication. What a quantity costs is therefore mostly what its T costs, and a release changes it per T: building one measures 2 ns as a float and 121 ns as a PreciseNumber, and a vector length measures 1 ns as a double and 520 ns as a decimal, which is the Newton refinement StorageMath.Sqrt falls back to when there is no hardware root to take. Operands are parsed from text rather than converted from a double, so that a storage type wider than 15 digits is measured carrying its own digits rather than a double's. One benchmark cannot be measured and says so. A relationship operator over a binary floating point type is one instruction over operands the loop does not change, so the JIT hoists it out and BenchmarkDotNet reports the method as indistinguishable from an empty one. Nothing fixes that without measuring the fix instead of the operator -- an operand array adds a load, a mutated field adds a store -- so those rows are documented as being below the harness's floor and the chart draws none of them, taking a unit conversion in their place. Times are made comparable across CI hosts by BaselineBenchmarks, a fixed integer loop measured in the same job that the chart divides every time by. Allocation needs none of that, being the same on any machine. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8 --- .github/workflows/benchmark-history.yml | 203 ++++++ Directory.Packages.props | 8 + README.md | 13 + Semantics.Benchmarks/AssemblyInfo.cs | 3 + Semantics.Benchmarks/BaselineBenchmarks.cs | 59 ++ Semantics.Benchmarks/BenchmarkConfig.cs | 32 + Semantics.Benchmarks/ComparisonBenchmarks.cs | 68 ++ .../ConstructionBenchmarks.cs | 67 ++ Semantics.Benchmarks/Operands.cs | 28 + Semantics.Benchmarks/OperatorBenchmarks.cs | 95 +++ Semantics.Benchmarks/Program.cs | 18 + Semantics.Benchmarks/README.md | 97 +++ .../Semantics.Benchmarks.csproj | 50 ++ .../UnitConversionBenchmarks.cs | 78 +++ Semantics.Benchmarks/VectorBenchmarks.cs | 88 +++ Semantics.sln | 6 + scripts/benchmark-history.cs | 634 ++++++++++++++++++ 17 files changed, 1547 insertions(+) create mode 100644 .github/workflows/benchmark-history.yml create mode 100644 Semantics.Benchmarks/AssemblyInfo.cs create mode 100644 Semantics.Benchmarks/BaselineBenchmarks.cs create mode 100644 Semantics.Benchmarks/BenchmarkConfig.cs create mode 100644 Semantics.Benchmarks/ComparisonBenchmarks.cs create mode 100644 Semantics.Benchmarks/ConstructionBenchmarks.cs create mode 100644 Semantics.Benchmarks/Operands.cs create mode 100644 Semantics.Benchmarks/OperatorBenchmarks.cs create mode 100644 Semantics.Benchmarks/Program.cs create mode 100644 Semantics.Benchmarks/README.md create mode 100644 Semantics.Benchmarks/Semantics.Benchmarks.csproj create mode 100644 Semantics.Benchmarks/UnitConversionBenchmarks.cs create mode 100644 Semantics.Benchmarks/VectorBenchmarks.cs create mode 100644 scripts/benchmark-history.cs 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/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); +} From 374899d11eff97da3ef750bf38ac9413e7e559ef Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 06:14:13 +0000 Subject: [PATCH 2/2] Seed the chart with every release the suite can measure [patch] Ten releases, 3.3.1 through 5.3.2, all measured in one pass against a single reference reading so that the times are comparable as they stand. Without this the README would show an empty chart until the next release, and a one-point chart after it. Every one of the ten answered all twenty benchmarks; nothing had to be skipped. The chart is not all one direction, and the two steps that go the other way are both deliberate and both already documented: * a decimal vector length steps from 165ns to 507ns at 5.2.0, where StorageMath.Sqrt stopped taking a double round trip and started refining with Newton steps in the storage type's own arithmetic. That is what buys a decimal length its 28 significant digits rather than a double's 15. * a decimal unit factory steps from 7.6ns to 15.3ns at 5.2.4, where the converted factors moved out of a static initializer into a nullable field per value. That is what stopped one factor too large for a type making every conversion for that type throw TypeInitializationException; the cost is a null check per read. 3.3.1 to 4.0.0 is the record struct, and it cuts both ways: building a quantity went from 27.4ns and 48 bytes to 3.4ns and nothing, while comparing two through IPhysicalQuantity went from 4.8ns and nothing to 14.2ns and 24 bytes, because what had been a reference conversion on a class became a box on a struct. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8 --- docs/benchmarks/history.json | 1315 ++++++++++++++++++++++++++ docs/benchmarks/performance-dark.svg | 263 ++++++ docs/benchmarks/performance.svg | 263 ++++++ 3 files changed, 1841 insertions(+) create mode 100644 docs/benchmarks/history.json create mode 100644 docs/benchmarks/performance-dark.svg create mode 100644 docs/benchmarks/performance.svg 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. +