Skip to content
C++
Library
since C++23
Intermediate

std::mdspan

Multi-dimensional non-owning view over contiguous data with pluggable layout and accessor policies. C++23 replacement for raw pointer and stride arithmetic.

std::mdspansince C++23

A non-owning, zero-overhead, multi-dimensional view over a contiguous sequence of objects, parameterized by element type, extent shape, layout policy, and accessor policy.

Overview

std::mdspan (C++23, <mdspan>) generalizes std::span to N dimensions. It holds a data handle (typically a raw pointer) plus a mapping from multi-dimensional indices to a flat offset, then applies an accessor to convert that offset into a reference. All four concerns are separate, pluggable template parameters:

cpp
template<
    class T,                                        // element type
    class Extents,                                  // shape descriptor
    class LayoutPolicy   = std::layout_right,       // index-to-offset mapping
    class AccessorPolicy = std::default_accessor<T> // offset-to-reference conversion
> class mdspan;

Because the mapping and accessor inline to arithmetic at compile time, a well-optimized mdspan access generates identical machine code to hand-written ptr[i * stride + j].

Extents

std::extents<IndexType, Dims...> encodes rank and the size of each dimension. Individual dimensions are either static (embedded in the type) or dynamic (std::dynamic_extent, stored at runtime):

cpp
std::extents<int, 3, 4>                                       // fully static β€” both sizes in the type
std::extents<int, std::dynamic_extent, std::dynamic_extent>   // fully dynamic
std::extents<int, 3, std::dynamic_extent>                     // mixed β€” first static, second runtime
std::dextents<int, 2>                                         // convenience alias for all-dynamic rank-2

Static extents reduce storage and enable constant-folding in the mapping arithmetic. Dynamic extents store one IndexType per dynamic dimension in the mdspan object itself.

Element access β€” C++23 multi-dimensional subscript

C++23 extended operator[] to accept multiple arguments (P2128R6). m[i, j] is canonical; the older operator() workaround is no longer needed.

cpp
float data[12]{};
std::mdspan<float, std::extents<int, 3, 4>> m{data};

m[0, 0] = 1.0f;    // row 0, col 0  β€” C++23 multi-index subscript
m[2, 3] = 9.0f;

m.rank();           // 2   (compile-time constant β€” always static)
m.rank_dynamic();   // 0   (no dynamic extents in this instantiation)
m.extent(0);        // 3
m.extent(1);        // 4
m.size();           // 12  (total element count)
m.data_handle();    // float* to the underlying buffer

Syntax

Constructors

cpp
// Static extents β€” sizes fully encoded in the type
std::mdspan<float, std::extents<int, 3, 4>> m1{ptr};

// Dynamic extents β€” sizes passed at construction
std::mdspan<float, std::dextents<int, 2>> m2{ptr, rows, cols};

// Explicit mapping β€” required for layout_stride
std::mdspan<float, std::dextents<int, 2>, std::layout_stride> m3{ptr, mapping};

Layout policies (C++23)

PolicyMemory orderCanonical use
std::layout_right (default)Row-major (C-style)General purpose, cache-friendly row traversal
std::layout_leftColumn-major (Fortran-style)LAPACK, BLAS, column-traversal workloads
std::layout_strideArbitrary per-dimension stridesSub-matrices, padded buffers, interleaved channels
cpp
std::vector<float> buf(rows * cols);

// Column-major view β€” same buffer, Fortran order
std::mdspan<float, std::dextents<int, 2>, std::layout_left> col_major{
    buf.data(), rows, cols
};

// Strided view β€” interleaved data, col_stride=1, row_stride=2*cols
std::layout_stride::mapping<std::dextents<int, 2>> mapping{
    std::dextents<int, 2>{rows, cols},
    std::array<std::size_t, 2>{2 * cols, 1}  // {stride_dim0, stride_dim1}
};
std::mdspan<float, std::dextents<int, 2>, std::layout_stride> strided{buf.data(), mapping};

strided.stride(0);  // 2*cols β€” step in flat buffer to advance one row
strided.stride(1);  // 1      β€” step to advance one column

Examples

Layout-agnostic algorithm

The same function body operates correctly regardless of the underlying memory order:

cpp
template<typename T, typename Extents, typename Layout>
void fill_identity(std::mdspan<T, Extents, Layout> m) {
    static_assert(std::remove_cvref_t<decltype(m)>::rank() == 2);
    assert(m.extent(0) == m.extent(1));
    for (std::size_t i = 0; i < m.extent(0); ++i)
        for (std::size_t j = 0; j < m.extent(1); ++j)
            m[i, j] = (i == j) ? T{1} : T{0};
}

std::vector<float> buf(n * n);
fill_identity(std::mdspan<float, std::dextents<int, 2>>{buf.data(), n, n});
fill_identity(std::mdspan<float, std::dextents<int, 2>, std::layout_left>{buf.data(), n, n});

Matrix multiplication

cpp
void matmul(
    std::mdspan<const float, std::dextents<int, 2>> A,  // MΓ—K
    std::mdspan<const float, std::dextents<int, 2>> B,  // KΓ—N
    std::mdspan<float,       std::dextents<int, 2>> C   // MΓ—N output
) {
    const int M = A.extent(0), K = A.extent(1), N = B.extent(1);
    for (int i = 0; i < M; ++i)
        for (int j = 0; j < N; ++j) {
            float acc = 0.0f;
            for (int k = 0; k < K; ++k)
                acc += A[i, k] * B[k, j];
            C[i, j] = acc;
        }
}

4D image tensor (NCHW)

cpp
const int N = 8, C = 3, H = 224, W = 224;
std::vector<float> data(N * C * H * W);

std::mdspan<float, std::dextents<int, 4>> nchw{data.data(), N, C, H, W};

// Zero the red channel (channel 0) across all batches
for (int n = 0; n < nchw.extent(0); ++n)
    for (int h = 0; h < nchw.extent(2); ++h)
        for (int w = 0; w < nchw.extent(3); ++w)
            nchw[n, 0, h, w] = 0.0f;

Custom accessor for debug bounds checking

The accessor policy controls how a flat offset becomes a reference β€” the right place to add per-element validation without touching the layout or call sites:

cpp
template<typename T>
struct bounds_checked_accessor {
    using element_type     = T;
    using reference        = T&;
    using data_handle_type = T*;
    using offset_policy    = bounds_checked_accessor;

    reference access(data_handle_type p, std::ptrdiff_t i) const noexcept {
        return p[i];
    }
    data_handle_type offset(data_handle_type p, std::ptrdiff_t i) const noexcept {
        return p + i;
    }
};

#ifdef NDEBUG
using Accessor = std::default_accessor<float>;
#else
using Accessor = bounds_checked_accessor<float>;
#endif

std::mdspan<float, std::dextents<int, 2>, std::layout_right, Accessor>
    safe_m{buf.data(), rows, cols};

Sub-views with std::submdspan (C++26)

std::submdspan ships in C++26, not C++23. Verify your standard library version before using it:

cpp
// C++26 only
auto m = std::mdspan<float, std::extents<int, 6, 6>>{data};

// Block: rows [1,4), all columns β†’ 3Γ—6 mdspan with layout_stride
auto block = std::submdspan(m, std::pair{1, 4}, std::full_extent);

// Row slice β†’ rank-1 mdspan
auto row2  = std::submdspan(m, 2, std::full_extent);

// Column slice β†’ rank-1 mdspan with stride = 6
auto col3  = std::submdspan(m, std::full_extent, 3);

The result type automatically acquires layout_stride whenever the selected region cannot be represented by a simpler mapping.


Best Practices

Pass mdspan by value. It is a lightweight view (pointer + small inline metadata). Passing by const reference adds an indirection and prevents the compiler from placing fields in registers.

Use const element type for read-only parameters. mdspan<const float, ...> documents intent and enables aliasing optimizations. mdspan<float, E, L> implicitly converts to mdspan<const float, E, L> with the same extents and layout.

Use dextents for fully runtime-sized views. It avoids spelling out extents<int, dynamic_extent, dynamic_extent, ...> and is less error-prone when rank changes.

Match layout to your access pattern. Traversing rows in a layout_left (column-major) mdspan produces stride-rows accesses β€” devastating for cache on large matrices. Either choose the layout that matches your hottest loop, or restructure loops to match the layout.

Use layout_left for BLAS/LAPACK interop. Both libraries default to Fortran column-major order. A layout_left mdspan means no copy is needed to hand data to dgemm or similar routines.


Common Pitfalls

m[i, j] is C++23 β€” earlier standards silently miscompile it. In C++20 and earlier, the comma inside [] is the built-in comma operator: m[i, j] parses as m[(i, j)] = m[j], a 1D access into the flat buffer. It compiles without warning and produces wrong answers.

std::submdspan requires C++26, not C++23. Many articles present it as part of the original mdspan feature set; it was standardized a cycle later.

Default layout is row-major (layout_right). Passing a row-major mdspan to a BLAS routine that expects column-major silently interprets the data as transposed.

Strides in layout_stride map dimension index, not the matrix row. For a 3D (D0, D1, D2) tensor in row-major order, the stride array is {D1*D2, D2, 1} β€” not {D0, 1} as one might guess from 2D intuition.

mdspan does not own its data. The underlying buffer must outlive every view over it. Storing an mdspan as a class member while the source std::vector lives elsewhere is a dangling-pointer bug waiting to fire.

rank() is always a compile-time constant. Even for fully dynamic mdspans, the rank is encoded in the Extents type and resolved at compile time. rank_dynamic() gives the count of dimensions whose sizes are stored at runtime.


Layout Policies In Depth

How memory order maps to indices

All three standard layout policies map a multi-index (i0, i1, ..., iN-1) to a single flat offset, but differ in which index varies fastest.

layout_right β€” row-major (C default)

The rightmost index is contiguous in memory (stride 1). Advancing the rightmost index by 1 moves to the very next byte. This matches how C arrays are laid out and is the default in std::mdspan.

For a 3Γ—4 matrix with layout_right, element [row, col] sits at flat offset row * 4 + col:

cpp
Flat buffer:  [0][1][2][3][4][5][6][7][8][9][10][11]
              ^row0^         ^row1^         ^ row2  ^
              [0,0][0,1][0,2][0,3]
                             [1,0][1,1][1,2][1,3]
                                          [2,0][2,1][2,2][2,3]

Walking row 1 left-to-right β€” m[1,0], m[1,1], m[1,2], m[1,3] β€” reads four consecutive memory locations. The hardware prefetcher loves this.

layout_left β€” column-major (Fortran / BLAS default)

The leftmost index is contiguous (stride 1). Element [row, col] sits at flat offset col * rows + row.

Same 3Γ—4 matrix with layout_left:

cpp
Flat buffer:  [0][1][2][3][4][5][6][7][8][9][10][11]
Column 0: [0,0]=buf[0]  [1,0]=buf[1]  [2,0]=buf[2]
Column 1: [0,1]=buf[3]  [1,1]=buf[4]  [2,1]=buf[5]
Column 2: [0,2]=buf[6]  [1,2]=buf[7]  [2,2]=buf[8]
Column 3: [0,3]=buf[9]  [1,3]=buf[10] [2,3]=buf[11]

Walking column 0 top-to-bottom β€” m[0,0], m[1,0], m[2,0] β€” reads three consecutive memory locations. Walking row 0 left-to-right β€” m[0,0], m[0,1], m[0,2], m[0,3] β€” jumps by stride 3 each time.

This output from cppstories.com confirms the effect in practice β€” given {0,1,2,3,4,5,6,7,8,9} viewed as a 2Γ—3 matrix:

cpp
layout_right:  0 1 2 / 3 4 5      (elements in buffer order, per row)
layout_left:   0 2 4 / 1 3 5      (same buffer, column-major reading)

layout_stride β€” arbitrary per-dimension strides

Stores one stride value per dimension. Element [i0, i1, ..., iN-1] maps to i0*s0 + i1*s1 + ... + iN-1*sN-1. Strides need not be contiguous and need not be ordered.

cpp
// Sub-matrix: every other column of a 4Γ—8 row-major matrix
// Underlying buffer is 4*8 = 32 floats, row stride = 8, col stride = 2
std::layout_stride::mapping<std::dextents<int, 2>> mapping{
    std::dextents<int, 2>{4, 4},         // logical shape 4Γ—4
    std::array<std::size_t, 2>{8, 2}     // {row_stride, col_stride}
};
std::mdspan<float, std::dextents<int, 2>, std::layout_stride>
    every_other_col{buf.data(), mapping};
// every_other_col[r, c] β†’ buf[r*8 + c*2]

layout_stride is the result type produced by std::submdspan whenever the slice cannot be represented by a simpler layout. It is also the canonical way to wrap padded or interleaved GPU buffers.

C++26 padded layouts

Two additional policies arrive in C++26 for BLAS-friendly tiled algorithms:

  • layout_right_padded<PaddingStride> β€” row-major with a leading-dimension padding, like the LDA parameter in BLAS. Enables safe submdspan slicing of columns while preserving a static stride.
  • layout_left_padded<PaddingStride> β€” column-major equivalent.

Layout and Cache Performance

Choosing the wrong layout for your access pattern is one of the most common performance mistakes with multidimensional data. The issue is cache lines.

A typical CPU cache line is 64 bytes β€” 16 float values or 8 double values. When you access one element, the hardware loads an entire cache line. If the next access is to the adjacent element in memory, the value is already in cache (cache hit). If the next access is N elements away β€” where N is large β€” the cache line is useless and a new one must be fetched (cache miss).

The row-major trap in column-major code

Suppose you have a large matrix stored in layout_left (column-major) and you iterate over it row by row:

cpp
// BAD: row-major traversal of a column-major matrix
std::mdspan<double, std::dextents<int, 2>, std::layout_left> m{ptr, rows, cols};

for (std::size_t i = 0; i < m.extent(0); ++i)       // rows (leftmost β€” stride = 1)
    for (std::size_t j = 0; j < m.extent(1); ++j)   // cols (rightmost β€” stride = rows)
        process(m[i, j]);

Each m[i, j] is rows elements away from m[i, j-1]. For a 1024-row matrix of double, that is a stride of 8 KB β€” far past any cache line. Every single element access is a cache miss. On a modern CPU this can be 100Γ— slower than the cache-friendly equivalent.

cpp
// GOOD: column-major traversal of a column-major matrix
for (std::size_t j = 0; j < m.extent(1); ++j)       // cols outer
    for (std::size_t i = 0; i < m.extent(0); ++i)   // rows inner β€” stride 1
        process(m[i, j]);

The rule: the inner loop index should be the index whose stride is 1.

  • layout_right (default): innermost loop over the last index (j for a 2D matrix).
  • layout_left: innermost loop over the first index (i for a 2D matrix).

Strided access and auto-vectorization

Strided access also breaks SIMD auto-vectorization. A vectorized load instruction expects contiguous data. A stride-1 inner loop can be vectorized (the compiler emits vmovups / vloadps). A strided inner loop forces scalar fallback or a gather instruction, which is far slower.

When you must traverse mismatched layouts β€” for example, to call a BLAS routine that expects column-major on row-major data β€” consider an explicit transposition into a temporary buffer rather than paying stride penalties in a hot loop.


std::submdspan (C++26)

std::submdspan constructs a new mdspan that views a contiguous or strided subset of an existing one, with no data copies and no heap allocation. The function lives in <mdspan> and requires C++26.

Signature

cpp
template<class T, class Extents, class Layout, class Accessor,
         class... SliceSpecifiers>
auto submdspan(
    std::mdspan<T, Extents, Layout, Accessor> src,
    SliceSpecifiers... slices
) -> /* mdspan with deduced extents, layout, accessor */;

Exactly one slice specifier is required per dimension of src.

Slice specifiers

SpecifierEffectRank change
Integer kFixes that dimension to index kDrops by 1
std::pair{lo, hi}Half-open range [lo, hi)Preserved
std::full_extentAll indices in that dimensionPreserved
std::strided_slice{.offset, .extent, .stride}Regularly spaced subsetPreserved

Basic slicing

cpp
// C++26
#include <mdspan>
std::vector<int> data = {
    10, 20, 30, 40,
    50, 60, 70, 80,
    90,100,110,120
};
std::mdspan matrix(data.data(), 3, 4);  // 3Γ—4, layout_right

// Fix row 1 β†’ rank-1 span of 4 elements
auto row1 = std::submdspan(matrix, 1, std::full_extent);
// row1[0]=50, row1[2]=70

// Fix col 2 β†’ rank-1 span of 3 elements, stride = 4
auto col2 = std::submdspan(matrix, std::full_extent, 2);
// col2[0]=30, col2[1]=70, col2[2]=110

// Block: rows [1,3), cols [1,3) β†’ 2Γ—2 sub-matrix
auto block = std::submdspan(matrix, std::pair{1, 3}, std::pair{1, 3});
// block[0,0]=60, block[1,1]=110

Strided slices with std::strided_slice

std::strided_slice is an aggregate with three fields: offset (start index), extent (count of indices in the half-open window), and stride (step between selected indices). The number of selected elements is 1 + (extent - 1) / stride when extent > 0.

cpp
// C++26
std::vector<int> data(36);
std::iota(data.begin(), data.end(), 0);
std::mdspan grid(data.data(), 6, 6);  // 6Γ—6

// Every other row (0, 2, 4), all columns β†’ 3Γ—6 result
auto even_rows = std::submdspan(
    grid,
    std::strided_slice{.offset = 0, .extent = 3, .stride = 2},
    std::full_extent
);
// even_rows[0, 0]=0, even_rows[1, 0]=12, even_rows[2, 0]=24

The .extent field is the count of desired output rows, not the end index. This distinction allows strided_slice to produce a static extent when extent and stride satisfy integral-constant-like, enabling compile-time loop unrolling.

3D example: simulation grid with ghost cells

A common HPC pattern: pad a grid with ghost-cell borders, compute only on the interior.

cpp
// C++26
#include <mdspan>
#include <vector>

void heat_step(std::mdspan<double, std::dextents<int, 2>> interior) {
    for (int r = 1; r < interior.extent(0) - 1; ++r)
        for (int c = 1; c < interior.extent(1) - 1; ++c)
            interior[r, c] = 0.25 * (
                interior[r-1, c] + interior[r+1, c] +
                interior[r, c-1] + interior[r, c+1]
            );
}

int main() {
    // 102Γ—102 full grid (1-cell ghost border on each side)
    std::vector<double> buf(102 * 102, 0.0);
    std::mdspan full_grid(buf.data(), 102, 102);

    // Slice to the 100Γ—100 interior β€” no manual offset arithmetic
    auto interior = std::submdspan(
        full_grid,
        std::pair{1, 101},
        std::pair{1, 101}
    );
    heat_step(interior);
}

Layout propagation rules

  • Fixing an integer index or taking std::full_extent may preserve layout_right or layout_left when the result is still contiguous.
  • Taking a std::pair range or std::strided_slice often forces the result to layout_stride.
  • The compiler deduces the correct result layout automatically; no explicit annotation is needed.
  • Downstream code that templates on layout will correctly receive layout_stride and must not assume contiguity.

submdspan is a view, not a copy

The resulting span holds a pointer into the same memory as src. The source buffer must outlive all submdspan objects derived from it. If the source std::vector reallocates or goes out of scope, every derived submdspan becomes dangling.


Accessor Policies In Depth

What an accessor does

The accessor policy is the fourth template parameter of mdspan. It bridges the flat offset produced by the layout mapping and the actual element reference returned by operator[]. The default, std::default_accessor<T>, simply returns ptr[offset].

An accessor must provide four nested names and two member functions:

cpp
struct MyAccessor {
    using element_type     = /* the element type */;
    using reference        = /* what operator[] returns */;
    using data_handle_type = /* what holds the pointer, e.g. T* */;
    using offset_policy    = /* accessor type returned by offset() */;

    // Convert pointer + flat offset to a reference
    reference access(data_handle_type p, std::size_t i) const noexcept;

    // Advance the data handle by i positions
    data_handle_type offset(data_handle_type p, std::size_t i) const noexcept;
};

offset_policy allows the accessor to change type when a submdspan is taken. For most custom accessors, setting it to the accessor itself is correct.

std::default_accessor<T> internals

default_accessor is trivially copyable and stateless. Its implementation is exactly:

cpp
template<class T>
struct default_accessor {
    using element_type     = T;
    using reference        = T&;
    using data_handle_type = T*;
    using offset_policy    = default_accessor;

    constexpr reference access(T* p, std::size_t i) const noexcept {
        return p[i];
    }
    constexpr T* offset(T* p, std::size_t i) const noexcept {
        return p + i;
    }
};

Because both functions are constexpr and have no state, the compiler inlines them to a single load instruction in optimized builds.

std::aligned_accessor (C++26)

C++26 adds std::aligned_accessor<T, ByteAlignment>, which expresses that the data pointer is over-aligned to at least ByteAlignment bytes. This lets the compiler emit aligned load instructions (vmovaps instead of vmovups on x86) and enables stricter auto-vectorization.

cpp
// C++26
#include <mdspan>
constexpr std::size_t kAlign = 64;  // AVX-512 alignment
alignas(kAlign) float buf[1024];

using AlignedView = std::mdspan<
    float,
    std::dextents<int, 2>,
    std::layout_right,
    std::aligned_accessor<float, kAlign>
>;
AlignedView m{buf, 32, 32};
// The compiler now knows buf is 64-byte aligned and may emit vmovaps

aligned_accessor can be constructed from a default_accessor via an explicit constructor, asserting at the call site that the precondition holds:

cpp
std::mdspan<float, std::dextents<int, 2>> untyped{buf, 32, 32};
AlignedView typed{untyped};  // explicit β€” programmer asserts alignment

Custom accessor: debug bounds checking

The existing page shows a stub; here is a complete, standards-conforming bounds-checking accessor:

cpp
#include <mdspan>
#include <stdexcept>
#include <format>

template<typename T>
struct checked_accessor {
    using element_type     = T;
    using reference        = T&;
    using data_handle_type = T*;
    using offset_policy    = checked_accessor;

    std::size_t total_size;  // stored so access() can validate

    explicit checked_accessor(std::size_t n) : total_size(n) {}

    // Implicit conversion from default_accessor β€” required for mdspan
    // implicit converting constructor
    checked_accessor(std::default_accessor<T>, std::size_t n)
        : total_size(n) {}

    reference access(T* p, std::size_t i) const {
        if (i >= total_size)
            throw std::out_of_range(
                std::format("mdspan: offset {} >= size {}", i, total_size));
        return p[i];
    }
    T* offset(T* p, std::size_t i) const noexcept { return p + i; }
};

// Usage
std::vector<float> buf(12);
checked_accessor<float> acc{buf.size()};
std::mdspan<float, std::dextents<int, 2>, std::layout_right,
            checked_accessor<float>>
    safe_m{buf.data(), acc, 3, 4};

safe_m[2, 3];   // ok β€” flat offset 11 < 12
// safe_m[3, 0] would throw β€” flat offset 12 >= 12

Custom accessor: atomic element access

An accessor can change the reference type entirely. Here reference is std::atomic_ref<T>, making every element access thread-safe with no extra synchronization at the call site:

cpp
#include <atomic>
#include <mdspan>

template<typename T>
struct atomic_accessor {
    using element_type     = T;
    using reference        = std::atomic_ref<T>;
    using data_handle_type = T*;
    using offset_policy    = atomic_accessor;

    reference access(T* p, std::size_t i) const noexcept {
        return std::atomic_ref<T>{p[i]};
    }
    T* offset(T* p, std::size_t i) const noexcept { return p + i; }
};

// Thread-safe histogram accumulation
std::vector<int> counts(256, 0);
std::mdspan<int, std::dextents<int, 1>, std::layout_right,
            atomic_accessor<int>>
    histogram{counts.data(), atomic_accessor<int>{}, 256};

// Safe to call concurrently from multiple threads
histogram[value].fetch_add(1, std::memory_order_relaxed);

See Also

  • std::span (C++20) β€” 1D non-owning view; same ownership model, simpler interface
  • std::submdspan (C++26) β€” slicing and sub-view extraction from an existing mdspan
  • std::layout_right, std::layout_left, std::layout_stride (C++23) β€” standard layout policies
  • std::default_accessor (C++23) β€” the default accessor; baseline for custom implementations
  • std::extents, std::dextents (C++23) β€” extent specification types for shape encoding