Core Concepts
Every Holoscan application is built from the same five primitives and moves through the same three phases. You describe what your application is — operators, typed ports, wiring, clocks, and temporal rules — and the runtime decides how to execute it: channel placement, memory pools, scheduling, and GPU lowering are all results of compilation, not code you write. This page gives you the complete mental model in one pass; the rest of the guide links back here instead of re-explaining it.
Complete C++ example: examples/first_pipeline/cpp
- The three-phase lifecycle — compose → compile → run — and the distinct failure style that belongs to each phase
- The five primitives:
Operator,Graph,Contract,ExecutionPlan, andRunSession - Why an operator is three declarations —
setup()= structure,contract()= time,compute()= work — and whycontract()earns its name - The five cross-cutting motifs you will meet on every other page: errors as values, bounded by construction, weak handles vs durable views, the mandatory default clock, and never syncing CUDA on the hot path
If you keep one sentence from this page, keep this: authoring throws, compilation returns an inspectable plan, and running fails closed.
The three-phase model: compose → compile → run
A Holoscan program has three strictly separated phases:
-
Compose. You author a
holoscan::Graph: register operator types withgraph.op<T>("name"), wire typed ports withgraph.add_flow(...), and declare clocks. Nothing executes; the graph is a pure description. -
Compile.
holoscan::compile(graph)validates topology, temporal contracts, and resource bounds, then returns anExecutionPlan— an immutable snapshot of everything the compiler accepted or rejected. -
Run.
holoscan::run(plan)blocks until the graph finishes;holoscan::run_async(plan)returns aRunSessionthat owns the running graph. External code may request a stop; an operator may request a stop or immediate whole-run success from insidecompute(). Inside the session, the runtime’s dispatcher drives operator activations according to the plan.On Linux, plain blocking
run(plan)manages SIGINT/SIGTERM and admits a transient domain only when both process-task census reads observe exactly one live task. Multithreaded embedding hosts should establish an early managed domain or select caller-managed policy; see running and stopping.
Each phase has its own failure style, and they never bleed into each other:
The separation is the point: by the time data flows, every structural question has already been answered, and the only failures left are the ones you have explicitly declared policies for.
An accepted plan is not a started run. Admission proves the graph is
well-formed and every resource is boundable. Startup separately verifies that
each required runtime binding is available and fails closed — with
diagnostics, before invoking user code — when one is missing. Check
session.termination() and session.diagnostics(), not just plan.ok().
The five primitives
Operator — three declarations
An operator is not “a class with a run method.” It is three declarations with a strict division of labor:
Why is it called a contract? Because it is one — between your operator and
the runtime. contract() declares what the runtime must satisfy before it
invokes compute(): the trigger (what causes an activation), input
selection (which samples participate), and the missing-data policy (what
happens when inputs are absent or stale). If the runtime cannot honor those
terms for an activation, it does not call compute() with degraded inputs —
your declared policy runs instead. That is what lets compute() be
intentionally oblivious to temporal concerns: by the time it runs, its inputs
are already the right samples.
setup() must be deterministic, idempotent, and side-effect-free. Both
compilation and run start reconstruct operators and call it again, and they
require the declarations to agree exactly. Put device discovery and resource
acquisition in lifecycle callbacks,
never in setup().
Deep dive: Operator anatomy and your first operator.
Graph — the sole public root
holoscan::Graph is the only public root. There is no Application class, no
Pipeline, and no runner object to subclass — a small script, a test, and a
production host program all author the same Graph. For reusable bodies,
subclass Graph, override compose(), and embed the body into a larger graph.
One subtlety worth internalizing early: graph.op<T>("name", args...) does not
store your live object. It copies the constructor arguments into an owning,
replayable factory, and each run constructs fresh operator instances from it.
That is why captures must own their state — and why a plan can outlive the
graph that produced it. Details in
composing a graph
and graph composition.
Contract — the temporal policy object
The value returned by contract() combines three parts, each from a small
closed vocabulary:
- a trigger —
OnAnyInput{}(the default for operators with inputs),OnClock{.period = ...}, or alignment/window/predicate triggers; - a per-input selector —
Exact{},LatestWithin{.max_age},DrainAll{}, orBracketedWindow{...}; - a per-input missing-data policy —
Skip{},SafeDefault{}, orReuseLast{.max_age}.
Contracts are owned by the operator type and compiled into the plan;
application code cannot rewrite another operator’s temporal behavior from the
outside. An operator with inputs that returns an empty contract gets the
explicit, plan-visible OnAnyInput{} default. The full vocabulary lives in
temporal contracts.
ExecutionPlan — immutable and inspectable
compile(graph) is a pure function: it runs no part of your graph and returns
an ExecutionPlan even when admission fails. The plan is an immutable
snapshot — it records what you requested, what the compiler accepted (channel
placement, pool sizes, scheduling, lowering decisions), and what it rejected
and why. It survives destruction of the Graph that produced it.
Three calls cover most needs: plan.ok() answers “any errors?”,
plan.diagnostics() returns the stable, complete list (warnings included), and
plan.require_no_errors() is the cold-path guard that throws on errors while
letting warnings pass. See
compile and check
for the workflow and plan tooling
for working with plan artifacts.
RunSession — strict RAII
run_async(plan) returns a RunSession that owns every runtime activity the
run starts. It is strict RAII: destroying a live session requests cooperative
stop and joins all session-owned work — it never detaches a thread, never
force-kills your code. request_stop() is nonblocking and idempotent;
wait() joins; termination() reports the coarse outcome (kRunning,
kCompleted, kRequestedCompleted, kStopped, kFailed). The blocking
run(plan) is a convenience wrapper over the exact same path.
On Linux, that convenience wrapper also selects managed SIGINT/SIGTERM by default. Its early-domain, single-task admission, and caller-managed opt-out rules are documented in running and stopping.
Because destruction joins, a compute() that never returns can block session
destruction indefinitely. Long-running work should poll the cooperative
cancellation view — ctx.cancellation().stop_requested() — at natural
boundaries and return. See
running and stopping.
The whole model in one sketch
One operator shown in full, plus the three-phase main(). The source and sink
follow the same pattern as the Doubler; for a complete multi-rate program,
see the camera + IMU control example.
The sketch below is illustrative. For compiled source using the same
compose → compile → run model, see the shipping first_pipeline example and
Slow/fast fusion.
Cross-cutting motifs
Five motifs recur through the entire SDK. They are stated once, here; every other page links back to this section.
Errors as values
Hot-path APIs — receive(), emit(), allocation, tensor access — return
holoscan::expected<T, holoscan::Error>. You branch on Error::code
(an ErrorCode) or propagate with HOLOSCAN_TRY(expr) /
HOLOSCAN_TRY(lhs, expr), which returns the original error object from the
enclosing function. Exceptions appear only at genuine cold-path boundaries:
authoring misuse and guards like plan.require_no_errors(). Full treatment in
errors as values.
Bounded by construction
Every queue depth, emit burst, and payload pool is declared while composing and
reserved while compiling; the compiler rejects over-subscription at admission
instead of letting it surface as a runtime stall. Nothing on the hot path
allocates or grows. When a declared bound is reached at run time, the failure
is ErrorCode::kResourceExhausted — the signature exhaustion error that means
“your budget was consumed,” not “the system is broken.” Interpreting it is
covered in understanding failures;
the sizing consequences in
performance considerations.
Weak handles vs durable views
Everything the API hands you has one of two lifetime characters, and neither is a raw pointer into someone else’s lifetime:
- Weak handles re-resolve on every use and fail closed when their owner
is gone. The handle from
graph.op<T>(...)revalidates against the graph on each access and reports a typed error once the graph or declaration is retired; run-scoped handles likewise go stale — never dangling — after teardown. - Durable views are immutable snapshots that remain valid on their own.
The
ExecutionPlanand its records are the canonical example: keep them, inspect them, archive them, independent of the graph or any run.
A third, narrower category exists inside compute(): activation-scoped views
(such as batch selections) are valid only for the current activation and must
never be stored past it.
The mandatory default clock
Ordinary clock- and input-triggered pipelines declare and select a default clock:
graph.set_default_clock(graph.add_clock<holoscan::RealtimeClock>("clock")).
Python can use hs.Graph("pipeline", default_clock=hs.RealtimeClock).
A missing declaration rejects compilation with TEMPORAL_CLOCK_UNDECLARED;
a missing required runtime binding instead fails startup. Readiness-only sources
can be clock-free when their selections require no time. Explicit clock domains
also let tests substitute a controlled clock. See
Time and clocks.
Never sync CUDA on the hot path
compute() receives its invocation stream via ctx.cuda_stream(). A received
tensor is already ordered on that stream, and committing a tensor you produce
records its readiness — stream ordering is the runtime’s job, end to end.
There is therefore never a reason to call cudaStreamSynchronize inside
compute(); doing so stalls the pipeline for work that was already correctly
ordered. Host access to device data is always an explicit step, never a hidden
copy. Start with tensors 101.
Recap
- A Holoscan program is three phases: compose (authoring throws), compile (returns a plan — rejected plans carry complete diagnostics), and run (fails closed; hot-path errors are values).
- An operator is three declarations:
setup()= structure,contract()= time,compute()= work — andcompute()never reasons about time. Graphis the sole public root;ExecutionPlanis an immutable, inspectable snapshot;RunSessionis strict RAII over everything a run starts.- The
contract()is a real contract: trigger, input selection, and missing-data policy that the runtime must satisfy before callingcompute(). - Five motifs recur everywhere: errors as values (
HOLOSCAN_TRY), bounded by construction (kResourceExhausted), weak handles vs durable views, the mandatory default clock, and never syncing CUDA on the hot path.
Next: Errors as values.