Performance¶
The numbers on this page come from the scripts in examples/benchmarks/.
They measure the current (post-dasp-parity) processors.
See Performance notes on the Faust NNX backend for the codegen findings that explain these numbers.
CPU vs GPU throughput (Linux/WSL2)¶
examples/benchmarks/bench.py runs on a single machine: an Intel i9-12900KF (24 threads) and an NVIDIA RTX 4080 SUPER under WSL2.
The GPU run uses jax 0.10.2 with the gpu dependency group (jax[cuda13]).
The CPU run sets JAX_PLATFORMS=cpu on the same install.
The input is 1 s of mono noise at 44.1 kHz.
Each time is the median of 5 runs after one compile/warmup call.
“RT” is the aggregate real-time factor: batch × audio seconds per second of wall time.
“grad” is the forward pass plus the backward pass with respect to the parameters.
The tables omit Freeverb’s gradient on both backends.
Freeverb has a scan carry of ~100 kB, which makes second-scale BPTT infeasible (see the Guidance section below).
CPU (--batch-sizes 1 8):
processor |
batch |
unroll |
fwd ms |
fwd RT |
grad ms |
grad RT |
|---|---|---|---|---|---|---|
Gain |
1 |
1 |
0.1 |
9,113.2x |
0.3 |
3,092.9x |
Gain |
8 |
1 |
0.4 |
18,872.4x |
0.5 |
15,820.1x |
Gain |
1 |
8 |
0.2 |
4,227.9x |
0.2 |
5,489.6x |
Gain |
8 |
8 |
0.5 |
16,337.4x |
1.2 |
6,518.9x |
ParametricEQ |
1 |
1 |
62.4 |
16.0x |
1,347.1 |
0.7x |
ParametricEQ |
8 |
1 |
60.6 |
132.1x |
790.5 |
10.1x |
ParametricEQ |
1 |
8 |
90.1 |
11.1x |
454.9 |
2.2x |
ParametricEQ |
8 |
8 |
112.4 |
71.1x |
671.2 |
11.9x |
Compressor |
1 |
1 |
0.7 |
1,334.8x |
1.9 |
513.6x |
Compressor |
8 |
1 |
4.8 |
1,660.7x |
101.0 |
79.2x |
Compressor |
1 |
8 |
4.3 |
234.8x |
49.9 |
20.0x |
Compressor |
8 |
8 |
14.5 |
551.4x |
88.5 |
90.4x |
Freeverb |
1 |
1 |
112.1 |
8.9x |
— |
— |
Freeverb |
8 |
1 |
236.9 |
33.8x |
— |
— |
Freeverb |
1 |
8 |
429.0 |
2.3x |
— |
— |
Freeverb |
8 |
8 |
1,787.2 |
4.5x |
— |
— |
GPU (--batch-sizes 1 8 64 256):
processor |
batch |
unroll |
fwd ms |
fwd RT |
grad ms |
grad RT |
|---|---|---|---|---|---|---|
Gain |
1 |
1 |
398.1 |
2.5x |
997.6 |
1.0x |
Gain |
8 |
1 |
427.9 |
18.7x |
979.9 |
8.2x |
Gain |
64 |
1 |
383.6 |
166.8x |
1,009.6 |
63.4x |
Gain |
256 |
1 |
381.1 |
671.7x |
976.8 |
262.1x |
Gain |
1 |
8 |
53.1 |
18.8x |
179.6 |
5.6x |
Gain |
8 |
8 |
56.5 |
141.7x |
124.4 |
64.3x |
Gain |
64 |
8 |
53.0 |
1,207.5x |
125.1 |
511.7x |
Gain |
256 |
8 |
51.5 |
4,972.5x |
141.6 |
1,808.3x |
ParametricEQ |
1 |
1 |
1,436.3 |
0.7x |
3,896.6 |
0.3x |
ParametricEQ |
8 |
1 |
1,088.0 |
7.4x |
3,206.8 |
2.5x |
ParametricEQ |
64 |
1 |
1,084.0 |
59.0x |
3,318.8 |
19.3x |
ParametricEQ |
256 |
1 |
1,108.0 |
231.0x |
3,376.0 |
75.8x |
ParametricEQ |
1 |
8 |
360.2 |
2.8x |
782.3 |
1.3x |
ParametricEQ |
8 |
8 |
291.1 |
27.5x |
693.0 |
11.5x |
ParametricEQ |
64 |
8 |
181.3 |
353.0x |
629.3 |
101.7x |
ParametricEQ |
256 |
8 |
165.3 |
1,548.9x |
625.8 |
409.1x |
Compressor |
1 |
1 |
586.1 |
1.7x |
1,119.6 |
0.9x |
Compressor |
8 |
1 |
567.3 |
14.1x |
1,109.0 |
7.2x |
Compressor |
64 |
1 |
573.6 |
111.6x |
1,130.4 |
56.6x |
Compressor |
256 |
1 |
590.5 |
433.5x |
1,124.5 |
227.7x |
Compressor |
1 |
8 |
105.9 |
9.4x |
227.6 |
4.4x |
Compressor |
8 |
8 |
104.5 |
76.6x |
197.4 |
40.5x |
Compressor |
64 |
8 |
102.1 |
627.0x |
203.1 |
315.1x |
Compressor |
256 |
8 |
185.4 |
1,380.9x |
228.3 |
1,121.1x |
Freeverb |
1 |
1 |
1,467.6 |
0.7x |
— |
— |
Freeverb |
8 |
1 |
1,214.8 |
6.6x |
— |
— |
Freeverb |
64 |
1 |
1,250.7 |
51.2x |
— |
— |
Freeverb |
256 |
1 |
1,423.2 |
179.9x |
— |
— |
Freeverb |
1 |
8 |
839.0 |
1.2x |
— |
— |
Freeverb |
8 |
8 |
767.9 |
10.4x |
— |
— |
Freeverb |
64 |
8 |
811.2 |
78.9x |
— |
— |
Freeverb |
256 |
8 |
890.2 |
287.6x |
— |
— |
The GPU’s wall time is almost constant across batch sizes.
Gain at unroll 8 costs ~52 ms at batch 1 and also at batch 256.
As a result, the aggregate real-time factor increases with the batch size (18.8x → 4,972.5x).
The GPU has a high per-step latency in exchange for this batch width.
Every batch-1 forward pass takes hundreds of milliseconds.
This is one to three orders of magnitude slower than the CPU (ParametricEQ forward 1,436 ms vs 62 ms; Compressor 586 ms vs 0.7 ms).
The crossover agrees with the ~64-lane rule in the Guidance section.
At batch 8, the CPU is faster in every column.
At batch 64, the GPU is faster in every column where the CPU’s threading no longer scales (EQ forward 353.0x vs 132.1x, Compressor gradient 315.1x vs 90.4x).
unroll has a much larger effect on GPU than on CPU.
On GPU, it decreases every batch-1 forward pass by 4–7x.
It also decreases Freeverb’s forward pass (1,468 → 839 ms).
On CPU, unrolling makes every effect slower, and it makes Freeverb 3.8x slower (112 → 429 ms).
Freeverb is also the only processor where the CPU’s batch-8 wall time is two times the batch-1 wall time.
Freeverb’s working set is delay lines, not arithmetic, so it saturates memory and not lanes.
Vmapped DX7 voices¶
examples/benchmarks/bench_voices.py compiles a full DX7 voice at runtime with faustax.compile.compile_dsp().
The voice is dx.algorithm(1) from the Faust checkout’s dx7 library, and it has 147 parameters.
The script then vmaps the per-sample render over voices with a per-voice frequency.
The gate stays on, and every other parameter stays at its default.
The machine and the timing protocol are the same as above.
The setting is unroll=2, which is the large-instrument setting (see the Guidance section):
backend |
voices |
wall ms |
RT aggregate |
|---|---|---|---|
CPU |
1 |
569.4 |
1.8x |
CPU |
16 |
1,382.4 |
11.6x |
CPU |
64 |
1,798.4 |
35.6x |
GPU |
1 |
2,335.0 |
0.4x |
GPU |
64 |
1,207.5 |
53.0x |
GPU |
256 |
1,241.3 |
206.2x |
GPU |
1,024 |
1,275.5 |
802.8x |
The instrument shows the same pattern as the effects, at synth scale. A single voice renders at 1.8x real time on CPU and at 0.4x on GPU. A 16-voice polyphonic patch is real-time on CPU (11.6x). From 64 voices up, the GPU is faster (53.0x vs 35.6x). The GPU’s wall time is almost constant through 1,024 voices. At 1,024 voices, the aggregate throughput is 802.8x, which is useful for offline rendering, parameter sweeps, and dataset generation.
Against dasp-pytorch (macOS arm64)¶
examples/benchmarks/bench_vs_dasp.py compares every processor that Faustax shares with dasp-pytorch, head-to-head on CPU.
The machine is Apple Silicon, with torch 2.13 and 4 threads.
The input is 1 s of audio at 44.1 kHz, and Faustax uses unroll=8.
On CPU, unroll=1 is the faster setting for these bodies (see the Guidance section), so the Faustax CPU columns below are conservative.
Both sides use the same physical parameter values.
“grad” is the forward pass plus the backward pass with respect to the effect parameters:
processor |
batch |
dasp fwd ms |
Faustax fwd ms |
dasp grad ms |
Faustax grad ms |
|---|---|---|---|---|---|
gain |
8 |
0.1 |
0.2 |
0.3 |
0.5 |
distortion |
8 |
0.3 |
0.3 |
0.5 |
1.1 |
parametric_eq |
1 |
9.2 |
37.2 |
34.2 |
283.4 |
parametric_eq |
8 |
26.8 |
46.1 |
83.6 |
481.7 |
compressor |
1 |
3.3 |
3.0 |
10.7 |
25.3 |
compressor |
8 |
11.1 |
8.0 |
28.6 |
35.7 |
noise_reverb |
1 |
8,958 |
447 |
14,791 |
477 |
stereo_panner |
8 |
0.3 |
0.8 |
2.9 |
3.9 |
stereo_widener |
8 |
0.3 |
0.3 |
1.3 |
0.8 |
stereo_bus |
8 |
0.4 |
0.3 |
1.5 |
1.1 |
The differences come from the architecture of each library.
The memoryless and stereo processors do the same math in both libraries.
Their times are equal within measurement noise.
The noise-shaped reverb is 20x faster forward and 31x faster in gradient in Faustax.
dasp applies its 65536-tap synthesized IR by direct conv1d.
Faustax applies the IR by FFT convolution.
The difference increases with the signal length.
The recursive effects are faster in dasp, by design.
dasp approximates the EQ and the compressor’s smoother in the frequency domain (one FFT per call).
Faustax renders the exact per-sample recurrence and differentiates it by BPTT.
The EQ costs ~4x forward and ~6-8x gradient for coefficient-exact IIR output.
The compressor shows that this cost can be small.
Its forward pass is faster than dasp’s at batch 8.
Its gradient is within 25% of dasp’s.
Faustax also applies release_ms, which dasp ignores.
Use dasp-style frequency sampling when the EQ gradient throughput is the most important factor.
Use Faustax when you need exactness, streaming parity with deployment, or fast reverb/dynamics training.
The reverb rows above are batch 1.
dasp’s direct convolution takes tens of seconds for each batch-8 call (--full runs them).
Against dasp-pytorch (GPU)¶
The same comparison runs on the RTX 4080 SUPER, via bench_vs_dasp.py --device cuda --batch-sizes 1 8 64 256 --full.
The environment is WSL2, with torch 2.13.0+cu130 and jax 0.10.2.
Both sides run on the GPU, and the torch timings synchronize the CUDA stream.
Both sides use the same physical parameter values.
Faustax uses unroll=8 for the recursive effects:
processor |
batch |
dasp fwd ms |
Faustax fwd ms |
dasp grad ms |
Faustax grad ms |
|---|---|---|---|---|---|
gain |
1 |
0.1 |
516.9 |
0.6 |
1,267.1 |
gain |
8 |
0.1 |
584.1 |
0.6 |
1,012.5 |
gain |
64 |
0.1 |
460.0 |
0.4 |
967.3 |
gain |
256 |
0.2 |
426.9 |
1.0 |
1,624.8 |
distortion |
1 |
0.1 |
522.1 |
0.4 |
684.2 |
distortion |
8 |
0.1 |
386.0 |
0.4 |
683.1 |
distortion |
64 |
0.1 |
452.1 |
0.4 |
700.7 |
distortion |
256 |
0.3 |
423.6 |
1.3 |
716.6 |
parametric_eq |
1 |
7.4 |
377.9 |
23.5 |
758.1 |
parametric_eq |
8 |
3.1 |
299.9 |
12.1 |
697.2 |
parametric_eq |
64 |
6.6 |
173.4 |
26.0 |
607.6 |
parametric_eq |
256 |
31.6 |
166.5 |
109.8 |
604.4 |
compressor |
1 |
1.3 |
116.1 |
3.4 |
224.3 |
compressor |
8 |
1.3 |
138.7 |
3.6 |
195.7 |
compressor |
64 |
2.4 |
133.2 |
9.3 |
214.8 |
compressor |
256 |
13.7 |
219.2 |
46.1 |
164.7 |
noise_reverb |
1 |
13.9 |
2.6 |
22.7 |
2.7 |
noise_reverb |
8 |
88.9 |
17.2 |
152.1 |
42.0 |
noise_reverb |
64 |
661.6 |
134.7 |
1,277.4 |
145.5 |
noise_reverb |
256 |
3,245.5 |
786.6 |
6,432.9 |
1,155.8 |
stereo_panner |
1 |
0.4 |
896.0 |
1.3 |
2,067.9 |
stereo_panner |
8 |
0.2 |
837.7 |
0.8 |
1,094.7 |
stereo_panner |
64 |
0.7 |
471.6 |
3.4 |
984.8 |
stereo_panner |
256 |
2.2 |
515.8 |
36.6 |
1,192.8 |
stereo_widener |
1 |
0.2 |
436.2 |
0.7 |
1,223.1 |
stereo_widener |
8 |
0.2 |
406.7 |
0.8 |
1,151.2 |
stereo_widener |
64 |
0.2 |
429.7 |
0.8 |
1,179.4 |
stereo_widener |
256 |
2.0 |
427.7 |
5.0 |
1,470.7 |
stereo_bus |
1 |
0.1 |
0.1 |
0.4 |
0.1 |
stereo_bus |
8 |
0.1 |
1.3 |
0.5 |
1.3 |
stereo_bus |
64 |
0.6 |
63.6 |
1.3 |
109.2 |
stereo_bus |
256 |
1.9 |
16.9 |
5.4 |
30.4 |
On the GPU, the difference between the two architectures follows a simple rule.
Every processor that Faustax renders as a per-sample scan has the scan’s constant iteration latency.
This latency is roughly 0.4–1 s per call at any batch size.
dasp’s vectorized memoryless and stereo processors keep a sub-millisecond to few-millisecond cost.
Therefore, dasp is faster on GPU for the processors that were equal on CPU (gain, distortion, panner, widener).
stereo_bus is an exception.
Faustax implements it as pure gain-and-sum vector math with no Faust module below it.
Therefore, it also stays within a few milliseconds.
For the recursive effects, the batch behavior is different. dasp’s per-call FFT cost increases with the batch size, while Faustax’s scan time is almost constant. Therefore, the EQ gradient gap decreases from 32x at batch 1 to 5.5x at batch 256. The compressor gradient gap decreases from 66x to 3.6x. The trend indicates a crossover at larger batch sizes. The noise-shaped reverb is faster in Faustax on GPU, as on CPU. It is 4–8x faster in both directions at every batch size, by the same FFT-vs-direct-convolution mechanism. In practice, the small-batch Faustax columns are not important. As the CPU-vs-GPU section above shows, use the CPU for the Faustax scan below ~64 parallel lanes. For that case, the ratios in the macOS table are the relevant comparison.
Faustax could also close the small-batch EQ gap directly, with a frequency-sampled path beside the exact recurrence. That path is a small amount of code, and Faustax does not have it on purpose. Future work scopes the work and gives the reasons against it.
Guidance¶
These recommendations come from the measurements in Performance notes on the Faust NNX backend. The tables above confirm them.
unroll: On GPU, use
unroll=8for every processor, Freeverb included. Unrolling divides the dominant per-iteration launch overhead there, and it is worth 4–7x. On CPU, keepunroll=1for the effects. Unrolling costs time for every effect in the CPU table (ParametricEQ 62.4 → 90.1 ms, Freeverb 112.1 → 429.0 ms). Useunroll=2on CPU only for an instrument-size body such as the DX7 voice, which gains 1.8x. XLA compile time also increases steeply with body size × unroll, and it reaches minutes for the DX7 above unroll 2.Batch/voices: Sequential scans are latency-bound on GPU. Use the CPU below roughly 64 parallel lanes (batch × voices). Above 64 lanes, use the GPU, because its wall time is almost constant with the batch size.
Training through long reverbs: A large scan carry (Freeverb’s is ~100 kB) makes second-scale reverse-mode infeasible, because BPTT saves the carry at each step. Use short segments or
nnx.remat.
Data parallelism across CPU cores¶
The per-sample scan is sequential in time but parallel over the batch, so throughput increases with the batch size. By default, XLA exposes the CPU as a single device. A large vmapped batch then uses XLA’s internal threading, which does not always saturate a many-core machine. Heavy offline work includes rendering a dataset, running a large parameter sweep, and prerendering training data. For heavy offline work, force XLA to expose several host “devices” and shard the batch across them. This is genuine data parallelism:
import os
# MUST be set before importing jax — expose N CPU devices (e.g. cores - 2).
# Setting it after jax is imported silently has no effect.
os.environ["XLA_FLAGS"] = "--xla_force_host_platform_device_count=12"
os.environ["JAX_PLATFORMS"] = "cpu"
import jax
import jax.numpy as jnp
import numpy as np
from jax.sharding import Mesh, NamedSharding, PartitionSpec
from faustax import Compressor
comp = Compressor(sample_rate=44100)
mesh = Mesh(np.array(jax.devices()), ("batch",))
shard = NamedSharding(mesh, PartitionSpec("batch"))
# A large offline batch. Pad to a multiple of the device count for an even
# split (trim the padding off the result afterwards).
n = 1200
x = jax.device_put(jnp.zeros((n, 2, 44100)), shard) # sharded over batch
thr = jax.device_put(jnp.full((n,), -24.0), shard) # per-item params too
render = jax.jit(lambda x, t: comp.process(x, threshold_db=t, ratio=4.0))
y = render(x, thr) # each device renders n/12 items in parallel
process already vmaps over the batch axis.
Therefore, when you jit it over batch-sharded inputs, XLA partitions the work SPMD-style: one shard of the scans per device.
Use this technique only for large batches on CPU.
The default single-device threading already handles small or interactive batches well.
Do not shard on GPU: the GPU is one device, and its wall time is almost constant with the batch size.