Skip to content

Beat Ghostty on every vt workload and keep windows on screen - #62

Merged
Marc-André Moreau (mamoreau-devolutions) merged 7 commits into
masterfrom
claude/perf-benchmarks-ghostty-f97bcb
Oct 27, 2026
Merged

Marc-André Moreau (mamoreau-devolutions) merged 7 commits into
masterfrom
claude/perf-benchmarks-ghostty-f97bcb

Conversation

@mamoreau-devolutions

Copy link
Copy Markdown
Contributor

Summary

The builtin engine now beats raw libghostty-vt on all 13 vt benchmark workloads, up from 8 of 13. The five that were behind or tied are now ahead:

Workload Before (#61) After
cjk 0.77× 1.12×
emoji 0.80× 1.21×
utf8 0.77× 1.36×
cursor-motion 0.90× 1.09×
scroll-region 0.96× 1.11×

Ratios are builtin speed ÷ Ghostty speed in the same run, on one Windows x64 laptop. Screens are identical to Ghostty's on every workload except styled, whose known one-row difference is unchanged. Full table and methodology: docs/performance-benchmarks.md.

This PR also fixes windows ending up larger than the screen, or off it, after a display is disconnected.

One commit per change, in order. Each perf commit builds and passes the Core tests on its own.

Builtin engine

  • PrintRunes append loop: the loop moves into a small static method. Each rune is one OR into precomputed template bits and one 16-byte store, with widths from a table where ambiguous widths are already resolved.
  • Short prints (cursor-motion, yes): the print templates are cached until attributes, protection or shell-integration state change. A single printable byte is one store. The DEC graphics check is inline.
  • CSI dispatch: parameters are parsed straight into the arrays the engine receives, and the parser calls the sealed engine directly. Plain sequences skip the special cases and go straight to the switch on the final byte.
  • Combining text as interned IDs: one CombiningTable per buffer. Lines store int IDs in an array that is reused when the line is recycled. A cluster seen before allocates nothing, and the table compacts so it cannot grow without bound. Plain combining marks and ZWJ skip the per-character path. utf8 allocation fell from 9.4 MB to under 0.1 MB per MiB.
  • Vectorized UTF-8 decode: runs of 3- or 4-byte sequences are decoded four at a time from 16-byte Vector128 loads, so Arm64 gets it too. They follow the scalar decoder's validity rules, and anything else falls back to it. CJK decoding is about 3× faster.

Every new path is checked against the per-character or byte-by-byte path by differential tests. Each check was also deliberately broken to confirm a test fails.

Benchmarks

  • Invoke-TerminalBenchmarks.ps1 -MinMs passes --min-ms to the bench. This laptop's CPU leaves turbo about 2.7 s into sustained load. With 500 ms samples, runs landed on both sides of that drop and spreads reached 50–180%. 3 s samples keep them around 5–20%.
  • docs/performance-benchmarks.md has the round 5 results, the engine changes, new per-stage costs and what remains. It also records that prefetching recycled scrollback lines was tried and measured slower.

Window placement

Windows moves windows off a disconnected display without shrinking them, so the terminal could overhang the laptop screen with its caption buttons out of reach. A saved position could also point at a display that is gone.

WindowPlacement.Fit leaves a window alone when the displays cover all of it, so a window spanning two connected displays stays put. Otherwise it shrinks the window and moves it onto the display it overlaps most, or the nearest one. MainWindow applies it:

  • at launch, when a saved or requested position is restored, before maximizing;
  • on Screens.Changed;
  • when the window returns to Normal.

A maximized or full-screen window keeps its state; its saved normal bounds are fitted instead. Windows Terminal only checks the top-left corner at launch and never shrinks.

Testing

  • Core tests: 1,191 passed.
  • App tests: 632 passed, 9 of them new for WindowPlacement.
  • Headless UI tests: 670 passed.
  • -Baseline gate passed after each perf change.
  • The window fix has not been tried with a real display disconnect.

🤖 Generated with Claude Code

The append loop of TextBuffer.PrintRunes moves into a small static
method, AppendSimpleRunes, so its state stays in registers. Each rune
becomes one OR into the template's rune-free bits plus one 16-byte vector
store (two for a wide glyph and its continuation). Before, every field
of the template was rebuilt and every write was bounds-checked.

Widths come from one table per AmbiguousAsWide setting with ambiguous
widths already resolved, so the loop does one load per rune instead of
a class lookup plus a static read. A line with one effective column
stays on the bounds-checked WriteRunes path.

New tests compare bulk and per-character printing with ambiguous widths
both narrow and wide, including runs that wrap.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Short writes between escape sequences (cursor-motion, `yes`) paid a
setup cost on every call that exceeded the store itself:

- The packed print templates are cached. Setting CurrentAttributes,
  CurrentProtection or the shell-integration state marks them stale.
  The ASCII template rebuilds apart from the rune templates, so a style
  change before every cell does not also rebuild those.
- A single printable byte that fits on the line, with no wrap pending,
  is one CellRow.StoreAscii call instead of the segment loop.
- The DEC Special Graphics check before each print compares length and
  first character inline instead of calling string.Equals.
- CircularBuffer.ValidateIndex throws from a separate method, so the
  index check on every line access can inline.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A complete plain CSI sequence is parsed straight into the arrays the
  dispatcher receives. Before, it went into the parser's working arrays
  and was then copied. A handler that feeds the parser again still takes
  the copying path, so it cannot overwrite the outer sequence.
- Subparameter flags are no longer cleared after each sequence. Every
  push writes its own flag, so the clearing loop had no effect.
- The parser calls the sealed TerminalEngine's DispatchCsi directly
  instead of through IVtDispatch. Other dispatchers still use the
  interface.
- In the engine, a sequence with neither a private marker nor an
  intermediate byte skips the special-case checks and goes straight to
  the switch on its final byte (DispatchPlainCsi). Behaviour is
  unchanged, including device attributes (`CSI c`) and intermediate-byte
  sequences that match no special case.

A new test feeds the parser again from inside a CSI handler and checks
that the outer sequence's parameters stay intact.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
On the utf8 workload, the first combining mark on a line allocated a
120-slot string array (about 1 KB per line, since rows are recycled every
line), and each mark built two full Cells and concatenated a new string.
That came to 9.4 MB allocated per MiB of input.

- CombiningTable (one per TextBuffer) maps a cell's combining text to an
  int ID; 0 means none, and the empty string has its own ID. A cache of
  (ID, mark) to ID means adding a mark already seen allocates nothing.
- CellRow stores IDs in an int[] side array with no GC references, kept
  as a spare when the row is reset so recycled lines allocate nothing.
  CellRow.AppendCombining adds a mark without building Cells. Rows that
  do not share a table copy the text between them instead of the ID.
- The table compacts once it passes max(4096, 2 x IDs in use): IDs that
  no line holds are dropped and the rest renumbered, so a stream of
  ever-new clusters cannot grow it without bound.
- PrintRunes stores a zero-width rune with no grapheme-join property
  (U+0301, ZWJ) straight into the previous cell's combining text. It
  used to go through PrintCore, which could only end in the same append.

utf8 allocation fell from 9.4 MB to 0.47 MB per MiB. CellRowModelTests now
shares a table in half its iterations, adds random appends and empty
combining text. A new test checks that compaction keeps every live cell's
text while 12,000 new clusters pass through.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
CJK and emoji text is long runs of same-length sequences, and decoding
them was the largest single cost on those workloads. DecodeWideBlock
reads 16 bytes with Vector128 (so Arm64 gets it too):

- 3-byte block: four sequences in 12 bytes. One masked compare checks
  the E0-EF lead and continuation bytes, a shuffle moves each sequence
  into an int lane, and vector compares reject overlong forms and
  surrogates.
- 4-byte block: four sequences in 16 bytes. Leads must be F0-F7, and one
  unsigned range check keeps U+10000-U+10FFFF, which also rejects
  overlong forms and F5-F7 leads.

Any block that fails a check returns 0 and the scalar decoder handles
it unchanged. On CJK, decoding is about 3x faster and emoji about 2.5x.

A new differential test feeds long runs of both lengths, edge scalars,
ASCII that shifts block alignment, runs longer than the 256-rune buffer,
and every malformed form the checks must reject, whole, byte by byte
and in random chunks.

Invoke-TerminalBenchmarks.ps1 gains -MinMs, passed to the bench as
--min-ms. Laptop CPUs leave turbo a few seconds into sustained load, so
500 ms samples land on both sides of that drop; ~3000 ms samples keep
spreads and verdicts stable.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Builtin now leads raw libghostty-vt on all 13 vt workloads, up from 8 of
13. cjk, emoji and utf8 went from about 0.8x to 1.12x, 1.21x and 1.36x;
cursor-motion and scroll-region from 0.90x and 0.96x to 1.09x and 1.11x.

- Results come from `-MinMs 3000`. This laptop's CPU leaves turbo about
  2.7 s into sustained load, so 500 ms samples landed on both sides of
  that drop; the methodology section now says so.
- The engine section describes round 5: interned combining text, the
  vectorized UTF-8 decode, the CSI dispatch changes and the cached print
  templates.
- The remaining-gaps section is rewritten around the new stage timings,
  and records that prefetching recycled lines did not help.
- The frame-preparation and Ghostty-mode tables are marked as round 4
  measurements; round 5 did not touch that code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
When an external display is disconnected, Windows moves its windows onto
the remaining display without shrinking them. A terminal sized for the
larger display then overhangs the laptop panel with its caption buttons
out of reach. A saved position can also point at a display that is gone,
and the window was restored there as-is.

WindowPlacement.Fit takes a window rectangle and the current work areas.
When they cover all of the window it returns it unchanged, so a window
spanning two connected displays stays put. Otherwise it shrinks the
window to fit and moves it inside the work area it overlaps most, or the
nearest one when it overlaps none. Mirrored displays count once.

MainWindow applies it:

- at launch, after restoring a saved position or applying a requested
  one, before maximizing, so the window maximizes on a display that
  exists;
- when Screens.Changed fires, and again a second later, once Windows
  has finished moving windows off a removed display;
- when the window returns to Normal, since the OS restores the bounds
  it remembered from before the change.

A maximized or full-screen window keeps its state; its saved normal
bounds are fitted instead, so un-maximizing and the persisted layout
land on a display that exists. Minimized windows are left alone.

Windows Terminal only checks, at launch, that the window's top-left
corner is on some monitor, and never shrinks a window.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit 80c15c6 into master Oct 27, 2026
54 checks passed
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) deleted the claude/perf-benchmarks-ghostty-f97bcb branch October 27, 2026 20:42

This branch was successfully deployed

1 active deployment
publish-dry-run — c1455e44 Deployed Oct 27, 2026 by mamoreau-devolutions via MSIX packages #379
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant