Experimental Raw POD Payloads
Raw POD payloads are an experimental, expert-level escape hatch for applications
that intentionally need fixed-layout C++ structs without a schema compiler or
generated code. They use ordinary Output<T>/Input<T> ports, but the
application owns the message identity, exact layout, compatibility policy, and
coordinated producer/consumer deployment.
Prefer a generated FlatBuffers schema for ordinary application messages.
Schemas provide generated owning types, verified serialization, reflection,
nested and variable-length fields, and a schema identity derived from the
.fbs source. Raw POD has no machine-readable schema, compatibility
negotiation, or rollout tooling. Its declarations may change between
releases.
Use this path only when avoiding FlatBuffers and generated code is itself a requirement, the payload is a small fixed-layout scalar aggregate, and every producer and consumer can be updated together. Start with Custom FlatBuffer schemas otherwise.
Complete C++ example: examples/experimental/pod_payloads/cpp
- Why a custom struct needs an explicit identity and layout declaration
- How to specialize
ChannelTypeIdentity<T>(name + version) - How to specialize
ChannelPodLayout<T>so the fields tile the struct exactly - How to wire a POD port exactly like a scalar port — and run it
- How the canonical little-endian codec is selected automatically
- Which compatibility, evolution, and rollout responsibilities remain yours
- How focused compile-time diagnostics reject invalid identities and layouts
Decide whether raw POD is justified
Raw POD can be appropriate when all of these are true:
- A schema compiler dependency and generated sources are unacceptable for the deployment.
- The message is a small, fixed set of portable scalar values or fixed arrays.
- You need the direct fixed-size in-process carriage and accept the restricted data model.
- You control and coordinate every producer and consumer.
Choose a FlatBuffers schema when the message may evolve, contains optional, nested, string, vector, or Tensor fields, crosses independently deployed components, or benefits from reflection and generated tooling. A raw POD cannot own external storage: pointers are rejected, and putting a Tensor or buffer handle in the struct does not create a valid lifetime or transport contract.
Generated schemas retain a stable family identity across source edits accepted
by a maintained conformance baseline; they do not negotiate compatibility at
runtime. Their advantage is that identity, native types, verification, and
codecs are generated consistently from one machine-readable .fbs source
instead of duplicated manual layout declarations. Participants
must still be rebuilt against the current schema set.
Binary compatibility and schema evolution are your responsibility. The SDK verifies the exact identity and declared field layout presented by each endpoint; it cannot determine whether two independently authored declarations still have the same meaning. Change the version whenever layout or semantics change, and deploy matching producers and consumers together.
Why declare anything at all?
The runtime never guesses what your struct means. There is no RTTI fallback, no compiler-name fallback, and no “same size, close enough” matching. Instead, a POD payload carries an explicit, versioned identity, and a producer and a consumer interoperate only when their canonical name, version, object size/alignment, and complete field layout are identical — even across processes, even when the two sides were built from different C++ types.
Pinning the identity at authoring time moves layout problems into compile errors and mismatched identities into typed, fail-closed rejections. It does not provide compatibility across versions or coordinate independent deployments. See payload type rules for where POD structs sit among the admitted port payload families.
The two trait specializations
A POD payload type must be standard-layout and trivially copyable, and must have
both traits specialized. When OperatorSpec::input/output or typed external-topic
authoring materializes the payload identity, the
assert_complete_channel_pod_contract check rejects an incomplete contract with
a focused compile-time diagnostic. The Boolean CatalogChannelPod<T> concept can
still be inspected safely in detection and overload-selection code.
ChannelTypeIdentity<T>: name and version
nameis a globally scoped, dotted UTF-8 string (it must contain at least one., must not start or end with., and is capped at 255 bytes). Use a reverse-DNS style prefix you control.versionis a nonzerostd::uint32_t. Bump it whenever the struct’s meaning or layout changes — producer and consumer must match it exactly.
ChannelPodLayout<T>: exact byte tiling
Each pod_field(name, member-pointer, offsetof(...)) describes one member. The
ordered list must tile the struct exactly:
- Fields appear in increasing byte-offset order, each starting exactly where the previous one ended.
- Every object byte is covered exactly once — no gaps, no overlap, and no tail
padding (
next offset == sizeof(T)after the last field). - Each field is
std::byte, a fixed-width scalar (std::uint8_t…std::uint64_t,std::int8_t…std::int64_t,float,double) or a fixed-length array of one (T[N]orstd::array<T, N>). - Field names are unique, and each member pointer must actually select the bytes its declared offset claims.
All of this is checked at compile time when typed authoring materializes the port identity. Diagnostics identify invalid names or versions, unsupported member types, duplicate fields, incorrect member offsets, gaps or overlap, and incomplete coverage including tail padding.
Fixed-width scalars only — and mind the padding. Use std::int32_t, not
int. bool, enums other than std::byte, pointers, nested structs, and std::string are not
admitted as POD fields (plain bool remains fine as a standalone scalar
port payload). If your member order introduces alignment padding, the tiling
check fails at compile time; reorder members (largest first is a good habit)
so the struct is padding-free.
Choose opaque bytes or numeric values
Use std::byte for opaque storage such as encoded packets or camera statistics blobs that
consumers pass to another decoder. Use std::uint8_t for numeric values such as intensities,
counts, or flags that consumers inspect or calculate with. Both occupy one byte, but they
declare different field semantics in a POD layout:
Opaque byte arrays copy directly into one Python bytes object, avoiding a Python integer
reference per element. Numeric arrays retain their tuple representation, including arrays
with one element. Neither choice changes a scalar field into a container.
For example, the following padding-free struct combines an opaque blob and numeric values; declare its identity and layout with the traits shown above:
Its Python field dictionary has this form:
The catalog records opaque byte elements as byte, separately from numeric u8, and includes
that distinction in the layout digest. Changing an existing field from std::uint8_t to
std::byte therefore requires a new ChannelTypeIdentity<T>::version and coordinated producer
and consumer updates, even when its size and offset are unchanged. Existing numeric payloads
do not need to change to benefit from the other Python conversion improvements.
Worked example: a fusion control struct
Continuing the camera + IMU running example: suppose the fusion stage emits a small control message — which frame it corresponds to, an exposure correction, and a confidence score — for a downstream controller. Three fixed-width members, 12 bytes, no padding:
From here on, FusionControl behaves exactly like a scalar port type. The
producer declares an Output<FusionControl> in setup(), states its temporal
contract, and emits values from compute():
The consumer mirrors it with an Input<FusionControl>; receive() returns an
expected<Sample<FusionControl>, Error> whose data member is the struct
value, ready to use:
Wiring, compiling, and running are byte-for-byte the same as the scalar pipeline you built in your first pipeline — there is no codec opt-in and no special compile option:
The shipping experimental pod_payloads example provides the complete FusionControl,
ChannelTypeIdentity, exact-tiling ChannelPodLayout, graph round trip, and
rejection cases.
The canonical codec — selected automatically
You never write encode/decode code for a POD payload. The runtime derives a
fieldwise codec from your ChannelPodLayout<T> declaration:
- The wire representation is canonical little-endian for integers, with
IEEE 754 formats for
float/double, so an encoded struct is portable across processes and ABIs by construction. - In-process, no encoding happens at all: the value is carried inline as the
typed struct (payload size is exactly
sizeof(T)), so a localadd_flow()connection pays no serialization cost. - The choice between the inline and serialized representations is made by the runtime from the connection — your operators are identical either way.
The same struct across processes
Because the codec is canonical, a POD payload is interprocess-capable. A
graph can export or import the port’s topic over an interprocess channel
(holomq://… via export_topic<T>() / import_topic<T>()), and a standalone
process can publish or subscribe with the ChannelRuntime client API — both
sides simply compile the same two trait specializations. See
interprocess channels
and standalone channel clients
for the wiring.
One graph, one vocabulary. Inside a graph, operators communicate through
typed ports (Input<T>/Output<T>) wired with add_flow() — the runtime
materializes the channels for you. The ChannelRuntime client API exists
only for standalone processes outside any graph. Never mix the two: using
ChannelRuntime inside an operator bypasses the compiled plan and its
guarantees.
At the boundary, identity is enforced strictly: an incoming frame whose name,
version, or layout digest does not match the declared identity is rejected with
a typed kIncompatible error and never reaches your compute(). There is
no negotiation, no “best effort” decode, and no partial struct — the mismatch
fails closed, in keeping with the runtime’s
errors-as-values
discipline.
From Python
A Python Operator can exchange a raw-POD payload with C++ Operators, or with other Python Operators, once the C++ type is registered by name. Register it in the library that defines it, next to the two traits:
The registration runs when the library loads, for example when Python imports the package that ships it. Python then names the type by its registered name:
A received value is a dict with one entry per field: an int or float for a
scalar field, bytes for a std::byte array, and a tuple for a numeric fixed-array field,
so a one-element array such as std::array<float, 1> is a one-element tuple.
std::uint8_t arrays remain numeric tuples. Emit accepts either a mapping with
exactly the field names, or any C-contiguous buffer holding the native object
bytes (for example a NumPy structured scalar), which skips the per-field
conversion. The mapping’s keys are checked before any value is read, and each
value is read once, so a defaultdict cannot fill in a missing field. Each field
is range- and type-checked like a scalar port. A missing or unknown field, a
wrong array length, a bad element, or a buffer of the wrong size raises an error
that names the field or the sizes, and nothing is emitted.
Python cannot declare a new raw-POD layout; the type and its traits must come from C++. The standalone Python Channel client does not support raw-POD endpoints.
Rules of thumb and failure modes
- Two traits, always.
ChannelTypeIdentity<T>andChannelPodLayout<T>are both required; there is no macro shortcut and no reflection fallback. - Tile the struct exactly. Fields in offset order, every byte covered, no padding — or typed port/topic authoring does not compile when it materializes the identity. This is the good failure mode: layout bugs die at build time.
- Fixed-width scalars (and fixed arrays of them) only.
std::int32_t, notint; nobool,enum, pointers, nested structs, orstd::stringas fields. - Identity is pinned, not negotiated. Producer and consumer must agree on
name + version (and, implicitly, the full field layout) exactly. Any mismatch
is a typed
kIncompatiblerejection — fail closed, never a garbled struct. - Bump the version when the struct changes. Same name + same version must always mean the same bytes; treat a layout edit without a version bump as a wire-protocol bug. The SDK does not coordinate rollout or establish semantic compatibility for you.
- No external ownership. Pointer, string, nested object, buffer-handle, and Tensor members are unsupported. Raw POD cannot express storage lifetime or device-transfer policy.
- Keep PODs small and self-contained. Bulk or device-backed data belongs on the tensor path; structured, evolving messages may fit schema payloads better.
- In-graph traffic uses
add_flow(), neverChannelRuntime.
Recap
- A custom POD struct becomes a first-class port payload with exactly two
declarations:
ChannelTypeIdentity<T>(dotted name + nonzero version) andChannelPodLayout<T>(pod_fields/pod_fieldtiling every byte exactly). - Ports,
emit()/receive(),add_flow(),compile(), andrun_async()are unchanged from the scalar workflow — the runtime selects the codec from the type. - The canonical little-endian codec makes the same struct work in-process (carried inline, zero serialization) and across processes.
- Layout mistakes fail at compile time; identity mismatches fail closed with a
typed
kIncompatibleerror before yourcompute()ever runs.
Next: FAQ