Skip to content

Multi-core processing

Settings › Audio › Processing Threads (AudioConfig::processing_threads, in threads.rs) lets independent strips play at the same time, on a small pool of real-time threads with the audio callback working too. Its output is bit for bit the serial loop’s, so nothing else in the program has to know. With the setting at 1 there is no pool: process_block runs the plain serial loop.

One strip, two kinds of route. run_strip in dsp/strip.rs is one strip’s whole block, and the serial loop and the workers run that same code. They differ only in Routes, how a strip reaches other strips. The serial Direct writes straight into later strips’ sum_l/sum_r and the bus. On the pool, in dsp/parallel.rs, nothing writes across strips: Deferred leaves a strip’s output in its own l/r and renders each send into its own Outlets, and a bus adds up its feeders’ outlets when its turn comes.

Why the sum is bit-identical. Float addition isn’t associative, so the order must be the serial loop’s: feeders by processing order, and within a feeder its pre-fader sends by slot, its output, its post-fader sends by slot. Deferred::inputs reads them in exactly that order (a task’s dependencies in TaskGraph are sorted by processing position), and Stereo Out’s bus is summed the same way once the strips are done. A send’s product l[i] * g is the same bits wherever it is computed. This is why a switch between serial and parallel at any block boundary is inaudible.

The graph. TaskGraph::of_project (dsp/graph.rs) makes every track a task and every route an edge: outputs, sends, side-chain keys and multi-output sources, from Project::feeds and keyed_by. Tasks are in process_order, so edges only point forward and a cycle can’t be built (TaskGraph::new refuses one). VCAs and stacks need no edge, since the session folds them into each track’s TrackParams. Folder automation doesn’t either: a subtrack doesn’t wait for its folder, because Parallel::run reads the folder’s lanes (fold_at) before the strips start.

Keeping the graph true. The session builds the graph off the audio thread and sends it in Cmd::Graph (Session::sync_parallel, called after every Tauri command and from idle, whenever a route-changing Cmd went out). The old one comes back as Garbage. The commands of one edit can straddle blocks, so the engine doesn’t take the graph on trust: whenever routes or the graph changed (resolve_keys), parallel::covers checks at the next block that every route the engine resolved has its edge, and until that holds it plays serially. So a route never races and never arrives a block late; at worst an edit costs a few serial blocks. Deferred::earlier also checks the edge on each read, so a side-chain key whose edge is somehow missing is silence rather than undefined behaviour. soak_check checks after every random edit that the graph fits.

The pool (dsp/pool.rs) holds no lock or queue. A task is a bit in a few words of atomics; finishing a task’s last dependency sets its bit, and whoever clears a bit owns the task, preferring tasks “at home” on that thread so a strip tends to stay on one core from block to block. The guarantees:

  • The callback is never stuck behind a worker for ready work. A ready task can be seen and taken by anyone, so there is no slot a preempted worker could be holding up, and a preempted or late worker costs only the one task it has taken. Rarely does the callback have to wait with nothing to take: it spins for 300 µs, then naps 50 µs at a time, which also lets a worker at normal priority run if it shares this core.
  • A stuck worker can’t hang the callback. After GIVE_UP_SECS (0.1 s, or four buffers if that’s longer) process gives the block up: silence, counted as an overload, so the session stops the transport and says so. The block stays open (Parallel::stalled) and the strips and all it reads belong to the threads playing it. Until it ends, each callback plays silence and applies no commands; then the engine flushes notes and carries on, shedding as after any overload. Anything that changes the graph off the audio thread (replace_graph, render, dropping the engine) first waits for it (Engine::settle).
  • A panic stays in its task. It is caught, counted in engine_errors, its dependants still run and the block is silenced. The pool isn’t poisoned.
  • Workers really sleep. A real-time thread that never blocks is killed at RLIMIT_RTTIME’s hard limit (200 ms). A worker with nothing to do spins for 30 µs at most, then parks on a futex. Blocks arriving back to back could keep it from ever truly sleeping, so one that hasn’t slept for RT_GUARD (50 ms) naps before its next task; the callback does the work meanwhile, and OVERLOAD_SECS sheds load as ever when the callback itself falls behind. survive_slow_blocks covers workers too, and every worker’s load counts toward the same block, so the meter and the shedding still see a late block. Past that a plug-in that never returns on a worker is what it always was on the callback: the kernel’s limit applies.

Real time. Workers call audio_thread_priority::promote_current_thread_to_real_time, as cpal does for its callback (rtkit, the same priority and limits), and stay at normal priority if refused. On a machine with performance and efficiency cores they keep to the performance cores (Topology::fast), as the scheduler places real-time threads without regard to a core’s speed: measured at 128 frames, that took 24 CHOWTapeModels from a mean of 340 µs to 270 µs and the 99th percentile of 5 TONE3000s from 1510 µs to about 900 µs, repeatably. They keep to the set, not one core each, so the kernel still balances them, and the callback, the driver’s thread, is left alone. Workers aren’t real time on the null device, as cpal’s callback isn’t there either. A worker has an 8 MB stack, as plug-ins expect of an audio thread, and touches the first part of it before its first block.

When it runs. Parallel::wanted: there are workers, the graph holds, two strips with plug-ins can overlap (heavy_width), and the load has reached 15% of the buffer (ENGAGE_LOAD), going back to one thread once the average load times the threads, the most it could come to alone, is under that. The session doesn’t even start the threads for a project with nothing to gain. Below that a project pays nothing at all for the feature. Automatic is a fast (performance) physical core count less one, at most 6 (threads::automatic); on 1 or 2 cores it is 1, and away from Linux too, where workers can’t get real-time priority yet.

Offline renders stay serial: their blocks come back to back with no real-time deadline, and real-time workers that never sleep would be killed for it. ParallelTest::renders sends them through the pool for parallel_check and soak_check, so the route is tested for identity.

What a block touches, and who may:

State Where it’s used How it’s kept safe
Commands, Garbage, cmd_rx process, before the strips Calling thread only; the graph and workers change only here, between blocks.
garbage: RefCell discard, from apply Calling thread only; the strips’ context doesn’t hold the engine, so the compiler sees to it.
live_midi, live Filled before the strips Read-only for the strips.
midi_rec (recorded MIDI ring) Also pushed by process (cycle marks) Moved before the strips, for the serial loop too; it comes from one track anyway.
analyzer_tx (SPSC, several pushers) A strip’s tap, Stereo Out’s On the pool the watched strip fills Outlets::analysis, and the calling thread pushes it after the strips. Stereo Out’s is on the calling thread.
inputs Copied in before the strips Read-only for the strips.
bus_l, bus_r Stereo Out Summed by the calling thread after the strips, in order.
tap_*, gain_* A strip’s scratch Scratch, one per thread.
sum_l, sum_r Serial loop only Unused on the pool, where a bus sums into its own l/r.
Strip’s own state (TrackDsp) Its task One task, one thread at a time, handed over by the pool’s Acquire/Release.
A feeder’s l, r, outs, key_* Its receivers Only read once its task is done (an edge), and by any number of receivers at once.
An instrument’s output buses, instrument_ran Output auxes The same: the instrument’s task is done.
A folder’s params, automation Its subtracks’ faders Read before the strips start (fold_now).
params, tempo, Isolate, chase, midi_target, transport values Every strip BlockCtx: copied or shared immutably; commands are applied only between blocks.
Regions and clips Their strip Shared read-only behind Arc.
TransportInfo for plug-ins Each strip A copy per strip, moved to its own input_time.
Plug-in instance Its strip Only its strip’s task calls it, so one thread at a time; which thread can change from block to block, as VST3 allows.
Gate, OutGate (VST3 bus changes) process, suspend, the controller Atomics, unchanged. Cmd::VstGate is applied between blocks; the task holds OutGate through the call.
Meters (Meter) Each strip’s own, Stereo Out’s Atomics, per strip.
EngineStatus counters (bad_audio, overruns, engine_errors, midi_activity) Any strip Atomic adds. bad_audio_uid is a plain store, so with two bad plug-ins in a block either may be named.
Input meters, cpu_*, late_blocks Before and after the strips Calling thread only.
t.rec (recording ring) Its strip One producer per track.
Bad-audio containment (check_output) Each plug-in, its strip Works on the strip’s own buffers and effect.
Effect ticks (FxShared) Each effect Atomics, per effect.
Global state in plug-ins Inside third-party code As in any host that plays instances in parallel, which is their concern. No mutable globals in fx/, instrument/, synth_dsp/, synth.rs or vst3-host’s processing; an instrument’s randomness comes from its slot’s seed.

The unsafe is in parallel.rs and pool.rs, each with its argument beside it: StripJob::run and Deferred::dep (who holds which strip), the job cell (only written with no block open), and extending BlockCtx’s lifetime (what it points at is on the heap and unchanged while a block is open). The parallel tests, parallel_check and soak_check ran clean under ThreadSanitizer: see DEVELOPING.md.

Plug-ins with threads of their own. TONE3000 runs its model on its own pool of worker threads, and the Linear Phase EQ designs filters on a thread. Their output depends on timing, serial or not, which is why parallel_check doesn’t use TONE3000, and swaps the EQ out live (a render waits for it).

The CPU meter (Tick.cpu, cpu_peak, late_blocks) is still the wall-clock time of the callback over the buffer’s duration, as that is what decides a dropout. Tick.threads says how many threads are playing right now, for its tooltip.

Measured on an i7-13700KF (8 performance and 8 efficiency cores, 24 hardware threads), driving Engine::process at real-time pace with real-time workers: block time in µs, mean / worst of about 8 s. The user’s own app was running on the same machine. Real plug-ins: 5 TONE3000s with a real project’s models, 24 CHOWTapeModels, 16 Dragonfly Hall Reverbs, and “buses”: 12 CHOWTapeModels in 3 summing stacks with a shared Dragonfly Plate Reverb on an aux.

Project Buffer 1 thread 2 4 8 Automatic (6)
5 TONE3000 512 2822 / 5447 1723 / 2714 1237 / 2165 1060 / 1393 1099 / 1833
24 CHOWTapeModel 512 4822 / 5852 2512 / 3009 1463 / 1603 754 / 859 979 / 1086
16 Dragonfly Hall 512 1686 / 2834 891 / 1234 622 / 737 387 / 495 546 / 682
buses 512 2458 / 3033 1254 / 1556 707 / 885 496 / 573 778 / 881
5 TONE3000 128 1371 / 2729 549 / 1700 337 / 927 331 / 1049 350 / 1036
24 CHOWTapeModel 128 1330 / 1988 794 / 1026 457 / 582 206 / 312 269 / 418
16 Dragonfly Hall 128 430 / 805 221 / 400 187 / 262 110 / 173 111 / 174
buses 128 1186 / 1448 350 / 547 240 / 367 142 / 236 155 / 235

A small project, where threads have nothing to gain, at 128 and 512 frames: mean / worst µs.

Project Buffer 1 thread Automatic Forced onto 6 threads Forced onto 2 threads
2 audio tracks 512 8 / 15 10 / 24 16 / 42 12 / 27
2 tracks with 2 built-in effects each 512 81 / 130 134 / 168 68 / 93 48 / 92
2 audio tracks 128 3 / 11 5 / 16 4 / 18 4 / 23
2 tracks with 2 built-in effects each 128 35 / 58 20 / 45 16 / 29 21 / 43

Reading it: wide projects scale close to the thread count. The worst block falls with the mean, except where one plug-in sets it: at 128 frames one of the TONE3000 models takes about 850 µs in some blocks, so past 4 threads its worst block stops improving. TONE3000’s own worker threads did no harm to it or to ours: no thread count made it worse than the one before beyond the noise. The one-thread column varies by up to a factor of two from run to run (5 TONE3000s at 128: mean 870 and 1370 µs), probably from whether the callback lands on an efficiency core. Going to 8 threads helps wide projects another 10 to 25% and does nothing for the 5-track project, and it would leave a performance core for nothing else, so Automatic stops at 6. Since the gate keeps a light project on the callback, these small projects pay nothing: without the gate the pool’s cost is about 10 µs a block, or none next to any real plug-in.