Today I finished Cantabile’s new “node clustering algorithm”, which I’m guessing I should probably explain… who’s up for some graph theory?
Cantabile audio engine is what’s called a directed acyclic graph (aka a DAG) - a set of connected nodes with no circular path.
The root node of the graph at the bottom is the output of the audio engine and all its precedents are the nodes that produce audio and MIDI output. At the very top are the input nodes that pull in incoming audio and MIDI. In between are MIDI routers, MIDI muxers, audio mixers, MIDI controler hooks, buffers, plugins and a whole set of other objects.
In order for this to work properly all these nodes need to be executed in the correct order (executing a plugin before its input audio mixer wouldn’t work out well). Also, we want the load for this spread across multiple CPU cores so things get done as fast as possible. But we don’t want to put every single node on a separate core, because there’s an overhead involved in that.
To do this efficiently the smaller nodes need to be merged into clusters which are then executed by the thread pool as larger chunks of work.
This is where this “node clustering algorithm” comes in… Cantabile sets up the graph of nodes and the node clustering groups them to allow spreading the load, but also ensuring everything is done in the correct order.
The algorithm I’ve gone with is based on standard task clustering algorithms such as Sarkar’s algorithm and Dominant Sequence Clustering but I’ve modified it to include a node-weight and dispatch-overhead cost.
Node weights give different execution costs to different types of nodes. eg: 1 for midi processing, 5 for audio mixing, 100 for plugins etc… This means the algorithm tries to put heavy items into separate clusters and tries not to put lightweight nodes into separate clusters unnecessarily.
A node can also declare that it wants to be clustered with its precedents (eg: an audio mixer wanting to be kept with its individual channels mixers).
The output of the algorithm is a “plan” - a set of clusters with information about which other clusters need to be executed first. Within each cluster is a topologically sorted list of nodes - that is the nodes are correctly ordered so precedents are always executed before their dependents. The plan contains all the information needed so the audio thread to just follow the instructions and everything will work.
How does this compare to currently Cantabile engine? Very different - the current version does hard coded clustering - basically one cluster for each plugin, media player, rack etc… It’s fine, and it works, but it makes the code complex because everywhere the graph is updated the clustering also needs to be maintained. Aside from clustering the rest of the execution planning is done on the audio thread. Also a bug that puts a node in the wrong cluster can blow the whole thing up.
I got the first version of this working today and after writing some correctness tests (thanks Claude) I did some performance testing and oops… 2,000 nodes took about 33 seconds to “clusterize”. A silly mistake meant the whole graph was being over examined - many times. Bug fix brought it down to about 1 second and an afternoon of profiling and performance tuning got it down to about 60 milliseconds. I think 2,000 nodes would be an extremely large configuration since this is only for nodes that are actually running (preloaded set lists don’t count)
Currently looks like this (on my fast dev machine)
- 2,000 nodes = 55ms
- 1,200 nodes = 20ms
- 800 nodes = 10ms
- 200 nodes < 1ms
I think good enough for now. This whole algorithm runs once each time the graph changes (add a plugin, re-route something etc…) not on every audio cycle, and it runs on the UI thread - not the audio thread. So, while its not performance critical for the audio engine - you don’t want to be waiting 30 seconds just because you changed a route. 55ms you won’t notice.
I’ll stop rambling now. Tomorrow’s job is to update the audio engine to use this new shininess.