Proposal: Refactor synthdef ugen optimizations

Continuing the discussion from Proposal: SynthDefs could eliminate duplicate UGens:

Currently the way ugens are optimized is confusing.

SynthDef calls a method on all the ugens delegating the responsibility to them. This is fine when it’s a simple replacement, but this is rarely the case, meaning the whole graph has to be modified, leading to a confusing inside-out flow of control, where the ugen alters the whole graph. It would be much easier to operate over the graph in a series of passes.

Initial issues:

  1. Are there any backwards compatibility problems here? do quarks or plugins define their own optimization rules? can we just remove the old way of doing things?
  2. Is doing multiple passes going to have a performance hit? does this matter?
  3. What should these passes be? and in what order should they be executed?
  4. How and where should these graph transforms be expressed? Perhaps UGens register a transformation with SynthDef on a static method that’s called during initClass?
1 Like

In VSTPlugin I’m (ab)using UGen -optimizeGraph to store data in the SynthDef metadata. However, we could take the opportunity and add a dedicated hook for this purpose, e.g. UGen -doneGraph.

Passes on the " whole graph" (why always " the whole graph’? What kind of “passes”?:

  • Analyzing the entire graph multiple times is more computationally intensive.
  • It will be more complex to implement initially
  • There could be interactions between different optimization passes that need to be carefully managed.

The best option is a hybrid approach combining recursive graph traversal with pattern matching.

  • could be used to identify specific structures or subgraphs within the audio graph.
    ---- find an " expressive" way to notate it; whenever you identify a structure, you add it later.
  • Flexibility: You can define different optimization functions for different graph levels.
  • pattern matching makes it easy to write (and think) (even if the final code is not written this way)
  • Composability
  • Adding an implied “type system” can help ensure the correctness of your optimizations.

– After you get a clean procedure, then you start coding in c++, because it will get much messier there, and hard to reason.

Minimalist example :


optimizeGraph :: AudioNode -> AudioNode
optimizeGraph node = case node of
UGen name children ->
  UGen name (map optimizeGraph children)
Connection a b ->
  Connection (optimizeGraph a) (optimizeGraph b)
Graph nodes ->
  let optimizedNodes = map optimizeGraph nodes
  in optimizeLocalPatterns (Graph optimizedNodes)

optimizeLocalPatterns :: AudioNode -> AudioNode
optimizeLocalPatterns (Graph nodes) = Graph (map optimizePattern nodes)
where
  optimizePattern node = case node of
    UGen "add" [UGen "mul" [a, b], c] ->
      UGen "madd" [a, b, c]
    -- etc etc etc ...
    _ -> node

Graphs are well-studied. When you try to forget they are graphs, you may make the problem more complicated (or much more difficult) to solve.

So a hook that triggers after all optimizations? That sounds good!

Can you think of any other cases where someone might need to overload that method? It would be really good to remove the current approach, maintaining the two side by side would be awkward.

I’ll make a list of all the methods that get called as a part of this process and then it would be good to decide with are ‘private’ and which (if any) are a part of the API.

I was thinking of having 3 maybe 4 passes

  1. (Optional) Dead code elimination
  2. UGen replacement (the binary op stuff goes here, basic pattern matching)
  3. Dead code elimination
  4. @jamshark70’s common subexpression elimination

I don’t think it needs to be any more complicated than that — this isn’t an x86 compiler.

Part 2 could be done by method call still, it just returns the ugen it wants to replace itself with — or nil if it doesn’t. This means all the graph manipulation takes place in SynthDef.

Edit: I forgot the topological sortong and the indexing, which happens after the above.

But is it worth the trouble of touching such important code, with passes that will transverse possibly complex structures, just for subtle optimizations? Why? How frequent are the 4 cases? How much is it saved each time?

So a hook that triggers after all optimizations? That sounds good!

Yes! I also need to override the synthIndex_ setter to get the actual Synth index. It’s really a mess. A proper post-optimization hook would be very much appreciated :slight_smile:

In theory, it could be used to implement custom graph optimizations for custom UGens, but I doubt that anyone has done this. Also, optimizeGraph and friends are not documented, so I think they cannot be considered part of the stable API.

The current code is very convoluted (please have a look!) and a cleanup would be more than justified. We’d just need enough tests to catch regressions. Once it’s cleaned up, it will be easier to implement further optimizations. But you are right that is a bit of a dangerous venture.

Actually, the hook should be called at the very end, after the topological sort and reindexing:

finishBuild {
		this.addCopiesIfNeeded;
		this.optimizeGraph;
		this.collectConstants;
		this.checkInputs;// will die on error

		// re-sort graph. reindex.
		this.topologicalSort;
		this.indexUGens;
		// NEW this.onFinish; // call 'onFinish' on all UGens
		UGen.buildSynthDef = nil;
	}

Topological sorting has more to do with the order of computation. Why do you do this with a graph that will be changed, and then your final graph will not get TS? You probably mean it will happen twice because it would be helpful early. And what “passes” will need it one again each?

I think there’s a misunderstanding. I was only talking about the (hypothetical) post-processing UGen hook. I wrongly called it a “post-optimization” hook, but then I realized that topological sorting comes after the optimizations and the hook really needs to be called at the very end. The hook itself has nothing to do with optimizations, it is just a function that is called on every UGen when the SynthDef has finished. A UGen can override this function to do certain things with the finished graph (in my case: store some metadata in the SynthDef).

I understood what you meant the frist time :smile: Some way of acting once the ugen’s position is finalised but before the synthdef is sent to the server.

1 Like

Exactly! (and some characters)

Is it the Ugen car trunk

It’s also worth mentioning there are some of things going on already with competing optimizations. I can’t figure out what’s going on with this one SynthDef compiler: redundant `Sum4`s are not deleted, but turned into `Sum3`s · Issue #6395 · supercollider/supercollider · GitHub

Analyzing the entire graph multiple times is more computationally intensive.
It will be more complex to implement initially
There could be interactions between different optimization passes that need to be carefully managed.

There are different kinds of edges. It would be best to have a " type system" or equivalent. Also, each can target different levels (sometimes one can call a different " dimension") since we know that the level greatly influences what works better.

If you treat the edges abstractly, you will have to do another sorting/analysis since you don’t know at what level or on what side there was a change. After many optimizations, you will be limited depending on how much you implement (a type-driven alternative may not be the only way)

Hmm, I think this functionality already exists.

SinOsc : PureUGen {
	*ar {
		arg freq=440.0, phase=0.0, mul=1.0, add=0.0;
		var out = this.multiNew('audio', freq, phase).madd(mul, add);
		SynthDef.addDependant(out);
		^out
	}
	update { |changed, changer, synthDef|
		if( (changed == SynthDef) and: {changer == \synthDefReady}) {
			this.synthIndex.debug(\synthIndex);
			SynthDef.removeDependant(this);
		}
	}
}

Yes, a ugen can have “instances” implemented for a class family (which concept should come out of the data and the problem). Among other things

Amazing! This has been added in SC 3.13. Naturally, it isn’t documented anywhere :roll_eyes:, but at least it’s mentioned in the release notes. I might update my extensions accordingly (keeping the old optimizeGraph hack for backwards compatibility with older SC version).

I just noticed one footgun: it is easy for a UGen to add itself as dependent, but it’s not trivial to remove itself again since UGens don’t really have destructors. If it doesn’t remove itself, it will stay in the dependents dictionary forever. One workaround is to remove the UGen at the end of the update method, which feels rather hacky.

synthDefReady can be used as a post-build hook for UGens, but IMO it’s a bit clumsy. I think a proper post-build hook explicitly for UGens would still be nice.

I’m sorry. I thought you guys were replying to my message on “type classes” to analyze the graph less abstractly. I guess I missed the topic (or what solution is an advancement for what goal…)