12dc648cff
Nine benchmark-gated changes (benches/ROUND4.md; every one ships with a BEFORE/AFTER bench + equivalence gate, rollback rule as ROUND2/3): - Row→entity path build: one-pass StoragePath::from_folder_and_name / from_joined + normalize_storage_name_owned + alloc-free Display — 743→417 ns/file-row (1.78x), −5 allocs/row on every listing surface. - WebDAV drive-selector: per-user readable_cache (single-flight, 30 s TTL, explicit invalidation incl. membership + group changes) replaces the grants join per request — 441 µs → 0.8 µs (~550x), 0 queries warm. - CalDAV from_ical/update_ical_data: 8 full IcalParser runs per VEVENT → 1 (7.1x per PUT, 4.4x on 50-event imports); alloc-free split_vevents, chunk scan without the whole-body uppercase copy (1.4x), borrowed-key UID grouping (1.3x), REPORT props no longer cloned. - PROPFIND emit: partition Vecs dropped (single-pass 404 list) + stack rendered RFC 3339/2822 dates, sizes, quoted etags (common::fmt, chrono-byte-identical, sweep-tested) on both DAV surfaces — 1.22x per page, 17.9→12.0 allocs/row. - Grant-listing hydration: calendars/address books/playlists batch hydrate via = ANY($1) — 15 serial queries → 1 (~13x per sync poll). - user-flags cache: get→insert → try_get_with single-flight (32→1 queries per cold herd). - Azure downloads: whole-blob Vec buffering → streamed SDK pages — TTFB 349→4 ms (87x), peak heap 480→1.9 MiB (254x) on 256 MiB blobs; new OXICLOUD_AZURE_ENDPOINT_URL override (Azurite/bench hook). - Face indexing: unbounded per-image tokio::spawn → core-count semaphore, permit before blob read — peak heap 1175→176 MiB (6.7x). Checks: cargo fmt, clippy --all-features --all-targets -D warnings, cargo test --workspace (523 passed) + --features test_utils. hurl API suite and dockerized integration DB not runnable in this environment — left to CI. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017aJu9ghvuT8WqC31ZEGTBA
174 lines
6.6 KiB
Rust
174 lines
6.6 KiB
Rust
//! Face-indexing fan-out benchmark — unbounded spawn vs semaphore (ROUND4).
|
||
//!
|
||
//! `FaceIndexingService::spawn_index` fired one `tokio::spawn` per
|
||
//! uploaded/copied image with NO ceiling; each task reads the full blob
|
||
//! into RAM and decodes it before inference. A bulk upload of N photos
|
||
//! therefore held up to N decoded images in flight simultaneously.
|
||
//! AFTER: an `Arc<Semaphore>` sized to the effective core count
|
||
//! (`OXICLOUD_FACES_INDEX_CONCURRENCY` override), permit acquired BEFORE
|
||
//! the blob read — the exact `ThumbnailService::decode_semaphore`
|
||
//! invariant ("peak memory = permits × image size").
|
||
//!
|
||
//! This is a *pattern* bench (like POOL-CONCURRENCY / RUNTIME): the real
|
||
//! service needs Postgres + an ONNX model, so the task body models the
|
||
//! dominant costs — full-file read + JPEG decode on the deterministic
|
||
//! `bench_support` photo corpus — while the spawn/permit shape is copied
|
||
//! from the service verbatim. Metrics: wall time, PEAK LIVE HEAP (exact,
|
||
//! via counting allocator), decode results asserted identical.
|
||
//!
|
||
//! Run (no Postgres needed):
|
||
//! cargo run --release --features bench --example bench_faces_bound
|
||
//! Tunables (env): BENCH_IMAGES (48), BENCH_PERMITS (effective cores).
|
||
|
||
use std::alloc::{GlobalAlloc, Layout, System};
|
||
use std::env;
|
||
use std::hint::black_box;
|
||
use std::sync::Arc;
|
||
use std::sync::atomic::{AtomicU64, AtomicUsize, Ordering};
|
||
use std::time::Instant;
|
||
|
||
// ─── Peak-live-heap tracking allocator ──────────────────────────────────────
|
||
|
||
static LIVE: AtomicU64 = AtomicU64::new(0);
|
||
static PEAK: AtomicU64 = AtomicU64::new(0);
|
||
|
||
struct PeakAlloc;
|
||
|
||
fn bump(sz: u64) {
|
||
let live = LIVE.fetch_add(sz, Ordering::Relaxed) + sz;
|
||
PEAK.fetch_max(live, Ordering::Relaxed);
|
||
}
|
||
|
||
unsafe impl GlobalAlloc for PeakAlloc {
|
||
unsafe fn alloc(&self, layout: Layout) -> *mut u8 {
|
||
bump(layout.size() as u64);
|
||
unsafe { System.alloc(layout) }
|
||
}
|
||
unsafe fn dealloc(&self, ptr: *mut u8, layout: Layout) {
|
||
LIVE.fetch_sub(layout.size() as u64, Ordering::Relaxed);
|
||
unsafe { System.dealloc(ptr, layout) }
|
||
}
|
||
unsafe fn realloc(&self, ptr: *mut u8, layout: Layout, new_size: usize) -> *mut u8 {
|
||
if new_size > layout.size() {
|
||
bump((new_size - layout.size()) as u64);
|
||
} else {
|
||
LIVE.fetch_sub((layout.size() - new_size) as u64, Ordering::Relaxed);
|
||
}
|
||
unsafe { System.realloc(ptr, layout, new_size) }
|
||
}
|
||
unsafe fn alloc_zeroed(&self, layout: Layout) -> *mut u8 {
|
||
bump(layout.size() as u64);
|
||
unsafe { System.alloc_zeroed(layout) }
|
||
}
|
||
}
|
||
|
||
#[global_allocator]
|
||
static GLOBAL: PeakAlloc = PeakAlloc;
|
||
|
||
/// The modelled per-image work: full blob read (as `index_file` does via
|
||
/// `tokio::fs::read`) + JPEG decode (the analyzer's first step).
|
||
async fn index_one(path: std::path::PathBuf, dims: Arc<AtomicUsize>) {
|
||
let bytes = tokio::fs::read(&path).await.expect("read blob");
|
||
let img = tokio::task::spawn_blocking(move || image::load_from_memory(&bytes).expect("decode"))
|
||
.await
|
||
.expect("join decode");
|
||
dims.fetch_add((img.width() + img.height()) as usize, Ordering::Relaxed);
|
||
black_box(img);
|
||
}
|
||
|
||
fn effective_parallelism() -> usize {
|
||
std::thread::available_parallelism()
|
||
.map(|n| n.get())
|
||
.unwrap_or(2)
|
||
}
|
||
|
||
#[tokio::main(flavor = "multi_thread")]
|
||
async fn main() {
|
||
let images: usize = env::var("BENCH_IMAGES")
|
||
.ok()
|
||
.and_then(|v| v.parse().ok())
|
||
.unwrap_or(48);
|
||
let permits: usize = env::var("BENCH_PERMITS")
|
||
.ok()
|
||
.and_then(|v| v.parse().ok())
|
||
.unwrap_or_else(effective_parallelism);
|
||
|
||
// Deterministic photo corpus (12 MP JPEG case) → one temp file per
|
||
// "upload" so each task pays a real filesystem read.
|
||
let corpus = oxicloud::bench_support::load_or_generate();
|
||
let jpeg = corpus
|
||
.iter()
|
||
.max_by_key(|c| c.bytes.len())
|
||
.expect("corpus nonempty");
|
||
println!(
|
||
"bench_faces_bound — {images} images ({} · {:.1} MiB encoded), permits={permits}\n",
|
||
jpeg.name,
|
||
jpeg.bytes.len() as f64 / (1024.0 * 1024.0)
|
||
);
|
||
let dir = tempfile::tempdir().expect("tempdir");
|
||
let mut paths = Vec::with_capacity(images);
|
||
for i in 0..images {
|
||
let p = dir.path().join(format!("{i}.blob"));
|
||
std::fs::write(&p, &jpeg.bytes).expect("write blob");
|
||
paths.push(p);
|
||
}
|
||
|
||
// ── BEFORE: unbounded spawn per image (the old spawn_index shape) ──
|
||
let dims_before = Arc::new(AtomicUsize::new(0));
|
||
PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed);
|
||
let t0 = Instant::now();
|
||
let mut handles = Vec::with_capacity(images);
|
||
for p in &paths {
|
||
let p = p.clone();
|
||
let dims = dims_before.clone();
|
||
handles.push(tokio::spawn(async move {
|
||
index_one(p, dims).await;
|
||
}));
|
||
}
|
||
for h in handles {
|
||
h.await.unwrap();
|
||
}
|
||
let wall_before = t0.elapsed().as_secs_f64() * 1e3;
|
||
let peak_before = PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0);
|
||
|
||
// ── AFTER: same spawn shape + semaphore permit before the read ──
|
||
let dims_after = Arc::new(AtomicUsize::new(0));
|
||
let semaphore = Arc::new(tokio::sync::Semaphore::new(permits));
|
||
PEAK.store(LIVE.load(Ordering::Relaxed), Ordering::Relaxed);
|
||
let t0 = Instant::now();
|
||
let mut handles = Vec::with_capacity(images);
|
||
for p in &paths {
|
||
let p = p.clone();
|
||
let dims = dims_after.clone();
|
||
let semaphore = semaphore.clone();
|
||
handles.push(tokio::spawn(async move {
|
||
let _permit = semaphore
|
||
.acquire_owned()
|
||
.await
|
||
.expect("semaphore never closes");
|
||
index_one(p, dims).await;
|
||
}));
|
||
}
|
||
for h in handles {
|
||
h.await.unwrap();
|
||
}
|
||
let wall_after = t0.elapsed().as_secs_f64() * 1e3;
|
||
let peak_after = PEAK.load(Ordering::Relaxed) as f64 / (1024.0 * 1024.0);
|
||
|
||
println!(" wall ms peak live heap MiB");
|
||
println!("BEFORE (unbounded) {wall_before:8.1} {peak_before:10.1}");
|
||
println!(
|
||
"AFTER (semaphore {permits:>2}) {wall_after:8.1} {peak_after:10.1} heap {:.1}x lower",
|
||
peak_before / peak_after
|
||
);
|
||
|
||
// ── Equivalence gate: identical decode results ──
|
||
let db = dims_before.load(Ordering::Relaxed);
|
||
let da = dims_after.load(Ordering::Relaxed);
|
||
if db != da || db == 0 {
|
||
eprintln!("GATE FAIL: dimension sums differ (before={db} after={da})");
|
||
std::process::exit(1);
|
||
}
|
||
println!("\n[gate] OK — all {images} images decoded identically in both modes");
|
||
}
|