Skip to content

Add Ghostty comparison benchmarks and speed up the builtin engine - #61

Merged
Marc-André Moreau (mamoreau-devolutions) merged 1 commit into
masterfrom
claude/dt-ghostty-perf-benchmarks-cf1a9f
Oct 26, 2026
Merged

Marc-André Moreau (mamoreau-devolutions) merged 1 commit into
masterfrom
claude/dt-ghostty-perf-benchmarks-cf1a9f

Conversation

@mamoreau-devolutions

Copy link
Copy Markdown
Contributor

Summary

This adds a benchmark that compares DT's default engine with Ghostty on identical input, then uses it to speed the default engine up. The default engine now matches or beats the Ghostty library on 8 of 13 workloads. It started at 0.01–0.4× on most of them.

Full methodology, results and remaining gaps: docs/performance-benchmarks.md.

Benchmarks

  • New vt mode in tools/Devolutions.Terminal.Bench. It feeds identical bytes, in one process, to three targets:
    • the builtin engine;
    • raw libghostty-vt;
    • DT's Ghostty engine.
  • Before timing, it checks that the final screens match.
  • The verdict is conservative: faster only when the builtin's slowest run beats Ghostty's fastest.
  • 13 deterministic workloads, modeled on ghostty-gen and vtebench. --frame-kb N also takes a screen snapshot every N KiB, as the renderer would.
  • scripts/Invoke-TerminalBenchmarks.ps1 runs one process per workload.
    • -Baseline fails on a builtin/Ghostty ratio drop of more than 15% on identical inputs.
    • -GhosttyBench also replays the inputs through upstream ghostty-bench.

Builtin engine

  • Packed cell storage (CellRow/PackedCell): 16 bytes per cell with no garbage-collected references, down from about 40. Combining marks and hyperlinks move to per-line side arrays. The public Cell and TermColor types are unchanged.
  • Logically blank cells are never written. Reusing a scrollback line and erasing to the end of a line are O(1).
  • Bulk paths, each checked against the original character-by-character path:
    • printable ASCII (AVX2 where available);
    • well-formed UTF-8 runs;
    • plain ESC [ sequences;
    • control characters such as CR and LF.
  • Lookup tables for character width and grapheme joining, covering planes 0 and 1.

Ghostty engine

  • The screen is copied out of Ghostty only when something reads it, and only for the rows Ghostty reports as changed. Before, it was copied in full after every chunk of output.

Results (one Windows x64 machine, builtin speed ÷ Ghostty speed in the same run)

Workload Before After
ascii / ascii-lines / light-cells 0.009x 1.19–1.24x
scrolling (y\n) 0.015x 1.33x
dense-cells 0.42x 1.44x
styled / osc 0.30x / 0.29x 2.91x / 4.59x
mixed 0.03x 1.84x
scroll-region / cursor-motion 0.018x / 0.11x 0.96x / 0.90x
utf8 / cjk / emoji 0.10x / — / — 0.77x / 0.77x / 0.80x

Screens are identical on every workload except one styled row. There, the builtin engine drops a combining mark that follows a space, a pre-existing difference.

Known gaps

  • Non-ASCII text is still about 0.8×. Closing it needs a dedicated SIMD UTF-8 decoder or 8-byte cells.
  • Frame snapshots cost about 0.03 ms more (0.11 ms per 120x30 frame), because each packed cell is unpacked into a public Cell.
  • These are JIT numbers from one Windows machine, not from the NativeAOT build.

Tests

  • New tests:
    • AsciiFastPathTests, CsiFastPathTests, CellRowModelTests, PackedCellStorageTests (Core);
    • GhosttyProjectionTests (Ghostty).
  • Every new shortcut was also checked by deliberately breaking it and confirming a test fails.
  • Release build of Devolutions.Terminal.slnx: 0 warnings, 0 errors.
  • Core, Render, Control, Ghostty, Compatibility, Connection, Cli, App, UI, Broker and Settings tests all pass (3,394 tests; 11 skipped in Connection).

🤖 Generated with Claude Code

Benchmarks:
- Add a `vt` bench mode that feeds identical bytes to the builtin engine,
  raw libghostty-vt and DT's Ghostty engine in one process, checks the
  screens match, and reports a conservative faster/overlap/slower verdict.
- Add 13 deterministic workloads modeled on ghostty-gen and vtebench,
  plus --frame-kb to include per-frame screen reads.
- Add scripts/Invoke-TerminalBenchmarks.ps1 (one process per workload,
  ratio-based -Baseline regression gate, optional upstream ghostty-bench)
  and docs/performance-benchmarks.md.

Builtin engine (now at or above raw libghostty-vt on 8 of 13 workloads):
- Store cells packed (16 bytes, no GC references) with combining text and
  hyperlinks in per-line side arrays; the public Cell is unchanged.
- Never write logically blank cells: recycling scrollback lines and
  erasing to end of line are O(1).
- Bulk paths for printable ASCII (AVX2 where available), well-formed
  UTF-8 runs, plain CSI sequences and C0 controls, each checked against
  the per-character path by differential tests.
- Precomputed width and grapheme-join tables for planes 0 and 1.

Ghostty engine:
- Project the render state lazily and only for rows Ghostty reports
  dirty, instead of re-projecting the whole screen after every feed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) merged commit f45b95b into master Oct 26, 2026
54 checks passed
@mamoreau-devolutions
Marc-André Moreau (mamoreau-devolutions) deleted the claude/dt-ghostty-perf-benchmarks-cf1a9f branch October 26, 2026 19:24
Marc-André Moreau (mamoreau-devolutions) added a commit that referenced this pull request Oct 27, 2026
## 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](https://claude.com/claude-code)

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

1 active deployment
publish-dry-run — 1bfda21e Deployed Oct 26, 2026 by mamoreau-devolutions via MSI packages #376
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