Asynchronous Programming in Mojo

The Mojo team is resuming work on asynchronous programming in Mojo. The proposal can be found modular/Mojo/proposals/async-design.md at main · modular/modular · GitHub . Please comment on the proposal in this forum.

Over the next few weeks you can expect to see the experimental example published as well as compiler changes necessary to support the example. The goal is to (1) enhance the example as more compiler support is added and (2) migrate the example from an experimental directory to a stable test directory as we add components to the standard library.

I think that among the stated goals, the following are important:

  • Low overhead, so async is usable in performance-critical code, and ideally on constrained targets.
  • Async without malloc. Async should work on targets with no heap allocator.

As for open-ended questions:

  • Type erasure. Storing different futures in one collection needs some form of existential or boxing, like Box<dyn Future> in Rust. How much does this depend on existentials landing in Mojo?

The fastest type-erased state machine is one where state is stored as a function pointer, and state transition reduces to a single level of indirection - Benchmark of Finite State Machine approaches This was also confirmed by my own benchmarks. It might be possible to make the implementation more efficient here than in Rust. It would be great if dyn Coroutine didn’t add another level of indirection.

Administrative Things

First, thanks Steffi for tackling async. It’s a big task with a lot sitting on top of it.

Since this is so important, I’ll ask everyone to try to keep this thread on track since Steffi is going to have to read the collective output of everyone with opinions on this.

Secondly, I’ll ask everyone to please respect that the compiler team is going to need substantial reasons to deviate from the points in the Decisions we have committed to section. The lowest levels of this interface are unlikely to be pretty, since they will have to handle every possible use-case for Mojo. There is interest in models of asynchronous computation which are strictly better than the proposed design, but I mean strictly in an academic sense. This means that if the model currently proposed can do express all of the same concepts, even if it requires 20 linear zero-sized types, a horrible abuse of origins, then you need to show that either your new model is better in at least one of the following categories and equivalent or extremely close to it in others; Runtime performance, compilation cost, ease of use for a particular use-case (performance may not suffer for this unless it’s otherwise impossible), memory usage, composability, and portability. I’ll ask that discussions of other designs which receive push-back under those criteria move to other threads where a resolution can be hashed out and, if the result is positive, then the model can be returned back to the main thread.

Open Questions

Async on the GPU. Should async def be usable inside GPU kernels, or only on the host to coordinate device work?

I think it absolutely should. async def and friends all form an abstraction over fairly primitive programming constructs (while and switch for one common mapping) which currently work on almost all of the MAX’s targets. Over time we may discover some targets are too limited to allow for many forms of async constructs, similar to those targets which do not allow for dynamic control flow the language feature will not be available there.

Type erasure. Storing different futures in one collection needs some form of existential or boxing, like Box<dyn Future> in Rust. How much does this depend on existentials landing in Mojo?

I think that, if we phrase existentials as the “make me a vtable please” feature, then I’d say we’d need some form of existentials for true dynamism. However, in many cases there is a compile-time known set of top-level coroutines such that a tagged union may be sufficient to de-virtualize everything. I think this means we can land “MVP” async without existentials, although its usage will be a bit un-ergonomic until existentials arrive, potentially depending on large hand-written TypeList values.

One other thing I’d like to poke at is the idea of something like a inline dyn Coroutine + size_bytes_leq[64]. Effectively, have the vtable be inline instead of behind another layer of indirection (yes, more binary size and memory consumption which isn’t great but it should be fine), and potentially the coroutine body as well, since this provides the guarantee that the actual data which matters is no larger than some amount, meaning that there is a safe way to have inline data without knowing the type. The “struct size” trait should be pretty cheap for the compiler to compute, given that it would be the amount of space needed for the vtable (potentially just a pointer) and space for the data, which is just size_of.

Function coloring. Given stackless coroutines, what can we do to reduce the cost of coloring? For example, a blocking wait() bridge, or APIs that are generic over sync and async.

My vote, although it might be a bit un-ergonomic in some cases, would be the following:

First, create def, sync def and async def. def means “I don’t care”, meaning that the function trait def() -> None is the union of async def() -> None and sync def -> None. Most functions in both user and library code are expected to be def functions, since most code doesn’t need to do fancy things with async and doesn’t really care about whether or not it makes a blocking syscall. sync def is for functions which are explicitly not async, and you may not call them in an async context. Similarly, async def is explicitly async and must be called in an async context.

From there, we have two options. In a def, either any function call which returns an awaitable has it automatically awaited (+ergonomics, -explicitness, potentially +confusion), or def must be written as “async correct” code where function calls to def or async def functions occur on await (potentially a lot of compiler transformation here). This second method is less ergonomic but is likely to cause less surprises.

Finally, at the lowest levels of code, you’ll run into why async def and sync def exist. These are for functions which are fundamentally one or the other, either due to making blocking syscalls themselves, or due to doing things like awaiting multiple things at once to increase productive CPU time (see Rust’s FuturesUnordered). Right before them, you have a bridge, of some form like the following:

def simple_print(s: String):
  comptime if async:
      await async_print(s)
  else:
      sync_print(s)

This async can be implemented as an “invisible parameter” passed down the call stack. It starts out sync, and then async executors can provide a primitive to “set” the “flag”. Additionally, awaiting a def function would make it definitely async since that means you’re in an async context. This means that, while we do technically end up with whole-program monomorphization to support this, it should be relatively cheap to support since it’s a boolean flag and I expect most programs mostly be written in one “dialect” (sync or async). This enables libraries to mostly be “synchness”-agnostic with a low-level IO layer handling the rest. This has the additional benefit of allowing the standard library to make std.atomic.Mutex or wherever we put it to behave “correctly” given the context, getting rid of a problem that Rust has where executors need to provide unique async synchronization primitives even for simple operations (although I expect executors will need to provide hooks in order to make this performant).

Use-cases

DPUs

DPUs, although they are nominally a fancy NIC, typically come with normal CPU cores running Linux. They also exist pretty much entirely for doing IO. As such, in my opinion they represent an easy target for “MAX should probably let me shove async code here so I can do data loading.”

Accelerator Driven IO

NVMe is a fundamentally asynchronous protocol, and given that most disks are moving towards using NVMe (we even have NVMe hard drives), it’s unlikely to go anywhere for a bit. Even if it does, I don’t expect that we as an industry going to suddenly decide that sync IO is a good idea again.

Given that production-grade KV caching now involves tiering to local and network storage, giving the Accelerator the ability to express the async protocol that is NVMe directly is very useful, because while we do have a lot of warps, there’s no reason to stall an entire warp per block requested from disk. If we’re already going to do async IO, then I think we should probably plumb async/await up to it.

Networking is also fundamentally asynchronous on pretty much every network in common use today. Having interactions with your scale-out network be able to be represented the same way on both the host and accelerator is quite nice.

DMA Devices

Another place this is useful is in expressing async transfers inside of accelerators. Once off of CPUs, it becomes fairly common to have some of “data mover” or DMA device you can ask to move things around for you, and on some platforms it’s mandatory if you want to access the accelerator’s main memory, such as AMD XDNA and Tenstorrent Tensix. In the latter case, where you may only have a few KiB of local memory, I don’t think anything other than precise control over the memory locations of coroutines is going to work.

General use in embedded devices

Async is a natural abstraction for cooperative multi-tasking, which is great for embedded devices which may not have the processing power to run a full threading abstraction or which may not have a large number of spare cores to support many threads.

No-allocator on larger servers

In networking, as traffic volume goes up, tighter and tighter coordination with the NIC is desirable for performance reasons. As a result, once one starts to push the server hard enough, manual control over memory becomes desirable. No-allocator generally means you need to provide the memory for everything, which, while typically a feature for embedded, is also very nice when working on a large server where you want to have a few hundred million coroutines floating around without getting smacked by time complexity.

Hopefully this isn’t too premature, as I’m not qualified to speak on some of these lower-level design questions. But I’m wondering if structured concurrency is planned for the stdlib implementation? I’d guess that might present challenges for Python interop. (The tricky thing is that structured code doesn’t mix well with non-structured code, as you lose structured concurrency guarantees when it’s possible to spawn standalone coroutines outside of a task group. So it’s best if it can be enforced. anyio has been really successful at bringing structured concurrency to Python, but many popular libraries still use asyncio.create_task all over the place.) I know Swift takes a structured approach, is the plan to replicate that here?

Well I would expect there to be a Thread.spawn relatively soon, since async would also use that with a multi threaded executor, although I suppose a single threaded executor would be the default.

I have a few question:

  1. Will the mojo LLDB fork support async and generator, so you can track variables like in sync code?
  2. How about multithreaded Vs single threaded executors? And how is the default executor going to look like?
  3. async def is going to sugar for block_on right?
block_on(_run)
  1. And multithreaded would look something like this?
block_on(_run, multi_threaded=True)
  1. How will the executor handle blocking code? E.g.
async def run():
    thread.sleep(secs=1)

Or

for x, y in very_large_list():
    compute(x, y)
  1. Will it be possible to detect such cases, e.g. with debugging tool like Tokio has?

Thanks for the feedback Owen! I will respond to each section independently, starting with Async on GPU. Firstly, the case where a host coroutine that awaits device works (though is unstable) today. We plan to add an example in the experimental directory to stress that path next week.

The more interesting case is executing the coroutine on the device and suspending inside a kernel, which is further out. It needs a pluggable frame allocator (today the frame is a host malloc), an address space on the frame pointer so the frame can live in shared or local memory, a representation for the resume handle other than a host function pointer, and an answer for what suspension means when lanes in a warp diverge.

We do not have a date for this support but it is shaping our design decisions. Ongoing frame construction refactoring is making frame analysis and layout pluggable so a device specific version can be slotted in rather than forking the pass.

One other very interesting option is that AMD documents their command processor, so you could potentially run an async executor there since it has a natural “spawn subtask” primitive. Hardware acceleration of these is potentially something to look at. Intel DLB is also something to consider, although it looks like it might be telco skus only for next gen Intel.

Everything else sounds good!

Being able to execute coroutines on device would be extremely interesting for latency-limited HPC applications, where this could enable overlapping computation with NVSHMEM-like device-initiated network communication without the code turning into an incomprehensible mess.

I ran across this paper from about a week ago and thought I’d share in case the Modular team wasn’t already aware of it. Looks like a few researchers did a kind of taxonomy of async/await systems in different languages. Could be a useful reference as you’re trying to design your own system.

Hi Steffi, thanks for opening this proposal. I just read the proposal again, and while I agree with the goals and the technical design of coroutines, it occurs to me that it never really goes into any detail regarding how the async/coroutine model composes with the rest of Mojo.

For example, it doesn’t discuss data flow between coroutine chains, nor does it describe how control is handed back to the Task abstraction. In Python, this is done by having yield somewhere in the coroutine chain, but since Mojo doesn’t have generators, I’m interested in hearing whether control models have been discussed.

Generally, I’m a Python enjoyer and think Mojo should use the same async control flow model since I believe it is really well thought out and dovetails cleanly with other Python features (which Mojo has or is going to have) - iterators, generators, context managers. However, it is not clear to me if this works in a compiled model, so I would love to hear your thoughts.

Hi Owen, continuing with my reply to your sections:

RE: Type erasure

“I think this means we can land “MVP” async without existentials, although its usage will be a bit un-ergonomic until existentials arrive, potentially depending on large hand-written TypeList values.”

Expressing the result value types over large TypeList values should not be necessary because existentials should land before async. That being said, they are developed separately and getting async to MVP will not depend on the completion of existentials.

RE: Function coloring

That is an interesting direction but I think that may deserve an independent proposal after the underlying compiler work has stablized. Colored functions today is a means to bridging the source to the compiler transformation so we can write integration tests and start to develop the runtime alongside the compiler work.

Why this decision can wait until compiler work stabilization:
To support both async and sync versions of the same function, every potentially async functions has potentially two symbols, we need a means to express the context of a call, and not only the symbol but the calling convention of the call is now determined by the context. I think all of this could be expressed using parametric expressions which means the IR that enters the coroutine lowering pass would look identical to what it looks like today since coroutine lowering happens after monomorphization.

Why this decision can wait until after library stabilization:
I think as long as we can produce a value that conforms to the Coroutine trait we have the object that can be managed by the runtime so as long as there is a context where we can assume async we can model the coroutine and hand this state to the runtime.

If Mojo already puts in the work to make every function parametric or generic over sync/async, it should be asked why the same should not apply for ref/var. It sounds logical to extend them too if the infra is already in place or planned.

I have been told, things are blocked on conditionally returning ref or var.

EDIT: Now regarding the feasibility of async parameterization.

If you look at Rust executors, which don’t abuse thread locals and globals heavily, then you can see that they have a Task and Executor abstraction not hidden from the programmer.
So, would the function signatures of sync and async have to match? If so, this would lead to globals and thread locals.

EDIT 2:
I think mojo should also pursue an Io abstraction like Zig. If it’s not for async, it should be for things like threadpools and callbacks. Zig is also pursueing stackless, thus it will be interesting to see if they can fit it within their Io abstraction.

I also think, Mojo should prefer runtime Vtable abstractions over expensive compile time abstractions.

EDIT 3:
I think it is also worth to take a look at compio: compio - Rust

Thanks for the question. The proposal focuses on the primitives and doesn’t
spell out the control flow model, so here is a concrete example of how it
works today.

Each async def is compiled into a state machine. Suspending means recording
the next state, arranging for the coroutine’s resume function to be called
later, and returning control to whoever resumed it. When the awaited work
completes, the resume function is called and execution picks up at the
recorded state. Each coroutine has a configurable completion callback, so
library code decides what happens when it finishes: for example, calling the
awaiting coroutine’s resume function directly, or notifying an executor.

The example below desugars an async def that awaits a Task into roughly
what the compiler generates, written as Mojo. Here the child’s callback is
configured to notify the runtime rather than to call the parent’s resume
function directly. The state machine is synthesized by the compiler, but the
code that configures the callback is ordinary Mojo library code
(create_task and Task.__await__), shown inlined into the resume function
for illustration.

Imagine we have the following async closures:

def test_runtime_task() raises:
    print("== test_runtime_task")

    __async def test_asyncrt_add[lhs: Int](rhs: Int) -> Int:
        return lhs + rhs

    __async def test_asyncrt_add_one_of_them(a: Int) -> Int:
        return __await create_task(test_asyncrt_add[1](a))

    var task = create_task(test_asyncrt_add_one_of_them(10))
    print(task.wait())

Let’s look at the transformation of
__async def test_asyncrt_add_one_of_them(a: Int) -> Int in today’s
implementation of coroutines.

Every async function is lowered into two functions: a ramp and a resume. The
ramp function creates the coroutine and the resume function contains the
coroutine state machine. Let’s start with the ramp function:

def test_asyncrt_add_one_of_them_ramp(a: Int) -> CoroWrapper[Int, NoOrigins]:
    # Allocation is not yet pluggable.
    var self = aligned_alloc[TestAsyncrtAddOneOfThemFrame]()
    self[].header.state = 0
    self[].header.resume_fn = test_asyncrt_add_one_of_them_resume
    self[].a = a
    return CoroWrapper[Int, NoOrigins](self.unsafe_bitcast[NoneType]())

TestAsyncrtAddOneOfThemFrame is a struct that contains a coroutine header
and frame state. All coroutine structs share a coroutine header. A coroutine
header has the following layout:

struct SynthCoroHeader:
    # Which section of the resume function to begin execution at.
    var state: Int32
    # Pointer to the compiler-generated resume function.
    var resume_fn: ResumeFn
    # Invoked upon completion of the coroutine.
    var callback_fn: CallbackFn
    # Input to `callback_fn`.
    var closure_state: OpaquePtr
    # Pointer to the location to write a raised error.
    var error_slot: OpaquePtr
    # Pointer to the location to write the by-reference result.
    var result_slot: OpaquePtr

    def complete(self):
        """Lowered as `musttail call callback_fn(closure_state)` at every exit
        of the resume function."""
        self.callback_fn(self.closure_state)

Note that the header does not contain the frame variables, since frame
variables are specific to the async function. Instead, those variables are
stored as a tail:

struct TestAsyncrtAddOneOfThemFrame:
    var header: SynthCoroHeader
    # Frame variables: the child task is stored in the frame so the parent can
    # recover its result and then deallocate it.
    var task_ctx: Ptr[_AsyncContext]
    var task_result: Int
    var task_chain: Ptr[_Chain]
    var task_handle: OpaquePtr

    # Frame variable: arguments are stored in the frame for cold-started
    # coroutines.
    var a: Int

CoroWrapper is a struct in the standard library that stores the origins of
the coroutine’s arguments. Coroutines are cold-started, so the arguments are
bound to the wrapper to ensure they are not destroyed before the coroutine
completes. CoroWrapper looks something like this:

struct CoroWrapper[type: AnyType, origins: OriginSet](
    not Deinitable, RegisterPassable
):
    var handle: OpaquePtr

    def __init__(out self, handle: OpaquePtr):
        self.handle = handle

    def take_handle(deinit self) -> OpaquePtr:
        return self.handle

Next, let’s examine the resume function, which contains the body of the async
function. It has a single argument: a handle to the coroutine frame, which
contains the header and the frame variables:

def test_asyncrt_add_one_of_them_resume(handle: OpaquePtr):
    var self = handle.unsafe_bitcast[TestAsyncrtAddOneOfThemFrame]()
    var state = self[].header.state

    if state == 0:
        # `create_task(test_asyncrt_add[1](a))`
        var child = test_asyncrt_add_ramp[1](self[].a).take_handle()

        # Store the child's handle in the frame so state 1 can free it.
        self[].task_handle = child
        # View the child's `callback_fn` and `closure_state` header fields
        # as an `_AsyncContext`: a callback and the state passed to it.
        var ctx = async_ctx(child)
        var callback_state = Pointer(to=ctx[].chain)
        self[].task_chain = callback_state

        # Heap-allocate a completion token (a refcounted AsyncRT value with a
        # ready flag and a waiter list) and write a reference to it into the
        # child's callback state.
        _init_asyncrt_chain(callback_state)
        # Set the child's callback to `_AsyncContext.complete`, which receives
        # the callback state and marks the completion token ready.
        ctx[].callback = _AsyncContext.complete

        header_of(child)[].result_slot = Pointer(
            to=self[].task_result
        ).unsafe_bitcast[NoneType]()

        # `__await`: record the next state and register this coroutine's
        # resume function as a waiter on the child's completion token.
        self[].task_ctx = ctx
        self[].header.state = 1
        and_then(coro_resume_fn, Pointer(to=self[].task_ctx[].chain), handle)

        # Schedule the child's resume function on the runtime's work queue.
        execute(coro_resume_fn, child)
        return

    else:
        # The child is done: read its result, release the completion token,
        # and free the child's frame.
        var result = self[].task_result
        _del_asyncrt_chain(self[].task_chain)
        aligned_free(self[].task_handle)
        # This coroutine is done: write its result and invoke its callback.
        self[].header.result_slot.unsafe_bitcast[Int]()[] = result
        self[].header.complete()

The first state illustrates how the coroutine is integrated with the runtime.
The parent owns the child’s handle and points the child’s result slot at its
own frame. It sets the child’s callback to _AsyncContext.complete, which
marks the child’s completion token as ready. At the __await, the parent
registers itself on that token with and_then and then schedules the child,
so once the child finishes, the runtime calls
test_asyncrt_add_one_of_them_resume through the parent’s resume_fn field.

All of this wiring reads and writes header fields, which Mojo code reaches
through embedded co ops: co.resume loads the resume function pointer,
co.get_callback_ptr returns a pointer to the callback fields, and
co.set_byref_error_result sets the result and error slots.

Both structured and unstructured concurrency can be built on the compiler-generated coroutines. A structured scope can be implemented with a linear type that must be joined before it goes out of scope, so the tasks spawned in it may safely borrow data that outlives the scope.

Stackless coroutines with a device-side executor makes this plausible but there are still a lot of open design questions we’re not quite ready to address, though we’re designing the abstractions with this in mind so more detail to come soon.

I understand that, my question was more what kind of concurrency Mojo plans to provide in the stdlib?

Also, I believe certain structured concurrency features, like exception group unpacking and certain async generator behaviors, do require language-level support.

Probably both but unstructured first. The focus for this first stage of development is stabilizing the language features necessary to support both: error propagation (already in place), linear types (already in place), and cancellation (planned).

There are a lot of things which require language support in other languages which Mojo can do in library code. For instance, wrapping things in Result makes the problem of exception group unpacking more or less go away provided you merge the error types (easy to do with Variant).

Can you be more specific about what async generator behaviors you are referring to?