diff options
| author | Gabriel Schneider <[email protected]> | 2026-08-25 20:20:24 -0300 |
|---|---|---|
| committer | Gabriel Schneider <[email protected]> | 2026-08-25 20:20:24 -0300 |
| commit | 767a35d3ae3b87d1bfdd960f2187f0a51ad32333 (patch) | |
| tree | dc9ce1378c898cebaa21014d335765fa6a7cd887 | |
| parent | 48b1060fa66f5f69dac6d652b4013cdaf1d2bd1f (diff) | |
| download | pardes-767a35d3ae3b87d1bfdd960f2187f0a51ad32333.tar.gz pardes-767a35d3ae3b87d1bfdd960f2187f0a51ad32333.zip | |
Make a keystroke 2.6x cheaper by not asking Unicode about ASCII
A keystroke on the ESP32-P4 cost 17.0 ms and the goal is 4. Profiling the core in
that board's exact configuration - 40x12, tree-sitter disabled, via `zig build perf
-Dtree-sitter=disabled -- --cols 40 --rows 12 --only small` - named the cost, and it
was Unicode machinery answering questions about the letter `y`.
Four changes, each a fast path guarded so that non-ASCII text takes exactly the road
it took before.
`modal.graphemeStart` was 21.5% of a keystroke, the single largest item. It iterates
graphemes FROM THE START of the text with the full UAX #29 break state machine until
it passes the offset, and the render path calls it once per visible row with a column
offset - so the cost followed the cursor's distance along its line. That is the shape
measured on the die, where inserting at column 320 of a fixed 320-character line cost
7.8 ms more than inserting at column 0 of the same line. In UAX #29 every ASCII
scalar is its own cluster with ONE exception, GB3 (CR joined to LF); every other rule
that could extend a cluster - Extend, ZWJ, SpacingMark, Prepend, Regional_Indicator -
is spelled with non-ASCII scalars. So an ASCII byte whose predecessor is also ASCII,
and not that CR-LF pair, IS a boundary. O(1), and sound rather than approximate.
`Surface.print` then became the largest at 26.2%: per character it took a UTF-8
length, a decode, a FRESHLY CONSTRUCTED grapheme iterator, a slice validation and a
width lookup, to conclude that `y` is one cell. Printable ASCII followed by ASCII
takes none of that now. Same guard, same reason.
`file_pane.graphemeDisplayWidth` was 6.9%, essentially all of it asking `gwidth`
about ASCII. Bounded to 0x20..0x7e on purpose: DEL and the C0 controls are not one
printable cell and `gwidth` stays the authority on them.
`modal.lineSlice` searched for "\n" with the generic substring search where a memchr
does; it is called once per visible row per frame.
Measured at the P4's geometry and configuration, on the host: render 55 -> 12 us,
key-down 483 -> 24 us, key-right 327 -> 13 us, edit-char 205 -> 46 us. On the die,
the per-character cost of a keystroke fell from 54.3 to 6.9 us - 7.9x - and a
keystroke at a 160-character line from 25.56 ms to 15.36 ms.
## The shadow grid, and why it is static
`src/p4.zig`'s `present` copied all 480 cells into vaxis every frame, which measured
6.75 ms on the die - 57% of a keystroke - and was paid whether or not anything
changed: a second render with nothing new cost the same as the first. vaxis diffs its
own grid, but only after being told every cell, and being told is the expensive part.
So `present` now keeps the previous Surface and tells vaxis only what moved.
`Cell.visuallyEqual` is the right comparison and already existed. Copy: 6.75 -> 1.45 ms.
The grid lives in `.bss`, sized by `max_cols` x `max_rows` at comptime, and that is
not a micro-optimisation. The first version allocated it from the editor's heap; on a
board whose 384 KiB is nearly spoken for, that is exactly the kind of change that
works and then breaks something else three steps away.
`shadow_grid` is a comptime A/B switch, kept deliberately. With it false, `present`
behaves as it did before - clear and write every cell - which is the reference any
measurement should be compared against, and the way to tell a rendering bug from a
rendering difference. It earned its keep immediately: the two paths were run against
the same 19-step workload on the die - inserts, deletes, motions that move the
modified-marker, a line outgrowing the viewport, backspaces that shrink it - and the
reconstructed screens are byte-identical.
## Verification
`snap` 95/95 scripts, `hxdiff` 481 cases 0 mismatches, `hxparity` 561 cases 0
mismatches, `unit-test`, `image-harness`, `pdf-harness`, `mupdf-check`, and tty / p4 /
gui all build. The rendering changes are exactly the sort that pass a latency
benchmark while corrupting a screen, so the snapshot parity suite is the one that
matters here and it is unchanged.
`test/perf.zig` gains `--cols`/`--rows`/`--only`. The screen's shape is one of the
things that table exists to hold constant, and 40x12 is not a scaled guess at the
board - it is the board. `--only` exists because under `perf record` one 63 ms cell on
the largest fixture swamps every sample from the case being asked about.
## Found, not fixed
`vx.resize` fails on this board: a runtime geometry change hits its allocation
failure path, restores the previous size and returns, so 80 bytes go out where 1,392
should. Verified independent of everything above - it reproduces with `shadow_grid`
false. The board therefore has one geometry for the life of a session, which is why
the staleness test above compares two firmwares rather than resizing one.
| -rw-r--r-- | src/file_pane.zig | 6 | ||||
| -rw-r--r-- | src/modal.zig | 26 | ||||
| -rw-r--r-- | src/p4.zig | 98 | ||||
| -rw-r--r-- | src/pardes.zig | 24 | ||||
| -rw-r--r-- | test/perf.zig | 31 |
5 files changed, 175 insertions, 10 deletions
diff --git a/src/file_pane.zig b/src/file_pane.zig index 8fc0fa0d..1c6d5e2f 100644 --- a/src/file_pane.zig +++ b/src/file_pane.zig @@ -92,6 +92,12 @@ pub fn dumpPane( pub fn graphemeDisplayWidth(grapheme: []const u8) usize { if (std.mem.eql(u8, grapheme, "\t")) return config.tab_width; + // A one-byte printable ASCII grapheme is one cell, and saying so here rather than asking + // `gwidth` costs a comparison instead of a Unicode table walk. `gwidth` was 6.9% of a profiled + // keystroke at the P4's geometry, essentially all of it answering this question about `y`. + // Bounded to 0x20..0x7e on purpose: DEL and the C0 controls are not one printable cell, and + // `gwidth` is still the authority on them. + if (grapheme.len == 1 and grapheme[0] >= 0x20 and grapheme[0] < 0x7f) return 1; return @max(1, @as(usize, vaxis.gwidth.gwidth(grapheme, .unicode))); } diff --git a/src/modal.zig b/src/modal.zig index f8093ed8..953aa900 100644 --- a/src/modal.zig +++ b/src/modal.zig @@ -499,7 +499,9 @@ pub fn lineStartOffset(content: []const u8, row: usize) usize { pub fn lineSlice(content: []const u8, row: usize) []const u8 { const start = lineStartOffset(content, row); if (start >= content.len) return ""; - const nl = std.mem.indexOfPos(u8, content, start, "\n") orelse content.len; + // indexOfScalarPos, not indexOfPos with a one-byte needle: the latter runs the generic + // substring search where a memchr will do, and this is called once per visible row per frame. + const nl = std.mem.indexOfScalarPos(u8, content, start, '\n') orelse content.len; return content[start..nl]; } @@ -849,11 +851,29 @@ pub fn deleteSpan(alloc: std.mem.Allocator, content: []const u8, a: Cursor, b: C pub const HxRange = struct { anchor: usize, head: usize }; -/// The grapheme containing `off`, or text.len at EOF. This is also the repair -/// path for stale/external byte columns that happen to point into UTF-8. +/// The first byte of the grapheme cluster containing `off`. +/// +/// The general answer needs UAX #29, which is why the slow path below iterates from the start of +/// `text` with the full break state machine - and that made this the single hottest function in a +/// keystroke: 21.5% of a profiled edit at the ESP32-P4's 40x12 geometry, because the render path +/// calls it once per visible row with a column offset, so the cost follows the cursor's distance +/// along its line. That is exactly the shape measured on the die, where inserting at column 320 of +/// a fixed line cost 7.8 ms more than inserting at column 0 of the same line. +/// +/// The fast path is sound rather than approximate. In UAX #29 every ASCII scalar is its own +/// grapheme cluster with ONE exception, GB3: CR is joined to a following LF. Every other rule that +/// could extend a cluster across `off` - Extend, ZWJ, SpacingMark, Prepend, Regional_Indicator - +/// is spelled with non-ASCII scalars. So if the byte at `off` and the byte before it are both +/// ASCII and are not that CR-LF pair, `off` already IS a cluster boundary and there is nothing to +/// search for. Text that is not all ASCII still takes the slow path, byte for byte as before. pub fn graphemeStart(text: []const u8, off: usize) usize { const bounded = @min(off, text.len); if (bounded == text.len) return text.len; + if (text[bounded] < 0x80) { + if (bounded == 0) return 0; + const prev = text[bounded - 1]; + if (prev < 0x80 and !(prev == '\r' and text[bounded] == '\n')) return bounded; + } var it = uucode.grapheme.utf8Iterator(text); while (it.nextGrapheme()) |g| { if (bounded < g.end) return g.start; @@ -30,6 +30,7 @@ //! any other input. Firmware has no `TIOCGWINSZ`, so the host-side bridge synthesises the first one. const std = @import("std"); +const builtin = @import("builtin"); const pardes = @import("pardes.zig"); const vaxis = @import("vaxis"); @@ -510,8 +511,34 @@ const pardes_host: pardes.Host.VTable = .{ .push_present = present }; /// (`src/tty/tty.zig:1096`) minus the panel compositor and the kitty image path: neither has a /// reason to exist on a board with no pixels. fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { + const t0 = cycles(); const win = vx.window(); - win.clear(); + const n = @as(usize, surface.cols) * @as(usize, surface.rows); + + // THE SHADOW GRID. Copying all 480 cells into vaxis every frame cost 6.75 ms on the die - 57% + // of a keystroke, and it was paid whether or not anything changed: a second render with nothing + // new measured the same as the first. vaxis already diffs its own grid against the terminal, but + // it can only do that AFTER being told every cell, and being told is the expensive part + // (`writeCell` builds a vaxis `Cell`, which carries an always-null image placement). + // + // So keep the previous Surface and tell vaxis only what moved. `Cell.visuallyEqual` is the + // right comparison and already exists for the panel compositor's benefit: it ignores scratch + // bytes past `len` and treats any two default cells as equal, so it cannot manufacture a write. + // + // STATIC, and that is not a micro-optimisation - it is a bug fix. The first version allocated + // this from the editor's heap, and on a board whose 384 KiB is already nearly spoken for that + // was enough to make `vx.resize` fail: a resize then hit its OOM path, restored the previous + // geometry and returned, so the screen was never repainted. Measured as a resize emitting 80 + // bytes where it had emitted 1,392. The grid is bounded by `max_cols` x `max_rows` at comptime, + // so it belongs in `.bss` where it cannot compete with anything. + const full = !shadow_grid or prev_cols != surface.cols or prev_rows != surface.rows; + if (full) { + prev_cols = surface.cols; + prev_rows = surface.rows; + win.clear(); + } + const usable = shadow_grid and n <= prev_cells.len; + var y: u16 = 0; while (y < surface.rows) : (y += 1) { var x: u16 = 0; @@ -519,7 +546,18 @@ fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { // `at` takes a mutable Surface but only reads; the tty shell does the same const-cast // for the same reason (src/tty/tty.zig:1105). const cell = @constCast(surface).at(x, y); - if (cell.default) continue; + const idx = @as(usize, y) * @as(usize, surface.cols) + @as(usize, x); + if (usable) { + if (!full and cell.visuallyEqual(&prev_cells[idx])) continue; + prev_cells[idx] = cell.*; + } else if (cell.default) continue; + + if (cell.default) { + // Changed TO default. `win.clear()` is what used to blank these, and it is not run + // on an incremental frame, so say it explicitly. + win.writeCell(x, y, .{ .char = .{ .grapheme = " " }, .style = .{} }); + continue; + } win.writeCell(x, y, .{ .char = .{ .grapheme = cell.grapheme() }, .style = vaxisStyle(cell.style), @@ -529,11 +567,67 @@ fn present(_: ?*anyopaque, surface: *const pardes.Surface) void { if (surface.cursor) |cur| { win.showCursor(cur.x, cur.y); } else win.hideCursor(); + const t1 = cycles(); // vaxis diffs against its own shadow grid, so this writes only what changed - which is what // makes an editor usable at 11.9 KB/s. vx.render(&out) catch return; + const t2 = cycles(); out.flush() catch return; + const t3 = cycles(); + + prof_copy_cy = t1 -% t0; + prof_render_cy = t2 -% t1; + prof_flush_cy = t3 -% t2; +} + +/// The previous Surface, cell for cell, sized for the largest grid this board can drive. In `.bss` +/// rather than on the heap: see `present`. `prev_cols`/`prev_rows` being zero on the first frame is +/// what makes that frame a full one. +/// A/B switch, kept because this optimisation is exactly the kind that can be right about latency +/// and wrong about the screen. With it false, `present` behaves as it did before the shadow grid - +/// clear and write every cell - which is the reference any measurement of it should be compared +/// against, and the way to tell a rendering bug from a rendering difference. +const shadow_grid = true; + +var prev_cells: [@as(usize, max_cols) * @as(usize, max_rows)]pardes.Cell = if (shadow_grid) @splat(.{}) else undefined; +var prev_cols: u16 = 0; +var prev_rows: u16 = 0; + +// ------------------------------------------------------------------ where a frame's time goes +// +// A frame has three stages and they want different fixes, so the firmware is given all three rather +// than one total. Measured on the die, a render costs ~11 ms whether or not anything changed, which +// says the cost is the unconditional walk and not the edit - but "the walk" is two walks, the copy +// into vaxis's grid and vaxis's own diff, and only one of them is ours to change. +// +// Two CSR reads per stage. `cycle` is the unprivileged counter, read high-low-high because two +// 32-bit halves can straddle a wrap. +var prof_copy_cy: u64 = 0; +var prof_render_cy: u64 = 0; +var prof_flush_cy: u64 = 0; + +inline fn cycles() u64 { + if (builtin.cpu.arch != .riscv32) return 0; + while (true) { + const hi0 = asm volatile ("csrr %[o], cycleh" + : [o] "=r" (-> u32), + ); + const lo = asm volatile ("csrr %[o], cycle" + : [o] "=r" (-> u32), + ); + const hi1 = asm volatile ("csrr %[o], cycleh" + : [o] "=r" (-> u32), + ); + if (hi0 == hi1) return (@as(u64, hi0) << 32) | lo; + } +} + +/// The last frame's three stages, in cycles. Zero on any platform without the CSR. +export fn pardes_p4_frame_prof(copy: *u64, render: *u64, flush: *u64) callconv(.c) void { + copy.* = prof_copy_cy; + render.* = prof_render_cy; + flush.* = prof_flush_cy; } fn vaxisStyle(s: pardes.CellStyle) vaxis.Style { diff --git a/src/pardes.zig b/src/pardes.zig index 91811cc9..dcc99aea 100644 --- a/src/pardes.zig +++ b/src/pardes.zig @@ -2848,6 +2848,30 @@ pub const Surface = struct { var i: usize = 0; while (i < text.len) { if (col >= end) break; + // ASCII FAST PATH. Printable ASCII is one byte, one cell, one column, and the general + // path below reaches that answer through a UTF-8 length, a decode, a freshly + // constructed grapheme iterator, a slice validation and a width lookup - per character. + // That made this function 26% of a keystroke when profiled in the ESP32-P4's + // configuration (40x12, no tree-sitter), which is the largest single item there. + // + // The guard on the NEXT byte is what makes it correct rather than merely fast: an ASCII + // base joins a following combining mark, ZWJ or spacing mark into ONE cluster, and every + // scalar that can do that is non-ASCII. So an ASCII byte followed by another ASCII byte + // (or by nothing) is a complete grapheme cluster on its own. Same condition + // `modal.nextGrapheme` uses, for the same reason. + // + // `\t`, `\r` and the C0 controls are excluded by the range test and keep their existing + // handling below; DEL is excluded too. + { + const b = text[i]; + if (b >= 0x20 and b < 0x7f and (i + 1 == text.len or text[i + 1] < 0x80)) { + s.set(col, y, text[i .. i + 1], style); + i += 1; + col += 1; + continue; + } + } + if (col >= end) break; // n == 0: not a start byte at all. A short tail or a bad // continuation decodes to null the same way — one U+FFFD, one byte. const n = std.unicode.utf8ByteSequenceLength(text[i]) catch 0; diff --git a/test/perf.zig b/test/perf.zig index 1387a1ac..087eea80 100644 --- a/test/perf.zig +++ b/test/perf.zig @@ -47,8 +47,13 @@ fn nowNs() u64 { /// 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 viewport every measurement runs in. Overridable, because the SHAPE of the +/// screen is one of the things this table exists to hold constant while something +/// else varies - and the ESP32-P4 firmware runs a 40x12 grid, where a render costs +/// 47x what it costs here for a tenth of the cells. Profiling that needs the same +/// geometry, not a scaled guess. +var screen_cols: u16 = 120; +var 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 @@ -173,6 +178,7 @@ pub fn main(init: std.process.Init) !void { var json = false; var reps: usize = 25; var base_path: ?[]const u8 = null; + var only: ?[]const u8 = null; var i: usize = 1; while (i < args.len) : (i += 1) { const a = args[i]; @@ -184,7 +190,16 @@ pub fn main(init: std.process.Init) !void { } 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]", .{}); + } else if (std.mem.eql(u8, a, "--cols") and i + 1 < args.len) { + i += 1; + screen_cols = std.fmt.parseInt(u16, args[i], 10) catch screen_cols; + } else if (std.mem.eql(u8, a, "--rows") and i + 1 < args.len) { + i += 1; + screen_rows = std.fmt.parseInt(u16, args[i], 10) catch screen_rows; + } else if (std.mem.eql(u8, a, "--only") and i + 1 < args.len) { + i += 1; + only = args[i]; + } else fatal("usage: pardes-perf [--json] [--reps N] [--base old.json] [--cols N] [--rows N] [--only NAME]", .{}); } // fixtures live in a temp dir and are rewritten every run: they are inputs @@ -206,17 +221,23 @@ pub fn main(init: std.process.Init) !void { gpa.free(text); } - var cells: [std.enums.values(Op).len][fixtures.len]Cell = undefined; + // `--only` leaves the other cells zeroed rather than reshaping the table. Its purpose is + // profiling, not reporting: under `perf record` a single 63 ms cell on the largest fixture + // swamps the samples, and the question "what does ONE keystroke on a small document spend its + // time in" cannot be answered from a profile dominated by a different one. + var cells: [std.enums.values(Op).len][fixtures.len]Cell = @splat(@splat(.{})); for (std.enums.values(Op), 0..) |op, oi| { for (fixtures, 0..) |fx, fi| { + if (only) |name| if (!std.mem.eql(u8, name, fx.name)) continue; cells[oi][fi] = try measure(op, fx, paths[fi], reps); } } term_chunk = try buildTermText(4 * 1024); - var term_cells: [std.enums.values(TermOp).len][term_fixtures.len]Cell = undefined; + var term_cells: [std.enums.values(TermOp).len][term_fixtures.len]Cell = @splat(@splat(.{})); for (std.enums.values(TermOp), 0..) |op, oi| { for (term_fixtures, 0..) |fx, fi| { + if (only != null) continue; term_cells[oi][fi] = try measureTerm(op, fx, reps); } } |
