From 79e1fe5069ca581c943024772d9eef39ca81e558 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 09:39:50 +0000 Subject: [PATCH 1/2] Build the source generator once, not twice into one directory [patch] Fixes the CS0006 that failed Analyze & Release on #242, where Semantics.Quantities could not find Semantics.SourceGenerators.dll while compiling. The generator had built cleanly two minutes earlier and built again twenty seconds later; nothing was wrong with either project. MSBuild keys its project-instance cache on a project's path together with the global properties it was asked for, and AdditionalProperties on a ProjectReference becomes a global property. Semantics.Quantities asked for the generator with none and Semantics.Test asked for it with BundleAnalyzerDependencies=false, so they were asking for two instances of one project. Both are netstandard2.0, so both built into the same bin/Release/netstandard2.0 -- and a compiler reading that path as an analyzer while the other instance's copy was in flight got nothing. A race, so it never showed locally and never on the test runners; it needed a build wide enough to lose. `dotnet build -c Release -v n | grep -c 'Semantics.SourceGenerators -> '` answers 2 before this change and 1 after. The property is gone rather than defaulted, because any value of it still forks the instance. Semantics.Test wanted the generator assembly without the bundle around it, which is a question about that project's own compile references, so it now filters those instead: keep the one assembly the generator project contributes, drop what rode along with it. The filter matches the generator by full path rather than by file name, so it cannot quietly start dropping references from somewhere else. GeneratorProjectReferenceTests pins it, in the style the rest of this repository pins things it cannot see fail: every reference to the generator asks for the same global properties. Restoring the old AdditionalProperties fails both of its cases with the offending project named, which is what turns a lost race on CI into a sentence. Generated output is unchanged, so the analyzer still loads and runs exactly as it did. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8 --- .../Semantics.SourceGenerators.csproj | 21 ++- .../GeneratorProjectReferenceTests.cs | 129 ++++++++++++++++++ Semantics.Test/Semantics.Test.csproj | 36 ++++- 3 files changed, 175 insertions(+), 11 deletions(-) create mode 100644 Semantics.Test/Quantities/GeneratorProjectReferenceTests.cs 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'" /> + + + From 1f9fe3eae466e1a7cfe929c36baf676ae01c54c5 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 09:56:17 +0000 Subject: [PATCH 2/2] Measure the quantity types against the bare storage type [patch] The library's claim is that a quantity is a readonly record struct over one value in the SI base unit, so an operator on it is the storage type's own arithmetic and a struct initialiser and nothing else once the JIT has inlined it. The C++ projection has always been held to that against bare floats. The .NET side asserted it and never measured it. AbstractionCostBenchmarks runs identical arithmetic twice, once on T and once on quantities over T, with the bare one as the BenchmarkDotNet baseline, so the answer is the Ratio column rather than two rows divided by hand: Add Multiply (to Area) Allocated 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. The ratios under 1.00 are noise and code layout rather than the quantity beating the number inside it; three short-run iterations spread that far and there is no mechanism for it. PreciseNumber is the row that says it most exactly, being the only storage type here that allocates at all: the allocation ratio is 1.00, so every byte belongs to the BigInteger and the quantity adds none of its own. These are loops rather than single operations, because a single operator over operands that do not change is loop-invariant and the JIT hoists it out -- which is what makes OperatorBenchmarks report ZeroMeasurement for double and float, and would make a ratio between two hoisted methods meaningless. Each iteration feeds the next, so there is nothing to hoist and every storage type is measurable. Two things about that loop, both stated in the remarks because they decide how the number should be read. Its own cost is paid by both sides and mostly overlaps the arithmetic, and whatever does not is added to numerator and denominator alike, so it pulls the ratio toward 1.00: a ratio at 1.00 is the claim kept, and one above it is a floor rather than the whole cost. And both loops accumulate rather than compound, because PreciseNumber carries as many digits as the arithmetic produces and a compounding chain would measure that growth instead of the operation. Vector length has no pair here: its bare counterpart is StorageMath.Sqrt, which is internal, and this suite reaches for no internals because it also runs against published packages. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8 --- .../AbstractionCostBenchmarks.cs | 168 ++++++++++++++++++ Semantics.Benchmarks/README.md | 45 +++++ 2 files changed, 213 insertions(+) create mode 100644 Semantics.Benchmarks/AbstractionCostBenchmarks.cs 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