KaMPIng-v2 Design Guide

This document describes the design patterns and layering in KaMPIng-v2. It’s meant as a reference for implementing new features and understanding the existing code without reading every header.

Architecture Overview

KaMPIng-v2 uses a layered architecture with a clear separation of concerns:

┌─────────────────────────────────────────────────┐
│  v2 Wrappers (kamping/v2/)                      │
│  - infer() protocol (deferred sizing, metadata) │
│  - Result types (struct binding support)        │
│  - Ownership semantics (ref_view vs owning)     │
└──────────────┬──────────────────────────────────┘
               ↓ delegates to
┌──────────────────────────────────────────────────┐
│  Core Layer (mpi-core/include/mpi/)             │
│  - Buffer protocol (concepts, accessors)        │
│  - Bare MPI wrappers (one call each)           │
│  - Handle dispatch (traits-based)               │
│  - No view machinery, no resizing               │
└──────────────────────────────────────────────────┘
               ↓ uses
┌──────────────────────────────────────────────────┐
│  View Adapters (kamping/v2/views/)              │
│  - Composable via | operator                    │
│  - Lazy metadata attachment                     │
│  - Ownership forwarding                         │
└──────────────────────────────────────────────────┘

The key principle: core layer is views-free. All view machinery, ownership forwarding, and resize logic lives in v2 and the views namespace. This keeps the MPI wrappers simple and reusable.


Core Concepts

1. Buffer Protocol

All MPI operations work with buffers satisfying one of these concepts:

// Single-buffer operations (send, recv, allgather, broadcast, etc.)
concept send_buffer {
    count(t) → std::integral                    // element count
    ptr(t) → convertible to (void const*)       // read-only pointer
    type(t) → MPI_Datatype                      // element type
};

concept recv_buffer {
    count(t) → std::integral
    ptr(t) → convertible to void*               // read-write pointer
    type(t) → MPI_Datatype
};

// data_buffer requires count + ptr (any pointer) + type
// send_buffer and recv_buffer both extend data_buffer,
// distinguished by ptr() constness (void const* vs void*)
concept data_buffer = /* count + ptr + type */;

// Variadic operations (allgatherv, alltoallv, etc.)
concept send_buffer_v = send_buffer + {
    counts(t) → std::ranges::contiguous_range<int const>  // per-rank counts
    displs(t) → std::ranges::contiguous_range<int const>  // per-rank displacements
};

concept recv_buffer_v = recv_buffer + {
    counts(t) → std::ranges::contiguous_range<int>        // mutable for infer()
    displs(t) → std::ranges::contiguous_range<int const>
};

Accessor dispatch follows a 3-tier priority:

  1. mpi::experimental::buffer_traits<T> specialization — non-intrusive, for types you don’t own

  2. Member functions — t.mpi_count(), t.mpi_ptr(), t.mpi_type(), t.mpi_counts(), t.mpi_displs()

  3. Fallback — std::ranges::size(), std::ranges::data(), builtin type deduction

To adapt a third-party type:

template <>
struct mpi::experimental::buffer_traits<MyBuffer> {
    static std::ptrdiff_t count(MyBuffer const& b) { return b.size; }
    static void* ptr(MyBuffer& b) { return b.data; }  // recv
    static void const* ptr(MyBuffer const& b) { return b.data; }  // send
    static MPI_Datatype type(MyBuffer const& b) { return MPI_INT; }
};

2. Handle Dispatch

All MPI handles (MPI_Comm, MPI_Request, MPI_Datatype, MPI_Status, MPI_Message) are extracted via mpi::experimental::handle(x) and mpi::experimental::handle_ptr(x), with the same 3-tier dispatch:

  1. handle_traits<T> specialization

  2. Member functions — t.mpi_handle(), t.mpi_handle_ptr()

  3. Builtin passthrough — raw MPI_Comm, MPI_Request, etc. pass through unchanged

Example (non-intrusive wrapper):

class MyComm {
    MPI_Comm comm_;
public:
    MPI_Comm get() const { return comm_; }
};

template <>
struct mpi::experimental::handle_traits<MyComm> {
    static MPI_Comm handle(MyComm const& c) { return c.get(); }
};

// Now you can pass MyComm directly to mpi::experimental::allgather()
mpi::experimental::allgather(sbuf, rbuf, MyComm{comm});

Handle ranges

Functions that take an array of handles (e.g. the requests of waitall) obtain it via mpi::experimental::handle_range(x), with the same 3-tier dispatch:

  1. handle_range_traits<T> specialization — static … handle_range(T& t)

  2. Member function — t.mpi_handle_range()

  3. Contiguous range passthrough — an lvalue std::vector<MPI_Request>, std::array, C array or std::span is returned by reference

The result must be a writable contiguous range of exactly the handle type that refers to the caller’s storage: an lvalue reference or a borrowed view such as std::span, never a container returned by value. MPI writes back into it (completed requests become MPI_REQUEST_NULL, statuses are filled in), so writes into a copy would be lost. convertible_to_mpi_handle_range<T, Handle> checks all of this. A handle range need not be sized; arguments that need a count (like a request array) additionally require a sized range.

The member tier exists for types whose iteration differs from their storage. For example, a vector of requests that iterates as request_views (with wait()/test()) but stores raw MPI_Requests:

class MyRequests {
    std::vector<MPI_Request> requests_;
public:
    std::span<MPI_Request> mpi_handle_range() noexcept { return requests_; }
    // begin()/end() may iterate as views instead of raw handles
};

Arguments and defaults

The two layers handle optional arguments differently:

  • Core (mpi::experimental) mirrors the MPI signature exactly. There are no default arguments, except a trailing output or hint argument that MPI lets you ignore (status = MPI_STATUS_IGNORE, info = MPI_INFO_NULL).

  • v2 (kamping::v2) always requires the communicator, and it is always the last argument, followed only by an optional status output. Optional arguments in front of it are left out by passing fewer arguments, never by default arguments before comm:

    v2::send(buf, dest, comm);          v2::send(buf, dest, tag, comm);
    v2::recv(buf, comm);                v2::recv(buf, source, comm);     v2::recv(buf, source, tag, comm);
    v2::bcast(buf, comm);               v2::bcast(buf, root, comm);
    v2::reduce(s, r, comm);             v2::reduce(s, r, op, comm);      v2::reduce(s, r, op, root, comm);
    

    A left-out argument takes the obvious value: send tag DEFAULT_SEND_TAG (0), receive from MPI_ANY_SOURCE with MPI_ANY_TAG, root 0, operation std::plus<>. A caller-supplied request for a non-blocking operation is passed first. Rooted collectives also have a non-root shorthand without the root-only buffer (gather(s, [root,] comm), scatter(r, [root,] comm), scatterv(r, [root,] comm), reduce(s, [op, [root,]] comm)), except gatherv, whose non-root receive buffer depends on whether root’s counts are deferred.

Why not default arguments? On MPICH, MPI_Comm, MPI_Request, MPI_Op and the other handles are int, so a communicator, a request, an operation, a rank and a tag cannot be told apart by type. Overloads that differ only in which of them they expect at a position would be ambiguous or, worse, silently take a communicator for a tag. The rules above keep every overload set unambiguous: forms differ in their number of arguments, or in an argument kind that is never an int (a buffer, a request in first position, a status pointer). The same reason excludes a reduce(s, r, root, comm) form: a root cannot be told apart from an MPI_Op.


Adding a New Collective

Here’s a step-by-step walkthrough using allgather as an example.

Step 1: Implement the Core Wrapper

File: mpi-core/include/mpi/collectives/allgather.hpp

namespace mpi::experimental {

template <send_buffer SBuf, recv_buffer RBuf, convertible_to_mpi_handle<MPI_Comm> Comm>
void allgather(SBuf&& sbuf, RBuf&& rbuf, Comm const& comm) {
    int comm_size = 0;
    MPI_Comm_size(mpi::experimental::handle(comm), &comm_size);
    KAMPING_V2_ASSERT(
        static_cast<int>(mpi::experimental::count(rbuf)) % comm_size == 0,
        "recv buffer size must be divisible by comm size"
    );

    int err = MPI_Allgather(
        mpi::experimental::ptr(sbuf),
        static_cast<int>(mpi::experimental::count(sbuf)),
        mpi::experimental::type(sbuf),
        mpi::experimental::ptr(rbuf),
        static_cast<int>(mpi::experimental::count(rbuf)) / comm_size,
        mpi::experimental::type(rbuf),
        mpi::experimental::handle(comm)
    );
    if (err != MPI_SUCCESS) {
        throw mpi_error(err);
    }
}

} // namespace mpi::experimental

Key points:

  • Template over buffer concepts (send_buffer, recv_buffer), not specific types

  • Extract metadata via the count(), ptr(), type() free functions and handles via handle(), always called qualified (mpi::experimental::count(x)), so argument-dependent lookup cannot pick up a user’s function of the same name

  • Mirror the MPI signature: no default arguments, except a trailing status = MPI_STATUS_IGNORE (or info)

  • Use KAMPING_V2_ASSERT for precondition validation

  • Throw mpi_error on MPI failure

  • No inference, no resizing, no ownership tricks — pure MPI wrapper

Step 2: Define the Infer Overload

File: kamping-v2/include/kamping/v2/infer.hpp

Add an operation tag to kamping::v2::comm_op (kamping-v2/include/kamping/v2/comm_op.hpp) and a default infer() overload for it:

namespace kamping::v2 {

namespace comm_op {
struct allgather {};
} // namespace comm_op

// Default infer overload for allgather
template <mpi::experimental::send_buffer SBuf, mpi::experimental::recv_buffer RBuf>
void infer(comm_op::allgather, SBuf const& sbuf, RBuf& rbuf, MPI_Comm comm) {
    // If recv is a deferred buffer (has set_recv_count), resize it
    if constexpr (kamping::v2::deferred_recv_buf<RBuf>) {
        int comm_size = mpi::experimental::comm_view{comm}.size();
        rbuf.set_recv_count(comm_size * static_cast<std::ptrdiff_t>(mpi::experimental::count(sbuf)));
    }
}

} // namespace kamping::v2

Why a separate infer? It discovers metadata before the MPI call:

  • For deferred buffers: calculate required size from comm_size and send count

  • For variadic ops: negotiate counts/displacements via collective calls

  • For recv: probe network to discover incoming message size

The infer is called by the v2 wrapper before delegating to the core layer.

Step 3: Implement the v2 Wrapper

Implement the full form with all arguments, then the shortened forms (see Arguments and defaults) as thin overloads that forward to it.

File: kamping-v2/include/kamping/v2/collectives/allgather.hpp

namespace kamping::v2 {

template <
    mpi::experimental::send_buffer                         SBuf,
    mpi::experimental::recv_buffer                         RBuf,
    mpi::experimental::convertible_to_mpi_handle<MPI_Comm> Comm>
auto allgather(SBuf&& sbuf, RBuf&& rbuf, Comm const& comm) -> result<SBuf, RBuf> {
    
    // Construct result, preserving ownership (lvalue → reference, rvalue → owned)
    result<SBuf, RBuf> res{std::forward<SBuf>(sbuf), std::forward<RBuf>(rbuf)};
    
    // Run infer to discover/resize metadata before MPI call
    infer(comm_op::allgather{}, res.send, res.recv, mpi::experimental::handle(comm));
    
    // Delegate to core layer
    mpi::experimental::allgather(res.send, res.recv, comm);
    
    return res;
}

} // namespace kamping::v2

Pattern:

  1. Construct result<SBuf, RBuf> wrapper (ownership preservation handled automatically)

  2. Call infer(comm_op::allgather{}, ...) to discover/resize metadata

  3. Delegate to mpi::experimental::allgather()

  4. Return result (supports structured bindings: auto [s, r] = allgather(...))

Step 4: Tests

Create kamping-v2/tests/allgather_test.cpp and register it in kamping-v2/tests/CMakeLists.txt as test_v2_allgather (add_executable, target_link_libraries with kamping_v2 kamping_v2_warnings KaTestrophe::main, and katestrophe_add_mpi_test(test_v2_allgather CORES 1 2 4)). Cover:

  • Single-rank send/recv

  • Multi-rank with uniform sizes

  • Multi-rank with non-uniform send sizes (validate recv sizing)

  • Structured binding usage

  • Deferred buffers (with views::resize)


Working with Views

The View Interface Pattern

All v2 views derive from view_interface<Derived> and implement a base() method:

template <typename Base>
struct resize_view : public view_interface<resize_view<Base>> {
    Base base_;  // The wrapped range
    
    auto base() { return base_; }
    auto base() const { return base_; }
};

view_interface is a CRTP mixin that:

  • Forwards begin(), end() to base()

  • Forwards MPI accessors (mpi_count(), mpi_ptr(), mpi_type()) via mpi::experimental::*() dispatch

  • Conditionally provides mpi_resize_for_receive() if base supports resize

This means a view automatically satisfies recv_buffer if its base does, even if the view itself doesn’t define these methods.

Composing Views with |

// Each view is an adaptor_closure that supports piping
std::vector<int> data = {...};

// Composed view: resize on receive, override element type
auto view = data | kamping::v2::views::resize | kamping::v2::views::with_type(MPI_INT);

// Lazy: metadata is computed only when MPI operation queries it
// resize is forwarded through the adaptor chain via mpi_resize_for_receive()

Implementation sketch:

namespace kamping::v2::views {

struct resize_adaptor : adaptor_closure<resize_adaptor> {
    template <std::ranges::range R>
    auto operator()(R&& r) const {
        return resize_view<all_t<R>>{all(std::forward<R>(r))};
    }
};

inline constexpr resize_adaptor resize;

} // namespace kamping::v2::views

The adaptor_closure base provides operator| overloads that call operator().


Variadic Operations (counts/displacements)

Variadic collectives (allgatherv, alltoallv, etc.) require send_buffer_v / recv_buffer_v:

template <send_buffer_v SBuf, recv_buffer_v RBuf, ...>
void allgatherv(SBuf&& sbuf, RBuf&& rbuf, MPI_Comm comm) {
    // Metadata: per-rank counts and displacements
    auto counts_s = counts(sbuf);  // std::span<int const>
    auto displs_s = displs(sbuf);  // std::span<int const>
    auto counts_r = counts(rbuf);  // std::span<int> (may be mutable for infer)
    auto displs_r = displs(rbuf);  // std::span<int const>
    
    MPI_Allgatherv(
        ptr(sbuf), counts_s.data(), displs_s.data(), type(sbuf),
        ptr(rbuf), counts_r.data(), displs_r.data(), type(rbuf),
        comm
    );
}

View support:

  • views::with_counts(range) — attaches per-rank counts

  • views::with_displs(range) — attaches per-rank displacements

  • views::auto_counts() — deferred counts; infer() fills in via set_comm_size + commit_counts()

  • views::auto_displs([container]) — computes displs from counts via exclusive_scan


Reduction Operations (make_op)

Reduce-like collectives (reduce, allreduce, scan, exscan) take an operation argument alongside their buffers. Operations follow the same core-layer, traits-dispatched design as buffers and handles — everything lives in mpi-core/include/mpi/ops.hpp (mpi::experimental), with the kamping::v2 layer only re-exporting the user-facing names.

Operation dispatch (as_mpi_op)

File: mpi-core/include/mpi/ops.hpp

Every reduce-like wrapper resolves its operation argument to a raw MPI_Op via mpi::experimental::as_mpi_op(op, sbuf, rbuf), with a 3-tier priority mirroring the buffer/handle accessors:

  1. op_traits<Op, SBuf, RBuf> specialization — non-intrusive, for op types you don’t own

  2. Builtin MPI_Op handle — anything convertible to MPI_Op via handle() passes through (raw MPI_SUM, and the scoped op handles below)

  3. Builtin range op — maps a functor like std::plus<> on the buffer’s element type to a predefined constant (MPI_SUM, …) via kamping::types::mpi_operation_traits

The valid_op<Op, SBuf, RBuf> concept is simply “as_mpi_op(op, sbuf, rbuf) is well-formed”; reduce-like wrappers constrain their Op template parameter on it and pass op by const& throughout (so a move-only op handle is never copied).

make_op — constructing user-defined operations

For operations that aren’t builtin, mpi::experimental::make_op(...) (re-exported as kamping::v2::make_op) creates an RAII MPI_Op from the kamping-types dependency. It is a thin factory — the element-wise reduction logic lives in kamping::types::ScopedFunctorOp / ScopedCallbackOp, not here — that does a single MPI_Op_create (“one MPI call, no inference”), which is why it belongs in the core layer next to as_mpi_op rather than in the bindings layer.

Two overloads:

// (a) default-constructible binary functor / captureless lambda → ScopedFunctorOp.
//     Element type T is explicit; the functor must be callable as T(T const&, T const&).
auto op = kamping::v2::make_op<int>([](int a, int b) { return a + b; });

// (b) raw MPI callback void(void*, void*, int*, MPI_Datatype*) → ScopedCallbackOp.
//     No element type needed — the callback is already type-erased.
auto op = kamping::v2::make_op(&my_mpi_callback);

kamping::v2::reduce(data, recv, op, /*root=*/0, comm);

Commutativity is an optional trailing argument (kamping::v2::commutative / non_commutative, re-exported from kamping::ops), defaulting to non_commutative — the correctness-safe choice, since MPI never reorders a non-commutative op. Opt in for performance:

auto op = kamping::v2::make_op<int>(std::plus<int>{}, kamping::v2::commutative);

Overload (a) is constrained on is_default_constructible_v<Op> && is_invocable_r_v<T, Op, T const&, T const&>, so a bad functor is a clean overload-resolution failure instead of a hard error inside the dependency. Stateful (capturing) lambdas are intentionally unsupported — they are neither default-constructible nor convertible to a raw callback; wrap the state in a functor, or build the callback yourself and use overload (b).

Making the result flow through collectives

make_op returns ScopedFunctorOp / ScopedCallbackOp, which expose their MPI_Op via .get() but have no mpi_handle() member. Two handle_traits specializations (also in mpi-core/include/mpi/ops.hpp) bridge that gap:

template <bool Commutative, typename T, typename Op>
struct mpi::experimental::handle_traits<kamping::types::ScopedFunctorOp<Commutative, T, Op>> {
    static MPI_Op handle(kamping::types::ScopedFunctorOp<Commutative, T, Op> const& op) noexcept {
        return op.get();
    }
};
// ... and the analogous ScopedCallbackOp specialization.

This makes any scoped op satisfy convertible_to_mpi_handle<MPI_Op>, so as_mpi_op tier 2 picks it up automatically — the op flows into every reduce-like collective with no per-collective wiring, however it was constructed.

Adding a custom operation type non-intrusively

To adapt an op type you don’t own (e.g. one that already knows its own MPI_Op), specialize op_traits — the tier-1 hook:

template <>
struct mpi::experimental::op_traits<MyOp, std::vector<int>, std::vector<int>> {
    static MPI_Op op(MyOp const& o, std::vector<int> const&, std::vector<int> const&) {
        return o.to_mpi_op();
    }
};

Non-Blocking Operations

Non-blocking MPI calls return iresult<Buf> (or iresult<SBuf, RBuf> for sendrecv):

// Core layer: the caller supplies the request (last, as in MPI)
template <send_buffer SBuf, rank Dest, tag Tag, convertible_to_mpi_handle<MPI_Comm> Comm,
          convertible_to_mpi_handle_ptr<MPI_Request> Request>
void isend(SBuf&& sbuf, Dest dest, Tag tag, Comm const& comm, Request&& request);

// v2 layer: the iresult owns the request and, for rvalues, the buffer (on the heap, so it survives a move)
template <send_buffer SBuf, rank Dest, tag Tag, convertible_to_mpi_handle<MPI_Comm> Comm>
auto isend(SBuf&& sbuf, Dest dest, Tag tag, Comm const& comm) -> iresult<SBuf> {
    iresult<SBuf> res{std::forward<SBuf>(sbuf)};
    mpi::experimental::isend(res.view(), dest, tag, comm, res.mpi_handle_ptr());
    return res;
}

The v2 layer also offers each operation with a caller-supplied request, passed as the first argument (see Arguments and defaults); those overloads return the buffer instead of an iresult.

iresult<Buf> properties:

  • Move-only (buffer pointer captured by MPI stays stable)

  • .wait() — blocks until completion, returns buffer (owned buffers moved out; borrowed buffers return reference)

  • .test() — non-blocking poll

  • [[nodiscard]]; destroying it with an outstanding request prints a diagnostic and calls std::terminate() (no implicit MPI_Wait): the library never completes a request implicitly, completion must be explicit


Deferred Buffers

Deferred buffers are used when sizes aren’t known until communication time (e.g., MPI_Recv without a prior MPI_Probe).

concept deferred_recv_buf<T> = recv_buffer<T> && requires(T& t) {
    t.set_recv_count(std::ptrdiff_t(0));  // Called by infer()
};

Example: views::resize

template <std::ranges::range Base>
struct resize_view : view_interface<resize_view<Base>> {
    Base base_;
    
    void mpi_resize_for_receive(std::ptrdiff_t n) {
        // Call resize(n) on the underlying container if it has one
        if constexpr (std::ranges::sized_range<Base> && requires { base_.resize(n); }) {
            base_.resize(n);
        }
    }
};

Resize contract. resize_for_receive(n) and every mpi_resize_for_receive(n) keep the values of the first min(old size, n) elements; new elements are unspecified (custom protocols may leave them uninitialized). Like a receive, which only writes the range it has data for, a resize only touches what it has to. So a buffer that already holds received data can be grown to receive more behind it: call set_recv_count again on a resize_view.

When infer(comm_op::recv{}, buf | views::resize, ...) is called:

  1. Detects that the view is deferred

  2. Probes the network via MPI_Mprobe to determine size

  3. Calls mpi_resize_for_receive(n) to resize the buffer

  4. Stores the matched message in infer() so the actual recv uses it


Extensibility

Custom Buffer Types

Implement the buffer protocol and optionally set_recv_count():

struct MyBuffer {
    void* data;
    std::ptrdiff_t size;
    MPI_Datatype dtype;
    
    void resize(std::ptrdiff_t n) { size = n; /* ... */ }
    void set_recv_count(std::ptrdiff_t n) { resize(n); }
    
    // Optional: provide intrusive accessors
    std::ptrdiff_t mpi_count() const { return size; }
    void* mpi_ptr() { return data; }
    MPI_Datatype mpi_type() const { return dtype; }
};

// Or use buffer_traits for non-intrusive customization
template <>
struct mpi::experimental::buffer_traits<MyBuffer> { /* ... */ };

Custom MPI Handles

Implement mpi_handle() or specialize handle_traits:

class CommWrapper {
    MPI_Comm comm_;
public:
    MPI_Comm mpi_handle() const { return comm_; }
};

// Or non-intrusively:
template <>
struct mpi::experimental::handle_traits<CommWrapper> {
    static MPI_Comm handle(CommWrapper const& c) { return c.get_comm(); }
};

Custom Infer Overloads

Add ADL-discovered infer() overloads for custom types or operations:

namespace my_namespace {
    struct CustomOp {};
    
    template <typename SBuf, typename RBuf>
    void infer(CustomOp, SBuf& sbuf, RBuf& rbuf, MPI_Comm comm) {
        // Custom size discovery logic
        // Resize rbuf if deferred
    }
}

// Called automatically before MPI operation when using a compatible buffer

Testing Patterns

Test structure:

#include <gtest/gtest.h>
#include <kamping/v2/collectives/allgather.hpp>
#include <mpi.h>

class AllgatherTest : public ::testing::Test {
protected:
    MPI_Comm comm = MPI_COMM_WORLD;
    int rank, size;
    
    void SetUp() override {
        MPI_Comm_rank(comm, &rank);
        MPI_Comm_size(comm, &size);
    }
};

TEST_F(AllgatherTest, UniformSendSizes) {
    std::vector<int> send_data(10);
    std::vector<int> recv_data(10 * size);
    
    auto [s, r] = kamping::v2::allgather(send_data, recv_data, comm);
    
    EXPECT_EQ(r.size(), 10 * size);
    // Verify contents...
}

TEST_F(AllgatherTest, DeferredRecvBuffer) {
    std::vector<int> send_data = {rank};
    std::vector<int> recv_data;  // Deferred size
    
    auto [s, r] = kamping::v2::allgather(
        send_data,
        recv_data | kamping::v2::views::resize,
        comm
    );
    
    EXPECT_EQ(r.size(), size);  // Should be resized by infer()
}

Run with multiple rank counts:

ctest --test-dir build -R test_v2_allgather  # Runs with 1, 2, 4, 8 ranks

Quick Reference: Where to Add Things

Feature

Files

Key Classes/Functions

New collective

mpi-core/include/mpi/collectives/*.hpp (core), kamping-v2/include/kamping/v2/collectives/*.hpp (v2), kamping-v2/include/kamping/v2/infer.hpp (infer tag + overload)

mpi::experimental::foo(), comm_op::foo, infer(comm_op::foo, ...)

New view adaptor

kamping-v2/include/kamping/v2/views/*.hpp

Derive from view_interface<Derived>, implement base()

Custom buffer

User code

Specialize buffer_traits<T> or provide mpi_count(), mpi_ptr(), mpi_type()

Custom handle

User code

Specialize handle_traits<T> or provide mpi_handle()

Reduction operation

mpi-core/include/mpi/ops.hpp (core), re-exported in kamping-v2/include/kamping/v2/ops.hpp

mpi::experimental::make_op(), as_mpi_op(), valid_op

Custom operation type

User code

Specialize op_traits<Op, SBuf, RBuf> (or make it convertible to MPI_Op via handle())

Non-blocking variant

Same files as blocking

Return iresult<Buf> instead of void


Ecosystem Adapters

Adapters live under ecosystem/ and connect KaMPIng to external C++ libraries. Each adapter is an independent CMake component that depends only on kamping::v2 and the target library.

SYCL adapter (ecosystem/sycl/)

Exposes SYCL buffer accessors as MPI buffers via two complementary types.

Compiler requirement: Intel oneAPI DPC++ only. The adapter relies on sycl::host_task + sycl::interop_handle (standard SYCL 2020) to extract raw device pointers. AdaptiveCpp does not implement this feature, so the adapter cannot be used with AdaptiveCpp.

Two access paths:

Accessor type

How to get a pointer

Adaptor usage

sycl::accessor (inside host_task)

interop_handle::get_native_mem<backend>(acc)

acc | views::sycl(ih)

sycl::host_accessor

host_accessor::get_pointer()

hacc | views::sycl

sycl_view<Base> wraps either accessor type:

  • For device accessors the constructor takes (Base, interop_handle const&) and stores a pointer to the handle. mpi_ptr() calls get_native_mem for the active SYCL backend (Level Zero, CUDA, HIP).

  • For host accessors the constructor takes (Base) and mpi_ptr() calls get_pointer(). The ih_ member is eliminated at zero cost via [[no_unique_address]] + std::monostate.

  • mpi_count() and mpi_type() propagate from the accessor through view_interface.

views::sycl is a single adaptor object (sycl_fn) that covers both paths:

// Device accessor — partial application binds ih, returns a pipeable closure:
acc | views::sycl(ih)

// Equivalent full call:
views::sycl(acc, ih)

// Host accessor — zero-arg closure, no handle needed:
hacc | views::sycl

sycl_accessor_traits<T> is the internal trait struct that provides mode and is_host for both sycl::accessor and sycl::host_accessor. Concepts sycl_device_accessor / sycl_host_accessor are built on top of it. Path selection in mpi_ptr() is if constexpr on these concepts.

SYCL USM pointers (sycl::malloc_device, sycl::malloc_shared) are plain C++ pointers. Wrap them in std::span or mpi_span — no sycl_view adaptor is needed.

supports_matched_probe and enable_borrowed_buffer are propagated from Base, so opt-out specializations on the accessor type carry through automatically.

Example: ecosystem/sycl/examples/sycl_send_recv_example.cpp

  • Rank 0 fills 0..9 on device, sends via device-aware MPI (acc | views::sycl(ih)).

  • Rank 1 receives into device memory, squares each element on device.

  • Rank 1 sends back via host accessor (hacc | views::sycl).

  • Rank 0 receives via host accessor and prints the result.


See Also

  • CLAUDE.md — project structure, build commands, code conventions