Guard the perf work in CI, exactly where it can be exact - #42
Merged
Conversation
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>
This was referenced Aug 28, 2026
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>
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.
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
BufferCellholds 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
stringfield 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.ymlbuilds 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:
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-asciiread 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
_placeholderCellguard fromPrint— the 12% found by hand while merging Kitty, fixed in #39 — reproduced as:The right corpus, and the other five silent.
Two things that keep the comparison honest
The
cimode touches onlyTerminal,TerminalOptionsandWrite(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
The workflow is
workflow_dispatch-able withcharsandrunsinputs, so a suspicious result can be re-run with more work — which narrows the noise column and tightens the gate automatically.Notes
1157 tests pass.
🤖 Generated with Claude Code