diff --git a/Semantics.Benchmarks/AbstractionCostBenchmarks.cs b/Semantics.Benchmarks/AbstractionCostBenchmarks.cs new file mode 100644 index 0000000..d9e78a0 --- /dev/null +++ b/Semantics.Benchmarks/AbstractionCostBenchmarks.cs @@ -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; + +/// +/// Measures what the quantity types cost over doing the same arithmetic on the bare storage type. +/// +/// +/// +/// The claim this library makes is that a quantity is a readonly record struct 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. +/// +/// +/// Each pair runs the identical arithmetic twice, once on and once on the +/// quantity types over it, and the bare one is the BenchmarkDotNet baseline — so the answer is read +/// off the Ratio column rather than by dividing two rows by hand. A ratio of 1.00 is the +/// claim being kept. +/// +/// +/// Why these are loops rather than single operations. A single operator over operands that +/// do not change between iterations is loop-invariant, and the JIT hoists it out: that is what +/// makes report a ZeroMeasurement warning for +/// and , 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. +/// +/// +/// What the loop costs, and which way it biases. 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. +/// +/// +/// Why the operands stay bounded. PreciseNumber 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. +/// +/// +/// The storage type. +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +[GenericTypeArguments(typeof(double))] +[GenericTypeArguments(typeof(float))] +[GenericTypeArguments(typeof(decimal))] +[GenericTypeArguments(typeof(PreciseNumber))] +public class AbstractionCostBenchmarks + where T : struct, INumber +{ + /// + /// 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. + /// + private const int Operations = 256; + + private T seed; + private T step; + private T other; + + private Length seedLength; + private Length stepLength; + private Length otherLength; + + /// + /// Prepares the operands, the bare ones and the wrapped ones holding the same values. + /// + [GlobalSetup] + public void Setup() + { + seed = Operands.Of("1234.5678901234567890"); + step = Operands.Of("0.0009765625"); + other = Operands.Of("3.14159265358979323846"); + + seedLength = Length.Create(seed); + stepLength = Length.Create(step); + otherLength = Length.Create(other); + } + + /// Adds along a chain, on the bare storage type. + /// The accumulated value. + [BenchmarkCategory("Add")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public T BareAdd() + { + T accumulator = seed; + + for (int i = 0; i < Operations; i++) + { + accumulator += step; + } + + return accumulator; + } + + /// Adds along the same chain, on quantities of the same dimension. + /// The accumulated quantity. + [BenchmarkCategory("Add")] + [Benchmark(OperationsPerInvoke = Operations)] + public Length QuantityAdd() + { + Length accumulator = seedLength; + + for (int i = 0; i < Operations; i++) + { + accumulator += stepLength; + } + + return accumulator; + } + + /// Multiplies and accumulates, on the bare storage type. + /// The accumulated value. + [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; + } + + /// + /// Multiplies and accumulates over the same values, through the generated physics relationship + /// that takes two lengths to an area. + /// + /// The accumulated area. + /// + /// 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. + /// + [BenchmarkCategory("Multiply")] + [Benchmark(OperationsPerInvoke = Operations)] + public Area QuantityMultiply() + { + Area accumulator = Area.Zero; + Length value = seedLength; + + for (int i = 0; i < Operations; i++) + { + accumulator += value * otherLength; + value += stepLength; + } + + return accumulator; + } +} diff --git a/Semantics.Benchmarks/README.md b/Semantics.Benchmarks/README.md index 1e8bc48..225f2ab 100644 --- a/Semantics.Benchmarks/README.md +++ b/Semantics.Benchmarks/README.md @@ -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 '**' + +# What the quantity types cost over the bare storage type +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*AbstractionCostBenchmarks*' ``` ## Measuring a published release @@ -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` 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 diff --git a/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj b/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj index 8bdeb4f..3731767 100644 --- a/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj +++ b/Semantics.SourceGenerators/Semantics.SourceGenerators.csproj @@ -44,12 +44,21 @@ - + + 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. --> + diff --git a/Semantics.Test/Quantities/GeneratorProjectReferenceTests.cs b/Semantics.Test/Quantities/GeneratorProjectReferenceTests.cs new file mode 100644 index 0000000..1c7ec6a --- /dev/null +++ b/Semantics.Test/Quantities/GeneratorProjectReferenceTests.cs @@ -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; + +/// +/// Checks that every reference to the source generator project asks for it the same way. +/// +/// +/// +/// MSBuild keys its project-instance cache on the project path together with the global properties +/// it was asked for. AdditionalProperties on a ProjectReference 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 +/// Semantics.SourceGenerators\bin\$(Configuration)\netstandard2.0 directory. +/// +/// +/// A compiler reading Semantics.SourceGenerators.dll as an analyzer while one of those +/// copies is in flight fails with CS0006: Metadata file could not be found, reported against +/// Semantics.Quantities, 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 +/// Analyze & Release. +/// +/// +/// 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 DropBundledGeneratorDependencies in this project's file. +/// +/// +[TestClass] +public class GeneratorProjectReferenceTests +{ + private const string GeneratorProject = "Semantics.SourceGenerators.csproj"; + + /// + /// The ProjectReference attributes that turn into global properties on the referenced project, + /// and so decide how many instances of it MSBuild builds. + /// + private static readonly string[] ForkingAttributes = + [ + "AdditionalProperties", + "SetTargetFramework", + "GlobalPropertiesToRemove", + "UndefineProperties", + ]; + + [TestMethod] + public void NoProjectForksTheGeneratorWithAdditionalProperties() + { + List 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 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 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; + } +} diff --git a/Semantics.Test/Semantics.Test.csproj b/Semantics.Test/Semantics.Test.csproj index 01b9343..a1edefd 100644 --- a/Semantics.Test/Semantics.Test.csproj +++ b/Semantics.Test/Semantics.Test.csproj @@ -21,11 +21,9 @@ - + this project. Only the generator assembly itself is wanted here; DropBundledGeneratorDependencies + below drops the rest. --> + @@ -57,4 +55,32 @@ CopyToOutputDirectory="PreserveNewest" /> + + + + + <_SemanticsGeneratorProject>$([MSBuild]::NormalizePath('$(MSBuildThisFileDirectory)..', 'Semantics.SourceGenerators', 'Semantics.SourceGenerators.csproj')) + + + <_ResolvedProjectReferencePaths + Remove="@(_ResolvedProjectReferencePaths)" + Condition="'%(MSBuildSourceProjectFile)' == '$(_SemanticsGeneratorProject)' And '%(Filename)' != 'Semantics.SourceGenerators'" /> + + +