//! This benchmark tests the performance of the Screen.clone //! function. This is useful because it is one of the primary lock //! holders that impact IO performance when the renderer is active. //! We do this very frequently. const ScreenClone = @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 global = @import("../global.zig"); const log = std.log.scoped(.@"terminal-stream-bench"); opts: Options, terminal: Terminal, pub const Options = struct { /// The type of codepoint width calculation to use. mode: Mode = .clone, /// Multiplier on the number of iterations each step runs. This is /// useful to make a benchmark run long enough for profiling. loops: u32 = 1, /// 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, /// The data to read as a filepath. 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. /// /// This will be used to initialize the terminal screen state before /// cloning. This data can switch to alt screen if it wants. The time /// to read this is not part of the benchmark. data: ?[]const u8 = null, }; pub const Mode = enum { /// The baseline mode copies the screen by value. noop, /// Full clone clone, /// RenderState rather than a screen clone. render, /// Like render, but only the portion of the render state update /// that requires holding a terminal lock (beginUpdate). The /// deferred work (endUpdate) is excluded since it happens outside /// of any locks. @"render-locked", /// RenderState update with no changes to the terminal. This is /// the common case for a renderer that is redrawing frames (e.g. /// cursor blink, mouse movement) without terminal changes. @"render-clean", /// RenderState update where a single row is dirty. This models the /// common case of a shell prompt or TUI updating a small portion /// of the screen between frames. @"render-partial", }; pub fn create( alloc: Allocator, opts: Options, ) !*ScreenClone { const ptr = try alloc.create(ScreenClone); errdefer alloc.destroy(ptr); ptr.* = .{ .opts = opts, .terminal = try .init(global.io(), alloc, .{ .rows = opts.@"terminal-rows", .cols = opts.@"terminal-cols", }), }; return ptr; } pub fn destroy(self: *ScreenClone, alloc: Allocator) void { self.terminal.deinit(alloc); alloc.destroy(self); } pub fn benchmark(self: *ScreenClone) Benchmark { return .init(self, .{ .stepFn = switch (self.opts.mode) { .noop => stepNoop, .clone => stepClone, .render => stepRender, .@"render-locked" => stepRenderLocked, .@"render-clean" => stepRenderClean, .@"render-partial" => stepRenderPartial, }, .setupFn = setup, .teardownFn = teardown, }); } fn setup(ptr: *anyopaque) Benchmark.Error!void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); // Always reset our terminal state self.terminal.fullReset(); // Force a style on every single row, which var s = self.terminal.vtStream(); defer s.deinit(); s.nextSlice("\x1b[48;2;20;40;60m"); for (0..self.terminal.rows - 1) |_| s.nextSlice("hello\r\n"); s.nextSlice("hello"); // Setup our terminal state const data_f: std.Io.File = (options.dataFile( self.opts.data, ) catch |err| { log.warn("error opening data file err={}", .{err}); return error.BenchmarkFailed; }) orelse return; var stream = self.terminal.vtStream(); defer stream.deinit(); var read_buf: [4096]u8 align(std.atomic.cache_line) = undefined; var f_reader = data_f.reader(global.io(), &read_buf); const r = &f_reader.interface; var buf: [4096]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 stream.nextSlice(buf[0..n]); } } fn teardown(ptr: *anyopaque) void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); _ = self; } fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. for (0..1000) |_| { const s: terminalpkg.Screen = self.terminal.screens.active.*; std.mem.doNotOptimizeAway(s); } } fn stepClone(ptr: *anyopaque) Benchmark.Error!void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. for (0..1000) |_| { const s: *terminalpkg.Screen = self.terminal.screens.active; const copy = s.clone( s.io, s.alloc, .{ .viewport = .{} }, null, ) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; std.mem.doNotOptimizeAway(copy); // Note: we purposely do not free memory because we don't want // to benchmark that. We'll free when the benchmark exits. } } fn stepRender(ptr: *anyopaque) Benchmark.Error!void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); // We do this once out of the loop because a significant slowdown // on the first run is allocation. After that first run, even with // a full rebuild, it is much faster. Let's ignore that first run // slowdown. const alloc = self.terminal.screens.active.alloc; var state: terminalpkg.RenderState = .empty; state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. for (0..50_000 * @as(u64, self.opts.loops)) |_| { // Forces a full rebuild because it thinks our screen changed state.screen = .alternate; state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; std.mem.doNotOptimizeAway(&state); // Note: we purposely do not free memory because we don't want // to benchmark that. We'll free when the benchmark exits. } } fn stepRenderLocked(ptr: *anyopaque) Benchmark.Error!void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); // We do this once out of the loop because a significant slowdown // on the first run is allocation. After that first run, even with // a full rebuild, it is much faster. Let's ignore that first run // slowdown. const alloc = self.terminal.screens.active.alloc; var state: terminalpkg.RenderState = .empty; state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. for (0..50_000 * @as(u64, self.opts.loops)) |_| { // Forces a full rebuild because it thinks our screen changed state.screen = .alternate; state.beginUpdate(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; std.mem.doNotOptimizeAway(&state); // Note: we purposely do not free memory because we don't want // to benchmark that. We'll free when the benchmark exits. } } fn stepRenderClean(ptr: *anyopaque) Benchmark.Error!void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); // Initial update so that subsequent updates are clean (nothing // dirty, no rebuilds). const alloc = self.terminal.screens.active.alloc; var state: terminalpkg.RenderState = .empty; state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. for (0..3_000_000 * @as(u64, self.opts.loops)) |_| { state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; std.mem.doNotOptimizeAway(&state); } } fn stepRenderPartial(ptr: *anyopaque) Benchmark.Error!void { const self: *ScreenClone = @ptrCast(@alignCast(ptr)); // Initial update so that subsequent updates are incremental. const alloc = self.terminal.screens.active.alloc; var state: terminalpkg.RenderState = .empty; state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; // Grab a pin roughly in the middle of the active area that we // dirty on every iteration to simulate a small screen update. const pages = &self.terminal.screens.active.pages; const pin = pages.pin(.{ .active = .{ .x = 0, .y = self.terminal.rows / 2, } }).?; // We loop because its so fast that a single benchmark run doesn't // properly capture our speeds. for (0..2_000_000 * @as(u64, self.opts.loops)) |_| { // Mark a single row dirty. `update` clears this so each // iteration rebuilds exactly one row. pin.markDirty(); state.update(alloc, &self.terminal) catch |err| { log.warn("error cloning screen err={}", .{err}); return error.BenchmarkFailed; }; std.mem.doNotOptimizeAway(&state); } }