|
Uni20
Asynchronous tensor and runtime library.
|
This is a short primer for contributors who have not used C++20/23 coroutines before. It explains just enough to be productive in src/uni20/async/ and tests/async/.
A coroutine is a function that can suspend and later resume. In C++ that happens when the function body uses co_await, co_yield, or co_return.
For Uni20 async code you will mostly see:
co_await: suspend until some async precondition is satisfiedco_return: finish the coroutineWhen a coroutine suspends, its state (locals, parameters, current line) lives in a coroutine frame allocated by the compiler/runtime. It does not live on the caller's stack.
In Uni20, an AsyncTask is the move-only owner of a coroutine handle.
Key consequences:
AsyncTask owns the initially suspended coroutine and the scheduler has no ownership or registration for it. A scheduler pointer in the promise is only the route for a later activation.AsyncTask and transfers ownership to one runnable scheduler activation.co_await an awaitable in this system, Uni20 passes ownership of the current task to that awaitable. The awaitable either reschedules the task later, or transfers control to a nested task.This is why Uni20 awaiters take the promise-neutral internal BasicTask ownership token rather than directly retaining raw std::coroutine_handle<> values. The debug task registry records state and dependency metadata but does not participate in frame ownership.
An awaiter that accepts BasicTask or TaskFactory in await_suspend(...) must declare that operation noexcept. The argument transfers ownership of the already-suspended coroutine into the awaiter. If registration threw after moving that ownership, the runtime could not generally recover every ownership claim or undo externally visible waiter registration.
This restriction applies only after ownership transfer begins. A composite awaiter may still perform fallible preparation before acquiring a TaskFactory. Failures discovered when an operation later resumes belong in await_resume(), where normal coroutine exception propagation remains valid.
An AsyncTask may directly co_await another AsyncTask. An unbound inner coroutine inherits the outer coroutine's scheduler. An inner coroutine already bound to the same scheduler runs through symmetric transfer; one bound to a different scheduler is submitted there and returns the suspended outer continuation to its original scheduler when it finishes. This shared-runtime routing is implemented behavior, not a future CUDA facility.
AsyncTask and CudaTask are distinct initial-admission types with distinct promise types. Their promises derive from the common TaskPromiseBase, and all suspended tasks use the same promise-neutral TaskHandle identity and BasicTask ownership machinery. A CPU scheduler therefore cannot initially admit a CudaTask, while buffer awaiters, rescheduling, and continuations remain shared. Promise type identifies the task kind declared by the coroutine; the scheduler recorded in the common promise routes each later activation.
An AsyncTask can await a CudaTask already bound to a CUDA scheduler. The CUDA child runs there and the parent returns to its own scheduler after the child finishes. A live coroutine does not change concrete task type; explicit scheduler migration changes only the non-owning route of an exclusively owned, suspended task. The internal mechanism is implemented; a user-facing migration awaiter remains future work.
See Scheduler Routing and Task Domains for the task admission and continuation contracts.
Buffers are the bridge between data dependencies and coroutine suspension:
ReadBuffer<T> is an awaitable gate for reading the value of an Async<T> at a particular epoch.WriteBuffer<T> is an awaitable gate for exclusive read/write access at a particular epoch.When you write:
the coroutine suspends until the epoch becomes readable, then resumes and returns the value.
Similarly:
suspends until the epoch becomes writable, then writes through the proxy returned by co_await writer.
Coroutine lambdas that return AsyncTask must be:
static (C++23 static lambda call operator)Correct pattern:
Why this exists:
Passing everything as parameters ensures the data lives in the coroutine frame and has the right lifetime.
If you are debugging or trying to understand exact behavior, these files are the usual starting points:
src/uni20/async/async.hpp: Async<T> container semanticssrc/uni20/async/buffers.hpp: ReadBuffer / WriteBuffer awaiterssrc/uni20/async/epoch_context.hpp: epoch phases and reader/writer schedulingsrc/uni20/async/async_task_promise.hpp: promise behavior (exceptions, continuations, ownership)src/uni20/async/debug_scheduler.hpp, src/uni20/async/tbb_scheduler.hpp: scheduler behaviorTests under tests/async/ encode current behavior. If they disagree with the canonical async contract, treat that as a defect or unresolved semantic conflict rather than assuming the test is automatically authoritative.