Skip to content

[minor] Chart performance per release, and show it in the README - #83

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

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

Conversation

@matt-edmondson

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

Copy link
Copy Markdown
Contributor

Summary

The README now opens on a picture of what each release did to allocation and speed, drawn from numbers this repository keeps rather than from a run someone remembered to do.

Three pieces:

Piece Job
PreciseNumber.Benchmarks/BaselineBenchmarks.cs A fixed integer workload touching none of this library — the reading of the machine that makes separate jobs comparable.
scripts/benchmark-history.cs A .NET 10 file-based app. ingest reads BenchmarkDotNet's JSON into a committed history keyed by version, render draws it as SVG, baseline reports the reference time. Run with dotnet run scripts/benchmark-history.cs -- …, so it needs no project and nothing installed beyond the SDK that already builds the library.
.github/workflows/benchmark-history.yml Measures a published release and adds a point, or backfills a list of refs on workflow_dispatch.

This is deliberately separate from benchmarks.yml, which exists to get a full ad-hoc run on demand and publishes nothing.

The awkward part, addressed rather than hidden

A benchmark chart across releases is mostly a chart of CI runners. Each release is measured in its own job, and the difference between the hosts a job can land on is larger than most releases are — the one-off comparison behind #76 had to put both versions in a single job for exactly this reason.

Two things deal with it:

  • Allocation is charted because it is exact. The same code allocates the same bytes on any machine, so a step in the top row is always a real change.
  • Times are divided by the reference workload measured in the same job. That cancels most of the difference between machines, and the README says plainly that what is left is indicative — a small wobble between two releases is more likely the runner than the library.

A backfill measures every ref in one job for the same reason: points gathered on one host are comparable as they stand, and the reference reading ties that job to later ones.

Seeded with real data

The chart ships with 1.9.0 and 2.0.0–2.0.4 already measured, so the README works the moment this merges rather than showing a broken image until the first release.

Those six points were measured in a dev container, not on a GitHub runner — each entry records the CPU it was measured on, and ingest replaces an entry for the same version, so running the backfill from the Actions tab cleanly overwrites them with runner-measured points whenever you want. The dispatch defaults are already set to that ref list.

It reproduces the #70 result independently, which is a decent check on the pipeline: every allocation drop at 2.0.0 is exactly 40 bytes — the object header — and ToDouble falls from 0.045 to 0.010 of the reference, the same ~79% the one-off comparison on #72 measured.

README

The existing Performance section moved up to sit after Features, rather than a second ## Performance heading being added, which would have taken the #performance anchor and broken the contents link. Light and dark charts are swapped with <picture> + prefers-color-scheme.

The tool was Python first, and that was wrong

Worth recording, since the same tool is meant to be copied into Semantics and SignificantNumber.

It was written in Python because that was quick and needs no pip install on a runner — a reason about writing it rather than living with it. There is no other .py file in any of the three repositories, PowerShell is what they use for scripting, and nothing in the repo's tooling covered it.

SonarCloud then failed the Quality Gate on it: a S2583 bug (a condition always evaluating to true) took the reliability rating to C, plus two cognitive-complexity findings and an argument of the wrong type passed to escape.

As a file-based app it is covered by the build instead, and the first compile failed on CA1305, CA1502, CA1505, CA1506, IL2026, IL3050 and SYSLIB1045. Most are fixed rather than suppressed — cultures are explicit, the regex is source-generated, and the work moved out of top-level statements into a class so each method is judged separately. Two of those are the same findings Sonar made about the Python, caught before the push instead of after.

The port is verified by output: re-ingesting the same reports and re-rendering leaves the history and both SVGs byte for byte identical. Getting there caught one real difference — parameters were coming out in ordinal order, which reads 200, 30, 8, where BenchmarkDotNet declares them 8, 30, 200 and the Digits axis is meant to be read across in order.

Five bugs the testing caught

Each would have failed silently:

  1. git diff --quiet does not see an untracked file, so the very first run would have committed nothing and still reported success — the same failure mode as the artifact-path bug in [patch] Write benchmark results where the workflow looks for them #73. Now staged first, then compared against the index.
  2. git show -s --format=%cs on an annotated tag prints the whole tag object, so every tagged entry recorded the tagger message where its date belonged. Caught by reading the real backfill output.
  3. git rev-parse --short v2.0.0 resolves to the tag object (0dbce0a), not the commit (da3aac6).
  4. A release event checks out the tag, so committing results from that checkout asks the default branch to move backwards. Both paths now check out the default branch and measure their ref through a worktree.
  5. ktsu.Sdk regenerates .gitignore on every build, so ignore entries added for the workflow's scratch directories did not survive a single dotnet build. Scratch output goes to BenchmarkDotNet.Artifacts/ instead, already ignored, and worktrees are created under RUNNER_TEMP, outside the repository where the commit step cannot see them.

Verified

  • Solution builds with 0 warnings, 0 errors; all 266 tests pass.
  • The chart palette was checked with a CVD validator in both light and dark modes — all checks pass, worst adjacent ΔE 24.7 / 26.8.
  • Both SVGs were rendered to PNG and inspected. That is what caught fast benchmarks collapsing to 0.01×: the set spans three orders of magnitude, so ratios now use significant figures.
  • The workflow's steps were exercised against real artifacts — the baseline subcommand returns a value, ingest is idempotent, and the staged-diff check detects new files.

Note on scope

You asked for this across Semantics and SignificantNumber too. Neither has a benchmark project at all, so each needs a suite written before it can be charted. This PR establishes the mechanism on the repo that already has one; the tool and workflow are written to port with only the headline list changing.

🤖 Generated with Claude Code

https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8

The README now opens on a picture of what each release did to allocation and
speed, drawn from numbers this repository keeps rather than from a run someone
remembered to do.

Three pieces. `BaselineBenchmarks` measures a fixed integer workload that
touches none of this library. `scripts/benchmark_history.py` reads
BenchmarkDotNet's JSON into a committed history keyed by version and draws it as
SVG. `benchmark-history.yml` measures a published release and adds a point, or
backfills a list of refs on demand.

The awkward part is that a benchmark chart across releases is mostly a chart of
CI runners. Each release is measured in its own job, and the difference between
the hosts a job can land on is larger than most releases are. Two things address
it rather than hide it. Allocation is charted because it is exact: the same code
allocates the same bytes anywhere, so a step in the top row is always real.
Times are divided by the reference workload measured in the same job, which
cancels most of the difference between machines, and the README says plainly
that what remains is indicative. A backfill measures every ref in one job for
the same reason — points gathered on one host are comparable as they stand.

The chart is seeded with 1.9.0 and 2.0.0 through 2.0.4, measured here rather
than on a runner, so the README works the moment this merges. Each entry records
the CPU it was measured on, and ingesting a version replaces its entry, so
re-running the backfill in CI overwrites these with runner-measured points.

It reproduces the value type result independently: every allocation drop at
2.0.0 is exactly 40 bytes, the object header, and ToDouble falls from 0.045 to
0.010 of the reference — the same 79% the one-off comparison on #72 measured.

The README's Performance section moved up to sit after Features, rather than a
second Performance heading being added, which would have taken the anchor.

Four things the tests caught, all of which would have failed quietly:

- `git diff --quiet` does not see an untracked file, so the first run would have
  committed nothing and reported success.
- `git show -s --format=%cs` on an annotated tag prints the whole tag object, so
  every tagged entry recorded the tagger message as its date.
- `git rev-parse --short v2.0.0` resolves to the tag object, not the commit.
- A release event checks out the tag, so committing results from that checkout
  asks the default branch to move backwards. Both paths now check out the
  default branch and measure their ref through a worktree.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8
The tool that reads BenchmarkDotNet's reports and draws the chart was Python,
which fits none of these repositories: there is no other .py file in
PreciseNumber, Semantics or SignificantNumber, and what scripting they do have is
PowerShell. It was chosen because it was quick to write and needs nothing
installed on a runner, which is a reason about writing it rather than about
living with it — and it was about to be copied into two more repositories.

It is now a .NET 10 file-based app, run as

  dotnet run scripts/benchmark-history.cs -- ingest ...

so it needs no project, no runtime beyond the SDK that already builds the
library, and no second language in the workflow. A third subcommand, baseline,
replaces the inline Python that read one number back out of a report.

The build covers it, which is the part that could not be had before: the first
compile failed on CA1305, CA1502, CA1505, CA1506, IL2026, IL3050 and SYSLIB1045.
Most are fixed rather than suppressed — cultures are explicit, the regex is
source-generated, and the work moved out of top-level statements into a class so
the analyzers judge each method rather than one 500-line Main.

The port is verified by output rather than by reading: re-ingesting the same
reports and re-rendering leaves both the history and the two SVGs byte for byte
identical to what Python produced. Getting there caught one real difference —
parameters were coming out in ordinal order, which reads 200, 30, 8, where
BenchmarkDotNet declares them 8, 30, 200 and the Digits axis is meant to be read
across in order.

Also drops the .gitignore entries added with the workflow. ktsu.Sdk regenerates
that file on every build, so they did not survive one — which is what quietly ate
them twice while this was being written. The scratch output goes to
BenchmarkDotNet.Artifacts/ instead, already ignored, and worktrees are created
under RUNNER_TEMP, outside the repository where the commit step cannot see them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8
Comment thread scripts/benchmark-history.cs Fixed
… [patch]

ReadReports opened each report inside the loop, which is a map written as a
statement. Projecting with Select says the same thing in the loop header.

Output is unchanged: re-ingesting the same reports and re-rendering leaves the
history and both SVGs byte for byte identical.

Reported by github-code-quality on #83. Its suggested `using System.Linq;` is not
needed here, because implicit usings already cover it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8
…patch]

Eight points now, v1.8.0 through v2.0.5, where there were six. Two were missing
rather than unavailable: v1.8.0 is a real tag that also carries the benchmark
project, and v2.0.5 has been released since the first seed. All eight are
re-measured in one pass against one reading of the reference workload, so every
point is comparable to every other by construction rather than by argument.

v1.8.0 is where this can reach, and the boundary is not arbitrary: the benchmark
project arrived in #68, so no earlier tag has one to run. Going further would mean
running today's benchmarks against old released packages instead of each tag's own
source — which works, and was tried: only ConstructionBenchmarks fails to compile
against 1.7.36, because CreateFromComponents is internal and the InternalsVisibleTo
that reaches it arrived with the suite. That is a different measurement for six of
the seven benchmarks, so it is not mixed in here silently.

The eighth point earns its place immediately. Divide allocated 312 bytes at
v1.8.0 and 240 at v1.9.0, so the exact-division work in #69 shows as its own step
before the value type takes another 40 off every operation at 2.0.0. The six-point
seed started after that and showed none of it.

The axis caption lists every release again. It thinned to every other label above
six, which reads as the whole list and would claim there were fewer releases than
there are; twelve is the point where they stop fitting.

Also drops the two .gitignore entries the first commit added. ktsu.Sdk regenerates
that file on every build and strips them, so committing them leaves a dirty tree
after any build, and the workflow no longer writes to either path.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017jrnV7N94UGL8fDRRE8Xt8
@sonarqubecloud

Copy link
Copy Markdown

@matt-edmondson
matt-edmondson merged commit 8cbad88 into main Sep 16, 2026
12 checks passed
@matt-edmondson
matt-edmondson deleted the claude/magical-knuth-idzj5r branch September 16, 2026 05:08
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants