Skip to main content

parallel/
parallel.rs

1//! Splits a block of frames across Bela's render threads and measures
2//! that the work was spread rather than duplicated.
3//!
4//! The load is a bank of sine oscillators evaluated per frame, which is
5//! divisible by frame and heavy enough to see. Each render thread
6//! renders its own share of the block, keeps its own oscillator
7//! phases — seeded per block by `render_pre` so the tone is the same
8//! whatever the split — and records what it did. `cleanup` prints one
9//! line per thread and a summary.
10//!
11//! The number of render threads is this program's own first argument,
12//! defaulting to 4:
13//!
14//! ```sh
15//! ./parallel 1
16//! ./parallel 4
17//! ```
18//!
19//! Comparing the two runs is the measurement. Four things say the work
20//! was divided:
21//!
22//! - every thread reports calls of its own, on a Linux thread id of its
23//!   own, running on a different core;
24//! - `rendered + uncovered` is exactly one block per block: every frame
25//!   was written once or not at all, rather than once per thread;
26//! - `abandoned + unfinished` is at most 1, and 0 in most runs.
27//!   `render_pre` stamps every frame with a sentinel and `render_post`
28//!   counts the ones left — see "The last block" below for the only way
29//!   those are not zero;
30//! - the audio thread's own busy percentage falls as threads are added,
31//!   for the same number of oscillators.
32//!
33//! `faults=0` on the last line is the fourth: not one callback had to
34//! be refused for arriving where the crate could not serve it safely.
35//!
36//! ## The last block
37//!
38//! A stop requested part-way through a block can leave that block
39//! unfinished, in one of two shapes. Both come from libbela's secondary
40//! render threads checking `Bela_stopRequested()` just before they call
41//! `render`, while `render_wrapper` checks the same flag as it waits
42//! for them:
43//!
44//! - the thread checks the flag **before** rendering and bows out.
45//!   `render_post` runs and finds the frames it owned still carrying
46//!   the sentinel: `uncovered=8 abandoned=1` with two threads and a
47//!   block of 16 — one thread's share of one block, exactly.
48//! - the thread is **inside** `render` when the stop lands. The main
49//!   thread gives up waiting and calls `render_post` over the top of
50//!   it, the crate refuses that callback rather than handing out
51//!   references a running `render` already holds, and the block is
52//!   never accounted for: `unfinished=1`.
53//!
54//! Either way `rendered + uncovered` falls short by at most one block,
55//! never over — a frame written twice would push it over — and
56//! `render_post` silences any sentinel that is left rather than
57//! sending a NaN to the codec.
58//!
59//! `faults` stays 0 through both. The first refuses nothing at all, and
60//! the second is refused *while stopping*, which the crate counts apart
61//! from a refusal during a live run for exactly this reason: an
62//! ordinary Ctrl-C must stay an ordinary Ctrl-C. `Bela::until_stopped`
63//! prints a line about it instead of failing.
64//!
65//! Reading the thread id and the core costs two system calls, which
66//! `render` must not normally make; this example makes them on its
67//! first block only, because they are the measurement.
68//!
69//! Cross-compile and run on the board (see docs/cross-compile.md):
70//!
71//! ```sh
72//! cargo build -p bela --release --target aarch64-unknown-linux-gnu --example parallel
73//! ```
74
75#![cfg_attr(
76    not(bela_device),
77    allow(
78        dead_code,
79        reason = "only the fallback main is reachable off-device; the application code should still compile and lint"
80    )
81)]
82
83use core::f32::consts::TAU;
84use core::num::NonZeroU32;
85#[cfg(not(bela_device))]
86use std::process::ExitCode;
87
88use bela::{
89    BelaApplication, BlockContext, CleanupContext, CpuTimer, RenderContext, SetupContext,
90    ThreadInfo, rt_println,
91};
92
93/// Enough oscillators for one thread to be busy and four to be visibly
94/// less so.
95const OSCILLATORS: usize = 192;
96const BASE_FREQUENCY: f32 = 55.0;
97const AMPLITUDE: f32 = 0.2;
98
99/// Render threads to use when the command line does not say.
100const DEFAULT_THREADS: NonZeroU32 =
101    NonZeroU32::new(4).expect("the default thread count is non-zero");
102
103const MEASUREMENTS_PER_CYCLE: u32 = 2000;
104
105/// Written into every frame by `render_pre` and gone by the end of the
106/// block if the threads between them covered it. Not a value any
107/// oscillator sum can produce.
108const UNWRITTEN: f32 = f32::NAN;
109
110const fn cycle() -> NonZeroU32 {
111    NonZeroU32::new(MEASUREMENTS_PER_CYCLE).expect("the cycle length is a non-zero constant")
112}
113
114/// The Linux thread id of the calling thread, or -1 off-device.
115#[cfg(bela_device)]
116fn thread_id() -> i64 {
117    // Safety: gettid takes no arguments and cannot fail.
118    i64::from(unsafe { libc::gettid() })
119}
120
121#[cfg(not(bela_device))]
122const fn thread_id() -> i64 {
123    -1
124}
125
126/// The core the calling thread is running on, or -1 off-device.
127#[cfg(bela_device)]
128fn current_cpu() -> i32 {
129    // Safety: sched_getcpu takes no arguments and reports -1 on
130    // failure, which is the value used for "not known" anyway.
131    unsafe { libc::sched_getcpu() }
132}
133
134#[cfg(not(bela_device))]
135const fn current_cpu() -> i32 {
136    -1
137}
138
139struct Parallel {
140    /// The phases the *block* starts at, advanced once per block.
141    phases: [f32; OSCILLATORS],
142    phase_increments: [f32; OSCILLATORS],
143    /// Blocks that started, counted in `render_pre` — which is the
144    /// callback that cannot be skipped for a block the threads then
145    /// render, so it is what `expected` has to be built from.
146    started: u64,
147    /// Blocks that were also accounted for, in `render_post`.
148    finished: u64,
149    /// Frames no thread wrote, over the whole run.
150    uncovered: u64,
151    /// Blocks that had any: at most the one abandoned on the way out.
152    abandoned: u64,
153}
154
155/// One render thread's oscillator bank and its record of the run.
156struct Voice {
157    thread: usize,
158    /// The frames this thread owns, worked out once and the same for
159    /// every block.
160    first_frame: usize,
161    last_frame: usize,
162    phases: [f32; OSCILLATORS],
163    calls: u64,
164    frames: u64,
165    thread_id: i64,
166    cpu: i32,
167    timer: CpuTimer,
168}
169
170impl Parallel {
171    const fn new() -> Self {
172        Self {
173            phases: [0.0; OSCILLATORS],
174            phase_increments: [0.0; OSCILLATORS],
175            started: 0,
176            finished: 0,
177            uncovered: 0,
178            abandoned: 0,
179        }
180    }
181}
182
183impl BelaApplication for Parallel {
184    type RenderState = Voice;
185
186    fn setup(&mut self, context: &SetupContext) -> bool {
187        let sample_rate = context.audio_sample_rate();
188        for (index, increment) in self.phase_increments.iter_mut().enumerate() {
189            #[allow(
190                clippy::cast_precision_loss,
191                reason = "the oscillator index is far below f32's exact integer range"
192            )]
193            let harmonic = index as f32 + 1.0;
194            *increment = TAU * BASE_FREQUENCY * harmonic / sample_rate;
195        }
196        rt_println!(
197            "parallel: setup threads={} frames={} oscillators={OSCILLATORS} rate={sample_rate}",
198            context.thread_count(),
199            context.audio_frames()
200        );
201        true
202    }
203
204    fn create_render_state(&mut self, thread: ThreadInfo, context: &SetupContext) -> Voice {
205        // The same frames `RenderContext::audio_frame_range` will hand
206        // this thread.
207        let frames = thread.frame_range(context.audio_frames());
208        Voice {
209            thread: thread.index(),
210            first_frame: frames.start,
211            last_frame: frames.end,
212            phases: [0.0; OSCILLATORS],
213            calls: 0,
214            frames: 0,
215            thread_id: -1,
216            cpu: -1,
217            timer: CpuTimer::new(cycle()),
218        }
219    }
220
221    // Real-time safe: arithmetic on values the states already hold,
222    // plus one store per frame of the sentinel.
223    fn render_pre(&mut self, states: &mut [Voice], context: &mut BlockContext) {
224        for state in states.iter_mut() {
225            #[allow(
226                clippy::cast_precision_loss,
227                reason = "a frame index within a block is far below f32's exact integer range"
228            )]
229            let offset = state.first_frame as f32;
230            for (phase, (block_phase, increment)) in state
231                .phases
232                .iter_mut()
233                .zip(self.phases.iter().zip(&self.phase_increments))
234            {
235                *phase = block_phase + offset * increment;
236            }
237        }
238
239        self.started += 1;
240        // Stamped now, looked for again in `render_post`: a frame that
241        // still carries it is a frame no thread claimed.
242        let channels = context.audio_out_channels();
243        for sample in context.audio_out().iter_mut().step_by(channels.max(1)) {
244            *sample = UNWRITTEN;
245        }
246    }
247
248    // Real-time safe from the second block on; the first also reads
249    // the thread id and the core, which is the measurement.
250    fn render(&self, state: &mut Voice, context: &mut RenderContext) {
251        if state.calls == 0 {
252            state.thread_id = thread_id();
253            state.cpu = current_cpu();
254        }
255        state.calls += 1;
256
257        let _oscillators = state.timer.measure();
258        let channels = context.audio_out_channels();
259        for frame in context.audio_frame_range() {
260            let mut sample = 0.0;
261            for (phase, increment) in state.phases.iter_mut().zip(&self.phase_increments) {
262                sample += phase.sin();
263                *phase += increment;
264                if *phase >= TAU {
265                    *phase -= TAU;
266                }
267            }
268            #[allow(
269                clippy::cast_precision_loss,
270                reason = "the oscillator count is far below f32's exact integer range"
271            )]
272            let sample = AMPLITUDE * sample / OSCILLATORS as f32;
273            for channel in 0..channels {
274                context.audio_write(frame, channel, sample);
275            }
276            state.frames += 1;
277        }
278    }
279
280    // Real-time safe: arithmetic and one read per frame.
281    fn render_post(&mut self, _states: &mut [Voice], context: &mut BlockContext) {
282        #[allow(
283            clippy::cast_precision_loss,
284            reason = "a block's frame count is far below f32's exact integer range"
285        )]
286        let frames = context.audio_frames() as f32;
287        for (phase, increment) in self.phases.iter_mut().zip(&self.phase_increments) {
288            *phase = (*phase + frames * increment) % TAU;
289        }
290
291        let channels = context.audio_out_channels();
292        let mut uncovered = 0;
293        for sample in context.audio_out().iter_mut().step_by(channels.max(1)) {
294            if sample.is_nan() {
295                // Nobody wrote this frame; silence it rather than
296                // sending a NaN to the codec.
297                *sample = 0.0;
298                uncovered += 1;
299            }
300        }
301        if uncovered != 0 {
302            self.uncovered += uncovered;
303            self.abandoned += 1;
304        }
305        self.finished += 1;
306    }
307
308    fn cleanup(&mut self, states: &mut [Voice], context: &CleanupContext) {
309        let mut rendered = 0;
310        for state in states.iter() {
311            rt_println!(
312                "parallel: thread={} tid={} cpu={} range={}..{} calls={} frames={} section={:.1}%",
313                state.thread,
314                state.thread_id,
315                state.cpu,
316                state.first_frame,
317                state.last_frame,
318                state.calls,
319                state.frames,
320                state.timer.usage().percentage()
321            );
322            rendered += state.frames;
323        }
324        let frames = context.audio_frames() as u64;
325        let expected = self.started * frames;
326        rt_println!(
327            "parallel: blocks={} frames={frames} rendered={rendered} expected={expected}",
328            self.started
329        );
330        rt_println!(
331            "parallel: uncovered={} abandoned={} unfinished={}",
332            self.uncovered,
333            self.abandoned,
334            self.started - self.finished
335        );
336        let busy = context.cpu_usage().map_or(0.0, |usage| usage.percentage());
337        rt_println!("parallel: audio-thread={busy:.1}%");
338    }
339}
340
341/// The render thread count from this program's own first argument.
342fn requested_threads() -> NonZeroU32 {
343    use std::env;
344
345    env::args()
346        .nth(1)
347        .and_then(|argument| argument.parse().ok())
348        .unwrap_or(DEFAULT_THREADS)
349}
350
351#[cfg(bela_device)]
352fn main() -> Result<(), bela::Error> {
353    let settings = bela::Settings::new()
354        .thread_count(requested_threads())
355        .cpu_monitoring(cycle());
356    // `Bela::run` only returns `Ok` when no callback was refused, so
357    // reporting the count here is the end-to-end check that the guard
358    // the parallel path relies on stayed out of the way — the one
359    // thing the per-thread numbers below cannot show.
360    match bela::Bela::run(Parallel::new(), &settings) {
361        Ok(()) => {
362            println!("parallel: faults=0");
363            Ok(())
364        }
365        Err(bela::Error::CallbackFaults(faults)) => {
366            println!("parallel: faults={faults}");
367            Err(bela::Error::CallbackFaults(faults))
368        }
369        Err(error) => Err(error),
370    }
371}
372
373#[cfg(not(bela_device))]
374fn main() -> ExitCode {
375    eprintln!("This example must be cross-compiled for Bela Gem (aarch64-unknown-linux-gnu).");
376    ExitCode::FAILURE
377}