Time and Clocks
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.
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
- How to get a read-only clock reference inside
compute()withctx.clock() - When to call
now()(measures, can fail) versusat_ns()(labels, cannot fail) - What a
TimePointis 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
Tagis 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:
C++
Python
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:
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_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:
At a glance
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:
Two common variants:
- Source operator with a hardware timestamp — use
clock.at_ns(hw_ns)as above. - Source operator without one — the
camera_imu_controlexample’s camera source stampscontext.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:
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:
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:
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.
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()handscompute()a borrowed, read-onlyClockRef— 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
TimePointis nanoseconds plus aClockId; compare only within one domain, and check.valid()— absent time is a zeroClockId, not the epoch. - Stamp emitted samples with
EmitOptions::capture_timeso 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. Tagis 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.