|
Uni20
Asynchronous tensor and runtime library.
|
Status: background design note. The canonical implemented operation contracts and current Async support matrix are in `../tensor/operations.md`. This draft retains the deeper lifetime rationale and future AsyncArray, slice, and Python design work.
This note records the async-specific part of the tensor design discussion: how Async<Tensor> should produce safe views/refs, how those views interact with AsyncArray, and how async operands fit into kernel dispatch.
Related notes:
Async<T> storage, value kinds, and write-proxy assignment.A resolved stdex::mdspan does not carry the ownership and epoch state required by an async tensor value. Such a descriptor is sufficient only after a buffer has been awaited and while the owning access proxy remains alive.
For example:
S cannot merely store a pointer into A. It must preserve:
A's storage control blockThis is the async version of the synchronous split between Tensor, TensorRef, and resolved mdspan-like views. The async handle has to be more than a resolved view.
The async tensor model should keep four things separate:
The resolved view is a short-lived object used by a leaf kernel. The async tensor handle or alias is the durable object that can be stored, copied, and passed to dispatch.
For Async<Tensor>, the owner token is the Async value's shared_storage<Tensor> control block. For a slice/view of that tensor, the alias must share that same owner token, or an equivalent subobject alias token that keeps the parent control block alive.
For AsyncArray, the owner token is typically a shared backing allocation plus a descriptor table. Each element handle adds an element/block epoch token.
Dense aliases use ordinary Async<View> types. The stored view models TensorView, not mdspec or mdspan, and therefore retains synchronous tensor metadata and a backend selector:
The alias shared_storage<View> owns the descriptor in a local control block. That control block retains one reference to the parent storage, forming a hierarchical reference-count shard. Async<View> also shares the parent's exact EpochQueue. Alias descriptor types declare async_alias_tag, so copies of Async<View> are structural handle copies rather than value copies onto a new timeline. Alias-marked types cannot use ordinary root Async<T> value constructors; they must be created with make_async_alias(...) or copied/moved from an existing alias.
read() and write() return buffer-like awaitables. Awaiting them performs the synchronization and then exposes the stored tensor-level descriptor. A leaf operation obtains the resolved mdspan by calling mdspan() on that view:
The resolved view should normally be treated as ephemeral. It is valid for the scope in which the access proxy or owning await result keeps the epoch and owner tokens alive. Persistent Python-facing or user-facing views need to own an alias token, not just a pointer.
The current Async<T> implementation already has the pieces this design should build on:
shared_storage<T> provides a refcounted control block with constructed and unconstructed states.ReadBuffer<T> and WriteBuffer<T> retain epoch context and storage ownership while reads/writes are pending.Async<T>::value_ptr() creates a pointer-like owner token by capturing shared_storage<T>.make_shared_storage_alias(...) owns a local descriptor while retaining one parent-storage reference.make_async_alias(...) combines that ownership with the parent's exact queue.That last point matters. A tensor slice/view alias must share the parent's lifetime and must use the correct hazard/epoch context. A fresh independent queue is not correct for a view that aliases the parent's bytes, because writes through the parent and writes through the view must be ordered together.
The first dense implementation conservatively shares the whole parent queue. Subrange hazard records remain future work for independently schedulable slices and coalesced block views.
AsyncArray should be the same design at block granularity:
block(i) should return a handle, not a raw resolved view. The handle keeps the backing allocation alive, refers to the descriptor for block i, and uses the epoch/hazard record for block i.
This lets AsyncArray avoid allocating a full Async<T> per block while still presenting a common read/write interface to block kernels.
Coalesced views need a wider hazard record. A coalesced view spanning blocks i...j cannot be protected by only one element queue. The first implementation can choose a conservative policy:
This is the async side of the dual block-view/coalesced-view problem in ../symmetry/block_coalescing.md.
Leaf kernels should not become async-aware. The async layer should lower async operands to the same resolved mdspan-like views that synchronous dispatch uses.
The first implemented flow uses strict all-async Tensor operands:
EpochQueue identity before buffer enrollment.async::read(...).WriteBuffer and input ReadBuffers.co_dispatch_kernel with the same operation tag and backend list used by synchronous dispatch.The task is a named free coroutine, or equivalently a captureless static lambda whose buffers are parameters. It never retains references to the caller-owned Async<T> handles.
This is deliberately different from asking Async<Tensor> to satisfy the ImmediateTensorView concept implemented by an immediately accessible Tensor. A synchronous tensor can produce an immediate read view. An async tensor produces a read handle that must be awaited.
For whole Async<Tensor>, shape, layout, and data handles are part of the stored Tensor value and become available after the await. The default selector does not inspect those values: select_backend_for<Tensors...>(operation) uses the common storage-policy type and operation value at submission. The runtime backend walk still waits for resolved mdspans because layout and accessor checks may decline a candidate. An explicit immutable selector is likewise passed into the coroutine by value.
The public wrappers intentionally do not normalize mixed synchronous and async Tensor operands. Every Tensor operand in one async operation is Async<T>. Resolved MdspanLike concepts remain leaf-kernel concepts.
prepare_output(out, shape, requirements...) is the replaceable-output hook, but a whole Async<Tensor> output cannot use it until its writer epoch is active.
The implemented matrix-product distinction is:
assign_product is an overwrite dispatched as assign_product_op. It passes the output's potentially unconstructed shared_storage<Tensor> to backend dispatch. The selected backend determines the required shape and storage placement before constructing, resizing, or replacing the output.add_product is an update. The output must already be constructed and have the correct shape because its old values participate in the result.An async handle with synchronously known structure, such as a future AsyncArray block descriptor, may validate shape at submission. Whole Async<Tensor> remains structure-async: shape and layout metadata become available only after await. Its storage-policy type is nevertheless sufficient to resolve the default selector before scheduling.
Temporary tensors still follow the synchronous backend-selector/storage-domain rule from ../tensor/dispatch_and_view_semantics_draft.md: allocation is chosen from the backend selector value plus a storage domain or factory, and filling the temporary is a copy/evaluation kernel.
The async layer adds a timing choice:
An immediate temporary is useful inside a coroutine after all inputs have been awaited. It has ordinary lifetime and is destroyed when the coroutine frame no longer needs it.
An async temporary is useful when later scheduled work must depend on the temporary after the producing wrapper returns. In that case the temporary needs the same ingredients as other async tensor aliases:
Population of the temporary is not a special operation. It is a copy or evaluation kernel scheduled through the same dispatch path:
If only a resolved mdspan-like view and a stateless backend type are available, an async temporary can be allocated only when the wrapper also has a storage domain or temporary factory. A CUDA backend type by itself is not enough to choose a device or allocator for scratch storage; the backend selector value or adaptor must carry that state.
Two related mechanisms govern async values:
is_async_alias_v<T> controls whether copying Async<T> creates an independent value timeline or structurally shares alias storage, lifetime ownership, and the epoch queue.assign_through operation; otherwise the expression is ill-formed.The intended tensor classifications are:
Here rebinding means detaching the outer Async handle and creating fresh storage with a fresh EpochQueue. It does not mean destroying and emplacing a new object inside the old storage. emplace(...) is an explicit low-level reconstruction operation within the current timeline.
Exact Async<T> copy/move assignment follows the same capability rule. An exact Async<MutableView> source writes through the destination view, while assignment to Async<ConstView> is ill-formed. Copy/move construction may still create another alias handle, but no assignment retargets an existing alias descriptor, owner chain, or queue.
An ADL-visible assign_through(target, source) defines the actual write-through operation. The capability-aware write proxy exposes an alias descriptor only as const and invokes that customization internally. It never uses ordinary descriptor assignment, emplace, storage access, or move-out for an alias.
A persistent async tensor slice should probably be an alias handle, not a stored resolved view. If a TensorRef type exists, an Async<TensorRef> value must ensure that the TensorRef carries an owner token; otherwise it is just a borrowed pointer with unsafe async lifetime.
This argues for distinguishing:
WriteBuffer accessDense CUDA views and aliases must carry device placement explicitly. The owner token keeps memory alive, but dispatch also needs to know:
For synchronous tensors this can live in the storage/adaptor domain. For async aliases it must be copied into or recoverable from the alias handle, because the resolved mdspan-like view may contain only a pointer and layout.
Python-facing references have similar lifetime needs. A Python view into a tensor cannot expose only a pointer and shape; it must hold an owner token that keeps the parent tensor alive. For async tensors, the boundary likely should not export a resolved view unless the value has been awaited. A persistent exported view should own the same kind of alias token described above.
This matches Python reference-binding expectations more closely than raw mdspan assignment semantics: copying the Python object copies the handle to the same data, while explicit tensor assignment or NumPy-style writes define when data is copied.
Expected behavior:
C.write(), A.read(), and B.read() into a coroutineExpected behavior:
S keeps A's storage aliveS shares the correct parent hazard/epoch stateS.read() awaits that hazard state and returns a resolved slice viewS as an output is a distinct mutable-alias design; read-only slice and conjugation aliases are input operandsExpected behavior:
Async<Tensor> should not be forced to satisfy the synchronous TensorView concept. The first operation wrappers use exact Async<T> signatures rather than a broader async operand concept.AsyncArray::block(i) should return the same kind of async tensor alias handle, backed by shared allocation lifetime and per-block hazard state.prepare_output is the replaceable-output hook. Async backends receive the active writer's shared_storage<Tensor> so they can also construct an uninitialized output.AsyncArray exists, is there a useful common async Tensor/block concept beyond the exact Async<T> overloads, or should wrappers remain explicit for each ownership and descriptor contract?AsyncArray view await many block queues, or should AsyncArray have a parent/coalesced queue that composes with element queues?Tensor when an Async<View> is accessed, while keeping bare C++ proxy objects out of the Python API?Async<Tensor<...>>, as an Async<TensorAlias> over scratch storage, or as a separate scratch-buffer handle owned by the scheduler?