diff --git a/benches/BASELINE.md b/benches/BASELINE.md index d42695e1..b09fd6e6 100644 --- a/benches/BASELINE.md +++ b/benches/BASELINE.md @@ -148,7 +148,30 @@ visually indistinguishable). Output bytes unchanged (e.g. 12 MP: 57→58 KB). ### Follow-ups this unlocked - **Task 1.5** (raise `cpus/2` → `cpus`): peak heap no longer scales with MP, so - the OOM ceiling that justified halving concurrency is largely gone. + the OOM ceiling that justified halving concurrency is largely gone. ✅ done below. - The `MAX_DECODE_PIXELS` 50 MP reject could be relaxed — huge JPEGs now decode cheaply at 1/8 — but that is a behaviour change, deferred. +--- + +# Phase 1.5 results — raise decode-concurrency cap (`cpus/2` → `cpus`) + +`max_concurrent_decodes()` now defaults to all cores (was half), overridable via +`OXICLOUD_THUMBNAIL_DECODE_CONCURRENCY`. Safe only because Phase 1.1 decoupled +peak heap from source resolution. + +Measured with a harness that mirrors the **real service path** (Table D: +`tokio::Semaphore(permits)` + `spawn_blocking`, many concurrent requests), +14 cores, 3 s window: + +| case | 7 permits (`cpus/2`, old) | 14 permits (`cpus`, new) | 28 (`cpus*2`) | +|-----------|--------------------------:|-------------------------:|--------------:| +| jpeg_12mp | 92.7 | **133.7 (1.44×)** | 133.3 (—) | +| jpeg_24mp | 49.5 | **69.9 (1.41×)** | 71.1 (+1.7%) | + +- **~1.4× throughput** on the real path, for free; peak heap unchanged (17–25 MB). +- `cpus*2` yields nothing → `cpus` is the right ceiling for CPU-bound work. +- It's 1.4× not 2× because `render_all` fans its 3 sizes onto rayon, so 7 permits + already partly fill all cores — the remaining headroom is **Task 1.7** + (rayon oversubscription). + diff --git a/examples/bench_thumbnails_mem.rs b/examples/bench_thumbnails_mem.rs index f49d3b9b..5bc47a74 100644 --- a/examples/bench_thumbnails_mem.rs +++ b/examples/bench_thumbnails_mem.rs @@ -19,6 +19,7 @@ use std::alloc::{GlobalAlloc, Layout, System}; use std::hint::black_box; use std::path::PathBuf; +use std::sync::Arc; use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering}; use std::thread; use std::time::{Duration, Instant}; @@ -199,7 +200,7 @@ fn main() { assert!(!corpus.is_empty(), "corpus is empty — generation failed"); println!("\n###########################################################"); - println!("# Phase 0 baseline — thumbnail render (current `image` crate)"); + println!("# Thumbnail render harness — current working tree"); println!("# cores (available_parallelism): {threads}"); println!("# corpus dir: {}", bench_support::corpus_dir().display()); println!("# heap = logical allocation high-water mark (not RSS)"); @@ -295,6 +296,38 @@ fn main() { ); } + // --- Table D: semaphore-bounded throughput (Task 1.5) --- + // Mirrors the real service path (Semaphore + spawn_blocking) so we can see + // the effect of the decode-concurrency cap. cpus/2 was the old default; + // cpus is the new default; cpus*2 checks for diminishing returns. + let half = (threads / 2).max(2); + let permit_levels = [half, threads, threads * 2]; + println!("\n== D. Semaphore-bounded throughput (real path: Semaphore+spawn_blocking, 3s) =="); + println!( + "| {:<17} | {:>7} | {:>11} | {:<22} |", + "case", "permits", "photos/sec", "note" + ); + println!("|{:-<19}|{:-<9}|{:-<13}|{:-<24}|", "", "", "", ""); + for case in corpus + .iter() + .filter(|c| matches!(c.name, "jpeg_12mp" | "jpeg_24mp")) + { + for &permits in &permit_levels { + let pps = measure_semaphore_throughput(case, permits, Duration::from_secs(3)); + let note = if permits == half { + "cpus/2 (old default)" + } else if permits == threads { + "cpus (new default)" + } else { + "cpus*2 (oversubscribed)" + }; + println!( + "| {:<17} | {:>7} | {:>11.1} | {:<22} |", + case.name, permits, pps, note + ); + } + } + write_json(threads, &peak_rows, &tp_rows); println!( @@ -369,6 +402,53 @@ fn write_json(threads: usize, peak_rows: &[PeakRow], tp_rows: &[ThroughputRow]) } } +/// Throughput of the real service path under a decode-concurrency cap: many +/// concurrent "requests" compete for `permits` slots, each holding its permit +/// while the CPU-bound render runs on the blocking pool (exactly how +/// `generate_all_sizes_background` + `decode_semaphore` behave). Returns +/// photos/sec over `window`. +fn measure_semaphore_throughput(case: &CorpusCase, permits: usize, window: Duration) -> f64 { + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .expect("tokio runtime"); + let bytes = Arc::new(case.bytes.clone()); + rt.block_on(async move { + let sem = Arc::new(tokio::sync::Semaphore::new(permits)); + let counter = Arc::new(AtomicU64::new(0)); + let start = Instant::now(); + let deadline = start + window; + + // Oversupply concurrent requests so the semaphore — not the task count — + // is the limiter, mirroring a burst of hundreds of uploads. + let workers = (permits * 3).max(48); + let mut handles = Vec::with_capacity(workers); + for _ in 0..workers { + let sem = sem.clone(); + let counter = counter.clone(); + let bytes = bytes.clone(); + handles.push(tokio::spawn(async move { + while Instant::now() < deadline { + let permit = sem.clone().acquire_owned().await.expect("permit"); + let b = bytes.clone(); + let res = + tokio::task::spawn_blocking(move || ThumbnailService::bench_render_all(&b)) + .await; + drop(permit); // release only after the render finishes + if matches!(res, Ok(Ok(_))) { + counter.fetch_add(1, Ordering::Relaxed); + } + } + })); + } + for h in handles { + let _ = h.await; + } + let elapsed = start.elapsed().as_secs_f64(); + counter.load(Ordering::Relaxed) as f64 / elapsed + }) +} + // --------------------------------------------------------------------------- // Quality verification helpers (Table C) // --------------------------------------------------------------------------- diff --git a/src/infrastructure/services/thumbnail_service.rs b/src/infrastructure/services/thumbnail_service.rs index dd50db65..bb7fb519 100644 --- a/src/infrastructure/services/thumbnail_service.rs +++ b/src/infrastructure/services/thumbnail_service.rs @@ -79,15 +79,30 @@ struct ThumbnailCacheKey { /// Images above this are silently skipped — protects against single-image OOM. const MAX_DECODE_PIXELS: u64 = 50_000_000; +/// Environment override for the decode-concurrency cap (ops tuning). +const DECODE_CONCURRENCY_ENV: &str = "OXICLOUD_THUMBNAIL_DECODE_CONCURRENCY"; + /// Compute max concurrent thumbnail decode operations at runtime. -/// Uses half the available CPUs (min 2) to scale with hardware while -/// bounding peak RAM. `available_parallelism()` respects cgroup limits -/// (Docker/K8s) and CPU affinity masks. +/// +/// Uses all available CPUs (min 2). Before shrink-on-load each decode +/// materialised the full-resolution RGBA bitmap (~96 MB for a 12 MP photo), so +/// concurrency was halved to keep peak RAM in check. Decodes are now DCT-shrunk +/// to the thumbnail size (~18–25 MB regardless of source resolution), so the RAM +/// ceiling no longer forces throttling and we can saturate every core. Override +/// with `OXICLOUD_THUMBNAIL_DECODE_CONCURRENCY`. `available_parallelism()` +/// respects cgroup limits (Docker/K8s) and CPU affinity masks. fn max_concurrent_decodes() -> usize { + if let Some(n) = std::env::var(DECODE_CONCURRENCY_ENV) + .ok() + .and_then(|v| v.parse::().ok()) + .filter(|&n| n >= 1) + { + return n; + } let cpus = std::thread::available_parallelism() .map(|n| n.get()) .unwrap_or(4); - (cpus / 2).max(2) + cpus.max(2) } /// Thumbnail service for generating and caching image thumbnails @@ -98,9 +113,9 @@ pub struct ThumbnailService { cache: moka::future::Cache, /// Configured maximum cache weight (for stats reporting) max_cache_bytes: u64, - /// Limits how many images are decoded in parallel to bound RAM usage. - /// Without this, 50 simultaneous uploads would decode 50 bitmaps - /// (~96 MB each for 6000×4000) = 4.8 GB peak. + /// Limits how many images are decoded in parallel. Post shrink-on-load each + /// decode is bounded (~18–25 MB), so this now exists mainly to avoid CPU + /// oversubscription rather than to cap RAM; defaults to all cores. decode_semaphore: Arc, /// Timeout for thumbnail generation operations to prevent hanging on large images. /// Defaults to 30 seconds.