8e: playback callback timing instrumentation + spike investigation

Adds a lock-free `PlaybackTiming` struct (atomics: call_count,
sum_us, max_us, spike_count, last_spike_us, last_spike_at_call)
shared between the bus filter's `playback_process` callback (RT
thread, writes) and the AGC controller (daemon thread, reads).
The audio thread wraps each inner call in
`Instant::now()` ... `state.timing.record(elapsed)` — wait-free,
no allocation. The AGC tick samples the snapshot once per second
and logs at WARN when new spikes have landed since the previous
sample, DEBUG otherwise. `#[global_allocator]` declaration in
`headroom-cli` now sits behind `cfg(debug_assertions)` so release
builds compile cleanly (assert_no_alloc strips `AllocDisabler`
under its default `disable_release` feature).

Spike investigation outcome

  PLAN §11 follow-up noted: ~240 μs steady state, ~2 ms BUSY
  spikes at ~10 s cadence. My ~3 min capture of a 1 kHz sine
  routed through processed (release build) showed:

  - Steady state ~2180 μs / call
  - Max climbed slowly: 2186 → 2222 → 2606 → 2655 → 2812 μs over
    ~1 min (1.3× steady-state, well within the per-quantum budget)
  - Callback rate ~4 Hz, implying the Mbox is negotiating a large
    quantum (~12k frames per call vs the 1024-frame baseline
    PLAN §4.7 measured). Per-frame DSP cost is identical to the
    original budget; the longer wall-clock is just the longer
    quantum

  No clear ~10 s-cadence outlier pattern reproduced. The system
  is comfortably inside budget (~2.2 ms / 250 ms quantum ≈ 1% of
  one core). Without an audible artefact or a reproducible
  failure mode I'm not chasing the original spike further; the
  instrumentation stays so future regressions are visible at
  WARN level. `SPIKE_THRESHOLD_US = 5000` is comfortably above
  steady-state at both small and large quanta, so only real
  outliers trip the log.

Verified

  185 tests pass; clippy clean at -D warnings --all-targets.
  Release build runs sine playback continuously for >3 min with
  no assert_no_alloc abort, no panic, no spike warning. Debug
  build (with assert_no_alloc active) likewise stable across
  thousands of audio callbacks (revalidated as part of the
  release-build comparison).
This commit is contained in:
atagen 2026-05-21 16:42:46 +10:00
parent 9220143db7
commit d52cd6db3b
5 changed files with 193 additions and 9 deletions

View file

@ -15,12 +15,12 @@ use clap::{Parser, Subcommand, ValueEnum};
use headroom_client::{Client, ClientError, Route, Topic};
// Wrap the system allocator so audio-thread `assert_no_alloc` blocks
// in headroom-core can detect any allocation. In debug builds an
// allocation inside such a block aborts the process — exactly what
// we want when the daemon is exercised under `cargo run` or under
// the test harness. In release builds the wrapper is a no-op
// (assert_no_alloc's default `disable_release` feature), so there's
// zero overhead in production.
// in headroom-core can detect any allocation. Debug-only — in
// release builds `assert_no_alloc`'s default `disable_release`
// feature strips both `AllocDisabler` and the `assert_no_alloc(||
// ...)` wrappers to no-ops, so there's zero overhead in production
// (and the symbol doesn't even exist to reference here).
#[cfg(debug_assertions)]
#[global_allocator]
static ALLOCATOR: assert_no_alloc::AllocDisabler = assert_no_alloc::AllocDisabler;