Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
168 changes: 168 additions & 0 deletions Semantics.Benchmarks/AbstractionCostBenchmarks.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Benchmarks;

using System.Numerics;

using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Configs;

using ktsu.PreciseNumber;
using ktsu.Semantics.Quantities;

/// <summary>
/// Measures what the quantity types cost over doing the same arithmetic on the bare storage type.
/// </summary>
/// <remarks>
/// <para>
/// The claim this library makes is that a quantity is a <c>readonly record struct</c> holding one
/// value in the SI base unit, so an operator on it is the storage type's own arithmetic and a
/// struct initialiser, and the wrapper costs nothing once the JIT has inlined it. That is a claim
/// about a number, and the C++ projection has always been held to it against bare floats. This is
/// the same question asked on this side.
/// </para>
/// <para>
/// Each pair runs the identical arithmetic twice, once on <typeparamref name="T"/> and once on the
/// quantity types over it, and the bare one is the BenchmarkDotNet baseline — so the answer is read
/// off the <c>Ratio</c> column rather than by dividing two rows by hand. A ratio of 1.00 is the
/// claim being kept.
/// </para>
/// <para>
/// <b>Why these are loops rather than single operations.</b> A single operator over operands that
/// do not change between iterations is loop-invariant, and the JIT hoists it out: that is what
/// makes <see cref="OperatorBenchmarks{T}"/> report a ZeroMeasurement warning for
/// <see cref="double"/> and <see cref="float"/>, and it would make a ratio between two hoisted
/// methods meaningless. Here each iteration feeds the next, so there is nothing to hoist and both
/// sides of a pair are measurable for every storage type.
/// </para>
/// <para>
/// <b>What the loop costs, and which way it biases.</b> Both sides pay the same counter increment
/// and branch. It is a dependency chain, so on a superscalar core most of that overlaps the
/// arithmetic rather than adding to it, but whatever does not is added equally to numerator and
/// denominator and therefore pulls the ratio toward 1.00. So a ratio at 1.00 is the claim kept, and
/// a ratio above it is a floor on the real cost rather than the whole of it.
/// </para>
/// <para>
/// <b>Why the operands stay bounded.</b> <c>PreciseNumber</c> carries as many digits as the
/// arithmetic produces, so a chain that grows its operand measures that growth instead of the
/// operation. Both loops here accumulate rather than compound: the running value gains a step and
/// the accumulator takes a product, so neither runs away, and the comparison stays about the
/// wrapper for every storage type rather than only for the fixed-width ones.
/// </para>
/// </remarks>
/// <typeparam name="T">The storage type.</typeparam>
[MemoryDiagnoser]
[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)]
[CategoriesColumn]
[GenericTypeArguments(typeof(double))]
[GenericTypeArguments(typeof(float))]
[GenericTypeArguments(typeof(decimal))]
[GenericTypeArguments(typeof(PreciseNumber))]
public class AbstractionCostBenchmarks<T>
where T : struct, INumber<T>
{
/// <summary>
/// Operations per invocation. Enough that the loop's own cost is a small share of the work,
/// few enough that an arbitrary-precision storage type still finishes an iteration promptly.
/// </summary>
private const int Operations = 256;

private T seed;
private T step;
private T other;

private Length<T> seedLength;
private Length<T> stepLength;
private Length<T> otherLength;

/// <summary>
/// Prepares the operands, the bare ones and the wrapped ones holding the same values.
/// </summary>
[GlobalSetup]
public void Setup()
{
seed = Operands.Of<T>("1234.5678901234567890");
step = Operands.Of<T>("0.0009765625");
other = Operands.Of<T>("3.14159265358979323846");

seedLength = Length<T>.Create(seed);
stepLength = Length<T>.Create(step);
otherLength = Length<T>.Create(other);
}

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

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

return accumulator;
}

/// <summary>Adds along the same chain, on quantities of the same dimension.</summary>
/// <returns>The accumulated quantity.</returns>
[BenchmarkCategory("Add")]
[Benchmark(OperationsPerInvoke = Operations)]
public Length<T> QuantityAdd()
{
Length<T> accumulator = seedLength;

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

return accumulator;
}

/// <summary>Multiplies and accumulates, on the bare storage type.</summary>
/// <returns>The accumulated value.</returns>
[BenchmarkCategory("Multiply")]
[Benchmark(Baseline = true, OperationsPerInvoke = Operations)]
public T BareMultiply()
{
T accumulator = T.Zero;
T value = seed;

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

return accumulator;
}

/// <summary>
/// Multiplies and accumulates over the same values, through the generated physics relationship
/// that takes two lengths to an area.
/// </summary>
/// <returns>The accumulated area.</returns>
/// <remarks>
/// The one to read. This is where a quantity does something a bare number cannot — the product
/// lands on a different dimension, and the type system knows it — so if any of the vocabulary
/// were going to cost something at run time rather than only at compile time, it would be here.
/// </remarks>
[BenchmarkCategory("Multiply")]
[Benchmark(OperationsPerInvoke = Operations)]
public Area<T> QuantityMultiply()
{
Area<T> accumulator = Area<T>.Zero;
Length<T> value = seedLength;

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

return accumulator;
}
}
45 changes: 45 additions & 0 deletions Semantics.Benchmarks/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ 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 '*<Decimal>*'

# What the quantity types cost over the bare storage type
dotnet run -c Release --project Semantics.Benchmarks -- --filter '*AbstractionCostBenchmarks*'
```

## Measuring a published release
Expand Down Expand Up @@ -73,8 +76,50 @@ working copy.
| `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<T>` overload of `Equals` box the argument and check dimensions first, which is what buys the ability to refuse a length against a mass. |
| `AbstractionCostBenchmarks` | The same arithmetic twice, once on the bare storage type and once on quantities over it, paired so BenchmarkDotNet reports the ratio. See below. |
| `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. |

### What the quantity types cost over the bare storage type

`AbstractionCostBenchmarks` is the one that answers the question the rest of the suite only implies:
a quantity is a `readonly record struct` holding one value in the SI base unit, so an operator on it
should be the storage type's own arithmetic and a struct initialiser, and nothing more once the JIT
has inlined it. The C++ projection has always been held to that against bare floats. This holds the
.NET side to it the same way.

Each pair runs identical arithmetic on `T` and on quantities over `T`, with the bare one marked
`Baseline = true`, so the answer is the `Ratio` column rather than two rows divided by hand:

| storage | `Add` | `Multiply` (to `Area`) | allocation |
|---|---|---|---|
| `float` | 1.00 | 0.94 | none either side |
| `double` | 1.02 | 0.94 | none either side |
| `decimal` | 1.04 | 0.90 | none either side |
| `PreciseNumber` | 1.03 | 1.03 | 193 B either side, ratio 1.00 |

The wrapper is free. Read the ratios below 1.00 as noise and code layout rather than as the quantity
being faster than the number inside it — the spread across three short-run iterations covers that
much, and there is no mechanism by which it could be.

The `PreciseNumber` row is the one that says it most precisely, because it is the only storage type
here that allocates at all: **the allocation ratio is exactly 1.00**. Every byte belongs to the
`BigInteger` inside, and the quantity adds none of its own.

**These are loops, and that is deliberate.** A single operator over operands that do not change is
loop-invariant and the JIT hoists it out, which is exactly what makes `OperatorBenchmarks` report
ZeroMeasurement for `double` and `float` — and a ratio between two hoisted methods would mean
nothing. Here each iteration feeds the next, so there is nothing to hoist and both sides of a pair
are measurable for every storage type.

**The loop's own cost biases toward 1.00, not away from it.** Both sides pay the same counter and
branch; it is a dependency chain, so most of that overlaps the arithmetic, and whatever does not is
added equally to numerator and denominator. So a ratio at 1.00 is the claim kept, and a ratio above
it is a floor on the real cost rather than the whole of it.

**The operands stay bounded on purpose.** `PreciseNumber` carries as many digits as the arithmetic
produces, so a chain that compounds its operand would measure digit growth instead of the operation.
Both loops accumulate rather than compound.

### An operator on a `double` is below the floor

`OperatorBenchmarks` over `double` and `float` comes back with a ZeroMeasurement warning: the
Expand Down
21 changes: 15 additions & 6 deletions Semantics.SourceGenerators/Semantics.SourceGenerators.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -44,12 +44,21 @@
</ItemGroup>

<!-- Bundle all dependencies alongside the generator so they're available at analyzer load time.
A consumer that references this project as a plain library rather than as an analyzer (the
test project, which drives the generators through CSharpGeneratorDriver) must opt out via
AdditionalProperties="BundleAnalyzerDependencies=false": the bundled netstandard2.0 facades
otherwise land in that consumer's compile references and collide with the in-box types
(CS0433 on ReadOnlySpan<T>, Vector4, and friends). -->
<Target Name="GetDependencyTargetPaths" Condition="'$(BundleAnalyzerDependencies)' != 'false'">

Unconditional, and it has to stay that way. This was once switchable per consumer through
AdditionalProperties="BundleAnalyzerDependencies=false", which MSBuild turns into a global
property, and global properties are what key its project-instance cache. Two consumers
asking for this project with different global properties are two instances of it, and both
of them build, concurrently, into this one bin\Release\netstandard2.0 directory. A third
node reading Semantics.SourceGenerators.dll as an analyzer while one of those copies is in
flight fails with CS0006 "Metadata file could not be found", naming the generator from a
project that has nothing wrong with it. That is a race, so it struck on CI and never
locally.

A consumer that wants the assembly without the bundle now filters its own compile
references instead; Semantics.Test carries that target and explains it. Filtering is a
consumer's own business and forks nothing. -->
<Target Name="GetDependencyTargetPaths">
<ItemGroup>
<TargetPathWithTargetPlatformMoniker Include="@(ResolvedCompileFileDefinitions)" IncludeRuntimeDependency="false" />
</ItemGroup>
Expand Down
129 changes: 129 additions & 0 deletions Semantics.Test/Quantities/GeneratorProjectReferenceTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
// Copyright (c) 2023-2026 ktsu-dev contributors

namespace ktsu.Semantics.Test.Quantities;

using System.Collections.Generic;
using System.Linq;
using System.Xml.Linq;
using Microsoft.VisualStudio.TestTools.UnitTesting;

/// <summary>
/// Checks that every reference to the source generator project asks for it the same way.
/// </summary>
/// <remarks>
/// <para>
/// MSBuild keys its project-instance cache on the project path together with the global properties
/// it was asked for. <c>AdditionalProperties</c> on a <c>ProjectReference</c> becomes a global
/// property, so two consumers passing different ones are asking for two instances of the same
/// project — and both build, concurrently, into the one
/// <c>Semantics.SourceGenerators\bin\$(Configuration)\netstandard2.0</c> directory.
/// </para>
/// <para>
/// A compiler reading <c>Semantics.SourceGenerators.dll</c> as an analyzer while one of those
/// copies is in flight fails with <c>CS0006: Metadata file could not be found</c>, reported against
/// <c>Semantics.Quantities</c>, which has nothing wrong with it. Being a race it needs the build to
/// be wide enough to lose, so it passed locally and on the test runners and failed in
/// <c>Analyze &amp; Release</c>.
/// </para>
/// <para>
/// The condition is cheap to state and cheap to check, so it is checked here rather than left to a
/// comment: every reference to the generator carries the same global properties, which today means
/// none carries any. A consumer wanting something different from the generator's output filters its
/// own references instead — see <c>DropBundledGeneratorDependencies</c> in this project's file.
/// </para>
/// </remarks>
[TestClass]
public class GeneratorProjectReferenceTests
{
private const string GeneratorProject = "Semantics.SourceGenerators.csproj";

/// <summary>
/// The ProjectReference attributes that turn into global properties on the referenced project,
/// and so decide how many instances of it MSBuild builds.
/// </summary>
private static readonly string[] ForkingAttributes =
[
"AdditionalProperties",
"SetTargetFramework",
"GlobalPropertiesToRemove",
"UndefineProperties",
];

[TestMethod]
public void NoProjectForksTheGeneratorWithAdditionalProperties()
{
List<string> offenders =
[
.. GeneratorReferences()
.Where(reference => reference.Element.Attribute("AdditionalProperties") is not null)
.Select(reference =>
$"{reference.Project}: AdditionalProperties=\"{reference.Element.Attribute("AdditionalProperties")!.Value}\"")
];

Assert.IsEmpty(
offenders,
"A ProjectReference to the source generator passes AdditionalProperties, which forks it " +
"into a second MSBuild instance building into the same output directory. Filter the " +
"resolved references in the consuming project instead. Offenders: " +
string.Join("; ", offenders));
}

[TestMethod]
public void EveryGeneratorReferenceAsksForTheSameGlobalProperties()
{
List<(string Project, string Properties)> asked =
[
.. GeneratorReferences()
.Select(reference => (
reference.Project,
Properties: string.Join(
";",
ForkingAttributes
.Select(name => reference.Element.Attribute(name)?.Value ?? "")
.Where(value => value.Length > 0))))
];

Assert.IsNotEmpty(asked, $"No project references {GeneratorProject}; this test is checking nothing.");

List<string> distinct = [.. asked.Select(one => one.Properties).Distinct(StringComparer.Ordinal)];

Assert.HasCount(
1,
distinct,
"References to the source generator ask for different global properties, so MSBuild " +
"builds it more than once into one output directory: " +
string.Join(", ", asked.Select(one => $"{one.Project} -> \"{one.Properties}\"")));
}

private static IEnumerable<(string Project, XElement Element)> GeneratorReferences()
{
foreach (string project in Directory.EnumerateFiles(RepositoryRoot(), "*.csproj", SearchOption.AllDirectories))
{
XDocument document = XDocument.Load(project);
IEnumerable<XElement> references = document
.Descendants()
.Where(element => element.Name.LocalName == "ProjectReference")
.Where(element =>
(element.Attribute("Include")?.Value ?? "")
.Replace('\\', '/')
.EndsWith(GeneratorProject, StringComparison.Ordinal));

foreach (XElement reference in references)
{
yield return (Path.GetFileName(project), reference);
}
}
}

private static string RepositoryRoot()
{
DirectoryInfo? directory = new(AppContext.BaseDirectory);
while (directory is not null && !Directory.Exists(Path.Combine(directory.FullName, "Semantics.SourceGenerators")))
{
directory = directory.Parent;
}

Assert.IsNotNull(directory, "Could not locate the repository root from the test output directory.");
return directory!.FullName;
}
}
Loading
Loading