Every Branch, Every Sample

Every effect module in Synthr ends with the same two lines:

output[c] -= output[c] * Convert.ToSingle(!active);
output[c] += input[c] * Convert.ToSingle(!active);

That's the bypass switch. There's no if (!active) return input; at the top of the function. The module computes its full effect, on every sample, whether it's switched on or not, and then multiplies the result away when it isn't wanted. When the module is off, the first line subtracts the whole processed signal and the second adds the dry input back. When it's on, both lines add and subtract zero.

The filter, the bit crusher, the reverb, the distortion, the compressor, the limiter, and the envelope filter all do this. So does the envelope. I've written synthesizers this way since the first version in 2013, and the reason is that a real-time audio callback cares about a different number than most code does.

The Number That Matters

Audio code has a hard deadline. At 48 kHz with a typical ASIO buffer, the callback has a few milliseconds to fill the next buffer, and missing it is an audible click rather than a slightly slow frame. I covered that deadline in a previous post about Synthr's audio thread.

What follows from it is that the average cost of the callback is close to irrelevant. A callback that usually takes one millisecond and occasionally takes six clicks every time it takes six. The only figure that decides whether the instrument works is the worst case.

Skipping disabled modules with an early return makes the average faster. It does nothing at all for the worst case, because the worst case is the patch with everything turned on. What it does do is make the cost of the callback depend on knob positions, and that's the real problem.

Constant Cost and the CPU Meter

With branches, a patch with the reverb, the delay, and the compressor bypassed runs cheaply. It also tells the player nothing about what happens when they switch the reverb on in the middle of a song. If that tips the callback over budget, they find out live, as a crackle, at the moment they're least able to do anything about it.

With every module always computing, the cost of a patch is fixed the moment the module is placed in the rack. Turning a module on or off changes what the player hears and nothing about how long the callback takes. Whatever the CPU meter reads while a patch is idling is what it reads at the peak of the performance. If the patch fits in the buffer now, it fits in the buffer when every switch is flipped, because flipping switches doesn't change the work.

That changes how the instrument gets tuned. The buffer size can be chosen once, against a cost that holds still, rather than against a guess about which combination of effects someone might enable later.

It's the same reason game engines like a frame budget that holds still. A frame that's fast most of the time and slow when a particular effect fires is harder to ship than one that's slightly slower and identical every frame. Audio is that, with a deadline that won't drop a frame to cope.

Nothing for the Predictor to Guess

The second reason is about what the CPU does with an if.

A modern core doesn't wait to find out which way a branch goes. It predicts, and starts executing the predicted side speculatively, often dozens of instructions ahead. When the guess is right the branch costs almost nothing. When it's wrong the core throws away everything it did on the wrong path and starts again from the branch, which costs somewhere in the region of fifteen to twenty cycles on current desktop parts.

A branch on active is well predicted, because the switch doesn't change between samples. The branches that hurt are the ones that depend on the signal. Synthr's bit crusher holds a sample for a number of steps and then takes a new one. Written the obvious way, that's an if that's false most of the time and true on a regular count, and the counter that drives it wraps with another if. Synthr writes it with nothing to predict:

savedSample[c] -= savedSample[c] * Convert.ToSingle(S == 0);
savedSample[c] += input[c] * Convert.ToSingle(S == 0);
// ...
++S;
S -= S * Convert.ToInt32(S >= downsample);

The comparison produces a zero or a one, the one multiplies the new sample in, and the counter resets itself by subtracting its own value. The core runs straight through, and every sample takes the same path as the one before it.

Straight-line code also helps speculation rather than fighting it. With no branch to resolve, the core can see further ahead and overlap more work. Computing both sides of a choice sounds like twice the effort, but much of that second side runs in execution units that would otherwise sit idle waiting on the first side's results. The extra arithmetic is often cheaper than it looks, and a mispredicted branch is often more expensive.

A Habit Since 2013

The first Synthr was an iOS app written in Objective-C++ against CoreAudio, and the same idea runs through it at a smaller scale. The oscillator's enable switch is a multiply:

inline float Get() const {
    return lerp(m_fMin,m_fMax,WaveForm::Get(m_Shape, m_fT))*m_bEnabled;
}

And the envelope advances from attack to decay to sustain without an if anywhere in its update. Each state is an index into a table of member function pointers, and the state moves on by adding the result of a comparison to itself:

inline void UpdateAttack()
{
    m_fAttackTimer += m_fT;
    m_state = (State)(m_state + (m_fAttackTimer>=m_fAttackTime)); // increment the state if the timer elapses
}

The C# version in Synthr 3 kept the table. Its bypass needs two masks, because a bypassed envelope shouldn't output silence. It should output full level while the key is held and zero once it's released, so the sound passes straight through:

float g = getFunctions[(int)state](this);
g -= g * Convert.ToSingle(!active);
g += 1 * Convert.ToSingle(!active && state < State.Release);
return g;

One mask removes the envelope's shape and the other replaces it with a gate.

State That Keeps Running

There's an audio reason for all of this too, separate from the CPU.

Filters, delays, and reverbs have memory. The low-pass filter keeps four history values per channel, and the delay keeps a ring buffer of the last few hundred milliseconds. If a bypassed module returns early, that memory freezes at whatever it held when the module was switched off. Switch it back on a minute later and the first thing it outputs is a minute-old filter state or a minute-old echo, which is a click or a ghost.

Because Synthr's modules keep computing while bypassed, their state stays current. The filter's histories track the live signal the whole time. The delay's buffer keeps filling:

float echo = buffer[c].Front();
float newSample = input[c] + echo * feedback;
buffer[c].PushBack(newSample);
output[c] = input[c] + echo * level * Convert.ToSingle(active);

Only the last line knows about active. Turning the delay on brings in the echo of what was played half a second ago, which is what a player expects from a hardware pedal, rather than the echo of whatever was playing when they last turned it off.

From a Switch to a Knob

A multiply by zero or one is a crossfade where the weight only takes two values. Once the code is shaped that way, letting the weight take every value in between costs nothing, and a switch becomes a knob.

Every Synthr effect has a dry/wet control that's exactly this, the same two lines as the bypass with wetness in place of the boolean:

output[c] *= wetness;
output[c] += (1 - wetness) * input[c];

The distortion goes further. It has a hard clipper and a soft exponential curve, and rather than choosing between them it computes both and blends them on a hard to soft knob:

output[c] += softness * Math.Sign(input[c]) * (float)(1-Math.Pow(Math.E,-Math.Abs(input[c]*gain)));
output[c] += (1 - softness) * Math.Sign(input[c]) * AggressiveMin(1, Math.Abs(input[c] * gain));

A branch would have given two distortion types. Computing both gave a continuous range between them that can be automated or modulated mid-note without a discontinuity. That's a better instrument, and it came out of the same habit.

Limits

Multiplying by zero doesn't remove everything. Zero times infinity is NaN, and NaN times zero is still NaN. If a bypassed path divides by a parameter that's currently zero, or a filter goes unstable, the mask won't hide it, and the NaN reaches the output. Branchy code would never have run that path at all. Every path has to be safe to compute with every parameter value, including the ones the player can't currently hear.

Convert.ToSingle(bool) is a conditional inside. Whether the JIT turns it into a conditional move or an actual branch is up to the JIT, and the C# source doesn't control that. The C++ version, where a bool converts to 0.0f or 1.0f directly, is on firmer ground. The C# version gets the constant cost regardless, and the branch-free machine code only where the JIT cooperates.

Not every part of Synthr follows the rule. The oscillator chooses between its two waveforms with an if on the phase, and calls the waveform through a delegate. That branch flips twice per cycle, on a regular schedule a predictor handles reasonably, but it's a branch on the signal in the inner loop of every voice, and I'd mask it now.

And the work is real. A bypassed reverb still costs a full reverb. On a laptop that's battery and heat spent on sound nobody hears. A plugin running fifty instances in a DAW session would reasonably want to skip bypassed processing to save CPU for everything else. For a live instrument, where a crackle on stage costs more than a warm laptop, my opinion is that a cost that never moves beats a cost that's lower on average. For a mixing plugin I'd likely decide the other way, and at least make the skip happen once per buffer rather than once per sample.

What Stays and What Changes

For anything that runs against a deadline, I keep all of it. Compute the whole effect, keep the state current, and let the switch be a multiply. The instrument costs the same with every knob in every position, the core has nothing to mispredict, and every switch is one small change away from being a knob.

If I were starting it again, I'd make the masks explicit types rather than repeated Convert.ToSingle calls, so the intent is in the name and the conversion is guaranteed to be branch-free. I'd also add a NaN check on the output in debug builds, because a NaN from a bypassed path is the bug this technique introduces.