Skip to main content

sound_to_patch/render/
timing.rs

1//! Render and load timing, the table refine sets its budget from.
2//!
3//! It holds how long a plug-in load, a state load and one 4 s render
4//! take, and how many renders per second the session gives with 1, 2, 4
5//! and 8 workers.
6//!
7//! The table measures one machine, so it stays a local file. The
8//! repository holds the method and no numbers.
9
10use std::num::NonZeroUsize;
11use std::sync::Arc;
12use std::time::Instant;
13
14use serde::{Deserialize, Serialize};
15
16use super::session::{BoxedRenderer, RenderOutcome, RenderSession};
17use super::{HostIdentity, Material, PresetState, RenderError, RenderJob};
18use crate::cast;
19use crate::dsp::quantile;
20use crate::units::Bpm;
21
22/// The worker counts of the throughput rows.
23pub const WORKER_COUNTS: [usize; 4] = [1, 2, 4, 8];
24/// The tempo of the timed renders.
25const BPM: f64 = 120.0;
26
27/// What a row measures.
28#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
29#[serde(rename_all = "snake_case")]
30pub enum TimingKind {
31    /// Starting a renderer: the host process and the plug-in load, in s.
32    PluginLoad,
33    /// Loading a preset state into the plug-in, in s, as the host
34    /// measures it.
35    StateLoad,
36    /// One render of the recipe's lead note, held 3 s in a clip of at
37    /// least 4 s, state load and pre-roll included, in s.
38    Render,
39    /// The length of the timed clip, in s: 4 s, or longer while the tail
40    /// sounds.
41    ClipLength,
42    /// Renders per second of a session.
43    RendersPerSecond,
44}
45
46/// One row of the table: the median and the 95th percentile of `runs`
47/// measurements.
48///
49/// For [`TimingKind::RendersPerSecond`] each measurement is one batch of
50/// one job per worker, and the rate is the jobs over the batch time. Its
51/// `p95` is the rate at the 95th percentile of the batch time, the slow
52/// end.
53#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
54pub struct TimingRow {
55    /// What the row measures.
56    pub kind: TimingKind,
57    /// The workers of a throughput row.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub workers: Option<usize>,
60    /// The median.
61    pub median: f64,
62    /// The 95th percentile, linear between the two nearest measurements.
63    pub p95: f64,
64}
65
66/// The timing of one synth build on one machine.
67#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
68pub struct TimingTable {
69    /// What rendered: the synth, its build, the host and the architecture.
70    pub identity: HostIdentity,
71    /// The recipe version of the renders.
72    pub recipe_version: u32,
73    /// How many measurements each row holds.
74    pub runs: usize,
75    /// The rows.
76    pub rows: Vec<TimingRow>,
77}
78
79/// Measures every row `runs` times with renderers that `spawn` starts.
80///
81/// The renders play the recipe's lead note with `state`. The throughput
82/// rows start every worker before they time a batch, so no batch waits for
83/// a plug-in load.
84///
85/// # Errors
86///
87/// Returns the [`RenderError`] of a renderer that does not start or a
88/// render that fails, and [`RenderError::Host`] for a backend that does
89/// not measure its state loads.
90pub fn measure(
91    spawn: impl Fn(usize) -> Result<BoxedRenderer, RenderError> + Send + Sync + 'static,
92    state: &PresetState,
93    runs: NonZeroUsize,
94) -> Result<TimingTable, RenderError> {
95    let runs = runs.get();
96    let spawn = Arc::new(spawn);
97    let job = RenderJob::for_material(state.clone(), Material::Lead, Bpm::from_const(BPM));
98    let mut loads = Vec::with_capacity(runs);
99    let mut identity = None;
100    for _ in 0..runs {
101        let start = Instant::now();
102        let renderer = spawn(0)?;
103        loads.push(start.elapsed().as_secs_f64());
104        identity.get_or_insert_with(|| renderer.identity());
105    }
106    let mut renderer = spawn(0)?;
107    let mut states = Vec::with_capacity(runs);
108    let mut renders = Vec::with_capacity(runs);
109    let mut clips = Vec::with_capacity(runs);
110    for _ in 0..runs {
111        let start = Instant::now();
112        let clip = renderer.render(&job)?;
113        renders.push(start.elapsed().as_secs_f64());
114        clips.push(clip.record.length.get());
115        let load = renderer.last_state_load().ok_or_else(|| {
116            timing_error(
117                "the renderer does not measure its state loads",
118                "Time the plug-in backend or the fake",
119            )
120        })?;
121        states.push(load.get());
122    }
123    drop(renderer);
124    let mut rows = single_rows(&Samples {
125        loads: &loads,
126        states: &states,
127        renders: &renders,
128        clips: &clips,
129    })
130    .to_vec();
131    for workers in WORKER_COUNTS {
132        let count = NonZeroUsize::new(workers).unwrap_or(NonZeroUsize::MIN);
133        let session = RenderSession::new(count, {
134            let spawn = Arc::clone(&spawn);
135            move |worker| spawn(worker)
136        });
137        session.start_all()?;
138        let batch = vec![job.clone(); workers];
139        let mut times = Vec::with_capacity(runs);
140        for _ in 0..runs {
141            let start = Instant::now();
142            let outcomes = session.render_all(&batch);
143            times.push(start.elapsed().as_secs_f64());
144            if let Some(RenderOutcome::Failed(failure)) = outcomes
145                .into_iter()
146                .find(|outcome| matches!(outcome, RenderOutcome::Failed(_)))
147            {
148                return Err(failure
149                    .errors
150                    .into_iter()
151                    .last()
152                    .unwrap_or_else(|| timing_error("a render failed", "Time it again")));
153            }
154        }
155        rows.push(rate_row(workers, &times));
156    }
157    Ok(TimingTable {
158        identity: identity.ok_or_else(|| timing_error("no renderer started", "Time it again"))?,
159        recipe_version: job.recipe().version(),
160        runs,
161        rows,
162    })
163}
164
165/// The measurements behind the four rows of a single worker.
166struct Samples<'a> {
167    loads: &'a [f64],
168    states: &'a [f64],
169    renders: &'a [f64],
170    clips: &'a [f64],
171}
172
173fn single_rows(samples: &Samples) -> [TimingRow; 4] {
174    [
175        row(TimingKind::PluginLoad, None, samples.loads),
176        row(TimingKind::StateLoad, None, samples.states),
177        row(TimingKind::Render, None, samples.renders),
178        row(TimingKind::ClipLength, None, samples.clips),
179    ]
180}
181
182/// The throughput row of `workers` workers from the time of each batch, in
183/// s. A batch holds one job per worker.
184fn rate_row(workers: usize, batch_seconds: &[f64]) -> TimingRow {
185    let time = row(TimingKind::RendersPerSecond, Some(workers), batch_seconds);
186    let rate = |seconds: f64| cast::usize_to_f64(workers) / seconds;
187    TimingRow {
188        median: rate(time.median),
189        p95: rate(time.p95),
190        ..time
191    }
192}
193
194fn row(kind: TimingKind, workers: Option<usize>, values: &[f64]) -> TimingRow {
195    let mut sorted = values.to_vec();
196    sorted.sort_by(f64::total_cmp);
197    TimingRow {
198        kind,
199        workers,
200        median: quantile(&sorted, 0.5).unwrap_or(f64::NAN),
201        p95: quantile(&sorted, 0.95).unwrap_or(f64::NAN),
202    }
203}
204
205fn timing_error(cause: impl Into<String>, fix: &str) -> RenderError {
206    RenderError::Host {
207        cause: cause.into(),
208        fix: fix.to_owned(),
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn each_single_row_takes_its_own_list() {
218        // Every list has one value, so each row holds exactly that value.
219        let rows = single_rows(&Samples {
220            loads: &[1.0],
221            states: &[2.0],
222            renders: &[3.0],
223            clips: &[4.0],
224        });
225        let seen: Vec<(TimingKind, Option<usize>, f64, f64)> = rows
226            .iter()
227            .map(|row| (row.kind, row.workers, row.median, row.p95))
228            .collect();
229        assert_eq!(
230            seen,
231            [
232                (TimingKind::PluginLoad, None, 1.0, 1.0),
233                (TimingKind::StateLoad, None, 2.0, 2.0),
234                (TimingKind::Render, None, 3.0, 3.0),
235                (TimingKind::ClipLength, None, 4.0, 4.0),
236            ]
237        );
238    }
239
240    #[test]
241    fn a_row_sorts_its_values_before_it_takes_the_quantiles() {
242        let row = row(TimingKind::Render, None, &[3.0, 1.0, 2.0]);
243        // Sorted 1 2 3: the median sits at 2, the 95th percentile at
244        // position 0.95 * 2 = 1.9, so at 2 + 0.9 * (3 - 2).
245        assert_eq!(row.median, 2.0);
246        assert!((row.p95 - 2.9).abs() < 1e-12, "{row:?}");
247    }
248
249    #[test]
250    fn the_rate_is_the_workers_over_the_batch_time() {
251        for workers in WORKER_COUNTS {
252            let row = rate_row(workers, &[0.5]);
253            assert_eq!(row.kind, TimingKind::RendersPerSecond);
254            assert_eq!(row.workers, Some(workers));
255            // One batch of 0.5 s gives workers / 0.5 renders per second.
256            let expected = cast::usize_to_f64(workers) / 0.5;
257            assert_eq!((row.median, row.p95), (expected, expected));
258        }
259    }
260
261    #[test]
262    fn the_p95_rate_comes_from_the_slow_end_of_the_batch_times() {
263        let row = rate_row(4, &[2.0, 1.0, 3.0]);
264        // The median batch takes 2 s and the 95th percentile 2.9 s.
265        assert_eq!(row.median, 4.0 / 2.0);
266        assert!((row.p95 - 4.0 / 2.9).abs() < 1e-12, "{row:?}");
267        assert!(row.p95 < row.median);
268    }
269}