Skip to main content

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}