Writing a Minimal Operator
🚧 Documentation is under development
The Holoscan SDK guide is currently under active development. Some sections may be incomplete or change without notice.
Questions? Contact RidgeRun or email to support@ridgerun.com.
-
Overview
-
Build, Run, and Validate
-
Core Concepts
-
Operators and Graphs
-
Tensor Data and Memory
-
Interprocess Communication
-
-
Application and Operator Development
- Building an HSDK Application
- Writing a Minimal Operator
- Typed Parameters and Configuration
- Reading, Writing, and Forwarding Data
- Writing Tensor Operators
- Complete Operator Patterns
- Authoring Runtime Behavior
- Runtime Parameter Updates
- Lifecycle Callbacks and Restart Control
- Performance and Debugging
- SDK Source Map
-
Operator Reference
-
Core Operators
-
Holoscan Camera
-
Holoscan RidgeRun
- Overview
- Build and Installation
- ArgusCameraOp
- V4L2SourceOp
- V4L2UserPtrSourceOp
- BgraToPlanarTensorOp
- RawBayerToBgraOp
- RawBayerToYoloTensorOp
- YuyvToBgraOp
- TensorRtInferenceOp
- DrmDisplayOp
- DrmBgraDisplayOp
- X11DisplayOp
- CudaCompositorOp
- DepthColorizerOp
- FakesinkOp
- FrameSkipperOp
- InterprocessSrcOp
- TestPatternOp
-
-
Migration from Holoscan 4.x
-
Examples
-
Core
-
Holoscan Camera
-
Holoscan RidgeRun
-
-
Benchmark and Use Cases
-
Holoscan vs GStreamer
- Overview
-
Capture and Display
-
Capture and Inference
- Compare the workloads
- Implemented Applications
- Framework Selection
- Interprocess
-
Writing a Minimal Operator
Build a small scalar pipeline to see the complete path from a produced value to a completed run. A source emits the numbers one through five, a transform doubles them, and a sink prints the results. The same source, transform, and sink shape is useful when the payload later becomes a sensor sample, Tensor, or inference result.
Start with the data flow
An operator has three main jobs. setup() declares its typed input and output ports, contract() tells the runtime when an activation is ready, and compute() processes one activation.
The graph connects compatible ports with Graph::add_flow(). The application then passes the graph to holoscan::compile() and starts the accepted plan with holoscan::run_async().
| Operator | Receives or produces | Job in this example |
|---|---|---|
| CounterSource | Produces uint32_t values |
Emits 1, 2, 3, 4, and 5 on a clock. |
| Doubler | Receives uint32_t, produces uint64_t |
Reads one value, multiplies it by two, and publishes the result. |
| PrintSink | Receives uint64_t values |
Prints five results and requests completion. |
The source has no input, so its contract uses OnClock. The two input operators use OnEach so the runtime calls them when a new value is ready on their input.
The filter reads with receive_data() and publishes with Output<T>::emit(). Both operations return checked results, so the code can return an error when a read or publication fails. The sink uses context.request_completion() after the fifth value.
Write the three operators
Save the following file as minimal_operator.cpp. The complete example keeps the application structure visible in one place.
#include <chrono>
#include <cstdint>
#include <iostream>
#include <utility>
#include <holoscan/core/compile.hpp>
#include <holoscan/core/errors.hpp>
#include <holoscan/core/execution_context.hpp>
#include <holoscan/core/expected.hpp>
#include <holoscan/core/graph.hpp>
#include <holoscan/core/operator.hpp>
#include <holoscan/core/operator_spec.hpp>
#include <holoscan/core/port.hpp>
#include <holoscan/core/run.hpp>
#include <holoscan/core/run_session.hpp>
#include <holoscan/core/temporal_contract.hpp>
#include <holoscan/time/realtime_clock.hpp>
class CounterSource final : public holoscan::Operator<> {
public:
void setup(holoscan::OperatorSpec& spec) override {
spec.output(output, "output");
}
[[nodiscard]] holoscan::Contract contract() const override {
holoscan::Contract result;
result.trigger(holoscan::OnClock{.period = std::chrono::milliseconds{100}});
return result;
}
[[nodiscard]] holoscan::expected<void, holoscan::Error> compute(
holoscan::ExecutionContext&) override {
if (next_ == 5U) {
return {};
}
return output.emit(++next_);
}
holoscan::Output<std::uint32_t> output;
private:
std::uint32_t next_{};
};
class Doubler final : public holoscan::Operator<> {
public:
void setup(holoscan::OperatorSpec& spec) override {
spec.input(input, "input").queue_depth(4U);
spec.output(output, "output");
}
[[nodiscard]] holoscan::Contract contract() const override {
holoscan::Contract result;
result.trigger(holoscan::OnEach{input});
return result;
}
[[nodiscard]] holoscan::expected<void, holoscan::Error> compute(
holoscan::ExecutionContext&) override {
auto value = input.receive_data();
if (!value) {
return holoscan::make_unexpected(std::move(value).error());
}
return output.emit(static_cast<std::uint64_t>(*value) * 2U);
}
holoscan::Input<std::uint32_t> input;
holoscan::Output<std::uint64_t> output;
};
class PrintSink final : public holoscan::Operator<> {
public:
void setup(holoscan::OperatorSpec& spec) override {
spec.input(input, "input").queue_depth(4U);
}
[[nodiscard]] holoscan::Contract contract() const override {
holoscan::Contract result;
result.trigger(holoscan::OnEach{input});
return result;
}
[[nodiscard]] holoscan::expected<void, holoscan::Error> compute(
holoscan::ExecutionContext& context) override {
auto value = input.receive_data();
if (!value) {
return holoscan::make_unexpected(std::move(value).error());
}
std::cout << "doubled: " << *value << '\n';
if (++received_ == 5U) {
context.request_completion();
}
return {};
}
holoscan::Input<std::uint64_t> input;
private:
std::uint32_t received_{};
};
int main() {
holoscan::Graph graph{"minimal-operator"};
const auto source = graph.op<CounterSource>("source");
const auto doubler = graph.op<Doubler>("doubler");
const auto sink = graph.op<PrintSink>("sink");
graph.add_flow(source->output, doubler->input);
graph.add_flow(doubler->output, sink->input);
graph.set_default_clock(graph.add_clock<holoscan::RealtimeClock>("clock"));
const auto plan = holoscan::compile(graph);
if (!plan.ok()) {
std::cerr << plan.json() << '\n';
return 1;
}
auto session = holoscan::run_async(plan);
session.wait();
if (session.termination() != holoscan::RunTermination::kRequestedCompleted) {
for (const auto& diagnostic : session.diagnostics()) {
std::cerr << diagnostic.token << '\n';
}
return 2;
}
std::cout << "minimal_operator complete\n";
return 0;
}
The port declarations use OperatorSpec::input() and OperatorSpec::output(). The queue_depth() calls give the two input ports a small buffer for this example.
The application registers the source clock with Graph::add_clock() and selects it with set_default_clock(). The source and filter use different scalar types, but each graph edge still connects matching endpoint types. The conversion happens inside Doubler::compute().
Build and run the example
Save this second file as CMakeLists.txt beside the C++ source:
cmake_minimum_required(VERSION 4.0) project(minimal_operator LANGUAGES CXX) find_package(holoscan REQUIRED CONFIG) add_executable(minimal_operator minimal_operator.cpp) target_compile_features(minimal_operator PRIVATE cxx_std_20) target_link_libraries(minimal_operator PRIVATE holoscan::core)
From the project directory inside the SDK development environment, configure, build, and run it. Replace the prefix if your SDK is installed elsewhere.
cmake -S . -B build \ -DCMAKE_PREFIX_PATH=/workspace/holoscan-sdk/install-aarch64 \ -DCMAKE_BUILD_TYPE=RelWithDebInfo cmake --build build --parallel ./build/minimal_operator
The expected output is:
doubled: 2 doubled: 4 doubled: 6 doubled: 8 doubled: 10 minimal_operator complete
The time between lines depends on scheduling. The example is a learning path, not a throughput measurement.
Check the contracts after the first run
Once the program runs, connect each part of the code to the behavior you observed:
| Code area | Role in the example |
|---|---|
setup() |
Declares the typed ports that form the graph interface. |
contract() |
Describes when the runtime may call compute(). The source follows its clock; each input operator follows a new input value.
|
compute() |
Reads the selected value, performs the operation, and publishes or prints the result. |
graph.add_flow() |
Connects the source to the transform and the transform to the sink. |
holoscan::compile() and holoscan::run_async() |
Turn the graph into an executable plan and start the run session. |
The filter's read and publish operations return expected<T, Error>. The example checks the read and returns make_unexpected() if it fails. Returning the publication result lets a downstream delivery problem reach the run session.
The sink requests completion after the fifth result. The application waits with RunSession::wait() and checks the terminal state. If compilation rejects the graph, inspect plan.json(). If startup, processing, or cleanup fails, inspect RunSession::diagnostics(). Running and Stopping covers external stop control and cooperative cancellation.
Adapt the pattern
Keep the application shape and change one decision at a time:
- Replace
CounterSourcewith a camera, file, or network source while keeping its output contract explicit. - Change
Doublerto a domain operation. For sensor data, read the sample or payload accessor that preserves the metadata your next operator needs. Reading and Writing Values and Samples explains that choice. - Use Typed Parameters and Configuration when the operation needs a threshold, scale, or other user-selected value.
- Use Writing Tensor Operators when the output is a Tensor and the operator must acquire, fill, and publish managed storage.
- Use Operator Lifecycle when the operator owns resources that need preparation or cleanup around the run.
- For multiple inputs, return to Temporal Contracts and Input Selection before adding synchronization logic to
compute().