-
Notifications
You must be signed in to change notification settings - Fork 2.6k
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[proposal] Abstracting over async
(the yields
effect)
#3986
base: main
Are you sure you want to change the base?
Conversation
I propose that we replace `async` with a `yields T` syntax, that allows us to abstract over whether a function/trait/library contains suspension points. Signed-off-by: Nick Smith <[email protected]>
bb5ab05
to
7145a01
Compare
@nmsmith I love this. Making it a function effect and being able to parametrize it would be awesome.
I'm personally not that fond of raising iterators or this conditional yield logic. I also don't think inserting comptime logic inside type signatures is a great idea. I think I'd rather see something along the lines of: trait Iterator[T: AnyType, generator: Awaitable]:
fn __next__(mut self) yields generator -> T: ...
# is the same as
fn __next__(mut self) -> generator[T]: ...
fn __has_next__(self) -> Bool: ... We could try to make coroutine have a "false async mode". And then we try to add some kind of compiler decorator which sets the path to materialization @nonmaterializable
struct Coroutine[T: AnyType, sync: Bool, auto_deref: Bool = True]:
fn __init__(out self: Coroutine[_, sync=False], handle: AnyCoroutine): ...
fn __init__(out self: Coroutine[_, sync=True], handle: AnyFunction): ...
@materialization_entrypoint(auto_deref=auto_deref)
fn __await__(owned self) -> T: ... What this would allow is an "auto-deref" but for the await keyword fn read_file[sync: Bool]() -> _FileGenerator[sync]: ...
# or the alternative syntax
fn read_file[sync: Bool]() yields _FileGenerator[sync] -> List[Byte]: ...
fn foo(path: String):
var data: List[Byte]
data = read_file[sync=False](path)
data = read_file[sync=True](path) IMO we should try to achieve something in this direction (even if the details of what I'm proposing here don't add up), where we don't end up having arbitrarily complex code executing inside type signatures. As for @owenhilyard 's #3946 and also trying the same solution for |
Thanks for the feedback @martinvuyk.
Acknowledged. In my proposal, I just replicated how iterators work in Python. It was not my intention to propose that Mojo iterators raise exceptions. This is a discussion worth having, but it's outside the scope of this proposal.
Mojo's metaprogramming model has always offered the ability to execute arbitrarily complex code at compile-time. This is a feature, not a bug. Also, I've only made use of a simple ternary-
I'm not a fan of this idea. It sounds very complicated/magical. |
I think that this causes some awkward syntax, since we want the ability to have a coroutine who's component functions return I like the idea of using ternaries to control the values yielded, returned and raised, but I think I want to try to take it further if the compiler team is willing. Ideally, this shouldn't be special cased and there should be some way to get a token from the compiler which represents a type (which can later be used in reflection, but for now may as well be opaque), and make any expression which returns that mlir type legal in those positions, so you can have a function which figures out what to do instead of having massive nested ternaries. I don't know if there is a "sane default" for yields except for I strongly prefer "awaiting something without await does nothing", since I think it's easier to explain and makes the code more unified. The option of making awaits implicit introduces a lot of headaches for things like timeouts, where you want a future that will be done when time is up and another to do actual IO. As far as function chaining, I agree that prefix await is a bit messy, and would prefer postfix await.
But github, discord, discourse and other places with regex-based highlighting cannot do that.
The reason that Go and Java can do this is because they have a runtime which can carry a lot of weight for them. Go uses what I call "the paint bucket approach to function coloring", and makes everything async. The compiler is technically free to suspend at any function call, even if you are holding locks that should be released quickly. There are substantial costs which come during performance analysis, and they result in users who want to write fast code having to do deep audits of the codebase and learn arcane rules for when the compiler suspends functions. You will also note that
This is the coroutine frame.
I disagree here, I would have normal coroutines used in async return an (excuse my Rust syntax) |
In a way yes, but it will make function signatures get even more bloated (any function can be placed inside the ternary if else condition). Also AFAIK (I'm no compiler person) it's one thing to add code to check the default values for a function argument or parameter, and another for the type itself to be computed (AFAIK that is the purpose of Traits). If we were to allow inserting dynamic code in places where there are normally traits (or function effects), I think it's easier to read if we let a type have a function that returns the end result according to the parameters. e.g. adapting from #3756 @value
struct Generator_1or2[parameter1: Bool, parameter2: Int]:
@staticmethod
fn __type__() -> AnyType:
return Generator1 if Self.parameter1 else Generator2
@staticmethod
fn __trait__() -> AnyTrait:
return SomeIntParamTrait[Self.parameter2 if Self.parameter1 else 0] And each function gets added by code-gen (for types that have them) when on one such part of a type signature (parameter, type, or function effect space). A crazy idea: extend this to include
We already use materialization for types like The main goal of that workaround is to not have to make conditionals on when each effect gets applied and simply say that there is a "NoneEffect", so that the functions actually always return the same types parametrized for different cases. |
Thanks for the feedback Owen! I'll reply to all of your main points.
I don't see why You seem to have made the assumption that the type appearing after
Yes, it makes sense for Mojo to eventually have this kind of capability. That's outside the scope of this proposal though. 🙂
I don't understand the issue here. Setting a timeout for an I/O operation could be as concise as: ...which would raise an exception if the operation times out. Alternatively, if you want to do that trick where you "race" two tasks—one of which is a timeout—that might look like: The only difference from the conventional async/await model is that if you don't want to execute the coroutine immediately, you need to add
Yes, that's true. That's a legitimate downside of relegating suspension to a function effect. But given that the
All of this boils down to a disagreement on what a "Future" is. You're using the Rust definition, whereas I was using the C++ definition. In C++ a future is a pointer. If you use this mental model, then I think that section of my proposal makes more sense. Also, I don't think a |
I've gone back and forth with @owenhilyard about the merit of this functionality on Discord, I've been convinced it is massively useful - if we're successful in making the Mojo community adopt async genericity from the get-go. I, however, have issues with the syntax and semantics proposed here.
I have a suggestion/proposal/improvement based on this that I'd like to submit for evaluation. What if we introduce the concept of "partial async" or something similar, which describes a function signature that can be satisfied in either an async or non-async form? DetailsI suggest we add the ability to denote the asynchronicity of a function is optional - trait SomeTrait:
async? fn method_name() -> Int: ... This trait can be satisfied by async and non-async functions. Functions can also be defined to be partially async: async? fn partially_async() -> Int: ... The idea here is that functions which are explicitly declared to be partially async can be assumed to be safe to call in both sync and non-sync manner. However, functions that are explicitly fully async - Coroutines and TasksWhen a function is partially async, the compiler can decide whether it is a coroutine or not based on how it is called. Tasks depend on the execution environment, there needs to be a guarantee that they're being created with a coroutine. I propose we cast a partially async function into fully async based on a model similar to type narrowing. Given a partially async function, we can verify it is async: async? def do_something(partial_async_function):
@paremeter
if is_async(partial_async_function):
x = partial_async_function() # creates a coroutine
task = loop.create_task(x)
value = await task
else:
value = partial_async_function() see - https://mypy.readthedocs.io/en/stable/type_narrowing.html#user-defined-type-guards Summary
I'm hoping we can have the sort of uniform generality we have with refs now. |
I have a version of this in my effect handlers proposal, but you put it inside of the function.
|
Forgot to mention that the condition is inside a function, edited for clarity. |
Hi @melodyogonna, thanks for your suggestion. I'll share my feedback below. In the proposal document, I walk through a ton of different use cases for the My second concern is that Mojo is going to have |
I do find at least one aspect of your design intriguing, @melodyogonna. Your
Here's the idea:
This allows us to shorten the signature of As explained in the proposal, if This design also happens to resolve a syntactic issue with function types. For the sake of readability, ideally effect declarations would be comma-separated, e.g. |
+1 on this. Also (a bit outside the main topic), if we extend it for docstrings overloading it would be great fn timeit(f: any_fn ()) yields f.YieldType, raises f.ExceptionType:
__doc__.summary = "Time any function."
__doc__.args = "f: Any function."
__doc__.raises = f.__doc__.raises
__doc__.yields = f.__doc__.yields |
FWIW, Nim has effect system which seems to work pretty well. They use braces as syntax which we can also adopt if needed, and AFICS, is extensible to custom effects as and when needed. |
While reasoning about my proposal yesterday I noticed a soundness problem. Specifically - calling a partially async function in sync context. If we assume that an API that accepts a partial async function dynamically (e.g trait) can be passed both a synchronous and an asynchronous function, and that coroutines must remain awaitable, then if the partial async function is called it'll just return a coroutine that does nothing. After thinking through different scenarios, it seems your idea to remove the necessity of making coroutines awaitable is the simplest solution. However, the functionality of lazy coroutines is very helpful, it is something we want. So perhaps we can have that option but in fully async environment. To that end, perhaps instead of having only eager coroutines or only lazy coroutines, we can support both? |
Quoting myself:
if the auto_deref parameter is there, it would mean we could control when coroutines are auto-awaited and when not. This doesn't need to look exactly like what I proposed or even use materialization, but it would be necessary to sometimes switch no manual await |
@nmsmith Here are my thinking models.
async? def function() -> Int: ... would look something like: def function() yields -> Int: ... which could be further be assumed to mean: def function() -> Int | Coroutine[Int]: ...
fn read_file(path: String) yields if config['async'] -> String: becomes async? read_file(path: String) -> String: when a function is fully async: async read_file(path: String) -> String: is desugared to something akin to... I don't really know how to represent it with your model. But if we go with Owen's model async=async fn read[async: Bool = True](): ... if partial async function is called, the environment it is executing in should be propagated by the function that called it. Comparing this to current reference semantics.
|
This is great. |
@ivellapillil, I just looked into Nim's effect-tracking system, and I've noticed that the creator of Nim has described the system as a mistake and as having a bad cost-benefit tradeoff. So that's an immediate red flag. That said, the curly-brace syntax that Nim uses in its function signatures has inspired me to consider whether Mojo effects should be bundled into a single data structure, rather than listed separately. (Note: Nim doesn't do this. Nim's Abstracting over arbitrary effects (not just
|
If we introduce custom effects, we would need custom effect handlers.
I think generic propagation of effects is easy - but challenges come in when we want to handle them. Overall, I suspect that while generic effect handling would be super cool, it would be quite complex to pull off all kinds of effects (basically would need AST transformations, communication with the compiler etc.). At the very least it could slow down compilation. We might be better to treat raises and async/yield and one or two more effects hard-coded in the compiler. |
@ivellapillil I didn't mean to suggest that Mojo should have algebraic effects with custom handlers, etc. I was thinking a bit smaller: we could let programmers put custom data in the
Yeah, this is really the target I was aiming for: abstracting over a bunch of built-in effects. We could leave custom effects as something to investigate in the (distant) future. |
I think you're part of the way to realizing why I proposed the Mojo Parameter store. I wanted key/value pairs with arbitrary Mojo objects, so that
I like this idea, but I also want to be able to do
I think the special handling would actually be to keep the data structure strongly typed.
I think I like the "compile-time data structure" approach more. And, remember that for compile time reasons we want to minimize the amount of information that can "travel" in multiple directions. An iterator may need some input, in the form of whether it's async, whether it can block, etc.
I think making it clear it's a property of the function is a good thing.
Library defined effects are something I absolutely want, as a way to propagate "how do I handle this situation?" through the stack. We don't want all of that to live in the compiler, so ideally we do library defined effects and have the standard library define "general" effects for common situations.
I think turning the origin checker into an effect is something that would need to be discussed a lot more. Effects are opt-in to some degree, and that shouldn't be.
I think
Closures have to have a place to store captured information, which means they need to interact with allocations in a way most functions do not. I'm open to exploring the idea, but that could get messy.
This is why I prefer the more explicit, data-structure-based method. You can get something like
I would much rather not force the cognitive load of defensively programming for things which cannot happen. There are also contexts, such as in some
I think we should aim for a general solution, but do a slow rollout. That way, we can try to roll out a full effect system and then if that doesn't work due to compile time or other reasons, we can lock that capability off and make the compiler provide the few effects we are willing to support, instead of allowing library definitions. |
Hmm.. yes, maybe an effect
I agree we would need the ability to add effects to an existing dictionary, but I find the above syntax a bit noisy. Maybe we could have an operator for this. Python's
I think what you mean is that as long as the compiler knows what the value of My original comment was moreso asserting that the compiler will need to do "special work" to support exceptions and yielding etc, even if Mojo allows the effect dictionary to contain custom effects. Also, it's not clear to me how/whether custom effects would be useful in the absence of custom effect handlers. And even more importantly: I have no idea what benefits custom effects + custom handlers would bring to Mojo, and whether it is worth the complexity. Personally, I can only see a solid use case for the
I think you've misunderstood the When
That's true, but my rationale for proposing
I don't see the use cases for custom effects, so maybe you should put together a separate proposal at some point (built atop the design(s) we've been discussing), that makes a compelling case for them. In the meantime, I'm probably going to operate on the assumption that we only need
Yes, whether closure captures can/should be modelled as effects requires a lot more investigation, I agree.
I don't understand what your point is here. If you're invoking a function that has unknown effects, you have no choice but to operate on the assumption that the function may raise or yield. That's an inescapable consequence of writing effect-generic code.
Or conversely: we can choose a design that is capable of supporting additional effects, but we only put effort into supporting |
The proposal document can be found here.
I am proposing that we replace
async
with ayields T
syntax. This allows us to abstract over whether a function isasync
, and more generally, it allows us to abstract over whether traits and even entire libraries areasync
. This solves the function/trait/library duplication problem that arises in languages that have async/await.This can be seen as a counter-proposal to @owenhilyard's recent proposal on effect handlers. We are both attempting to solve the same problem, and my proposal is heavily inspired by Owen's. The main difference between our proposals is that I have taken the
raises Never
trick that @lattner presented here, and adapted it toasync
. The end result is really exciting, in my opinion.I am keen to hear everybody's thoughts. 🙂