Skip to content

PipeWire: 100% CPU on one core and broken audio after the first stream stops #305

Closed

Description

@4st4rot

Summary

After any client of the virtual sink has played and stopped, JamesDSP pins one
CPU core at 100% and audio breaks up until the application is restarted. A
freshly started instance is fine, so it looks like the problem "appears over
time" - in fact it starts with the first digital silence that reaches the DSP.

Root cause is denormal float arithmetic in the effect chain; PR #304 fixes it.

Environment

  • JamesDSP 2.7.0-81-g3ea7d73 (Pipewire flavour, built from master)
  • PipeWire 1.6.11 / WirePlumber 0.5.19, 48 kHz, 256-frame quantum
  • Qt 6.11.3, Gentoo Linux, x86_64 (Haswell Xeon)
  • Default preset: bass +6 dB, tube preamp 130, compander, stereo wide 46,
    master limiter on; output device USB (C-Media) with AudioOutputUseDefault=false
  • jamesdsp_sink is the default sink, cava also taps its monitor

Steps to reproduce

  1. Start jamesdsp --tray and give it a clean start (no streams yet).
  2. Play anything through the default sink (jamesdsp_sink), e.g. pw-play file.wav.
  3. Let playback finish and stop (~2 s later is enough).
  4. Watch pw-top and the threads of the process:
R  34  256  48000   5.3ms  14.2us  1.00  0.00     0    S16LE 2 48000 alsa_output...C-Media...
R  96    0      0   6.2us   4.9us  0.00  0.00     0     F32P 2 48000  + jamesdsp_sink
R 107    0      0   4.8ms     +++  0.91   +++  2249                   + jdsp_@PwJamesDspPlugin_JamesDsp

The filter node is permanently over budget (BUSY +++) and its ERR counter
grows by roughly 100 per second, i.e. every graph cycle is an xrun and the
node is immediately rescheduled. top -H -p $(pgrep -x jamesdsp) shows the
data-loop thread at 100%:

  PID USER   PR  NI  %CPU   TIME+ COMMAND
 5322 jam   -84   0  99.6   1:48.52 data-loop

Expected

The filter keeps running in real time; idle CPU stays at a few percent.

Actual

One core is burnt indefinitely, audio is distorted / dropping out, and the only
way out is restarting JamesDSP (the next stream that stops re-triggers it).

Measurements

Probe on pw_filter_get_dsp_buffer (block size is always 256 frames; the graph
is driven at 187.5 Hz, so a healthy filter should see ~188 calls/s):

state block calls/s CPU of data-loop
right after start, input never carried a signal ~750 10%
after a stream played and stopped ~600 100%
after a stream played and stopped, with a continuous -80 dBFS keep-alive stream ~600 7-8%

The third row is the key: as long as the input never becomes pure digital
silence
, the CPU stays low. A fresh, exactly-zero DSP state is fast too - only
the state that has decayed from a real signal is slow.

Root cause

The DSP state (bass / tube / compander / limiter / ...) decays into the
denormal float range once the input is all zeros. Denormal arithmetic is about
an order of magnitude slower on x86, so processing 256 frames no longer fits
into the 5.3 ms quantum: every cycle ends in an xrun and the graph reschedules
the node immediately, which is what eats the core.

JamesDSP never enables flush-to-zero / denormals-are-zero, unlike most audio
DSPs. (Curiously the vendored EEL2 interpreter, used only by liveprog, does
carry its own denormal handling in nseel-compiler.c - the native effect chain
has nothing equivalent.)

Ruled out (all tested, no effect):

  • node.passive / node.always-process on the sink and the filter
  • pactl suspend-sink jamesdsp_sink
  • graph wiring itself (links are exactly as intended, buffers are always valid,
    pw_filter_get_dsp_buffer never returns NULL)
  • it is not a PipeWire bug: the hot frames are inside the JamesDSP binary (not
    in the graph/scheduler code), and an LD_PRELOAD shim that only sets MXCSR
    fixes it without touching PipeWire at all

Workarounds for other users

Either restart JamesDSP after the audio stops, or run it with FTZ/DAZ enabled:

/* jdsp-ftz.c -> gcc -shared -fPIC -o jdsp-ftz.so jdsp-ftz.c -ldl */
#define _GNU_SOURCE
#include <dlfcn.h>
#include <pthread.h>
#include <stdlib.h>
#include <xmmintrin.h>

static void enable_ftz(void) { _mm_setcsr(_mm_getcsr() | 0x8040u); }
__attribute__((constructor)) static void init(void) { enable_ftz(); }

struct tramp { void *(*fn)(void *); void *arg; };

static void *run(void *p)
{
    struct tramp t = *(struct tramp *)p;
    free(p);
    enable_ftz();
    return t.fn(t.arg);
}

int pthread_create(pthread_t *th, const pthread_attr_t *a, void *(*fn)(void *), void *arg)
{
    static int (*real)(pthread_t *, const pthread_attr_t *, void *(*)(void *), void *);
    if (!real)
        real = (void *)dlsym(RTLD_NEXT, "pthread_create");
    struct tramp *t = malloc(sizeof(*t));
    if (!t) return real(th, a, fn, arg);
    t->fn = fn; t->arg = arg;
    int r = real(th, a, run, t);
    if (r) free(t);
    return r;
}
LD_PRELOAD=/path/to/jdsp-ftz.so jamesdsp --tray

With that, four play/stop cycles in a row keep the thread at 7-8% and the error
counter at 0.

Fix

PR #304: enable MXCSR FTZ|DAZ once per audio processing thread, in
PwJamesDspPlugin::process() (PipeWire flavour) and in
gst_jamesdsp_transform_ip() (the GStreamer element used by the PulseAudio
flavour, which has the same latent problem).

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions