Skip to content

Build the source generator once; measure the quantity types against bare numbers - #245

Merged
matt-edmondson merged 2 commits into
mainfrom
claude/magical-knuth-idzj5r
Sep 16, 2026
Merged

matt-edmondson merged 2 commits into
mainfrom
claude/magical-knuth-idzj5r

Conversation

@matt-edmondson

@matt-edmondson matt-edmondson commented Sep 16, 2026

Copy link
Copy Markdown
Contributor

Two commits, and they are independent — the first fixes the CI race, the second adds a benchmark. They share a branch rather than a topic; say the word and I will split them once the first has landed.


1. 79e1fe5 — Build the source generator once, not twice into one directory

Fixes the CS0006 that failed Analyze & Release on #242.

What was happening

The failure read as a compile error in Semantics.Quantities:

CSC : error CS0006: Metadata file
'...\Semantics.SourceGenerators\bin\Release\netstandard2.0\Semantics.SourceGenerators.dll'
could not be found  [...\Semantics.Quantities.csproj::TargetFramework=net8.0]

But the generator had built cleanly two minutes earlier and built again twenty seconds later, and neither project had anything wrong with it:

time event
07:06:35 Semantics.SourceGenerators -> ...\netstandard2.0\Semantics.SourceGenerators.dll
07:08:21 Semantics.Quantities (net8.0) invokes csc, cannot find that file
07:08:43 Semantics.SourceGenerators -> ... again

Why

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. The generator had two consumers asking differently:

<!-- Semantics.Quantities -->
<ProjectReference Include="...\Semantics.SourceGenerators.csproj"
                  OutputItemType="Analyzer" ReferenceOutputAssembly="false" />

<!-- Semantics.Test -->
<ProjectReference Include="...\Semantics.SourceGenerators.csproj"
                  AdditionalProperties="BundleAnalyzerDependencies=false" />

Two global-property sets, so two instances of one project. Both are netstandard2.0, so unlike multi-targeting they got no separate output directory — both built into the same bin/Release/netstandard2.0. A compiler reading that path as an analyzer while the other instance's copy was in flight found nothing.

Being a race, it needed a build wide enough to lose it: it never showed locally and never on the three test runners, only in Analyze & Release.

The double build is directly observable, and is the before/after measurement:

$ dotnet build -c Release -v n | grep -c 'Semantics.SourceGenerators -> '
2      # before
1      # after

The fix

The property is removed rather than given a default, because any value of it still forks the instance — two consumers passing true and false fork exactly as much as one passing nothing and one passing false. GetDependencyTargetPaths is now unconditional.

What Semantics.Test actually wanted was the generator assembly without the bundle around it. That is a question about its own compile references, not about how the generator builds, so it is now answered there: keep the one assembly that project contributes, drop what rode along with it.

<Target Name="DropBundledGeneratorDependencies" AfterTargets="ResolveProjectReferences">
  <ItemGroup>
    <_ResolvedProjectReferencePaths
      Remove="@(_ResolvedProjectReferencePaths)"
      Condition="'%(MSBuildSourceProjectFile)' == '$(_SemanticsGeneratorProject)' And '%(Filename)' != 'Semantics.SourceGenerators'" />
  </ItemGroup>
</Target>

Filtering asks for no second instance, so it cannot recur. The generator is matched by full path rather than by file name, so the target cannot quietly start dropping references from some other project that happens to share a name.

The opt-out was protecting against something real, and still is — removing it without the filter gives:

error CS0433: The type 'Span<T>' exists in both 'System.Memory, Version=4.0.2.0...'
              and 'System.Runtime, Version=10.0.0.0...'

The regression guard

GeneratorProjectReferenceTests pins the invariant the way this repository pins other things it cannot watch fail: every reference to the generator asks for the same global properties. Restoring the old AdditionalProperties fails both cases and names the offender:

References to the source generator ask for different global properties, so MSBuild builds it
more than once into one output directory: Semantics.Test.csproj ->
"BundleAnalyzerDependencies=false", Semantics.Quantities.csproj -> ""

That is the point of it: it turns a race lost on CI, reported against an innocent project, into a sentence naming the cause.


2. 1f9fe3e — Measure the quantity types against the bare storage type

CLAUDE.md says an operator on a quantity 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. This side asserted it and never measured it.

AbstractionCostBenchmarks runs identical arithmetic twice — once on T, once 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. The ratios under 1.00 are noise and code layout, not the quantity beating the number inside it — three short-run iterations spread that far, and there is no mechanism by which it could.

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 inside and the quantity adds none of its own.

Multiply is the one to read. It is the generated physics relationship Length * Length -> Area, where 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 there.

Three things about the method, because they decide how the number should be read

These are loops, deliberately. 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 would make a ratio between two hoisted methods meaningless. Here each iteration feeds the next, so there is nothing to hoist and every storage type is measurable.

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. A ratio at 1.00 is the claim kept; 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 compounded its operand would measure digit growth instead of the operation. Both loops accumulate rather than compound.

What is not measured

Vector length has no bare counterpart here. Its equivalent is StorageMath.Sqrt, which is internal, and this suite reaches for no internals — it also runs against published packages, where nothing internal is visible, so a benchmark built on one could only ever measure the working copy.


Verification

  • dotnet build -c Release -v n from a clean tree — succeeds, 1 generator build, measured twice for determinism.
  • dotnet test -c Release — 1275 passed, 0 failed, 8 skipped (Windows-only path tests), up from 1273 by the two new guards.
  • Generated output is unchanged — no diff under Semantics.Quantities/Generated/ or the alias props, so the analyzer still loads and runs exactly as before. That is what verify-generated checks, and it is the evidence that dropping the bundle from Semantics.Test did not disturb the consumer that needs it.
  • The guard was verified to fail, not merely to pass: reintroducing AdditionalProperties fails both of its cases with the message above.
  • The benchmark table above is a real local run, --job short, on one machine in one job.

One error in the local dotnet test run is not from either change: Semantics.Cpp.Test multi-targets net10.0;net9.0 as it does on main, and the container has only the .NET 10 runtime, so the net9.0 pass cannot start. CI installs both.

🤖 Generated with Claude Code

https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8

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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8
@matt-edmondson matt-edmondson changed the title Build the source generator once, not twice into one directory Build the source generator once; measure the quantity types against bare numbers Sep 16, 2026
@sonarqubecloud

Copy link
Copy Markdown

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants