diff --git a/crates/gpu/src/lib.rs b/crates/gpu/src/lib.rs index 5ef3011b7..312d42635 100644 --- a/crates/gpu/src/lib.rs +++ b/crates/gpu/src/lib.rs @@ -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"))] diff --git a/crates/gpu/src/render.rs b/crates/gpu/src/render.rs index 46b47f1a4..8fda204f5 100644 --- a/crates/gpu/src/render.rs +++ b/crates/gpu/src/render.rs @@ -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, Arc)>> = std::sync::Mutex::new(None); + +fn shared_source(src: &Arc, upload: impl FnOnce() -> Buf) -> Arc { + 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) } diff --git a/crates/gpu/tests/equivalence.rs b/crates/gpu/tests/equivalence.rs index bfb72e02f..cb6e7c024 100644 --- a/crates/gpu/tests/equivalence.rs +++ b/crates/gpu/tests/equivalence.rs @@ -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); +}