Skip to main content

mobench_runtime/
lib.rs

1//! Small, provider-free runtime primitives shared by Mobench surfaces.
2
3use std::sync::OnceLock;
4
5/// Maximum accepted measured or warmup iteration count at runtime boundaries.
6pub const MAX_BENCHMARK_COUNT: u32 = 1_000_000;
7
8/// Saturate a `u128` value into the public `u64` wire range.
9#[must_use]
10pub fn saturating_u128_to_u64(value: u128) -> u64 {
11    value.min(u128::from(u64::MAX)) as u64
12}
13
14/// Saturate an in-memory collection length into the public `u32` wire range.
15#[must_use]
16pub fn saturating_usize_to_u32(value: usize) -> u32 {
17    u32::try_from(value).unwrap_or(u32::MAX)
18}
19
20/// Sum `u64` values with a `u128` accumulator and saturate at `u64::MAX`.
21#[must_use]
22pub fn saturating_sum_u64(values: impl IntoIterator<Item = u64>) -> u64 {
23    saturating_u128_to_u64(values.into_iter().fold(0_u128, |total, value| {
24        total.saturating_add(u128::from(value))
25    }))
26}
27
28/// Round `part / total` to the nearest whole percent without intermediate overflow.
29#[must_use]
30pub fn rounded_percent_u64(part: u64, total: u64) -> Option<u64> {
31    if total == 0 {
32        return None;
33    }
34    let rounded = (u128::from(part) * 100 + (u128::from(total) / 2)) / u128::from(total);
35    Some(saturating_u128_to_u64(rounded))
36}
37
38/// Calculate the released SDK's floating mean over an allocation-free iterator.
39#[must_use]
40pub fn sdk_v1_mean_u64(values: impl IntoIterator<Item = u64>) -> f64 {
41    let (sum, count) = values
42        .into_iter()
43        .fold((0_u128, 0_usize), |(sum, count), value| {
44            (sum.saturating_add(u128::from(value)), count + 1)
45        });
46    if count == 0 {
47        0.0
48    } else {
49        sum as f64 / count as f64
50    }
51}
52
53/// Calculate the released SDK's sample standard deviation without allocating.
54#[must_use]
55pub fn sdk_v1_std_dev_u64<I>(values: I) -> f64
56where
57    I: Iterator<Item = u64> + Clone,
58{
59    let (sum, count) = values
60        .clone()
61        .fold((0_u128, 0_usize), |(sum, count), value| {
62            (sum.saturating_add(u128::from(value)), count + 1)
63        });
64    if count < 2 {
65        return 0.0;
66    }
67    let mean = sum as f64 / count as f64;
68    let variance = values
69        .map(|value| {
70            let difference = value as f64 - mean;
71            difference * difference
72        })
73        .sum::<f64>()
74        / (count - 1) as f64;
75    variance.sqrt()
76}
77
78/// A timing distribution used by compatibility-specific summaries.
79///
80/// Sorting is lazy, so mean-only SDK calls do not pay an allocation or sort.
81#[derive(Debug)]
82pub struct Distribution<'a> {
83    samples: DistributionSamples<'a>,
84    sorted: OnceLock<Vec<u64>>,
85    sum: u128,
86}
87
88#[derive(Debug)]
89enum DistributionSamples<'a> {
90    Borrowed(&'a [u64]),
91    Sorted(Vec<u64>),
92}
93
94/// The released CLI's integer summary semantics.
95#[derive(Debug, Clone, Copy, PartialEq, Eq)]
96pub struct CliV1Summary {
97    pub mean_ns: u64,
98    pub median_ns: u64,
99    pub p95_ns: u64,
100    pub min_ns: u64,
101    pub max_ns: u64,
102}
103
104/// The released SDK's floating-point summary semantics.
105#[derive(Debug, Clone, Copy, PartialEq)]
106pub struct SdkV1Summary {
107    pub mean_ns: f64,
108    pub median_ns: f64,
109    pub std_dev_ns: f64,
110    pub min_ns: u64,
111    pub max_ns: u64,
112    pub p95_ns: f64,
113    pub p99_ns: f64,
114}
115
116/// Optional resource measurements for one benchmark iteration.
117#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
118pub struct ResourceSample {
119    pub cpu_time_ms: Option<u64>,
120    pub peak_memory_growth_kb: Option<u64>,
121    pub process_peak_memory_kb: Option<u64>,
122}
123
124/// Aggregated resource measurements across the samples that provided them.
125#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
126pub struct ResourceAggregate {
127    pub cpu_total_ms: Option<u64>,
128    pub cpu_median_ms: Option<u64>,
129    pub peak_memory_growth_kb: Option<u64>,
130    pub process_peak_memory_kb: Option<u64>,
131}
132
133/// Incrementally aggregate partial resource measurements without overflow.
134#[derive(Debug, Default)]
135pub struct ResourceAccumulator {
136    cpu_samples: Vec<u64>,
137    cpu_total_ms: u128,
138    peak_memory_growth_kb: Option<u64>,
139    process_peak_memory_kb: Option<u64>,
140}
141
142impl ResourceAccumulator {
143    /// Create an empty resource accumulator.
144    #[must_use]
145    pub fn new() -> Self {
146        Self::default()
147    }
148
149    /// Record the fields present for one benchmark iteration.
150    pub fn record(&mut self, sample: ResourceSample) {
151        if let Some(cpu_time_ms) = sample.cpu_time_ms {
152            self.cpu_samples.push(cpu_time_ms);
153            self.cpu_total_ms = self.cpu_total_ms.saturating_add(u128::from(cpu_time_ms));
154        }
155        if let Some(peak_memory_growth_kb) = sample.peak_memory_growth_kb {
156            self.peak_memory_growth_kb = Some(
157                self.peak_memory_growth_kb
158                    .map_or(peak_memory_growth_kb, |current| {
159                        current.max(peak_memory_growth_kb)
160                    }),
161            );
162        }
163        if let Some(process_peak_memory_kb) = sample.process_peak_memory_kb {
164            self.process_peak_memory_kb = Some(
165                self.process_peak_memory_kb
166                    .map_or(process_peak_memory_kb, |current| {
167                        current.max(process_peak_memory_kb)
168                    }),
169            );
170        }
171    }
172
173    /// Finish aggregation, consuming the accumulator.
174    #[must_use]
175    pub fn finish(mut self) -> ResourceAggregate {
176        self.cpu_samples.sort_unstable();
177        let cpu_median_ms = match self.cpu_samples.len() {
178            0 => None,
179            len if len % 2 == 1 => Some(self.cpu_samples[len / 2]),
180            len => {
181                let lower = u128::from(self.cpu_samples[(len / 2) - 1]);
182                let upper = u128::from(self.cpu_samples[len / 2]);
183                Some(saturating_u128_to_u64((lower + upper) / 2))
184            }
185        };
186
187        ResourceAggregate {
188            cpu_total_ms: (!self.cpu_samples.is_empty())
189                .then_some(saturating_u128_to_u64(self.cpu_total_ms)),
190            cpu_median_ms,
191            peak_memory_growth_kb: self.peak_memory_growth_kb,
192            process_peak_memory_kb: self.process_peak_memory_kb,
193        }
194    }
195}
196
197impl<'a> Distribution<'a> {
198    /// Borrow nanosecond samples and prepare safe shared accumulation.
199    #[must_use]
200    pub fn from_slice(samples: &'a [u64]) -> Self {
201        let sum = samples
202            .iter()
203            .fold(0_u128, |total, sample| total + u128::from(*sample));
204        Self {
205            samples: DistributionSamples::Borrowed(samples),
206            sorted: OnceLock::new(),
207            sum,
208        }
209    }
210
211    /// Own and eagerly sort nanosecond samples for one-allocation order statistics.
212    #[must_use]
213    pub fn from_vec(mut samples: Vec<u64>) -> Self {
214        let sum = samples
215            .iter()
216            .fold(0_u128, |total, sample| total + u128::from(*sample));
217        samples.sort_unstable();
218        Self {
219            samples: DistributionSamples::Sorted(samples),
220            sorted: OnceLock::new(),
221            sum,
222        }
223    }
224
225    /// Summarize with the released CLI's floor and nearest-rank rules.
226    #[must_use]
227    pub fn cli_v1_summary(&self) -> Option<CliV1Summary> {
228        let len = self.values().len();
229        if len == 0 {
230            return None;
231        }
232        let sorted = self.sorted();
233
234        let median_ns = if len % 2 == 1 {
235            sorted[len / 2]
236        } else {
237            let lower = u128::from(sorted[(len / 2) - 1]);
238            let upper = u128::from(sorted[len / 2]);
239            saturating_u128_to_u64((lower + upper) / 2)
240        };
241        let p95_rank = (95_u128 * len as u128).div_ceil(100) as usize;
242
243        Some(CliV1Summary {
244            mean_ns: saturating_u128_to_u64(self.sum / len as u128),
245            median_ns,
246            p95_ns: sorted[p95_rank.saturating_sub(1)],
247            min_ns: sorted[0],
248            max_ns: sorted[len - 1],
249        })
250    }
251
252    /// Summarize with the released SDK's floating-point and rounded-index rules.
253    #[must_use]
254    pub fn sdk_v1_summary(&self) -> SdkV1Summary {
255        if self.values().is_empty() {
256            return SdkV1Summary {
257                mean_ns: 0.0,
258                median_ns: 0.0,
259                std_dev_ns: 0.0,
260                min_ns: 0,
261                max_ns: 0,
262                p95_ns: 0.0,
263                p99_ns: 0.0,
264            };
265        }
266
267        SdkV1Summary {
268            mean_ns: self.sdk_v1_mean(),
269            median_ns: self.sdk_v1_median(),
270            std_dev_ns: self.sdk_v1_std_dev(),
271            min_ns: self.min().unwrap_or(0),
272            max_ns: self.max().unwrap_or(0),
273            p95_ns: self.sdk_v1_percentile(95.0),
274            p99_ns: self.sdk_v1_percentile(99.0),
275        }
276    }
277
278    /// Return the SDK's floating-point mean, or zero for no samples.
279    #[must_use]
280    pub fn sdk_v1_mean(&self) -> f64 {
281        if self.values().is_empty() {
282            0.0
283        } else {
284            sdk_v1_mean_u64(self.values().iter().copied())
285        }
286    }
287
288    /// Return the SDK's floating-point median, or zero for no samples.
289    #[must_use]
290    pub fn sdk_v1_median(&self) -> f64 {
291        let len = self.values().len();
292        if len == 0 {
293            return 0.0;
294        }
295        let sorted = self.sorted();
296        if len % 2 == 1 {
297            sorted[len / 2] as f64
298        } else {
299            (sorted[(len / 2) - 1] as f64 + sorted[len / 2] as f64) / 2.0
300        }
301    }
302
303    /// Return the SDK's sample standard deviation, or zero below two samples.
304    #[must_use]
305    pub fn sdk_v1_std_dev(&self) -> f64 {
306        sdk_v1_std_dev_u64(self.values().iter().copied())
307    }
308
309    /// Return the minimum sample without sorting.
310    #[must_use]
311    pub fn min(&self) -> Option<u64> {
312        self.values().iter().copied().min()
313    }
314
315    /// Return the maximum sample without sorting.
316    #[must_use]
317    pub fn max(&self) -> Option<u64> {
318        self.values().iter().copied().max()
319    }
320
321    /// Select a percentile with the SDK's clamped, rounded `(n - 1)` index.
322    #[must_use]
323    pub fn sdk_v1_percentile(&self, percentile: f64) -> f64 {
324        if self.values().is_empty() {
325            return 0.0;
326        }
327        let sorted = self.sorted();
328        let percentile = percentile.clamp(0.0, 100.0) / 100.0;
329        let index = (percentile * (sorted.len() - 1) as f64).round() as usize;
330        sorted[index.min(sorted.len() - 1)] as f64
331    }
332
333    fn sorted(&self) -> &[u64] {
334        match &self.samples {
335            DistributionSamples::Sorted(samples) => samples,
336            DistributionSamples::Borrowed(samples) => self.sorted.get_or_init(|| {
337                let mut sorted = samples.to_vec();
338                sorted.sort_unstable();
339                sorted
340            }),
341        }
342    }
343
344    fn values(&self) -> &[u64] {
345        match &self.samples {
346            DistributionSamples::Borrowed(samples) => samples,
347            DistributionSamples::Sorted(samples) => samples,
348        }
349    }
350}