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



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:
PreciseNumber.Benchmarks/BaselineBenchmarks.csscripts/benchmark-history.csingestreads BenchmarkDotNet's JSON into a committed history keyed by version,renderdraws it as SVG,baselinereports the reference time. Run withdotnet 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.ymlworkflow_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:
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
ingestreplaces 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
ToDoublefalls 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
## Performanceheading being added, which would have taken the#performanceanchor 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 installon a runner — a reason about writing it rather than living with it. There is no other.pyfile 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
S2583bug (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 toescape.As a file-based app it is covered by the build instead, and the first compile failed on
CA1305,CA1502,CA1505,CA1506,IL2026,IL3050andSYSLIB1045. 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:
git diff --quietdoes 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.git show -s --format=%cson 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.git rev-parse --short v2.0.0resolves to the tag object (0dbce0a), not the commit (da3aac6)..gitignoreon every build, so ignore entries added for the workflow's scratch directories did not survive a singledotnet build. Scratch output goes toBenchmarkDotNet.Artifacts/instead, already ignored, and worktrees are created underRUNNER_TEMP, outside the repository where the commit step cannot see them.Verified
0.01×: the set spans three orders of magnitude, so ratios now use significant figures.baselinesubcommand returns a value,ingestis 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