Repository navigation
Beat Ghostty on every vt workload and keep windows on screen - #62
Merged
Marc-André Moreau (mamoreau-devolutions) merged 7 commits intoOct 27, 2026
Merged
Marc-André Moreau (mamoreau-devolutions) merged 7 commits into
Marc-André Moreau (mamoreau-devolutions) merged 7 commits into
Conversation
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>
Marc-André Moreau (mamoreau-devolutions)
deployed
to
publish-dry-run
October 27, 2026 20:33 — with
GitHub Actions
Active
Marc-André Moreau (mamoreau-devolutions)
deployed
to
publish-dry-run
October 27, 2026 20:33 — with
GitHub Actions
Active
Marc-André Moreau (mamoreau-devolutions)
deployed
to
publish-dry-run
October 27, 2026 20:33 — with
GitHub Actions
Active
Marc-André Moreau (mamoreau-devolutions)
deployed
to
publish-dry-run
October 27, 2026 20:33 — with
GitHub Actions
Active
Marc-André Moreau (mamoreau-devolutions)
merged commit Oct 27, 2026
80c15c6
into
master
54 checks passed
Marc-André Moreau (mamoreau-devolutions)
deleted the
claude/perf-benchmarks-ghostty-f97bcb
branch
October 27, 2026 20:42
This branch was successfully deployed
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 builtin engine now beats raw
libghostty-vton all 13vtbenchmark workloads, up from 8 of 13. The five that were behind or tied are now ahead: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
PrintRunesappend 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.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.CombiningTableper buffer. Lines storeintIDs 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.Vector128loads, 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 -MinMspasses--min-msto 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.mdhas 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.Fitleaves 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.MainWindowapplies it:Screens.Changed;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
WindowPlacement.-Baselinegate passed after each perf change.🤖 Generated with Claude Code