Skip to main content

ipfrs_storage/
storage_benchmark.rs

1//! Storage performance benchmarking utilities
2//!
3//! Provides comprehensive tools for measuring storage read/write/delete
4//! throughput and latency across all operation types, including:
5//!
6//! - Configurable benchmark runs with warmup support
7//! - Per-operation latency sampling (microsecond resolution)
8//! - Statistical analysis: p50/p95/p99 percentiles, min/max
9//! - Throughput computation in MB/s
10//! - Deterministic pseudo-random block generation (xorshift64)
11//! - Human-readable result formatting
12//! - Aggregate statistics across multiple runs
13
14use serde::{Deserialize, Serialize};
15use std::fmt;
16
17// ─────────────────────────────────────────────────────────────────────────────
18// Public types
19// ─────────────────────────────────────────────────────────────────────────────
20
21/// The type of storage operation being benchmarked.
22#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub enum BenchmarkOp {
24    /// Write / put operation
25    Write,
26    /// Read / get operation
27    Read,
28    /// Delete / remove operation
29    Delete,
30    /// Mixed workload exercising all operation types
31    Mixed,
32}
33
34impl fmt::Display for BenchmarkOp {
35    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
36        match self {
37            BenchmarkOp::Write => write!(f, "Write"),
38            BenchmarkOp::Read => write!(f, "Read"),
39            BenchmarkOp::Delete => write!(f, "Delete"),
40            BenchmarkOp::Mixed => write!(f, "Mixed"),
41        }
42    }
43}
44
45/// Configuration controlling a single benchmark run.
46#[derive(Debug, Clone, Serialize, Deserialize)]
47pub struct BenchmarkConfig {
48    /// Operation type to benchmark
49    pub op: BenchmarkOp,
50    /// Size of each data block in bytes
51    pub block_size: usize,
52    /// Number of measured operations (after warmup)
53    pub num_operations: usize,
54    /// Number of warmup operations (excluded from measurements)
55    pub warmup_ops: usize,
56    /// PRNG seed for reproducible block generation
57    pub seed: u64,
58}
59
60impl BenchmarkConfig {
61    /// Create a default write-benchmark configuration.
62    ///
63    /// - 4 KiB blocks
64    /// - 1 000 measured operations
65    /// - 100 warmup operations
66    pub fn default_write() -> Self {
67        Self {
68            op: BenchmarkOp::Write,
69            block_size: 4096,
70            num_operations: 1_000,
71            warmup_ops: 100,
72            seed: 0xdeadbeef_cafebabe,
73        }
74    }
75
76    /// Create a default read-benchmark configuration.
77    pub fn default_read() -> Self {
78        Self {
79            op: BenchmarkOp::Read,
80            block_size: 4096,
81            num_operations: 1_000,
82            warmup_ops: 100,
83            seed: 0xcafebabe_deadbeef,
84        }
85    }
86
87    /// Create a default mixed-workload configuration.
88    pub fn default_mixed() -> Self {
89        Self {
90            op: BenchmarkOp::Mixed,
91            block_size: 65536,
92            num_operations: 500,
93            warmup_ops: 50,
94            seed: 0x1234567890abcdef,
95        }
96    }
97}
98
99impl Default for BenchmarkConfig {
100    fn default() -> Self {
101        Self::default_write()
102    }
103}
104
105/// A single latency measurement for one storage operation.
106#[derive(Debug, Clone, Serialize, Deserialize)]
107pub struct LatencySample {
108    /// The operation that was timed
109    pub op: BenchmarkOp,
110    /// Round-trip latency in microseconds
111    pub latency_us: u64,
112    /// Number of bytes involved in the operation
113    pub bytes: usize,
114    /// Whether the operation completed successfully
115    pub success: bool,
116}
117
118impl LatencySample {
119    /// Construct a new sample.
120    pub fn new(op: BenchmarkOp, latency_us: u64, bytes: usize, success: bool) -> Self {
121        Self {
122            op,
123            latency_us,
124            bytes,
125            success,
126        }
127    }
128}
129
130/// Aggregated result of a completed benchmark run.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct BenchmarkResult {
133    /// The configuration used to produce this result
134    pub config: BenchmarkConfig,
135    /// Total operations attempted (successful + failed)
136    pub total_ops: usize,
137    /// Operations that completed without error
138    pub successful_ops: usize,
139    /// Operations that returned an error
140    pub failed_ops: usize,
141    /// Total bytes transferred across all successful operations
142    pub total_bytes: u64,
143    /// Wall-clock duration of the benchmark in microseconds
144    pub duration_us: u64,
145    /// Aggregate throughput in megabytes per second
146    pub throughput_mbps: f64,
147    /// 50th-percentile (median) latency in microseconds
148    pub latency_p50_us: u64,
149    /// 95th-percentile latency in microseconds
150    pub latency_p95_us: u64,
151    /// 99th-percentile latency in microseconds
152    pub latency_p99_us: u64,
153    /// Minimum observed latency in microseconds
154    pub latency_min_us: u64,
155    /// Maximum observed latency in microseconds
156    pub latency_max_us: u64,
157}
158
159/// Running aggregate statistics across multiple benchmark runs.
160#[derive(Debug, Clone, Default, Serialize, Deserialize)]
161pub struct BenchmarkStats {
162    /// Number of benchmark runs completed
163    pub runs: u64,
164    /// Cumulative operation count across all runs
165    pub total_ops: u64,
166    /// Cumulative bytes transferred across all runs
167    pub total_bytes: u64,
168}
169
170impl BenchmarkStats {
171    /// Incorporate a completed [`BenchmarkResult`] into these stats.
172    pub fn record(&mut self, result: &BenchmarkResult) {
173        self.runs = self.runs.saturating_add(1);
174        self.total_ops = self.total_ops.saturating_add(result.total_ops as u64);
175        self.total_bytes = self.total_bytes.saturating_add(result.total_bytes);
176    }
177
178    /// Average operations per run (returns 0 when no runs have been recorded).
179    pub fn avg_ops_per_run(&self) -> f64 {
180        if self.runs == 0 {
181            0.0
182        } else {
183            self.total_ops as f64 / self.runs as f64
184        }
185    }
186
187    /// Average bytes per run.
188    pub fn avg_bytes_per_run(&self) -> f64 {
189        if self.runs == 0 {
190            0.0
191        } else {
192            self.total_bytes as f64 / self.runs as f64
193        }
194    }
195}
196
197// ─────────────────────────────────────────────────────────────────────────────
198// StorageBenchmark
199// ─────────────────────────────────────────────────────────────────────────────
200
201/// Storage performance benchmarking engine.
202///
203/// Collects [`LatencySample`]s during a benchmark run, then computes
204/// statistical summaries via [`StorageBenchmark::compute_result`].
205///
206/// # Example
207///
208/// ```rust
209/// use ipfrs_storage::{BenchmarkConfig, BenchmarkOp, StorageBenchmark};
210///
211/// let config = BenchmarkConfig::default_write();
212/// let mut bench = StorageBenchmark::new(config);
213///
214/// // Simulate recording some operations
215/// bench.record_op(BenchmarkOp::Write, 120, 4096, true);
216/// bench.record_op(BenchmarkOp::Write, 95, 4096, true);
217///
218/// let result = bench.compute_result(500_000); // 0.5 s in µs
219/// println!("{}", StorageBenchmark::format_result(&result));
220/// ```
221pub struct StorageBenchmark {
222    config: BenchmarkConfig,
223    samples: Vec<LatencySample>,
224    /// Internal xorshift64 PRNG state (seeded from `config.seed`)
225    rng_state: u64,
226}
227
228impl StorageBenchmark {
229    /// Create a new benchmark engine from the given configuration.
230    ///
231    /// The PRNG is seeded from `config.seed`; if the seed is zero the engine
232    /// falls back to a fixed non-zero seed to satisfy xorshift64 requirements.
233    pub fn new(config: BenchmarkConfig) -> Self {
234        // xorshift64 must never have state == 0
235        let rng_state = if config.seed == 0 {
236            0x9e3779b97f4a7c15
237        } else {
238            config.seed
239        };
240        Self {
241            config,
242            samples: Vec::new(),
243            rng_state,
244        }
245    }
246
247    // ── Sample management ────────────────────────────────────────────────────
248
249    /// Push a pre-built [`LatencySample`] onto the collection.
250    pub fn add_sample(&mut self, sample: LatencySample) {
251        self.samples.push(sample);
252    }
253
254    /// Record a single operation result, constructing a [`LatencySample`]
255    /// and adding it to the internal collection.
256    pub fn record_op(&mut self, op: BenchmarkOp, latency_us: u64, bytes: usize, success: bool) {
257        self.add_sample(LatencySample::new(op, latency_us, bytes, success));
258    }
259
260    /// Return the number of samples currently held.
261    pub fn sample_count(&self) -> usize {
262        self.samples.len()
263    }
264
265    /// Fraction of recorded operations that succeeded (0.0 when no samples).
266    pub fn success_rate(&self) -> f64 {
267        if self.samples.is_empty() {
268            return 0.0;
269        }
270        let successes = self.samples.iter().filter(|s| s.success).count();
271        successes as f64 / self.samples.len() as f64
272    }
273
274    /// Clear all recorded samples and reset the PRNG to the original seed.
275    pub fn reset(&mut self) {
276        self.samples.clear();
277        self.rng_state = if self.config.seed == 0 {
278            0x9e3779b97f4a7c15
279        } else {
280            self.config.seed
281        };
282    }
283
284    // ── Statistical computation ──────────────────────────────────────────────
285
286    /// Compute a [`BenchmarkResult`] from all samples collected so far.
287    ///
288    /// `duration_us` is the wall-clock time of the entire run in microseconds.
289    /// When `duration_us` is zero, throughput is reported as 0.0 to avoid
290    /// division-by-zero.
291    pub fn compute_result(&self, duration_us: u64) -> BenchmarkResult {
292        let total_ops = self.samples.len();
293        let successful_ops = self.samples.iter().filter(|s| s.success).count();
294        let failed_ops = total_ops - successful_ops;
295
296        let total_bytes: u64 = self
297            .samples
298            .iter()
299            .filter(|s| s.success)
300            .map(|s| s.bytes as u64)
301            .fold(0u64, |acc, b| acc.saturating_add(b));
302
303        let throughput_mbps = Self::compute_throughput(total_bytes, duration_us);
304
305        // Collect latencies for all samples (regardless of success) for stats
306        let mut latencies: Vec<u64> = self.samples.iter().map(|s| s.latency_us).collect();
307
308        let (latency_min_us, latency_max_us) = if latencies.is_empty() {
309            (0, 0)
310        } else {
311            let min = latencies.iter().copied().min().unwrap_or(0);
312            let max = latencies.iter().copied().max().unwrap_or(0);
313            (min, max)
314        };
315
316        let latency_p50_us = Self::compute_percentile(&mut latencies, 50.0);
317        let latency_p95_us = Self::compute_percentile(&mut latencies, 95.0);
318        let latency_p99_us = Self::compute_percentile(&mut latencies, 99.0);
319
320        BenchmarkResult {
321            config: self.config.clone(),
322            total_ops,
323            successful_ops,
324            failed_ops,
325            total_bytes,
326            duration_us,
327            throughput_mbps,
328            latency_p50_us,
329            latency_p95_us,
330            latency_p99_us,
331            latency_min_us,
332            latency_max_us,
333        }
334    }
335
336    /// Compute a percentile value from a mutable slice of latency samples.
337    ///
338    /// The slice is sorted in place. Returns 0 for an empty slice.
339    /// `percentile` must be in the range [0.0, 100.0]; values outside that
340    /// range are clamped.
341    pub fn compute_percentile(samples: &mut [u64], percentile: f64) -> u64 {
342        if samples.is_empty() {
343            return 0;
344        }
345        samples.sort_unstable();
346
347        let pct = percentile.clamp(0.0, 100.0);
348        // Nearest-rank method (1-based)
349        let rank = ((pct / 100.0) * samples.len() as f64).ceil() as usize;
350        let idx = rank.saturating_sub(1).min(samples.len() - 1);
351        samples[idx]
352    }
353
354    /// Compute throughput in megabytes per second.
355    ///
356    /// Returns `0.0` when `duration_us` is zero.
357    pub fn compute_throughput(bytes: u64, duration_us: u64) -> f64 {
358        if duration_us == 0 {
359            return 0.0;
360        }
361        // bytes / (duration_us × 1e-6) / 1e6  ==  bytes / duration_us
362        bytes as f64 / duration_us as f64
363    }
364
365    // ── Block generation ─────────────────────────────────────────────────────
366
367    /// Generate a pseudo-random block of `size` bytes using the internal
368    /// xorshift64 PRNG.
369    ///
370    /// Each call advances the PRNG state so successive calls produce
371    /// different (but deterministic) data.
372    pub fn generate_block(&mut self, size: usize) -> Vec<u8> {
373        let mut out = Vec::with_capacity(size);
374        let mut state = self.rng_state;
375
376        let full_words = size / 8;
377        let remainder = size % 8;
378
379        for _ in 0..full_words {
380            state = xorshift64(state);
381            out.extend_from_slice(&state.to_le_bytes());
382        }
383
384        if remainder > 0 {
385            state = xorshift64(state);
386            let word_bytes = state.to_le_bytes();
387            out.extend_from_slice(&word_bytes[..remainder]);
388        }
389
390        self.rng_state = state;
391        out
392    }
393
394    // ── Formatting ───────────────────────────────────────────────────────────
395
396    /// Format a [`BenchmarkResult`] as a human-readable summary string.
397    pub fn format_result(result: &BenchmarkResult) -> String {
398        let success_pct = if result.total_ops == 0 {
399            0.0
400        } else {
401            100.0 * result.successful_ops as f64 / result.total_ops as f64
402        };
403
404        let duration_ms = result.duration_us as f64 / 1_000.0;
405        let block_kb = result.config.block_size as f64 / 1024.0;
406        let total_mb = result.total_bytes as f64 / (1024.0 * 1024.0);
407
408        format!(
409            "=== Benchmark Result ===\n\
410             Operation  : {op}\n\
411             Block size : {block_kb:.1} KiB\n\
412             Operations : {total_ops} total / {successful_ops} ok / {failed_ops} failed ({success_pct:.1}%)\n\
413             Data       : {total_mb:.2} MiB in {duration_ms:.2} ms\n\
414             Throughput : {throughput_mbps:.3} MB/s\n\
415             Latency    : min={min}µs  p50={p50}µs  p95={p95}µs  p99={p99}µs  max={max}µs",
416            op              = result.config.op,
417            block_kb        = block_kb,
418            total_ops       = result.total_ops,
419            successful_ops  = result.successful_ops,
420            failed_ops      = result.failed_ops,
421            success_pct     = success_pct,
422            total_mb        = total_mb,
423            duration_ms     = duration_ms,
424            throughput_mbps = result.throughput_mbps,
425            min             = result.latency_min_us,
426            p50             = result.latency_p50_us,
427            p95             = result.latency_p95_us,
428            p99             = result.latency_p99_us,
429            max             = result.latency_max_us,
430        )
431    }
432}
433
434// ─────────────────────────────────────────────────────────────────────────────
435// xorshift64 PRNG
436// ─────────────────────────────────────────────────────────────────────────────
437
438/// Advance one xorshift64 step and return the new state.
439///
440/// The algorithm is from Marsaglia 2003; period is 2⁶⁴ − 1.
441/// Panics if `state == 0` (invalid for xorshift).
442#[inline]
443fn xorshift64(mut state: u64) -> u64 {
444    debug_assert_ne!(state, 0, "xorshift64 state must not be zero");
445    state ^= state << 13;
446    state ^= state >> 7;
447    state ^= state << 17;
448    state
449}
450
451// ─────────────────────────────────────────────────────────────────────────────
452// Tests
453// ─────────────────────────────────────────────────────────────────────────────
454
455#[cfg(test)]
456mod tests {
457    use super::*;
458
459    // ── Helper constructors ──────────────────────────────────────────────────
460
461    fn default_bench() -> StorageBenchmark {
462        StorageBenchmark::new(BenchmarkConfig::default_write())
463    }
464
465    fn bench_with_seed(seed: u64) -> StorageBenchmark {
466        let mut cfg = BenchmarkConfig::default_write();
467        cfg.seed = seed;
468        StorageBenchmark::new(cfg)
469    }
470
471    fn make_sample(latency: u64, bytes: usize, success: bool) -> LatencySample {
472        LatencySample::new(BenchmarkOp::Write, latency, bytes, success)
473    }
474
475    // ── 1. add_sample / sample_count ────────────────────────────────────────
476
477    #[test]
478    fn test_add_sample_increments_count() {
479        let mut bench = default_bench();
480        assert_eq!(bench.sample_count(), 0);
481        bench.add_sample(make_sample(100, 4096, true));
482        assert_eq!(bench.sample_count(), 1);
483        bench.add_sample(make_sample(200, 4096, false));
484        assert_eq!(bench.sample_count(), 2);
485    }
486
487    // ── 2. record_op convenience wrapper ────────────────────────────────────
488
489    #[test]
490    fn test_record_op_stores_sample() {
491        let mut bench = default_bench();
492        bench.record_op(BenchmarkOp::Read, 50, 8192, true);
493        assert_eq!(bench.sample_count(), 1);
494        let s = &bench.samples[0];
495        assert_eq!(s.latency_us, 50);
496        assert_eq!(s.bytes, 8192);
497        assert!(s.success);
498    }
499
500    // ── 3. success_rate with no samples ─────────────────────────────────────
501
502    #[test]
503    fn test_success_rate_empty() {
504        let bench = default_bench();
505        assert_eq!(bench.success_rate(), 0.0);
506    }
507
508    // ── 4. success_rate all success ──────────────────────────────────────────
509
510    #[test]
511    fn test_success_rate_all_success() {
512        let mut bench = default_bench();
513        for _ in 0..10 {
514            bench.record_op(BenchmarkOp::Write, 100, 4096, true);
515        }
516        let rate = bench.success_rate();
517        assert!((rate - 1.0).abs() < f64::EPSILON);
518    }
519
520    // ── 5. success_rate all failure ──────────────────────────────────────────
521
522    #[test]
523    fn test_success_rate_all_failure() {
524        let mut bench = default_bench();
525        for _ in 0..5 {
526            bench.record_op(BenchmarkOp::Write, 300, 4096, false);
527        }
528        assert_eq!(bench.success_rate(), 0.0);
529    }
530
531    // ── 6. success_rate mixed ────────────────────────────────────────────────
532
533    #[test]
534    fn test_success_rate_mixed() {
535        let mut bench = default_bench();
536        bench.record_op(BenchmarkOp::Write, 100, 4096, true);
537        bench.record_op(BenchmarkOp::Write, 200, 4096, false);
538        let rate = bench.success_rate();
539        assert!((rate - 0.5).abs() < f64::EPSILON);
540    }
541
542    // ── 7. reset clears samples and restores PRNG ───────────────────────────
543
544    #[test]
545    fn test_reset_clears_samples() {
546        let mut bench = default_bench();
547        bench.record_op(BenchmarkOp::Write, 100, 4096, true);
548        bench.record_op(BenchmarkOp::Write, 200, 4096, true);
549        bench.reset();
550        assert_eq!(bench.sample_count(), 0);
551    }
552
553    #[test]
554    fn test_reset_restores_prng() {
555        let mut bench = bench_with_seed(42);
556        let block_a = bench.generate_block(16);
557        bench.reset();
558        let block_b = bench.generate_block(16);
559        // After reset, PRNG starts from the same seed → same output
560        assert_eq!(block_a, block_b);
561    }
562
563    // ── 8. generate_block produces correct size ──────────────────────────────
564
565    #[test]
566    fn test_generate_block_exact_size() {
567        let mut bench = bench_with_seed(1);
568        for size in [0, 1, 7, 8, 9, 15, 16, 63, 64, 100, 4096, 65536] {
569            let block = bench.generate_block(size);
570            assert_eq!(block.len(), size, "expected {size} bytes");
571        }
572    }
573
574    // ── 9. generate_block is deterministic ───────────────────────────────────
575
576    #[test]
577    fn test_generate_block_deterministic() {
578        let mut a = bench_with_seed(999);
579        let mut b = bench_with_seed(999);
580        assert_eq!(a.generate_block(64), b.generate_block(64));
581    }
582
583    // ── 10. generate_block advances PRNG (successive blocks differ) ──────────
584
585    #[test]
586    fn test_generate_block_advances_prng() {
587        let mut bench = bench_with_seed(7);
588        let b1 = bench.generate_block(8);
589        let b2 = bench.generate_block(8);
590        assert_ne!(b1, b2);
591    }
592
593    // ── 11. compute_percentile empty slice ───────────────────────────────────
594
595    #[test]
596    fn test_percentile_empty() {
597        let mut v: Vec<u64> = Vec::new();
598        assert_eq!(StorageBenchmark::compute_percentile(&mut v, 50.0), 0);
599    }
600
601    // ── 12. compute_percentile p50 on known data ─────────────────────────────
602
603    #[test]
604    fn test_percentile_p50_known() {
605        // sorted: [1, 2, 3, 4, 5] → median = 3
606        let mut v = vec![5, 1, 3, 2, 4];
607        assert_eq!(StorageBenchmark::compute_percentile(&mut v, 50.0), 3);
608    }
609
610    // ── 13. compute_percentile p95 ───────────────────────────────────────────
611
612    #[test]
613    fn test_percentile_p95() {
614        // 20 values 1..=20; p95 → ceil(0.95*20)=19th → value 19
615        let mut v: Vec<u64> = (1..=20).collect();
616        fastrand::shuffle(&mut v);
617        assert_eq!(StorageBenchmark::compute_percentile(&mut v, 95.0), 19);
618    }
619
620    // ── 14. compute_percentile p99 ───────────────────────────────────────────
621
622    #[test]
623    fn test_percentile_p99() {
624        // 100 values 1..=100; p99 → ceil(0.99*100)=99th → value 99
625        let mut v: Vec<u64> = (1..=100).collect();
626        fastrand::shuffle(&mut v);
627        assert_eq!(StorageBenchmark::compute_percentile(&mut v, 99.0), 99);
628    }
629
630    // ── 15. compute_percentile single element ────────────────────────────────
631
632    #[test]
633    fn test_percentile_single() {
634        let mut v = vec![42u64];
635        assert_eq!(StorageBenchmark::compute_percentile(&mut v, 50.0), 42);
636        assert_eq!(StorageBenchmark::compute_percentile(&mut v, 99.0), 42);
637    }
638
639    // ── 16. compute_throughput formula ───────────────────────────────────────
640
641    #[test]
642    fn test_throughput_formula() {
643        // 1 MiB in 1 second → 1.0 MB/s
644        // bytes = 1_048_576, duration_us = 1_000_000
645        // result = 1_048_576 / 1_000_000 = 1.048576 MB/s
646        let mbps = StorageBenchmark::compute_throughput(1_048_576, 1_000_000);
647        assert!((mbps - 1.048576).abs() < 1e-6, "got {mbps}");
648    }
649
650    // ── 17. compute_throughput zero duration ─────────────────────────────────
651
652    #[test]
653    fn test_throughput_zero_duration() {
654        assert_eq!(StorageBenchmark::compute_throughput(1_000_000, 0), 0.0);
655    }
656
657    // ── 18. compute_result with known latency data ───────────────────────────
658
659    #[test]
660    fn test_compute_result_known_data() {
661        let mut bench = default_bench();
662        // 5 successful ops, each 4096 bytes, latencies 10–50 µs
663        for i in 1u64..=5 {
664            bench.record_op(BenchmarkOp::Write, i * 10, 4096, true);
665        }
666        // latencies: [10, 20, 30, 40, 50]
667        let result = bench.compute_result(1_000_000);
668
669        assert_eq!(result.total_ops, 5);
670        assert_eq!(result.successful_ops, 5);
671        assert_eq!(result.failed_ops, 0);
672        assert_eq!(result.total_bytes, 5 * 4096);
673        assert_eq!(result.latency_min_us, 10);
674        assert_eq!(result.latency_max_us, 50);
675        // p50 of [10,20,30,40,50]: ceil(0.5*5)=3rd → 30
676        assert_eq!(result.latency_p50_us, 30);
677    }
678
679    // ── 19. compute_result all-failure case ──────────────────────────────────
680
681    #[test]
682    fn test_compute_result_all_failures() {
683        let mut bench = default_bench();
684        for _ in 0..10 {
685            bench.record_op(BenchmarkOp::Write, 500, 4096, false);
686        }
687        let result = bench.compute_result(500_000);
688        assert_eq!(result.successful_ops, 0);
689        assert_eq!(result.failed_ops, 10);
690        assert_eq!(result.total_bytes, 0);
691        // throughput should be 0 because no bytes were transferred
692        assert_eq!(result.throughput_mbps, 0.0);
693    }
694
695    // ── 20. compute_result zero duration ────────────────────────────────────
696
697    #[test]
698    fn test_compute_result_zero_duration() {
699        let mut bench = default_bench();
700        bench.record_op(BenchmarkOp::Write, 100, 4096, true);
701        let result = bench.compute_result(0);
702        assert_eq!(result.throughput_mbps, 0.0);
703    }
704
705    // ── 21. compute_result empty sample set ──────────────────────────────────
706
707    #[test]
708    fn test_compute_result_empty() {
709        let bench = default_bench();
710        let result = bench.compute_result(1_000_000);
711        assert_eq!(result.total_ops, 0);
712        assert_eq!(result.successful_ops, 0);
713        assert_eq!(result.total_bytes, 0);
714        assert_eq!(result.latency_min_us, 0);
715        assert_eq!(result.latency_max_us, 0);
716    }
717
718    // ── 22. mixed op results tracked correctly ───────────────────────────────
719
720    #[test]
721    fn test_mixed_op_results() {
722        let mut cfg = BenchmarkConfig::default_mixed();
723        cfg.seed = 1;
724        let mut bench = StorageBenchmark::new(cfg);
725
726        bench.record_op(BenchmarkOp::Write, 80, 4096, true);
727        bench.record_op(BenchmarkOp::Read, 40, 4096, true);
728        bench.record_op(BenchmarkOp::Delete, 20, 0, true);
729        bench.record_op(BenchmarkOp::Write, 200, 4096, false);
730
731        let result = bench.compute_result(1_000_000);
732        assert_eq!(result.total_ops, 4);
733        assert_eq!(result.successful_ops, 3);
734        assert_eq!(result.failed_ops, 1);
735        // bytes from successful ops only: 4096 + 4096 + 0 = 8192
736        assert_eq!(result.total_bytes, 8192);
737    }
738
739    // ── 23. large sample set (1000 ops) ─────────────────────────────────────
740
741    #[test]
742    fn test_large_sample_set() {
743        let mut bench = bench_with_seed(12345);
744        let mut state = 12345u64;
745        for _ in 0..1000 {
746            state = xorshift64(state);
747            let latency = (state % 10_000) + 1; // 1..=10000 µs
748            bench.record_op(BenchmarkOp::Write, latency, 4096, true);
749        }
750        let result = bench.compute_result(5_000_000);
751
752        assert_eq!(result.total_ops, 1000);
753        assert_eq!(result.successful_ops, 1000);
754        assert!(result.latency_p50_us > 0);
755        assert!(result.latency_p95_us >= result.latency_p50_us);
756        assert!(result.latency_p99_us >= result.latency_p95_us);
757        assert!(result.latency_max_us >= result.latency_p99_us);
758        assert!(result.latency_min_us <= result.latency_p50_us);
759    }
760
761    // ── 24. warmup ops exclusion concept ─────────────────────────────────────
762
763    #[test]
764    fn test_warmup_ops_not_in_samples() {
765        // Concept: callers perform warmup_ops iterations WITHOUT calling
766        // record_op, then record only the measured ops.  We verify that
767        // the result only reflects the explicitly recorded samples.
768        let mut cfg = BenchmarkConfig::default_write();
769        cfg.warmup_ops = 10;
770        cfg.num_operations = 5;
771        let mut bench = StorageBenchmark::new(cfg);
772
773        // Only record the measured ops (warmup is the caller's responsibility)
774        for _ in 0..5 {
775            bench.record_op(BenchmarkOp::Write, 100, 4096, true);
776        }
777
778        let result = bench.compute_result(500_000);
779        // Must equal num_operations, not num_operations + warmup_ops
780        assert_eq!(result.total_ops, 5);
781    }
782
783    // ── 25. format_result contains key fields ────────────────────────────────
784
785    #[test]
786    fn test_format_result_contains_key_info() {
787        let mut bench = default_bench();
788        for i in 1u64..=4 {
789            bench.record_op(BenchmarkOp::Write, i * 25, 4096, true);
790        }
791        let result = bench.compute_result(1_000_000);
792        let s = StorageBenchmark::format_result(&result);
793
794        assert!(s.contains("Write"), "op type missing");
795        assert!(s.contains("Throughput"), "throughput label missing");
796        assert!(s.contains("Latency"), "latency label missing");
797        assert!(s.contains("p50"), "p50 label missing");
798        assert!(s.contains("p95"), "p95 label missing");
799        assert!(s.contains("p99"), "p99 label missing");
800        assert!(s.contains("Operations"), "operations label missing");
801    }
802
803    // ── 26. BenchmarkStats.record accumulates correctly ──────────────────────
804
805    #[test]
806    fn test_benchmark_stats_record() {
807        let mut stats = BenchmarkStats::default();
808        assert_eq!(stats.runs, 0);
809
810        let mut bench = default_bench();
811        bench.record_op(BenchmarkOp::Write, 100, 4096, true);
812        bench.record_op(BenchmarkOp::Write, 200, 4096, false);
813        let r1 = bench.compute_result(1_000_000);
814        stats.record(&r1);
815
816        assert_eq!(stats.runs, 1);
817        assert_eq!(stats.total_ops, 2);
818        // only successful bytes: 4096
819        assert_eq!(stats.total_bytes, 4096);
820    }
821
822    // ── 27. BenchmarkStats avg helpers ───────────────────────────────────────
823
824    #[test]
825    fn test_benchmark_stats_averages() {
826        let mut stats = BenchmarkStats::default();
827
828        let mut bench = default_bench();
829        bench.record_op(BenchmarkOp::Write, 100, 4096, true);
830        let r = bench.compute_result(1_000_000);
831        stats.record(&r);
832
833        bench.reset();
834        bench.record_op(BenchmarkOp::Write, 100, 4096, true);
835        bench.record_op(BenchmarkOp::Write, 100, 4096, true);
836        let r2 = bench.compute_result(1_000_000);
837        stats.record(&r2);
838
839        // Two runs: 1 op and 2 ops → avg 1.5
840        assert!((stats.avg_ops_per_run() - 1.5).abs() < f64::EPSILON);
841    }
842
843    // ── 28. BenchmarkStats avg with zero runs ────────────────────────────────
844
845    #[test]
846    fn test_benchmark_stats_avg_zero_runs() {
847        let stats = BenchmarkStats::default();
848        assert_eq!(stats.avg_ops_per_run(), 0.0);
849        assert_eq!(stats.avg_bytes_per_run(), 0.0);
850    }
851
852    // ── 29. generate_block zero bytes ────────────────────────────────────────
853
854    #[test]
855    fn test_generate_block_zero_bytes() {
856        let mut bench = bench_with_seed(1);
857        let block = bench.generate_block(0);
858        assert!(block.is_empty());
859    }
860
861    // ── 30. BenchmarkOp Display ──────────────────────────────────────────────
862
863    #[test]
864    fn test_benchmark_op_display() {
865        assert_eq!(format!("{}", BenchmarkOp::Write), "Write");
866        assert_eq!(format!("{}", BenchmarkOp::Read), "Read");
867        assert_eq!(format!("{}", BenchmarkOp::Delete), "Delete");
868        assert_eq!(format!("{}", BenchmarkOp::Mixed), "Mixed");
869    }
870}