Graph Composition in Depth
You composed your first graph by constructing a holoscan::Graph, adding
operators with op<Op>(), and wiring typed ports with add_flow(). This page
goes deeper: packaging a subgraph as a reusable component with its own typed
interface, tuning individual ports and edges with configurators, grouping
operators into policy regions, declaring graph-scope clocks and lifecycle
ordering, and reading everything back through an immutable GraphView
snapshot.
Throughout, remember the three-phase model: compose → compile → run. A
Graph is cold authoring state — it describes a pipeline but never runs one.
Everything on this page happens before compile(), and the failure style here
is distinct: authoring throws, while compile and run report failures as
values.
Complete C++ example: examples/graph_composition/cpp.
The Python tabs below show the corresponding composition API.
Partitioning example: examples/automatic_partitioning/cpp
- How to derive a reusable graph from
holoscan::Graphand author its body incompose() - How to expose a subgraph’s interface with
export_input()/export_output()— and why to prefer them over raw same-directionadd_flow() - How to tune ports and edges with
configure()/try_configure()configurators - How to group operators into named regions with
region(name).add(...) - How automatic, hybrid, and
include_all()partitioning define serial execution ownership - How to inspect the accepted partition cover with
ExecutionPlan::partitions() - How to declare graph-scope clocks and lifecycle ordering
- How
view()freezes an immutableGraphViewyou can inspect safely
Derived reusable graphs
A Graph subclass is a reusable building block. Declare input/output members
as its public interface, and author the private body by overriding compose():
C++
Python
A containing graph embeds the component with embed<G>("instance-name") and
wires only its exported interface — the internal operators stay private. The
camera_imu_control example nests a FusionGraph inside a
CameraImuGraph exactly this way.
compose() runs lazily, exactly once — the first time the graph is
observed (for example by view() or by an outer graph embedding it). This has
two consequences:
Do not author from the constructor, and do not re-enter composition.
Build the body only inside compose(). Calling an observer such as view()
or default_clock() from within compose() re-enters the composition
gate and throws with the GRAPH_COMPOSE_REENTRANT diagnostic. A terminal
compose() failure is retained and replayed on every later observation —
the graph does not silently half-compose.
Execution partitions are also the default ordinary CUDA-stream parallelism
boundary. Every CUDA-active (process group, partition, device) tuple receives
one runtime-owned stream; host-only partitions receive none. Separate streams
permit independent GPU submission but do not guarantee concurrent execution,
and Tensor readiness/release fences preserve ordering across partition
boundaries. Applications that have measured a benefit from serialized
submission can set
CompileOptions::cuda_execution.compute_stream_policy to
CudaComputeStreamPolicy::kSharedProcessDevice; that policy changes stream
sharing, not callback ownership or Tensor pool sizing.
The Graph itself is non-copyable and non-movable: handles into it are tied
to that one instance. Handles (OperatorHandle<Op>, flow handles, and
friends) are weak — they do not keep the graph alive, and they go invalid
when it is destroyed. If you retain a handle beyond the composition scope,
check graph_valid() before using it.
Exporting an interface: prefer export_input / export_output
export_input(public_port, internal_port) and
export_output(public_port, internal_port) bind a derived graph’s public
interface port to an internal operator port. Both take the public port
first, and both require the two ports to carry the same payload type T.
Under the hood these are thin wrappers over same-direction add_flow()
interface-alias overloads — add_flow(Input<T>, Input<T>) and
add_flow(Output<T>, Output<T>). You may see those raw overloads in older
code, but avoid writing them:
Raw same-direction add_flow() is easy to reverse silently. The
(Input, Input) overload takes the public port first, while the
(Output, Output) overload takes the internal port first — and because both
endpoints share the same type T, a call with the arguments swapped
compiles cleanly and authors the wrong binding. export_input() and
export_output() normalize both directions to one clear
(public, internal) order. Use them.
Queue edges are different: add_flow(Output<S>, Input<T>) between two
operator ports remains the one and only way to author a dataflow edge, and it
stays fully type- and direction-checked. The alias overloads exist solely for
interface plumbing.
Per-port and per-edge configurators
Structural wiring goes through add_flow(); policy on a port or edge goes
through fluent configurators returned by configure():
C++
Python
You can also pass initial edge options directly at wiring time —
add_flow(src, dst, holoscan::ConnectionOptions{.queue_depth = 4U}) — and
refine later through the flow configurator. add_flow() returns a
[[nodiscard]] flow handle; keep it if you intend to configure(flow) later,
or discard it explicitly with (void).
Unlike the rest of the authoring surface, configurators have a non-throwing
twin: try_configure(...) returns
holoscan::expected<Configurator, Error>, which suits generated or
data-driven composition code:
Configurators are short-lived. A configurator is valid only while the
port, flow, and Graph remain alive and composition is still open. Apply it
where you obtain it; never stash one for later.
Regions: coarse policy over groups of operators
A region names a group of operators (or embedded graphs) so that constraints
and profiles can be authored once at graph scope instead of per operator.
region(name) returns a fluent RegionHandle:
RegionHandle offers add(...) for membership (operator handles and
embedded-graph handles alike), constrain(...) for placement or resource
constraints, and profile(...) for a named profile. CUDA Graph execution is
optional and explicitly requested with RegionConstraint::cuda_graph();
regions without it use ordinary execution. If the compiler cannot safely lower
a requested CUDA Graph region, compilation fails instead of silently ignoring
the request. Regions are pure authoring metadata here; the compiler consumes
them when it lowers the graph to an executable plan.
CUDA Graph regions have a deliberately strict contract: capture-eligible
operators in a linear Tensor chain, exact Runtime-owned Tensor storage, one
process and CUDA context, and capture-safe compute() callbacks. See
CUDA Graph Regions
for the complete authoring pattern, supported triggers, Tensor and placement
rules, backpressure behavior, diagrams, and troubleshooting guide.
Execution partitions
An execution partition owns an exact set of executable graph vertices. Callback-bearing partitions give their Operators one serial execution owner; endpoint-only partitions retain topology without manufacturing a callback owner. Regions remain analysis and placement metadata; they do not implicitly define execution ownership.
Operators in distinct execution partitions may run callbacks concurrently on
stable owner threads, and there is no total callback order across partitions.
Synchronize any shared mutable state. include_all() places the complete
executable application in one partition and gives all of its Operators one
serial owner, but it does not make callbacks run on the caller thread.
Inspect plan.partitions() to confirm the accepted ownership and topology.
With no partition declarations, the production
holoscan.partition.source_chain/1 planner produces a complete automatic
cover. The name is a stable algorithm identity, not a promise that only source
Operators are inspected: the planner walks the complete canonical DAG and
starts a linear execution chain at each source or other mandatory boundary.
The retained partitions() view reports the accepted mode, authored and
automatic counts, canonical order, execution lane, origin, and logical
Operator paths. It also supports exact lookup by dense ID, authored name, and
canonical path. Automatic partitions deliberately have no public generated
name; use their ID, path, or an Operator handle instead. Dynamic lookup
returns a typed error for an invalid or missing key.
Operator paths are exact lookup keys over decoded Graph and Operator identity
segments. Each segment is UTF-8 byte-wise percent-encoded with uppercase hex
before / separators are inserted, so decoded names such as a/b cannot
collide with separate a and b segments. Every key uses the same
no-leading-slash OperatorPath grammar as Parameter records and Tensor output
placement. Pass the published spelling back to
lookup_for_path() unchanged—the lookup does not decode or normalize input.
For example, root Operator camera/left% publishes
camera%2Fleft%25, while Operator b/c in embedded Graph a publishes
a/b%2Fc.
The planner considers the whole DAG. A fan-out starts separate successor legs, and a fan-in starts its own join partition rather than collapsing multiple predecessor chains. A diamond therefore exposes both branch legs and the join boundary. A linear successor can join its one predecessor only when the execution domain and edge-collapse rules permit it. Two edges between the same ordered Operators are parallel edges, not a structural branch.
Exact manual and hybrid covers
Manual declarations remain an exact, disjoint cover:
An incomplete manual cover is rejected unless the application explicitly opts into a bounded hybrid cover. Exact named anchors remain unchanged and the compiler partitions only the uncovered remainder:
The frozen authoring view exposes this directive directly through
graph.view().automatic_partition_remaining(), alongside the exact manual
declarations returned by graph.view().partitions().
A named partition can also select an embedded Graph without enumerating its internal Operators:
The selection includes all nested descendants and may contain independent
sources. The selected subtree must have no internal partition declarations,
even empty ones. Chaining adds sibling instances or direct Operators to the
same exact set; direct-Operator-only partitions keep their single-root rule.
The retained graph_members() view reports selected instance roots, while
members() and operator_paths() report resolved work. See
atomic instance placement
for ownership, conflict and lifetime rules.
Use partition(name).include_all() on the compile root when one explicit
serial owner should cover the complete composed application, including
embedded Graph descendants:
include_all() is manual intent and bypasses automatic partitioning. It is
mutually exclusive with another root partition declaration or an automatic
remainder request. Use the exact authored name with
CompileOptions::deployment.bind_thread_placement(...) or a Green Context
binding when deployment placement is required; names select an authored
partition and never select a planner.
Serialization domains
Operators in one serialization domain never run their callbacks concurrently.
Python Operators share the python.gil domain, because each callback holds the
Python GIL. Automatic partitioning keeps a domain’s Operators in one partition
when it can. An authored cover, a mandatory boundary, or an acyclic partition
order can still split a domain across partitions, and compilation then reports
OPERATOR_SERIALIZATION_DOMAIN_SPLIT_ADVISORY for that domain, because the
extra partition threads mostly wait on each other.
A split can be intentional, for example when callbacks release the GIL during long native calls, or when one partition needs its own thread placement. Select the policy in the compile options:
kWarn (WARN), the default, admits each split domain with the advisory.
kAllow admits it without a diagnostic, and kReject fails compilation with
OPERATOR_SERIALIZATION_DOMAIN_SPLIT_REJECTED. The policy never changes the
partition cover; inspect plan.partitions() to see where a domain is split.
Graph-scope clocks
Clocks are declared on the graph and read from operators at run time (see Time and clocks). Three verbs cover the authoring side:
add_clock<C>(name, args...) captures a factory for an owned clock —
bind_shared_clock(name, std::shared_ptr<Clock>) is the only form that
retains a live instance. set_default_clock(key) nominates the graph’s
default, and default_clock() reads the nomination back (it is an observer,
so calling it triggers — and must not re-enter — composition). When a
pipeline spans clock domains, add_clock_conversion(from, to, conversion)
declares a directed affine mapping between two declared clocks.
The camera_imu_control example opens its compose() with exactly this
pattern:
Lifecycle ordering
Most pipelines never need explicit lifecycle ordering — the runtime already runs every stage behind global barriers, with a deterministic default order within each stage. When one operator’s start-up genuinely must precede another’s within a stage (a device owner before its consumers, say), author it on the graph’s lifecycle builder:
before(first, second, stage) orders the two operators within one lifecycle
stage. The same builder is where the three deny-by-default restart permissions
are granted — allow_self_restart(op), allow_external_restart(op), and
allow_restart_request(requester, op); see
Lifecycle callbacks
for the operator-side view and
Restart control for
restart semantics. Both endpoints must belong to the declaring graph’s scope
or a descendant scope — sibling graphs cannot order one another’s operators —
and duplicates and ordering cycles are rejected as you author.
GraphView: immutable snapshots
view() freezes the composed graph into a GraphView — an immutable,
deep-owning snapshot that stays valid even after you mutate or destroy the
source Graph:
GraphView exposes read-only spans over every authored fact: graphs(),
vertices(), ports(), edges(), interface_bindings(),
external_topics(), readiness_sources(), regions(), partitions(), clocks(),
clock_conversions(), lifecycle_dependencies(), and
lifecycle_restart_permissions(), plus identity() and generation().
Copying a GraphView is cheap — copies share the frozen storage — but the
spans borrow from it, so keep at least one sharing view alive while you hold
a span.
identity() returns the process-local graph instance, the frozen authoring
version, and a deterministic content hash — enough to tell two snapshots of
the same graph apart. One caution for later: these authoring IDs are not
the compiler’s canonical plan IDs. When you inspect a compiled plan, match
records against the plan’s own graph view, not against handles you kept from
authoring (see
Compile and check).
The authoring error model: throws here, values downstream
Composition is the one place in the pipeline lifecycle where errors surface
as exceptions. op(), add_flow(), region(), add_clock(),
lifecycle().before(), and view() all throw holoscan::RuntimeError —
carrying a structured Error with an ErrorCode and a stable diagnostic
token such as GRAPH_COMPOSE_REENTRANT — when a request is structurally
invalid: a duplicate name, input fan-in, a directed cycle, a stale handle.
This is deliberate, and it is the opposite of the runtime surfaces. An
authoring rejection is a programming bug in code you control, detected cold,
before any resource is committed — the natural response is to fail loudly at
the exact call site. Once past authoring, the model inverts:
holoscan::compile() returns a rejected plan as a value, compute() reports
failures through holoscan::expected<T, Error>, and the running pipeline
fails closed. There is accordingly no try_op or try_add_flow; the only
non-throwing authoring family is try_configure(...), because configuration
is the one surface data-driven composition code routinely probes. If you
generate graphs programmatically from external input, wrap the composition
block in a single try/catch and treat any RuntimeError as a rejected
description. See
Errors as values
for the full boundary rules.
Recap
- Derive from
holoscan::Graph, author incompose()(lazy, exactly once), and never observe the graph from inside its own composition. - Expose a subgraph’s interface with
export_input()/export_output()— their(public, internal)order is consistent, unlike the raw same-directionadd_flow()overloads. - Tune ports and edges with
configure()/try_configure()configurators; apply them immediately, never stash them. - Group operators with
region(name).add(...)for graph-scope policy. - Use
partition(name).add(...)for exact direct-Operator or atomic embedded-instance ownership. - Declare clocks with
add_clock/bind_shared_clock/set_default_clock; order lifecycle work withlifecycle().before(...). view()freezes an immutableGraphView— spans over vertices, edges, regions, partitions, and every other authored fact, plus a stableidentity().- C++ authoring throws
RuntimeError, while Python authoring raisesHoloscanError; compile and run report failures as values.
Next: package and version these components properly in Reusable graphs, or connect your graph to the outside world in External topics.