Skip to navigation

Time and Clocks

View as Markdown

Holoscan does not run operators on a fixed wall-clock loop; it activates them when events occur — a sample arrives, a timer fires, a readiness signal trips. Almost every one of those decisions involves time: a periodic trigger fires on a schedule, a temporal contract decides whether an input is fresh enough, and each sample can carry a capture time for downstream freshness checks and tracing. A clock is the single, well-defined time base the runtime reads to make all of those decisions and to interpret the timestamps that travel with your data.

This page covers the read side of time: what your operator sees inside compute() and how to stamp the samples it emits. Authoring clocks on a graph — declaring named clocks, choosing the default, wiring conversions — is a separate, one-time concern covered in Composing a graph.

API stability

Clock selection/admission, temporal policies, and time/sample metadata contracts are Beta. Stable graph and compile/run entry points do not freeze their defaults or resolution behavior. See API stability.

Complete C++ example: examples/time_and_clocks/cpp

You will learn
  • How to get a read-only clock reference inside compute() with ctx.clock()
  • When to call now() (measures, can fail) versus at_ns() (labels, cannot fail)
  • What a TimePoint is and why it carries a clock domain
  • How to stamp emitted samples with EmitOptions::capture_time
  • How to read the publication time the runtime stamps for you
  • What a Tag is and why it is an identity, not a time

Where the clock comes from

The application selects the graph’s time base during composition. For ordinary clock- and input-triggered pipelines, there is no implicit real-time clock:

graph.set_default_clock(graph.add_clock<holoscan::RealtimeClock>("clock"));

The runtime constructs the declared clock when the run starts; operator code only borrows a reference. Forgetting the declaration or default selection for these triggers rejects compilation with TEMPORAL_CLOCK_UNDECLARED. A missing runtime binding is a separate startup failure. Readiness-only sources can run without a default clock if their selections do not require time. See Composing a graph.

Crucially, the clock is swappable. Because triggers, temporal selection, and timestamps all read the same clock abstraction, the same operator code runs unchanged against real time in production or against a ManualClock that a test harness advances step by step — which is how you get deterministic, repeatable tests. See Testing for the manual-clock pattern.

Reading the clock from compute()

Inside compute(), the ExecutionContext hands you a borrowed, read-only clock reference for this activation’s clock:

holoscan::expected<void, holoscan::Error> compute(holoscan::ExecutionContext& ctx) override {
const holoscan::ClockRef clock = ctx.clock(); // borrowed; valid for this callback only
// ... use clock.now() / clock.at_ns(...) ...
return {};
}

ClockRef is deliberately minimal: you can read the current time (now()), label an existing nanosecond value with this clock’s domain (at_ns()), and query its identity and capabilities (id(), capabilities()). That is all.

A callback observes time; it never drives it. ClockRef has no sleep and no advance operations, on purpose. Waiting is the scheduler’s job, expressed through your operator’s temporal contract — sleeping inside compute() would stall your execution lane and everything sharing it. And if an operator could advance the clock, it would perturb the single time base every trigger and timestamp depends on. If you feel the need to wait or step time from compute(), reshape the trigger in contract() instead.

now() — “what time is it right now?”

now() reads the live clock and returns the current instant as a TimePoint. It is fallible: it returns expected<TimePoint, Error>, because a clock that was not admitted for reads, or a reference that has gone stale, returns an error instead of a bogus timestamp. Check the result — or use HOLOSCAN_TRY to make it one line (see Errors as values):

holoscan::expected<void, holoscan::Error> compute(holoscan::ExecutionContext& ctx) override {
const holoscan::ClockRef clock = ctx.clock();
HOLOSCAN_TRY(auto t_now, clock.now());
const std::int64_t ns = t_now.timestamp_ns; // current time in this clock's domain
return {};
}

HOLOSCAN_TRY expands to an early return of the error on failure, so it only compiles inside a function that returns expected<..., Error> — compute() above qualifies. The remaining HOLOSCAN_TRY fragments on this page are shown without that wrapper for brevity, but each assumes one.

Reach for now() when you need the clock’s current value:

  • measuring how long processing took, or how much latency you added,
  • checking a deadline or timeout,
  • recording the time at which this operator handled a sample.

at_ns() — “label this nanosecond value as mine”

at_ns(timestamp_ns) does not read the clock. It takes a nanosecond count you already have — typically a hardware acquisition timestamp from a camera or IMU driver — and stamps it with this clock’s domain, producing a comparable TimePoint. It never fails and never allocates:

const std::int64_t sensor_hw_ns = /* acquisition timestamp from the sensor driver */;
const holoscan::TimePoint capture = clock.at_ns(sensor_hw_ns); // does not read the clock

At a glance

clock.now()clock.at_ns(ts)
Reads the live clock?YesNo
Returnsexpected<TimePoint, Error> (fallible)TimePoint (never fails)
Answers”What time is it now?""Tag this known instant with my domain.”
Typical uselatency, deadlines, processing timecapture time from a hardware timestamp

Rule of thumb: now() measures, at_ns() labels. If you are asking the clock a question, use now(); if you already have the number and only need the domain, use at_ns().

TimePoint basics

A TimePoint is a signed int64 nanosecond coordinate plus a ClockId naming the clock domain it belongs to. The domain matters: two clocks can run at different rates or offsets, so a raw nanosecond count is meaningless until you know which clock produced it. now(), at_ns(), ctx.activation_time(), and a received sample’s metadata.capture_time() all give you TimePoints.

Compare TimePoints only within one clock domain. TimePoint deliberately has no < operator — subtract or compare timestamp_ns fields only after confirming both values share the same clock_id. Comparing times from two unrelated clocks is a bug the compiler cannot catch for you.

Absent time is a zero ClockId, not the epoch. A default-constructed TimePoint (or a sample with no capture time) means “no time recorded.” Check .valid() before using one; timestamp zero in a valid domain is a real coordinate.

Stamping emitted samples with capture time

The most common reason to touch the clock is to preserve a sample’s true acquisition instant when you emit it. EmitOptions::capture_time carries an optional TimePoint alongside the payload, so downstream operators can measure end-to-end freshness against the original capture rather than against whenever your operator happened to run:

holoscan::EmitOptions options;
options.capture_time = clock.at_ns(sensor_hw_ns); // preserve the true acquisition instant
HOLOSCAN_TRY(out.emit(std::move(frame), options));

Two common variants:

  • Source operator with a hardware timestamp — use clock.at_ns(hw_ns) as above.
  • Source operator without one — the camera_imu_control example’s camera source stamps context.activation_time() as the capture time, using the activation instant as the best available approximation.
  • Pass-through or fusion operator — forward the input’s capture time (frame.metadata.capture_time()) so the chain still measures from the original acquisition, not from your stage.

A downstream operator can then compute a sample’s age:

const holoscan::TimePoint captured = frame.metadata.capture_time();
if (captured.valid()) {
HOLOSCAN_TRY(auto t_now, clock.now());
const std::int64_t age_ns = t_now.timestamp_ns - captured.timestamp_ns; // same domain
// ... drop or degrade if age_ns exceeds a budget ...
}

Publication time: what the runtime stamps for you

Capture time is yours to supply; publication time is the runtime’s. Every sample the runtime publishes carries metadata.publish_time(), a TimePoint naming the instant the runtime submitted that sample for publication. You never set it, and a value you write into publish_timestamp_ns before emitting does not survive the call.

The instant is precise: the runtime reads its clock once per publication, right after it assigns the sample’s sequence and before any recipient or transport accepts it. Two consequences follow, and both matter when you use the number:

  • Every recipient of one publication sees the same coordinate. When an output fans out, the copies do not carry independent clock reads, so two consumers’ delivery latency is measured against one shared event.
  • It is a send-side instant, not a delivery one. It precedes transport queueing, serialization, and delivery, so publish_time() never tells you when a consumer observed the sample — only when the runtime handed it off.

The coordinate lives in the graph’s default clock domain. Since that is the same clock ctx.clock() hands your compute(), a consumer can subtract the two directly to measure how long a sample spent in transit and scheduling:

holoscan::expected<void, holoscan::Error> compute(holoscan::ExecutionContext& ctx) override {
HOLOSCAN_TRY(auto frame, in.receive());
const holoscan::TimePoint published = frame.metadata.publish_time();
if (published.valid()) {
HOLOSCAN_TRY(auto t_now, ctx.clock().now());
const std::int64_t transit_ns = t_now.timestamp_ns - published.timestamp_ns;
// ... same domain, so the subtraction is meaningful ...
}
return {};
}

Always check .valid() first. Publication time is absent — a zero ClockId, as with any absent TimePoint — whenever the sample reached you from outside the clock generation that stamped it:

  • A sample your graph imported across an external channel boundary. That publication belongs to the peer that produced the sample, and its clock is not one your plan can place.
  • A sample that crossed a process boundary inside your own plan. Each worker reconstructs the plan and resolves the default clock to its own live instance, so the two workers number the same clock declaration identically while counting from unrelated origins. The runtime withdraws the coordinate on that hop rather than hand you a number that would subtract to a meaningless — possibly negative — interval.

The subtraction above is therefore meaningful for a consumer in the same process as the producer, which is the ordinary single-process graph.

Reading publication time as an external client

A sample your graph exports does carry publication time, so a client outside the graph can correlate what it receives with the runtime’s own timeline. The instant is the same one an in-graph consumer sees, but the clock is named in your catalog’s numbering rather than the plan’s: an endpoint catalog assigns its own dense clock ids, so the id on the wire is the catalog’s, not the graph’s. Resolve it against the clock records the catalog declares:

auto sample = subscriber->receive_sample();
const holoscan::TimePoint published = sample->metadata.publish_time();
if (published.valid()) {
// published.clock_id indexes the clocks this catalog declares, so the client can
// look up that clock's kind, unit, epoch, and monotonicity before comparing.
}

Check .valid() here too: a graph that declares no clock has no publication domain to attribute a send to, and its exports carry the absent coordinate.

Resolving the clock id is not the same as being able to subtract the number. The catalog record describes the publishing clock — its kind, unit, epoch, monotonicity, and discipline — but carries no origin, offset, or conversion, so it cannot tell a client how the publisher’s coordinate relates to a reading the client takes itself. The default RealtimeClock counts from its own construction, so subtracting an exported coordinate from an unrelated clock’s now() yields a meaningless interval rather than a latency.

Compare across the boundary only when the deployment makes the two coordinates commensurate: publish and read the same disciplined clock, or a clock configured to count from a shared epoch, and confirm that from the catalog’s epoch and discipline records before trusting a difference. Otherwise treat the coordinate as an identifier that orders and correlates a publisher’s own sends, which it always does correctly.

Publication time is not a substitute for capture time. It records when the runtime sent a sample, which for a source operator is already later than the acquisition instant and, for every stage after the first, later still. Measuring end-to-end freshness against publish_time() silently discards the pipeline latency accumulated upstream — use capture_time() for that.

Tag: identifying an activation

Alongside physical time, each compute() callback has an identity: ctx.tag() returns a Tag, a small value naming this specific activation (a sequence number within an execution generation). A Tag supports equality and hashing but not ordering — it answers “which activation is this?”, never “when did it happen?”. Use it to correlate: label a debug record, key per-activation state, or let a downstream consumer match a fused result back to the activation that produced it.

const holoscan::Tag id = ctx.tag(); // identity: {sequence, generation}
record_debug(id, /* ... */);

There is a third time-flavored type, TagTime (ctx.tag_time()): the activation’s deterministic logical position in the run’s timeline, totally ordered and reproducible across replays even when the wall clock jitters. You will not need it until you care about reproducible ordering — see Determinism and replay for the deep dive.

One mnemonic for all three: TimePoint is the wall clock in the lab, TagTime is the frame number on a deterministic simulation timeline, and Tag is the serial number stamped on this one run of the operator. Do not sort by Tag; do not read TagTime as real nanoseconds; do not compare TimePoints across domains.

Recap

  • ctx.clock() hands compute() a borrowed, read-only ClockRef — you can observe time, never sleep on it or advance it.
  • now() measures, at_ns() labels: now() reads the live clock and is fallible; at_ns() stamps a nanosecond value you already have and cannot fail.
  • A TimePoint is nanoseconds plus a ClockId; compare only within one domain, and check .valid() — absent time is a zero ClockId, not the epoch.
  • Stamp emitted samples with EmitOptions::capture_time so downstream stages measure freshness from the true acquisition instant.
  • The runtime stamps publish_time() itself, in the default clock domain, at the instant it submits a sample — a send-side coordinate shared by every recipient of that publication. Exported samples carry it, named in the receiving catalog’s clock numbering; samples imported across an external channel boundary carry no publication time.
  • Tag is an activation identity for correlation — equality and hashing only, never ordering.
  • Every graph needs a default clock; production defaults to real time, tests swap in a manual clock, and your compute() code is unchanged either way.

Next: write your first operator in First operator.