|
Uni20
Asynchronous tensor and runtime library.
|
This guide defines the first supported pattern for lifting synchronous Tensor operations onto Uni20's async runtime. The implemented references are uni20::linalg::gemm, uni20::linalg::assign_product, uni20::linalg::add_product, and the multi-output uni20::linalg::eigh and uni20::linalg::svd overloads, plus full and axis-selective uni20::sum, from <uni20/linalg/async.hpp>. See Tensor Operations for the canonical operation semantics and current synchronous/Async support matrix.
An async Tensor operation is a scheduling wrapper around the same operation-tag kernel dispatch used by synchronous Tensor operations:
Backends and leaf kernels remain unaware of Async<T>, epoch queues, and schedulers. The async wrapper resolves the storage policy's static selector, owns any frontend output preparation, normalizes fixed operands to stable mdspecs, and then enters co_dispatch_kernel.
Do not add async overloads directly to a backend or make Async<Tensor> model TensorView.
The first checkpoint uses a strict all-async interface:
The rules are:
Async<T>.Async<T> const& and the output as Async<T>&.async::read(...) turns the former into an always-ready ValueAwaiter and the latter into its real read buffer.Async Tensor aliases such as async::conj(tensor) and async::reshape_view(tensor, ...) are valid input operands. Their Async<View> handles retain the complete parent storage chain and share its epoch queue.
The non-coroutine wrapper performs only work that is valid on the async handles:
async::read(...).WriteBuffer for the output and one ReadBuffer for each distinct Tensor input. For an update, the writer supplies the old output value; do not also enroll an output reader.The coroutine then:
co_dispatch_kernel with the operation tag and resolved selector. A replaceable output remains shared storage until the backend prepares it.Use a named free coroutine, or a captureless C++23 static lambda. Never pass references to the Async<T> handles into the coroutine. The moved buffers already retain storage, queue context, and the selected epochs, so the original handles may be destroyed immediately after submission.
Use names that distinguish C++ coroutine bodies from ordinary functions that return task handles:
| Function kind | Naming form | Example |
|---|---|---|
Coroutine body containing co_await, co_yield, or co_return | co_<operation> | co_gemm_submission(...) |
| Ordinary function returning a task | make_<operation>_task | make_prefetch_task(...) |
| Ordinary fallible factory returning an optional or attempt wrapper | try_make_<operation>_task | try_make_kernel_task(...) |
| Ordinary function that submits work to a scheduler | schedule_<operation> | schedule_async_gemm(...) |
| Synchronous implementation | <operation> | gemm(...) |
Reserve the co_ prefix for actual coroutine bodies. Returning AsyncTask, CudaTask, or a wrapper around one does not by itself make an ordinary factory a coroutine. Conversely, _async describes an API or execution model and does not identify whether the function body uses the C++ coroutine mechanism.
An ordinary one-output operation has one writer and any number of distinct input readers. A mutating operation reads the old output through its WriteBuffer; it must not acquire a separate output ReadBuffer or pass the output again as an input. This preserves the synchronous operation's semantic distinction between overwrite and update.
For example, async A = f(A, B) lowers as one writer for A and one reader for B. It does not lower as a reader for A, a reader for B, and a later writer for A. Async A = f(A) is a unary update with only the writer for A.
The matrix-product wrapper rejects this exact condition before buffer enrollment:
This catches the obvious same-value and parent/view alias cases that would self-block with whole-parent queue sharing. Two inputs may share a queue because read/read access is compatible.
This check is deliberately narrow. It does not prove that separately managed storage ranges do not overlap, and it does not attempt to recognize arbitrary dependency cycles. More involved invalid graphs belong to async deadlock detection and debug task creation traces.
Default selector resolution is static: select_backend_for<Tensors...>(op) uses the common storage-policy type and operation value without inspecting a Tensor value. A user backend_selector_override takes precedence over a Uni20-owned operation-specific backend_selector_default, which in turn may compose the storage policy's general selector. The immutable result is chosen before scheduling and moved into the coroutine. An explicit selector bypasses default resolution, follows the same ownership rule, and may carry immutable operation context.
This does not mean a concrete backend has run or accepted the operation before scheduling. The coroutine awaits the Tensors, normalizes their fixed operands to mdspecs, and only then enters the normal runtime backend walk. The selected backend acquires any execution-domain leases and resolves mdspans. Layout- and accessor-dependent declines therefore remain valid.
A future async block handle may expose a synchronous descriptor outside its data epoch. Such a wrapper may perform more layout-dependent planning before scheduling, but that is a different operand contract and must not be assumed for Async<Tensor>.
Async gemm, assign_product, add_product, solve, and solve_inplace accept an optional working precision for both fixed and runtime scalar types. The request is copied into the coroutine; inferred defaults are read after the input epochs become readable. Fixed types reject requests that do not match their native precision. See generic precision.
gemm is a fixed-output operation:
alpha and beta may independently be immediate or async scalar operands.beta; gemm never constructs or resizes the output.gemm_op; a numerically zero beta does not change the fixed-output contract.assign_product is an overwrite operation. For fixed scalar types:
assign_product_op, which has no beta argument.shared_storage<Tensor> to dispatch, so an unconstructed output remains representable at the backend boundary.assign_product_op dispatch while retaining the writer. The view remains fixed in shape and binding; backend layout and accessor requirements still apply.cuda::Device; CudaStorage resolves that device to its canonical allocation resources.For runtime-precision host scalars, the coroutine resolves precision and prepares an independent output before invoking synchronous assign_product; a mutable alias instead passes its fixed bound view. That frontend dispatches GEMM with the resolved precision. Owning outputs record that precision, while structural views preserve the parent's default. Provider precision scopes cover only synchronous calls after awaits; no scope survives coroutine suspension. The fixed-scalar CUDA path retains its asynchronous backend dispatch.
add_product is an update operation:
gemm with beta = 1.Apply the same distinction to future overwrite and compound-update operations.
assign_transform and transform_inplace apply these rules to a variadic reader pack. The callable is ordinary immutable task state, not an async operand. It is moved into the coroutine and then into the synchronous operation value after all Tensor readers and the output writer are ready. Read-only input queues may coincide with one another; only output/input queue equality is rejected. A mutable async alias remains fixed to its parent: while holding the writer, the transform coroutine copies the bound descriptor locally and dispatches through that copy rather than requesting value storage or replacing the descriptor.
sum is a reduction overwrite. Runtime axes are ordinary configuration state: the wrapper normalizes and validates them before scheduling, then moves the fixed-rank descriptor into the coroutine. The coroutine awaits the input, derives the required output extents, constructs or resizes an owning output, and invokes the synchronous reduction. A mutable async alias is a valid fixed-shape output; the coroutine copies its bound descriptor under the writer and writes through that copy. Value-returning sums create a fresh independent result epoch, while sum_host creates Async<Element> and schedules the host result instead of blocking the submitting thread.
eigh is an allocating multi-output operation. It returns SelfAdjointEighResult<Async<Values>, Async<Vectors>>, not an Async<SelfAdjointEighResult<...>>. The two independent output epochs can be structured-bound and passed directly to downstream async operations without an extra extraction coroutine. The preserving overload reads an Async<Tensor> const&; the consuming overload takes Async<Tensor>&& and may transfer the stored owning Tensor's allocation to the eigenvector output.
The exact SVD family follows the same allocating pattern. singular_values returns one Async<S>, svd_left returns SvdLeftResult<Async<U>, Async<S>>, svd_right returns SvdRightResult<Async<S>, Async<Vh>>, and svd returns SvdResult<Async<U>, Async<S>, Async<Vh>>. Each returned value has an independent epoch, while one coroutine computes and commits all outputs of a multi-output operation.
truncated_svd extends the same pattern to four independent outputs: TruncatedSvdResult<Async<U>, Async<S>, Async<Vh>, Async<SvdTruncationInfo<Real>>>. Rank selection and factor right-sizing happen inside the scheduled coroutine after the exact SVD is available. Every output writer is therefore an exception sink; consuming lowering also retains the input writer through result publication so exact-SVD or policy failures reach the input and all four outputs.
Preserving overloads read Async<Tensor> const&. Consuming overloads take Async<OwningTensor>&&, enroll the input writer, and obtain the matrix through take(). A reduced singular-vector output may adopt the consumed allocation through LAPACK's O job. Full factors and incompatible mappings allocate or materialize as needed; the rvalue grants permission to reuse storage rather than promising that every call will do so.
An operation that may reuse an input allocation must accept only Async<Tensor> values whose stored type models OwningTensor. A tensor view, including a conjugating or slicing alias, never becomes consumable merely because its descriptor was passed by value.
Enroll a consuming input with WriteBuffer, then obtain the value through take() or take_release(). Do not read the Tensor and subsequently move from the referenced object: that would leave the async storage marked constructed while exposing a moved-from owner to later epochs and aliases.
take() moves out and destroys the stored value while retaining the writer. Use it when the operation will reconstruct that same async value or when the consumed epoch must remain gated until several outputs commit or receive an exception. take_release() also releases the writer and is appropriate only when ownership is permanently transferred elsewhere and early release is part of the operation contract. Existing views and async aliases of a consumed owner must not be used afterward. Epoch ordering prevents concurrent access, but it does not extend the lifetime of an object removed from its storage.
Passing a WriteBuffer as a coroutine parameter automatically makes its epoch an exception sink. Failures from input epochs, shape checks, backend dispatch, or the kernel are therefore observed when each output is read.
Do not convert a failed backend execution into a kernel decline. Backend declines happen inside ordinary synchronous dispatch before side effects. Once the selected synchronous operation fails to produce its result, the async task fails and propagates that exception to its output.
For a task with multiple outputs, pass every output WriteBuffer as a coroutine parameter and document that all output epochs receive failures. Automatic argument processing registers every such writer; use propagate_exceptions_to(...) only for an additional sink that is not already a writer parameter.
A consuming input is also enrolled as a WriteBuffer. A multi-output operation that uses take() keeps that writer gate active through output commit and exception routing. If later work fails, reads from the invalidated input timeline observe the same exception as the outputs. On success, the consumed input has no readable value.
The core implementation pattern is:
The actual output preparation depends on whether the operation overwrites, updates, or produces a differently typed result.
static.Keep operation-specific Tensor wrappers explicit because output construction, mutation, and multi-output exception routing differ materially between algorithms. Once the wrapper has awaited its values and normalized stable mdspec operands, use generic co_dispatch_kernel. Blocking backends require no coroutine wrapper; individual backend/operation pairs add try_make_kernel_task only when resource admission or execution must suspend.