Compare commits

..

3 Commits

Author SHA1 Message Date
Mikkeli Matlock 058d73f6cd Merge branch 'perf/true-peak-early-out' 2026-08-31 21:55:41 +09:00
Mikkeli Matlock 5958b077e2 perf: lift idle per-sample smoothers out of the process loop
Every sample ran 20 Smoother::next() calls and 4 db_to_gain_fast() - one set
per channel for pre-gain, makeup, mix, low slope and low curve - whether or not
anything was actually moving. Measured standalone at ~17 ns/sample, so roughly
8% of the chain, paid permanently for the rare case where a knob is in motion.

Params here only change on block boundaries (we do not split blocks on events),
so a smoother idle at the top of a block stays idle for that block. When every
smoother feeding a channel is idle - i.e. nobody is touching anything, which is
the overwhelming common case - its per-sample values are constants and lift out
of the loop. The moving case keeps the original per-sample path unchanged.

Unlike the true-peak early-out this is an always-on saving, not a
signal-dependent one.

Note this path has no automated coverage: process() needs a host to drive it,
so the DSP tests do not reach it. The logic is small and the moving case is
byte-for-byte the old code, but automation smoothing is worth an ear in FL
before trusting it - move a pre-gain or mix knob and listen for zipper.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 21:55:40 +09:00
Mikkeli Matlock b862f5a971 perf: skip the true-peak evaluation when it provably cannot reach the ceiling
The polyphase evaluation is PHASES*TAPS_PER_PHASE multiply-adds per channel
(96 for stereo) on every sample, unconditionally, and measured as 42 ns/sample
- 17% of the whole chain, and the largest single item in it. It is also the
only part that is pure throughput rather than a serial dependency chain, which
makes it the one worth attacking.

It is skippable. The interpolator is linear, so over its FIR support

    |true peak| <= peak_gain * max|history|,  peak_gain = max_phase sum |c|

and when that bound sits under the ceiling the true peak provably cannot reach
it. The limiter's gain target would be 1.0 for that sample either way, so
feeding the sample peak instead cannot change the outcome - a genuinely loud
neighbour still gets caught by its own evaluation.

- oversampler: split max_true_peak into push (cheap, must run every sample or
  the FIR history develops holes) and evaluate (the expensive half, skippable).
  Expose peak_gain and SUPPORT_LEN so a caller can compute the bound.
- limiter: track the sample peak over the FIR support with the SlidingMax
  already used for the look-ahead window, and skip evaluate when bounded under.

Measured, 48 kHz stereo:
  limiter, quiet 0.1 (skips)            66 -> 31 ns/sample
  limiter, hot 0.95 (always evaluates)  66 -> 72 ns/sample   (no real change)
  FULL CHAIN                           232 -> 195 ns/sample

So this pays off on material that is not pinned to the ceiling and costs
nothing when it is. On a maximizer the hot case is common, so treat the win as
situational rather than a flat 16%.

evaluate_never_exceeds_peak_gain_bound asserts the bound directly against
adversarial input (full-scale square waves, impulses), since the whole
optimisation rests on it, and also asserts it is not vacuously loose.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LU1NRL7fAaZTtFMTTPn2b1
2026-08-31 21:55:40 +09:00
3 changed files with 167 additions and 19 deletions
+26 -2
View File
@@ -9,7 +9,7 @@
//! Detection is stereo-linked (one gain for all channels). This stage limits **sample** peaks at
//! the base rate; true-peak (inter-sample) limiting via oversampling is a later addition.
use super::oversampler::Oversampler;
use super::oversampler::{self, Oversampler};
const MAX_CHANNELS: usize = 2;
/// Fixed look-ahead — also this stage's constant latency contribution.
@@ -121,6 +121,10 @@ pub struct Limiter {
margin_gain: f32,
/// 4× interpolator for true-peak (inter-sample) detection.
oversampler: Oversampler,
/// Sliding max of the sample peak over the interpolator's FIR support, which bounds what
/// its next evaluation can possibly return. Lets `process` skip the evaluation entirely
/// when that bound is already under the ceiling.
support_max: SlidingMax,
}
impl Default for Limiter {
@@ -135,6 +139,7 @@ impl Default for Limiter {
attack_coef: 0.0,
margin_gain: 1.0,
oversampler: Oversampler::new(),
support_max: SlidingMax::new(),
}
}
}
@@ -153,6 +158,7 @@ impl Limiter {
let channels = num_channels.clamp(1, MAX_CHANNELS);
self.delay = vec![vec![0.0; self.capacity]; channels];
self.sliding_max.prepare(self.capacity);
self.support_max.prepare(oversampler::SUPPORT_LEN);
self.oversampler.prepare(channels);
self.reset();
}
@@ -162,6 +168,7 @@ impl Limiter {
ch.iter_mut().for_each(|s| *s = 0.0);
}
self.sliding_max.reset();
self.support_max.reset();
self.oversampler.reset();
self.write_pos = 0;
self.gain = 1.0;
@@ -190,11 +197,28 @@ impl Limiter {
for &x in &input[..n] {
sample_peak = sample_peak.max(x.abs());
}
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 * self.margin_gain;
// The polyphase evaluation is ~PHASES*TAPS multiply-adds per channel and runs on every
// sample, which makes it the most expensive single thing in the chain. It is also
// skippable: the interpolator is linear, so over its FIR support
// |true peak| <= peak_gain * max|history|
// and when that bound sits under the ceiling the true peak provably cannot reach it.
// The limiter's target would be 1.0 for this sample either way, so feeding the sample
// peak instead cannot change the outcome - `sliding_max` still sees any genuinely loud
// neighbour through that neighbour's own evaluation.
//
// The history must still be fed on every sample, or later evaluations read holes.
self.oversampler.push(&input[..n]);
let support_max = self.support_max.push(sample_peak);
let peak = if support_max * self.oversampler.peak_gain() <= target_ceiling {
sample_peak
} else {
sample_peak.max(self.oversampler.evaluate(n))
};
// Write into the ring.
for ch in 0..n {
self.delay[ch][self.write_pos] = input[ch];
+85 -6
View File
@@ -17,6 +17,9 @@
const PHASES: usize = 4;
const TAPS_PER_PHASE: usize = 12;
/// Length of the FIR support, i.e. how many past input frames an evaluation reads. A caller
/// bounding the true peak needs the max over exactly this many frames.
pub const SUPPORT_LEN: usize = TAPS_PER_PHASE;
const MAX_CHANNELS: usize = 2;
/// Build the normalised polyphase coefficients: a windowed-sinc prototype split into `PHASES`
@@ -53,17 +56,32 @@ fn build_coefficients() -> [[f32; TAPS_PER_PHASE]; PHASES] {
coeffs
}
/// Largest L1 norm across phases: `max_phase sum |c|`. Bounds the interpolator's output for a
/// bounded input, which is what makes the [`Oversampler::peak_gain`] early-out sound.
fn build_peak_gain(coeffs: &[[f32; TAPS_PER_PHASE]; PHASES]) -> f32 {
coeffs
.iter()
.map(|row| row.iter().map(|c| c.abs()).sum::<f32>())
.fold(0.0f32, f32::max)
}
pub struct Oversampler {
coeffs: [[f32; TAPS_PER_PHASE]; PHASES],
/// Per-channel circular history of the last `TAPS_PER_PHASE` input samples.
history: Vec<[f32; TAPS_PER_PHASE]>,
pos: usize,
/// Largest L1 norm across the phases. Since every interpolated sample is a dot product of
/// one phase with the history, `|interpolated| <= peak_gain * max|history|` - a hard bound,
/// which lets a caller prove the true peak cannot reach a ceiling without evaluating it.
peak_gain: f32,
}
impl Default for Oversampler {
fn default() -> Self {
let coeffs = build_coefficients();
Self {
coeffs: build_coefficients(),
peak_gain: build_peak_gain(&coeffs),
coeffs,
history: Vec::new(),
pos: 0,
}
@@ -90,19 +108,38 @@ impl Oversampler {
/// Feed one input frame; return the maximum inter-sample magnitude across all channels and the
/// 4× grid (the reconstructed samples are not kept).
pub fn max_true_peak(&mut self, input: &[f32]) -> f32 {
/// Upper bound on `|interpolated|` for an input bounded by 1.0. See [`Self::peak_gain`].
#[inline(always)]
pub fn peak_gain(&self) -> f32 {
self.peak_gain
}
/// Feed one input frame into the history. Cheap - a write per channel plus a counter.
/// Must be called for EVERY frame even when [`Self::evaluate`] is skipped, or the FIR
/// history develops holes and later evaluations read stale samples.
#[inline]
pub fn push(&mut self, input: &[f32]) {
let n = input.len().min(self.history.len());
let slot = self.pos % TAPS_PER_PHASE;
for ch in 0..n {
self.history[ch][slot] = input[ch];
}
self.pos += 1;
}
// The tap index only depends on `pos` and `k`, not on `phase` or `ch` — hoist it out of
/// The actual polyphase evaluation: `PHASES * TAPS_PER_PHASE` multiply-adds per channel.
/// This is the expensive half, and the only half a caller can skip.
#[inline]
pub fn evaluate(&self, channels: usize) -> f32 {
let n = channels.min(self.history.len());
// 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.
// `pos` already advanced past the newest sample in push(), hence the -1.
let base = self.pos + TAPS_PER_PHASE - 1;
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;
*slot = (base - k) % TAPS_PER_PHASE;
}
let mut peak = 0.0f32;
@@ -116,10 +153,18 @@ impl Oversampler {
peak = peak.max(acc.abs());
}
}
self.pos += 1;
peak
}
/// Push a frame and evaluate it unconditionally. The real path splits these so the
/// evaluation can be skipped (see `Limiter::process`); this is kept for tests that want
/// the true peak of every frame regardless of any ceiling.
#[cfg(test)]
pub fn max_true_peak(&mut self, input: &[f32]) -> f32 {
let n = input.len().min(self.history.len());
self.push(input);
self.evaluate(n)
}
}
#[cfg(test)]
@@ -153,4 +198,38 @@ mod tests {
}
assert!((last - 0.5).abs() < 0.02, "amplitude not preserved: {last}");
}
/// The early-out in `Limiter::process` is only sound if `evaluate()` can never exceed
/// `peak_gain * max|history|`. Assert that directly against adversarial input - full-scale
/// square waves and alternating impulses maximise the interpolator's overshoot.
#[test]
fn evaluate_never_exceeds_peak_gain_bound() {
let mut os = Oversampler::default();
os.prepare(2);
let mut rng: u32 = 0xC0FF_EE01;
let mut worst_ratio = 0.0f32;
for i in 0..20_000 {
rng = rng.wrapping_mul(1_664_525).wrapping_add(1_013_904_223);
// Mix of adversarial shapes: alternating full scale, impulses, and noise.
let x = match i % 3 {
0 => if (i / 3) % 2 == 0 { 1.0 } else { -1.0 },
1 => if i % 97 == 0 { 1.0 } else { 0.0 },
_ => (rng >> 8) as f32 / (1 << 23) as f32 - 1.0,
};
let frame = [x, -x];
os.push(&frame);
let got = os.evaluate(2);
// Bound uses the max over the FIR support, which for these inputs is at most 1.0.
let bound = os.peak_gain() * 1.0;
assert!(
got <= bound + 1e-4,
"evaluate() = {got} exceeded peak_gain bound {bound} at i={i}"
);
if bound > 0.0 {
worst_ratio = worst_ratio.max(got / bound);
}
}
// Sanity: the bound must be reachable enough to be useful, not vacuously huge.
assert!(worst_ratio > 0.3, "bound looks far too loose (worst ratio {worst_ratio})");
}
}
+55 -10
View File
@@ -216,6 +216,43 @@ impl Plugin for Codename206 {
let mut gr = [0.0f32; meters::NUM_CHANNELS];
let mut lim_gr = 0.0f32;
// Params only change on block boundaries here (we do not split blocks on events), so a
// smoother that is idle now stays idle for this whole block. When every smoother feeding
// a channel is idle - the overwhelming common case, i.e. nobody is touching a knob -
// its per-sample values are constants, so lift them out of the sample loop entirely.
// That removes 5 Smoother::next() calls and a db_to_gain per channel per sample.
let chan_static = |p: &params::CompressorParams| {
!p.pre_gain_db.smoothed.is_smoothing()
&& !p.makeup_db.smoothed.is_smoothing()
&& !p.mix.smoothed.is_smoothing()
&& !p.low_slope.smoothed.is_smoothing()
&& !p.low_curve.smoothed.is_smoothing()
};
let band_static = [
chan_static(&self.params.low),
chan_static(&self.params.mid),
chan_static(&self.params.high),
];
let all_static = chan_static(&self.params.all);
let mut band_pre = [1.0f32; 3];
for b in 0..3 {
if band_static[b] {
band_pre[b] = util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next());
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
band_set[b].mix = band_params[b].mix.smoothed.next();
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
}
}
let mut all_pre_static = 1.0f32;
if all_static {
all_pre_static = util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next());
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
all_set.mix = self.params.all.mix.smoothed.next();
all_set.low_slope = self.params.all.low_slope.smoothed.next();
all_set.low_curve = self.params.all.low_curve.smoothed.next();
}
let mut in_frame = [0.0f32; 2];
let mut band_in = [[0.0f32; 2]; 3];
let mut band_out = [[0.0f32; 2]; 3];
@@ -243,14 +280,18 @@ impl Plugin for Codename206 {
for b in 0..3 {
// `_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());
let pre = if band_static[b] {
band_pre[b]
} else {
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
band_set[b].mix = band_params[b].mix.smoothed.next();
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
util::db_to_gain_fast(band_params[b].pre_gain_db.smoothed.next())
};
for ch in 0..n {
band_in[b][ch] *= pre;
}
band_set[b].makeup_db = band_params[b].makeup_db.smoothed.next();
band_set[b].mix = band_params[b].mix.smoothed.next();
band_set[b].low_slope = band_params[b].low_slope.smoothed.next();
band_set[b].low_curve = band_params[b].low_curve.smoothed.next();
self.comps[b].process(&band_in[b][..n], &mut band_out[b][..n], &band_set[b]);
for ch in 0..n {
summed[ch] += band_out[b][ch];
@@ -274,14 +315,18 @@ impl Plugin for Codename206 {
}
// 'All' aggregate channel over the summed bands (driven before its compressor).
let all_pre = util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next());
let all_pre = if all_static {
all_pre_static
} else {
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
all_set.mix = self.params.all.mix.smoothed.next();
all_set.low_slope = self.params.all.low_slope.smoothed.next();
all_set.low_curve = self.params.all.low_curve.smoothed.next();
util::db_to_gain_fast(self.params.all.pre_gain_db.smoothed.next())
};
for ch in 0..n {
summed[ch] *= all_pre;
}
all_set.makeup_db = self.params.all.makeup_db.smoothed.next();
all_set.mix = self.params.all.mix.smoothed.next();
all_set.low_slope = self.params.all.low_slope.smoothed.next();
all_set.low_curve = self.params.all.low_curve.smoothed.next();
self.comps[ALL].process(&summed[..n], &mut out_frame[..n], &all_set);
// Output brickwall limiter.