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++23A 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:
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):
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-2Static 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.
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 bufferSyntax
Constructors
// 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)
| Policy | Memory order | Canonical use |
|---|---|---|
std::layout_right (default) | Row-major (C-style) | General purpose, cache-friendly row traversal |
std::layout_left | Column-major (Fortran-style) | LAPACK, BLAS, column-traversal workloads |
std::layout_stride | Arbitrary per-dimension strides | Sub-matrices, padded buffers, interleaved channels |
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 columnExamples
Layout-agnostic algorithm
The same function body operates correctly regardless of the underlying memory order:
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
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)
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:
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:
// 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.
See Also
std::span(C++20) β 1D non-owning view; same ownership model, simpler interfacestd::submdspan(C++26) β slicing and sub-view extraction from an existing mdspanstd::layout_right,std::layout_left,std::layout_stride(C++23) β standard layout policiesstd::default_accessor(C++23) β the default accessor; baseline for custom implementationsstd::extents,std::dextents(C++23) β extent specification types for shape encoding