lightcraft/crates/ui-egui/examples/grid_bench.rs

172 lines
6.7 KiB
Rust

//! Unchanged-frame cost of the photo grid (issue #35), on synthetic libraries:
//! `cargo run --release -p lightcraft-ui-egui --example grid_bench`
//! (`SIZES=200,10000,85000` photos, `FRAMES=60` measured frames per scenario).
//!
//! Each scenario builds a library of N photos (mixed aspect ratios, ~40 photos per capture day,
//! a three-photo stack every 100 photos), shows it in the Photo Grid (justified rows) or the
//! Square Grid, with All Photos or a recursive Local folder view as the source, settles (missing
//! files: thumbnails fail fast, so no decoding is timed), scrolls to the middle and then times
//! frames in which nothing changes. Prints per-frame wall-clock (min / median / p95 of the whole
//! app frame and of the grid panel alone), heap allocations per frame on the UI thread, and the
//! grid's own counters (cells visited, layout / view / index rebuilds).
use std::alloc::{GlobalAlloc, Layout, System};
use std::cell::Cell;
use std::time::{Duration, Instant};
use lightcraft_catalog::{Op, Photo, PhotoId, Source, Stack, StackId};
use lightcraft_engine::{Browse, LibrarySource, Session};
use lightcraft_ui_egui::headless::Headless;
use lightcraft_ui_egui::state::ViewMode;
use lightcraft_ui_egui::{LightcraftApp, Services};
/// Counts allocations made by the current thread (the UI thread is the one measured).
struct Counting;
thread_local! {
static ALLOCS: Cell<u64> = const { Cell::new(0) };
static BYTES: Cell<u64> = const { Cell::new(0) };
}
fn count(size: usize) {
let _ = ALLOCS.try_with(|n| n.set(n.get() + 1));
let _ = BYTES.try_with(|n| n.set(n.get() + size as u64));
}
// SAFETY: every call is forwarded unchanged to the system allocator; the counters are plain
// thread-locals that never allocate. Dev-only example.
#[allow(unsafe_code)]
unsafe impl GlobalAlloc for Counting {
unsafe fn alloc(&self, l: Layout) -> *mut u8 {
count(l.size());
// SAFETY: same contract as ours.
unsafe { System.alloc(l) }
}
unsafe fn alloc_zeroed(&self, l: Layout) -> *mut u8 {
count(l.size());
// SAFETY: same contract as ours.
unsafe { System.alloc_zeroed(l) }
}
unsafe fn realloc(&self, p: *mut u8, l: Layout, new: usize) -> *mut u8 {
count(new);
// SAFETY: same contract as ours.
unsafe { System.realloc(p, l, new) }
}
unsafe fn dealloc(&self, p: *mut u8, l: Layout) {
// SAFETY: same contract as ours.
unsafe { System.dealloc(p, l) }
}
}
#[global_allocator]
static A: Counting = Counting;
fn allocs() -> (u64, u64) {
(ALLOCS.with(Cell::get), BYTES.with(Cell::get))
}
const ROOT: &str = "/lightcraft-grid-bench/root";
fn library(n: usize, local: bool) -> Session {
let mut s = Session::new();
let shapes = [(6000, 4000), (4000, 6000), (4000, 4000), (6000, 3375), (3000, 4000)];
for i in 0..n {
let id = PhotoId(i as u64 + 1);
let (w, h) = shapes[i % shapes.len()];
let path = format!("{ROOT}/sub{}/IMG_{i:06}.jpg", i / 1000);
let mut p = Photo::new(id, Source::File { path }, &format!("IMG_{i:06}.jpg"), "JPEG", w, h, "2026-01-01T00:00:00");
// ~40 photos a day, going back in time
let day = i / 40;
let (y, m, d) = (2026 - (day / 336) as i32, 12 - (day / 28) % 12, 28 - day % 28);
p.captured = Some(format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:00", 8 + (i % 40) / 4, i % 60));
p.local = local;
let _ = s.catalog.apply(Op::AddPhoto { photo: Box::new(p) });
}
for k in 0..n / 100 {
let base = k as u64 * 100 + 1;
let stack = Stack { id: StackId(k as u64 + 1), photos: vec![PhotoId(base), PhotoId(base + 1), PhotoId(base + 2)], collapsed: false };
let _ = s.catalog.apply(Op::AddStack { stack });
}
if local {
s.source = LibrarySource::Folder;
s.browse = Some(Browse { path: ROOT.into(), subfolders: true });
}
s
}
struct Dist {
min: f64,
med: f64,
p95: f64,
}
fn dist(mut v: Vec<f64>) -> Dist {
v.sort_by(f64::total_cmp);
let at = |q: f64| v.get(((v.len() as f64 - 1.0) * q).round() as usize).copied().unwrap_or(0.0);
Dist { min: at(0.0), med: at(0.5), p95: at(0.95) }
}
fn scenario(n: usize, view: ViewMode, local: bool, frames: usize) {
let t0 = Instant::now();
let session = library(n, local);
let services = Services { png: None, ..Default::default() };
let app = LightcraftApp::new(session, services);
let mut h = Headless::new(app, [1400.0, 900.0], 1.0);
h.app.ui.view = view;
h.app.ui.thumb_size = 160.0;
h.settle(Duration::from_secs(120));
// to the middle of the grid (and let the newly visible thumbnails fail / settle)
if let Some(mid) = h.app.session.visible().get(n / 2).copied() {
h.app.session.selection.active = Some(mid);
h.app.session.selection.ids = vec![mid];
}
h.settle(Duration::from_secs(120));
let setup = t0.elapsed();
let stats0 = h.app.caches.grid_stats.clone();
let mut frame_ms = Vec::with_capacity(frames);
let mut grid_ms = Vec::with_capacity(frames);
let (a0, b0) = allocs();
for _ in 0..frames {
let t = Instant::now();
h.step();
frame_ms.push(t.elapsed().as_secs_f64() * 1e3);
grid_ms.push(h.app.caches.grid_stats.last_show.as_secs_f64() * 1e3);
}
let (a1, b1) = allocs();
let st = &h.app.caches.grid_stats;
let f = dist(frame_ms);
let g = dist(grid_ms);
let name = format!("{n:>6} {:<7} {:<5}", if view == ViewMode::SquareGrid { "square" } else { "photo" }, if local { "local" } else { "all" });
println!(
"{name} frame {:>7.2} / {:>7.2} / {:>7.2} ms grid {:>7.2} / {:>7.2} / {:>7.2} ms allocs/frame {:>7} ({:>9} B) cells/frame {:>6} rebuilds view {} layout {} stacks {} (setup {:.1} s)",
f.min,
f.med,
f.p95,
g.min,
g.med,
g.p95,
(a1 - a0) / frames as u64,
(b1 - b0) / frames as u64,
(st.cells_visited - stats0.cells_visited) / frames as u64,
st.view_builds - stats0.view_builds,
st.layout_builds - stats0.layout_builds,
st.stack_index_builds - stats0.stack_index_builds,
setup.as_secs_f64(),
);
}
fn main() {
let sizes: Vec<usize> = std::env::var("SIZES")
.ok()
.map(|s| s.split(',').filter_map(|x| x.trim().parse().ok()).collect())
.unwrap_or_else(|| vec![200, 10_000, 85_000]);
let frames = std::env::var("FRAMES").ok().and_then(|s| s.parse().ok()).unwrap_or(60);
println!("photos layout source frame min / median / p95 grid min / median / p95");
for &n in &sizes {
for view in [ViewMode::PhotoGrid, ViewMode::SquareGrid] {
for local in [false, true] {
scenario(n, view, local, frames);
}
}
}
}