Volta can render each audio block's tracks on several CPU cores at once. The audio is bit-for-bit the audio one core renders — the same samples, the same WAV bytes — so the only thing that changes is how long a block takes. Turn it on when a heavy song glitches at a small device buffer, or to export faster. Long convolutions can also go to the GPU: Run DSP on the GPU.
Turn it on#
- In Studio: Session → Audio/MIDI I/O… → AUDIO OUT → dsp threads. serial is the default; auto is every core but two, at most eight; or pick 2, 4, 6 or 8 workers. It applies immediately — no stream restart, no reload — and the readout beside it counts the lanes (the workers plus the audio thread). Studio remembers the setting.
- For renders and tools: set
VOLTA_RENDER_THREADS=auto(or a worker count) in the environment. Every engine the process creates then renders on one shared pool:voltaexports, the MCP server's renders, tests. - In code:
AudioEngine::set_render_threads(n)orset_render_pool(Some(pool))before rendering; on a running device,EngineHandle::set_render_pool, with a pool built off the audio thread byparallel::PoolConfig::realtime(workers, BLOCK, sample_rate).parallel::set_default_render_threads(n)sets the process default.
What runs side by side#
One block, in the order the engine runs it:
| Step | Runs | Notes |
|---|---|---|
| Commands, arrangement, scheduling, macros, shreds | serial | they trigger and steal voices from one shared pool |
| Every track's voices | one lane per track | the anonymous voices (one-shots, shreds) are one more lane; voices that share a sympathetic bridge render together |
| Plugin instruments, generated tracks, previews | serial | as before |
| Every track's strip — fader, pan, meters, detector, snapshot | one lane per track | sends are computed here and held |
| Sends into the buses | serial, in track order | |
| Every track's inserts, and every return fed only by sends | one lane each, longest first | a return reverb runs beside the track inserts |
| Track and bus sums, other buses, the master chain, meters | serial, in order |
The longest lanes start first (by last block's measured cost), and the audio thread works lanes too. A live block forks only when the lanes would save at least 20 µs (voices: once they total 40 µs); otherwise it stays on the audio thread. Workers are woken at the top of a block that forked last time, so the wake-up overlaps the block's serial start.
Same audio, every time#
A parallel block is byte-identical to a serial one because no lane writes anything another track reads in the same block:
- every value summed across tracks — sends into a bus, a track into the master or a sub-mix — is added on the audio thread in track order, the order the serial loop adds them;
- a send is computed in its lane (
signal × gain, the gain smoothed exactly as before) and only added later — the same arithmetic, so the same bits; - the sidechain detector's new window is published after the lanes join;
followerandduckread it a block behind, a part of the ring that block never touches; - each worker adopts the calling thread's floating-point mode (flush-to-zero, rounding) for every batch.
engine/tests/parallel_null.rs proves it: the corpus sample plus fixtures for sends and presends, sidechains and taps, a sub-mix fed by one-shots, strings sharing a bridge across tracks, a monitored insert, a hot swap mid-render and the ordered tapLive waves, at 1 and 4 workers and under a ragged callback partition, compared byte for byte. Every other engine test also passes with the mode forced on (VOLTA_RENDER_THREADS=4).
When it stays on one core#
- Staged routes. A song with a staged routing plan — a
tapwith astage, atapLiveof a bus, or atapLiveinside an insert or a control graph — renders on the routed path, serially for now. A song whose voicestapLivea track renders in waves: each track that reads a current-block bus starts a new wave, and the tracks between readers run side by side. - Neural decode units (
decode,neuralOsc,neuralPrior) keep a song serial: their voices write the unit's controls, last writer wins. - Hosted plugins and the insert monitor. A track whose chain hosts a native plugin, or that the Rack's ◉ insert monitor taps, keeps its inserts on the audio thread; its voices and strip still run in parallel.
- The browser renders serially (an AudioWorklet has one thread).
How much faster#
Measured on an M5 Max (18 cores) with every track playing, not listened to.
Live, paced like a device — one buffer per period on a real-time thread for 60 s (engine/examples/deadline-bench.rs); µs per buffer, p50 / p99:
| Song, buffer | serial | 6 workers | 8 workers (auto here) |
|---|---|---|---|
cathode, 128 frames | 1 127 / 1 303 | 353 / 411 | 352 / 394 |
machina, 128 | 754 / 854 | 186 / 219 | 190 / 225 |
hexatonic, 128 | 766 / 877 | 274 / 310 | 263 / 293 |
cathode, 64 | 561 / 646 | 190 / 217 | 192 / 215 |
machina, 64 | 388 / 446 | 103 / 129 | 106 / 130 |
hexatonic, 64 | 408 / 463 | 159 / 178 | 158 / 174 |
No buffer overran its deadline (2 667 µs at 128 frames, 1 333 µs at 64) in any run, serial included: these songs already fit on this machine, so here the pool buys headroom — room for more tracks, a smaller buffer or a slower machine. A song too light to fork plays at its serial time.
Offline, two-bar renders of the corpus sample (engine/examples/parallel-bench.rs): heavy songs render 2–4× faster at 7 workers (cathode 3.2×, machina 4.1×, hexatonic 3.0×, orrery 2.6×); light songs gain little.
The ceiling is the heaviest single track plus the serial master section, so a song whose weight is one track or the master chain is a freeze candidate (Freeze a track to save CPU) rather than a threads one.
For contributors#
- A lane never writes shared state. Per-track work goes in
lane.rs(strip,stage_send,fx,bus_fx); anything it would add into a bus or the master is staged and added by the caller in index order. - Lane scratch is sized at install.
lane::ParallelScratchis built control-side per module and shipped in theLoadPayload; growing it on the audio thread is an allocationtests/rt_safety.rscatches. - The pool's real-time rules (
parallel.rs): no allocation, lock or syscall on the job path;tests/rt_safety_parallel.rscounts allocations on every worker. - A batch opens in one order. Workers claim indices by compare-and-swap on one
generation << 32 | indexword, soRenderPool::runcloses that word under the new generation before it touches the job, the count or anything else, and opens it last. Changing that order lets a worker still holding the last batch's word take an index of the next one;growing_batches_never_run_an_index_twiceholds that window open.