diff options
Diffstat (limited to 'test/pdf_pointer_bench.zig')
| -rw-r--r-- | test/pdf_pointer_bench.zig | 224 |
1 files changed, 224 insertions, 0 deletions
diff --git a/test/pdf_pointer_bench.zig b/test/pdf_pointer_bench.zig new file mode 100644 index 00000000..f1d61229 --- /dev/null +++ b/test/pdf_pointer_bench.zig @@ -0,0 +1,224 @@ +//! Pointer gestures over a native PDF pane: hovering words (the Look preview +//! highlight), and dragging a selection to its release. +//! +//! zig build pdf-pointer-bench -Doptimize=ReleaseFast -- [--label L] [--reps N] [--path FILE] +//! [--cell WxH] [--scroll NOTCHES] [--verify 1] [--live 1] +//! +//! Every frame is timed the way a shell pump spends it: the event through +//! `Pardes.update`, one `render()` into a reset arena, the effect drain. A +//! hover step is its motion frame plus every tick frame the preview animates +//! before settling, which is when the highlight lands. Stdout is CSV, +//! `label,scenario:kind,round,ns`, one row per frame and one per step +//! (`kind=step`), for an external statistics pass. +//! +//! `visual` hashes every presented raster in full, with its placement, on the +//! frame each step settles on. It is the no-regression proof: a change to how highlights +//! are painted may change what it costs, never one presented pixel. +const std = @import("std"); +const pardes = @import("pardes"); +const pdf = @import("mupdf"); + +pub const std_options: std.Options = .{ .log_level = .err }; + +const gpa = std.heap.smp_allocator; +const cols: u16 = 120; +const rows: u16 = 40; + +const Config = struct { + path: []const u8 = "docs/design.pdf", + label: []const u8 = "run", + reps: usize = 5, + /// Wheel notches before the gestures, off the sparse title page. + scroll: usize = 40, + /// 8x16 is the Kitty policy's cell; the SDL shell's is closer to 16x35. + cell_w: u16 = 8, + cell_h: u16 = 16, + /// Re-render every raster that claims to hold the current highlights and + /// compare it row by row: exactness without a baseline build. + verify: bool = false, + /// A shell that takes row patches: selections repaint while dragged. + live: bool = false, +}; + +pub fn main(init: std.process.Init) !void { + const args = try init.minimal.args.toSlice(init.arena.allocator()); + var config: Config = .{}; + var i: usize = 1; + while (i + 1 < args.len) : (i += 2) { + const value = args[i + 1]; + if (std.mem.eql(u8, args[i], "--path")) config.path = value else if (std.mem.eql(u8, args[i], "--label")) config.label = value else if (std.mem.eql(u8, args[i], "--reps")) config.reps = try std.fmt.parseUnsigned(usize, value, 10) else if (std.mem.eql(u8, args[i], "--scroll")) config.scroll = try std.fmt.parseUnsigned(usize, value, 10) else if (std.mem.eql(u8, args[i], "--verify")) config.verify = std.mem.eql(u8, value, "1") else if (std.mem.eql(u8, args[i], "--live")) config.live = std.mem.eql(u8, value, "1") else if (std.mem.eql(u8, args[i], "--cell")) { + var it = std.mem.splitScalar(u8, value, 'x'); + config.cell_w = try std.fmt.parseUnsigned(u16, it.next() orelse "", 10); + config.cell_h = try std.fmt.parseUnsigned(u16, it.next() orelse "", 10); + } else return error.UnknownOption; + } + if (i != args.len) return error.MissingValue; + + var visual: ?u64 = null; + for (0..config.reps) |round| { + const got = try measure(config, round); + if (visual) |want| if (want != got) { + std.debug.print("pdf-pointer-bench: visual changed between rounds: {x} vs {x}\n", .{ want, got }); + return error.Unstable; + }; + visual = got; + } + std.debug.print("pdf-pointer-bench {s}: visual={x:0>16}\n", .{ config.label, visual.? }); +} + +const Run = struct { + core: *pardes.Pardes, + arena: std.heap.ArenaAllocator, + config: Config, + round: usize, + visual: u64 = 0x7061_7264_6573_5050, + step_ns: u64 = 0, + frames: usize = 0, + steps: usize = 0, + last: ?*const pardes.Surface = null, + + fn frame(run: *Run, scenario: []const u8, kind: []const u8, event: pardes.Event) !void { + const started = nowNs(); + run.core.update(event); + while (run.core.nextEffect()) |_| {} + _ = run.arena.reset(.retain_capacity); + const surface = try run.core.render(run.arena.allocator()); + while (run.core.nextEffect()) |_| {} + const elapsed = nowNs() - started; + run.step_ns += elapsed; + run.frames += 1; + print("{s},{s}:{s},{d},{d}\n", .{ run.config.label, scenario, kind, run.round, elapsed }); + run.last = surface; + if (run.config.verify) try verify(run.core, run.arena.allocator()); + } + + /// The step, then the tick frames its animation asks for (a hover's + /// preview arrives on one of them), as a pump with nothing else queued. + fn step(run: *Run, scenario: []const u8, kind: []const u8, event: pardes.Event) !void { + run.step_ns = 0; + try run.frame(scenario, kind, event); + var ticks: usize = 0; + while (run.core.animationActive() and ticks < 32) : (ticks += 1) + try run.frame(scenario, "tick", .tick); + print("{s},{s}:step,{d},{d}\n", .{ run.config.label, scenario, run.round, run.step_ns }); + // Only the settled frame is the invariant: how many frames a preview + // takes to arrive is a setting (look_preview_delay_frames). + run.observe(run.last.?); + run.steps += 1; + } + + fn observe(run: *Run, surface: *const pardes.Surface) void { + for (surface.images[0..surface.nimages]) |maybe| { + const place = maybe orelse continue; + run.visual = mix(run.visual, [_]u64{ place.pane, place.x, place.y, place.w, place.h, place.native.page, place.iw, place.ih }); + if (place.native.geometry) |g| run.visual = mix(run.visual, [_]u64{ g.src.x, g.src.y, g.src.w, g.src.h, g.dst.x, g.dst.y, g.dst.w, g.dst.h }); + run.visual = std.hash.Wyhash.hash(run.visual, place.rgba); + } + } +}; + +var verify_failures: usize = 0; + +fn verify(core: *pardes.Pardes, arena: std.mem.Allocator) !void { + const Pdf = pardes.panes.Pdf; + const pane = core.panes[0] orelse return; + const state = &(pane.pdf orelse return); + const highlights = try Pdf.buildHighlights(state, arena, Pdf.highlightInput(core, 0, pane)); + for (state.rasters[0..state.rasters_len]) |*r| { + if (!r.valid or r.rgba.len == 0 or r.tint_key == null) continue; + const want = highlights.forPage(r.page); + if (comptime !@hasField(@TypeOf(r.*), "baked")) return else if (!std.mem.eql(u8, std.mem.sliceAsBytes(want), std.mem.sliceAsBytes(r.baked))) continue; + const shape = try state.document.measureRenderAt(r.page, r.request); + // The oracle is the WHOLE page: a band's own edge rows can differ. + const whole = try arena.alloc(u8, shape.len); + try state.document.renderIntoAtWithPaper(r.page, r.request, shape, shape.wholePage(), want, whole, false); + try pdf.tintRgba(whole, r.tint_key.?.mode, r.tint_key.?.colors); + const fresh = whole[r.band_y * shape.stride ..][0 .. r.band_h * shape.stride]; + var first: ?usize = null; + var last: usize = 0; + var count: usize = 0; + for (0..r.band_h) |y| { + const row = fresh[y * shape.stride ..][0..shape.stride]; + if (std.mem.eql(u8, row, r.rgba[y * shape.stride ..][0..shape.stride])) continue; + if (first == null) first = y; + last = y; + count += 1; + } + if (first) |f| { + verify_failures += 1; + if (verify_failures <= 20) std.debug.print("verify: page {d} rows {d}..{d} ({d} differ), highlights {d}, band {d}+{d}\n", .{ r.page, f + r.band_y, last + r.band_y, count, want.len, r.band_y, r.band_h }); + } + } +} + +fn measure(config: Config, round: usize) !u64 { + const core = try pardes.Pardes.init(gpa, .{ .file = config.path, .cols = cols, .rows = rows }); + defer core.deinit(); + core.native_images = true; + if (comptime @hasField(pardes.Pardes, "native_image_patches")) core.native_image_patches = config.live; + var run: Run = .{ .core = core, .arena = .init(gpa), .config = config, .round = round }; + defer run.arena.deinit(); + const cell: pardes.CellPixels = .{ .w = config.cell_w, .h = config.cell_h }; + core.update(.{ .resize = .{ .cols = cols, .rows = rows, .cell_pixels = cell } }); + _ = try core.render(run.arena.allocator()); + const pane = core.panes[0] orelse return error.NoPane; + if (pane.pdf == null) return error.NotPdf; + const rect = core.rects[0]; + const mid_col = rect.x + rect.w / 2; + const mid_row = rect.y + rect.h / 2; + for (0..config.scroll) |_| { + core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = mid_col, .row = mid_row } }); + _ = run.arena.reset(.retain_capacity); + _ = try core.render(run.arena.allocator()); + while (core.nextEffect()) |_| {} + } + + const top = rect.y + 4; + const bottom = rect.y + rect.h -| 3; + const left = rect.x + 4; + const right = rect.x + rect.w -| 4; + + // Hover: walk four rows of the body, a word-ish stride at a time. + var row: u16 = top; + while (row < bottom) : (row += (bottom - top) / 4) { + var col: u16 = left; + while (col < right) : (col += 3) + try run.step("hover", "motion", .{ .mouse = .{ .button = .none, .kind = .motion, .col = col, .row = row } }); + } + // Leave the page so the last preview is withdrawn like a real exit. + try run.step("hover", "leave", .pointer_leave); + + // Select: press, drag across three lines, release; five times down the page. + row = top; + while (row + 3 < bottom) : (row += (bottom - top) / 5) { + try run.step("select", "press", .{ .mouse = .{ .button = .left, .kind = .press, .col = left, .row = row } }); + var n: u16 = 1; + while (n <= 24) : (n += 1) { + const col = left + (right - left) * (n % 8) / 8; + try run.step("select", "drag", .{ .mouse = .{ .button = .left, .kind = .drag, .col = col, .row = row + n / 8 } }); + } + try run.step("select", "release", .{ .mouse = .{ .button = .left, .kind = .release, .col = right, .row = row + 3 } }); + } + return mix(run.visual, @as(u64, run.steps)); +} + +fn print(comptime format: []const u8, args: anytype) void { + var buf: [256]u8 = undefined; + const line = std.fmt.bufPrint(&buf, format, args) catch return; + _ = std.c.write(1, line.ptr, line.len); +} + +fn mix(seed: u64, value: anytype) u64 { + var copy = value; + return std.hash.Wyhash.hash(seed, std.mem.asBytes(©)); +} + +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)); +} + +comptime { + _ = pdf.Document; +} |
