Tensors 101
So far the running example has moved small values between operators: IMU readings,
control scalars. Camera frames are different — kilobytes to megabytes per sample,
often living in GPU memory, produced at a fixed rate. holoscan::Tensor is the
payload type built for exactly this: a cheap handle over runtime-owned, pool-backed
storage, with CUDA stream ordering handled for you.
This page covers the basics you need for the first pipeline: declaring tensor ports, the producer hot path, why a received tensor “just works” on the GPU without any synchronization on your part, and how to reach the data from the host when you genuinely need to. The deeper machinery — pool sizing, the two-stream lifetime contract, foreign DLPack tensors, fanout — waits in Tensors in depth.
Complete C++ example: examples/tensors_101/cpp
- How to declare Tensor ports with direction-specific
produces_tensor()andexpects_tensor()contracts - The producer lifecycle:
allocate_tensor()→write()→ fill →commit()→emit() - Why stream ordering is automatic — and why you must never call
cudaStreamSynchronizeon the hot path - Why
allocate_tensor()borrows from a pool instead of allocating, and whatkResourceExhaustedmeans - How to copy
Tensorhandles cheaply and access data from the host explicitly
Declaring tensor ports
Tensor ports follow the same three-declaration model as every operator: structure
belongs in setup(). A TensorInputSpec groups consumer requirements, while
a TensorOutputSpec groups producer promises, finite bounds, and storage
intent. Both use TensorRepresentation for memory kind, dtype, rank, and stride
facts. Omit the entire directional contract for an unconstrained pass-through
port; a present contract must say something meaningful.
Here is the camera source and fusion consumer from the running example
(camera_imu_control):
When both endpoints of an edge explicitly constrain the same layout field with
incompatible values, compile() rejects the plan — one more check that runs
before anything executes.
A runtime-pool contract needs finite metadata and byte capacities. Exact rank
supplies the metadata capacity here; tensor_bounds(64U) supplies the byte cap.
Storage intent and the shortest allocation form
An output defaults to pass-through storage. Calling allocate_tensor() without
an admitted runtime pool returns kNotReady; allocating producers explicitly
select TensorOutputStorage::kRuntimePool and finite bounds. The compiler derives
the number of pool entries, so you do not construct a pool or count slots.
When the output declares an exact dtype, use frame.allocate_tensor(shape)
instead of repeating it in TensorLoanRequest. For a single host fill,
loan->write_host_and_commit(fill) combines writing and commit; then explicitly
frame.emit(std::move(*loan)). Keep publication checked. The explicit lifecycle
below explains the same operations; exhaustion and device-stream details are
covered in Tensors in depth.
Producing a tensor: the hot path
Production is an explicit four-step lifecycle, and each step consumes the previous
object. Every step returns holoscan::expected, so failures follow the same
errors-as-values
pattern as the rest of the SDK:
This device-pool form is demonstrated by the shipping tensors_in_depth
example; tensors_101 and the camera example intentionally use host storage.
The move-only types enforce the order for you: the write guard must be committed (or destroyed) before the loan can be emitted, and every path consumes the loan exactly once. If you return early on an error, the loan and guard destructors abandon the allocation safely — nothing leaks, nothing half-publishes.
For the common “one callback fills the whole tensor” case, the fused helpers
loan->write_and_commit(ctx.cuda_stream(), fill) and
loan->write_host_and_commit(fill) collapse steps 2–4 into one call: the callback
receives a const TensorOutputWriteGuard& to fill through, and commit happens on
successful return. emit() stays a separate, explicit publication step.
Despite the name, allocate_tensor() never allocates memory on the hot path.
The pool behind your output is fully materialized before the graph starts
running, sized from your declared bounds and the graph’s queue and fanout
capacities. The call borrows one preallocated slot and hands you a
TensorOutputLoan. If every slot is checked out, it returns
ErrorCode::kResourceExhausted immediately — it never falls back to malloc,
never grows the pool, and never waits. That is the SDK’s bounded by
construction principle applied to bulk data: memory use is fixed at compile
time, and exhaustion is a typed value you can branch on, not a stall.
The headline: stream ordering is automatic
This is the one fact to take away from this page. On the in-process data path, CUDA stream ordering between producer and consumer is the runtime’s job, not yours:
- On the producer side,
commit()records your writes’ readiness on your compute stream. That is an asynchronous CUDA event record — it does not block the host, and it does not wait for your kernels to finish. - On the consumer side, before
compute()is invoked, the runtime enqueues an asynchronous wait on your compute stream for the producer’s readiness event. A receivedTensortherefore arrives already ordered on your compute stream: any kernel you launch onctx.cuda_stream()will observe the producer’s writes, even though neither of you ever synchronized.
You write, commit, and emit; the consumer receives and launches kernels. The GPU pipeline stays full end to end because nothing on the path ever blocks the host.
Never call cudaStreamSynchronize on the hot path. commit() records
readiness; it is not a synchronize, and it does not need one. Adding a blocking
sync “just to be safe” serializes the CPU against the GPU, collapses the
pipelining the runtime built for you, and buys no additional correctness. The
only blocking calls in the tensor API are the explicit host-access methods
described below — and those do not belong in a steady-state compute() either.
Receiving a tensor
A consumer declares expects_tensor(layout) and receives an ordinary sample:
The runtime keeps the backing storage alive and read-only for your whole
activation; you do not manage leases or release anything. A received tensor is
read-only by construction: data() is const, and mutable_data() returns
ErrorCode::kPermissionDenied because you do not hold write authority.
A Tensor is a cheap handle
Copying a holoscan::Tensor copies a handle, not data: the copy retains one more
claim on the same shared backing allocation, and zero payload bytes move. This is
what makes tensors safe to pass around by value, stash in a small struct, or emit
to several downstream operators — every recipient sees the same storage, and the
pool recycles the slot only after the last claim retires. Treat a Tensor like a
shared_ptr with tensor metadata attached, because that is essentially what it is.
Host access is explicit — and blocking
Everything above stays on the GPU. When you genuinely need bytes on the CPU — a verification tap, a file writer at the end of the pipeline — the tensor API makes you say so:
t.synchronize_for_host()blocks the calling thread until the producer’s writes are observable from the host, then you may readt.data()directly (host-visible memory only).t.copy_to_host(dst, dst_bytes, stream)copies and blocks until the copy completes.t.copy_to_host_async(dst, dst_bytes, stream)enqueues the copy on your stream; you own the completion (for example, synchronize that stream in a sink operator that has nothing downstream).
Blocking is the honest cost of moving data to the host, so the API confines it to these clearly named calls. Keep them at the edges of your graph — never in a steady-state producer or a mid-pipeline transform.
The running example: camera frames as tensors
With this page, the camera leg of the
camera + IMU pipeline
becomes real: CameraSource borrows a frame-sized slot each activation, fills it,
commits, and emits it with a capture timestamp; WindowFusion anchors its temporal
window on the camera input, brackets the high-rate IMU samples around each frame,
and reads frame metadata (frame->data.nbytes()) without ever copying the payload.
The complete program is the camera_imu_control example application.
Going deeper
This page deliberately stopped at the in-process basics. When you need more, Tensors in depth covers the two-stream lifetime contract (readiness versus deallocation streams), how the compiler sizes pools, importing foreign DLPack tensors from libraries like CuPy or PyTorch (and why they need an explicit readiness stream), and multi-consumer fanout limits. To install PyTorch next to Holoscan, see Installing PyTorch.
Recap
- Declare Tensor structure in
setup():produces_tensor(TensorOutputSpec)on outputs andexpects_tensor(TensorInputSpec)on inputs. Put finite bounds andkRuntimePoolstorage intent in the output spec. - Produce with
allocate_tensor()→write(ctx.cuda_stream())→ fill →commit()→emit(); the fusedwrite_and_commit()covers the single-fill case. allocate_tensor()borrows a preallocated pool slot; exhaustion returnskResourceExhaustedimmediately — bounded by construction, no hiddenmalloc.- Stream ordering is automatic:
commit()records readiness without blocking, and a received tensor arrives already ordered on your compute stream. Never callcudaStreamSynchronizeon the hot path. - A
Tensoris a cheap refcounted handle; copies share one backing, zero-copy. - Host access is explicit and blocking (
synchronize_for_host(),copy_to_host*) — keep it at the edges of the graph.
Next: with data flowing, step back and look at the full shape of an operator in Operator anatomy.