Jump to content

Writing a Minimal Operator

From RidgeRun Developer Wiki

🚧 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.

Contents Pin





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.


Related Concepts


Prerequisites
  • Complete Build the SDK on AGX Orin or install the SDK by another supported method. Use the matching SDK prefix in the commands below.
  • Create a project directory in the SDK development environment. The example uses C++20 and CMake.


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 CounterSource with a camera, file, or network source while keeping its output contract explicit.
  • Change Doubler to 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().


Source reference map and official documentation


Related Examples


Cookies help us deliver our services. By using our services, you agree to our use of cookies.