|
Uni20
Asynchronous tensor and runtime library.
|
Status: background design note. The canonical implemented operation contracts and current Async support matrix are in `operations.md`. This draft retains rationale and future design discussion; where the documents differ, the canonical guide and code are authoritative.
This note records the implemented dense tensor/view boundary and the remaining design questions around output assignment and higher-level dispatch.
Related notes:
The implemented dense GEMM and GEMV paths use a strict layer split:
MdspecLike descriptors.A plain stdex::mdspan or structural MdspanLike is sufficient for a lower-level kernel and is also the immediate case of MdspecLike. It does not carry tensor-level backend-selection policy, so a bare-mdspan frontend requires an explicit selector.
The tensor-level concepts are intentionally not mdspan-like:
The tensor object itself does not model MdspecLike. This keeps policy selection at the tensor layer and makes the normalization boundary explicit. A public bare-mdspan convenience overload takes an explicit selector and uses the mdspan directly as its immediate mdspec representation.
The current semantic split is:
Tensor<Element, Rank, StoragePolicy, LayoutPolicy, AccessorFactory> is the concrete owning dense tensor class; its default mdspan extents are dynamic on every axis. BasicTensor<Element, Extents, ...> is an extents-first alias for the same class when static or mixed extents belong in the type. Most code should use Tensor<T, Rank>, deduction through Tensor(view) or make_tensor(view), or a domain alias such as DenseMatrix<T> rather than spelling an extents type or every policy. Assignment has value/replace semantics:
make_tensor(view) deliberately returns the runtime-extents Tensor form, even when the source mdspan has static or mixed extents. Code that intends to carry extent values in the C++ type names BasicTensor explicitly.
Both forms retain compile-time rank because their resolved descriptor is an mdspan. A future runtime-rank tensor or dynamic_mdspan is a separate type and kernel-interface design; it is not another mode of Tensor.
An implementation may reuse existing storage when shape/layout are compatible, or reallocate/rebuild the owned storage when needed. This is value assignment, not strict write-through assignment.
A resolved view is a shallow descriptor: data handle plus mapping/extents/accessor. This can often be a plain stdex::mdspan or a small mdspan-like object. Assignment of a resolved view should have mdspan semantics:
TensorView is a concept, not a central base class. There is currently no general concrete non-owning tensor adaptor. Fixed operands normalize to mdspecs; leaf kernels receive mdspans resolved by the selected backend.
For dense CUDA data, the tensor/adaptor object must carry enough dispatch information to identify device memory. At minimum that means a storage/backend domain and likely a device ordinal or equivalent placement record. Dense MPI distribution is not a near-term requirement for dense tensors; dense tensors are expected to be local to one node, while block/MPI distribution belongs to BlockTensor storage policy.
TensorRef is the proposed write-through non-owning tensor lvalue proxy:
It should expose or produce a resolved mdspan-like view for kernels, but its own assignment operator writes elements into the referenced region. Shape must already be compatible. TensorRef is the likely type for slices, block mutation targets, and other fixed non-owning outputs.
An explicit adaptor such as as_tensor(std::vector<T>&) does not need to be a TensorRef. Since dispatch uses concepts, user code does not name the adaptor type. The adaptor can have semantics tailored to the wrapped object.
Uni20 should distinguish structural views from semantic transform views. This distinction controls which views may appear on the left-hand side of assignment and which views are only readable expression operands.
Structural views change how storage is addressed without changing the write law. Examples include slices, block views, reshape/reindex views, and component views such as real(x) or imag(x) for complex storage. These views may change the element type, data handle, extents, and strides, but writes remain local:
real(x) and imag(x) are therefore slice-like component views, not proxy accessor adaptors. They should be represented by adjusted handles and strides over the original complex storage when the scalar/storage policy guarantees that component layout. They can be writable because assignment writes one addressed component directly.
Semantic transform views change the value read from storage. Examples include conj(x), scaling views, and arbitrary elementwise transform_view(...) expressions. These are read-only by default. A conjugating view is not an ordinary lvalue because assigning through it has a contravariant law:
If Uni20 ever supports such a write, it should be introduced as a deliberate operation-specific rewrite or a separately named internal adaptor, not as the default behavior of conj(x).
Generic writable outputs should therefore require ordinary raw/default-style accessors over a structural view. Writable proxy accessors are special cases that must document their assignment law and backend lowering explicitly. The implemented mdspan and Tensor conj(...) helpers follow this rule: complex inputs become read-only conjugating views, double conjugation cancels to a read-only identity view of the original storage, and non-complex inputs become const identity views. The Tensor adaptor resolves the existing mdspan conjugated_accessor; it does not allocate or run a kernel.
Eager evaluation is explicit:
copy_op is backend-dispatched. Its reference CPU backend reads through accessor semantics. A future BLAS backend may lower rank-two layout and conjugation metadata to provider matrix-copy extensions such as omatcopy; this does not change the lazy semantics of conj(in) itself.
Do not treat a pointer data_handle_type as proof that a backend may read or write the storage directly. The data handle identifies storage; the accessor defines the value observed at that storage. A semantic accessor can keep the same pointer while changing the value returned by access(...). Direct provider paths that bypass access(...) must require stdex::default_accessor or a specifically recognized accessor whose semantics are lowered into provider metadata. Uni20's conjugating mdspan accessor follows the C++26 std::linalg::conjugated_accessor direction described in WG21 P3050R3, but Uni20 keeps uni20::conj as the operation that fixes real-scalar conjugation semantics instead of using standard std::conj behavior.
Tensor types still have three useful semantic contracts:
Async write proxies do not add a second buffer category. For independent values, proxy assignment constructs empty storage or invokes T::operator=. For aliases, the descriptor is read-only and assignment invokes ADL assign_through. Consequently:
Tensor: if unconstructed, emplace from the right-hand side; if constructed, call Tensor::operator=, which may reuse or reallocate.TensorRef: when explicitly classified as an async alias, assignment writes through to the existing tensor region via assign_through.as_tensor(std::vector<T>&) may support resizing and element assignment, while a slice adaptor should remain fixed/write-through.At the outer handle level, is_async_alias_v<T> distinguishes independent values from owner-bound aliases. Direct assignment detaches an independent value onto fresh storage and a fresh EpochQueue. Alias assignment retains its identity and is available only when ADL finds assign_through; exact and heterogeneous sources follow the same capability rule.
For independent values, explicit proxy.emplace(...) reconstructs the stored object inside the current async storage and queue. It is not a handle rebind. Alias proxies do not expose emplace. Element copying that requires shape preparation or backend dispatch remains a named tensor operation rather than hidden proxy-assignment behavior.
There are two broad output modes.
Both use the implemented shape helpers:
For an owning/resizable Tensor, prepare_output retains matching storage and otherwise calls reset_shape, which rebuilds the tensor using its default mapping. For a non-reallocatable output such as TensorRef, a block view, or a resolved mdspan-like output, prepare_output validates that the current shape already matches and otherwise throws/asserts. For an adaptor such as as_tensor(std::vector<T>&), the adaptor decides: it may resize the vector, or it may expose fixed/write-through semantics. This lets front-end kernels use one output-preparation step while the output type controls whether resizing is legal.
prepare_output must run before materializing the output write view. For a resizable tensor it may reallocate and invalidate any previous mdspan-like object referring to the old storage. When the operation declares its output replaceable, a backend candidate may provisionally prepare it and later decline without writing result elements or submitting work. The next candidate may reuse or replace that prepared output. Fixed and update outputs remain unchanged on decline.
A fixed output cannot reallocate parent storage. It validates shape and then produces a mutable view:
This is the natural mode for TensorRef, fixed adaptors, block views, and BLAS update forms where the output is also an input.
A resizable output can prepare the requested shape by reuse or allocation:
Uni20 uses output-first mutable parameters for linalg APIs, so the update form is gemm(C, alpha, A, B, beta). When beta != 0, C is both input and output, so a fixed-output/update API is the clearer default. Reallocation is natural for overwrite/value-producing operations such as assign_product(C, A, B) and explicit materialization through make_tensor(view).
The concept names should be derived from call sites. These examples are the current target surface.
Needed behavior:
prepare_output(C, shape)tensor_write_view(C)Concept pressure:
ResizableTensorOutput<C> for prepare_output plus writable viewNeeded behavior:
A and B provide synchronous metadata and read viewsC provides prepare_output and a write viewConcept pressure:
TensorView<A>TensorView<B>ResizableTensorOutput<C> for a shape-changing outputNeeded behavior:
C is both input and output when beta != 0mdspan() is resolved only after tensor-level dispatch policy is knownConcept pressure:
MutableTensorView<C>TensorView<A> and TensorView<B>Needed behavior:
Concept pressure:
MdspanLike / StridedMdspanLike remains the leaf-kernel conceptMdspanLike operands only when a backend selector is explicitNeeded behavior:
X is an unnamed concept-modeling adaptortensor_view(X) returns a rank-1 mdspan-like viewtensor_backend_selector(X) defaults to a CPU/host selector unless overriddenprepare_output(X, shape) may resize the vector because this adaptor explicitly owns that policyConcept pressure:
std::vector<T> should not automatically model the tensor conceptNeeded behavior:
S is a non-owning fixed outputprepare_output(S, shape) validates onlytensor_write_view(S) exposes a resolved mdspan-like viewConcept pressure:
TensorRef remains useful as a named non-owning tensor lvalue typeNeeded behavior:
Mpi) and recurse into local dense selectorsConcept pressure:
Needed behavior:
A is only an mdspan-like view plus explicit backend/domain informationConcept pressure:
TensorBackendSelectable should stay separate from a possible TensorStorageDomain / TensorTemporaryFactory conceptThe dense checkpoint distinguishes policy-bearing tensor views, normalized mdspecs, and resolved mdspan leaf views:
ResizableTensorOutput detects reset_shape(extents). prepare_output uses it for overwrite operations; it is not part of fixed-output GEMM, where allocation policy has already been decided by the caller.
The top-level dispatch API uses concepts plus ordinary members rather than base-class conversion. External customization remains future work.
The customization layer can be implemented as free CPOs that call member functions when present. Uni20's own tensor and adaptor classes can therefore use ordinary members for locality and documentation, while external types can still opt in through wrappers or ADL/customization.
The implemented select_backend(operation, operands...) requires a common backend-bound storage policy and resolves its selector in layers. A user backend_selector_override<Operation, StoragePolicy> specialization may define select(operation) to replace the backend list completely. Otherwise a Uni20-owned backend_selector_default<Operation, StoragePolicy> specialization may define select(operation, storage_selector) to compose an operation-specific list around the storage policy's general static selector. Without either customization, selection returns that storage selector directly. Operand values are present only for type deduction and front-end ergonomics. Explicit-selector operation overloads bypass this default-selection step and remain the route for caller-specific immutable context.
The user override is deliberately distinct from the library default and does not fall back to it. A user can therefore remove a backend with a suspected kernel defect for one operation/storage combination without changing the storage policy or Uni20 source.
The default path rejects mixed storage policies at compile time. In particular, a host tensor and a CUDA tensor do not acquire an implicit transfer merely because some backend appears in both selector lists. The caller must request an explicit transfer or use an operation-specific higher-level path whose global policy explicitly defines the mixed-domain semantics. Mixed-policy global selection is intentionally left open rather than inferred by the current single-policy override.
For example:
The important point is that top-level dispatch is written against the concept, not against inheritance from ImmediateTensorView.
Possible CPO shape:
Whether an output is fixed or resizable may be a separate semantic trait, for example tensor_can_resize_v<T>, rather than a separate concept. Most front-end kernels can simply call prepare_output; APIs that specifically forbid reallocation can constrain on the fixed-output trait or pass a fixed view/ref.
Then a value-producing operation can be structured as:
The selected backend acquires the execution-domain views:
This keeps MdspanLike at the leaf-kernel level, MdspecLike at the operation-tag backend boundary, and backend selection at the tensor/storage level.
For local dense tensors, ImmediateTensorView is the refinement whose normalized representation is already an mdspan. Tensor itself deliberately does not satisfy MdspanLike; the result of mdspan() does.
Kernel front ends need to create temporaries in all modes: synchronous dense tensors, async tensors, block tensors, and direct mdspan entry points. This is not special assignment semantics; it is allocation plus a copy/evaluation kernel.
The current host implementation provides the first explicit form:
The bare-mdspan overload requires an explicit selector because a mdspan does not provide storage policy. The more general temporary type should eventually be selected from the backend selector/list value and its storage domain, not from the C++ name of the operand:
For Uni20-owned tensors, the domain comes from the storage policy:
For non-owning views/adaptors, the domain must be carried explicitly or be recoverable from the adaptor:
The resulting concrete temporary type can be internal and unnamed by user code. It might be Tensor<T, Rank, HostStorage> for host domains, a future Tensor<T, Rank, CudaStorage> for CUDA domains, or an async/block scratch handle when the caller is already in an async/block path. The front-end algorithm should only rely on the returned object satisfying the tensor output/read-write concepts.
Backend selector state and storage domain are related but not identical. A backend list says which kernels may run. A storage domain says where bytes live and how to allocate more of them. In simple cases [Blas, CpuGeneric] implies host memory and [Cublas, DeviceGeneric] implies CUDA device memory, but relying on that equivalence loses information:
double* mdspan plus a stateless CublasBackend{} does not identify the CUDA device unless the pointer/accessor type, adaptor, or backend selector state carries itTherefore direct mdspan calls that need temporaries should either use an adapter carrying a storage domain or pass an explicit domain/factory:
The copy into or out of such a temporary is itself a backend-dispatched kernel. That lets staging be explicit in the dispatch model: a backend may decline because operands are in incompatible domains, a staging backend may allocate a temporary in the target domain and call copy kernels, and then the ordinary compute backend can run on the staged views.
Temporary allocation should normally happen after a backend has accepted the operation, or inside a staging backend that has accepted responsibility for the operation. That keeps the fallback contract clean: a backend that has not yet committed should not allocate scratch for a path it may later decline. Runtime feasibility checks should happen before allocation where possible; once a backend allocates and starts copies or writes, it is committed and later failure is an error rather than a fallback.
Backend selection belongs to tensor/storage operands:
[Blas, CpuGeneric].[Cublas, DeviceGeneric].[Mpi], which recurses into local dense backends per block or coalesced group.For dense local tensors, TensorView requires a backend selector and a readable mdspec result; MutableTensorView additionally requires an eventually writable result. ImmediateTensorView is the subset whose result is already an mdspan. The tensor object is not MdspecLike. Memory kind and runtime placement are properties of the normalized descriptor and its accessor, not of the default selector.
Storage and backend selection should remain separate concepts. A storage policy owns or describes how bytes are stored, and one of its traits is the default backend selector. A non-owning adaptor may have no storage object at all, but it still needs a backend selector or storage-domain descriptor. For example:
For CUDA views, the selector/domain must distinguish device memory from host memory and carry or recover the device ordinal. The view cannot rely on owning storage to provide that information.
The selector can be overridden with either an ordered backend-list value or a singleton backend value:
A singleton backend is normalized to a one-entry backend list value. It does not get an implicit fallback.
Plain mdspan entry points should require an explicit backend selector:
or an explicit backend helper:
The reason is that mdspans do not carry Uni20 storage policy.
The term backend_list<...> should be read as a shorthand for the static ordering of backend entries. The actual selector passed through dispatch should be a value. Default backend entries should normally be stateless tags. An explicit override may carry operation context:
For a selector such as:
the list value supplies both ordering and the per-backend runtime state. A separate composed selector-state tuple was an earlier design option, inherited from the cytnx dispatch experiment. It may still be useful internally for a future selector implementation, but the leaf-kernel customization points should not require a separate State& parameter.
For distributed block tensors, communicator and operation policy may belong in an explicit backend value, while rank-local ownership and section maps belong to the container's placement metadata.
Inheritance is still possible, but it should only help declare or share type-level requirements. It should not define the backend order. Simple inheritance such as CuSolverBackend : CudaBackend is too strong: it says a cuSOLVER backend is the CUDA generic backend, and it risks coupling capability inheritance to fallback ordering. The ordered backend list should remain the only thing that says "try cuTensorNetwork, then cuBLAS, then generic CUDA."
An empty base can still be a spelling for shared type-level capabilities:
A CRTP family base is another possible spelling:
That can factor common type-level traits or helper APIs across related backends, but it should still be used only to express requirements/capabilities. It should not imply that CublasBackend inherits from CudaGenericBackend or that CudaGenericBackend is the next fallback.
A construction convenience may still be useful for the initial implementation. For example, a user could provide a richer backend configuration object, and the selector can build ordered backend values from it:
Then:
can produce a selector equivalent to:
An implementation may still store shared state once inside the selector and hand each CPO a lightweight backend view. That is an optimization or implementation detail. The leaf CPO shape remains kernel_accepts_types(backend, op, args...) as a consteval tri-state function over ordinary reference parameters, and try_kernel(backend, op, args...) as the runtime attempt.
Advisory state may vary by backend entry and may be ignored by a backend that does not understand it.
Operand placement does not vary across fallback entries because it comes from the operand accessors and data handles. Operation context may vary by candidate. A staging backend may deliberately change domains by allocating temporaries and copying. A fallback from CUDA to CPU is therefore not just "next backend in the list"; it is either a declared staging path or an error if the operands cannot be read by the CPU backend.
The Tensor implementation owns storage, mapping, and accessor-factory state directly, and constructs resolved mdspans on demand. BasicTensor aliases a specialization with an explicit extents type:
This keeps owner copy/move semantics independent of mdspan descriptor rebinding. Tensor satisfies the tensor-level concepts but not MdspanLike; its returned mdspans satisfy the leaf concepts. A future slice or external-storage adaptor must carry storage/execution policy and explicit lifetime semantics.
A concept/CPO design would let non-Uni20 types opt in:
could be treated as a rank-1 host tensor if we provide:
For higher-rank external buffers, an explicit adapter is probably better:
The adapter can carry the backend selector that a plain mdspan lacks.
There is no need for raw std::vector<T> to automatically satisfy the tensor concept globally. That would make generic tensor overloads surprisingly accept ordinary vectors. A safer path is explicit adaptation:
The adaptor object may have member functions (view(), backend_selector(), descriptor()), and the dispatch CPOs can forward to those members. That gives the pluggability benefits without making every standard container implicitly a Uni20 tensor.
The adaptor's concrete type can encode semantics specific to the wrapped object, and user code normally does not need to name that type. For example, as_tensor(std::vector<T>&) can expose the existing vector storage as a rank-1 host tensor and also allow prepare_output to resize the vector. A slice adaptor or block-view adaptor would instead validate shape and write through fixed storage. Both can satisfy the same TensorOutput concept because the behavior is owned by the adaptor's prepare_output and assignment operations.
The async/block design keeps descriptor and data separate:
For a block tensor, the planner reads block descriptors synchronously. Each block handle then produces either an immediate mdspan-like resolved view or an awaitable read/write handle that resolves to such a view.
For slices/views of async or owning tensors, TensorRef may need to carry a lifetime or epoch token in addition to the shallow view descriptor. It should still expose a plain resolved view to leaf kernels.
The proposed roles align with Python-style behavior:
In C++, mdspan-like descriptor assignment corresponds to rebinding the view object. TensorRef::operator= corresponds to slice/adaptor assignment. Tensor::operator= corresponds to replacing the owning tensor value.
Python bindings may still choose to expose NumPy/nanobind arrays at the boundary, with Uni20 tensor/adaptor objects used internally for dispatch. Such adaptors must retain their Python owner and storage/execution policy rather than being raw mdspan descriptors.
TensorView and MutableTensorView concepts?TensorRef need for slices of async or shared owning tensors?std::vector, stdex::mdspan, nanobind arrays), and which should require explicit adapters?TensorOutput concept plus a trait controlling prepare_output behavior?prepare_output? as_tensor(std::vector<T>&) is a plausible resizable adaptor, while slices and block views should remain fixed/write-through.tensor_storage_domain(x), make_temporary_tensor(selector, domain, descriptor), and make_temporary_like(x, descriptor).CudaDomain{device} or a device-aware accessor type.MdspecLike fixed operands. Bare-mdspan convenience overloads use an explicit selector and dispatch those mdspans as immediate mdspecs.TensorView and MutableTensorView are tensor-level concepts. Their objects deliberately do not model MdspecLike; mdspec_of() produces the normalized representation. ImmediateTensorView is the already-resolved refinement.Tensor, mdspan-like resolved views, and TensorRef should have distinct assignment semantics: value/replace, rebind descriptor, and write-through. Async storage defers to those underlying semantics rather than classifying them with another trait.as_tensor(std::vector<T>&) may be resizable even though slice and block-view adaptors are fixed/write-through.try_kernel and kernel_accepts_types.Tensor uses composition; future non-owning adaptors must define lifetime and assignment semantics explicitly rather than relying on inheritance.