Skip to main content

perf_features/
perf_features.rs

1//! Per-feature bench: sweeps each opt-in feature (`arc`, `tinylfu`, `weighted`,
2//! `concurrent-shards`, `metrics`) across three cache capacities, lets
3//! `classify_feature` DECIDE the category from the shape of that sweep, and
4//! merge-writes the decision into `../.subms/features/rust.json`.
5//!
6//! A cache's "size" is its capacity, so the sweep fills to N and times the
7//! lookup path there. A per-op cost that holds steady as N grows is `hot-path`;
8//! one that climbs with N is `structural`. That is the claim worth measuring for
9//! a cache: an eviction policy that quietly walks the resident set does not stay
10//! sub-millisecond as the cache grows, and only a sweep catches it.
11//!
12//! The sweep classifies on p50. p99 over a few dozen samples is just the worst
13//! one, and a single scheduler slice is large enough to swamp the size signal the
14//! sweep is reading. The p99 still goes into the manifest for the stage table.
15//!
16//! This replaces the previous shape, which ran every variant at ONE capacity and
17//! ASSERTED hot-path via `SubMsStageKind::HotPath`. An asserted category is an
18//! opinion the bench cannot contradict; a sweep measures it, and can disagree.
19//!
20//! `clock-sweep` is not a feature here: it IS the base cache, so its lookup is
21//! the baseline every feature is classified against.
22//!
23//! These p99 figures describe THIS machine. They are published only when the
24//! manifest is stamped `p99_source: fleet`; a local run leaves the category,
25//! which is machine independent, and no published number.
26//!
27//! Run:
28//!   cargo run --release --example perf_features \
29//!       --features "harness arc tinylfu weighted concurrent-shards metrics"
30
31use std::collections::BTreeMap;
32use std::io::{self, Write};
33use std::path::PathBuf;
34
35use subms::{
36    SubMsFeatureManifest, SubMsLcg, SubMsP99Source, SubMsPerfHarness, classify_feature, summarize,
37};
38
39/// Cache capacities the sweep walks.
40const SIZES: [usize; 3] = [4_096, 32_768, 262_144];
41const SEED: u64 = 0;
42
43fn stage_stats(h: &SubMsPerfHarness, name: &str) -> (u64, u64) {
44    summarize(h)
45        .stages
46        .iter()
47        .find(|s| s.name == name)
48        .map_or((0, 0), |s| (s.p50_ns, s.p99_ns))
49}
50
51/// (p50, p99) in ns of `n` reads of keys drawn from the resident set `0..n`.
52fn get_hit(n: usize, mut get: impl FnMut(u32) -> bool) -> (u64, u64) {
53    let mut rng = SubMsLcg::new(SEED);
54    let mut h = SubMsPerfHarness::new("block-cache-feature", "rust");
55    {
56        let st = h.stage("op", n);
57        for _ in 0..n {
58            let key = rng.next_u32() % (n as u32);
59            st.time(|| {
60                let _ = get(key);
61            });
62        }
63    }
64    stage_stats(&h, "op")
65}
66
67/// (p50, p99) in ns of `n` inserts of FRESH keys against a full cache, so every
68/// put drives an eviction - the path a capacity claim actually rests on.
69fn put_evicting(n: usize, mut put: impl FnMut(u32)) -> (u64, u64) {
70    let base = n as u32;
71    let mut h = SubMsPerfHarness::new("block-cache-feature", "rust");
72    {
73        let st = h.stage("op", n);
74        for i in 0..n {
75            let key = base + i as u32;
76            st.time(|| put(key));
77        }
78    }
79    stage_stats(&h, "op")
80}
81
82fn main() -> io::Result<()> {
83    let canon = SIZES[SIZES.len() - 1];
84
85    let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
86        .join("..")
87        .join(".subms")
88        .join("features")
89        .join("rust.json");
90    let existing = std::fs::read_to_string(&path).unwrap_or_default();
91    let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
92    // Stamp the box these numbers came from. The bench runs wherever it is
93    // invoked, so an unstamped manifest is indistinguishable from a fleet
94    // capture; the renderer will not publish one it cannot attribute.
95    let (source, instance) = SubMsP99Source::from_env();
96    manifest.set_p99_source(source, instance.as_deref());
97
98    // ---------- base (clock-sweep): the baseline, not a feature ----------
99    // Every feature is classified against this. A variant whose lookup lands
100    // within a whisker of the base is a capability, not a latency change, and
101    // classify_feature says so rather than calling it hot-path by default.
102    // The baseline is a p50, because the sweep values are p50s. Handing
103    // classify_feature a base p99 against p50 sweep points compares two
104    // different statistics: the p50 sits below the p99 almost by construction,
105    // so every feature reads as "within 10% of base" and lands auxiliary.
106    let base_p50 = {
107        use subms_block_cache::BlockCache;
108        let mut c: BlockCache<u32, u64> = BlockCache::with_capacity(canon);
109        for k in 0..canon as u32 {
110            c.put(k, k as u64);
111        }
112        let (p50, _) = get_hit(canon, |key| c.get(&key).is_some());
113        p50
114    };
115
116    // ---------- arc: adaptive replacement, recency + frequency lists ----------
117    #[cfg(feature = "arc")]
118    {
119        use subms_block_cache::ArcCache;
120        let sweep: Vec<(usize, u64)> = SIZES
121            .iter()
122            .map(|&n| {
123                let mut c: ArcCache<u32, u64> = ArcCache::with_capacity(n);
124                for k in 0..n as u32 {
125                    c.put(k, k as u64);
126                }
127                let (p50, _) = get_hit(n, |key| c.get(&key).is_some());
128                (n, p50)
129            })
130            .collect();
131        let (cat, reason) = classify_feature(&sweep, Some(base_p50), None);
132
133        let mut c: ArcCache<u32, u64> = ArcCache::with_capacity(canon);
134        for k in 0..canon as u32 {
135            c.put(k, k as u64);
136        }
137        let (_, get99) = get_hit(canon, |key| c.get(&key).is_some());
138        let (_, put99) = put_evicting(canon, |key| {
139            c.put(key, key as u64);
140        });
141        let mut p99 = BTreeMap::new();
142        p99.insert("get_hit".to_string(), get99);
143        p99.insert("put".to_string(), put99);
144        manifest.set_feature("arc", cat, &p99, &reason);
145    }
146
147    // ---------- tinylfu: frequency-sketch admission ----------
148    #[cfg(feature = "tinylfu")]
149    {
150        use subms_block_cache::TinyLfuCache;
151        let sweep: Vec<(usize, u64)> = SIZES
152            .iter()
153            .map(|&n| {
154                let mut c: TinyLfuCache<u32, u64> = TinyLfuCache::with_capacity(n);
155                for k in 0..n as u32 {
156                    c.put(k, k as u64);
157                }
158                let (p50, _) = get_hit(n, |key| c.get(&key).is_some());
159                (n, p50)
160            })
161            .collect();
162        let (cat, reason) = classify_feature(&sweep, Some(base_p50), None);
163
164        let mut c: TinyLfuCache<u32, u64> = TinyLfuCache::with_capacity(canon);
165        for k in 0..canon as u32 {
166            c.put(k, k as u64);
167        }
168        let (_, get99) = get_hit(canon, |key| c.get(&key).is_some());
169        let (_, put99) = put_evicting(canon, |key| {
170            c.put(key, key as u64);
171        });
172        let mut p99 = BTreeMap::new();
173        p99.insert("get_hit".to_string(), get99);
174        p99.insert("put".to_string(), put99);
175        manifest.set_feature("tinylfu", cat, &p99, &reason);
176    }
177
178    // ---------- weighted: a byte budget rather than a slot count ----------
179    #[cfg(feature = "weighted")]
180    {
181        use subms_block_cache::WeightedCache;
182        // 1 byte per entry so capacity_bytes == slot capacity; eviction behaves
183        // like the base cache, which isolates the weight bookkeeping itself.
184        let sweep: Vec<(usize, u64)> = SIZES
185            .iter()
186            .map(|&n| {
187                let mut c: WeightedCache<u32, u64> =
188                    WeightedCache::with_capacity_bytes(n, |_v: &u64| 1);
189                for k in 0..n as u32 {
190                    c.put(k, k as u64);
191                }
192                let (p50, _) = get_hit(n, |key| c.get(&key).is_some());
193                (n, p50)
194            })
195            .collect();
196        let (cat, reason) = classify_feature(&sweep, Some(base_p50), None);
197
198        let mut c: WeightedCache<u32, u64> =
199            WeightedCache::with_capacity_bytes(canon, |_v: &u64| 1);
200        for k in 0..canon as u32 {
201            c.put(k, k as u64);
202        }
203        let (_, get99) = get_hit(canon, |key| c.get(&key).is_some());
204        let (_, put99) = put_evicting(canon, |key| {
205            let _ = c.put(key, key as u64);
206        });
207        let mut p99 = BTreeMap::new();
208        p99.insert("get_hit".to_string(), get99);
209        p99.insert("put".to_string(), put99);
210        manifest.set_feature("weighted", cat, &p99, &reason);
211    }
212
213    // ---------- concurrent-shards: measured single-threaded ----------
214    // Uncontended on purpose. This isolates the sharding INDIRECTION from the
215    // contention it exists to relieve; a multi-threaded number here would say
216    // more about the thread count than about the feature.
217    #[cfg(feature = "concurrent-shards")]
218    {
219        use subms_block_cache::ShardedCache;
220        let sweep: Vec<(usize, u64)> = SIZES
221            .iter()
222            .map(|&n| {
223                let c: ShardedCache<u32, u64> = ShardedCache::with_capacity(n, 16);
224                for k in 0..n as u32 {
225                    c.put(k, k as u64);
226                }
227                let (p50, _) = get_hit(n, |key| c.get(&key).is_some());
228                (n, p50)
229            })
230            .collect();
231        let (cat, reason) = classify_feature(&sweep, Some(base_p50), None);
232
233        let c: ShardedCache<u32, u64> = ShardedCache::with_capacity(canon, 16);
234        for k in 0..canon as u32 {
235            c.put(k, k as u64);
236        }
237        let (_, get99) = get_hit(canon, |key| c.get(&key).is_some());
238        let (_, put99) = put_evicting(canon, |key| {
239            c.put(key, key as u64);
240        });
241        let mut p99 = BTreeMap::new();
242        p99.insert("get_hit".to_string(), get99);
243        p99.insert("put".to_string(), put99);
244        manifest.set_feature("concurrent-shards", cat, &p99, &reason);
245    }
246
247    // ---------- metrics: hit/miss counters on the lookup path ----------
248    #[cfg(feature = "metrics")]
249    {
250        use subms_block_cache::MetricsCache;
251        let sweep: Vec<(usize, u64)> = SIZES
252            .iter()
253            .map(|&n| {
254                let mut c: MetricsCache<u32, u64> = MetricsCache::with_capacity(n);
255                for k in 0..n as u32 {
256                    c.put(k, k as u64);
257                }
258                let (p50, _) = get_hit(n, |key| c.get(&key).is_some());
259                (n, p50)
260            })
261            .collect();
262        let (cat, reason) = classify_feature(&sweep, Some(base_p50), None);
263
264        let mut c: MetricsCache<u32, u64> = MetricsCache::with_capacity(canon);
265        for k in 0..canon as u32 {
266            c.put(k, k as u64);
267        }
268        let (_, get99) = get_hit(canon, |key| c.get(&key).is_some());
269        let (_, put99) = put_evicting(canon, |key| {
270            c.put(key, key as u64);
271        });
272        let mut p99 = BTreeMap::new();
273        p99.insert("get_hit".to_string(), get99);
274        p99.insert("put".to_string(), put99);
275        manifest.set_feature("metrics", cat, &p99, &reason);
276    }
277
278    std::fs::create_dir_all(path.parent().unwrap())?;
279    std::fs::write(&path, manifest.to_json())?;
280    io::stdout().write_all(manifest.to_json().as_bytes())?;
281    Ok(())
282}