Introduce exec::function<...>#2040
Conversation
There was a problem hiding this comment.
why capture a function and arguments instead of just a sender that aggregates the arguments? what does lazy construction of the sender offer here?
could this be implemented in terms of:
template <class Result,
class ReceiverQueries = queries<>,
class Completions = completion_signatures<set_error_t(exception_ptr),
set_stopped_t()>,
class SenderQueries = queries<>>
auto function(auto sndr)
{
using _completions_t =
__minvoke<__mpush_back<__q<completion_signatures>>, Completions, set_value_t(Result)>;
using _sender_t =
any_sender<any_receiver<_completions_t, ReceiverQueries>, SenderQueries>;
return _sender_t(let_value(read_env(get_frame_allocator),
[=](auto const& alloc)
{
return __uses_frame_allocator(sndr, alloc);
}));
}EDIT: also, the exec::function interface suggests to me that it would be used like:
exec::function<int(int)> fn([](int i) { return ex::just(i); });
auto [result] = ex::sync_wait(fn(42)).value();the lazy construction of the sender would then makes sense.
This diff starts the work to add a type-erased sender named `io_sender<Return(Args...)>`. The intent is for such a sender to represent "an async function from `Args...` to `Return`", a bit like a task coroutine, but with different trade offs. The sender itself stores a `std::tuple<Args...>` and a `sender auto(Args&&...)` factory that can construct the intended erased sender from the stored arguments on demand. This representation allows us to defer allocation of the type-erased operation state until `connect` time, giving us coroutine-like behaviour but allowing us to choose the frame allocator by querying the eventual receiver's environment. The completion signatures for an `io_sender<Return(Args...)>` are: - `set_value_t(R&&)` - `set_error_t(std::exception_ptr)` - `set_stopped_t()` We may be able to eliminate the error channel for `io_sender<R(A...) noexcept>` but that direction requires more thought. This first diff proves that we can store a tuple of arguments and a factory and, at `connect` time, use those values to allocate a type-erased operation state. The test cases cover only basic cases, and all allocations happen through `::operator new`. Future changes will expand the test cases and invent a `get_frame_allocator` environment query that can be used to control frame allocations. The expectation is that we can meet Capy's performance characteristics with a slightly different API in a sender-first way.
This diff changes the name of `io_sender<R(A...)>` to `function<R(A...)>` after some discussion with other folks working on `std::execution`. `exec::function<...>` is a type-erased wrapper around an async function with the given signature (elided here as `...`). More features are coming in future diffs.
Move to an implementation that spreads `completion_signatures` throughout the internals so that we're not restricted to `R(A...)`-style constraints. The tests still only validate `R(A...)`-style constraints, with no validation of no-throw functions, or controlling the completion signature and environment; that'll come next. This implementation also relies on virtual inheritance of a pack of abstract base classes, which feels like a kludge. I should figure out how to reimplement the virtual dispatch in terms of a hand-rolled vtable.
Thanks to a suggestion from @RobertLeahy, I've been able to rework the virtual function inheritance to not need virtual inheritance.
`function<ex::sender_tag(Args...), ex::completion_signatures<...>>` now declares an async function mapping `Args...` to the explicitly specified completion signatures.
Support for explicit completion signatures, environment, or both in the declaration of an `exec:function`.
Rework the dynamically allocated operation state type to support allocators, but always use `std::allocator` for now.
This diff needs tests, but the existing tests build and pass, which seems like a good signal. I've added a `get_frame_allocator` query, and a defaulting cascade from `get_frame_allocator` -> `get_allocator` -> `std::allocator<std::byte>` to the allocation of `_derived_op`.
This diff marks almost every function `constexpr`. It doesn't mark the imlementation of `complete` in the CRTP `_func_op_completion` class template because Clang rejects the down-cast to `Derived` as not a core constant expression; apparently, `Derived` is incomplete when it's being evaluated as a side effect of constraint satisfaction testing. This `constexpr` "hole" means `exec::function` can't be used at compile time, but maybe it can be worked around later.
Validate that more kinds of senders can be erased and then connected and started. Also clean up the captures in some lambdas in `connect` and `clang-format`.
Still TODO is that the `get_frame_allocator` query shouldn't have to be specified in the `function`'s custom environment (and, come to think of it, neither should `get_allocator`), but, when specified, it works.
This needs cleaning up and a *lot* more tests, but the current tests build and pass with a synthesized polymorphic environment.
This diff does some tidying and adds documentation. There are still some TODOs, but this is in good enough shape that I can start sharing it, I think.
Per code review feedback, replace `[[no_unique_address]]` with `STDEXEC_ATTRIBUTE(no_unique_address)`.
f7fdca2 to
dc225b8
Compare
Take @ericniebler's code review feedback to clean up the declaration of `exec::_func::_func_impl`'s constructor.
This commit replaces the vtable-building shenanigans in `exec::function` with the `exec::_any::_any_receiver_ref` class template in `any_sender_of.hpp`. The comments probably still need cleaning up, and there's a `TODO` to pull the stuff in `any_sender_of.hpp` that's shared between `exec::any_sender_of` and `exec::function` into a separate, shared header.
Update comments to match the new implementation.
Take code review feedback and replace attempts to deduce a function type's `noexcept` clause with explicit partial specializations for both the throwing and non-throwing cases.
Replace the `unique_ptr` to custom type-erased operation state with an `STDEXEC::__any::__any<exec::_any::_iopstate>`; I might be able to go further and replace `_func_op` with `exec::_any::_any_opstate`, but I need to think about the stop token adaption it does before committing to that.
This change moves `_func::_func_op` to store its receiver as an `_any::_state`, and its child op as an `_any::_any_opstate_base`, similar to how `_any::_any_opstate` works. This means there's now support for adapting stop tokens, and it slightly shortens some declarations because `_any_opstate_base` is shorter than `__any::__any<_any::_iopstate>`.
ericniebler
left a comment
There was a problem hiding this comment.
i still don't understand why this utility captures args and a function that returns a sender instead of just capturing the resulting sender. what benefit do we gain from lazily constructing the sender at connect time?
|
/ok to test bcdaae2 |
Yeah, sorry, I was responding to the easier feedback before getting to this philosophical question and it's not surprising you have this question—somewhere between having the idea for this PR and actually publishing it, I got distracted by the details and didn't include a very good motivation. Let me try to fix that. The back story is that I'm looking into the benchmark that Vinnie and Steve have published here. I'm still working on developing a deep enough understanding of the benchmark to have intelligent opinions about it but my initial impression is that the coroutine code is very well written (unsurprising given the author's expertise), but the sender code is unidiomatic, suggesting to me that the author is less experienced with senders than with coroutines. I'd like to improve the sender code to make the comparison between approaches as fair as possible. One of the apparent weaknesses of senders that is exposed in the benchmark is that it's more difficult to optimize the allocation patterns of an The idea behind this The usage I expect would look like this: struct interface {
virtual exec::function<int(interface*) noexcept> get_int() noexcept = 0;
};
struct impl : interface {
exec::function<int(interface*) noexcept> get_int() noexcept override {
return exec::function<int(interface*) noexcept>(this, [](interface* base) noexcept {
auto* self = static_cast<impl*>(base);
// presumably some more interesting composition of senders in practice
return ex::just(self->i_);
});
}
private:
int i_;
};Letting myself think grandiose thoughts, I could imagine extending this with a language feature that lets you use coroutine syntax to let the compiler write the actual sender for you, like this: exec::function<std::string(int)> async_to_string(int i) {
co_return std::format("{}", i);
}For cases where exec::function<ex::sender_tag> async_to_string(int i) {
// compiler computes set_value_t(std::string) and set_error_t(std::exception_ptr)
// but not set_stopped_t() because this coroutine doesn't complete with stopped
co_return std::format("{}", i);
} |
Take @ericniebler's suggestion and simplify the `_sigs_from_t` alias template.
Clean up the implementation of `connect`: * switch from `std::tuple` to `STDEXEC::__tuple` * rvalue ref-qualify the existing `connect` * add a const lvalue ref-qualified `connect` that copies the source sender and rvalue connects the temporary * add a test of lvalue connect
Add some descriptive `SECTION("blah")` declarations to the basic tests.
Add a test proving that @ericniebler's suggestion to deduce `function`'s factory argument as a value is necessary to accept lvalue factories, and then take the suggestion to make the test pass.
so the allocation this saves is the one that can potentially happen when type-erasing a sender? could you achieve the same thing with a (non-type-erasing) sender adaptor that returns a type-erased opstate from |
|
/ok to test ede4ba2 |
Yes.
I don't think so, but maybe you can see a way? Suppose we had Also, fwiw, I'm open to feedback on the name of this type. If you look at the commit history, you can see I originally named this thing
That said, the confusion you expressed earlier about the interface suggests to me that the name might be a problem. Perhaps something like |
This PR proposes a new type-erased sender named
exec::function. There's an in-code comment giving a bunch of examples, but a simple example is:There are a bunch of TODOs left, including lots of tests that are missing, but the API is ready to collect early feedback. If this looks like a promising direction, I intend to write a paper for Brno proposing this type for inclusion in C++29.