Performance Tuning

This page covers the runtime knobs that determine how low you can push the
mixer’s buffer size before xruns appear. The end-to-end recipe (governor +
preempt + quantum) is at the bottom under Realtime tuning recipe.

JACK buffer size trade-off

The JACK/PipeWire quantum (buffer size in frames) is the primary latency/stability knob.

Frames Latency at 48 kHz Stability
32 0.67 ms Needs PREEMPT_RT kernel + IRQ pinning
64 1.3 ms Very low — one IRQ spike = xrun
128 2.7 ms Sweet spot with governor=performance + preempt=full
256 5.3 ms Reliable on standard kernels
512 10.7 ms Stable on most hardware
1024 21.3 ms Very stable; PipeWire default

Check current quantum:

pw-metadata -n settings 0 clock.quantum

Note: clock.quantum reads/writes the default quantum that PipeWire advertises.
The tuning recipe below uses clock.force-quantum instead, which additionally
prevents any application from requesting a different buffer size. Both keys
accept the same values; force-quantum is the stricter variant suitable for
a dedicated audio server.

Change quantum (temporary):

pw-metadata -n settings 0 clock.quantum 512
pw-metadata -n settings 0 clock.min-quantum 512
pw-metadata -n settings 0 clock.max-quantum 512

Change quantum (permanent):

Create ~/.config/pipewire/pipewire.conf.d/quantum.conf:

context.properties = {
  default.clock.quantum     = 512
  default.clock.min-quantum = 512
  default.clock.max-quantum = 512
}

Then systemctl --user restart pipewire pipewire-pulse wireplumber.

Relationship to ingest ring buffer:

The mixer’s ingest ring is sized at broadcast_frames * 64. At 512 frames this is 32768 frames (~683 ms at 48 kHz). If broadcasters send at 128-frame packets, the ring holds ~256 packets of headroom — enough to absorb network jitter without dropouts. Larger JACK buffers proportionally increase this headroom.


CPU realtime priority

For the lowest possible xrun rate, run the JACK server (PipeWire) and the mixer with realtime CPU priority.

Check that RT is working:

# Should show non-zero nice value / RT policy
chrt -p $(pidof shiloh-mixer-linux-x86_64)

The systemd unit already sets LimitRTPRIO=95. JACK will request SCHED_FIFO at priority 70 by default; the mixer’s audio callback inherits this.

If RT is not granted:

Check limits.conf or PAM limits. On Ubuntu the audio group gets RT permission via /etc/security/limits.d/audio.conf:

@audio   -  rtprio     95
@audio   -  memlock    unlimited

Verify you are in the audio group:

groups | grep audio

Raise JACK server priority:

With PipeWire-JACK, PipeWire itself runs the RT graph. Set its priority in ~/.config/pipewire/pipewire.conf.d/rt.conf:

context.properties = {
  mem.allow-mlock   = true
  default.clock.quantum = 512
}

context.modules = [
  { name = libpipewire-module-rt
    args = {
      nice.level    = -11
      rt.prio       = 88
      rt.time.soft  = 200000
      rt.time.hard  = 200000
    }
    flags = [ ifexists nofail ]
  }
]

CPU frequency governor

Power-saving governors introduce latency spikes that cause xruns. Lock all
CPUs to performance on the mixer server:

# Install cpupower (Ubuntu)
sudo apt install linux-tools-generic

# Set all cores to performance (runtime, not persistent)
sudo cpupower frequency-set -g performance

# Verify
for c in /sys/devices/system/cpu/cpu*/cpufreq/scaling_governor; do cat $c; done | sort -u
# → performance

Persistent (across reboots)cpupower.service isn’t shipped on
modern Ubuntu, so drop a small systemd one-shot:

sudo tee /etc/systemd/system/cpu-performance.service >/dev/null <<'EOF'
[Unit]
Description=Lock all CPUs to performance governor
After=multi-user.target

[Service]
Type=oneshot
ExecStart=/usr/bin/cpupower frequency-set -g performance
RemainAfterExit=yes

[Install]
WantedBy=multi-user.target
EOF
sudo systemctl daemon-reload
sudo systemctl enable --now cpu-performance.service

Effect: ewma DSP time on stg-srv001 dropped from ~360 µs to ~150 µs at
1024 frames after flipping powersaveperformance, simply because the
JACK callback no longer waits for CPUs to ramp from the idle clock.


Kernel preempt mode

Ubuntu’s linux-generic ships PREEMPT_DYNAMIC, meaning any of four
modes can be selected at boot or runtime:

Mode Behaviour Best for
none Kernel only preempts at explicit schedule() Servers, batch
voluntary Adds might_resched() hints — default for linux-generic Desktops
full Preempts almost anywhere (except spinlocks) — equivalent to linux-lowlatency’s CONFIG_PREEMPT=y Audio
lazy Preempts but defers TIF flag to next tick — compromise Mixed

For low-latency audio (≤ 256 frames), use full. You don’t need to
install linux-lowlatency-hwe.

Runtime change (resets on reboot):

sudo bash -c 'echo full > /sys/kernel/debug/sched/preempt'
sudo cat /sys/kernel/debug/sched/preempt
# → none voluntary (full) lazy

Persistent — append preempt=full to GRUB kernel cmdline:

# Edit /etc/default/grub, change e.g.
#   GRUB_CMDLINE_LINUX_DEFAULT='quiet splash'
# to
#   GRUB_CMDLINE_LINUX_DEFAULT='quiet splash preempt=full'

sudo sed -i -E "s/^(GRUB_CMDLINE_LINUX_DEFAULT='[^']*)'/\1 preempt=full'/" /etc/default/grub
sudo update-grub
# takes effect on next reboot

Beyond full: PREEMPT_RT — the real-time patch (mainlined in
kernel 6.12) turns spinlocks into mutexes so the kernel is fully
preemptible. Available on Ubuntu via linux-realtime (Ubuntu Pro)
or linux-lowlatency-rt (Ubuntu Studio). Required for stable
operation below 128 frames; usually unnecessary at 128+.


Network socket buffer sizes

At high packet rates (375 pps × multiple clients), the kernel UDP receive buffer can overflow if the mixer’s receive loop has any latency. Increase the default and maximum socket buffer sizes:

# Temporary
sysctl -w net.core.rmem_max=67108864
sysctl -w net.core.wmem_max=67108864
sysctl -w net.core.rmem_default=8388608
sysctl -w net.core.wmem_default=8388608

# Permanent (add to /etc/sysctl.d/99-audio.conf)
net.core.rmem_max     = 67108864
net.core.wmem_max     = 67108864
net.core.rmem_default = 8388608
net.core.wmem_default = 8388608

Apply: sudo sysctl --system


IRQ affinity

For USB audio devices, pin the USB host controller’s IRQ to a dedicated CPU core to reduce latency jitter:

# Find the IRQ for your USB controller
cat /proc/interrupts | grep xhci

# Pin to CPU 2 (example; adjust to a non-hyperthreaded core)
echo 4 | sudo tee /proc/irq/<IRQ_NUMBER>/smp_affinity

irqbalance can undo manual affinity settings — disable it on a dedicated audio server:

sudo systemctl disable --now irqbalance

PipeWire real-world profile

The following combination is stable on a Pi 4 (4-core A72) used as a relay endpoint:

quantum = 1024 frames (21 ms)
clock.rate = 48000
governor = performance
LimitRTPRIO = 95

On an x86_64 server (mixer role, Ubuntu 24.04, generic kernel 6.17):

quantum = 128 frames (2.67 ms)
clock.rate = 48000
governor = performance
preempt = full
LimitRTPRIO = 95
rmem_max = 67108864

Measured on stg-srv001 with the above:

  • DSP ewma: 19 µs (of 2666 µs budget — 99 % headroom)
  • DSP peak: 36 µs
  • JACK xruns: 0 (sustained operation)

Realtime tuning recipe

End-to-end procedure to bring a mixer server from defaults (1024 frames,
powersave, voluntary) to 128 frames stable. Each step is reversible.

0. Audit prerequisites

# rtkit running
systemctl status rtkit-daemon | head -3

# user in audio group (with rtprio + memlock allowance)
groups | grep audio
grep -h "" /etc/security/limits.d/audio.conf /etc/security/limits.d/99-shiloh-rt.conf 2>/dev/null

# JACK callback thread (inside the mixer process) is SCHED_FIFO
PID=$(pgrep -f shiloh-mixer-linux | head -1)
ps -L -p $PID -o tid,cls,rtprio,comm | grep pw-data-loop
#  → "FF 83 pw-data-loop"  ← required

If pw-data-loop is not FF, rtkit isn’t granting RT. Check rtkit
status and that the mixer is launched via pw-jack.

Note: the pw-data-loop thread name is PipeWire-specific. Under native
jackd, the RT callback thread may have a different name (e.g.
shiloh-mixer or a thread named after the JACK client). Grep for
FF (SCHED_FIFO) threads under the mixer process instead:
ps -L -p $PID -o cls,rtprio,comm | grep FF.

1. Flip CPU governor to performance

Eliminates DVFS jitter — single biggest improvement before touching the
buffer size. See CPU frequency governor.

2. Switch preempt to full

Required below 256 frames. See Kernel preempt mode.

3. Ramp the PipeWire quantum down in steps

Pin in halves, holding at each level long enough to measure xrun delta
from ~/mixer-state/diag.json. Restart the mixer between steps
shiloh-mixer’s JACK client latches its buffer size at activation and
doesn’t renegotiate live.

for Q in 512 256 128; do
  pw-metadata -n settings 0 clock.force-quantum $Q
  systemctl --user restart shiloh-mixer
  sleep 30
  jq '{jack, process}' ~/mixer-state/diag.json
  # check xrun_count and headroom_us; abort if either is bad
done

Persist 128 with a conf.d drop-in (see Change quantum (permanent)).

4. Verify after reboot

# All three should be in effect:
cat /sys/devices/system/cpu/cpu0/cpufreq/scaling_governor   # → performance
cat /sys/kernel/debug/sched/preempt                          # → (full)
jq '.jack.buffer_size' ~/mixer-state/diag.json               # → 128

Rollback

Each layer reverts independently:

# governor
sudo systemctl disable --now cpu-performance.service
sudo cpupower frequency-set -g powersave

# preempt (next reboot)
sudo sed -i "s/ preempt=full//" /etc/default/grub
sudo update-grub
# or immediately:
sudo bash -c 'echo voluntary > /sys/kernel/debug/sched/preempt'

# quantum
rm ~/.config/pipewire/pipewire.conf.d/quantum.conf
pw-metadata -n settings 0 clock.force-quantum 0   # unpin
systemctl --user restart pipewire wireplumber pipewire-pulse
systemctl --user restart shiloh-mixer

Profiling xruns

To capture which process caused an xrun:

# Enable JACK xrun callback logging (verbose mode)
RUST_LOG=debug ~/mixer/shiloh-mixer-linux-x86_64 2>&1 | grep -i xrun

# Check system-level scheduling misses
sudo perf sched record -a sleep 10
sudo perf sched latency | head -20

High latency from kernel threads (IRQ handlers, kworkers) usually indicates that irqbalance is competing with your RT audio thread. See IRQ affinity section above.