Files
pdfcraft/crates/ui-egui/tests/perf.rs
T

195 lines
8.2 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! Frame-time budget on a 500-page document (parity view.large-document-performance): scrolling
//! continuously must keep the UI's own per-frame work far below a 60 fps frame. Rendering runs on
//! worker threads and is not part of the measurement.
use egui_kittest::Harness;
use pdfcraft_ui_egui::PdfCraftApp;
/// The one-minute load average, where the OS reports it (macOS `vm.loadavg`, Linux
/// `/proc/loadavg`).
fn load_average() -> Option<f64> {
if let Ok(s) = std::fs::read_to_string("/proc/loadavg") {
return s.split_whitespace().next()?.parse().ok();
}
let out = std::process::Command::new("sysctl").args(["-n", "vm.loadavg"]).output().ok()?;
String::from_utf8_lossy(&out.stdout).trim_matches(|c: char| c == '{' || c == '}' || c.is_whitespace()).split_whitespace().next()?.parse().ok()
}
/// Frame budgets only mean something on a machine that isn't overcommitted: with the load
/// average above twice the core count (other builds running), skip and say so.
fn overloaded() -> bool {
let cores = std::thread::available_parallelism().map_or(4, |n| n.get()) as f64;
match load_average() {
Some(l) if l > 2.0 * cores => {
eprintln!("PERF skipped: load average {l:.0} on {cores} cores; frame budgets need a quiet machine");
true
}
_ => false,
}
}
/// The budget tests take turns: each starts render workers, and measuring one while another
/// runs would charge it for the other's work on a small CI machine.
static SERIAL: std::sync::Mutex<()> = std::sync::Mutex::new(());
/// Generous for debug builds and shared CI machines; release builds are ~10× faster.
const BUDGET: std::time::Duration = std::time::Duration::from_millis(40);
/// The average frame over `frames` steps, best of up to three rounds: another process taking the
/// CPU only ever adds time, so the fastest round is the closest to the UI's own cost, and a real
/// regression is over budget in every round. Later rounds run only when a round is over budget.
fn best_average(label: &str, frames: usize, mut step: impl FnMut(usize)) -> std::time::Duration {
let mut best = std::time::Duration::MAX;
for round in 1..=3 {
let start = std::time::Instant::now();
let mut worst = std::time::Duration::ZERO;
for f in 0..frames {
let t = std::time::Instant::now();
step(f);
worst = worst.max(t.elapsed());
}
let avg = start.elapsed() / frames as u32;
eprintln!("PERF {label} round {round}: avg frame {avg:?}, worst {worst:?}");
best = best.min(avg);
if best < BUDGET {
break;
}
}
best
}
/// `n` text pages with a few comments each, so panels and overlays have work to do.
fn big(n: usize) -> Vec<u8> {
let mut objs: Vec<String> = vec!["<< /Type /Catalog /Pages 2 0 R >>".into()];
let kids: Vec<String> = (0..n).map(|i| format!("{} 0 R", 4 + 3 * i)).collect();
objs.push(format!("<< /Type /Pages /Kids [{}] /Count {n} /MediaBox [0 0 612 792] >>", kids.join(" ")));
objs.push("<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>".into());
for i in 0..n {
objs.push(format!(
"<< /Type /Page /Parent 2 0 R /Contents {} 0 R /Resources << /Font << /F1 3 0 R >> >> /Annots [{} 0 R] >>",
5 + 3 * i,
6 + 3 * i
));
let body: String =
(0..40).map(|l| format!("BT /F1 10 Tf 72 {} Td (Page {} line {l} with some words to lay out) Tj ET\n", 720 - l * 15, i + 1)).collect();
objs.push(format!("<< /Length {} >>\nstream\n{body}\nendstream", body.len()));
objs.push("<< /Type /Annot /Subtype /Square /Rect [72 72 144 144] /C [1 0 0] /T (Ada) /Contents (Check) >>".into());
}
pdf(objs)
}
fn pdf(objs: Vec<String>) -> Vec<u8> {
let mut out = b"%PDF-1.7\n".to_vec();
let mut offs = Vec::new();
for (i, o) in objs.iter().enumerate() {
offs.push(out.len());
out.extend_from_slice(format!("{} 0 obj\n{o}\nendobj\n", i + 1).as_bytes());
}
let x = out.len();
out.extend_from_slice(format!("xref\n0 {}\n0000000000 65535 f \n", objs.len() + 1).as_bytes());
for o in offs {
out.extend_from_slice(format!("{o:010} 00000 n \n").as_bytes());
}
out.extend_from_slice(format!("trailer\n<< /Size {} /Root 1 0 R >>\nstartxref\n{x}\n%%EOF\n", objs.len() + 1).as_bytes());
out
}
/// Named roots exercise thread lookups even when none of the comments has replies.
fn named_comments(n: usize) -> Vec<u8> {
let annots: Vec<String> = (0..n).map(|i| format!("{} 0 R", i + 4)).collect();
let mut objs = vec![
"<< /Type /Catalog /Pages 2 0 R >>".into(),
"<< /Type /Pages /Kids [3 0 R] /Count 1 >>".into(),
format!("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] /Resources << >> /Annots [{}] >>", annots.join(" ")),
];
for i in 0..n {
objs.push(format!(
"<< /Type /Annot /Subtype /Square /Rect [72 72 144 144] /C [1 0 0] /T (Ada) /NM (root-{i}) /Contents (Review note {i}) >>"
));
}
pdf(objs)
}
#[test]
fn collapsed_comments_with_ten_thousand_named_roots_stay_within_the_frame_budget() {
if overloaded() {
return;
}
let _turn = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let bytes = named_comments(10_000);
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("review.pdf", None, bytes).expect("opens");
app.set_option("left", "closed").unwrap();
app.set_option("panel", "comments").unwrap();
app.views[0].comments.collapsed_pages = vec![0];
app
});
h.run_steps(4);
{
let state = h.state();
let info = &state.session.get(state.views[0].id).unwrap().info;
assert_eq!(info.pages.len(), 1);
assert_eq!(info.annotations.len(), 10_000);
for a in &info.annotations {
assert_eq!(a.name.as_deref(), Some(format!("root-{}", a.index).as_str()));
assert!(a.in_reply_to.is_none());
assert!(a.state.is_none());
}
assert_eq!(state.views[0].comments.collapsed_pages, [0]);
}
// Folding removes card layout cost, so this measures filtering and thread lookup work.
let avg = best_average("collapsed named comments", 30, |_| h.step());
assert!(avg < BUDGET, "collapsed comments: average frame {avg:?}");
}
#[test]
fn scrolling_a_500_page_document_stays_within_the_frame_budget() {
if overloaded() {
return;
}
let _turn = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let bytes = big(500);
let t0 = std::time::Instant::now();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("big.pdf", None, bytes).expect("opens");
app
});
h.run_steps(4);
eprintln!("PERF open {:?}", t0.elapsed());
// Scroll through the document: 200 frames, jumping ~2 pages per frame.
let avg = best_average("scroll", 200, |f| {
h.state_mut().views[0].goto = Some(((f * 5) / 2 % 500, 0.3));
h.step();
});
assert!(avg < BUDGET, "average frame {avg:?}");
}
#[test]
fn panels_with_hundreds_of_items_stay_within_the_frame_budget() {
if overloaded() {
return;
}
let _turn = SERIAL.lock().unwrap_or_else(std::sync::PoisonError::into_inner);
let bytes = big(500);
for panel in ["comments", "pages", "fields"] {
let b = bytes.clone();
let mut h = Harness::builder().with_size(egui::vec2(1400.0, 900.0)).build_eframe(move |_cc| {
let mut app = PdfCraftApp::new();
app.set_option("language", "en").unwrap();
app.open_bytes("big.pdf", None, b).expect("opens");
app.set_option("panel", panel).unwrap();
app
});
h.run_steps(4);
let avg = best_average(panel, 100, |f| {
h.state_mut().views[0].goto = Some(((f * 5) % 500, 0.3));
h.step();
});
assert!(avg < BUDGET, "{panel}: average frame {avg:?}");
}
}