Skip to main content

subms/
lib.rs

1//! `subms` — a tiny, std-only perf-test harness for Rust programs.
2//!
3//! Records timed samples per stage, computes percentiles, and emits a
4//! stable JSON shape suitable for upload to
5//! [submillisecond.com](https://submillisecond.com) cookbook samples.
6//!
7//! Zero external dependencies.
8//!
9//! # Example
10//!
11//! ```
12//! use subms::PerfHarness;
13//!
14//! let mut h = PerfHarness::new("lsm-tree", "rust");
15//! h.input("entries", &50_000.to_string());
16//! h.input("bloom_mode", "on");
17//! h.meta("sstables", "46");
18//!
19//! let put = h.stage("put", 50_000);
20//! for _ in 0..50_000 {
21//!     put.time(|| { /* work under test */ });
22//! }
23//!
24//! h.write_json(&mut std::io::stdout()).unwrap();
25//! ```
26//!
27//! # JSON shape (stable; matches the Java harness in the cookbook)
28//!
29//! ```text
30//! {
31//!   "workload": "lsm-tree",
32//!   "lang": "rust",
33//!   "timestamp": "2026-05-13T20:24:38Z",
34//!   "inputs":  { "<k>": "<v>", ... },
35//!   "meta":    { "<k>": "<v>", ... },
36//!   "stages": {
37//!     "<name>": {
38//!       "count": <int>,
39//!       "p50_ns": <int>, "p99_ns": <int>, "p999_ns": <int>, "max_ns": <int>,
40//!       "mean_ns": <int>,
41//!       "samples_ns": [<int>, ...]
42//!     }
43//!   }
44//! }
45//! ```
46
47// `recipe` ships the Recipe trait + BenchParams + `benchmark()` helper that
48// drives a recipe through the harness; `util` carries small reproducible
49// helpers (deterministic LCG, etc.). Both live in this crate so a recipe
50// author can grab everything with a single `use subms::*;`.
51pub mod recipe;
52pub mod util;
53
54pub use recipe::{benchmark, BenchParams, Recipe};
55pub use util::Lcg;
56
57use std::collections::BTreeMap;
58use std::fmt::Write as _;
59use std::io::{self, Write};
60use std::time::{Instant, SystemTime, UNIX_EPOCH};
61
62/// Per-stage sample buffer + recorder.
63pub struct Stage {
64    name: String,
65    samples: Vec<u64>,
66}
67
68impl Stage {
69    fn new(name: &str, capacity: usize) -> Self {
70        Self {
71            name: name.to_string(),
72            samples: Vec::with_capacity(capacity),
73        }
74    }
75    /// Record an explicit duration in nanoseconds.
76    pub fn record(&mut self, ns: u64) {
77        self.samples.push(ns);
78    }
79    /// Time a closure and record its duration.
80    pub fn time<F: FnOnce() -> R, R>(&mut self, f: F) -> R {
81        let t0 = Instant::now();
82        let r = f();
83        self.samples.push(t0.elapsed().as_nanos() as u64);
84        r
85    }
86    pub fn name(&self) -> &str {
87        &self.name
88    }
89    pub fn samples(&self) -> &[u64] {
90        &self.samples
91    }
92}
93
94/// A workload run: inputs, metadata, and one or more timed stages.
95pub struct PerfHarness {
96    workload: String,
97    lang: String,
98    inputs: BTreeMap<String, String>,
99    meta: BTreeMap<String, String>,
100    stages: Vec<Stage>,
101}
102
103impl PerfHarness {
104    pub fn new(workload: &str, lang: &str) -> Self {
105        Self {
106            workload: workload.to_string(),
107            lang: lang.to_string(),
108            inputs: BTreeMap::new(),
109            meta: BTreeMap::new(),
110            stages: Vec::new(),
111        }
112    }
113
114    /// Add an input parameter (rendered in the cookbook UI's "inputs" panel).
115    pub fn input(&mut self, key: &str, value: &str) -> &mut Self {
116        self.inputs.insert(key.to_string(), value.to_string());
117        self
118    }
119
120    /// Add a meta value (sstable count, host info, etc).
121    pub fn meta(&mut self, key: &str, value: &str) -> &mut Self {
122        self.meta.insert(key.to_string(), value.to_string());
123        self
124    }
125
126    /// Create a new stage with sample-buffer capacity. Returns a mutable
127    /// reference to the stage; record samples via [`Stage::time`] or
128    /// [`Stage::record`].
129    pub fn stage(&mut self, name: &str, capacity: usize) -> &mut Stage {
130        self.stages.push(Stage::new(name, capacity));
131        self.stages.last_mut().unwrap()
132    }
133
134    /// Borrow a previously-created stage by name.
135    pub fn stage_mut(&mut self, name: &str) -> Option<&mut Stage> {
136        self.stages.iter_mut().find(|s| s.name == name)
137    }
138
139    /// Serialise to the standard JSON shape.
140    pub fn write_json<W: Write>(&self, out: &mut W) -> io::Result<()> {
141        let mut s = String::with_capacity(64 * 1024);
142        s.push('{');
143        json_kv_str(&mut s, "workload", &self.workload);
144        s.push(',');
145        json_kv_str(&mut s, "lang", &self.lang);
146        s.push(',');
147        json_kv_str(&mut s, "timestamp", &iso8601_now());
148        s.push(',');
149        s.push_str("\"inputs\":");
150        json_map(&mut s, &self.inputs);
151        s.push(',');
152        s.push_str("\"meta\":");
153        json_map(&mut s, &self.meta);
154        s.push(',');
155        s.push_str("\"stages\":{");
156        for (i, stage) in self.stages.iter().enumerate() {
157            if i > 0 {
158                s.push(',');
159            }
160            json_str(&mut s, &stage.name);
161            s.push(':');
162            json_stage(&mut s, &stage.samples);
163        }
164        s.push_str("}}");
165        out.write_all(s.as_bytes())?;
166        out.write_all(b"\n")?;
167        Ok(())
168    }
169
170    /// Drop a stage if you never recorded into it.
171    pub fn discard_stage(&mut self, name: &str) {
172        self.stages.retain(|s| s.name != name);
173    }
174}
175
176fn json_str(out: &mut String, s: &str) {
177    out.push('"');
178    for c in s.chars() {
179        match c {
180            '"' => out.push_str("\\\""),
181            '\\' => out.push_str("\\\\"),
182            '\n' => out.push_str("\\n"),
183            '\r' => out.push_str("\\r"),
184            '\t' => out.push_str("\\t"),
185            c if (c as u32) < 0x20 => {
186                let _ = write!(out, "\\u{:04x}", c as u32);
187            }
188            c => out.push(c),
189        }
190    }
191    out.push('"');
192}
193
194fn json_kv_str(out: &mut String, k: &str, v: &str) {
195    json_str(out, k);
196    out.push(':');
197    json_str(out, v);
198}
199
200fn json_map(out: &mut String, m: &BTreeMap<String, String>) {
201    out.push('{');
202    for (i, (k, v)) in m.iter().enumerate() {
203        if i > 0 {
204            out.push(',');
205        }
206        json_kv_str(out, k, v);
207    }
208    out.push('}');
209}
210
211fn json_stage(out: &mut String, samples: &[u64]) {
212    let mut sorted = samples.to_vec();
213    sorted.sort_unstable();
214    let n = sorted.len();
215    let p = |q: f64| -> u64 {
216        if n == 0 {
217            return 0;
218        }
219        let idx = ((q * n as f64) as usize).min(n - 1);
220        sorted[idx]
221    };
222    let max = sorted.last().copied().unwrap_or(0);
223    let mean = if n == 0 {
224        0
225    } else {
226        sorted.iter().sum::<u64>() / n as u64
227    };
228
229    out.push('{');
230    let _ = write!(out, "\"count\":{},", n);
231    let _ = write!(out, "\"p50_ns\":{},", p(0.50));
232    let _ = write!(out, "\"p99_ns\":{},", p(0.99));
233    let _ = write!(out, "\"p999_ns\":{},", p(0.999));
234    let _ = write!(out, "\"max_ns\":{},", max);
235    let _ = write!(out, "\"mean_ns\":{},", mean);
236    // Downsample samples_ns to at most 500 evenly-spaced points so the JSON
237    // stays compact for the web layer. Order preserved (chronological).
238    out.push_str("\"samples_ns\":[");
239    let step = (n / 500).max(1);
240    let mut first = true;
241    for i in (0..n).step_by(step) {
242        if !first {
243            out.push(',');
244        }
245        first = false;
246        let _ = write!(out, "{}", samples[i]);
247    }
248    out.push_str("]}");
249}
250
251fn iso8601_now() -> String {
252    let d = SystemTime::now()
253        .duration_since(UNIX_EPOCH)
254        .unwrap_or_default();
255    let secs = d.as_secs() as i64;
256    let mut year = 1970i64;
257    let mut days = secs / 86_400;
258    let rem = secs % 86_400;
259    let hour = rem / 3600;
260    let minute = (rem % 3600) / 60;
261    let second = rem % 60;
262    while days >= year_days(year) {
263        days -= year_days(year);
264        year += 1;
265    }
266    let mut month = 1u32;
267    for m in 1..=12 {
268        let dm = month_days(year, m);
269        if days < dm as i64 {
270            month = m;
271            break;
272        }
273        days -= dm as i64;
274    }
275    let day = (days + 1) as u32;
276    format!(
277        "{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
278        year, month, day, hour, minute, second
279    )
280}
281
282fn year_days(y: i64) -> i64 {
283    if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) {
284        366
285    } else {
286        365
287    }
288}
289fn month_days(y: i64, m: u32) -> u32 {
290    match m {
291        1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
292        4 | 6 | 9 | 11 => 30,
293        2 => {
294            if (y % 4 == 0 && y % 100 != 0) || (y % 400 == 0) {
295                29
296            } else {
297                28
298            }
299        }
300        _ => 0,
301    }
302}
303
304/// Parse stdin `key=value` lines into a flat map. Skips blank lines and `#` comments.
305pub fn read_stdin_kv() -> BTreeMap<String, String> {
306    use std::io::BufRead;
307    let mut m = BTreeMap::new();
308    let stdin = io::stdin();
309    for line in stdin.lock().lines().map_while(Result::ok) {
310        let line = line.trim();
311        if line.is_empty() || line.starts_with('#') {
312            continue;
313        }
314        if let Some((k, v)) = line.split_once('=') {
315            m.insert(k.trim().to_string(), v.trim().to_string());
316        }
317    }
318    m
319}