summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig10
-rw-r--r--src/pardes.zig373
-rw-r--r--src/pdf.zig146
-rw-r--r--src/pdf_bridge.c66
-rw-r--r--src/pdf_bridge.h12
-rw-r--r--test/pdf_scroll_bench.zig156
6 files changed, 671 insertions, 92 deletions
diff --git a/build.zig b/build.zig
index 80e995b6..1df6e8dd 100644
--- a/build.zig
+++ b/build.zig
@@ -496,11 +496,15 @@ pub fn build(b: *std.Build) void {
// bench: what it measures is one shell frame — a whole batch of
// wheel notches through update() followed by a single render() —
// so it needs the production core, not the raster wrapper alone.
+ // The exe follows -Doptimize with the core rather than pinning
+ // ReleaseFast: a profile or a crash in the harness itself needs the
+ // same line numbers and un-inlined frames as one in the core, and a
+ // scoreboard run passes ReleaseFast anyway (see the file header).
const pdf_scroll_bench = b.addExecutable(.{
.name = "pardes-pdf-scroll-bench",
.root_module = b.createModule(.{
.target = target,
- .optimize = .ReleaseFast,
+ .optimize = optimize,
.root_source_file = b.path("test/pdf_scroll_bench.zig"),
.link_libc = true,
}),
@@ -509,7 +513,11 @@ pub fn build(b: *std.Build) void {
pdf_scroll_bench.root_module.addImport("mupdf", mupdf_mod);
const scroll_bench_opts = b.addOptions();
scroll_bench_opts.addOption(bool, "release_fast_core", optimize == .ReleaseFast);
+ scroll_bench_opts.addOption([]const u8, "core_optimize", @tagName(optimize));
pdf_scroll_bench.root_module.addOptions("pdf_scroll_bench_config", scroll_bench_opts);
+ // Installed, unlike the other two: `perf record zig-out/bin/...`
+ // needs a stable path, and a cache hash is not one.
+ b.installArtifact(pdf_scroll_bench);
const run_pdf_scroll_bench = b.addRunArtifact(pdf_scroll_bench);
if (b.args) |args| run_pdf_scroll_bench.addArgs(args);
run_pdf_scroll_bench.setCwd(b.path("."));
diff --git a/src/pardes.zig b/src/pardes.zig
index af12cb96..3c236f34 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -41,6 +41,11 @@ pub const allocators = @import("allocators.zig");
pub const image = @import("image.zig");
pub const dump = @import("dump.zig");
pub const lsp = @import("lsp/lsp.zig");
+/// Tracy's frame boundary, re-exported so a host that is not a shell — the
+/// fling benchmark — can delimit the same frames the tty loop delimits without
+/// reaching around the core for src/tracy.zig and its build options. A no-op
+/// unless -Dtracy names a Tracy checkout.
+pub const frameMark = tracy.frameMark;
pub const Platform = enum { tty, gui, web, macos };
pub const platform: Platform = @field(Platform, @tagName(@import("pardes_config").platform));
@@ -257,7 +262,14 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
// At a page boundary both page rasters coexist, the gap remains uncovered,
// and a row step crosses it without snapping either page to an edge.
+ //
+ // This lands by ASSIGNMENT, which is a jump and not a fling — and the
+ // travel counter has to say so: the keys above scrolled two screenfuls
+ // without any frame in between to spend that distance, which no shell does
+ // (every wheel batch is followed by a draw). Left unspent it would make the
+ // frame below the first frame of a fling and hand it bands.
const viewport = p.pdfViewport(pane).?;
+ pane.pdf.?.scroll_travel = 0;
pane.pdf.?.document_scroll_y = @floatFromInt(
pane.pdf.?.page_starts[1] -| viewport.pixel_h / 2,
);
@@ -430,6 +442,107 @@ test "MuPDF pane renders, navigates, searches, and round-trips its page" {
try std.testing.expectEqual(PdfTintMode.filtered, restored.panes[0].?.pdf.?.tint);
}
+test "a fling's banded pages show the reader exactly what whole pages would" {
+ if (!pdf_enabled or platform == .web) return;
+
+ // The contract fast scrolling is allowed to change: HOW pixels are carried
+ // (a strip of a page instead of the page) but never WHICH pixels arrive. So
+ // the same frame is drawn twice — once at fling speed, once at reading
+ // speed — and every pixel inside every source rectangle must match, along
+ // with where on screen it goes.
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .file = "docs/design.pdf", .cols = 120, .rows = 40 });
+ defer p.deinit();
+ p.native_images = true;
+ const pane = p.panes[0].?;
+ const pv = &pane.pdf.?;
+ var frame = std.heap.ArenaAllocator.init(gpa);
+ defer frame.deinit();
+ _ = try p.render(frame.allocator());
+
+ const viewport = p.pdfViewport(pane) orelse return error.MissingPdfViewport;
+ // Land mid-page-boundary so the frame carries TWO pages, each showing a
+ // fraction of itself — the shape a fling actually produces.
+ const landing = @as(f64, @floatFromInt(pv.page_starts[1] -| viewport.pixel_h / 3));
+ const Shot = struct {
+ page: u32,
+ dst: image.PixelRect,
+ pixels: []u8,
+ };
+ var shots: [8]Shot = undefined;
+ var shots_len: usize = 0;
+ defer for (shots[0..shots_len]) |shot| gpa.free(shot.pixels);
+
+ // A fling: one frame's worth of wheel travel carrying the viewport more
+ // than a screenful, delivered through the real scroll path so the distance
+ // is counted the way a wheel batch counts it.
+ pv.document_scroll_y = 0;
+ pv.scroll_travel = 0;
+ try std.testing.expect(p.scrollPdfDocument(pane, landing));
+ _ = frame.reset(.retain_capacity);
+ const flung = try p.render(frame.allocator());
+ try std.testing.expect(flung.nimages >= 2);
+ var banded = false;
+ for (flung.images[0..flung.nimages]) |maybe| {
+ const place = maybe orelse continue;
+ const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
+ const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse
+ return error.MissingPdfRaster;
+ if (raster.band_h < raster.ih) banded = true;
+ try std.testing.expectEqual(place.iw * raster.band_h * 4, place.rgba.len);
+ shots[shots_len] = .{
+ .page = place.native.page,
+ .dst = geometry.dst,
+ .pixels = try copySourceRect(gpa, place, geometry.src),
+ };
+ shots_len += 1;
+ }
+ // ...and it really did band, or the comparison below is two identical
+ // whole-page renders agreeing with each other.
+ try std.testing.expect(banded);
+ // ...at reading speed: no travel at all since the frame above, so every
+ // page is rasterized whole again, and that is the picture the banded frame
+ // has to have matched.
+ _ = frame.reset(.retain_capacity);
+ const rested = try p.render(frame.allocator());
+ try std.testing.expectEqual(shots_len, rested.nimages);
+ for (rested.images[0..rested.nimages], shots[0..shots_len]) |maybe, shot| {
+ const place = maybe orelse return error.MissingPdfPlacement;
+ const geometry = place.native.geometry orelse return error.MissingPdfGeometry;
+ const raster = Pardes.pdfRasterForPage(pv, place.native.page) orelse
+ return error.MissingPdfRaster;
+ try std.testing.expectEqual(raster.ih, raster.band_h); // promoted at rest
+ try std.testing.expectEqual(shot.page, place.native.page);
+ try std.testing.expectEqual(shot.dst.x, geometry.dst.x);
+ try std.testing.expectEqual(shot.dst.y, geometry.dst.y);
+ try std.testing.expectEqual(shot.dst.w, geometry.dst.w);
+ try std.testing.expectEqual(shot.dst.h, geometry.dst.h);
+ const whole = try copySourceRect(gpa, place, geometry.src);
+ defer gpa.free(whole);
+ try std.testing.expectEqualSlices(u8, shot.pixels, whole);
+ }
+}
+
+/// The pixels a backend samples out of one placement: the source rectangle,
+/// row by row, at the texture's own stride. Test-only, and the one operation
+/// that makes "same picture" mean something when the textures differ in shape.
+fn copySourceRect(
+ gpa: std.mem.Allocator,
+ place: ImagePlace,
+ src: image.PixelRect,
+) ![]u8 {
+ const stride = place.iw * 4;
+ const row_len = @as(usize, src.w) * 4;
+ const out = try gpa.alloc(u8, row_len * src.h);
+ errdefer gpa.free(out);
+ var row: usize = 0;
+ while (row < src.h) : (row += 1) {
+ const from = (@as(usize, src.y) + row) * stride + @as(usize, src.x) * 4;
+ @memcpy(out[row * row_len ..][0..row_len], place.rgba[from..][0..row_len]);
+ }
+ return out;
+}
+
test "PDF normal adapter consumes unsupported actions and navigates page fallback" {
if (!pdf_enabled or platform == .web) return;
@@ -3708,8 +3821,18 @@ const PdfRaster = if (pdf_enabled) struct {
valid: bool = false,
page: usize = 0,
rgba: []u8 = &.{},
+ /// The FULL page raster's dimensions, whatever part of it `rgba` holds.
+ /// Everything that maps between the document and the screen — placement,
+ /// the mouse, panning, a search reveal — is arithmetic about the whole
+ /// page, and none of it wants to know that a fling only rasterized a
+ /// strip.
iw: usize = 0,
ih: usize = 0,
+ /// ...and the strip that is actually here: rows [band_y, band_y + band_h)
+ /// of that full raster, so `rgba.len == iw * band_h * 4`. A page at rest
+ /// is the whole page as one band. See `pdfWantedBand`.
+ band_y: usize = 0,
+ band_h: usize = 0,
request: pdf_impl.RenderRequest = .{},
request_valid: bool = false,
tried: bool = false,
@@ -3775,6 +3898,15 @@ const PdfView = if (pdf_enabled) struct {
layout_anchor_page: usize = 0,
layout_anchor_fraction: f64 = 0,
next_raster_revision: u32 = 0,
+ /// Display pixels this view has been SCROLLED by since the last frame it
+ /// drew — the fling signal, and the reason the core needs no clock: a wheel
+ /// batch that moves a screenful or more inside one frame lands on pages
+ /// nobody will see again, and only their visible rows are worth
+ /// rasterizing (see `pdfWantedBand`). Deliberately counts SCROLLING only:
+ /// a page jump, a search reveal or a resize moves the viewport just as far
+ /// but leaves the reader looking at one place, where whole pages are what
+ /// makes the frames after it free.
+ scroll_travel: f64 = 0,
fit: PdfFitMode = .width,
tint: PdfTintMode = .filtered,
/// Normalized 0..65535 pan positions. NativeGeometry maps the extrema
@@ -12005,6 +12137,8 @@ pub const Pardes = struct {
/// are touched, and ordinary scrolling never enters this path.
fn ensurePdfLayout(p: *Pardes, pane: *Pane, pv: *PdfView) ?PdfViewport {
if (comptime !pdf_enabled) return null;
+ const tz = tracy.zone(@src(), "pdf.ensure_layout");
+ defer tz.end();
const viewport = p.pdfViewport(pane) orelse return null;
// Pane-local geometry changes (divider drags, splits, closes) do not
// pass through Event.resize. Capture against the still-valid OLD
@@ -12150,17 +12284,34 @@ pub const Pardes = struct {
pv.render_revision = raster.revision;
}
- fn pdfPlacedGeometry(
+ /// Which rows of a page's FULL raster the viewport shows, and where they
+ /// land on screen. Split out of `pdfPlacedGeometry` because the reconcile
+ /// needs the same answer BEFORE a raster exists — it is what decides how
+ /// much of the page is worth rasterizing at all (see `pdfWantedBand`), and
+ /// two copies of this arithmetic would be two chances to place a band one
+ /// row away from the rows it rendered.
+ const PdfVisibleRows = struct {
+ base: image.NativeGeometry,
+ /// rows [y0, y1) of the full page raster
+ y0: u32,
+ y1: u32,
+ dst_y: u32,
+ dst_h: u32,
+ pixel_offset_y: f32,
+ };
+
+ fn pdfVisibleRows(
pv: *const PdfView,
- raster: *const PdfRaster,
viewport: PdfViewport,
page: usize,
- ) ?PdfPlacedGeometry {
+ iw: usize,
+ ih: usize,
+ ) ?PdfVisibleRows {
if (comptime !pdf_enabled) return null;
const page_h = pv.page_heights[page];
const base = image.nativeGeometry(
- raster.iw,
- raster.ih,
+ iw,
+ ih,
viewport.pixel_w,
page_h,
switch (pv.fit) {
@@ -12199,21 +12350,48 @@ pub const Pardes = struct {
)));
if (src_y1 <= src_y0) return null;
return .{
+ .base = base,
+ .y0 = src_y0,
+ .y1 = src_y1,
+ .dst_y = @intCast(visible_y),
+ .dst_h = @intCast(visible_bottom - visible_y),
+ .pixel_offset_y = -fractional,
+ };
+ }
+
+ /// The same rows, expressed against the raster that actually exists: a
+ /// band-rendered page holds only rows [band_y, band_y+band_h), so the
+ /// source rectangle a backend samples is shifted by the band's origin.
+ /// A raster that does not cover the visible rows places NOTHING rather
+ /// than sampling rows it never rendered — the reconcile is what keeps that
+ /// from happening, and this is the assertion that it did.
+ fn pdfPlacedGeometry(
+ pv: *const PdfView,
+ raster: *const PdfRaster,
+ viewport: PdfViewport,
+ page: usize,
+ ) ?PdfPlacedGeometry {
+ if (comptime !pdf_enabled) return null;
+ const rows = pdfVisibleRows(pv, viewport, page, raster.iw, raster.ih) orelse return null;
+ const band_y: u32 = @intCast(raster.band_y);
+ const band_end: u32 = @intCast(raster.band_y + raster.band_h);
+ if (rows.y0 < band_y or rows.y1 > band_end) return null;
+ return .{
.geometry = .{
.src = .{
- .x = base.src.x,
- .y = src_y0,
- .w = base.src.w,
- .h = src_y1 - src_y0,
+ .x = rows.base.src.x,
+ .y = rows.y0 - band_y,
+ .w = rows.base.src.w,
+ .h = rows.y1 - rows.y0,
},
.dst = .{
- .x = base.dst.x,
- .y = @intCast(visible_y),
- .w = base.dst.w,
- .h = @intCast(visible_bottom - visible_y),
+ .x = rows.base.dst.x,
+ .y = rows.dst_y,
+ .w = rows.base.dst.w,
+ .h = rows.dst_h,
},
},
- .pixel_offset_y = -fractional,
+ .pixel_offset_y = rows.pixel_offset_y,
};
}
@@ -12483,12 +12661,19 @@ pub const Pardes = struct {
/// semantic search/selection state, but never evict unchanged page pixels.
fn scrollPdfDocument(p: *Pardes, pane: *Pane, delta_pixels: f64) bool {
if (comptime !pdf_enabled) return false;
+ const tz = tracy.zone(@src(), "pdf.scroll_notch");
+ defer tz.end();
if (!std.math.isFinite(delta_pixels) or delta_pixels == 0) return false;
const pv = &(pane.pdf orelse return false);
const viewport = p.ensurePdfLayout(pane, pv) orelse return false;
const max_scroll = @as(f64, @floatFromInt(pv.document_height -| viewport.pixel_h));
const next = std.math.clamp(pv.document_scroll_y + delta_pixels, 0, max_scroll);
if (next == pv.document_scroll_y) return false;
+ // Travel this frame, for the fling test in drawPdf. Accumulated rather
+ // than compared against the last position because a wheel batch arrives
+ // as a hundred separate notches and it is their SUM inside one frame
+ // that says how fast the reader is going.
+ pv.scroll_travel += @abs(next - pv.document_scroll_y);
pv.document_scroll_y = next;
const active_page = pdfPageAtOffset(pv, next);
if (active_page != pv.page) p.activatePdfPage(pane, active_page, false);
@@ -14496,14 +14681,74 @@ pub const Pardes = struct {
return page >= visible.first and page - visible.first < visible.len;
}
+ /// How far a frame must travel before its pages are worth only their
+ /// visible rows. One screenful: at that speed a page is on screen for this
+ /// frame and gone by the next, so the rows outside the viewport are pixels
+ /// nobody will ever see. Below it, nothing changes — a reader nudging
+ /// through a document keeps getting whole pages, which is what makes the
+ /// following frames free.
+ fn pdfFlinging(pv: *const PdfView, viewport: PdfViewport) bool {
+ if (comptime !pdf_enabled) return false;
+ return pv.scroll_travel >= @as(f64, @floatFromInt(viewport.pixel_h));
+ }
+
+ /// Band heights are rounded out to this many rows, so that consecutive
+ /// pages of a fling ask for buffers of the SAME length and the retired-
+ /// buffer relay can hand one page's bytes to the next instead of going back
+ /// to the allocator every frame. Rounding out also makes the covered rows
+ /// outlast a few frames of ordinary scrolling.
+ ///
+ /// 64 by measurement, not by taste: on the 5363-page Intel manual, grain 64
+ /// runs a fling at a median 2.38-2.43 ms per frame against 2.49-2.52 at 128
+ /// and 2.93-3.03 at 256 (rounding out pixels nobody sees), while 32 matches
+ /// 64's median with a visibly worse tail — the buffer lengths stop repeating
+ /// and the relay starts missing. 48 and 96 are indistinguishable from 64.
+ const pdf_band_grain: usize = 64;
+
+ /// The rows of `page` worth rasterizing this frame: the whole page at
+ /// reading speed, and the visible strip (grain-rounded) during a fling.
+ fn pdfWantedBand(
+ pv: *const PdfView,
+ viewport: PdfViewport,
+ page: usize,
+ shape: pdf_impl.Raster,
+ flinging: bool,
+ ) pdf_impl.Raster.Band {
+ if (!flinging) return shape.wholePage();
+ const rows = pdfVisibleRows(pv, viewport, page, shape.width, shape.height) orelse
+ return shape.wholePage();
+ const grain = @max(@as(usize, 1), pdf_band_grain);
+ const first = (@as(usize, rows.y0) / grain) * grain;
+ const last = std.math.divCeil(usize, @as(usize, rows.y1), grain) catch
+ return shape.wholePage();
+ return shape.band(first, last * grain - first);
+ }
+
+ /// The raster shape a retained slot was rendered against. Identical to what
+ /// `measureRenderAt` would answer for the same request — which is exactly
+ /// why the reconcile may use it instead of asking, as long as the request
+ /// has not changed (a changed request is `stale` and re-measures).
+ fn pdfSlotShape(slot: *const PdfRaster) pdf_impl.Raster {
+ const stride = slot.iw * 4;
+ return .{
+ .width = slot.iw,
+ .height = slot.ih,
+ .stride = stride,
+ .len = stride * slot.ih,
+ };
+ }
+
fn reconcilePdfRasters(
p: *Pardes,
pv: *PdfView,
request: pdf_impl.RenderRequest,
highlights: []const PdfHighlight,
visible: PdfVisiblePages,
+ viewport: PdfViewport,
) void {
if (comptime !pdf_enabled) return;
+ const tz = tracy.zone(@src(), "pdf.reconcile");
+ defer tz.end();
const tint_key = p.pdfTintKey(pv);
// Remove first, and remove EVERYTHING outside the visible set: owned
@@ -14526,6 +14771,7 @@ pub const Pardes = struct {
if (index != pv.rasters_len) pv.rasters[index] = pv.rasters[pv.rasters_len];
}
+ const flinging = pdfFlinging(pv, viewport);
var page = visible.first;
const end = visible.first + visible.len;
while (page < end) : (page += 1) {
@@ -14538,45 +14784,75 @@ pub const Pardes = struct {
}
const slot = raster.?;
const decorated = page == pv.page and highlights.len > 0;
- if (!slot.tried or !slot.request_valid or !slot.request.eql(request) or
- slot.decorated != decorated or slot.tint_key == null or
- !slot.tint_key.?.eql(tint_key))
- {
+ // Everything that makes the pixels wrong rather than merely
+ // insufficient. Checked first and cheaply: a request or tint change
+ // means the raster's very dimensions may differ, so nothing below
+ // may reason about the band it holds.
+ const stale = !slot.tried or !slot.request_valid or
+ !slot.request.eql(request) or slot.decorated != decorated or
+ slot.tint_key == null or !slot.tint_key.?.eql(tint_key) or
+ slot.rgba.len == 0 or slot.band_h == 0;
+ // ...and then coverage, against the raster ALREADY here — its
+ // full-page dimensions are this request's, or `stale` would be
+ // true. Asking MuPDF to measure again per frame per page is what
+ // this avoids: at reading speed a covered page must cost nothing.
+ const uncovered = !stale and uncovered: {
+ const want = pdfWantedBand(pv, viewport, page, pdfSlotShape(slot), flinging);
+ break :uncovered slot.band_y > want.y or
+ slot.band_y + slot.band_h < want.y + want.height;
+ };
+ if (stale or uncovered) {
slot.tried = true;
slot.request = request;
slot.request_valid = true;
- // Measure first so a retired buffer of exactly this size can
- // be reclaimed. Render into that separate buffer and swap
- // only on complete success; the old pixels stay presentable
- // until then and then become the next page's buffer.
- if (pv.document.measureRenderAt(page, request) catch null) |shape| {
- if (p.claimPdfRgba(pv, shape.len)) |fresh| {
- const filled = filled: {
+ // Measure first so the band is a range of rows of THIS
+ // request's raster and a retired buffer can be reclaimed
+ // against a length that is already known.
+ const shape_or_null = shape: {
+ const tz_measure = tracy.zone(@src(), "pdf.measure");
+ defer tz_measure.end();
+ break :shape pv.document.measureRenderAt(page, request) catch null;
+ };
+ const shape = shape_or_null orelse continue;
+ const want = pdfWantedBand(pv, viewport, page, shape, flinging);
+ // Render into a separate buffer and swap only on complete
+ // success; the old pixels stay presentable until then and then
+ // become the next page's buffer.
+ if (p.claimPdfRgba(pv, want.len)) |fresh| {
+ const filled = filled: {
+ {
+ const tz_render = tracy.zone(@src(), "pdf.render_into");
+ defer tz_render.end();
pv.document.renderIntoAt(
page,
request,
shape,
+ want,
if (decorated) highlights else &.{},
fresh,
) catch break :filled false;
- pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch
- break :filled false;
- break :filled true;
- };
- if (!filled) {
- p.retirePdfRgba(pv, fresh);
- continue;
}
- p.retirePdfRgba(pv, slot.rgba);
- slot.rgba = fresh;
- slot.iw = shape.width;
- slot.ih = shape.height;
- slot.decorated = decorated;
- slot.tint_key = tint_key;
- pv.next_raster_revision +%= 1;
- if (pv.next_raster_revision == 0) pv.next_raster_revision = 1;
- slot.revision = pv.next_raster_revision;
+ const tz_tint = tracy.zone(@src(), "pdf.tint");
+ defer tz_tint.end();
+ pdf_impl.tintRgba(fresh, tint_key.mode, tint_key.colors) catch
+ break :filled false;
+ break :filled true;
+ };
+ if (!filled) {
+ p.retirePdfRgba(pv, fresh);
+ continue;
}
+ p.retirePdfRgba(pv, slot.rgba);
+ slot.rgba = fresh;
+ slot.iw = shape.width;
+ slot.ih = shape.height;
+ slot.band_y = want.y;
+ slot.band_h = want.height;
+ slot.decorated = decorated;
+ slot.tint_key = tint_key;
+ pv.next_raster_revision +%= 1;
+ if (pv.next_raster_revision == 0) pv.next_raster_revision = 1;
+ slot.revision = pv.next_raster_revision;
}
}
if (page == pv.page and slot.rgba.len > 0) syncPdfRasterAliases(pv, slot);
@@ -14589,6 +14865,8 @@ pub const Pardes = struct {
/// scrolling a retained page changes geometry only.
fn drawPdf(p: *Pardes, pane: *Pane, r: Rect, tx: u16, tw: u16) bool {
if (comptime !pdf_enabled) return false;
+ const tz = tracy.zone(@src(), "pdf.draw");
+ defer tz.end();
const pv = &(pane.pdf orelse return false);
if (r.h <= BOX_H) return false;
const viewport = p.ensurePdfLayout(pane, pv) orelse return false;
@@ -14596,14 +14874,14 @@ pub const Pardes = struct {
p.resolvePdfSearch(pv);
const highlights = p.pdfHighlights(pv) catch &.{};
var visible = pdfVisiblePages(pv, viewport);
- p.reconcilePdfRasters(pv, request, highlights, visible);
+ p.reconcilePdfRasters(pv, request, highlights, visible, viewport);
p.rearmPdfRevealForViewport(pane, pv);
p.revealPdfSearch(pane, pv);
// Reveal can move within the current page; placements below use its
// final document coordinate, but unchanged raster pixels are reused.
visible = pdfVisiblePages(pv, viewport);
- p.reconcilePdfRasters(pv, request, highlights, visible);
+ p.reconcilePdfRasters(pv, request, highlights, visible, viewport);
const id: u8 = @intCast(for (p.panes, 0..) |slot, i| {
if (slot == pane) break i;
} else 0);
@@ -14634,10 +14912,17 @@ pub const Pardes = struct {
.h = r.h - BOX_H,
.rgba = slot.rgba,
.iw = slot.iw,
- .ih = slot.ih,
+ // the TEXTURE is the band that is here, not the page it is cut
+ // from: rgba.len == iw * band_h * 4, and the placement's source
+ // rectangle is already band-local
+ .ih = slot.band_h,
})) break;
placed_any = true;
}
+ // This frame has now shown whatever its travel earned; the next frame's
+ // speed is its own. Reset only when something was actually drawn: a pane
+ // too short to place a page has not spent the reader's scrolling.
+ if (placed_any) pv.scroll_travel = 0;
return placed_any;
}
diff --git a/src/pdf.zig b/src/pdf.zig
index c1faafc5..b7e12fe8 100644
--- a/src/pdf.zig
+++ b/src/pdf.zig
@@ -894,18 +894,22 @@ pub const Document = struct {
return layout;
}
- /// Rasterize into `rgba`, which MUST be exactly `raster.len` bytes from a
- /// matching `measureRenderAt` with the same request. On any failure the
- /// buffer's contents are unspecified and it still belongs to the caller.
+ /// Rasterize `band` of a page into `rgba`, which MUST be exactly
+ /// `band.len` bytes for a `band` taken from the same `measureRenderAt`
+ /// shape and request. On any failure the buffer's contents are unspecified
+ /// and it still belongs to the caller.
pub fn renderIntoAt(
document: *Document,
page: usize,
request: RenderRequest,
raster: Raster,
+ band: Raster.Band,
highlights: []const Highlight,
rgba: []u8,
) !void {
- if (rgba.len != raster.len) return error.BadPixmap;
+ if (rgba.len != band.len) return error.BadPixmap;
+ if (band.height == 0 or band.y + band.height > raster.height)
+ return error.BadPixmap;
const bounded = try boundedRequest(document, page, request);
if (highlights.len > c.PARDES_PDF_MAX_RESULT_QUADS) return error.RenderFailed;
const highlight_ptr: ?[*]const c.pardes_pdf_highlight =
@@ -924,6 +928,8 @@ pub const Document = struct {
@intCast(raster.width),
@intCast(raster.height),
@intCast(raster.stride),
+ @intCast(band.y),
+ @intCast(band.height),
) != c.PARDES_PDF_OK) return error.RenderFailed;
}
@@ -968,7 +974,14 @@ pub const Document = struct {
const raster = try document.measureRenderAt(page, request);
const rgba = try gpa.alloc(u8, raster.len);
errdefer gpa.free(rgba);
- try document.renderIntoAt(page, request, raster, highlights orelse &.{}, rgba);
+ try document.renderIntoAt(
+ page,
+ request,
+ raster,
+ raster.wholePage(),
+ highlights orelse &.{},
+ rgba,
+ );
return .{ .rgba = rgba, .width = raster.width, .height = raster.height };
}
@@ -1130,13 +1143,40 @@ fn validQuad(quad: Quad) bool {
}
/// The exact shape of the raster a render request produces. `len` is the
-/// packed RGBA byte count `renderIntoAt` demands, so a caller can match a
-/// retired buffer against it before deciding to allocate.
+/// packed RGBA byte count a FULL-page `renderIntoAt` demands, so a caller can
+/// match a retired buffer against it before deciding to allocate.
pub const Raster = struct {
width: usize,
height: usize,
stride: usize,
len: usize,
+
+ /// The rows a reader can actually see are usually a fraction of a page:
+ /// flinging through a manual, a page is on screen for one frame showing a
+ /// couple of hundred of its twelve hundred rows. `Band` is that fraction,
+ /// and rendering one is the same rendering clipped — see the bridge's
+ /// comment and the test that proves the rows are identical.
+ pub const Band = struct {
+ y: usize,
+ height: usize,
+ /// bytes of a `y`/`height` band at this raster's stride
+ len: usize,
+ };
+
+ /// The whole page as a band, which is what a reader at rest gets.
+ pub fn wholePage(raster: Raster) Band {
+ return .{ .y = 0, .height = raster.height, .len = raster.len };
+ }
+
+ /// Clamp a wanted row range to the page and return it as a band. A range
+ /// that misses the page entirely comes back as its nearest single row
+ /// rather than as an error: the caller is describing a viewport, and a
+ /// viewport that has just left a page is not a malformed request.
+ pub fn band(raster: Raster, first_row: usize, rows: usize) Band {
+ const y = @min(first_row, raster.height - 1);
+ const height = @min(@max(rows, 1), raster.height - y);
+ return .{ .y = y, .height = height, .len = raster.stride * height };
+ }
};
fn checkedRasterLayout(raw: c.pardes_pdf_raster_layout) !Raster {
@@ -1604,6 +1644,8 @@ test "RGBA allocation and buffer-validation failures leave the document renderab
raw_layout.width,
raw_layout.height,
raw_layout.stride,
+ 0,
+ raw_layout.height,
));
for (scratch) |byte|
try std.testing.expectEqual(@as(u8, 0xa5), byte);
@@ -1618,6 +1660,81 @@ test "RGBA allocation and buffer-validation failures leave the document renderab
try expectOpaque(repeated.rgba);
}
+test "a band's rows are the full page's rows, and highlights land in them" {
+ // THE claim the fast-scroll path rests on: rendering rows [y, y+h) of a
+ // page is the same rendering, clipped — not a different one. If MuPDF ever
+ // let the pixmap's bbox change antialiasing, hinting or subpixel placement,
+ // a fling would show pixels a reader could tell apart from a rested view,
+ // and this test is what refuses to let that happen quietly.
+ var document = try Document.open("docs/design.pdf");
+ defer document.deinit();
+
+ const request: RenderRequest = .{ .dpi = 96, .max_dimension = 1200 };
+ const raster = try document.measureRenderAt(0, request);
+ const whole = try std.testing.allocator.alloc(u8, raster.len);
+ defer std.testing.allocator.free(whole);
+ try document.renderIntoAt(0, request, raster, raster.wholePage(), &.{}, whole);
+
+ // Odd offsets and an odd height on purpose: a band that starts on a row
+ // MuPDF would never choose itself is exactly where an off-by-one in the
+ // bbox arithmetic would show up.
+ const cases = [_]struct { y: usize, h: usize }{
+ .{ .y = 0, .h = 1 },
+ .{ .y = 1, .h = 37 },
+ .{ .y = raster.height / 3, .h = 101 },
+ .{ .y = raster.height / 2, .h = raster.height / 2 },
+ .{ .y = raster.height - 1, .h = 1 },
+ };
+ for (cases) |case| {
+ const band = raster.band(case.y, case.h);
+ const rows = try std.testing.allocator.alloc(u8, band.len);
+ defer std.testing.allocator.free(rows);
+ try document.renderIntoAt(0, request, raster, band, &.{}, rows);
+ const from = band.y * raster.stride;
+ try std.testing.expectEqualSlices(u8, whole[from..][0..band.len], rows);
+ try expectOpaque(rows);
+ }
+
+ // A baked highlight is drawn under the same CTM, so it has to land on the
+ // same rows through a band as it does through a whole page.
+ const highlights = [_]Highlight{.{
+ .quad = .{
+ .ul = .{ .x = 0.1, .y = 0.4 },
+ .ur = .{ .x = 0.9, .y = 0.4 },
+ .ll = .{ .x = 0.1, .y = 0.6 },
+ .lr = .{ .x = 0.9, .y = 0.6 },
+ },
+ .kind = .search,
+ .rgba = .{ 0x30, 0x80, 0xf0, 0x80 },
+ }};
+ const marked_whole = try std.testing.allocator.alloc(u8, raster.len);
+ defer std.testing.allocator.free(marked_whole);
+ try document.renderIntoAt(0, request, raster, raster.wholePage(), &highlights, marked_whole);
+ const marked_band = raster.band(raster.height / 3, raster.height / 3);
+ const marked_rows = try std.testing.allocator.alloc(u8, marked_band.len);
+ defer std.testing.allocator.free(marked_rows);
+ try document.renderIntoAt(0, request, raster, marked_band, &highlights, marked_rows);
+ const marked_from = marked_band.y * raster.stride;
+ try std.testing.expectEqualSlices(
+ u8,
+ marked_whole[marked_from..][0..marked_band.len],
+ marked_rows,
+ );
+ // ...and the highlight really is in the band that was compared, or the
+ // comparison above would be two identical plain renders agreeing.
+ try std.testing.expect(!std.mem.eql(
+ u8,
+ whole[marked_from..][0..marked_band.len],
+ marked_rows,
+ ));
+
+ // A band request that misses the page is a viewport that has left it, not
+ // a malformed call: it clamps instead of failing.
+ const past_end = raster.band(raster.height + 500, 64);
+ try std.testing.expect(past_end.y < raster.height);
+ try std.testing.expectEqual(@as(usize, 1), past_end.height);
+}
+
test "MuPDF search returns normalized oriented quads and word selection text" {
var document = try Document.open("docs/design.pdf");
defer document.deinit();
@@ -1706,6 +1823,21 @@ test "offset crop and rotation keep normalized highlights pixel-aligned" {
try std.testing.expectEqual(@as(usize, 300), plain.width);
try std.testing.expectEqual(@as(usize, 320), plain.height);
+ // The SIZE comes off the page object rather than a loaded page (see
+ // pardes_pdf_get_page_size), and this is the fixture that would catch it
+ // being a different answer: an offset CropBox plus /Rotate 90, where a
+ // MediaBox reading, an untransformed cropbox or a missed rotation all give
+ // plausible-looking wrong numbers. The rendered raster above is what
+ // fz_bound_page produces, so the strip's layout has to agree with it.
+ const size = try document.pageSize(0);
+ try std.testing.expectEqual(@as(f32, 150), size.width);
+ try std.testing.expectEqual(@as(f32, 160), size.height);
+ try std.testing.expectApproxEqAbs(
+ @as(f32, @floatFromInt(plain.width)) / @as(f32, @floatFromInt(plain.height)),
+ size.width / size.height,
+ 0.001,
+ );
+
const kitty = try document.renderAt(std.testing.allocator, 0, .{
.dpi = 96,
.max_dimension = 1200,
diff --git a/src/pdf_bridge.c b/src/pdf_bridge.c
index 25870f20..bd72a54a 100644
--- a/src/pdf_bridge.c
+++ b/src/pdf_bridge.c
@@ -1,6 +1,7 @@
#include "pdf_bridge.h"
#include <mupdf/fitz.h>
+#include <mupdf/pdf.h>
#include <math.h>
#include <limits.h>
@@ -380,6 +381,22 @@ pardes_pdf_close(pardes_pdf_document *document)
atomic_fetch_sub(&pardes_pdf_active_documents, 1);
}
+/*
+ * A page's SIZE, without building a page.
+ *
+ * fz_bound_page(fz_load_page(n)) is the obvious spelling and it is what this
+ * used to do, but fz_load_page builds a whole pdf_page: it resolves the page
+ * dictionary, then loads and parses every link annotation on it. Laying out a
+ * 5363-page manual's strip asks for 5363 sizes, and profiling an open showed
+ * pdf_load_link_annots alone at 29% of the run — parsing links for pages
+ * nobody has looked at yet, to answer a question about their height.
+ *
+ * The page OBJECT answers it directly, and identically: fz_bound_page on a PDF
+ * is pdf_bound_page(FZ_CROP_BOX), which is pdf_page_obj_transform_box on
+ * page->obj followed by fz_transform_rect — exactly the two calls below, from
+ * exactly the same object. Non-PDF documents (cbz, xps, svg) have no page
+ * objects and keep the loading path.
+ */
int
pardes_pdf_get_page_size(
pardes_pdf_document *document,
@@ -388,6 +405,7 @@ pardes_pdf_get_page_size(
{
fz_context *ctx;
fz_rect bounds;
+ pdf_document *pdf;
if (document == NULL || out == NULL || page_number < 0 ||
page_number >= document->page_count)
@@ -397,8 +415,18 @@ pardes_pdf_get_page_size(
ctx = document->ctx;
fz_try(ctx)
{
- pardes_pdf_cache_page(document, page_number, 0);
- bounds = document->cached_bounds;
+ pdf = pdf_specifics(ctx, document->doc);
+ if (pdf != NULL) {
+ fz_matrix page_ctm;
+ fz_rect cropbox;
+ pdf_obj *page_obj = pdf_lookup_page_obj(ctx, pdf, page_number);
+ pdf_page_obj_transform_box(ctx, page_obj, &cropbox, &page_ctm,
+ FZ_CROP_BOX);
+ bounds = fz_transform_rect(cropbox, page_ctm);
+ } else {
+ pardes_pdf_cache_page(document, page_number, 0);
+ bounds = document->cached_bounds;
+ }
out->width = bounds.x1 - bounds.x0;
out->height = bounds.y1 - bounds.y0;
if (!(out->width > 0.0f) || !(out->height > 0.0f))
@@ -526,7 +554,9 @@ pardes_pdf_render_into(
size_t samples_len,
int width,
int height,
- int stride)
+ int stride,
+ int band_y,
+ int band_height)
{
fz_context *ctx;
fz_pixmap *pixmap = NULL;
@@ -542,9 +572,10 @@ pardes_pdf_render_into(
(highlight_count != 0 && highlights == NULL) ||
highlight_count > PARDES_PDF_MAX_RESULT_QUADS || samples == NULL ||
width < 1 || height < 1 || stride < 1 || width > INT_MAX / 4 ||
- stride != width * 4 ||
- (size_t)height > SIZE_MAX / (size_t)stride ||
- samples_len != (size_t)stride * (size_t)height)
+ stride != width * 4 || band_y < 0 || band_height < 1 ||
+ band_y > height - band_height ||
+ (size_t)band_height > SIZE_MAX / (size_t)stride ||
+ samples_len != (size_t)stride * (size_t)band_height)
return PARDES_PDF_ERROR;
ctx = document->ctx;
@@ -559,17 +590,38 @@ pardes_pdf_render_into(
fz_irect_height(bbox) != height)
fz_throw(ctx, FZ_ERROR_ARGUMENT,
"PDF raster layout changed between measure and render");
+ /*
+ * The BAND: rows [band_y, band_y + band_height) of the page raster,
+ * and nothing else. A pixmap's bbox IS the draw device's clip, so the
+ * page runs under the same CTM it would for a full raster and MuPDF
+ * discards everything outside these rows. Every row inside them is
+ * therefore bit-identical to the same row of the full-page raster —
+ * "band rows equal full-page rows" in pdf.zig proves it, because the
+ * whole point of a band is that the reader cannot tell.
+ */
+ bbox.y0 += band_y;
+ bbox.y1 = bbox.y0 + band_height;
/* External samples are never marked FZ_PIXMAP_FLAG_FREE_SAMPLES. */
pixmap = fz_new_pixmap_with_bbox_and_data(
ctx, fz_device_rgb(ctx), bbox, NULL, 1, samples);
if (fz_pixmap_samples(ctx, pixmap) != samples ||
fz_pixmap_width(ctx, pixmap) != width ||
- fz_pixmap_height(ctx, pixmap) != height ||
+ fz_pixmap_height(ctx, pixmap) != band_height ||
fz_pixmap_stride(ctx, pixmap) != stride ||
fz_pixmap_components(ctx, pixmap) != 4)
fz_throw(ctx, FZ_ERROR_FORMAT,
"MuPDF wrapped an unexpected RGBA layout");
+ /*
+ * ponytail: this memset is ~9% of a fast scroll's profile and it has
+ * twice measured as unremovable. Filling with 32-byte vector stores
+ * instead (a memset of a multi-megabyte raster goes out through
+ * non-temporal stores, 7.4 GB/s against 41.7 for a store loop) changed
+ * a fling's median frame by nothing at all, and skipping the fill
+ * ENTIRELY changed it by 1-2%: the cache misses it is blamed for are
+ * paid either way by the glyph spans and the tint pass that walk the
+ * same buffer immediately afterwards. Leave it alone.
+ */
fz_clear_pixmap_with_value(ctx, pixmap, 0xFF);
if (document->cached_display_list != NULL ||
diff --git a/src/pdf_bridge.h b/src/pdf_bridge.h
index 40eaa46c..a3d2de89 100644
--- a/src/pdf_bridge.h
+++ b/src/pdf_bridge.h
@@ -198,9 +198,15 @@ int pardes_pdf_measure_render(
);
/*
- * Rasterize directly into an exactly-sized caller-owned packed RGBA buffer.
+ * Rasterize one horizontal BAND of a page — rows [band_y, band_y+band_height)
+ * of the raster `pardes_pdf_measure_render` describes — directly into an
+ * exactly-sized caller-owned packed RGBA buffer, `stride * band_height` bytes.
+ * A full page is the band `(0, height)`.
+ *
* MuPDF wraps but never owns or frees samples. Every successful pixel is
* opaque, including the caller-supplied highlights composited onto the page.
+ * The CTM does not depend on the band, so a band's rows are bit-identical to
+ * the same rows of the whole page: it is a clip, not a different rendering.
*/
int pardes_pdf_render_into(
pardes_pdf_document *document,
@@ -215,7 +221,9 @@ int pardes_pdf_render_into(
size_t samples_len,
int width,
int height,
- int stride
+ int stride,
+ int band_y,
+ int band_height
);
int pardes_pdf_page_text(
diff --git a/test/pdf_scroll_bench.zig b/test/pdf_scroll_bench.zig
index 7bd644e4..31aab270 100644
--- a/test/pdf_scroll_bench.zig
+++ b/test/pdf_scroll_bench.zig
@@ -1,9 +1,17 @@
-//! ReleaseFast scoreboard for FAST scrolling of the continuous PDF strip.
+//! Scoreboard for FAST scrolling of the continuous PDF strip.
//!
-//! zig build pdf-scroll-bench -Doptimize=ReleaseFast
+//! 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=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
@@ -75,6 +83,9 @@ const Config = struct {
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,
};
/// The bit-exact description of one scrolling run. `visual` is the invariant;
@@ -147,8 +158,11 @@ const Plan = struct {
};
pub fn main(init: std.process.Init) !void {
- if (!bench_config.release_fast_core)
- fatal("the production core must be ReleaseFast; add -Doptimize=ReleaseFast", .{});
+ 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);
@@ -157,16 +171,17 @@ pub fn main(init: std.process.Init) !void {
// viewport rather than of the machine.
const travel = probeTravel(config.path);
+ const cap = config.frames orelse max_frames;
const results = [_]Result{
- try measure(config, planFor(.fling_down, travel)),
- try measure(config, planFor(.fling_reverse, travel)),
- try measure(config, planFor(.slow_scroll, travel)),
+ try measure(config, planFor(.fling_down, travel, cap)),
+ try measure(config, planFor(.fling_reverse, travel, cap)),
+ try measure(config, planFor(.slow_scroll, travel, cap)),
};
if (config.json)
- reportJson(init.io, config, &results)
+ reportJson(init.io, config, travel, &results)
else
- reportText(config, &results);
+ reportText(config, travel, &results);
}
fn parseArgs(args: []const []const u8) Config {
@@ -184,8 +199,12 @@ fn parseArgs(args: []const []const u8) Config {
} 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 fatal(
- "usage: pardes-pdf-scroll-bench [--json] [--warmup N] [--reps N] [--path FILE]",
+ "usage: pardes-pdf-scroll-bench [--json] [--warmup N] [--reps N]" ++
+ " [--path FILE] [--frames N]",
.{},
);
}
@@ -205,6 +224,11 @@ const Travel = struct {
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,
};
fn probeTravel(path: []const u8) Travel {
@@ -227,20 +251,40 @@ fn probeTravel(path: []const u8) Travel {
// 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) * bench_cell_pixels.h;
const notch: f64 = @floatFromInt(bench_cell_pixels.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, @as(f64, @floatFromInt(pv.document_height -| viewport_h))),
+ .max_scroll = @max(notch, strip),
.per_frame = notch * @as(f64, @floatFromInt(fling_ticks_per_frame)),
+ .pages = pv.page_count,
+ .strip_px = pv.document_height,
};
}
-fn planFor(scenario: Scenario, travel: Travel) Plan {
- const down = @min(
- max_frames,
- @as(usize, @intFromFloat(@ceil(travel.max_scroll / travel.per_frame))),
- );
+/// 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) 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 },
- .fling_reverse => .{ .scenario = scenario, .frames = down * 2, .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_frames, .down_frames = slow_frames },
};
}
@@ -314,6 +358,9 @@ fn measure(config: Config, plan: Plan) !Result {
const elapsed = nowNs() -| started;
total += elapsed;
recorder.observe(frame, surface);
+ // 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;
@@ -369,6 +416,14 @@ const Recorder = struct {
};
}
+ /// 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);
@@ -385,31 +440,59 @@ const Recorder = struct {
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.iw);
- self.visual = mix(self.visual, place.ih);
- self.visual = mix(self.visual, place.rgba.len);
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.src, geometry.dst }) |rect| {
+ 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);
}
- } 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.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);
@@ -481,8 +564,15 @@ fn nowNs() u64 {
@as(u64, @intCast(ts.nsec));
}
-fn reportText(config: Config, results: []const Result) void {
- std.debug.print("pardes PDF fast-scroll benchmark (ReleaseFast)\n", .{});
+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, bench_cell_pixels.w, bench_cell_pixels.h,
+ },
+ );
std.debug.print(
"warmup: {d}, sampled reps: {d}; every number is ONE FRAME (notch batch + render + drain)\n\n",
.{ config.warmup, config.reps },
@@ -521,12 +611,16 @@ fn reportText(config: Config, results: []const Result) void {
const json_report_max_bytes = 16 * 1024;
-fn reportJson(io: std.Io, config: Config, results: []const Result) void {
+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\":\"ReleaseFast\",\"warmup\":{d},\"reps\":{d},\"results\":[",
- .{ config.warmup, config.reps },
+ "{{\"benchmark\":\"pardes-pdf-scroll\",\"build\":\"{s}\",\"path\":\"{s}\"," ++
+ "\"pages\":{d},\"strip_px\":{d},\"warmup\":{d},\"reps\":{d},\"results\":[",
+ .{
+ bench_config.core_optimize, config.path, travel.pages,
+ travel.strip_px, config.warmup, config.reps,
+ },
) catch return;
for (results, 0..) |result, i| out.print(
"{s}{{\"scenario\":\"{s}\",\"frames\":{d},\"notches_per_frame\":{d}," ++