Fast Math
Overview
Audio DSP calls transcendental functions constantly: an oscillator needs a sine every sample, a compressor converts to and from decibel (log10 / pow10) on every gain update, a soft clipper computes a tanh per sample. The standard-library versions are correctly rounded to the last bit, at a cost in cycles that a per-sample loop rarely needs to pay: the result is quantized, summed with noise and turned into sound. Q ships a family of fast approximations that trade a little accuracy for a large speed-up.
Every function comes in two tiers, distinguished by name:
-
fast_*: the accurate tier. Error is small enough to be inaudible in almost any audio use (a few parts in 10^-5 for the bounded functions). Use it by default. -
faster_*: the cheap tier. It drops a polynomial term or two, so the error grows to the low percent range, in exchange for the fewest possible operations. Use it where the result feeds something forgiving, a modulation source or a rough envelope, and the cycles matter.
The approximations are IEEE-754 bit tricks and short polynomials, from Paul Mineiro’s fastapprox. They take and return float; there are no double overloads.
The error ripples as the input sweeps: each approximation is a smooth curve fitted to the true function, running slightly high then slightly low between the points where it crosses the exact value. fast_* fits closely, faster_* loosely, but neither introduces discontinuities, so both are safe to modulate. The worst-case sizes are tabulated in Accuracy and Speed.
Most of these functions are only valid over a restricted domain, listed per function below. The trigonometric approximations in particular assume the input is already range-reduced; feeding them an angle outside the stated range gives a wrong answer, not a clamped one.
fast_sqrt is an alias for std::sqrt, which lowers to a single hardware instruction on every target Q supports and is both exact and the fastest option. It is kept in the family so existing call sites need not change.
|
The fast_* trigonometric functions do no range reduction. fast_sin and fast_cos require -pi <= x <= pi; fast_tan requires -pi/2 <= x <= pi/2. Outside those ranges the polynomial diverges. Reduce the angle yourself (for example with a phase accumulator, which wraps by construction) before calling.
|
Declaration
namespace cycfi::q
{
// Exponential and logarithm
inline float fast_exp(float x); inline float faster_exp(float x);
inline float fast_log(float x); inline float faster_log(float x);
inline float fast_log2(float x); inline float faster_log2(float x);
inline float fast_log10(float x); inline float faster_log10(float x);
inline float fast_pow2(float x); inline float faster_pow2(float x);
inline float fast_pow10(float x); inline float faster_pow10(float x);
// Trigonometric (sin/cos: [-pi, pi]; tan: [-pi/2, pi/2])
inline float fast_sin(float x); inline float faster_sin(float x);
inline float fast_cos(float x); inline float faster_cos(float x);
inline float fast_tan(float x); inline float faster_tan(float x);
// Hyperbolic tangent (valid over all reals; saturates to +/-1)
inline float fast_tanh(float x); inline float faster_tanh(float x);
constexpr float fast_rational_tanh(float x); // Pade, -3 <= x <= 3
// Reciprocal and division
inline float fast_inverse(float val);
inline float fast_div(float a, float b);
// Square root (exact: alias for std::sqrt)
inline float fast_sqrt(float x);
// Taylor-series exp, order 3..9 (small x)
constexpr float fast_exp3(float x); // ... through ...
constexpr float fast_exp9(float x);
// Fast integer RNG
inline int fast_rand(); // result in [0, 0x7FFF]
}
Expressions
Exponential and Logarithm
The logarithms require x > 0. The exponentials are valid over all reals; underflow is handled (a large negative input returns 0, not a denormal or NaN).
| Expression | Semantics | Return Type |
|---|---|---|
|
e raised to |
|
|
Natural logarithm of |
|
|
Base-2 logarithm of |
|
|
Base-10 logarithm of |
|
|
2 raised to |
|
|
10 raised to |
|
Each row has a faster_ counterpart with the same signature and semantics but larger error (see Accuracy and Speed).
fast_pow10 computes pow2(x * log2(10)) using the exact log2(10), rather than routing through a generic pow that would scale by an approximate constant. Folding in the exact constant costs the same single multiply yet is markedly more accurate; against std::pow, fast_pow10 RMSE drops about 5x and faster_pow10 about 2x.
|
Trigonometric
Range-reduced only. fast_sin / fast_cos assume -pi <= x <= pi; fast_tan assumes -pi/2 <= x <= pi/2.
| Expression | Semantics | Return Type |
|---|---|---|
|
Sine of |
|
|
Cosine of |
|
|
Tangent of |
|
Each has a faster_ counterpart.
Hyperbolic Tangent
A staple nonlinearity for saturation and soft clipping.
| Expression | Semantics | Return Type |
|---|---|---|
|
Hyperbolic tangent, valid over all reals; saturates to
|
|
|
Cheaper, less accurate |
|
|
Pade rational approximation, |
|
Reciprocal, Division, and Square Root
| Expression | Semantics | Return Type |
|---|---|---|
|
Approximate reciprocal |
|
|
|
|
|
Square root, |
|
fast_inverse (and therefore fast_div) is a low-accuracy estimate, good to only a few percent. Use it for control-rate math where the imprecision washes out, not where you need a faithful quotient.
|
Polynomial and Utility
| Expression | Semantics | Return Type |
|---|---|---|
|
|
|
|
Fast integer pseudo-random number in |
|
The bit-trick functions (fast_exp, fast_log*, fast_pow*, the trig and fast_tanh families, fast_inverse) are inline, not constexpr; they rely on runtime type punning. Only fast_rational_tanh and fast_exp3..fast_exp9 are constexpr.
|
Accuracy and Speed
Accuracy is the worst-case error over each function’s working domain: inaudible for the fast_* tier, coarse for faster_*. The figures below were measured with clang -O3 on an Apple-silicon Mac, scalar path; they shift with compiler, flags and target.
| Function | fast_* |
faster_* |
|---|---|---|
|
~1.5e-4 (output bits) |
~0.05 (output bits) |
|
~0.006% (relative) |
~4% (relative) |
|
~4e-5 (absolute) |
~9e-4 (absolute) |
|
~3e-5 (absolute) |
~2e-2 (absolute) |
Speed depends on how the calls are arranged. Independent calls pipeline, and the CPU runs many at once (throughput); chained calls each wait for the last (latency). For these functions the two differ by up to an order of magnitude: the large savings are in throughput, and inside a dependency chain the pipeline latency dominates and the saving shrinks. Measure on your own target; the build-only micro-benchmarks under test/benchmark/ are for that.
decibel_bench.cpp reports both metrics for the log10 / pow10 pair used by decibel:
| Function | Metric | std | fast_* |
faster_* |
|---|---|---|---|---|
|
throughput |
5.8 |
0.31 |
0.16 |
latency |
20 |
14 |
12 |
|
|
throughput |
2.5 |
0.46 |
0.21 |
latency |
23 |
19 |
11 |
In throughput fast_log10 is about twenty times cheaper than std::log10; in a dependency chain, about 1.4 times. log2_bench.cpp and sin_bench.cpp measure log2 and sin with a dependency-bound loop, nearer the latency column: std::sin runs about 12 ns against roughly 2 ns for both fast_sin and faster_sin, and std::log2 about 8 ns against 2.5 ns.
On a modern desktop FPU the faster_* tier is often no quicker than fast_*: both already collapse to a handful of instructions, so dropping a polynomial term is lost in pipeline latency. faster_* pulls ahead on cheaper hardware, where every multiply counts, and under vectorization. fast_* over the libc call is the saving that holds on every target. Rebuild any of these numbers on your own target by running the benchmarks.
|
Example
A cheap saturating soft clipper, one tanh per sample:
float drive(float x, float amount)
{
return q::fast_tanh(x * amount);
}
Converting a linear amplitude to decibel and back in a gain stage, where log10 / pow10 would otherwise dominate the cost:
float g_db = 20.0f * q::fast_log10(amplitude); // linear -> dB
// ... adjust g_db ...
float g = q::fast_pow10(g_db * 0.05f); // dB -> linear
A range-reduced sine oscillator. fast_sin does no wrapping of its own, so the phase is accumulated in [-pi, pi) and folded back each sample to stay inside the valid domain:
float phase = 0.0f; // in [-pi, pi)
float incr = 2.0f * float(q::pi) * 440.0f / sps; // radians per sample
// ... per sample:
float s = q::fast_sin(phase);
phase += incr;
if (phase >= float(q::pi))
phase -= 2.0f * float(q::pi); // fold back into range
For production oscillators prefer the phase / phase_iterator accumulator, whose fixed-point counter wraps by construction, or the table-based Sine Wave Oscillator. The snippet above is here to make the domain restriction concrete.
|