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.
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:
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:
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:
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.
// 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 theLDAparameter 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:
// 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.
// 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 (jfor a 2D matrix).layout_left: innermost loop over the first index (ifor 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
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
| Specifier | Effect | Rank change |
|---|---|---|
Integer k | Fixes that dimension to index k | Drops by 1 |
std::pair{lo, hi} | Half-open range [lo, hi) | Preserved |
std::full_extent | All indices in that dimension | Preserved |
std::strided_slice{.offset, .extent, .stride} | Regularly spaced subset | Preserved |
Basic slicing
// 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]=110Strided 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.
// 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]=24The .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.
// 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_extentmay preservelayout_rightorlayout_leftwhen the result is still contiguous. - Taking a
std::pairrange orstd::strided_sliceoften forces the result tolayout_stride. - The compiler deduces the correct result layout automatically; no explicit annotation is needed.
- Downstream code that templates on layout will correctly receive
layout_strideand 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:
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:
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.
// 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 vmovapsaligned_accessor can be constructed from a default_accessor via an explicit constructor, asserting at the call site that the precondition holds:
std::mdspan<float, std::dextents<int, 2>> untyped{buf, 32, 32};
AlignedView typed{untyped}; // explicit β programmer asserts alignmentCustom accessor: debug bounds checking
The existing page shows a stub; here is a complete, standards-conforming bounds-checking accessor:
#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 >= 12Custom 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:
#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 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