//! Scoreboard for FAST scrolling of the continuous PDF strip. //! //! zig build pdf-scroll-bench -Doptimize=ReleaseFast the numbers //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --json //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --warmup 1 --reps 15 //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --path other.pdf //! zig build pdf-scroll-bench -Doptimize=ReleaseFast -- --cell 16x32 a 2x display //! zig build pdf-scroll-bench -Doptimize=Debug -- --reps 1 the diagnosis //! zig build pdf-scroll-bench -Dtracy=~/05-genizah/tracy the zones //! //! Every optimize mode runs. A profile or a crash wants safety checks, line //! numbers and un-inlined frames; only the SCOREBOARD wants ReleaseFast, so the //! mode is stamped in both output formats and shouted about on stderr rather //! than being refused — a benchmark you cannot run under a debugger is a //! benchmark whose regressions you cannot explain. //! //! What a fling IS, and why one-tick-per-frame benchmarks miss it: a shell //! pump drains every input the OS queued since the last present and then calls //! render() once. A trackpad fling therefore arrives as a BATCH of wheel //! notches inside one frame, and the frame budget is spent on the batch plus //! the single render — not on one notch. Every timed region below is exactly //! that: `ticks_per_frame` real `Mouse.wheel_down`/`wheel_up` presses through //! `Pardes.update`, one `render()` into a reset frame arena, and the effect //! drain, which is the loop src/tty/tty.zig and src/gui/gui.zig both run. //! //! Rows are per-FRAME latency, not per-notch: a dropped frame is what a reader //! feels, and the batch is indivisible from the reader's point of view. //! //! Two checksums, because they answer two different questions: //! //! `visual` is the no-regression proof. Frame count, emitted image count, //! every placement rectangle, the presented RGBA (strided every //! frame, in full every `full_rgba_stride`-th frame), and the //! final scroll offset and page. Any optimization that changes //! ONE presented pixel or ONE placement changes this. It must be //! bit-identical across builds. //! `transport` is the texture-upload trace: the (page, raster generation) //! pair per emitted image, with generations rebased on the //! counter's value at run start. Caching work legitimately //! changes this while `visual` stays fixed — reusing a retained //! raster keeps its old generation, which is precisely the //! retransmit the revision mechanism exists to suppress. //! //! Setup, teardown, checksumming, and reporting are outside every timer. const std = @import("std"); const bench_util = @import("bench_util.zig"); const nowNs = bench_util.nowNs; const fatal = bench_util.Fatal("pdf-scroll-bench").f; const mix = bench_util.mix; const drainEffects = bench_util.drainEffects; const libc = std.c; const pardes = @import("pardes"); const pdf = @import("mupdf"); const bench_config = @import("pdf_scroll_bench_config"); pub const std_options: std.Options = .{ .log_level = .err }; const gpa = std.heap.smp_allocator; // A reading-sized window. Wide enough that fit-width pages are taller than the // viewport (so a fling genuinely crosses page boundaries) and small enough // that the Kitty raster policy's 1200px cap is the binding quality term, which // is what the tty shell actually ships. const bench_cols: u16 = 120; const bench_rows: u16 = 40; const default_cell_pixels: pardes.CellPixels = .{ .w = 8, .h = 16 }; /// config.wheel_rows is one cell per notch, so a notch is `cell_pixels.h` /// display pixels. 192 notches is ~3072px — between two and three fit-width /// pages of travel inside a single frame, i.e. a hard flick whose momentum the /// OS delivers as one burst. This is the number that makes the benchmark about /// flinging rather than about nudging. const fling_ticks_per_frame: usize = 192; const slow_ticks_per_frame: usize = 1; /// The control row is a fixed wall of frames rather than a whole-document /// traversal: one notch per frame over a real document is tens of thousands of /// frames, and 240 already crosses several page boundaries at reading speed. const default_slow_frames: usize = 240; /// Guard against a pathological document turning one row into a multi-minute /// run. Deterministic either way: the frame count is derived from layout. const max_frames: usize = 2048; /// Full-RGBA hashing is O(document) per frame, so it samples the run instead of /// every frame; the strided hash below runs on every frame and every image. const full_rgba_stride: usize = 8; const checksum_seed: u64 = 0x7061_7264_6573_5343; const Config = struct { path: []const u8 = "docs/design.pdf", json: bool = false, warmup: usize = 2, reps: usize = 25, /// Override `max_frames` for one run: a 5000-page manual is 2000 frames of /// travel per round, which is a fine scoreboard and a poor edit-run loop. frames: ?usize = null, /// One cell in display pixels: 16x32 is the same window on a 2x display. cell: pardes.CellPixels = default_cell_pixels, /// Frames of the one-notch control row. slow_frames: usize = default_slow_frames, }; /// The bit-exact description of one scrolling run. `visual` is the invariant; /// see the file header for why `transport` is reported beside it rather than /// folded into it. const Identity = struct { frames: u64, images: u64, visual: u64, transport: u64, fn eql(a: Identity, b: Identity) bool { return a.frames == b.frames and a.images == b.images and a.visual == b.visual and a.transport == b.transport; } }; const Result = struct { name: []const u8, ticks_per_frame: usize, frames: usize, min_ns: u64, median_ns: u64, p90_ns: u64, p99_ns: u64, max_ns: u64, total_ns: u64, rasterizations: u64, rasters_retained: u64, /// Most bytes the pane's rasters held after any frame: every slot's /// pixels, its clean copy, and the spare buffers kept for reuse. peak_raster_bytes: u64, identity: Identity, }; const Scenario = enum { /// Sustained high-velocity travel from page zero to the last page. fling_down, /// Down to the end and straight back up. Worst case for any cache that /// only retains what the current frame shows: the way back finds nothing. fling_reverse, /// One notch per frame. The control: it must not regress. slow_scroll, fn label(scenario: Scenario) []const u8 { return switch (scenario) { .fling_down => "fling_down", .fling_reverse => "fling_reverse", .slow_scroll => "slow_scroll", }; } fn ticks(scenario: Scenario) usize { return switch (scenario) { .fling_down, .fling_reverse => fling_ticks_per_frame, .slow_scroll => slow_ticks_per_frame, }; } }; const Plan = struct { scenario: Scenario, frames: usize, /// Frames of downward travel before `fling_reverse` turns around. down_frames: usize, fn button(plan: Plan, frame: usize) pardes.Mouse.Button { return switch (plan.scenario) { .fling_reverse => if (frame < plan.down_frames) .wheel_down else .wheel_up, .fling_down, .slow_scroll => .wheel_down, }; } }; pub fn main(init: std.process.Init) !void { if (!bench_config.release_fast_core) std.debug.print( "WARNING: the core is {s}, not ReleaseFast — these timings are for" ++ " reading a profile, NOT for the scoreboard\n", .{bench_config.core_optimize}, ); const args = try init.minimal.args.toSlice(init.arena.allocator()); const config = parseArgs(args); // One untimed probe boot supplies the strip height every plan is sized // from, so the frame counts below are a property of the document and the // viewport rather than of the machine. const travel = probeTravel(config); const cap = config.frames orelse max_frames; const results = [_]Result{ try measure(config, planFor(.fling_down, travel, cap, config.slow_frames)), try measure(config, planFor(.fling_reverse, travel, cap, config.slow_frames)), try measure(config, planFor(.slow_scroll, travel, cap, config.slow_frames)), }; if (config.json) reportJson(init.io, config, travel, &results) else reportText(config, travel, &results); } fn parseArgs(args: []const []const u8) Config { var config: Config = .{}; var i: usize = 1; while (i < args.len) : (i += 1) { if (std.mem.eql(u8, args[i], "--json")) { config.json = true; } else if (std.mem.eql(u8, args[i], "--warmup") and i + 1 < args.len) { i += 1; config.warmup = parseCount("--warmup", args[i]); } else if (std.mem.eql(u8, args[i], "--reps") and i + 1 < args.len) { i += 1; config.reps = parseCount("--reps", args[i]); } else if (std.mem.eql(u8, args[i], "--path") and i + 1 < args.len) { i += 1; config.path = args[i]; } else if (std.mem.eql(u8, args[i], "--frames") and i + 1 < args.len) { i += 1; config.frames = parseCount("--frames", args[i]); } else if (std.mem.eql(u8, args[i], "--slow-frames") and i + 1 < args.len) { i += 1; config.slow_frames = parseCount("--slow-frames", args[i]); } else if (std.mem.eql(u8, args[i], "--cell") and i + 1 < args.len) { i += 1; var it = std.mem.splitScalar(u8, args[i], 'x'); const w = parseCount("--cell", it.next() orelse ""); const h = parseCount("--cell", it.next() orelse ""); if (w > std.math.maxInt(u16) or h > std.math.maxInt(u16)) fatal("--cell {s} is too large", .{args[i]}); config.cell = .{ .w = @intCast(w), .h = @intCast(h) }; } else fatal( "usage: pardes-pdf-scroll-bench [--json] [--warmup N] [--reps N]" ++ " [--path FILE] [--frames N] [--slow-frames N] [--cell WxH]", .{}, ); } return config; } const Travel = struct { /// Scrollable display pixels: the whole strip minus one screenful. max_scroll: f64, /// Display pixels one fling frame's notch batch covers. per_frame: f64, /// What the document turned out to be, so a row is readable next to a row /// from another file: a 7-page paper and a 5000-page manual are not the /// same benchmark and the report must not pretend otherwise. pages: usize, strip_px: u64, /// The first frame of a fresh pane: opening the document and rasterizing /// what it shows first. One sample, from the probe boot. first_frame_ns: u64, }; fn probeTravel(config: Config) Travel { const path = config.path; const core = bootCore(config) catch |err| fatal("cannot boot a PDF pane on {s}: {t}", .{ path, err }); defer core.deinit(); var frame_arena = std.heap.ArenaAllocator.init(gpa); defer frame_arena.deinit(); const started = nowNs(); _ = core.render(frame_arena.allocator()) catch |err| fatal("first render failed: {t}", .{err}); drainEffects(core); const first_frame_ns = nowNs() -| started; const pane = core.panes[0] orelse fatal("PDF pane disappeared", .{}); if (pane.pdf == null) fatal("{s} did not open as a PDF", .{path}); const pv = &pane.pdf.?; if (!pv.layout_valid or pv.document_height == 0) fatal("the pixel strip never laid out; is native_images/cell_pixels wired?", .{}); // Exactly the clamp scrollPdfDocument enforces, so the last fling frame // lands on the document end instead of adding a no-op frame to the row. const viewport_h: u64 = @as(u64, bench_rows -| pardes.BOX_H) * config.cell.h; // Four screenfuls of strip, in pages or in one tall page. if (pv.document_height < 4 * viewport_h) fatal("{s} is {d}px of strip; a fling benchmark needs at least four screenfuls", .{ path, pv.document_height }); const notch: f64 = @floatFromInt(config.cell.h); if (!std.math.isFinite(notch) or notch <= 0) fatal("a notch of {d} display pixels cannot size a fling", .{notch}); const strip = @as(f64, @floatFromInt(pv.document_height -| viewport_h)); if (!std.math.isFinite(strip)) fatal("a {d}-page strip does not fit a float; refusing to invent a plan", .{pv.page_count}); return .{ .max_scroll = @max(notch, strip), .per_frame = notch * @as(f64, @floatFromInt(fling_ticks_per_frame)), .pages = pv.page_count, .strip_px = pv.document_height, .first_frame_ns = first_frame_ns, }; } /// Frames are derived from the document, then capped — `max_frames` is a /// property of the BENCHMARK (a row must not become a multi-minute run on a /// 5000-page manual), and it caps the whole plan, not merely its downward half. /// The cap is applied before any arithmetic on the count, so a document the /// float division cannot describe still produces a runnable plan instead of an /// overflow. fn planFor(scenario: Scenario, travel: Travel, cap: usize, slow: usize) Plan { const wanted = travel.max_scroll / travel.per_frame; const whole: usize = if (std.math.isFinite(wanted) and wanted >= 1) @intFromFloat(@min(@as(f64, @floatFromInt(cap)), @ceil(wanted))) else 1; const down = @min(cap, whole); return switch (scenario) { .fling_down => .{ .scenario = scenario, .frames = down, .down_frames = down }, // half down, half back: the reverse row keeps the plan's frame budget .fling_reverse => .{ .scenario = scenario, .frames = @max(2, down), .down_frames = @max(1, down / 2), }, .slow_scroll => .{ .scenario = scenario, .frames = slow, .down_frames = slow }, }; } /// A pane on a real multi-page document with the native pixel-strip path live /// and a genuine pixel viewport delivered the way a shell delivers one. fn bootCore(config: Config) !*pardes.Pardes { const core = try pardes.Pardes.init(gpa, .{ .file = config.path, .cols = bench_cols, .rows = bench_rows, }); errdefer core.deinit(); core.native_images = true; core.update(.{ .resize = .{ .cols = bench_cols, .rows = bench_rows, .cell_pixels = config.cell, } }); drainEffects(core); return core; } fn measure(config: Config, plan: Plan) !Result { const name = plan.scenario.label(); const ticks = plan.scenario.ticks(); const totals = try gpa.alloc(u64, config.reps); defer gpa.free(totals); const pool = try gpa.alloc(u64, config.reps * plan.frames); defer gpa.free(pool); var pool_len: usize = 0; const recorder = try gpa.create(Recorder); defer gpa.destroy(recorder); var expected: ?Identity = null; var rasterizations: u64 = 0; var rasters_retained: u64 = 0; var peak_raster_bytes: u64 = 0; for (0..config.warmup + config.reps) |round| { const core = try bootCore(config); defer core.deinit(); var frame_arena = std.heap.ArenaAllocator.init(gpa); defer frame_arena.deinit(); // Untimed: the steady state a reader flings FROM already has page zero // resident and the strip laid out. _ = try core.render(frame_arena.allocator()); drainEffects(core); const pane = core.panes[0] orelse fatal("PDF pane disappeared", .{}); const pv = &pane.pdf.?; const rect = core.rects[0]; // Anywhere inside the pane body resolves to this pane's hover, which // is what routes the notch; the exact cell is irrelevant to a wheel. const wheel: pardes.Mouse = .{ .button = .wheel_down, .kind = .press, .col = rect.x + rect.w / 2, .row = rect.y + rect.h / 2, }; const base_revision = pv.next_raster_revision; recorder.reset(base_revision); var total: u64 = 0; for (0..plan.frames) |frame| { var event = wheel; event.button = plan.button(frame); const started = nowNs(); for (0..ticks) |_| core.update(.{ .mouse = event }); _ = frame_arena.reset(.retain_capacity); const surface = core.render(frame_arena.allocator()) catch |err| fatal("render failed in {s} frame {d}: {t}", .{ name, frame, err }); drainEffects(core); const elapsed = nowNs() -| started; total += elapsed; recorder.observe(frame, surface); peak_raster_bytes = @max(peak_raster_bytes, rasterBytes(pv)); // The same frame boundary src/tty/tty.zig marks, so a Tracy capture // of this benchmark reads like a capture of the real shell. pardes.frameMark(); if (round >= config.warmup) { pool[pool_len] = @max(1, elapsed); pool_len += 1; } } const identity = recorder.finish(pv); try verifyIdentity(name, &expected, identity, round); if (round >= config.warmup) { totals[round - config.warmup] = total; rasterizations = pv.next_raster_revision -% base_revision; rasters_retained = pv.rasters_len; } } std.debug.assert(pool_len == pool.len); std.mem.sort(u64, pool, {}, std.sort.asc(u64)); std.mem.sort(u64, totals, {}, std.sort.asc(u64)); return .{ .name = name, .ticks_per_frame = ticks, .frames = plan.frames, .min_ns = pool[0], .median_ns = pool[pool.len / 2], .p90_ns = pool[(pool.len * 9) / 10 -| 1], .p99_ns = pool[(pool.len * 99) / 100 -| 1], .max_ns = pool[pool.len - 1], .total_ns = totals[totals.len / 2], .rasterizations = rasterizations, .rasters_retained = rasters_retained, .peak_raster_bytes = peak_raster_bytes, .identity = expected.?, }; } /// What the pane's rasters hold right now (untimed: outside the frame). fn rasterBytes(pv: anytype) u64 { var total: u64 = 0; for (pv.rasters[0..pv.rasters_len]) |raster| total += raster.rgba.len + raster.clean.len; for (pv.spare[0..pv.spare_len]) |spare| total += spare.len; return total; } /// Accumulates the two checksums across one run. Raster generations are /// rebased on the counter's value at run start so the trace describes the /// ORDER textures were produced in rather than absolute counter values, which /// depend on how much the untimed boot happened to rasterize. const Recorder = struct { frames: u64, images: u64, visual: u64, transport: u64, base_revision: u32, fn reset(self: *Recorder, base_revision: u32) void { self.* = .{ .frames = 0, .images = 0, .visual = checksum_seed, .transport = checksum_seed, .base_revision = base_revision, }; } /// The `visual` half hashes WHAT THE READER SEES and nothing else: the /// destination rectangle, and the pixels inside the source rectangle the /// backend samples — walked row by row out of the texture, so a page /// delivered as a narrow band and the same page delivered whole hash the /// same. Texture dimensions, buffer lengths and revisions are transport, /// and they live in the other checksum on purpose: fast scrolling is /// ALLOWED to change how pixels are carried and is never allowed to change /// which pixels arrive. fn observe(self: *Recorder, frame: usize, surface: *const pardes.Surface) void { self.frames += 1; self.visual = mix(self.visual, surface.nimages); self.transport = mix(self.transport, surface.nimages); const full = frame % full_rgba_stride == 0; for (surface.images[0..surface.nimages]) |maybe| { const place = maybe orelse { self.visual = mix(self.visual, std.math.maxInt(u64)); continue; }; self.images += 1; self.visual = mix(self.visual, place.pane); self.visual = mix(self.visual, place.x); self.visual = mix(self.visual, place.y); self.visual = mix(self.visual, place.w); self.visual = mix(self.visual, place.h); self.visual = mix(self.visual, place.native.page); self.visual = mix(self.visual, @intFromEnum(place.native.fit)); self.visual = mix(self.visual, place.native.pan_x); self.visual = mix(self.visual, place.native.pan_y); self.visual = mix(self.visual, @as(u32, @bitCast(place.native.pixel_offset_y))); if (place.native.geometry) |geometry| { inline for (.{geometry.dst}) |rect| { self.visual = mix(self.visual, rect.x); self.visual = mix(self.visual, rect.y); self.visual = mix(self.visual, rect.w); self.visual = mix(self.visual, rect.h); } self.visual = self.hashSource(self.visual, place, geometry.src, full); } else { self.visual = mix(self.visual, std.math.maxInt(u64)); self.visual = if (full) std.hash.Wyhash.hash(self.visual, place.rgba) else stridedHash(self.visual, place.rgba); } self.transport = mix(self.transport, place.native.page); self.transport = mix(self.transport, place.iw); self.transport = mix(self.transport, place.ih); self.transport = mix(self.transport, place.rgba.len); self.transport = mix(self.transport, place.native.revision -% self.base_revision); } } /// Hash the source rectangle's pixels out of a texture of `place.iw` pixels /// per row. `full` walks every row; otherwise the same stride the whole- /// buffer hash used, so the cost stays a fraction of a frame. fn hashSource( self: *Recorder, seed: u64, place: pardes.ImagePlace, src: anytype, full: bool, ) u64 { _ = self; const stride = place.iw * 4; if (stride == 0 or place.rgba.len < stride) return mix(seed, std.math.maxInt(u64)); const rows = @min(src.h, @as(u32, @intCast(place.rgba.len / stride)) -| src.y); var hash = mix(seed, rows); var row: u32 = 0; while (row < rows) : (row += if (full) 1 else 7) { const start = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4; const len = @min(@as(usize, src.w) * 4, place.rgba.len -| start); if (len == 0) break; hash = std.hash.Wyhash.hash(hash, place.rgba[start..][0..len]); } return hash; } fn finish(self: *Recorder, pv: anytype) Identity { var visual = mix(self.visual, @as(u64, @bitCast(pv.document_scroll_y))); visual = mix(visual, pv.page); visual = mix(visual, pv.page_count); visual = mix(visual, pv.document_height); return .{ .frames = self.frames, .images = self.images, .visual = visual, .transport = self.transport, }; } }; /// 256 fixed-stride RGBA words. Cheap enough for every image of every frame /// while still failing on any change that touches a band of the page, and the /// full hash above closes the gap on sampled frames. fn stridedHash(seed: u64, rgba: []const u8) u64 { if (rgba.len < 4) return mix(seed, rgba.len); const stride = @max(@as(usize, 4), (rgba.len / 256) & ~@as(usize, 3)); var hash = seed; var at: usize = 0; while (at + 4 <= rgba.len) : (at += stride) hash = std.hash.Wyhash.hash(hash, rgba[at..][0..4]); return hash; } fn verifyIdentity( name: []const u8, expected: *?Identity, got: Identity, round: usize, ) !void { if (expected.*) |want| { if (!want.eql(got)) { std.debug.print( "pdf-scroll-bench: unstable identity in {s} round {d}:\n" ++ " expected frames={d} images={d} visual={x:0>16} transport={x:0>16}\n" ++ " got frames={d} images={d} visual={x:0>16} transport={x:0>16}\n", .{ name, round, want.frames, want.images, want.visual, want.transport, got.frames, got.images, got.visual, got.transport, }, ); return error.UnstableIdentity; } } else expected.* = got; } fn reportText(config: Config, travel: Travel, results: []const Result) void { std.debug.print("pardes PDF fast-scroll benchmark ({s})\n", .{bench_config.core_optimize}); std.debug.print( "{s}: {d} pages, {d}px strip at {d}x{d} cells of {d}x{d}px\n", .{ config.path, travel.pages, travel.strip_px, bench_cols, bench_rows, config.cell.w, config.cell.h, }, ); std.debug.print("first frame of a fresh pane: {d}us\n", .{travel.first_frame_ns / 1000}); std.debug.print( "warmup: {d}, sampled reps: {d}; every number is ONE FRAME (notch batch + render + drain)\n\n", .{ config.warmup, config.reps }, ); std.debug.print("{s:<16} {s:>6} {s:>7} {s:>10} {s:>10} {s:>10} {s:>10} {s:>10} {s:>11} {s:>7} {s:>5} {s:>9} {s:<16} {s}\n", .{ "scenario", "frames", "notches", "min", "median", "p90", "p99", "max", "total_us", "rasters", "kept", "peak_kib", "visual", "transport", }); std.debug.print("{s}\n", .{"-" ** 160}); for (results) |result| std.debug.print( "{s:<16} {d:>6} {d:>7} {d:>10} {d:>10} {d:>10} {d:>10} {d:>10} {d:>11} {d:>7} {d:>5} {d:>9} {x:0>16} {x:0>16}\n", .{ result.name, result.frames, result.ticks_per_frame, result.min_ns, result.median_ns, result.p90_ns, result.p99_ns, result.max_ns, result.total_ns / 1000, result.rasterizations, result.rasters_retained, result.peak_raster_bytes / 1024, result.identity.visual, result.identity.transport, }, ); std.debug.print( "\n`rasters` counts MuPDF page rasterizations over one run; `kept` is the retained raster count at the end;\n" ++ "`peak_kib` is the most the pane's rasters, clean copies and spares held after any frame.\n" ++ "`visual` pins presented pixels and placement and must never change; `transport` traces texture uploads.\n", .{}, ); } const json_report_max_bytes = 16 * 1024; fn reportJson(io: std.Io, config: Config, travel: Travel, results: []const Result) void { var storage: [json_report_max_bytes]u8 = undefined; var out: std.Io.Writer = .fixed(&storage); out.print( "{{\"benchmark\":\"pardes-pdf-scroll\",\"build\":\"{s}\",\"path\":\"{s}\"," ++ "\"pages\":{d},\"strip_px\":{d},\"first_frame_ns\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[", .{ bench_config.core_optimize, config.path, travel.pages, travel.strip_px, travel.first_frame_ns, config.warmup, config.reps, }, ) catch return; for (results, 0..) |result, i| out.print( "{s}{{\"scenario\":\"{s}\",\"frames\":{d},\"notches_per_frame\":{d}," ++ "\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d},\"p99_ns\":{d},\"max_ns\":{d},\"total_ns\":{d}," ++ "\"rasterizations\":{d},\"rasters_retained\":{d},\"peak_raster_bytes\":{d},\"images\":{d}," ++ "\"visual\":\"{x:0>16}\",\"transport\":\"{x:0>16}\"}}", .{ if (i == 0) "" else ",", result.name, result.frames, result.ticks_per_frame, result.min_ns, result.median_ns, result.p90_ns, result.p99_ns, result.max_ns, result.total_ns, result.rasterizations, result.rasters_retained, result.peak_raster_bytes, result.identity.images, result.identity.visual, result.identity.transport, }, ) catch return; out.writeAll("]}\n") catch return; std.Io.File.stdout().writeStreamingAll(io, out.buffered()) catch {}; } comptime { // The bench links the real MuPDF wrapper the core uses; keep the import // load-bearing so a build that silently dropped it fails here. _ = pdf.Document; } fn parseCount(flag: []const u8, text: []const u8) usize { return bench_util.parsePositive(fatal, flag, text, 10_000); }