summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-07-31 07:32:15 -0300
committerGabriel Schneider <[email protected]>2026-08-01 15:02:08 -0300
commit560ae0a63f7b5d354bad3ba13dd8ab26b5f0a870 (patch)
treedb2762cc2bc7bdeaae187e15cfc68a33ef2d1c48
parent5bf8d6dd077517270377e5d8551108ecf252374f (diff)
downloadpardes-560ae0a63f7b5d354bad3ba13dd8ab26b5f0a870.tar.gz
pardes-560ae0a63f7b5d354bad3ba13dd8ab26b5f0a870.zip
motion, scrolling and redraw are flat in file size now
zig build perf drives the core directly — event, effects, one frame, no pty — over four generated fixtures: 1k lines, 50k, 300k, and 400 lines of 8000 columns, because a file that is long and a file that is wide fail differently. Every sample seeks somewhere else in the file first, since measuring at line 3 of a 300k-line file hides exactly the bug. perf record said half the run was scanning for newlines from byte 0. So File carries a line index, built on demand and invalidated in exactly ONE place — setContent, the funnel every content swap already goes through. That killed the scrollbar's per-frame line count (12.6% of the whole run by itself), scrollBy, ensureCursorVisible, lastNavRow, the syntax window bounds and two O(scroll) walks. normalKey computed max_line as a const at the top: two full passes over the buffer on every keystroke of every kind, for three g/G branches. It is lazy now. The modal primitives each walked the text twice for the same line. And the visible window was re-parsed on every scrolled row — a third of a megabyte per keypress on the wide fixture. The highlighted range is remembered, a scroll inside it is free, and only a re-parse that FOLLOWS a scroll takes slack: doing it unconditionally made typing 2.1x slower, since every character paid for a band it could never amortise. One j on a 19 MB file: 37.8ms -> 266us. Render: 4.1ms -> 77us. Open costs 1.25x more for the one extra pass, which buys 54x on every frame after, and 8 bytes per line of memory. Left standing, measured and named: edit-char is 14ms on 19MB because content is immutable and every keystroke copies the buffer. A third of that is the index rebuild, which could be a shift if setContent knew the edit offset; the rest wants a rope. bodyText's double copy and Surface.print's per-cell decode never rose above 2% of the profile afterwards, so they were left alone. No golden moved.
-rw-r--r--build.zig20
-rw-r--r--next-steps.txt2
-rw-r--r--src/file_pane.zig118
-rw-r--r--src/modal.zig21
-rw-r--r--src/pardes.zig148
-rw-r--r--test/perf.zig466
6 files changed, 721 insertions, 54 deletions
diff --git a/build.zig b/build.zig
index d0730239..59eb6e87 100644
--- a/build.zig
+++ b/build.zig
@@ -670,6 +670,26 @@ pub fn build(b: *std.Build) void {
run_lspbench.setCwd(b.path("."));
b.step("lspbench", "language-backend latency + feature matrix (-- [--json] [repo-root])").dependOn(&run_lspbench.step);
+ // the editing scoreboard: one gesture, one file size, one number.
+ // ReleaseFast for the same reason lspbench is — a Debug build measures
+ // safety checks, and the question here is what the algorithm costs.
+ // Same core module again, so the `render` this times is the editor's.
+ const perf = b.addExecutable(.{
+ .name = "pardes-perf",
+ .root_module = b.createModule(.{
+ .target = target,
+ .optimize = .ReleaseFast,
+ .root_source_file = b.path("test/perf.zig"),
+ .link_libc = true,
+ }),
+ });
+ perf.root_module.addImport("pardes", hx_core_mod);
+ b.installArtifact(perf);
+ const run_perf = b.addRunArtifact(perf);
+ if (b.args) |args| run_perf.addArgs(args);
+ run_perf.setCwd(b.path("."));
+ b.step("perf", "large-file / long-line latency table (-- [--json] [--reps N] [--base old.json])").dependOn(&run_perf.step);
+
// modal.zig is pure std — its inline unit tests run here
const unit = b.addTest(.{ .root_module = b.createModule(.{
.target = target,
diff --git a/next-steps.txt b/next-steps.txt
index 7599ebdd..0dd68823 100644
--- a/next-steps.txt
+++ b/next-steps.txt
@@ -7,7 +7,7 @@
+ Add a new builtin to list the themes and create an output window to select those themes; each theme now must live on its own .zig file, when moving with n/N on out ThemeSel output window it will select that theme so it's a interactive way to select the themes. We're being clever now with how these output windows will work, this feature depends on the new semantics for output windows but basically now the ThemeSel output panes will set the Command flag that will basically tell that when doing n/N movement on the output pane instead of Look-ing it will Execute the line, so the ThemeSel output pane will be like: (Theme acme, Theme a, Theme b) seperated by lines so the n/N will automatically consider selecting the Theme + argument (this is not output pane specific, it should be generalized to work on every pane kind), and will Exec it. Add new themes that will be automatically exported from helix and zed, a new binary will execute that will generate .zig files, this binary will be added as a execute step on the build.zig so when building it will regenarate those .zig that will be then included on the runtime as themes.
+ Use zig's 0.16 new threaded io and the std functions that the build system uses to listen for file changes, it needs to use something from the os so it's not a busy wait. It will only track files on open panes. when a file is updated, doing Undo will return to the state that it was before so if a file automatically updates the user won't lose the unsaved state as it was before and the editor won't need to handle with merging the unsaved changes and the new update.
+ There's some bug when rendering from a ssh session - the screen gets dirty with old chars when scrolling.
-- There's some bad performance when dealing with specially large files or long lines; write a perf harness for this and optimize the performance.
++ There's some bad performance when dealing with specially large files or long lines; write a perf harness for this and optimize the performance.
+ The Look and Exec should be regular semantic entities, i.e. their only special feature should be the keys/shortcuts they're assigned so we could execute Look main.zig and it would call the regular look on it, but also we could Look some string like @`ls -la` (you can change the syntax, but this also depends on that syntax change so it's centralized and easy to change later). This basically will allow you to make some big refactorings and cut a lot of code (hopefully) and also allow some funny things like @`Look .` (basically allowing nested things like this for no good reason other than, it's elegant and it's fun!)
+ We'll make the focus history a meaningful and strong semantic entity: instead of being a stack of Panes, it'll be a stack of locations. This will allow us to implement vim's ctrl-i and ctrl-o that will just move the current focus up or down on that stack. there'll be also a new builtin that will toggle the focus of the latest panes, basically moving the current focus by changing the current pointer to the location on the stack. The Jumplist (new builtin) will be an Output pane with each line being a location that can be Looked, n/N should work.
+ The main pardes.zig file should hold the logic that applies to all kinds of panes and backends. Maybe right now the pane specifiticies are too intermingled. Maintain the panes logics into their own files, right now tty.zig exists but I don't think output panes live by themselves nor file panes on their own files. Also, each builtin command should be implemented as a struct, the current logic on switching on the enum should use some comptime logic to build the tagged union/enum from those structs now. Also I think it's time to create a few directories on @src now since some files are specific to some backends and others are part of the core, it should be clear just by looking at the directory structure (don't add more dir layers, just new dirs under src).
diff --git a/src/file_pane.zig b/src/file_pane.zig
index 739d7b54..b164a7b3 100644
--- a/src/file_pane.zig
+++ b/src/file_pane.zig
@@ -15,13 +15,63 @@ const look = @import("look.zig");
const syntax = @import("syntax.zig");
const tracy = @import("tracy.zig");
-const SYNTAX_CONTEXT_BEFORE_ROWS: usize = 0;
const SYNTAX_CONTEXT_AFTER_ROWS: usize = 2;
pub fn lineCount(content: []const u8) usize {
return std.mem.count(u8, content, "\n") + 1;
}
+/// THE LINE INDEX, built on demand: `line_starts[i]` is the byte offset where
+/// line i begins and its length is the line count. Without it, every question
+/// about lines is a scan from byte 0, and a file pane asks several of them per
+/// keystroke — the scrollbar's total, the scroll clamp, the syntax window's
+/// bounds, the body's first visible line. On a 300k-line file that was ~35% of
+/// the whole frame, and it is what made a single `j` cost 25ms.
+///
+/// INVALIDATION — the part that rots if nobody says it out loud. The index is
+/// dropped in EXACTLY ONE PLACE: setContent, immediately below, which is the
+/// funnel every content swap in the editor already goes through (typing, undo,
+/// redo, a save's normalisation, an output buffer refilling itself). A File
+/// built by a struct literal starts with an empty index, and empty reads as
+/// "not built yet" — a real index always has at least one entry, because a
+/// file always has at least one line. So there is one and only one way to make
+/// this wrong: assign `f.content` without going through setContent. Don't.
+///
+/// Fails only when the index could not be allocated. nlines and lineStart
+/// swallow that and scan the old way, so OOM there is slow rather than wrong;
+/// callers that need the whole table say `try` and drop the keystroke, which
+/// is what they already did when their own arena ran out.
+pub fn lineIndex(gpa: std.mem.Allocator, f: *File) ![]const usize {
+ if (f.line_starts.len > 0) return f.line_starts;
+ // exact alloc, not an ArrayList: deinitPane frees `line_starts` itself, and
+ // an ArrayList's items slice is shorter than its allocation whenever the
+ // growth strategy rounded up — freeing that is a wrong-size free.
+ const starts = try gpa.alloc(usize, lineCount(f.content));
+ starts[0] = 0;
+ var i: usize = 1;
+ var off: usize = 0;
+ while (std.mem.indexOfScalarPos(u8, f.content, off, '\n')) |nl| {
+ off = nl + 1;
+ starts[i] = off;
+ i += 1;
+ }
+ f.line_starts = starts;
+ return starts;
+}
+
+/// line count, O(1) once the index is warm
+pub fn nlines(gpa: std.mem.Allocator, f: *File) usize {
+ const idx = lineIndex(gpa, f) catch return lineCount(f.content);
+ return idx.len;
+}
+
+/// byte offset of line `row`, or content.len past the end — modal
+/// .lineStartOffset's contract exactly, without its walk
+pub fn lineStart(gpa: std.mem.Allocator, f: *File, row: usize) usize {
+ const idx = lineIndex(gpa, f) catch return modal.lineStartOffset(f.content, row);
+ return if (row >= idx.len) f.content.len else idx[row];
+}
+
pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
const content = try look.readFile(p.gpa, path);
errdefer p.gpa.free(content);
@@ -39,9 +89,19 @@ pub fn open(p: *Pardes, id: usize, path: []const u8, line: usize) !*Pane {
return pane;
}
+/// The ONE content swap. Everything that edits a file pane lands here, which
+/// is what lets the line index above have a single invalidation point.
pub fn setContent(p: *Pardes, f: *File, new: []u8) void {
p.gpa.free(f.content);
f.content = new;
+ if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
+ f.line_starts = &.{};
+ // the highlights go too, and not just because they are stale: their byte
+ // range is what refreshHighlights tests a scroll against, and a range
+ // measured on the OLD content would let it skip a re-parse it needs
+ if (f.highlights.len > 0) p.gpa.free(f.highlights);
+ f.highlights = &.{};
+ f.highlight_start = 0;
f.syntax_dirty = true;
}
@@ -49,8 +109,8 @@ pub fn setContent(p: *Pardes, f: *File, new: []u8) void {
/// selections in its history transactions), clamped: the content it was taken
/// against may be shorter than the one it is being restored onto.
pub fn restoreSnap(pane: *Pane, f: *File, snap: FileSnap) void {
- const n = modal.lineCount(f.content);
- const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), if (n == 0) 0 else n - 1);
+ const n = nlines(pane.gpa, f);
+ const row: usize = @min(@as(usize, @intCast(@max(0, snap.cur_row))), n - 1);
const llen = modal.lineSlice(f.content, row).len;
pane.cur_row = @intCast(row);
pane.cur_col = @intCast(@min(@as(usize, @intCast(@max(0, snap.cur_col))), llen));
@@ -78,10 +138,35 @@ pub fn refreshHighlights(p: *Pardes) void {
f.syntax_dirty = false;
continue;
}
- const start_row = f.scroll -| SYNTAX_CONTEXT_BEFORE_ROWS;
- const start = modal.lineStartOffset(f.content, start_row);
- const end_row = start_row + SYNTAX_CONTEXT_BEFORE_ROWS + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS;
- const end = @max(start, modal.lineStartOffset(f.content, end_row));
+ // What the screen needs coloured right now. If the last parse still
+ // covers it, this scroll is free — and that is the whole point of the
+ // slack below. Highlights only ever survive while the CONTENT does:
+ // setContent throws them away, so these byte offsets cannot be stale.
+ const need_start = lineStart(p.gpa, f, f.scroll);
+ const need_end = @max(need_start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS));
+ if (f.highlights.len > 0 and need_start >= f.highlight_start and
+ need_end <= f.highlight_start + f.highlights.len)
+ {
+ f.syntax_dirty = false;
+ continue;
+ }
+ // How much MORE than the screen to parse. An edit or a fresh open has
+ // no previous parse to widen (setContent throws the highlights away),
+ // and slack would be pure loss there: every keystroke of typing pays
+ // this parse and none of it is amortised over anything. A SCROLL that
+ // outran the covered range is the opposite case — take a screenful
+ // above and below and the next ~pane.rows rows cost nothing at all.
+ // Scrolling used to re-parse the visible window on every single row,
+ // which on a file with 8000-column lines is a third of a megabyte per
+ // keypress. Three screens once beats one screen forty times.
+ //
+ // The slack also means those lines are parsed with real context above
+ // them, so a construct that opens off-screen now colours correctly —
+ // a fidelity gain, and one that cannot reach a file shown from the top
+ // (scroll 0 clamps the window to exactly what it always was).
+ const slack: usize = if (f.highlights.len == 0) 0 else pane.rows;
+ const start = lineStart(p.gpa, f, f.scroll -| slack);
+ const end = @max(start, lineStart(p.gpa, f, f.scroll + pane.rows + SYNTAX_CONTEXT_AFTER_ROWS + slack));
const new_highlights = syntax.highlightFileRange(p.gpa, f.path, f.content, start, end) catch {
f.syntax_dirty = false;
continue;
@@ -97,11 +182,13 @@ pub fn refreshHighlights(p: *Pardes) void {
/// offset, each behind its right-aligned line number, then cut by hscroll.
pub fn bodyText(arena: std.mem.Allocator, pane: *Pane, f: *File) ![]const u8 {
var out: std.ArrayList(u8) = .empty;
- var flines = std.mem.splitScalar(u8, f.content, '\n');
- var skipped: usize = 0;
- while (skipped < f.scroll) : (skipped += 1) {
- if (flines.next() == null) break;
- }
+ // start ON the first visible line instead of walking the file to it: this
+ // walk was O(f.scroll) and recolorSyntax below ran the identical one again
+ var flines = std.mem.splitScalar(u8, f.content[lineStart(pane.gpa, f, f.scroll)..], '\n');
+ // scrolled past EOF (an edit shortened the file under a stale scroll): the
+ // old walk left the iterator dry, so drop the one empty line a slice split
+ // still yields, or the body grows a phantom numbered row
+ if (f.scroll >= nlines(pane.gpa, f)) _ = flines.next();
var i: usize = 0;
while (i < pane.rows) : (i += 1) {
if (i > 0) try out.append(arena, '\n');
@@ -174,11 +261,8 @@ pub fn recolorSyntax(p: *Pardes, pane: *Pane, f: *File, r: pardes.Rect, tx: u16,
const s = &p.surface;
const tz_recolor = tracy.zone(@src(), "synRecolor");
defer tz_recolor.end();
- var flines = std.mem.splitScalar(u8, f.content, '\n');
- var skip: usize = 0;
- while (skip < f.scroll) : (skip += 1) {
- if (flines.next() == null) break;
- }
+ // indexed start, same as bodyText — an empty tail simply paints nothing
+ var flines = std.mem.splitScalar(u8, f.content[lineStart(p.gpa, f, f.scroll)..], '\n');
const hs: usize = @intCast(@max(0, pane.hscroll));
var vr: u16 = 0;
while (vr < body_h) : (vr += 1) {
diff --git a/src/modal.zig b/src/modal.zig
index 742dbc6f..cdc49142 100644
--- a/src/modal.zig
+++ b/src/modal.zig
@@ -438,7 +438,7 @@ pub fn lineStartOffset(content: []const u8, row: usize) usize {
var off: usize = 0;
var r: usize = 0;
while (r < row) : (r += 1) {
- const nl = std.mem.indexOfPos(u8, content, off, "\n") orelse return content.len;
+ const nl = std.mem.indexOfScalarPos(u8, content, off, '\n') orelse return content.len;
off = nl + 1;
}
return off;
@@ -775,15 +775,24 @@ pub fn hxLineEndIdx(text: []const u8, line: usize) usize {
/// gap offset -> (row, col) cell
pub fn hxPos(text: []const u8, off: usize) Cursor {
- const row = hxLineOf(text, off);
- return .{ .row = row, .col = @min(off, text.len) - lineStartOffset(text, row) };
+ const o = @min(off, text.len);
+ // The line start is the byte after the last '\n' BEFORE off, which is the
+ // same number lineStartOffset(text, row) walks the whole prefix to reach —
+ // one backward scan of a single line instead of a second pass over
+ // everything above the cursor. On a multi-MB buffer that second pass was
+ // most of what a keystroke cost.
+ const s = if (std.mem.lastIndexOfScalar(u8, text[0..o], '\n')) |nl| nl + 1 else 0;
+ return .{ .row = hxLineOf(text, off), .col = o - s };
}
/// (row, col) -> clamped gap offset; col == line length lands ON the '\n'
pub fn hxOff(text: []const u8, c: Cursor) usize {
const row = @min(c.row, hxLineCount(text) - 1);
const s = lineStartOffset(text, row);
- return @min(s + c.col, hxLineEndIdx(text, row));
+ // hxLineEndIdx(text, row) inlined: it starts by walking to `row` again,
+ // and we are already standing there
+ const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
+ return @min(s + c.col, e);
}
pub const WordTarget = enum {
@@ -979,7 +988,9 @@ pub fn hxVertTarget(text: []const u8, pos: usize, down: bool, count: usize, goal
const line = hxLineOf(text, pos);
const nline = if (down) @min(line + @max(1, count), nlines - 1) else line -| @max(1, count);
const s = lineStartOffset(text, nline);
- return @min(s + goal_col, hxLineEndIdx(text, nline));
+ // hxLineEndIdx(text, nline) without its second walk to nline (see hxOff)
+ const e = std.mem.indexOfScalarPos(u8, text, s, '\n') orelse text.len;
+ return @min(s + goal_col, e);
}
/// f/F/t/T target cell. helix find_char: the exclusive (till) search starts
diff --git a/src/pardes.zig b/src/pardes.zig
index 947c1c96..ced4c2f9 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -728,6 +728,12 @@ pub const File = struct {
/// resolve like anywhere else.
output: ?output_pane.Output = null,
scroll: usize = 0,
+ /// THE LINE INDEX: `line_starts[i]` is the byte offset where line i begins,
+ /// and `line_starts.len` IS the line count. Empty means "not built yet" —
+ /// a real index always holds at least one entry, because a file always has
+ /// at least one line. Built and read only through file_pane.lineIndex /
+ /// nlines / lineStart; see the invalidation contract on lineIndex.
+ line_starts: []usize = &.{},
/// one syntax.Syn byte per content byte in the highlighted window
highlights: []u8 = &.{},
highlight_start: usize = 0,
@@ -761,6 +767,12 @@ pub const Image = struct {
pub const Pane = struct {
vt: ghostty_vt.Terminal,
stream: ghostty_vt.TerminalStream,
+ /// The same allocator Pardes holds. A pane already owns heap (its content,
+ /// its emulator, its undo stacks) and Pardes frees all of it; this is here
+ /// so the pane methods that need the file's LINE INDEX — scrollBy,
+ /// ensureCursorVisible, lastNavRow — can build it. The alternative was
+ /// threading an allocator through ensureCursorVisible's 33 call sites.
+ gpa: std.mem.Allocator,
/// WHICH pane this is, for anything that outlives the pane: slots are
/// REUSED (freeSlot hands back the lowest free one), so a remembered id
/// alone can silently come to mean an unrelated pane. Handed out by
@@ -920,7 +932,7 @@ pub const Pane = struct {
fn scrollBy(pane: *Pane, delta: i32) void {
if (pane.file) |*f| {
- const max: i64 = @intCast(modal.lineCount(f.content) -| 1);
+ const max: i64 = @intCast(file_pane.nlines(pane.gpa, f) -| 1);
const n = std.math.clamp(@as(i64, @intCast(f.scroll)) + delta, 0, max);
const next: usize = @intCast(n);
if (next != f.scroll) {
@@ -945,7 +957,7 @@ pub const Pane = struct {
// don't scroll a file past EOF-at-bottom-row (vim's bottom clamp);
// terminals overshoot harmlessly — the vt clamps at the live bottom
var to = pane.cur_row + margin;
- if (pane.file) |f| to = @min(to, @as(i32, @intCast(modal.lineCount(f.content) -| 1)));
+ if (pane.file) |*f| to = @min(to, @as(i32, @intCast(file_pane.nlines(pane.gpa, f) -| 1)));
pane.scrollBy(@max(0, to - last));
}
// the horizontal mirror, files only: keep scroll_off columns of
@@ -963,7 +975,7 @@ pub const Pane = struct {
/// the last navigable row: file -> last content line; terminal -> viewport bottom
fn lastNavRow(pane: *Pane) i32 {
- if (pane.file) |f| return @intCast(modal.lineCount(f.content) -| 1);
+ if (pane.file) |*f| return @intCast(file_pane.nlines(pane.gpa, f) -| 1);
return pane.scroll() + @as(i32, pane.rows) - 1;
}
@@ -1243,6 +1255,7 @@ pub const Pardes = struct {
};
p.gpa.free(f.path);
p.gpa.free(f.content);
+ if (f.line_starts.len > 0) p.gpa.free(f.line_starts);
if (f.highlights.len > 0) p.gpa.free(f.highlights);
for (f.undo.items) |s| p.gpa.free(s.content);
f.undo.deinit(p.gpa);
@@ -1279,6 +1292,7 @@ pub const Pardes = struct {
.max_scrollback = 16 * 1024 * 1024,
}),
.stream = undefined,
+ .gpa = p.gpa,
.cols = p.screen_w,
.rows = p.screen_h,
};
@@ -1302,6 +1316,7 @@ pub const Pardes = struct {
pane.* = .{
.vt = try ghostty_vt.Terminal.init(p.gpa, .{ .cols = 1, .rows = 1 }),
.stream = undefined,
+ .gpa = p.gpa,
.cols = p.screen_w,
.rows = p.screen_h,
};
@@ -2276,11 +2291,18 @@ pub const Pardes = struct {
/// this surface as a tty case's final text.)
pub fn paneCursorLines(p: *Pardes, pane: *Pane) !PaneLines {
const arena = p.scratch.allocator();
- if (pane.file) |f| {
- var ls: std.ArrayList([]const u8) = .empty;
- var it = std.mem.splitScalar(u8, f.content, '\n');
- while (it.next()) |ln| try ls.append(arena, ln);
- return .{ .lines = ls.items, .row0 = 0 };
+ if (pane.file) |*f| {
+ // straight off the line index: exact size (no ArrayList regrowth
+ // copying a 300k-entry table forward) and no second scan for the
+ // newlines the index already found. Identical to splitting the
+ // content — a file's line count is its newline count plus one.
+ const idx = try file_pane.lineIndex(pane.gpa, f);
+ const ls = try arena.alloc([]const u8, idx.len);
+ for (idx, 0..) |s, i| {
+ const e = if (i + 1 < idx.len) idx[i + 1] - 1 else f.content.len;
+ ls[i] = f.content[s..e];
+ }
+ return .{ .lines = ls, .row0 = 0 };
}
const rows = try term_pane.shellRows(p, pane);
// the buffer may sit below the dump's last row (the grid's trailing
@@ -2333,11 +2355,58 @@ pub const Pardes = struct {
return buf.items;
}
+ /// modal.hxOff and modal.hxPos, answered from the file's LINE INDEX when
+ /// `text` is that file's content — which is exactly what flatSurface hands
+ /// back for a file pane, and so what every motion in a document goes
+ /// through. The modal pair has to scan the buffer for the same two numbers
+ /// (a newline count and a walk to the line start), and on a multi-MB file
+ /// those scans were the bulk of a keystroke.
+ ///
+ /// The identity check is the whole safety argument: anything that is NOT
+ /// this file's content — a terminal's joined motion surface, an edit
+ /// buffer, a search haystack — falls through to the scan, where it is O(a
+ /// screenful) anyway. The two branches must agree exactly or the 481
+ /// differential and 561 parity cases say so; the arithmetic below is
+ /// hxOff/hxPos transcribed, with line i's terminator read off idx[i+1]-1
+ /// instead of searched for.
+ fn indexOf(pane: *Pane, text: []const u8) ?[]const usize {
+ const f = if (pane.file) |*ff| ff else return null;
+ if (text.ptr != f.content.ptr or text.len != f.content.len) return null;
+ return file_pane.lineIndex(pane.gpa, f) catch null;
+ }
+
+ fn paneOff(pane: *Pane, text: []const u8, c: modal.Cursor) usize {
+ const idx = indexOf(pane, text) orelse return modal.hxOff(text, c);
+ const row = @min(c.row, idx.len - 1);
+ const s = idx[row];
+ const e = if (row + 1 < idx.len) idx[row + 1] - 1 else text.len;
+ return @min(s + c.col, e);
+ }
+
+ /// modal.lineStartOffset, same contract (row past the end -> text.len)
+ fn paneLineStart(pane: *Pane, text: []const u8, row: usize) usize {
+ const idx = indexOf(pane, text) orelse return modal.lineStartOffset(text, row);
+ return if (row >= idx.len) text.len else idx[row];
+ }
+
+ fn panePos(pane: *Pane, text: []const u8, off: usize) modal.Cursor {
+ const idx = indexOf(pane, text) orelse return modal.hxPos(text, off);
+ const o = @min(off, text.len);
+ // the last line start at or before `o`; idx is sorted and idx[0] == 0,
+ // so upperBound never returns 0
+ const row = std.sort.upperBound(usize, idx, o, struct {
+ fn cmp(key: usize, item: usize) std.math.Order {
+ return std.math.order(key, item);
+ }
+ }.cmp) - 1;
+ return .{ .row = row, .col = o - idx[row] };
+ }
+
/// the current selection as a helix gap range over `text`, whose first
/// line is absolute row `row0` (0 for the motion surface and for file
/// content; a terminal's edit buffer starts wherever it was anchored)
fn paneRange(pane: *Pane, text: []const u8, row0: i32) modal.HxRange {
- const c = modal.hxOff(text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) });
+ const c = paneOff(pane, text, .{ .row = @intCast(@max(0, pane.cur_row - row0)), .col = @intCast(@max(0, pane.cur_col)) });
if (pane.msel.active) {
// legacy line selection (file-search results highlight): linewise
const r0: usize = @intCast(@max(0, @min(pane.msel.r0, pane.msel.r1) - row0));
@@ -2381,8 +2450,10 @@ pub const Pardes = struct {
var r = r0;
if (r.anchor == r.head) r.head = modal.nextGrapheme(text, r.head); // min_width_1
const off = rangeCells(text, r);
- const cc = modal.hxPos(text, off.cur);
- const ac = modal.hxPos(text, off.anc);
+ const cc = panePos(pane, text, off.cur);
+ // a bare block cursor has both cells on the same offset — the common
+ // case by far — and this conversion is not free even indexed
+ const ac = if (off.anc == off.cur) cc else panePos(pane, text, off.anc);
pane.cur_row = @as(i32, @intCast(cc.row)) + pl.row0;
pane.cur_col = @intCast(cc.col);
pane.vsel = .{
@@ -2957,6 +3028,18 @@ pub const Pardes = struct {
return .{ .row = s.row, .col = s.col };
}
+ /// The last line a goto may land on: helix skips the empty trailing line.
+ /// Called from the three `g`/`G` branches that need it and NOWHERE else —
+ /// it used to be a `const` at the top of normalKey, which meant every
+ /// keystroke of every kind paid a full count of the buffer's newlines.
+ /// "Does the buffer end in a newline" is the same question as the walk to
+ /// the last line start that stood here, and it is one byte instead of a
+ /// second pass.
+ fn maxLine(text: []const u8) usize {
+ const nl = modal.hxLineCount(text);
+ return if (text.len == 0 or text[text.len - 1] == '\n') nl -| 2 else nl - 1;
+ }
+
/// point-target motion: collapse there (extend in select mode)
fn pointMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, target: usize) void {
setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, target, pane.select), false);
@@ -2984,8 +3067,15 @@ pub const Pardes = struct {
/// j/k and friends: sticky goal column, clamped onto short lines' newline
fn verticalMove(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, down: bool, cnt: usize) void {
const cur = modal.hxCursor(text, range);
- const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else modal.hxPos(text, cur).col;
- const t = modal.hxVertTarget(text, cur, down, cnt, goal);
+ const pos = panePos(pane, text, cur);
+ const goal: usize = if (pane.sticky_col >= 0) @intCast(pane.sticky_col) else pos.col;
+ // modal.hxVertTarget with the row we already have and the indexed
+ // offset conversion — it would otherwise recount the buffer's newlines
+ // and walk to the target line, two more full passes per j/k
+ const t = if (indexOf(pane, text)) |idx| blk: {
+ const nline = if (down) @min(pos.row + @max(1, cnt), idx.len - 1) else pos.row -| @max(1, cnt);
+ break :blk paneOff(pane, text, .{ .row = nline, .col = goal });
+ } else modal.hxVertTarget(text, cur, down, cnt, goal);
// extend mode never walks onto the empty trailing line (helix)
if (pane.select and t == text.len and text.len > 0 and text[text.len - 1] == '\n') return;
setPaneRange(pane, pl, text, modal.hxPutCursor(text, range, t, pane.select), false);
@@ -3005,17 +3095,17 @@ pub const Pardes = struct {
const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
pane.scrollBy(delta);
const top = pane.scroll();
- const last_row: i32 = @intCast(modal.hxLineCount(text) - 1);
+ const last_row: i32 = @intCast((if (indexOf(pane, text)) |idx| idx.len else modal.hxLineCount(text)) - 1);
const cur = modal.hxCursor(text, range);
if (delta > 0) {
const snap: i32 = @max(0, @min(top + margin, last_row));
- const head = modal.lineStartOffset(text, @intCast(snap));
+ const head = paneLineStart(pane, text, @intCast(snap));
if (head <= cur) return;
const anchor = if (pane.select) range.anchor else head;
setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false);
} else {
const snap: i32 = @max(0, @min(top + @as(i32, pane.rows) - margin - 1, last_row));
- const head = modal.lineStartOffset(text, @intCast(snap));
+ const head = paneLineStart(pane, text, @intCast(snap));
if (head >= cur) return;
const anchor = if (pane.select) range.anchor else head;
setPaneRange(pane, pl, text, .{ .anchor = anchor, .head = head }, false);
@@ -3026,7 +3116,7 @@ pub const Pardes = struct {
fn gotoWindow(pane: *Pane, pl: PaneLines, text: []const u8, range: modal.HxRange, which: enum { top, center, bottom }, cnt: usize) void {
const margin: i32 = @min(config.scroll_off, @divTrunc(@as(i32, pane.rows) - 1, 2));
const top = pane.scroll();
- const last_row: i32 = @intCast(modal.hxLineCount(text) - 1);
+ const last_row: i32 = @intCast((if (indexOf(pane, text)) |idx| idx.len else modal.hxLineCount(text)) - 1);
const last_vis: i32 = @min(@as(i32, pane.rows) - 1, last_row - top);
const n: i32 = @intCast(cnt - 1);
var vline: i32 = switch (which) {
@@ -3037,7 +3127,7 @@ pub const Pardes = struct {
vline = @max(vline, top + margin);
vline = @min(vline, top + last_vis - margin);
const row: i32 = std.math.clamp(vline, 0, last_row);
- pointMove(pane, pl, text, range, modal.lineStartOffset(text, @intCast(row)));
+ pointMove(pane, pl, text, range, paneLineStart(pane, text, @intCast(row)));
}
/// helix Range::line_range — the inclusive line span a range covers
@@ -3161,20 +3251,15 @@ pub const Pardes = struct {
const range = paneRange(pane, text, pl.row0);
const cur = modal.hxCursor(text, range); // block-cursor gap offset
- // the last line a goto may land on: helix skips the empty trailing line
- const max_line = blk: {
- const nl = modal.hxLineCount(text);
- break :blk if (modal.lineStartOffset(text, nl - 1) >= text.len) nl -| 2 else nl - 1;
- };
// prefix continuations
if (pane.pending == config.goto_prefix) {
pane.pending = 0;
if (hit(key, config.goto_file_start)) {
- const line = if (has_count) @min(cnt - 1, max_line) else 0;
+ const line = if (has_count) @min(cnt - 1, maxLine(text)) else 0;
return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line));
}
- if (hit(key, config.goto_last_line)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, max_line));
+ if (hit(key, config.goto_last_line)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, maxLine(text)));
if (hit(key, config.goto_line_start)) return pointMove(pane, pl, text, range, modal.lineStartOffset(text, modal.hxLineOf(text, cur)));
if (hit(key, config.goto_line_end)) {
const line = modal.hxLineOf(text, cur);
@@ -3394,7 +3479,7 @@ pub const Pardes = struct {
// helix goto_line: only acts WITH a count (bare G is a no-op;
// ge is goto-last-line)
if (!has_count) return;
- const line = @min(cnt - 1, max_line);
+ const line = @min(cnt - 1, maxLine(text));
return pointMove(pane, pl, text, range, modal.lineStartOffset(text, line));
}
if (hit(key, config.half_page_down)) return halfPageMove(pane, pl, text, range, true);
@@ -5966,7 +6051,7 @@ pub const Pardes = struct {
p.active = id;
if (line == 0) return;
if (pane.file) |*f| {
- if (line > file_pane.lineCount(f.content)) return;
+ if (line > file_pane.nlines(p.gpa, f)) return;
const next = (line - 1) -| pane.rows / 2; // center, clamp at top
if (next != f.scroll) {
f.scroll = next;
@@ -6419,6 +6504,7 @@ pub const Pardes = struct {
.max_scrollback = 16 * 1024 * 1024,
}),
.stream = undefined,
+ .gpa = gpa,
.cols = @max(1, src.cols),
.rows = @max(1, src.rows),
};
@@ -6796,9 +6882,9 @@ pub const Pardes = struct {
}
var sb_off: usize = undefined;
var sb_total: usize = undefined;
- if (at.file) |f| {
+ if (at.file) |*f| {
sb_off = f.scroll;
- sb_total = file_pane.lineCount(f.content);
+ sb_total = file_pane.nlines(p.gpa, f);
} else {
const sb = at.vt.screens.active.pages.scrollbar();
sb_off = sb.offset;
@@ -7056,8 +7142,8 @@ pub const Pardes = struct {
s.fill(r.x, r.y, config.GUTTER, BOX_H, .{ .bg = .{ .rgb = box_bg } });
if (r.h > BOX_H) {
s.fill(r.x, r.y + BOX_H, config.GUTTER, r.h - BOX_H, .{ .bg = .{ .rgb = th.scroll_track } });
- const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |f| .{
- .total = file_pane.lineCount(f.content),
+ const sb: struct { total: usize, offset: usize, len: usize } = if (pane.file) |*f| .{
+ .total = file_pane.nlines(p.gpa, f),
.offset = f.scroll,
.len = pane.rows,
} else blk: {
diff --git a/test/perf.zig b/test/perf.zig
new file mode 100644
index 00000000..368e6ce1
--- /dev/null
+++ b/test/perf.zig
@@ -0,0 +1,466 @@
+//! The editing scoreboard: how long one user gesture costs against file size.
+//!
+//! zig build perf -- the table
+//! zig build perf -- --json -- the same, machine-readable
+//! zig build perf -- --base old.json -- table + a before/after delta column
+//! zig build perf -- --reps 60 -- more samples (default 25)
+//!
+//! It drives the sans-IO core directly — feed an Event, drain the effects,
+//! call render — exactly like test/hxdiff.zig, so there is no pty, no shell
+//! and no scheduler between the clock and the code under test. A run is
+//! self-contained: the fixtures are generated into /tmp/pardes-perf here and
+//! rewritten every run, so the numbers do not move when this repo's own
+//! sources do and there is no stale input to chase.
+//!
+//! WHAT A CELL MEANS. Every input row is the WHOLE gesture: update(event),
+//! drain the effect queue, render one frame into a fresh arena — what a user
+//! waits for between pressing a key and seeing the screen. `render` on its own
+//! is the redraw with nothing changed (a resize, a refresh), which is the floor
+//! every other row sits on.
+//!
+//! WHERE THE CURSOR IS matters more than anything else here: several of the
+//! core's line lookups are scans from byte 0, so a measurement taken at line 3
+//! of a 200k-line file reports a cost the user never pays. Each sample seeks to
+//! a different position spread across the whole file BEFORE the clock starts,
+//! so the median is the cost at a typical place in the document, not at its
+//! top.
+const std = @import("std");
+const libc = std.c;
+const pardes = @import("pardes");
+
+// ghostty-vt narrates every sequence it does not implement, and the fixture
+// text goes nowhere near a terminal here — cut the libraries to errors so the
+// table is the only thing on the screen.
+pub const std_options: std.Options = .{ .log_level = .err };
+
+const gpa = std.heap.page_allocator;
+
+/// std.time.Timer is gone in 0.16; clock_gettime is what dump.zig and
+/// test/lspbench.zig already use.
+fn nowNs() u64 {
+ var ts: std.c.timespec = undefined;
+ _ = std.c.clock_gettime(.MONOTONIC, &ts);
+ return @as(u64, @intCast(ts.sec)) *| 1_000_000_000 +| @as(u64, @intCast(ts.nsec));
+}
+
+/// The viewport every fixture is measured in. Bigger than the 80x24 the helix
+/// harness pins (that one matches helix's own harness; this one wants a screen
+/// somebody actually works in), and fixed, because half of these numbers scale
+/// with the number of cells on the screen.
+const screen_cols: u16 = 120;
+const screen_rows: u16 = 40;
+
+/// The four shapes a file comes in. `lines` x `cols` is the generated body;
+/// the point of the pair is that "big" has two different meanings and they
+/// break different code — a 200k-line file is long in the LINE index, an
+/// 8000-column file is long inside ONE line, and a scan that is fine for one
+/// is quadratic for the other.
+const Fixture = struct {
+ name: []const u8,
+ lines: usize,
+ cols: usize,
+};
+
+const fixtures = [_]Fixture{
+ // the control: a normal source file. Anything that shows up here is a
+ // constant cost, not a scaling one.
+ .{ .name = "small", .lines = 1_000, .cols = 60 },
+ .{ .name = "medium", .lines = 50_000, .cols = 60 },
+ // "a few MB, hundreds of thousands of lines"
+ .{ .name = "large", .lines = 300_000, .cols = 60 },
+ // the other failure mode: few lines, each one wider than any screen
+ .{ .name = "longline", .lines = 400, .cols = 8_000 },
+};
+
+const Op = enum {
+ /// Look at the path: disk read, pane creation, layout, first frame.
+ open,
+ /// one redraw, nothing changed
+ render,
+ /// `j` — one row of cursor movement
+ key_down,
+ /// `l` — one column. On `longline` the cursor sits deep in the line, so
+ /// this is the horizontal-scroll path; elsewhere it is a plain step.
+ key_right,
+ /// PageDown
+ page_down,
+ /// one mouse wheel tick
+ wheel,
+ /// one printable typed in insert mode
+ edit_char,
+
+ fn label(o: Op) []const u8 {
+ return switch (o) {
+ .open => "open",
+ .render => "render",
+ .key_down => "key-down",
+ .key_right => "key-right",
+ .page_down => "page-down",
+ .wheel => "wheel",
+ .edit_char => "edit-char",
+ };
+ }
+};
+
+const Cell = struct {
+ min_us: u64 = 0,
+ med_us: u64 = 0,
+ p90_us: u64 = 0,
+ max_us: u64 = 0,
+};
+
+pub fn main(init: std.process.Init) !void {
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ var json = false;
+ var reps: usize = 25;
+ var base_path: ?[]const u8 = null;
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ const a = args[i];
+ if (std.mem.eql(u8, a, "--json")) {
+ json = true;
+ } else if (std.mem.eql(u8, a, "--reps") and i + 1 < args.len) {
+ i += 1;
+ reps = std.fmt.parseInt(usize, args[i], 10) catch reps;
+ } else if (std.mem.eql(u8, a, "--base") and i + 1 < args.len) {
+ i += 1;
+ base_path = args[i];
+ } else fatal("usage: pardes-perf [--json] [--reps N] [--base old.json]", .{});
+ }
+
+ // fixtures live in a temp dir and are rewritten every run: they are inputs
+ // to a measurement, and a stale one silently changes what the table means.
+ // .zig extensions on purpose — that is what puts tree-sitter in the frame.
+ const dir = "/tmp/pardes-perf";
+ _ = libc.mkdir(dir, 0o755); // EEXIST is fine
+ var paths: [fixtures.len][]const u8 = undefined;
+ var bytes: [fixtures.len]usize = undefined;
+ for (fixtures, 0..) |fx, fi| {
+ const path = try std.fmt.allocPrint(gpa, "{s}/{s}.zig", .{ dir, fx.name });
+ const text = try generate(fx);
+ try writeFile(path, text);
+ paths[fi] = path;
+ // what got WRITTEN, not lines*cols: a line whose code is already wider
+ // than `cols` is left alone rather than truncated, so the nominal
+ // product would understate the file the editor actually opens
+ bytes[fi] = text.len;
+ gpa.free(text);
+ }
+
+ var cells: [std.enums.values(Op).len][fixtures.len]Cell = undefined;
+ for (std.enums.values(Op), 0..) |op, oi| {
+ for (fixtures, 0..) |fx, fi| {
+ cells[oi][fi] = try measure(op, fx, paths[fi], reps);
+ }
+ }
+
+ if (json) return reportJson(init.io, &cells, &bytes, reps);
+ reportText(&cells, &bytes, reps, base_path);
+}
+
+// ---- the measured gestures ----
+
+/// One (op, fixture) cell: `reps` timed samples plus three warmup rounds,
+/// reported as min / median / p90 / max. The spread between median and p90 is
+/// the run's noise, and reportText prints the worst one so a reader knows how
+/// big a difference has to be before it is real.
+fn measure(op: Op, fx: Fixture, path: []const u8, reps: usize) !Cell {
+ const warmup = 3;
+ var samples: std.ArrayList(u64) = .empty;
+ defer samples.deinit(gpa);
+
+ if (op == .open) {
+ // fresh core per sample: opening is a one-shot, and the second Look at
+ // the same path only refocuses the pane already holding it.
+ for (0..warmup + reps) |n| {
+ const core = try boot();
+ defer core.deinit();
+ const t0 = nowNs();
+ core.lookAt(0, path);
+ pump(core);
+ _ = try frame(core);
+ const dt = nowNs() -| t0;
+ if (n >= warmup) try samples.append(gpa, dt / 1000);
+ }
+ return summarize(samples.items);
+ }
+
+ const core = try boot();
+ defer core.deinit();
+ core.lookAt(0, path);
+ pump(core);
+ const id = filePane(core) orelse fatal("Look {s} opened no file pane", .{path});
+ core.active = id;
+ const pane = core.panes[id].?;
+ _ = try frame(core);
+ // insert mode is entered ONCE: `i` is a mode change, not a keystroke of
+ // typing, and measuring it inside every edit sample would report the wrong
+ // thing entirely.
+ if (op == .edit_char) {
+ core.update(.{ .key = .{ .cp = 'i', .text = "i" } });
+ pump(core);
+ }
+
+ for (0..warmup + reps) |n| {
+ seek(pane, fx, n);
+ _ = try frame(core); // settle the view at the new spot, untimed
+ const t0 = nowNs();
+ switch (op) {
+ .open => unreachable,
+ .render => {},
+ .key_down => core.update(.{ .key = .{ .cp = 'j', .text = "j" } }),
+ .key_right => core.update(.{ .key = .{ .cp = 'l', .text = "l" } }),
+ .page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }),
+ .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = 4, .row = 8 } }),
+ .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
+ }
+ pump(core);
+ _ = try frame(core);
+ const dt = nowNs() -| t0;
+ if (n >= warmup) try samples.append(gpa, dt / 1000);
+ }
+ return summarize(samples.items);
+}
+
+/// Park the cursor and the view at sample `n`'s position, walked across the
+/// file by a prime stride so consecutive samples land nowhere near each other
+/// and a whole run covers the document rather than one neighbourhood of it.
+/// Rows sit in the middle 80% (a position at the very top or bottom measures
+/// the clamp, not the work), and on a wide fixture the column is deep inside
+/// the line so `key-right` exercises the hscroll cut rather than column 1.
+/// The sequence depends only on `n`, so two builds see the same positions in
+/// the same order and their cells are comparable one for one.
+fn seek(pane: *pardes.Pane, fx: Fixture, n: usize) void {
+ const f = &pane.file.?;
+ const span = @max(1, fx.lines * 8 / 10);
+ const row = fx.lines / 10 + (n *% 7919) % span;
+ f.scroll = @min(row, fx.lines -| 1);
+ f.syntax_dirty = true;
+ pane.cur_row = @intCast(f.scroll);
+ pane.cur_col = if (fx.cols > 200) @intCast(fx.cols * 3 / 4) else 0;
+ pane.cur_pinned = true;
+ pane.hscroll = @max(0, pane.cur_col - 40);
+ pane.sticky_col = -1;
+}
+
+fn boot() !*pardes.Pardes {
+ const core = try pardes.Pardes.init(gpa, .{ .tty_only = true });
+ core.update(.{ .resize = .{ .cols = screen_cols, .rows = screen_rows } });
+ pump(core);
+ return core;
+}
+
+/// one frame into a throwaway arena — the shell's per-frame arena, which is
+/// what keeps the retained-render contract honest (vaxis stores slices into
+/// whatever it was handed, so the frame's text must outlive vx.render)
+fn frame(core: *pardes.Pardes) !*pardes.Surface {
+ var arena: std.heap.ArenaAllocator = .init(gpa);
+ defer arena.deinit();
+ return core.render(arena.allocator());
+}
+
+fn filePane(core: *pardes.Pardes) ?usize {
+ for (core.panes, 0..) |slot, i| {
+ const pane = slot orelse continue;
+ if (pane.file != null) return i;
+ }
+ return null;
+}
+
+/// Drain queued effects, all ignored (spawn, write, watch, ...): there is no
+/// shell here, and nothing measured depends on one answering.
+fn pump(core: *pardes.Pardes) void {
+ while (core.nextEffect()) |_| {}
+}
+
+fn summarize(samples: []u64) Cell {
+ if (samples.len == 0) return .{};
+ std.mem.sort(u64, samples, {}, std.sort.asc(u64));
+ return .{
+ .min_us = samples[0],
+ .med_us = samples[samples.len / 2],
+ .p90_us = samples[(samples.len * 9) / 10 -| 1],
+ .max_us = samples[samples.len - 1],
+ };
+}
+
+// ---- the fixture generator ----
+
+/// Plausible Zig, so the tree-sitter pass has real nodes to walk rather than
+/// one giant error node: a repeating four-line shape padded to `cols`. Long
+/// lines get their width from a comment tail — the alternative (an enormous
+/// string literal) makes the whole file one token and flatters every scan that
+/// looks for a newline.
+fn generate(fx: Fixture) ![]u8 {
+ var out: std.ArrayList(u8) = .empty;
+ try out.ensureTotalCapacity(gpa, fx.lines * (fx.cols + 1) + 64);
+ var n: usize = 0;
+ while (n < fx.lines) : (n += 1) {
+ const start = out.items.len;
+ switch (n % 4) {
+ 0 => try out.print(gpa, "const value_{d}: u32 = {d}; // ", .{ n, n *% 2654435761 }),
+ 1 => try out.print(gpa, "pub fn helper_{d}(a: u32, b: u32) u32 {{ return a +% b *% {d}; }} // ", .{ n, n }),
+ 2 => try out.print(gpa, " const text_{d} = \"lorem ipsum dolor sit amet {d}\"; // ", .{ n, n }),
+ else => try out.print(gpa, "// comment line {d} — ", .{n}),
+ }
+ while (out.items.len - start < fx.cols) try out.append(gpa, 'x');
+ try out.append(gpa, '\n');
+ }
+ return out.toOwnedSlice(gpa);
+}
+
+fn writeFile(path: []const u8, text: []const u8) !void {
+ var pathbuf: [4096]u8 = undefined;
+ const path_z = try std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0);
+ const fd = libc.open(path_z, .{ .ACCMODE = .WRONLY, .CREAT = true, .TRUNC = true }, @as(libc.mode_t, 0o644));
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var off: usize = 0;
+ while (off < text.len) {
+ const n = libc.write(fd, text.ptr + off, text.len - off);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.WriteFailed;
+ }
+ off += @intCast(n);
+ }
+}
+
+// ---- reporting ----
+
+fn reportText(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: *const [fixtures.len]usize, reps: usize, base_path: ?[]const u8) void {
+ const o = std.debug.print;
+ const base = if (base_path) |bp| readBase(bp) else null;
+
+ o("pardes perf — {d}x{d} viewport, {d} samples/cell, median us\n\n", .{ screen_cols, screen_rows, reps });
+ o("{s:<12}", .{"fixture"});
+ for (fixtures) |fx| o(" {s:>12}", .{fx.name});
+ o("\n{s:<12}", .{"lines"});
+ for (fixtures) |fx| o(" {d:>12}", .{fx.lines});
+ o("\n{s:<12}", .{"cols"});
+ for (fixtures) |fx| o(" {d:>12}", .{fx.cols});
+ o("\n{s:<12}", .{"size"});
+ for (bytes) |b| o(" {d:>10} KB", .{b / 1024});
+ o("\n\n", .{});
+
+ // one row per gesture. `open` is a whole Look; every other row is
+ // update+effects+one frame, which is the latency a user actually sees.
+ o("{s:<12}", .{"op"});
+ for (fixtures) |fx| {
+ if (base != null) o(" {s:>19}", .{fx.name}) else o(" {s:>12}", .{fx.name});
+ }
+ // 12 for the op name + one column per fixture, wider when a baseline adds
+ // its ratio to each cell
+ o("\n{s}\n", .{if (base != null) "-" ** (12 + fixtures.len * 20) else "-" ** (12 + fixtures.len * 13)});
+ var worst_jitter: f64 = 0;
+ for (std.enums.values(Op), 0..) |op, oi| {
+ o("{s:<12}", .{op.label()});
+ for (0..fixtures.len) |fi| {
+ const c = cells[oi][fi];
+ if (c.med_us > 0) {
+ const j = (@as(f64, @floatFromInt(c.p90_us)) - @as(f64, @floatFromInt(c.med_us))) / @as(f64, @floatFromInt(c.med_us));
+ if (j > worst_jitter) worst_jitter = j;
+ }
+ if (base) |b| {
+ const prev = b.med[oi][fi];
+ if (prev == 0) {
+ o(" {d:>12}{s:>7}", .{ c.med_us, "-" });
+ } else {
+ const ratio = @as(f64, @floatFromInt(c.med_us)) / @as(f64, @floatFromInt(prev));
+ o(" {d:>12} {d:>6.2}x", .{ c.med_us, ratio });
+ }
+ } else o(" {d:>12}", .{c.med_us});
+ }
+ o("\n", .{});
+ }
+ // The spread is not only scheduler noise: samples are taken at DIFFERENT
+ // places in the file on purpose (see seek), so a cost that still depends on
+ // where the cursor is shows up here as well. Both are reasons not to
+ // believe a small difference, and the sample positions are identical from
+ // run to run, so two builds are still comparable cell for cell.
+ o("\nnoise: worst cell p90 is {d:.0}% over its median (scheduler + the spread\n", .{worst_jitter * 100});
+ o("of sample positions through the file). Treat a difference smaller than\n", .{});
+ o("that as nothing, and re-run before believing a small win.\n", .{});
+ if (base_path) |bp| o("baseline: {s} (x column = now / then; under 1.00 is faster)\n", .{bp});
+}
+
+/// Real stdout, not std.debug.print's stderr: this is the form `--base` reads
+/// back, and `zig build perf -- --json > runs/old.json` writing an empty file
+/// would make the next comparison silently print no ratios at all.
+fn reportJson(io: std.Io, cells: *const [std.enums.values(Op).len][fixtures.len]Cell, bytes: *const [fixtures.len]usize, reps: usize) void {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ out.print(gpa, "{{\"cols\":{d},\"rows\":{d},\"reps\":{d},\"fixtures\":[", .{ screen_cols, screen_rows, reps }) catch return;
+ for (fixtures, 0..) |fx, fi| {
+ out.print(gpa, "{s}{{\"name\":\"{s}\",\"lines\":{d},\"cols\":{d},\"bytes\":{d}}}", .{
+ if (fi > 0) "," else "", fx.name, fx.lines, fx.cols, bytes[fi],
+ }) catch return;
+ }
+ out.appendSlice(gpa, "],\"cells\":[") catch return;
+ var first = true;
+ for (std.enums.values(Op), 0..) |op, oi| {
+ for (fixtures, 0..) |fx, fi| {
+ const c = cells[oi][fi];
+ out.print(gpa, "{s}{{\"op\":\"{s}\",\"fixture\":\"{s}\",\"min_us\":{d},\"med_us\":{d},\"p90_us\":{d},\"max_us\":{d}}}", .{
+ if (first) "" else ",", op.label(), fx.name, c.min_us, c.med_us, c.p90_us, c.max_us,
+ }) catch return;
+ first = false;
+ }
+ }
+ out.appendSlice(gpa, "]}\n") catch return;
+ std.Io.File.stdout().writeStreamingAll(io, out.items) catch {};
+}
+
+const Base = struct { med: [std.enums.values(Op).len][fixtures.len]u64 };
+
+/// A previous --json run, reduced to the medians this table compares against.
+/// Cells the old run did not have stay 0 and print as "-": the op list may
+/// have grown since, and a missing number is not a regression. An unreadable
+/// file is fatal rather than silently ratio-less — a comparison you asked for
+/// and did not get is worse than no comparison.
+fn readBase(path: []const u8) ?Base {
+ const src = readFileAlloc(path) catch fatal("--base: cannot read {s}", .{path});
+ var b: Base = .{ .med = @splat(@splat(0)) };
+ const parsed = std.json.parseFromSlice(struct {
+ cells: []const struct {
+ op: []const u8,
+ fixture: []const u8,
+ med_us: u64,
+ },
+ }, gpa, src, .{ .ignore_unknown_fields = true }) catch return null;
+ defer parsed.deinit();
+ for (parsed.value.cells) |c| {
+ for (std.enums.values(Op), 0..) |op, oi| {
+ if (!std.mem.eql(u8, op.label(), c.op)) continue;
+ for (fixtures, 0..) |fx, fi| {
+ if (std.mem.eql(u8, fx.name, c.fixture)) b.med[oi][fi] = c.med_us;
+ }
+ }
+ }
+ return b;
+}
+
+fn readFileAlloc(path: []const u8) ![]u8 {
+ var pathbuf: [4096]u8 = undefined;
+ const path_z = try std.fmt.bufPrintSentinel(&pathbuf, "{s}", .{path}, 0);
+ const fd = libc.open(path_z, .{ .ACCMODE = .RDONLY });
+ if (fd < 0) return error.OpenFailed;
+ defer _ = libc.close(fd);
+ var buf: std.ArrayList(u8) = .empty;
+ var chunk: [16384]u8 = undefined;
+ while (true) {
+ const n = libc.read(fd, &chunk, chunk.len);
+ if (n < 0) {
+ if (libc.errno(n) == .INTR) continue;
+ return error.ReadFailed;
+ }
+ if (n == 0) break;
+ try buf.appendSlice(gpa, chunk[0..@intCast(n)]);
+ }
+ return buf.items;
+}
+
+fn fatal(comptime fmt: []const u8, args: anytype) noreturn {
+ std.debug.print("pardes-perf: " ++ fmt ++ "\n", args);
+ std.process.exit(1);
+}