Conditional logic in a SynthDef...works normally?

Hi all,

Just when I thought I completely understood why ‘if’ and other language-side conditionals “don’t work as expected” inside a SynthDef, I had a student submit the following, which has me a bit stumped…

(
SynthDef(\vibrato, {
	arg freq = 500, vib = 1;
	var sig, lfo;
	lfo = SinOsc.kr(5).bipolar(0.5).midiratio;
	freq = if(vib > 0.5, freq * lfo, freq);
	sig = SinOsc.ar(freq, mul: 0.1 ! 2);
	Out.ar(0, sig);
}).add;
)

x = Synth(\vibrato, [vib: 0]);

x.set(\vib, 1); // -> works, somehow...?

I also notice that if we enclose the true/false items in curly braces, establishing what I understand to be the “correct” syntax, then the SynthDef fails with the more predictable ‘non-Boolean in test’ erromessage:

(
SynthDef(\vibrato, {
	arg freq = 500, vib = 1;
	var sig, lfo;
	lfo = SinOsc.kr(5).bipolar(0.5).midiratio;
	freq = if(vib > 0.5, { freq * lfo }, { freq });
	sig = SinOsc.ar(freq, mul: 0.1 ! 2);
	Out.ar(0, sig);
}).add;
)

Can anyone enlighten me?

Eli

1 Like

This is called inlining in the interpreter. There is a huge issue about this on GitHub… falling back from inlining · Issue #3567 · supercollider/supercollider · GitHub

Tldr: do not use ugen:if, just do a linear interpolation (pretty sure this works).

(a > 1).linlin(0, 1, false, true);

Essentially, in smalltalk we like to think all ‘things’ are Object, and all we do is send messages. Unfortunately this is horrifically slow. The most beneficial way we can add speed is through inlining, which is copying the definition of the function to where it is called at compile time. This requires two pieces of information at compile time, the class of the objects and the message name.

Here is a hypothetical example.

A {
   foo { |a|
     ^a.bar
   }
}
A().foo(1)

Here, the compile knows we are calling *new on A, which return an instance of A, then can see the definition of foo so it can directly emit bytecode that does the following…

1.bar

This is excellent performance wise.

However supercollider’s compiler is quite crude in terms of semantic analysis so doesn’t have the ability to reason about types, so since the beginning, the decision was made to just assume that if you call if on something, it’s a bool. Then the compiler goes ahead and inlines what it guesses is it’s definition - it doesn’t obey extension methods.

Now it only does this if the true and false branches are what it calls ‘inlineable’… Some here will probably describe what these properties are… But they are completely arbitrary and we can make more things inlinable which will improve performance, but randomly break code. It is better to just not know what they are and avoid using ugen:if.

There are many methods that are inlined like this. If, and, or, while, to name but a few… Additionally, you cannot change them with extension methods, and other methods like isNil and === are additionally confusing when you store the object in certain containers.

So just to be clear, the compiler assumes the type and the implementation if you use if (and friends) but can only do the inlining if the true and false branches meet some arbitrary criteria.

I’ve got a proposal based off other’s ideas at the bottom of that long issue that will solve this, until then id strongly recommend not using ugen:if, not defining these methods yourself, and not redefining any ‘core’ methods in extensions.

Side note, things like integer.do do a different type of inlining, where only the extension methods break - unfortunately this doesn’t really work with the simpler control flow methods.

Side side note, if you overload mustBeBooleanError and don’t throw, you get amazing results because it just falls through into the next bytecode.

Side side side note…

There is absolutely no reason why if(..., 1, 2) can’t be inlined, in fact, it’s quite silly that it isn’t considering how simple it would be.

Does “IF” work correctly with mags and phases from UnpackFFT? I need to check if they exceed certain threshold.

Not in the way that you’re thinking. You will not be able to do if(thisMag > 15) { ... do language stuff... } { ... do other language stuff... } because anything in the server that is changing cannot directly trigger language operations. FFT mags and phases are changing, so they are signals, so you cannot do language style conditionals on them.

hjh

Wow, quite the response. I naïvely expected this to be a simpler explanation, but it seems like I’ve touched on something a little deeper. I’ll continue to tell my students not to use UGen:if; I’ve seen this fail plenty of times and understand why it is not good practice.

I understand the concept of inlining only in the most superficial sense, so I wonder if I can rephrase my confusion a bit.

In this first example,

(
SynthDef(\vibrato, {
	arg freq = 500, vib = 1;
	var sig, lfo;
	lfo = SinOsc.kr(5).bipolar(0.5).midiratio;
	freq = if(vib > 0.5, freq * lfo, freq);
	sig = SinOsc.ar(freq, mul: 0.1 ! 2);
	Out.ar(0, sig);
}).add;
)

vib is a control-rate instance of Control. The conditional expression with > 0.5 returns a BinaryOpUgen, which outputs either 0.0 or 1.0, depending on the value of vib. This much, I understand.

My confusion arises with what happens next. I was under the impression that if must be evaluated language-side, and that it is forced to choose one of its two options, and permanently bake it into the UGen graph function, based on the result of the conditional check when the SynthDef is added, rather than when the Synth is created.

Without enclosing the true/false actions in curly braces, these actions are “seen” as UGens, rather than Functions, which allows the interpreter to correctly produce the desired result as defined in UGen.sc, effectively behaving like Select.ar(), as far as I can tell:

	if { arg trueUGen, falseUGen;
		^(this * (trueUGen - falseUGen)) + falseUGen;
	}

But if the true/false actions are enclosed in curly braces, they are seen as Functions, and that causes the above definition of UGen:if to fail…somehow.

What I don’t quite understand is that, in both cases, the receiver is vib > 0.5, which is a UGen. So when the curly braces are added, why do we get the ‘non-Boolean in test’ error message? The receiver is technically non-Boolean in both cases. And, with curly braces added, we are essentially producing a UGen multiplied by a { UGen }, but SC doesn’t seem to object to that:

{ SinOsc.ar() * {LFDNoise3.ar()} * 0.05!2 }.play; // works fine

Apologies if I am being pedantic or just fundamentally misunderstanding something — I would like to understand this failure a little more precisely.

I would also be curious to understand what the UGen graph function looks like in my first example.

Eli

No this is really confusing!

This is the key here. Both freq * lfp and freq need to be inlineable. As I said this is a very loose term and somewhat arbitrary as it is just how the compiler is implemented right now.

So, today, the compiler considers inlineable things as being (all of these things): enclosed in curly braces, having no arguments, and having no variables.

I made an effort to not call them functions, because they are never actually turned into functions in this case.

So if you do if(vib > 0.5, { |meow| freq * lfo}, {freq}) this is no longer inlineable, and it will pass the functions to UGen:if, which will probably produce an error, but it will be a sclang error, not one from the interpreter.

That is because when the compiler sees that the true and false branch can be inlined, it automatically assumes that the first bit is a boolean (this is obviously wrong, but offers a huge speed up), and emits a byte code that does this in the interpreter…

 InterpretOpcode(JumpIfFalse) {
    // cannot compare with o_false because it is NaN
    if (IsFalse(sp)) {
        const auto [i1, i0] = JumpIfFalse.pullOperandsFromInstructions(ip);
        ip += i1.asInt(i0);
        --sp;
    } else if (IsTrue(sp)) {
        const auto [i1, i0] = JumpIfFalse.pullOperandsFromInstructions(ip);
        --sp;
    } else {
        numArgsPushed = 1;
        selector = gSpecialSelectors[opmNonBooleanError];
        slot = sp;
        goto class_lookup_then_msg_lookup;
    }
    dispatch_opcode;
} 

The functions IsFalse and IsTrue require the value to be exactly true or false — not an sclang ‘object’ that behaves like one, but exactly the true or false object.

gSpecialSelectors[opmNonBooleanError]; this bit it telling it to call the method mustBeBoolean on the receiver (the first bit of the if message) ­— if this doesn’t throw, we just fall into the following if branch and chaos reigns!

So to summarise, if you have if(...receiver...) { ...block a... } { ...block b... } where both blocks don’t have args and vars, the compiler is going to assume that the receiver is either the literal true or false and emit bytecode that only works with this.

I think that has to do with the implementation of AbstractFunction, not really my area as it is implemented in the class library not the backend.


By the way, if you think this is weird, you should look at the implementation of while!

Without tracing the entire code path, aUGen * { bUGen } eventually ends up in UGen multiNewList. The first thing that happens is that the argument list gets converted to a list of valid UGen inputs.

		args = args.asUGenInput(this);

asUGenInput on an array just collects asUGenInput over its items – no surprise there.

In this example, the two inputs are aUGen and { bUGen }. aUGen – any UGen – is already a valid UGen input, so asUGenInput just returns this. { bUGen } calls AbstractFunction:asUGenInput, which evaluates the function with one input, and this evaluation returns bUGen.

So the * operators inputs end up simply being aUGen and bUGen.

Note that this is also why you can write PlayBuf.ar(2, myBuffer...) – a Buffer object couldn’t be compiled into the binary SynthDef, but asUGenInput strips away the Buffer object and leaves the bufnum. We rely on this automatic conversion in several places, but don’t usually think about it for functions.

But, as Jordan correctly notes, in this must-be-boolean error case, there aren’t any functions, and execution never gets into UGen:if.

hjh