Tutorial: Your First Pipeline
This is the page where everything comes together. You have written a single operator, composed a graph, compiled it, and learned how a run starts and stops — each in isolation. Now you will assemble those pieces into one complete program: a pipeline that streams integers from a clock-driven source, transforms them in flight, prints them from a sink, and shuts down cleanly on request.
The walkthrough builds the pipeline step by step; the full compilable
program appears at the end. Every step follows the three-phase model you
already know — compose → compile → run — with its three failure styles:
authoring mistakes throw, compile() returns a rejected plan you can
inspect, and anything the runtime cannot satisfy at startup fails closed.
Complete C++ example: examples/first_pipeline/cpp
- Choosing payload types the runtime will carry: fixed-width scalars like
std::int32_t— neverint - Bounding every port with
queue_depthandmax_emits_per_compute - Writing a minimal
contract()— trigger, selector, missing-input policy - Declaring a clock with
add_clock+set_default_clock compile()and theok()check, thenrun_async(),request_stop()/wait()/termination()
What you are about to build
Three operators, two connections, one clock:
Small as it is, this pipeline exercises the same machinery as a production application: typed ports, bounded queues, contracts, a clock, a compiled plan, and a cooperatively stopped run session.
1. Choose payload types the runtime can carry
Ports are typed — holoscan::Output<T> on the producer,
holoscan::Input<T> on the consumer, and the two Ts must match. For
in-process scalar pipelines, T must come from the closed catalog of
fixed-width scalars: bool, the std::uint8_t … std::int64_t family,
float, and double. This tutorial uses std::int32_t throughout.
Write std::int32_t, never int. Platform-dependent aliases like int
and size_t are not in the payload catalog — it is defined in terms of
fixed-width types so acceptance semantics are exact and portable. Structs,
enums, generated schemas, and Tensor payloads have their own rules; the
complete catalog lives in
Payload type rules.
2. Declare ports with bounded queue geometry
Every port declares its geometry in setup(), and every bound is finite —
queues are bounded by construction, so the compiler can reserve capacity up
front and reject over-subscription at admission.
Two knobs matter here, and both default to 1:
queue_depth(inputs) — how many retained samples the runtime keeps; selectors that look back in time need enough depth to find what they select.max_emits_per_compute(outputs) — the largest burst onecompute()activation may emit; the compiler uses it to size downstream capacity.
3. Give each operator a minimal contract
An operator is three declarations: setup() is structure, contract() is
time, compute() is work. The contract() tells the runtime what must be
true before it invokes compute(); it has three parts — a trigger
(what causes an activation), an input selector per input (which retained
samples participate), and an on_missing policy per input (what happens
when nothing satisfies the selector).
The source has no inputs, so its contract is a single clock trigger:
result.trigger(holoscan::OnClock{.period = 100ms}). DoubleIt and
PrintSink each fire when a sample arrives, select exactly the triggering
sample, and skip the activation if it is absent:
This trigger + selector + policy trio is the smallest useful contract; the full selector vocabulary is covered in Temporal contracts.
4. Do the work in compute()
compute() receives, transforms, and emits. input.receive() returns
expected<Sample<std::int32_t>, Error> — your value in sample->data plus
capture metadata — and output.emit(value) also returns an expected. You
branch on both results rather than catching exceptions, propagating failures
with holoscan::make_unexpected, exactly as introduced in
Errors as values.
If you only want the value, input.receive_data() returns
expected<T, Error> directly; going the other way, output.emit(Sample<T>)
forwards a value together with its capture metadata.
receive() and emit() results are [[nodiscard]] for a reason: an
ignored error is a dropped sample you will never hear about. Always branch
on the result, even in a sink that “cannot fail.”
5. Compose the graph and wire the flows
Instantiate operators with graph.op<T>(name) and connect ports with
add_flow; per-edge ConnectionOptions carries the queue depth. You never
write transport code — the runtime materializes each connection for you:
Authoring errors — a name collision, a type mismatch across an edge, a dangling port — throw here, as described in Composing a graph.
6. Add a clock — then make it the default
A clock is a two-step opt-in: add_clock declares a named graph-owned
clock, and set_default_clock selects the default that drives OnClock
triggers and timestamps. One line covers both:
Forgetting the clock — or adding one without setting it as the default —
is not a compile-time error: the run fails closed at startup with a
RUNTIME_CLOCK_BINDING_UNAVAILABLE diagnostic rather than running on an
implicit wall clock you never chose. If your session reports kFailed
immediately, check the clock lines first; clock kinds are covered in
Time and clocks.
7. Compile the plan and check ok()
compile() never throws for design problems — it returns an
ExecutionPlan that is either accepted or rejected, and a rejected plan
tells you why:
Check ok() unconditionally: passing a rejected plan to run_async() is a
programming error, not a recoverable state. Reading diagnostics is covered
in Compile and check.
8. Run, stop, wait, verify
run_async() starts the pipeline on runtime-owned threads and returns a
RunSession — an RAII handle that owns everything the run started:
The shutdown handshake has three parts: request_stop() asks for a
cooperative stop and returns immediately; wait() joins every run-owned
activity; termination() then reports the stable outcome — kStopped for
a cooperative stop, kCompleted when an admitted plan has no work left (for
example, a compiler-accepted empty graph), kRequestedCompleted when an
operator requests immediate whole-run success, and kFailed if startup or
execution failed. Requested completion does not imply EOS, downstream drain,
or delivery of queued samples. This recurring nonempty pipeline never infers a
natural completion point, so it requests a stop explicitly. The fixed sleep
here is tutorial simplicity; real applications block on their own completion
signal, as shown in Running and stopping.
The complete program
Here is the whole pipeline in one file, following the same authoring pattern
as richer SDK sample applications such as slow_fast_fusion.
This listing is the readable tutorial variant. The shipped first_pipeline
uses the same graph and contracts with a shorter period and a
condition-variable completion signal so its smoke test finishes quickly.
Build it against your SDK install (see SDK installation), run it, and watch your first pipeline stream:
Doubled values arriving on schedule, then a clean cooperative stop. That is the whole lifecycle — compose, compile, run — in about a hundred lines.
The opt-in checklist
Several runtime behaviors are deliberate multi-part opt-ins, and half an opt-in is the most common source of first-pipeline confusion — the failure is either silent degradation or a fail-closed startup, not a compile error:
- Temporal selection is three parts: a
trigger(...), an.input(port, Selector)per input, and an.on_missing(port, Strategy)per input. Leave one out and the operator may fire with defaults you did not choose, or never fire at all — silently. - The clock is two parts:
add_clock<...>(name)andset_default_clock(key). Forget either and startup fails closed withRUNTIME_CLOCK_BINDING_UNAVAILABLE. - Queue geometry is declared, not discovered: a selector that looks
back in time needs a
queue_depthdeep enough to hold what it selects — depth 1 degrades a windowed selector to whatever single sample remains.
When something behaves unexpectedly, walk this checklist first — then see Understanding failures.
Recap
- Ports carry fixed-width scalars (
std::int32_t, neverint) — see Payload type rules. - Every queue is bounded by construction:
queue_depthon inputs,max_emits_per_computeon outputs,ConnectionOptions{.queue_depth}on edges. - A minimal contract is trigger + selector +
on_missing; the source needs only a trigger. - A clock is a two-step opt-in:
add_clock+set_default_clock, or startup fails closed. compile()returns a plan — checkok()before running; a rejected plan explains itself through its diagnostics.- Shutdown is a three-call handshake:
request_stop(),wait(), then verifytermination()isRunTermination::kStopped.
Next: Tensors 101 — your pipeline carries scalars; time to move real data.