Holoflow Task Model¶
Holoflow describes every computation as a task with zero or more tensor inputs and zero or more tensor outputs. A task is either synchronous, completing one input-to-output operation per call, or asynchronous, separating input consumption from output production. By convention, a task with no inputs is called a source, and a task with no outputs is called a sink, matching flow-theory naming convention.
Synchronous tasks¶
A synchronous task behaves like an ordinary operator: one call consumes the current inputs and produces the corresponding outputs. The call is blocking from the scheduler's point of view, although a GPU task may enqueue work on its assigned CUDA stream and return before the device finishes it.
The essential interface is relatively small:
inputs and outputs are ordered by the slots declared for the node. Each TView combines a tensor description with access to its storage. The cancellation flag lets a long-running task stop cooperatively. The event reader and writer let synchronous tasks receive and emit application events without coupling the task to the event router.
The return value describes expected control flow:
| Result | Meaning |
|---|---|
OpResult::Ok |
The operation completed and its outputs are available. |
OpResult::NotReady |
Reserved for asynchronous operations; returning it from execute(...) is invalid. |
OpResult::Cancelled |
The operation stopped because cancellation was requested. |
OpResult::Eof |
The task reached the end of its input stream. |
The scheduler accepts Ok, Cancelled, or Eof from a synchronous task. NotReady is only meaningful for an asynchronous operation, where the scheduler can retry. Exceptions are reserved for validation or runtime failures; OpResult is not an error-reporting mechanism.
A simple synchronous task¶
The following tasks compute the square root of a contiguous float32 tensor. Their execution code is nearly identical conceptually, but the CUDA implementation stores its assigned stream and enqueues a kernel on that stream.
The CUDA call is asynchronous with respect to the CPU, but execute(...) is still a synchronous-task operation in Holoflow: it submits exactly one input-to-output computation. The scheduler preserves ordering on the section's CUDA stream and performs the required synchronization before publishing work across an ordinary asynchronous boundary.
Warning
A task enqueing CUDA work on a stream should generally not synchronise the stream after. It is the scheduler responsibility to do so. However it remains possible, as it may be required for thread safety.
Task factories¶
Tasks do not expose a standard public constructor to Holoflow. Instead, the application registers a factory for each task kind. This separates three concerns:
infer(...)validates settings and input tensor descriptions, then declares the output descriptions and memory semantics.create(...)constructs the runtime task with the resources assigned by the compiler.update(...)reuses or replaces an existing task when its inputs or settings change.
Inference is shared by both factory kinds:
A synchronous factory adds construction and update operations:
InferResult is the task's contract with the compiler:
The tensor descriptions record the validated inputs and inferred outputs. in_place declares outputs that reuse input storage, while the ownership masks identify slots whose memory lifecycle is controlled by the task. kind selects synchronous or asynchronous construction.
The final flag applies only to asynchronous tasks. When it is true, try_push(...) must synchronize its producer stream before returning any result that lets the scheduler advance. With the default value of false, the scheduler supplies the required barrier before calling the producer side. Because inference happens before task construction, the compiler can validate the whole graph and plan its memory and execution sections without running a task.
SyncCreateCtx supplies the CUDA stream assigned to the execution section. The asynchronous equivalent, AsyncCreateCtx, supplies separate producer and consumer streams. The default update(...) implementation discards the old task and calls create(...); a factory only needs to override it when preserving allocations or other internal state is worthwhile.
Factories for the square-root tasks¶
Each factory below accepts one contiguous float32 input and rejects the wrong memory location during inference. The output has the same shape, data type, and memory location as the input. Neither implementation owns its storage or operates in place.
The registry stores factories rather than task instances:
When a graph node names one of these task kinds, the compiler looks up the corresponding factory, infers its contract, and creates the appropriate runtime task.
One task kind in production
The two square-root kinds make the CPU and CUDA implementations easy to compare. In an application, a single Sqrt factory would typically accept both memory locations during inference, then inspect the inferred input location in create(...) and construct the matching implementation.
From specification to execution¶
Compilation separates graph validation and resource planning from task execution. The scheduler only receives tasks after the compiler has inferred their tensor contracts, assigned resources, constructed or updated them, and injected their runtime services.
Factories prepare tasks during compilation; the scheduler invokes the resulting task instances at runtime.
Asynchronous tasks¶
An asynchronous task separates input consumption from output production. Its producer side accepts data through try_push(...); its consumer side exposes data through try_pop(...).
The two calls are independent. A successful push does not imply that a pop will immediately succeed, and try_pop(...) may return NotReady until enough data has accumulated. try_push(...) may likewise return NotReady when the task cannot accept another input. The scheduler retries either operation until it succeeds, reaches end of stream, is cancelled, or throws an exception.
A non-owning asynchronous task¶
Consider a pass-through rate limiter with a one-element staging buffer. Its producer and consumer sides may run on different threads. try_push(...) applies backpressure while an element is buffered, so the task never drops an input. try_pop(...) withholds that element until its deadline.
Push fills one private slot; pop releases it only after the rate-limit deadline.
Note
The CUDA implementation synchronizes each copy before publishing a state change. This makes the example's cross-stream buffer safe.
Rate-limiter factories¶
CpuRateLimiterFactory and CudaRateLimiterFactory validate one contiguous input and a positive max_fps. The CPU factory accepts only Host memory; the CUDA factory accepts only Device memory and passes both streams from AsyncCreateCtx to its task. Both factories declare the same non-owning tensor contract:
For the CPU factory, create(...) ignores the stream context and constructs CpuRateLimiterTask. For the CUDA factory, it forwards ctx.producer_stream and ctx.consumer_stream to CudaRateLimiterTask.
One task kind in production
As with the square-root example, the separate RateLimiterCpu and RateLimiterCuda kinds make the implementations easy to compare. An application can expose one RateLimiter factory that selects the implementation from the inferred input memory location.
The rate limiter demonstrates asynchronous control flow. The storage ownership guide revisits the same task with a task-owned slot, eliminating both copies while preserving its rate-limiting behavior. Because the producer and consumer sides may run in different execution sections, they may be called from different CPU threads and use different CUDA streams.
Warning
Asynchronous tasks must guarantee safe operations under single producer single consumer concurency.
An asynchronous boundary gives the compiler an opportunity to split the graph into independently scheduled sections. In the queued band-pass example, this lets upload, computation, and download overlap across consecutive frames.
The common task interface¶
ISyncTask and IAsyncTask both derive from ITask. The base interface provides runtime services shared by the two execution models:
The compiler binds the logger after constructing every task. It binds storage access when factory inference declares at least one owned slot. Consequently, a task must not call logger() or storage_access() from its constructor. logger() is available after compilation during normal runtime methods. storage_access() is available in those methods only to tasks whose inference result requires it.
The task-specific logger includes the node kind and name, which keeps messages attributable when several instances of the same implementation appear in a graph. Cancellation is provided separately through each execution context because it belongs to the current scheduler run.
Choosing a task model¶
Use a synchronous task when one invocation naturally maps the current inputs to the current outputs. This includes most CPU functions, CUDA kernels, transfers, sources that produce one item per call, and sinks that consume one item per call.
Use an asynchronous task when input acceptance and output availability must progress independently. Queues, batching, windowing, rate conversion, and buffering between independently scheduled sections are the common cases. Asynchronous tasks require more state and stricter ownership reasoning, so they should represent a real scheduling boundary rather than merely a long-running computation.
In-place mappings¶
An in-place mapping tells the compiler that an output reuses an input's storage. The task still borrows that storage: it neither allocates it nor participates in the owned-input or owned-output lifecycle.
For example, a square-root factory can declare that output 0 aliases input 0 when its implementation supports src == dst:
During storage planning, the compiler assigns the input and output tensor IDs the same storage ID. Consequently, ctx.inputs[0] and ctx.outputs[0] describe distinct logical tensors backed by the same allocation.
| Mechanism | Allocation | Pointer publication | Purpose |
|---|---|---|---|
| In-place mapping | Compiler-managed | Fixed by the compiler | Reuse an input allocation for an output. |
| Owned storage | Task-managed | Changed through IOStorageAccess |
Let a task control when its storage is writable or readable. |
In-place safety is the factory's responsibility
Declare a mapping only when the operation is correct with aliased input and output, the output descriptor fits the input allocation, and overwriting the input cannot affect another live consumer. The compiler assigns the shared storage but does not prove these conditions.
Where to go next¶
- Learn how tasks control buffer lifetimes in Storage Ownership.
- Learn how control and status messages move through Events.
- Return to the Holoflow overview to see synchronous tasks and asynchronous queues in complete graphs.
- Follow the planned LDH pipeline tutorial for an application-level example.
- Browse the existing Holovibes task reference for concrete task implementations.