Perf: cache the photo grid's layout — 43 → 5 ms per frame at 100k photos

The justified layout (and the aspect ratio of every photo it needs) was rebuilt every frame.
It is now kept in LightcraftApp::caches, keyed by catalog revision, the photos in view, the
width, the thumbnail size, the mode and the date groups. Headless measurement (ignored test
grid_frame_100k, machine under load): 43.1 → 5.3 ms on the UI thread.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Brandon Thomas 2026-10-03 00:21:26 -04:00
parent c24ed6e94a
commit b3249bfb57
3 changed files with 67 additions and 16 deletions

View File

@ -634,6 +634,8 @@ pub struct Caches {
counts: Option<(u64, LibraryCounts)>,
date_groups: Option<(u64, std::sync::Arc<Vec<lightcraft_catalog::DateGroup>>)>,
filter_values: Option<(u64, std::sync::Arc<FilterValues>)>,
/// The grid's layout (by photos, shapes, width, thumbnail size, grouping).
pub grid_layout: Option<(u64, std::sync::Arc<panels::grid::GridLayout>)>,
}
/// The left panel's counts.
@ -652,7 +654,7 @@ pub struct FilterValues {
pub keywords: Vec<String>,
}
fn key_of(parts: impl std::hash::Hash) -> u64 {
pub(crate) fn key_of(parts: impl std::hash::Hash) -> u64 {
use std::hash::{BuildHasher, BuildHasherDefault, DefaultHasher};
BuildHasherDefault::<DefaultHasher>::default().hash_one(parts)
}

View File

@ -59,20 +59,6 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
let square = app.ui.view == ViewMode::SquareGrid;
let target = app.ui.thumb_size;
let avail_w = ui.available_width() - 8.0;
let aspects: Vec<f32> = if square {
vec![1.0; ids.len()]
} else {
ids.iter()
.map(|id| {
let p = app.session.catalog.photo(*id);
let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0));
let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes());
let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT);
let (w, h) = if swap { (h, w) } else { (w, h) };
(w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3)
})
.collect()
};
let by = resolve_group(app.session.sort.group, target);
let runs = if app.session.source == lightcraft_engine::LibrarySource::RecentlyDeleted {
Vec::new()
@ -85,7 +71,30 @@ pub fn show(app: &mut LightcraftApp, ui: &mut egui::Ui) {
(*app.caches.date_runs(&app.session.catalog, &ids, key, by)).clone()
};
let spans: Vec<(usize, usize)> = runs.iter().map(|r| (r.start, r.count)).collect();
let lay = layout(&aspects, &spans, avail_w, target, square);
// the layout only changes with the photos, their shapes, the width and the thumbnail size
let lay_key = crate::key_of((app.session.catalog.revision, &ids, avail_w.to_bits(), target.to_bits(), square, &spans));
let lay = match &app.caches.grid_layout {
Some((k, l)) if *k == lay_key => l.clone(),
_ => {
let aspects: Vec<f32> = if square {
vec![1.0; ids.len()]
} else {
ids.iter()
.map(|id| {
let p = app.session.catalog.photo(*id);
let (w, h) = p.map(|p| (p.width.max(1) as f32, p.height.max(1) as f32)).unwrap_or((3.0, 2.0));
let swap = p.is_some_and(|p| p.develop.orientation.swaps_axes());
let crop = p.map(|p| p.develop.crop.geometry.rect).unwrap_or(lightcraft_geom::Rect::UNIT);
let (w, h) = if swap { (h, w) } else { (w, h) };
(w * crop.width() as f32) / (h * crop.height() as f32).max(1e-3)
})
.collect()
};
let l = std::sync::Arc::new(layout(&aspects, &spans, avail_w, target, square));
app.caches.grid_layout = Some((lay_key, l.clone()));
l
}
};
struct Cell {
id: PhotoId,
rect: Rect,

View File

@ -462,3 +462,43 @@ fn second_window_shows_the_active_photo() {
assert!(!h.app.ui.second_window);
h.settle(SETTLE);
}
/// Frame time of the photo grid on a 100k-photo library (ignored:
/// `cargo test --release -p lightcraft-ui-egui -- --ignored grid_frame_100k --nocapture`).
#[test]
#[ignore]
fn grid_frame_100k() {
use lightcraft_catalog::{Op, Photo, PhotoId, Source};
let mut session = lightcraft_engine::Session::new();
let ops = (0..100_000u64)
.map(|i| {
let mut p = Photo::new(
PhotoId(i + 1),
Source::Demo { scene: (i % 20) as u32 },
&format!("IMG_{i:06}.jpg"),
"JPEG",
6000,
4000 - (i % 3) as u32 * 1000,
"2026-01-01T00:00:00",
);
p.captured = Some(format!("20{:02}-{:02}-{:02}T10:00:00", 10 + i % 16, 1 + i % 12, 1 + i % 28));
p.meta.keywords = vec![format!("kw{}", i % 300)];
Op::AddPhoto { photo: Box::new(p) }
})
.collect();
session.commit("Add", Op::Batch { ops }).unwrap();
let app = LightcraftApp::new(session, Services { png: None, ..Default::default() });
let mut h = Headless::new(app, [1600.0, 1000.0], 1.0);
h.app.ui.view = crate::state::ViewMode::PhotoGrid;
h.app.ui.left_panel = true;
for _ in 0..5 {
h.step();
}
let t = std::time::Instant::now();
let n = 30;
for _ in 0..n {
h.step();
}
let ms = t.elapsed().as_secs_f64() * 1e3 / n as f64;
eprintln!("grid frame at 100k photos: {ms:.1} ms (UI thread, renders excluded)");
}