perf(thumbnails): raise decode-concurrency cap cpus/2 -> cpus
Shrink-on-load decoupled peak heap from source resolution (~18-25 MB per decode regardless of MP), so the RAM ceiling that justified halving decode concurrency is gone. max_concurrent_decodes() now defaults to all cores, with an OXICLOUD_THUMBNAIL_DECODE_CONCURRENCY override for ops tuning. Measured on the real service path (Semaphore + spawn_blocking), 14 cores: - 12MP: 92.7 -> 133.7 photos/s (1.44x) - 24MP: 49.5 -> 69.9 photos/s (1.41x) peak heap unchanged; cpus*2 yields nothing, confirming cpus is the right ceiling for CPU-bound work (the gap to 2x is rayon oversubscription -- Task 1.7). Adds a semaphore-bounded throughput harness (Table D) to the bench example. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
+24
-1
@@ -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).
|
||||
|
||||
|
||||
@@ -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)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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::<usize>().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<ThumbnailCacheKey, Bytes>,
|
||||
/// 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<Semaphore>,
|
||||
/// Timeout for thumbnail generation operations to prevent hanging on large images.
|
||||
/// Defaults to 30 seconds.
|
||||
|
||||
Reference in New Issue
Block a user