perf: round 4 — one-pass row paths, drive-selector cache, CalDAV single-parse, streamed Azure, batched hydration

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
This commit is contained in:
Claude
2026-07-17 13:48:37 +00:00
parent 8a73607229
commit 12dc648cff
44 changed files with 5092 additions and 402 deletions
+173
View File
@@ -0,0 +1,173 @@
//! 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");
}