mirror of
https://github.com/storytold/photocraft.git
synced 2026-11-10 12:22:22 +00:00
* perf: reuse large buffers so a pinned glibc trim threshold costs no speed (#10479) With MALLOC_TRIM_THRESHOLD_ set (#8743), glibc maps every allocation over 128 KiB fresh, so tiles, compositor bands and filter scratch were page-faulted in on every call: viewport compose 3.6x slower, Gaussian Blur up to 1.8x, .pcraft save 2.4x. - photocraft_raster::scratch: a process-wide, capped pool of large Vec buffers (u8/u16/f32/[f32; 4]). A miss drops the oldest buffers worth its size down to a 256 MiB floor, so peak memory stays near glibc's defaults. The engine releases the pool after any command that closes a document, Headless::close does too, and an idle thread releases it after 2 s unused. - Tiles, compose Buffer/masks/bands, algo Image and the blur paths, engine adjustment/filter scratch, GPU tile upload and io import buffers go through the pool. - .pcraft save reuses one zstd FrameCompressor per worker (byte-identical). - interactive_bench reports minor page faults per run. 36 MP, trim threshold set vs glibc defaults: viewport 5.6 vs 6.1 ms (was 22.7), Blur r4 445 vs 437 ms (was 852), Curves CPU proxy 35.0 vs 35.7 ms (was 50), flatten 146 vs 147 ms (was 237), save .pcraft 251 vs 230 ms (was 545). RSS after closing a 5400x7200 document: 21 MB with trim set. * fix: corpus tests build again; return remaining pooled buffers (#10479 review) - Corpus-gated io tests and the Photoshop-oracle test moved `.px` out of a `Buffer`, which now implements `Drop` (E0509 with `--features corpus`): use `into_px()`. - io: masks, the merged image, extra channels, smart-filter masks, smart-object pixels and patterns give their `interleave` buffers back to the scratch pool after writing them. - compose: recycle the mask values of a pass-through group, `effect_shape` and both `split_parts` callers. - Styled Free Transform preview copies each band out instead of moving it, so the band returns to the pool. - interactive_bench: the fault count covers each whole run, untimed setup included; say so. * fix: recycle the masks of adjustment-atop and shape layers (#10479 review) * fix: recycle effect hiding masks (#10479 review) * fix: recycle knockout, CMYK and GPU shape masks; release the pool when Split/Merge Channels close a document (#10479 review) * fix: release the pool when a closed document's cancelled workers exit; idle-release switch for tests; don't pool PSD planes (#10479 review) * fix: release the pool for workers cancelled before their document closes; take the pool lock for budget and idle-release changes (#10479 review) * fix: idle release rechecks activity under the lock; try_take_exact releases the pool and retries before failing (#10479 review) * test: cover a successful try_take_exact miss; name the failed-reservation test for what it checks (#10479 review) * fix: pool the final GPU effect shape buffer and return it after use (#10479 review) * docs: describe scratch pool lifecycle regression invariants * test: verify polling releases cancelled document scratch buffers * perf: recycle remaining transform selection and PSD scratch buffers * fix: free PSD gather bands before allocating channel planes * test: assert exact scratch take reuses pooled storage
115 lines
4.7 KiB
Rust
115 lines
4.7 KiB
Rust
//! One integration-test process keeps the process-wide scratch pool isolated from parallel unit
|
|
//! tests (#10479).
|
|
use photocraft_engine::Session;
|
|
use photocraft_engine::jobs::Started;
|
|
use photocraft_raster::scratch;
|
|
use serde_json::json;
|
|
use std::sync::{Mutex, PoisonError};
|
|
|
|
static SERIAL: Mutex<()> = Mutex::new(());
|
|
|
|
/// Holds the test lock with the pool's idle release off, so a slow runner can't empty the pool
|
|
/// between a command and the check after it; the release comes back on when dropped.
|
|
struct Quiet {
|
|
_serial: std::sync::MutexGuard<'static, ()>,
|
|
}
|
|
|
|
impl Quiet {
|
|
/// Locks the test process and disables idle pool release for this guard's lifetime.
|
|
fn new() -> Self {
|
|
let _serial = SERIAL.lock().unwrap_or_else(PoisonError::into_inner);
|
|
scratch::set_idle_release(false);
|
|
Quiet { _serial }
|
|
}
|
|
}
|
|
|
|
impl Drop for Quiet {
|
|
fn drop(&mut self) {
|
|
scratch::set_idle_release(true);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
/// Verifies closing one or all documents releases their scratch buffers.
|
|
fn closing_a_document_leaves_none_of_its_buffers_in_the_pool() {
|
|
let _q = Quiet::new();
|
|
for close in ["file.close", "file.closeAll"] {
|
|
let mut s = Session::new();
|
|
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
|
|
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
|
s.execute("filter.blur.gaussianBlur", json!({"radius": 3})).unwrap();
|
|
// Dispatch holds the active document across the command; its tiles must not land in
|
|
// the pool after the release.
|
|
s.execute(close, json!({})).unwrap();
|
|
assert_eq!(scratch::retained_bytes(), 0, "{close}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
/// Verifies a command that keeps its document does not release unrelated pooled buffers.
|
|
fn commands_that_keep_their_documents_keep_the_pool() {
|
|
let _q = Quiet::new();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", json!({"width": 1024, "height": 1024})).unwrap();
|
|
scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]);
|
|
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
|
assert!(scratch::retained_bytes() > 0);
|
|
}
|
|
|
|
#[test]
|
|
/// Verifies a cancelled worker releases its document buffers after it exits.
|
|
fn a_cancelled_job_of_a_closed_document_returns_its_buffers_once_it_exits() {
|
|
let _q = Quiet::new();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap();
|
|
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
|
let Started::Job(_) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else {
|
|
panic!("Gaussian Blur runs as a background job");
|
|
};
|
|
// Closing cancels the job, but its worker still holds the document until it exits.
|
|
s.close(0);
|
|
scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]);
|
|
s.join_cancelled_jobs();
|
|
assert_eq!(scratch::retained_bytes(), 0);
|
|
}
|
|
|
|
#[test]
|
|
/// Verifies cancelling a job before document close still releases its buffers.
|
|
fn a_job_cancelled_before_its_document_closes_still_returns_its_buffers() {
|
|
let _q = Quiet::new();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap();
|
|
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
|
let Started::Job(id) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else {
|
|
panic!("Gaussian Blur runs as a background job");
|
|
};
|
|
// Cancelled first: the worker is already off the running list when the document closes.
|
|
assert!(s.cancel_job(id));
|
|
s.close(0);
|
|
scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]);
|
|
s.join_cancelled_jobs();
|
|
assert_eq!(scratch::retained_bytes(), 0);
|
|
}
|
|
|
|
/// Verifies polling alone releases buffers after a closed document's cancelled worker exits.
|
|
#[test]
|
|
fn a_cancelled_job_of_a_closed_document_is_released_by_polling() {
|
|
let _q = Quiet::new();
|
|
let mut s = Session::new();
|
|
s.execute("file.new", json!({"width": 2048, "height": 2048})).unwrap();
|
|
s.execute("edit.fill", json!({"contents": "color", "color": "#808080"})).unwrap();
|
|
let Started::Job(_) = s.start("filter.blur.gaussianBlur", json!({"radius": 60})).unwrap() else {
|
|
panic!("Gaussian Blur runs as a background job");
|
|
};
|
|
s.close(0);
|
|
// Seed after close so the close path cannot satisfy the assertion.
|
|
scratch::give(vec![0u8; scratch::MIN_POOLED_BYTES]);
|
|
assert!(scratch::retained_bytes() > 0);
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(30);
|
|
while scratch::retained_bytes() > 0 && std::time::Instant::now() < deadline {
|
|
s.poll_jobs();
|
|
std::thread::sleep(std::time::Duration::from_millis(5));
|
|
}
|
|
assert_eq!(scratch::retained_bytes(), 0);
|
|
}
|