Skip to content

fix: flush denormals to zero in the DSP threads - #304

Merged
timschneeb merged 1 commit into
Audio4Linux:masterfrom
4st4rot:fix/denormal-ftz
Nov 11, 2026
Merged

timschneeb merged 1 commit into
Audio4Linux:masterfrom
4st4rot:fix/denormal-ftz

Conversation

@4st4rot

@4st4rot 4st4rot commented Nov 10, 2026 •

Copy link
Copy Markdown
Contributor

Symptom

After any client of the virtual sink has played and stopped, JamesDSP starts
burning exactly one CPU core and audio breaks (until the app is restarted).
It only starts once the input goes back to digital silence, which is why it
looks like a "gradual" problem: right after startup everything is fine.

pw-top in that state:

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 permanently reports BUSY +++ and the ERR counter grows by
~100 per second, i.e. every graph cycle is an xrun and the node is
immediately rescheduled.

Root cause

The DSP state of the effect chain (bass / tube / compander / limiter / ...)
decays into the denormal float range once the input is all zeros. Denormal
arithmetic is roughly an order of magnitude slower on x86, so the filter no
longer fits into the 256-frame quantum, the graph overruns and spins.

Measured with a probe on pw_filter_get_dsp_buffer (block size is always 256):

state block calls/s CPU of data-loop thread
after startup, input silently zero ~750 10%
after a stream played and stopped ~600 100%
same, with a continuous -80 dBFS keep-alive stream ~600 7-8%

The last row is the giveaway: as long as the input never becomes pure
digital silence, the state stays out of the denormal range and the CPU stays
low. A fresh, exactly-zero state is fast too; only the decayed state is slow.

Not related to PipeWire itself - node.passive, node.always-process,
suspending the sink, and different PipeWire versions were all tested without
effect.

Fix

Enable flush-to-zero / denormals-are-zero (MXCSR bits FTZ|DAZ) once per
audio processing thread:

  • PwJamesDspPlugin::process() - the thread that runs the filter
    (PW_FILTER_FLAG_RT_PROCESS), so this is the one that matters;
  • gst_jamesdsp_transform_ip() - the GStreamer element used by the
    PulseAudio flavour has the same latent problem.

It is done per thread (instead of in main()) so the UI thread keeps the
default FP behaviour and no other code is affected. Signals quieter than
~-126 dBFS are flushed to zero; that is far below the noise floor of any
hardware.

Verification

  • Reproduced on PipeWire 1.6.11 (48 kHz, 256-frame quantum), Qt 6.11.3,
    Pipewire flavour, commit 3ea7d73, with the default preset (bass +6 dB,
    tube preamp 130, compander, stereo wide, limiter on).
  • The exact same MXCSR change was first validated with an LD_PRELOAD
    shim that sets FTZ|DAZ in every thread of the process: 4 play/stop cycles
    in a row -> data-loop stays at 7-8%, ERR stays at 0. Without it the
    first stop immediately goes to 100% with ~100 errors/s.
  • gstjamesdsp.c was syntax-checked against GStreamer 1.x headers; the
    PulseAudio flavour was not run at runtime here (no GStreamer build).

Reproducer

jamesdsp --tray &
sleep 5
pw-play some-file.wav        # plays into jamesdsp_sink (the default sink)
sleep 2                      # playback ends here
pw-top                       # watch jdsp_@PwJamesDspPlugin_... -> BUSY +++, ERR++
top -H -p "$(pgrep -x jamesdsp)"   # data-loop thread pinned at 100%

Fixes #305.

The DSP state decays into denormal floats once the input becomes digital
silence, e.g. right after the last client of jamesdsp_sink stops playing.
On x86 denormal arithmetic is about an order of magnitude slower, so the
filter no longer fits into the PipeWire quantum: every cycle ends in an
xrun, the graph reschedules the node immediately and one CPU core stays at
100% (pw-top shows the node with BUSY +++ and a growing ERR counter), while
audio is broken until the app is restarted.

Enable flush-to-zero / denormals-are-zero (MXCSR bits FTZ|DAZ) once per
processing thread. The same latent problem exists in the GStreamer element
used by the PulseAudio flavour, so it is fixed there as well.

Measured with PipeWire 1.6.11, 48 kHz / 256 frames: without the patch the
data-loop thread of jamesdsp stays at 100% of a core after a stream stops
and the node reports ~100 errors per second; with the patch the thread
stays at 7-8% and the error counter does not move.

Reproducer: start JamesDSP, play anything through the default sink
(jamesdsp_sink) and stop it, then watch pw-top or the CPU usage of the
data-loop thread.
@timschneeb
timschneeb merged commit 3b92457 into Audio4Linux:master Nov 11, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

2 participants