From 048e81e3471c73b9ac690901819976f689f6a4ce Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Wed, 30 Sep 2026 10:50:18 -0300 Subject: An append to a body through 9P costs its own bytes, not five passes over the whole body: 60 KB appends go from 35.7 to 2.1 ms each Round 25 measured bulk body writes at about 8 ms a write. There is no frame wait in it: a profile of open, write, clunk in a loop found the flush of each close walking the whole body five times. dotOf, setDot and showOffset turned the cursor between offsets and rows by counting every newline from the top; setContent found the line index's changed span by comparing old and new byte for byte, and hashed the new text to see whether it was back to the saved one. The rows now come from the file's line index by binary search (checked against the counting at every offset), a splice tells setContent the span it changed, and the text is hashed only when its length is the saved text's. Measured over 9P on a Debug build: 60 KB appends 35.7 to 2.1 ms, 8 KB 5.3 to 0.8 ms, 1 KB 1.3 to 0.9 ms. What is left is two copies of the text an edit (the splice's and the undo snapshot's). The perf gate gains body-appends, 64 appends of 8 KB on the 50k-line file, and its three baselines are recorded again. Co-Authored-By: Claude Opus 5.5 --- src/File.zig | 46 ++++++++++++++----- src/ninep/ctl.zig | 1 + src/ninep/pane.zig | 80 ++++++++++++++++++++++++++++++--- test/perf-baseline-gui-Debug.json | 2 +- test/perf-baseline-tty-Debug.json | 2 +- test/perf-baseline-tty-ReleaseFast.json | 2 +- test/perf.zig | 21 +++++++++ 7 files changed, 135 insertions(+), 19 deletions(-) diff --git a/src/File.zig b/src/File.zig index e8a9e2f8..43b921e3 100644 --- a/src/File.zig +++ b/src/File.zig @@ -57,6 +57,9 @@ pub const State = struct { /// replaces it: an edit that brings the text back to it is clean again, /// as an undo to the save point is in acme. saved_hash: ?u64 = null, + /// The length of the text `saved_hash` is of, when it is of one: an + /// edit to another length cannot be back to it, and is not hashed. + saved_len: ?usize = null, watch_after_save: bool = false, /// Save makes the file's directory, and any above it, first: `Config`'s /// pane for an init file that is not there yet, whose directory may not @@ -617,14 +620,22 @@ pub fn lineIndex(gpa: std.mem.Allocator, f: *State) ![]const usize { /// The line index of `new` from `old`'s: an edit replaces one span, so /// line starts before it stand, starts after it shift, and only the span /// is scanned. Rebuilding scanned the whole file on every keystroke. -fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8) ![]usize { +/// The bytes a splice left alone at either end, when its caller knows them. +pub const Span = struct { head: usize, tail: usize }; + +fn editedLineStarts(gpa: std.mem.Allocator, starts: []const usize, old: []const u8, new: []const u8, known: ?Span) ![]usize { const n = @min(old.len, new.len); var head: usize = 0; - while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; - while (head < n and old[head] == new[head]) head += 1; var tail: usize = 0; - while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; - while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; + if (known) |span| { + head = span.head; + tail = span.tail; + } else { + while (head + 64 <= n and std.mem.eql(u8, old[head..][0..64], new[head..][0..64])) head += 64; + while (head < n and old[head] == new[head]) head += 1; + while (tail + 64 <= n - head and std.mem.eql(u8, old[old.len - tail - 64 ..][0..64], new[new.len - tail - 64 ..][0..64])) tail += 64; + while (tail < n - head and old[old.len - tail - 1] == new[new.len - tail - 1]) tail += 1; + } // A start s follows the newline at s-1: kept while that newline is in // the common head, shifted while it is in the common tail. const kept = std.sort.upperBound(usize, starts, head, struct { @@ -677,7 +688,12 @@ test "edited line index equals a rebuilt one" { const old = try gpa.dupe(u8, text.items); defer gpa.free(old); try text.replaceRange(gpa, at, del, ins[0..ins_len]); - const edited = try editedLineStarts(gpa, starts, old, text.items); + // As a splice that says what it kept, and as one that does not. + const known: Span = .{ .head = at, .tail = old.len - at - del }; + const told = try editedLineStarts(gpa, starts, old, text.items, known); + defer gpa.free(told); + const edited = try editedLineStarts(gpa, starts, old, text.items, null); + try std.testing.expectEqualSlices(usize, edited, told); defer gpa.free(edited); var fresh_state: State = undefined; fresh_state.content = text.items; @@ -906,6 +922,12 @@ fn reportEdit(p: *Pardes, f: *State, new: []const u8) void { } pub fn setContent(p: *Pardes, f: *State, new: []u8) void { + setContentSpan(p, f, new, null); +} + +/// `setContent` for a splice that knows what it left alone at either end, +/// so the line index is not found again by comparing the whole text. +pub fn setContentSpan(p: *Pardes, f: *State, new: []u8, span: ?Span) void { locations.freeRows(p.gpa, f.location_rows); f.location_rows = &.{}; for (p.panes) |slot| if (slot) |pane| { @@ -919,13 +941,17 @@ pub fn setContent(p: *Pardes, f: *State, new: []u8) void { reportEdit(p, f, new); if (f.mini) |*mini| mini.deinit(p.gpa); f.mini = null; - const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new) catch &.{} else &.{}; - // ponytail: a whole-text hash per edit; a rolling hash when files grow large. - if (f.revision == f.saved_revision or f.saved_hash == null) f.saved_hash = std.hash.Wyhash.hash(0, f.content); + const starts: []usize = if (f.line_starts.len > 0) editedLineStarts(p.gpa, f.line_starts, f.content, new, span) catch &.{} else &.{}; + // ponytail: a whole-text hash per edit that keeps the saved length; a + // rolling hash when that too matters. + if (f.revision == f.saved_revision or f.saved_hash == null) { + f.saved_hash = std.hash.Wyhash.hash(0, f.content); + f.saved_len = f.content.len; + } p.gpa.free(f.content); f.content = new; f.revision +%= 1; - if (f.saved_hash) |h| if (std.hash.Wyhash.hash(0, new) == h) { + if (f.saved_hash) |h| if ((f.saved_len orelse new.len) == new.len and std.hash.Wyhash.hash(0, new) == h) { f.saved_revision = f.revision; }; f.mtime = pardes.ctlfs.events.now(); diff --git a/src/ninep/ctl.zig b/src/ninep/ctl.zig index 5ed03c58..05f93ac5 100644 --- a/src/ninep/ctl.zig +++ b/src/ninep/ctl.zig @@ -983,6 +983,7 @@ fn get(p: *Pardes, pane: *Pane, failed: *anyerror) u16 { panes.File.setContent(p, f, bytes); f.saved_revision = f.revision; f.saved_hash = std.hash.Wyhash.hash(0, f.content); + f.saved_len = f.content.len; f.disk_newer = null; f.disk_gone = false; if (discarded) { diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig index 3fad1fae..af8bb52c 100644 --- a/src/ninep/pane.zig +++ b/src/ninep/pane.zig @@ -149,12 +149,62 @@ pub fn clip(n: usize) u32 { return std.math.cast(u32, n) orelse std.math.maxInt(u32); } +/// The file's line index when it describes `text` (built by a render or a +/// look), else null: with it a row is a binary search, not a count of every +/// newline from the top, which a bulk write paid on each flush, twice over. +fn lineIndex(pane: *const Pane, text: []const u8) ?[]const usize { + const f = if (pane.file) |*file| file else return null; + if (f.line_starts.len == 0 or f.content.ptr != text.ptr or f.content.len != text.len) return null; + return f.line_starts; +} + +/// The line `off` is on: the last start at or before it. +fn rowAt(starts: []const usize, off: usize) usize { + const after = std.sort.upperBound(usize, starts, off, struct { + fn order(key: usize, item: usize) std.math.Order { + return std.math.order(key, item); + } + }.order); + return after -| 1; +} + +fn lineEnd(starts: []const usize, text: []const u8, row: usize) usize { + return if (row + 1 < starts.len) starts[row + 1] - 1 else text.len; +} + +/// `modal.runeOffsetAt`, through the line index when there is one. +fn runeOffsetAt(pane: *const Pane, text: []const u8, c: modal.Cursor) usize { + const starts = lineIndex(pane, text) orelse return modal.runeOffsetAt(text, c); + const row = @min(c.row, starts.len - 1); + const s = starts[row]; + const e = lineEnd(starts, text, row); + return s + modal.runeStart(text[s..e], @min(c.col, e - s)); +} + +/// `modal.runePositionAt`, through the line index when there is one. +fn runePositionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { + const starts = lineIndex(pane, text) orelse return modal.runePositionAt(text, off); + const bounded = modal.runeStart(text, off); + const row = rowAt(starts, bounded); + return .{ .row = row, .col = bounded - starts[row] }; +} + +/// `modal.positionAt`, through the line index when there is one. +fn positionAt(pane: *const Pane, text: []const u8, off: usize) modal.Cursor { + const starts = lineIndex(pane, text) orelse return modal.positionAt(text, off); + const bounded = @min(off, text.len); + const row = rowAt(starts, bounded); + const s = starts[row]; + const e = lineEnd(starts, text, row); + return .{ .row = row, .col = modal.graphemeStart(text[s..e], @min(bounded - s, e - s)) }; +} + pub fn dotOf(pane: *Pane) State.Range { const text = bodyOf(pane); // In runes, as every address is (modal.runeStart). - const head = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); + const head = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.cur_row)), .col = @intCast(@max(0, pane.body.cur_col)) }); if (!pane.body.vsel.active) return .{ .q0 = clip(head), .q1 = clip(head) }; - const anchor = modal.runeOffsetAt(text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); + const anchor = runeOffsetAt(pane, text, .{ .row = @intCast(@max(0, pane.body.vsel.row)), .col = @intCast(@max(0, pane.body.vsel.col)) }); var hi = @max(head, anchor); if (hi < text.len) hi = modal.nextRune(text, hi); return .{ .q0 = clip(@min(head, anchor)), .q1 = clip(hi) }; @@ -164,9 +214,9 @@ pub fn setDot(pane: *Pane, r: State.Range) void { const text = bodyOf(pane); const q0 = @min(@as(usize, r.q0), text.len); const q1 = @max(q0, @min(@as(usize, r.q1), text.len)); - const a = modal.runePositionAt(text, q0); + const a = runePositionAt(pane, text, q0); pane.body.vsel = .{ .active = q1 > q0, .row = @intCast(a.row), .col = @intCast(a.col), .explicit = true }; - const h = modal.runePositionAt(text, if (q1 > q0) modal.prevRune(text, q1) else q0); + const h = runePositionAt(pane, text, if (q1 > q0) modal.prevRune(text, q1) else q0); pane.body.cur_row = @intCast(h.row); pane.body.cur_col = @intCast(h.col); pane.body.cur_pinned = true; @@ -177,7 +227,7 @@ pub fn setDot(pane: *Pane, r: State.Range) void { pub fn showOffset(pane: *Pane, off: usize) void { const text = bodyOf(pane); - const c = modal.positionAt(text, @min(off, text.len)); + const c = positionAt(pane, text, @min(off, text.len)); pane.body.cur_row = @intCast(c.row); pane.body.cur_col = @intCast(c.col); pane.body.cur_pinned = true; @@ -229,10 +279,28 @@ pub fn spliceBody(p: *Pardes, pane: *Pane, q0: usize, q1: usize, bytes: []const const join = pane.fs.joined == f.revision; pane.fs.joined = null; if (!pane.fs.nomark and !join) panes.File.pushUndo(p, pane); - panes.File.setContent(p, f, new); + panes.File.setContentSpan(p, f, new, .{ .head = lo, .tail = f.content.len - hi }); return take; } +test "rows through the line index are the ones counting newlines gives, at every offset" { + const p = try withFile(testing.allocator, "héllo\nwo\u{301}rld\n\n last line é\ntail"); + defer p.deinit(); + const pane = p.panes[0].?; + const f = &pane.file.?; + _ = try panes.File.lineIndex(p.gpa, f); + const text = bodyOf(pane); + try testing.expect(lineIndex(pane, text) != null); + for (0..text.len + 2) |off| { + try testing.expectEqual(modal.positionAt(text, off), positionAt(pane, text, off)); + try testing.expectEqual(modal.runePositionAt(text, off), runePositionAt(pane, text, off)); + } + for (0..8) |row| for (0..16) |col| { + const c: modal.Cursor = .{ .row = row, .col = col }; + try testing.expectEqual(modal.runeOffsetAt(text, c), runeOffsetAt(pane, text, c)); + }; +} + test "a terminal's body stats as long as it reads" { const gpa = testing.allocator; const p = try pardes.Pardes.init(gpa, .{ .tty_only = true, .cols = 80, .rows = 24 }); diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json index 55bb2b70..8808dd55 100644 --- a/test/perf-baseline-gui-Debug.json +++ b/test/perf-baseline-gui-Debug.json @@ -1 +1 @@ -{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1970,"med_us":2159,"p90_us":2428,"max_us":2940},{"op":"render","fixture":"medium","min_us":75,"med_us":76,"p90_us":78,"max_us":79},{"op":"key-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":83,"max_us":95},{"op":"key-right","fixture":"medium","min_us":77,"med_us":78,"p90_us":95,"max_us":107},{"op":"page-down","fixture":"medium","min_us":75,"med_us":78,"p90_us":81,"max_us":92},{"op":"wheel","fixture":"medium","min_us":75,"med_us":79,"p90_us":82,"max_us":83},{"op":"edit-char","fixture":"medium","min_us":1056,"med_us":1524,"p90_us":1648,"max_us":1751},{"op":"body-2m","fixture":"medium","min_us":8421,"med_us":15590,"p90_us":19804,"max_us":21613}]} +{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1740,"med_us":2009,"p90_us":2077,"max_us":2079},{"op":"render","fixture":"medium","min_us":76,"med_us":80,"p90_us":90,"max_us":101},{"op":"key-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":94,"max_us":95},{"op":"key-right","fixture":"medium","min_us":77,"med_us":82,"p90_us":86,"max_us":89},{"op":"page-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":86,"max_us":99},{"op":"wheel","fixture":"medium","min_us":76,"med_us":76,"p90_us":80,"max_us":89},{"op":"edit-char","fixture":"medium","min_us":924,"med_us":1330,"p90_us":1522,"max_us":1566},{"op":"body-2m","fixture":"medium","min_us":4888,"med_us":8435,"p90_us":10635,"max_us":11460},{"op":"body-appends","fixture":"medium","min_us":119102,"med_us":187377,"p90_us":230392,"max_us":245721}]} diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json index c4361c57..ef42f0ad 100644 --- a/test/perf-baseline-tty-Debug.json +++ b/test/perf-baseline-tty-Debug.json @@ -1 +1 @@ -{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1831,"med_us":2060,"p90_us":2194,"max_us":2252},{"op":"render","fixture":"medium","min_us":75,"med_us":79,"p90_us":79,"max_us":87},{"op":"key-down","fixture":"medium","min_us":77,"med_us":80,"p90_us":86,"max_us":91},{"op":"key-right","fixture":"medium","min_us":77,"med_us":81,"p90_us":89,"max_us":92},{"op":"page-down","fixture":"medium","min_us":77,"med_us":81,"p90_us":85,"max_us":86},{"op":"wheel","fixture":"medium","min_us":75,"med_us":77,"p90_us":83,"max_us":93},{"op":"edit-char","fixture":"medium","min_us":1071,"med_us":1446,"p90_us":1889,"max_us":2537},{"op":"body-2m","fixture":"medium","min_us":8740,"med_us":15875,"p90_us":19640,"max_us":21463}]} +{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1743,"med_us":2011,"p90_us":2152,"max_us":2217},{"op":"render","fixture":"medium","min_us":76,"med_us":79,"p90_us":80,"max_us":84},{"op":"key-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":85,"max_us":89},{"op":"key-right","fixture":"medium","min_us":77,"med_us":80,"p90_us":83,"max_us":95},{"op":"page-down","fixture":"medium","min_us":78,"med_us":78,"p90_us":87,"max_us":95},{"op":"wheel","fixture":"medium","min_us":76,"med_us":80,"p90_us":88,"max_us":90},{"op":"edit-char","fixture":"medium","min_us":1122,"med_us":1501,"p90_us":1812,"max_us":2440},{"op":"body-2m","fixture":"medium","min_us":4949,"med_us":8504,"p90_us":10909,"max_us":11452},{"op":"body-appends","fixture":"medium","min_us":115605,"med_us":190785,"p90_us":226244,"max_us":245258}]} diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json index 96ea5801..66421ba3 100644 --- a/test/perf-baseline-tty-ReleaseFast.json +++ b/test/perf-baseline-tty-ReleaseFast.json @@ -1 +1 @@ -{"harness":"75a55b9f838d3f0","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1760,"med_us":2041,"p90_us":2185,"max_us":2206},{"op":"render","fixture":"medium","min_us":76,"med_us":85,"p90_us":100,"max_us":102},{"op":"key-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":85,"max_us":97},{"op":"key-right","fixture":"medium","min_us":76,"med_us":77,"p90_us":90,"max_us":92},{"op":"page-down","fixture":"medium","min_us":77,"med_us":80,"p90_us":85,"max_us":87},{"op":"wheel","fixture":"medium","min_us":75,"med_us":80,"p90_us":89,"max_us":97},{"op":"edit-char","fixture":"medium","min_us":1146,"med_us":1473,"p90_us":1659,"max_us":1796},{"op":"body-2m","fixture":"medium","min_us":8487,"med_us":15600,"p90_us":20312,"max_us":21859}]} +{"harness":"f420fe5db60d6e97","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1751,"med_us":2134,"p90_us":2183,"max_us":2415},{"op":"render","fixture":"medium","min_us":76,"med_us":77,"p90_us":91,"max_us":92},{"op":"key-down","fixture":"medium","min_us":77,"med_us":78,"p90_us":82,"max_us":83},{"op":"key-right","fixture":"medium","min_us":79,"med_us":85,"p90_us":100,"max_us":102},{"op":"page-down","fixture":"medium","min_us":78,"med_us":82,"p90_us":86,"max_us":95},{"op":"wheel","fixture":"medium","min_us":76,"med_us":79,"p90_us":83,"max_us":87},{"op":"edit-char","fixture":"medium","min_us":1063,"med_us":1290,"p90_us":1527,"max_us":1564},{"op":"body-2m","fixture":"medium","min_us":4950,"med_us":9414,"p90_us":10945,"max_us":13152},{"op":"body-appends","fixture":"medium","min_us":117839,"med_us":189253,"p90_us":232943,"max_us":247720}]} diff --git a/test/perf.zig b/test/perf.zig index fca4a3b0..95b55748 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -49,6 +49,10 @@ const Op = enum { /// 2 MB appended to the file through 9P, on one open, in 256 KB /// writes: linear, not a copy of the whole buffer per write. body_write, + /// 64 appends to the file through 9P, each its own open, 8 KB write and + /// close, as a shell's `>>` loop makes: each costs its own bytes, not a + /// pass over the whole body for its cursor, line index and saved hash. + body_appends, fn label(o: Op) []const u8 { return switch (o) { @@ -60,6 +64,7 @@ const Op = enum { .wheel => "wheel", .edit_char => "edit-char", .body_write => "body-2m", + .body_appends => "body-appends", }; } }; @@ -78,6 +83,20 @@ fn bodyWrite(core: *pardes.Pardes, serial: u32) void { _ = core.serveFs(.{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true }); } +const append_chunk_len = 8 * 1024; +const append_count = 64; + +fn bodyAppends(core: *pardes.Pardes, serial: u32) void { + const Node = pardes.ctlfs.Node; + const body = Node.of(serial, .body); + for (body_chunk[0..append_chunk_len], 0..) |*c, i| c.* = if (i % 64 == 63) '\n' else 'y'; + for (0..append_count) |_| { + const h = core.serveFs(.{ .tag = 1, .op = .open, .node = body, .omode = 1 }).handle; + _ = core.serveFs(.{ .tag = 2, .op = .write, .node = body, .handle = h, .data = body_chunk[0..append_chunk_len] }); + _ = core.serveFs(.{ .tag = 4, .op = .release, .node = body, .handle = h, .opened = true }); + } +} + const TermFixture = struct { name: []const u8, kb: usize, @@ -533,6 +552,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u .wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }), .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }), .body_write => bodyWrite(core, pane.serial), + .body_appends => bodyAppends(core, pane.serial), } pump(core); _ = try frame(core); @@ -540,6 +560,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u switch (op) { .open => unreachable, .body_write => if (pane.file.?.content.len != before.len + 8 * body_chunk.len) return error.BodyWriteWasNotAppended, + .body_appends => if (pane.file.?.content.len != before.len + append_count * append_chunk_len) return error.BodyAppendsWereNotAppended, .render => if (pane.body.cur_row != before.row or pane.body.cur_col != before.col or pane.file.?.content.len != before.len) return error.RenderChangedDocument, .key_down => if (pane.body.cur_row != before.row + 1) return error.DownDidNotMove, -- cgit v1.3