diff --git a/.github/workflows/benchmark-history.yml b/.github/workflows/benchmark-history.yml index b835dae4..9279b23d 100644 --- a/.github/workflows/benchmark-history.yml +++ b/.github/workflows/benchmark-history.yml @@ -16,6 +16,17 @@ 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. +# +# 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. on: release: @@ -27,6 +38,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,29 +53,33 @@ 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 - *AbstractionCostBenchmarks* # 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. + # + # 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 *AbstractionCostBenchmarks* + 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 @@ -102,19 +122,28 @@ 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 }}" + # 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" /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" /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" `, `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`. `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. | @@ -321,6 +322,38 @@ since this is a package with a compatibility baseline and the shape is frozen on `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. 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 + 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. +- **All three subjects' benchmarks live in one project, so a compile error in any one file blocks + every subject's run regardless of `--filter`.** `AbstractionCostBenchmarks.cs` demonstrated this: + it held a `Length` field with no initializer, valid only once `Length` became a + `readonly record struct`, so against a pre-4.0.0 package it failed to build and took every + subject's backfill down with it. The fields carry `default!` now and all three histories reach + 3.3.1. Keep the pattern when adding a field to any benchmark class the backfill compiles: an + error introduced for one subject costs the other two their history, and the failure reads as a + build error in a file the person backfilling was not touching. + +`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. + ### 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..c9abe622 100644 --- a/README.md +++ b/README.md @@ -162,16 +162,62 @@ public class UserService(ISemanticStringFactory 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. 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 + Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Quantities release -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 + + + + Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Strings release + + +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 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 +is that an invalid value cannot exist, checked once at the boundary instead of everywhere the value +is used. + +### Paths -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`. + + + Allocated bytes per operation, and time relative to a fixed reference workload, for each Semantics.Paths release + -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/Paths/PathAbstractionCostBenchmarks.cs b/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs new file mode 100644 index 00000000..38c47472 --- /dev/null +++ b/Semantics.Benchmarks/Paths/PathAbstractionCostBenchmarks.cs @@ -0,0 +1,215 @@ +// 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; + +/// +/// Measures what the path types cost over doing the same work. +/// +/// +/// +/// This pairing needs none of the care the string one does. is a real API doing +/// the real work, so each category is the same operation twice and the ratio is a straight answer. +/// +/// +/// 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. +/// +/// +/// 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. +/// +/// +/// Read 's ratio differently from the other three. 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. +/// , and each do +/// real string work, while 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. +/// +/// +/// And is deliberately narrower than the validator it stands in for. +/// IsAbsolutePathAttribute asks +/// Path.IsPathFullyQualified(value + Path.DirectorySeparatorChar), 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 the code a caller would otherwise write, 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. +/// +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class PathAbstractionCostBenchmarks +{ + /// Operations per invocation. + 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!; + + /// Prepares both sides of every pair, holding the same values. + [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); + } + + /// Extracting a file name with the runtime's own helper. + /// The accumulated length. + [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; + } + + /// Extracting it through the path type, which validates the result. + /// The accumulated length. + [BenchmarkCategory("FileName")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticFileName() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += absoluteFile.FileName.Length; + } + + return accumulator; + } + + /// Resolving a relative path with the runtime's own helper. + /// The accumulated length. + [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; + } + + /// Resolving it through the path type. + /// The accumulated length. + [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; + } + + /// Relativizing with the runtime's own helper. + /// The accumulated length. + [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; + } + + /// Relativizing through the path type. + /// The accumulated length. + [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; + } + + /// + /// Checking a path is rooted by hand, which is what creation validates — modulo the separator the + /// validator appends before asking. + /// + /// The count of rooted paths, which is every iteration. + [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; + } + + /// Building the path type, which asks the same question and keeps the answer. + /// The accumulated length. + [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; + } +} 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; + +/// +/// Measures building each of the path types 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 same reflection machinery +/// StringCreationBenchmarks.Unvalidated measures plus one such question. The absolute rows +/// ask Path.IsPathFullyQualified and the relative row asks its negation, so the pair is the +/// same work in both directions rather than two different validators. +/// +[MemoryDiagnoser] +public class PathCreationBenchmarks +{ + private string absoluteFile = ""; + private string absoluteDirectory = ""; + private string relativeFile = ""; + private string fileName = ""; + + /// Copies the inputs into fields. + [GlobalSetup] + public void Setup() + { + absoluteFile = PathSpecimens.AbsoluteFile; + absoluteDirectory = PathSpecimens.AbsoluteDirectory; + relativeFile = PathSpecimens.RelativeFile; + fileName = PathSpecimens.FileNameOnly; + } + + /// Builds a fully qualified file path. + /// The created path. + [Benchmark] + public AbsoluteFilePath AbsoluteFilePath() => + Semantics.Paths.AbsoluteFilePath.Create(absoluteFile); + + /// Builds a relative file path. + /// The created path. + [Benchmark] + public RelativeFilePath RelativeFilePath() => + Semantics.Paths.RelativeFilePath.Create(relativeFile); + + /// Builds a fully qualified directory path. + /// The created path. + [Benchmark] + public AbsoluteDirectoryPath AbsoluteDirectoryPath() => + Semantics.Paths.AbsoluteDirectoryPath.Create(absoluteDirectory); + + /// Builds a bare file name, whose validator checks for separators. + /// The created file name. + [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; + +/// +/// Measures the operations a path type offers over the string it holds. +/// +/// +/// +/// Two rows are here to be read against each other. +/// caches into a field on the path, so the second read and +/// every read after it is a field read. does not: it builds a fresh +/// FileName, 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. +/// +/// +/// 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 relative path and the relative direction from an absolute +/// one. +/// +/// +/// Nothing here touches the filesystem. IsDirectory and IsFile are excluded on +/// purpose: they call Directory.Exists and File.Exists, so they would measure the +/// disk and the state of the machine rather than this library. +/// +/// +[MemoryDiagnoser] +public class PathOperationBenchmarks +{ + private AbsoluteFilePath absoluteFile = null!; + private RelativeFilePath relativeFile = null!; + private AbsoluteDirectoryPath baseDirectory = null!; + + /// Builds the operands once, outside the measurement. + [GlobalSetup] + public void Setup() + { + absoluteFile = AbsoluteFilePath.Create(PathSpecimens.AbsoluteFile); + relativeFile = RelativeFilePath.Create(PathSpecimens.RelativeFile); + baseDirectory = AbsoluteDirectoryPath.Create(PathSpecimens.AbsoluteDirectory); + } + + /// Reads the file name, which builds and validates a new one every time. + /// The file name. + [Benchmark] + public FileName FileName() => absoluteFile.FileName; + + /// Reads the stem, which is cached into a field after the first read. + /// The file name without its extension. + [Benchmark] + public FileName FileNameWithoutExtension() => absoluteFile.FileNameWithoutExtension; + + /// Reads the containing directory, which builds and validates a new path. + /// The directory path. + [Benchmark] + public DirectoryPath DirectoryPath() => absoluteFile.DirectoryPath; + + /// Resolves a relative path against a base directory. + /// The resolved absolute path. + [Benchmark] + public AbsoluteFilePath AsAbsolute() => relativeFile.AsAbsolute(baseDirectory); + + /// Expresses an absolute path relative to a base directory. + /// The relative path. + [Benchmark] + public RelativeFilePath AsRelative() => absoluteFile.AsRelative(baseDirectory); + + /// Strips the extension, which rebuilds and revalidates the whole path. + /// The path without its extension. + [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..714b365c --- /dev/null +++ b/Semantics.Benchmarks/Paths/PathSpecimens.cs @@ -0,0 +1,51 @@ +// Copyright (c) 2023-2026 ktsu-dev contributors + +namespace ktsu.Semantics.Benchmarks.Paths; + +using System; +using System.IO; + +/// +/// The path strings the path benchmarks run against. +/// +/// +/// +/// The absolute ones are built per platform, and they have to be. +/// IsAbsolutePathAttribute validates through Path.IsPathFullyQualified, whose answer +/// depends on the operating system: C:\x 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. +/// +/// +/// 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 +/// BaselineBenchmarks 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. +/// +/// +/// Nothing here touches the filesystem, and none of these paths needs to exist. The benchmarks +/// deliberately avoid IsDirectory and IsFile, which call Directory.Exists and +/// File.Exists and would measure the disk rather than the library. +/// +/// +internal static class PathSpecimens +{ + private static readonly string Root = + OperatingSystem.IsWindows() ? @"C:\projects" : "/projects"; + + /// A fully qualified file path, four segments below the root. + internal static readonly string AbsoluteFile = + Path.Combine(Root, "semantics", "src", "Semantics.Paths", "FilePath.cs"); + + /// The directory that file sits in, used as the base for both conversions. + internal static readonly string AbsoluteDirectory = + Path.Combine(Root, "semantics", "src"); + + /// A relative file path. Forward slashes are accepted on both platforms. + internal const string RelativeFile = "Semantics.Paths/FilePath.cs"; + + /// A bare file name, with no separator in it. + internal const string FileNameOnly = "FilePath.cs"; +} diff --git a/Semantics.Benchmarks/README.md b/Semantics.Benchmarks/README.md index 083c9b5d..ae862361 100644 --- a/Semantics.Benchmarks/README.md +++ b/Semantics.Benchmarks/README.md @@ -1,10 +1,14 @@ # 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 +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 +### The axis that matters here is the storage type Every generated quantity is a `readonly record struct` over a storage type — `Length`, `Length`, `Length` — and it does almost nothing of its own. A value is held @@ -22,52 +26,7 @@ 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 '**' - -# What the quantity types cost over the bare storage type -dotnet run -c Release --project Semantics.Benchmarks -- --filter '*AbstractionCostBenchmarks*' -``` - -## 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 '**' -``` - -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. - -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. - -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 +38,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 @@ -143,7 +102,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. @@ -156,6 +115,153 @@ 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 '**' + +# 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 '**' +``` + +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. + +A skip is also shared across subjects, which is worth naming because it is not obvious from a +`--filter` that names one of them. All three subjects live in one project, so a compile error in any +one file blocks every subject's run whatever the filter says. That is not hypothetical: against +packages older than 4.0.0, `AbstractionCostBenchmarks.cs` — a quantities-only file — once failed to +build, because it declared a `Length` field with no initializer, which is an error only while +`Length` is a reference type. Every subject's backfill stopped at 4.0.0 as a result. The fields +now carry `default!` and all three histories reach 3.3.1, but the coupling remains: a compile error +introduced for one subject silently costs the other two their history. + +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 +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. + +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 | +|---|---|---|---| +| 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.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. | + +`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 | +|---|---|---|---| +| 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 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. --> + + + + + + diff --git a/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs b/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs new file mode 100644 index 00000000..7edd4724 --- /dev/null +++ b/Semantics.Benchmarks/Strings/StringAbstractionCostBenchmarks.cs @@ -0,0 +1,232 @@ +// 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; + +/// +/// Measures what the semantic string types cost over the code a caller would otherwise write. +/// +/// +/// +/// What the baseline side is, and why it is not a bare string. The quantities suite pairs +/// T + T against Length<T> + Length<T>, which is fair because both sides +/// do identical work and the only question is what the wrapper adds. There is no such pairing for +/// Uuid.Create: 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. +/// +/// +/// 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 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 pattern is duplicated on purpose. StringSpecimens.UuidPattern is the same +/// string IsUuidAttribute 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. +/// +/// +/// The last two categories are fair pairs in the quantities sense, but only because each +/// baseline makes the same call the semantic side ends up making. That is worth stating for +/// Ordering in particular: SemanticString.CompareTo forwards to +/// , which is culture-sensitive, so a baseline written with +/// would have measured ordinal collation against +/// culture collation and reported that difference as though it were the wrapper's cost. +/// +/// +/// An observation the pair surfaces, which this suite reports rather than fixes. Equality on +/// a semantic string is ordinal, because string equality always is, while ordering is +/// culture-sensitive, because is. Two values can therefore +/// compare equal by == and sort by a different rule than that suggests. Nothing here changes +/// it; the benchmark only makes it visible. +/// +/// +/// Why these are loops. 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. +/// +/// +[MemoryDiagnoser] +[GroupBenchmarksBy(BenchmarkLogicalGroupRule.ByCategory)] +[CategoriesColumn] +public class StringAbstractionCostBenchmarks +{ + /// + /// Operations per invocation. Enough that the loop's own cost is a small share of the work. + /// + private const int Operations = 64; + + private string text = ""; + private string rejected = ""; + private string pattern = ""; + private Uuid left = null!; + private Uuid right = null!; + + /// Prepares both sides of every pair. + [GlobalSetup] + public void Setup() + { + text = StringSpecimens.UuidText; + rejected = StringSpecimens.UuidRejected; + pattern = StringSpecimens.UuidPattern; + left = Uuid.Create(StringSpecimens.UuidText); + right = Uuid.Create(StringSpecimens.UuidText); + } + + /// Validating at a boundary by hand, throwing on rejection. + /// The accumulated length, returned so nothing here is dead code. + [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; + } + + /// Validating at a boundary through the type. + /// The accumulated length. + [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; + } + + /// Rejecting by hand without throwing. + /// The count of rejections, which is every iteration. + [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; + } + + /// Rejecting through the type without throwing. + /// The count of rejections, which is every iteration. + [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; + } + + /// Ordinal equality on the bare strings. + /// The count of matches, which is every iteration. + [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; + } + + /// Record equality on the semantic values holding those strings. + /// The count of matches, which is every iteration. + [BenchmarkCategory("Equality")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticEquality() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + if (left == right) + { + accumulator++; + } + } + + return accumulator; + } + + /// Ordering on the bare strings. + /// The accumulated comparison results. + [BenchmarkCategory("Ordering")] + [Benchmark(Baseline = true, OperationsPerInvoke = Operations)] + public int BareOrdering() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += left.WeakString.CompareTo(right.WeakString); + } + + return accumulator; + } + + /// Ordering on the semantic values. + /// The accumulated comparison results. + [BenchmarkCategory("Ordering")] + [Benchmark(OperationsPerInvoke = Operations)] + public int SemanticOrdering() + { + int accumulator = 0; + + for (int i = 0; i < Operations; i++) + { + accumulator += left.CompareTo(right); + } + + return accumulator; + } +} diff --git a/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs b/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs new file mode 100644 index 00000000..e817a0a7 --- /dev/null +++ b/Semantics.Benchmarks/Strings/StringCreationBenchmarks.cs @@ -0,0 +1,120 @@ +// 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 a rearrangement, a +/// character-to-digit expansion, and modular arithmetic. +/// +/// +/// What the ladder turned out to show. 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. +/// +/// +/// Both failure paths are here, and they cost the same. and +/// reject the same input, and measuring them side by side turns up +/// something the names hide: 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, +/// and both cost far more than any success rung above. TryCreate 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. catches inside the benchmark on purpose: the throw and +/// the catch together are what a caller pays either way. +/// +/// +[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); + + /// A rearrangement, a character-to-digit expansion, and 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; + } + } +} 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; + +/// +/// 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; +} 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"; +} diff --git a/docs/benchmarks/paths-history.json b/docs/benchmarks/paths-history.json new file mode 100644 index 00000000..ec446645 --- /dev/null +++ b/docs/benchmarks/paths-history.json @@ -0,0 +1,786 @@ +{ + "schemaVersion": 1, + "entries": [ + { + "version": "3.3.1", + "commit": "fffa086", + "date": "2026-09-09", + "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": 4774.4695, + "allocatedBytes": 1680 + } + }, + "PathCreationBenchmarks.AbsoluteFilePath": { + "": { + "meanNs": 5089.1118, + "allocatedBytes": 1816 + } + }, + "PathCreationBenchmarks.FileNameType": { + "": { + "meanNs": 2494.0516, + "allocatedBytes": 896 + } + }, + "PathCreationBenchmarks.RelativeFilePath": { + "": { + "meanNs": 4559.9129, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.AsAbsolute": { + "": { + "meanNs": 5098.5545, + "allocatedBytes": 1944 + } + }, + "PathOperationBenchmarks.AsRelative": { + "": { + "meanNs": 5552.833, + "allocatedBytes": 1760 + } + }, + "PathOperationBenchmarks.DirectoryPath": { + "": { + "meanNs": 4162.9669, + "allocatedBytes": 1432 + } + }, + "PathOperationBenchmarks.FileName": { + "": { + "meanNs": 2555.5514, + "allocatedBytes": 944 + } + }, + "PathOperationBenchmarks.FileNameWithoutExtension": { + "": { + "meanNs": 2.0953, + "allocatedBytes": 0 + } + }, + "PathOperationBenchmarks.RemoveExtension": { + "": { + "meanNs": 4679.0298, + "allocatedBytes": 1904 + } + } + } + }, + { + "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", + "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..ef73963f --- /dev/null +++ b/docs/benchmarks/paths-performance-dark.svg @@ -0,0 +1,279 @@ +<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">11 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" /> +<polyline points="62.0,135.6 81.6,136.7 101.2,135.6 120.8,135.6 140.4,135.6 160.0,136.7 179.6,135.6 199.2,135.6 218.8,135.6 238.4,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="81.6" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="101.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="120.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="140.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="160.0" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="179.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="199.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="218.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="238.4" 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="126.6" class="muted-value" text-anchor="middle">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" /> +<polyline points="290.0,135.6 309.6,135.6 329.2,135.6 348.8,135.6 368.4,135.6 388.0,135.6 407.6,136.7 427.2,136.7 446.8,135.6 466.4,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="309.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="329.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="348.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="368.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="388.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="407.6" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="427.2" cy="136.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="446.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,135.6 537.6,135.6 557.2,135.6 576.8,135.6 596.4,135.6 616.0,135.6 635.6,135.6 655.2,135.6 674.8,135.6 694.4,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="537.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="557.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="576.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="596.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="616.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="635.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="655.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="674.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="694.4" 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" /> +<polyline points="746.0,135.6 765.6,135.6 785.2,135.6 804.8,135.6 824.4,135.6 844.0,135.6 863.6,135.6 883.2,135.6 902.8,135.6 922.4,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="765.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="785.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="804.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="824.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="844.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="863.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="883.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="902.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="922.4" 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" /> +<polyline points="62.0,330.0 81.6,330.0 101.2,330.0 120.8,330.0 140.4,330.0 160.0,330.0 179.6,330.0 199.2,330.0 218.8,330.0 238.4,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="81.6" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="101.2" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="120.8" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="140.4" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="160.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="179.6" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="199.2" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="218.8" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="238.4" 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" /> +<polyline points="290.0,267.6 309.6,267.6 329.2,267.6 348.8,267.6 368.4,267.6 388.0,267.6 407.6,268.6 427.2,268.6 446.8,267.6 466.4,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="309.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="329.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="348.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="368.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="388.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="407.6" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="427.2" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="446.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,267.6 537.6,267.6 557.2,267.6 576.8,267.6 596.4,267.6 616.0,268.7 635.6,267.6 655.2,267.6 674.8,267.6 694.4,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="537.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="557.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="576.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="596.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="616.0" cy="268.7" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="635.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="655.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="674.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="694.4" 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" /> +<polyline points="746.0,268.6 765.6,267.6 785.2,268.6 804.8,267.6 824.4,267.6 844.0,267.6 863.6,267.6 883.2,268.6 902.8,267.6 922.4,267.6 942.0,268.6" fill="none" stroke="#3987e5" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="765.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="785.2" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="804.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="824.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="844.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="863.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="883.2" cy="268.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="902.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="922.4" 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="259.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" /> +<polyline points="62.0,435.3 81.6,440.7 101.2,439.1 120.8,440.1 140.4,440.8 160.0,440.0 179.6,433.6 199.2,439.1 218.8,435.1 238.4,440.4 258.0,438.2" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="81.6" cy="440.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="101.2" cy="439.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="120.8" cy="440.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="140.4" cy="440.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="160.0" cy="440.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="179.6" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="199.2" cy="439.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="218.8" cy="435.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="238.4" 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="426.3" class="muted-value" text-anchor="middle">6.81×</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" /> +<polyline points="290.0,435.6 309.6,435.9 329.2,434.0 348.8,435.3 368.4,435.1 388.0,433.9 407.6,433.6 427.2,436.0 446.8,434.7 466.4,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.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="309.6" cy="435.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="329.2" cy="434.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="348.8" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="368.4" cy="435.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="388.0" cy="433.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="407.6" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="427.2" cy="436.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="446.8" cy="434.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="466.4" 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.6" class="muted-value" text-anchor="middle">6.10×</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" /> +<polyline points="518.0,437.4 537.6,437.6 557.2,436.4 576.8,437.2 596.4,433.6 616.0,436.2 635.6,435.7 655.2,435.3 674.8,434.8 694.4,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.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="537.6" cy="437.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="557.2" cy="436.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="576.8" cy="437.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="596.4" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="616.0" cy="436.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="635.6" cy="435.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="655.2" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="674.8" cy="434.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="694.4" 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.4" class="muted-value" text-anchor="middle">3.34×</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" /> +<polyline points="746.0,435.9 765.6,435.5 785.2,435.4 804.8,435.0 824.4,433.6 844.0,435.5 863.6,434.7 883.2,436.2 902.8,433.8 922.4,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.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="765.6" cy="435.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="785.2" cy="435.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="804.8" cy="435.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="824.4" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="844.0" cy="435.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="863.6" cy="434.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="883.2" cy="436.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="902.8" cy="433.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="922.4" 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.9" class="muted-value" text-anchor="middle">3.42×</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" /> +<polyline points="62.0,565.6 81.6,569.9 101.2,573.3 120.8,584.3 140.4,581.4 160.0,580.8 179.6,578.6 199.2,580.6 218.8,579.5 238.4,582.8 258.0,578.2" 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="81.6" cy="569.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="101.2" cy="573.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="120.8" cy="584.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="140.4" cy="581.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="160.0" cy="580.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="179.6" cy="578.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="199.2" cy="580.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="218.8" cy="579.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="238.4" cy="582.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="258.0" cy="578.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="258.0" y="569.2" class="value" text-anchor="end">0.0022×</text> +<text x="62.0" y="556.6" class="muted-value" text-anchor="middle">0.0028×</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" /> +<polyline points="290.0,567.5 309.6,568.3 329.2,567.6 348.8,568.0 368.4,567.4 388.0,567.4 407.6,566.2 427.2,567.3 446.8,566.1 466.4,568.1 486.0,565.6" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="567.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="309.6" cy="568.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="329.2" cy="567.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="348.8" cy="568.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="368.4" cy="567.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="388.0" cy="567.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="407.6" cy="566.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="427.2" cy="567.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="446.8" cy="566.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="466.4" 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="558.5" class="muted-value" text-anchor="middle">6.82×</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" /> +<polyline points="518.0,565.6 537.6,568.5 557.2,567.7 576.8,568.3 596.4,568.1 616.0,568.6 635.6,567.0 655.2,567.1 674.8,567.5 694.4,568.3 714.0,566.5" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="537.6" cy="568.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="557.2" cy="567.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="576.8" cy="568.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="596.4" cy="568.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="616.0" cy="568.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="635.6" cy="567.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="655.2" cy="567.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="674.8" cy="567.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="694.4" cy="568.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="714.0" cy="566.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="714.0" y="557.5" class="value" text-anchor="end">7.32×</text> +<text x="518.0" y="556.6" class="muted-value" text-anchor="middle">7.43×</text> +<text x="740" y="538" class="panel-title">RemoveExtension</text> +<line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> +<polyline points="746.0,575.4 765.6,575.6 785.2,573.6 804.8,575.1 824.4,575.6 844.0,574.4 863.6,574.2 883.2,573.9 902.8,565.6 922.4,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.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="765.6" cy="575.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="785.2" cy="573.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="804.8" cy="575.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="824.4" cy="575.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="844.0" cy="574.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="863.6" cy="574.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="883.2" cy="573.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="902.8" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="922.4" 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.4" class="muted-value" text-anchor="middle">6.26×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 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 → 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..dcab75fc --- /dev/null +++ b/docs/benchmarks/paths-performance.svg @@ -0,0 +1,279 @@ +<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">11 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" /> +<polyline points="62.0,135.6 81.6,136.7 101.2,135.6 120.8,135.6 140.4,135.6 160.0,136.7 179.6,135.6 199.2,135.6 218.8,135.6 238.4,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="81.6" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="101.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="120.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="140.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="160.0" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="179.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="199.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="218.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="238.4" 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="126.6" class="muted-value" text-anchor="middle">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" /> +<polyline points="290.0,135.6 309.6,135.6 329.2,135.6 348.8,135.6 368.4,135.6 388.0,135.6 407.6,136.7 427.2,136.7 446.8,135.6 466.4,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="309.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="329.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="348.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="368.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="388.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="407.6" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="427.2" cy="136.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="446.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,135.6 537.6,135.6 557.2,135.6 576.8,135.6 596.4,135.6 616.0,135.6 635.6,135.6 655.2,135.6 674.8,135.6 694.4,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="537.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="557.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="576.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="596.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="616.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="635.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="655.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="674.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="694.4" 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" /> +<polyline points="746.0,135.6 765.6,135.6 785.2,135.6 804.8,135.6 824.4,135.6 844.0,135.6 863.6,135.6 883.2,135.6 902.8,135.6 922.4,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="765.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="785.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="804.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="824.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="844.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="863.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="883.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="902.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="922.4" 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" /> +<polyline points="62.0,330.0 81.6,330.0 101.2,330.0 120.8,330.0 140.4,330.0 160.0,330.0 179.6,330.0 199.2,330.0 218.8,330.0 238.4,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="81.6" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="101.2" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="120.8" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="140.4" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="160.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="179.6" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="199.2" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="218.8" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="238.4" 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" /> +<polyline points="290.0,267.6 309.6,267.6 329.2,267.6 348.8,267.6 368.4,267.6 388.0,267.6 407.6,268.6 427.2,268.6 446.8,267.6 466.4,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="309.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="329.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="348.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="368.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="388.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="407.6" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="427.2" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="446.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,267.6 537.6,267.6 557.2,267.6 576.8,267.6 596.4,267.6 616.0,268.7 635.6,267.6 655.2,267.6 674.8,267.6 694.4,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="537.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="557.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="576.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="596.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="616.0" cy="268.7" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="635.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="655.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="674.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="694.4" 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" /> +<polyline points="746.0,268.6 765.6,267.6 785.2,268.6 804.8,267.6 824.4,267.6 844.0,267.6 863.6,267.6 883.2,268.6 902.8,267.6 922.4,267.6 942.0,268.6" fill="none" stroke="#2a78d6" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="765.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="785.2" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="804.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="824.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="844.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="863.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="883.2" cy="268.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="902.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="922.4" 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="259.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" /> +<polyline points="62.0,435.3 81.6,440.7 101.2,439.1 120.8,440.1 140.4,440.8 160.0,440.0 179.6,433.6 199.2,439.1 218.8,435.1 238.4,440.4 258.0,438.2" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="81.6" cy="440.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="101.2" cy="439.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="120.8" cy="440.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="140.4" cy="440.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="160.0" cy="440.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="179.6" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="199.2" cy="439.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="218.8" cy="435.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="238.4" 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="426.3" class="muted-value" text-anchor="middle">6.81×</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" /> +<polyline points="290.0,435.6 309.6,435.9 329.2,434.0 348.8,435.3 368.4,435.1 388.0,433.9 407.6,433.6 427.2,436.0 446.8,434.7 466.4,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.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="309.6" cy="435.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="329.2" cy="434.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="348.8" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="368.4" cy="435.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="388.0" cy="433.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="407.6" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="427.2" cy="436.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="446.8" cy="434.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="466.4" 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.6" class="muted-value" text-anchor="middle">6.10×</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" /> +<polyline points="518.0,437.4 537.6,437.6 557.2,436.4 576.8,437.2 596.4,433.6 616.0,436.2 635.6,435.7 655.2,435.3 674.8,434.8 694.4,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.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="537.6" cy="437.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="557.2" cy="436.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="576.8" cy="437.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="596.4" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="616.0" cy="436.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="635.6" cy="435.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="655.2" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="674.8" cy="434.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="694.4" 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.4" class="muted-value" text-anchor="middle">3.34×</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" /> +<polyline points="746.0,435.9 765.6,435.5 785.2,435.4 804.8,435.0 824.4,433.6 844.0,435.5 863.6,434.7 883.2,436.2 902.8,433.8 922.4,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.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="765.6" cy="435.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="785.2" cy="435.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="804.8" cy="435.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="824.4" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="844.0" cy="435.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="863.6" cy="434.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="883.2" cy="436.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="902.8" cy="433.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="922.4" 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.9" class="muted-value" text-anchor="middle">3.42×</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" /> +<polyline points="62.0,565.6 81.6,569.9 101.2,573.3 120.8,584.3 140.4,581.4 160.0,580.8 179.6,578.6 199.2,580.6 218.8,579.5 238.4,582.8 258.0,578.2" 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="81.6" cy="569.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="101.2" cy="573.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="120.8" cy="584.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="140.4" cy="581.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="160.0" cy="580.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="179.6" cy="578.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="199.2" cy="580.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="218.8" cy="579.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="238.4" cy="582.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="258.0" cy="578.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="258.0" y="569.2" class="value" text-anchor="end">0.0022×</text> +<text x="62.0" y="556.6" class="muted-value" text-anchor="middle">0.0028×</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" /> +<polyline points="290.0,567.5 309.6,568.3 329.2,567.6 348.8,568.0 368.4,567.4 388.0,567.4 407.6,566.2 427.2,567.3 446.8,566.1 466.4,568.1 486.0,565.6" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="567.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="309.6" cy="568.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="329.2" cy="567.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="348.8" cy="568.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="368.4" cy="567.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="388.0" cy="567.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="407.6" cy="566.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="427.2" cy="567.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="446.8" cy="566.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="466.4" 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="558.5" class="muted-value" text-anchor="middle">6.82×</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" /> +<polyline points="518.0,565.6 537.6,568.5 557.2,567.7 576.8,568.3 596.4,568.1 616.0,568.6 635.6,567.0 655.2,567.1 674.8,567.5 694.4,568.3 714.0,566.5" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="537.6" cy="568.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="557.2" cy="567.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="576.8" cy="568.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="596.4" cy="568.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="616.0" cy="568.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="635.6" cy="567.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="655.2" cy="567.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="674.8" cy="567.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="694.4" cy="568.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="714.0" cy="566.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="714.0" y="557.5" class="value" text-anchor="end">7.32×</text> +<text x="518.0" y="556.6" class="muted-value" text-anchor="middle">7.43×</text> +<text x="740" y="538" class="panel-title">RemoveExtension</text> +<line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> +<polyline points="746.0,575.4 765.6,575.6 785.2,573.6 804.8,575.1 824.4,575.6 844.0,574.4 863.6,574.2 883.2,573.9 902.8,565.6 922.4,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.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="765.6" cy="575.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="785.2" cy="573.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="804.8" cy="575.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="824.4" cy="575.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="844.0" cy="574.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="863.6" cy="574.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="883.2" cy="573.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="902.8" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="922.4" 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.4" class="muted-value" text-anchor="middle">6.26×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 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 → 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..28285a95 --- /dev/null +++ b/docs/benchmarks/strings-history.json @@ -0,0 +1,984 @@ +{ + "schemaVersion": 1, + "entries": [ + { + "version": "3.3.1", + "commit": "fffa086", + "date": "2026-09-09", + "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": 2753.4231, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.Checksum": { + "": { + "meanNs": 2542.6661, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.CreateThrows": { + "": { + "meanNs": 7745.7606, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.FormatRegex": { + "": { + "meanNs": 2879.4108, + "allocatedBytes": 928 + } + }, + "StringCreationBenchmarks.Mod97": { + "": { + "meanNs": 2834.641, + "allocatedBytes": 896 + } + }, + "StringCreationBenchmarks.TryCreateRejects": { + "": { + "meanNs": 6681.0384, + "allocatedBytes": 1424 + } + }, + "StringCreationBenchmarks.Unvalidated": { + "": { + "meanNs": 1659.7438, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.AsConversion": { + "": { + "meanNs": 1660.2821, + "allocatedBytes": 504 + } + }, + "StringOperationBenchmarks.CompareTo": { + "": { + "meanNs": 1.0335, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.EqualityOperator": { + "": { + "meanNs": 0.5191, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.HashCode": { + "": { + "meanNs": 42.7122, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.ToStringImplicit": { + "": { + "meanNs": 0.9671, + "allocatedBytes": 0 + } + }, + "StringOperationBenchmarks.WithSuffix": { + "": { + "meanNs": 1752.3707, + "allocatedBytes": 616 + } + } + } + }, + { + "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", + "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..fc9ea867 --- /dev/null +++ b/docs/benchmarks/strings-performance-dark.svg @@ -0,0 +1,279 @@ +<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">11 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" /> +<polyline points="62.0,135.6 81.6,135.6 101.2,135.6 120.8,135.6 140.4,135.6 160.0,135.6 179.6,135.6 199.2,135.6 218.8,135.6 238.4,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="81.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="101.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="120.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="140.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="160.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="179.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="199.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="218.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="238.4" 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" /> +<polyline points="290.0,135.6 309.6,135.6 329.2,135.6 348.8,135.6 368.4,135.6 388.0,135.6 407.6,135.6 427.2,135.6 446.8,135.6 466.4,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="309.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="329.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="348.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="368.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="388.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="407.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="427.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="446.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,135.6 537.6,135.6 557.2,135.6 576.8,135.6 596.4,135.6 616.0,135.6 635.6,135.6 655.2,135.6 674.8,135.6 694.4,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="537.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="557.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="576.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="596.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="616.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="635.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="655.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="674.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="694.4" 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" /> +<polyline points="746.0,135.6 765.6,135.6 785.2,135.6 804.8,135.6 824.4,135.6 844.0,135.6 863.6,135.6 883.2,135.6 902.8,135.6 922.4,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="765.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="785.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="804.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="824.4" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="844.0" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="863.6" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="883.2" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="902.8" cy="135.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="922.4" 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" /> +<polyline points="62.0,267.6 81.6,267.6 101.2,267.6 120.8,267.6 140.4,267.6 160.0,267.6 179.6,267.6 199.2,267.6 218.8,267.6 238.4,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="81.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="101.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="120.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="140.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="160.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="179.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="199.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="218.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="238.4" 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" /> +<polyline points="290.0,267.6 309.6,267.6 329.2,267.6 348.8,267.6 368.4,267.6 388.0,267.6 407.6,267.6 427.2,267.6 446.8,267.6 466.4,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="309.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="329.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="348.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="368.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="388.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="407.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="427.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="446.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,267.6 537.6,267.6 557.2,267.6 576.8,267.6 596.4,267.6 616.0,267.6 635.6,267.6 655.2,267.6 674.8,267.6 694.4,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="537.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="557.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="576.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="596.4" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="616.0" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="635.6" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="655.2" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="674.8" cy="267.6" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="694.4" 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" /> +<polyline points="746.0,330.0 765.6,330.0 785.2,330.0 804.8,330.0 824.4,330.0 844.0,330.0 863.6,330.0 883.2,330.0 902.8,330.0 922.4,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="765.6" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="785.2" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="804.8" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="824.4" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="844.0" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="863.6" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="883.2" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="902.8" cy="330.0" r="4" fill="#3987e5" stroke="#1a1a19" stroke-width="2" /> +<circle cx="922.4" 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" /> +<polyline points="62.0,434.6 81.6,433.6 101.2,435.4 120.8,434.4 140.4,433.9 160.0,435.3 179.6,434.8 199.2,434.7 218.8,434.0 238.4,434.1 258.0,434.8" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="434.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="81.6" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="101.2" cy="435.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="120.8" cy="434.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="140.4" cy="433.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="160.0" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="179.6" cy="434.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="199.2" cy="434.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="218.8" cy="434.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="238.4" 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="425.6" class="muted-value" text-anchor="middle">2.22×</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" /> +<polyline points="290.0,438.6 309.6,438.6 329.2,438.7 348.8,433.6 368.4,437.8 388.0,438.8 407.6,438.5 427.2,437.7 446.8,435.7 466.4,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="309.6" cy="438.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="329.2" cy="438.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="348.8" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="368.4" cy="437.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="388.0" cy="438.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="407.6" cy="438.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="427.2" cy="437.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="446.8" cy="435.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,439.9 537.6,440.6 557.2,439.7 576.8,439.5 596.4,435.0 616.0,435.3 635.6,439.5 655.2,440.5 674.8,433.6 694.4,437.1 714.0,439.2" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="439.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="537.6" cy="440.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="557.2" cy="439.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="576.8" cy="439.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="596.4" cy="435.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="616.0" cy="435.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="635.6" cy="439.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="655.2" cy="440.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="674.8" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="694.4" 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="430.9" class="muted-value" text-anchor="middle">3.85×</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" /> +<polyline points="746.0,438.9 765.6,437.6 785.2,437.8 804.8,437.7 824.4,436.7 844.0,437.7 863.6,437.6 883.2,438.1 902.8,433.6 922.4,435.0 942.0,437.4" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="438.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="765.6" cy="437.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="785.2" cy="437.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="804.8" cy="437.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="824.4" cy="436.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="844.0" cy="437.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="863.6" cy="437.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="883.2" cy="438.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="902.8" cy="433.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="922.4" 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="429.9" class="muted-value" text-anchor="middle">3.79×</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" /> +<polyline points="62.0,584.5 81.6,583.9 101.2,583.9 120.8,584.2 140.4,584.1 160.0,584.1 179.6,583.3 199.2,581.1 218.8,565.6 238.4,583.2 258.0,583.6" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="584.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="81.6" cy="583.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="101.2" cy="583.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="120.8" cy="584.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="140.4" cy="584.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="160.0" cy="584.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="179.6" cy="583.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="199.2" cy="581.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="218.8" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="238.4" 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="575.5" class="muted-value" text-anchor="middle">8.94×</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" /> +<polyline points="290.0,565.6 309.6,574.1 329.2,573.6 348.8,572.1 368.4,574.0 388.0,573.5 407.6,570.4 427.2,573.0 446.8,573.3 466.4,569.7 486.0,573.2" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="309.6" cy="574.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="329.2" cy="573.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="348.8" cy="572.1" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="368.4" cy="574.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="388.0" cy="573.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="407.6" cy="570.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="427.2" cy="573.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="446.8" cy="573.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="466.4" cy="569.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="486.0" cy="573.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<text x="486.0" y="564.2" class="value" text-anchor="end">9.11×</text> +<text x="290.0" y="556.6" class="muted-value" text-anchor="middle">10.4×</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" /> +<polyline points="518.0,568.4 537.6,567.5 557.2,569.0 576.8,565.6 596.4,568.9 616.0,565.6 635.6,565.7 655.2,568.4 674.8,568.8 694.4,568.8 714.0,568.6" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="568.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="537.6" cy="567.5" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="557.2" cy="569.0" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="576.8" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="596.4" cy="568.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="616.0" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="635.6" cy="565.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="655.2" cy="568.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="674.8" cy="568.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="694.4" 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="559.4" class="muted-value" text-anchor="middle">2.22×</text> +<text x="740" y="538" class="panel-title">GetHashCode</text> +<line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> +<polyline points="746.0,569.8 765.6,570.2 785.2,565.6 804.8,569.8 824.4,569.7 844.0,570.3 863.6,568.4 883.2,569.9 902.8,569.9 922.4,570.1 942.0,570.0" fill="none" stroke="#d95926" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="569.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="765.6" cy="570.2" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="785.2" cy="565.6" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="804.8" cy="569.8" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="824.4" cy="569.7" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="844.0" cy="570.3" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="863.6" cy="568.4" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="883.2" cy="569.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="902.8" cy="569.9" r="4" fill="#d95926" stroke="#1a1a19" stroke-width="2" /> +<circle cx="922.4" 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="560.8" class="muted-value" text-anchor="middle">0.0571×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 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 → 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..3f137858 --- /dev/null +++ b/docs/benchmarks/strings-performance.svg @@ -0,0 +1,279 @@ +<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">11 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" /> +<polyline points="62.0,135.6 81.6,135.6 101.2,135.6 120.8,135.6 140.4,135.6 160.0,135.6 179.6,135.6 199.2,135.6 218.8,135.6 238.4,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="81.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="101.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="120.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="140.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="160.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="179.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="199.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="218.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="238.4" 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" /> +<polyline points="290.0,135.6 309.6,135.6 329.2,135.6 348.8,135.6 368.4,135.6 388.0,135.6 407.6,135.6 427.2,135.6 446.8,135.6 466.4,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="309.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="329.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="348.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="368.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="388.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="407.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="427.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="446.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,135.6 537.6,135.6 557.2,135.6 576.8,135.6 596.4,135.6 616.0,135.6 635.6,135.6 655.2,135.6 674.8,135.6 694.4,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="537.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="557.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="576.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="596.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="616.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="635.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="655.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="674.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="694.4" 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" /> +<polyline points="746.0,135.6 765.6,135.6 785.2,135.6 804.8,135.6 824.4,135.6 844.0,135.6 863.6,135.6 883.2,135.6 902.8,135.6 922.4,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="765.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="785.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="804.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="824.4" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="844.0" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="863.6" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="883.2" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="902.8" cy="135.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="922.4" 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" /> +<polyline points="62.0,267.6 81.6,267.6 101.2,267.6 120.8,267.6 140.4,267.6 160.0,267.6 179.6,267.6 199.2,267.6 218.8,267.6 238.4,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="81.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="101.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="120.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="140.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="160.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="179.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="199.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="218.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="238.4" 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" /> +<polyline points="290.0,267.6 309.6,267.6 329.2,267.6 348.8,267.6 368.4,267.6 388.0,267.6 407.6,267.6 427.2,267.6 446.8,267.6 466.4,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="309.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="329.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="348.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="368.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="388.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="407.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="427.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="446.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,267.6 537.6,267.6 557.2,267.6 576.8,267.6 596.4,267.6 616.0,267.6 635.6,267.6 655.2,267.6 674.8,267.6 694.4,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="537.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="557.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="576.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="596.4" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="616.0" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="635.6" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="655.2" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="674.8" cy="267.6" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="694.4" 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" /> +<polyline points="746.0,330.0 765.6,330.0 785.2,330.0 804.8,330.0 824.4,330.0 844.0,330.0 863.6,330.0 883.2,330.0 902.8,330.0 922.4,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="765.6" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="785.2" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="804.8" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="824.4" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="844.0" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="863.6" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="883.2" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="902.8" cy="330.0" r="4" fill="#2a78d6" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="922.4" 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" /> +<polyline points="62.0,434.6 81.6,433.6 101.2,435.4 120.8,434.4 140.4,433.9 160.0,435.3 179.6,434.8 199.2,434.7 218.8,434.0 238.4,434.1 258.0,434.8" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="434.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="81.6" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="101.2" cy="435.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="120.8" cy="434.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="140.4" cy="433.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="160.0" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="179.6" cy="434.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="199.2" cy="434.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="218.8" cy="434.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="238.4" 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="425.6" class="muted-value" text-anchor="middle">2.22×</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" /> +<polyline points="290.0,438.6 309.6,438.6 329.2,438.7 348.8,433.6 368.4,437.8 388.0,438.8 407.6,438.5 427.2,437.7 446.8,435.7 466.4,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="309.6" cy="438.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="329.2" cy="438.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="348.8" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="368.4" cy="437.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="388.0" cy="438.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="407.6" cy="438.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="427.2" cy="437.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="446.8" cy="435.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="466.4" 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" /> +<polyline points="518.0,439.9 537.6,440.6 557.2,439.7 576.8,439.5 596.4,435.0 616.0,435.3 635.6,439.5 655.2,440.5 674.8,433.6 694.4,437.1 714.0,439.2" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="439.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="537.6" cy="440.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="557.2" cy="439.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="576.8" cy="439.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="596.4" cy="435.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="616.0" cy="435.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="635.6" cy="439.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="655.2" cy="440.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="674.8" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="694.4" 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="430.9" class="muted-value" text-anchor="middle">3.85×</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" /> +<polyline points="746.0,438.9 765.6,437.6 785.2,437.8 804.8,437.7 824.4,436.7 844.0,437.7 863.6,437.6 883.2,438.1 902.8,433.6 922.4,435.0 942.0,437.4" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="438.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="765.6" cy="437.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="785.2" cy="437.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="804.8" cy="437.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="824.4" cy="436.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="844.0" cy="437.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="863.6" cy="437.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="883.2" cy="438.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="902.8" cy="433.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="922.4" 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="429.9" class="muted-value" text-anchor="middle">3.79×</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" /> +<polyline points="62.0,584.5 81.6,583.9 101.2,583.9 120.8,584.2 140.4,584.1 160.0,584.1 179.6,583.3 199.2,581.1 218.8,565.6 238.4,583.2 258.0,583.6" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="62.0" cy="584.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="81.6" cy="583.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="101.2" cy="583.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="120.8" cy="584.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="140.4" cy="584.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="160.0" cy="584.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="179.6" cy="583.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="199.2" cy="581.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="218.8" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="238.4" 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="575.5" class="muted-value" text-anchor="middle">8.94×</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" /> +<polyline points="290.0,565.6 309.6,574.1 329.2,573.6 348.8,572.1 368.4,574.0 388.0,573.5 407.6,570.4 427.2,573.0 446.8,573.3 466.4,569.7 486.0,573.2" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="290.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="309.6" cy="574.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="329.2" cy="573.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="348.8" cy="572.1" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="368.4" cy="574.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="388.0" cy="573.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="407.6" cy="570.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="427.2" cy="573.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="446.8" cy="573.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="466.4" cy="569.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="486.0" cy="573.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<text x="486.0" y="564.2" class="value" text-anchor="end">9.11×</text> +<text x="290.0" y="556.6" class="muted-value" text-anchor="middle">10.4×</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" /> +<polyline points="518.0,568.4 537.6,567.5 557.2,569.0 576.8,565.6 596.4,568.9 616.0,565.6 635.6,565.7 655.2,568.4 674.8,568.8 694.4,568.8 714.0,568.6" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="518.0" cy="568.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="537.6" cy="567.5" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="557.2" cy="569.0" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="576.8" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="596.4" cy="568.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="616.0" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="635.6" cy="565.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="655.2" cy="568.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="674.8" cy="568.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="694.4" 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="559.4" class="muted-value" text-anchor="middle">2.22×</text> +<text x="740" y="538" class="panel-title">GetHashCode</text> +<line x1="746" y1="628.0" x2="942" y2="628.0" class="axis" /> +<polyline points="746.0,569.8 765.6,570.2 785.2,565.6 804.8,569.8 824.4,569.7 844.0,570.3 863.6,568.4 883.2,569.9 902.8,569.9 922.4,570.1 942.0,570.0" fill="none" stroke="#eb6834" stroke-width="2" stroke-linejoin="round" stroke-linecap="round" /> +<circle cx="746.0" cy="569.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="765.6" cy="570.2" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="785.2" cy="565.6" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="804.8" cy="569.8" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="824.4" cy="569.7" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="844.0" cy="570.3" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="863.6" cy="568.4" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="883.2" cy="569.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="902.8" cy="569.9" r="4" fill="#eb6834" stroke="#fcfcfb" stroke-width="2" /> +<circle cx="922.4" 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="560.8" class="muted-value" text-anchor="middle">0.0571×</text> +<text x="56" y="664" class="tick">releases, oldest to newest: 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 → 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/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..e51874c1 --- /dev/null +++ b/docs/superpowers/plans/2026-09-18-strings-paths-benchmarks.md @@ -0,0 +1,2177 @@ +# 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 <name> --history <path> --out <path>`, where `<name>` 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 `<remarks>` 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 + /// <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, Panel[] 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 Panel(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 each picture shows, so it can change + /// without re-running anything. + /// </para> + /// <para> + /// <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 + /// 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. + /// </para> + /// </remarks> + private static readonly Dictionary<string, Subject> Subjects = new(StringComparer.Ordinal) + { + ["quantities"] = new("Semantics.Quantities", 4, + [ + new("ConstructionBenchmarks<Double>.FromNauticalMile", null, "Construct (double)"), + new("ConstructionBenchmarks<Single>.FromNauticalMile", null, "Construct (float)"), + new("ConstructionBenchmarks<Decimal>.FromNauticalMile", null, "Construct (decimal)"), + new("ConstructionBenchmarks<PreciseNumber>.FromNauticalMile", null, "Construct (precise)"), + new("UnitConversionBenchmarks<Decimal>.InNauticalMile", null, "Read back (decimal)"), + new("UnitConversionBenchmarks<PreciseNumber>.InNauticalMile", null, "Read back (precise)"), + new("VectorBenchmarks<Decimal>.Length", null, "Vector length (decimal)"), + new("ComparisonBenchmarks<Double>.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<string>() ?? "?")]; + 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("</svg>"); + return svg.ToString(); + } +``` + +In `Preamble`, add `Subject subject` as the last parameter and replace the two hardcoded strings: + +```csharp + 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">"""); +``` + +```csharp + svg.AppendLine(CultureInfo.InvariantCulture, $"""<text x="{Left}" y="28" class="title">{Escape(subject.Title)} performance by release</text>"""); +``` + +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 + <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> +``` + +- [ ] **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; + +/// <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"; +} +``` + +- [ ] **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; + +/// <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"/> a rearrange-expand-and-modulo pass. +/// </para> +/// <para> +/// <b>Both failure paths are here.</b> <see cref="TryCreateRejects"/> and +/// <see cref="CreateThrows"/> reject the same input, so the pair is the cost of choosing +/// <c>Create</c> over <c>TryCreate</c> at a boundary that sees bad input — a number rather than a +/// guess. <see cref="CreateThrows"/> catches the exception inside the benchmark on purpose: the +/// throw and the catch together are what a caller pays. +/// </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>A rearrangement, a character-to-digit expansion, and 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; + } + } +} +``` + +- [ ] **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 `.HashCode` (`.CompareTo` was measured at 0.5714 ns and hoisted, so it is not drawn). + +- [ ] **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; + +/// <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; +} +``` + +- [ ] **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 += left.WeakString.CompareTo(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` 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** + +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> + /// <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"; +} +``` + +- [ ] **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** + +**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: + +```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.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"), + ]), +``` + +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 + /// 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 + /// 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. | +| 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. | +| 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 new file mode 100644 index 00000000..f8f582f7 --- /dev/null +++ b/docs/superpowers/specs/2026-09-18-strings-paths-benchmarks-design.md @@ -0,0 +1,364 @@ +# 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` | 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. | + +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<TDest>` (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`, +`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. +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&lt;T&gt; conversion | +| 8 | `StringOperationBenchmarks.HashCode` | GetHashCode | + +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 (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. + +## 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<T> + Length<T>` 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 | `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 +validation both sides pay and the per-call reflection only one side does. The last two rows are fair +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`, +`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<string, Subject> 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|<filter> + strings|docs/benchmarks/strings-history.json|docs/benchmarks/strings-performance.svg|<filter> + paths|docs/benchmarks/paths-history.json|docs/benchmarks/paths-performance.svg|<filter> +``` + +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 `<picture>` 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 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 + 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. **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 + 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 + `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. diff --git a/scripts/benchmark-history.cs b/scripts/benchmark-history.cs index 0e09f055..f5198fc7 100644 --- a/scripts/benchmark-history.cs +++ b/scripts/benchmark-history.cs @@ -22,23 +22,45 @@ 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 of the allocation and time sections, in reading order.</param> + /// <param name="Cost"> + /// The paired benchmarks the cost section draws. Empty for a subject whose history carries no + /// bare-baseline pair, and that subject's chart then has two sections rather than three. + /// </param> + private sealed record Subject(string Title, int Columns, PanelSpec[] Headline, CostPair[] Cost); + + /// <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>One cost panel: a measured benchmark over the bare baseline it is paired with.</summary> + /// <param name="Key">The benchmark measuring the wrapped type.</param> + /// <param name="Baseline">The benchmark measuring the same work on the bare type.</param> + /// <param name="Label">The panel heading.</param> + private sealed record CostPair(string Key, string Baseline, 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 @@ -50,18 +72,75 @@ internal static partial class BenchmarkHistory /// 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 + /// 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 + /// 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 (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)"), + ], + [ + // Both halves of each pair are stored like any other benchmark; the ratio is computed at + // render time rather than recorded, so an entry gathered before this section existed draws + // as soon as its run includes the pair, and no history has to be rewritten to change what + // the section shows. + new CostPair("AbstractionCostBenchmarks<Double>.QuantityAdd", "AbstractionCostBenchmarks<Double>.BareAdd", "Add (double)"), + new CostPair("AbstractionCostBenchmarks<Double>.QuantityMultiply", "AbstractionCostBenchmarks<Double>.BareMultiply", "Multiply (double)"), + new CostPair("AbstractionCostBenchmarks<Decimal>.QuantityMultiply", "AbstractionCostBenchmarks<Decimal>.BareMultiply", "Multiply (decimal)"), + new CostPair("AbstractionCostBenchmarks<PreciseNumber>.QuantityMultiply", "AbstractionCostBenchmarks<PreciseNumber>.BareMultiply", "Multiply (precise)"), + ]), + ["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> /// Validated for colour-vision separation against both surfaces: every check passes, worst @@ -89,22 +168,6 @@ private enum Measure Cost, } - /// <summary> - /// The paired benchmarks the cost section draws, as (measured, baseline, label). - /// </summary> - /// <remarks> - /// Both halves are stored like any other benchmark; the ratio is computed here rather than - /// recorded, so an entry gathered before this section existed still draws once its run - /// includes the pair, and no history has to be rewritten to change what the section shows. - /// </remarks> - private static readonly (string Key, string Baseline, string Label)[] CostHeadline = - [ - ("AbstractionCostBenchmarks<Double>.QuantityAdd", "AbstractionCostBenchmarks<Double>.BareAdd", "Add (double)"), - ("AbstractionCostBenchmarks<Double>.QuantityMultiply", "AbstractionCostBenchmarks<Double>.BareMultiply", "Multiply (double)"), - ("AbstractionCostBenchmarks<Decimal>.QuantityMultiply", "AbstractionCostBenchmarks<Decimal>.BareMultiply", "Multiply (decimal)"), - ("AbstractionCostBenchmarks<PreciseNumber>.QuantityMultiply", "AbstractionCostBenchmarks<PreciseNumber>.BareMultiply", "Multiply (precise)"), - ]; - internal static int Run(string[] args) { if (args.Length == 0) @@ -420,6 +483,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) { @@ -438,7 +508,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); } @@ -446,21 +516,30 @@ 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 costRows = (CostHeadline.Length + Columns - 1) / Columns; - int height = 72 + ((34 + (rows * CellHeight)) * 2) + 34 + (costRows * CellHeight) + 54; + int width = Left + (subject.Columns * CellWidth) + 24; + int rows = (subject.Headline.Length + subject.Columns - 1) / subject.Columns; + int costRows = (subject.Cost.Length + subject.Columns - 1) / subject.Columns; + + // A subject with no cost pairs draws two sections rather than three, and reserves no height + // for the third. Only the quantities suite measures a bare-storage-type baseline beside each + // operation; the strings and paths cost classes exist but are not run by the release workflow, + // so their histories carry no pair to divide. + Measure[] measures = subject.Cost.Length > 0 + ? [Measure.Allocation, Measure.Time, Measure.Cost] + : [Measure.Allocation, Measure.Time]; + int height = 72 + ((34 + (rows * CellHeight)) * 2) + + (costRows > 0 ? 34 + (costRows * CellHeight) : 0) + 54; StringBuilder svg = new(); - Preamble(svg, theme, width, height, entries); + Preamble(svg, theme, width, height, entries, subject); int y = 72; - foreach (Measure measure in (Measure[])[Measure.Allocation, Measure.Time, Measure.Cost]) + foreach (Measure measure in measures) { - Section(svg, theme, entries, labels.Length, y, measure); + Section(svg, theme, entries, labels.Length, y, measure, subject); y += 34 + ((measure == Measure.Cost ? costRows : rows) * CellHeight); } @@ -469,9 +548,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; }"); @@ -483,7 +563,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>() ?? ""; @@ -491,7 +571,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, Measure measure) + private static void Section( + StringBuilder svg, Theme theme, JsonArray entries, int points, int y, Measure measure, Subject subject) { string colour = measure switch { @@ -518,14 +599,14 @@ private static void Section(StringBuilder svg, Theme theme, JsonArray entries, i if (measure == Measure.Cost) { - for (int position = 0; position < CostHeadline.Length; position++) + for (int position = 0; position < subject.Cost.Length; position++) { - (string key, string baseline, string label) = CostHeadline[position]; + (string key, string baseline, string label) = subject.Cost[position]; double?[] values = [.. entries.Select(entry => Cost(entry!, key, baseline))]; Panel( svg, - Left + (position % Columns * CellWidth), - y + 26 + (position / Columns * CellHeight), + Left + (position % subject.Columns * CellWidth), + y + 26 + (position / subject.Columns * CellHeight), label, points, values, @@ -538,14 +619,14 @@ private static void Section(StringBuilder svg, Theme theme, JsonArray entries, i } bool isTime = measure == Measure.Time; - 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,