Files
ghostty/src/terminal
Mitchell Hashimoto d351d9ce07 terminal/snapshot: more efficient binary form, optimize wire size + encode/decode speeds (#13566)
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.
2026-08-02 15:01:00 -07:00
..
2026-07-21 12:35:05 -07:00
2026-07-21 12:35:05 -07:00
2026-07-26 20:28:58 -07:00
2026-07-21 12:35:05 -07:00
2026-07-21 12:35:05 -07:00
2026-07-26 15:04:16 -07:00
2026-07-21 17:19:16 -07:00
2026-07-28 11:04:13 -05:00
2026-07-21 12:35:05 -07:00
2026-07-27 13:19:37 -04:00
2025-11-27 13:37:53 -08:00
2026-05-23 14:37:59 -07:00
2026-07-21 12:35:05 -07:00