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:
parent
c24ed6e94a
commit
b3249bfb57
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)");
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue