Good approach to implementing modulation matrix?

DSP, Plugin and Host development discussion.
RELATED
PRODUCTS

Post

mystran wrote: Tue Feb 28, 2023 10:24 am
JustinJ wrote: Tue Feb 28, 2023 9:44 am I haven't implemented a stride value but thinking about it, for non-mutating value buffers the stride could be 0 rather than 1.
The problem I have with a stride approach is that it complicates things unnecessarily. Now you need all the extra logic for dealing with the strides. The key thing is to realize that this extra logic is not free in terms of performance either, especially if you try to deal with all of it in a general code path.. but specializing for all kinds of different strides is a huge increase in code-size that you really don't want.

The thing about constant vs. non-constant is that in many cases it's actually reasonable to specialize code. For example if a filter takes cutoff, resonance and some general "morph" parameter, then we'd have 2^3=8 cases of constant vs. non-constant inputs.

template <bool constantCutoff, bool constantRes, bool constantMorph> processFilter(...)

Then you can predicate things on the flags so that your filter statically skips loading new parameter values inside the per-sample loop if it's running with a constant input... and then if you had something like a trapezoidal solver where some of the system factorization stays the same when parameters don't change, the compiler can potentially hoist these out of the loop too.
I'm already using this template technique with dynamic dispatch for oscillators to only call code that supports specific enabled oscillator features. On the parameters side of things my oscillators have ~32 parameters, so using this template specialisation technique for optimising constant parameters isn't going to fly.

Maybe we're talking cross-purposes regarding strides? I'm just seeing it as a setting that's either 0 or 1 for the control value type. If it's 0 then only the first buffer position has the control value. If it's 1 then the buffer is full of smoothing or modulating values. The stride value then is just added to the scan position as you access the value array and process the block. There isn't even a conditional here. I understand what you mean about being able to skip this loading of constants, but it should be cache friendly right?

Post

mystran wrote: Tue Feb 28, 2023 10:24 am The problem I have with a stride approach is that it complicates things unnecessarily. Now you need all the extra logic for dealing with the strides. The key thing is to realize that this extra logic is not free in terms of performance either, especially if you try to deal with all of it in a general code path.. but specializing for all kinds of different strides is a huge increase in code-size that you really don't want.
Agree, I make use of SIMD 'horizontally' (I process a single buffer 4 samples at a time). So this only works if the samples are tightly consecutive in memory.
I have seen big companies using 'strides' though, so for them I guess the tradeoff was justified.

Post

JustinJ wrote: Tue Feb 28, 2023 5:33 pm Maybe we're talking cross-purposes regarding strides? I'm just seeing it as a setting that's either 0 or 1 for the control value type. If it's 0 then only the first buffer position has the control value. If it's 1 then the buffer is full of smoothing or modulating values. The stride value then is just added to the scan position as you access the value array and process the block.
The problem is you need to keep all those increments (flags) and indexes somewhere. If you have 32 parameters and one increment and one index for each, that's 64 variables which is way more than we have registers on most architectures. We could pack the flags into a single register and do a shift+bitmask to extract which would cut the register pressure by about half, but it's still ugly.

Meanwhile if you can afford to have a big struct with statically sized arrays (of some max internal blocksize where you then split bigger blocks) and you expand them all into full buffers, now it's one pointer (to the struct) and one index... which is only two registers... and even if we had to copy a bit of data around, that's not very expensive if it's in continuous blocks.

Perhaps that's not workable? If we pack all the flags into a register, then we could also just branch on those flags. The insight here is that these branches are totally predictable: the whole block always goes the same way.

The idea solution is probably a combination of all these techniques, but I think it's important to keep the low-level considerations in mind, in terms of cache, but also in terms of how many variables you can actually hope to keep in registers, because rest of them will spill into the stack and this could hurt your performance too unless you're doing enough floating point arithmetics to hide the latency of all those spills and reloads.

Post

mystran wrote: Wed Mar 01, 2023 8:23 am Meanwhile if you can afford to have a big struct with statically sized arrays (of some max internal blocksize where you then split bigger blocks) and you expand them all into full buffers, now it's one pointer (to the struct) and one index... which is only two registers... and even if we had to copy a bit of data around, that's not very expensive if it's in continuous blocks.
I think I'm in good shape here because that's pretty much what I've got. My ModMatrix references a single block of memory that contains all the values for all the control parameter/destinations x the maximum voices x block size. So the values for, say, oscillator 1 are all in a contagious block. That means that there only needs to be one pointer to the start of that block with hard-wired offsets for each parameter. And incrementing that base pointer will move the read position along for all parameters. One issue is that I don't really have a fixed internal block size - I use the block size given by the host and that can change. This means that the offsets aren't quite hard wired, they have to be calculated. I remember you mentioning fixed size internal buffers before and it's on my list to try out sometime.

But yes, in this case adding a stride argument is one-more-thing-to-track per parameter which doesn't help matters. Guess I'll profile it sometime to see if it really hurts in practice.

Post

JustinJ wrote: Wed Mar 01, 2023 10:14 am One issue is that I don't really have a fixed internal block size - I use the block size given by the host and that can change.
This means that the offsets aren't quite hard wired, they have to be calculated. I remember you mentioning fixed size internal buffers before and it's on my list to try out sometime.
Yeah.. I don't know how much of a performance difference it makes one way or another (haven't really properly compared it apples-to-apples), but I just gave up a while ago and decided I can have a fixed max blocksize, so now I just declare buffers as fixed sized arrays (eg. directly as members of plugin or voice or whatever) and if the host asks for a bigger block, I just split it internally. Mostly I do this because it simplifies code, but it also avoids all the pointer chases and buffer arithmetics.

That said, I'm curious as to how well the "pack constant flags as a single bitmask variable and branch" strategy would work in practice. The branches should be all predictable, but I wonder if it's going to overwhelm CPU predictors if there's a lot of them.

Post

Lots of thought provoking ideas and observations in this thread. I have to think these through and see what would work best for my case. Mystran's constant flag idea sounds intriguing and needs more thinking from my part what the code would look like in practise in my case. Probably could work well, I think.

@Mystran:
How would you do the calling of the correct versions of the templated optimized constant/non-constant flagged methods? A table lookup of function pointers?
Misspellers of the world, unit!
https://soundcloud.com/aflecht

Post

Kraku wrote: Fri Mar 03, 2023 11:08 am @Mystran:
How would you do the calling of the correct versions of the templated optimized constant/non-constant flagged methods? A table lookup of function pointers?
Usually I've used this sort of template expansion:

Code: Select all

template <bool a, bool b> void func2() { ... }
template <bool a> void func1(bool b) { if(b) func2<a, true>(); else func2<a, false>(); }
void func(bool a, bool b) { if(a) func1<true>(b); else func1<false>(b); }
This makes boilerplate manageable... but the downside now is that we're doing a separate branch for each parameter. I don't know if this is huge deal though, because by the time the branches become a problem, you're expanding so much code that code size might become a problem too.

That said, you could probably use a similar idea to generate a lookup array though, where you use this kind of expansion to just return a pointer to the specialized function and then we pack the flags into bitmasks and generate a tables..

Code: Select all

for(int i = 0; i < 4; ++i)
{
  bool a = i & 1;
  bool b = i & 2;
  funcArray[i] = getFunction(a,b);
}
Something like that.

Post

mystran wrote: Fri Mar 03, 2023 12:10 pm This makes boilerplate manageable... but the downside now is that we're doing a separate branch for each parameter. I don't know if this is huge deal though, because by the time the branches become a problem, you're expanding so much code that code size might become a problem too.

That said, you could probably use a similar idea to generate a lookup array though, where you use this kind of expansion to just return a pointer to the specialized function and then we pack the flags into bitmasks and generate a tables..

Code: Select all

for(int i = 0; i < 4; ++i)
{
  bool a = i & 1;
  bool b = i & 2;
  funcArray[i] = getFunction(a,b);
}
Something like that.
Ah OK. That makes sense. I'm probably trying what happens if I create the lookup array of function pointers to such templated functions.
Misspellers of the world, unit!
https://soundcloud.com/aflecht

Post

I just finished implementing a mod matrix for my plugin, here's how mine works:
- My mod matrix is a hash map mapping parameter enums to an array (for fast iteration) of modulation amount values, one per modulator which are also defined by enums.
- GUI controls manipulate this hash map and on every change, copy the whole map to a triple buffer.
- The triple buffer is read by the audio thread once per processing block.
- Audio code can ask for a parameter value by enum. The mod matrix will then take the normalized value from the plugin framework (that has already applied DAW modulation), apply each modulator if any modulation has been defined for the parameter, and return the normalized value converted to the proper range.
- The above also works for sample-accurate buffers, but my FX algorithm doesn't support updating parameters more than once per processing block so most parameters aside from for example dry/wet mix are only updated once per block.

Post

AUTO-ADMIN: Non-MP3, WAV, OGG, SoundCloud, YouTube, Vimeo, Twitter and Facebook links in this post have been protected automatically. Once the member reaches 5 posts the links will function as normal.
so my current approach to parameters generally is to have a flat list of parameter values and poll it where needed:

Code: Select all (#)

vector<float> parameters;
OnAutomationEvent() {
  parameters[cutoff] = ...;
}
ProcessSample() {
  filter.SetCutoff(parameters[cutoff]); // filter compares to last input to make this less awful
  filter.Process(...)
}
which is: not great, but it's easy and it matches well with my hardware dsp (where polling is the only option). The fact that my code started as eurorack hardware is also reflected in the fact that everything is per-sample instead of per block :p

I just added clap polyphonic modulation now, which complicates this to:

Code: Select all (#)

vector<Value> parameters;
vector<pair<key, Value>> modulations;
OnAutomationEvent() {
  parameters[cutoff] = ...;
}
OnModulationEvent() {
  modulations[cutoff, noteKey] = ...
}
ProcessSample() {
  // modulations is a sorted vector of key/value, so querying it requires linear search :/ which will fail more often than not and require a full scan
  filter.SetCutoff(MapRawValue(parameters[cutoff] + modulations[cutoff, voice.note] + hardcodedEnvelopOrSomething));
  filter.Process(...)
}

Now, it's time to imagine internal modulation:

Code: Select all (#)

vector<Value> parameters;
vector<pair<key, Value>> externalmodulation;

vector<Value> modsources;
vector<pair<key, Value>> modslots;

OnAutomationEvent() {
  parameters[cutoff] = ...;
}
OnModulationEvent() {
  externalmodulation[cutoff, noteKey] = ...
}
ProcessSample() {
  modslot.reset();
  for(param: parameters) {
    modslot[param.key, all_notes] = param.value; 
  }
  for(param: externalmodulation) {
    modslot[param.key, param.note] += param.value; 
  }
  for(mod: modmatrix) {
    modslot[mod.dest, all_notes] += modsource[mod.source] * mod.amount;
  }

  ...
  // internal modulation has to be 1-sample delayed to account for feedback
  modsource[lfo1] = lfo.Process(...);

  // still a linear search. this is when making the dense list of per param per voice values might make sense
  filter.SetCutoff(MapRawValue(modslots[cutoff, voice.note]));
  filter.Process(...)
}
so. you can kinda see why i googled to find this thread :p. I wonder exactly how much trouble I'm making for myself for not investing in block-processing early.

Post Reply

Return to “DSP and Plugin Development”