Skip to main content

tract_libcli/
profile_autobatch.rs

1//! Benching a model which serves several sessions at once: a saturating load,
2//! a paced one, and what a capacity search reports of either.
3//!
4//! Split from [`super::profile`] because none of it works without threads, so
5//! the whole module is gated off on Wasm rather than every item in it.
6
7use std::sync::Arc;
8use std::time::{Duration, Instant};
9
10use tract_core::internal::*;
11use tract_core::num_traits::Zero;
12use tract_core::runtime::Runnable;
13
14use crate::profile::{BenchLimits, reusable_state};
15use crate::tensor::RunTensors;
16
17impl BenchLimits {
18    /// One saturating loop per stream: `streams` threads, a state each, every one
19    /// of them feeding turns as fast as it can until the limits run out. The
20    /// limits are per stream, the wall clock is shared.
21    ///
22    /// This is what an autobatch runtime is compared against: the same load offered
23    /// to the plain runtime is `streams` independent states running side by
24    /// side.
25    pub fn bench_streams(
26        &self,
27        runnable: &Arc<dyn Runnable>,
28        inputs: &RunTensors,
29        streams: usize,
30    ) -> TractResult<StreamsBench> {
31        ensure!(streams > 0, "A saturating bench needs a stream at least");
32        let reuse = reusable_state(runnable);
33        let max_loops = if self.max_loops.is_zero() { usize::MAX } else { self.max_loops };
34        let max_time = if self.max_time.is_zero() { Duration::MAX } else { self.max_time };
35        let start = Instant::now();
36        let turns: Vec<Vec<Duration>> = std::thread::scope(|scope| {
37            let running: Vec<_> = (0..streams)
38                .map(|_| {
39                    scope.spawn(|| -> TractResult<Vec<Duration>> {
40                        let mut state = runnable.spawn()?;
41                        let mut turns = vec![];
42                        while turns.len() < max_loops && start.elapsed() < max_time {
43                            if !reuse {
44                                state = runnable.spawn()?;
45                            }
46                            let turn = Instant::now();
47                            state.run(inputs.sources[0].clone())?;
48                            turns.push(turn.elapsed());
49                        }
50                        Ok(turns)
51                    })
52                })
53                .collect();
54            running.into_iter().map(|stream| stream.join().unwrap()).collect::<Vec<_>>()
55        })
56        .into_iter()
57        .collect::<TractResult<_>>()?;
58        let wall = start.elapsed();
59        let mut latencies: Vec<Duration> = turns.into_iter().flatten().collect();
60        latencies.sort();
61        Ok(StreamsBench { streams, wall, latencies })
62    }
63
64    /// One paced loop per stream: `streams` threads, a state each, every one of
65    /// them owing a turn every period of the wall clock, the way a live stream
66    /// does. Turn `n` of a stream is due at a fixed offset from its first, so a
67    /// turn which runs long does not push the ones behind it back -- the stream
68    /// catches up or falls further behind, and `added` is what says which.
69    ///
70    /// Streams are phased evenly over one period. Each session runs one turn
71    /// before the clock starts, so the buffers a first turn allocates are not
72    /// charged to the load.
73    ///
74    /// Under `churn` a stream is a seat in a steady population rather than one
75    /// session: it holds a session for a draw around `hold`, gives it up, and
76    /// admits another in its place, so the lanes turn over while the load does
77    /// not.
78    pub fn bench_streams_paced(
79        &self,
80        runnable: &Arc<dyn Runnable>,
81        inputs: &RunTensors,
82        streams: usize,
83        pacing: &Pacing,
84    ) -> TractResult<PacedBench> {
85        use rand::SeedableRng;
86        ensure!(streams > 0, "A paced bench needs a stream at least");
87        ensure!(!pacing.period.is_zero(), "A paced bench needs a turn period");
88        let max_loops = if self.max_loops.is_zero() { usize::MAX } else { self.max_loops };
89        let max_time = if self.max_time.is_zero() { Duration::MAX } else { self.max_time };
90        ensure!(
91            max_time >= 4 * pacing.period,
92            "A turn period of {:?} wants four periods of run at least, --max-time gives {max_time:?}",
93            pacing.period
94        );
95        let reuse = reusable_state(runnable);
96        ensure!(
97            pacing.churn.is_none() || reuse,
98            "Churn ends a session and admits another in its place, which only means something for a model carrying a session across turns"
99        );
100        let start = Instant::now();
101        let served: Vec<StreamTurns> = std::thread::scope(|scope| {
102            let running: Vec<_> = (0..streams)
103                .map(|stream| {
104                    scope.spawn(move || -> TractResult<StreamTurns> {
105                        let mut rng = rand::rngs::StdRng::seed_from_u64(stream as u64);
106                        let phase = pacing.period.mul_f64(stream as f64 / streams as f64);
107                        let mut turns = StreamTurns::default();
108                        // The seat's slots in the period, one turn owed at each.
109                        // Laid down once the first session has run its pre-clock
110                        // turn -- 144 of those serialize through the worker, and
111                        // a grid laid before them starts every stream already
112                        // behind -- and never re-phased, so churn costs what
113                        // admitting a session costs rather than what bunching
114                        // every arrival would.
115                        let mut grid: Option<Instant> = None;
116                        let mut slot = 0usize;
117                        while turns.service.len() < max_loops && start.elapsed() < max_time {
118                            let (mut state, starved, took) = admit(runnable, &start, max_time)?;
119                            turns.admissions += 1;
120                            turns.starved += usize::from(starved);
121                            turns.admitting.push(took);
122                            // A session's first turn allocates its buffers, and
123                            // runs before its clock starts, so the load is not
124                            // charged for it.
125                            state.run(inputs.sources[0].clone())?;
126                            let admitted = Instant::now();
127                            let grid = *grid.get_or_insert(admitted + phase);
128                            // A session serves the seat's slots which fall in
129                            // its own life, starting at the first one at or
130                            // after it arrived: a stream's clock starts when it
131                            // does, so a session does not inherit what the seat
132                            // was owed before it.
133                            if let Some(late) = admitted.checked_duration_since(grid) {
134                                let missed = late.div_duration_f64(pacing.period).ceil();
135                                slot = slot.max(missed as usize);
136                            }
137                            let hold = pacing
138                                .churn
139                                .map(|churn| exponential(&mut rng, churn.hold).max(pacing.period));
140                            while turns.service.len() < max_loops && start.elapsed() < max_time {
141                                if hold.is_some_and(|hold| admitted.elapsed() >= hold) {
142                                    break;
143                                }
144                                if !reuse {
145                                    state = runnable.spawn()?;
146                                }
147                                let due = grid + pacing.period * slot as u32;
148                                slot += 1;
149                                if let Some(wait) = due.checked_duration_since(Instant::now()) {
150                                    std::thread::sleep(wait);
151                                }
152                                let turn = Instant::now();
153                                state.run(inputs.sources[0].clone())?;
154                                let done = Instant::now();
155                                turns.service.push(done - turn);
156                                turns.added.push(done.saturating_duration_since(due));
157                                turns.span = done - grid;
158                            }
159                        }
160                        Ok(turns)
161                    })
162                })
163                .collect();
164            running.into_iter().map(|stream| stream.join().unwrap()).collect::<Vec<_>>()
165        })
166        .into_iter()
167        .collect::<TractResult<_>>()?;
168        let wall = served.iter().map(|turns| turns.span).max().unwrap_or_default();
169        let admissions = served.iter().map(|turns| turns.admissions).sum();
170        let starved = served.iter().map(|turns| turns.starved).sum();
171        let mut admitting: Vec<Duration> =
172            served.iter().flat_map(|turns| turns.admitting.iter().copied()).collect();
173        admitting.sort();
174        let mut service: Vec<Duration> =
175            served.iter().flat_map(|turns| turns.service.iter().copied()).collect();
176        let mut added: Vec<Duration> = served.into_iter().flat_map(|turns| turns.added).collect();
177        service.sort();
178        added.sort();
179        Ok(PacedBench {
180            streams,
181            deadline: pacing.deadline,
182            wall,
183            service,
184            added,
185            admissions,
186            starved,
187            admitting,
188        })
189    }
190}
191
192/// Take a session, retrying while every lane is held. An autobatch runtime hands a
193/// lane back through its worker's queue, so a session leaving and another
194/// arriving in its place can find none free for a moment: that is the starve
195/// count, and how long it waited shows up as the new session's first turns
196/// being late.
197fn admit(
198    runnable: &Arc<dyn Runnable>,
199    start: &Instant,
200    max_time: Duration,
201) -> TractResult<(Box<dyn State>, bool, Duration)> {
202    let mut starved = false;
203    let asked = Instant::now();
204    loop {
205        match runnable.spawn() {
206            Ok(state) => return Ok((state, starved, asked.elapsed())),
207            Err(e) => {
208                if start.elapsed() >= max_time {
209                    return Err(e).context("Admitting a session");
210                }
211                starved = true;
212                std::thread::sleep(Duration::from_micros(200));
213            }
214        }
215    }
216}
217
218/// A holding time around `mean`, so sessions do not all leave together.
219fn exponential(rng: &mut impl rand::RngExt, mean: Duration) -> Duration {
220    let uniform: f64 = rng.random::<f64>();
221    mean.mul_f64(-(1. - uniform).max(f64::MIN_POSITIVE).ln())
222}
223
224/// The wall clock a paced load runs against: what a turn's input covers, and
225/// how much a turn may add over its arrival before it counts as late.
226#[derive(Clone, Copy, Debug)]
227pub struct Pacing {
228    pub period: Duration,
229    pub deadline: Duration,
230    pub churn: Option<Churn>,
231}
232
233/// Sessions coming and going under a steady population: how long one is held
234/// before it leaves and another is admitted in its place.
235#[derive(Clone, Copy, Debug)]
236pub struct Churn {
237    pub hold: Duration,
238}
239
240/// One stream's turns, as it served them.
241#[derive(Default)]
242struct StreamTurns {
243    service: Vec<Duration>,
244    added: Vec<Duration>,
245    span: Duration,
246    admissions: usize,
247    starved: usize,
248    admitting: Vec<Duration>,
249}
250
251/// What `streams` paced streams did against that clock: how long each turn took
252/// to run, and how late each one completed against the moment its input
253/// arrived. The second is what a real-time load is judged on -- it holds the
254/// queueing a turn waited through as well as its own run.
255pub struct PacedBench {
256    pub streams: usize,
257    pub deadline: Duration,
258    pub wall: Duration,
259    /// One entry per turn, over every stream, sorted.
260    pub service: Vec<Duration>,
261    /// One entry per turn, over every stream, sorted.
262    pub added: Vec<Duration>,
263    /// Sessions admitted over the trial: one per stream without churn, and
264    /// however many the holding time turned over with it.
265    pub admissions: usize,
266    /// Admissions which found every lane held and had to wait for one.
267    pub starved: usize,
268    /// What each admission waited between asking for a session and getting one,
269    /// sorted. An autobatch runtime answers on its worker, between turns, so this
270    /// holds the turn in flight as well as the lane's own reset -- it is the
271    /// wait, not the cost of the reset. Read it at a quantile: every seat's
272    /// first admission lands in one storm at the start of the trial, so a mean
273    /// over a short trial is mostly that storm.
274    pub admitting: Vec<Duration>,
275}
276
277impl PacedBench {
278    /// Sessions admitted per second: how fast the lanes turn over.
279    pub fn admissions_per_second(&self) -> f64 {
280        self.admissions as f64 / self.wall.as_secs_f64()
281    }
282
283    /// The wait `q` of the admissions came in under, `q` in 0..1.
284    pub fn admission_quantile(&self, q: f64) -> Duration {
285        quantile(&self.admitting, q)
286    }
287
288    /// The share of admissions which waited for a lane.
289    pub fn starve_fraction(&self) -> f64 {
290        if self.admissions == 0 {
291            return 0.;
292        }
293        self.starved as f64 / self.admissions as f64
294    }
295
296    pub fn turns(&self) -> usize {
297        self.service.len()
298    }
299
300    pub fn turns_per_second(&self) -> f64 {
301        self.turns() as f64 / self.wall.as_secs_f64()
302    }
303
304    pub fn mean_service(&self) -> Duration {
305        self.service.iter().sum::<Duration>().checked_div(self.turns() as u32).unwrap_or_default()
306    }
307
308    /// The added latency `q` of the turns came in under, `q` in 0..1.
309    pub fn added_quantile(&self, q: f64) -> Duration {
310        quantile(&self.added, q)
311    }
312
313    /// The share of turns which completed past the deadline.
314    pub fn late_fraction(&self) -> f64 {
315        if self.added.is_empty() {
316            return 0.;
317        }
318        let in_time = self.added.partition_point(|added| *added <= self.deadline);
319        (self.turns() - in_time) as f64 / self.turns() as f64
320    }
321
322    /// Whether this load held: the `q`th added latency met the deadline. A load
323    /// which served nothing never holds.
324    pub fn holds(&self, q: f64) -> bool {
325        !self.added.is_empty() && self.added_quantile(q) <= self.deadline
326    }
327}
328
329/// The value `q` of a sorted series comes in under, `q` in 0..1.
330fn quantile(sorted: &[Duration], q: f64) -> Duration {
331    if sorted.is_empty() {
332        return Duration::default();
333    }
334    sorted[((sorted.len() - 1) as f64 * q).round() as usize]
335}
336
337/// What `streams` saturating streams did: how long they ran for, and how long
338/// every one of their turns took.
339pub struct StreamsBench {
340    pub streams: usize,
341    pub wall: Duration,
342    /// One entry per turn, over every stream, sorted.
343    pub latencies: Vec<Duration>,
344}
345
346impl StreamsBench {
347    pub fn turns(&self) -> usize {
348        self.latencies.len()
349    }
350
351    /// Turns served per second, all streams together: the number an autobatch
352    /// runtime has to beat.
353    pub fn turns_per_second(&self) -> f64 {
354        self.turns() as f64 / self.wall.as_secs_f64()
355    }
356
357    pub fn mean_latency(&self) -> Duration {
358        self.latencies.iter().sum::<Duration>().checked_div(self.turns() as u32).unwrap_or_default()
359    }
360
361    /// The latency `q` of the turns came in under, `q` in 0..1.
362    pub fn latency_quantile(&self, q: f64) -> Duration {
363        quantile(&self.latencies, q)
364    }
365}