Reusable Graph Embedding
Once a pipeline stage proves itself — a sensor-fusion block, a preprocessing
chain, a control loop — you will want to reuse it in other applications, or
instantiate it several times inside one. Holoscan lets you package a subgraph
as a typed reusable component: a Graph subtype with a small public
interface of Input<T>/Output<T> ports and a private compose() body.
Parents drop named instances into their own graph with embed<G>() and wire
to the child’s interface with the same compile-checked add_flow() they use
for ordinary operators.
Think of a reusable graph as a function for pipelines: one definition, many independent instances, and a typed signature that the compiler enforces at every call site.
Complete C++ example: examples/reusable_graphs/cpp
The Python tabs below provide the reusable-graph authoring walkthrough; a standalone Python application is not yet shipped with this example.
- How to derive a reusable
Graphsubtype with publicInput<T>/Output<T>interface members and acompose()body that exports them - How
embed<G>("name", args...)creates a named child instance and returns aGraphHandle<G> - The lifetime rules of the weak, nullable
GraphHandle— and why stale handles throw instead of returning garbage - What embedding actually costs (per-instance construction and flattening) and why publication is transactional
- How nesting, re-export,
GraphInstancePath, and parent-applied policy work across the embedding boundary - How Python defines reusable graphs and embeds graph types registered by a C++ provider
Defining a reusable graph
A reusable component derives from Graph. Its public interface is a set
of input/output members, and its body is authored in a compose() override
using the ordinary composition verbs. Exports bind the public interface to
internal operator ports:
C++
Python
There is no separate interface schema or descriptor file: the type’s public
class surface is its interface boundary. You declare root ports and
export_* them; the graph handle exposes only that declared interface.
Anything you do not export fails closed — internal operator ports are simply
not reachable from outside, so refactoring the body never breaks a parent as
long as the exported interface holds.
An export is an interface alias, not a data edge: it declares that the public port and the internal port are the same connection point. No queue is created by the export itself; the queue materializes when a parent wires a flow to the exported port. Payload type and direction must match exactly, and a mismatch throws during authoring — the same fail-fast style as the rest of graph composition.
The body may use everything a top-level graph can, including regions and clocks. A reusable graph can even ship its own policy as part of its definition:
Embedding registered C++ graph types from Python
A C++ library can register a reusable graph type just as it registers an
operator type. After the provider is imported, Python exposes unambiguous graph
names through holoscan.graphs and lists every registered graph through
hs.registry.graph_types(). This example uses an illustrative independently
installed provider:
The imported ScalePairGraph proxy is the preferred typed spelling.
graph.embed() also accepts the fully qualified name, for example
"holoscan.examples.reusable_graphs.ScalePairGraph", for dynamic builders. Use
hs.registry.resolve(name) when a data-driven builder needs the immutable
GraphType metadata before embedding. The returned root hs.GraphHandle
exposes the child’s exported ports by name as attributes, through indexing, or
through its ports mapping; it never exposes the child’s internal operators.
Constructor keyword arguments are validated from the registered C++ parameter
schema. Unknown names, invalid arguments, recursive embedding, and stale
handles therefore retain the same Core validation and stable error tokens as
typed C++ embed<G>(). Graph-type discovery is explicit and process-local: a
separately shipped provider must be imported before its graph proxy can be
resolved.
Clock imports
A reusable graph does not inherit an ancestor’s default clock. Declare the clock dependency in the child, select that import as its local default, and expose the key so the immediate parent can bind it:
The embedding parent resolves that exact child import to one of its concrete clocks:
Python uses the same explicit boundary through the weak child handle:
Every nested boundary is explicit: a wrapper that embeds another reusable
graph binds the inner import to its own import before the root binds the
wrapper. Compilation rejects an unresolved dependency with
CLOCK_IMPORT_UNBOUND; it never guesses or inherits an ancestor clock.
Embedding an instance
Inside a parent’s compose(), embed<G>("name", args...) constructs one
child instance, runs its compose(), and publishes it into the parent. It
returns a GraphHandle<G> — a typed handle through which you reach the
child’s exported interface members and wire them like any other ports. This
is an illustrative bidirectional embedding pattern:
For compiled nested re-export, repeated-instance, and replay evidence, see the
shipping reusable_graphs example.
The picture the parent ends up with looks like this: two plain operators, one
embedded instance whose exported interface ports sit on its boundary, and
add_flow edges that cross the boundary only through those ports:
Two things to notice:
- The wiring is compile-checked.
fusion->camerais the child’s typedInput<std::uint32_t>, so a payload or direction mismatch is a build error, not a runtime surprise. - Extra arguments forward once to the child’s constructor. Unlike an
operator factory,
embed<G>()does not replay the child constructor, so it can accept move-only ownership such asstd::unique_ptr<T>. String views and literals still normalize to owned strings. The same shallow checks reject obvious top-level borrows and Graph authoring handles. Nested borrows cannot be inspected: their referents must outlive every child or reconstructed operator that uses them. A child remains independently owned by its parent after construction.
Embedding is not a cheap reference. Each embed<G>() fully constructs
its own child object, runs its compose(), and flattens the result into the
parent. Each instance has its own Graph object and operator prototypes, but
captured application state follows ordinary C++ copy semantics. Passing the
same std::shared_ptr<T> to two children deliberately shares its pointee;
separate Graph ownership does not imply independent captured state. The
capture lifetime rules
also apply when children pass shared state into operator factories.
A large reusable body pays its full construction and flattening cost at
every instance and every nesting level. Reach for embed to reuse graph
structure with distinct child objects, and choose constructor captures to
express the intended application-state ownership.
Transactional publication
Publication into the parent is atomic. If the child’s construction,
compose(), validation, or allocation fails, embed throws and the parent
is left exactly as it was — no half-registered instance, no dangling
ports. This matters for generated or programmatic authoring: you can catch,
adjust, and retry an embed without rebuilding the parent. There is no
try_embed variant; embedding is exception-only and eager, so establish an
exception boundary around authoring code that composes speculatively.
Handles and lifetime
GraphHandle<G> reads like a smart pointer (handle->member) but it is a
weak, read-only view. The parent owns the child; the handle does not
extend the child’s lifetime, and it never grants authoring authority over the
child’s internals.
A retained handle can go stale. After the parent retires, the handle is
invalid — and dereferencing it throws an error tagged
GRAPH_HANDLE_INVALID rather than returning garbage. Before dereferencing
any handle you kept around past the embed() call, check
handle.graph_valid() or the equivalent explicit operator bool():
For anything you persist, compare, or display, take a durable GraphView
snapshot from the parent instead of holding handles — a view deep-owns
immutable records and stays valid after the parent and all handles retire.
Treat the handle like a std::weak_ptr you must validate, not a
shared_ptr. Within the same compose() body that called embed, using the
fresh handle immediately is always safe; the validity dance is for handles
you retain.
Python GraphHandle objects follow the same weak rule. Check
handle.graph_valid before retained access. Once the parent is gone,
handle.path becomes None, port handles report graph_valid == False, and
normal attribute probes fail with AttributeError rather than reviving the
child.
Instance identity: GraphInstancePath
Every embedded instance has a structured identity. Instance names are
literal path segments, not /-split strings: a name like "camera/left"
is one segment containing a slash, and the segments ".", "..", and
"_root" are reserved. Use GraphInstancePath — which exposes its
segments() — to render and compare identities; never split a rendered path
on /.
handle.path() returns std::optional<GraphInstancePath> relative to the
direct parent (for the example above, the single segment
"fusion-stage"). Once a graph is itself embedded in an outer graph, the
root-qualified paths live in the GraphView records — the view is the source
of truth for globally unique identity; handle.path() is a local
convenience.
Nesting and re-export
Reusable graphs compose recursively: a reusable graph may itself embed
other reusable graphs, and a parent may embed the same type more than once —
distinct names produce distinct, fully independent instances with distinct
instance paths.
Visibility across levels follows one rule: only ports exported at each
level are reachable from further out. A nested child’s interface is visible
to an outer graph only if every intermediate reusable graph re-exports it —
bind the inner instance’s exported port to your own public port with the same
export_input()/export_output() verbs. Unexported nested ports fail
closed, exactly like unexported internal operator ports.
In Python, the wrapper declares its own Input and Output descriptors and
uses the same export methods on the nested handle:
Assigning an instance to one thread partition
Use the embedded handle directly to give an instance one serial execution owner:
Selecting fusion includes every Operator in its nested graph tree. Multiple
independent sources share that owner and may progress independently. Chain
.add(other_instance) or .add(direct_operator) to place additional work on
the same owner. Use the accepted partition name with
CompileOptions::deployment.bind_thread_placement(...) for CPU placement.
The handle must name a live direct child of the partition’s Graph. The selected
subtree must contain Operators and must have no internal partition declarations,
including empty declarations. Duplicate assignment, foreign or stale handles,
and conflicts with include_all() reject atomically. A failed call preserves
the previous membership and Graph identity; separate fluent calls are separate
transactions.
Without auto_partition_remaining(), named partitions must cover all Operators.
The compiler preserves each selected Operator set exactly and still validates
process, domain, readiness and topology constraints. Partitions containing only
direct Operator handles retain their existing single-root restriction. Interface
ports and existing external-topic attachment rules are preserved.
The device-domain check applies to the whole selected partition, including any
sibling instances and direct Operators added to it. Conflicting Tensor device
bindings or a conflicting named Green Context placement reject with
PARTITION_ANCHOR_DOMAIN_INCOMPATIBLE in both complete-manual and
automatic-remainder mode. Separate partitions can use different devices.
include_all() retains its separate whole-application contract.
graph_members() retains the explicitly selected roots; members() and
operator_paths() show resolved executable membership. Match the selected IDs
to plan.graph().graphs() for canonical instance paths. Retained partition views
remain valid after the authoring Graph and original plan are destroyed.
Locating a rejected or unassigned instance
An internal declaration rejection starts with
GRAPH_PARTITION_INTERNAL_DECLARATION and includes the offending owner path and
partition name. Path segments and names are percent-encoded; long fields end in
.... The first declaration is selected by owner path and then partition name,
independently of authoring order.
To inspect complete names, retain a GraphView and join each partition’s
graph ID to view.graphs(). This helper prints paths as quoted segments, so a
literal / in a name remains unambiguous:
Call this helper with graph.view() outside compose(), after embedding
the child and before attempting the selection, or after catching a failed
selection made outside compose(). Do not observe a Graph during its active
compose(). If selection failed inside compose(), composition is terminally
failed; reproduce the embedding in a separate diagnostic Graph and inspect it
before selecting the instance. An empty owner array denotes the root Graph.
A complete manual cover that omits an embedded descendant reports
PARTITION_EMBEDDED_INSTANCE_UNASSIGNED. Its Diagnostic::source.graph and
source.vertex identify the first uncovered descendant in the canonical
plan.graph() inventory. Assign its instance to a partition, or call
auto_partition_remaining() to cover remaining Operators automatically. The
older PARTITION_MANUAL_EMBEDDED_GRAPH_UNSUPPORTED token is historical;
embedded instance selection is supported.
Parent-applied policy across the boundary
A parent can apply topology-level policy to an embedded child as a whole
using the child handle. region(name).add(handle) adds every
flattened operator of the embedded instance to a parent-authored region:
Python applies the same whole-child region policy:
CUDA Graph regions are an exception. A region created with
add(GraphHandle) cannot use RegionConstraint::cuda_graph(). CUDA Graph
regions currently must be declared in the root graph and may contain only
directly listed operators owned by that root graph. Regions declared inside
an embedded graph are also rejected. Capturing operators across reusable
graph boundaries is not supported in this release.
Lifecycle ordering across the boundary uses the same
lifecycle().before(a, b, holoscan::LifecycleStage::...) vocabulary as
ordinary operators, over OperatorHandles that the reusable type chooses
to expose (for example, via public const getters). Exposing an internal
operator handle for lifecycle policy does not make its data ports
wireable — data reachability and policy reachability are separate doors. See
lifecycle callbacks
for the lifecycle stages themselves.
Recap
- A reusable component derives from
Graph, declares publicInput<T>/Output<T>interface members, and binds them to internal ports withexport_input()/export_output()insidecompose(); the C++ public surface is the interface boundary. embed<G>("name", args...)constructs, composes, and flattens one independent child per call and returns a typedGraphHandle<G>; it is not a cheap reference to a shared definition.- Publication is transactional — a failed embed throws and leaves the parent unchanged.
GraphHandleis weak and nullable: checkgraph_valid()/operator bool()on any retained handle, expectGRAPH_HANDLE_INVALIDon stale dereference, and prefer a durableGraphViewfor anything you keep.- Instances have structured identities:
handle.path()is a parent-relativeGraphInstancePath; root-qualified paths come from theGraphView. - Nesting works level by level (re-export to widen visibility), and parents apply compatible regions and lifecycle ordering across the boundary through the handle. CUDA Graph regions are currently restricted to directly listed operators in the root graph.
Next: connect your graph to the world outside the process with external topics.