Skip to content

Latest commit

 

History

7 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 

Repository files navigation

bitwig-rt-tuning

Pro-audio session tuning for Bitwig Studio + Kontakt/yabridge on Linux.

This is a personal project, heavily optimized for one specific machine. It is published as a record of an investigation, not as a tool to install. Nearly every value in it — core numbers, IRQ numbers, device names, the Wine prefix path, the sysctl baselines — is hard-coded for the hardware below. Running it unmodified on any other system will, at best, do nothing useful, and at worst pin your audio chain to cores that do not exist. Read it, take the ideas, and rewrite the constants for your own box.

The machine it was written for

CPU Intel i9-13900K — hybrid, CPUs 0–15 P-cores, 16–31 E-cores
Audio interface RME HDSPe AIO Pro (snd_hdspe, IRQ 16)
RAM 32 GB
Kernel 7.2.2-cachyos-rt-bore-lto
Audio stack PipeWire 1.6.8 / WirePlumber 0.5.15, quantum 256/48000
Host Bitwig Studio, native PipeWire client
Plugins Kontakt 6, FM8, Diva via yabridge 5.1.1-57-gb580a9f7 (upstream master, Wine 11.16 TkG staging, ntsync)
GPU Nvidia (proprietary driver, IRQ 211)

What it does

start-bitwig.sh takes a baseline, applies session-scoped tuning, launches Bitwig, and restores everything on exit (including after a crash, via --restore and a state file under $XDG_RUNTIME_DIR).

  • CPU placement. Bitwig's tree is pinned to the P-cores; a background steward (tools/steer-threads.sh) then re-splits it by realtime priority — threads at rtprio ≥ 50 keep the P-cores, every other thread is pushed to the E-cores. One documented exception: yabridge's audio-N threads get the P-cores by name, because yabridge only raises them to rtprio 85 after the plug-in is activated. The steward also re-sweeps as soon as a new process appears, not just on its timer. This is the fix that actually mattered; see below.
  • Power. performance governor, deep C-states (C2/C3 ACPI) disabled on the P-cores only, Nvidia PowerMizer to max.
  • Interrupts. snd_hdspe IRQ pinned to one P-core; the two noisiest IRQs (xhci, nvidia) pushed to the E-cores.
  • Memory. vm.swappiness=10, vm.min_free_kbytes=256M so a realtime thread never lands in direct reclaim.
  • Storage. NVMe queue scheduler set to none for the session — preventive, for streaming libraries. The A/B that was supposed to justify this watched the wrong device, so it is unmeasured, not null — see F1 in the investigation.
  • Noise. EasyEffects stopped for the session (and restarted after, only if the script was the one that stopped it). yabridge STDERR logging off by default: it cost 71.6 ms per 5 s across the plugin hosts with 11 instances.

The finding

Sporadic audible glitches at quantum 256, with Load MAX hitting 7.5–8.0 ms against a 5.333 ms deadline, at near-zero DSP load.

It was not throughput and not the buffer size. It was CPU placement: the single taskset that pinned Bitwig to the P-cores was inherited by the whole process tree, so 16 cores carried 121 realtime audio threads and 412 non-realtime ones — the JVM UI, wineserver, explorer.exe, the NI services. Aggregate load was about 6 % per P-core. wineserver is single-threaded, SCHED_OTHER nice 0, and every Wine process calls into it synchronously: a textbook priority inversion.

Splitting the tree by rtprio instead of by process fixed it. Load MAX 1.911 ms, period jitter 9.41 % → 0.75 %, zero deadline misses in 90 s, quantum unchanged at 256.

Two consequences worth repeating:

  • Pinning without the steward is worse than not pinning at all. The script refuses that combination rather than starting a silently degraded session.
  • A 512 quantum only masked this. 256 holds with 11 plugin instances once the chain is split correctly.

And a second one: the plugin-load spike

Steady state healthy, but one spike every time a plugin is instantiated — Load MAX 2.05–2.12 ms at 0.08–0.12 ms average.

Same class of cause, opposite direction. fork() gives a child the calling thread's affinity, and Bitwig forks BitwigPluginHost from a JVM worker thread that the steward has already moved to the E-cores. So the host is born inside 16-31 and creates its 33 SCHED_FIFO 85 audio threads there — where they stay until the next 15 s sweep. Measured: 8.7 s of a 6.8 s plugin load with the audio threads on 4.3 GHz cores.

Not fixable by pre-setting the inherited mask: there is no main thread to pre-set, because the JVM does not fork from main. --watch now polls /proc every 0.25 s and sweeps in a 12 s burst when a matched process appears. E-core window 8.7 s → 0.52 s, Load MAX 2.054 → 1.867 ms, Load AVG 0.104 → 0.064 ms, worst callback on-CPU 2.075 → 0.455 ms.

Then the same bug one level down. yabridge names its per-plugin audio thread audio-N at creation but only elevates it to SCHED_FIFO 85 when the host activates the plugin — so during the load it is FIFO 5, which the rtprio rule correctly reads as "not audio" and sends to the E-cores. Kontakt's first process() therefore ran at 4.3 GHz: 1.619 ms on-CPU in a single 2 ms window against a 0.028 ms median. Promoting by name (scoped to that process, so the other FIFO-5 Wine threads stay put) put it on a P-core before the activation callback: 0.945 ms, Load MAX 1.861 → 1.192 ms.

Finding it needed a 2 ms sampling rate — at 20 ms a window holds ~3.75 callbacks and averages the one expensive one away — plus kernel.sched_schedstats=1, whose sum_block_runtime = 0, iowait_sum = 0 and wait_max = 0.538 ms (a lifetime maximum) ruled out IO and scheduling delay outright. Ruled out along the way, each with numbers: priority inversion, disk, clocks, Bitwig's graph rebuild, a 2.66 M minor-fault storm, and DXVK/lavapipe.

Load MAX 2.115 → 1.192 ms overall, quantum unchanged.

The full write-up — including the corrections, the dead ends, and the hypotheses that measured as wrong — is in docs/dsp-spike-investigation.md.

Layout

start-bitwig.sh                 session: tune, launch, restore
perf.sh                         standalone one-shot tweaks (no restore)
tools/steer-threads.sh          the rtprio split; --watch loop and --restore
tools/measure-xruns.sh          deadline misses on the playback path
tools/catch-spike.py            find the thread burning CPU in a spike
tools/catch-stall.py            tell a long *run* apart from a long *wait*
tools/catch-load.py             sample the audio chain across a plugin load
tools/summarize-load.py         reduce a catch-load.py run to the five decisive views
tools/faultgen.c                controlled minor-fault storm, to test memory pressure
tools/run-arm.sh                one measurement arm: sampler + a tuned Bitwig session
tools/ab-nvme-sched.sh          A/B the NVMe scheduler in one live session
docs/dsp-spike-investigation.md the investigation
docs/kontakt7-zmq-crash.md      why the yabridge host needs a cwd inside the wine prefix
docs/measurements/              raw logs behind the claims
docs/reference-*                config files and wrappers this setup depends on, for reference

Usage

./start-bitwig.sh              # start a session
./start-bitwig.sh --restore    # clean up after a crash that skipped the trap

Environment knobs, mainly for A/B testing:

var default effect
PIN_BITWIG 1 0 = no CPU placement at all
STEER_THREADS 1 0 = pin, but do not re-split by rtprio
STEER_INTERVAL 15 seconds between steward sweeps
YABRIDGE_LOG 0 1 = enable yabridge debug log (costs DSP)
YABRIDGE_DEBUG_LEVEL 1 when logging 2 = also trace every bridged call. Level 0 logs errors only, which is not enough to see a plugin editor fail to embed
YABRIDGE_DEBUG_FILE /tmp/yabridge.log where that log goes

tools/steer-threads.sh has its own knobs. It inherits the environment from start-bitwig.sh, so setting them on the session command line reaches it:

var default effect
POLL 0.25 seconds between new-process scans
BURST 12 seconds of fast sweeping after a new process appears
RT_MIN 50 rtprio at or above which a thread keeps a P-core
LATE_RT_NAMES ^audio-[0-9]+$ thread comms promoted by name
LATE_RT_PROCS ^yabridge-host\.e$ processes the name rule applies in
WINESERVER_NICE -10 nice level forced on wineserver
PCORES / ECORES 0-15 / 16-31 the split itself

Requires passwordless-ish sudo (the script keeps the timestamp alive for the length of the session), cpupower, taskset, nvidia-settings, and pw-top.

Measuring

Don't trust Bitwig's Load MAX for small differences — two identical runs measured 5.402 and 6.052 ms. Use tools/measure-xruns.sh, watch the output node (the RME capture node has no links and its error count is a red herring), and run for at least 300 s: glitch bursts have 60–90 s gaps, so short windows give false "fixed" readings.

Verify placement before trusting any measurement. The steward can fail to launch with no symptom other than a bad Load MAX.

Two things learned the hard way while chasing the plugin-load spike:

  • Load is wall-clock per callback, not one thread's CPU time. Bitwig reports its own callback time plus whatever it spends waiting for the plug-in. A 1.86 ms Load MAX came out as 0.35 ms in Bitwig's audio thread and 1.62 ms in yabridge's, in the same 2 ms window. Attributing Load to a single thread will send you after the wrong one.
  • Sample fast enough to isolate one callback. At 20 ms a window holds ~3.75 callbacks at quantum 256, so a single expensive one is averaged into invisibility. The 2 ms rate in tools/catch-load.py is what finally located it — raising the sampling rate beat reaching for perf and bpftrace, both of which were installed and turned out unnecessary.

schedstat cannot see block time at all: it accounts for on-CPU and runqueue-wait time, and a thread asleep on a futex is in neither. With kernel.sched_schedstats=1, /proc/<tid>/sched adds sum_block_runtime, iowait_sum and wait_max — and note those are float milliseconds, so parsing them as integers throws away exactly the resolution they exist for.

About

Pro-audio session tuning for Bitwig Studio + Kontakt/yabridge on Linux.

Resources

Stars

1 star

Watchers

1 watching

Forks

Releases

Packages

Contributors

Languages