VOLTAreference

Reference / Work in Studio

Render on every core

Work in Studio

Open in the workbench →
On this page · 6 sections

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: volta exports, the MCP server's renders, tests.
  • In code: AudioEngine::set_render_threads(n) or set_render_pool(Some(pool)) before rendering; on a running device, EngineHandle::set_render_pool, with a pool built off the audio thread by parallel::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:

StepRunsNotes
Commands, arrangement, scheduling, macros, shredsserialthey trigger and steal voices from one shared pool
Every track's voicesone lane per trackthe anonymous voices (one-shots, shreds) are one more lane; voices that share a sympathetic bridge render together
Plugin instruments, generated tracks, previewsserialas before
Every track's strip — fader, pan, meters, detector, snapshotone lane per tracksends are computed here and held
Sends into the busesserial, in track order
Every track's inserts, and every return fed only by sendsone lane each, longest firsta return reverb runs beside the track inserts
Track and bus sums, other buses, the master chain, metersserial, 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; follower and duck read 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 tap with a stage, a tapLive of a bus, or a tapLive inside an insert or a control graph — renders on the routed path, serially for now. A song whose voices tapLive a 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, bufferserial6 workers8 workers (auto here)
cathode, 128 frames1 127 / 1 303353 / 411352 / 394
machina, 128754 / 854186 / 219190 / 225
hexatonic, 128766 / 877274 / 310263 / 293
cathode, 64561 / 646190 / 217192 / 215
machina, 64388 / 446103 / 129106 / 130
hexatonic, 64408 / 463159 / 178158 / 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::ParallelScratch is built control-side per module and shipped in the LoadPayload; growing it on the audio thread is an allocation tests/rt_safety.rs catches.
  • The pool's real-time rules (parallel.rs): no allocation, lock or syscall on the job path; tests/rt_safety_parallel.rs counts allocations on every worker.
  • A batch opens in one order. Workers claim indices by compare-and-swap on one generation << 32 | index word, so RenderPool::run closes 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_twice holds that window open.