Future work¶
This document lists proposed tasks that are scoped but not started. Each task description gives sufficient context so that a later session (or a contributor) can start the task without new research.
Emit ops.allpole / ops.state_space from the generated NNX tick¶
Status: proposed. The task is cross-repo (it changes the Faust compiler). Do a feasibility study and a prototype before you commit to the task.
Motivation¶
The generated NNX modules differentiate their per-sample scan with ordinary reverse-mode autodiff (BPTT).
BPTT stores the scan carry at every step.
Linear IIR recursions are the main component of EQs, reverbs, and dynamics.
For these recursions, faustax.ops already implements a better rule.
The VJP of an all-pole recursion is the same filter, run backwards, with time-shifted coefficients (ops.allpole, from torchlpc).
The VJP of a general linear state space is its transposed adjoint, run backwards (ops.state_space).
The proposal is that the compiler recognizes those recursions in the emitted tick and calls the primitive over the whole block.
Then the generated gradients get the bounded residual.
For all-pole shapes, the generated gradients also get the measured speed/memory improvement.
No person must rewrite a DSP by hand.
Measured results that scope the task¶
examples/benchmarks/bench_state_space.py measured that the dense state_space adjoint has time parity with BPTT-through-scan on CPU.
The cause is that XLA already differentiates a linear scan efficiently.
Thus the benefit is not uniform:
The benefit is large and proven for all-pole / one-pole ballistics shapes — the torchlpc/torchcomp rules. For example, BPTT through the generated
Compressorscan costs ~19× more than the custom-VJPdynamics.compexp_gainchain at batch 8 (see Custom gradient primitives). Detect these shapes first.The benefit is small for generic small biquads. For these, the benefit is bounded memory and clean higher-order derivatives, not CPU time.
Thus, target the patterns with the large benefit first: a single linear recursion with one delay line (ops.allpole), and the switched attack/release one-pole (ops.ballistics / dynamics).
Outline of the work¶
Change the NNX backend codegen in the Faust repo (
compiler/generator/nnx/nnx_instructions.hh,nnx_code_container.cpp,nnx_base_instructions.hh). Detect when anfRecupdate insidetickis an affine function of its own delayed outputs. Such an update is a linear all-pole section with one shared delay line. Do not emit the scalar recursion inside the scan. Instead, emit a call toallpole(x, a, zi)over the block. Gatherafrom the coefficients. The coefficients can be parameter-modulated and thus time-varying.Emit the FIR read-out on the same delay line as an
ops.fir. Then a full direct-form section islfilter = fir ∘ allpole.ops.lfilteralready uses this decomposition.The generated modules currently embed the architecture file textually. The modules must import
faustax.ops(a new runtime dependency for the emitted Python), or the modules must inline the primitive. Select one of the two options. Record the selection inarchitecture/jax/minimal.py.
Validation plan¶
Exactness first: Regenerate a known IIR DSP (
src/faustax/dsp/parametric_eq.dsp, a reverb,src/faustax/dsp/compressor.dsp). Diff the forward output against the output of the current scalar-scan codegen. The outputs must match to floating-point precision. The output must also pass the impulse-conformance tests against the C++ reference.Gradients: Compare the gradients against BPTT through the old codegen (the math is the same). Then benchmark the gradient time and the peak memory against the state dimension and the block length. Extend
examples/benchmarks/bench_state_space.pyfor this benchmark.
Risks and open questions¶
Pattern-matching robustness: Reject nonlinear couplings, delay lines that sections share, and fractional/variable delays. Handle coefficients that are time-varying because a slider modulates them (→ LPV
allpole/state_space).State convention: The
zi/ warm-up of the primitive must reproduce the initial carry of the scan exactly. This includes one-sample-block mode.Determinism: Keep the float results sufficiently bit-stable for the conformance suite.
An optional frequency-sampled path for small-batch GPU gradients¶
Status: proposed, and deliberately not started. Read the “Reasons to not do this” section before you write code.
Motivation¶
Performance measures one gap that the architecture of Faustax causes. On GPU at batch 1, the dasp-pytorch EQ costs 7.4 ms forward and 23.5 ms for the gradient. The Faustax EQ costs 377.9 ms and 758.1 ms, which is approximately 32× more. The cause is the sequential scan, and NNX backend notes measures its per-iteration cost. The gap closes as the batch grows (5.5× at batch 256), and it reverses for the reverb at every batch size. Thus the gap matters only for small-batch EQ gradients on GPU.
dasp-pytorch closes this gap with the frequency sampling method.
It builds the biquad coefficients elementwise, evaluates the transfer function of the cascade on an FFT grid, and multiplies in the frequency domain.
The whole idea is approximately 200 lines of dasp_pytorch/signal.py: biquad, fft_sosfreqz, and sosfilt_via_fsm.
The method needs no recurrence, so it needs no custom VJP: every operation is already a differentiable primitive.
Outline of the work¶
Add one function, not a second processor hierarchy.
faustax.freqsampled.parametric_eq(x, sample_rate, **params) is approximately 150 lines and closes the measured gap.
src/faustax/reverb.py is the pattern to follow: it already ports dasp_pytorch.functional.noise_shaped_reverberation, its 12-band octave filterbank, and its FFT convolution.
The translation from torch to JAX is mechanical, with four known differences:
The masked in-place writes of the compressor (
x_sc[idx] = ...) becomejnp.where.n_fft = 2 ** ceil(log2(...))must be a Pythonint, because shapes are static underjit.The preallocated
sos[:, i, :] = ...writes become a list and onejnp.stack.The unseeded
torch.randnof dasp-pytorch becomes a keyed RNG, as inreverb.py.
Validation plan¶
examples/benchmarks/bench_vs_dasp.py already renders both libraries with the same physical parameter values, and dasp-pytorch is already a dev dependency.
Thus an equivalence test is a small addition to an existing harness: assert that the new function matches dasp_pytorch.functional.parametric_eq to float32 tolerance.
Then measure the same function against the exact recurrence, and record how the approximation error changes with the parameter values.
Reasons to not do this¶
Two implementations of the same effect have different numerics. The docs must then answer “which EQ do I mean” on every page.
A frequency-sampled effect does not stream. It is a block circular convolution with no carry, so it cannot hold the streaming parity that Real-time deployment sells, and it cannot take a per-sample time-varying parameter.
The parameter metadata needs a second mechanism.
Processorintrospects the ranges from the Faust slider declarations. A function with no Faust source has no declarations, so its ranges become hand-maintained, asdasp_pytorch/modules.pymaintains them.Matching the dasp-pytorch compressor means reproducing its bugs. Its smoother ignores
release_ms. The parity table of the README calls the application ofrelease_msan intentional upgrade.
An alternative that duplicates no DSP definition¶
Any linear time-invariant module has an impulse response. Render the response once by scanning an impulse, then apply it to the whole batch by FFT convolution. This method needs no second definition of any effect, and it works for any LTI Faust module. The arithmetic is favorable: the GPU costs 1.70 µs per scan iteration at unroll 8, so an 8,192-tap response costs approximately 14 ms, against the 377.9 ms that the batch-1 EQ costs now.
The open question is the length of the response, and it decides whether the method works. The length is a property of the parameter values, not a constant. A low shelf at 20 Hz with a Q of 6 rings for tens of thousands of samples, so a truncation at 8,192 taps is an approximation with an error that the slider values control. Measure that error across the slider ranges before you build anything on this idea.
Compile arbitrary Faust in-process with DawDreamer¶
Status: blocked on an upstream release.
Motivation¶
faustax.compile already has the provider.
_compile_dawdreamer calls boxFromDSP and boxToSource through the libfaust that DawDreamer bundles, and provider="auto" selects it when no faust binary resolves.
The provider raises today, because the released DawDreamer wheels predate the NNX backend.
DawDreamer 0.9.0 does not ship the backend.
A 0.9.1 release built against a Faust that has the backend would make the provider work.
The result is that a user could compile arbitrary Faust with pip install, and with no Faust build from master-dev.
This changes a contributor-only capability into a user-facing one.
The widget modulation recipe needs exactly this capability: a slider becomes an input channel only when you author and compile your own DSP.
examples/parameter_estimation/automation.py --compile demonstrates the loop today and needs FAUST_BIN to run.
The learnable-soundfile and learnable-menu workflows have the same constraint.
Outline of the work¶
Add a
compileextra that pinsdawdreamer>=0.9.1. Do not make DawDreamer a base dependency: the base install advertises five runtime dependencies, and DawDreamer is a large binary wheel that a user of the shipped effects never loads.Resolve the NNX architecture file.
_compile_dawdreamerneeds an explicitarch_file=today, and a user with no Faust checkout has no such file.Enable the compile tests that skip without a compiler, and run them against the DawDreamer provider in CI.
Update the installation sections and Development to describe the two providers.
Open questions¶
Where does the architecture file come from? Either DawDreamer ships
architecture/jax/minimal.pynext to its libfaust, or Faustax vendors a copy. A vendored copy must track the backend, and a stale copy generates modules that do not match the checked-in ones. Check what the wheel contains before you select an option.Where do the Faust libraries come from?
boxFromDSPreceives the-Idirectories. The CLI provider resolvesstdfaust.libfrom the Faust checkout. Confirm that the DawDreamer wheel bundles the libraries, and confirm which version they are.Which platforms have wheels? The base install supports Python 3.11 through 3.14 on every platform that JAX supports. The
compileextra covers only the platforms that DawDreamer builds for.Which provider does
autoprefer? The CLI provider is first today. A contributor with a Faust checkout and the extra installed gets the checkout, which is the correct default for regeneration work.