Skip to content

Guard the perf work in CI, exactly where it can be exact - #42

Merged
tomlm merged 3 commits into
tomlm:mainfrom
JohnCampionJr:perf-ci
Aug 27, 2026
Merged

Guard the perf work in CI, exactly where it can be exact#42
tomlm merged 3 commits into
tomlm:mainfrom
JohnCampionJr:perf-ci

Conversation

@JohnCampionJr

Copy link
Copy Markdown
Collaborator

Two guards for the work in #38, held to deliberately different standards — because one of these things can be measured exactly on a shared CI runner and the other cannot.

The struct layout is a unit test

BufferCell holds no managed references, and is 24 bytes. Neither is a measurement — they hold or they do not — so they run in the ordinary test job and cost nothing.

The reference one is load-bearing. GC write barriers are all-or-nothing: a single string field added back to the cell makes the collector trace the whole scrollback and makes every cell write emit a barrier, taking a 240-column fill from 70 ns to 239 ns. Nothing else in the suite would notice, and the failure message says what to do instead.

Throughput is a comparison, not a threshold

PerfCompare.yml builds the branch and the commit it forked from, runs one harness against both by assembly substitution, alternating, and compares medians.

Allocation is gated exactly. Bytes allocated for a fixed amount of work is a count: it does not care what else the machine is doing, how warm it is, or what the clock is. Across every calibration run below it came back byte-identical.

Time is gated against the noise the job observes in itself. That is not a preference — it is what the calibration showed. Comparing a build against itself on a quiet laptop:

work per corpus apparent Δ on scroll-ascii spread
60M chars +27% ±30%
300M chars +0.2% ±1%

A fixed threshold would have to sit above 30% to survive the first row, which is far too loose to catch anything real. The first row is also where scroll-ascii read 3.9 ns/char against the 1.7 it actually runs at — the work was too short to finish warming, which is the same trap that cost real time earlier in this project.

So the gate is max(5%, 3 × observed spread). A quiet machine earns a tight gate, a busy one raises its own bar instead of crying wolf, and the band between the floor and the gate is reported as worth a look rather than disappearing.

Checked against a real regression

Not assumed to work. Removing the _placeholderCell guard from Print — the 12% found by hand while merging Kitty, fixed in #39 — reproduced as:

corpus bytes/char ns/char Δ time noise gate
scroll-ascii 0.00 → 0.00 1.62 → 1.64 +1.5% ±3% 9%
sgr-churn 2.33 → 2.33 4.28 → 4.38 +2.2% ±4% 11%
truecolor 1.72 → 1.72 4.44 → 4.64 +4.5% ±4% 11%
alt-redraw 0.26 → 0.26 5.37 → 5.97 +11.2% ⚠️ ±2% 7%
unicode 7.66 → 7.66 12.38 → 13.46 +8.7% ±3% 10%
flood 0.00 → 0.00 40.21 → 39.70 -1.3% ±3% 10%

The right corpus, and the other five silent.

Two things that keep the comparison honest

The ci mode touches only Terminal, TerminalOptions and Write(string). That is what lets one build of the harness measure an older library — anything newer would fail at run time, and the job could then only ever compare a build against itself.

It reports the module version id of what it loaded. The path and the assembly version are both identical by design when the comparison works by swapping a file, so neither can answer "are these two different builds". If the MVIDs match, the report says so and fails rather than reporting a flawless result that means nothing.

Running it by hand

dotnet run --project src/XTerm.NET.Bench -c Release -- ci --out head.json
dotnet run --project src/XTerm.NET.Bench -c Release -- compare --base a.json b.json --head c.json d.json

The workflow is workflow_dispatch-able with chars and runs inputs, so a suspicious result can be re-run with more work — which narrows the noise column and tightens the gate automatically.

Notes

  • It posts the table to the job summary always, and to the PR as a comment on a best-effort basis: a PR from a fork gets a read-only token and cannot comment.
  • Defaults are 150M chars × 3 runs per side, which is a few minutes. Both are dials.
  • It fails the job on a regression. Happy to make it advisory instead if you would rather see it run for a while first.

1157 tests pass.

🤖 Generated with Claude Code

JohnCampionJr and others added 3 commits August 27, 2026 19:18
Two guards, held to different standards on purpose.

The struct layout is a unit test: BufferCell holds no managed references and is 24
bytes. Neither is a measurement, so both run in the ordinary test job and cost
nothing. The reference one is load-bearing -- a string field added back to the cell
would undo the largest win here at a stroke and nothing else in the suite would
notice.

Throughput is a comparison. PerfCompare.yml builds the branch and the commit it
forked from, runs one harness against both by assembly substitution, alternating,
and compares medians. Allocation per character is gated exactly, because bytes
allocated for fixed work is a count and does not care what else the machine is
doing. Time is gated against the spread the job observes in itself.

That is not a preference. Calibrated by comparing a build against ITSELF: at 60M
characters per corpus the same library differed from itself by 27% with a 30%
spread, and at 300M by 0.2% with a 1% spread -- and the short run also read
scroll-ascii at 3.9 ns/char against the 1.7 it actually runs at, because it had not
finished warming. A fixed threshold would have to sit above 30% to survive that,
which is far too loose to catch anything. So the gate is max(5%, 3x observed
spread): a quiet machine earns a tight one, a busy machine raises its own bar rather
than crying wolf, and the band between the floor and the gate is reported as worth a
look instead of disappearing.

Checked against a real regression rather than assumed to work. Removing the
_placeholderCell guard from Print -- the 12% found by hand while merging Kitty --
was flagged at +11.2% against a 7.0% gate, with the other five corpora silent.

The ci mode touches only Terminal, TerminalOptions and Write(string), which is what
lets one harness measure an older library. It reports the module version id of what
it loaded, because two runs of the same assembly would otherwise report a flawless
result and mean nothing -- the path and the version are both identical by design
when the comparison works by swapping the file.

1157 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The first run on a GitHub runner passed and showed why it should not be trusted yet:
scroll-ascii came back with a +/-16% spread against +/-1-4% for every other corpus,
which put its gate at 49% -- no gate at all.

The cause is that equal characters is not equal work. flood costs some 28x per
character what scroll-ascii does, so an equal-character budget measures the fast
corpora for a twenty-eighth as long and hands them all the noise -- and the fast
corpora are the ones this library exists to be fast at.

The budget is now divided by a fixed per-corpus cost, so each is measured for about
the same length of time. Every spread came back at +/-1-3%, gates at 5-8%, and the
whole run takes a third as long. The costs are constants rather than measured at run
time: both sides of a comparison must do identical work, and a figure derived from a
warm-up would differ between them. Being wrong only makes the run uneven, never
incorrect, since every number is reported per character.

Percentages are now formatted by hand in the invariant culture. "P0" renders as
"16 %" where there is no ICU -- which is the CI runner this report is written for --
and "16%" on a developer machine, and a report that reads differently depending on
where it ran is a report nobody can diff.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@tomlm
tomlm merged commit 4987815 into tomlm:main Aug 27, 2026
2 checks passed
JohnCampionJr added a commit to JohnCampionJr/XTerm.NET that referenced this pull request Aug 28, 2026
An application scrolling one column of a side-by-side layout scrolls the whole
screen without this. It is the last of the two VT gaps the roadmap listed, and
unlike REP it is not a small one: margins are not a parameter on the existing
scroll, they are a second scroll.

CSI s is two sequences sharing a final character. With DECLRMM (mode 69) set it is
DECSLRM; without it, Save Cursor. So this changes the meaning of a sequence already
handled rather than adding one, and getting it backwards would make an application's
margins silently save the cursor.

A HALF implementation would have been worse than none. An application sets mode 69,
asks DECRQM whether it took, and changes its drawing on the answer -- so reporting
the mode as supported while scrolling ignored the margins would corrupt exactly the
layouts the feature exists for, quietly, and only in the applications careful enough
to ask. Every operation that moves content therefore honours them:

- Scrolling. TerminalBuffer.ScrollUp/Down move whole LINES through the ring, which
  is what feeds the scrollback and what line recycling depends on. Narrowed margins
  cannot use that path at all -- only part of each line moves and nothing is promoted
  to scrollback -- so ScrollMarginColumns is a separate implementation. The choice is
  made inside ScrollUp rather than at each call site, because every wrap, LF and IND
  arrives through it.
- Autowrap, in all three print paths.
- IL and DL, which shift only the margin columns, and do nothing at all from a cursor
  outside them: a cursor in the right-hand pane shifting the left pane's lines is the
  corruption this prevents.
- ICH and DCH, bounded by the right margin, so nothing is pushed into the next pane
  and the blanks appear at the margin rather than the screen edge.
- Origin mode, where the region is a box, so column 1 is the left margin.
- A resize, which clamps the margins and widens them if the pair would go degenerate.
- RIS and DECSTR, which widen them.

Two things nearly leaked past the margin, both the same shape as bugs found earlier
in this work -- a batched writer bypassing a rule the per-character path follows:

- The run writers clamped `take` to Cols, so a batch wrote straight through the
  margin, and only when the fast path took the write.
- A double-width character on the last column of a region planted its spacer in the
  pane next door.

And one that only a test found: the cursor is allowed to rest one past the last
column it wrote, which is how a pending wrap is represented. Reading that column as
OUTSIDE the margins handed the wrap limit back to the screen edge at exactly the
moment the wrap was due, so text ran through the margin anyway.

Twenty tests. Measured against main with the harness from tomlm#42, at four hundred
million characters a corpus so the noise floor is ±1%: scroll-ascii +0.5%,
everything else within ±3%, allocation identical. A first run at a quarter of that
work read +4.7% on scroll-ascii against ±5% noise, which is the reason for the
longer one rather than a shrug.

Not here, deliberately: DECIC and DECDC, insert and delete COLUMN. They are separate
sequences that exist alongside margins rather than part of them, and this is already
the invasive change.

1177 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@JohnCampionJr
JohnCampionJr deleted the perf-ci branch August 31, 2026 18:47
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