From 62b6e3f2de31ca5a00b029d2b75f442f8c1f3f45 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Fri, 18 Sep 2026 10:53:38 +1000 Subject: [PATCH 01/26] Design the benchmark history and charts for strings and paths 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) --- ...6-09-18-strings-paths-benchmarks-design.md | 338 ++++++++++++++++++ 1 file changed, 338 insertions(+) create mode 100644 docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md diff --git a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md new file mode 100644 index 00000000..6a685dc1 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md @@ -0,0 +1,338 @@ +# Design: Benchmark history and release charts for semantic strings and paths + +Status: approved (2026-09-18). Implements [#252](https://github.com/ktsu-dev/Semantics/issues/252). +Read alongside `Semantics.Benchmarks/README.md` (the existing suite), `scripts/benchmark-history.cs` +(ingest and render), and `.github/workflows/benchmark-history.yml` (the release pipeline). + +## Problem + +`Semantics.Quantities` has a measured, charted performance history. Every release adds a point to +`docs/benchmarks/history.json`, the chart is redrawn into `docs/benchmarks/performance.svg`, and the +README shows it. `Semantics.Strings` and `Semantics.Paths` have nothing. + +The gap is not only that the two packages are unmeasured. It is that the whole pipeline is +single-subject by construction: one history file, one hardcoded headline set, one chart title, one +package swapped by `BenchmarkAgainstVersion`. Adding a second subject is a change to how those pieces +fit together, not an addition alongside them. + +There is also a substantive reason to want the numbers. `SemanticString.Create` goes through +`Activator.CreateInstance`, `Type.GetProperty`, and `PropertyInfo.SetValue` on every call, then +validates by walking attributes reflectively. The quantities suite's headline finding was that the +wrapper is free. The strings finding will not be that, and quantifying the difference is the most +valuable thing this work produces. + +## Goals + +- Measure `Semantics.Strings` and `Semantics.Paths` with the same rigor the quantities suite applies. +- Give each its own committed history file and its own chart, drawn by the same renderer. +- Keep the quantities chart byte-identical through the refactor. +- State what the wrapper costs against the code a user would otherwise have written, honestly paired. +- Record in `CLAUDE.md` what the pipeline is, which it currently does not mention at all. + +## Non-goals + +- No combined chart across subjects. Each subject gets its own picture. +- No change to what the quantities suite measures or how its chart looks. +- No `verify-charts` drift guard. Real gap, separate concern, its own issue (see Deferred work). +- No test harness for `scripts/benchmark-history.cs`. It is untested tooling today and stays that way. +- No optimization of the code being measured. This work reports numbers, it does not change them. + +## Decisions + +These were settled during design and should not be reopened without revisiting this document. + +1. **One chart per subject.** Three history files, three charts, one renderer. A combined grid would + have meant migrating the existing quantities history into a new schema for no reader's benefit. +2. **Absolute values on the chart, ratios in the suite README.** The charts draw time and allocation + per operation, as the quantities chart does. The abstraction-cost ratios are written into + `Semantics.Benchmarks/README.md` by a human reading a run, not redrawn by CI. +3. **One benchmark project, three subjects.** New classes join `Semantics.Benchmarks` in `Strings/` + and `Paths/` folders. `BaselineBenchmarks` stays shared, so every subject measured in one job ties + to the same reference reading. +4. **Subject table in C#, not in a data file.** The headline sets stay in `benchmark-history.cs` as a + dictionary of records. The prose explaining why a benchmark is or is not drawn is the most + valuable content in that file, and an XML doc comment holds it better than a JSON field. +5. **History and chart paths stay command-line arguments.** The script keeps knowing nothing about + repository layout. The workflow holds the paths, which is where paths already live. +6. **Specimens are shipped types wherever one exists.** The string benchmarks use `Uuid`, `Ulid`, + `CreditCardNumber` and `Iban` from `Semantics.Strings.Identifiers`, so the numbers describe types + users actually hold. Exactly one fixture type is declared in the benchmark project, for the + no-validation rung, because nothing shipped occupies it. +7. **Histories are seeded locally.** The two new histories are backfilled on a development machine + and committed with `runId: "local-seed"`, exactly as the quantities history was. `baselineNs` is + what makes those points comparable with CI points that follow. + +## What gets measured + +### Strings + +The axis is validation weight. A semantic string is a record wrapping a `string`, so its cost is +concentrated at creation, and what varies between types is what their attributes do. The shipped +identifier types form a genuine ladder: each carries exactly one attribute, and those attributes span +interpreted regular expressions, hand-written checksums, and modular arithmetic. + +`StringCreationBenchmarks`: + +| Method | Specimen | What it isolates | +|---|---|---| +| `Unvalidated` | fixture type declared in the suite | The reflection machinery alone: `Activator.CreateInstance`, `GetProperty`, `SetValue`, then a strategy lookup finding no attributes. The floor every other row sits on. | +| `CharsetRegex` | `Ulid` | That floor plus an interpreted `Regex.IsMatch` over a fixed character set. | +| `FormatRegex` | `Uuid` | The same, over a pattern with groups and separators. | +| `Checksum` | `CreditCardNumber` | The floor plus a hand-written Luhn pass. Paired against the two above, this is regular expressions against arithmetic at comparable input lengths. | +| `Mod97` | `Iban` | The heaviest shipped validator: rearrangement, character-to-digit expansion, modular arithmetic. | +| `TryCreateRejects` | `Uuid` | The failure path that does not throw. | +| `CreateThrows` | `Uuid` | The failure path that does, so the cost of choosing `Create` over `TryCreate` at a boundary that sees bad input is a number rather than a guess. | + +The one fixture type is unavoidable. `Semantics.Strings` ships the framework and no concrete types, +so nothing shipped sits on the no-validation rung. It is declared against the public API only. + +`StringOperationBenchmarks` covers life after creation: `Equals`, `CompareTo`, `GetHashCode`, +`As` (which re-runs the entire creation path against the target type, and should read as +such), `WithSuffix` (likewise), and the implicit conversion back to `string`. + +### Paths + +`PathCreationBenchmarks` builds `AbsoluteFilePath`, `RelativeFilePath`, `AbsoluteDirectoryPath` and +`FileName` from well-formed input. + +`PathOperationBenchmarks` covers `FileName`, `FileNameWithoutExtension`, `DirectoryPath`, +`AsAbsolute(baseDirectory)`, `AsRelative(baseDirectory)` and `WithoutExtension`. + +Two notes on that set. `FileNameWithoutExtension` caches into a field on `AbsoluteFilePath`, while +`FileName` on `SemanticFilePath` builds a fresh `FileName`, validation included, on every read. +Charting both makes that difference visible, and it is the kind of thing a release could quietly +change. And `IsDirectory` and `IsFile` are excluded outright, because they call `Directory.Exists` and +`File.Exists` and would measure the filesystem rather than the library. + +### Headline panels + +Of everything measured, these are what the two charts draw, four columns by two rows to match the +quantities grid. Everything else stays in the history file and can be promoted later without +re-running anything, because `ingest` records every benchmark in a run and only `render` selects. + +Strings: + +| Position | Benchmark | Label | +|---|---|---| +| 1 | `StringCreationBenchmarks.Unvalidated` | Create (no validation) | +| 2 | `StringCreationBenchmarks.CharsetRegex` | Create (charset regex) | +| 3 | `StringCreationBenchmarks.FormatRegex` | Create (format regex) | +| 4 | `StringCreationBenchmarks.Mod97` | Create (mod-97) | +| 5 | `StringCreationBenchmarks.TryCreateRejects` | TryCreate (rejects) | +| 6 | `StringCreationBenchmarks.CreateThrows` | Create (throws) | +| 7 | `StringOperationBenchmarks.AsConversion` | As<T> conversion | +| 8 | `StringOperationBenchmarks.CompareTo` | CompareTo | + +Paths: + +| Position | Benchmark | Label | +|---|---|---| +| 1 | `PathCreationBenchmarks.AbsoluteFilePath` | Create (absolute file) | +| 2 | `PathCreationBenchmarks.RelativeFilePath` | Create (relative file) | +| 3 | `PathCreationBenchmarks.FileNameType` | Create (file name) | +| 4 | `PathOperationBenchmarks.FileName` | FileName (uncached) | +| 5 | `PathOperationBenchmarks.FileNameWithoutExtension` | FileName (cached) | +| 6 | `PathOperationBenchmarks.AsAbsolute` | AsAbsolute | +| 7 | `PathOperationBenchmarks.AsRelative` | AsRelative | +| 8 | `PathOperationBenchmarks.WithoutExtension` | WithoutExtension | + +Both sets are subject to the measurability check under Verification. A benchmark that reports +`ZeroMeasurement` is replaced by the next candidate from its class rather than left on the chart. + +## The cost pairs + +This is where strings differ from quantities in kind rather than degree. + +The quantities pair is fair because both sides do identical work. `T + T` against +`Length + Length` is the same addition, and the only open question is what the wrapper adds. +There is no equivalent for `Uuid.Create(s)`, because the bare-string counterpart is an assignment, +which is no work at all. Paired against that, the wrapper's ratio would be a large number restating +only that validation is not free, which needs no benchmark. + +So `StringAbstractionCostBenchmarks` pairs against the code a user would otherwise have written, with +the hand-written side marked `Baseline = true`: + +| Pair | Baseline side | Semantic side | +|---|---|---| +| Validate at a boundary | `Regex.IsMatch(s, pattern)` then throw on failure, the same pattern the attribute uses | `Uuid.Create(s)` | +| Validate without throwing | the same match, returning a `bool` | `Uuid.TryCreate(s, out _)` | +| Equality | `string.Equals(a, b, StringComparison.Ordinal)` | `Uuid` record equality | +| Ordering | `string.CompareTo` | `Uuid.CompareTo` | + +Read that way, the ratio answers the question a user actually has. *I was going to validate this +anyway, so what does routing it through the type cost me on top?* The answer separates into the +validation both sides pay and the per-call reflection only one side does. The last two rows are fair +pairs in the quantities sense and are expected near 1.00. + +`PathAbstractionCostBenchmarks` needs none of that care, because `System.IO.Path` is a real API doing +the real work: `FileName` against `Path.GetFileName`, `AsAbsolute` against `Path.GetFullPath`, +`AsRelative` against `Path.GetRelativePath`, and creation against a hand-written `Path.IsPathRooted` +guard. + +Both cost classes use the accumulating-loop shape the quantities cost class established, for the same +reason: a single call over an unchanging operand is loop-invariant, gets hoisted, and would make the +ratio meaningless. + +Neither cost class is plotted. Their ratios go into `Semantics.Benchmarks/README.md` as tables, the +way the quantities ratios sit there today. + +## The script + +Every subject-specific thing in `scripts/benchmark-history.cs` is inside `render`. `ingest` never +touches the headline set or the subject name. It takes `--history` and `--results` and records +whatever the run produced, and the results directory is already per-subject-per-version because the +filter selected it. **`ingest` therefore needs no change.** + +What `render` holds today, by line: + +```text +line 25 Columns = 4 +line 54 Headline = [ ...eight quantities entries... ] +line 424 rows computed from Headline.Length +line 444 aria-label "Semantics.Quantities allocation and relative time per release" +line 456 title "Semantics.Quantities performance by release" +line 478 loop over Headline +``` + +Those become a subject: + +```csharp +private sealed record Subject(string Title, int Columns, Panel[] Headline); +private sealed record Panel(string Key, string? Parameters, string Label); + +private static readonly Dictionary Subjects = new(StringComparer.Ordinal) +{ + ["quantities"] = new("Semantics.Quantities", 4, [ /* exactly today's eight */ ]), + ["strings"] = new("Semantics.Strings", 4, [ /* eight */ ]), + ["paths"] = new("Semantics.Paths", 4, [ /* eight */ ]), +}; +``` + +`render` gains `--subject`, looks the subject up, and threads it through `Draw`, `Preamble` and +`Section`. The two title strings become interpolations over `subject.Title`. `Columns` moves from a +constant onto the record. Nothing else in the file moves. + +The `(parameters) digits` suffix in `Section` stays untouched. It is quantities-only formatting, +reached only when a benchmark carries parameters, and none of the new ones do. Generalizing it would +be churn for no reader. + +## The workflow and project wiring + +**The csproj.** `BenchmarkAgainstVersion` currently swaps one `ProjectReference` for one +`PackageReference`. It grows to four pairs: `Quantities`, `Strings`, `Paths`, `Strings.Identifiers`. +That is correct because this repository ships one version across every package. `Semantics.Paths` +already references `Semantics.Strings`, so the project-reference side could omit it, but all four are +listed explicitly on both sides. The package side must name all four regardless, and two lists that +differ in membership invite the wrong edit later. + +**The subject table.** Three `(name, history, chart, filter)` triples, and environment variables +cannot hold arrays. One block-scalar variable with one line per subject, parsed with +`IFS='|' read`, keeps the table in one place: + +```yaml +env: + SUBJECTS: | + quantities|docs/benchmarks/history.json|docs/benchmarks/performance.svg| + strings|docs/benchmarks/strings-history.json|docs/benchmarks/strings-performance.svg| + paths|docs/benchmarks/paths-history.json|docs/benchmarks/paths-performance.svg| +``` + +Each of the four steps that currently does one thing loops that table, skipping any subject not named +in the dispatch's new `subjects` input (default: all three). The release path loops subjects inside +the existing worktree. The backfill loops versions and then subjects, so one `BenchmarkAgainstVersion` +build serves all three subjects at that version. The render step loops. The commit step stages three +histories and six SVG files and still makes one push. + +**Unchanged deliberately.** The baseline is measured once, before the loops, and stamped on every +entry the job produces. One runner, one reading, and that is what lets a strings point and a +quantities point from the same job be compared at all. The `concurrency` group stays a single serial +queue, so two dispatches cannot race the same files. + +**Changed: the timeout.** It is 240 minutes for one subject. A three-subject backfill across ten +versions is roughly triple. `timeout-minutes` goes to 360, with a comment saying the `subjects` input +is the intended way to split a long backfill rather than raising the number further. + +**Failure semantics carry over unchanged.** A version whose API the current benchmarks cannot express +is warned and skipped rather than failing the run, and so is a run that builds but reports a table of +`NA`. With three subjects that matters more, not less. `Semantics.Strings` has been far more stable +than the quantities package, so a backfill will likely reach versions where the strings benchmarks run +and the quantities ones do not. Skipping per subject per version, rather than per version, is what +lets the strings chart reach back further than the quantities one. + +## Documentation + +**`README.md`, the `## Performance` section.** Today it is one picture and four paragraphs, and the +prose is entirely about storage types. It becomes three subsections, one per subject, each with its +own `` and a paragraph saying what its axis is and why. + +The two paragraphs that are not subject-specific are lifted out and stated once above the three: that +allocation is exact and a step in it is always a real change, while time is measured on shared CI +runners and divided by a reference workload, making it indicative rather than precise. + +The new prose must be honest about what the charts show. The quantities paragraph gets to say the +wrapper is free. The strings paragraph will be saying that creation goes through +`Activator.CreateInstance`, a `PropertyInfo.SetValue` and a reflective attribute walk on every call, +and that this is what the numbers are. Better the README states that plainly than a reader discovers +it from a chart with no explanation attached. + +**`Semantics.Benchmarks/README.md`.** Currently framed end to end around one subject, opening "The +axis that matters here is the storage type." It grows a subject heading above that material and two +siblings: + +- *Quantities*: everything there now, moved down a heading level, otherwise untouched. +- *Strings*: the validation ladder, why the specimens are shipped identifier types, why the one + fixture exists, and the cost-pair table with its explanation of why the baseline side is + hand-written validation rather than a bare string. +- *Paths*: the operation set, why `IsDirectory` and `IsFile` are excluded, the cached against + uncached contrast, and the `System.IO.Path` cost-pair table. + +"Running" and "Measuring a published release" stay shared and gain a line each about `--filter` +selecting a subject and about the four packages `BenchmarkAgainstVersion` now swaps. + +The two ratio tables ship with real numbers from the seeding run. An empty table with a note saying +it will be filled in later is how a document starts rotting. + +**`CLAUDE.md`.** `Semantics.Benchmarks` joins the project-layout table, which omits it entirely today. +A short "Benchmarks and the release charts" subsection records the three things that are non-obvious +and will otherwise be rediscovered the hard way: + +- `BenchmarkAgainstVersion` must be set in the environment rather than with `-p:`, because + BenchmarkDotNet builds a generated project of its own that a command-line property never reaches. +- The histories and SVG files are committed output that a bot pushes, so a local `render` must be + diffed rather than assumed. +- The benchmark project deliberately touches no internal member, because the `InternalsVisibleTo` + that would expose one names only the test assembly, and a benchmark built on internals could only + ever measure the working copy. + +## Verification + +1. **Byte equality gates everything.** Before anything else, `render --subject quantities` against the + untouched `history.json` must reproduce `performance.svg` and `performance-dark.svg` exactly. + `git diff` empty, or the refactor is wrong. +2. **Every new benchmark is checked for being measurable.** The headline sets above are proposals, not + commitments. Each new class runs at `--job short`, the warnings get read, and any benchmark coming + back as `ZeroMeasurement` or `NA` is dropped or replaced before it reaches a chart. Anything cut is + reported with its reason rather than quietly substituted. `Equals` and `CompareTo` on the strings + operations chart are the plausible casualties, followed by the path property reads. +3. **Seeding proceeds one version first.** The full run is two new subjects across a ten-version list + and will take hours. Version 5.3.4 is seeded alone and its numbers read against expectations + stated in advance: the unvalidated floor below every validated rung, `Iban` slowest, allocation + non-zero everywhere because a semantic string is a reference type. The full backfill starts only + once those hold. If they do not hold, that is a finding to raise, not something to chart. +4. **Ordinary gates.** `dotnet build` warnings-clean, since ktsu.Sdk treats warnings as errors. + `dotnet test` green. `Semantics.Benchmarks` carries `SonarQubeExclude` so the new benchmark classes + are not analysed, but `scripts/benchmark-history.cs` is, so the local Sonar build documented in + `CLAUDE.md` runs over the script change rather than findings surfacing after a push. + +## Version tag + +`[patch]`. No library API changes. This is tooling, CI and documentation, matching the precedent of +the commit that introduced the quantities chart. + +## Deferred work + +A `verify-charts` workflow, re-rendering from committed history and failing a pull request on drift, +would guard the committed SVG files the way `verify-generated` guards the generated sources. It is a +real gap, and the quantities chart has had it since the day it shipped. It is a separate concern from +this issue, and bundling it would change the CI contract for a reason the issue never raised. It +should be its own issue. From 8fbdcb429492cb3623ad3d2bd2b5a0367aec5910 Mon Sep 17 00:00:00 2001 From: Matt Edmondson Date: Fri, 18 Sep 2026 11:10:40 +1000 Subject: [PATCH 02/26] Plan the strings and paths benchmark work 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) --- .../2026-09-18-strings-paths-benchmarks.md | 2156 +++++++++++++++++ ...6-09-18-strings-paths-benchmarks-design.md | 13 +- 2 files changed, 2165 insertions(+), 4 deletions(-) create mode 100644 docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md diff --git a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md new file mode 100644 index 00000000..38f3bb56 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md @@ -0,0 +1,2156 @@ +# Strings and Paths Benchmark Charts Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Give `Semantics.Strings` and `Semantics.Paths` the same measured, charted, per-release performance history that `Semantics.Quantities` already has. + +**Architecture:** The existing pipeline is single-subject by construction. Task 1 makes the renderer subject-aware while keeping the quantities chart byte-identical. Tasks 2 to 6 add benchmark classes to the existing `Semantics.Benchmarks` project. Tasks 7 and 8 register the two new subjects with the renderer and the workflow. Tasks 9 and 10 seed the histories locally. Task 11 writes the documentation, using real numbers from the seeding run. + +**Tech Stack:** .NET 10, BenchmarkDotNet, MSTest (existing suite, untouched), a file-based C# app (`scripts/benchmark-history.cs`) run by `dotnet run`, GitHub Actions. + +**Spec:** `docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md` + +## Global Constraints + +Copied from the spec and from `CLAUDE.md`. Every task's requirements include these. + +- **Indentation is tabs** in C# files. Line endings CRLF for C# sources, LF for generated output and markdown (`.gitattributes` sets `* text=auto eol=lf`). +- **File header on every new C# file:** `// Copyright (c) 2023-2026 ktsu-dev contributors` as the first line, followed by a blank line. +- **File-scoped namespaces.** Using directives go inside the namespace. No `this.` qualifiers. Explicit accessibility modifiers everywhere. Braces on every control flow statement. +- **Warnings are errors** (ktsu.Sdk default). `dotnet build` must be clean. +- **No global warning suppressions.** Use targeted `[SuppressMessage]` with a justification if one is genuinely needed. +- **No `var` in test bodies.** Benchmark bodies follow the same convention as the existing suite, which uses explicit types throughout. +- **The benchmark project touches no internal member.** `InternalsVisibleTo` names only `ktsu.Semantics.Test`, and a benchmark built on internals could only ever measure the working copy, never a published package. +- **Every benchmark operand is a field set in `[GlobalSetup]`,** never a literal in the benchmark body, so the JIT cannot constant-fold the work away. +- **`BenchmarkAgainstVersion` is set in the environment, never with `-p:`.** BenchmarkDotNet builds a generated project of its own that a command-line property never reaches. +- **Commit message version tag:** the final implementation commit carries `[patch]`. Intermediate commits carry no tag. +- **Do not edit `BaselineBenchmarks.ReferenceWork`.** Its body is the fixed point that makes timings from different machines comparable. Editing it silently rescales every comparison against history recorded before the edit. +- **Generated output is committed source.** `docs/benchmarks/*.json` and `docs/benchmarks/*.svg` are diffed before commit, never assumed. + +--- + +### Task 1: Make the renderer subject-aware + +The gate for everything after it. The quantities chart must come out byte-identical. + +**Files:** +- Modify: `scripts/benchmark-history.cs:25` (the `Columns` constant), `:54-64` (the `Headline` array), `:391-427` (`Render`), `:429-441` (`Draw`), `:443-459` (`Preamble`), `:461-495` (`Section`) +- Test: no test file. The regression check is byte equality of the two committed SVG files, run as a shell command in Step 2 and Step 5. + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `render --subject --history --out `, where `` is a key of the `Subjects` dictionary. Task 7 adds `"strings"` and `"paths"` keys. Task 8 calls this from the workflow. The two record types other tasks reference by name are `Subject(string Title, int Columns, Panel[] Headline)` and `Panel(string Key, string? Parameters, string Label)`. + +- [ ] **Step 1: Record the current chart bytes as the regression baseline** + +```bash +cd /c/dev/ktsu-dev/Semantics +mkdir -p "$TMPDIR/chartcheck" +cp docs/benchmarks/performance.svg "$TMPDIR/chartcheck/performance.svg.expected" +cp docs/benchmarks/performance-dark.svg "$TMPDIR/chartcheck/performance-dark.svg.expected" +``` + +- [ ] **Step 2: Confirm the baseline reproduces before any edit** + +Proves the check is meaningful rather than vacuous. If the committed SVG files do not reproduce from the committed history *before* the refactor, stop and report that, because then byte equality cannot gate anything. + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run scripts/benchmark-history.cs -- render \ + --history docs/benchmarks/history.json --out docs/benchmarks/performance.svg +git diff --stat docs/benchmarks/ +``` + +Expected: `git diff --stat` reports no changes. + +- [ ] **Step 3: Replace the `Headline` array and `Columns` constant with a subject table** + +Delete the `private const int Columns = 4;` line. Replace the whole `Headline` field, XML documentation comment included, with the following. The `` prose is moved verbatim from the old `Headline` comment onto the `quantities` entry, because it explains that subject's panel choice and nothing else. + +```csharp + /// One drawable chart: its title, its grid width, and the panels it shows. + /// The library the chart is about, used in the heading and the aria-label. + /// Panels per row. + /// The panels, in reading order. + private sealed record Subject(string Title, int Columns, Panel[] Headline); + + /// One panel: which benchmark it draws, and what to call it. + /// The benchmark key as ingest stores it. + /// The parameter case to draw, or null when the benchmark has none. + /// The panel heading. + private sealed record Panel(string Key, string? Parameters, string Label); + + /// The charts this script can draw, by the name --subject takes. + /// + /// + /// Everything measured is stored; this only decides what each picture shows, so it can change + /// without re-running anything. + /// + /// + /// Quantities is drawn as a grid of one operation per storage type rather than of every + /// operation at one storage type. A quantity is a value type over T and does almost + /// nothing of its own, so what a release changes it changes per storage type — and the same + /// line of user code costs four different things depending on the T it was written + /// against. The top row is one construction across the four, so that row reads as the + /// comparison it is. + /// + /// + /// None of the eight is a bare operator, although the suite measures those too. A relationship + /// operator over a binary floating point type is a single machine instruction on values the + /// loop does not change, so the JIT hoists it out and BenchmarkDotNet reports it as + /// indistinguishable from an empty method. That is a true answer about the library and a + /// useless one to plot: a panel of it would chart the harness's resolution rather than any + /// release. What is drawn instead is the work around an operator — building a quantity from a + /// unit, reading it back out in one, a vector length, a comparison — all of which are far + /// enough above that floor to move when the library does. + /// + /// + private static readonly Dictionary Subjects = new(StringComparer.Ordinal) + { + ["quantities"] = new("Semantics.Quantities", 4, + [ + new("ConstructionBenchmarks.FromNauticalMile", null, "Construct (double)"), + new("ConstructionBenchmarks.FromNauticalMile", null, "Construct (float)"), + new("ConstructionBenchmarks.FromNauticalMile", null, "Construct (decimal)"), + new("ConstructionBenchmarks.FromNauticalMile", null, "Construct (precise)"), + new("UnitConversionBenchmarks.InNauticalMile", null, "Read back (decimal)"), + new("UnitConversionBenchmarks.InNauticalMile", null, "Read back (precise)"), + new("VectorBenchmarks.Length", null, "Vector length (decimal)"), + new("ComparisonBenchmarks.CompareToInterface", null, "CompareTo (double)"), + ]), + }; +``` + +- [ ] **Step 4: Thread the subject through `Render`, `Draw`, `Preamble` and `Section`** + +Four signature changes and six body edits. Nothing else in the file moves. + +In `Render`, immediately after the existing `string historyPath = Required(options, "history");` line, add the lookup, then pass `subject` to `Draw`: + +```csharp + string subjectName = Required(options, "subject"); + if (!Subjects.TryGetValue(subjectName, out Subject? subject)) + { + throw new InvalidOperationException( + $"Unknown subject '{subjectName}'. Known: {string.Join(", ", Subjects.Keys)}"); + } +``` + +The existing `File.WriteAllText(path, Draw(entries, Themes[name]));` becomes: + +```csharp + File.WriteAllText(path, Draw(entries, Themes[name], subject)); +``` + +`Draw` takes the subject and reads its columns and panel count from it: + +```csharp + private static string Draw(JsonArray entries, Theme theme, Subject subject) + { + string[] labels = [.. entries.Select(entry => entry!["version"]?.GetValue() ?? "?")]; + int width = Left + (subject.Columns * CellWidth) + 24; + int rows = (subject.Headline.Length + subject.Columns - 1) / subject.Columns; + int height = 72 + (((34 + (rows * CellHeight)) * 2) + 54); + + StringBuilder svg = new(); + Preamble(svg, theme, width, height, entries, subject); + + int y = 72; + foreach (bool isTime in (bool[])[false, true]) + { + Section(svg, theme, entries, labels.Length, y, isTime, subject); + y += 34 + (rows * CellHeight); + } + + Footer(svg, entries, labels, y - 4); + svg.AppendLine(""); + return svg.ToString(); + } +``` + +In `Preamble`, add `Subject subject` as the last parameter and replace the two hardcoded strings: + +```csharp + svg.AppendLine(CultureInfo.InvariantCulture, $""""""); +``` + +```csharp + svg.AppendLine(CultureInfo.InvariantCulture, $"""{Escape(subject.Title)} performance by release"""); +``` + +In `Section`, add `Subject subject` as the last parameter and change the panel loop's three references: + +```csharp + for (int position = 0; position < subject.Headline.Length; position++) + { + (string key, string? parameters, string label) = subject.Headline[position]; + double?[] values = [.. entries.Select(entry => Value(entry!, key, parameters, isTime))]; + Panel( + svg, + Left + (position % subject.Columns * CellWidth), + y + 26 + (position / subject.Columns * CellHeight), + label + (parameters is null ? "" : $" ({parameters} digits)"), + points, + values, + isTime, + colour, + theme); + } +``` + +Note: the existing private method `Panel(...)` that draws a panel now shares its name with the new `Panel` record. C# resolves the method call and the type usage without ambiguity here, but if the compiler complains, rename the **record** to `PanelSpec` and update the three references in `Subject`, `Subjects` and the loop. Do not rename the drawing method, which is referenced elsewhere. + +- [ ] **Step 5: Verify byte equality** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run scripts/benchmark-history.cs -- render --subject quantities \ + --history docs/benchmarks/history.json --out docs/benchmarks/performance.svg +diff docs/benchmarks/performance.svg "$TMPDIR/chartcheck/performance.svg.expected" +diff docs/benchmarks/performance-dark.svg "$TMPDIR/chartcheck/performance-dark.svg.expected" +git diff --stat docs/benchmarks/ +``` + +Expected: both `diff` commands produce no output, and `git diff --stat` reports no changes. **If a single byte moved, the refactor is wrong. Fix it before continuing rather than accepting the new bytes.** + +- [ ] **Step 6: Verify the unknown-subject error** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run scripts/benchmark-history.cs -- render --subject nosuch \ + --history docs/benchmarks/history.json --out /tmp/x.svg; echo "exit=$?" +``` + +Expected: `Unknown subject 'nosuch'. Known: quantities` on standard error, `exit=2`. + +- [ ] **Step 7: Run the Sonar analyzers over the change** + +The script is not `SonarQubeExclude`d, and CI will analyze it. Catching findings here saves a ten-minute round trip. + +```powershell +cd C:\dev\ktsu-dev\Semantics +dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD\.sonarlint\sonar-local.props +``` + +Expected: build succeeds with no new warnings. + +- [ ] **Step 8: Commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add scripts/benchmark-history.cs +git commit -m "Make the release chart renderer subject-aware + +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." +``` + +--- + +### Task 2: String specimens and creation benchmarks + +**Files:** +- Modify: `Semantics.Benchmarks/Semantics.Benchmarks.csproj` (both `ItemGroup`s that `BenchmarkAgainstVersion` switches between) +- Create: `Semantics.Benchmarks/Strings/StringSpecimens.cs` +- Create: `Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs` +- Test: no test file. Verification is a `--job short` run whose summary shows a measurement for every method and no `NA` or `ZeroMeasurement`. + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: `internal static class StringSpecimens` with `internal const string` members `PlainInput`, `UuidText`, `UuidPattern`, `UuidRejected`, `UlidText`, `CardText`, `IbanText`, and the public type `PlainText`. Tasks 3 and 4 use all of them. Benchmark keys `StringCreationBenchmarks.Unvalidated`, `.CharsetRegex`, `.FormatRegex`, `.Checksum`, `.Mod97`, `.TryCreateRejects`, `.CreateThrows`, which Task 7 draws. + +- [ ] **Step 1: Add the three package references to the benchmark project** + +`BenchmarkAgainstVersion` switches between a project-reference group and a package-reference group. Both grow to four entries. All four are listed on both sides even though `Semantics.Paths` transitively brings `Semantics.Strings`, because the package side must name all four regardless and two lists differing in membership invite the wrong edit later. + +Replace the two existing `ItemGroup` elements at the bottom of `Semantics.Benchmarks/Semantics.Benchmarks.csproj` with: + +```xml + + + + + + + + + + + + +``` + +- [ ] **Step 2: Verify the project still builds both ways** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet build -c Release Semantics.Benchmarks +BenchmarkAgainstVersion=5.3.4 dotnet build -c Release Semantics.Benchmarks +``` + +Expected: both succeed. The second proves all four packages exist at that version on nuget.org, which the backfill in Task 10 depends on. + +- [ ] **Step 3: Write the specimens file** + +Create `Semantics.Benchmarks/Strings/StringSpecimens.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Strings; + +using ktsu.Semantics.Strings; + +/// +/// A semantic string that declares no validation at all. +/// +/// +/// The one specimen in this suite that is not a shipped type, and it has to be. +/// Semantics.Strings ships the validation framework and no concrete types, so nothing +/// shipped occupies the no-validation rung — and that rung is what every other measurement is read +/// against, being the reflection machinery alone with no validator behind it. Declared against the +/// public API only, like everything else here. +/// +public sealed record PlainText : SemanticString; + +/// +/// The input strings the string benchmarks run against. +/// +/// +/// Every value here is valid for the type it is paired with, except , +/// and each is held as a constant so that a benchmark body reads as one call rather than as a +/// literal the JIT might fold. The benchmark classes copy these into fields in +/// [GlobalSetup] for the same reason. +/// +internal static class StringSpecimens +{ + /// Input for the unvalidated rung. Length is in the same range as the others. + internal const string PlainInput = "0123456789abcdef0123456789abcdef0123"; + + /// A canonical lowercase RFC 4122 identifier, which IsUuid accepts as written. + internal const string UuidText = "123e4567-e89b-12d3-a456-426614174000"; + + /// + /// The pattern IsUuidAttribute uses, repeated here for the hand-written side of the + /// cost pairs in StringAbstractionCostBenchmarks. It must stay identical to the one in + /// Semantics.Strings.Identifiers, or that pair stops being a comparison. + /// + internal const string UuidPattern = + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + + /// The same identifier one character short, so the pattern rejects it. + internal const string UuidRejected = "123e4567-e89b-12d3-a456-42661417400"; + + /// A 26-character Crockford base32 identifier, which IsUlid accepts. + internal const string UlidText = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + + /// A 16-digit number that passes the Luhn check. Not a real card. + internal const string CardText = "4111111111111111"; + + /// The standard example account number, which passes the mod-97 check. + internal const string IbanText = "GB82WEST12345698765432"; +} +``` + +- [ ] **Step 4: Write the creation benchmarks** + +Create `Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Strings; + +using System; + +using BenchmarkDotNet.Attributes; + +using ktsu.Semantics.Strings.Identifiers; + +/// +/// Measures creating a semantic string, across the range of validation a type can declare. +/// +/// +/// +/// The axis here is validation weight. A semantic string is a record wrapping a +/// , so its cost is concentrated at creation, and what differs between types +/// is what their attributes do. is the floor: the reflection machinery +/// alone, which is Activator.CreateInstance, a GetProperty, a +/// PropertyInfo.SetValue, and a strategy lookup that finds no attributes. Every other row +/// is that floor plus one validator, so the difference between rows is the validator. +/// +/// +/// The validators are shipped types rather than fixtures, so the numbers describe types a user +/// actually holds. They also happen to span the interesting ground: and +/// are interpreted regular expressions looked up from the static cache +/// on every call and carrying a one-second timeout, is a hand-written Luhn +/// pass over the same sort of input, and is the heaviest shipped validator. +/// +/// +/// Both failure paths are here. and +/// reject the same input, so the pair is the cost of choosing +/// Create over TryCreate at a boundary that sees bad input — a number rather than a +/// guess. catches the exception inside the benchmark on purpose: the +/// throw and the catch together are what a caller pays. +/// +/// +[MemoryDiagnoser] +public class StringCreationBenchmarks +{ + private string plain = ""; + private string uuid = ""; + private string uuidRejected = ""; + private string ulid = ""; + private string card = ""; + private string iban = ""; + + /// Copies the inputs into fields, so no benchmark body holds a literal. + [GlobalSetup] + public void Setup() + { + plain = StringSpecimens.PlainInput; + uuid = StringSpecimens.UuidText; + uuidRejected = StringSpecimens.UuidRejected; + ulid = StringSpecimens.UlidText; + card = StringSpecimens.CardText; + iban = StringSpecimens.IbanText; + } + + /// The reflection machinery with no validator behind it. + /// The created value. + [Benchmark] + public PlainText Unvalidated() => PlainText.Create(plain); + + /// That floor plus an interpreted regular expression over a fixed character set. + /// The created value. + [Benchmark] + public Ulid CharsetRegex() => Ulid.Create(ulid); + + /// The same, over a pattern with groups and separators. + /// The created value. + [Benchmark] + public Uuid FormatRegex() => Uuid.Create(uuid); + + /// That floor plus a hand-written Luhn pass, for regular expressions against arithmetic. + /// The created value. + [Benchmark] + public CreditCardNumber Checksum() => CreditCardNumber.Create(card); + + /// The heaviest shipped validator: rearrangement, expansion, modular arithmetic. + /// The created value. + [Benchmark] + public Iban Mod97() => Iban.Create(iban); + + /// The failure path that does not throw. + /// Whether creation succeeded, which is always false here. + [Benchmark] + public bool TryCreateRejects() => Uuid.TryCreate(uuidRejected, out _); + + /// The failure path that does, throw and catch together. + /// Whether the expected exception was raised, which is always true here. + [Benchmark] + public bool CreateThrows() + { + try + { + _ = Uuid.Create(uuidRejected); + return false; + } + catch (ArgumentException) + { + return true; + } + } +} +``` + +- [ ] **Step 5: Run the class and read the summary** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter '*StringCreationBenchmarks*' --job short +``` + +Expected: seven rows, each with a mean in nanoseconds and an allocation figure. **Check for and report:** +- Any row reading `NA` means the benchmark threw. The most likely cause is a specimen the validator rejects. Fix the specimen, do not remove the row. +- Any `ZeroMeasurement` warning means the JIT hoisted the work. None is expected here, since every path allocates. +- `Unvalidated` should be the fastest row and `Mod97` the slowest. If that ordering does not hold, stop and report it rather than continuing, because it means the benchmark is not measuring what this task claims. + +- [ ] **Step 6: Commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add Semantics.Benchmarks/Semantics.Benchmarks.csproj Semantics.Benchmarks/Strings/ +git commit -m "Measure creating a semantic string across the validation ladder + +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." +``` + +--- + +### Task 3: String operation benchmarks + +**Files:** +- Create: `Semantics.Benchmarks/Strings/StringOperationBenchmarks.cs` +- Test: no test file. Verification is the `--job short` run in Step 2. + +**Interfaces:** +- Consumes: `StringSpecimens.UuidText`, `StringSpecimens.PlainInput`, and the `PlainText` type from Task 2. +- Produces: benchmark keys `StringOperationBenchmarks.EqualityOperator`, `.CompareTo`, `.HashCode`, `.AsConversion`, `.WithSuffix`, `.ToStringImplicit`. Task 7 draws `.AsConversion` and `.CompareTo`. + +- [ ] **Step 1: Write the class** + +Note two shapes that are not arbitrary. The equality method is named `EqualityOperator` rather than `Equals`, because a method called `Equals` on the class would hide `object.Equals` and draw a compiler warning, which is an error here. And `WithSuffix` runs on `PlainText` rather than on `Uuid`, because appending to a `Uuid` produces a value its own validator rejects and the benchmark would throw on every invocation. + +Create `Semantics.Benchmarks/Strings/StringOperationBenchmarks.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Strings; + +using BenchmarkDotNet.Attributes; + +using ktsu.Semantics.Strings.Identifiers; + +/// +/// Measures what a semantic string costs after it has been created. +/// +/// +/// +/// Creation is where a semantic string spends its cost, and the rest of this suite says so. This +/// class is the other half of that claim: once a value exists, an operation on it should be the +/// underlying 's own work plus a wrapper, and the rows here are how far that +/// holds. +/// +/// +/// Two of them are creation in disguise, which is the point of including them. +/// and both route back through +/// Create on the target type, so each pays the full reflection and validation cost a +/// StringCreationBenchmarks row measures. They read as ordinary member calls at a call +/// site, and they are not, and that is worth being able to point at. +/// +/// +/// runs on rather than on an identifier, because +/// appending to a produces a value its own validator rejects. Measuring the +/// throw is StringCreationBenchmarks.CreateThrows's job, not this one's. +/// +/// +[MemoryDiagnoser] +public class StringOperationBenchmarks +{ + private Uuid left = null!; + private Uuid right = null!; + private PlainText plain = null!; + private string suffix = ""; + + /// Builds the operands once, outside the measurement. + [GlobalSetup] + public void Setup() + { + left = Uuid.Create(StringSpecimens.UuidText); + right = Uuid.Create(StringSpecimens.UuidText); + plain = PlainText.Create(StringSpecimens.PlainInput); + suffix = "-suffixed"; + } + + /// Record equality over two equal values, which is the worst case for it. + /// Whether the two are equal, which is always true here. + [Benchmark] + public bool EqualityOperator() => left == right; + + /// Ordering, which routes through the underlying string's comparison. + /// The comparison result. + [Benchmark] + public int CompareTo() => left.CompareTo(right); + + /// Hashing, which a dictionary of semantic strings pays on every lookup. + /// The hash code. + [Benchmark] + public int HashCode() => left.GetHashCode(); + + /// Cross-type conversion, which is a full creation against the target type. + /// The converted value. + [Benchmark] + public PlainText AsConversion() => left.As(); + + /// <summary>Appending, which is also a full creation against the same type.</summary> + /// <returns>The extended value.</returns> + [Benchmark] + public PlainText WithSuffix() => plain.WithSuffix(suffix); + + /// <summary>The implicit conversion back out, which should be a field read.</summary> + /// <returns>The underlying string.</returns> + [Benchmark] + public string ToStringImplicit() => left; +} +``` + +- [ ] **Step 2: Run the class and read the summary** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter '*StringOperationBenchmarks*' --job short +``` + +Expected: six rows with measurements. **Check for and report:** +- `ToStringImplicit` is the row most likely to report `ZeroMeasurement`, being a field read the JIT can hoist. If it does, leave it in the class but note it, because Task 7 does not draw it. +- `EqualityOperator` and `CompareTo` are the next most likely. **If either reports `ZeroMeasurement`, Task 7 must replace the `CompareTo` panel with `HashCode`.** Record which happened, because Task 7 depends on the answer. +- `AsConversion` and `WithSuffix` should each cost roughly what a `StringCreationBenchmarks` creation row costs. If either is dramatically cheaper, the call is being optimized away and the row is not measuring what it claims. + +- [ ] **Step 3: Commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add Semantics.Benchmarks/Strings/StringOperationBenchmarks.cs +git commit -m "Measure a semantic string after it exists + +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." +``` + +--- + +### Task 4: String cost pairs + +**Files:** +- Create: `Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs` +- Test: no test file. Verification is the `--job short` run in Step 2, read for the `Ratio` column. + +**Interfaces:** +- Consumes: `StringSpecimens.UuidText`, `.UuidPattern`, `.UuidRejected` from Task 2. +- Produces: a `Ratio` column per category, which Task 11 copies into `Semantics.Benchmarks/README.md`. Nothing later depends on the benchmark keys, because this class is never plotted. + +- [ ] **Step 1: Write the class** + +The pairing is the substance of this task, so the reasoning is in the class documentation rather than only in the spec. + +Create `Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Strings; + +using System; +using System.Text.RegularExpressions; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +using ktsu.Semantics.Strings.Identifiers; + +/// <summary> +/// Measures what the semantic string types cost over the code a caller would otherwise write. +/// </summary> +/// <remarks> +/// <para> +/// <b>What the baseline side is, and why it is not a bare string.</b> The quantities suite pairs +/// <c>T + T</c> against <c>Length&lt;T&gt; + Length&lt;T&gt;</c>, which is fair because both sides +/// do identical work and the only question is what the wrapper adds. There is no such pairing for +/// <c>Uuid.Create</c>: the bare-string counterpart is an assignment, which is no work at all. +/// Against that, the ratio would be a large number restating only that validation is not free, +/// which needs no benchmark to establish. +/// </para> +/// <para> +/// So the baseline side here is the code a caller would otherwise have written — the same pattern +/// matched by hand, and the same throw on failure. Read that way the ratio answers the question a +/// caller actually has: <i>I was going to validate this anyway, so what does routing it through +/// the type cost me on top?</i> The answer separates into the validation both sides pay and the +/// per-call reflection only one side does. +/// </para> +/// <para> +/// <b>The pattern is duplicated on purpose.</b> <c>StringSpecimens.UuidPattern</c> is the same +/// string <c>IsUuidAttribute</c> holds. If the two ever drift the comparison stops being one, so +/// it is worth saying here: the constant exists to be kept identical, not to be tuned. +/// </para> +/// <para> +/// <b>The last two categories are fair pairs in the quantities sense</b> and are expected near +/// 1.00, because a semantic string's equality and ordering are the underlying string's own. +/// </para> +/// <para> +/// <b>Why these are loops.</b> A single call over an operand that does not change is +/// loop-invariant and the JIT hoists it, and a ratio between two hoisted methods means nothing. +/// Each iteration here feeds the next through an accumulator, so there is nothing to hoist and +/// both sides of a pair stay measurable. Both sides also pay the same counter and branch, which +/// pulls the ratio toward 1.00 rather than away from it, so a ratio above 1.00 is a floor on the +/// real cost rather than the whole of it. +/// </para> +/// </remarks> +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class StringAbstractionCostBenchmarks +{ + /// <summary> + /// Operations per invocation. Enough that the loop's own cost is a small share of the work. + /// </summary> + private const int Operations = 64; + + private string text = ""; + private string rejected = ""; + private string pattern = ""; + private Uuid left = null!; + private Uuid right = null!; + + /// <summary>Prepares both sides of every pair.</summary> + [GlobalSetup] + public void Setup() + { + text = StringSpecimens.UuidText; + rejected = StringSpecimens.UuidRejected; + pattern = StringSpecimens.UuidPattern; + left = Uuid.Create(StringSpecimens.UuidText); + right = Uuid.Create(StringSpecimens.UuidText); + } + + /// <summary>Validating at a boundary by hand, throwing on rejection.</summary> + /// <returns>The accumulated length, returned so nothing here is dead code.</returns> + [BenchmarkCategory("Validate")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareValidate() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (!Regex.IsMatch(text, pattern, RegexOptions.None, TimeSpan.FromSeconds(1))) + { + throw new ArgumentException("unreachable: the specimen is valid"); + } + + accumulator += text.Length; + } + + return accumulator; + } + + /// <summary>Validating at a boundary through the type.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("Validate")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticValidate() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += Uuid.Create(text).Length; + } + + return accumulator; + } + + /// <summary>Rejecting by hand without throwing.</summary> + /// <returns>The count of rejections, which is every iteration.</returns> + [BenchmarkCategory("Reject")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareReject() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (!Regex.IsMatch(rejected, pattern, RegexOptions.None, TimeSpan.FromSeconds(1))) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Rejecting through the type without throwing.</summary> + /// <returns>The count of rejections, which is every iteration.</returns> + [BenchmarkCategory("Reject")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticReject() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (!Uuid.TryCreate(rejected, out _)) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Ordinal equality on the bare strings.</summary> + /// <returns>The count of matches, which is every iteration.</returns> + [BenchmarkCategory("Equality")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareEquality() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (string.Equals(left.WeakString, right.WeakString, StringComparison.Ordinal)) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Record equality on the semantic values holding those strings.</summary> + /// <returns>The count of matches, which is every iteration.</returns> + [BenchmarkCategory("Equality")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticEquality() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (left == right) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Ordering on the bare strings.</summary> + /// <returns>The accumulated comparison results.</returns> + [BenchmarkCategory("Ordering")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareOrdering() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += string.CompareOrdinal(left.WeakString, right.WeakString); + } + + return accumulator; + } + + /// <summary>Ordering on the semantic values.</summary> + /// <returns>The accumulated comparison results.</returns> + [BenchmarkCategory("Ordering")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticOrdering() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += left.CompareTo(right); + } + + return accumulator; + } +} +``` + +- [ ] **Step 2: Run the class and read the ratios** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter '*StringAbstractionCostBenchmarks*' --job short +``` + +Expected: eight rows in four categories, each category showing a `Ratio` column with the bare row at 1.00. + +**Record the four ratios and the four allocation figures. Task 11 writes them into the benchmark README verbatim, so copy them rather than rounding from memory.** + +Sanity expectations, to be reported if violated rather than silently accepted: +- `Validate` and `Reject` ratios above 1.00, because the semantic side does the same regular expression plus reflection. +- `Equality` and `Ordering` ratios near 1.00. +- The semantic side of `Validate` allocates and the bare side does not, because a semantic string is a reference type. + +- [ ] **Step 3: Commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs +git commit -m "Pair the string types against hand-written validation + +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." +``` + +--- + +### Task 5: Path benchmarks + +**Files:** +- Create: `Semantics.Benchmarks/Paths/PathSpecimens.cs` +- Create: `Semantics.Benchmarks/Paths/PathCreationBenchmarks.cs` +- Create: `Semantics.Benchmarks/Paths/PathOperationBenchmarks.cs` +- Test: no test file. Verification is the `--job short` run in Step 4. + +**Interfaces:** +- Consumes: the package references added in Task 2 Step 1. +- Produces: `internal static class PathSpecimens` with `internal static readonly string` members `AbsoluteFile`, `AbsoluteDirectory`, `RelativeFile`, and `internal const string FileNameOnly`. Task 6 uses all of them. Benchmark keys `PathCreationBenchmarks.AbsoluteFilePath`, `.RelativeFilePath`, `.FileNameType`, `.AbsoluteDirectoryPath`, and `PathOperationBenchmarks.FileName`, `.FileNameWithoutExtension`, `.DirectoryPath`, `.AsAbsolute`, `.AsRelative`, `.RemoveExtension`, which Task 7 draws. + +- [ ] **Step 1: Write the specimens file** + +The portability hazard is the substance of this step. `IsAbsolutePathAttribute` validates through `Path.IsPathFullyQualified`, which is operating-system dependent: `C:\...` is fully qualified on Windows and is a relative path on Linux. A hardcoded Windows path would make every absolute-path benchmark throw in CI while passing locally. So the root is chosen per platform. + +Create `Semantics.Benchmarks/Paths/PathSpecimens.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using System; +using System.IO; + +/// <summary> +/// The path strings the path benchmarks run against. +/// </summary> +/// <remarks> +/// <para> +/// <b>The absolute ones are built per platform, and they have to be.</b> +/// <c>IsAbsolutePathAttribute</c> validates through <c>Path.IsPathFullyQualified</c>, whose answer +/// depends on the operating system: <c>C:\x</c> is fully qualified on Windows and is an ordinary +/// relative path on Linux. A hardcoded Windows root would throw from every absolute benchmark on +/// the CI runner while passing on a developer's machine, which is the worst way for this to fail. +/// </para> +/// <para> +/// The two roots differ by two characters in length, so a measurement taken on Windows is not +/// exactly a measurement taken on Linux. That is smaller than the difference between CI hosts that +/// <c>BaselineBenchmarks</c> already exists to normalize, and it is why the history records a +/// baseline reading alongside every entry. +/// </para> +/// <para> +/// Nothing here touches the filesystem, and none of these paths needs to exist. The benchmarks +/// deliberately avoid <c>IsDirectory</c> and <c>IsFile</c>, which call <c>Directory.Exists</c> and +/// <c>File.Exists</c> and would measure the disk rather than the library. +/// </para> +/// </remarks> +internal static class PathSpecimens +{ + private static readonly string Root = + OperatingSystem.IsWindows() ? @"C:\projects" : "/projects"; + + /// <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"); + + /// <summary>A relative file path. Forward slashes are accepted on both platforms.</summary> + internal static readonly string RelativeFile = "Semantics.Paths/FilePath.cs"; + + /// <summary>A bare file name, with no separator in it.</summary> + internal const string FileNameOnly = "FilePath.cs"; +} +``` + +- [ ] **Step 2: Write the creation benchmarks** + +Create `Semantics.Benchmarks/Paths/PathCreationBenchmarks.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using BenchmarkDotNet.Attributes; + +using ktsu.Semantics.Paths; + +/// <summary> +/// Measures building each of the path types from a well-formed string. +/// </summary> +/// <remarks> +/// A path type is a semantic string whose validator asks the runtime a question about the shape of +/// the value, so each row is the same reflection machinery +/// <c>StringCreationBenchmarks.Unvalidated</c> measures plus one such question. The absolute rows +/// ask <c>Path.IsPathFullyQualified</c> and the relative row asks its negation, so the pair is the +/// same work in both directions rather than two different validators. +/// </remarks> +[MemoryDiagnoser] +public class PathCreationBenchmarks +{ + private string absoluteFile = ""; + private string absoluteDirectory = ""; + private string relativeFile = ""; + private string fileName = ""; + + /// <summary>Copies the inputs into fields.</summary> + [GlobalSetup] + public void Setup() + { + absoluteFile = PathSpecimens.AbsoluteFile; + absoluteDirectory = PathSpecimens.AbsoluteDirectory; + relativeFile = PathSpecimens.RelativeFile; + fileName = PathSpecimens.FileNameOnly; + } + + /// <summary>Builds a fully qualified file path.</summary> + /// <returns>The created path.</returns> + [Benchmark] + public AbsoluteFilePath AbsoluteFilePath() => + Semantics.Paths.AbsoluteFilePath.Create(absoluteFile); + + /// <summary>Builds a relative file path.</summary> + /// <returns>The created path.</returns> + [Benchmark] + public RelativeFilePath RelativeFilePath() => + Semantics.Paths.RelativeFilePath.Create(relativeFile); + + /// <summary>Builds a fully qualified directory path.</summary> + /// <returns>The created path.</returns> + [Benchmark] + public AbsoluteDirectoryPath AbsoluteDirectoryPath() => + Semantics.Paths.AbsoluteDirectoryPath.Create(absoluteDirectory); + + /// <summary>Builds a bare file name, whose validator checks for separators.</summary> + /// <returns>The created file name.</returns> + [Benchmark] + public FileName FileNameType() => FileName.Create(fileName); +} +``` + +If the compiler objects to a benchmark method sharing a name with its return type, qualify the call as written above. The fully qualified form `Semantics.Paths.AbsoluteFilePath.Create` is there for exactly that reason and should be kept even if a shorter form happens to compile. + +- [ ] **Step 3: Write the operation benchmarks** + +Create `Semantics.Benchmarks/Paths/PathOperationBenchmarks.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using BenchmarkDotNet.Attributes; + +using ktsu.Semantics.Paths; + +/// <summary> +/// Measures the operations a path type offers over the string it holds. +/// </summary> +/// <remarks> +/// <para> +/// <b>Two rows are here to be read against each other.</b> +/// <see cref="FileNameWithoutExtension"/> caches into a field on the path, so the second read and +/// every read after it is a field read. <see cref="FileName"/> does not: it builds a fresh +/// <c>FileName</c>, validation included, on every single read. Both are properties, both read like +/// field access at a call site, and they differ by the cost of a semantic string creation. Putting +/// them side by side is what makes that visible, and it is the kind of thing a release could +/// quietly change in either direction. +/// </para> +/// <para> +/// <b>Both conversions are measured in the direction that does work.</b> +/// <c>AbsoluteFilePath.AsAbsolute()</c> returns <c>this</c> and would measure nothing, so the +/// absolute direction is taken from a relative path and the relative direction from an absolute +/// one. +/// </para> +/// <para> +/// Nothing here touches the filesystem. <c>IsDirectory</c> and <c>IsFile</c> are excluded on +/// purpose: they call <c>Directory.Exists</c> and <c>File.Exists</c>, so they would measure the +/// disk and the state of the machine rather than this library. +/// </para> +/// </remarks> +[MemoryDiagnoser] +public class PathOperationBenchmarks +{ + private AbsoluteFilePath absoluteFile = null!; + private RelativeFilePath relativeFile = null!; + private AbsoluteDirectoryPath baseDirectory = null!; + + /// <summary>Builds the operands once, outside the measurement.</summary> + [GlobalSetup] + public void Setup() + { + absoluteFile = AbsoluteFilePath.Create(PathSpecimens.AbsoluteFile); + relativeFile = RelativeFilePath.Create(PathSpecimens.RelativeFile); + baseDirectory = AbsoluteDirectoryPath.Create(PathSpecimens.AbsoluteDirectory); + } + + /// <summary>Reads the file name, which builds and validates a new one every time.</summary> + /// <returns>The file name.</returns> + [Benchmark] + public FileName FileName() => absoluteFile.FileName; + + /// <summary>Reads the stem, which is cached into a field after the first read.</summary> + /// <returns>The file name without its extension.</returns> + [Benchmark] + public FileName FileNameWithoutExtension() => absoluteFile.FileNameWithoutExtension; + + /// <summary>Reads the containing directory, which builds and validates a new path.</summary> + /// <returns>The directory path.</returns> + [Benchmark] + public DirectoryPath DirectoryPath() => absoluteFile.DirectoryPath; + + /// <summary>Resolves a relative path against a base directory.</summary> + /// <returns>The resolved absolute path.</returns> + [Benchmark] + public AbsoluteFilePath AsAbsolute() => relativeFile.AsAbsolute(baseDirectory); + + /// <summary>Expresses an absolute path relative to a base directory.</summary> + /// <returns>The relative path.</returns> + [Benchmark] + public RelativeFilePath AsRelative() => absoluteFile.AsRelative(baseDirectory); + + /// <summary>Strips the extension, which rebuilds and revalidates the whole path.</summary> + /// <returns>The path without its extension.</returns> + [Benchmark] + public AbsoluteFilePath RemoveExtension() => absoluteFile.RemoveExtension(); +} +``` + +Three methods here share a name with their own return type (`FileName`, `DirectoryPath`). C# resolves +that, because method names do not participate in type-name lookup, but if the compiler does object, +qualify the return type as `Semantics.Paths.FileName` rather than renaming the method: Task 7 draws +these by name and a rename there would silently produce empty panels. + +- [ ] **Step 4: Run both classes and read the summaries** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter '*PathCreationBenchmarks*' '*PathOperationBenchmarks*' --job short +``` + +Expected: four rows and six rows, all with measurements. + +**Check for and report:** +- Any `NA` row means the benchmark threw. On a path benchmark the near-certain cause is a specimen that is not valid for its type on this operating system. Fix the specimen in `PathSpecimens`, and re-read the remarks in that file before doing so. +- `FileNameWithoutExtension` should be dramatically cheaper than `FileName`, because the first is a field read after the first call and the second is a full creation. **If they are close, the caching is not doing what the class documentation claims, which is a finding to report rather than a benchmark to adjust.** +- `FileNameWithoutExtension` is the row most likely to report `ZeroMeasurement`, being a field read. If it does, report it: Task 7 draws it, and the panel choice needs revisiting. + +- [ ] **Step 5: Commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add Semantics.Benchmarks/Paths/ +git commit -m "Measure the path types, building and operating + +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." +``` + +--- + +### Task 6: Path cost pairs + +**Files:** +- Create: `Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs` +- Test: no test file. Verification is the `--job short` run in Step 2. + +**Interfaces:** +- Consumes: `PathSpecimens.AbsoluteFile`, `.AbsoluteDirectory`, `.RelativeFile` from Task 5. +- Produces: a `Ratio` column per category, which Task 11 copies into `Semantics.Benchmarks/README.md`. Nothing later depends on the benchmark keys. + +- [ ] **Step 1: Write the class** + +Create `Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs`: + +```csharp +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using System.IO; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +using ktsu.Semantics.Paths; + +/// <summary> +/// Measures what the path types cost over <see cref="Path"/> doing the same work. +/// </summary> +/// <remarks> +/// <para> +/// This pairing needs none of the care the string one does. <see cref="Path"/> is a real API doing +/// the real work, so each category is the same operation twice and the ratio is a straight answer. +/// </para> +/// <para> +/// What the semantic side adds is a validated wrapper around the result: every one of these +/// operations returns a path type rather than a string, which means a creation, which means the +/// reflection machinery and the validator. The ratio is therefore expected well above 1.00 +/// throughout, and the number is the point rather than a disappointment — it is what a caller pays +/// for a result that cannot be silently passed where a different kind of path belongs. +/// </para> +/// <para> +/// The loops exist for the reason they do everywhere in this suite: a single call over an +/// unchanging operand is loop-invariant, the JIT hoists it, and a ratio between two hoisted +/// methods means nothing. +/// </para> +/// </remarks> +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class PathAbstractionCostBenchmarks +{ + /// <summary>Operations per invocation.</summary> + private const int Operations = 64; + + private string absoluteFileText = ""; + private string relativeFileText = ""; + private string baseDirectoryText = ""; + private AbsoluteFilePath absoluteFile = null!; + private RelativeFilePath relativeFile = null!; + private AbsoluteDirectoryPath baseDirectory = null!; + + /// <summary>Prepares both sides of every pair, holding the same values.</summary> + [GlobalSetup] + public void Setup() + { + absoluteFileText = PathSpecimens.AbsoluteFile; + relativeFileText = PathSpecimens.RelativeFile; + baseDirectoryText = PathSpecimens.AbsoluteDirectory; + + absoluteFile = AbsoluteFilePath.Create(absoluteFileText); + relativeFile = RelativeFilePath.Create(relativeFileText); + baseDirectory = AbsoluteDirectoryPath.Create(baseDirectoryText); + } + + /// <summary>Extracting a file name with the runtime's own helper.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("FileName")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareFileName() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += Path.GetFileName(absoluteFileText).Length; + } + + return accumulator; + } + + /// <summary>Extracting it through the path type, which validates the result.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("FileName")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticFileName() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += absoluteFile.FileName.Length; + } + + return accumulator; + } + + /// <summary>Resolving a relative path with the runtime's own helper.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("AsAbsolute")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareAsAbsolute() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += Path.GetFullPath(relativeFileText, baseDirectoryText).Length; + } + + return accumulator; + } + + /// <summary>Resolving it through the path type.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("AsAbsolute")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticAsAbsolute() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += relativeFile.AsAbsolute(baseDirectory).Length; + } + + return accumulator; + } + + /// <summary>Relativizing with the runtime's own helper.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("AsRelative")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareAsRelative() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += Path.GetRelativePath(baseDirectoryText, absoluteFileText).Length; + } + + return accumulator; + } + + /// <summary>Relativizing through the path type.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("AsRelative")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticAsRelative() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += absoluteFile.AsRelative(baseDirectory).Length; + } + + return accumulator; + } + + /// <summary>Checking a path is rooted by hand, which is what creation validates.</summary> + /// <returns>The count of rooted paths, which is every iteration.</returns> + [BenchmarkCategory("Create")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareCreate() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (Path.IsPathFullyQualified(absoluteFileText)) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Building the path type, which asks the same question and keeps the answer.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("Create")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticCreate() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += AbsoluteFilePath.Create(absoluteFileText).Length; + } + + return accumulator; + } +} +``` + +- [ ] **Step 2: Run the class and read the ratios** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter '*PathAbstractionCostBenchmarks*' --job short +``` + +Expected: eight rows in four categories, each with a `Ratio` column. + +**Record the four ratios and the eight allocation figures for Task 11.** + +Sanity expectation: every ratio above 1.00, because the semantic side does the same work and then wraps the result in a validated type. A ratio at or below 1.00 means the semantic side is being optimized away and should be reported, not accepted. + +- [ ] **Step 3: Build clean and commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet build -c Release Semantics.Benchmarks +git add Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs +git commit -m "Pair the path types against System.IO.Path + +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." +``` + +--- + +### Task 7: Register the two new subjects with the renderer + +**Files:** +- Modify: `scripts/benchmark-history.cs` (the `Subjects` dictionary from Task 1) +- Test: no test file. Verification is a render against a stub history in Step 2, then byte equality of the quantities chart in Step 3. + +**Interfaces:** +- Consumes: the `Subject` and `Panel` records and the `Subjects` dictionary from Task 1; the benchmark keys produced by Tasks 2, 3 and 5. +- Produces: `--subject strings` and `--subject paths` as valid arguments, which Task 8 calls from the workflow. + +- [ ] **Step 1: Add the two entries** + +Before writing these, check the notes recorded in Task 3 Step 2 and Task 5 Step 4. If `CompareTo` reported `ZeroMeasurement`, replace the strings position 8 panel with `new("StringOperationBenchmarks.HashCode", null, "GetHashCode")`. If `FileNameWithoutExtension` reported `ZeroMeasurement`, replace the paths position 5 panel with `new("PathCreationBenchmarks.AbsoluteDirectoryPath", null, "Create (absolute dir)")`. + +Add to the `Subjects` dictionary, after the `quantities` entry: + +```csharp + ["strings"] = new("Semantics.Strings", 4, + [ + new("StringCreationBenchmarks.Unvalidated", null, "Create (no validation)"), + new("StringCreationBenchmarks.CharsetRegex", null, "Create (charset regex)"), + new("StringCreationBenchmarks.FormatRegex", null, "Create (format regex)"), + new("StringCreationBenchmarks.Mod97", null, "Create (mod-97)"), + new("StringCreationBenchmarks.TryCreateRejects", null, "TryCreate (rejects)"), + new("StringCreationBenchmarks.CreateThrows", null, "Create (throws)"), + new("StringOperationBenchmarks.AsConversion", null, "As<T> conversion"), + new("StringOperationBenchmarks.CompareTo", null, "CompareTo"), + ]), + ["paths"] = new("Semantics.Paths", 4, + [ + new("PathCreationBenchmarks.AbsoluteFilePath", null, "Create (absolute file)"), + new("PathCreationBenchmarks.RelativeFilePath", null, "Create (relative file)"), + new("PathCreationBenchmarks.FileNameType", null, "Create (file name)"), + new("PathOperationBenchmarks.FileName", null, "FileName (uncached)"), + new("PathOperationBenchmarks.FileNameWithoutExtension", null, "FileName (cached)"), + new("PathOperationBenchmarks.AsAbsolute", null, "AsAbsolute (from relative)"), + new("PathOperationBenchmarks.AsRelative", null, "AsRelative (from absolute)"), + new("PathOperationBenchmarks.RemoveExtension", null, "RemoveExtension"), + ]), +``` + +Extend the `Subjects` XML documentation with two paragraphs, placed after the existing quantities paragraphs: + +```csharp + /// <para> + /// <b>Strings</b> is drawn along validation weight, because that is the axis there is. A + /// semantic string is a record wrapping a <see cref="string"/> and its cost is concentrated at + /// creation, so the top row walks from the reflection machinery alone up through a character + /// set check, a format check and a mod-97 check. The bottom row is what a caller pays around + /// that: both failure paths, the cross-type conversion that is secretly another creation, and + /// an ordering that should be the underlying string's own. + /// </para> + /// <para> + /// <b>Paths</b> is the same shape: build each kind, then operate on one. The two file name + /// panels sit next to each other because one caches into a field and one rebuilds and + /// revalidates on every read, and both look like field access at a call site. + /// </para> +``` + +- [ ] **Step 2: Verify both subjects render from a stub history** + +The real histories do not exist until Task 9, so render against a stub to prove the panel keys resolve and the layout is sound. + +```bash +cd /c/dev/ktsu-dev/Semantics +cat > "$TMPDIR/stub-history.json" <<'JSON' +{ + "schemaVersion": 1, + "entries": [ + { + "version": "5.3.4", + "commit": "0000000", + "date": "2026-09-18", + "cpu": "stub", + "runtime": "stub", + "baselineNs": 100.0, + "runId": "stub", + "benchmarks": { + "StringCreationBenchmarks.Unvalidated": { "": { "meanNs": 100.0, "allocatedBytes": 64 } } + } + } + ] +} +JSON +dotnet run scripts/benchmark-history.cs -- render --subject strings \ + --history "$TMPDIR/stub-history.json" --out "$TMPDIR/strings.svg" +dotnet run scripts/benchmark-history.cs -- render --subject paths \ + --history "$TMPDIR/stub-history.json" --out "$TMPDIR/paths.svg" +grep -c "panel-title" "$TMPDIR/strings.svg" "$TMPDIR/paths.svg" +grep -o 'class="title">[^<]*' "$TMPDIR/strings.svg" +``` + +Expected: both renders succeed. Each file contains 16 panel titles, which is eight panels drawn twice, once for allocation and once for time. The title line reads `Semantics.Strings performance by release`. Panels whose key is absent from the stub draw empty, which is the correct behavior for a missing measurement and is what a skipped version looks like. + +- [ ] **Step 3: Re-verify the quantities chart is still byte-identical** + +Adding dictionary entries must not disturb the existing one, but this is cheap and it is the invariant the whole task set rests on. + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run scripts/benchmark-history.cs -- render --subject quantities \ + --history docs/benchmarks/history.json --out docs/benchmarks/performance.svg +git diff --stat docs/benchmarks/ +``` + +Expected: no changes. + +- [ ] **Step 4: Commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add scripts/benchmark-history.cs +git commit -m "Draw the strings and paths charts + +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." +``` + +--- + +### Task 8: Make the workflow run three subjects + +**Files:** +- Modify: `.github/workflows/benchmark-history.yml` +- Test: no test file. Verification is a `yamllint`-free parse check in Step 5 and a local dry run of the loop logic in Step 4. The workflow itself is proven by the first real dispatch, which is out of scope here. + +**Interfaces:** +- Consumes: `render --subject <name>` from Tasks 1 and 7; the benchmark classes from Tasks 2 to 6. +- Produces: nothing later tasks consume. Task 9 and Task 10 run the benchmarks locally rather than through this workflow. + +- [ ] **Step 1: Replace the single-subject environment block** + +Replace the `HISTORY`, `CHART` and `HEADLINE_FILTER` entries in the workflow's `env:` block with a single subject table. Keep `DOTNET_VERSION`, `RUNS` and `BENCHMARK_JOB` exactly as they are. + +```yaml +env: + DOTNET_VERSION: "10.0" + # Already ignored, and ktsu.Sdk regenerates .gitignore on build so a new entry would not last. + RUNS: BenchmarkDotNet.Artifacts + # Short runs: three iterations is enough for a trend line, and a release should not tie up a + # runner for half an hour. + BENCHMARK_JOB: short + # One line per chart: name|history|chart|filter. An environment variable cannot hold an array, + # and three steps need the same three triples, so this is the one place they are written. + # + # Each filter is one operation per panel drawn. What varies between subjects is the axis: a + # quantity is a value type over T and does almost nothing of its own, so its release changes land + # per storage type; a semantic string spends its cost at creation, so its axis is how much + # validation the type declares. + SUBJECTS: | + quantities|docs/benchmarks/history.json|docs/benchmarks/performance.svg|*ConstructionBenchmarks*FromNauticalMile *UnitConversionBenchmarks*InNauticalMile *OperatorBenchmarks*LengthTimesLength *VectorBenchmarks*.Length *ComparisonBenchmarks*CompareToInterface + strings|docs/benchmarks/strings-history.json|docs/benchmarks/strings-performance.svg|*StringCreationBenchmarks* *StringOperationBenchmarks* + paths|docs/benchmarks/paths-history.json|docs/benchmarks/paths-performance.svg|*PathCreationBenchmarks* *PathOperationBenchmarks* +``` + +The strings and paths filters name whole classes rather than individual methods, because those classes are small and every method in them is wanted in the history. The quantities filter stays method-by-method, unchanged, because its classes are generic over four storage types and naming a whole class would multiply the run by four for panels nobody draws. + +- [ ] **Step 2: Add the `subjects` dispatch input and raise the timeout** + +In `workflow_dispatch.inputs`, after the existing `versions` input: + +```yaml + subjects: + description: "Space-separated subjects to measure: quantities strings paths" + required: false + default: "quantities strings paths" + type: string +``` + +Change `timeout-minutes: 240` to: + +```yaml + # Three subjects rather than one. The dispatch's `subjects` input is the intended way to split + # a long backfill across runs; raising this number further is not. + timeout-minutes: 360 +``` + +- [ ] **Step 3: Loop the subject table in the three measuring and rendering steps** + +In the release step, replace the single `dotnet run` and `ingest` pair with a loop inside the existing worktree. The worktree is created once and removed once, exactly as now: + +```bash + set -euo pipefail + version="${TAG#v}" + work="${RUNNER_TEMP}/bench-$version" + git worktree add --detach "$work" "$TAG" + + while IFS='|' read -r subject history chart filter; do + [ -n "$subject" ] || continue + echo "::group::$subject $version" + (cd "$work" && dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter $filter \ + --job "$BENCHMARK_JOB" \ + --artifacts "$GITHUB_WORKSPACE/$RUNS/$subject/$version") + + dotnet run scripts/benchmark-history.cs -- ingest \ + --history "$history" \ + --results "$RUNS/$subject/$version" \ + --version "$version" \ + --commit "$(git rev-parse --short "$TAG^{commit}")" \ + --date "$(git log -1 --format=%cs "$TAG")" \ + --run-id "${{ github.run_id }}" \ + --baseline-ns "${{ steps.baseline.outputs.ns }}" + echo "::endgroup::" + done <<< "$SUBJECTS" + + git worktree remove --force "$work" +``` + +In the backfill step, nest the subject loop inside the existing version loop, so one `BenchmarkAgainstVersion` build serves all three subjects at that version. Keep both existing skip paths, now scoped per subject per version rather than per version, which is what lets the strings chart reach back further than the quantities one: + +```bash + set -euo pipefail + read -ra versions <<< "$VERSIONS" + read -ra wanted <<< "$SUBJECTS_WANTED" + + for version in "${versions[@]}"; do + while IFS='|' read -r subject history chart filter; do + [ -n "$subject" ] || continue + # Skip a subject the dispatch did not ask for. + case " ${wanted[*]} " in *" $subject "*) ;; *) continue ;; esac + + echo "::group::$subject $version" + # Through the environment rather than a -p: switch, because BenchmarkDotNet generates + # and builds a project of its own per run, which a property passed on the command line + # does not reach. MSBuild reads environment variables as properties in every project. + if ! BenchmarkAgainstVersion="$version" dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter $filter \ + --job "$BENCHMARK_JOB" \ + --artifacts "$GITHUB_WORKSPACE/$RUNS/$subject/$version"; then + echo "::warning::$subject $version could not be benchmarked by the current suite; skipping" + echo "::endgroup::" + continue + fi + + tag="v$version" + commit="" + date="" + if git rev-parse -q --verify "$tag^{commit}" >/dev/null; then + commit="$(git rev-parse --short "$tag^{commit}")" + date="$(git log -1 --format=%cs "$tag")" + fi + + # Skipped here too: a package can build against these benchmarks and still throw from + # every one of them at run time, which BenchmarkDotNet reports as a table of NA rather + # than as a failure. Ingest refuses such a run, and the backfill carries on. + if ! dotnet run scripts/benchmark-history.cs -- ingest \ + --history "$history" \ + --results "$RUNS/$subject/$version" \ + --version "$version" \ + --commit "$commit" \ + --date "$date" \ + --run-id "${{ github.run_id }}" \ + --baseline-ns "$BASELINE_NS"; then + echo "::warning::$subject $version produced no usable measurement; skipping" + fi + echo "::endgroup::" + done <<< "$SUBJECTS" + done +``` + +Add `SUBJECTS_WANTED` to that step's `env:` block alongside the existing `VERSIONS` and `BASELINE_NS`: + +```yaml + SUBJECTS_WANTED: ${{ inputs.subjects }} +``` + +Replace the render step's single command with a loop: + +```yaml + - name: Redraw the charts + shell: bash + run: | + set -euo pipefail + while IFS='|' read -r subject history chart filter; do + [ -n "$subject" ] || continue + if [ ! -f "$history" ]; then + echo "No history for $subject yet; nothing to draw." + continue + fi + dotnet run scripts/benchmark-history.cs -- render \ + --subject "$subject" --history "$history" --out "$chart" + done <<< "$SUBJECTS" +``` + +- [ ] **Step 4: Widen the commit step's staging** + +The current step stages one history and one chart stem. It stages all of them: + +```bash + git add docs/benchmarks/ +``` + +The whole directory rather than an enumerated list, because the enumeration would be a fourth place the subject table is written down and would silently drop a chart if it fell out of step. Nothing else lives in that directory. + +- [ ] **Step 5: Verify the loop logic locally** + +The workflow cannot be run locally, but the parsing can, which is where the real risk is. This catches a mistyped separator or a filter that splits wrongly. + +```bash +cd /c/dev/ktsu-dev/Semantics +SUBJECTS=$(sed -n '/^ SUBJECTS: |$/,/^[a-zA-Z]/p' .github/workflows/benchmark-history.yml \ + | sed '1d;$d' | sed 's/^ //') +while IFS='|' read -r subject history chart filter; do + [ -n "$subject" ] || continue + echo "subject=[$subject] history=[$history] chart=[$chart]" + echo " filter=[$filter]" +done <<< "$SUBJECTS" +``` + +Expected: three lines, each with a non-empty subject, a history path under `docs/benchmarks/`, a chart path under `docs/benchmarks/`, and a filter containing at least one `*`. If any field is empty or a filter has leaked into the chart field, a separator is wrong. + +- [ ] **Step 6: Verify the workflow file parses as YAML** + +```bash +cd /c/dev/ktsu-dev/Semantics +python3 -c "import yaml,sys; yaml.safe_load(open('.github/workflows/benchmark-history.yml')); print('ok')" +``` + +Expected: `ok`. If `python3` is unavailable, use `gh workflow view benchmark-history.yml` or push the branch and let GitHub's own parser report, but do not skip the check. + +- [ ] **Step 7: Update the workflow's header comment** + +The comment block at the top of the file describes a single-subject pipeline. Extend it rather than rewriting it, keeping every existing paragraph, and add: + +```yaml +# Three subjects share one job: quantities, strings, and paths. One job rather than a matrix so +# that the reference workload is measured once and stamped on every entry the run produces -- which +# is what lets a strings point and a quantities point from the same run be compared at all. It also +# keeps the results to a single push. +# +# The cost is wall clock, and `subjects` on the dispatch is the answer to that: a long backfill is +# split by subject across runs rather than by raising the timeout. +``` + +- [ ] **Step 8: Commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add .github/workflows/benchmark-history.yml +git commit -m "Measure three subjects per run + +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." +``` + +--- + +### Task 9: Seed one version and check the numbers + +The gate before the long run. Its purpose is to find out whether the measurements mean anything *before* spending hours producing more of them. + +**Files:** +- Create: `docs/benchmarks/strings-history.json`, `docs/benchmarks/paths-history.json` +- Create: `docs/benchmarks/strings-performance.svg`, `docs/benchmarks/strings-performance-dark.svg`, `docs/benchmarks/paths-performance.svg`, `docs/benchmarks/paths-performance-dark.svg` +- Test: no test file. The check is the expectations table in Step 4. + +**Interfaces:** +- Consumes: everything from Tasks 1 to 7. +- Produces: the two history files Task 10 appends to. + +- [ ] **Step 1: Measure the reference workload** + +Every entry needs a baseline reading from the same machine, exactly as the workflow takes one. + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter '*BaselineBenchmarks.ReferenceWork' --job short \ + --artifacts "$PWD/BenchmarkDotNet.Artifacts/baseline" +dotnet run scripts/benchmark-history.cs -- baseline --results BenchmarkDotNet.Artifacts/baseline +``` + +Expected: a single number in nanoseconds. **Record it. Every ingest in this task and Task 10 passes the same value**, because they all run on this one machine. + +- [ ] **Step 2: Measure both subjects at the working copy** + +The working copy is 5.3.4 plus this branch's changes, and none of those changes touch the libraries being measured, so it is a fair stand-in for the released 5.3.4. + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter '*StringCreationBenchmarks*' '*StringOperationBenchmarks*' --job short \ + --artifacts "$PWD/BenchmarkDotNet.Artifacts/strings/5.3.4" +dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter '*PathCreationBenchmarks*' '*PathOperationBenchmarks*' --job short \ + --artifacts "$PWD/BenchmarkDotNet.Artifacts/paths/5.3.4" +``` + +- [ ] **Step 3: Ingest both** + +Substitute the baseline number from Step 1 for `<NS>`. + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run scripts/benchmark-history.cs -- ingest \ + --history docs/benchmarks/strings-history.json \ + --results BenchmarkDotNet.Artifacts/strings/5.3.4 \ + --version 5.3.4 --commit "$(git rev-parse --short v5.3.4^{commit})" \ + --date "$(git log -1 --format=%cs v5.3.4)" --run-id local-seed --baseline-ns <NS> + +dotnet run scripts/benchmark-history.cs -- ingest \ + --history docs/benchmarks/paths-history.json \ + --results BenchmarkDotNet.Artifacts/paths/5.3.4 \ + --version 5.3.4 --commit "$(git rev-parse --short v5.3.4^{commit})" \ + --date "$(git log -1 --format=%cs v5.3.4)" --run-id local-seed --baseline-ns <NS> +``` + +Expected: each prints `ingested 5.3.4: N benchmarks, baseline <NS> ns, cpu ...`. + +- [ ] **Step 4: Check the numbers against expectations stated in advance** + +**This is the gate. Do not proceed to Task 10 until every line holds, and report any that does not rather than adjusting the expectation to fit the number.** + +| Expectation | Why it must hold | +|---|---| +| `StringCreationBenchmarks.Unvalidated` is the fastest creation row | It is the reflection machinery with no validator. Anything faster means another row is not running its validator. | +| `StringCreationBenchmarks.Mod97` is the slowest creation row | `Iban` is the heaviest shipped validator. | +| `CreateThrows` costs far more than `TryCreateRejects` | A .NET exception throw and catch is orders of magnitude above a regular expression match. If they are close, the throw is not happening, which means the specimen is being accepted. | +| Every string creation row allocates more than 0 bytes | A semantic string is a reference type. A zero here means the allocation is not being counted and `[MemoryDiagnoser]` is missing or the row did not run. | +| `PathOperationBenchmarks.FileNameWithoutExtension` is far cheaper than `.FileName` | One caches into a field, the other rebuilds and revalidates. | +| Every path row allocates more than 0 bytes except `FileNameWithoutExtension` | Same reasoning, and the cached one returns an existing reference. | + +```bash +cd /c/dev/ktsu-dev/Semantics +python3 - <<'PY' +import json +for name in ("strings", "paths"): + with open(f"docs/benchmarks/{name}-history.json") as handle: + entry = json.load(handle)["entries"][-1] + print(f"--- {name} {entry['version']} baseline {entry['baselineNs']} ns ---") + for key, cases in sorted(entry["benchmarks"].items()): + for _, measurement in cases.items(): + print(f" {key:60s} {measurement['meanNs']:12.2f} ns {measurement['allocatedBytes']:6d} B") +PY +``` + +- [ ] **Step 5: Render both charts and look at them** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run scripts/benchmark-history.cs -- render --subject strings \ + --history docs/benchmarks/strings-history.json --out docs/benchmarks/strings-performance.svg +dotnet run scripts/benchmark-history.cs -- render --subject paths \ + --history docs/benchmarks/paths-history.json --out docs/benchmarks/paths-performance.svg +ls -la docs/benchmarks/ +``` + +Expected: six SVG files in the directory, the two new light charts and their two dark counterparts alongside the two quantities ones. A one-point chart draws a single marker per panel, which is correct and will fill in as Task 10 adds versions. + +- [ ] **Step 6: Commit the seed** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add docs/benchmarks/ +git status --short +git commit -m "Seed the strings and paths histories at 5.3.4 + +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." +``` + +--- + +### Task 10: Backfill the remaining versions + +**Files:** +- Modify: `docs/benchmarks/strings-history.json`, `docs/benchmarks/paths-history.json`, and the four new SVG files +- Test: no test file. Verification is the entry count and the per-version skip report in Step 3. + +**Interfaces:** +- Consumes: the histories from Task 9, and the baseline nanosecond figure recorded in Task 9 Step 1. +- Produces: filled histories and charts that Task 11 describes in the README. + +- [ ] **Step 1: Run the backfill** + +The same version list the quantities workflow defaults to, minus 5.3.4 which Task 9 already seeded. Substitute the Task 9 baseline figure for `<NS>`. This is the long step. Leave the machine otherwise idle. + +```bash +cd /c/dev/ktsu-dev/Semantics +BASELINE_NS=<NS> +for version in 3.3.1 4.0.0 4.1.0 4.2.0 4.3.2 5.0.0 5.1.0 5.2.0 5.2.4 5.3.2; do + for subject in strings paths; do + case "$subject" in + strings) filter="*StringCreationBenchmarks* *StringOperationBenchmarks*" ;; + paths) filter="*PathCreationBenchmarks* *PathOperationBenchmarks*" ;; + esac + echo "=== $subject $version ===" + if ! BenchmarkAgainstVersion="$version" dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter $filter --job short \ + --artifacts "$PWD/BenchmarkDotNet.Artifacts/$subject/$version"; then + echo "SKIP: $subject $version could not be benchmarked by the current suite" + continue + fi + if ! dotnet run scripts/benchmark-history.cs -- ingest \ + --history "docs/benchmarks/$subject-history.json" \ + --results "BenchmarkDotNet.Artifacts/$subject/$version" \ + --version "$version" \ + --commit "$(git rev-parse --short "v$version^{commit}" 2>/dev/null || echo '')" \ + --date "$(git log -1 --format=%cs "v$version" 2>/dev/null || echo '')" \ + --run-id local-seed --baseline-ns "$BASELINE_NS"; then + echo "SKIP: $subject $version produced no usable measurement" + fi + done +done +``` + +- [ ] **Step 2: Render both charts** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet run scripts/benchmark-history.cs -- render --subject strings \ + --history docs/benchmarks/strings-history.json --out docs/benchmarks/strings-performance.svg +dotnet run scripts/benchmark-history.cs -- render --subject paths \ + --history docs/benchmarks/paths-history.json --out docs/benchmarks/paths-performance.svg +``` + +- [ ] **Step 3: Report what was measured and what was skipped** + +```bash +cd /c/dev/ktsu-dev/Semantics +python3 - <<'PY' +import json +for name in ("strings", "paths"): + with open(f"docs/benchmarks/{name}-history.json") as handle: + entries = json.load(handle)["entries"] + versions = [e["version"] for e in entries] + print(f"{name}: {len(entries)} entries -> {', '.join(versions)}") +PY +``` + +**Report the skipped versions and their reasons.** A version skipped because the older package's API cannot express today's benchmarks is expected and fine, and is exactly why the workflow warns rather than fails. `Semantics.Strings` has been more stable than the quantities package, so the strings history may well reach further back than `docs/benchmarks/history.json` does, which is a result worth stating in Task 11 rather than hiding. + +- [ ] **Step 4: Commit** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add docs/benchmarks/ +git commit -m "Backfill the strings and paths histories + +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." +``` + +--- + +### Task 11: Documentation + +**Files:** +- Modify: `README.md` (the `## Performance` section, currently at lines 148-176) +- Modify: `Semantics.Benchmarks/README.md` (headings throughout, plus two new sections) +- Modify: `CLAUDE.md` (the project layout table, plus a new subsection) +- Test: no test file. Verification is the link and image check in Step 5. + +**Interfaces:** +- Consumes: the ratio tables recorded in Task 4 Step 2 and Task 6 Step 2; the version coverage reported in Task 10 Step 3. +- Produces: nothing. This is the last task. + +- [ ] **Step 1: Restructure the README performance section** + +Replace the whole `## Performance` section. The two paragraphs about how to read the charts are stated once above the three subsections rather than three times. + +```markdown +## Performance + +Every release measures a fixed set of benchmarks and adds a point to a chart per library. The numbers +behind them are in [`docs/benchmarks/`](docs/benchmarks/), and the suite is +[`Semantics.Benchmarks`](Semantics.Benchmarks/README.md). + +Read the two halves of every chart differently. **Allocation is exact** — the same code allocates the +same bytes on any machine, so a step in the top row is always a real change. **Time is measured on +shared CI runners**, where the host a job happens to land on varies more than most releases do, so +each time is divided by a reference workload measured in the same job. That cancels most of the +difference between machines; what is left is indicative rather than precise. + +### Quantities + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="docs/benchmarks/performance-dark.svg"> + <img alt="Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Quantities release" src="docs/benchmarks/performance.svg"> +</picture> + +The grid is one operation per storage type rather than every operation at one storage type. A quantity +is a `readonly record struct` over its `T` and does almost nothing of its own — a value is held in the +SI base unit, so an operator is the storage type's arithmetic and a struct initialiser — so the same +line of user code costs different things depending on the `T` it was written against, and a release +changes it per `T`. + +### Strings + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="docs/benchmarks/strings-performance-dark.svg"> + <img alt="Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Strings release" src="docs/benchmarks/strings-performance.svg"> +</picture> + +The axis here is validation weight, because that is where a semantic string spends. `Create` goes +through `Activator.CreateInstance`, a `PropertyInfo.SetValue`, and a reflective walk of the type's +validation attributes on every call, so the top row walks from that machinery alone up through a +character set check, a format check, and a mod-97 check. The bottom row is what surrounds it: both +failure paths, the cross-type conversion that is a full creation in disguise, and an ordering that is +the underlying string's own. + +This is a different answer from the quantities one, and worth stating plainly rather than leaving to +be inferred from a chart: a quantity's wrapper is free, and a semantic string's is not. What it buys +is that an invalid value cannot exist, checked once at the boundary instead of everywhere the value +is used. + +### Paths + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="docs/benchmarks/paths-performance-dark.svg"> + <img alt="Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Paths release" src="docs/benchmarks/paths-performance.svg"> +</picture> + +Building each kind of path, then operating on one. The two file name panels sit next to each other +deliberately: `FileNameWithoutExtension` caches into a field, `FileName` rebuilds and revalidates on +every read, and both look like field access at a call site. +``` + +- [ ] **Step 2: Restructure the benchmark suite README** + +Three edits, in order. + +First, insert a `## Quantities` heading immediately before the existing `## The axis that matters here is the storage type` section, and demote that section and everything down to the end of `### An operator on a double is below the floor` by one heading level, so `##` becomes `###` and `###` becomes `####`. The `## Running`, `## Measuring a published release` and `## Reading the results` sections stay at `##` and stay where they are, because they are shared. + +Second, after the quantities material, add `## Strings` and `## Paths` sections. Write them from the class documentation in `StringCreationBenchmarks`, `StringAbstractionCostBenchmarks`, `PathOperationBenchmarks` and `PathAbstractionCostBenchmarks`, and include the two ratio tables using **the real figures recorded in Task 4 Step 2 and Task 6 Step 2**. The tables take this shape: + +```markdown +| pair | ratio | allocation, bare | allocation, semantic | +|---|---|---|---| +| Validate at a boundary | N.NN | 0 B | NNN B | +| Reject without throwing | N.NN | 0 B | NNN B | +| Equality | N.NN | 0 B | 0 B | +| Ordering | N.NN | 0 B | 0 B | +``` + +Each table is followed by a sentence saying what it means, written after reading the numbers rather than predicted here. + +Third, extend the two shared sections. In `## Running`, add a paragraph reading "The suite covers +three libraries, and `--filter` is how one is picked:" followed by a fenced `bash` block containing +these two lines: + +````text +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*String*Benchmarks*' +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*Path*Benchmarks*' +```` + +In `## Measuring a published release`, note that `BenchmarkAgainstVersion` swaps all four packages — `Quantities`, `Strings`, `Strings.Identifiers` and `Paths` — at the one version, which is correct because this repository ships one version across every package. + +- [ ] **Step 3: Add the benchmark project to the CLAUDE.md layout table** + +In the project layout table, after the `Semantics.Quantities.{Double,Float,Decimal,Precise}` row, add: + +```markdown +| `Semantics.Benchmarks` | BenchmarkDotNet suite covering quantities, strings and paths. Not shipped and not covered by tests, so it carries `SonarQubeExclude`. Feeds the per-release charts in `docs/benchmarks/`. | +``` + +- [ ] **Step 4: Add the CLAUDE.md benchmarks subsection** + +Add after the "Conversion factors and square roots at the storage type's precision" section: + +```markdown +### Benchmarks and the release charts + +`Semantics.Benchmarks` measures three libraries. `.github/workflows/benchmark-history.yml` runs a +fixed set once per release, appends to a history file per subject under `docs/benchmarks/`, and +redraws the chart the README shows. Three things about it are not guessable from the code: + +- **`BenchmarkAgainstVersion` must be set in the environment, never with `-p:`.** BenchmarkDotNet + generates and builds a project of its own for each run, which a property passed on the command line + never reaches: the benchmark assembly would build against the version asked for and the harness + against the one pinned centrally, which fails to compile if a type changed shape between them. + MSBuild reads environment variables as properties in every project, so the environment form reaches + both. It swaps all four shipped packages at once, which is correct because this repository ships one + version across every package. +- **The histories and the SVG files are committed output that a bot pushes.** A local `render` must be + diffed before commit rather than assumed. The quantities chart in particular is a regression test: + a change to the renderer that moves a byte in it has broken something. +- **Nothing here touches an internal member.** The `InternalsVisibleTo` that would expose one names + only the test assembly, and a benchmark built on internals could only ever measure the working copy, + never a published package — which would make the release history impossible to backfill. + +`BaselineBenchmarks.ReferenceWork` measures a fixed workload that touches none of this library, so +timings from different CI runners can be compared. **Its body must never change.** Editing it silently +rescales every comparison drawn against history recorded before the edit. +``` + +- [ ] **Step 5: Verify every link and image resolves** + +```bash +cd /c/dev/ktsu-dev/Semantics +for target in $(grep -o 'src="docs/benchmarks/[^"]*"\|srcset="docs/benchmarks/[^"]*"' README.md \ + | sed 's/.*="//;s/"//'); do + [ -f "$target" ] && echo "ok $target" || echo "MISSING $target" +done +grep -n "docs/benchmarks/" README.md | head -20 +``` + +Expected: six `ok` lines, no `MISSING`. + +- [ ] **Step 6: Full build, full test, Sonar check** + +```bash +cd /c/dev/ktsu-dev/Semantics +dotnet build +dotnet test +``` + +```powershell +cd C:\dev\ktsu-dev\Semantics +dotnet build -p:CustomAfterMicrosoftCommonProps=$PWD\.sonarlint\sonar-local.props +``` + +Expected: build clean with no warnings, all tests pass, no new Sonar findings. Note that `dotnet test` is not passed `--nologo`, because this repository uses Microsoft.Testing.Platform, where that flag makes the run discover zero tests and exit 5. + +- [ ] **Step 7: Final commit with the version tag** + +```bash +cd /c/dev/ktsu-dev/Semantics +git add README.md Semantics.Benchmarks/README.md CLAUDE.md +git commit -m "Document the strings and paths benchmarks [patch] + +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." +``` + +--- + +## Wrap-up + +- [ ] **Push the branch and open a pull request** + +```bash +cd /c/dev/ktsu-dev/Semantics +git push -u origin claude/benchmarks-strings-paths-252 +gh pr create --fill --base main +``` + +The pull request body must disclose that it was written by Claude, as the first line, per the outward-facing communication rules in the global instructions. It should state which versions the backfill reached for each subject and name any that were skipped, with reasons. + +- [ ] **Raise the deferred issue** + +The spec defers a `verify-charts` workflow that would re-render from committed history and fail a pull request on drift, guarding the committed SVG files the way `verify-generated` guards the generated sources. Open it as its own issue, referencing this one. diff --git a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md index 6a685dc1..1acca412 100644 --- a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md +++ b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md @@ -96,7 +96,12 @@ such), `WithSuffix` (likewise), and the implicit conversion back to `string`. `FileName` from well-formed input. `PathOperationBenchmarks` covers `FileName`, `FileNameWithoutExtension`, `DirectoryPath`, -`AsAbsolute(baseDirectory)`, `AsRelative(baseDirectory)` and `WithoutExtension`. +`RelativeFilePath.AsAbsolute(baseDirectory)`, `AbsoluteFilePath.AsRelative(baseDirectory)` and +`RemoveExtension()`. + +Both conversions are measured in the direction that does work. `AbsoluteFilePath.AsAbsolute()` +returns `this` and would measure nothing, so the absolute direction is taken from a +`RelativeFilePath` and the relative direction from an `AbsoluteFilePath`. Two notes on that set. `FileNameWithoutExtension` caches into a field on `AbsoluteFilePath`, while `FileName` on `SemanticFilePath` builds a fresh `FileName`, validation included, on every read. @@ -132,9 +137,9 @@ Paths: | 3 | `PathCreationBenchmarks.FileNameType` | Create (file name) | | 4 | `PathOperationBenchmarks.FileName` | FileName (uncached) | | 5 | `PathOperationBenchmarks.FileNameWithoutExtension` | FileName (cached) | -| 6 | `PathOperationBenchmarks.AsAbsolute` | AsAbsolute | -| 7 | `PathOperationBenchmarks.AsRelative` | AsRelative | -| 8 | `PathOperationBenchmarks.WithoutExtension` | WithoutExtension | +| 6 | `PathOperationBenchmarks.AsAbsolute` | AsAbsolute (from relative) | +| 7 | `PathOperationBenchmarks.AsRelative` | AsRelative (from absolute) | +| 8 | `PathOperationBenchmarks.RemoveExtension` | RemoveExtension | Both sets are subject to the measurability check under Verification. A benchmark that reports `ZeroMeasurement` is replaced by the next candidate from its class rather than left on the chart. From 367c5af9ad5db5e434ec2d3b58ab2cdff11fa1ee Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 11:28:34 +1000 Subject: [PATCH 03/26] Make the release chart renderer subject-aware 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> --- scripts/benchmark-history.cs | 90 +++++++++++++++++++++++------------- 1 file changed, 57 insertions(+), 33 deletions(-) diff --git a/scripts/benchmark-history.cs b/scripts/benchmark-history.cs index c1161f0f..0908e23d 100644 --- a/scripts/benchmark-history.cs +++ b/scripts/benchmark-history.cs @@ -22,23 +22,35 @@ internal static partial class BenchmarkHistory { private const int SchemaVersion = 1; private const string BaselineKey = "BaselineBenchmarks.ReferenceWork"; - private const int Columns = 4; private const int CellWidth = 228; private const int CellHeight = 132; private const int Left = 56; - /// <summary>The benchmarks the README draws, in order.</summary> + /// <summary>One drawable chart: its title, its grid width, and the panels it shows.</summary> + /// <param name="Title">The library the chart is about, used in the heading and the aria-label.</param> + /// <param name="Columns">Panels per row.</param> + /// <param name="Headline">The panels, in reading order.</param> + private sealed record Subject(string Title, int Columns, PanelSpec[] Headline); + + /// <summary>One panel: which benchmark it draws, and what to call it.</summary> + /// <param name="Key">The benchmark key as <c>ingest</c> stores it.</param> + /// <param name="Parameters">The parameter case to draw, or null when the benchmark has none.</param> + /// <param name="Label">The panel heading.</param> + private sealed record PanelSpec(string Key, string? Parameters, string Label); + + /// <summary>The charts this script can draw, by the name <c>--subject</c> takes.</summary> /// <remarks> /// <para> - /// Everything measured is stored; this only decides what the picture shows, so it can change + /// Everything measured is stored; this only decides what each picture shows, so it can change /// without re-running anything. /// </para> /// <para> - /// Drawn as a grid of one operation per storage type rather than of every operation at one - /// storage type. A quantity is a value type over <c>T</c> and does almost nothing of its own, - /// so what a release changes it changes per storage type — and the same line of user code costs - /// four different things depending on the <c>T</c> it was written against. The top row is one - /// construction across the four, so that row reads as the comparison it is. + /// <b>Quantities</b> is drawn as a grid of one operation per storage type rather than of every + /// operation at one storage type. A quantity is a value type over <c>T</c> and does almost + /// nothing of its own, so what a release changes it changes per storage type — and the same + /// line of user code costs four different things depending on the <c>T</c> it was written + /// against. The top row is one construction across the four, so that row reads as the + /// comparison it is. /// </para> /// <para> /// None of the eight is a bare operator, although the suite measures those too. A relationship @@ -51,17 +63,20 @@ internal static partial class BenchmarkHistory /// enough above that floor to move when the library does. /// </para> /// </remarks> - private static readonly (string Key, string? Parameters, string Label)[] Headline = - [ - ("ConstructionBenchmarks<Double>.FromNauticalMile", null, "Construct (double)"), - ("ConstructionBenchmarks<Single>.FromNauticalMile", null, "Construct (float)"), - ("ConstructionBenchmarks<Decimal>.FromNauticalMile", null, "Construct (decimal)"), - ("ConstructionBenchmarks<PreciseNumber>.FromNauticalMile", null, "Construct (precise)"), - ("UnitConversionBenchmarks<Decimal>.InNauticalMile", null, "Read back (decimal)"), - ("UnitConversionBenchmarks<PreciseNumber>.InNauticalMile", null, "Read back (precise)"), - ("VectorBenchmarks<Decimal>.Length", null, "Vector length (decimal)"), - ("ComparisonBenchmarks<Double>.CompareToInterface", null, "CompareTo (double)"), - ]; + private static readonly Dictionary<string, Subject> Subjects = new(StringComparer.Ordinal) + { + ["quantities"] = new("Semantics.Quantities", 4, + [ + new PanelSpec("ConstructionBenchmarks<Double>.FromNauticalMile", null, "Construct (double)"), + new PanelSpec("ConstructionBenchmarks<Single>.FromNauticalMile", null, "Construct (float)"), + new PanelSpec("ConstructionBenchmarks<Decimal>.FromNauticalMile", null, "Construct (decimal)"), + new PanelSpec("ConstructionBenchmarks<PreciseNumber>.FromNauticalMile", null, "Construct (precise)"), + new PanelSpec("UnitConversionBenchmarks<Decimal>.InNauticalMile", null, "Read back (decimal)"), + new PanelSpec("UnitConversionBenchmarks<PreciseNumber>.InNauticalMile", null, "Read back (precise)"), + new PanelSpec("VectorBenchmarks<Decimal>.Length", null, "Vector length (decimal)"), + new PanelSpec("ComparisonBenchmarks<Double>.CompareToInterface", null, "CompareTo (double)"), + ]), + }; /// <summary> /// Validated for colour-vision separation against both surfaces: every check passes, worst @@ -391,6 +406,13 @@ private static int[] Numbers(string? text) => private static int Render(Dictionary<string, string> options) { string historyPath = Required(options, "history"); + string subjectName = Required(options, "subject"); + if (!Subjects.TryGetValue(subjectName, out Subject? subject)) + { + throw new InvalidOperationException( + $"Unknown subject '{subjectName}'. Known: {string.Join(", ", Subjects.Keys)}"); + } + JsonArray entries = LoadHistory(historyPath)["entries"]!.AsArray(); if (entries.Count == 0) { @@ -409,7 +431,7 @@ private static int Render(Dictionary<string, string> options) string path = string.Equals(name, "light", StringComparison.Ordinal) ? output : $"{stem}-dark{extension}"; - File.WriteAllText(path, Draw(entries, Themes[name])); + File.WriteAllText(path, Draw(entries, Themes[name], subject)); written.Add(path); } @@ -417,20 +439,20 @@ private static int Render(Dictionary<string, string> options) return 0; } - private static string Draw(JsonArray entries, Theme theme) + private static string Draw(JsonArray entries, Theme theme, Subject subject) { string[] labels = [.. entries.Select(entry => entry!["version"]?.GetValue<string>() ?? "?")]; - int width = Left + (Columns * CellWidth) + 24; - int rows = (Headline.Length + Columns - 1) / Columns; + int width = Left + (subject.Columns * CellWidth) + 24; + int rows = (subject.Headline.Length + subject.Columns - 1) / subject.Columns; int height = 72 + (((34 + (rows * CellHeight)) * 2) + 54); StringBuilder svg = new(); - Preamble(svg, theme, width, height, entries); + Preamble(svg, theme, width, height, entries, subject); int y = 72; foreach (bool isTime in (bool[])[false, true]) { - Section(svg, theme, entries, labels.Length, y, isTime); + Section(svg, theme, entries, labels.Length, y, isTime, subject); y += 34 + (rows * CellHeight); } @@ -439,9 +461,10 @@ private static string Draw(JsonArray entries, Theme theme) return svg.ToString(); } - private static void Preamble(StringBuilder svg, Theme theme, int width, int height, JsonArray entries) + private static void Preamble( + StringBuilder svg, Theme theme, int width, int height, JsonArray entries, Subject subject) { - svg.AppendLine(CultureInfo.InvariantCulture, $"""<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="Semantics.Quantities allocation and relative time per release">"""); + svg.AppendLine(CultureInfo.InvariantCulture, $"""<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" viewBox="0 0 {width} {height}" role="img" aria-label="{Escape(subject.Title)} allocation and relative time per release">"""); svg.AppendLine("<style>"); svg.AppendLine(CultureInfo.InvariantCulture, $" text {{ font-family: ui-sans-serif, -apple-system, 'Segoe UI', Roboto, sans-serif; fill: {theme.Ink}; }}"); svg.AppendLine(" .title { font-size: 15px; font-weight: 600; }"); @@ -453,7 +476,7 @@ private static void Preamble(StringBuilder svg, Theme theme, int width, int heig svg.AppendLine(CultureInfo.InvariantCulture, $" .axis {{ stroke: {theme.Grid}; stroke-width: 1; }}"); svg.AppendLine("</style>"); svg.AppendLine(CultureInfo.InvariantCulture, $"""<rect width="{width}" height="{height}" fill="{theme.Surface}" />"""); - svg.AppendLine(CultureInfo.InvariantCulture, $"""<text x="{Left}" y="28" class="title">Semantics.Quantities performance by release</text>"""); + svg.AppendLine(CultureInfo.InvariantCulture, $"""<text x="{Left}" y="28" class="title">{Escape(subject.Title)} performance by release</text>"""); JsonNode latest = entries[^1]!; string date = latest["date"]?.GetValue<string>() ?? ""; @@ -461,7 +484,8 @@ private static void Preamble(StringBuilder svg, Theme theme, int width, int heig svg.AppendLine(CultureInfo.InvariantCulture, $"""<text x="{Left}" y="45" class="caption">{entries.Count} releases · newest {Escape(latest["version"]?.GetValue<string>() ?? "?")}{suffix}</text>"""); } - private static void Section(StringBuilder svg, Theme theme, JsonArray entries, int points, int y, bool isTime) + private static void Section( + StringBuilder svg, Theme theme, JsonArray entries, int points, int y, bool isTime, Subject subject) { string colour = isTime ? theme.Time : theme.Alloc; string title = isTime @@ -475,14 +499,14 @@ private static void Section(StringBuilder svg, Theme theme, JsonArray entries, i svg.AppendLine(CultureInfo.InvariantCulture, $"""<text x="{Left + 15}" y="{y - 2}" class="section">{Escape(title)}</text>"""); svg.AppendLine(CultureInfo.InvariantCulture, $"""<text x="{Left + 15}" y="{y + 12}" class="caption">{Escape(note)}</text>"""); - for (int position = 0; position < Headline.Length; position++) + for (int position = 0; position < subject.Headline.Length; position++) { - (string key, string? parameters, string label) = Headline[position]; + (string key, string? parameters, string label) = subject.Headline[position]; double?[] values = [.. entries.Select(entry => Value(entry!, key, parameters, isTime))]; Panel( svg, - Left + (position % Columns * CellWidth), - y + 26 + (position / Columns * CellHeight), + Left + (position % subject.Columns * CellWidth), + y + 26 + (position / subject.Columns * CellHeight), label + (parameters is null ? "" : $" ({parameters} digits)"), points, values, From f471c3f21886a1db03cbc94ac811a98795af7c9d Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 11:46:10 +1000 Subject: [PATCH 04/26] Measure creating a semantic string across the validation ladder 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> --- .../Semantics.Benchmarks.csproj | 6 + .../Strings/StringCreationBenchmarks.cs | 109 ++++++++++++++++++ .../Strings/StringSpecimens.cs | 55 +++++++++ 3 files changed, 170 insertions(+) create mode 100644 Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs create mode 100644 Semantics.Benchmarks/Strings/StringSpecimens.cs diff --git a/Semantics.Benchmarks/Semantics.Benchmarks.csproj b/Semantics.Benchmarks/Semantics.Benchmarks.csproj index d76a90e7..71468b37 100644 --- a/Semantics.Benchmarks/Semantics.Benchmarks.csproj +++ b/Semantics.Benchmarks/Semantics.Benchmarks.csproj @@ -42,9 +42,15 @@ could only ever measure the working copy. --> <ItemGroup Condition="'$(BenchmarkAgainstVersion)' == ''"> <ProjectReference Include="..\Semantics.Quantities\Semantics.Quantities.csproj" /> + <ProjectReference Include="..\Semantics.Strings\Semantics.Strings.csproj" /> + <ProjectReference Include="..\Semantics.Strings.Identifiers\Semantics.Strings.Identifiers.csproj" /> + <ProjectReference Include="..\Semantics.Paths\Semantics.Paths.csproj" /> </ItemGroup> <ItemGroup Condition="'$(BenchmarkAgainstVersion)' != ''"> <PackageReference Include="ktsu.Semantics.Quantities" VersionOverride="$(BenchmarkAgainstVersion)" /> + <PackageReference Include="ktsu.Semantics.Strings" VersionOverride="$(BenchmarkAgainstVersion)" /> + <PackageReference Include="ktsu.Semantics.Strings.Identifiers" VersionOverride="$(BenchmarkAgainstVersion)" /> + <PackageReference Include="ktsu.Semantics.Paths" VersionOverride="$(BenchmarkAgainstVersion)" /> </ItemGroup> </Project> diff --git a/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs b/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs new file mode 100644 index 00000000..a560bc1b --- /dev/null +++ b/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs @@ -0,0 +1,109 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Strings; + +using System; + +using BenchmarkDotNet.Attributes; + +using ktsu.Semantics.Strings.Identifiers; + +/// <summary> +/// Measures creating a semantic string, across the range of validation a type can declare. +/// </summary> +/// <remarks> +/// <para> +/// <b>The axis here is validation weight.</b> A semantic string is a record wrapping a +/// <see cref="string"/>, so its cost is concentrated at creation, and what differs between types +/// is what their attributes do. <see cref="Unvalidated"/> is the floor: the reflection machinery +/// alone, which is <c>Activator.CreateInstance</c>, a <c>GetProperty</c>, a +/// <c>PropertyInfo.SetValue</c>, and a strategy lookup that finds no attributes. Every other row +/// is that floor plus one validator, so the difference between rows is the validator. +/// </para> +/// <para> +/// The validators are shipped types rather than fixtures, so the numbers describe types a user +/// actually holds. They also happen to span the interesting ground: <see cref="CharsetRegex"/> and +/// <see cref="FormatRegex"/> are interpreted regular expressions looked up from the static cache +/// on every call and carrying a one-second timeout, <see cref="Checksum"/> is a hand-written Luhn +/// pass over the same sort of input, and <see cref="Mod97"/> is the heaviest shipped validator. +/// </para> +/// <para> +/// <b>Both failure paths are here, and they cost the same.</b> <see cref="TryCreateRejects"/> and +/// <see cref="CreateThrows"/> reject the same input, and measuring them side by side turns up +/// something the names hide: <c>SemanticString.TryFromString</c> is implemented as +/// <c>try { Create(...) } catch (ArgumentException) { return false; }</c>, so <c>TryCreate</c> +/// throws and catches internally on every rejection. Both rows therefore pay a full .NET exception, +/// and both cost far more than any success rung above. <c>TryCreate</c> is exception-free in the +/// caller's control flow and not in the caller's cost, which is worth knowing at a boundary that +/// rejects often. <see cref="CreateThrows"/> catches inside the benchmark on purpose: the throw and +/// the catch together are what a caller pays either way. +/// </para> +/// </remarks> +[MemoryDiagnoser] +public class StringCreationBenchmarks +{ + private string plain = ""; + private string uuid = ""; + private string uuidRejected = ""; + private string ulid = ""; + private string card = ""; + private string iban = ""; + + /// <summary>Copies the inputs into fields, so no benchmark body holds a literal.</summary> + [GlobalSetup] + public void Setup() + { + plain = StringSpecimens.PlainInput; + uuid = StringSpecimens.UuidText; + uuidRejected = StringSpecimens.UuidRejected; + ulid = StringSpecimens.UlidText; + card = StringSpecimens.CardText; + iban = StringSpecimens.IbanText; + } + + /// <summary>The reflection machinery with no validator behind it.</summary> + /// <returns>The created value.</returns> + [Benchmark] + public PlainText Unvalidated() => PlainText.Create(plain); + + /// <summary>That floor plus an interpreted regular expression over a fixed character set.</summary> + /// <returns>The created value.</returns> + [Benchmark] + public Ulid CharsetRegex() => Ulid.Create(ulid); + + /// <summary>The same, over a pattern with groups and separators.</summary> + /// <returns>The created value.</returns> + [Benchmark] + public Uuid FormatRegex() => Uuid.Create(uuid); + + /// <summary>That floor plus a hand-written Luhn pass, for regular expressions against arithmetic.</summary> + /// <returns>The created value.</returns> + [Benchmark] + public CreditCardNumber Checksum() => CreditCardNumber.Create(card); + + /// <summary>The heaviest shipped validator: rearrangement, expansion, modular arithmetic.</summary> + /// <returns>The created value.</returns> + [Benchmark] + public Iban Mod97() => Iban.Create(iban); + + /// <summary>The failure path that does not throw.</summary> + /// <returns>Whether creation succeeded, which is always false here.</returns> + [Benchmark] + public bool TryCreateRejects() => Uuid.TryCreate(uuidRejected, out _); + + /// <summary>The failure path that does, throw and catch together.</summary> + /// <returns>Whether the expected exception was raised, which is always true here.</returns> + [Benchmark] + public bool CreateThrows() + { + try + { + _ = Uuid.Create(uuidRejected); + return false; + } + catch (ArgumentException) + { + return true; + } + } +} diff --git a/Semantics.Benchmarks/Strings/StringSpecimens.cs b/Semantics.Benchmarks/Strings/StringSpecimens.cs new file mode 100644 index 00000000..6e8f3e42 --- /dev/null +++ b/Semantics.Benchmarks/Strings/StringSpecimens.cs @@ -0,0 +1,55 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Strings; + +using ktsu.Semantics.Strings; + +/// <summary> +/// A semantic string that declares no validation at all. +/// </summary> +/// <remarks> +/// The one specimen in this suite that is not a shipped type, and it has to be. +/// <c>Semantics.Strings</c> ships the validation framework and no concrete types, so nothing +/// shipped occupies the no-validation rung — and that rung is what every other measurement is read +/// against, being the reflection machinery alone with no validator behind it. Declared against the +/// public API only, like everything else here. +/// </remarks> +public sealed record PlainText : SemanticString<PlainText>; + +/// <summary> +/// The input strings the string benchmarks run against. +/// </summary> +/// <remarks> +/// Every value here is valid for the type it is paired with, except <see cref="UuidRejected"/>, +/// and each is held as a constant so that a benchmark body reads as one call rather than as a +/// literal the JIT might fold. The benchmark classes copy these into fields in +/// <c>[GlobalSetup]</c> for the same reason. +/// </remarks> +internal static class StringSpecimens +{ + /// <summary>Input for the unvalidated rung. Length is in the same range as the others.</summary> + internal const string PlainInput = "0123456789abcdef0123456789abcdef0123"; + + /// <summary>A canonical lowercase RFC 4122 identifier, which <c>IsUuid</c> accepts as written.</summary> + internal const string UuidText = "123e4567-e89b-12d3-a456-426614174000"; + + /// <summary> + /// The pattern <c>IsUuidAttribute</c> uses, repeated here for the hand-written side of the + /// cost pairs in <c>StringAbstractionCostBenchmarks</c>. It must stay identical to the one in + /// <c>Semantics.Strings.Identifiers</c>, or that pair stops being a comparison. + /// </summary> + internal const string UuidPattern = + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"; + + /// <summary>The same identifier one character short, so the pattern rejects it.</summary> + internal const string UuidRejected = "123e4567-e89b-12d3-a456-42661417400"; + + /// <summary>A 26-character Crockford base32 identifier, which <c>IsUlid</c> accepts.</summary> + internal const string UlidText = "01ARZ3NDEKTSV4RRFFQ69G5FAV"; + + /// <summary>A 16-digit number that passes the Luhn check. Not a real card.</summary> + internal const string CardText = "4111111111111111"; + + /// <summary>The standard example account number, which passes the mod-97 check.</summary> + internal const string IbanText = "GB82WEST12345698765432"; +} From 905bff85b57264d3106eca4d1563935299692f13 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 11:47:20 +1000 Subject: [PATCH 05/26] Correct the expected cost of the two string failure paths 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. --- docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md index 38f3bb56..10b61f64 100644 --- a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md +++ b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md @@ -1822,7 +1822,7 @@ Expected: each prints `ingested 5.3.4: N benchmarks, baseline <NS> ns, cpu ...`. |---|---| | `StringCreationBenchmarks.Unvalidated` is the fastest creation row | It is the reflection machinery with no validator. Anything faster means another row is not running its validator. | | `StringCreationBenchmarks.Mod97` is the slowest creation row | `Iban` is the heaviest shipped validator. | -| `CreateThrows` costs far more than `TryCreateRejects` | A .NET exception throw and catch is orders of magnitude above a regular expression match. If they are close, the throw is not happening, which means the specimen is being accepted. | +| `CreateThrows` and `TryCreateRejects` cost about the same, and both cost far more than any success row | Established by measurement in Task 2, and it is a property of the library rather than of the benchmark: `SemanticString.TryFromString` is implemented as `try { Create(...) } catch (ArgumentException) { return false; }`, so `TryCreate` throws and catches internally on every rejection. Both rows therefore pay a full .NET exception. **If instead either row is cheap and close to a success row, the specimen is being accepted and the rejection is not happening** — that is the failure mode to watch for. | | Every string creation row allocates more than 0 bytes | A semantic string is a reference type. A zero here means the allocation is not being counted and `[MemoryDiagnoser]` is missing or the row did not run. | | `PathOperationBenchmarks.FileNameWithoutExtension` is far cheaper than `.FileName` | One caches into a field, the other rebuilds and revalidates. | | Every path row allocates more than 0 bytes except `FileNameWithoutExtension` | Same reasoning, and the cached one returns an existing reference. | From 4b17a2a0e57a3582a8e931d73cf06ab6e369e0dc Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 11:57:25 +1000 Subject: [PATCH 06/26] Measure a semantic string after it exists 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> --- .../Strings/StringOperationBenchmarks.cs | 79 +++++++++++++++++++ 1 file changed, 79 insertions(+) create mode 100644 Semantics.Benchmarks/Strings/StringOperationBenchmarks.cs diff --git a/Semantics.Benchmarks/Strings/StringOperationBenchmarks.cs b/Semantics.Benchmarks/Strings/StringOperationBenchmarks.cs new file mode 100644 index 00000000..30390648 --- /dev/null +++ b/Semantics.Benchmarks/Strings/StringOperationBenchmarks.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Strings; + +using BenchmarkDotNet.Attributes; + +using ktsu.Semantics.Strings.Identifiers; + +/// <summary> +/// Measures what a semantic string costs after it has been created. +/// </summary> +/// <remarks> +/// <para> +/// Creation is where a semantic string spends its cost, and the rest of this suite says so. This +/// class is the other half of that claim: once a value exists, an operation on it should be the +/// underlying <see cref="string"/>'s own work plus a wrapper, and the rows here are how far that +/// holds. +/// </para> +/// <para> +/// <b>Two of them are creation in disguise, which is the point of including them.</b> +/// <see cref="AsConversion"/> and <see cref="WithSuffix"/> both route back through +/// <c>Create</c> on the target type, so each pays the full reflection and validation cost a +/// <c>StringCreationBenchmarks</c> row measures. They read as ordinary member calls at a call +/// site, and they are not, and that is worth being able to point at. +/// </para> +/// <para> +/// <see cref="WithSuffix"/> runs on <see cref="PlainText"/> rather than on an identifier, because +/// appending to a <see cref="Uuid"/> produces a value its own validator rejects. Measuring the +/// throw is <c>StringCreationBenchmarks.CreateThrows</c>'s job, not this one's. +/// </para> +/// </remarks> +[MemoryDiagnoser] +public class StringOperationBenchmarks +{ + private Uuid left = null!; + private Uuid right = null!; + private PlainText plain = null!; + private string suffix = ""; + + /// <summary>Builds the operands once, outside the measurement.</summary> + [GlobalSetup] + public void Setup() + { + left = Uuid.Create(StringSpecimens.UuidText); + right = Uuid.Create(StringSpecimens.UuidText); + plain = PlainText.Create(StringSpecimens.PlainInput); + suffix = "-suffixed"; + } + + /// <summary>Record equality over two equal values, which is the worst case for it.</summary> + /// <returns>Whether the two are equal, which is always true here.</returns> + [Benchmark] + public bool EqualityOperator() => left == right; + + /// <summary>Ordering, which routes through the underlying string's comparison.</summary> + /// <returns>The comparison result.</returns> + [Benchmark] + public int CompareTo() => left.CompareTo(right); + + /// <summary>Hashing, which a dictionary of semantic strings pays on every lookup.</summary> + /// <returns>The hash code.</returns> + [Benchmark] + public int HashCode() => left.GetHashCode(); + + /// <summary>Cross-type conversion, which is a full creation against the target type.</summary> + /// <returns>The converted value.</returns> + [Benchmark] + public PlainText AsConversion() => left.As<PlainText>(); + + /// <summary>Appending, which is also a full creation against the same type.</summary> + /// <returns>The extended value.</returns> + [Benchmark] + public PlainText WithSuffix() => plain.WithSuffix(suffix); + + /// <summary>The implicit conversion back out, which should be a field read.</summary> + /// <returns>The underlying string.</returns> + [Benchmark] + public string ToStringImplicit() => left; +} From 37a742f5c7d608633f86ebb258ed9581c236b68c Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 11:59:27 +1000 Subject: [PATCH 07/26] Chart GetHashCode rather than CompareTo for strings 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. --- .../plans/2026-09-18-strings-paths-benchmarks.md | 14 +++++++++++--- .../2026-09-18-strings-paths-benchmarks-design.md | 8 +++++--- 2 files changed, 16 insertions(+), 6 deletions(-) diff --git a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md index 10b61f64..76817a09 100644 --- a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md +++ b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md @@ -507,7 +507,7 @@ a boundary that sees bad input is a number." **Interfaces:** - Consumes: `StringSpecimens.UuidText`, `StringSpecimens.PlainInput`, and the `PlainText` type from Task 2. -- Produces: benchmark keys `StringOperationBenchmarks.EqualityOperator`, `.CompareTo`, `.HashCode`, `.AsConversion`, `.WithSuffix`, `.ToStringImplicit`. Task 7 draws `.AsConversion` and `.CompareTo`. +- Produces: benchmark keys `StringOperationBenchmarks.EqualityOperator`, `.CompareTo`, `.HashCode`, `.AsConversion`, `.WithSuffix`, `.ToStringImplicit`. Task 7 draws `.AsConversion` and `.HashCode` (`.CompareTo` was measured at 0.5714 ns and hoisted, so it is not drawn). - [ ] **Step 1: Write the class** @@ -1409,7 +1409,15 @@ be passed where a different kind belongs." - [ ] **Step 1: Add the two entries** -Before writing these, check the notes recorded in Task 3 Step 2 and Task 5 Step 4. If `CompareTo` reported `ZeroMeasurement`, replace the strings position 8 panel with `new("StringOperationBenchmarks.HashCode", null, "GetHashCode")`. If `FileNameWithoutExtension` reported `ZeroMeasurement`, replace the paths position 5 panel with `new("PathCreationBenchmarks.AbsoluteDirectoryPath", null, "Create (absolute dir)")`. +**The strings contingency has already fired and is baked into the list below.** Task 3 measured +`StringOperationBenchmarks.CompareTo` at 0.5714 ns and BenchmarkDotNet reported `ZeroMeasurement` — +the JIT hoists it, so a panel of it would chart the harness's resolution rather than the library. +Position 8 is therefore `HashCode` (43.8881 ns, measured cleanly, no warning) rather than `CompareTo`. +`ToStringImplicit` was also hoisted, and is not drawn either way. + +Still to check before writing the paths list: the note recorded in Task 5 Step 4. If +`FileNameWithoutExtension` reported `ZeroMeasurement`, replace the paths position 5 panel with +`new("PathCreationBenchmarks.AbsoluteDirectoryPath", null, "Create (absolute dir)")`. Add to the `Subjects` dictionary, after the `quantities` entry: @@ -1423,7 +1431,7 @@ Add to the `Subjects` dictionary, after the `quantities` entry: new("StringCreationBenchmarks.TryCreateRejects", null, "TryCreate (rejects)"), new("StringCreationBenchmarks.CreateThrows", null, "Create (throws)"), new("StringOperationBenchmarks.AsConversion", null, "As<T> conversion"), - new("StringOperationBenchmarks.CompareTo", null, "CompareTo"), + new("StringOperationBenchmarks.HashCode", null, "GetHashCode"), ]), ["paths"] = new("Semantics.Paths", 4, [ diff --git a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md index 1acca412..76fd098d 100644 --- a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md +++ b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md @@ -126,7 +126,7 @@ Strings: | 5 | `StringCreationBenchmarks.TryCreateRejects` | TryCreate (rejects) | | 6 | `StringCreationBenchmarks.CreateThrows` | Create (throws) | | 7 | `StringOperationBenchmarks.AsConversion` | As&lt;T&gt; conversion | -| 8 | `StringOperationBenchmarks.CompareTo` | CompareTo | +| 8 | `StringOperationBenchmarks.HashCode` | GetHashCode | Paths: @@ -317,8 +317,10 @@ and will otherwise be rediscovered the hard way: 2. **Every new benchmark is checked for being measurable.** The headline sets above are proposals, not commitments. Each new class runs at `--job short`, the warnings get read, and any benchmark coming back as `ZeroMeasurement` or `NA` is dropped or replaced before it reaches a chart. Anything cut is - reported with its reason rather than quietly substituted. `Equals` and `CompareTo` on the strings - operations chart are the plausible casualties, followed by the path property reads. + reported with its reason rather than quietly substituted. **Outcome on the strings side:** + `CompareTo` was hoisted (0.5714 ns, `ZeroMeasurement`) and is replaced on the chart by `HashCode` + (43.8881 ns, measured cleanly); `ToStringImplicit` was hoisted too and was never charted. + `EqualityOperator` survived at 1.1446 ns. The path property reads remain the outstanding risk. 3. **Seeding proceeds one version first.** The full run is two new subjects across a ten-version list and will take hours. Version 5.3.4 is seeded alone and its numbers read against expectations stated in advance: the unvalidated floor below every validated rung, `Iban` slowest, allocation From 619defd1f6dcf0fe2eeeaf6974e4697cab90e81d Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:05:40 +1000 Subject: [PATCH 08/26] Pair the string types against hand-written validation 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> --- .../StringAbstractionCostBenchmarks.cs | 221 ++++++++++++++++++ 1 file changed, 221 insertions(+) create mode 100644 Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs diff --git a/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs b/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs new file mode 100644 index 00000000..259b51a3 --- /dev/null +++ b/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs @@ -0,0 +1,221 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Strings; + +using System; +using System.Text.RegularExpressions; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +using ktsu.Semantics.Strings.Identifiers; + +/// <summary> +/// Measures what the semantic string types cost over the code a caller would otherwise write. +/// </summary> +/// <remarks> +/// <para> +/// <b>What the baseline side is, and why it is not a bare string.</b> The quantities suite pairs +/// <c>T + T</c> against <c>Length&lt;T&gt; + Length&lt;T&gt;</c>, which is fair because both sides +/// do identical work and the only question is what the wrapper adds. There is no such pairing for +/// <c>Uuid.Create</c>: the bare-string counterpart is an assignment, which is no work at all. +/// Against that, the ratio would be a large number restating only that validation is not free, +/// which needs no benchmark to establish. +/// </para> +/// <para> +/// So the baseline side here is the code a caller would otherwise have written — the same pattern +/// matched by hand, and the same throw on failure. Read that way the ratio answers the question a +/// caller actually has: <i>I was going to validate this anyway, so what does routing it through +/// the type cost me on top?</i> The answer separates into the validation both sides pay and the +/// per-call reflection only one side does. +/// </para> +/// <para> +/// <b>The pattern is duplicated on purpose.</b> <c>StringSpecimens.UuidPattern</c> is the same +/// string <c>IsUuidAttribute</c> holds. If the two ever drift the comparison stops being one, so +/// it is worth saying here: the constant exists to be kept identical, not to be tuned. +/// </para> +/// <para> +/// <b>The last two categories are fair pairs in the quantities sense</b> and are expected near +/// 1.00, because a semantic string's equality and ordering are the underlying string's own. +/// </para> +/// <para> +/// <b>Why these are loops.</b> A single call over an operand that does not change is +/// loop-invariant and the JIT hoists it, and a ratio between two hoisted methods means nothing. +/// Each iteration here feeds the next through an accumulator, so there is nothing to hoist and +/// both sides of a pair stay measurable. Both sides also pay the same counter and branch, which +/// pulls the ratio toward 1.00 rather than away from it, so a ratio above 1.00 is a floor on the +/// real cost rather than the whole of it. +/// </para> +/// </remarks> +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class StringAbstractionCostBenchmarks +{ + /// <summary> + /// Operations per invocation. Enough that the loop's own cost is a small share of the work. + /// </summary> + private const int Operations = 64; + + private string text = ""; + private string rejected = ""; + private string pattern = ""; + private Uuid left = null!; + private Uuid right = null!; + + /// <summary>Prepares both sides of every pair.</summary> + [GlobalSetup] + public void Setup() + { + text = StringSpecimens.UuidText; + rejected = StringSpecimens.UuidRejected; + pattern = StringSpecimens.UuidPattern; + left = Uuid.Create(StringSpecimens.UuidText); + right = Uuid.Create(StringSpecimens.UuidText); + } + + /// <summary>Validating at a boundary by hand, throwing on rejection.</summary> + /// <returns>The accumulated length, returned so nothing here is dead code.</returns> + [BenchmarkCategory("Validate")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareValidate() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (!Regex.IsMatch(text, pattern, RegexOptions.None, TimeSpan.FromSeconds(1))) + { + throw new ArgumentException("unreachable: the specimen is valid"); + } + + accumulator += text.Length; + } + + return accumulator; + } + + /// <summary>Validating at a boundary through the type.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("Validate")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticValidate() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += Uuid.Create(text).Length; + } + + return accumulator; + } + + /// <summary>Rejecting by hand without throwing.</summary> + /// <returns>The count of rejections, which is every iteration.</returns> + [BenchmarkCategory("Reject")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareReject() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (!Regex.IsMatch(rejected, pattern, RegexOptions.None, TimeSpan.FromSeconds(1))) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Rejecting through the type without throwing.</summary> + /// <returns>The count of rejections, which is every iteration.</returns> + [BenchmarkCategory("Reject")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticReject() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (!Uuid.TryCreate(rejected, out _)) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Ordinal equality on the bare strings.</summary> + /// <returns>The count of matches, which is every iteration.</returns> + [BenchmarkCategory("Equality")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareEquality() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (string.Equals(left.WeakString, right.WeakString, StringComparison.Ordinal)) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Record equality on the semantic values holding those strings.</summary> + /// <returns>The count of matches, which is every iteration.</returns> + [BenchmarkCategory("Equality")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticEquality() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (left == right) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Ordering on the bare strings.</summary> + /// <returns>The accumulated comparison results.</returns> + [BenchmarkCategory("Ordering")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareOrdering() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += string.CompareOrdinal(left.WeakString, right.WeakString); + } + + return accumulator; + } + + /// <summary>Ordering on the semantic values.</summary> + /// <returns>The accumulated comparison results.</returns> + [BenchmarkCategory("Ordering")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticOrdering() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += left.CompareTo(right); + } + + return accumulator; + } +} From 42f6879b3fc3a84709cd5693c26e98544c20ca0f Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:07:12 +1000 Subject: [PATCH 09/26] Compare like with like in the string ordering pair 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. --- .../plans/2026-09-18-strings-paths-benchmarks.md | 2 +- .../2026-09-18-strings-paths-benchmarks-design.md | 14 +++++++++++++- 2 files changed, 14 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md index 76817a09..2f5e6d44 100644 --- a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md +++ b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md @@ -840,7 +840,7 @@ public class StringAbstractionCostBenchmarks for (int i = 0; i < Operations; i++) { - accumulator += string.CompareOrdinal(left.WeakString, right.WeakString); + accumulator += left.WeakString.CompareTo(right.WeakString); } return accumulator; diff --git a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md index 76fd098d..b7ec963a 100644 --- a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md +++ b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md @@ -162,7 +162,19 @@ the hand-written side marked `Baseline = true`: | Validate at a boundary | `Regex.IsMatch(s, pattern)` then throw on failure, the same pattern the attribute uses | `Uuid.Create(s)` | | Validate without throwing | the same match, returning a `bool` | `Uuid.TryCreate(s, out _)` | | Equality | `string.Equals(a, b, StringComparison.Ordinal)` | `Uuid` record equality | -| Ordering | `string.CompareTo` | `Uuid.CompareTo` | +| Ordering | `left.WeakString.CompareTo(right.WeakString)` | `Uuid.CompareTo` | + +**One correction the measurements forced.** The ordering baseline was first written as +`string.CompareOrdinal`, which made that pair invalid: `SemanticString.CompareTo` forwards to +`string.CompareTo(string)`, which is culture-sensitive, so the pair measured ordinal collation +against culture collation and attributed the difference to the wrapper. A cost pair only isolates +the wrapper when both sides make the same call, so the baseline is now the same `CompareTo` the +semantic side reaches. + +That correction surfaced something worth recording about the library, which this work reports rather +than changes: equality on a semantic string is ordinal, because string equality always is, while +ordering is culture-sensitive. Two values can compare equal under `==` and sort by a different rule +than that implies. Read that way, the ratio answers the question a user actually has. *I was going to validate this anyway, so what does routing it through the type cost me on top?* The answer separates into the From 01a8117b6a118e6f716b748e6539eca85ecba8d2 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:10:29 +1000 Subject: [PATCH 10/26] Fix Ordering baseline to match the culture-sensitive comparison it pairs 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> --- .../Strings/StringAbstractionCostBenchmarks.cs | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs b/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs index 259b51a3..7edd4724 100644 --- a/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs +++ b/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs @@ -35,8 +35,19 @@ namespace ktsu.Semantics.Benchmarks.Strings; /// it is worth saying here: the constant exists to be kept identical, not to be tuned. /// </para> /// <para> -/// <b>The last two categories are fair pairs in the quantities sense</b> and are expected near -/// 1.00, because a semantic string's equality and ordering are the underlying string's own. +/// <b>The last two categories are fair pairs in the quantities sense</b>, but only because each +/// baseline makes the same call the semantic side ends up making. That is worth stating for +/// <c>Ordering</c> in particular: <c>SemanticString.CompareTo</c> forwards to +/// <see cref="string.CompareTo(string)"/>, which is culture-sensitive, so a baseline written with +/// <see cref="string.CompareOrdinal(string, string)"/> would have measured ordinal collation against +/// culture collation and reported that difference as though it were the wrapper's cost. +/// </para> +/// <para> +/// <b>An observation the pair surfaces, which this suite reports rather than fixes.</b> Equality on +/// a semantic string is ordinal, because string equality always is, while ordering is +/// culture-sensitive, because <see cref="string.CompareTo(string)"/> is. Two values can therefore +/// compare equal by <c>==</c> and sort by a different rule than that suggests. Nothing here changes +/// it; the benchmark only makes it visible. /// </para> /// <para> /// <b>Why these are loops.</b> A single call over an operand that does not change is @@ -197,7 +208,7 @@ public int BareOrdering() for (int i = 0; i < Operations; i++) { - accumulator += string.CompareOrdinal(left.WeakString, right.WeakString); + accumulator += left.WeakString.CompareTo(right.WeakString); } return accumulator; From 280a22b24a09dc988e94d8cae292d60782866d84 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:23:43 +1000 Subject: [PATCH 11/26] Measure the path types, building and operating 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> --- .../Paths/PathCreationBenchmarks.cs | 59 ++++++++++++++ .../Paths/PathOperationBenchmarks.cs | 79 +++++++++++++++++++ Semantics.Benchmarks/Paths/PathSpecimens.cs | 56 +++++++++++++ 3 files changed, 194 insertions(+) create mode 100644 Semantics.Benchmarks/Paths/PathCreationBenchmarks.cs create mode 100644 Semantics.Benchmarks/Paths/PathOperationBenchmarks.cs create mode 100644 Semantics.Benchmarks/Paths/PathSpecimens.cs diff --git a/Semantics.Benchmarks/Paths/PathCreationBenchmarks.cs b/Semantics.Benchmarks/Paths/PathCreationBenchmarks.cs new file mode 100644 index 00000000..bcbb3eac --- /dev/null +++ b/Semantics.Benchmarks/Paths/PathCreationBenchmarks.cs @@ -0,0 +1,59 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using BenchmarkDotNet.Attributes; + +using ktsu.Semantics.Paths; + +/// <summary> +/// Measures building each of the path types from a well-formed string. +/// </summary> +/// <remarks> +/// A path type is a semantic string whose validator asks the runtime a question about the shape of +/// the value, so each row is the same reflection machinery +/// <c>StringCreationBenchmarks.Unvalidated</c> measures plus one such question. The absolute rows +/// ask <c>Path.IsPathFullyQualified</c> and the relative row asks its negation, so the pair is the +/// same work in both directions rather than two different validators. +/// </remarks> +[MemoryDiagnoser] +public class PathCreationBenchmarks +{ + private string absoluteFile = ""; + private string absoluteDirectory = ""; + private string relativeFile = ""; + private string fileName = ""; + + /// <summary>Copies the inputs into fields.</summary> + [GlobalSetup] + public void Setup() + { + absoluteFile = PathSpecimens.AbsoluteFile; + absoluteDirectory = PathSpecimens.AbsoluteDirectory; + relativeFile = PathSpecimens.RelativeFile; + fileName = PathSpecimens.FileNameOnly; + } + + /// <summary>Builds a fully qualified file path.</summary> + /// <returns>The created path.</returns> + [Benchmark] + public AbsoluteFilePath AbsoluteFilePath() => + Semantics.Paths.AbsoluteFilePath.Create(absoluteFile); + + /// <summary>Builds a relative file path.</summary> + /// <returns>The created path.</returns> + [Benchmark] + public RelativeFilePath RelativeFilePath() => + Semantics.Paths.RelativeFilePath.Create(relativeFile); + + /// <summary>Builds a fully qualified directory path.</summary> + /// <returns>The created path.</returns> + [Benchmark] + public AbsoluteDirectoryPath AbsoluteDirectoryPath() => + Semantics.Paths.AbsoluteDirectoryPath.Create(absoluteDirectory); + + /// <summary>Builds a bare file name, whose validator checks for separators.</summary> + /// <returns>The created file name.</returns> + [Benchmark] + public FileName FileNameType() => FileName.Create(fileName); +} diff --git a/Semantics.Benchmarks/Paths/PathOperationBenchmarks.cs b/Semantics.Benchmarks/Paths/PathOperationBenchmarks.cs new file mode 100644 index 00000000..38a9a0f8 --- /dev/null +++ b/Semantics.Benchmarks/Paths/PathOperationBenchmarks.cs @@ -0,0 +1,79 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using BenchmarkDotNet.Attributes; + +using ktsu.Semantics.Paths; + +/// <summary> +/// Measures the operations a path type offers over the string it holds. +/// </summary> +/// <remarks> +/// <para> +/// <b>Two rows are here to be read against each other.</b> +/// <see cref="FileNameWithoutExtension"/> caches into a field on the path, so the second read and +/// every read after it is a field read. <see cref="FileName"/> does not: it builds a fresh +/// <c>FileName</c>, validation included, on every single read. Both are properties, both read like +/// field access at a call site, and they differ by the cost of a semantic string creation. Putting +/// them side by side is what makes that visible, and it is the kind of thing a release could +/// quietly change in either direction. +/// </para> +/// <para> +/// <b>Both conversions are measured in the direction that does work.</b> +/// <c>AbsoluteFilePath.AsAbsolute()</c> returns <c>this</c> and would measure nothing, so the +/// absolute direction is taken from a relative path and the relative direction from an absolute +/// one. +/// </para> +/// <para> +/// Nothing here touches the filesystem. <c>IsDirectory</c> and <c>IsFile</c> are excluded on +/// purpose: they call <c>Directory.Exists</c> and <c>File.Exists</c>, so they would measure the +/// disk and the state of the machine rather than this library. +/// </para> +/// </remarks> +[MemoryDiagnoser] +public class PathOperationBenchmarks +{ + private AbsoluteFilePath absoluteFile = null!; + private RelativeFilePath relativeFile = null!; + private AbsoluteDirectoryPath baseDirectory = null!; + + /// <summary>Builds the operands once, outside the measurement.</summary> + [GlobalSetup] + public void Setup() + { + absoluteFile = AbsoluteFilePath.Create(PathSpecimens.AbsoluteFile); + relativeFile = RelativeFilePath.Create(PathSpecimens.RelativeFile); + baseDirectory = AbsoluteDirectoryPath.Create(PathSpecimens.AbsoluteDirectory); + } + + /// <summary>Reads the file name, which builds and validates a new one every time.</summary> + /// <returns>The file name.</returns> + [Benchmark] + public FileName FileName() => absoluteFile.FileName; + + /// <summary>Reads the stem, which is cached into a field after the first read.</summary> + /// <returns>The file name without its extension.</returns> + [Benchmark] + public FileName FileNameWithoutExtension() => absoluteFile.FileNameWithoutExtension; + + /// <summary>Reads the containing directory, which builds and validates a new path.</summary> + /// <returns>The directory path.</returns> + [Benchmark] + public DirectoryPath DirectoryPath() => absoluteFile.DirectoryPath; + + /// <summary>Resolves a relative path against a base directory.</summary> + /// <returns>The resolved absolute path.</returns> + [Benchmark] + public AbsoluteFilePath AsAbsolute() => relativeFile.AsAbsolute(baseDirectory); + + /// <summary>Expresses an absolute path relative to a base directory.</summary> + /// <returns>The relative path.</returns> + [Benchmark] + public RelativeFilePath AsRelative() => absoluteFile.AsRelative(baseDirectory); + + /// <summary>Strips the extension, which rebuilds and revalidates the whole path.</summary> + /// <returns>The path without its extension.</returns> + [Benchmark] + public AbsoluteFilePath RemoveExtension() => absoluteFile.RemoveExtension(); +} diff --git a/Semantics.Benchmarks/Paths/PathSpecimens.cs b/Semantics.Benchmarks/Paths/PathSpecimens.cs new file mode 100644 index 00000000..8d795722 --- /dev/null +++ b/Semantics.Benchmarks/Paths/PathSpecimens.cs @@ -0,0 +1,56 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using System; +using System.Diagnostics.CodeAnalysis; +using System.IO; + +/// <summary> +/// The path strings the path benchmarks run against. +/// </summary> +/// <remarks> +/// <para> +/// <b>The absolute ones are built per platform, and they have to be.</b> +/// <c>IsAbsolutePathAttribute</c> validates through <c>Path.IsPathFullyQualified</c>, whose answer +/// depends on the operating system: <c>C:\x</c> is fully qualified on Windows and is an ordinary +/// relative path on Linux. A hardcoded Windows root would throw from every absolute benchmark on +/// the CI runner while passing on a developer's machine, which is the worst way for this to fail. +/// </para> +/// <para> +/// The two roots differ by two characters in length, so a measurement taken on Windows is not +/// exactly a measurement taken on Linux. That is smaller than the difference between CI hosts that +/// <c>BaselineBenchmarks</c> already exists to normalize, and it is why the history records a +/// baseline reading alongside every entry. +/// </para> +/// <para> +/// Nothing here touches the filesystem, and none of these paths needs to exist. The benchmarks +/// deliberately avoid <c>IsDirectory</c> and <c>IsFile</c>, which call <c>Directory.Exists</c> and +/// <c>File.Exists</c> and would measure the disk rather than the library. +/// </para> +/// </remarks> +internal static class PathSpecimens +{ + private static readonly string Root = + OperatingSystem.IsWindows() ? @"C:\projects" : "/projects"; + + /// <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"); + + /// <summary>A relative file path. Forward slashes are accepted on both platforms.</summary> + [SuppressMessage( + "Performance", + "CA1802:Use literals where appropriate", + Justification = "Kept as a readonly field, alongside AbsoluteFile and AbsoluteDirectory " + + "which cannot be const, so the three path specimens declare uniformly; only the bare " + + "FileNameOnly is a true const.")] + internal static readonly string RelativeFile = "Semantics.Paths/FilePath.cs"; + + /// <summary>A bare file name, with no separator in it.</summary> + internal const string FileNameOnly = "FilePath.cs"; +} From a8cfeb41ba794890891369db143a3a95f7c87e30 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:26:40 +1000 Subject: [PATCH 12/26] Make RelativeFile a const, not a suppressed readonly field 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> --- Semantics.Benchmarks/Paths/PathSpecimens.cs | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/Semantics.Benchmarks/Paths/PathSpecimens.cs b/Semantics.Benchmarks/Paths/PathSpecimens.cs index 8d795722..019abd56 100644 --- a/Semantics.Benchmarks/Paths/PathSpecimens.cs +++ b/Semantics.Benchmarks/Paths/PathSpecimens.cs @@ -3,7 +3,6 @@ namespace ktsu.Semantics.Benchmarks.Paths; using System; -using System.Diagnostics.CodeAnalysis; using System.IO; /// <summary> @@ -43,13 +42,7 @@ internal static class PathSpecimens Path.Combine(Root, "semantics", "src"); /// <summary>A relative file path. Forward slashes are accepted on both platforms.</summary> - [SuppressMessage( - "Performance", - "CA1802:Use literals where appropriate", - Justification = "Kept as a readonly field, alongside AbsoluteFile and AbsoluteDirectory " + - "which cannot be const, so the three path specimens declare uniformly; only the bare " + - "FileNameOnly is a true const.")] - internal static readonly string RelativeFile = "Semantics.Paths/FilePath.cs"; + internal const string RelativeFile = "Semantics.Paths/FilePath.cs"; /// <summary>A bare file name, with no separator in it.</summary> internal const string FileNameOnly = "FilePath.cs"; From 7fbd857172fd81e14f710c138260fbe250606629 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:27:36 +1000 Subject: [PATCH 13/26] Declare the relative path specimen as a const --- .../plans/2026-09-18-strings-paths-benchmarks.md | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md index 2f5e6d44..da8e93c5 100644 --- a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md +++ b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md @@ -907,7 +907,7 @@ the reflection only one side does." **Interfaces:** - Consumes: the package references added in Task 2 Step 1. -- Produces: `internal static class PathSpecimens` with `internal static readonly string` members `AbsoluteFile`, `AbsoluteDirectory`, `RelativeFile`, and `internal const string FileNameOnly`. Task 6 uses all of them. Benchmark keys `PathCreationBenchmarks.AbsoluteFilePath`, `.RelativeFilePath`, `.FileNameType`, `.AbsoluteDirectoryPath`, and `PathOperationBenchmarks.FileName`, `.FileNameWithoutExtension`, `.DirectoryPath`, `.AsAbsolute`, `.AsRelative`, `.RemoveExtension`, which Task 7 draws. +- Produces: `internal static class PathSpecimens` with `internal static readonly string` members `AbsoluteFile` and `AbsoluteDirectory`, plus `internal const string` members `RelativeFile` and `FileNameOnly`. Task 6 uses all of them. Benchmark keys `PathCreationBenchmarks.AbsoluteFilePath`, `.RelativeFilePath`, `.FileNameType`, `.AbsoluteDirectoryPath`, and `PathOperationBenchmarks.FileName`, `.FileNameWithoutExtension`, `.DirectoryPath`, `.AsAbsolute`, `.AsRelative`, `.RemoveExtension`, which Task 7 draws. - [ ] **Step 1: Write the specimens file** @@ -960,7 +960,13 @@ internal static class PathSpecimens Path.Combine(Root, "semantics", "src"); /// <summary>A relative file path. Forward slashes are accepted on both platforms.</summary> - internal static readonly string RelativeFile = "Semantics.Paths/FilePath.cs"; + /// <remarks> + /// <c>const</c> rather than <c>static readonly</c>: it is a compile-time literal, and CA1802 + /// reports the latter as an error under this repository's warnings-as-errors setting. The two + /// absolute specimens above genuinely must be <c>static readonly</c>, because they call + /// <see cref="Path.Combine"/> against a root chosen per platform. + /// </remarks> + internal const string RelativeFile = "Semantics.Paths/FilePath.cs"; /// <summary>A bare file name, with no separator in it.</summary> internal const string FileNameOnly = "FilePath.cs"; From 365507ce24bf58ce06750decd7eab1f5bdc5cc1b Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:35:11 +1000 Subject: [PATCH 14/26] Pair the path types against System.IO.Path 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> --- .../Paths/PathAbstractionCostBenchmarks.cs | 191 ++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs diff --git a/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs b/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs new file mode 100644 index 00000000..f36caa33 --- /dev/null +++ b/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs @@ -0,0 +1,191 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using System.IO; + +using BenchmarkDotNet.Attributes; +using BenchmarkDotNet.Configs; + +using ktsu.Semantics.Paths; + +/// <summary> +/// Measures what the path types cost over <see cref="Path"/> doing the same work. +/// </summary> +/// <remarks> +/// <para> +/// This pairing needs none of the care the string one does. <see cref="Path"/> is a real API doing +/// the real work, so each category is the same operation twice and the ratio is a straight answer. +/// </para> +/// <para> +/// What the semantic side adds is a validated wrapper around the result: every one of these +/// operations returns a path type rather than a string, which means a creation, which means the +/// reflection machinery and the validator. The ratio is therefore expected well above 1.00 +/// throughout, and the number is the point rather than a disappointment — it is what a caller pays +/// for a result that cannot be silently passed where a different kind of path belongs. +/// </para> +/// <para> +/// The loops exist for the reason they do everywhere in this suite: a single call over an +/// unchanging operand is loop-invariant, the JIT hoists it, and a ratio between two hoisted +/// methods means nothing. +/// </para> +/// </remarks> +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class PathAbstractionCostBenchmarks +{ + /// <summary>Operations per invocation.</summary> + private const int Operations = 64; + + private string absoluteFileText = ""; + private string relativeFileText = ""; + private string baseDirectoryText = ""; + private AbsoluteFilePath absoluteFile = null!; + private RelativeFilePath relativeFile = null!; + private AbsoluteDirectoryPath baseDirectory = null!; + + /// <summary>Prepares both sides of every pair, holding the same values.</summary> + [GlobalSetup] + public void Setup() + { + absoluteFileText = PathSpecimens.AbsoluteFile; + relativeFileText = PathSpecimens.RelativeFile; + baseDirectoryText = PathSpecimens.AbsoluteDirectory; + + absoluteFile = AbsoluteFilePath.Create(absoluteFileText); + relativeFile = RelativeFilePath.Create(relativeFileText); + baseDirectory = AbsoluteDirectoryPath.Create(baseDirectoryText); + } + + /// <summary>Extracting a file name with the runtime's own helper.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("FileName")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareFileName() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += Path.GetFileName(absoluteFileText).Length; + } + + return accumulator; + } + + /// <summary>Extracting it through the path type, which validates the result.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("FileName")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticFileName() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += absoluteFile.FileName.Length; + } + + return accumulator; + } + + /// <summary>Resolving a relative path with the runtime's own helper.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("AsAbsolute")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareAsAbsolute() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += Path.GetFullPath(relativeFileText, baseDirectoryText).Length; + } + + return accumulator; + } + + /// <summary>Resolving it through the path type.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("AsAbsolute")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticAsAbsolute() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += relativeFile.AsAbsolute(baseDirectory).Length; + } + + return accumulator; + } + + /// <summary>Relativizing with the runtime's own helper.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("AsRelative")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareAsRelative() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += Path.GetRelativePath(baseDirectoryText, absoluteFileText).Length; + } + + return accumulator; + } + + /// <summary>Relativizing through the path type.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("AsRelative")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticAsRelative() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += absoluteFile.AsRelative(baseDirectory).Length; + } + + return accumulator; + } + + /// <summary>Checking a path is rooted by hand, which is what creation validates.</summary> + /// <returns>The count of rooted paths, which is every iteration.</returns> + [BenchmarkCategory("Create")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareCreate() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (Path.IsPathFullyQualified(absoluteFileText)) + { + accumulator++; + } + } + + return accumulator; + } + + /// <summary>Building the path type, which asks the same question and keeps the answer.</summary> + /// <returns>The accumulated length.</returns> + [BenchmarkCategory("Create")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticCreate() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += AbsoluteFilePath.Create(absoluteFileText).Length; + } + + return accumulator; + } +} From 67e03f5eccd01011c5407a3b197cef4ef2a05571 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:39:09 +1000 Subject: [PATCH 15/26] Draw the strings and paths charts 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> --- scripts/benchmark-history.cs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/scripts/benchmark-history.cs b/scripts/benchmark-history.cs index 0908e23d..c841155d 100644 --- a/scripts/benchmark-history.cs +++ b/scripts/benchmark-history.cs @@ -62,6 +62,19 @@ private sealed record PanelSpec(string Key, string? Parameters, string Label); /// unit, reading it back out in one, a vector length, a comparison — all of which are far /// enough above that floor to move when the library does. /// </para> + /// <para> + /// <b>Strings</b> is drawn along validation weight, because that is the axis there is. A + /// semantic string is a record wrapping a <see cref="string"/> and its cost is concentrated at + /// creation, so the top row walks from the reflection machinery alone up through a character + /// set check, a format check and a mod-97 check. The bottom row is what a caller pays around + /// that: both failure paths, the cross-type conversion that is secretly another creation, and + /// an ordering that should be the underlying string's own. + /// </para> + /// <para> + /// <b>Paths</b> is the same shape: build each kind, then operate on one. The two file name + /// panels sit next to each other because one caches into a field and one rebuilds and + /// revalidates on every read, and both look like field access at a call site. + /// </para> /// </remarks> private static readonly Dictionary<string, Subject> Subjects = new(StringComparer.Ordinal) { @@ -76,6 +89,28 @@ private sealed record PanelSpec(string Key, string? Parameters, string Label); new PanelSpec("VectorBenchmarks<Decimal>.Length", null, "Vector length (decimal)"), new PanelSpec("ComparisonBenchmarks<Double>.CompareToInterface", null, "CompareTo (double)"), ]), + ["strings"] = new("Semantics.Strings", 4, + [ + new("StringCreationBenchmarks.Unvalidated", null, "Create (no validation)"), + new("StringCreationBenchmarks.CharsetRegex", null, "Create (charset regex)"), + new("StringCreationBenchmarks.FormatRegex", null, "Create (format regex)"), + new("StringCreationBenchmarks.Mod97", null, "Create (mod-97)"), + new("StringCreationBenchmarks.TryCreateRejects", null, "TryCreate (rejects)"), + new("StringCreationBenchmarks.CreateThrows", null, "Create (throws)"), + new("StringOperationBenchmarks.AsConversion", null, "As<T> conversion"), + new("StringOperationBenchmarks.HashCode", null, "GetHashCode"), + ]), + ["paths"] = new("Semantics.Paths", 4, + [ + new("PathCreationBenchmarks.AbsoluteFilePath", null, "Create (absolute file)"), + new("PathCreationBenchmarks.RelativeFilePath", null, "Create (relative file)"), + new("PathCreationBenchmarks.FileNameType", null, "Create (file name)"), + new("PathOperationBenchmarks.FileName", null, "FileName (uncached)"), + new("PathOperationBenchmarks.FileNameWithoutExtension", null, "FileName (cached)"), + new("PathOperationBenchmarks.AsAbsolute", null, "AsAbsolute (from relative)"), + new("PathOperationBenchmarks.AsRelative", null, "AsRelative (from absolute)"), + new("PathOperationBenchmarks.RemoveExtension", null, "RemoveExtension"), + ]), }; /// <summary> From fc47058eb91a7d7b6e9078122723ac0d8ce42f8e Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:40:17 +1000 Subject: [PATCH 16/26] Explain the Create ratio's outlier size in the source, not just the report 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> --- .../Paths/PathAbstractionCostBenchmarks.cs | 26 ++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs b/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs index f36caa33..38c47472 100644 --- a/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs +++ b/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs @@ -29,6 +29,27 @@ namespace ktsu.Semantics.Benchmarks.Paths; /// unchanging operand is loop-invariant, the JIT hoists it, and a ratio between two hoisted /// methods means nothing. /// </para> +/// <para> +/// <b>Read <see cref="SemanticCreate"/>'s ratio differently from the other three.</b> It is the +/// largest by a wide margin, and that is a fact about its baseline rather than about creation. The +/// semantic side costs about the same in all four categories, because all four are dominated by the +/// same reflection and validation; what differs is what each is measured against. +/// <see cref="BareFileName"/>, <see cref="BareAsAbsolute"/> and <see cref="BareAsRelative"/> each do +/// real string work, while <see cref="BareCreate"/> is a single boolean check costing a few +/// nanoseconds. Dividing a roughly constant numerator by a much smaller denominator is most of the +/// difference between 1,810 and the double- and triple-digit ratios above it. +/// </para> +/// <para> +/// <b>And <see cref="BareCreate"/> is deliberately narrower than the validator it stands in for.</b> +/// <c>IsAbsolutePathAttribute</c> asks +/// <c>Path.IsPathFullyQualified(value + Path.DirectorySeparatorChar)</c>, concatenating and +/// allocating first; the baseline here asks the question without the separator. That is not an +/// oversight, and it is worth roughly a fourteenfold difference in the ratio on its own, so it is +/// worth saying why: a baseline in this class is <i>the code a caller would otherwise write</i>, and +/// a caller checking whether a path is absolute writes the plain check. Appending a separator is the +/// library's own way of handling edge cases, so it belongs on the library's side of the comparison, +/// which is exactly what the ratio is meant to report. +/// </para> /// </remarks> [MemoryDiagnoser] [GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] @@ -154,7 +175,10 @@ public int SemanticAsRelative() return accumulator; } - /// <summary>Checking a path is rooted by hand, which is what creation validates.</summary> + /// <summary> + /// Checking a path is rooted by hand, which is what creation validates — modulo the separator the + /// validator appends before asking. + /// </summary> /// <returns>The count of rooted paths, which is every iteration.</returns> [BenchmarkCategory("Create")] [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] From 907d45da165a035655bdf9fc22ac97c89268d631 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:44:06 +1000 Subject: [PATCH 17/26] Describe the panel the strings chart actually draws 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. --- .../plans/2026-09-18-strings-paths-benchmarks.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md index da8e93c5..1626ebe8 100644 --- a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md +++ b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md @@ -1461,7 +1461,14 @@ Extend the `Subjects` XML documentation with two paragraphs, placed after the ex /// creation, so the top row walks from the reflection machinery alone up through a character /// set check, a format check and a mod-97 check. The bottom row is what a caller pays around /// that: both failure paths, the cross-type conversion that is secretly another creation, and - /// an ordering that should be the underlying string's own. + /// the hash a dictionary of semantic strings pays on every lookup. + /// </para> + /// <para> + /// Ordering is measured and stored, and deliberately not drawn. A semantic string's + /// <c>CompareTo</c> is the underlying string's own, over operands a loop does not change, so + /// the JIT hoists it and BenchmarkDotNet reports it as indistinguishable from an empty method — + /// the same reason no bare quantity operator is drawn above. A panel of it would chart the + /// harness's resolution rather than any release. /// </para> /// <para> /// <b>Paths</b> is the same shape: build each kind, then operate on one. The two file name From 30ba9b51f19b9c192e1e808b34ac6b42009a8d9f Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:44:26 +1000 Subject: [PATCH 18/26] Fix strings XML doc to describe HashCode, not CompareTo 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> --- scripts/benchmark-history.cs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/scripts/benchmark-history.cs b/scripts/benchmark-history.cs index c841155d..6e8bc575 100644 --- a/scripts/benchmark-history.cs +++ b/scripts/benchmark-history.cs @@ -68,7 +68,14 @@ private sealed record PanelSpec(string Key, string? Parameters, string Label); /// creation, so the top row walks from the reflection machinery alone up through a character /// set check, a format check and a mod-97 check. The bottom row is what a caller pays around /// that: both failure paths, the cross-type conversion that is secretly another creation, and - /// an ordering that should be the underlying string's own. + /// the hash a dictionary of semantic strings pays on every lookup. + /// </para> + /// <para> + /// Ordering is measured and stored, and deliberately not drawn. A semantic string's + /// <c>CompareTo</c> is the underlying string's own, over operands a loop does not change, so + /// the JIT hoists it and BenchmarkDotNet reports it as indistinguishable from an empty method — + /// the same reason no bare quantity operator is drawn above. A panel of it would chart the + /// harness's resolution rather than any release. /// </para> /// <para> /// <b>Paths</b> is the same shape: build each kind, then operate on one. The two file name From 7486e2bb3165214ec90d7a69fd8202c8e2532b76 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:46:26 +1000 Subject: [PATCH 19/26] Measure three subjects per run 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> --- .github/workflows/benchmark-history.yml | 166 ++++++++++++++---------- 1 file changed, 100 insertions(+), 66 deletions(-) diff --git a/.github/workflows/benchmark-history.yml b/.github/workflows/benchmark-history.yml index 57841c2d..a3733d24 100644 --- a/.github/workflows/benchmark-history.yml +++ b/.github/workflows/benchmark-history.yml @@ -16,6 +16,14 @@ name: Benchmark History # The backfill running as a single job still matters: separate runs land on different CI hosts, # and that difference is larger than most releases are. Within one job the points are comparable # as they stand; across jobs, BaselineBenchmarks is what ties them together. +# +# Three subjects share one job: quantities, strings, and paths. One job rather than a matrix so +# that the reference workload is measured once and stamped on every entry the run produces -- which +# is what lets a strings point and a quantities point from the same run be compared at all. It also +# keeps the results to a single push. +# +# The cost is wall clock, and `subjects` on the dispatch is the answer to that: a long backfill is +# split by subject across runs rather than by raising the timeout. on: release: @@ -27,6 +35,11 @@ on: required: false default: "3.3.1 4.0.0 4.1.0 4.2.0 4.3.2 5.0.0 5.1.0 5.2.0 5.2.4 5.3.2" type: string + subjects: + description: "Space-separated subjects to measure: quantities strings paths" + required: false + default: "quantities strings paths" + type: string permissions: contents: write @@ -37,28 +50,30 @@ concurrency: env: DOTNET_VERSION: "10.0" - HISTORY: docs/benchmarks/history.json - CHART: docs/benchmarks/performance.svg # Already ignored, and ktsu.Sdk regenerates .gitignore on build so a new entry would not last. RUNS: BenchmarkDotNet.Artifacts - # The set drawn in the README. One operation per storage type rather than every operation at one - # storage type: a quantity is a value type over T and does almost nothing of its own, so what a - # release changes it changes per storage type. - HEADLINE_FILTER: >- - *ConstructionBenchmarks*FromNauticalMile - *UnitConversionBenchmarks*InNauticalMile - *OperatorBenchmarks*LengthTimesLength - *VectorBenchmarks*.Length - *ComparisonBenchmarks*CompareToInterface # Short runs: three iterations is enough for a trend line, and a release should not tie up a # runner for half an hour. BENCHMARK_JOB: short + # One line per chart: name|history|chart|filter. An environment variable cannot hold an array, + # and three steps need the same three triples, so this is the one place they are written. + # + # Each filter is one operation per panel drawn. What varies between subjects is the axis: a + # quantity is a value type over T and does almost nothing of its own, so its release changes land + # per storage type; a semantic string spends its cost at creation, so its axis is how much + # validation the type declares. + SUBJECTS: | + quantities|docs/benchmarks/history.json|docs/benchmarks/performance.svg|*ConstructionBenchmarks*FromNauticalMile *UnitConversionBenchmarks*InNauticalMile *OperatorBenchmarks*LengthTimesLength *VectorBenchmarks*.Length *ComparisonBenchmarks*CompareToInterface + strings|docs/benchmarks/strings-history.json|docs/benchmarks/strings-performance.svg|*StringCreationBenchmarks* *StringOperationBenchmarks* + paths|docs/benchmarks/paths-history.json|docs/benchmarks/paths-performance.svg|*PathCreationBenchmarks* *PathOperationBenchmarks* jobs: measure: name: Measure and chart runs-on: ubuntu-latest - timeout-minutes: 240 + # Three subjects rather than one. The dispatch's `subjects` input is the intended way to split + # a long backfill across runs; raising this number further is not. + timeout-minutes: 360 steps: - name: Checkout Repository @@ -101,19 +116,24 @@ jobs: work="${RUNNER_TEMP}/bench-$version" git worktree add --detach "$work" "$TAG" - (cd "$work" && dotnet run -c Release --project Semantics.Benchmarks -- \ - --filter $HEADLINE_FILTER \ - --job "$BENCHMARK_JOB" \ - --artifacts "$GITHUB_WORKSPACE/$RUNS/$version") - - dotnet run scripts/benchmark-history.cs -- ingest \ - --history "$HISTORY" \ - --results "$RUNS/$version" \ - --version "$version" \ - --commit "$(git rev-parse --short "$TAG^{commit}")" \ - --date "$(git log -1 --format=%cs "$TAG")" \ - --run-id "${{ github.run_id }}" \ - --baseline-ns "${{ steps.baseline.outputs.ns }}" + while IFS='|' read -r subject history chart filter; do + [ -n "$subject" ] || continue + echo "::group::$subject $version" + (cd "$work" && dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter $filter \ + --job "$BENCHMARK_JOB" \ + --artifacts "$GITHUB_WORKSPACE/$RUNS/$subject/$version") + + dotnet run scripts/benchmark-history.cs -- ingest \ + --history "$history" \ + --results "$RUNS/$subject/$version" \ + --version "$version" \ + --commit "$(git rev-parse --short "$TAG^{commit}")" \ + --date "$(git log -1 --format=%cs "$TAG")" \ + --run-id "${{ github.run_id }}" \ + --baseline-ns "${{ steps.baseline.outputs.ns }}" + echo "::endgroup::" + done <<< "$SUBJECTS" git worktree remove --force "$work" @@ -122,56 +142,70 @@ jobs: shell: bash env: VERSIONS: ${{ inputs.versions }} + SUBJECTS_WANTED: ${{ inputs.subjects }} BASELINE_NS: ${{ steps.baseline.outputs.ns }} run: | set -euo pipefail read -ra versions <<< "$VERSIONS" + read -ra wanted <<< "$SUBJECTS_WANTED" for version in "${versions[@]}"; do - echo "::group::$version" - # Through the environment rather than a -p: switch, because BenchmarkDotNet generates - # and builds a project of its own per run, which a property passed on the command line - # does not reach. MSBuild reads environment variables as properties in every project. - # - # A version whose API the current benchmarks cannot express is reported and skipped, - # rather than failing the whole backfill after the ones before it have been measured. - if ! BenchmarkAgainstVersion="$version" dotnet run -c Release --project Semantics.Benchmarks -- \ - --filter $HEADLINE_FILTER \ - --job "$BENCHMARK_JOB" \ - --artifacts "$GITHUB_WORKSPACE/$RUNS/$version"; then - echo "::warning::$version could not be benchmarked by the current suite; skipping" + while IFS='|' read -r subject history chart filter; do + [ -n "$subject" ] || continue + # Skip a subject the dispatch did not ask for. + case " ${wanted[*]} " in *" $subject "*) ;; *) continue ;; esac + + echo "::group::$subject $version" + # Through the environment rather than a -p: switch, because BenchmarkDotNet generates + # and builds a project of its own per run, which a property passed on the command line + # does not reach. MSBuild reads environment variables as properties in every project. + if ! BenchmarkAgainstVersion="$version" dotnet run -c Release --project Semantics.Benchmarks -- \ + --filter $filter \ + --job "$BENCHMARK_JOB" \ + --artifacts "$GITHUB_WORKSPACE/$RUNS/$subject/$version"; then + echo "::warning::$subject $version could not be benchmarked by the current suite; skipping" + echo "::endgroup::" + continue + fi + + tag="v$version" + commit="" + date="" + if git rev-parse -q --verify "$tag^{commit}" >/dev/null; then + commit="$(git rev-parse --short "$tag^{commit}")" + date="$(git log -1 --format=%cs "$tag")" + fi + + # Skipped here too: a package can build against these benchmarks and still throw from + # every one of them at run time, which BenchmarkDotNet reports as a table of NA rather + # than as a failure. Ingest refuses such a run, and the backfill carries on. + if ! dotnet run scripts/benchmark-history.cs -- ingest \ + --history "$history" \ + --results "$RUNS/$subject/$version" \ + --version "$version" \ + --commit "$commit" \ + --date "$date" \ + --run-id "${{ github.run_id }}" \ + --baseline-ns "$BASELINE_NS"; then + echo "::warning::$subject $version produced no usable measurement; skipping" + fi echo "::endgroup::" - continue - fi - - tag="v$version" - commit="" - date="" - if git rev-parse -q --verify "$tag^{commit}" >/dev/null; then - commit="$(git rev-parse --short "$tag^{commit}")" - date="$(git log -1 --format=%cs "$tag")" - fi - - # Skipped here too, and for the same reason: a package can build against these - # benchmarks and still throw from every one of them at run time, which BenchmarkDotNet - # reports as a table of NA rather than as a failure. Ingest refuses such a run, and - # the backfill carries on to the next version. - if ! dotnet run scripts/benchmark-history.cs -- ingest \ - --history "$HISTORY" \ - --results "$RUNS/$version" \ - --version "$version" \ - --commit "$commit" \ - --date "$date" \ - --run-id "${{ github.run_id }}" \ - --baseline-ns "$BASELINE_NS"; then - echo "::warning::$version produced no usable measurement; skipping" - fi - echo "::endgroup::" + done <<< "$SUBJECTS" done - - name: Redraw the chart + - name: Redraw the charts shell: bash - run: dotnet run scripts/benchmark-history.cs -- render --history "$HISTORY" --out "$CHART" + run: | + set -euo pipefail + while IFS='|' read -r subject history chart filter; do + [ -n "$subject" ] || continue + if [ ! -f "$history" ]; then + echo "No history for $subject yet; nothing to draw." + continue + fi + dotnet run scripts/benchmark-history.cs -- render \ + --subject "$subject" --history "$history" --out "$chart" + done <<< "$SUBJECTS" - name: Commit the history and the chart shell: bash @@ -182,7 +216,7 @@ jobs: # Staged first, then compared against the index: on the first run these files are new, # and `git diff` alone does not see an untracked file, so the run would push nothing and # still report success. - git add "$HISTORY" "${CHART%.svg}"*.svg + git add docs/benchmarks/ if git diff --cached --quiet; then echo "Nothing changed." exit 0 From bf0c251af4c2c108ccd749de9b55258d56ebe1cc Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 12:53:17 +1000 Subject: [PATCH 20/26] Redirect stdin from /dev/null in the subject loops 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> --- .github/workflows/benchmark-history.yml | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/.github/workflows/benchmark-history.yml b/.github/workflows/benchmark-history.yml index a3733d24..225ad967 100644 --- a/.github/workflows/benchmark-history.yml +++ b/.github/workflows/benchmark-history.yml @@ -116,13 +116,17 @@ jobs: work="${RUNNER_TEMP}/bench-$version" git worktree add --detach "$work" "$TAG" + # Every command in these loops inherits the here-string as stdin. A process that drains it + # would truncate the loop to one iteration and still exit zero, silently dropping subjects, + # so each invocation reads from /dev/null instead. BenchmarkDotNet does prompt when a + # filter selects nothing, which the backfill treats as an ordinary skip. while IFS='|' read -r subject history chart filter; do [ -n "$subject" ] || continue echo "::group::$subject $version" (cd "$work" && dotnet run -c Release --project Semantics.Benchmarks -- \ --filter $filter \ --job "$BENCHMARK_JOB" \ - --artifacts "$GITHUB_WORKSPACE/$RUNS/$subject/$version") + --artifacts "$GITHUB_WORKSPACE/$RUNS/$subject/$version" </dev/null) dotnet run scripts/benchmark-history.cs -- ingest \ --history "$history" \ @@ -131,7 +135,7 @@ jobs: --commit "$(git rev-parse --short "$TAG^{commit}")" \ --date "$(git log -1 --format=%cs "$TAG")" \ --run-id "${{ github.run_id }}" \ - --baseline-ns "${{ steps.baseline.outputs.ns }}" + --baseline-ns "${{ steps.baseline.outputs.ns }}" </dev/null echo "::endgroup::" done <<< "$SUBJECTS" @@ -162,7 +166,7 @@ jobs: if ! BenchmarkAgainstVersion="$version" dotnet run -c Release --project Semantics.Benchmarks -- \ --filter $filter \ --job "$BENCHMARK_JOB" \ - --artifacts "$GITHUB_WORKSPACE/$RUNS/$subject/$version"; then + --artifacts "$GITHUB_WORKSPACE/$RUNS/$subject/$version" </dev/null; then echo "::warning::$subject $version could not be benchmarked by the current suite; skipping" echo "::endgroup::" continue @@ -186,7 +190,7 @@ jobs: --commit "$commit" \ --date "$date" \ --run-id "${{ github.run_id }}" \ - --baseline-ns "$BASELINE_NS"; then + --baseline-ns "$BASELINE_NS" </dev/null; then echo "::warning::$subject $version produced no usable measurement; skipping" fi echo "::endgroup::" @@ -204,7 +208,7 @@ jobs: continue fi dotnet run scripts/benchmark-history.cs -- render \ - --subject "$subject" --history "$history" --out "$chart" + --subject "$subject" --history "$history" --out "$chart" </dev/null done <<< "$SUBJECTS" - name: Commit the history and the chart From 62cba1b7430a3d0a744fc1ace7b50d3d2bd6186d Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 13:10:15 +1000 Subject: [PATCH 21/26] Record what the validation ladder actually measured 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. --- .../plans/2026-09-18-strings-paths-benchmarks.md | 6 +++--- .../specs/2026-09-18-strings-paths-benchmarks-design.md | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md index 1626ebe8..e51874c1 100644 --- a/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md +++ b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md @@ -388,7 +388,7 @@ using ktsu.Semantics.Strings.Identifiers; /// actually holds. They also happen to span the interesting ground: <see cref="CharsetRegex"/> and /// <see cref="FormatRegex"/> are interpreted regular expressions looked up from the static cache /// on every call and carrying a one-second timeout, <see cref="Checksum"/> is a hand-written Luhn -/// pass over the same sort of input, and <see cref="Mod97"/> is the heaviest shipped validator. +/// pass over the same sort of input, and <see cref="Mod97"/> a rearrange-expand-and-modulo pass. /// </para> /// <para> /// <b>Both failure paths are here.</b> <see cref="TryCreateRejects"/> and @@ -440,7 +440,7 @@ public class StringCreationBenchmarks [Benchmark] public CreditCardNumber Checksum() => CreditCardNumber.Create(card); - /// <summary>The heaviest shipped validator: rearrangement, expansion, modular arithmetic.</summary> + /// <summary>A rearrangement, a character-to-digit expansion, and modular arithmetic.</summary> /// <returns>The created value.</returns> [Benchmark] public Iban Mod97() => Iban.Create(iban); @@ -1842,7 +1842,7 @@ Expected: each prints `ingested 5.3.4: N benchmarks, baseline <NS> ns, cpu ...`. | Expectation | Why it must hold | |---|---| | `StringCreationBenchmarks.Unvalidated` is the fastest creation row | It is the reflection machinery with no validator. Anything faster means another row is not running its validator. | -| `StringCreationBenchmarks.Mod97` is the slowest creation row | `Iban` is the heaviest shipped validator. | +| The four validator rows all land within ~340 ns of each other, on a floor of ~1,650 ns | Measured in Task 9. The validator is a minor term and the reflection machinery is the bill. `Mod97` and `FormatRegex` are a tie within noise, so do not expect a stable ordering between them. **The failure mode to watch for is a validator row at or near the unvalidated floor**, which would mean its validator is not running. | | `CreateThrows` and `TryCreateRejects` cost about the same, and both cost far more than any success row | Established by measurement in Task 2, and it is a property of the library rather than of the benchmark: `SemanticString.TryFromString` is implemented as `try { Create(...) } catch (ArgumentException) { return false; }`, so `TryCreate` throws and catches internally on every rejection. Both rows therefore pay a full .NET exception. **If instead either row is cheap and close to a success row, the specimen is being accepted and the rejection is not happening** — that is the failure mode to watch for. | | Every string creation row allocates more than 0 bytes | A semantic string is a reference type. A zero here means the allocation is not being counted and `[MemoryDiagnoser]` is missing or the row did not run. | | `PathOperationBenchmarks.FileNameWithoutExtension` is far cheaper than `.FileName` | One caches into a field, the other rebuilds and revalidates. | diff --git a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md index b7ec963a..3a87af86 100644 --- a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md +++ b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md @@ -79,7 +79,7 @@ interpreted regular expressions, hand-written checksums, and modular arithmetic. | `CharsetRegex` | `Ulid` | That floor plus an interpreted `Regex.IsMatch` over a fixed character set. | | `FormatRegex` | `Uuid` | The same, over a pattern with groups and separators. | | `Checksum` | `CreditCardNumber` | The floor plus a hand-written Luhn pass. Paired against the two above, this is regular expressions against arithmetic at comparable input lengths. | -| `Mod97` | `Iban` | The heaviest shipped validator: rearrangement, character-to-digit expansion, modular arithmetic. | +| `Mod97` | `Iban` | A rearrangement, a character-to-digit expansion, and modular arithmetic. Predicted to be the heaviest validator; measured as a tie with `FormatRegex`. | | `TryCreateRejects` | `Uuid` | The failure path that does not throw. | | `CreateThrows` | `Uuid` | The failure path that does, so the cost of choosing `Create` over `TryCreate` at a boundary that sees bad input is a number rather than a guess. | From cdcb7925f2d2968a12da92171e8faa141f49ee72 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 13:10:43 +1000 Subject: [PATCH 22/26] Seed the strings and paths histories at 5.3.4 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> --- .../Strings/StringCreationBenchmarks.cs | 15 ++- docs/benchmarks/paths-history.json | 76 +++++++++++++++ docs/benchmarks/paths-performance-dark.svg | 87 +++++++++++++++++ docs/benchmarks/paths-performance.svg | 87 +++++++++++++++++ docs/benchmarks/strings-history.json | 94 +++++++++++++++++++ docs/benchmarks/strings-performance-dark.svg | 87 +++++++++++++++++ docs/benchmarks/strings-performance.svg | 87 +++++++++++++++++ 7 files changed, 531 insertions(+), 2 deletions(-) create mode 100644 docs/benchmarks/paths-history.json create mode 100644 docs/benchmarks/paths-performance-dark.svg create mode 100644 docs/benchmarks/paths-performance.svg create mode 100644 docs/benchmarks/strings-history.json create mode 100644 docs/benchmarks/strings-performance-dark.svg create mode 100644 docs/benchmarks/strings-performance.svg diff --git a/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs b/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs index a560bc1b..e817a0a7 100644 --- a/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs +++ b/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs @@ -25,7 +25,18 @@ namespace ktsu.Semantics.Benchmarks.Strings; /// actually holds. They also happen to span the interesting ground: <see cref="CharsetRegex"/> and /// <see cref="FormatRegex"/> are interpreted regular expressions looked up from the static cache /// on every call and carrying a one-second timeout, <see cref="Checksum"/> is a hand-written Luhn -/// pass over the same sort of input, and <see cref="Mod97"/> is the heaviest shipped validator. +/// pass over the same sort of input, and <see cref="Mod97"/> is a rearrangement, a +/// character-to-digit expansion, and modular arithmetic. +/// </para> +/// <para> +/// <b>What the ladder turned out to show.</b> The four validators land within about 340 ns of each +/// other on a floor of roughly 1,650 ns, so the validator is a minor term and the reflection +/// machinery is the bill: a Luhn pass, a character-set regular expression, a mod-97 pass and a +/// format regular expression cost 0.93, 1.06, 1.26 and 1.27 microseconds on top of it. The last two +/// are a tie within the noise, which is worth stating because the expectation going in was that the +/// checksum work would dominate. An interpreted regular expression carrying a timeout and looked up +/// from the static cache on every call is simply not cheap next to arithmetic over twenty-two +/// characters. /// </para> /// <para> /// <b>Both failure paths are here, and they cost the same.</b> <see cref="TryCreateRejects"/> and @@ -81,7 +92,7 @@ public void Setup() [Benchmark] public CreditCardNumber Checksum() => CreditCardNumber.Create(card); - /// <summary>The heaviest shipped validator: rearrangement, expansion, modular arithmetic.</summary> + /// <summary>A rearrangement, a character-to-digit expansion, and modular arithmetic.</summary> /// <returns>The created value.</returns> [Benchmark] public Iban Mod97() => Iban.Create(iban); diff --git a/docs/benchmarks/paths-history.json b/docs/benchmarks/paths-history.json new file mode 100644 index 00000000..ff26dfcd --- /dev/null +++ b/docs/benchmarks/paths-history.json @@ -0,0 +1,76 @@ +{ + "schemaVersion": 1, + "entries": [ + { + "version": "5.3.4", + "commit": "8e401ee", + "date": "2026-09-16", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4598.085, + "allocatedBytes": 1712 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 4848.9339, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2490.8984, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4472.5624, + "allocatedBytes": 1728 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5259.1688, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5468.5588, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4128.0039, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2542.5893, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.6714, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4674.3001, + "allocatedBytes": 1904 + } + } + } + } + ] +} diff --git a/docs/benchmarks/paths-performance-dark.svg b/docs/benchmarks/paths-performance-dark.svg new file mode 100644 index 00000000..16904f1b --- /dev/null +++ b/docs/benchmarks/paths-performance-dark.svg @@ -0,0 +1,87 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="992" height="722" viewBox="0 0 992 722" role="img" aria-label="Semantics.Paths allocation and relative time per release"> +<style> + text { font-family: ui-sans-serif, -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #ffffff; } + .title { font-size: 15px; font-weight: 600; } + .section { font-size: 12.5px; font-weight: 600; } + .panel-title { font-size: 11px; font-weight: 600; fill: #ffffff; } + .muted, .muted-value, .caption { font-size: 9.5px; fill: #c3c2b7; } + .value { font-size: 10px; font-weight: 600; fill: #ffffff; } + .tick { font-size: 9px; fill: #c3c2b7; } + .axis { stroke: #333330; stroke-width: 1; } +</style> +<rect width="992" height="722" fill="#1a1a19" /> +<text x="56" y="28" class="title">Semantics.Paths performance by release</text> +<text x="56" y="45" class="caption">1 releases · newest 5.3.4 · 2026-09-16</text> +<rect x="56" y="62" width="9" height="9" rx="2" fill="#3987e5" /> +<text x="71" y="70" class="section">Allocated bytes per operation</text> +<text x="71" y="84" class="caption">Deterministic: the same code allocates the same bytes on any machine.</text> +<text x="56" y="108" class="panel-title">Create (absolute file)</text> +<line x1="62" y1="198.0" x2="258" y2="198.0" class="axis" /> +<circle cx="160.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="160.0" y="126.6" class="value" text-anchor="end">1.8 KB</text> +<text x="284" y="108" class="panel-title">Create (relative file)</text> +<line x1="290" y1="198.0" x2="486" y2="198.0" class="axis" /> +<circle cx="388.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="388.0" y="126.6" class="value" text-anchor="end">1.7 KB</text> +<text x="512" y="108" class="panel-title">Create (file name)</text> +<line x1="518" y1="198.0" x2="714" y2="198.0" class="axis" /> +<circle cx="616.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="616.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="740" y="108" class="panel-title">FileName (uncached)</text> +<line x1="746" y1="198.0" x2="942" y2="198.0" class="axis" /> +<circle cx="844.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="844.0" y="126.6" class="value" text-anchor="end">944 B</text> +<text x="56" y="240" class="panel-title">FileName (cached)</text> +<line x1="62" y1="330.0" x2="258" y2="330.0" class="axis" /> +<circle cx="160.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="160.0" y="321.0" class="value" text-anchor="end">0 B</text> +<text x="284" y="240" class="panel-title">AsAbsolute (from relative)</text> +<line x1="290" y1="330.0" x2="486" y2="330.0" class="axis" /> +<circle cx="388.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="388.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<text x="512" y="240" class="panel-title">AsRelative (from absolute)</text> +<line x1="518" y1="330.0" x2="714" y2="330.0" class="axis" /> +<circle cx="616.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="616.0" y="258.6" class="value" text-anchor="end">1.7 KB</text> +<text x="740" y="240" class="panel-title">RemoveExtension</text> +<line x1="746" y1="330.0" x2="942" y2="330.0" class="axis" /> +<circle cx="844.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="844.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<rect x="56" y="360" width="9" height="9" rx="2" fill="#d95926" /> +<text x="71" y="368" class="section">Time, as a multiple of a fixed reference workload</text> +<text x="71" y="382" class="caption">Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster.</text> +<text x="56" y="406" class="panel-title">Create (absolute file)</text> +<line x1="62" y1="496.0" x2="258" y2="496.0" class="axis" /> +<circle cx="160.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="160.0" y="424.6" class="value" text-anchor="end">6.49×</text> +<text x="284" y="406" class="panel-title">Create (relative file)</text> +<line x1="290" y1="496.0" x2="486" y2="496.0" class="axis" /> +<circle cx="388.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="388.0" y="424.6" class="value" text-anchor="end">5.98×</text> +<text x="512" y="406" class="panel-title">Create (file name)</text> +<line x1="518" y1="496.0" x2="714" y2="496.0" class="axis" /> +<circle cx="616.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="616.0" y="424.6" class="value" text-anchor="end">3.33×</text> +<text x="740" y="406" class="panel-title">FileName (uncached)</text> +<line x1="746" y1="496.0" x2="942" y2="496.0" class="axis" /> +<circle cx="844.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="844.0" y="424.6" class="value" text-anchor="end">3.40×</text> +<text x="56" y="538" class="panel-title">FileName (cached)</text> +<line x1="62" y1="628.0" x2="258" y2="628.0" class="axis" /> +<circle cx="160.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="160.0" y="556.6" class="value" text-anchor="end">0.0022×</text> +<text x="284" y="538" class="panel-title">AsAbsolute (from relative)</text> +<line x1="290" y1="628.0" x2="486" y2="628.0" class="axis" /> +<circle cx="388.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="388.0" y="556.6" class="value" text-anchor="end">7.04×</text> +<text x="512" y="538" class="panel-title">AsRelative (from absolute)</text> +<line x1="518" y1="628.0" x2="714" y2="628.0" class="axis" /> +<circle cx="616.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="616.0" y="556.6" class="value" text-anchor="end">7.32×</text> +<text x="740" y="538" class="panel-title">RemoveExtension</text> +<line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> +<circle cx="844.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="844.0" y="556.6" class="value" text-anchor="end">6.25×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 5.3.4</text> +<text x="56" y="680" class="caption">Measured on Intel Core i9-14900. Full tables: Semantics.Benchmarks.</text> +</svg> diff --git a/docs/benchmarks/paths-performance.svg b/docs/benchmarks/paths-performance.svg new file mode 100644 index 00000000..9dd36690 --- /dev/null +++ b/docs/benchmarks/paths-performance.svg @@ -0,0 +1,87 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="992" height="722" viewBox="0 0 992 722" role="img" aria-label="Semantics.Paths allocation and relative time per release"> +<style> + text { font-family: ui-sans-serif, -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #0b0b0b; } + .title { font-size: 15px; font-weight: 600; } + .section { font-size: 12.5px; font-weight: 600; } + .panel-title { font-size: 11px; font-weight: 600; fill: #0b0b0b; } + .muted, .muted-value, .caption { font-size: 9.5px; fill: #52514e; } + .value { font-size: 10px; font-weight: 600; fill: #0b0b0b; } + .tick { font-size: 9px; fill: #52514e; } + .axis { stroke: #e4e3df; stroke-width: 1; } +</style> +<rect width="992" height="722" fill="#fcfcfb" /> +<text x="56" y="28" class="title">Semantics.Paths performance by release</text> +<text x="56" y="45" class="caption">1 releases · newest 5.3.4 · 2026-09-16</text> +<rect x="56" y="62" width="9" height="9" rx="2" fill="#2a78d6" /> +<text x="71" y="70" class="section">Allocated bytes per operation</text> +<text x="71" y="84" class="caption">Deterministic: the same code allocates the same bytes on any machine.</text> +<text x="56" y="108" class="panel-title">Create (absolute file)</text> +<line x1="62" y1="198.0" x2="258" y2="198.0" class="axis" /> +<circle cx="160.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="160.0" y="126.6" class="value" text-anchor="end">1.8 KB</text> +<text x="284" y="108" class="panel-title">Create (relative file)</text> +<line x1="290" y1="198.0" x2="486" y2="198.0" class="axis" /> +<circle cx="388.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="388.0" y="126.6" class="value" text-anchor="end">1.7 KB</text> +<text x="512" y="108" class="panel-title">Create (file name)</text> +<line x1="518" y1="198.0" x2="714" y2="198.0" class="axis" /> +<circle cx="616.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="616.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="740" y="108" class="panel-title">FileName (uncached)</text> +<line x1="746" y1="198.0" x2="942" y2="198.0" class="axis" /> +<circle cx="844.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="844.0" y="126.6" class="value" text-anchor="end">944 B</text> +<text x="56" y="240" class="panel-title">FileName (cached)</text> +<line x1="62" y1="330.0" x2="258" y2="330.0" class="axis" /> +<circle cx="160.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="160.0" y="321.0" class="value" text-anchor="end">0 B</text> +<text x="284" y="240" class="panel-title">AsAbsolute (from relative)</text> +<line x1="290" y1="330.0" x2="486" y2="330.0" class="axis" /> +<circle cx="388.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="388.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<text x="512" y="240" class="panel-title">AsRelative (from absolute)</text> +<line x1="518" y1="330.0" x2="714" y2="330.0" class="axis" /> +<circle cx="616.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="616.0" y="258.6" class="value" text-anchor="end">1.7 KB</text> +<text x="740" y="240" class="panel-title">RemoveExtension</text> +<line x1="746" y1="330.0" x2="942" y2="330.0" class="axis" /> +<circle cx="844.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="844.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<rect x="56" y="360" width="9" height="9" rx="2" fill="#eb6834" /> +<text x="71" y="368" class="section">Time, as a multiple of a fixed reference workload</text> +<text x="71" y="382" class="caption">Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster.</text> +<text x="56" y="406" class="panel-title">Create (absolute file)</text> +<line x1="62" y1="496.0" x2="258" y2="496.0" class="axis" /> +<circle cx="160.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="160.0" y="424.6" class="value" text-anchor="end">6.49×</text> +<text x="284" y="406" class="panel-title">Create (relative file)</text> +<line x1="290" y1="496.0" x2="486" y2="496.0" class="axis" /> +<circle cx="388.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="388.0" y="424.6" class="value" text-anchor="end">5.98×</text> +<text x="512" y="406" class="panel-title">Create (file name)</text> +<line x1="518" y1="496.0" x2="714" y2="496.0" class="axis" /> +<circle cx="616.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="616.0" y="424.6" class="value" text-anchor="end">3.33×</text> +<text x="740" y="406" class="panel-title">FileName (uncached)</text> +<line x1="746" y1="496.0" x2="942" y2="496.0" class="axis" /> +<circle cx="844.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="844.0" y="424.6" class="value" text-anchor="end">3.40×</text> +<text x="56" y="538" class="panel-title">FileName (cached)</text> +<line x1="62" y1="628.0" x2="258" y2="628.0" class="axis" /> +<circle cx="160.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="160.0" y="556.6" class="value" text-anchor="end">0.0022×</text> +<text x="284" y="538" class="panel-title">AsAbsolute (from relative)</text> +<line x1="290" y1="628.0" x2="486" y2="628.0" class="axis" /> +<circle cx="388.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="388.0" y="556.6" class="value" text-anchor="end">7.04×</text> +<text x="512" y="538" class="panel-title">AsRelative (from absolute)</text> +<line x1="518" y1="628.0" x2="714" y2="628.0" class="axis" /> +<circle cx="616.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="616.0" y="556.6" class="value" text-anchor="end">7.32×</text> +<text x="740" y="538" class="panel-title">RemoveExtension</text> +<line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> +<circle cx="844.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="844.0" y="556.6" class="value" text-anchor="end">6.25×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 5.3.4</text> +<text x="56" y="680" class="caption">Measured on Intel Core i9-14900. Full tables: Semantics.Benchmarks.</text> +</svg> diff --git a/docs/benchmarks/strings-history.json b/docs/benchmarks/strings-history.json new file mode 100644 index 00000000..baa27cab --- /dev/null +++ b/docs/benchmarks/strings-history.json @@ -0,0 +1,94 @@ +{ + "schemaVersion": 1, + "entries": [ + { + "version": "5.3.4", + "commit": "8e401ee", + "date": "2026-09-16", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2712.9913, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2578.7094, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 6807.6602, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 2920.1192, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2912.5356, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6811.9949, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1652.5791, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1655.4552, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.5027, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.0221, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.5549, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 1.0345, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1753.4582, + "allocatedBytes": 616 + } + } + } + } + ] +} diff --git a/docs/benchmarks/strings-performance-dark.svg b/docs/benchmarks/strings-performance-dark.svg new file mode 100644 index 00000000..8043329d --- /dev/null +++ b/docs/benchmarks/strings-performance-dark.svg @@ -0,0 +1,87 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="992" height="722" viewBox="0 0 992 722" role="img" aria-label="Semantics.Strings allocation and relative time per release"> +<style> + text { font-family: ui-sans-serif, -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #ffffff; } + .title { font-size: 15px; font-weight: 600; } + .section { font-size: 12.5px; font-weight: 600; } + .panel-title { font-size: 11px; font-weight: 600; fill: #ffffff; } + .muted, .muted-value, .caption { font-size: 9.5px; fill: #c3c2b7; } + .value { font-size: 10px; font-weight: 600; fill: #ffffff; } + .tick { font-size: 9px; fill: #c3c2b7; } + .axis { stroke: #333330; stroke-width: 1; } +</style> +<rect width="992" height="722" fill="#1a1a19" /> +<text x="56" y="28" class="title">Semantics.Strings performance by release</text> +<text x="56" y="45" class="caption">1 releases · newest 5.3.4 · 2026-09-16</text> +<rect x="56" y="62" width="9" height="9" rx="2" fill="#3987e5" /> +<text x="71" y="70" class="section">Allocated bytes per operation</text> +<text x="71" y="84" class="caption">Deterministic: the same code allocates the same bytes on any machine.</text> +<text x="56" y="108" class="panel-title">Create (no validation)</text> +<line x1="62" y1="198.0" x2="258" y2="198.0" class="axis" /> +<circle cx="160.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="160.0" y="126.6" class="value" text-anchor="end">504 B</text> +<text x="284" y="108" class="panel-title">Create (charset regex)</text> +<line x1="290" y1="198.0" x2="486" y2="198.0" class="axis" /> +<circle cx="388.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="388.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="512" y="108" class="panel-title">Create (format regex)</text> +<line x1="518" y1="198.0" x2="714" y2="198.0" class="axis" /> +<circle cx="616.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="616.0" y="126.6" class="value" text-anchor="end">928 B</text> +<text x="740" y="108" class="panel-title">Create (mod-97)</text> +<line x1="746" y1="198.0" x2="942" y2="198.0" class="axis" /> +<circle cx="844.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="844.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="56" y="240" class="panel-title">TryCreate (rejects)</text> +<line x1="62" y1="330.0" x2="258" y2="330.0" class="axis" /> +<circle cx="160.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="160.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<text x="284" y="240" class="panel-title">Create (throws)</text> +<line x1="290" y1="330.0" x2="486" y2="330.0" class="axis" /> +<circle cx="388.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="388.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<text x="512" y="240" class="panel-title">As&lt;T&gt; conversion</text> +<line x1="518" y1="330.0" x2="714" y2="330.0" class="axis" /> +<circle cx="616.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="616.0" y="258.6" class="value" text-anchor="end">504 B</text> +<text x="740" y="240" class="panel-title">GetHashCode</text> +<line x1="746" y1="330.0" x2="942" y2="330.0" class="axis" /> +<circle cx="844.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="844.0" y="321.0" class="value" text-anchor="end">0 B</text> +<rect x="56" y="360" width="9" height="9" rx="2" fill="#d95926" /> +<text x="71" y="368" class="section">Time, as a multiple of a fixed reference workload</text> +<text x="71" y="382" class="caption">Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster.</text> +<text x="56" y="406" class="panel-title">Create (no validation)</text> +<line x1="62" y1="496.0" x2="258" y2="496.0" class="axis" /> +<circle cx="160.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="160.0" y="424.6" class="value" text-anchor="end">2.21×</text> +<text x="284" y="406" class="panel-title">Create (charset regex)</text> +<line x1="290" y1="496.0" x2="486" y2="496.0" class="axis" /> +<circle cx="388.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="388.0" y="424.6" class="value" text-anchor="end">3.63×</text> +<text x="512" y="406" class="panel-title">Create (format regex)</text> +<line x1="518" y1="496.0" x2="714" y2="496.0" class="axis" /> +<circle cx="616.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="616.0" y="424.6" class="value" text-anchor="end">3.91×</text> +<text x="740" y="406" class="panel-title">Create (mod-97)</text> +<line x1="746" y1="496.0" x2="942" y2="496.0" class="axis" /> +<circle cx="844.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="844.0" y="424.6" class="value" text-anchor="end">3.90×</text> +<text x="56" y="538" class="panel-title">TryCreate (rejects)</text> +<line x1="62" y1="628.0" x2="258" y2="628.0" class="axis" /> +<circle cx="160.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="160.0" y="556.6" class="value" text-anchor="end">9.11×</text> +<text x="284" y="538" class="panel-title">Create (throws)</text> +<line x1="290" y1="628.0" x2="486" y2="628.0" class="axis" /> +<circle cx="388.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="388.0" y="556.6" class="value" text-anchor="end">9.11×</text> +<text x="512" y="538" class="panel-title">As&lt;T&gt; conversion</text> +<line x1="518" y1="628.0" x2="714" y2="628.0" class="axis" /> +<circle cx="616.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="616.0" y="556.6" class="value" text-anchor="end">2.21×</text> +<text x="740" y="538" class="panel-title">GetHashCode</text> +<line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> +<circle cx="844.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="844.0" y="556.6" class="value" text-anchor="end">0.0569×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 5.3.4</text> +<text x="56" y="680" class="caption">Measured on Intel Core i9-14900. Full tables: Semantics.Benchmarks.</text> +</svg> diff --git a/docs/benchmarks/strings-performance.svg b/docs/benchmarks/strings-performance.svg new file mode 100644 index 00000000..e24bf79c --- /dev/null +++ b/docs/benchmarks/strings-performance.svg @@ -0,0 +1,87 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="992" height="722" viewBox="0 0 992 722" role="img" aria-label="Semantics.Strings allocation and relative time per release"> +<style> + text { font-family: ui-sans-serif, -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #0b0b0b; } + .title { font-size: 15px; font-weight: 600; } + .section { font-size: 12.5px; font-weight: 600; } + .panel-title { font-size: 11px; font-weight: 600; fill: #0b0b0b; } + .muted, .muted-value, .caption { font-size: 9.5px; fill: #52514e; } + .value { font-size: 10px; font-weight: 600; fill: #0b0b0b; } + .tick { font-size: 9px; fill: #52514e; } + .axis { stroke: #e4e3df; stroke-width: 1; } +</style> +<rect width="992" height="722" fill="#fcfcfb" /> +<text x="56" y="28" class="title">Semantics.Strings performance by release</text> +<text x="56" y="45" class="caption">1 releases · newest 5.3.4 · 2026-09-16</text> +<rect x="56" y="62" width="9" height="9" rx="2" fill="#2a78d6" /> +<text x="71" y="70" class="section">Allocated bytes per operation</text> +<text x="71" y="84" class="caption">Deterministic: the same code allocates the same bytes on any machine.</text> +<text x="56" y="108" class="panel-title">Create (no validation)</text> +<line x1="62" y1="198.0" x2="258" y2="198.0" class="axis" /> +<circle cx="160.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="160.0" y="126.6" class="value" text-anchor="end">504 B</text> +<text x="284" y="108" class="panel-title">Create (charset regex)</text> +<line x1="290" y1="198.0" x2="486" y2="198.0" class="axis" /> +<circle cx="388.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="388.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="512" y="108" class="panel-title">Create (format regex)</text> +<line x1="518" y1="198.0" x2="714" y2="198.0" class="axis" /> +<circle cx="616.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="616.0" y="126.6" class="value" text-anchor="end">928 B</text> +<text x="740" y="108" class="panel-title">Create (mod-97)</text> +<line x1="746" y1="198.0" x2="942" y2="198.0" class="axis" /> +<circle cx="844.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="844.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="56" y="240" class="panel-title">TryCreate (rejects)</text> +<line x1="62" y1="330.0" x2="258" y2="330.0" class="axis" /> +<circle cx="160.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="160.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<text x="284" y="240" class="panel-title">Create (throws)</text> +<line x1="290" y1="330.0" x2="486" y2="330.0" class="axis" /> +<circle cx="388.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="388.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<text x="512" y="240" class="panel-title">As&lt;T&gt; conversion</text> +<line x1="518" y1="330.0" x2="714" y2="330.0" class="axis" /> +<circle cx="616.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="616.0" y="258.6" class="value" text-anchor="end">504 B</text> +<text x="740" y="240" class="panel-title">GetHashCode</text> +<line x1="746" y1="330.0" x2="942" y2="330.0" class="axis" /> +<circle cx="844.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="844.0" y="321.0" class="value" text-anchor="end">0 B</text> +<rect x="56" y="360" width="9" height="9" rx="2" fill="#eb6834" /> +<text x="71" y="368" class="section">Time, as a multiple of a fixed reference workload</text> +<text x="71" y="382" class="caption">Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster.</text> +<text x="56" y="406" class="panel-title">Create (no validation)</text> +<line x1="62" y1="496.0" x2="258" y2="496.0" class="axis" /> +<circle cx="160.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="160.0" y="424.6" class="value" text-anchor="end">2.21×</text> +<text x="284" y="406" class="panel-title">Create (charset regex)</text> +<line x1="290" y1="496.0" x2="486" y2="496.0" class="axis" /> +<circle cx="388.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="388.0" y="424.6" class="value" text-anchor="end">3.63×</text> +<text x="512" y="406" class="panel-title">Create (format regex)</text> +<line x1="518" y1="496.0" x2="714" y2="496.0" class="axis" /> +<circle cx="616.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="616.0" y="424.6" class="value" text-anchor="end">3.91×</text> +<text x="740" y="406" class="panel-title">Create (mod-97)</text> +<line x1="746" y1="496.0" x2="942" y2="496.0" class="axis" /> +<circle cx="844.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="844.0" y="424.6" class="value" text-anchor="end">3.90×</text> +<text x="56" y="538" class="panel-title">TryCreate (rejects)</text> +<line x1="62" y1="628.0" x2="258" y2="628.0" class="axis" /> +<circle cx="160.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="160.0" y="556.6" class="value" text-anchor="end">9.11×</text> +<text x="284" y="538" class="panel-title">Create (throws)</text> +<line x1="290" y1="628.0" x2="486" y2="628.0" class="axis" /> +<circle cx="388.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="388.0" y="556.6" class="value" text-anchor="end">9.11×</text> +<text x="512" y="538" class="panel-title">As&lt;T&gt; conversion</text> +<line x1="518" y1="628.0" x2="714" y2="628.0" class="axis" /> +<circle cx="616.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="616.0" y="556.6" class="value" text-anchor="end">2.21×</text> +<text x="740" y="538" class="panel-title">GetHashCode</text> +<line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> +<circle cx="844.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="844.0" y="556.6" class="value" text-anchor="end">0.0569×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 5.3.4</text> +<text x="56" y="680" class="caption">Measured on Intel Core i9-14900. Full tables: Semantics.Benchmarks.</text> +</svg> From 4da5c9be12a453c35b10c072ffb24909901db3cb Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 13:51:48 +1000 Subject: [PATCH 23/26] Backfill the strings and paths histories 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> --- docs/benchmarks/paths-history.json | 639 +++++++++++++++ docs/benchmarks/paths-performance-dark.svg | 244 +++++- docs/benchmarks/paths-performance.svg | 244 +++++- docs/benchmarks/strings-history.json | 801 +++++++++++++++++++ docs/benchmarks/strings-performance-dark.svg | 244 +++++- docs/benchmarks/strings-performance.svg | 244 +++++- 6 files changed, 2280 insertions(+), 136 deletions(-) diff --git a/docs/benchmarks/paths-history.json b/docs/benchmarks/paths-history.json index ff26dfcd..20264493 100644 --- a/docs/benchmarks/paths-history.json +++ b/docs/benchmarks/paths-history.json @@ -1,6 +1,645 @@ { "schemaVersion": 1, "entries": [ + { + "version": "4.0.0", + "commit": "496d895", + "date": "2026-09-10", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4614.2787, + "allocatedBytes": 1680 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 4635.8948, + "allocatedBytes": 1784 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2482.6632, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4537.0056, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5028.3394, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5290.4836, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4095.492, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2574.5865, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.9515, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4664.0503, + "allocatedBytes": 1936 + } + } + } + }, + { + "version": "4.1.0", + "commit": "fa79094", + "date": "2026-09-11", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 6675.3822, + "allocatedBytes": 1680 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 4772.7331, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2536.43, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4681.3601, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5093.8858, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5362.0499, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4169.2261, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2579.3607, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.8375, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4840.7435, + "allocatedBytes": 1904 + } + } + } + }, + { + "version": "4.2.0", + "commit": "5e19d32", + "date": "2026-09-11", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4613.9081, + "allocatedBytes": 1712 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 4686.2808, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2502.3744, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4582.6469, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5058.6235, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5314.6327, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4096.5622, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2594.1218, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.4668, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4708.5073, + "allocatedBytes": 1936 + } + } + } + }, + { + "version": "4.3.2", + "commit": "256def6", + "date": "2026-09-12", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4655.4433, + "allocatedBytes": 1712 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 4629.012, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2654.79, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4601.1131, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5109.3857, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5329.8172, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4104.9306, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2654.5672, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.5647, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4666.6, + "allocatedBytes": 1936 + } + } + } + }, + { + "version": "5.0.0", + "commit": "bb69de0", + "date": "2026-09-12", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4667.0563, + "allocatedBytes": 1712 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 4692.4042, + "allocatedBytes": 1784 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2543.0866, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4689.6596, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5103.6428, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5284.5301, + "allocatedBytes": 1728 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4159.1685, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2575.8639, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.5833, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4766.6524, + "allocatedBytes": 1936 + } + } + } + }, + { + "version": "5.1.0", + "commit": "4c71e14", + "date": "2026-09-13", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4661.0981, + "allocatedBytes": 1712 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 5233.1055, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2564.454, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4711.8357, + "allocatedBytes": 1728 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5204.7104, + "allocatedBytes": 1912 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5431.0295, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4206.9066, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2608.9784, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.6581, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4786.9629, + "allocatedBytes": 1936 + } + } + } + }, + { + "version": "5.2.0", + "commit": "eda9fc8", + "date": "2026-09-13", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4738.5226, + "allocatedBytes": 1712 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 4770.8158, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2581.3623, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4533.1146, + "allocatedBytes": 1728 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5119.7281, + "allocatedBytes": 1912 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5421.257, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4163.529, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2543.9855, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.5901, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4811.3393, + "allocatedBytes": 1904 + } + } + } + }, + { + "version": "5.2.4", + "commit": "cbc914c", + "date": "2026-09-14", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4735.5934, + "allocatedBytes": 1712 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 5106.4896, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2604.023, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4630.6149, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5219.9702, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5386.6689, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4154.0525, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2647.8668, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.6292, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 5552.3127, + "allocatedBytes": 1936 + } + } + } + }, + { + "version": "5.3.2", + "commit": "2a3e53b", + "date": "2026-09-16", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "PathCreationBenchmarks.AbsoluteDirectoryPath": { + "": { + "meanNs": 4589.7764, + "allocatedBytes": 1712 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 4666.2328, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2509.1151, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4535.2832, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5044.2739, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5309.7987, + "allocatedBytes": 1728 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4103.9195, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2629.9965, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 1.5165, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4615.3793, + "allocatedBytes": 1936 + } + } + } + }, { "version": "5.3.4", "commit": "8e401ee", diff --git a/docs/benchmarks/paths-performance-dark.svg b/docs/benchmarks/paths-performance-dark.svg index 16904f1b..1c3587cd 100644 --- a/docs/benchmarks/paths-performance-dark.svg +++ b/docs/benchmarks/paths-performance-dark.svg @@ -11,77 +11,253 @@ </style> <rect width="992" height="722" fill="#1a1a19" /> <text x="56" y="28" class="title">Semantics.Paths performance by release</text> -<text x="56" y="45" class="caption">1 releases · newest 5.3.4 · 2026-09-16</text> +<text x="56" y="45" class="caption">10 releases · newest 5.3.4 · 2026-09-16</text> <rect x="56" y="62" width="9" height="9" rx="2" fill="#3987e5" /> <text x="71" y="70" class="section">Allocated bytes per operation</text> <text x="71" y="84" class="caption">Deterministic: the same code allocates the same bytes on any machine.</text> <text x="56" y="108" class="panel-title">Create (absolute file)</text> <line x1="62" y1="198.0" x2="258" y2="198.0" class="axis" /> -<circle cx="160.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="160.0" y="126.6" class="value" text-anchor="end">1.8 KB</text> +<polyline points="62.0,136.7 83.8,135.6 105.6,135.6 127.3,135.6 149.1,136.7 170.9,135.6 192.7,135.6 214.4,135.6 236.2,135.6 258.0,135.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="83.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="105.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="127.3" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="149.1" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="170.9" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="192.7" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="214.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="236.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="126.6" class="value" text-anchor="end">1.8 KB</text> +<text x="62.0" y="127.7" class="muted-value" text-anchor="middle">1.7 KB</text> <text x="284" y="108" class="panel-title">Create (relative file)</text> <line x1="290" y1="198.0" x2="486" y2="198.0" class="axis" /> -<circle cx="388.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="388.0" y="126.6" class="value" text-anchor="end">1.7 KB</text> +<polyline points="290.0,135.6 311.8,135.6 333.6,135.6 355.3,135.6 377.1,135.6 398.9,136.7 420.7,136.7 442.4,135.6 464.2,135.6 486.0,136.7" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="311.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="333.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="355.3" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="377.1" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="398.9" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="420.7" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="442.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="464.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="127.7" class="value" text-anchor="end">1.7 KB</text> +<text x="290.0" y="126.6" class="muted-value" text-anchor="middle">1.7 KB</text> <text x="512" y="108" class="panel-title">Create (file name)</text> <line x1="518" y1="198.0" x2="714" y2="198.0" class="axis" /> -<circle cx="616.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="616.0" y="126.6" class="value" text-anchor="end">896 B</text> +<polyline points="518.0,135.6 539.8,135.6 561.6,135.6 583.3,135.6 605.1,135.6 626.9,135.6 648.7,135.6 670.4,135.6 692.2,135.6 714.0,135.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="539.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="561.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="583.3" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="605.1" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="626.9" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="648.7" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="670.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="692.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="518.0" y="126.6" class="muted-value" text-anchor="middle">896 B</text> <text x="740" y="108" class="panel-title">FileName (uncached)</text> <line x1="746" y1="198.0" x2="942" y2="198.0" class="axis" /> -<circle cx="844.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="844.0" y="126.6" class="value" text-anchor="end">944 B</text> +<polyline points="746.0,135.6 767.8,135.6 789.6,135.6 811.3,135.6 833.1,135.6 854.9,135.6 876.7,135.6 898.4,135.6 920.2,135.6 942.0,135.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="767.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="789.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="811.3" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="833.1" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="854.9" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="876.7" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="898.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="920.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="942.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="942.0" y="126.6" class="value" text-anchor="end">944 B</text> +<text x="746.0" y="126.6" class="muted-value" text-anchor="middle">944 B</text> <text x="56" y="240" class="panel-title">FileName (cached)</text> <line x1="62" y1="330.0" x2="258" y2="330.0" class="axis" /> -<circle cx="160.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="160.0" y="321.0" class="value" text-anchor="end">0 B</text> +<polyline points="62.0,330.0 83.8,330.0 105.6,330.0 127.3,330.0 149.1,330.0 170.9,330.0 192.7,330.0 214.4,330.0 236.2,330.0 258.0,330.0" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="83.8" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="105.6" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="127.3" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="149.1" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="170.9" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="192.7" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="214.4" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="236.2" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="321.0" class="value" text-anchor="end">0 B</text> +<text x="62.0" y="321.0" class="muted-value" text-anchor="middle">0 B</text> <text x="284" y="240" class="panel-title">AsAbsolute (from relative)</text> <line x1="290" y1="330.0" x2="486" y2="330.0" class="axis" /> -<circle cx="388.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="388.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<polyline points="290.0,267.6 311.8,267.6 333.6,267.6 355.3,267.6 377.1,267.6 398.9,268.6 420.7,268.6 442.4,267.6 464.2,267.6 486.0,267.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="311.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="333.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="355.3" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="377.1" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="398.9" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="420.7" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="442.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="464.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<text x="290.0" y="258.6" class="muted-value" text-anchor="middle">1.9 KB</text> <text x="512" y="240" class="panel-title">AsRelative (from absolute)</text> <line x1="518" y1="330.0" x2="714" y2="330.0" class="axis" /> -<circle cx="616.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="616.0" y="258.6" class="value" text-anchor="end">1.7 KB</text> +<polyline points="518.0,267.6 539.8,267.6 561.6,267.6 583.3,267.6 605.1,268.7 626.9,267.6 648.7,267.6 670.4,267.6 692.2,268.7 714.0,267.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="539.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="561.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="583.3" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="605.1" cy="268.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="626.9" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="648.7" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="670.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="692.2" cy="268.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="258.6" class="value" text-anchor="end">1.7 KB</text> +<text x="518.0" y="258.6" class="muted-value" text-anchor="middle">1.7 KB</text> <text x="740" y="240" class="panel-title">RemoveExtension</text> <line x1="746" y1="330.0" x2="942" y2="330.0" class="axis" /> -<circle cx="844.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="844.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<polyline points="746.0,267.6 767.8,268.6 789.6,267.6 811.3,267.6 833.1,267.6 854.9,267.6 876.7,268.6 898.4,267.6 920.2,267.6 942.0,268.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="767.8" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="789.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="811.3" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="833.1" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="854.9" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="876.7" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="898.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="920.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="942.0" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="942.0" y="259.6" class="value" text-anchor="end">1.9 KB</text> +<text x="746.0" y="258.6" class="muted-value" text-anchor="middle">1.9 KB</text> <rect x="56" y="360" width="9" height="9" rx="2" fill="#d95926" /> <text x="71" y="368" class="section">Time, as a multiple of a fixed reference workload</text> <text x="71" y="382" class="caption">Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster.</text> <text x="56" y="406" class="panel-title">Create (absolute file)</text> <line x1="62" y1="496.0" x2="258" y2="496.0" class="axis" /> -<circle cx="160.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="160.0" y="424.6" class="value" text-anchor="end">6.49×</text> +<polyline points="62.0,440.7 83.8,439.1 105.6,440.1 127.3,440.8 149.1,440.0 170.9,433.6 192.7,439.1 214.4,435.1 236.2,440.4 258.0,438.2" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="440.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="83.8" cy="439.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="105.6" cy="440.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="127.3" cy="440.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="149.1" cy="440.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="170.9" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="192.7" cy="439.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="214.4" cy="435.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="236.2" cy="440.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="438.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="429.2" class="value" text-anchor="end">6.49×</text> +<text x="62.0" y="431.7" class="muted-value" text-anchor="middle">6.20×</text> <text x="284" y="406" class="panel-title">Create (relative file)</text> <line x1="290" y1="496.0" x2="486" y2="496.0" class="axis" /> -<circle cx="388.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="388.0" y="424.6" class="value" text-anchor="end">5.98×</text> +<polyline points="290.0,435.9 311.8,434.0 333.6,435.3 355.3,435.1 377.1,433.9 398.9,433.6 420.7,436.0 442.4,434.7 464.2,435.9 486.0,436.8" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="435.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="311.8" cy="434.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="333.6" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="355.3" cy="435.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="377.1" cy="433.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="398.9" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="420.7" cy="436.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="442.4" cy="434.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="464.2" cy="435.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="436.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="427.8" class="value" text-anchor="end">5.98×</text> +<text x="290.0" y="426.9" class="muted-value" text-anchor="middle">6.07×</text> <text x="512" y="406" class="panel-title">Create (file name)</text> <line x1="518" y1="496.0" x2="714" y2="496.0" class="axis" /> -<circle cx="616.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="616.0" y="424.6" class="value" text-anchor="end">3.33×</text> +<polyline points="518.0,437.6 539.8,436.4 561.6,437.2 583.3,433.6 605.1,436.2 626.9,435.7 648.7,435.3 670.4,434.8 692.2,437.0 714.0,437.5" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="437.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="539.8" cy="436.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="561.6" cy="437.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="583.3" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="605.1" cy="436.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="626.9" cy="435.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="648.7" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="670.4" cy="434.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="692.2" cy="437.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="437.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="428.5" class="value" text-anchor="end">3.33×</text> +<text x="518.0" y="428.6" class="muted-value" text-anchor="middle">3.32×</text> <text x="740" y="406" class="panel-title">FileName (uncached)</text> <line x1="746" y1="496.0" x2="942" y2="496.0" class="axis" /> -<circle cx="844.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="844.0" y="424.6" class="value" text-anchor="end">3.40×</text> +<polyline points="746.0,435.5 767.8,435.4 789.6,435.0 811.3,433.6 833.1,435.5 854.9,434.7 876.7,436.2 898.4,433.8 920.2,434.2 942.0,436.2" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="435.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="767.8" cy="435.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="789.6" cy="435.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="811.3" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="833.1" cy="435.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="854.9" cy="434.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="876.7" cy="436.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="898.4" cy="433.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="920.2" cy="434.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="942.0" cy="436.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="942.0" y="427.2" class="value" text-anchor="end">3.40×</text> +<text x="746.0" y="426.5" class="muted-value" text-anchor="middle">3.44×</text> <text x="56" y="538" class="panel-title">FileName (cached)</text> <line x1="62" y1="628.0" x2="258" y2="628.0" class="axis" /> -<circle cx="160.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="160.0" y="556.6" class="value" text-anchor="end">0.0022×</text> +<polyline points="62.0,565.6 83.8,569.2 105.6,581.1 127.3,578.0 149.1,577.4 170.9,575.0 192.7,577.2 214.4,575.9 236.2,579.5 258.0,574.6" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="83.8" cy="569.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="105.6" cy="581.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="127.3" cy="578.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="149.1" cy="577.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="170.9" cy="575.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="192.7" cy="577.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="214.4" cy="575.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="236.2" cy="579.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="574.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="565.6" class="value" text-anchor="end">0.0022×</text> +<text x="62.0" y="556.6" class="muted-value" text-anchor="middle">0.0026×</text> <text x="284" y="538" class="panel-title">AsAbsolute (from relative)</text> <line x1="290" y1="628.0" x2="486" y2="628.0" class="axis" /> -<circle cx="388.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="388.0" y="556.6" class="value" text-anchor="end">7.04×</text> +<polyline points="290.0,568.3 311.8,567.6 333.6,568.0 355.3,567.4 377.1,567.4 398.9,566.2 420.7,567.3 442.4,566.1 464.2,568.1 486.0,565.6" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="568.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="311.8" cy="567.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="333.6" cy="568.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="355.3" cy="567.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="377.1" cy="567.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="398.9" cy="566.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="420.7" cy="567.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="442.4" cy="566.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="464.2" cy="568.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="556.6" class="value" text-anchor="end">7.04×</text> +<text x="290.0" y="559.3" class="muted-value" text-anchor="middle">6.73×</text> <text x="512" y="538" class="panel-title">AsRelative (from absolute)</text> <line x1="518" y1="628.0" x2="714" y2="628.0" class="axis" /> -<circle cx="616.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="616.0" y="556.6" class="value" text-anchor="end">7.32×</text> +<polyline points="518.0,567.6 539.8,566.8 561.6,567.4 583.3,567.2 605.1,567.7 626.9,566.0 648.7,566.1 670.4,566.5 692.2,567.4 714.0,565.6" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="567.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="539.8" cy="566.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="561.6" cy="567.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="583.3" cy="567.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="605.1" cy="567.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="626.9" cy="566.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="648.7" cy="566.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="670.4" cy="566.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="692.2" cy="567.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="556.6" class="value" text-anchor="end">7.32×</text> +<text x="518.0" y="558.6" class="muted-value" text-anchor="middle">7.08×</text> <text x="740" y="538" class="panel-title">RemoveExtension</text> <line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> -<circle cx="844.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="844.0" y="556.6" class="value" text-anchor="end">6.25×</text> -<text x="56" y="664" class="tick">releases, oldest to newest: 5.3.4</text> +<polyline points="746.0,575.6 767.8,573.6 789.6,575.1 811.3,575.6 833.1,574.4 854.9,574.2 876.7,573.9 898.4,565.6 920.2,576.1 942.0,575.5" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="575.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="767.8" cy="573.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="789.6" cy="575.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="811.3" cy="575.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="833.1" cy="574.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="854.9" cy="574.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="876.7" cy="573.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="898.4" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="920.2" cy="576.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="942.0" cy="575.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="942.0" y="566.5" class="value" text-anchor="end">6.25×</text> +<text x="746.0" y="566.6" class="muted-value" text-anchor="middle">6.24×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 4.0.0 → 4.1.0 → 4.2.0 → 4.3.2 → 5.0.0 → 5.1.0 → 5.2.0 → 5.2.4 → 5.3.2 → 5.3.4</text> <text x="56" y="680" class="caption">Measured on Intel Core i9-14900. Full tables: Semantics.Benchmarks.</text> </svg> diff --git a/docs/benchmarks/paths-performance.svg b/docs/benchmarks/paths-performance.svg index 9dd36690..999dcad4 100644 --- a/docs/benchmarks/paths-performance.svg +++ b/docs/benchmarks/paths-performance.svg @@ -11,77 +11,253 @@ </style> <rect width="992" height="722" fill="#fcfcfb" /> <text x="56" y="28" class="title">Semantics.Paths performance by release</text> -<text x="56" y="45" class="caption">1 releases · newest 5.3.4 · 2026-09-16</text> +<text x="56" y="45" class="caption">10 releases · newest 5.3.4 · 2026-09-16</text> <rect x="56" y="62" width="9" height="9" rx="2" fill="#2a78d6" /> <text x="71" y="70" class="section">Allocated bytes per operation</text> <text x="71" y="84" class="caption">Deterministic: the same code allocates the same bytes on any machine.</text> <text x="56" y="108" class="panel-title">Create (absolute file)</text> <line x1="62" y1="198.0" x2="258" y2="198.0" class="axis" /> -<circle cx="160.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="160.0" y="126.6" class="value" text-anchor="end">1.8 KB</text> +<polyline points="62.0,136.7 83.8,135.6 105.6,135.6 127.3,135.6 149.1,136.7 170.9,135.6 192.7,135.6 214.4,135.6 236.2,135.6 258.0,135.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="83.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="105.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="127.3" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="149.1" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="170.9" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="192.7" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="214.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="236.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="126.6" class="value" text-anchor="end">1.8 KB</text> +<text x="62.0" y="127.7" class="muted-value" text-anchor="middle">1.7 KB</text> <text x="284" y="108" class="panel-title">Create (relative file)</text> <line x1="290" y1="198.0" x2="486" y2="198.0" class="axis" /> -<circle cx="388.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="388.0" y="126.6" class="value" text-anchor="end">1.7 KB</text> +<polyline points="290.0,135.6 311.8,135.6 333.6,135.6 355.3,135.6 377.1,135.6 398.9,136.7 420.7,136.7 442.4,135.6 464.2,135.6 486.0,136.7" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="311.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="333.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="355.3" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="377.1" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="398.9" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="420.7" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="442.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="464.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="127.7" class="value" text-anchor="end">1.7 KB</text> +<text x="290.0" y="126.6" class="muted-value" text-anchor="middle">1.7 KB</text> <text x="512" y="108" class="panel-title">Create (file name)</text> <line x1="518" y1="198.0" x2="714" y2="198.0" class="axis" /> -<circle cx="616.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="616.0" y="126.6" class="value" text-anchor="end">896 B</text> +<polyline points="518.0,135.6 539.8,135.6 561.6,135.6 583.3,135.6 605.1,135.6 626.9,135.6 648.7,135.6 670.4,135.6 692.2,135.6 714.0,135.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="539.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="561.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="583.3" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="605.1" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="626.9" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="648.7" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="670.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="692.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="518.0" y="126.6" class="muted-value" text-anchor="middle">896 B</text> <text x="740" y="108" class="panel-title">FileName (uncached)</text> <line x1="746" y1="198.0" x2="942" y2="198.0" class="axis" /> -<circle cx="844.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="844.0" y="126.6" class="value" text-anchor="end">944 B</text> +<polyline points="746.0,135.6 767.8,135.6 789.6,135.6 811.3,135.6 833.1,135.6 854.9,135.6 876.7,135.6 898.4,135.6 920.2,135.6 942.0,135.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="767.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="789.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="811.3" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="833.1" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="854.9" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="876.7" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="898.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="920.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="942.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="942.0" y="126.6" class="value" text-anchor="end">944 B</text> +<text x="746.0" y="126.6" class="muted-value" text-anchor="middle">944 B</text> <text x="56" y="240" class="panel-title">FileName (cached)</text> <line x1="62" y1="330.0" x2="258" y2="330.0" class="axis" /> -<circle cx="160.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="160.0" y="321.0" class="value" text-anchor="end">0 B</text> +<polyline points="62.0,330.0 83.8,330.0 105.6,330.0 127.3,330.0 149.1,330.0 170.9,330.0 192.7,330.0 214.4,330.0 236.2,330.0 258.0,330.0" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="83.8" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="105.6" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="127.3" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="149.1" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="170.9" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="192.7" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="214.4" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="236.2" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="321.0" class="value" text-anchor="end">0 B</text> +<text x="62.0" y="321.0" class="muted-value" text-anchor="middle">0 B</text> <text x="284" y="240" class="panel-title">AsAbsolute (from relative)</text> <line x1="290" y1="330.0" x2="486" y2="330.0" class="axis" /> -<circle cx="388.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="388.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<polyline points="290.0,267.6 311.8,267.6 333.6,267.6 355.3,267.6 377.1,267.6 398.9,268.6 420.7,268.6 442.4,267.6 464.2,267.6 486.0,267.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="311.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="333.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="355.3" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="377.1" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="398.9" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="420.7" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="442.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="464.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<text x="290.0" y="258.6" class="muted-value" text-anchor="middle">1.9 KB</text> <text x="512" y="240" class="panel-title">AsRelative (from absolute)</text> <line x1="518" y1="330.0" x2="714" y2="330.0" class="axis" /> -<circle cx="616.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="616.0" y="258.6" class="value" text-anchor="end">1.7 KB</text> +<polyline points="518.0,267.6 539.8,267.6 561.6,267.6 583.3,267.6 605.1,268.7 626.9,267.6 648.7,267.6 670.4,267.6 692.2,268.7 714.0,267.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="539.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="561.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="583.3" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="605.1" cy="268.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="626.9" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="648.7" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="670.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="692.2" cy="268.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="258.6" class="value" text-anchor="end">1.7 KB</text> +<text x="518.0" y="258.6" class="muted-value" text-anchor="middle">1.7 KB</text> <text x="740" y="240" class="panel-title">RemoveExtension</text> <line x1="746" y1="330.0" x2="942" y2="330.0" class="axis" /> -<circle cx="844.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="844.0" y="258.6" class="value" text-anchor="end">1.9 KB</text> +<polyline points="746.0,267.6 767.8,268.6 789.6,267.6 811.3,267.6 833.1,267.6 854.9,267.6 876.7,268.6 898.4,267.6 920.2,267.6 942.0,268.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="767.8" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="789.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="811.3" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="833.1" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="854.9" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="876.7" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="898.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="920.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="942.0" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="942.0" y="259.6" class="value" text-anchor="end">1.9 KB</text> +<text x="746.0" y="258.6" class="muted-value" text-anchor="middle">1.9 KB</text> <rect x="56" y="360" width="9" height="9" rx="2" fill="#eb6834" /> <text x="71" y="368" class="section">Time, as a multiple of a fixed reference workload</text> <text x="71" y="382" class="caption">Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster.</text> <text x="56" y="406" class="panel-title">Create (absolute file)</text> <line x1="62" y1="496.0" x2="258" y2="496.0" class="axis" /> -<circle cx="160.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="160.0" y="424.6" class="value" text-anchor="end">6.49×</text> +<polyline points="62.0,440.7 83.8,439.1 105.6,440.1 127.3,440.8 149.1,440.0 170.9,433.6 192.7,439.1 214.4,435.1 236.2,440.4 258.0,438.2" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="440.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="83.8" cy="439.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="105.6" cy="440.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="127.3" cy="440.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="149.1" cy="440.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="170.9" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="192.7" cy="439.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="214.4" cy="435.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="236.2" cy="440.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="438.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="429.2" class="value" text-anchor="end">6.49×</text> +<text x="62.0" y="431.7" class="muted-value" text-anchor="middle">6.20×</text> <text x="284" y="406" class="panel-title">Create (relative file)</text> <line x1="290" y1="496.0" x2="486" y2="496.0" class="axis" /> -<circle cx="388.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="388.0" y="424.6" class="value" text-anchor="end">5.98×</text> +<polyline points="290.0,435.9 311.8,434.0 333.6,435.3 355.3,435.1 377.1,433.9 398.9,433.6 420.7,436.0 442.4,434.7 464.2,435.9 486.0,436.8" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="435.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="311.8" cy="434.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="333.6" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="355.3" cy="435.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="377.1" cy="433.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="398.9" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="420.7" cy="436.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="442.4" cy="434.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="464.2" cy="435.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="436.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="427.8" class="value" text-anchor="end">5.98×</text> +<text x="290.0" y="426.9" class="muted-value" text-anchor="middle">6.07×</text> <text x="512" y="406" class="panel-title">Create (file name)</text> <line x1="518" y1="496.0" x2="714" y2="496.0" class="axis" /> -<circle cx="616.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="616.0" y="424.6" class="value" text-anchor="end">3.33×</text> +<polyline points="518.0,437.6 539.8,436.4 561.6,437.2 583.3,433.6 605.1,436.2 626.9,435.7 648.7,435.3 670.4,434.8 692.2,437.0 714.0,437.5" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="437.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="539.8" cy="436.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="561.6" cy="437.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="583.3" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="605.1" cy="436.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="626.9" cy="435.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="648.7" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="670.4" cy="434.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="692.2" cy="437.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="437.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="428.5" class="value" text-anchor="end">3.33×</text> +<text x="518.0" y="428.6" class="muted-value" text-anchor="middle">3.32×</text> <text x="740" y="406" class="panel-title">FileName (uncached)</text> <line x1="746" y1="496.0" x2="942" y2="496.0" class="axis" /> -<circle cx="844.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="844.0" y="424.6" class="value" text-anchor="end">3.40×</text> +<polyline points="746.0,435.5 767.8,435.4 789.6,435.0 811.3,433.6 833.1,435.5 854.9,434.7 876.7,436.2 898.4,433.8 920.2,434.2 942.0,436.2" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="435.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="767.8" cy="435.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="789.6" cy="435.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="811.3" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="833.1" cy="435.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="854.9" cy="434.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="876.7" cy="436.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="898.4" cy="433.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="920.2" cy="434.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="942.0" cy="436.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="942.0" y="427.2" class="value" text-anchor="end">3.40×</text> +<text x="746.0" y="426.5" class="muted-value" text-anchor="middle">3.44×</text> <text x="56" y="538" class="panel-title">FileName (cached)</text> <line x1="62" y1="628.0" x2="258" y2="628.0" class="axis" /> -<circle cx="160.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="160.0" y="556.6" class="value" text-anchor="end">0.0022×</text> +<polyline points="62.0,565.6 83.8,569.2 105.6,581.1 127.3,578.0 149.1,577.4 170.9,575.0 192.7,577.2 214.4,575.9 236.2,579.5 258.0,574.6" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="83.8" cy="569.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="105.6" cy="581.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="127.3" cy="578.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="149.1" cy="577.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="170.9" cy="575.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="192.7" cy="577.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="214.4" cy="575.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="236.2" cy="579.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="574.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="565.6" class="value" text-anchor="end">0.0022×</text> +<text x="62.0" y="556.6" class="muted-value" text-anchor="middle">0.0026×</text> <text x="284" y="538" class="panel-title">AsAbsolute (from relative)</text> <line x1="290" y1="628.0" x2="486" y2="628.0" class="axis" /> -<circle cx="388.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="388.0" y="556.6" class="value" text-anchor="end">7.04×</text> +<polyline points="290.0,568.3 311.8,567.6 333.6,568.0 355.3,567.4 377.1,567.4 398.9,566.2 420.7,567.3 442.4,566.1 464.2,568.1 486.0,565.6" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="568.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="311.8" cy="567.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="333.6" cy="568.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="355.3" cy="567.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="377.1" cy="567.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="398.9" cy="566.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="420.7" cy="567.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="442.4" cy="566.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="464.2" cy="568.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="556.6" class="value" text-anchor="end">7.04×</text> +<text x="290.0" y="559.3" class="muted-value" text-anchor="middle">6.73×</text> <text x="512" y="538" class="panel-title">AsRelative (from absolute)</text> <line x1="518" y1="628.0" x2="714" y2="628.0" class="axis" /> -<circle cx="616.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="616.0" y="556.6" class="value" text-anchor="end">7.32×</text> +<polyline points="518.0,567.6 539.8,566.8 561.6,567.4 583.3,567.2 605.1,567.7 626.9,566.0 648.7,566.1 670.4,566.5 692.2,567.4 714.0,565.6" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="567.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="539.8" cy="566.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="561.6" cy="567.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="583.3" cy="567.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="605.1" cy="567.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="626.9" cy="566.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="648.7" cy="566.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="670.4" cy="566.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="692.2" cy="567.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="556.6" class="value" text-anchor="end">7.32×</text> +<text x="518.0" y="558.6" class="muted-value" text-anchor="middle">7.08×</text> <text x="740" y="538" class="panel-title">RemoveExtension</text> <line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> -<circle cx="844.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="844.0" y="556.6" class="value" text-anchor="end">6.25×</text> -<text x="56" y="664" class="tick">releases, oldest to newest: 5.3.4</text> +<polyline points="746.0,575.6 767.8,573.6 789.6,575.1 811.3,575.6 833.1,574.4 854.9,574.2 876.7,573.9 898.4,565.6 920.2,576.1 942.0,575.5" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="575.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="767.8" cy="573.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="789.6" cy="575.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="811.3" cy="575.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="833.1" cy="574.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="854.9" cy="574.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="876.7" cy="573.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="898.4" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="920.2" cy="576.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="942.0" cy="575.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="942.0" y="566.5" class="value" text-anchor="end">6.25×</text> +<text x="746.0" y="566.6" class="muted-value" text-anchor="middle">6.24×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 4.0.0 → 4.1.0 → 4.2.0 → 4.3.2 → 5.0.0 → 5.1.0 → 5.2.0 → 5.2.4 → 5.3.2 → 5.3.4</text> <text x="56" y="680" class="caption">Measured on Intel Core i9-14900. Full tables: Semantics.Benchmarks.</text> </svg> diff --git a/docs/benchmarks/strings-history.json b/docs/benchmarks/strings-history.json index baa27cab..4423c8af 100644 --- a/docs/benchmarks/strings-history.json +++ b/docs/benchmarks/strings-history.json @@ -1,6 +1,807 @@ { "schemaVersion": 1, "entries": [ + { + "version": "4.0.0", + "commit": "496d895", + "date": "2026-09-10", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2754.1835, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2564.8275, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 6692.7511, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 2844.0389, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2899.4436, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6770.7062, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1686.0263, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1686.9087, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.4821, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.0502, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.4328, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 0, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1778.0575, + "allocatedBytes": 616 + } + } + } + }, + { + "version": "4.1.0", + "commit": "fa79094", + "date": "2026-09-11", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2748.4431, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2612.7986, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 6755.5278, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 2894.4377, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2889.1551, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6771.3933, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1636.4607, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1643.848, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.666, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.1026, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 45.7944, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 0.9802, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1758.5668, + "allocatedBytes": 616 + } + } + } + }, + { + "version": "4.2.0", + "commit": "5e19d32", + "date": "2026-09-11", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2995.0653, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2585.8948, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 6939.1083, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 2902.3132, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2898.1583, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6731.0313, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1663.8268, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1739.0472, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.5063, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.0308, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.7399, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 0.4088, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1740.1211, + "allocatedBytes": 616 + } + } + } + }, + { + "version": "4.3.2", + "commit": "256def6", + "date": "2026-09-12", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2794.1538, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2598.2615, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 6708.6965, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 3133.8379, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2944.838, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6746.1594, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1678.2132, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1648.0204, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.5447, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.0209, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.7824, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 1.4598, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1754.1719, + "allocatedBytes": 616 + } + } + } + }, + { + "version": "5.0.0", + "commit": "bb69de0", + "date": "2026-09-12", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2745.8506, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2623.4472, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 6759.5576, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 3120.2199, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2895.1233, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6732.017, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1639.5547, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1738.3368, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.5235, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.0378, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.3489, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 0.937, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1749.8879, + "allocatedBytes": 616 + } + } + } + }, + { + "version": "5.1.0", + "commit": "4c71e14", + "date": "2026-09-13", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2761.4034, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2598.5987, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 7152.223, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 2902.9093, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2901.0132, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6859.3948, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1652.9215, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1735.8643, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.5267, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.0443, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 43.7689, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 0, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1777.9709, + "allocatedBytes": 616 + } + } + } + }, + { + "version": "5.2.0", + "commit": "eda9fc8", + "date": "2026-09-13", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2798.642, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2570.9897, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 6823.0708, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 2851.988, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2876.4201, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 7193.4514, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1655.2476, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1660.2755, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.5799, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.0319, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.6665, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 1.0938, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1760.7339, + "allocatedBytes": 616 + } + } + } + }, + { + "version": "5.2.4", + "commit": "cbc914c", + "date": "2026-09-14", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2894.3103, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2552.5675, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 6791.7066, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 3205.3452, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 3099.5834, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 9578.7145, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1675.0594, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1649.6442, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.5197, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 0.9831, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.6705, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 1.0737, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1745.6762, + "allocatedBytes": 616 + } + } + } + }, + { + "version": "5.3.2", + "commit": "2a3e53b", + "date": "2026-09-16", + "cpu": "Intel Core i9-14900", + "runtime": ".NET 10.0.12 (10.0.12, 10.0.1226.42308)", + "baselineNs": 747.4861, + "runId": "local-seed", + "benchmarks": { + "StringCreationBenchmarks.CharsetRegex": { + "": { + "meanNs": 2898.5174, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2630.5242, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 7235.4894, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 3027.9012, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 3027.6426, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6872.9309, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1672.3486, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1648.6234, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 0.4969, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 1.0098, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.4777, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 0, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1735.742, + "allocatedBytes": 616 + } + } + } + }, { "version": "5.3.4", "commit": "8e401ee", diff --git a/docs/benchmarks/strings-performance-dark.svg b/docs/benchmarks/strings-performance-dark.svg index 8043329d..6f72f02e 100644 --- a/docs/benchmarks/strings-performance-dark.svg +++ b/docs/benchmarks/strings-performance-dark.svg @@ -11,77 +11,253 @@ </style> <rect width="992" height="722" fill="#1a1a19" /> <text x="56" y="28" class="title">Semantics.Strings performance by release</text> -<text x="56" y="45" class="caption">1 releases · newest 5.3.4 · 2026-09-16</text> +<text x="56" y="45" class="caption">10 releases · newest 5.3.4 · 2026-09-16</text> <rect x="56" y="62" width="9" height="9" rx="2" fill="#3987e5" /> <text x="71" y="70" class="section">Allocated bytes per operation</text> <text x="71" y="84" class="caption">Deterministic: the same code allocates the same bytes on any machine.</text> <text x="56" y="108" class="panel-title">Create (no validation)</text> <line x1="62" y1="198.0" x2="258" y2="198.0" class="axis" /> -<circle cx="160.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="160.0" y="126.6" class="value" text-anchor="end">504 B</text> +<polyline points="62.0,135.6 83.8,135.6 105.6,135.6 127.3,135.6 149.1,135.6 170.9,135.6 192.7,135.6 214.4,135.6 236.2,135.6 258.0,135.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="83.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="105.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="127.3" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="149.1" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="170.9" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="192.7" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="214.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="236.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="126.6" class="value" text-anchor="end">504 B</text> +<text x="62.0" y="126.6" class="muted-value" text-anchor="middle">504 B</text> <text x="284" y="108" class="panel-title">Create (charset regex)</text> <line x1="290" y1="198.0" x2="486" y2="198.0" class="axis" /> -<circle cx="388.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="388.0" y="126.6" class="value" text-anchor="end">896 B</text> +<polyline points="290.0,135.6 311.8,135.6 333.6,135.6 355.3,135.6 377.1,135.6 398.9,135.6 420.7,135.6 442.4,135.6 464.2,135.6 486.0,135.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="311.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="333.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="355.3" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="377.1" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="398.9" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="420.7" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="442.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="464.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="290.0" y="126.6" class="muted-value" text-anchor="middle">896 B</text> <text x="512" y="108" class="panel-title">Create (format regex)</text> <line x1="518" y1="198.0" x2="714" y2="198.0" class="axis" /> -<circle cx="616.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="616.0" y="126.6" class="value" text-anchor="end">928 B</text> +<polyline points="518.0,135.6 539.8,135.6 561.6,135.6 583.3,135.6 605.1,135.6 626.9,135.6 648.7,135.6 670.4,135.6 692.2,135.6 714.0,135.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="539.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="561.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="583.3" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="605.1" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="626.9" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="648.7" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="670.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="692.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="126.6" class="value" text-anchor="end">928 B</text> +<text x="518.0" y="126.6" class="muted-value" text-anchor="middle">928 B</text> <text x="740" y="108" class="panel-title">Create (mod-97)</text> <line x1="746" y1="198.0" x2="942" y2="198.0" class="axis" /> -<circle cx="844.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="844.0" y="126.6" class="value" text-anchor="end">896 B</text> +<polyline points="746.0,135.6 767.8,135.6 789.6,135.6 811.3,135.6 833.1,135.6 854.9,135.6 876.7,135.6 898.4,135.6 920.2,135.6 942.0,135.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="767.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="789.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="811.3" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="833.1" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="854.9" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="876.7" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="898.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="920.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="942.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="942.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="746.0" y="126.6" class="muted-value" text-anchor="middle">896 B</text> <text x="56" y="240" class="panel-title">TryCreate (rejects)</text> <line x1="62" y1="330.0" x2="258" y2="330.0" class="axis" /> -<circle cx="160.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="160.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<polyline points="62.0,267.6 83.8,267.6 105.6,267.6 127.3,267.6 149.1,267.6 170.9,267.6 192.7,267.6 214.4,267.6 236.2,267.6 258.0,267.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="83.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="105.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="127.3" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="149.1" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="170.9" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="192.7" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="214.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="236.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<text x="62.0" y="258.6" class="muted-value" text-anchor="middle">1.4 KB</text> <text x="284" y="240" class="panel-title">Create (throws)</text> <line x1="290" y1="330.0" x2="486" y2="330.0" class="axis" /> -<circle cx="388.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="388.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<polyline points="290.0,267.6 311.8,267.6 333.6,267.6 355.3,267.6 377.1,267.6 398.9,267.6 420.7,267.6 442.4,267.6 464.2,267.6 486.0,267.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="311.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="333.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="355.3" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="377.1" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="398.9" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="420.7" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="442.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="464.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<text x="290.0" y="258.6" class="muted-value" text-anchor="middle">1.4 KB</text> <text x="512" y="240" class="panel-title">As&lt;T&gt; conversion</text> <line x1="518" y1="330.0" x2="714" y2="330.0" class="axis" /> -<circle cx="616.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="616.0" y="258.6" class="value" text-anchor="end">504 B</text> +<polyline points="518.0,267.6 539.8,267.6 561.6,267.6 583.3,267.6 605.1,267.6 626.9,267.6 648.7,267.6 670.4,267.6 692.2,267.6 714.0,267.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="539.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="561.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="583.3" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="605.1" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="626.9" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="648.7" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="670.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="692.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="258.6" class="value" text-anchor="end">504 B</text> +<text x="518.0" y="258.6" class="muted-value" text-anchor="middle">504 B</text> <text x="740" y="240" class="panel-title">GetHashCode</text> <line x1="746" y1="330.0" x2="942" y2="330.0" class="axis" /> -<circle cx="844.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> -<text x="844.0" y="321.0" class="value" text-anchor="end">0 B</text> +<polyline points="746.0,330.0 767.8,330.0 789.6,330.0 811.3,330.0 833.1,330.0 854.9,330.0 876.7,330.0 898.4,330.0 920.2,330.0 942.0,330.0" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="767.8" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="789.6" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="811.3" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="833.1" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="854.9" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="876.7" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="898.4" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="920.2" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="942.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<text x="942.0" y="321.0" class="value" text-anchor="end">0 B</text> +<text x="746.0" y="321.0" class="muted-value" text-anchor="middle">0 B</text> <rect x="56" y="360" width="9" height="9" rx="2" fill="#d95926" /> <text x="71" y="368" class="section">Time, as a multiple of a fixed reference workload</text> <text x="71" y="382" class="caption">Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster.</text> <text x="56" y="406" class="panel-title">Create (no validation)</text> <line x1="62" y1="496.0" x2="258" y2="496.0" class="axis" /> -<circle cx="160.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="160.0" y="424.6" class="value" text-anchor="end">2.21×</text> +<polyline points="62.0,433.6 83.8,435.4 105.6,434.4 127.3,433.9 149.1,435.3 170.9,434.8 192.7,434.7 214.4,434.0 236.2,434.1 258.0,434.8" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="83.8" cy="435.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="105.6" cy="434.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="127.3" cy="433.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="149.1" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="170.9" cy="434.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="192.7" cy="434.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="214.4" cy="434.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="236.2" cy="434.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="434.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="425.8" class="value" text-anchor="end">2.21×</text> +<text x="62.0" y="424.6" class="muted-value" text-anchor="middle">2.26×</text> <text x="284" y="406" class="panel-title">Create (charset regex)</text> <line x1="290" y1="496.0" x2="486" y2="496.0" class="axis" /> -<circle cx="388.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="388.0" y="424.6" class="value" text-anchor="end">3.63×</text> +<polyline points="290.0,438.6 311.8,438.7 333.6,433.6 355.3,437.8 377.1,438.8 398.9,438.5 420.7,437.7 442.4,435.7 464.2,435.6 486.0,439.5" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="438.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="311.8" cy="438.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="333.6" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="355.3" cy="437.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="377.1" cy="438.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="398.9" cy="438.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="420.7" cy="437.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="442.4" cy="435.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="464.2" cy="435.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="439.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="430.5" class="value" text-anchor="end">3.63×</text> +<text x="290.0" y="429.6" class="muted-value" text-anchor="middle">3.68×</text> <text x="512" y="406" class="panel-title">Create (format regex)</text> <line x1="518" y1="496.0" x2="714" y2="496.0" class="axis" /> -<circle cx="616.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="616.0" y="424.6" class="value" text-anchor="end">3.91×</text> +<polyline points="518.0,440.6 539.8,439.7 561.6,439.5 583.3,435.0 605.1,435.3 626.9,439.5 648.7,440.5 670.4,433.6 692.2,437.1 714.0,439.2" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="440.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="539.8" cy="439.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="561.6" cy="439.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="583.3" cy="435.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="605.1" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="626.9" cy="439.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="648.7" cy="440.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="670.4" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="692.2" cy="437.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="439.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="430.2" class="value" text-anchor="end">3.91×</text> +<text x="518.0" y="431.6" class="muted-value" text-anchor="middle">3.80×</text> <text x="740" y="406" class="panel-title">Create (mod-97)</text> <line x1="746" y1="496.0" x2="942" y2="496.0" class="axis" /> -<circle cx="844.0" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="844.0" y="424.6" class="value" text-anchor="end">3.90×</text> +<polyline points="746.0,437.6 767.8,437.8 789.6,437.7 811.3,436.7 833.1,437.7 854.9,437.6 876.7,438.1 898.4,433.6 920.2,435.0 942.0,437.4" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="437.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="767.8" cy="437.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="789.6" cy="437.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="811.3" cy="436.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="833.1" cy="437.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="854.9" cy="437.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="876.7" cy="438.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="898.4" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="920.2" cy="435.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="942.0" cy="437.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="942.0" y="428.4" class="value" text-anchor="end">3.90×</text> +<text x="746.0" y="428.6" class="muted-value" text-anchor="middle">3.88×</text> <text x="56" y="538" class="panel-title">TryCreate (rejects)</text> <line x1="62" y1="628.0" x2="258" y2="628.0" class="axis" /> -<circle cx="160.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="160.0" y="556.6" class="value" text-anchor="end">9.11×</text> +<polyline points="62.0,583.9 83.8,583.9 105.6,584.2 127.3,584.1 149.1,584.1 170.9,583.3 192.7,581.1 214.4,565.6 236.2,583.2 258.0,583.6" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="583.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="83.8" cy="583.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="105.6" cy="584.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="127.3" cy="584.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="149.1" cy="584.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="170.9" cy="583.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="192.7" cy="581.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="214.4" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="236.2" cy="583.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="583.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="574.6" class="value" text-anchor="end">9.11×</text> +<text x="62.0" y="574.9" class="muted-value" text-anchor="middle">9.06×</text> <text x="284" y="538" class="panel-title">Create (throws)</text> <line x1="290" y1="628.0" x2="486" y2="628.0" class="axis" /> -<circle cx="388.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="388.0" y="556.6" class="value" text-anchor="end">9.11×</text> +<polyline points="290.0,570.3 311.8,569.7 333.6,568.2 355.3,570.1 377.1,569.7 398.9,566.3 420.7,569.2 442.4,569.4 464.2,565.6 486.0,569.3" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="570.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="311.8" cy="569.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="333.6" cy="568.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="355.3" cy="570.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="377.1" cy="569.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="398.9" cy="566.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="420.7" cy="569.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="442.4" cy="569.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="464.2" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="569.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="560.3" class="value" text-anchor="end">9.11×</text> +<text x="290.0" y="561.3" class="muted-value" text-anchor="middle">8.95×</text> <text x="512" y="538" class="panel-title">As&lt;T&gt; conversion</text> <line x1="518" y1="628.0" x2="714" y2="628.0" class="axis" /> -<circle cx="616.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="616.0" y="556.6" class="value" text-anchor="end">2.21×</text> +<polyline points="518.0,567.5 539.8,569.0 561.6,565.6 583.3,568.9 605.1,565.6 626.9,565.7 648.7,568.4 670.4,568.8 692.2,568.8 714.0,568.6" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="567.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="539.8" cy="569.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="561.6" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="583.3" cy="568.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="605.1" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="626.9" cy="565.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="648.7" cy="568.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="670.4" cy="568.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="692.2" cy="568.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="568.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="559.6" class="value" text-anchor="end">2.21×</text> +<text x="518.0" y="558.5" class="muted-value" text-anchor="middle">2.26×</text> <text x="740" y="538" class="panel-title">GetHashCode</text> <line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> -<circle cx="844.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> -<text x="844.0" y="556.6" class="value" text-anchor="end">0.0569×</text> -<text x="56" y="664" class="tick">releases, oldest to newest: 5.3.4</text> +<polyline points="746.0,570.2 767.8,565.6 789.6,569.8 811.3,569.7 833.1,570.3 854.9,568.4 876.7,569.9 898.4,569.9 920.2,570.1 942.0,570.0" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="570.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="767.8" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="789.6" cy="569.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="811.3" cy="569.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="833.1" cy="570.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="854.9" cy="568.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="876.7" cy="569.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="898.4" cy="569.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="920.2" cy="570.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="942.0" cy="570.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="942.0" y="561.0" class="value" text-anchor="end">0.0569×</text> +<text x="746.0" y="561.2" class="muted-value" text-anchor="middle">0.0568×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 4.0.0 → 4.1.0 → 4.2.0 → 4.3.2 → 5.0.0 → 5.1.0 → 5.2.0 → 5.2.4 → 5.3.2 → 5.3.4</text> <text x="56" y="680" class="caption">Measured on Intel Core i9-14900. Full tables: Semantics.Benchmarks.</text> </svg> diff --git a/docs/benchmarks/strings-performance.svg b/docs/benchmarks/strings-performance.svg index e24bf79c..cff16d42 100644 --- a/docs/benchmarks/strings-performance.svg +++ b/docs/benchmarks/strings-performance.svg @@ -11,77 +11,253 @@ </style> <rect width="992" height="722" fill="#fcfcfb" /> <text x="56" y="28" class="title">Semantics.Strings performance by release</text> -<text x="56" y="45" class="caption">1 releases · newest 5.3.4 · 2026-09-16</text> +<text x="56" y="45" class="caption">10 releases · newest 5.3.4 · 2026-09-16</text> <rect x="56" y="62" width="9" height="9" rx="2" fill="#2a78d6" /> <text x="71" y="70" class="section">Allocated bytes per operation</text> <text x="71" y="84" class="caption">Deterministic: the same code allocates the same bytes on any machine.</text> <text x="56" y="108" class="panel-title">Create (no validation)</text> <line x1="62" y1="198.0" x2="258" y2="198.0" class="axis" /> -<circle cx="160.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="160.0" y="126.6" class="value" text-anchor="end">504 B</text> +<polyline points="62.0,135.6 83.8,135.6 105.6,135.6 127.3,135.6 149.1,135.6 170.9,135.6 192.7,135.6 214.4,135.6 236.2,135.6 258.0,135.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="83.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="105.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="127.3" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="149.1" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="170.9" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="192.7" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="214.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="236.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="126.6" class="value" text-anchor="end">504 B</text> +<text x="62.0" y="126.6" class="muted-value" text-anchor="middle">504 B</text> <text x="284" y="108" class="panel-title">Create (charset regex)</text> <line x1="290" y1="198.0" x2="486" y2="198.0" class="axis" /> -<circle cx="388.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="388.0" y="126.6" class="value" text-anchor="end">896 B</text> +<polyline points="290.0,135.6 311.8,135.6 333.6,135.6 355.3,135.6 377.1,135.6 398.9,135.6 420.7,135.6 442.4,135.6 464.2,135.6 486.0,135.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="311.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="333.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="355.3" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="377.1" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="398.9" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="420.7" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="442.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="464.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="290.0" y="126.6" class="muted-value" text-anchor="middle">896 B</text> <text x="512" y="108" class="panel-title">Create (format regex)</text> <line x1="518" y1="198.0" x2="714" y2="198.0" class="axis" /> -<circle cx="616.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="616.0" y="126.6" class="value" text-anchor="end">928 B</text> +<polyline points="518.0,135.6 539.8,135.6 561.6,135.6 583.3,135.6 605.1,135.6 626.9,135.6 648.7,135.6 670.4,135.6 692.2,135.6 714.0,135.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="539.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="561.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="583.3" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="605.1" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="626.9" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="648.7" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="670.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="692.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="126.6" class="value" text-anchor="end">928 B</text> +<text x="518.0" y="126.6" class="muted-value" text-anchor="middle">928 B</text> <text x="740" y="108" class="panel-title">Create (mod-97)</text> <line x1="746" y1="198.0" x2="942" y2="198.0" class="axis" /> -<circle cx="844.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="844.0" y="126.6" class="value" text-anchor="end">896 B</text> +<polyline points="746.0,135.6 767.8,135.6 789.6,135.6 811.3,135.6 833.1,135.6 854.9,135.6 876.7,135.6 898.4,135.6 920.2,135.6 942.0,135.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="767.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="789.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="811.3" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="833.1" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="854.9" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="876.7" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="898.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="920.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="942.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="942.0" y="126.6" class="value" text-anchor="end">896 B</text> +<text x="746.0" y="126.6" class="muted-value" text-anchor="middle">896 B</text> <text x="56" y="240" class="panel-title">TryCreate (rejects)</text> <line x1="62" y1="330.0" x2="258" y2="330.0" class="axis" /> -<circle cx="160.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="160.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<polyline points="62.0,267.6 83.8,267.6 105.6,267.6 127.3,267.6 149.1,267.6 170.9,267.6 192.7,267.6 214.4,267.6 236.2,267.6 258.0,267.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="83.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="105.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="127.3" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="149.1" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="170.9" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="192.7" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="214.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="236.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<text x="62.0" y="258.6" class="muted-value" text-anchor="middle">1.4 KB</text> <text x="284" y="240" class="panel-title">Create (throws)</text> <line x1="290" y1="330.0" x2="486" y2="330.0" class="axis" /> -<circle cx="388.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="388.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<polyline points="290.0,267.6 311.8,267.6 333.6,267.6 355.3,267.6 377.1,267.6 398.9,267.6 420.7,267.6 442.4,267.6 464.2,267.6 486.0,267.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="311.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="333.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="355.3" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="377.1" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="398.9" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="420.7" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="442.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="464.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="258.6" class="value" text-anchor="end">1.4 KB</text> +<text x="290.0" y="258.6" class="muted-value" text-anchor="middle">1.4 KB</text> <text x="512" y="240" class="panel-title">As&lt;T&gt; conversion</text> <line x1="518" y1="330.0" x2="714" y2="330.0" class="axis" /> -<circle cx="616.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="616.0" y="258.6" class="value" text-anchor="end">504 B</text> +<polyline points="518.0,267.6 539.8,267.6 561.6,267.6 583.3,267.6 605.1,267.6 626.9,267.6 648.7,267.6 670.4,267.6 692.2,267.6 714.0,267.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="539.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="561.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="583.3" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="605.1" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="626.9" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="648.7" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="670.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="692.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="258.6" class="value" text-anchor="end">504 B</text> +<text x="518.0" y="258.6" class="muted-value" text-anchor="middle">504 B</text> <text x="740" y="240" class="panel-title">GetHashCode</text> <line x1="746" y1="330.0" x2="942" y2="330.0" class="axis" /> -<circle cx="844.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> -<text x="844.0" y="321.0" class="value" text-anchor="end">0 B</text> +<polyline points="746.0,330.0 767.8,330.0 789.6,330.0 811.3,330.0 833.1,330.0 854.9,330.0 876.7,330.0 898.4,330.0 920.2,330.0 942.0,330.0" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="767.8" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="789.6" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="811.3" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="833.1" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="854.9" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="876.7" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="898.4" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="920.2" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="942.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<text x="942.0" y="321.0" class="value" text-anchor="end">0 B</text> +<text x="746.0" y="321.0" class="muted-value" text-anchor="middle">0 B</text> <rect x="56" y="360" width="9" height="9" rx="2" fill="#eb6834" /> <text x="71" y="368" class="section">Time, as a multiple of a fixed reference workload</text> <text x="71" y="382" class="caption">Divided by a reference loop measured in the same job, which cancels most of the difference between CI runners. Lower is faster.</text> <text x="56" y="406" class="panel-title">Create (no validation)</text> <line x1="62" y1="496.0" x2="258" y2="496.0" class="axis" /> -<circle cx="160.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="160.0" y="424.6" class="value" text-anchor="end">2.21×</text> +<polyline points="62.0,433.6 83.8,435.4 105.6,434.4 127.3,433.9 149.1,435.3 170.9,434.8 192.7,434.7 214.4,434.0 236.2,434.1 258.0,434.8" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="83.8" cy="435.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="105.6" cy="434.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="127.3" cy="433.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="149.1" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="170.9" cy="434.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="192.7" cy="434.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="214.4" cy="434.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="236.2" cy="434.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="434.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="425.8" class="value" text-anchor="end">2.21×</text> +<text x="62.0" y="424.6" class="muted-value" text-anchor="middle">2.26×</text> <text x="284" y="406" class="panel-title">Create (charset regex)</text> <line x1="290" y1="496.0" x2="486" y2="496.0" class="axis" /> -<circle cx="388.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="388.0" y="424.6" class="value" text-anchor="end">3.63×</text> +<polyline points="290.0,438.6 311.8,438.7 333.6,433.6 355.3,437.8 377.1,438.8 398.9,438.5 420.7,437.7 442.4,435.7 464.2,435.6 486.0,439.5" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="438.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="311.8" cy="438.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="333.6" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="355.3" cy="437.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="377.1" cy="438.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="398.9" cy="438.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="420.7" cy="437.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="442.4" cy="435.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="464.2" cy="435.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="439.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="430.5" class="value" text-anchor="end">3.63×</text> +<text x="290.0" y="429.6" class="muted-value" text-anchor="middle">3.68×</text> <text x="512" y="406" class="panel-title">Create (format regex)</text> <line x1="518" y1="496.0" x2="714" y2="496.0" class="axis" /> -<circle cx="616.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="616.0" y="424.6" class="value" text-anchor="end">3.91×</text> +<polyline points="518.0,440.6 539.8,439.7 561.6,439.5 583.3,435.0 605.1,435.3 626.9,439.5 648.7,440.5 670.4,433.6 692.2,437.1 714.0,439.2" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="440.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="539.8" cy="439.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="561.6" cy="439.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="583.3" cy="435.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="605.1" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="626.9" cy="439.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="648.7" cy="440.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="670.4" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="692.2" cy="437.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="439.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="430.2" class="value" text-anchor="end">3.91×</text> +<text x="518.0" y="431.6" class="muted-value" text-anchor="middle">3.80×</text> <text x="740" y="406" class="panel-title">Create (mod-97)</text> <line x1="746" y1="496.0" x2="942" y2="496.0" class="axis" /> -<circle cx="844.0" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="844.0" y="424.6" class="value" text-anchor="end">3.90×</text> +<polyline points="746.0,437.6 767.8,437.8 789.6,437.7 811.3,436.7 833.1,437.7 854.9,437.6 876.7,438.1 898.4,433.6 920.2,435.0 942.0,437.4" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="437.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="767.8" cy="437.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="789.6" cy="437.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="811.3" cy="436.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="833.1" cy="437.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="854.9" cy="437.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="876.7" cy="438.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="898.4" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="920.2" cy="435.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="942.0" cy="437.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="942.0" y="428.4" class="value" text-anchor="end">3.90×</text> +<text x="746.0" y="428.6" class="muted-value" text-anchor="middle">3.88×</text> <text x="56" y="538" class="panel-title">TryCreate (rejects)</text> <line x1="62" y1="628.0" x2="258" y2="628.0" class="axis" /> -<circle cx="160.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="160.0" y="556.6" class="value" text-anchor="end">9.11×</text> +<polyline points="62.0,583.9 83.8,583.9 105.6,584.2 127.3,584.1 149.1,584.1 170.9,583.3 192.7,581.1 214.4,565.6 236.2,583.2 258.0,583.6" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="583.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="83.8" cy="583.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="105.6" cy="584.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="127.3" cy="584.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="149.1" cy="584.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="170.9" cy="583.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="192.7" cy="581.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="214.4" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="236.2" cy="583.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="583.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="574.6" class="value" text-anchor="end">9.11×</text> +<text x="62.0" y="574.9" class="muted-value" text-anchor="middle">9.06×</text> <text x="284" y="538" class="panel-title">Create (throws)</text> <line x1="290" y1="628.0" x2="486" y2="628.0" class="axis" /> -<circle cx="388.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="388.0" y="556.6" class="value" text-anchor="end">9.11×</text> +<polyline points="290.0,570.3 311.8,569.7 333.6,568.2 355.3,570.1 377.1,569.7 398.9,566.3 420.7,569.2 442.4,569.4 464.2,565.6 486.0,569.3" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="570.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="311.8" cy="569.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="333.6" cy="568.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="355.3" cy="570.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="377.1" cy="569.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="398.9" cy="566.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="420.7" cy="569.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="442.4" cy="569.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="464.2" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="569.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="560.3" class="value" text-anchor="end">9.11×</text> +<text x="290.0" y="561.3" class="muted-value" text-anchor="middle">8.95×</text> <text x="512" y="538" class="panel-title">As&lt;T&gt; conversion</text> <line x1="518" y1="628.0" x2="714" y2="628.0" class="axis" /> -<circle cx="616.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="616.0" y="556.6" class="value" text-anchor="end">2.21×</text> +<polyline points="518.0,567.5 539.8,569.0 561.6,565.6 583.3,568.9 605.1,565.6 626.9,565.7 648.7,568.4 670.4,568.8 692.2,568.8 714.0,568.6" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="567.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="539.8" cy="569.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="561.6" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="583.3" cy="568.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="605.1" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="626.9" cy="565.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="648.7" cy="568.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="670.4" cy="568.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="692.2" cy="568.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="568.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="559.6" class="value" text-anchor="end">2.21×</text> +<text x="518.0" y="558.5" class="muted-value" text-anchor="middle">2.26×</text> <text x="740" y="538" class="panel-title">GetHashCode</text> <line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> -<circle cx="844.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> -<text x="844.0" y="556.6" class="value" text-anchor="end">0.0569×</text> -<text x="56" y="664" class="tick">releases, oldest to newest: 5.3.4</text> +<polyline points="746.0,570.2 767.8,565.6 789.6,569.8 811.3,569.7 833.1,570.3 854.9,568.4 876.7,569.9 898.4,569.9 920.2,570.1 942.0,570.0" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="570.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="767.8" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="789.6" cy="569.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="811.3" cy="569.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="833.1" cy="570.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="854.9" cy="568.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="876.7" cy="569.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="898.4" cy="569.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="920.2" cy="570.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="942.0" cy="570.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="942.0" y="561.0" class="value" text-anchor="end">0.0569×</text> +<text x="746.0" y="561.2" class="muted-value" text-anchor="middle">0.0568×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 4.0.0 → 4.1.0 → 4.2.0 → 4.3.2 → 5.0.0 → 5.1.0 → 5.2.0 → 5.2.4 → 5.3.2 → 5.3.4</text> <text x="56" y="680" class="caption">Measured on Intel Core i9-14900. Full tables: Semantics.Benchmarks.</text> </svg> From e806e9a9e9ae119f62460219125c1ffa51bbc291 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 14:00:39 +1000 Subject: [PATCH 24/26] Document the strings and paths benchmarks [patch] 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 | 33 +++++++++++ README.md | 48 +++++++++++++++- Semantics.Benchmarks/README.md | 102 +++++++++++++++++++++++++++++++-- 3 files changed, 176 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 0d53df7a..d43935b9 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,6 +45,7 @@ The opt-in lives in `.sonarlint/sonar-local.props` (analyzer package) and `.sona | `Semantics.Quantities` | Hand-written runtime types (`IPhysicalQuantity<TSelf, T>`, `PhysicalQuantityCore`, `IVector0`..`IVector4`, `UnitSystem`) plus generator output under `Generated/`. Every generated quantity is a `readonly record struct`. | | `Semantics.SourceGenerators` | Roslyn incremental generators that emit quantity types, units, conversions, magnitudes, physical constants, and storage-type helpers from metadata. Only the physics-specific half lives here — `Models/`, `Metadata/`, `Generators/`, and the bindings in `SemanticsGenerator`/`SemanticsDiagnostics`/`Emit`. The C# syntax templates come from `ktsu.CodeBlocker.Templates`; the metadata-driven generator base, metadata loading and the diagnostic catalogue come from `ktsu.SourceGeneratorToolkit` (#181, #192). | | `Semantics.Quantities.{Double,Float,Decimal,Precise}` | Props-only satellite packages. Each ships a `buildTransitive` props file (generated by `scripts/Generate-AliasProps.ps1`) that injects global-using aliases binding every quantity to one storage type, so consumers write `Mass` instead of `Mass<double>`. `Precise` binds to `ktsu.PreciseNumber.PreciseNumber` and is the one whose storage type comes from a package rather than being a C# keyword, so it carries a `PackageReference` the others do not; the core `Semantics.Quantities` still has no PreciseNumber dependency. | +| `Semantics.Benchmarks` | BenchmarkDotNet suite covering quantities, strings and paths. Not shipped and not covered by tests, so it carries `SonarQubeExclude`. Feeds the per-release charts in `docs/benchmarks/`. | | `Semantics.Vocabulary` | **Shared source, not a project.** Resolves `dimensions.json` into the quantities and operators it describes and separates out what cannot be honoured. Compiled into both `Semantics.SourceGenerators` and `Semantics.Cpp` via `Compile Include`; see its README for why source rather than an assembly, and what that costs. | | `Semantics.Cpp` | The C++ projection of the quantity vocabulary, in its own project because `ktsu.Coder` ships no `net8.0`. Reads `dimensions.json` and emits one C++ class per dimension, per vector form and per named overload, plus the declared relationships as operators. | | `Semantics.Cpp.Test` | Its tests, including ones that compile the whole generated vocabulary with `g++`/`clang++`, assert what it means through `static_assert`, and check that a dimensionally wrong product — scalar or componentwise — is refused by the compiler. Also `SevenTargetProjectionTests`, which is not about C++ at all: see below. | @@ -309,6 +310,38 @@ through `double`. `double` and `decimal`, exactly where the answer terminates and to a relative tolerance where it does not. Adding a storage type is one derived class. +### Benchmarks and the release charts + +`Semantics.Benchmarks` measures three libraries. `.github/workflows/benchmark-history.yml` runs a +fixed set once per release, appends to a history file per subject under `docs/benchmarks/`, and +redraws the chart the README shows. Three things about it are not guessable from the code: + +- **`BenchmarkAgainstVersion` must be set in the environment, never with `-p:`.** BenchmarkDotNet + generates and builds a project of its own for each run, which a property passed on the command line + never reaches: the benchmark assembly would build against the version asked for and the harness + against the one pinned centrally, which fails to compile if a type changed shape between them. + MSBuild reads environment variables as properties in every project, so the environment form reaches + both. It swaps all four shipped packages at once, which is correct because this repository ships one + version across every package. +- **The histories and the SVG files are committed output that a bot pushes.** A local `render` must be + diffed before commit rather than assumed. The quantities chart in particular is a regression test: + a change to the renderer that moves a byte in it has broken something. +- **Nothing here touches an internal member.** The `InternalsVisibleTo` that would expose one names + only the test assembly, and a benchmark built on internals could only ever measure the working copy, + never a published package — which would make the release history impossible to backfill. + +`BaselineBenchmarks.ReferenceWork` measures a fixed workload that touches none of this library, so +timings from different CI runners can be compared. **Its body must never change.** Editing it silently +rescales every comparison drawn against history recorded before the edit. + +All three subjects' benchmarks live in one project, so a compile error in any one file blocks every +subject's run regardless of `--filter`. This is why the strings and paths histories start at 4.0.0 +with no 3.3.1 entry, while the quantities history reaches back to 3.3.1: against a pre-4.0.0 package, +`AbstractionCostBenchmarks.cs` (a quantities-only file) fails to build, because it holds a `Length<T>` +field with no initializer, valid only once `Length<T>` became a `readonly record struct`. `docs/benchmarks/history.json` +still carries a 3.3.1 entry predating that file's current shape, which the current suite can no longer +regenerate for any subject. + ### Operators and physics relationships Cross-dimensional relationships are also declared in `dimensions.json` (`integrals`, `derivatives`, `dotProducts`, `crossProducts`). The generator emits operators like: diff --git a/README.md b/README.md index 31ee7307..9f0e7d4a 100644 --- a/README.md +++ b/README.md @@ -162,16 +162,58 @@ public class UserService(ISemanticStringFactory<EmailAddress> emails) ## Performance +Every release measures a fixed set of benchmarks and adds a point to a chart per library. The numbers +behind them are in [`docs/benchmarks/`](docs/benchmarks/), and the suite is +[`Semantics.Benchmarks`](Semantics.Benchmarks/README.md). + +Read the two halves of every chart differently. **Allocation is exact** — the same code allocates the +same bytes on any machine, so a step in the top row is always a real change. **Time is measured on +shared CI runners**, where the host a job happens to land on varies more than most releases do, so +each time is divided by a reference workload measured in the same job. That cancels most of the +difference between machines; what is left is indicative rather than precise. + +### Quantities + <picture> <source media="(prefers-color-scheme: dark)" srcset="docs/benchmarks/performance-dark.svg"> <img alt="Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Quantities release" src="docs/benchmarks/performance.svg"> </picture> -Every release measures a fixed set of benchmarks and adds a point to the chart; the numbers behind it are in [`docs/benchmarks/history.json`](docs/benchmarks/history.json), and the suite is [`Semantics.Benchmarks`](Semantics.Benchmarks/README.md). +The grid is one operation per storage type rather than every operation at one storage type. A quantity +is a `readonly record struct` over its `T` and does almost nothing of its own — a value is held in the +SI base unit, so an operator is the storage type's arithmetic and a struct initialiser — so the same +line of user code costs different things depending on the `T` it was written against, and a release +changes it per `T`. + +### Strings + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="docs/benchmarks/strings-performance-dark.svg"> + <img alt="Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Strings release" src="docs/benchmarks/strings-performance.svg"> +</picture> + +The axis here is validation weight, because that is where a semantic string spends. `Create` goes +through `Activator.CreateInstance`, a `PropertyInfo.SetValue`, and a reflective walk of the type's +validation attributes on every call, so the top row walks from that machinery alone up through a +character set check, a format check, and a mod-97 check. The bottom row is what surrounds it: both +failure paths, the cross-type conversion that is a full creation in disguise, and an ordering that is +the underlying string's own. + +This is a different answer from the quantities one, and worth stating plainly rather than leaving to +be inferred from a chart: a quantity's wrapper is free, and a semantic string's is not. What it buys +is that an invalid value cannot exist, checked once at the boundary instead of everywhere the value +is used. -The grid is one operation per storage type rather than every operation at one storage type. A quantity is a `readonly record struct` over its `T` and does almost nothing of its own — a value is held in the SI base unit, so an operator is the storage type's arithmetic and a struct initialiser — so the same line of user code costs different things depending on the `T` it was written against, and a release changes it per `T`. +### Paths + +<picture> + <source media="(prefers-color-scheme: dark)" srcset="docs/benchmarks/paths-performance-dark.svg"> + <img alt="Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Paths release" src="docs/benchmarks/paths-performance.svg"> +</picture> -Read the two halves differently. **Allocation is exact** — the same code allocates the same bytes on any machine, so a step in the top row is always a real change. **Time is measured on shared CI runners**, where the host a job happens to land on varies more than most releases do, so each time is divided by a reference workload measured in the same job. That cancels most of the difference between machines; what is left is indicative rather than precise. +Building each kind of path, then operating on one. The two file name panels sit next to each other +deliberately: `FileNameWithoutExtension` caches into a field, `FileName` rebuilds and revalidates on +every read, and both look like field access at a call site. ## Architecture diff --git a/Semantics.Benchmarks/README.md b/Semantics.Benchmarks/README.md index 225f2ab3..6f0b83f8 100644 --- a/Semantics.Benchmarks/README.md +++ b/Semantics.Benchmarks/README.md @@ -4,7 +4,9 @@ A [BenchmarkDotNet](https://benchmarkdotnet.org) suite covering the quantity sys quantity from a unit, reading it back out in one, the generated physics operators, the componentwise vector operations, and comparison. -## The axis that matters here is the storage type +## Quantities + +### The axis that matters here is the storage type Every generated quantity is a `readonly record struct` over a storage type — `Length<double>`, `Length<decimal>`, `Length<PreciseNumber>` — and it does almost nothing of its own. A value is held @@ -41,6 +43,13 @@ dotnet run -c Release --project Semantics.Benchmarks -- --filter '*<Decimal>*' dotnet run -c Release --project Semantics.Benchmarks -- --filter '*AbstractionCostBenchmarks*' ``` +The suite covers three libraries, and `--filter` is how one is picked: + +```bash +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*String*Benchmarks*' +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*Path*Benchmarks*' +``` + ## Measuring a published release Set `BenchmarkAgainstVersion` and the suite measures that package instead of the working copy: @@ -54,6 +63,9 @@ its own for each run, and a property passed on the command line does not reach t would build the benchmark assembly against the version you asked for and the harness against the one pinned centrally, which fails to compile if a type changed shape between them. MSBuild reads environment variables as properties in every project, so the environment form reaches both. +`BenchmarkAgainstVersion` swaps all four shipped packages, `Quantities`, `Strings`, +`Strings.Identifiers` and `Paths`, at the one version, which is correct because this repository +ships one version across every package. This switch is how `docs/benchmarks/` is filled. No tag in this repository carries a benchmark project, so there is no older source to check out and run; and measuring packages is the better @@ -63,11 +75,20 @@ reported and skipped rather than failing the backfill — 4.0 made every quantit 5.0 removed four operators, so reaching back far enough eventually finds a version this suite cannot ask. +That skip is also shared across subjects in a way worth naming, because it is the reason the strings +and paths charts start at 4.0.0 with no 3.3.1 point. All three subjects live in one project, so a +compile error in any one file blocks every subject's run regardless of `--filter`. Against packages +older than 4.0.0, `AbstractionCostBenchmarks.cs` — a quantities-only file — fails to build, because +it declares a `Length<T>` field with no initializer, which is only an error once `Length<T>` becomes +a `readonly record struct`; before that migration it was a reference type and the field was valid. +`docs/benchmarks/history.json` still carries a 3.3.1 entry from before that file existed in its +current form, but the current suite cannot regenerate it for any subject. + It is also why nothing here touches an internal member: the `InternalsVisibleTo` that would expose one names the test assembly, and a benchmark built on internals could only ever measure the working copy. -## What each class is for +### What each class is for | Class | What it isolates | |---|---| @@ -79,7 +100,7 @@ working copy. | `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 +#### 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 @@ -120,7 +141,7 @@ it is a floor on the real cost rather than the whole of it. 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 +#### An operator on a `double` is below the floor `OperatorBenchmarks` over `double` and `float` comes back with a ZeroMeasurement warning: the method is indistinguishable from an empty one. That is not a broken benchmark, it is the answer. @@ -133,6 +154,79 @@ those rows are read as "below what the harness resolves" rather than as numbers, `PreciseNumber` rows in the same table are the ones that mean something, and the release chart draws no operator panel at all. +## Strings + +A suite covering three things: building a semantic string across a ladder of validators, what a +value costs once it exists, and what the type costs over the code a caller would otherwise write. + +### What each class is for + +| Class | What it isolates | +|---|---| +| `StringCreationBenchmarks` | Creation across a ladder of validators. `Unvalidated` is the reflection floor alone (`Activator.CreateInstance`, a `PropertyInfo.SetValue`, and a strategy lookup that finds nothing), and `CharsetRegex`, `FormatRegex`, `Checksum` and `Mod97` each add one validator on top of it. `TryCreateRejects` and `CreateThrows` are both failure paths, measured side by side. | +| `StringOperationBenchmarks` | What a value costs after creation: equality, ordering, hashing, and two calls that look like field access at a call site but are a full creation in disguise, `AsConversion` and `WithSuffix`. | +| `StringAbstractionCostBenchmarks` | The same operation written twice, once by hand at a boundary and once through the type, paired so BenchmarkDotNet reports the ratio directly. See below. | + +Four structurally different validators land within about 340 ns of each other on a floor of roughly +1,650 ns. A Luhn pass, a character-set regular expression, a mod-97 pass and a format regular +expression cost 0.93, 1.06, 1.26 and 1.27 microseconds on top of that floor, so the validator is a +minor term and the reflection machinery is the bill. The last two are a near-tie, worth naming +because the mod-97 check was expected to dominate going in, and it does not. + +### What the type costs over the code a caller would otherwise write + +| pair | ratio | allocation, bare | allocation, semantic | +|---|---|---|---| +| Validate at a boundary | 12.94 | 0 B | 928 B | +| Reject without throwing | 164.98 | 0 B | 1424 B | +| Equality | 2.53 | 0 B | 0 B | +| Ordering | 1.11 | 0 B | 0 B | + +The Reject ratio is the most actionable number in the table. `SemanticString.TryFromString` is +implemented as a `Create` call wrapped in a `try`/`catch` for `ArgumentException`, so a rejection +pays a full .NET exception where the hand-written pattern match returns a plain `false`. At a +boundary that rejects often, `TryCreate` should be read as costing an exception every time, not as +the cheap option its name suggests. + +Equality and ordering are a fair pair only once each baseline is pinned to the rule the semantic side +actually follows. Record equality on a semantic string uses `EqualityComparer<string>.Default`, +which is ordinal, while `CompareTo` forwards to `string.CompareTo(string)`, which is +current-culture collation. Two values can therefore compare equal under `==` and still sort by a +different rule. Nothing here changes that behavior. It is reported because it is easy to assume the +two follow the same rule. + +## Paths + +A suite covering building each path type from a string, operating on a path once it exists, and what +the type costs over `System.IO.Path` doing the same work. + +### What each class is for + +| Class | What it isolates | +|---|---| +| `PathCreationBenchmarks` | Building each path type from a well-formed string. A path type is a semantic string whose validator asks the runtime a question about the shape of the value, so each row is the reflection floor plus one such question. | +| `PathOperationBenchmarks` | What a path costs once it exists. `FileNameWithoutExtension` caches into a field and reads it back; `FileName` rebuilds and revalidates on every read. Both are properties and both look like field access at a call site. | +| `PathAbstractionCostBenchmarks` | The same operation written twice, once against `System.IO.Path` and once through the type, paired so BenchmarkDotNet reports the ratio directly. See below. | + +### What the type costs over `System.IO.Path` + +| pair | ratio | allocation, bare | allocation, semantic | +|---|---|---|---| +| FileName | 130.22 | 48 B | 944 B | +| AsAbsolute | 15.69 | 256 B | 1944 B | +| AsRelative | 10.35 | 80 B | 1728 B | +| Create | 1,810.28 | 0 B | 1816 B | + +The Create ratio needs its own framing stated next to it, because on its own the number overstates +what changed. It is large because its baseline is cheap, a single boolean check, not because +creation itself is unusually expensive: the semantic side costs roughly the same 4.7 microseconds +across all four categories, and almost all of the spread between the ratios comes from what each one +happens to be divided by. The baseline also deliberately leaves out the separator concatenation +`IsAbsolutePathAttribute` performs before asking the same question, because a baseline in this class +means the code a caller would otherwise write, and a caller checking whether a path is absolute +writes the plain check rather than the library's own way of handling edge cases. That one choice +accounts for roughly a fourteenfold difference in the reported ratio on its own. + ## Reading the results Allocation is reported alongside time and matters just as much. A quantity is a value type, so From 8e4bb96dd136f0e9350206d4a0e66a0aaeeefede Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 14:09:57 +1000 Subject: [PATCH 25/26] Fix review findings: bullet count and heading nesting 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> --- CLAUDE.md | 17 +++-- Semantics.Benchmarks/README.md | 128 ++++++++++++++++----------------- 2 files changed, 72 insertions(+), 73 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d43935b9..4df4e4b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -314,7 +314,7 @@ does not. Adding a storage type is one derived class. `Semantics.Benchmarks` measures three libraries. `.github/workflows/benchmark-history.yml` runs a fixed set once per release, appends to a history file per subject under `docs/benchmarks/`, and -redraws the chart the README shows. Three things about it are not guessable from the code: +redraws the chart the README shows. Four things about it are not guessable from the code: - **`BenchmarkAgainstVersion` must be set in the environment, never with `-p:`.** BenchmarkDotNet generates and builds a project of its own for each run, which a property passed on the command line @@ -329,19 +329,18 @@ redraws the chart the README shows. Three things about it are not guessable from - **Nothing here touches an internal member.** The `InternalsVisibleTo` that would expose one names only the test assembly, and a benchmark built on internals could only ever measure the working copy, never a published package — which would make the release history impossible to backfill. +- **All three subjects' benchmarks live in one project, so a compile error in any one file blocks + every subject's run regardless of `--filter`.** This is why the strings and paths histories start + at 4.0.0 with no 3.3.1 entry, while the quantities history reaches back to 3.3.1: against a + pre-4.0.0 package, `AbstractionCostBenchmarks.cs` (a quantities-only file) fails to build, because + it holds a `Length<T>` field with no initializer, valid only once `Length<T>` became a + `readonly record struct`. `docs/benchmarks/history.json` still carries a 3.3.1 entry predating that + file's current shape, which the current suite can no longer regenerate for any subject. `BaselineBenchmarks.ReferenceWork` measures a fixed workload that touches none of this library, so timings from different CI runners can be compared. **Its body must never change.** Editing it silently rescales every comparison drawn against history recorded before the edit. -All three subjects' benchmarks live in one project, so a compile error in any one file blocks every -subject's run regardless of `--filter`. This is why the strings and paths histories start at 4.0.0 -with no 3.3.1 entry, while the quantities history reaches back to 3.3.1: against a pre-4.0.0 package, -`AbstractionCostBenchmarks.cs` (a quantities-only file) fails to build, because it holds a `Length<T>` -field with no initializer, valid only once `Length<T>` became a `readonly record struct`. `docs/benchmarks/history.json` -still carries a 3.3.1 entry predating that file's current shape, which the current suite can no longer -regenerate for any subject. - ### Operators and physics relationships Cross-dimensional relationships are also declared in `dimensions.json` (`integrals`, `derivatives`, `dotProducts`, `crossProducts`). The generator emits operators like: diff --git a/Semantics.Benchmarks/README.md b/Semantics.Benchmarks/README.md index 6f0b83f8..fc86a4aa 100644 --- a/Semantics.Benchmarks/README.md +++ b/Semantics.Benchmarks/README.md @@ -24,70 +24,6 @@ Operands are **parsed from text**, never converted from a `double`. A `decimal` `PreciseNumber` seeded through a double would be measured carrying a double's worth of digits, which is the opposite of why those types are in the list. -## Running - -From the repository root: - -```bash -# Pick benchmarks from an interactive list -dotnet run -c Release --project Semantics.Benchmarks - -# Run everything -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 '*<Decimal>*' - -# What the quantity types cost over the bare storage type -dotnet run -c Release --project Semantics.Benchmarks -- --filter '*AbstractionCostBenchmarks*' -``` - -The suite covers three libraries, and `--filter` is how one is picked: - -```bash -dotnet run -c Release --project Semantics.Benchmarks -- --filter '*String*Benchmarks*' -dotnet run -c Release --project Semantics.Benchmarks -- --filter '*Path*Benchmarks*' -``` - -## Measuring a published release - -Set `BenchmarkAgainstVersion` and the suite measures that package instead of the working copy: - -```bash -BenchmarkAgainstVersion=5.2.0 dotnet run -c Release --project Semantics.Benchmarks -- --filter '*<Decimal>*' -``` - -Set it **in the environment, not with `-p:`**. BenchmarkDotNet generates and builds a project of -its own for each run, and a property passed on the command line does not reach that project — it -would build the benchmark assembly against the version you asked for and the harness against the -one pinned centrally, which fails to compile if a type changed shape between them. MSBuild reads -environment variables as properties in every project, so the environment form reaches both. -`BenchmarkAgainstVersion` swaps all four shipped packages, `Quantities`, `Strings`, -`Strings.Identifiers` and `Paths`, at the one version, which is correct because this repository -ships one version across every package. - -This switch is how `docs/benchmarks/` is filled. No tag in this repository carries a benchmark -project, so there is no older source to check out and run; and measuring packages is the better -comparison anyway, because every version is timed by identical benchmark code rather than by -whatever each tag happened to ship. A version whose API the current benchmarks cannot express is -reported and skipped rather than failing the backfill — 4.0 made every quantity a record struct and -5.0 removed four operators, so reaching back far enough eventually finds a version this suite -cannot ask. - -That skip is also shared across subjects in a way worth naming, because it is the reason the strings -and paths charts start at 4.0.0 with no 3.3.1 point. All three subjects live in one project, so a -compile error in any one file blocks every subject's run regardless of `--filter`. Against packages -older than 4.0.0, `AbstractionCostBenchmarks.cs` — a quantities-only file — fails to build, because -it declares a `Length<T>` field with no initializer, which is only an error once `Length<T>` becomes -a `readonly record struct`; before that migration it was a reference type and the field was valid. -`docs/benchmarks/history.json` still carries a 3.3.1 entry from before that file existed in its -current form, but the current suite cannot regenerate it for any subject. - -It is also why nothing here touches an internal member: the `InternalsVisibleTo` that would expose -one names the test assembly, and a benchmark built on internals could only ever measure the -working copy. - ### What each class is for | Class | What it isolates | @@ -154,6 +90,70 @@ those rows are read as "below what the harness resolves" rather than as numbers, `PreciseNumber` rows in the same table are the ones that mean something, and the release chart draws no operator panel at all. +## Running + +From the repository root: + +```bash +# Pick benchmarks from an interactive list +dotnet run -c Release --project Semantics.Benchmarks + +# Run everything +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 '*<Decimal>*' + +# What the quantity types cost over the bare storage type +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*AbstractionCostBenchmarks*' +``` + +The suite covers three libraries, and `--filter` is how one is picked: + +```bash +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*String*Benchmarks*' +dotnet run -c Release --project Semantics.Benchmarks -- --filter '*Path*Benchmarks*' +``` + +## Measuring a published release + +Set `BenchmarkAgainstVersion` and the suite measures that package instead of the working copy: + +```bash +BenchmarkAgainstVersion=5.2.0 dotnet run -c Release --project Semantics.Benchmarks -- --filter '*<Decimal>*' +``` + +Set it **in the environment, not with `-p:`**. BenchmarkDotNet generates and builds a project of +its own for each run, and a property passed on the command line does not reach that project — it +would build the benchmark assembly against the version you asked for and the harness against the +one pinned centrally, which fails to compile if a type changed shape between them. MSBuild reads +environment variables as properties in every project, so the environment form reaches both. +`BenchmarkAgainstVersion` swaps all four shipped packages, `Quantities`, `Strings`, +`Strings.Identifiers` and `Paths`, at the one version, which is correct because this repository +ships one version across every package. + +This switch is how `docs/benchmarks/` is filled. No tag in this repository carries a benchmark +project, so there is no older source to check out and run; and measuring packages is the better +comparison anyway, because every version is timed by identical benchmark code rather than by +whatever each tag happened to ship. A version whose API the current benchmarks cannot express is +reported and skipped rather than failing the backfill — 4.0 made every quantity a record struct and +5.0 removed four operators, so reaching back far enough eventually finds a version this suite +cannot ask. + +That skip is also shared across subjects in a way worth naming, because it is the reason the strings +and paths charts start at 4.0.0 with no 3.3.1 point. All three subjects live in one project, so a +compile error in any one file blocks every subject's run regardless of `--filter`. Against packages +older than 4.0.0, `AbstractionCostBenchmarks.cs` — a quantities-only file — fails to build, because +it declares a `Length<T>` field with no initializer, which is only an error once `Length<T>` becomes +a `readonly record struct`; before that migration it was a reference type and the field was valid. +`docs/benchmarks/history.json` still carries a 3.3.1 entry from before that file existed in its +current form, but the current suite cannot regenerate it for any subject. + +It is also why nothing here touches an internal member: the `InternalsVisibleTo` that would expose +one names the test assembly, and a benchmark built on internals could only ever measure the +working copy. + ## Strings A suite covering three things: building a semantic string across a ladder of validators, what a From f92a619d50d3a2530c311d26e7c53e798e3b8ba1 Mon Sep 17 00:00:00 2001 From: Matt Edmondson <matthew.edmondson@gmail.com> Date: Fri, 18 Sep 2026 14:31:35 +1000 Subject: [PATCH 26/26] Fix whole-branch review findings in benchmark docs [patch] 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> --- .github/workflows/benchmark-history.yml | 14 ++++++++++---- README.md | 10 +++++++--- Semantics.Benchmarks/Paths/PathSpecimens.cs | 2 ++ Semantics.Benchmarks/README.md | 18 +++++++++++++++--- ...26-09-18-strings-paths-benchmarks-design.md | 11 +++++++++-- 5 files changed, 43 insertions(+), 12 deletions(-) diff --git a/.github/workflows/benchmark-history.yml b/.github/workflows/benchmark-history.yml index 225ad967..e01ddd42 100644 --- a/.github/workflows/benchmark-history.yml +++ b/.github/workflows/benchmark-history.yml @@ -22,6 +22,9 @@ name: Benchmark History # is what lets a strings point and a quantities point from the same run be compared at all. It also # keeps the results to a single push. # +# That applies from the first run of this workflow onward. The histories committed before it carry +# baselines from two different machines, so the three charts' time rows are not yet on one scale. +# # The cost is wall clock, and `subjects` on the dispatch is the answer to that: a long backfill is # split by subject across runs rather than by raising the timeout. @@ -58,10 +61,13 @@ env: # One line per chart: name|history|chart|filter. An environment variable cannot hold an array, # and three steps need the same three triples, so this is the one place they are written. # - # Each filter is one operation per panel drawn. What varies between subjects is the axis: a - # quantity is a value type over T and does almost nothing of its own, so its release changes land - # per storage type; a semantic string spends its cost at creation, so its axis is how much - # validation the type declares. + # The quantities filter names one operation per panel. The strings and paths filters name whole + # classes instead, so they measure more than they draw: ingest stores every row and only render + # selects, which lets a panel be promoted later without re-running any history. Do not prune these + # to match the panel count. + # What varies between subjects is the axis: a quantity is a value type over T and does almost + # nothing of its own, so its release changes land per storage type; a semantic string spends its + # cost at creation, so its axis is how much validation the type declares. SUBJECTS: | quantities|docs/benchmarks/history.json|docs/benchmarks/performance.svg|*ConstructionBenchmarks*FromNauticalMile *UnitConversionBenchmarks*InNauticalMile *OperatorBenchmarks*LengthTimesLength *VectorBenchmarks*.Length *ComparisonBenchmarks*CompareToInterface strings|docs/benchmarks/strings-history.json|docs/benchmarks/strings-performance.svg|*StringCreationBenchmarks* *StringOperationBenchmarks* diff --git a/README.md b/README.md index 9f0e7d4a..c9abe622 100644 --- a/README.md +++ b/README.md @@ -170,7 +170,11 @@ Read the two halves of every chart differently. **Allocation is exact** — the same bytes on any machine, so a step in the top row is always a real change. **Time is measured on shared CI runners**, where the host a job happens to land on varies more than most releases do, so each time is divided by a reference workload measured in the same job. That cancels most of the -difference between machines; what is left is indicative rather than precise. +difference between machines; what is left is indicative rather than precise. One caveat on the paths +chart: its inputs are built per platform, because whether a path is absolute is a question the +operating system answers differently, so its allocation row can step when the measuring host changes +rather than when the library does. The other two charts take their inputs from compile-time constants +and do not have that exposure. ### Quantities @@ -196,8 +200,8 @@ The axis here is validation weight, because that is where a semantic string spen through `Activator.CreateInstance`, a `PropertyInfo.SetValue`, and a reflective walk of the type's validation attributes on every call, so the top row walks from that machinery alone up through a character set check, a format check, and a mod-97 check. The bottom row is what surrounds it: both -failure paths, the cross-type conversion that is a full creation in disguise, and an ordering that is -the underlying string's own. +failure paths, the cross-type conversion that is a full creation in disguise, and the hash a +dictionary of semantic strings pays on every lookup. This is a different answer from the quantities one, and worth stating plainly rather than leaving to be inferred from a chart: a quantity's wrapper is free, and a semantic string's is not. What it buys diff --git a/Semantics.Benchmarks/Paths/PathSpecimens.cs b/Semantics.Benchmarks/Paths/PathSpecimens.cs index 019abd56..714b365c 100644 --- a/Semantics.Benchmarks/Paths/PathSpecimens.cs +++ b/Semantics.Benchmarks/Paths/PathSpecimens.cs @@ -21,6 +21,8 @@ namespace ktsu.Semantics.Benchmarks.Paths; /// exactly a measurement taken on Linux. That is smaller than the difference between CI hosts that /// <c>BaselineBenchmarks</c> already exists to normalize, and it is why the history records a /// baseline reading alongside every entry. +/// That normalization covers the time row only. Allocation is not divided by anything, so a +/// platform change moves it directly, and the README says so beside the paths chart. /// </para> /// <para> /// Nothing here touches the filesystem, and none of these paths needs to exist. The benchmarks diff --git a/Semantics.Benchmarks/README.md b/Semantics.Benchmarks/README.md index fc86a4aa..a9cb9998 100644 --- a/Semantics.Benchmarks/README.md +++ b/Semantics.Benchmarks/README.md @@ -1,11 +1,13 @@ # Semantics Benchmarks -A [BenchmarkDotNet](https://benchmarkdotnet.org) suite covering the quantity system: building a -quantity from a unit, reading it back out in one, the generated physics operators, the -componentwise vector operations, and comparison. +A [BenchmarkDotNet](https://benchmarkdotnet.org) suite covering three libraries: `Semantics.Quantities`, +`Semantics.Strings`, and `Semantics.Paths`. ## Quantities +Building a quantity from a unit, reading it back out in one, the generated physics operators, the +componentwise vector operations, and comparison. + ### The axis that matters here is the storage type Every generated quantity is a `readonly record struct` over a storage type — `Length<double>`, @@ -173,6 +175,12 @@ expression cost 0.93, 1.06, 1.26 and 1.27 microseconds on top of that floor, so minor term and the reflection machinery is the bill. The last two are a near-tie, worth naming because the mod-97 check was expected to dominate going in, and it does not. +The chart draws three of the four validators. `Checksum` is measured and stored in the history but +not drawn, for room rather than for principle: the grid is four columns, and the two failure paths +earn their places more than a fourth validator would when all four land within 340 ns of each other. +It can be promoted to a panel later without re-running anything, because `ingest` records every row +and only `render` selects. + ### What the type costs over the code a caller would otherwise write | pair | ratio | allocation, bare | allocation, semantic | @@ -208,6 +216,10 @@ the type costs over `System.IO.Path` doing the same work. | `PathOperationBenchmarks` | What a path costs once it exists. `FileNameWithoutExtension` caches into a field and reads it back; `FileName` rebuilds and revalidates on every read. Both are properties and both look like field access at a call site. | | `PathAbstractionCostBenchmarks` | The same operation written twice, once against `System.IO.Path` and once through the type, paired so BenchmarkDotNet reports the ratio directly. See below. | +`AbstractionCostBenchmarks` aside, two path benchmarks are stored and not drawn: +`PathCreationBenchmarks.AbsoluteDirectoryPath` and `PathOperationBenchmarks.DirectoryPath`. Both are +close cousins of panels already on the grid, so they add history without adding a column. + ### What the type costs over `System.IO.Path` | pair | ratio | allocation, bare | allocation, semantic | diff --git a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md index 3a87af86..f8f582f7 100644 --- a/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md +++ b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md @@ -179,7 +179,12 @@ than that implies. Read that way, the ratio answers the question a user actually has. *I was going to validate this anyway, so what does routing it through the type cost me on top?* The answer separates into the validation both sides pay and the per-call reflection only one side does. The last two rows are fair -pairs in the quantities sense and are expected near 1.00. +pairs in the quantities sense; measured, Equality came in at 2.53 and Ordering at 1.11, not the near +1.00 either was expected to land at. Equality's 2.53 is genuine wrapper cost: the record's +`EqualityContract` check and the null guards sit on top of the same ordinal string comparison the +baseline makes. Ordering's 1.11 is the figure after the baseline was corrected to make the same +culture-sensitive call, which is close enough to the floor that the remaining gap is call overhead +rather than a second thing the wrapper is doing. `PathAbstractionCostBenchmarks` needs none of that care, because `System.IO.Path` is a real API doing the real work: `FileName` against `Path.GetFileName`, `AsAbsolute` against `Path.GetFullPath`, @@ -310,7 +315,7 @@ The two ratio tables ship with real numbers from the seeding run. An empty table it will be filled in later is how a document starts rotting. **`CLAUDE.md`.** `Semantics.Benchmarks` joins the project-layout table, which omits it entirely today. -A short "Benchmarks and the release charts" subsection records the three things that are non-obvious +A short "Benchmarks and the release charts" subsection records the four things that are non-obvious and will otherwise be rediscovered the hard way: - `BenchmarkAgainstVersion` must be set in the environment rather than with `-p:`, because @@ -338,6 +343,8 @@ and will otherwise be rediscovered the hard way: stated in advance: the unvalidated floor below every validated rung, `Iban` slowest, allocation non-zero everywhere because a semantic string is a reference type. The full backfill starts only once those hold. If they do not hold, that is a finding to raise, not something to chart. + **Outcome:** `Iban` was not slowest — the format regex came in 0.26% above mod-97, which is what + stopped the seeding gate. The other two expectations held. 4. **Ordinary gates.** `dotnet build` warnings-clean, since ktsu.Sdk treats warnings as errors. `dotnet test` green. `Semantics.Benchmarks` carries `SonarQubeExclude` so the new benchmark classes are not analysed, but `scripts/benchmark-history.cs` is, so the local Sonar build documented in