Files
Oxicloud/examples/bench_blob_cache_index.rs
T

423 lines
15 KiB
Rust
Raw Normal View History

//! Blob-cache index benchmark — `Mutex<LruCache>` vs moka byte-weigher
//! (the ROUND11 deferred lead; no Postgres).
//!
//! `CachedBlobBackend` keeps its cache index in a
//! `tokio::sync::Mutex<LruCache<String, CacheEntry>>`: EVERY cached chunk
//! read acquires the one global async mutex to probe + LRU-promote (the
//! promote needs `&mut`), so N-core read concurrency collapses onto a
//! single serialization domain — and an N-chunk CDC file read is N
//! acquisitions, with every other concurrent reader contending.
//!
//! AFTER: a `moka::sync::Cache` with a byte weigher — lock-free sharded
//! reads with striped recency, byte-budget eviction handled by moka
//! (replacing the manual `current_size` + `collect_evictions` machinery),
//! and an eviction listener that unlinks the evicted `.blob` file (only on
//! size-eviction — Replaced/Explicit must NOT unlink, gated below).
//!
//! Arms:
//! [1] pure index ops, K tasks × M hit-probes (the scaling ceiling)
//! [2] end-to-end warm-hit read (index probe + open + 64 KiB read),
//! K = 1/2/4/8 readers over a shared corpus
//! [3] safety gates: byte budget enforced + evicted files unlinked +
//! replaced entries keep their file + single-flight still coalesces
//! K concurrent misses onto 1 inner fetch
//!
//! Run:
//! cargo run --release --features bench --example bench_blob_cache_index
//! Tunables (env): BENCH_OPS (200000), BENCH_FILES (256), BENCH_READERS (8)
use std::num::NonZeroUsize;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Instant;
use lru::LruCache;
use tokio::sync::Mutex;
fn env_or<T: std::str::FromStr>(key: &str, default: T) -> T {
std::env::var(key)
.ok()
.and_then(|v| v.parse().ok())
.unwrap_or(default)
}
#[derive(Debug, Clone)]
struct CacheEntry {
size: u64,
}
/// BEFORE, verbatim: the shipped index shape + the per-hit prologue
/// allocations of `get_blob_stream` (hash `to_string`, `cached_path`
/// build, unconditional `cache_dir.clone()`).
struct BeforeIndex {
cache_dir: PathBuf,
index: Arc<Mutex<LruCache<String, CacheEntry>>>,
}
impl BeforeIndex {
fn cached_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[..2.min(hash.len())];
self.cache_dir.join(prefix).join(format!("{hash}.blob"))
}
/// The exact hit-path prologue of `get_blob_stream`.
async fn hit_probe(&self, hash: &str) -> Option<PathBuf> {
let hash = hash.to_string();
let cached = self.cached_path(&hash);
let _cache_dir = self.cache_dir.clone(); // paid on hits, used on misses
if self.index.lock().await.get(&hash).is_some() {
return Some(cached);
}
None
}
}
/// AFTER: moka byte-weigher index + borrow-only hit prologue.
struct AfterIndex {
cache_dir: PathBuf,
index: moka::sync::Cache<String, CacheEntry>,
}
impl AfterIndex {
fn new(cache_dir: PathBuf, max_bytes: u64) -> Self {
Self {
cache_dir,
index: moka::sync::Cache::builder()
.weigher(|_k: &String, e: &CacheEntry| e.size.clamp(1, u32::MAX as u64) as u32)
.max_capacity(max_bytes)
.build(),
}
}
fn cached_path(&self, hash: &str) -> PathBuf {
let prefix = &hash[..2.min(hash.len())];
self.cache_dir.join(prefix).join(format!("{hash}.blob"))
}
fn hit_probe(&self, hash: &str) -> Option<PathBuf> {
if self.index.get(hash).is_some() {
return Some(self.cached_path(hash));
}
None
}
}
// ────────────────────────────────────────────────────────────────────────────
async fn section_index_ops(hashes: Arc<Vec<String>>) {
let ops: usize = env_or("BENCH_OPS", 200_000);
let readers_max: usize = env_or("BENCH_READERS", 8);
let before = Arc::new(BeforeIndex {
cache_dir: PathBuf::from("/tmp/bench-blob-idx"),
index: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(1_000_000).unwrap(),
))),
});
let after = Arc::new(AfterIndex::new(
PathBuf::from("/tmp/bench-blob-idx"),
u64::MAX,
));
for h in hashes.iter() {
before
.index
.lock()
.await
.put(h.clone(), CacheEntry { size: 1024 });
after.index.insert(h.clone(), CacheEntry { size: 1024 });
}
println!("\n## [1] Pure index hit-probes (ops total = {ops}, split across K tasks)");
println!("| K | BEFORE Mutex<LruCache> Mops/s | AFTER moka Mops/s | speedup |");
for k in [1usize, 2, 4, 8].into_iter().filter(|k| *k <= readers_max) {
let per_task = ops / k;
let t = Instant::now();
let mut handles = Vec::new();
for t_id in 0..k {
let idx = before.clone();
let hs = hashes.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_task {
let h = &hs[(i * 31 + t_id * 7) % hs.len()];
std::hint::black_box(idx.hit_probe(h).await);
}
}));
}
for h in handles {
h.await.unwrap();
}
let before_mops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e6;
let t = Instant::now();
let mut handles = Vec::new();
for t_id in 0..k {
let idx = after.clone();
let hs = hashes.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_task {
let h = &hs[(i * 31 + t_id * 7) % hs.len()];
std::hint::black_box(idx.hit_probe(h));
}
}));
}
for h in handles {
h.await.unwrap();
}
let after_mops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e6;
println!(
"| {k} | {before_mops:>10.2} | {after_mops:>10.2} | {:>6.2}x |",
after_mops / before_mops
);
}
}
async fn section_warm_reads(hashes: Arc<Vec<String>>) {
let readers_max: usize = env_or("BENCH_READERS", 8);
let reads: usize = 20_000;
// Real cached files on disk (64 KiB each).
let dir = PathBuf::from("/tmp/bench-blob-idx");
let _ = std::fs::remove_dir_all(&dir);
let payload = vec![0xA5u8; 64 * 1024];
let before = Arc::new(BeforeIndex {
cache_dir: dir.clone(),
index: Arc::new(Mutex::new(LruCache::new(
NonZeroUsize::new(1_000_000).unwrap(),
))),
});
let after = Arc::new(AfterIndex::new(dir.clone(), u64::MAX));
for h in hashes.iter() {
let p = before.cached_path(h);
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, &payload).unwrap();
before.index.lock().await.put(
h.clone(),
CacheEntry {
size: payload.len() as u64,
},
);
after.index.insert(
h.clone(),
CacheEntry {
size: payload.len() as u64,
},
);
}
async fn read_file(path: &PathBuf) -> u64 {
use tokio::io::AsyncReadExt;
let mut f = tokio::fs::File::open(path).await.unwrap();
let mut buf = vec![0u8; 64 * 1024];
let mut total = 0u64;
loop {
let n = f.read(&mut buf).await.unwrap();
if n == 0 {
break;
}
total += n as u64;
}
total
}
println!("\n## [2] Warm-hit read (probe + open + 64 KiB read), {reads} reads split across K");
println!("| K | BEFORE Kops/s | AFTER Kops/s | speedup |");
for k in [1usize, 2, 4, 8].into_iter().filter(|k| *k <= readers_max) {
let per_task = reads / k;
let t = Instant::now();
let mut handles = Vec::new();
for t_id in 0..k {
let idx = before.clone();
let hs = hashes.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_task {
let h = &hs[(i * 31 + t_id * 7) % hs.len()];
let p = idx.hit_probe(h).await.expect("hit");
std::hint::black_box(read_file(&p).await);
}
}));
}
for h in handles {
h.await.unwrap();
}
let before_kops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e3;
let t = Instant::now();
let mut handles = Vec::new();
for t_id in 0..k {
let idx = after.clone();
let hs = hashes.clone();
handles.push(tokio::spawn(async move {
for i in 0..per_task {
let h = &hs[(i * 31 + t_id * 7) % hs.len()];
let p = idx.hit_probe(h).expect("hit");
std::hint::black_box(read_file(&p).await);
}
}));
}
for h in handles {
h.await.unwrap();
}
let after_kops = (per_task * k) as f64 / t.elapsed().as_secs_f64() / 1e3;
println!(
"| {k} | {before_kops:>9.1} | {after_kops:>9.1} | {:>6.2}x |",
after_kops / before_kops
);
}
}
// ────────────────────────────────────────────────────────────────────────────
async fn section_safety_gates() {
use dashmap::DashMap;
println!("\n## [3] Safety gates");
// (a) Byte budget + eviction-unlink + replaced-keeps-file, on the moka
// shape the production migration ships.
let dir = PathBuf::from("/tmp/bench-blob-idx-gate");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let unlinked = Arc::new(AtomicU64::new(0));
let cache_dir = dir.clone();
let unlinked_l = unlinked.clone();
let cache: moka::sync::Cache<String, CacheEntry> = moka::sync::Cache::builder()
.weigher(|_k: &String, e: &CacheEntry| e.size.clamp(1, u32::MAX as u64) as u32)
.max_capacity(10 * 1024 * 1024) // 10 MiB budget
.eviction_listener(move |hash: Arc<String>, _entry, cause| {
// Unlink ONLY blobs moka pushed out for size; a Replaced entry
// refers to the same path as its replacement, and Explicit
// removals (delete_blob) unlink at the call site.
if cause == moka::notification::RemovalCause::Size {
let prefix = &hash[..2.min(hash.len())];
let p = cache_dir.join(prefix).join(format!("{hash}.blob"));
let _ = std::fs::remove_file(&p);
unlinked_l.fetch_add(1, Ordering::Relaxed);
}
})
.build();
let payload = vec![0x5Au8; 1024 * 1024]; // 1 MiB blobs
for i in 0..100 {
let hash = format!("{i:02x}gatehash{i:04}");
let prefix = &hash[..2];
let p = dir.join(prefix).join(format!("{hash}.blob"));
std::fs::create_dir_all(p.parent().unwrap()).unwrap();
std::fs::write(&p, &payload).unwrap();
cache.insert(
hash,
CacheEntry {
size: payload.len() as u64,
},
);
}
cache.run_pending_tasks();
let weighted = cache.weighted_size();
assert!(weighted <= 10 * 1024 * 1024, "budget exceeded: {weighted}");
// Every surviving entry's file exists; evicted files unlinked.
let mut on_disk = 0u64;
for i in 0..100 {
let hash = format!("{i:02x}gatehash{i:04}");
let prefix = &hash[..2];
let p = dir.join(prefix).join(format!("{hash}.blob"));
let exists = p.exists();
if cache.get(&hash).is_some() {
assert!(exists, "surviving entry lost its file: {hash}");
}
if exists {
on_disk += 1;
}
}
assert!(
on_disk <= 12,
"disk not trimmed to budget: {on_disk} files remain"
);
assert!(unlinked.load(Ordering::Relaxed) >= 88);
println!(
"# gate (a) OK — weighted {:.1} MiB ≤ 10 MiB budget, {} files on disk, {} unlinked",
weighted as f64 / (1024.0 * 1024.0),
on_disk,
unlinked.load(Ordering::Relaxed)
);
// (b) Replacing an entry must NOT unlink the shared path.
let u0 = unlinked.load(Ordering::Relaxed);
let some_hash = cache
.iter()
.next()
.map(|(k, _)| (*k).clone())
.expect("nonempty");
let some_path = {
let prefix = &some_hash[..2.min(some_hash.len())];
dir.join(prefix).join(format!("{some_hash}.blob"))
};
cache.insert(some_hash.clone(), CacheEntry { size: 1024 * 1024 });
cache.run_pending_tasks();
assert!(some_path.exists(), "replace unlinked the live file");
assert_eq!(
unlinked.load(Ordering::Relaxed),
u0,
"replace must not count as size-eviction unlink"
);
println!("# gate (b) OK — replaced entry keeps its file");
// (c) Single-flight (DashMap gate, unchanged by the migration) still
// coalesces K concurrent misses to one inner fetch.
let fetches = Arc::new(AtomicU64::new(0));
let inflight: Arc<DashMap<String, Arc<Mutex<()>>>> = Arc::new(DashMap::new());
let done: Arc<moka::sync::Cache<String, CacheEntry>> =
Arc::new(moka::sync::Cache::builder().max_capacity(1_000_000).build());
let mut handles = Vec::new();
for _ in 0..16 {
let fetches = fetches.clone();
let inflight = inflight.clone();
let done = done.clone();
handles.push(tokio::spawn(async move {
let hash = "sf-hash".to_string();
let gate = inflight
.entry(hash.clone())
.or_insert_with(|| Arc::new(Mutex::new(())))
.clone();
let _guard = gate.lock().await;
if done.get(&hash).is_some() {
return;
}
// simulate the remote fetch
tokio::time::sleep(std::time::Duration::from_millis(20)).await;
fetches.fetch_add(1, Ordering::Relaxed);
done.insert(hash.clone(), CacheEntry { size: 1 });
inflight.remove(&hash);
}));
}
for h in handles {
h.await.unwrap();
}
assert_eq!(fetches.load(Ordering::Relaxed), 1, "single-flight broken");
println!("# gate (c) OK — 16 concurrent misses → 1 fetch");
}
#[tokio::main(flavor = "multi_thread", worker_threads = 4)]
async fn main() {
let n_files: usize = env_or("BENCH_FILES", 256);
let hashes: Arc<Vec<String>> = Arc::new(
(0..n_files)
.map(|i| format!("{:02x}benchhash{i:06}", i % 256))
.collect(),
);
println!("#################################################################");
println!("# Blob-cache index — Mutex<LruCache> vs moka byte-weigher");
println!("#################################################################");
section_index_ops(hashes.clone()).await;
section_warm_reads(hashes.clone()).await;
section_safety_gates().await;
println!("\nGATE PASS (safety gates all hold — adopt if [1]/[2] favour moka)");
}