From 03d60a157b97b821fc0bfe8974bf9b28ba3cdd77 Mon Sep 17 00:00:00 2001 From: Mikkeli Matlock Date: Mon, 31 Aug 2026 21:19:23 +0900 Subject: [PATCH] perf: remove per-sample waste from the compressor/limiter hot path Found by reading the audio-thread code, not by profiling - no profiler is set up in this project yet. All four are algorithmic or arithmetic wins, none change the DSP result; the existing tests still pass. - oversampler: max_true_peak recomputed its circular-history tap index (including a % TAPS_PER_PHASE) inside the phase x channel loop, though the index depends only on pos/k - 8x redundant. Hoisted to a per-sample precomputed index array. This runs unconditionally every sample (it feeds the limiter's true-peak detector, not gated by metering), so it was the highest-value fix. - limiter: the sliding-window max rescanned the entire look-ahead ring (~73 samples at 48kHz/1.5ms) every sample - O(window) per sample. Replaced with an O(1)-amortized monotonic-deque sliding max, backed by preallocated ring arrays and RT-safe. Covered by a new test against a naive brute-force reference. Also precomputed the constant true-peak margin gain in prepare() instead of a powf every sample. - compressor: replaced per-sample log10/powf(10, x/20) with the equivalent ln/exp forms, mirroring nih-plug's own gain_to_db_fast / db_to_gain_fast. Called 4x/sample (3 bands + 'All'). - both: replaced % capacity ring-index math (a real integer division, since capacity is not a power of two) with a conditional-subtract wrap_add helper. Left the oversampler's % TAPS_PER_PHASE alone - that divisor is a compile-time constant LLVM already turns into a multiply. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1 --- src/dsp/compressor.rs | 39 ++++++++++-- src/dsp/limiter.rs | 131 +++++++++++++++++++++++++++++++++++++---- src/dsp/oversampler.rs | 14 +++-- src/lib.rs | 6 +- 4 files changed, 165 insertions(+), 25 deletions(-) diff --git a/src/dsp/compressor.rs b/src/dsp/compressor.rs index 595d01b..b1a402c 100644 --- a/src/dsp/compressor.rs +++ b/src/dsp/compressor.rs @@ -24,9 +24,20 @@ pub const MAX_LOOKAHEAD_MS: f32 = 5.0; /// Most channels we ever process in one frame (our audio layouts are mono/stereo). const MAX_CHANNELS: usize = 2; -/// ~ -240 dBFS; keeps `log10` away from zero without affecting audible levels. +/// ~ -240 dBFS; keeps the level conversion away from zero without affecting audible levels. const LEVEL_EPS: f32 = 1e-12; +/// `ln(x) * LOG10_GAIN_TO_DB == log10(x) * 20`, but a bare `ln()` runs faster than `log10()` on +/// most architectures (mirrors nih-plug's `util::gain_to_db_fast`). Used in the per-sample level +/// detector, called 4x/sample (once per band + 'All'). +const LOG10_GAIN_TO_DB: f32 = std::f32::consts::LOG10_E * 20.0; + +/// `exp(x * DB_TO_GAIN_EXP) == 10.0f32.powf(x / 20.0)`, but `exp()` runs faster than a general +/// `powf()` on most architectures (mirrors nih-plug's `util::db_to_gain_fast`) — `powf` has to +/// handle an arbitrary runtime base, while this bakes `ln(10)` in as a constant. Used for the +/// per-sample wet gain, called 4x/sample. +const DB_TO_GAIN_EXP: f32 = std::f32::consts::LN_10 / 20.0; + /// Silence-floor anchor for the below-threshold shaping: at/below this level the gain change is 0 /// (silence stays silence), and the low region fans up/down from here toward the threshold. Matches /// the editor gain-curve's display floor. @@ -230,8 +241,12 @@ impl Compressor { // 2) Tap positions (circular). Output is always `fixed_delay` old; the detector reads // `l` samples newer than the output, i.e. `l` samples into the output's future. - let out_pos = (self.write_pos + self.capacity - self.fixed_delay) % self.capacity; - let det_pos = (self.write_pos + self.capacity - (self.fixed_delay - l)) % self.capacity; + // `wrap_add` instead of `%`: capacity is rarely a power of two, so a plain modulo forces + // an integer division here; a conditional subtract is cheaper and behaves identically + // given the add is always <= capacity (true for every offset computed in this function). + let out_pos = wrap_add(self.write_pos, self.capacity - self.fixed_delay, self.capacity); + let det_pos = + wrap_add(self.write_pos, self.capacity - (self.fixed_delay - l), self.capacity); // 3) Linked peak detector at the look-ahead tap. let mut peak = 0.0f32; @@ -245,7 +260,7 @@ impl Compressor { // active (regardless of mode) so switching peak<->RMS is seamless. self.mean_sq = self.rms_coef * self.mean_sq + (1.0 - self.rms_coef) * peak * peak; let detector = if set.use_rms { self.mean_sq.sqrt() } else { peak }; - let level_db = 20.0 * (detector + LEVEL_EPS).log10(); + let level_db = (detector + LEVEL_EPS).ln() * LOG10_GAIN_TO_DB; // Desired attenuation in dB, as a positive quantity. let target = -Self::gain_computer( level_db, @@ -262,7 +277,7 @@ impl Compressor { self.y1 = target.max(set.release_coef * self.y1 + (1.0 - set.release_coef) * target); self.yl = set.attack_coef * self.yl + (1.0 - set.attack_coef) * self.y1; - let wet_gain = 10.0f32.powf((set.makeup_db - self.yl) / 20.0); + let wet_gain = ((set.makeup_db - self.yl) * DB_TO_GAIN_EXP).exp(); // 5) Dry/wet mix (parallel compression). Both paths use the same delayed input, so the // blend is phase-aligned. mix = 0 -> dry passthrough (clean bypass), mix = 1 -> wet. @@ -273,7 +288,19 @@ impl Compressor { } // 6) Advance the write head. - self.write_pos = (self.write_pos + 1) % self.capacity; + self.write_pos = wrap_add(self.write_pos, 1, self.capacity); + } +} + +/// Wrap `pos + add` into `[0, capacity)`, assuming `add <= capacity` — a conditional subtract in +/// place of `%`, since `capacity` is rarely a power of two (so `%` costs a real integer division). +#[inline] +fn wrap_add(pos: usize, add: usize, capacity: usize) -> usize { + let sum = pos + add; + if sum >= capacity { + sum - capacity + } else { + sum } } diff --git a/src/dsp/limiter.rs b/src/dsp/limiter.rs index 62eb59d..1d532e2 100644 --- a/src/dsp/limiter.rs +++ b/src/dsp/limiter.rs @@ -28,17 +28,97 @@ fn time_to_coef(time_ms: f32, sample_rate: f32) -> f32 { } } +/// Wrap `pos + add` into `[0, capacity)`, assuming `add <= capacity` — a conditional subtract in +/// place of `%`, since `capacity` is rarely a power of two (so `%` costs a real integer division). +#[inline] +fn wrap_add(pos: usize, add: usize, capacity: usize) -> usize { + let sum = pos + add; + if sum >= capacity { + sum - capacity + } else { + sum + } +} + +/// O(1)-amortized fixed-window sliding maximum (the classic monotonic-deque algorithm), in place +/// of rescanning the whole window every sample. Values are pushed one at a time; each push returns +/// the max over the last `window` pushes (inclusive of the one just pushed). +/// +/// Backed by two preallocated ring buffers sized to `window` (never grows past that: entries are +/// evicted once their age reaches `window`, and a strictly-decreasing run of un-evicted entries +/// still ages out within `window` pushes) — so `push` never allocates, keeping it RT-safe. +struct SlidingMax { + val: Vec, + seq: Vec, + cap: usize, + head: usize, + len: usize, + next_seq: u64, +} + +impl SlidingMax { + fn new() -> Self { + Self { val: Vec::new(), seq: Vec::new(), cap: 0, head: 0, len: 0, next_seq: 0 } + } + + fn prepare(&mut self, window: usize) { + self.cap = window.max(1); + self.val = vec![0.0; self.cap]; + self.seq = vec![0; self.cap]; + self.reset(); + } + + fn reset(&mut self) { + self.head = 0; + self.len = 0; + self.next_seq = 0; + } + + /// Push a new value; return the maximum over the last `cap` pushes (including this one). + fn push(&mut self, value: f32) -> f32 { + let t = self.next_seq; + + // Values can never be the max while the new one is still in the window, so drop them. + while self.len > 0 { + let back = (self.head + self.len - 1) % self.cap; + if self.val[back] <= value { + self.len -= 1; + } else { + break; + } + } + + let back = (self.head + self.len) % self.cap; + self.val[back] = value; + self.seq[back] = t; + self.len += 1; + + // Evict entries that have aged out of the window. + while self.seq[self.head] + self.cap as u64 <= t { + self.head = (self.head + 1) % self.cap; + self.len -= 1; + } + + self.next_seq = t + 1; + self.val[self.head] + } +} + pub struct Limiter { /// Per-channel audio delay ring. delay: Vec>, - /// Linked `|x|` history, same length as the delay ring (for the sliding maximum). - peaks: Vec, + /// O(1)-amortized sliding-window maximum over the linked `|x|` history (replaces an O(window) + /// full-rescan every sample). + sliding_max: SlidingMax, capacity: usize, write_pos: usize, fixed_delay: usize, /// Current smoothed gain (<= 1). gain: f32, attack_coef: f32, + /// `10^(-TRUE_PEAK_MARGIN_DB/20)`, precomputed once (the margin is a fixed constant, so + /// there's no reason to recompute this `powf` on every sample). + margin_gain: f32, /// 4× interpolator for true-peak (inter-sample) detection. oversampler: Oversampler, } @@ -47,12 +127,13 @@ impl Default for Limiter { fn default() -> Self { Self { delay: Vec::new(), - peaks: Vec::new(), + sliding_max: SlidingMax::new(), capacity: 0, write_pos: 0, fixed_delay: 0, gain: 1.0, attack_coef: 0.0, + margin_gain: 1.0, oversampler: Oversampler::new(), } } @@ -68,9 +149,10 @@ impl Limiter { self.fixed_delay = (LOOKAHEAD_MS * 0.001 * sample_rate).ceil() as usize; self.capacity = self.fixed_delay + 1; self.attack_coef = time_to_coef(ATTACK_MS, sample_rate); + self.margin_gain = 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0); let channels = num_channels.clamp(1, MAX_CHANNELS); self.delay = vec![vec![0.0; self.capacity]; channels]; - self.peaks = vec![0.0; self.capacity]; + self.sliding_max.prepare(self.capacity); self.oversampler.prepare(channels); self.reset(); } @@ -79,7 +161,7 @@ impl Limiter { for ch in &mut self.delay { ch.iter_mut().for_each(|s| *s = 0.0); } - self.peaks.iter_mut().for_each(|p| *p = 0.0); + self.sliding_max.reset(); self.oversampler.reset(); self.write_pos = 0; self.gain = 1.0; @@ -111,21 +193,17 @@ impl Limiter { let peak = sample_peak.max(self.oversampler.max_true_peak(&input[..n])); // Target a hair below the ceiling so the (slightly under-read) true peak stays under it. - let target_ceiling = ceiling * 10.0f32.powf(-TRUE_PEAK_MARGIN_DB / 20.0); + let target_ceiling = ceiling * self.margin_gain; // Write into the ring. for ch in 0..n { self.delay[ch][self.write_pos] = input[ch]; } - self.peaks[self.write_pos] = peak; // Sliding maximum over the look-ahead window (= the whole ring). Because the oldest sample // (the one we output now) is in this window, `ceiling / window_max` applied to it can never // exceed the ceiling, and the gain has pre-dropped for any louder sample still to come. - let mut window_max = 0.0f32; - for &p in &self.peaks { - window_max = window_max.max(p); - } + let window_max = self.sliding_max.push(peak); let target = if window_max > target_ceiling { target_ceiling / window_max } else { @@ -140,12 +218,14 @@ impl Limiter { }; // Output the delayed sample, clamped to the ceiling as the hard guarantee. - let out_pos = (self.write_pos + 1) % self.capacity; // oldest sample = fixed_delay ago + // Conditional subtract instead of `%`: capacity is rarely a power of two, and the add + // here (1) is always <= capacity, so a plain compare-and-subtract is enough. + let out_pos = wrap_add(self.write_pos, 1, self.capacity); // oldest sample = fixed_delay ago for ch in 0..n { output[ch] = (self.delay[ch][out_pos] * self.gain).clamp(-ceiling, ceiling); } - self.write_pos = (self.write_pos + 1) % self.capacity; + self.write_pos = wrap_add(self.write_pos, 1, self.capacity); } } @@ -160,6 +240,31 @@ mod tests { time_to_coef(50.0, SR) } + #[test] + fn sliding_max_matches_naive_full_rescan() { + // A small xorshift-ish PRNG (no extra deps) exercising both rising and falling runs. + let mut state = 0x1234_5678u32; + let mut next = || { + state ^= state << 13; + state ^= state >> 17; + state ^= state << 5; + (state % 1000) as f32 / 1000.0 + }; + + let window = 7usize; + let mut sm = SlidingMax::new(); + sm.prepare(window); + let mut history: Vec = Vec::new(); + + for _ in 0..2_000 { + let v = next(); + history.push(v); + let start = history.len().saturating_sub(window); + let expected = history[start..].iter().cloned().fold(0.0f32, f32::max); + assert_eq!(sm.push(v), expected); + } + } + #[test] fn never_exceeds_ceiling_on_spikes() { // Mostly silence with occasional large spikes — output must never exceed the ceiling. diff --git a/src/dsp/oversampler.rs b/src/dsp/oversampler.rs index 8ca0a68..d243f16 100644 --- a/src/dsp/oversampler.rs +++ b/src/dsp/oversampler.rs @@ -97,15 +97,21 @@ impl Oversampler { self.history[ch][slot] = input[ch]; } + // The tap index only depends on `pos` and `k`, not on `phase` or `ch` — hoist it out of + // those loops instead of recomputing (and re-dividing) it PHASES*MAX_CHANNELS times over. + let mut idx = [0usize; TAPS_PER_PHASE]; + for (k, slot) in idx.iter_mut().enumerate() { + // k = 0 is the newest sample, increasing k goes back in time. + *slot = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE; + } + let mut peak = 0.0f32; for ch in 0..n { let hist = &self.history[ch]; for phase in &self.coeffs { let mut acc = 0.0f32; - for (k, &c) in phase.iter().enumerate() { - // k = 0 is the newest sample, increasing k goes back in time. - let idx = (self.pos + TAPS_PER_PHASE - k) % TAPS_PER_PHASE; - acc += c * hist[idx]; + for (&c, &i) in phase.iter().zip(idx.iter()) { + acc += c * hist[i]; } peak = peak.max(acc.abs()); } diff --git a/src/lib.rs b/src/lib.rs index 5039895..b318180 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -241,7 +241,9 @@ impl Plugin for Codename206 { // Drive + compress each band (per-sample smoothed pre-gain & makeup), then sum. summed[..n].fill(0.0); for b in 0..3 { - let pre = util::db_to_gain(band_params[b].pre_gain_db.smoothed.next()); + // `_fast` skips the below-(-100dB) branch, which pre_gain_db's -24..+36 range + // never reaches, in exchange for a cheaper exp() instead of a general powf(). + let pre = util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next()); for ch in 0..n { band_in[b][ch] *= pre; } @@ -272,7 +274,7 @@ impl Plugin for Codename206 { } // 'All' aggregate channel over the summed bands (driven before its compressor). - let all_pre = util::db_to_gain(self.params.all.pre_gain_db.smoothed.next()); + let all_pre = util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next()); for ch in 0..n { summed[ch] *= all_pre; }