Skip to main content

dynamo_mocker/loadgen/
arrival.rs

1// SPDX-FileCopyrightText: Copyright (c) 2025-2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0
3
4use anyhow::{Result, bail};
5use rand::rngs::StdRng;
6use rand::{Rng, SeedableRng};
7
8use super::types::ArrivalSpec;
9
10impl ArrivalSpec {
11    pub fn timestamps(&self, request_count: usize, seed: u64) -> Result<Vec<f64>> {
12        let mean_gap_ms = self.mean_gap_ms()?;
13        let mut rng = StdRng::seed_from_u64(seed);
14        let mut timestamps = Vec::with_capacity(request_count);
15        let mut next_arrival_ms = 0.0;
16
17        for request_idx in 0..request_count {
18            if request_idx > 0 {
19                next_arrival_ms += self.sample_gap_ms(mean_gap_ms, &mut rng)?;
20            }
21            timestamps.push(next_arrival_ms);
22        }
23
24        Ok(timestamps)
25    }
26
27    fn mean_gap_ms(&self) -> Result<f64> {
28        match self {
29            Self::Burst => Ok(0.0),
30            Self::ConstantQps { qps } | Self::PoissonQps { qps } | Self::GammaQps { qps, .. } => {
31                if !qps.is_finite() || *qps <= 0.0 {
32                    bail!("qps must be a finite positive number, got {qps}");
33                }
34                Ok(1000.0 / qps)
35            }
36        }
37    }
38
39    fn sample_gap_ms(&self, mean_gap_ms: f64, rng: &mut StdRng) -> Result<f64> {
40        match self {
41            Self::Burst => Ok(0.0),
42            Self::ConstantQps { .. } => Ok(mean_gap_ms),
43            Self::PoissonQps { .. } => Ok(sample_exponential_ms(mean_gap_ms, rng)),
44            Self::GammaQps { smoothness, .. } => {
45                if !smoothness.is_finite() || *smoothness <= 0.0 {
46                    bail!("gamma smoothness must be a finite positive number, got {smoothness}");
47                }
48                Ok(sample_gamma_ms(*smoothness, mean_gap_ms / smoothness, rng))
49            }
50        }
51    }
52}
53
54fn sample_exponential_ms(mean_ms: f64, rng: &mut StdRng) -> f64 {
55    if mean_ms == 0.0 {
56        return 0.0;
57    }
58    let u = (1.0 - rng.random::<f64>()).clamp(f64::MIN_POSITIVE, 1.0);
59    -mean_ms * u.ln()
60}
61
62fn sample_gamma_ms(shape: f64, scale: f64, rng: &mut StdRng) -> f64 {
63    if scale == 0.0 {
64        return 0.0;
65    }
66    if shape < 1.0 {
67        let u = (1.0 - rng.random::<f64>()).clamp(f64::MIN_POSITIVE, 1.0);
68        return sample_gamma_ms(shape + 1.0, scale, rng) * u.powf(1.0 / shape);
69    }
70
71    let d = shape - 1.0 / 3.0;
72    let c = (1.0 / (9.0 * d)).sqrt();
73    loop {
74        let u1 = (1.0 - rng.random::<f64>()).clamp(f64::MIN_POSITIVE, 1.0);
75        let u2 = rng.random::<f64>();
76        let z = (-2.0 * u1.ln()).sqrt() * (std::f64::consts::TAU * u2).cos();
77        let v = (1.0 + c * z).powi(3);
78        if v <= 0.0 {
79            continue;
80        }
81        let u = rng.random::<f64>();
82        if u < 1.0 - 0.0331 * z.powi(4) {
83            return d * v * scale;
84        }
85        if u.ln() < 0.5 * z * z + d * (1.0 - v + v.ln()) {
86            return d * v * scale;
87        }
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    use super::*;
94
95    fn gap_moments(timestamps: &[f64]) -> (f64, f64) {
96        let gaps = timestamps
97            .windows(2)
98            .map(|values| values[1] - values[0])
99            .collect::<Vec<_>>();
100        assert!(gaps.iter().all(|gap| *gap > 0.0));
101
102        let mean = gaps.iter().sum::<f64>() / gaps.len() as f64;
103        let variance = gaps
104            .iter()
105            .map(|gap| {
106                let delta = gap - mean;
107                delta * delta
108            })
109            .sum::<f64>()
110            / gaps.len() as f64;
111        (mean, variance.sqrt())
112    }
113
114    #[test]
115    fn fixed_schedule_has_exact_cadence() {
116        assert_eq!(ArrivalSpec::Burst.timestamps(4, 17).unwrap(), vec![0.0; 4]);
117        let fixed = ArrivalSpec::ConstantQps { qps: 10.0 }
118            .timestamps(4, 17)
119            .unwrap();
120        assert_eq!(fixed, vec![0.0, 100.0, 200.0, 300.0]);
121    }
122
123    #[test]
124    fn poisson_schedule_is_seeded_and_matches_exponential_moments() {
125        let poisson = ArrivalSpec::PoissonQps { qps: 10.0 }
126            .timestamps(100_001, 17)
127            .unwrap();
128        let repeated = ArrivalSpec::PoissonQps { qps: 10.0 }
129            .timestamps(100_001, 17)
130            .unwrap();
131        assert_eq!(poisson, repeated);
132
133        let (mean_gap_ms, stddev_gap_ms) = gap_moments(&poisson);
134        assert!(
135            (mean_gap_ms - 100.0).abs() < 1.0,
136            "expected a 100ms mean gap, got {mean_gap_ms}"
137        );
138        assert!(
139            (stddev_gap_ms - 100.0).abs() < 2.0,
140            "expected a 100ms gap standard deviation, got {stddev_gap_ms}"
141        );
142    }
143
144    #[test]
145    fn gamma_schedule_is_seeded_and_matches_requested_moments() {
146        let spec = ArrivalSpec::GammaQps {
147            qps: 20.0,
148            smoothness: 4.0,
149        };
150        let timestamps = spec.timestamps(100_001, 23).unwrap();
151        assert_eq!(timestamps, spec.timestamps(100_001, 23).unwrap());
152
153        let (mean_gap_ms, stddev_gap_ms) = gap_moments(&timestamps);
154        assert!(
155            (mean_gap_ms - 50.0).abs() < 0.5,
156            "expected a 50ms mean gap, got {mean_gap_ms}"
157        );
158        assert!(
159            (stddev_gap_ms - 25.0).abs() < 0.5,
160            "expected a 25ms gap standard deviation, got {stddev_gap_ms}"
161        );
162    }
163
164    #[test]
165    fn arrival_parameters_are_validated_before_sampling() {
166        for qps in [0.0, -1.0, f64::NAN, f64::INFINITY] {
167            assert!(
168                ArrivalSpec::PoissonQps { qps }
169                    .timestamps(2, 42)
170                    .unwrap_err()
171                    .to_string()
172                    .contains("qps must be")
173            );
174        }
175        for smoothness in [0.0, -1.0, f64::NAN, f64::INFINITY] {
176            assert!(
177                ArrivalSpec::GammaQps {
178                    qps: 1.0,
179                    smoothness,
180                }
181                .timestamps(2, 42)
182                .unwrap_err()
183                .to_string()
184                .contains("gamma smoothness")
185            );
186        }
187    }
188}