Refactoring Daisy-Chained Expander memory model

I’ve written a set of modules that can be a daisy-chained, up to 8 “participating” via expander functionality. The current behavior has two types of messages:

  • “Control” messages passed to the right that are ultimately consumed by the “Primary” module
  • “Command” messages passed to the left that hold configuration, produced by the Primary

This has worked great on my prototype but it’s admittedly pretty horrid on resources: the Control messages daisy-chain copies dsp::Frame every process(). No, it’s worse than that: two dsp::Frame<27> every cycle. All the data copying: so the Nth module in the chain is going to have its data copied N-1 times to make it to the right-most module. Most of which are redundant copies. Minor annoyance is that the Nth module has an N-1 latency in getting its data all the way to the right.

To solve this, I’m thinking I should utilize a shared double-buffer on the Primary. Expanders would write directly into this buffer, eliminating per-module copying and reducing latency to a consistent one sample. Idea being:

  • The Primary maintains two buffers.
  • Expanders write into one buffer while the Primary consumes the other.
  • Buffer selection is derived from engine->getFrame() % 2.
  • Command messages flowing right-to-left would provide each expander with pointers (or offsets) into the shared buffers indicating where they should write.

This would eliminate the daisy-chain copying entirely and collapse expander-to-Primary latency to a single sample.

Assuming that works, I’ll need to address how the double-buffer should be managed. I’ve got two ideas there I’m stewing on.

(A) Use a std::shared_ptr

  • Primary allocates the double buffer and distributes it to expanders via Command messages.
  • Expanders hold a shared_ptr to the buffer.
  • Memory remains valid even if modules are removed.
  • If the Primary is removed, non-adjacent expanders (which may not receive onExpanderChange()) could eventually receive a Command message invalidating the buffer.

Upshot is management of the buffer is handled by the shared_ptr. Downside is management of the buffer is handled by the shared_ptr. Any atomic contention or locking, well, it’s whatever shared_ptr does. On the audio thread. Other messages in the forum advocate against shared_ptr.

(B) Primary-owned buffer and walk-the-chain.

  • The Primary owns the buffer as a member variable.
  • Every process(), each expander walks the right-hand daisy chain to:
    • Verify the chain is intact
    • Confirm the Primary is present (via moduleId)
  • If the Primary is found, expanders treat this as a heartbeat and write into the shared buffer.

This has no shared ownership or whatever-shared_ptr-does. All left-facing modules immediately detect removal. To get here, there is a fair bit of pointer chasing every process().

I’m leaning towards (B) as it avoids anything shared_ptr may be doing and has instantaneous effect on the daisy chain if any interediary module is pulled.

Would option (B) be a fair way of handling daisy chaining of expanders in this use case? Would there be any threading issues with the strategy of using getFrame() % 2 as a convention for expander modules to write to producer buffer while the opposite buffer is read from a consumer?

1 Like

I think there are many right approaches on this topic, depending on what your modules and expanders are doing. I few thoughts, which might help you:

  • You can use dsp::RingBuffer, which is thread-safe for single producer/single consumer. But no one prevents you to use one shared dsp::RingBuffer for master and each of your expanders, and you don’t need to care about threading.
  • shared_ptr does not help about threading. It simply takes care of automatic deconstruction when the last shared owner releases its handle. I see no issue using shared_ptr on the dsp thread, but I’m not sure if it is an answer to any of your questions.

I also have implemented different mechanisms for expanders, but most of the time I used a “single-brain” approach (I made this term up, not sure how to call it):
The expanders are simply more ports/leds/params, but have no or minimal processing on their own. The master module is responsible to process the additional elements, just like a simple hardware-expander with buttons or something. This way I don’t need to care about threading at all, because only one module/one thread is responsible for the whole chain. This approach might be not suitable if your expanders are more complex or need a brain on their own. On the positive side, you don’t have any delay at all, because the master processes all expanders at the same frame.

The only thing you must take care of is any change within the chain. At first I also did “walk-the-chain”, but switched to subscriber-model recently: Any expander sends a notification to a “topic” on onExpanderChange. This is approach is fine with threading, because the dsp engine is locked while onExpanderChange anyway. This method is even helpful if using something more complex like a shared dsp::RingBuffer between master and each expander, because all the pointers must be only updated or invalidated when any module on the topic gets its expanders changed.

Just ask if anything is not understandable. I’m far from knowing everything on this matter and I’m happy to learn when things can be done better or easier… :slight_smile:

6 Likes

Can you describe how an expander provides a knob (param) and a jack (input/output). Where do the underlying Param, Input, and Output live? Or are they mirrored in core and expander?

Thanks Ben! I’d been stewing on this for a few days and had a similar thought: have the primary pull the data from the expanders. I’m making things overly complex by having expander modules push data to a primary, when it’d be easier to have a single-brain orchestrate by pulling instead. Wouldn’t be the first time I overcomplicated something.

And you’re right that the The expander modules do very little work. Read param values, input jacks, set a few lights. Moving all that to have the primary orchestrated pull model is certainly do-able. I wasn’t sure what to do with the non-adjacent expander change, where it isn’t next to the Primary. The subscriber/notification for onExpanderChange this seems like a good solution here. Great to have a zero-latency for this, that’s kind of icing on the cake.

I stumbled on Accessing Expanders… thread from a year ago, looks like @not-things modules do something similar.

Once you have a core (aka single brain) and listeners, you don’t need to rely on the expander mechanism for making connections with its limitation to horizontal adjacency. Expanders can locate a core and subscribe to it by scanning modules or using a plugin-global broker (or by retaining the link after unconnected modules have “kissed” once via expander change). This is the way #d CHEM works, because it’s unusable to have all 16 modules (or a significant number) on one row. Once connected, they can remember which core they’re connected to by remembering it’s id, (Module ids are retained across save/load of a patch).

Modules connected this way can’t use the Rack message passing mechanism.

1 Like

I really like this idea, that would be the ultimate.

Usually I access them directly using the properties of Module (params, lights etc.), but it depends on the case. Sometimes I define an interface class for the master, so that the expanders can access data of the master, for example for context menu options on the expander.

1 Like

The modules in my plugin that use expanders indeed also follow the “single brain” approach. The only thing the expander modules actually do themselves is update some lights inside the draw method, or detect input triggers (in the one expander module that provides trigger inputs).

The detection of expanders in my modules relies on the simple “walk throught the expander chain” using getLeft/RgihtExpander methods each time the process method of the main module is called. Since the expanders are only there to provide additional inputs/outputs/controls without a brain, there was no need to make it more complex.

2 Likes