perf_features/perf_features.rs
1//! Feature classification bench. Each feature's representative op is swept
2//! across three FLEET 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//! The sweep axis is the number of independent limited entities the workload
7//! cycles over: buckets for `token-bucket` and `metrics`, children for
8//! `hierarchical`, live keys for `distributed-backend`. A limiter has no
9//! internal array to grow, so the only thing a deployment scales is the fleet
10//! of tenants, and that is the axis every feature here shares. Four of the five
11//! are O(1) per acquire and should read flat; `distributed-backend` sweeps the
12//! whole counter map on every `incr`, so it should climb.
13//!
14//! Run:
15//! cargo run --release --example perf_features \
16//! --features "harness token-bucket hierarchical distributed-backend metrics keyed"
17
18use std::collections::BTreeMap;
19use std::io::{self, Write};
20use std::path::PathBuf;
21use std::sync::atomic::{AtomicU64, Ordering};
22use std::time::Instant;
23
24use subms::{SubMsFeatureManifest, SubMsP99Source, SubMsPerfHarness, classify_feature, summarize};
25#[cfg(feature = "keyed")]
26use subms_rate_limiter::Acquire;
27use subms_rate_limiter::{Clock, RateLimiter};
28
29/// 1024 / 8192 / 65536 limiters, a 64x span.
30const SIZES: [usize; 3] = [1024, 8192, 65536];
31const CANON_N: usize = SIZES[SIZES.len() - 1];
32/// Timed ops per measurement, fixed across the sweep so a slope has one cause.
33/// Equal to the largest fleet, so every limiter is touched and the FILL scales
34/// with the size instead of a fixed 20k of them carrying the whole workload.
35const OPS: usize = CANON_N;
36
37/// The distributed backend's per-call cost is the counter-map GC, so its span
38/// is in live keys and it has to start high enough that the fixed per-call
39/// work (a `String` alloc, a hash, a lock) does not compress the ratio.
40const DIST_SIZES: [usize; 3] = [512, 2048, 8192];
41const DIST_CANON: usize = DIST_SIZES[DIST_SIZES.len() - 1];
42const DIST_OPS: usize = 4_096;
43/// Timed calls hit a FIXED-size hot subset of the prefilled keys, so each hot
44/// key takes the same number of bumps at every sweep point and the accept
45/// ratio does not move with N. The cold keys still sit in the map and are
46/// still swept by the GC - which is the cost being measured.
47const DIST_HOT: usize = 256;
48/// DIST_OPS / DIST_HOT = 16 bumps per hot key on top of the prefill's one, so
49/// counts run 2..=17 and a limit of 9 splits them exactly in half.
50const DIST_LIMIT: u64 = 9;
51/// An hour. The window must not roll during a run: a roll expires every
52/// prefilled key at once and the map collapses to the hot subset, which would
53/// silently delete the size axis.
54const DIST_WINDOW_NS: u64 = 3_600_000_000_000;
55
56/// Synthetic ns added per clock read. See `SteppingClock`.
57const STRIDE_NS: u64 = 1_000;
58
59/// Bucket capacity, and the rates that put the accept ratio near half.
60/// `TokenBucket::try_acquire` reads the clock once, so it accrues
61/// `TB_RATE * STRIDE_NS / 1e9` = 0.5 tokens per call. `MeteredTokenBucket`
62/// reads it three times (available, try_acquire, available), so its rate is a
63/// third of that to land on the same ratio.
64const TB_CAP: u64 = 4;
65const TB_RATE: f64 = 500_000.0;
66const MET_RATE: f64 = 166_667.0;
67/// Untimed acquires each bucket takes during setup, enough to spend the
68/// capacity it is built full with and settle into the alternating
69/// accept/reject steady state. Without it the accept ratio is a function of
70/// how many timed calls each bucket receives, which is `OPS / n` - so the
71/// mix, not the size, is what the sweep varies: measured 55% grants at 1024
72/// buckets, 88% at 8192 and 100% at 65536, where each bucket is touched once
73/// and a full bucket cannot do anything but grant.
74///
75/// Odd-indexed buckets take one extra, which is what makes the ratio hold at
76/// the top of the sweep. A settled bucket alternates, so a fleet settled in
77/// LOCKSTEP still grants on every first touch - 100%, not 50%, at the size
78/// where each bucket is touched exactly once. Half the fleet has to be
79/// settled on the other phase for the mix to come from across the fleet.
80const PRE_DRAIN: usize = 16;
81/// The parent is deliberately not the throttle: it accrues 2 tokens per call
82/// against the 1 it can spend, so it always grants and the child governs the
83/// accept ratio. A parent that also rejected would change the mix of paths
84/// taken as the fleet grew.
85const HIER_PARENT_CAP: u64 = 64;
86const HIER_PARENT_RATE: f64 = 2_000_000.0;
87
88const BASE_RATE: f64 = 1_000_000.0;
89const BASE_BURST: u64 = 4;
90
91/// Warm is TIME-BOXED rather than a fixed rep count. A fixed count leaves the
92/// cheap sweep points under-warmed and the expensive ones over-warmed, and an
93/// allocating op (every `incr` here builds a `String` key) has an allocator
94/// ramp that a handful of reps does not settle.
95const WARM_NANOS: u64 = 300_000_000;
96const WARM_MAX_REPS: usize = 200_000;
97
98/// Ops per timed sample on the classification pass. The platform timer ticks
99/// at 100 ns on this box, and a bucket acquire is a few tens of ns, so a
100/// per-op sample can only ever read 0 or 100 - base and `token-bucket` both
101/// landed on exactly 100 and the classifier called a mutex-guarded refill a
102/// non-effect. A batch of 16 puts the sample an order of magnitude above the
103/// tick. The published `p99ByStage` is still measured one op at a time: a
104/// batch mean would hide the tail, which is the number the site prints.
105const BATCH: usize = 16;
106
107/// A clock that reads the platform clock and then throws the reading away,
108/// returning a synthetic value that steps by a fixed `STRIDE_NS` per read.
109///
110/// Both halves are load-bearing. The read is kept because the production
111/// `SystemClock` makes exactly that call, and a fixture that skipped it would
112/// hand every feature a free saving the base limiter still pays, which reads
113/// as "cheaper than base" - auxiliary - for a feature that is not. The value
114/// is synthetic because real elapsed time between two touches of the SAME
115/// bucket scales with how many buckets the loop cycles: at 65536 buckets a
116/// bucket sees milliseconds between its own calls and refills to full every
117/// time, so the sweep would be varying token occupancy rather than size.
118///
119/// A frozen clock is the other half of the same trap: `refill_locked`
120/// early-returns on `elapsed == 0`, so a bucket driven by a stopped clock
121/// never runs the refill arithmetic at all and the feature measures as a
122/// compare-and-subtract.
123struct SteppingClock {
124 origin: Instant,
125 steps: AtomicU64,
126 sink: AtomicU64,
127}
128
129impl SteppingClock {
130 fn new() -> Self {
131 Self {
132 origin: Instant::now(),
133 steps: AtomicU64::new(0),
134 sink: AtomicU64::new(0),
135 }
136 }
137}
138
139impl Clock for SteppingClock {
140 fn now_ns(&self) -> u64 {
141 self.sink
142 .store(self.origin.elapsed().as_nanos() as u64, Ordering::Relaxed);
143 self.steps.fetch_add(STRIDE_NS, Ordering::Relaxed) + STRIDE_NS
144 }
145}
146
147struct Measured {
148 p50: u64,
149 p99: u64,
150 accept: f64,
151}
152
153fn stat(h: &SubMsPerfHarness, name: &str, median: bool) -> u64 {
154 summarize(h)
155 .stages
156 .iter()
157 .find(|s| s.name == name)
158 .map_or(0, |s| if median { s.p50_ns } else { s.p99_ns })
159}
160
161/// Builds the structure in `setup` OUTSIDE the timed region, warms on a
162/// throwaway copy, then measures a FRESH one so the state at sample 0 is
163/// identical at every sweep point - a warm pass on the measured instance would
164/// leave its buckets drained and its counters bumped by however many reps the
165/// time box happened to allow.
166///
167/// Two timed passes over that instance: one op per sample for the published
168/// p99, then `batch` ops per sample for the p50 the classifier reads. Pass
169/// `batch = 1` for an op already well clear of the timer tick; the second pass
170/// is then skipped rather than run for nothing.
171fn measure<T>(
172 mut setup: impl FnMut() -> T,
173 mut op: impl FnMut(&T, usize) -> bool,
174 ops: usize,
175 batch: usize,
176) -> Measured {
177 let warm = setup();
178 let start = Instant::now();
179 for rep in 0..WARM_MAX_REPS {
180 if start.elapsed().as_nanos() as u64 >= WARM_NANOS {
181 break;
182 }
183 op(&warm, rep % ops);
184 }
185 drop(warm);
186
187 let target = setup();
188 let mut h = SubMsPerfHarness::new("rate-limiter-feature", "rust");
189 let mut granted = 0usize;
190 {
191 let st = h.stage("op", ops);
192 for i in 0..ops {
193 if st.time(|| op(&target, i)) {
194 granted += 1;
195 }
196 }
197 }
198 let p99 = stat(&h, "op", false);
199 let p50 = if batch > 1 {
200 let samples = ops / batch;
201 let st = h.stage("batched", samples);
202 for s in 0..samples {
203 st.time(|| {
204 for k in 0..batch {
205 op(&target, s * batch + k);
206 }
207 });
208 }
209 stat(&h, "batched", true) / batch as u64
210 } else {
211 stat(&h, "op", true)
212 };
213 Measured {
214 p50,
215 p99,
216 accept: granted as f64 / ops as f64,
217 }
218}
219
220/// Sweeps, PRINTS the curve, and hands back both the `(size, p50)` rows the
221/// classifier reads and the canonical (largest) point, whose p99 goes in the
222/// manifest. Printing the accept ratio alongside is what makes it checkable
223/// that a sweep point did not quietly slide onto one branch.
224fn sweep(
225 label: &str,
226 sizes: &[usize],
227 mut at: impl FnMut(usize) -> Measured,
228) -> (Vec<(usize, u64)>, Measured) {
229 let mut rows = Vec::with_capacity(sizes.len());
230 let mut line = format!("sweep {label}:");
231 let mut last = None;
232 for &n in sizes {
233 let m = at(n);
234 line.push_str(&format!(
235 " (n={n} p50={}ns p99={}ns accept={:.0}%)",
236 m.p50,
237 m.p99,
238 m.accept * 100.0
239 ));
240 rows.push((n, m.p50));
241 last = Some(m);
242 }
243 eprintln!("{line}");
244 (rows, last.expect("non-empty sizes"))
245}
246
247fn main() -> io::Result<()> {
248 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
249 .join("..")
250 .join(".subms")
251 .join("features")
252 .join("rust.json");
253 let existing = std::fs::read_to_string(&path).unwrap_or_default();
254 let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
255 // Stamp the box these numbers came from. The bench runs wherever it is
256 // invoked, so an unstamped manifest is indistinguishable from a fleet
257 // capture; the renderer will not publish one it cannot attribute.
258 let (source, instance) = SubMsP99Source::from_env();
259 manifest.set_p99_source(source, instance.as_deref());
260
261 // The baseline: a plain `try_acquire` on the base GCRA limiter, cycled
262 // over a fleet the same size as the canonical sweep point so it pays the
263 // same cache footprint the features do. Every call is granted; GCRA's
264 // reject path returns before the CAS, so the grant is the dearer branch
265 // and the conservative baseline.
266 let base = measure(
267 || {
268 (0..CANON_N)
269 .map(|_| RateLimiter::new(BASE_RATE, BASE_BURST))
270 .collect::<Vec<_>>()
271 },
272 |v, i| v[i % v.len()].try_acquire(),
273 OPS,
274 BATCH,
275 );
276 let base_p50 = base.p50;
277 eprintln!(
278 "base try_acquire over {CANON_N} limiters: p50={base_p50}ns p99={}ns accept={:.0}%",
279 base.p99,
280 base.accept * 100.0
281 );
282 // One hammered limiter, for context only. It is not the classifier's base:
283 // comparing a fleet-cycling feature against a single hot limiter would
284 // charge the feature for the cache misses the workload shape causes.
285 let hot = measure(
286 || RateLimiter::new(BASE_RATE, (2 * OPS) as u64),
287 |r, _| r.try_acquire(),
288 OPS,
289 BATCH,
290 );
291 eprintln!(
292 "base try_acquire on 1 hot limiter: p50={}ns p99={}ns (context only)",
293 hot.p50, hot.p99
294 );
295
296 // ---------- token-bucket: mutex-guarded refill + batch drain ----------
297 #[cfg(feature = "token-bucket")]
298 {
299 use subms_rate_limiter::TokenBucket;
300
301 fn fleet(n: usize) -> Vec<TokenBucket> {
302 let v: Vec<TokenBucket> = (0..n)
303 .map(|_| TokenBucket::with_clock(TB_CAP, TB_RATE, Box::new(SteppingClock::new())))
304 .collect();
305 for (j, b) in v.iter().enumerate() {
306 for _ in 0..PRE_DRAIN + (j & 1) {
307 b.try_acquire(1);
308 }
309 }
310 v
311 }
312
313 let (rows, canon) = sweep("token-bucket/try_acquire", &SIZES, |n| {
314 measure(
315 || fleet(n),
316 |v, i| v[i % v.len()].try_acquire(1),
317 OPS,
318 BATCH,
319 )
320 });
321 let (cat, reason) = classify_feature(&rows, Some(base_p50), None);
322
323 let avail = measure(
324 || fleet(CANON_N),
325 |v, i| {
326 let _ = v[i % v.len()].available();
327 true
328 },
329 OPS,
330 BATCH,
331 );
332 let mut p99 = BTreeMap::new();
333 p99.insert("try_acquire".to_string(), canon.p99);
334 p99.insert("available".to_string(), avail.p99);
335 manifest.set_feature("token-bucket", cat, &p99, &reason);
336 }
337
338 // ---------- hierarchical: child AND parent must both grant ----------
339 #[cfg(feature = "hierarchical")]
340 {
341 use subms_rate_limiter::HierarchicalLimiter;
342
343 // Swept on the CHILD COUNT, which is the only thing this feature can
344 // scale. It is not a parent CHAIN - the source holds one parent and a
345 // flat `Vec` of children, and a call is a `Vec` index plus a fixed
346 // three bucket operations - so the cost is expected to be flat and the
347 // sweep is what says so rather than a reading of the code.
348 fn hier(n: usize) -> HierarchicalLimiter {
349 let h = HierarchicalLimiter::with_clock_fn(
350 HIER_PARENT_CAP,
351 HIER_PARENT_RATE,
352 n,
353 TB_CAP,
354 TB_RATE,
355 || Box::new(SteppingClock::new()),
356 );
357 for c in 0..h.num_children() {
358 for _ in 0..PRE_DRAIN + (c & 1) {
359 h.try_acquire(c, 1);
360 }
361 }
362 h
363 }
364
365 let (rows, canon) = sweep("hierarchical/try_acquire", &SIZES, |n| {
366 measure(
367 || hier(n),
368 |h, i| h.try_acquire(i % h.num_children(), 1),
369 OPS,
370 BATCH,
371 )
372 });
373 let (cat, reason) = classify_feature(&rows, Some(base_p50), None);
374
375 let mut p99 = BTreeMap::new();
376 p99.insert("try_acquire".to_string(), canon.p99);
377 manifest.set_feature("hierarchical", cat, &p99, &reason);
378 }
379
380 // ---------- distributed-backend: fixed-window counters ----------
381 #[cfg(feature = "distributed-backend")]
382 {
383 use subms_rate_limiter::{DistributedLimiter, InMemoryBackend};
384
385 // Keys are built once, outside every timed region: formatting one
386 // inside the loop would put string construction in the measurement.
387 // Fixed width so key length is not a second variable.
388 let keys: Vec<String> = (0..DIST_CANON).map(|i| format!("key-{i:06}")).collect();
389
390 let prefilled = |n: usize| {
391 let d = DistributedLimiter::with_clock(
392 Box::new(InMemoryBackend::new()),
393 DIST_LIMIT,
394 DIST_WINDOW_NS,
395 Box::new(SteppingClock::new()),
396 );
397 for k in &keys[..n] {
398 d.try_acquire(k);
399 }
400 d
401 };
402
403 let (rows, canon) = sweep("distributed-backend/try_acquire", &DIST_SIZES, |n| {
404 measure(
405 || prefilled(n),
406 |d, i| d.try_acquire(&keys[i % DIST_HOT]),
407 DIST_OPS,
408 1,
409 )
410 });
411 let (cat, reason) = classify_feature(&rows, Some(base_p50), None);
412
413 let mut p99 = BTreeMap::new();
414 p99.insert("try_acquire".to_string(), canon.p99);
415 manifest.set_feature("distributed-backend", cat, &p99, &reason);
416 }
417
418 // ---------- metrics: counters around the same bucket ----------
419 #[cfg(feature = "metrics")]
420 {
421 use subms_rate_limiter::MeteredTokenBucket;
422
423 fn fleet(n: usize) -> Vec<MeteredTokenBucket> {
424 let v: Vec<MeteredTokenBucket> = (0..n)
425 .map(|_| {
426 MeteredTokenBucket::with_clock(TB_CAP, MET_RATE, Box::new(SteppingClock::new()))
427 })
428 .collect();
429 for (j, b) in v.iter().enumerate() {
430 for _ in 0..PRE_DRAIN + (j & 1) {
431 b.try_acquire(1);
432 }
433 }
434 v
435 }
436
437 let (rows, canon) = sweep("metrics/try_acquire", &SIZES, |n| {
438 measure(
439 || fleet(n),
440 |v, i| v[i % v.len()].try_acquire(1),
441 OPS,
442 BATCH,
443 )
444 });
445 let (cat, reason) = classify_feature(&rows, Some(base_p50), None);
446
447 let snap = measure(
448 || fleet(CANON_N),
449 |v, i| {
450 let _ = v[i % v.len()].snapshot();
451 true
452 },
453 OPS,
454 BATCH,
455 );
456 let mut p99 = BTreeMap::new();
457 p99.insert("try_acquire".to_string(), canon.p99);
458 p99.insert("snapshot".to_string(), snap.p99);
459 manifest.set_feature("metrics", cat, &p99, &reason);
460 }
461
462 // ---------- keyed: one GCRA limiter per key, sharded ----------
463 #[cfg(feature = "keyed")]
464 {
465 use subms_rate_limiter::KeyedRateLimiter;
466
467 // Swept on the LIVE KEY COUNT, the only axis this feature scales on.
468 // A hash map lookup is expected to read flat; what the sweep is really
469 // testing is that the sharded lock does not turn into the bottleneck as
470 // the key set outgrows cache.
471 //
472 // Keys are formatted once, outside every timed region, and the clock is
473 // driven rather than read: a fixed `now` of 0 with a burst wide enough
474 // to cover every timed call keeps each op on the grant branch, which is
475 // the dearer one (a reject returns before the map write).
476 let keys: Vec<String> = (0..CANON_N).map(|i| format!("key-{i:06}")).collect();
477 let burst = (OPS / SIZES[0] + 2) as u64;
478
479 let (rows, canon) = sweep("keyed/try_acquire", &SIZES, |n| {
480 measure(
481 || KeyedRateLimiter::new(BASE_RATE, burst),
482 |k, i| matches!(k.try_acquire_at(0, &keys[i % n], 1), Acquire::Ok),
483 OPS,
484 BATCH,
485 )
486 });
487 let (cat, reason) = classify_feature(&rows, Some(base_p50), None);
488
489 let mut p99 = BTreeMap::new();
490 p99.insert("try_acquire".to_string(), canon.p99);
491 manifest.set_feature("keyed", cat, &p99, &reason);
492 }
493
494 std::fs::create_dir_all(path.parent().unwrap())?;
495 std::fs::write(&path, manifest.to_json())?;
496 io::stdout().write_all(manifest.to_json().as_bytes())?;
497 Ok(())
498}