subms 0.7.1

The sub-millisecond perf harness for Rust. Zero-dependency std-only library that records timed samples per stage, computes percentiles, supports coordinated-omission correction, runs scale sweeps, and emits a stable JSON contract. Byte-equivalent to the Java sibling com.submillisecond:subms.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
//! Storage-growth harness. Where the latency harness ([`crate::SubMsPerfHarness`])
//! answers "how fast is one op", this answers "does the footprint stay bounded as
//! work accumulates" - the leak / write-amplification axis.
//!
//! A recipe runs R rounds of a write/fork workload. After each round the harness
//! records the physical footprint (`on_disk_bytes`), the logical live size
//! (`live_bytes`), named structure counts (SSTables, rooms, ...), and the round's
//! op p50/p99. It then gates the curve against the author's declared expectation
//! ([`SubMsGrowthClass`]): a store that grows super-linearly while live data is
//! flat (a compaction leak, or accumulating forks) FAILS its own gate.
//!
//! Rust-only for now (the storage-backed recipes measured here - lsm-tree,
//! icehouse - own their on-disk format on the Rust side).

use std::collections::BTreeMap;
// `write!` into the String buffer needs fmt::Write in scope; the JSON sink bound
// (`W: Write`) is io::Write. Import fmt's anonymously so `Write` still names io's.
use std::fmt::Write as _;
use std::io::{self, Write};
use std::time::Instant;

use crate::stats;

/// Growth JSON schema version, stamped into every emitted file. Consumers gate
/// "current model?" on this the same way latency files use `bench_version`.
pub const GROWTH_VERSION: u32 = 2;

/// What footprint growth a recipe's author expects - and the shape the harness
/// gates on. The paired `bound` (see [`SubMsGrowthRecipe::expected`]) is
/// interpreted per variant.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SubMsGrowthClass {
    /// Footprint is capped regardless of ops (e.g. a fixed-capacity cache).
    /// Gate: peak `on_disk_bytes` across rounds <= `bound` bytes.
    Bounded,
    /// On-disk stays proportional to live data - amplification is bounded by a
    /// constant. Gate: max(`on_disk_bytes` / `live_bytes`) <= `bound`. This is
    /// the write-amplification / accumulating-garbage catch: if live is flat but
    /// on-disk climbs every round, the ratio blows past the ceiling.
    AmplificationBounded,
    /// On-disk PLATEAUS - it may pre-allocate to a steady size, but must not keep
    /// climbing round over round. Gate: `on_disk`(last round) <= `on_disk`(first
    /// round) * `bound`. This is the reclaim / leak catch for churn workloads
    /// (calve+drop, insert+delete) where live data is flat and the absolute size
    /// is dominated by allocator behaviour, not the payload - so an amplification
    /// ratio against a tiny live set would false-alarm, but sustained growth is
    /// still a real leak.
    PlateauBounded,
    /// Growth is expected and unbounded by design (an append-only log). Reported,
    /// never gated.
    UnboundedOk,
}

impl SubMsGrowthClass {
    /// The stable snake_case token used in the JSON `verdict.class` field.
    pub fn as_str(self) -> &'static str {
        match self {
            SubMsGrowthClass::Bounded => "bounded",
            SubMsGrowthClass::AmplificationBounded => "amplification_bounded",
            SubMsGrowthClass::PlateauBounded => "plateau_bounded",
            SubMsGrowthClass::UnboundedOk => "unbounded_ok",
        }
    }
}

/// A storage-growth workload. The harness calls `op` `ops_per_round` times per
/// round, then reads the three footprint hooks. All `&mut self` so a hook may
/// stat files or walk the store; keep them cheap (called once per round).
pub trait SubMsGrowthRecipe {
    /// Workload identity, e.g. "subms-lsm-tree" or "icehouse-catalog".
    fn name(&self) -> &str;
    /// Short op label for the page, e.g. "put" or "fork".
    fn op_name(&self) -> &str {
        "op"
    }
    /// Number of rounds R.
    fn rounds(&self) -> usize;
    /// Timed ops performed (and measured for p50/p99) each round.
    fn ops_per_round(&self) -> usize;
    /// One timed unit of work. `round` is 1-based; `i` is 0-based within the round.
    fn op(&mut self, round: usize, i: usize);
    /// Called once after a round's timed ops, before the footprint hooks. Use it
    /// to force a flush/checkpoint so on-disk reflects the round's writes. The
    /// work here is NOT timed. Default: no-op.
    fn end_round(&mut self, _round: usize) {}
    /// Bytes physically ON DISK right now (sum of the store's file sizes). 0 for a
    /// pure in-memory recipe. Together with [`Self::memory_bytes`] this is the
    /// footprint the verdict gates on (their sum).
    fn disk_bytes(&mut self) -> u64 {
        0
    }
    /// Resident MEMORY bytes the structure holds right now - the recipe's best
    /// estimate of its heap footprint (e.g. entries * entry_size, or an arena's
    /// used bytes). 0 for a recipe whose footprint is purely on disk.
    fn memory_bytes(&mut self) -> u64 {
        0
    }
    /// Logical/live bytes the store must retain - what a from-scratch rewrite of
    /// the current key set would cost. The denominator for amplification.
    fn live_bytes(&mut self) -> u64;
    /// Named structure counts at this round, e.g. `[("sstables", 6)]`.
    fn structures(&mut self) -> Vec<(String, u64)> {
        Vec::new()
    }
    /// The declared expectation and its bound (bytes for `Bounded`, a ratio for
    /// `AmplificationBounded`, ignored for `UnboundedOk`). The gate is applied to
    /// the TOTAL footprint (disk + memory).
    fn expected(&self) -> (SubMsGrowthClass, f64);
    /// Render hint: a recipe whose footprint is O(1) by construction (a single
    /// bucket, fixed histogram) has no interesting curve - set this so the page
    /// shows a compact verdict card instead of a chart + heatmap.
    fn compact(&self) -> bool {
        false
    }
}

/// One round's captured footprint + op latency.
#[derive(Debug, Clone)]
pub struct SubMsGrowthRound {
    pub round: usize,
    pub ops: usize,
    pub cumulative_ops: usize,
    /// Bytes on disk (0 for in-memory recipes).
    pub disk_bytes: u64,
    /// Resident memory bytes (0 for recipes with no in-memory footprint).
    pub memory_bytes: u64,
    /// disk + memory - the footprint the verdict gates on.
    pub total_bytes: u64,
    pub live_bytes: u64,
    /// total / live at this round (0.0 when live is 0).
    pub amplification: f64,
    pub structures: BTreeMap<String, u64>,
    pub p50_ns: u64,
    pub p99_ns: u64,
    pub max_ns: u64,
}

/// The gate outcome over the whole curve.
#[derive(Debug, Clone)]
pub struct SubMsGrowthVerdict {
    pub class: SubMsGrowthClass,
    pub bound: f64,
    /// Whether the observed curve satisfies the class's gate.
    pub holds: bool,
    /// The observed value the gate compares (peak bytes, or max amplification).
    pub observed: f64,
    pub summary: String,
}

/// A completed growth capture: the per-round curve + the verdict.
#[derive(Debug, Clone)]
pub struct SubMsGrowthReport {
    pub workload: String,
    pub lang: String,
    pub op_name: String,
    pub rounds: Vec<SubMsGrowthRound>,
    pub verdict: SubMsGrowthVerdict,
    /// Render hint: show a compact verdict card instead of the full chart.
    pub compact: bool,
    pub meta: BTreeMap<String, String>,
}

/// Run a growth recipe end to end: R rounds of timed ops, footprint after each,
/// then the verdict.
pub fn grow(recipe: &mut dyn SubMsGrowthRecipe, lang: &str) -> SubMsGrowthReport {
    let r = recipe.rounds().max(1);
    let ops = recipe.ops_per_round().max(1);
    let mut rounds = Vec::with_capacity(r);
    let mut cumulative = 0usize;

    for round in 1..=r {
        let mut samples = Vec::with_capacity(ops);
        for i in 0..ops {
            let t = Instant::now();
            recipe.op(round, i);
            samples.push(t.elapsed().as_nanos() as u64);
        }
        recipe.end_round(round);
        cumulative += ops;
        samples.sort_unstable();
        let disk = recipe.disk_bytes();
        let memory = recipe.memory_bytes();
        let total = disk + memory;
        let live = recipe.live_bytes();
        let amplification = if live > 0 {
            total as f64 / live as f64
        } else {
            0.0
        };
        let structures: BTreeMap<String, u64> = recipe.structures().into_iter().collect();
        rounds.push(SubMsGrowthRound {
            round,
            ops,
            cumulative_ops: cumulative,
            disk_bytes: disk,
            memory_bytes: memory,
            total_bytes: total,
            live_bytes: live,
            amplification,
            structures,
            p50_ns: stats::percentile(&samples, 0.50),
            p99_ns: stats::percentile(&samples, 0.99),
            max_ns: samples.last().copied().unwrap_or(0),
        });
    }

    let verdict = compute_verdict(recipe.expected(), &rounds);
    SubMsGrowthReport {
        workload: recipe.name().to_string(),
        lang: lang.to_string(),
        op_name: recipe.op_name().to_string(),
        rounds,
        verdict,
        compact: recipe.compact(),
        meta: BTreeMap::new(),
    }
}

fn compute_verdict(
    (class, bound): (SubMsGrowthClass, f64),
    rounds: &[SubMsGrowthRound],
) -> SubMsGrowthVerdict {
    match class {
        SubMsGrowthClass::Bounded => {
            let peak = rounds.iter().map(|r| r.total_bytes).max().unwrap_or(0) as f64;
            SubMsGrowthVerdict {
                class,
                bound,
                holds: peak <= bound,
                observed: peak,
                summary: format!(
                    "peak footprint {} bytes vs bound {} bytes",
                    peak as u64, bound as u64
                ),
            }
        }
        SubMsGrowthClass::AmplificationBounded => {
            let amp = rounds
                .iter()
                .filter(|r| r.live_bytes > 0)
                .map(|r| r.amplification)
                .fold(0.0_f64, f64::max);
            SubMsGrowthVerdict {
                class,
                bound,
                holds: amp <= bound,
                observed: amp,
                summary: format!(
                    "max footprint/live amplification {amp:.2}x vs ceiling {bound:.2}x"
                ),
            }
        }
        SubMsGrowthClass::PlateauBounded => {
            // Baseline at the mid-point, not round 1: a store with a warm-up ramp
            // (a retention window filling, an allocator pre-sizing) is small early
            // and that is not growth. What must stay flat is the second half - if
            // the footprint is still climbing from mid-run to the end, it leaks.
            let n = rounds.len();
            let mid = rounds.get(n / 2).map(|r| r.total_bytes).unwrap_or(0).max(1) as f64;
            let last = rounds.last().map(|r| r.total_bytes).unwrap_or(0) as f64;
            let ratio = last / mid;
            SubMsGrowthVerdict {
                class,
                bound,
                holds: ratio <= bound,
                observed: ratio,
                summary: format!(
                    "footprint grew {ratio:.2}x from mid-run to round {n} (ceiling {bound:.2}x)"
                ),
            }
        }
        SubMsGrowthClass::UnboundedOk => SubMsGrowthVerdict {
            class,
            bound,
            holds: true,
            observed: 0.0,
            summary: "growth expected and unbounded by design".to_string(),
        },
    }
}

/// CI gate: `Err(summary)` if the observed curve breaches its declared class.
pub fn assert_growth_holds(report: &SubMsGrowthReport) -> Result<(), String> {
    if report.verdict.holds {
        Ok(())
    } else {
        Err(format!(
            "{} growth gate breached: {}",
            report.workload, report.verdict.summary
        ))
    }
}

// ---- JSON ----

/// Emit the stable growth JSON the products/recipe page renders. Shape:
/// `{ kind:"growth", workload, lang, op, growth_version, verdict{...},
///    rounds:[{round, ops, cumulative_ops, on_disk_bytes, live_bytes,
///    amplification, structures{...}, p50_ns, p99_ns, max_ns}], meta{...} }`.
pub fn growth_to_json<W: Write>(report: &SubMsGrowthReport, out: &mut W) -> io::Result<()> {
    let mut s = String::with_capacity(256 + report.rounds.len() * 128);
    s.push('{');
    s.push_str("\"kind\":\"growth\",");
    kv_str(&mut s, "workload", &report.workload);
    s.push(',');
    kv_str(&mut s, "lang", &report.lang);
    s.push(',');
    kv_str(&mut s, "op", &report.op_name);
    s.push(',');
    let _ = write!(s, "\"growth_version\":{GROWTH_VERSION},");

    // verdict
    s.push_str("\"verdict\":{");
    kv_str(&mut s, "class", report.verdict.class.as_str());
    s.push(',');
    let _ = write!(s, "\"bound\":{:.4},", report.verdict.bound);
    let _ = write!(s, "\"holds\":{},", report.verdict.holds);
    let _ = write!(s, "\"observed\":{:.4},", report.verdict.observed);
    kv_str(&mut s, "summary", &report.verdict.summary);
    s.push_str("},");

    let _ = write!(s, "\"compact\":{},", report.compact);

    // rounds
    s.push_str("\"rounds\":[");
    for (i, r) in report.rounds.iter().enumerate() {
        if i > 0 {
            s.push(',');
        }
        s.push('{');
        let _ = write!(s, "\"round\":{},", r.round);
        let _ = write!(s, "\"ops\":{},", r.ops);
        let _ = write!(s, "\"cumulative_ops\":{},", r.cumulative_ops);
        let _ = write!(s, "\"disk_bytes\":{},", r.disk_bytes);
        let _ = write!(s, "\"memory_bytes\":{},", r.memory_bytes);
        let _ = write!(s, "\"total_bytes\":{},", r.total_bytes);
        let _ = write!(s, "\"live_bytes\":{},", r.live_bytes);
        let _ = write!(s, "\"amplification\":{:.4},", r.amplification);
        s.push_str("\"structures\":{");
        for (j, (name, count)) in r.structures.iter().enumerate() {
            if j > 0 {
                s.push(',');
            }
            json_str(&mut s, name);
            let _ = write!(s, ":{count}");
        }
        s.push_str("},");
        let _ = write!(s, "\"p50_ns\":{},", r.p50_ns);
        let _ = write!(s, "\"p99_ns\":{},", r.p99_ns);
        let _ = write!(s, "\"max_ns\":{}", r.max_ns);
        s.push('}');
    }
    s.push(']');

    // meta (optional, omitted when empty)
    if !report.meta.is_empty() {
        s.push_str(",\"meta\":{");
        for (i, (k, v)) in report.meta.iter().enumerate() {
            if i > 0 {
                s.push(',');
            }
            json_str(&mut s, k);
            s.push(':');
            json_str(&mut s, v);
        }
        s.push('}');
    }

    s.push('}');
    out.write_all(s.as_bytes())
}

fn kv_str(out: &mut String, k: &str, v: &str) {
    json_str(out, k);
    out.push(':');
    json_str(out, v);
}

#[cfg(test)]
mod tests {
    use super::*;

    // A recipe whose on-disk footprint per round is scripted, so the verdict
    // logic can be exercised deterministically. live_bytes is fixed (a flat
    // working set); on_disk follows `disk[round-1]`.
    struct ScriptedRecipe {
        disk: Vec<u64>,
        live: u64,
        class: SubMsGrowthClass,
        bound: f64,
        round: usize,
    }
    impl SubMsGrowthRecipe for ScriptedRecipe {
        fn name(&self) -> &str {
            "scripted"
        }
        fn rounds(&self) -> usize {
            self.disk.len()
        }
        fn ops_per_round(&self) -> usize {
            4
        }
        fn op(&mut self, round: usize, _i: usize) {
            self.round = round;
        }
        fn disk_bytes(&mut self) -> u64 {
            self.disk[self.round - 1]
        }
        fn live_bytes(&mut self) -> u64 {
            self.live
        }
        fn structures(&mut self) -> Vec<(String, u64)> {
            vec![("sstables".to_string(), self.round as u64)]
        }
        fn expected(&self) -> (SubMsGrowthClass, f64) {
            (self.class, self.bound)
        }
    }

    #[test]
    fn amplification_bounded_holds_when_disk_tracks_live() {
        // on-disk stays within ~1.2x of a flat 1000-byte live set -> holds.
        let mut r = ScriptedRecipe {
            disk: vec![1000, 1100, 1050, 1200],
            live: 1000,
            class: SubMsGrowthClass::AmplificationBounded,
            bound: 3.0,
            round: 0,
        };
        let report = grow(&mut r, "rust");
        assert!(report.verdict.holds, "{}", report.verdict.summary);
        assert!((report.verdict.observed - 1.2).abs() < 1e-9);
        assert!(assert_growth_holds(&report).is_ok());
    }

    #[test]
    fn amplification_bounded_breaches_when_disk_grows_but_live_flat() {
        // the leak shape: live flat at 1000, on-disk climbs to 21x -> breach.
        let mut r = ScriptedRecipe {
            disk: vec![1000, 5000, 12000, 21000],
            live: 1000,
            class: SubMsGrowthClass::AmplificationBounded,
            bound: 3.0,
            round: 0,
        };
        let report = grow(&mut r, "rust");
        assert!(!report.verdict.holds);
        assert!((report.verdict.observed - 21.0).abs() < 1e-9);
        assert!(assert_growth_holds(&report).is_err());
    }

    #[test]
    fn plateau_holds_when_flat_and_breaches_when_climbing() {
        // Flat file (pre-allocated, then steady) -> holds even though absolute
        // size dwarfs the tiny live set.
        let mut flat = ScriptedRecipe {
            disk: vec![66_000, 66_000, 66_000, 66_000],
            live: 20,
            class: SubMsGrowthClass::PlateauBounded,
            bound: 1.5,
            round: 0,
        };
        let held = grow(&mut flat, "rust");
        assert!(held.verdict.holds, "{}", held.verdict.summary);

        // File that keeps climbing through the second half -> breach (a leak).
        // Baseline is the mid-point (round 3 here); last / mid = 16000 / 4000 = 4x.
        let mut climbing = ScriptedRecipe {
            disk: vec![1000, 2000, 4000, 16000],
            live: 20,
            class: SubMsGrowthClass::PlateauBounded,
            bound: 1.5,
            round: 0,
        };
        let leak = grow(&mut climbing, "rust");
        assert!(!leak.verdict.holds);
        assert!((leak.verdict.observed - 4.0).abs() < 1e-9);
    }

    #[test]
    fn bounded_gates_on_peak_bytes() {
        let mut r = ScriptedRecipe {
            disk: vec![100, 128, 128, 128],
            live: 128,
            class: SubMsGrowthClass::Bounded,
            bound: 128.0,
            round: 0,
        };
        let report = grow(&mut r, "rust");
        assert!(report.verdict.holds);
    }

    #[test]
    fn json_has_verdict_and_rounds() {
        let mut r = ScriptedRecipe {
            disk: vec![1000, 1100],
            live: 1000,
            class: SubMsGrowthClass::AmplificationBounded,
            bound: 3.0,
            round: 0,
        };
        let report = grow(&mut r, "rust");
        let mut buf: Vec<u8> = Vec::new();
        growth_to_json(&report, &mut buf).unwrap();
        let s = String::from_utf8(buf).unwrap();
        assert!(s.contains("\"kind\":\"growth\""));
        assert!(s.contains("\"class\":\"amplification_bounded\""));
        assert!(s.contains("\"holds\":true"));
        assert!(s.contains("\"sstables\":2"));
        assert!(s.contains("\"amplification\":"));
    }
}

// Minimal JSON string escaper, mirroring bench::json_str (kept local so this
// module does not widen that one's visibility).
fn json_str(out: &mut String, s: &str) {
    out.push('"');
    for c in s.chars() {
        match c {
            '"' => out.push_str("\\\""),
            '\\' => out.push_str("\\\\"),
            '\n' => out.push_str("\\n"),
            '\r' => out.push_str("\\r"),
            '\t' => out.push_str("\\t"),
            c if (c as u32) < 0x20 => {
                let _ = write!(out, "\\u{:04x}", c as u32);
            }
            c => out.push(c),
        }
    }
    out.push('"');
}