Skip to content

when_all: implementation missing mechanism for explicit resource transition? #1937

Closed

Description

@maartenarnst

In a when_all with child senders that complete on different execution contexts, it appears the resource transitions are not explicit. This appears to be at odds with the p2300 proposal:

We propose that, for senders advertising their completion scheduler, all execution resource transitions must be explicit; running user code anywhere but where they defined it to run must be considered a bug.

For example, if we build a sender like,

TEST(WhenAll, different_domain_children) {
    experimental::execution::single_thread_context cpu{};
    nvexec::stream_context gpu{};

    auto when_all = stdexec::when_all(
        stdexec::schedule(cpu.get_scheduler()) | stdexec::then([] { }),
        stdexec::schedule(gpu.get_scheduler()) | stdexec::then([] __device__ { }));

    auto sndr = std::move(when_all) | stdexec::continues_on(cpu.get_scheduler()) | stdexec::then([] { });

    stdexec::sync_wait(sndr);
}

there is a resource transition from the gpu to the cpu, but it appears the current implementation of when_all does not make it explicit. In particular, the implementation of when_all does not insert a schedule_from to transition away from the gpu.

Note that it appears that the continues_on may not be considered to be responsible for the transition because the completion domain of the when_all is the default_domain. Also note that this snippet does not compile because of a host device error, but this compilation problem might be separate from the conceptual problem of how to think of resource transitions in when_all.

We therefore wanted to ask whether it is a deliberate design decision that the current implementation of when_all does not end branches with an explicit resource transition? If so, how should we think of such resource transitions?

Joint work with @romintomasetti.

Activity

  1. RobertLeahy commented on Mar 17, 2026

    @RobertLeahy
    Contributor

    If the design of when_all included a resource transition that transition would be "implicit" (as it would be hidden inside when_all) which is at odds with your quote from P2300.

  2. maartenarnst commented on Mar 18, 2026

    @maartenarnst
    ContributorAuthor

    If the design of when_all included a resource transition that transition would be "implicit" (as it would be hidden inside when_all) which is at odds with your quote from P2300.

    Hi @RobertLeahy. Thank you for your answer. It is a fair point. But we are still not entirely sure to fully understand, and so we wanted to continue the discussion a bit further.

    This quote from P3826R3 may also be helpful:

    In order to transition between two execution contexts, each of which may know nothing about the other, it is necessary to do it in two steps: a transition from a context to the default domain, and a transition from the default domain to another context. A scheduler can customize either or both of these two steps by customizing the continues_on and schedule_from algorithms.

    When we consider a linear chain, we can clearly see this in action. E.g., for

    schedule(gpu) | then --> continues_on(cpu) | then
    

    as in

    experimental::execution::single_thread_context cpu{};
    nvexec::stream_context gpu{};
    
    auto sndr = stdexec::schedule(gpu.get_scheduler()) | stdexec::then(Functor{})
              | stdexec::continues_on(cpu.get_scheduler()) | stdexec::then(Functor{});

    we can look at the sender that stdexec builds up:

    static_assert(
        std::same_as<
            stdexec::__demangle_t<decltype(sndr)>,
            stdexec::__basic_sender<
                stdexec::then_t,
                Functor,
                stdexec::__basic_sender<
                    stdexec::continues_on_t,
                    stdexec::run_loop::scheduler,
                    stdexec::__basic_sender<
                        stdexec::schedule_from_t,
                        stdexec::__,
                        stdexec::__basic_sender<
                            stdexec::then_t,
                            Functor,
                            decltype(::stdexec::schedule(std::declval<nvexec::stream_context>().get_scheduler()))
                        >::type
                    >::type
                >::type
            >::type
        >);

    Here:

    • stdexec builds up a sender in which the transition is represented explicitly in terms of a schedule_from away from the gpu, followed by a continues_on onto the cpu.
    • the schedule_from provides a customisation point to insert work useful when transitioning away from the execution resource.

    We can contrast this with the current behavior of when_all with heterogeneous branches. E.g., for

    schedule(gpu) | then -- \
                             when_all --> continues_on(cpu) | then
    schedule(cpu) | then -- /
    

    as in

    experimental::execution::single_thread_context cpu{};
    nvexec::stream_context gpu{};
    
    auto when_all = stdexec::when_all(
        stdexec::schedule(cpu.get_scheduler()) | stdexec::then(Functor{}),
        stdexec::schedule(gpu.get_scheduler()) | stdexec::then(Functor{}));
    
    auto sndr = std::move(when_all) | stdexec::continues_on(cpu.get_scheduler()) | stdexec::then(Functor{});

    we can again look at the sender that stdexec builds up:

    static_assert(
        std::same_as<
            stdexec::__demangle_t<decltype(sndr)>,
            stdexec::__basic_sender<
                stdexec::then_t,
                Functor,
                stdexec::__basic_sender<
                    stdexec::continues_on_t,
                    stdexec::run_loop::scheduler,
                    stdexec::__basic_sender<
                        stdexec::schedule_from_t,
                        stdexec::__,
                        stdexec::__basic_sender<
                            stdexec::when_all_t,
                            stdexec::__,
                            stdexec::__basic_sender<
                                stdexec::then_t,
                                Functor,
                                decltype(stdexec::schedule(
                                    std::declval<experimental::execution::single_thread_context>().get_scheduler()))
                            >::type,
                            stdexec::__basic_sender<
                                stdexec::then_t,
                                Functor,
                                decltype(::stdexec::schedule(std::declval<::nvexec::stream_context>().get_scheduler()))
                            >::type
                        >::type
                    >::type
                >::type
            >::type
        >);

    This time:

    • the transition away from a branch's execution resource to the common domain is not represented explicitly
    • there is no customisation point to insert work useful when transitioning away from a branch's execution resource.

    We note again that this snippet does not compile when connecting with stdexec::sync_wait(sndr);. Compilation fails with a host device error.

    To us, this raises several questions:

    • Semantics and need to represent transitions explicitly: Thinking again of the original quote from p2300 and the quote "In order to transition between two execution contexts, ... it is necessary to do it in two steps", it seems it is OK not to represent the second step within when_all because the destination is not known. However, it does seem worth wondering whether the first step of transitioning from each branch's execution resource to the/a default/common domain should be represented, because when_all does seem to possess all needed information. It seems it would not be implicit.
    • Absence of customisation point for work when transitioning away from branches: How should a user handle branches that require work when transitioning off them? Is the recommended approach to customise when_all entirely? Terminate such branches manually with a continues_on? Customise schedule_from for indeterminate domains? Should the default implementation disallow usage with branches on execution contexts that require work when transitioning away from them? If so, how could this be achieved?
    • transfer_when_all: Is transfer_when_all an algorithm that may lend itself to representing transitions explicitly? Indeed, this algorithm knows both the sources and the destination.

    As a concrete example, we have implemented a case of a scheduler that encrypts a string during schedule. The corresponding schedule_from decrypts the string. Without explicit transition, the string remains encrypted.

    example
    #include <iostream>
    #include <ranges>
    
    #include <exec/completion_signatures.hpp>
    #include <exec/single_thread_context.hpp>
    #include <stdexec/execution.hpp>
    
    //! Call @ref rot13 once to encrypt a string, and a second time to decipher it.
    inline std::string rot13(std::string text) noexcept {
        for (auto& chk: text)
            if (std::isalpha(chk))
                chk = std::isupper(chk) ? 'A' + (chk - 'A' + 13) % 26 : 'a' + (chk - 'a' + 13) % 26;
        return text;
    }
    
    template <stdexec::receiver Rcvr>
    struct CryptoReceiver {
        using receiver_concept = stdexec::receiver_t;
    
        Rcvr rcvr;
    
        void set_value(std::string value) && noexcept {
            stdexec::set_value(std::move(rcvr), rot13(std::move(value)));
        }
    
        auto get_env() const noexcept -> stdexec::env_of_t<Rcvr> {
            return stdexec::get_env(rcvr);
        }
    };
    
    template <stdexec::sender Sndr>
    struct CryptoSender {
        using sender_concept = stdexec::sender_t;
    
        Sndr sndr;
    
        template <stdexec::__decays_to<CryptoSender> Self, typename... Env>
        static consteval auto get_completion_signatures() {
            return experimental::execution::get_child_completion_signatures<Self, Sndr, Env...>();
        }
    
        template <stdexec::receiver Rcvr>
        auto connect(Rcvr rcvr) && {
            return stdexec::connect(std::move(sndr), CryptoReceiver<Rcvr>{std::move(rcvr)});
        }
    
        auto get_env() const noexcept -> stdexec::env_of_t<Sndr> {
            return stdexec::get_env(sndr);
        }
    };
    
    //! Forward declaration.
    struct CryptoScheduler;
    
    //! Custom domain.
    struct CryptoSchedulerDomain {
        //! For @c stdexec::schedule_from_t.
        template <stdexec::sender Sndr, typename Env>
        requires(std::same_as<stdexec::tag_of_t<Sndr>, stdexec::schedule_from_t>)
        static auto transform_sender(stdexec::set_value_t, Sndr&& sndr, const Env&) -> stdexec::sender auto {
            auto [tag, data, inner] = std::forward<Sndr>(sndr);
            return CryptoSender<decltype(inner)>{std::move(inner)};
        }
    
        //! For @c stdexec::continues_on_t.
        template <stdexec::sender Sndr, typename Env>
        requires(std::same_as<stdexec::tag_of_t<Sndr>, stdexec::continues_on_t>)
        static auto transform_sender(stdexec::set_value_t, Sndr&& sndr, const Env&) -> stdexec::sender auto {
            auto [tag, data, inner] = std::forward<Sndr>(sndr);
            return CryptoSender<decltype(inner)>{std::move(inner)};
        }
    };
    
    //! Scheduler.
    struct CryptoScheduler {
        struct Sender {
            using sender_concept = stdexec::sender_t;
    
            using completion_signatures = stdexec::completion_signatures<stdexec::set_value_t()>;
    
            template <stdexec::receiver Rcvr>
            struct OpState {
                Rcvr rcvr;
    
                void start() & noexcept {
                    stdexec::set_value(std::move(rcvr));
                }
            };
    
            template <stdexec::receiver Rcvr>
            OpState<Rcvr> connect(Rcvr rcvr) {
                return {std::move(rcvr)};
            }
    
            struct attrs {
                constexpr auto
                    query(stdexec::get_completion_scheduler_t<stdexec::set_value_t>) const noexcept -> CryptoScheduler {
                    return {};
                }
                constexpr auto
                    query(stdexec::get_completion_domain_t<stdexec::set_value_t>) const noexcept -> CryptoSchedulerDomain {
                    return {};
                }
            };
    
            constexpr auto get_env() const noexcept -> attrs {
                return {};
            }
        };
    
        Sender schedule() const {
            return {};
        }
    
        constexpr auto
            query(stdexec::get_completion_domain_t<stdexec::set_value_t>) const noexcept -> CryptoSchedulerDomain {
            return {};
        }
    
        bool operator==(const CryptoScheduler&) const = default;
    };
    
    //! Shout if any forbidden word is found.
    void moderate_message_content(const std::string& message) {
        static constexpr std::array forbidden = {"bad", "evil", "wrong"};
        std::ranges::for_each(forbidden, [&](const auto& word) {
            if (message.find(word) != std::string::npos)
                throw std::runtime_error(
                    std::string("Your message is rude because it contains '") + word + "'. Be nicer please.");
        });
    }
    
    int main() {
        exec::single_thread_context stc{};
        const CryptoScheduler scheduler{};
    
        const std::string message("This is my message to you! It is not rude but 'bad' is forbidden.");
    
        /// @c continues_on and @c schedule_from ensure that the value channel gets encrypted when entering the domain, and
        /// deciphered when exiting the domain.
        /// The @ref moderate_message_content thus properly warns and saved you from harsh words.
        try {
            const auto [good] = stdexec::sync_wait(
                                    stdexec::just(message) | stdexec::continues_on(scheduler)
                                    | stdexec::then([](std::string value) noexcept {
                                          /* Do something meaningful with the encrypted message. */
                                          return value;
                                      })
                                    | stdexec::continues_on(stc.get_scheduler()) | stdexec::then([](std::string value) {
                                          moderate_message_content(value);
                                          return value;
                                      }))
                                    .value();
            throw std::runtime_error("It should not arrive here, because your message is rude.");
        } catch (const std::runtime_error& e) {
            std::cout << "The message was properly detected as aggressive:\n\t" << e.what() << '\n';
        }
    
        //! The @ref moderate_message_content will let some rude words go through because @c schedule_from was not called.
        const auto [bad] = stdexec::sync_wait(
            stdexec::when_all(
                stdexec::schedule(stc.get_scheduler())
                    | stdexec::then([]() { std::printf("Do whatever concurrently here\n"); }),
                stdexec::just(message) | stdexec::continues_on(scheduler) | stdexec::then([](std::string value) noexcept {
                    /* Do something meaningful with the encrypted message. */
                    return value;
                }))
            | stdexec::continues_on(stc.get_scheduler()) | stdexec::then([](std::string value) {
                  moderate_message_content(value);
                  return value;
              })).value();
        
        if (bad != rot13(message))
            throw std::runtime_error("The message should still be encrypted.");
    
        std::cout << "This is a rude message that is still encrypted and so the moderation did not catch it:\n\t" << bad << '\n';
    }
  3. romintomasetti commented on Mar 31, 2026

    @romintomasetti
    Contributor

    @ericniebler @RobertLeahy What are your thoughts about these? Thanks!

  4. ericniebler commented on Apr 11, 2026

    @ericniebler
    Collaborator
    auto when_all = stdexec::when_all(
        stdexec::schedule(cpu.get_scheduler()) | stdexec::then(Functor{}),
        stdexec::schedule(gpu.get_scheduler()) | stdexec::then(Functor{}));
    
    auto sndr = std::move(when_all) | stdexec::continues_on(cpu.get_scheduler())

    sync_wait-ing this sender shouldn't compile at all, precisely because of the issue you mentioned: that continues_on cannot pick a customization. the completion domain of the when_all is indeterminate.

    i'll fix this so you get a sensible error. to make this valid, all the branches of the when_all must have compatible completion domains, which can be achieved by adding an explicit transition to one or both branches.

  5. ericniebler commented on Apr 12, 2026

    @ericniebler
    Collaborator

    i've closed this now that the underlying problem is fixed. it's possible you don't like the resolution. if so, just reopen the issue and we can talk more.

  6. maartenarnst commented on Apr 13, 2026

    @maartenarnst
    ContributorAuthor

    Hey @ericniebler,

    Thanks for the answer and for the fixes. It's much clearer now indeed what happens when branches
    of a stdexec::when_all have no common domain, as well as how it can be fixed by adding an explicit transition.

    Nevertheless, we still have some questions, which may be relevant to clarify for people writing customizations,
    so we wanted to reopen the discussion.

    What is the intended meaning of inheriting from a common domain?

    Let's take exec::static_thread_pool (stp) and exec::single_thread_context (stc) schedulers. Their domains
    have the stdexec::default_domain in common.

    auto br_stp = stdexec::schedule(stp) | stdexec::then(...);
    auto br_stc = stdexec::schedule(stc) | stdexec::then(...);
    
    auto sndr = stdexec::when_all(br_stp, br_stc) | stdexec::continues_on(whatever) | stdexec::then(...);
    
    stdexec::sync_wait(sndr);

    Observations:

    • this code inserts a stdexec::schedule_from between stdexec::when_all and stdexec::continuous_on
    • the default domain's implementations of stdexec::when_all and stdexec::schedule_from are selected
    • the default domain's implementation of stdexec::when_all does nothing to transition off the branches
    • the default domain's implementation of stdexec::schedule_from does nothing to transition either

    Interpretations:

    • nothing is done in this code to transition off stp and stc
    • because the stp domain itself does not customize stdexec::schedule_from and inherits from the stdexec::default_domain, doing nothing to transition off it as in this code may be fine

    In other words, because there is no customization of stdexec::schedule_from, thus suggesting that
    both schedulers don't need work when transition off them, this code seems ok.

    Can we generalize? Are the following conceptually how we should think of this?

    1. Is a domain that customizes stdexec::schedule_from inherently incompatible with deriving from the stdexec::default domain? I.e., if work must be done to transition off an execution resource, we cannot inherit from the default domain.
      This is also consistent with the stream domain no longer deriving from the default domain (fix bug in domain handling that permits when_all(a,b) when a and b have different completion domains #2018).
    2. More generally, does inheriting from a common domain imply that no work is needed to transition from a derived domain to this common domain? Conversely, does not inheriting imply that work is needed?

    Can branches on distinct execution resources be joined in a when_all?

    // set device 0
    nvexec::stream_context dev0;
    
    // set device 1
    nvexec::stream_context dev1;
    
    auto br_dev0 = stdexec::schedule(dev0.get_scheduler()) | stdexec::then(...);
    auto br_dev1 = stdexec::schedule(dev1.get_scheduler()) | stdexec::then(...);
    
    auto sndr = stdexec::when_all(br_dev0, br_dev1) | stdexec::continues_on(whatever) | stdexec::then(...);
    
    stdexec::sync_wait(sndr);
    • Are these distinct execution resources (device 0, device 1) from the point of view of the stream domain?
    • We expect this code to compile, even if the compiler cannot check the device ids.
    • Is it valid to have branches with a scheduler of the same type, but pointing to different execution resources? Should the runtime validate?
  7. ericniebler commented on Apr 14, 2026

    @ericniebler
    Collaborator

    What is the intended meaning of inheriting from a common domain?

    a domain selects a set of algorithm customizations. for a thread pool, that set is the same as for any ol' CPU thread with the exception of the bulk algorithms, which can be run faster on multiple threads. but there's nothing wrong per se with using the default (serial) bulk implementation on a thread in the thread pool. it's just slower to do it serially.

    so, if you have a when_all that can complete either on a thread pool or on some other CPU thread, it doesn't really matter. any work chained on the when_all can just use the default algorithm implementations, and bob's your uncle. that's why the thread pool domain inherits from default_domain.

    the same is not true for GPU threads. it very much matters whether a when_all completes on the GPU or the CPU. that is why the GPU domain is (no longer) convertible to the default domain. they select disjoint sets of algorithm implementations, and one will not work for the other.

    Can branches on distinct execution resources be joined in a when_all?

    yes. it is indeterminate which resource the when_all will complete on, but that's ok. if there is any difficulty selecting an implementation for a downstream algorithm, it won't compile.

  8. romintomasetti commented on Apr 14, 2026

    @romintomasetti
    Contributor

    @ericniebler Thanks for clarifying the role of deriving from a common domain.

    The case of the two nvexec::stream_context still seems to raise some real questions.

    If I have:

    stdexec::sync_wait(stdexec::schedule(dev0.get_scheduler()) | stdexec::then(...)
                     | stdexec::continues_on(dev1.get_scheduler()) | ...);

    there is real work going on in both specializations of schedule_from and continues_on for going from dev0 to dev1.
    This work is explicit.

    Yet, writing:

    auto br_dev0 = stdexec::schedule(dev0.get_scheduler()) | stdexec::then(...);
    auto br_dev1 = stdexec::schedule(dev1.get_scheduler()) | stdexec::then(...);
    
    stdexec::sync_wait(stdexec::when_all(br_dev0, br_dev1) | stdexec::continues_on(whatever) | stdexec::then(...));

    seems to rely on the customization of when_all to do all the work, and this now happens implicitly within the when_all.

    This appears to be a gap in the "all execution resource transitions must be explicit" guarantee.
    The fix in #2018 closes the compile-time gap for branches with incompatible algorithm sets.
    But for heterogeneous same-domain branches (like dev0 and dev1 above), the type system cannot detect the mismatch, and when_all exposes no customization point through which the transition work can be made visible to the user.
    The transition happens, but implicitly, buried inside when_all's logic.
    This is precisely the initial concern of @maartenarnst: not all execution resource transitions are explicit (?).

    Could you comment on that? Thanks!

  9. RobertLeahy commented on Apr 14, 2026

    @RobertLeahy
    Contributor

    In other words, because there is no customization of stdexec::schedule_from, thus suggesting that both schedulers don't need work when transition off them, this code seems ok.

    My reading of your responses leads me to believe that your understanding is that std::execution::schedule_from is a customization point that a scheduler/execution context author can use to determine what happens when "work" "transition[s] off" your scheduler/execution context.

    This doesn't match my understanding. std::execution::schedule_from is an implementation detail of std::execution::continues_on (the standard even says as much). std::execution::continues_on is an algorithm which is used in the places which the standard prescribes that it be used, and where users use it, not in the general case of "work" "transition[ing] off" some scheduler or execution context.

    I keep scare quoting because I don't think these concepts exist as first-class citizens of std::execution. Your execution context doesn't run "work" it simply calls std::execution::set_value on the receiver which was provided when connecting a sender obtained via std::execution::schedule. There's no intrusive, unified concept of higher-level "work" which places obligations on the receiver to do anything particularly (for example use std::execution::schedule_from) if it wants to "transition off" the current execution context.

    Consider these examples:

    std::execution::scheduler auto sch_a = ctx_a.get_scheduler();
    std::execution::scheduler auto sch_b = ctx_b.get_scheduler();
    auto sndr =
      std::execution::schedule(sch_a) |
      std::execution::let_value([sch_b]() { return std::execution::schedule(sch_b); });

    The asynchronous operation which will result if you connect sndr and start the resulting operation state will not use std::execution::schedule_from to "transition off" the execution context associated with sch_a.

    Or consider:

    std::execution::scheduler auto sch_a = ctx_a.get_scheduler();
    std::execution::scheduler auto sch_b = ctx_b.get_scheduler();
    auto sndr = exec::sequence(
      std::execution::schedule(sch_a),
      std::execution::schedule(sch_b));

    The same calculus applies.

    This appears to be a gap in the "all execution resource transitions must be explicit" guarantee.

    std::execution::when_all doesn't transition execution resources at all, so I'm unsure why analysis is focusing on it. std::execution::when_all completes on whichever execution resource the final child completes on. So if std::execution::when_all completes on some execution resource then one of its children completes on that execution resource and so std::execution::when_all didn't transition execution resources because one of its children was already running thereon.

  10. maartenarnst commented on Apr 15, 2026

    @maartenarnst
    ContributorAuthor

    @RobertLeahy Thanks for your answer.

    From your answer and examples, we have the impression that you are confirming that the gap we are worried about is real and is actually present not only in when_all but also at other places in stdexec.

    My reading of your responses leads me to believe that your understanding is that std::execution::schedule_from is a customization point that a scheduler/execution context author can use to determine what happens when "work" "transition[s] off" your scheduler/execution context.

    This doesn't match my understanding. std::execution::schedule_from is an implementation detail of std::execution::continues_on (the standard even says as much). std::execution::continues_on is an algorithm which is used in the places which the standard prescribes that it be used, and where users use it, not in the general case of "work" "transition[ing] off" some scheduler or execution context.

    However, according to P3826:

    Semantically, schedule_from(sndr) is equivalent to sndr. Crucially, that means that schedule_from(sndr) has the same completion domain as sndr. And that makes schedule_from a great way to customize how to transition off of an execution context.

    On the other hand, continues_on(sndr, sch) completes on the domain of sch, making it a great way to customize how to transition onto an execution context.

    So our understanding of the recent papers and nvexec implementation led us to believe that:

    1. stdexec::schedule_from is indeed not meant to be used by the user, but is a customisation point for implementers
    2. it is the way to transition off of a context.

    We agree with you that, according to std::execution, we must call set_value of the "downstream" receiver once the work is complete. Let's note that for GPU schedulers like nvexec and ours, this is inefficient so we instead launch all kernels (e.g. latency hiding) and at some point need to decide to synchronize. But let's put aside the synchronization problem and focus on the value channel for the following situation.

    Let's assume that we don't have a unified memory space, that @ericniebler used to circumvent the problem, that is, each device has a memory space inaccessible to the other, and there is no CPU/GPU shared memory space.

    auto br_dev0 = stdexec::schedule(dev0.get_scheduler()) | stdexec::then(...);
    auto br_dev1 = stdexec::schedule(dev1.get_scheduler()) | stdexec::then(...);
    
    stdexec::sync_wait(stdexec::when_all(br_dev0, br_dev1) | stdexec::continues_on(whatever) | stdexec::then(...));

    Since we assumed the value channels of dev0 and dev1 are inaccessible to one another and inaccessible from the CPU, "work" must be done to transfer the values from the dev0 and dev1 memories to whatever's memory. A sufficiently motivated implementer can customize when_all and schedule_from and determine that the values of the branches need to be moved to 'whatever', i.e. the destination is known, as well as the sources.

    Our impression is that such "work" is recurrent in various situations that users can be expected to write, as your examples had also illustrated. And we have the impression such "work" is similar to what happens in a schedule_from/continues_on in a linear chain:

    // The transition must move the value channel from dev0 to whatever
    stdexec::sync_wait(stdexec::schedule(dev0.get_scheduler()) | stdexec::then(...)
                     | stdexec::continues_on(whatever.get_scheduler()) | ...);

    Customising all three algorithms (when_all, schedule_from and continues_on) is necessary and they must work together to implement the transition. Customising only schedule_from and continues_on is not sufficient to make when_all work (and neither to make let_value and other algorithms work as you had illustrated).

    Our concern is thus:

    the transitions aren't always sufficiently explicitly represented and they are not always surfaced as customisation points.

    Implementers thus must customise not only schedule_from and continues_on, but also many other algorithms, to ensure transitions work.

  11. ericniebler commented on Apr 15, 2026

    @ericniebler
    Collaborator

    The transition happens, but implicitly, buried inside when_all's logic.

    i don't understand. when_all inserts no hidden transitions. it completes wherever the last operation completes. its completion domain is the common domain of all the branches (which could be an indeterminate_domain specialization). when_all exposes no completion scheduler, because even if the two branches have the same scheduler type, they might have two different scheduler values.

  12. ericniebler commented on Apr 15, 2026

    @ericniebler
    Collaborator

    Let's assume that we don't have a unified memory space, that @ericniebler used to circumvent the problem, that is, each device has a memory space inaccessible to the other, and there is no CPU/GPU shared memory space.

    the algorithms in std::execution are about moving execution from one resource to another. moving data from one memory space to another is a separate concern. you must explicitly use an algorithm like schedule or continues_on to effect an execution space transfer. it is desirable for the same reasons to make your data transfers explicit also. there are no std algorithms for data movement; you would have to provide your own and use them in your sender expressions. if you forget a necessary data transfer, then you would naturally end up with an invalid memory access.

    with explicit data transfer algorithms, there is no need to customize when_all. the default implementation will do.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions