Local Transport and Shared Memory
MADS Version 3.0.0 speeds up the communication between a broker and the agents running on the same computer: agents now talk to the broker through local ipc sockets, and agents that publish large binary payloads (camera frames, point clouds, big arrays) can hand them over through shared memory instead of pushing the bytes through the broker. This guide explains when to use these features, how to configure and monitor them, and how to write plugins that read and write data without copying it.
This guide describe a feature that is introduced by MADS v3.x, and that is not available on previous versions.
Motivation
In a typical MADS network, every message goes from the publishing agent to the broker and from the broker to every subscriber. When all these agents run on the same computer as the broker (a very common setup: a single PC or an embedded board controlling a machine), going through the network stack is a waste, even over the loopback address 127.0.0.1. And when the messages are big (say, a 2 MB camera frame at 30 fps), the cost of copying the same bytes several times (agent → broker → each subscriber, plus compression and decompression) becomes the bottleneck.
MADS 3.0 attacks this with two independent and complementary features:
| Feature | What it does | Needs configuration? | Applies to |
|---|---|---|---|
Local ipc transport |
Agents on the broker’s host connect through Unix-domain sockets (files) instead of TCP | No, it is automatic | All messages |
| Shared-memory data plane | Blobs are written once in a memory area visible to all local agents; only a small reference travels through the broker | Yes, shm = true on the producer |
Binary blobs only |
You can use the first without the second, but not the other way around: the shared-memory data plane is only used when the broker is on the same host as the producer.
Jargon, in one sentence each. A socket is the endpoint that a program uses to send and receive messages; ZeroMQ is the messaging library MADS is built on. A Unix-domain socket (ipc:// in ZeroMQ) is a socket that is a file on the local disk and never touches the network card. A blob is the binary payload that can be attached to a MADS message next to its JSON part. Shared memory is a region of RAM that several processes can map in their address space at the same time, so that what one writes the others can read without any copy.
Part 1: the local ipc transport
What it is
Starting from v3.0.0, the broker opens its three sockets (frontend, backend and settings) twice: as usual on TCP ports, and also on Unix-domain socket files, stored in a per-user runtime directory. Agents learn about these extra endpoints at startup, by asking the broker with a new transport command on the settings socket. The reply contains the broker’s host token (hostname and machine id), and the path of the socket files.
If the agent finds out that it runs on the same host as the broker, and it can actually see the socket files, it connects through them. Otherwise it silently keeps using TCP. Remote agents are not affected at all.
Advantages and limitations
Advantages
- lower latency and lower CPU usage than loopback TCP, especially with large messages;
- the broker’s ipc sockets get 1 MiB kernel buffers by default (the stock OS default for Unix sockets is tiny, 8 KiB on macOS, and made ipc slower than TCP for messages of 256 KiB and more);
- no configuration is needed: it is on by default;
- TCP clients, including all remote agents, are unaffected.
Limitations
- agent and broker must be on the same host (same operating system instance). Inside containers, they must share the directory where the socket files live;
- the socket files live in an owner-only directory (mode
0700): only processes of the same OS user as the broker can connect; - the
ip_whitelistoption of the broker does not apply to ipc peers, since they have no IP address: the permissions of the runtime directory are their access control. CURVE encryption (--crypto), when enabled, applies to ipc exactly as to TCP; - a socket path must fit in 100 bytes (the
sun_pathlimit of Unix sockets), so keepipc_dirshort; - on Windows, ipc uses AF_UNIX sockets and needs Windows 10 or later and a libzmq built against the Windows 10 SDK. Otherwise the broker serves TCP only and agents silently fall back;
- service discovery (
--room) still advertises the TCP endpoints only.
Configuration
The default (transport = "auto") is almost always what you want. These are the keys, as documented in mads-broker(1) and in the template mads.ini:
| Key | Section | Default | Meaning |
|---|---|---|---|
transport |
[agents] or a specific agent’s section |
"auto" |
"auto": use ipc if the broker is on this host and the socket files are visible, TCP otherwise. "tcp": always use TCP. "ipc": use ipc, and fail at startup if it cannot be honoured |
--transport MODE |
command line of every agent | (none) | Same values as above. It overrides the agent’s section, which in turn overrides [agents] |
ipc |
[broker] |
true |
Also bind the sockets on ipc endpoints. Set it to false to serve TCP only |
ipc_dir |
[broker] |
$XDG_RUNTIME_DIR/mads, else /tmp/mads-<uid>; %LOCALAPPDATA%\mads\run on Windows |
Directory of the socket files |
frontend_ipc, backend_ipc, settings_ipc |
[broker] |
derived from ipc_dir |
Explicit ipc://... endpoint for one socket (overrides ipc_dir) |
ipc_sndbuf, ipc_rcvbuf |
[agents] |
1048576 (1 MiB; 0 = OS default) |
Kernel buffer size of ipc connections only |
The socket files are named broker-<port>-front.ipc, broker-<port>-back.ipc and broker-<port>-settings.ipc, where <port> is the TCP port of the same socket. Hence several brokers can coexist on one host without clashing.
A settings URL of the form ipc://... is also accepted by the -s option of the agents (for example mads source -s ipc:///tmp/mads-1000/broker-9092-settings.ipc ...); this implies ipc for the data sockets.
For example, to force TCP for everybody but one agent that must use ipc:
[agents]
transport = "tcp"
[camera]
transport = "ipc" # fails at startup if ipc cannot be usedOn Linux the kernel silently caps the buffer request at net.core.wmem_max and net.core.rmem_max (about 208 KiB on a stock install). Until you raise the limits, the 1 MiB default has no effect:
sudo sysctl -w net.core.wmem_max=4194304 net.core.rmem_max=4194304Make it permanent with a file in /etc/sysctl.d/. On macOS the cap is kern.ipc.maxsockbuf (8 MiB by default), so 1 MiB applies as is.
Is it working?
Three places tell you:
- the broker startup banner prints one extra
Binding broker ... at ipc://...line for each of the frontend, backend and settings sockets. The N key of the broker lists the ipc endpoints next to the TCP ones; - the info block that each agent prints at startup has a line like
Transport: ipc (mode auto); mads doctor(check 8, Local transport) reports whether libzmq supports ipc, whether the runtime directory exists, belongs to you and is owner-only, which endpoints the broker advertises and whether the socket files are visible from here. It also predicts which transport an agent started on this host would select, including per-agent overrides.
Troubleshooting
- An agent on the broker’s host uses TCP. Run
mads doctor: it prints the reason (broker too old to know thetransportcommand,ipc = falseon the broker, socket file not visible, agent running as another user, container with a different/tmp…). A reason is also printed whentransport = "ipc"makes the agent refuse to start. - The broker warns that an ipc endpoint was skipped. A problem with ipc never affects TCP service. The broker replaces stale socket files left by a crashed broker, but it leaves alone a socket that another live process still serves, and a path that is not a socket. On a clean exit it removes its own socket files.
- Large messages are slower than expected on Linux. See the buffer cap above.
- You want to go back to the 2.x behavior. Set
transport = "tcp"in[agents]and, optionally,ipc = falsein[broker].
Part 3: programming plugins for zero copy
The plugin protocol P9
MADS 3.0 moves the plugin protocol from P8 to P9. Loaders accept exactly the host’s protocol: a plugin built for P8 or older stops with an error asking to rebuild it, and a plugin built for a newer protocol is refused too. The zero-copy API is an addition: P9 only adds virtual methods whose default implementations copy, so a plugin keeps working unchanged. To port an existing plugin:
mads plugin --update path/to/my_plugin # use --dry-run first, to see what changes
cmake -Bbuild && cmake --build buildmads plugin --update moves the mads_plugin pin in CMakeLists.txt to v2.6-P9 and makes Release the default build type (set before project(), because MSVC otherwise builds Debug with single-config generators). No source change is needed. Zero-copy is opt-in, per method.
Rust plugins (mads plugin --rust) do not have a zero-copy API: the mads-plugin crate has no shared-memory support, and --zero-copy is ignored with a warning for them. Rust plugins therefore only benefit from the local ipc transport; for large payloads, write the plugin in C++.
What the host does for you
The plugin loaders (mads source, mads filter, mads sink) drive the new hooks as follows:
| Plugin kind | Hook | Used when | Otherwise |
|---|---|---|---|
| Source | supports_shared_output() + get_output_shared(out, buf) |
the plugin returns true and the agent has shm = true (and the broker is local) |
get_output(out, &blob) |
| Filter | supports_shared_output() + process_shared(out, buf) |
same | process(out, &blob) |
| Filter, Sink | load_data_view(data, topic, blob) |
a shared-memory blob arrives | default implementation: copies the view and calls load_data(data, topic, &blob) |
Two details worth knowing:
supports_shared_output()is queried once, afterset_params(), and must not change afterwards;- the classic
get_output(),process()andload_data()stay mandatory: they are the fallback when shared memory is off, when the blob does not fit a slot, and for consumers that are not on shared memory at all. Your plugin must work on both paths.
Producing without copies
The host passes the plugin a BlobBuffer (defined in mads_plugin’s common.hpp):
struct BlobBuffer {
unsigned char *data = nullptr; // start of the writable buffer (a ring slot)
size_t capacity = 0; // bytes available at `data`
size_t size = 0; // bytes written by the plugin
};The contract is:
- the host sets
dataandcapacity, andsizeto 0; - write at most
capacitybytes atdata, then setsizeto the number of bytes written (size == 0means no blob, a JSON-only message). Fillout(the JSON part) exactly as inget_output(); - if the payload does not fit: write nothing, set
sizeto the number of bytes you would need (sosize > capacity) and returnreturn_type::retry. The host detects the overflow fromsize > capacityand, in the same loop iteration, callsget_output()(orprocess()) to deliver that payload through the ordinary, copying path. For this to work you must check the size before consuming your pending data, so that the fallback call delivers the same payload; buf.datais valid only during the call: never store the pointer.
Write your data production as a function that takes the destination pointer, like render_into(unsigned char *dst, size_t n), and call it from both get_output() (with the blob vector as destination) and get_output_shared() (with buf.data). The two paths then can’t diverge.
A complete example: a source producing an image
The following source generates a grey gradient frame, whose size is set by parameters. It is a trimmed version of what mads plugin --zero-copy source generates, with the body filled in:
#include <source.hpp>
#include <nlohmann/json.hpp>
#include <pugg/Kernel.h>
#include <vector>
#ifndef PLUGIN_NAME
#define PLUGIN_NAME "gradient"
#endif
using namespace std;
using json = nlohmann::json;
class Gradient : public Source<json> {
public:
using Source::Source; // inherit constructors
string kind() override { return PLUGIN_NAME; }
// Required: shown by the agent when it loads the plugin
map<string, string> info() override { return {{"Zero-copy", "yes"}}; }
void set_params(const json ¶ms) override {
Source::set_params(params);
_params["width"] = 640;
_params["height"] = 480;
_params.merge_patch(params);
_w = _params["width"].get<size_t>();
_h = _params["height"].get<size_t>();
_blob_format = "gray8"; // reported as "format" in the message
}
// Classic path: the blob goes in a vector (always required)
return_type get_output(json &out, vector<unsigned char> *blob = nullptr) override {
out.clear();
if (blob) {
blob->resize(_w * _h);
render_into(blob->data());
}
describe(out);
return return_type::success;
}
// Zero-copy path: opt in...
bool supports_shared_output() const override { return true; }
// ...and write straight into the ring slot
return_type get_output_shared(json &out, BlobBuffer &buf) override {
out.clear();
const size_t needed = _w * _h;
if (needed > buf.capacity) {
buf.size = needed; // overflow: report what we need,
return return_type::retry; // write nothing; the host calls get_output()
}
render_into(buf.data);
buf.size = needed; // bytes written
describe(out);
return return_type::success;
}
private:
size_t _w = 0, _h = 0;
size_t _frame = 0;
void render_into(unsigned char *dst) {
for (size_t y = 0; y < _h; ++y)
for (size_t x = 0; x < _w; ++x)
dst[y * _w + x] = static_cast<unsigned char>((x + y + _frame) & 0xff);
++_frame;
}
void describe(json &out) {
out["width"] = _w; // the geometry travels in the JSON part
out["height"] = _h;
if (!_agent_id.empty()) out["agent_id"] = _agent_id;
}
};
MADS_REGISTER_PLUGINS(Gradient)A few remarks:
- in the overflow case this example does not advance
_frame(it does not consume anything), so the fallbackget_output()produces the very same frame; - the loader adds the
formatfield to the message (fromblob_format(), heregray8) when the plugin did not set it; geometry likewidthandheightis the plugin’s business and goes in the JSON part, which always travels through the broker; - to see the zero-copy path, run it with
-o shm=trueand look for theShared output: zero-copyline at startup.
Filters
A filter works the same way, with process_shared(out, buf) in place of get_output_shared(). The only difference: on overflow it must leave its loaded input untouched, because the host will call process() on the same input right after. Filters can combine both directions: read their input as a view (load_data_view(), below) and write their output into a slot with process_shared().
Consuming without copies
Filters and sinks can override:
return_type load_data_view(json const &input, string topic, BlobView blob) override;where BlobView is a plain pair:
struct BlobView {
const unsigned char *data = nullptr; // first byte of the payload
size_t size = 0; // payload size in bytes
};The host calls load_data_view() instead of load_data() when the incoming blob lives in a shared-memory ring; messages without a blob still go through load_data(). The default implementation copies the view and calls load_data(), which is why existing plugins keep working. Once you override it, you must respect the contract:
- the view is read-only: never write through
blob.data, the memory is shared with other readers (and may be mapped read-only); - the view is valid only during the call and, for a filter, the following
process()/process_shared()call of the same loop iteration; in any case only until the nextload_data()orload_data_view(). Never storeblob.datain a member. If you need the bytes later, copy them; - the ring is lossy, so the producer may overwrite the slot at any time, even in the window above. The host re-checks the slot after your call(s) (and after
process()for a filter); if it was overwritten, it discards the result and counts a torn read. So you may see garbage while reading, and it will be thrown away: never use data from a view for anything that cannot be safely discarded, such as irreversible side effects (actuating a machine, deleting a file). Compute, then let the host decide whether the result is valid; blob.data == nullptr(withsize == 0) means no blob.
A complete example: a sink reading a view
This sink computes the average grey level of the frames produced by the source above, without copying them:
#include <sink.hpp>
#include <nlohmann/json.hpp>
#include <pugg/Kernel.h>
#include <iostream>
#ifndef PLUGIN_NAME
#define PLUGIN_NAME "mean_gray"
#endif
using namespace std;
using json = nlohmann::json;
class MeanGray : public Sink<json> {
public:
using Sink::Sink;
string kind() override { return PLUGIN_NAME; }
// Required: shown by the agent when it loads the plugin
map<string, string> info() override { return {{"Zero-copy", "yes"}}; }
// Classic path: needed for non-shm producers
return_type load_data(json const &input, string topic = "",
vector<unsigned char> const *blob = nullptr) override {
if (!blob || blob->empty()) return return_type::retry; // nothing to do
report(blob->data(), blob->size(), input);
return return_type::success;
}
// Zero-copy path: `blob` points straight into the producer's ring
return_type load_data_view(json const &input, string topic,
BlobView blob) override {
if (!blob.data) return return_type::retry;
report(blob.data, blob.size, input); // read in place, keep nothing
return return_type::success;
}
private:
void report(const unsigned char *p, size_t n, json const &meta) {
unsigned long long sum = 0;
for (size_t i = 0; i < n; ++i) sum += p[i];
cout << meta.value("agent_id", "?") << ": " << meta.value("width", 0)
<< "x" << meta.value("height", 0) << ", mean = "
<< (n ? static_cast<double>(sum) / n : 0.0) << endl;
}
};
MADS_REGISTER_PLUGINS(MeanGray)Both entry points share the same report() routine, which only reads. If the producer overwrites the slot while report() is running, the line has already been printed: for a sink that causes an external effect, this is something to consider (do the computation first, and only act at the end of a call that the host will validate, or accept that an occasional torn frame produces a spurious output).
Scaffolding and testing
mads plugin --zero-copy (short -z) generates the hooks for you, with the contract in comments:
mads plugin --zero-copy -t source gradient # get_output_shared()
mads plugin -z -t filter my_filter # load_data_view() + process_shared()
mads plugin -z -t sink my_sink # load_data_view()The option is valid for C++ only, and without it the generated code carries a one-line comment pointing at the flag. The plugin name is the positional argument (or -n), the type is -t source|filter|sink, and -d sets the directory.
Testing tips:
- the generated
main()(used when you run the plugin as an executable) calls the shared hooks with a localstd::vectorstanding in for the ring slot, so you can unit-test the zero-copy code without any agent: callplugin.get_output_shared(out, BlobBuffer{ptr, capacity, 0})orplugin.load_data_view(in, "", BlobView{ptr, n})yourself. Test the overflow case too, with a buffer that is too small: you expectretryandbuf.size > buf.capacity; - run the real thing with
-o shm=trueon the producer and watch theShared output:line at startup; - check the consumer for
tornwarnings: if they are frequent, the ring is too short for that consumer: raiseshm_slots; - test both paths, shared memory on and off (
-o shm=false): they must produce the same messages.
Part 4: custom C++ agents
If you write an agent with the Agent class directly (not as a plugin), the same machinery is exposed in agent.hpp. The transport selection needs nothing from you (agent.init() honours transport and shm from the settings). The relevant API:
| Method | Purpose |
|---|---|
void enable_shared_output(uint32_t slots, size_t slot_size) |
Create the ring (slots >= 2, slot_size > 0). Called by init() when shm = true; calling it again replaces the ring. Takes effect only if the broker is local |
bool shared_output_enabled() const |
Whether blob publishes go through the ring |
size_t shared_slot_size() const |
Slot capacity in bytes, 0 if disabled |
WritableSlot acquire_slot() |
Take the next slot (round robin). slot.data and slot.capacity give the writable area. Throws AgentError if shared output is not enabled |
void publish_slot(WritableSlot &slot, size_t size, nlohmann::json meta, std::string topic) |
Publish the first size bytes of the slot as a shared-memory reference. Consumes the slot |
void abandon_slot(WritableSlot &slot) |
Give back a slot into which nothing was written: the previous payload of that slot stays valid |
void discard_slot(WritableSlot &slot) |
Give back a slot that may have been written: its old payload is invalidated |
void accept_shared_views(bool) |
Consumer: opt in to receive message_type::shared instead of a copied message_type::blob |
SharedView const &last_shared() const |
The last view received. data(), size(), meta(), and valid() |
void record_torn_view() |
Count a torn read noticed by the caller |
size_t shm_torn() const, size_t shm_failed() const |
Counters of dropped payloads |
void set_transport_mode(std::string const &), std::string transport_mode() const, std::string transport() const |
Force and read the transport ("auto", "tcp", "ipc"; transport() is what is actually in use) |
A producer:
Mads::Agent agent("camera", "tcp://localhost:9092");
agent.enable_shared_output(8, 8 * 1024 * 1024); // or shm = true in mads.ini
agent.init();
// ... in the loop:
if (agent.shared_output_enabled() && frame_bytes <= agent.shared_slot_size()) {
auto slot = agent.acquire_slot();
size_t n = grab_frame_into(slot.data, slot.capacity);
if (n == 0) {
agent.abandon_slot(slot); // nothing written
} else {
agent.publish_slot(slot, n, {{"format", "gray8"}, {"width", w}, {"height", h}}, "camera");
}
} else {
agent.publish(frame_vector, {{"format", "gray8"}}, "camera"); // inline
}A consumer:
agent.accept_shared_views(true);
// ... in the loop:
switch (agent.receive()) {
case Mads::message_type::shared: {
auto const &view = agent.last_shared();
process(view.data(), view.size()); // read in place
if (!view.valid()) agent.record_torn_view(); // overwritten while reading
break;
}
case Mads::message_type::blob: /* ordinary copy (inline blob or opted out) */ break;
default: break;
}Always test valid() after reading the bytes; a copy of a SharedView is cheap and keeps the mapping alive, but not the validity of the data. As in the plugin case, acquire_slot()/publish_slot() are meant to be used from a single thread. If you keep a slot unpublished while acquiring slots more, it gets overwritten: publish promptly.
receive_raw_message() always materializes a shared-memory reference as a normal blob frame, so tools that forward raw messages keep working.
The Agent API is C++ only and Agent gained members in v3.0.0: code that links MadsCore must be recompiled.
Compatibility and FAQ
Can I mix 2.5.x and 3.0.x components? No. The settings handshake compares MAJOR.MINOR, so a 3.0 broker refuses 2.5 agents and vice-versa. Upgrade the broker and all agents together; recompile anything that links MadsCore and rebuild plugins (mads plugin --update).
Do I need to change my existing plugins? Only rebuild them against P9 (see Part 3). They automatically get ipc, since that is a property of the agent and not of the plugin.
Will a remote agent still work? Yes. TCP is unchanged, and transport = "auto" falls back to TCP when the broker is not on the same host.
I enabled shm = true and one of the consumers receives nothing. It is most probably on another host, running as another user, or in another container: check the first warning in its console, and mads doctor. If you cannot fix that, turn shm off for that topic.
Does shared memory work with encryption (CURVE)? The blob bytes in the ring are not encrypted by CURVE: only the small reference travels through the broker, and access control is that of the OS (owner-only segments). If you need encrypted payloads, leave shm off.
Does the C API or mads record need changes? No. Both receive ordinary blob copies.
Can the JSON part use shared memory? No: only blobs do.
What if my blob is sometimes larger than a slot? It goes inline for that message, with a one-time warning. Raise shm_slot_size if it is frequent.
Where to read more. man mads-broker (LOCAL TRANSPORT), man mads-source (SHARED MEMORY), man mads-doctor (checks 8 and 9), man mads-plugin (--zero-copy, --update), the CHANGES.md file of the v3.0.0 release, and the mads_plugin headers (common.hpp, source.hpp, filter.hpp, sink.hpp), whose comments define the contract of every hook.