Build the source generator once; measure the quantity types against bare numbers - #245
Merged
Merged
Conversation
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
|
This was referenced Sep 16, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.



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 directoryFixes the
CS0006that failedAnalyze & Releaseon #242.What was happening
The failure read as a compile error in
Semantics.Quantities:But the generator had built cleanly two minutes earlier and built again twenty seconds later, and neither project had anything wrong with it:
Semantics.SourceGenerators -> ...\netstandard2.0\Semantics.SourceGenerators.dllSemantics.Quantities(net8.0) invokescsc, cannot find that fileSemantics.SourceGenerators -> ...againWhy
MSBuild keys its project-instance cache on a project's path together with the global properties it was asked for, and
AdditionalPropertieson aProjectReferencebecomes a global property. The generator had two consumers asking differently: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 samebin/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:
The fix
The property is removed rather than given a default, because any value of it still forks the instance — two consumers passing
trueandfalsefork exactly as much as one passing nothing and one passingfalse.GetDependencyTargetPathsis now unconditional.What
Semantics.Testactually 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.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:
The regression guard
GeneratorProjectReferenceTestspins 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 oldAdditionalPropertiesfails both cases and names the offender: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 typeCLAUDE.mdsays 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.AbstractionCostBenchmarksruns identical arithmetic twice — once onT, once on quantities overT— with the bare one markedBaseline = true, so the answer is theRatiocolumn rather than two rows divided by hand:AddMultiply(toArea)floatdoubledecimalPreciseNumberThe 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.
PreciseNumberis 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 theBigIntegerinside and the quantity adds none of its own.Multiplyis the one to read. It is the generated physics relationshipLength * 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
OperatorBenchmarksreport ZeroMeasurement fordoubleandfloat, 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.
PreciseNumbercarries 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 isinternal, 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 nfrom 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.Semantics.Quantities/Generated/or the alias props, so the analyzer still loads and runs exactly as before. That is whatverify-generatedchecks, and it is the evidence that dropping the bundle fromSemantics.Testdid not disturb the consumer that needs it.AdditionalPropertiesfails both of its cases with the message above.--job short, on one machine in one job.One error in the local
dotnet testrun is not from either change:Semantics.Cpp.Testmulti-targetsnet10.0;net9.0as it does onmain, 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