Files
ghostty/src/benchmark/TerminalStream.zig
Mitchell Hashimoto 68beeeb3f6 terminal: add stream continuation tracking for replay
This adds opt-in continuation tracking to `terminal.Stream` that allows
any caller to call `writeContinuation` in order to get the minimum bytes
necessary from a grounded parser state to the identical state.

This enables reliable stream restart across serialization states, which
could be used for local restart, networked terminals, etc. For me, this
is used for multiplexers. :)

## Implementation

The implementation of this was really carefully done to avoid any
negative performance impact particularly when continuation tracking is
_off_.

The way this work is simple:

  1. ESC is the only char that leaves the ground state and most
     ESC sequences are short. So if we're in a non-ground state, we
     do a backwards vectorized search to find the last `ESC` in the
     input slice. If one doesn't exist, we assume we found it previously
     and store the whole slice (rare, since ESC sequences are usually
     short like I said).

  2. If we're in the ground state that means we only have a potential
     incomplete UTF-8 codepoint, so we find the lead UTF-8 byte.

  3. When writing, we normalize the suffix to drop things like BEL
     commands that would've already been handled to avoid
     double-calling.

## Performance

Via `ghostty-bench +terminal-stream`

  Corpus                     main      tracking off  tracking on
  plain ASCII (256 MiB)      175.4ms   175.8ms       175.5ms
  UTF-8 (32 MiB)             268.4ms   270.0ms       269.9ms
  5% invalid UTF-8 (32 MiB)  316.4ms   317.8ms       320.2ms
  CSI-heavy (32 MiB)         145.0ms   146.3ms       145.9ms
  OSC (32 MiB)               1621.0ms  1627.5ms      1638.3ms
  Kitty APC (128 MiB)        95.5ms    96.9ms        96.3ms
  mixed traffic (32 MiB)     172.4ms   172.0ms       172.3ms
  giant APC (128 MiB)        38.3ms    38.3ms        40.8ms
2026-08-01 13:39:21 -07:00

168 lines
5.3 KiB
Zig

//! This benchmark tests the performance of the terminal stream
//! handler from input to terminal state update. This is useful to
//! test general throughput of VT parsing and handling.
//!
//! This uses the full readonly terminal stream handler
//! (terminal.TerminalStream) so every escape sequence updates real
//! terminal state (styles, cursor movement, erases, modes, etc.).
//! This closely mirrors the work done by the real IO thread.
//!
//! For more isolated measurements see the terminal-parser and
//! osc-parser benchmarks.
const TerminalStream = @This();
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const terminalpkg = @import("../terminal/main.zig");
const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Terminal = terminalpkg.Terminal;
const Stream = terminalpkg.TerminalStream;
const global = @import("../global.zig");
const log = std.log.scoped(.@"terminal-stream-bench");
opts: Options,
terminal: Terminal,
stream: Stream,
/// The file, opened in the setup function.
data_f: ?std.Io.File = null,
pub const Options = struct {
/// The size of the terminal. This affects benchmarking when
/// dealing with soft line wrapping and the memory impact
/// of page sizes.
@"terminal-rows": u16 = 80,
@"terminal-cols": u16 = 120,
/// Enable opt-in continuation tracking on the stream.
@"continuation-enabled": bool = false,
/// Maximum continuation suffix retained when tracking is enabled.
@"continuation-max-bytes": usize = 1024 * 1024,
/// Pre-generated data from ghostty-gen. If this is "-" then
/// we will read stdin. If this is unset, then we will
/// do nothing (benchmark is a noop). It'd be more unixy to
/// use stdin by default but I find that a hanging CLI command
/// with no interaction is a bit annoying.
data: ?[]const u8 = null,
};
/// Create a new terminal stream handler for the given arguments.
pub fn create(
alloc: Allocator,
opts: Options,
) !*TerminalStream {
const ptr = try alloc.create(TerminalStream);
errdefer alloc.destroy(ptr);
ptr.* = .{
.opts = opts,
.terminal = try .init(global.io(), alloc, .{
.rows = opts.@"terminal-rows",
.cols = opts.@"terminal-cols",
}),
.stream = undefined,
};
errdefer ptr.terminal.deinit(alloc);
ptr.stream = .init(.{
.allocator = alloc,
.handler = .init(&ptr.terminal),
.continuation_max_bytes = if (opts.@"continuation-enabled")
opts.@"continuation-max-bytes"
else
null,
});
return ptr;
}
pub fn destroy(self: *TerminalStream, alloc: Allocator) void {
self.stream.deinit();
self.terminal.deinit(alloc);
alloc.destroy(self);
}
pub fn benchmark(self: *TerminalStream) Benchmark {
return .init(self, .{
.stepFn = step,
.setupFn = setup,
.teardownFn = teardown,
});
}
fn setup(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
// Always reset our terminal state
self.terminal.fullReset();
// Open our data file to prepare for reading. We can do more validation
// here eventually.
assert(self.data_f == null);
self.data_f = options.dataFile(self.opts.data) catch |err| {
log.warn("error opening data file err={}", .{err});
return error.BenchmarkFailed;
};
}
fn teardown(ptr: *anyopaque) void {
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
if (self.data_f) |f| {
f.close(global.io());
self.data_f = null;
}
}
fn step(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalStream = @ptrCast(@alignCast(ptr));
// Get our buffered reader so we're not predominantly
// waiting on file IO. It'd be better to move this fully into
// memory. If we're IO bound though that should show up on
// the benchmark results and... I know writing this that we
// aren't currently IO bound.
const f = self.data_f orelse return;
// Unbuffered: readSliceShort below reads directly into `buf`,
// avoiding a per-chunk memcpy through an intermediate reader
// buffer that would pollute the measurement.
var f_reader = f.reader(global.io(), &.{});
const r = &f_reader.interface;
// This buffer size matches the read buffer size used by the
// real IO thread (see termio Exec.zig buffer_capacity) so that
// the benchmark exercises the stream with realistic chunk sizes.
var buf: [64 * 1024]u8 = undefined;
while (true) {
const n = r.readSliceShort(&buf) catch {
log.warn("error reading data file err={?}", .{f_reader.err});
return error.BenchmarkFailed;
};
if (n == 0) break; // EOF reached
self.stream.nextSlice(buf[0..n]);
}
}
test TerminalStream {
const testing = std.testing;
const alloc = testing.allocator;
const impl: *TerminalStream = try .create(alloc, .{});
defer impl.destroy(alloc);
const bench = impl.benchmark();
_ = try bench.run(.once);
const tracked: *TerminalStream = try .create(alloc, .{
.@"continuation-enabled" = true,
});
defer tracked.destroy(alloc);
const tracked_bench = tracked.benchmark();
_ = try tracked_bench.run(.once);
}