summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-08-02 23:04:07 -0300
committerGabriel Schneider <[email protected]>2026-08-10 09:17:07 -0300
commitf9faba2c3bda14ef7942fb089d6ce56e914b9109 (patch)
treec8c6fb7bab154ffd8e64b1b90b065b00c3a25251 /test
parentec19caddb60e748115adb562b46d007598dcdb95 (diff)
downloadpardes-f9faba2c3bda14ef7942fb089d6ce56e914b9109.tar.gz
pardes-f9faba2c3bda14ef7942fb089d6ce56e914b9109.zip
add PDF rendering performance harness
Diffstat (limited to 'test')
-rw-r--r--test/pdf_bench.zig555
1 files changed, 555 insertions, 0 deletions
diff --git a/test/pdf_bench.zig b/test/pdf_bench.zig
new file mode 100644
index 00000000..a1713d31
--- /dev/null
+++ b/test/pdf_bench.zig
@@ -0,0 +1,555 @@
+//! Headless MuPDF raster scoreboard.
+//!
+//! zig build pdf-bench
+//! zig build pdf-bench -- --json
+//! zig build pdf-bench -- --reps 25 --warmup 4
+//! zig build pdf-bench -- --path other.pdf --page 2 --pages 6
+//!
+//! The benchmark links the real src/pdf.zig wrapper and C exception bridge in
+//! ReleaseFast. Timed regions include MuPDF rasterization, RGBA allocation and
+//! RGB-to-RGBA expansion; checksum/free/reporting stay outside. "cold" means a
+//! fresh Document and MuPDF page cache (the OS filesystem cache is deliberately
+//! uncontrolled), while warm scenarios retain one Document across rounds.
+const std = @import("std");
+const pdf = @import("mupdf");
+
+pub const std_options: std.Options = .{ .log_level = .err };
+
+const gpa = std.heap.page_allocator;
+const default_reps: usize = 25;
+const default_warmup: usize = 3;
+const default_multi_pages: usize = 8;
+const checksum_seed: u64 = 0x7061_7264_6573_5044;
+
+const Config = struct {
+ path: []const u8 = "docs/design.pdf",
+ page: usize = 0,
+ reps: usize = default_reps,
+ warmup: usize = default_warmup,
+ multi_pages: usize = default_multi_pages,
+ json: bool = false,
+};
+
+const Profile = struct {
+ id: []const u8,
+ description: []const u8,
+ viewport_w: u32,
+ viewport_h: u32,
+ request: pdf.RenderRequest,
+};
+
+const profiles = [_]Profile{
+ .{
+ .id = "kitty-96",
+ .description = "Kitty 96 DPI, bandwidth-capped raster",
+ .viewport_w = 0,
+ .viewport_h = 0,
+ .request = .{ .dpi = 96, .max_dimension = 1200 },
+ },
+ .{
+ .id = "sdl-192-1600x900",
+ .description = "SDL 192 DPI, physical 1600x900 viewport minimum",
+ .viewport_w = 1600,
+ .viewport_h = 900,
+ .request = .{
+ .dpi = 192,
+ .minimum_width = 1600,
+ .minimum_height = 900,
+ .max_dimension = 4096,
+ },
+ },
+};
+
+/// Kept orthogonal so a later benchmark can add another pixel treatment
+/// without changing lifecycle/cold-cache accounting.
+const Decoration = enum { plain, highlighted };
+const Lifecycle = enum { cold_document_page, warm_page, warm_document_pass };
+
+const Scenario = struct {
+ id: []const u8,
+ lifecycle: Lifecycle,
+ decoration: Decoration,
+};
+
+const scenarios = [_]Scenario{
+ .{ .id = "cold-document-plain", .lifecycle = .cold_document_page, .decoration = .plain },
+ .{ .id = "warm-page-plain", .lifecycle = .warm_page, .decoration = .plain },
+ .{ .id = "warm-page-highlighted", .lifecycle = .warm_page, .decoration = .highlighted },
+ .{ .id = "warm-document-pass", .lifecycle = .warm_document_pass, .decoration = .plain },
+};
+
+const highlights = [_]pdf.Highlight{
+ pdf.Highlight.init(.{
+ .ul = .{ .x = 0.10, .y = 0.12 },
+ .ur = .{ .x = 0.90, .y = 0.12 },
+ .ll = .{ .x = 0.10, .y = 0.20 },
+ .lr = .{ .x = 0.90, .y = 0.20 },
+ }, .{ 0xff, 0xd5, 0x4f, 0x70 }, .search),
+ pdf.Highlight.init(.{
+ .ul = .{ .x = 0.18, .y = 0.46 },
+ .ur = .{ .x = 0.82, .y = 0.46 },
+ .ll = .{ .x = 0.18, .y = 0.54 },
+ .lr = .{ .x = 0.82, .y = 0.54 },
+ }, .{ 0x55, 0xa7, 0xff, 0x78 }, .selection),
+};
+
+const Identity = struct {
+ pixels: u64 = 0,
+ rgba_bytes: u64 = 0,
+ checksum: u64 = checksum_seed,
+
+ fn eql(a: Identity, b: Identity) bool {
+ return a.pixels == b.pixels and a.rgba_bytes == b.rgba_bytes and
+ a.checksum == b.checksum;
+ }
+};
+
+const Timed = struct { ns: u64, identity: Identity };
+
+const Result = struct {
+ profile: usize,
+ scenario: usize,
+ identity: Identity,
+ min_ns: u64,
+ median_ns: u64,
+ p90_ns: u64,
+ max_ns: u64,
+ sampled_ns: u64,
+};
+
+pub fn main(init: std.process.Init) !void {
+ const args = try init.minimal.args.toSlice(init.arena.allocator());
+ const config = parseArgs(args);
+
+ var probe = pdf.Document.open(config.path) catch |err|
+ fatal("cannot open {s}: {t}", .{ config.path, err });
+ const document_pages = probe.pages;
+ probe.deinit();
+ if (config.page >= document_pages)
+ fatal("--page {d} is outside the document's {d} pages", .{ config.page + 1, document_pages });
+ const multi_pages = @min(config.multi_pages, document_pages);
+
+ var results: [profiles.len * scenarios.len]Result = undefined;
+ for (profiles, 0..) |profile, pi| {
+ for (scenarios, 0..) |scenario, si| {
+ results[pi * scenarios.len + si] = try measure(
+ config,
+ pi,
+ profile,
+ si,
+ scenario,
+ multi_pages,
+ );
+ }
+ checkProfileResults(results[pi * scenarios.len ..][0..scenarios.len], profile);
+ }
+
+ if (config.json)
+ reportJson(init.io, config, document_pages, multi_pages, &results)
+ else
+ reportText(init.io, config, document_pages, multi_pages, &results);
+}
+
+fn parseArgs(args: []const []const u8) Config {
+ var config: Config = .{};
+ var i: usize = 1;
+ while (i < args.len) : (i += 1) {
+ const arg = args[i];
+ if (std.mem.eql(u8, arg, "--json")) {
+ config.json = true;
+ } else if (std.mem.eql(u8, arg, "--reps")) {
+ i += 1;
+ if (i >= args.len) usage();
+ config.reps = parsePositive("--reps", args[i], 1000);
+ } else if (std.mem.eql(u8, arg, "--warmup")) {
+ i += 1;
+ if (i >= args.len) usage();
+ config.warmup = std.fmt.parseUnsigned(usize, args[i], 10) catch
+ fatal("--warmup expects an integer", .{});
+ if (config.warmup > 100) fatal("--warmup must be at most 100", .{});
+ } else if (std.mem.eql(u8, arg, "--path")) {
+ i += 1;
+ if (i >= args.len or args[i].len == 0) usage();
+ config.path = args[i];
+ } else if (std.mem.eql(u8, arg, "--page")) {
+ i += 1;
+ if (i >= args.len) usage();
+ config.page = parsePositive("--page", args[i], std.math.maxInt(usize)) - 1;
+ } else if (std.mem.eql(u8, arg, "--pages")) {
+ i += 1;
+ if (i >= args.len) usage();
+ config.multi_pages = parsePositive("--pages", args[i], 10_000);
+ } else if (std.mem.eql(u8, arg, "--help") or std.mem.eql(u8, arg, "-h")) {
+ printUsage();
+ std.process.exit(0);
+ } else usage();
+ }
+ return config;
+}
+
+fn parsePositive(name: []const u8, text: []const u8, maximum: usize) usize {
+ const value = std.fmt.parseUnsigned(usize, text, 10) catch
+ fatal("{s} expects a positive integer", .{name});
+ if (value == 0 or value > maximum)
+ fatal("{s} must be in 1..{d}", .{ name, maximum });
+ return value;
+}
+
+fn usage() noreturn {
+ printUsage();
+ std.process.exit(2);
+}
+
+fn printUsage() void {
+ std.debug.print(
+ "usage: pardes-pdf-bench [--json] [--reps N] [--warmup N] " ++
+ "[--path FILE] [--page N] [--pages N]\n",
+ .{},
+ );
+}
+
+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));
+}
+
+fn measure(
+ config: Config,
+ profile_index: usize,
+ profile: Profile,
+ scenario_index: usize,
+ scenario: Scenario,
+ multi_pages: usize,
+) !Result {
+ const samples = try gpa.alloc(u64, config.reps);
+ defer gpa.free(samples);
+ var sample_at: usize = 0;
+ var expected: ?Identity = null;
+ var sampled_ns: u64 = 0;
+ const rounds = config.warmup + config.reps;
+
+ if (scenario.lifecycle == .cold_document_page) {
+ for (0..rounds) |round| {
+ const timed = try timeCold(config.path, config.page, profile.request, scenario.decoration);
+ try verifyIdentity(&expected, timed.identity, profile, scenario, round);
+ if (round >= config.warmup) {
+ samples[sample_at] = timed.ns;
+ sample_at += 1;
+ sampled_ns +|= timed.ns;
+ }
+ }
+ } else {
+ var document = try pdf.Document.open(config.path);
+ defer document.deinit();
+ for (0..rounds) |round| {
+ const timed = switch (scenario.lifecycle) {
+ .warm_page => try timePage(
+ &document,
+ config.page,
+ profile.request,
+ scenario.decoration,
+ ),
+ .warm_document_pass => try timeDocumentPass(
+ &document,
+ multi_pages,
+ profile.request,
+ scenario.decoration,
+ ),
+ .cold_document_page => unreachable,
+ };
+ try verifyIdentity(&expected, timed.identity, profile, scenario, round);
+ if (round >= config.warmup) {
+ samples[sample_at] = timed.ns;
+ sample_at += 1;
+ sampled_ns +|= timed.ns;
+ }
+ }
+ }
+
+ std.debug.assert(sample_at == samples.len);
+ std.mem.sort(u64, samples, {}, std.sort.asc(u64));
+ const p90_index = @min(samples.len - 1, (samples.len * 90 + 99) / 100 - 1);
+ return .{
+ .profile = profile_index,
+ .scenario = scenario_index,
+ .identity = expected.?,
+ .min_ns = samples[0],
+ .median_ns = samples[samples.len / 2],
+ .p90_ns = samples[p90_index],
+ .max_ns = samples[samples.len - 1],
+ .sampled_ns = sampled_ns,
+ };
+}
+
+fn timeCold(
+ path: []const u8,
+ page: usize,
+ request: pdf.RenderRequest,
+ decoration: Decoration,
+) !Timed {
+ const started = nowNs();
+ var document = try pdf.Document.open(path);
+ errdefer document.deinit();
+ const rendered = try renderPage(&document, page, request, decoration);
+ const elapsed = @max(@as(u64, 1), nowNs() -| started);
+ const identity = try consumeRender(rendered, page, .{});
+ document.deinit();
+ return .{ .ns = elapsed, .identity = identity };
+}
+
+fn timePage(
+ document: *pdf.Document,
+ page: usize,
+ request: pdf.RenderRequest,
+ decoration: Decoration,
+) !Timed {
+ const started = nowNs();
+ const rendered = try renderPage(document, page, request, decoration);
+ const elapsed = @max(@as(u64, 1), nowNs() -| started);
+ return .{
+ .ns = elapsed,
+ .identity = try consumeRender(rendered, page, .{}),
+ };
+}
+
+fn timeDocumentPass(
+ document: *pdf.Document,
+ page_count: usize,
+ request: pdf.RenderRequest,
+ decoration: Decoration,
+) !Timed {
+ const rendered = try gpa.alloc(pdf.Render, page_count);
+ defer gpa.free(rendered);
+ var elapsed: u64 = 0;
+ {
+ var initialized: usize = 0;
+ errdefer for (rendered[0..initialized]) |item| gpa.free(item.rgba);
+ const started = nowNs();
+ for (rendered, 0..) |*item, page| {
+ item.* = try renderPage(document, page, request, decoration);
+ initialized += 1;
+ }
+ elapsed = @max(@as(u64, 1), nowNs() -| started);
+ }
+ var identity: Identity = .{};
+ for (rendered, 0..) |item, page|
+ identity = try consumeRender(item, page, identity);
+ return .{ .ns = elapsed, .identity = identity };
+}
+
+fn renderPage(
+ document: *pdf.Document,
+ page: usize,
+ request: pdf.RenderRequest,
+ decoration: Decoration,
+) !pdf.Render {
+ return switch (decoration) {
+ .plain => document.renderAt(gpa, page, request),
+ .highlighted => document.renderWithHighlightsAt(gpa, page, request, &highlights),
+ };
+}
+
+/// Hash every output byte after the timer stops. Reporting and cross-round
+/// equality make the pixels observable, while keeping checksum cost out of the
+/// renderer latency. Page/dimension mixing makes a multi-page pass ordered.
+fn consumeRender(rendered: pdf.Render, page: usize, prior: Identity) !Identity {
+ defer gpa.free(rendered.rgba);
+ const pixels = std.math.mul(usize, rendered.width, rendered.height) catch
+ return error.BadRasterDimensions;
+ const expected_bytes = std.math.mul(usize, pixels, 4) catch
+ return error.BadRasterDimensions;
+ if (rendered.rgba.len != expected_bytes) return error.BadRasterLength;
+ var checksum = std.hash.Wyhash.hash(
+ prior.checksum ^ (@as(u64, page) *% 0x9e37_79b9_7f4a_7c15),
+ rendered.rgba,
+ );
+ checksum ^= @as(u64, rendered.width) *% 0xbf58_476d_1ce4_e5b9;
+ checksum ^= @as(u64, rendered.height) *% 0x94d0_49bb_1331_11eb;
+ return .{
+ .pixels = std.math.add(u64, prior.pixels, @intCast(pixels)) catch
+ return error.CounterOverflow,
+ .rgba_bytes = std.math.add(u64, prior.rgba_bytes, @intCast(expected_bytes)) catch
+ return error.CounterOverflow,
+ .checksum = checksum,
+ };
+}
+
+fn verifyIdentity(
+ expected: *?Identity,
+ got: Identity,
+ profile: Profile,
+ scenario: Scenario,
+ round: usize,
+) !void {
+ if (expected.*) |want| {
+ if (!want.eql(got)) {
+ std.debug.print(
+ "pdf-bench: unstable pixels in {s}/{s} round {d}: " ++
+ "expected {x:0>16}, got {x:0>16}\n",
+ .{ profile.id, scenario.id, round, want.checksum, got.checksum },
+ );
+ return error.UnstableRaster;
+ }
+ } else expected.* = got;
+}
+
+fn checkProfileResults(results: []const Result, profile: Profile) void {
+ std.debug.assert(results.len == scenarios.len);
+ const cold = results[0].identity;
+ const warm = results[1].identity;
+ const marked = results[2].identity;
+ if (!cold.eql(warm))
+ fatal("{s}: cold and warm plain renders produced different pixels", .{profile.id});
+ if (warm.pixels != marked.pixels or warm.rgba_bytes != marked.rgba_bytes)
+ fatal("{s}: highlighted render changed raster dimensions", .{profile.id});
+ if (warm.checksum == marked.checksum)
+ fatal("{s}: highlighted render did not change pixels", .{profile.id});
+}
+
+fn medianMpixPerSecond(result: Result) f64 {
+ return @as(f64, @floatFromInt(result.identity.pixels)) * 1000.0 /
+ @as(f64, @floatFromInt(result.median_ns));
+}
+
+fn medianGibPerSecond(result: Result) f64 {
+ return @as(f64, @floatFromInt(result.identity.rgba_bytes)) * 1_000_000_000.0 /
+ (@as(f64, @floatFromInt(result.median_ns)) * 1024.0 * 1024.0 * 1024.0);
+}
+
+fn ms(ns: u64) f64 {
+ return @as(f64, @floatFromInt(ns)) / 1_000_000.0;
+}
+
+fn mib(bytes: u64) f64 {
+ return @as(f64, @floatFromInt(bytes)) / (1024.0 * 1024.0);
+}
+
+fn reportText(
+ io: std.Io,
+ config: Config,
+ document_pages: usize,
+ multi_pages: usize,
+ results: []const Result,
+) void {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ out.print(
+ gpa,
+ "pardes PDF render benchmark (ReleaseFast)\n" ++
+ "path: {s} ({d} pages), selected page: {d}, multi-page pass: {d} pages\n" ++
+ "warmup: {d}, sampled reps: {d}; cold = fresh Document (OS cache uncontrolled)\n\n",
+ .{ config.path, document_pages, config.page + 1, multi_pages, config.warmup, config.reps },
+ ) catch return;
+ out.appendSlice(
+ gpa,
+ "profile scenario min ms median p90 max ms MPix/s GiB/s pixels/op RGBA MiB checksum\n" ++
+ "-----------------------------------------------------------------------------------------------------------------------------------\n",
+ ) catch return;
+ for (results) |result| {
+ const profile = profiles[result.profile];
+ const scenario = scenarios[result.scenario];
+ out.print(
+ gpa,
+ "{s:<20} {s:<26} {d:>8.2} {d:>8.2} {d:>8.2} {d:>8.2} {d:>7.1} {d:>6.2} {d:>10} {d:>9.2} {x:0>16}\n",
+ .{
+ profile.id,
+ scenario.id,
+ ms(result.min_ns),
+ ms(result.median_ns),
+ ms(result.p90_ns),
+ ms(result.max_ns),
+ medianMpixPerSecond(result),
+ medianGibPerSecond(result),
+ result.identity.pixels,
+ mib(result.identity.rgba_bytes),
+ result.identity.checksum,
+ },
+ ) catch return;
+ }
+ out.appendSlice(gpa, "\nrequests:\n") catch return;
+ for (profiles) |profile| {
+ out.print(
+ gpa,
+ " {s}: dpi={d}, viewport-min={d}x{d}, max-dimension={d} — {s}\n",
+ .{
+ profile.id,
+ profile.request.dpi,
+ profile.viewport_w,
+ profile.viewport_h,
+ profile.request.max_dimension,
+ profile.description,
+ },
+ ) catch return;
+ }
+ std.Io.File.stdout().writeStreamingAll(io, out.items) catch {};
+}
+
+fn reportJson(
+ io: std.Io,
+ config: Config,
+ document_pages: usize,
+ multi_pages: usize,
+ results: []const Result,
+) void {
+ var out: std.ArrayList(u8) = .empty;
+ defer out.deinit(gpa);
+ const path_json = std.json.Stringify.valueAlloc(gpa, config.path, .{}) catch return;
+ defer gpa.free(path_json);
+ out.print(
+ gpa,
+ "{{\"benchmark\":\"pardes-pdf-render\",\"build\":\"ReleaseFast\"," ++
+ "\"path\":{s},\"document_pages\":{d},\"selected_page\":{d}," ++
+ "\"multi_pages\":{d},\"warmup\":{d},\"reps\":{d},\"profiles\":[",
+ .{ path_json, document_pages, config.page + 1, multi_pages, config.warmup, config.reps },
+ ) catch return;
+ for (profiles, 0..) |profile, i| {
+ out.print(
+ gpa,
+ "{s}{{\"id\":\"{s}\",\"dpi\":{d},\"minimum_width\":{d}," ++
+ "\"minimum_height\":{d},\"max_dimension\":{d}}}",
+ .{
+ if (i == 0) "" else ",",
+ profile.id,
+ profile.request.dpi,
+ profile.request.minimum_width,
+ profile.request.minimum_height,
+ profile.request.max_dimension,
+ },
+ ) catch return;
+ }
+ out.appendSlice(gpa, "],\"results\":[") catch return;
+ for (results, 0..) |result, i| {
+ out.print(
+ gpa,
+ "{s}{{\"profile\":\"{s}\",\"scenario\":\"{s}\"," ++
+ "\"min_ns\":{d},\"median_ns\":{d},\"p90_ns\":{d}," ++
+ "\"max_ns\":{d},\"sampled_ns\":{d},\"pixels\":{d}," ++
+ "\"rgba_bytes\":{d},\"median_mpix_s\":{d:.3}," ++
+ "\"median_gib_s\":{d:.3},\"checksum\":\"{x:0>16}\"}}",
+ .{
+ if (i == 0) "" else ",",
+ profiles[result.profile].id,
+ scenarios[result.scenario].id,
+ result.min_ns,
+ result.median_ns,
+ result.p90_ns,
+ result.max_ns,
+ result.sampled_ns,
+ result.identity.pixels,
+ result.identity.rgba_bytes,
+ medianMpixPerSecond(result),
+ medianGibPerSecond(result),
+ result.identity.checksum,
+ },
+ ) catch return;
+ }
+ out.appendSlice(gpa, "]}\n") catch return;
+ std.Io.File.stdout().writeStreamingAll(io, out.items) catch {};
+}
+
+fn fatal(comptime format: []const u8, args: anytype) noreturn {
+ std.debug.print("pdf-bench: " ++ format ++ "\n", args);
+ std.process.exit(1);
+}