Composing a Graph
Once you have operators — your own or reusable ones from a library — you
assemble them into a pipeline by composing a Graph. The Graph is the
single authoring root: you name it, add operators to it, wire their typed
ports together, and attach graph-wide policy such as the clock. There is no
separate Application, Fragment, or Pipeline class to learn.
Composition is the first phase of the three-phase model — compose →
compile → run — and it is deliberately cold: a Graph describes a
pipeline but never executes one. Everything on this page happens before any
scheduler exists. C++ catches payload and direction mismatches at compile
time; both languages throw at the exact authoring call for invalid dynamic
requests.
Complete C++ examples: examples/first_pipeline/cpp
and examples/slow_fast_fusion/cpp.
The Python tabs below demonstrate the equivalent authoring surface with an
installed Operator provider; standalone Python siblings are not yet shipped.
- How to construct a named
Graphand add operators withop<Op>() - How
add_flow()wires typed ports and rejects mismatches at C++ compile time - How to tune an edge with
ConnectionOptions(queue_depth,queue_policy) - Why every graph must declare and select a default clock before it can run
- Why
Graphhas norun()— and where compilation and execution happen instead
Construct a Graph and add operators
A Graph takes a name at construction. Adding an operator registers it under
a unique instance name and returns an operator handle:
C++
Python
Two things are worth internalizing early:
-
The C++ handle is typed.
camera->framerefers to the realOutput<Frame>member you declared in the operator’ssetup()(see Your first operator). There are no string-keyed port lookups, so a misspelled port name is a C++ compile error, not a runtime surprise. -
The Python proxy is typed and reflected.
camera.frameandfusion.cameraresolve declared registry ports. Unknown ports and invalid parameter keywords fail at authoring time and are reported by supported static type checkers. Port names that are Python keywords gain a trailing underscore; indexing such asoperator["non-identifier-name"]remains available for dynamic code. -
Extra C++ arguments use ordinary copy semantics. Anything you pass after the name (
graph.op<BoundedSink>("sink", SinkConfig{...})) is copied into a replayable factory: the runtime constructs fresh operator instances from it later. Values, strings, containers, copyable callables, and smart pointers behave as they normally do in C++. In particular,std::shared_ptr<T>keeps its process-local object alive in copies of the retained factory and in operators reconstructed from it, including across sessions that reuse those factories.std::weak_ptr<T>does not extend the object’s lifetime.Shared captures do not provide interprocess sharing. Independently composing a graph in another process constructs that process’s own captures. Partition placement does not turn a C++ pointer into an interprocess connection or guarantee a private pointee. Applications must synchronize shared mutation.
HSDK rejects obvious top-level borrows such as object pointers,
std::reference_wrapper,std::span, and range views. This is a guardrail, not an ownership proof: C++ cannot inspect pointer elements inside a container, fields inside your type, or a lambda’s captures. Those lifetimes remain your responsibility. Use a value for independent state andstd::shared_ptrwhen shared identity is intentional; no HSDK ownership wrapper is required.
Handles are weak. An OperatorHandle does not keep the Graph alive,
and it goes invalid when the Graph is destroyed. Use handles during
composition; do not stash them for use after the graph is gone.
Wire ports with add_flow
add_flow(source_port, destination_port) declares one edge from an output to
an input:
C++
Python
Because both endpoints are typed, the wiring is compile-checked: the
payload types must match, and the direction is fixed — the first argument
must be an output, the second an input. Connecting an Output<Frame> to an
Input<ImuSample>, or passing the arguments in the wrong order, does not
build.
What the C++ type system cannot see, authoring checks at the call site: fan-in to an already-connected input, duplicate edges, and cycles are all rejected the moment you try to author them, by throwing. You never carry a malformed topology forward into compilation.
add_flow returns a [[nodiscard]] flow handle; if you do not need it
(you usually don’t at this stage), discard it explicitly:
Tune an edge with ConnectionOptions
Every edge is a bounded queue — part of the SDK’s bounded-by-construction
design. The third argument to add_flow is a holoscan::ConnectionOptions
struct that sets the edge-local policy:
kBlocking preserves accepted samples; it does not guarantee publication will
wait until space is available. Pre-submit pressure can return kBackpressured,
and propagating that error from compute() normally fails the run. A same-lane
recipient cannot progress while its producer waits. Cross-lane credit waiting
requires owner-progress wake support and uses a finite retry budget; it can
also end early. A slower nominal source is not a guarantee against stalls.
See Understanding failures
for ownership, partial-publication, and explicit retry constraints.
Leaving queue_depth unset asks compilation to size the edge from admitted
nominal-rate and temporal-contract evidence when that evidence is finite. An
explicit value is different: it is an exact logical and storage-authority pin,
not a lower bound that automatic sizing may silently increase.
The two options you will reach for first:
The slow/fast pattern is a good intuition pump: a high-rate IMU feeding a
fusion stage can use kDropOldest with depth one when fresh data beats
complete data, while a frame pipeline that must not drop wants kBlocking
with a depth sized for its worst-case burst. Increasing a kDropOldest depth
above one intentionally changes the survivor set: the consumer receives the
newest bounded backlog in order rather than only the freshest unread sample.
The slow_fast_fusion example wires exactly this shape:
These options are cold authoring inputs: they request a shape for the queue that compilation will materialize. They never resize a live runtime queue. If an explicit depth is below a semantic hard floor, compilation rejects it; if it is merely below the nominal recommendation, compilation preserves the requested depth and reports the advisory evidence. See Rate-aware queue and pool sizing for the sizing equations and lifecycle allocation boundary.
Select a clock for time- and input-driven pipelines
Ordinary OnClock, OnEach, and OnAnyInput pipelines need a declared,
selected default clock; aligned, windowed, and predicate timing also needs a
resolved time base. A readiness-only source can be clock-free when its
selection does not require time.
The standard incantation is two calls, usually fused into one line:
C++
Python
add_clock<RealtimeClock>("runtime-clock") declares a named, graph-owned
clock and returns a ClockKey; set_default_clock(key) selects it as the
graph default. RealtimeClock is the wall-clock choice you want for
essentially every real application — see
Time and clocks
for the full clock model and the alternatives.
Forgetting the default clock fails compilation for a connected graph.
Connected inputs synthesize an arrival-backed trigger when you do not author
one explicitly. compile() rejects that trigger with
TEMPORAL_CLOCK_UNDECLARED unless its owning graph selects a default clock.
Make add_clock + set_default_clock a reflex in every graph you author —
put it right next to the Graph construction so it is never missed.
Python also combines declaration and selection at construction:
hs.Graph("pipeline", default_clock=hs.RealtimeClock). The keyword defaults to
None, so it does not supply an ambient clock when omitted.
Both calls follow the authoring error style: add_clock throws on a
duplicate or invalid name, and set_default_clock throws if the key does
not name a clock declared on this graph.
Reusable graphs do not inherit an ancestor’s default clock. The child declares
an import with import_clock(), selects it with set_default_clock(), and the
immediate parent resolves that dependency with bind_clock(). See
Reusable graphs: clock imports
for the complete pattern and the CLOCK_IMPORT_UNBOUND failure.
Composition is not execution
Graph has no run() — and no compile(), run_async(), or
scheduler() either. This is a deliberate wall, not a missing feature.
Authoring a graph is cold, in-process bookkeeping; compiling it into an
executable plan and running that plan are separate downstream phases with
their own APIs and their own failure styles:
- Authoring throws.
op,add_flow,add_clock, and friends throw aRuntimeErrorat the exact line that made an invalid request — duplicate name, fan-in, cycle, unknown clock key. If you generate graphs programmatically, wrap composition in atry/catch. - Compilation returns rejected plans as values, which you inspect — see Compile and check.
- The run fails closed — see Running and stopping.
Keeping the phases separate means a fully composed Graph is just data: you
can build it, inspect it, and hand it to the compiler in a test without any
scheduler or device in sight.
Putting it together
A complete composition, ready to hand to the compiler:
C++
Python
This page covers direct, imperative composition — the everyday case. Graph
can also be subclassed to package a subgraph as a reusable component with
its own typed interface, and graphs can declare external topic boundaries
for other processes. Those are covered in
Graph composition
and Reusable graphs.
Recap
- A named
holoscan::Graphis the single authoring root;op<Op>("name", args...)adds an operator and returns a typedOperatorHandle. add_flow(a->output, b->input)wires typed ports; type or direction mismatches fail at C++ compile time, and fan-in, cycles, and duplicates throw at authoring time.ConnectionOptionssets edge-local queue policy:queue_depthfor history depth, andqueue_policyforkBlocking(backpressure), orkDropOldest(keep the newest bounded in-order backlog). UsekDropOldestwith depth one for a freshest-only mailbox.- Every graph must call
add_clock<RealtimeClock>+set_default_clock— omitting it from a connected graph failscompile()withTEMPORAL_CLOCK_UNDECLARED. Graphhas norun(): composition is cold and throws on invalid authoring; compiling and running are separate phases.
Next: Compile and check