Skip to content

Measure and chart semantic strings and paths per release - #257

Merged
matt-edmondson merged 27 commits into
mainfrom
claude/benchmarks-strings-paths-252
Sep 18, 2026
Merged

matt-edmondson merged 27 commits into
mainfrom
claude/benchmarks-strings-paths-252

Conversation

@matt-edmondson

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

Copy link
Copy Markdown
Contributor

Written by Claude (Claude Code) on behalf of Matt Edmondson.

Closes #252.

Semantics.Quantities has had a measured, charted performance history since #242. Semantics.Strings and Semantics.Paths had none. This gives them the same treatment, and the pipeline they share is now subject-aware rather than single-subject.

What the numbers say

The quantities suite's headline finding was that the wrapper is free. The strings finding is the opposite, and that is the point of measuring it.

TryCreate is not the cheap option its name suggests. SemanticString.TryFromString is implemented as try { Create(...) } catch (ArgumentException) { return false; }, so it throws and catches internally on every rejection. Against a hand-written regex check that returns false, the ratio is 165x. At a boundary that rejects often, this is the most actionable number here.

The validation ladder is nearly flat. Four structurally different validators cost 0.93, 1.06, 1.26 and 1.27 microseconds on top of a ~1,650 ns reflection floor. A Luhn pass, a character-set regular expression, a mod-97 pass and a format regular expression all land within about 340 ns of each other: the validator is a minor term and the reflection machinery is the bill. The plan predicted mod-97 would dominate; it ties with the format regex.

Equality is ordinal and ordering is not. Record == routes through EqualityComparer<string>.Default; CompareTo forwards to string.CompareTo(string), which is culture-sensitive. Two values can compare equal under == and sort by a different rule. Reported, not changed.

FileName rebuilds on every read. 2,528 ns and 944 B, against 2.1 ns and no allocation for the cached FileNameWithoutExtension. A 1,181x gap between two properties that look identical at a call site.

Cost-pair ratios, semantic against the code a caller would otherwise write:

strings ratio paths ratio
Validate 12.94 FileName 130.22
Reject 164.98 AsAbsolute 15.69
Equality 2.53 AsRelative 10.35
Ordering 1.11 Create 1,810.28

The path Create ratio is large because its baseline is cheap (a single boolean check), not because creation is unusual: the semantic side costs about the same in all four path categories. Its baseline also deliberately omits the separator concatenation IsAbsolutePathAttribute performs, because a baseline here means the code a caller writes rather than the library's internals. Both points are stated beside the table.

Backfill coverage

Measured as published packages, so every version is timed by identical benchmark code.

subject entries span
quantities 10 3.3.1 - 5.3.2 (unchanged)
strings 11 3.3.1 - 5.3.4
paths 11 3.3.1 - 5.3.4

No versions skipped. An earlier revision of this branch stopped both new subjects at 4.0.0, because
AbstractionCostBenchmarks.cs held a Length<T> field with no initializer and would not compile
against a package where Length<T> was still a reference type. Since all three subjects share one
benchmark project, that quantities-only file took the strings and paths backfills down with it.
Main has since fixed the field, so 3.3.1 is reachable and both new histories start there.

The coupling is worth keeping in mind even though this instance is fixed: a compile error introduced
for one subject costs the other two their history, and it surfaces as a build failure in a file the
person running the backfill was not touching. Both Semantics.Benchmarks/README.md and CLAUDE.md
say so.

What changed

  • Semantics.Benchmarks gains Strings/ and Paths/ folders: creation, operation and cost-pair classes for each. Specimens are shipped types (Uuid, Ulid, CreditCardNumber, Iban, the path types) rather than fixtures, except one unvalidated fixture for the no-validation rung, which nothing shipped occupies.
  • scripts/benchmark-history.cs takes --subject on render and looks the chart up from a dictionary. ingest is untouched: it never read the headline set. The quantities chart renders byte-identically, which was the acceptance test for the refactor and is re-verified at every step of this branch.
  • .github/workflows/benchmark-history.yml loops a subject table, gains a subjects dispatch input for splitting long backfills, and raises its timeout to 360 minutes.
  • Documentation in README.md, Semantics.Benchmarks/README.md and CLAUDE.md, which did not previously mention the benchmark project at all.

Merged with main

Main grew a third chart section (the quantity wrapper's cost over the bare storage type) inside the
same Draw and Section functions this branch made subject-aware, so the two refactors were
reconciled rather than one picked over the other. The cost pairs moved from a standalone array onto
the Subject record: quantities keeps the four main added, strings and paths declare none, and a
subject with no pairs draws two sections and reserves no height for a third.

The quantities chart still renders byte-identically, now against main's three-section version. That
check has gated every renderer change on this branch.

Charting cost pairs for strings and paths is deliberately not done here. The classes exist and their
ratios are in the table above, but the release workflow does not run them, so no history carries the
pair to divide. Worth its own issue now that main has set the precedent for quantities: the strings
Reject ratio is the number most worth watching per release.

Notes for review

Two benchmarks are measured and stored but not drawn, deliberately: CompareTo (0.57 ns, hoisted by the JIT, so a panel would chart the harness's resolution) and ToStringImplicit. ingest records every row and only render selects, so either can be promoted later without re-running any history.

The paths chart's allocation row is the one place the usual "allocation is exact on any machine" rule does not hold: path specimens are built per platform, because whether a path is absolute is a question the operating system answers differently. All committed paths points are Windows-measured; the first CI point will be Linux. Stated beside the chart.

Neither workflow path has ever run on a runner. That predates this change, but this change triples what a first run must get right. Suggest cutting the first release after merge deliberately and watching the job, and dispatching the backfill with subjects: strings alone before trusting the full default.

Three follow-ups worth their own issues: a verify-charts drift guard (re-render from committed
history, fail on drift), which would protect the committed SVGs the way verify-generated protects
generated sources; cost-section panels for the strings and paths charts, now that main has set the
precedent for quantities; and the shared-project build coupling described above, which is mitigated
by a comment rather than by structure.

🤖 Generated with Claude Code

matt-edmondson and others added 26 commits September 18, 2026 10:53
Spec for #252. Three history files and three charts drawn by one
renderer, with the quantities chart required to come out byte-identical
through the refactor.

Records why the strings cost pairs are measured against hand-written
validation rather than a bare string: the bare counterpart of
Uuid.Create is an assignment, so pairing against it would report a large
ratio that only restates that validation is not free.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Eleven tasks. The first makes the renderer subject-aware and is gated on
the quantities chart coming out byte-identical, which is the only real
regression test this pipeline has.

Corrects two API names in the spec found while writing the plan:
RemoveExtension rather than WithoutExtension, and AsAbsolute measured
from a relative path because the absolute one returns this.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The headline set, the grid width and the two title strings move onto a
Subject record looked up by --subject. Ingest is untouched: it never
read either, and the results directory is already per-subject because
the filter selected it.

The quantities chart renders byte-identical, which is the whole test.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Seven rungs from the reflection machinery alone up to the mod-97 check,
using shipped identifier types so the numbers describe types a caller
actually holds. The one fixture is the no-validation floor, which
nothing shipped occupies.

Both failure paths are measured, so the cost of Create over TryCreate at
a boundary that sees bad input is a number. The two rows come out nearly
identical because SemanticString.TryFromString throws and catches an
ArgumentException internally on every rejection, so TryCreate is
exception-free in the caller's control flow only, not in the caller's
cost -- documented in the class remarks.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 2's measurements disproved the plan's assertion that a throwing
rejection costs far more than a non-throwing one. SemanticString's
TryFromString is try/Create/catch, so TryCreate throws and catches
internally on every rejection and both paths pay a full exception.

The failure mode to watch for is the opposite of what was written: a
cheap failure row means the specimen is being accepted.
Equality, ordering, hashing and the conversion back out, plus the two
members that read as ordinary calls and are really full creations
against the target type.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Task 3 measured CompareTo at 0.5714 ns and BenchmarkDotNet reported
ZeroMeasurement: the JIT hoists it, so a panel would chart the harness's
resolution rather than the library. Same effect the quantities suite
already documents for relationship operators on a double.

HashCode measures cleanly at 43.8881 ns and takes the eighth panel. Both
benchmarks stay in the class; only what is drawn changes.
Not against a bare string: the bare counterpart of Uuid.Create is an
assignment, and a ratio against no work at all would only restate that
validation is not free. Paired against the check a caller would have
written anyway, the ratio separates the validation both sides pay from
the reflection only one side does.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The baseline was string.CompareOrdinal while SemanticString.CompareTo
forwards to string.CompareTo, which is culture-sensitive. The pair was
measuring ordinal collation against culture collation and reporting the
difference as the wrapper's cost.

The correction surfaced a property of the library worth recording:
equality on a semantic string is ordinal and ordering is not, so two
values can compare equal under == and sort by a different rule.
…irs against

SemanticString.CompareTo forwards to String.CompareTo(String), which is
culture-sensitive, but the baseline used string.CompareOrdinal. That
compared ordinal collation against culture collation rather than
isolating the wrapper's cost. The baseline now calls
WeakString.CompareTo(WeakString) directly, the same call the semantic
side ultimately makes.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Absolute specimens are built per platform because IsAbsolutePath asks
Path.IsPathFullyQualified, whose answer differs between Windows and
Linux; a hardcoded Windows root would throw on every CI run and pass
locally.

The cached and uncached file name properties are measured side by side,
because both read like field access and one is a full creation.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CA1802 was correct: RelativeFile holds a compile-time literal with
nothing computed at runtime, so const is what it should have been.
The prior suppression bought nothing over taking the one-word fix,
and grouping it with AbsoluteFile/AbsoluteDirectory (which must stay
static readonly, since they call Path.Combine per platform) was
stylistic rather than required.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
A straight comparison, unlike the string one: Path is a real API doing
the real work. What the semantic side adds is a validated wrapper around
every result, so the ratio is what a caller pays for a path that cannot
be passed where a different kind belongs.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Strings along validation weight, which is the axis there is when the
cost is concentrated at creation. Paths the same shape, with the cached
and uncached file name panels adjacent because both read like field
access and only one is.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
…eport

Adds two remarks paragraphs: the semantic side costs about the same
across all four categories, and Create's much larger ratio is a
property of its baseline being unusually cheap, not of creation being
unusually expensive. Also notes the separator asymmetry between
BareCreate and the validator it stands in for directly on BareCreate's
summary, where a reader of the class can see it.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The subjects documentation still said the eighth panel was an ordering,
which it stopped being when CompareTo turned out to be hoisted and
HashCode took its place. Says hashing now, and says why ordering is
measured but not drawn.
The Subjects documentation described panel 8 as an ordering benchmark
after the ZeroMeasurement swap replaced CompareTo with HashCode in the
panel list; the prose was never updated to match. Corrects the bottom
row description and adds a paragraph on why CompareTo is measured but
not drawn, matching the precedent set by the quantities paragraph.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
One job rather than a matrix, so the reference workload is read once and
stamped on every entry: that is what makes a strings point and a
quantities point from the same run comparable, and it keeps the results
to one push.

The dispatch gains a subjects input, which is how a long backfill gets
split rather than by raising the timeout.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Every command inside a `while ... done <<< "$SUBJECTS"` loop inherits
the here-string as its own stdin. BenchmarkDotNet prompts interactively
when a filter selects nothing, which the backfill's version-skip path
deliberately triggers for older packages -- so a drained stdin would
silently truncate a loop to one subject while the step still exits
zero. Redirecting each invocation from /dev/null closes that off.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The plan predicted the mod-97 check would be the heaviest validator.
It is a tie with the format regular expression, and more to the point
the whole spread across four structurally different validators is about
340 ns on a floor of 1,650 ns: the reflection machinery is the bill and
the validator is a minor term.

The seeding expectation is rewritten to match, and now names the real
failure mode -- a validator row sitting at the unvalidated floor, which
would mean its validator never ran.
Measured on one machine with one reference reading, recorded as
local-seed the way the quantities history was. baselineNs is what lets
these sit alongside the CI points that follow.

The creation-benchmark gate check found the ladder flatter than the
class docs predicted: Mod97 and FormatRegex tie within noise, so the
"heaviest shipped validator" claim on Mod97 was wrong. Corrected the
doc comment to state what was measured instead of what was assumed.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Measured as published packages by today's benchmarks, which is the
better comparison than checking out each tag: every version is timed by
identical code rather than by whatever each tag shipped.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
The README gains a chart per library, with the way to read a chart
stated once above all three rather than three times. The strings section
says plainly what the chart shows: a quantity's wrapper is free and a
semantic string's is not, and what that buys.

CLAUDE.md gains the benchmark project, which it never listed, and the
three things about the pipeline that are not guessable from the code.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
CLAUDE.md said "three things" while listing four; folds the 3.3.1
build-failure point into the bullet list as a fourth bullet instead of
a trailing paragraph the intro line didn't count.

Semantics.Benchmarks/README.md had the quantities class table and its
two subsections nested under the shared "Measuring a published
release" heading rather than under "Quantities", because the shared
sections sit between the two quantities blocks in reading order.
Moved the block so the quantities material is contiguous.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Corrects seven documentation issues found by a final whole-branch review
of the strings/paths benchmarks work: a stale "ordering" panel reference,
an allocation-exactness claim that does not hold for the paths chart
(whose inputs are built per platform), a workflow comment that
contradicted the strings/paths filters' whole-class design, an
unexplained gap between the validators/benchmarks the prose names and
what the charts draw, a wrong opening sentence in the benchmarks README,
a workflow comment claiming a not-yet-true cross-chart baseline
guarantee, and two disproven predictions left standing in the design
spec.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>

/// <summary>A fully qualified file path, four segments below the root.</summary>
internal static readonly string AbsoluteFile =
Path.Combine(Root, "semantics", "src", "Semantics.Paths", "FilePath.cs");

/// <summary>The directory that file sits in, used as the base for both conversions.</summary>
internal static readonly string AbsoluteDirectory =
Path.Combine(Root, "semantics", "src");
Main grew a third chart section in the same functions this branch made
subject-aware, so the two refactors had to be reconciled rather than
picked between. The cost pairs move from a standalone array onto the
Subject record: quantities keeps the four main added, strings and paths
declare none, and a subject with no pairs draws two sections and
reserves no height for a third.

The quantities chart still renders byte-identically, now against main's
three-section version, which is the same gate this branch has held at
every step.

Main also fixed the CS8618 that made pre-4.0.0 packages unbuildable, so
3.3.1 is measurable again. Both new histories are backfilled to it and
all three subjects now start there. The documentation said that version
was permanently out of reach; it says instead what the episode actually
teaches, which is that one project shared by three subjects means a
compile error for one costs the other two their history.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 587890d into main Sep 18, 2026
13 checks passed
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.

I want performance benchmark logging and a graphic for the semantic string and path types like we did for quantities

1 participant