`upcxx::reference_wrapper` for supporting move-only types in `upcxx::future`

Issue #472 new
Colin MacLean created an issue

As discussed in the 4/28/2020 meeting, the inability to use move semantics with upcxx::future prevents using types that are MoveConstructible but not CopyConstructible, such as std::unique_ptr. This limitation also prevents moving objects from futures in cases where copying an object requires an expensive deep copy and a move, if possible, would be more efficient. At the time of the meeting, none of us had a workable solution, as upcxx::future is a handle that can have a reference count >1. Thinking more about this problem after the meeting, I came up with a upcxx::reference_wrapper as a potential solution. This solution takes inspiration from std::reference_wrapper, as std::bind has a similar issue in that multiple invocations should behave the same except for when references are used explicitly.

template<typename T>
class reference_wrapper
{
public:
    reference_wrapper() noexcept = default;
    reference_wrapper(const reference_wrapper&) noexcept = default;
    reference_wrapper(reference_wrapper&&) noexcept = default;
    reference_wrapper(T&& t) noexcept(noexcept(T{std::declval<T&&>()}))
        : ptr{std::make_shared<T>(std::move(t))}
    {}
    reference_wrapper(const T& t) noexcept(noexcept(T{std::declval<const T&>()}))
        : ptr{std::make_shared<T>(t)}
    {}

    T& get() const { return *ptr }

    T move() const noexcept(noexcept(T{std::declval<T&&>()})) { 
        return std::move(*ptr);
    }
private:
    std::shared_ptr<T> ptr;

public:
    struct upcxx_serialization {
        template<typename Writer>
        static void serialize(Writer& writer, const reference_wrapper& o) {
            writer.write(o.move());
        }

        template<typename Reader>
        static reference_wrapper* deserialize(Reader& reader, void* storage) {
            reference_wrapper* res = ::new(storage) reference_wrapper(reader.template read<T>());
            return res;
        }
    };
};

We can also introduce a helper function, upcxx::move():

template<typename T>
inline reference_wrapper<typename std::remove_reference<T>::type> move(T&& t) noexcept(noexcept(T{std::declval<T&&>()})) {
    return reference_wrapper<typename std::remove_reference<T>::type>(std::move(t));
}

The helpers upcxx::ref and upcxx::cref to mirror the STL are also worth considering but probably aren’t as useful.

This provides a simple workaround for the copyability problem. upcxx::move() is called when returning from an RPC to move the type to the return buffer and upcxx::reference_wrapper<T>::move() is used from the returned future to move the object off of the buffer:

moveonly_type res = upcxx::rpc(0, []() {
  moveonly_type m{};
  return upcxx::move(m); //move into return buffer
}).wait().move(); //move out of return buffer

This proposal is compatible with future chaining:

moveonly_type res = upcxx::rpc(0, []() {
  moveonly_type m{};
  return upcxx::move(m); //move into return buffer
}).then([](const upcxx::reference_wrapper<moveonly_type>& m) {
  m.get().mutate(); //modifies the type in-place from an lvalue
  return m;
}).wait().move(); //move out of return buffer

This essentially makes the type accessible as non-const lvalue or rvalue references.

As with any move, the programmer would be responsible for only moving once. The side effects and ordering of non-const lvalue usage have to also be kept in mind. This requires more care than having only const lvalue references, but due to the explicit sematics of using a wrapper, this doesn’t introduce pitfalls that one could stumble into unwittingly. Because .move() must be called explicitly and reference_wrapper isn’t implicitly convertible to an rvalue of the underlying type, this design helps avoid UB when moving a type out of the future. As with std::reference_wrapper and actual references, the constness of the wrapper/reference does not impact the constness of the referred-to object.

The main disadvantage of this technique is the additional allocation needed for the std::shared_ptr within upcxx::reference_wrapper. The alternative would be to have the upcxx::future only contain references and wrappers, only indirectly pointing to the actual data. This is the technique I developed to avoid this problem entirely in my MPIRPC and support any type of object. A std::shared_ptr<data_storage<unwrapped_t<T>...>> data would contain the actual data and only exist once. A std::tuple<wrapped_or_reference_t<T>...> refs is what would be passed around as a copyable type in a future or . A future created as a composite of other futures would thus contain a std::vector<std::shared_ptr<data_storage_base>> to keep the underlying data alive and a std::tuple of references referring to data from these multiple locations. Because no actual data would be copied or moved, only references, the future could be copied or moved freely without touching the referred-to data. This technique even allows for types which are both non-copyable and non-movable to be used, as the type would be deserialized and constructed in-place in the data_storage and could be kept alive by getting a copy of the std::shared_ptr. The future would never deal in the underlying types directly. This technique is highly flexible, but would require a significant architectural change.

The proposed upcxx::reference_wrapper and upcxx::move() would enable this functionality and optimization potential with just a few dozen lines of code. Although the original motivation was only for dealing with std::unique_ptr, the wrapper is general purpose and is useful functionality not only for move-only types but also for optimizing code that uses types with expensive copy constructors. Thus, I propose it as a part of core UPC++ rather than part of extras.

The original name I came up with was upcxx::move_wrapper. However, I realized it might be useful to retrieve lvalue references to the stored object in upcxx::future chains. reference_wrapper might not quite be the right name for it.

Comments (7)

  1. Log in to comment