perf_features/perf_features.rs
1//! Feature classification bench. Each feature's representative op is swept
2//! across three PRECISIONS, `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//! Significant digits is the sweep axis because it is what sets a histogram's
7//! size: 3, 4 and 5 digits give 2048, 32768 and 262144 sub-buckets. A
8//! `record` writes one bucket regardless, so it should read flat; anything that
9//! folds the bucket array should climb. `decay`'s record does the second while
10//! looking like the first.
11//!
12//! Run:
13//! cargo run --release --example perf_features \
14//! --features "harness dual-recorder concurrent-writes merge decay value-tagging iterators"
15
16use std::collections::BTreeMap;
17use std::io::{self, Write};
18use std::path::PathBuf;
19
20use subms::{SubMsFeatureManifest, SubMsP99Source, SubMsPerfHarness, classify_feature, summarize};
21use subms_hdr_histogram::HdrHistogram;
22
23/// 2048 / 32768 / 262144 sub-buckets, a 128x span. NOT 1/2/3 digits: that gives
24/// 32 / 256 / 2048, and at 32 buckets a fold is entirely fixed per-call cost, so
25/// three genuinely O(buckets) ops - the interval read, the decaying record, the
26/// percentile walk - all measured flat and classified hot-path. Sweeping from a
27/// size where the fold already dominates measures the fold.
28const DIGITS: [u32; 3] = [3, 4, 5];
29const CANON_D: u32 = DIGITS[DIGITS.len() - 1];
30/// Recorded ops per measurement. Fixed across the sweep so a slope has one cause.
31const OPS: usize = 20_000;
32/// Samples per bulk op. A whole-structure call is far above the per-key budget,
33/// so a distribution needs repeats rather than one shot. 256 is a FLOOR, not a
34/// preference: the harness takes p99 as `sorted[floor(0.99 * n)]`, so at n <= 100
35/// that index IS `n - 1` and the "p99" is the single worst sample. A structural
36/// verdict then turns on whichever rep caught a page fault. 256 puts two samples
37/// above the index and makes it a real percentile. Do not lower it.
38const BULK_REPS: usize = 256;
39/// Bulk warmup is TIME-BOXED, not a fixed rep count - the same fix the Java port
40/// needs, for a different reason. Rust has no JIT, but an op that ALLOCATES has
41/// an allocator and a page-fault ramp, and 8 reps do not settle either: the
42/// interval read's smallest sweep point moved 7000 -> 30800 -> 44800 ns across
43/// three runs and flipped the feature between structural and hot-path. A budget
44/// gives cheap sizes thousands of reps and expensive ones enough.
45const BULK_WARM_NANOS: u64 = 300_000_000;
46const BULK_WARM_MAX_REPS: usize = 5_000;
47
48const MAX_VALUE: u64 = 10_000_000;
49
50/// Values spread over seven orders of magnitude, so buckets across the whole
51/// range carry counts and a fold cannot skip most of the array.
52fn value_at(i: usize) -> u64 {
53 1 + ((i as u64).wrapping_mul(2_654_435_761) % MAX_VALUE)
54}
55
56fn sub_count(d: u32) -> usize {
57 HdrHistogram::new(d).sub_count() as usize
58}
59
60fn stat(h: &SubMsPerfHarness, median: bool) -> u64 {
61 summarize(h)
62 .stages
63 .iter()
64 .find(|s| s.name == "op")
65 .map_or(0, |s| if median { s.p50_ns } else { s.p99_ns })
66}
67
68fn keyed(mut op: impl FnMut(usize), median: bool) -> u64 {
69 let mut h = SubMsPerfHarness::new("hdr-feature", "rust");
70 let st = h.stage("op", OPS);
71 for i in 0..OPS {
72 st.time(|| op(i));
73 }
74 stat(&h, median)
75}
76
77/// A whole-array op. `setup` runs OUTSIDE the timed region and the first
78/// `BULK_WARM` reps are discarded: measured cold, a bulk op lands its
79/// first-touch cost on whichever sweep point runs first, which reads as a curve
80/// that FALLS with size - the opposite of the structural signal.
81fn bulk<T>(mut setup: impl FnMut() -> T, mut op: impl FnMut(&mut T), median: bool) -> u64 {
82 let mut input = setup();
83 let start = std::time::Instant::now();
84 for _ in 0..BULK_WARM_MAX_REPS {
85 op(&mut input);
86 if start.elapsed().as_nanos() as u64 >= BULK_WARM_NANOS {
87 break;
88 }
89 }
90 let mut h = SubMsPerfHarness::new("hdr-feature", "rust");
91 let st = h.stage("op", BULK_REPS);
92 for _ in 0..BULK_REPS {
93 st.time(|| op(&mut input));
94 }
95 stat(&h, median)
96}
97
98/// Sweeps and PRINTS the curve, indexed by SUB-BUCKET COUNT rather than by
99/// digits - the classifier reads the size column as a magnitude.
100fn sweep(label: &str, mut at: impl FnMut(u32) -> u64) -> Vec<(usize, u64)> {
101 let rows: Vec<(usize, u64)> = DIGITS.iter().map(|&d| (sub_count(d), at(d))).collect();
102 eprintln!("sweep {label}: {rows:?}");
103 rows
104}
105
106/// Filled to OCCUPANCY, not to a fixed record count. `merge` and the iterators
107/// visit NON-EMPTY buckets, so a fixed 20k values leaves 20k of them occupied
108/// whether the array holds 2048 buckets or 262144 - the op stops scaling and
109/// both features read flat. Recording `sub_count` values keeps the occupied
110/// fraction roughly constant, which is what makes the sweep a size sweep.
111fn filled(d: u32) -> HdrHistogram {
112 let mut h = HdrHistogram::new(d);
113 for i in 0..sub_count(d) {
114 h.record(value_at(i));
115 }
116 h
117}
118
119fn main() -> io::Result<()> {
120 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
121 .join("..")
122 .join(".subms")
123 .join("features")
124 .join("rust.json");
125 let existing = std::fs::read_to_string(&path).unwrap_or_default();
126 let mut manifest = SubMsFeatureManifest::load_str("rust", &existing);
127 // Stamp the box these numbers came from. The bench runs wherever it is
128 // invoked, so an unstamped manifest is indistinguishable from a fleet
129 // capture; the renderer will not publish one it cannot attribute.
130 let (source, instance) = SubMsP99Source::from_env();
131 manifest.set_p99_source(source, instance.as_deref());
132
133 // The baseline: base `record`, the per-op write path. Every feature is
134 // classified against the cost of the write it is decorating.
135 let mut base = HdrHistogram::new(CANON_D);
136 let base_p50 = keyed(|i| base.record(value_at(i)), true);
137 eprintln!("base record p50: {base_p50}ns");
138
139 // ---------- concurrent-writes: atomic buckets, &self record ----------
140 #[cfg(feature = "concurrent-writes")]
141 {
142 use subms_hdr_histogram::ConcurrentHdrHistogram;
143 let sw = sweep("concurrent-writes/record", |d| {
144 let c = ConcurrentHdrHistogram::new(d);
145 keyed(|i| c.record(value_at(i)), true)
146 });
147 let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
148
149 let c = ConcurrentHdrHistogram::new(CANON_D);
150 let mut p99 = BTreeMap::new();
151 p99.insert(
152 "record".to_string(),
153 keyed(|i| c.record(value_at(i)), false),
154 );
155 p99.insert(
156 "percentile".to_string(),
157 keyed(|_| _ = c.value_at_percentile(99.0), false),
158 );
159 manifest.set_feature("concurrent-writes", cat, &p99, &reason);
160 }
161
162 // ---------- dual-recorder: hot/stable pair, swap and drain ----------
163 #[cfg(feature = "dual-recorder")]
164 {
165 use subms_hdr_histogram::DualRecorder;
166 // Swept on the interval read, not on `record`. The record is the same
167 // atomic write the concurrent feature already covers; the swap-and-drain
168 // is what a dual recorder is FOR, and it is O(buckets).
169 let sw = sweep("dual-recorder/interval", |d| {
170 let r = DualRecorder::new(d);
171 for i in 0..sub_count(d) {
172 r.record(value_at(i));
173 }
174 bulk(|| (), |()| _ = r.get_interval_histogram(), true)
175 });
176 // PINNED structural. The drain is O(buckets) from the source in BOTH
177 // ports, but it is DESTRUCTIVE, so repeating it measures an already-empty
178 // histogram rather than the drain: after the first rep each side has
179 // nothing left to copy. Refilling between reps would put the refill
180 // inside the timed region, which is the bug the ART port shipped. The
181 // two ports disagree precisely here for that reason - Rust copies the
182 // counter array unconditionally (468us at 262144 buckets) while Java
183 // collapses a high-water index and reads 100ns - and neither number is
184 // the operation a caller performs. Recording either as hot-path would
185 // say a whole-array drain is safe per-op.
186 let (cat, reason) = classify_feature(
187 &sw,
188 Some(base_p50),
189 Some(subms::SubMsFeatureCategory::Structural),
190 );
191
192 let r = DualRecorder::new(CANON_D);
193 for i in 0..OPS {
194 r.record(value_at(i));
195 }
196 let mut p99 = BTreeMap::new();
197 p99.insert(
198 "record".to_string(),
199 keyed(|i| r.record(value_at(i)), false),
200 );
201 p99.insert(
202 "interval_read".to_string(),
203 bulk(|| (), |()| _ = r.get_interval_histogram(), false),
204 );
205 manifest.set_feature("dual-recorder", cat, &p99, &reason);
206 }
207
208 // ---------- merge: element-wise add over the bucket arrays ----------
209 #[cfg(feature = "merge")]
210 {
211 use subms_hdr_histogram::merge;
212 // Both histograms are built by `setup`, outside the timed region.
213 // Repeating the merge grows the destination's counts but does identical
214 // work each rep, so the figure is the merge and nothing else.
215 let sw = sweep("merge/merge", |d| {
216 bulk(
217 || (filled(d), filled(d)),
218 |(dst, src)| merge(dst, src).expect("same precision"),
219 true,
220 )
221 });
222 let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
223
224 let mut p99 = BTreeMap::new();
225 p99.insert(
226 "merge".to_string(),
227 bulk(
228 || (filled(CANON_D), filled(CANON_D)),
229 |(dst, src)| merge(dst, src).expect("same precision"),
230 false,
231 ),
232 );
233 manifest.set_feature("merge", cat, &p99, &reason);
234 }
235
236 // ---------- decay: exponentially-weighted counts ----------
237 #[cfg(feature = "decay")]
238 {
239 use subms_hdr_histogram::{Clock, DecayingHdrHistogram};
240 // The clock ADVANCES on every read. A frozen clock lets `decay_to_now`
241 // early-return and the feature measures as a plain record, which is the
242 // opposite of the truth: with time moving, every record first brings the
243 // whole counter array up to date, so the write is O(buckets). Freezing
244 // the clock here would have published that as hot-path.
245 struct TickingClock(std::cell::Cell<u64>);
246 impl Clock for TickingClock {
247 fn now_ns(&self) -> u64 {
248 self.0.set(self.0.get() + 1_000_000);
249 self.0.get()
250 }
251 }
252 let halflife = 1_000_000_000;
253 let sw = sweep("decay/record", |d| {
254 let mut x =
255 DecayingHdrHistogram::new(d, halflife, TickingClock(std::cell::Cell::new(0)));
256 keyed(|i| x.record(value_at(i)), true)
257 });
258 let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
259
260 let mut x =
261 DecayingHdrHistogram::new(CANON_D, halflife, TickingClock(std::cell::Cell::new(0)));
262 let mut p99 = BTreeMap::new();
263 p99.insert(
264 "record".to_string(),
265 keyed(|i| x.record(value_at(i)), false),
266 );
267 p99.insert(
268 "percentile".to_string(),
269 keyed(|_| _ = x.value_at_percentile(99.0), false),
270 );
271 manifest.set_feature("decay", cat, &p99, &reason);
272 }
273
274 // ---------- value-tagging: a parallel histogram per tag ----------
275 #[cfg(feature = "value-tagging")]
276 {
277 use subms_hdr_histogram::TaggedHdrHistogram;
278 let sw = sweep("value-tagging/record", |d| {
279 let mut t = TaggedHdrHistogram::new(d);
280 keyed(|i| t.record(value_at(i), (i % 4) as u8), true)
281 });
282 let (cat, reason) = classify_feature(&sw, Some(base_p50), None);
283
284 let mut t = TaggedHdrHistogram::new(CANON_D);
285 let mut p99 = BTreeMap::new();
286 p99.insert(
287 "record".to_string(),
288 keyed(|i| t.record(value_at(i), (i % 4) as u8), false),
289 );
290 p99.insert(
291 "percentile_for_tag".to_string(),
292 keyed(|_| _ = t.value_at_percentile_for_tag(99.0, 0), false),
293 );
294 manifest.set_feature("value-tagging", cat, &p99, &reason);
295 }
296
297 // ---------- iterators: linear / logarithmic / percentile walks ----------
298 #[cfg(feature = "iterators")]
299 {
300 // A percentile walk accumulates counts across the bucket array, so it is
301 // O(buckets), and the sweep is monotonic and strongly rising. It is not
302 // 64x: the walk emits a BOUNDED number of entries (1% steps, ~100 of
303 // them) however large the array is, so the per-bucket work amortises
304 // better at the top and it measures ~57x over a 128x span - under the
305 // classifier's 0.5 guard.
306 //
307 // `iter_linear` was tried as the swept op instead, on the reasoning that
308 // it visits every bucket. It does not: it steps by VALUE unit, so over a
309 // 10^7 value range it emits millions of entries and the walk never
310 // finished. Wrong op, not a slower one.
311 //
312 // PINNED structural rather than published as hot-path, which would tell
313 // a reader a full percentile walk is safe per-operation. It is not.
314 let sw = sweep("iterators/percentiles", |d| {
315 let h = filled(d);
316 bulk(|| (), |()| _ = h.iter_percentiles(1.0).count(), true)
317 });
318 let (cat, reason) = classify_feature(
319 &sw,
320 Some(base_p50),
321 Some(subms::SubMsFeatureCategory::Structural),
322 );
323
324 let h = filled(CANON_D);
325 let mut p99 = BTreeMap::new();
326 p99.insert(
327 "iter_percentiles".to_string(),
328 bulk(|| (), |()| _ = h.iter_percentiles(1.0).count(), false),
329 );
330 p99.insert(
331 "iter_logarithmic".to_string(),
332 bulk(|| (), |()| _ = h.iter_logarithmic().count(), false),
333 );
334 manifest.set_feature("iterators", cat, &p99, &reason);
335 }
336
337 std::fs::create_dir_all(path.parent().unwrap())?;
338 std::fs::write(&path, manifest.to_json())?;
339 io::stdout().write_all(manifest.to_json().as_bytes())?;
340 Ok(())
341}