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.
| 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) |
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'saudio-Nthreads 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.
performancegovernor, deep C-states (C2/C3 ACPI) disabled on the P-cores only, Nvidia PowerMizer to max. - Interrupts.
snd_hdspeIRQ pinned to one P-core; the two noisiest IRQs (xhci, nvidia) pushed to the E-cores. - Memory.
vm.swappiness=10,vm.min_free_kbytes=256Mso a realtime thread never lands in direct reclaim. - Storage. NVMe queue scheduler set to
nonefor 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.
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.
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.
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
./start-bitwig.sh # start a session
./start-bitwig.sh --restore # clean up after a crash that skipped the trapEnvironment 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.
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.pyis what finally located it — raising the sampling rate beat reaching forperfandbpftrace, 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.