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
This commit is contained in:
+26
-2
@@ -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
@@ -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})");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user