mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-21 21:17:40 +00:00
Reworks the terminal PAGE grid wire format and optimize both
encode/decode. Example improvements for 1MB of VT input w/ full
scrollback: ~30x smaller wire size, ~45x faster encoding and decoding.
> [!IMPORTANT]
>
> **Snapshot version 1 is still explicitly a work-in-progress format, so
this breaks wire compatibility**.
The original snapshot version I merged favored simplicity over
optimization. This was the format used a proof-of-concept in my own
projects, but I knew it wasn't what I wanted to ship. This PR looks at
the record formats and trades simplicity for performance, a fair trade
for a performance-sensitive binary format.
Overview of changes:
- **8-byte grid cells.** Cells are now one 64-bit word whose layout
deliberately coincides with the native cell. Previously, cells were 16
bytes each and in our 1MB corpus 97% of the data was `0`. Lol.
- **Blank trailing cells are not written.** Rows declare an encoded cell
count so trailing blank cells cost nothing.
- **Hardware CRC32C.** Added `src/crc32c.zig` that uses inline-asm on
aarch64/x86_64 to get hardware speeds for CRC32. Zig's stdlib is 0.56
GB/s, aarch64 hardware is 10 GB/s on my computer.
- **Variable-width cells.** Each row declares how many bytes transport
each cell word: 1, 2, 4, or 8 depending on the widest row cell.
## Format
Grid layout, per PAGE record:
```
old new
+--------------------------+ +--------------------------+
| row 0 | | row 0 |
| flags (1) | | flags + width (1) |
| cols * 16B cells with | | encoded cell count (2) |
| inline suffixes | | count * width cells |
+--------------------------+ +--------------------------+
| ... | | ... |
+--------------------------+ +--------------------------+
| row (rows - 1) | | row (rows - 1) |
+--------------------------+ +--------------------------+
| grapheme suffix section |
+--------------------------+
```
Every row previously carried exactly `cols` cells; now it carries cells
only through its last non-default cell, and the cells past the count are
implicitly zero. The row flag byte gains the encoded cell width in its
previously reserved bits:
```
bit 0 wrap bit 2-3 semantic prompt
bit 1 wrap continuation bit 4-5 encoded cell width (log2 bytes)
```
The cell itself, old fixed 16-byte header versus the new single word:
```
old (16 bytes + inline suffixes) new (one u64 word)
+--------+---------+--------+ bit 0 +------------------+
| kind 1 | width 1 | flags 1| | content kind 2b |
+--------+---------+--------+ bit 2 +------------------+
| zero 1 | style id 2 | | content 24b |
+--------+------------------+ bit 26 +------------------+
| hyperlink id 2 | | style ID 16b |
+---------------------------+ bit 42 +------------------+
| value 4 | | width kind 2b |
+---------------------------+ bit 44 +------------------+
| grapheme count 4 | | protected 1b |
+---------------------------+ bit 45 +------------------+
| grapheme cps 4 * count | | hyperlink 1b |
+---------------------------+ bit 46 +------------------+
| semantic 2b |
bit 48 +------------------+
| hyperlink ID 16b |
bit 64 +------------------+
```
The word's bit layout intentionally matches the native cell (with the
wire hyperlink ID in the native padding), so full-width rows are a
straight copy of page memory. The row's encoded width then transports
each word truncated, and decode is the matching zero-extension:
```
width | bytes | admitted cells
------+-------+------------------------------------------------
0 | 1 | codepoint <= U+00FF, nothing else set
1 | 2 | codepoint <= U+FFFF, nothing else set
2 | 4 | any content kind/codepoint, style IDs 1-63,
| | narrow, no flags, no hyperlink
3 | 8 | everything
```
Grapheme suffixes were inline after each cell, which forced per-cell
framing decisions; they are now one section after the rows, so a
grapheme-free page (the overwhelming case) pays 4 bytes total:
```
old: ... | cell | cp cp | cell | ... (inline, per cell)
new: +----------------+----------------------------------+
| entry count 4 | entries: row 2, col 2, count 2, |
| | count * codepoint 4 |
+----------------+----------------------------------+
```
## Performance
Setup: `ghostty-bench +terminal-snapshot`, 80x24 terminal with unlimited
scrollback fed 1 MB of VT input.
Per-commit improvements:
| change | wire size | encode | decode |
|-------------------------------|-----------|---------|----------|
| baseline (v1 before this PR) | 34.16 MB | 92.8 ms | 119.8 ms |
| 8-byte cells + blank elision | 7.66 MB | 18.2 ms | 28.0 ms |
| hardware CRC32C | 7.66 MB | 5.8 ms | 15.5 ms |
| gate page verification | 7.66 MB | 5.8 ms | 12.2 ms |
| staged PAGE payload decoding | 7.66 MB | 5.8 ms | 8.1 ms |
| variable-width cells | 1.03 MB | 2.0 ms | 2.7 ms |
Final result across various inputs:
| corpus | wire size | encode | decode |
|----------------------------|------------------------|----------------------|-----------------------|
| ascii lines 1-70 | 34.16 -> 1.03 MB (33x) | 92.8 -> 2.0 ms (46x) |
119.8 -> 2.7 ms (44x) |
| ascii full-width wrap | 16.01 -> 1.04 MB (15x) | 43.6 -> 1.3 ms (34x)
| 56.2 -> 1.8 ms (31x) |
| utf8 (wide/grapheme heavy) | 4.33 -> 1.89 MB (2.3x) | 12.4 -> 1.7 ms
(7x) | 19.0 -> 2.2 ms (9x) |
### Relationship with Compression
I expect that users of this will wrap everything in compression, so I
also benchmarked all my changes against a caller-owned zstd compressor
to ensure we're making the write tradeoffs. Less bytes means less time
in a compressor, even if a ton of 0s compresses really well.
My results: `zstd -1` over the `lines` snapshot drops from 12.7 ms to
0.8 ms, and the compressed artifact shrinks from 1.35 MB to 0.86 MB. So
the end state is a win-win.