Skip to content

Commit e355682

Browse files
authored
perf(rt): eliminate permit_alloc sites on the RT audio path (#239) (#254)
1 parent 3e9ea24 commit e355682

15 files changed

Lines changed: 669 additions & 137 deletions

File tree

rustortion-core/Cargo.toml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,14 @@ anyhow = "1.0"
1616
rustfft = "6.4"
1717
realfft = "3.5"
1818
arc-swap = "1.8"
19-
assert_no_alloc = { version = "1.1", features = ["warn_debug"] }
2019
nam-rs = "0.2.0"
2120

2221
[dev-dependencies]
2322
criterion = { version = "0.8", features = ["html_reports"] }
2423
tempfile = "3.24"
24+
# Test-only: RT-path allocation auditing (tests/no_alloc.rs). `warn_debug`
25+
# makes it count violations instead of aborting the test binary.
26+
assert_no_alloc = { version = "1.1", features = ["warn_debug"] }
2527

2628
[[bench]]
2729
name = "impulse_responses"

rustortion-core/benches/common/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ pub fn create_test_cabinet(ir_length: usize, sample_rate: usize) -> IrCabinet {
2424

2525
let mut convolver = Convolver::new_fir(max_ir_samples);
2626
convolver.set_ir(&ir_samples).unwrap();
27-
cabinet.swap_convolver(convolver);
27+
cabinet.set_convolver(convolver);
2828

2929
cabinet
3030
}

rustortion-core/src/amp/chain.rs

Lines changed: 39 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,16 +5,37 @@ struct BypassableStage {
55
bypassed: bool,
66
}
77

8+
/// Stage capacity reserved up front, and the hard cap on chain length.
9+
///
10+
/// `insert_stage` (the RT `AddStage` path) never grows the backing `Vec` past
11+
/// its reserved capacity, so it never reallocates on the RT thread; the UI
12+
/// enforces the same number as the maximum stage count. ~24 bytes per slot, so
13+
/// ~1.5 KB reserved.
14+
pub const DEFAULT_CHAIN_CAPACITY: usize = 64;
15+
816
// AmplifierChain holds a sequence of processing stages.
9-
#[derive(Default)]
1017
pub struct AmplifierChain {
1118
stages: Vec<BypassableStage>,
1219
}
1320

21+
impl Default for AmplifierChain {
22+
fn default() -> Self {
23+
Self::new()
24+
}
25+
}
26+
1427
impl AmplifierChain {
1528
#[must_use]
16-
pub const fn new() -> Self {
17-
Self { stages: Vec::new() }
29+
pub fn new() -> Self {
30+
Self::with_capacity(DEFAULT_CHAIN_CAPACITY)
31+
}
32+
33+
/// Create a chain that can hold `capacity` stages before reallocating.
34+
#[must_use]
35+
pub fn with_capacity(capacity: usize) -> Self {
36+
Self {
37+
stages: Vec::with_capacity(capacity),
38+
}
1839
}
1940

2041
pub fn add_stage(&mut self, stage: Box<dyn Stage>) {
@@ -62,8 +83,18 @@ impl AmplifierChain {
6283
self.stages.get(idx).map(|s| s.inner.get_parameter(name))
6384
}
6485

65-
/// Insert a stage at the given index.
66-
pub fn insert_stage(&mut self, idx: usize, stage: Box<dyn Stage>) {
86+
/// Insert a stage at the given index **without reallocating**.
87+
///
88+
/// This runs on the RT thread (via `AddStage`). If the chain is already at
89+
/// its reserved capacity, the stage is **returned** rather than inserted, so
90+
/// the caller can dispose of it off the RT thread (growing the `Vec` here
91+
/// would allocate, and dropping the rejected box here would free, on the
92+
/// audio thread). Returns `None` when the stage was inserted.
93+
#[must_use]
94+
pub fn insert_stage(&mut self, idx: usize, stage: Box<dyn Stage>) -> Option<Box<dyn Stage>> {
95+
if self.stages.len() == self.stages.capacity() {
96+
return Some(stage);
97+
}
6798
let idx = idx.min(self.stages.len());
6899
self.stages.insert(
69100
idx,
@@ -72,6 +103,7 @@ impl AmplifierChain {
72103
bypassed: false,
73104
},
74105
);
106+
None
75107
}
76108

77109
/// Remove and return the stage at the given index.
@@ -144,7 +176,7 @@ mod tests {
144176
let mut chain = AmplifierChain::new();
145177
chain.add_stage(make_level(1.0));
146178
chain.add_stage(make_level(1.0));
147-
chain.insert_stage(1, make_level(0.5));
179+
assert!(chain.insert_stage(1, make_level(0.5)).is_none());
148180
let out = chain.process(1.0);
149181
assert!((out - 0.5).abs() < 1e-6);
150182
}
@@ -239,7 +271,7 @@ mod tests {
239271
let mut chain = AmplifierChain::new();
240272
chain.add_stage(make_level(1.0));
241273
chain.set_bypassed(0, true);
242-
chain.insert_stage(0, make_level(0.5)); // insert before bypassed
274+
assert!(chain.insert_stage(0, make_level(0.5)).is_none()); // insert before bypassed
243275
// idx 0 = new (active, 0.5x), idx 1 = old (bypassed, 1.0x)
244276
let out = chain.process(1.0);
245277
assert!(

rustortion-core/src/audio/engine.rs

Lines changed: 66 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
11
use anyhow::Result;
2-
use assert_no_alloc::permit_alloc;
32
use crossbeam::channel::{Receiver, Sender, bounded};
43
use log::{debug, error};
54

@@ -17,7 +16,10 @@ use crate::tuner::Tuner;
1716

1817
pub struct PreparedIr {
1918
pub name: String,
20-
pub convolver: Convolver,
19+
/// Boxed so it can be swapped into the cabinet on the RT thread without
20+
/// reallocating, and so the whole `PreparedIr` (old convolver + name) can
21+
/// be retired off the RT thread in one piece.
22+
pub convolver: Box<Convolver>,
2123
}
2224

2325
pub enum EngineMessage {
@@ -35,7 +37,9 @@ pub enum EngineMessage {
3537
SetIrBypass(bool),
3638
SetIrGain(f32),
3739
SetTunerEnabled(bool),
38-
SetPitchShift(i32),
40+
/// Carries a fully-constructed pitch shifter (built off the RT thread), or
41+
/// `None` to disable pitch shifting (the `0` semitones bypass case).
42+
SetPitchShift(Option<Box<PitchShifter>>),
3943
SetStageBypassed(usize, bool),
4044
SetSamplers(Box<Samplers>),
4145
}
@@ -49,12 +53,14 @@ pub struct Engine {
4953
engine_receiver: Receiver<EngineMessage>,
5054
/// Handle for sending arbitrary objects off the RT thread for deallocation.
5155
rt_drop: RtDropHandle,
52-
samplers: Samplers,
56+
/// Boxed so swapping samplers (sample-rate / buffer changes) on the RT
57+
/// thread exchanges pointers and retires the old box directly.
58+
samplers: Box<Samplers>,
5359
tuner: Option<Tuner>,
5460
recorder: Option<Recorder>,
5561
peak_meter: Option<PeakMeter>,
5662
metronome: Option<Metronome>,
57-
pitch_shifter: Option<PitchShifter>,
63+
pitch_shifter: Option<Box<PitchShifter>>,
5864
input_highpass: Option<Box<dyn Stage>>,
5965
input_lowpass: Option<Box<dyn Stage>>,
6066
/// When true, skip tuner, peak meter, recorder, and metronome processing.
@@ -83,7 +89,7 @@ impl Engine {
8389
ir_cabinet,
8490
engine_receiver,
8591
rt_drop,
86-
samplers,
92+
samplers: Box::new(samplers),
8793
tuner: Some(tuner),
8894
recorder: None,
8995
peak_meter: Some(peak_meter),
@@ -115,7 +121,7 @@ impl Engine {
115121
ir_cabinet,
116122
engine_receiver,
117123
rt_drop: rt_drop_handle,
118-
samplers,
124+
samplers: Box::new(samplers),
119125
tuner: None,
120126
recorder: None,
121127
peak_meter: None,
@@ -162,27 +168,21 @@ impl Engine {
162168
}
163169

164170
if let Some(ref mut shifter) = self.pitch_shifter {
165-
// FIXME(no_alloc): PitchShifter::process_block uses Vec scratch
166-
// buffers internally — see audio/pitch_shifter.rs.
167-
permit_alloc(|| shifter.process_block(output));
171+
shifter.process_block(output);
168172
}
169173

170174
if let Some(ref mut cab) = self.ir_cabinet {
171175
cab.process_block(output);
172176
}
173177

174178
if let Some(ref mut peak_meter) = self.peak_meter {
175-
// FIXME(no_alloc): PeakMeter::process does Arc::new(PeakMeterInfo)
176-
// every block — see audio/peak_meter.rs:62.
177-
permit_alloc(|| peak_meter.process(output));
179+
peak_meter.process(output);
178180
}
179181

180182
if !self.lightweight
181183
&& let Some(recorder) = self.recorder.as_mut()
182184
{
183-
// FIXME(no_alloc): Recorder::record_block does Vec::with_capacity
184-
// per block — see audio/recorder.rs:47.
185-
permit_alloc(|| recorder.record_block(output))?;
185+
recorder.record_block(output);
186186
}
187187

188188
Ok(())
@@ -263,8 +263,14 @@ impl Engine {
263263
}
264264
}
265265
EngineMessage::AddStage(idx, stage) => {
266-
self.chain.insert_stage(idx, stage);
267-
debug!("Added stage at index {idx}");
266+
if let Some(rejected) = self.chain.insert_stage(idx, stage) {
267+
// Chain is at its reserved capacity. Retire the rejected
268+
// stage off the RT thread rather than dropping (freeing)
269+
// it here. The UI caps stage count, so this is a backstop.
270+
self.rt_drop.retire(rejected);
271+
} else {
272+
debug!("Added stage at index {idx}");
273+
}
268274
}
269275
EngineMessage::RemoveStage(idx) => {
270276
if let Some(old) = self.chain.remove_stage(idx) {
@@ -286,16 +292,26 @@ impl Engine {
286292
}
287293
}
288294
EngineMessage::SetInputFilters(hp, lp) => {
289-
self.input_highpass = hp;
290-
self.input_lowpass = lp;
295+
// Retire the previous filters off the RT thread instead of
296+
// dropping them here on direct assignment.
297+
if let Some(old) = std::mem::replace(&mut self.input_highpass, hp) {
298+
self.rt_drop.retire(old);
299+
}
300+
if let Some(old) = std::mem::replace(&mut self.input_lowpass, lp) {
301+
self.rt_drop.retire(old);
302+
}
291303
debug!("Updated input filters");
292304
}
293-
EngineMessage::SwapIrConvolver(prepared) => {
305+
EngineMessage::SwapIrConvolver(mut prepared) => {
294306
if let Some(ref mut cab) = self.ir_cabinet {
295307
debug!("IR convolver swapped: {}", prepared.name);
296-
let old = cab.swap_convolver(prepared.convolver);
297-
permit_alloc(|| self.rt_drop.retire(Box::new(old)));
308+
// Swap the new convolver in; `prepared` is left holding
309+
// the old convolver. Retire the whole `PreparedIr` (old
310+
// convolver + name `String`) off the RT thread so
311+
// nothing deallocates here.
312+
cab.swap_convolver(&mut prepared.convolver);
298313
}
314+
self.rt_drop.retire(prepared);
299315
}
300316
EngineMessage::ClearIr => {
301317
if let Some(ref mut cab) = self.ir_cabinet {
@@ -326,12 +342,12 @@ impl Engine {
326342
EngineMessage::StopRecording => {
327343
self.handle_stop_recording();
328344
}
329-
EngineMessage::SetPitchShift(semitones) => {
330-
self.handle_pitch_shift(semitones);
345+
EngineMessage::SetPitchShift(shifter) => {
346+
self.handle_pitch_shift(shifter);
331347
}
332348
EngineMessage::SetSamplers(new_samplers) => {
333-
let old = std::mem::replace(&mut self.samplers, *new_samplers);
334-
permit_alloc(|| self.rt_drop.retire(Box::new(old)));
349+
let old = std::mem::replace(&mut self.samplers, new_samplers);
350+
self.rt_drop.retire(old);
335351
debug!("Samplers swapped");
336352
}
337353
}
@@ -364,19 +380,15 @@ impl Engine {
364380
self.recorder = None;
365381
}
366382

367-
fn handle_pitch_shift(&mut self, semitones: i32) {
368-
if semitones == 0 {
369-
self.pitch_shifter = None;
370-
debug!("Pitch shift disabled (bypass)");
371-
} else if let Some(ref mut shifter) = self.pitch_shifter {
372-
shifter.set_semitones(semitones as f32);
373-
debug!("Pitch shift set to {semitones} semitones");
374-
} else {
375-
// FIXME(no_alloc): PitchShifter::new allocates FFT scratch buffers
376-
// — see audio/pitch_shifter.rs.
377-
self.pitch_shifter = Some(permit_alloc(|| PitchShifter::new(semitones as f32)));
378-
debug!("Pitch shift set to {semitones} semitones");
383+
fn handle_pitch_shift(&mut self, shifter: Option<Box<PitchShifter>>) {
384+
// The shifter (if any) is constructed off the RT thread in
385+
// `EngineHandle::set_pitch_shift`; here we just swap it in and retire
386+
// the previous one off the RT thread.
387+
let old = std::mem::replace(&mut self.pitch_shifter, shifter);
388+
if let Some(old) = old {
389+
self.rt_drop.retire(old);
379390
}
391+
debug!("Pitch shifter updated");
380392
}
381393
}
382394

@@ -448,8 +460,14 @@ impl EngineHandle {
448460
}
449461

450462
pub fn set_pitch_shift(&self, semitones: i32) {
451-
let update = EngineMessage::SetPitchShift(semitones);
452-
self.send(update);
463+
// Construct the pitch shifter here (GUI thread) so the RT thread never
464+
// allocates its FFT plans / scratch buffers. `0` semitones == bypass.
465+
let shifter = if semitones == 0 {
466+
None
467+
} else {
468+
Some(Box::new(PitchShifter::new(semitones as f32)))
469+
};
470+
self.send(EngineMessage::SetPitchShift(shifter));
453471
}
454472

455473
pub fn set_stage_bypassed(&self, idx: usize, bypassed: bool) {
@@ -461,8 +479,13 @@ impl EngineHandle {
461479
self.send(update);
462480
}
463481

464-
pub fn start_recording(&self, sample_rate: usize, output_dir: &str) -> Result<()> {
465-
let recorder = Recorder::new(sample_rate as u32, output_dir)?;
482+
pub fn start_recording(
483+
&self,
484+
sample_rate: usize,
485+
output_dir: &str,
486+
max_block_samples: usize,
487+
) -> Result<()> {
488+
let recorder = Recorder::new(sample_rate as u32, output_dir, max_block_samples)?;
466489

467490
let update = EngineMessage::StartRecording(recorder);
468491
self.send(update);

0 commit comments

Comments
 (0)