Skip to main content

perf_features/
perf_features.rs

1//! Feature classification bench. Each feature's representative op is swept
2//! across three TREE SIZES, `classify_feature` DECIDES the category from the
3//! shape of that sweep, and the decision plus a measured `p99ByStage` is
4//! merge-written into `.subms/features/rust.json`.
5//!
6//! Live key count is the sweep axis because it is what sets everything an LSM
7//! tree owns: the wal it has to replay, the entries a compaction has to rewrite,
8//! the runs a snapshot pins, the blocks a cache has to hold. A per-op read is
9//! size-independent and should read flat; anything that rewrites or rescans the
10//! whole structure should climb with N.
11//!
12//! Run:
13//!   cargo run --release --example perf_features \
14//!       --features "harness wal tiered-compaction leveled-compaction \
15//!                   snapshot lz4 zstd block-cache-integration"
16
17use std::collections::BTreeMap;
18use std::hint::black_box;
19use std::io::{self, Write};
20use std::path::{Path, PathBuf};
21
22use subms::{SubMsFeatureManifest, SubMsP99Source, SubMsPerfHarness, classify_feature, summarize};
23use subms_lsm_tree::LsmTree;
24
25/// 8k / 64k / 512k live keys, a 64x span. The bottom is deliberately not 1k:
26/// at a thousand entries a wal replay or a run merge is mostly fixed per-call
27/// cost, which compresses the measured ratio and reads as flat. The top is
28/// where the merges actually cost something (a 512k-entry BTreeMap rebuild).
29const SIZES: [usize; 3] = [8_192, 65_536, 524_288];
30const CANON_N: usize = SIZES[SIZES.len() - 1];
31/// Per-op reps. Fixed across the sweep so a slope has one cause.
32const OPS: usize = 20_000;
33/// Samples per bulk op. A whole-structure call is far above the per-key budget,
34/// so a distribution needs repeats rather than one shot. 256 is a FLOOR, not a
35/// preference: the harness takes p99 as `sorted[floor(0.99 * n)]`, so at n <= 100
36/// that index IS `n - 1` and the "p99" is the single worst sample. A structural
37/// verdict then turns on whichever rep caught a page fault. 256 puts two samples
38/// above the index and makes it a real percentile. Do not lower it.
39const BULK_REPS: usize = 256;
40/// Bulk warmup is TIME-BOXED, not a fixed rep count. Rust has no JIT, but these
41/// ops allocate hard (a merge builds a fresh BTreeMap; a replay builds a fresh
42/// Vec of owned entries) and the allocator plus page-fault ramp does not settle
43/// in a fixed handful of reps. A budget gives the cheap sizes thousands of
44/// passes and the expensive ones as many as fit.
45const BULK_WARM_NANOS: u64 = 300_000_000;
46const BULK_WARM_MAX_REPS: usize = 5_000;
47/// Per-op warm, also time-boxed, for the same reason and one more. A fixed
48/// 20_000 reps is 24 ms of a block codec, which is not enough to settle an
49/// allocator that has just had a few hundred megabytes of compaction templates
50/// freed under it: lz4 read 2200 -> 1500 -> 1100 ns across a sweep whose axis it
51/// does not even touch, and on the previous run the same artifact landed on
52/// zstd instead. The op is capped as well as timed so a 200 ns planner call does
53/// not spend the full budget.
54const KEYED_WARM_NANOS: u64 = 300_000_000;
55const KEYED_WARM_MAX_REPS: usize = 200_000;
56
57/// One SSTable data block. Held CONSTANT across the sweep on purpose - see the
58/// compression blocks below.
59const BLOCK_BYTES: usize = 4096;
60/// Entries per run in the compaction manifests. 128 runs at the top size.
61const ENTRIES_PER_RUN: usize = 4_096;
62/// Live keys behind one cached 4KB block at this recipe's entry size, so the
63/// cache scales with the tree instead of staying a fixed 1024 slots.
64const KEYS_PER_BLOCK: usize = 8;
65/// Live keys per on-disk run, so a snapshot pins a manifest that grows with N.
66const KEYS_PER_SSTABLE: usize = 4_096;
67/// Big enough that the base tree ends up with ~20 runs rather than ~1300; a
68/// read walking 1300 blooms measures the flush threshold, not the read path.
69const FLUSH_BYTES: usize = 1_000_000;
70
71const VALUE: &[u8] = b"value-payload-bytes-24ch";
72
73/// Zero-padded so key order matches insertion order and a run's key range is a
74/// contiguous interval - what leveled compaction's overlap selection assumes.
75fn key(i: usize) -> String {
76    format!("k{i:09}")
77}
78
79/// Spreads probes over the whole key space without a live rng in the timed loop.
80fn probe(i: usize, n: usize) -> usize {
81    (i.wrapping_mul(2_654_435_761)) % n
82}
83
84fn stat(h: &SubMsPerfHarness, median: bool) -> u64 {
85    summarize(h)
86        .stages
87        .iter()
88        .find(|s| s.name == "op")
89        .map_or(0, |s| if median { s.p50_ns } else { s.p99_ns })
90}
91
92/// A per-op measurement, warmed over the same index range it then times.
93fn keyed(mut op: impl FnMut(usize), median: bool) -> u64 {
94    let start = std::time::Instant::now();
95    for i in 0..KEYED_WARM_MAX_REPS {
96        op(i % OPS);
97        if start.elapsed().as_nanos() as u64 >= KEYED_WARM_NANOS {
98            break;
99        }
100    }
101    let mut h = SubMsPerfHarness::new("lsm-feature", "rust");
102    let st = h.stage("op", OPS);
103    for i in 0..OPS {
104        st.time(|| op(i));
105    }
106    stat(&h, median)
107}
108
109/// A whole-structure op that leaves its input intact, so one setup serves every
110/// rep. The warm pass is discarded: measured cold, a bulk op lands its
111/// first-touch cost on whichever sweep point runs first, which reads as a curve
112/// that FALLS with size - the opposite of the structural signal.
113fn bulk(mut op: impl FnMut(), median: bool) -> u64 {
114    let start = std::time::Instant::now();
115    for _ in 0..BULK_WARM_MAX_REPS {
116        op();
117        if start.elapsed().as_nanos() as u64 >= BULK_WARM_NANOS {
118            break;
119        }
120    }
121    let mut h = SubMsPerfHarness::new("lsm-feature", "rust");
122    let st = h.stage("op", BULK_REPS);
123    for _ in 0..BULK_REPS {
124        st.time(&mut op);
125    }
126    stat(&h, median)
127}
128
129/// A whole-structure op that CONSUMES its input. Both compaction entry points
130/// take the runs out of the level they compact, so a second rep would merge an
131/// empty level and the curve would read flat. `setup` rebuilds the input before
132/// each rep, OUTSIDE the timed region - the alternative, rebuilding inside the
133/// closure, publishes the manifest build as if it were the merge.
134fn bulk_each<T>(mut setup: impl FnMut() -> T, mut op: impl FnMut(&mut T), median: bool) -> u64 {
135    let start = std::time::Instant::now();
136    for _ in 0..BULK_WARM_MAX_REPS {
137        let mut input = setup();
138        op(&mut input);
139        if start.elapsed().as_nanos() as u64 >= BULK_WARM_NANOS {
140            break;
141        }
142    }
143    let mut h = SubMsPerfHarness::new("lsm-feature", "rust");
144    let st = h.stage("op", BULK_REPS);
145    for _ in 0..BULK_REPS {
146        let mut input = setup();
147        st.time(|| op(&mut input));
148    }
149    stat(&h, median)
150}
151
152/// Sweeps and PRINTS the curve. A ratio-compressed or non-monotonic curve
153/// classifies flat and the rows are the only place it shows.
154fn sweep(label: &str, mut at: impl FnMut(usize) -> u64) -> Vec<(usize, u64)> {
155    let rows: Vec<(usize, u64)> = SIZES.iter().map(|&n| (n, at(n))).collect();
156    eprintln!("sweep {label}: {rows:?}");
157    rows
158}
159
160fn main() -> io::Result<()> {
161    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
162        .join("..")
163        .join(".subms")
164        .join("features")
165        .join("rust.json");
166    let existing = std::fs::read_to_string(&path).unwrap_or_default();
167    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
168    // Stamp the box these numbers came from. The bench runs wherever it is
169    // invoked, so an unstamped manifest is indistinguishable from a fleet
170    // capture; the renderer will not publish one it cannot attribute.
171    let (source, instance) = SubMsP99Source::from_env();
172    manifest.set_p99_source(source, instance.as_deref());
173
174    let tmp = TempDir::new("subms-lsm-features");
175
176    // The baseline: a base `get` against a real tree at the canonical size.
177    // Every feature is classified against the cost of the read it decorates.
178    let base_p50 = {
179        let mut tree = LsmTree::open(tmp.path().join("base"), FLUSH_BYTES)?;
180        for i in 0..CANON_N {
181            tree.put(&key(i), VALUE)?;
182        }
183        tree.flush()?;
184        let probes: Vec<String> = (0..OPS).map(|i| key(probe(i, CANON_N))).collect();
185        keyed(
186            |i| {
187                black_box(tree.get(&probes[i]).expect("get"));
188            },
189            true,
190        )
191    };
192    eprintln!("base get p50: {base_p50}ns ({CANON_N} live keys)");
193
194    #[cfg(feature = "wal")]
195    feature_wal(&mut manifest, base_p50, tmp.path());
196
197    #[cfg(feature = "tiered-compaction")]
198    feature_tiered(&mut manifest, base_p50);
199
200    #[cfg(feature = "leveled-compaction")]
201    feature_leveled(&mut manifest, base_p50);
202
203    #[cfg(feature = "snapshot")]
204    feature_snapshot(&mut manifest, base_p50);
205
206    #[cfg(feature = "lz4")]
207    feature_lz4(&mut manifest, base_p50);
208
209    #[cfg(feature = "zstd")]
210    feature_zstd(&mut manifest, base_p50);
211
212    #[cfg(feature = "block-cache-integration")]
213    feature_block_cache(&mut manifest, base_p50);
214
215    drop(tmp);
216    std::fs::create_dir_all(path.parent().unwrap())?;
217    std::fs::write(&path, manifest.to_json())?;
218    io::stdout().write_all(manifest.to_json().as_bytes())?;
219    Ok(())
220}
221
222// ---------- wal: durable append, whole-log replay ----------
223
224/// Writes `n` put records and returns the log path.
225#[cfg(feature = "wal")]
226fn wal_of(dir: &Path, n: usize) -> PathBuf {
227    use subms_lsm_tree::WriteAheadLog;
228    let path = dir.join(format!("replay-{n}.wal"));
229    let _ = std::fs::remove_file(&path);
230    let mut wal = WriteAheadLog::open(&path).expect("open wal");
231    for i in 0..n {
232        wal.log_put(&key(i), VALUE).expect("log_put");
233    }
234    wal.sync().expect("sync");
235    path
236}
237
238#[cfg(feature = "wal")]
239fn feature_wal(manifest: &mut SubMsFeatureManifest, base_p50: u64, dir: &Path) {
240    use subms_lsm_tree::WriteAheadLog;
241
242    // Swept on `replay`, not on `log_put`. The append is one buffered write per
243    // record and is flat by construction; recovery - reading and CRC-verifying
244    // every record written since the last flush - is what the wal EXISTS for,
245    // and it is the part that grows with the tree.
246    let sw = sweep("wal/replay", |n| {
247        let path = wal_of(dir, n);
248        let out = bulk(
249            || {
250                black_box(WriteAheadLog::replay(&path).expect("replay").len());
251            },
252            true,
253        );
254        let _ = std::fs::remove_file(&path);
255        out
256    });
257    let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
258
259    let replay_path = wal_of(dir, CANON_N);
260    let mut p99 = BTreeMap::new();
261    p99.insert(
262        "replay".to_string(),
263        bulk(
264            || {
265                black_box(WriteAheadLog::replay(&replay_path).expect("replay").len());
266            },
267            false,
268        ),
269    );
270    let _ = std::fs::remove_file(&replay_path);
271
272    // A scratch log, so the append measurement does not inflate the one above.
273    let append_path = dir.join("append.wal");
274    let _ = std::fs::remove_file(&append_path);
275    {
276        let mut wal = WriteAheadLog::open(&append_path).expect("open wal");
277        let keys: Vec<String> = (0..OPS).map(key).collect();
278        p99.insert(
279            "log_put".to_string(),
280            keyed(|i| wal.log_put(&keys[i], VALUE).expect("log_put"), false),
281        );
282    }
283    let _ = std::fs::remove_file(&append_path);
284    // `sync` is deliberately absent from both the sweep and the stage table.
285    // fsync is a device property - tens of us on battery-backed NVMe, single-
286    // digit ms on this laptop tier - so a number for it would move with the
287    // hardware under a column the reader reads as the cost of the code, and
288    // sweeping it would dress a constant storage-stack cost as a scaling result.
289    manifest.set_feature("wal", cat, &p99, &reason);
290}
291
292// ---------- tiered-compaction: merge every run at a level into one ----------
293
294#[cfg(feature = "tiered-compaction")]
295fn feature_tiered(manifest: &mut SubMsFeatureManifest, base_p50: u64) {
296    use subms_lsm_tree::{TieredCompactionPlanner, TieredManifest, TieredRun};
297
298    // One template per size, deep-cloned per rep. Cloning is the same order of
299    // work as building the runs from scratch but skips re-formatting 512k keys.
300    fn runs(n: usize) -> Vec<TieredRun> {
301        (0..n.div_ceil(ENTRIES_PER_RUN))
302            .map(|r| {
303                let entries: Vec<(String, Option<Vec<u8>>)> = (0..ENTRIES_PER_RUN)
304                    .map(|j| (key(r * ENTRIES_PER_RUN + j), Some(VALUE.to_vec())))
305                    .collect();
306                TieredRun::new(r as u64, entries)
307            })
308            .collect()
309    }
310
311    let planner = TieredCompactionPlanner::new(2);
312    // Swept on `merge`, not on `pick_level`. Picking a level is a scan of the
313    // per-level run counts and is O(levels); the merge is the whole point of
314    // the feature and rewrites every entry at the level.
315    let sw = sweep("tiered-compaction/merge", |n| {
316        let template = runs(n);
317        bulk_each(
318            || TieredManifest {
319                levels: vec![template.clone()],
320            },
321            |m| planner.merge(m, 0, 9_999),
322            true,
323        )
324    });
325    let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
326
327    let template = runs(CANON_N);
328    let mut p99 = BTreeMap::new();
329    p99.insert(
330        "merge".to_string(),
331        bulk_each(
332            || TieredManifest {
333                levels: vec![template.clone()],
334            },
335            |m| planner.merge(m, 0, 9_999),
336            false,
337        ),
338    );
339    let planned = TieredManifest {
340        levels: vec![template],
341    };
342    p99.insert(
343        "plan".to_string(),
344        keyed(|_| _ = black_box(planner.pick_level(&planned)), false),
345    );
346    manifest.set_feature("tiered-compaction", cat, &p99, &reason);
347}
348
349// ---------- leveled-compaction: merge L0 into the overlapping L1 runs ----------
350
351#[cfg(feature = "leveled-compaction")]
352fn feature_leveled(manifest: &mut SubMsFeatureManifest, base_p50: u64) {
353    use subms_lsm_tree::{LeveledCompactionPlanner, LeveledManifest, LeveledRun};
354
355    // L0 and L1 interleave over the same key space (even indices vs odd), so
356    // every L1 run overlaps the L0 range and the compaction picks all of them
357    // up. Disjoint halves would leave L1 untouched and the merge would only
358    // ever rewrite half the tree.
359    fn halves(n: usize) -> (Vec<LeveledRun>, Vec<LeveledRun>) {
360        let per_level = n / 2;
361        let build = |parity: usize| -> Vec<LeveledRun> {
362            (0..per_level.div_ceil(ENTRIES_PER_RUN))
363                .map(|r| {
364                    let entries: Vec<(String, Option<Vec<u8>>)> = (0..ENTRIES_PER_RUN)
365                        .map(|j| {
366                            let idx = 2 * (r * ENTRIES_PER_RUN + j) + parity;
367                            (key(idx), Some(VALUE.to_vec()))
368                        })
369                        .collect();
370                    LeveledRun::new((r * 2 + parity) as u64, entries)
371                })
372                .collect()
373        };
374        (build(0), build(1))
375    }
376
377    let planner = LeveledCompactionPlanner::new(64_000, 10, 4);
378    // Swept on `compact`, not on `pick_level`. The budget scan is O(runs); the
379    // compaction rewrites every entry it touches.
380    let sw = sweep("leveled-compaction/compact", |n| {
381        let (l0, l1) = halves(n);
382        bulk_each(
383            || LeveledManifest {
384                levels: vec![l0.clone(), l1.clone()],
385            },
386            |m| planner.compact(m, 0, 9_999),
387            true,
388        )
389    });
390    let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
391
392    let (l0, l1) = halves(CANON_N);
393    let mut p99 = BTreeMap::new();
394    p99.insert(
395        "compact".to_string(),
396        bulk_each(
397            || LeveledManifest {
398                levels: vec![l0.clone(), l1.clone()],
399            },
400            |m| planner.compact(m, 0, 9_999),
401            false,
402        ),
403    );
404    let planned = LeveledManifest {
405        levels: vec![l0, l1],
406    };
407    p99.insert(
408        "plan".to_string(),
409        keyed(|_| _ = black_box(planner.pick_level(&planned)), false),
410    );
411    manifest.set_feature("leveled-compaction", cat, &p99, &reason);
412}
413
414// ---------- snapshot: pinned point-in-time view of the run manifest ----------
415
416#[cfg(feature = "snapshot")]
417fn feature_snapshot(manifest: &mut SubMsFeatureManifest, base_p50: u64) {
418    use subms_lsm_tree::{SnapshotManager, SnapshotManifest};
419
420    fn manager(n: usize) -> SnapshotManager {
421        let ids: Vec<u64> = (0..(n / KEYS_PER_SSTABLE).max(1) as u64).collect();
422        SnapshotManager::with_initial(SnapshotManifest::new(ids))
423    }
424
425    // The manifest under test grows with the tree - 2 run ids at the bottom
426    // size, 128 at the top - which is what makes a flat result mean something.
427    // Taking a snapshot is an Arc bump and an id increment behind two short
428    // mutex sections, so it should not care, and the sweep is how that is shown
429    // rather than asserted.
430    let sw = sweep("snapshot/snapshot", |n| {
431        let mgr = manager(n);
432        keyed(|_| _ = black_box(mgr.snapshot()), true)
433    });
434    let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
435
436    let mgr = manager(CANON_N);
437    let mut p99 = BTreeMap::new();
438    p99.insert(
439        "snapshot".to_string(),
440        keyed(|_| _ = black_box(mgr.snapshot()), false),
441    );
442    // The read side: resolve a key against a held view by walking its pinned run
443    // ids newest-first, the order the tree's own read path uses.
444    let held = mgr.snapshot();
445    let ids = held.sstable_ids();
446    let targets: Vec<u64> = (0..OPS)
447        .map(|i| probe(i, ids.len().max(1) * 2) as u64)
448        .collect();
449    p99.insert(
450        "get_on_snapshot".to_string(),
451        keyed(
452            |i| {
453                let t = targets[i];
454                black_box(ids.iter().rev().any(|&id| id == t));
455            },
456            false,
457        ),
458    );
459    manifest.set_feature("snapshot", cat, &p99, &reason);
460}
461
462// ---------- lz4 / zstd: SSTable block compression ----------
463
464/// A representative ~4KB SSTable data block: repeating record-shaped text so
465/// the compressors have realistic-but-not-degenerate redundancy.
466#[cfg(any(feature = "lz4", feature = "zstd", feature = "block-cache-integration"))]
467fn representative_block() -> Vec<u8> {
468    let pattern = b"key-0000042\x00present\x00value-payload-bytes-for-block|";
469    let mut out = Vec::with_capacity(BLOCK_BYTES + pattern.len());
470    while out.len() < BLOCK_BYTES {
471        out.extend_from_slice(pattern);
472    }
473    out.truncate(BLOCK_BYTES);
474    out
475}
476
477#[cfg(feature = "lz4")]
478fn feature_lz4(manifest: &mut SubMsFeatureManifest, base_p50: u64) {
479    use subms_lsm_tree::Lz4BlockCompressor;
480
481    let c = Lz4BlockCompressor::new();
482    // The block is held at BLOCK_BYTES at EVERY sweep point. Compression cost
483    // tracks the bytes handed to the codec, so growing the block with the tree
484    // would publish a payload sweep dressed as a tree-size sweep - and an LSM
485    // block size is a configuration constant, not a function of how many keys
486    // are live. The flat curve is the finding: a bigger tree is more blocks at
487    // the same per-block cost, not a more expensive block.
488    let block = representative_block();
489    let sw = sweep("lz4/compress", |_| {
490        keyed(|_| _ = black_box(c.compress(&block)), true)
491    });
492    let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
493
494    let encoded = c.compress(&block);
495    let mut p99 = BTreeMap::new();
496    p99.insert(
497        "compress_block".to_string(),
498        keyed(|_| _ = black_box(c.compress(&block)), false),
499    );
500    p99.insert(
501        "decompress_block".to_string(),
502        keyed(
503            |_| _ = black_box(c.decompress(&encoded).expect("lz4 decode")),
504            false,
505        ),
506    );
507    manifest.set_feature("lz4", cat, &p99, &reason);
508}
509
510#[cfg(feature = "zstd")]
511fn feature_zstd(manifest: &mut SubMsFeatureManifest, base_p50: u64) {
512    use subms_lsm_tree::ZstdBlockCompressor;
513
514    let c = ZstdBlockCompressor::new();
515    let block = representative_block();
516    let sw = sweep("zstd/compress", |_| {
517        keyed(
518            |_| _ = black_box(c.compress(&block).expect("zstd encode")),
519            true,
520        )
521    });
522    let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
523
524    let encoded = c.compress(&block).expect("zstd encode");
525    let mut p99 = BTreeMap::new();
526    p99.insert(
527        "compress_block".to_string(),
528        keyed(
529            |_| _ = black_box(c.compress(&block).expect("zstd encode")),
530            false,
531        ),
532    );
533    p99.insert(
534        "decompress_block".to_string(),
535        keyed(
536            |_| _ = black_box(c.decompress(&encoded).expect("zstd decode")),
537            false,
538        ),
539    );
540    manifest.set_feature("zstd", cat, &p99, &reason);
541}
542
543// ---------- block-cache-integration: read-path block cache ----------
544
545#[cfg(feature = "block-cache-integration")]
546fn feature_block_cache(manifest: &mut SubMsFeatureManifest, base_p50: u64) {
547    use std::sync::Arc;
548    use subms_lsm_tree::{Block, BlockCache, BlockKey, LruBlockCache};
549
550    // Capacity scales with the tree and the cache is filled to it, so the
551    // occupied fraction is the same at every sweep point. A fixed 1024 slots
552    // would hold the hash map at one size while claiming to sweep the tree.
553    // One shared `Arc<[u8]>` payload keeps 64k cached blocks in memory instead
554    // of 256 MB of identical bytes; the cache stores the pointer either way.
555    fn filled(n: usize) -> (LruBlockCache, usize) {
556        let cap = (n / KEYS_PER_BLOCK).max(64);
557        let cache = LruBlockCache::new(cap);
558        let block: Block = Arc::from(representative_block().into_boxed_slice());
559        for i in 0..cap as u64 {
560            cache.put(BlockKey::new(i % 8, i * BLOCK_BYTES as u64), block.clone());
561        }
562        (cache, cap)
563    }
564
565    let sw = sweep("block-cache-integration/get_cached", |n| {
566        let (cache, cap) = filled(n);
567        let keys: Vec<BlockKey> = (0..OPS)
568            .map(|i| {
569                let k = probe(i, cap) as u64;
570                BlockKey::new(k % 8, k * BLOCK_BYTES as u64)
571            })
572            .collect();
573        keyed(|i| _ = black_box(cache.get(&keys[i])), true)
574    });
575    let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
576
577    let (cache, cap) = filled(CANON_N);
578    let hits: Vec<BlockKey> = (0..OPS)
579        .map(|i| {
580            let k = probe(i, cap) as u64;
581            BlockKey::new(k % 8, k * BLOCK_BYTES as u64)
582        })
583        .collect();
584    let misses: Vec<BlockKey> = (0..OPS)
585        .map(|i| BlockKey::new(999, probe(i, cap) as u64))
586        .collect();
587    let mut p99 = BTreeMap::new();
588    p99.insert(
589        "get_cached".to_string(),
590        keyed(|i| _ = black_box(cache.get(&hits[i])), false),
591    );
592    p99.insert(
593        "get_miss".to_string(),
594        keyed(|i| _ = black_box(cache.get(&misses[i])), false),
595    );
596    manifest.set_feature("block-cache-integration", cat, &p99, &reason);
597}
598
599/// Minimal unique-per-process temp dir with best-effort cleanup on drop.
600struct TempDir {
601    path: PathBuf,
602}
603
604impl TempDir {
605    fn new(label: &str) -> Self {
606        let path = std::env::temp_dir().join(format!("{}-{}", label, std::process::id()));
607        let _ = std::fs::remove_dir_all(&path);
608        std::fs::create_dir_all(&path).expect("create temp dir");
609        Self { path }
610    }
611    fn path(&self) -> &Path {
612        &self.path
613    }
614}
615
616impl Drop for TempDir {
617    fn drop(&mut self) {
618        let _ = std::fs::remove_dir_all(&self.path);
619    }
620}