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}