#323: the windows of a big photo share one device copy of its source

Keeping a 288 MB upload with each view's stages made them thrash against the stage
budget; not keeping it made every new window (a pan crossing a snap, the first
window of the Before side, any control that invalidates the sampled pixels) upload
and convert the source again, about 64 ms against 6-12 ms. A source too big for a
view's stages is now kept once for all views of the photo (shared_source), outside
the views' stages, and given back when the app idles (trim_pool(0)).
source_uploads and shared_source_bytes expose it for tests and diagnostics.

Co-Authored-By: Claude Sonnet 5.5 <[email protected]>
This commit is contained in:
Nathaniel LedfordandClaude Sonnet 5.5 committed 2026-10-08 18:28:45 -04:00
1 parent e9fb4b08e1
commit f68cd69b1e
3 files changed
+100 -3

No files matched your search

+27
View File
@@ -270,12 +270,39 @@ pub fn set_pool_limit(bytes: u64) {
pub fn trim_pool(keep: u64) {
#[cfg(not(target_arch = "wasm32"))]
if let Some(g) = existing_device() {
if keep == 0 {
render::release_shared_source();
}
g.trim(keep);
}
#[cfg(target_arch = "wasm32")]
let _ = keep;
}
/// Device bytes of the one big source (a zoom window's original) shared by every view of the photo.
pub fn shared_source_bytes() -> usize {
#[cfg(not(target_arch = "wasm32"))]
{
render::shared_source_bytes()
}
#[cfg(target_arch = "wasm32")]
{
0
}
}
/// Big sources uploaded to the shared copy so far (a diagnostic: one big photo is uploaded once, not once per window).
pub fn source_uploads() -> u64 {
#[cfg(not(target_arch = "wasm32"))]
{
render::source_uploads()
}
#[cfg(target_arch = "wasm32")]
{
0
}
}
/// Device bytes held by a view's GPU stages (kept with its [`StageCache`]); 0 when it has none.
pub fn stage_bytes(stages: &StageCache) -> usize {
#[cfg(not(target_arch = "wasm32"))]
+41 -3
View File
@@ -417,6 +417,43 @@ const COVER_ROWS: usize = 16;
/// The largest source (bytes on the device) a view's stages keep uploaded between renders.
const RETAIN_SOURCE_BYTES: usize = 96 << 20;
/// Big sources uploaded to the shared copy so far (diagnostics and tests).
static SOURCE_UPLOADS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// The one big source (the original a zoom window is cut from) kept on the device for all the
/// views of the photo: the Before and After windows and every pan share it, instead of each
/// view's stages holding (or re-uploading) 288 MB of a 24 MP photo. It does not count as a view's
/// stages; it is released with the buffer pool when the app idles.
static SHARED_SOURCE: std::sync::Mutex<Option<(std::sync::Weak<Rgb32f>, Arc<Buf>)>> = std::sync::Mutex::new(None);
fn shared_source(src: &Arc<Rgb32f>, upload: impl FnOnce() -> Buf) -> Arc<Buf> {
let mut g = SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner());
if let Some((weak, buf)) = &*g
&& weak.upgrade().is_some_and(|s| Arc::ptr_eq(&s, src))
{
return buf.clone();
}
*g = None; // the previous photo's copy goes before the next is made
SOURCE_UPLOADS.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let buf = Arc::new(upload());
*g = Some((Arc::downgrade(src), buf.clone()));
buf
}
/// Free the shared big source (see [`shared_source`]).
pub(crate) fn release_shared_source() {
*SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()) = None;
}
/// Device bytes of the shared big source, 0 when there is none.
pub(crate) fn shared_source_bytes() -> usize {
SHARED_SOURCE.lock().unwrap_or_else(|e| e.into_inner()).as_ref().map_or(0, |(_, b)| b.len * 4)
}
pub(crate) fn source_uploads() -> u64 {
SOURCE_UPLOADS.load(std::sync::atomic::Ordering::Relaxed)
}
/// The reference framing decision ([`Warp::covers`](lightcraft_pipeline::optics::Warp::covers)) for every
/// output pixel, one bit per pixel, rows padded to 32-bit words. Blocks of 32 × [`COVER_ROWS`] pixels whose
/// interval bounds place them clearly inside or outside the image are filled at once
@@ -555,11 +592,12 @@ pub fn render(
Some(e) => e.sampled.clone(),
None if gpu.fits(src.data.len() * 3) => {
let upload = || gpu.upload(rgb_words(src));
// (a source too big to keep is uploaded for this render only: a window is cut from the
// photo's own pixels, and what its stages keep is its own sampled pixels)
// (a source too big for a view's stages to keep is the one shared copy: a window is
// cut from the photo's own pixels, and what its stages keep is its own sampled pixels)
let src_buf = match stages {
Some(c) if src.data.len() * 12 <= RETAIN_SOURCE_BYTES => c.source(src, upload),
_ => Arc::new(upload()),
Some(_) => shared_source(src, upload),
None => Arc::new(upload()),
};
sample(&mut cx, src, src_buf, &plan)
}
+32
View File
@@ -706,8 +706,12 @@ fn windows_match() {
/// upload with the view's stages counted it against the stage budget on every tick of a drag and
/// got the stages cleared, and the upload and resampling redone: the window's stages are its own
/// pixels, not the photo's.
// (these two share the process's one big source: a test lock keeps them from replacing each other's)
static BIG_SOURCE_TESTS: std::sync::Mutex<()> = std::sync::Mutex::new(());
#[test]
fn a_window_does_not_keep_the_whole_source_on_the_device() {
let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner());
if !gpu() {
return;
}
@@ -727,3 +731,31 @@ fn a_window_does_not_keep_the_whole_source_on_the_device() {
assert_eq!((again.image.width, again.image.height), (640, 480));
assert!(lightcraft_gpu::stage_bytes(&stages) < 100 << 20);
}
/// …but the 288 MB is uploaded once for every window of every view of the photo, not once per
/// window: panning at 1:1, and the Before and After windows, share one device copy of it, which
/// goes when the app idles (`trim_pool(0)`).
#[test]
fn windows_of_a_big_photo_share_one_uploaded_source() {
let _lock = BIG_SOURCE_TESTS.lock().unwrap_or_else(|e| e.into_inner());
if !gpu() {
return;
}
use lightcraft_pipeline::PixelWindow;
let src = scene(1, 6000, 4000);
let info = SourceInfo { raw: true, ..Default::default() };
let s = DevelopSettings::default();
let (after, before) = (StageCache::default(), StageCache::default());
let at = |x: usize| RenderRequest { window: Some(PixelWindow { x, y: 1500, w: 640, h: 480 }), ..RenderRequest::fit(6000, 4000) };
let start = lightcraft_gpu::source_uploads();
for (cache, x) in [(&after, 1000), (&after, 1256), (&after, 1512), (&before, 1512), (&before, 1768)] {
lightcraft_gpu::render(&src, &info, &s, &at(x), Some(cache)).expect("gpu render");
}
assert_eq!(lightcraft_gpu::source_uploads() - start, 1, "one upload for five windows of two views");
assert!(lightcraft_gpu::shared_source_bytes() >= 280_000_000, "{}", lightcraft_gpu::shared_source_bytes());
assert!(lightcraft_gpu::stage_bytes(&after) < 100 << 20, "and it is not in the views' stages");
lightcraft_gpu::trim_pool(0);
assert_eq!(lightcraft_gpu::shared_source_bytes(), 0, "an idle app gives it back");
lightcraft_gpu::render(&src, &info, &s, &at(1000), Some(&after)).expect("gpu render");
assert_eq!(lightcraft_gpu::source_uploads() - start, 2);
}