std::mdspan в C++23: многомерные данные без самодельных view

от автора

Привет, Хабр!

В C++ долго не было нормального стандартизованного способа адресовать многомерные данные без самодельных обвязок на указателях, индексации по формуле и пачек typedef с макросами. В C++20 появился std::span для одномерных непрерывных диапазонов. Следующий логичный шаг — многомерный view с настраиваемым отображением индексов в адреса памяти. Этим шагом в C++23 стал std::mdspan в заголовке <mdspan>. Это не контейнер и не владеет памятью, это слой адресации поверх уже существующего буфера. Формально идею закрепили в P0009, а в стандарт попали mdspan, extents и политики layout; отдельная функция submdspan пошла в следующую версию стандарта C++26.

Что такое mdspan и зачем он нужен

std::mdspan<T, Extents, LayoutPolicy, AccessorPolicy> — это способ сказать компилятору: у меня есть буфер из T, а обращаться я хочу по многомерным индексам (i, j, k, ...), причём правило преобразования индексов в линейный offset настраивается. Базовые вещи:

  • extents описывает размерность, где каждая размерность может быть статической константой или динамической величиной.

  • layout_right и layout_left задают row‑major и column‑major порядок размещения соответственно. Есть и гибкий layout_stride для произвольных шагов по каждой оси.

  • accessor управляет тем, как по одномерному offset»у достать ссылку на элемент. По дефолту‑ прямой доступ по указателю, но интерфейс расширяемый.

Важно помнить две вещи. Первое: mdspan не проверяет границы и не следит за сроком жизни буфера. Второе: время выполнения индексации — чистая арифметика по strides, одинаковая для любой конфигурации при оптимизациях. Конструкторы требуют, чтобы размер буфера покрывал mapping.required_span_size() — иначе поведение не определено, т.е проверяйте сами входные данные.

Базовая инициализация

Начнём с обычной матрицы rows × cols поверх std::vector<double> в памяти row‑major. Валидируем размеры, создаём view, аккуратно работаем с константностью и не плодим лишних копий.

#include <vector> #include <mdspan> #include <cassert> #include <cstddef>  using index_t = std::size_t; using dyn2d = std::extents<index_t, std::dynamic_extent, std::dynamic_extent>; using layout = std::layout_right; // row-major  // Невладеющий view матрицы double[rows][cols] using md_matrix = std::mdspan<double, dyn2d, layout>; using cmd_matrix = std::mdspan<const double, dyn2d, layout>;  inline md_matrix make_matrix(double* data, index_t rows, index_t cols) {     assert(data != nullptr);     md_matrix m{data, rows, cols};     // Проверка объёма буфера: для row-major required_span_size == rows*cols     assert(m.mapping().required_span_size() == rows * cols);     return m; }  inline cmd_matrix make_matrix(const double* data, index_t rows, index_t cols) {     assert(data != nullptr);     cmd_matrix m{data, rows, cols};     assert(m.mapping().required_span_size() == rows * cols);     return m; }  int main() {     std::vector<double> buf(6);     auto M = make_matrix(buf.data(), 2, 3);     M(0,0) = 1.0;     M(1,2) = 42.0; }

Индексация всегда в математическом порядке (row, col), а реальный порядок в памяти решает layout. Поменяете на layout_left — внешний код с (i, j) останется прежним, изменится только формула offset.

Статические и динамические extents

Если какие‑то размерности известны на этапе компиляции, имеет смысл зафиксировать их в типе.

// 4x4 матрица со статическими размерностями using mat4x4 = std::mdspan<float,     std::extents<index_t, 4, 4>, std::layout_right>;  void mul_add(mat4x4 A, mat4x4 B, mat4x4 C) {     // Простой i-j-k, компилятор любит разворачивать такие циклы     for (index_t i = 0; i < 4; ++i)         for (index_t k = 0; k < 4; ++k) {             float s = 0.f;             for (index_t j = 0; j < 4; ++j) s += A(i, j) * B(j, k);             C(i, k) += s;         } }

extents статичны, значит часть вычислений по stride доступны компилятору в виде констант.

layout_stride: вью на подматрицу, pitch и транспонирование без копий

layout_stride позволяет создать view с произвольными шагами по осям. Типичный кейс — изображение с шагом строки pitch или подматрица в большом буфере. Другой кейс — транспонированный вид поверх той же памяти.

Первое — ROI с фиксированным шагом строки. Пусть есть буфер uint8_t с высотой H, шириной W и шагом строки pitch в элементах. Хотим адресовать прямоугольник h × w, начиная с (row0, col0).

#include <mdspan>  using index_t = std::size_t; using dyn2d   = std::extents<index_t, std::dynamic_extent, std::dynamic_extent>; using stride_map = std::layout_stride::mapping<dyn2d>;  template<class T> std::mdspan<T, dyn2d, std::layout_stride> make_roi(T* base, index_t pitch, index_t row0, index_t col0,          index_t h, index_t w) {     // Смещение в линейном буфере     const index_t offset = row0 * pitch + col0;      // Страйды для 2D: шаг по строке — pitch, по столбцу — 1     stride_map m{ dyn2d{h, w}, std::array<index_t,2>{pitch, 1} };      // required_span_size учитывает самую дальнюю точку ROI     auto span_size = m.required_span_size();      // проверьте, что буфер действительно покрывает [offset, offset + span_size)     return { base + offset, m }; }

Второе — транспонирование без копирования. Для row‑major буфера транспонированный вид — это перестановка extents и соответствующих stride. Делается на тех же примитивах:

template<class T> auto transpose_view(std::mdspan<T, dyn2d, std::layout_right> a) {     // Оригинальные параметры     const index_t r = a.extent(0);     const index_t c = a.extent(1);     // Страйды для row-major: stride_row = c, stride_col = 1     const index_t stride_row = a.mapping().stride(0);     const index_t stride_col = a.mapping().stride(1);      // Меняем роли осей: новая "строка" шагает как старый столбец и наоборот     stride_map mt{ dyn2d{c, r}, std::array<index_t,2>{stride_col, stride_row} };     return std::mdspan<T, dyn2d, std::layout_stride>{ a.data_handle(), mt }; }

Через layout_stride удобно выражать вырожденные срезы и виды на кусок памяти, где нет непрерывности по строке.

Где взять срезы сейчас и что с submdspan

Отдельная свободная функция submdspan(x, slices...) позволяет выдать view на поддиапазон любого ранга: фиксировать индекс, задавать полуинтервалы, шаги. По идее она шла вместе с mdspan в ранних ревизиях P0009, но в финал C++23 не вошла. Консенсусный вариант попал в C++26, поверх него в 2024 году дорабатывали специфику pair‑like типов и детали совместимости с padded‑layout. На C++23 сегодня либо пишут тонкие адаптеры через layout_stride, как выше, либо используют промежуточные реализации из библиотек

Компиляторная и библиотечная поддержка эволюционирует: libstdc++ и libc++ несут <mdspan> в стандартной библиотеке под C++23, а submdspan появляется вместе с C++26. Отдельные вендоры предоставляли экспериментальные реализации в пространствах имён experimental, а референсная реализация лежит в репозитории Kokkos mdspan.

Универсальные сигнатуры: принимать mdspan как параметр

Удобно принимать mdspan по значению с максимально общими параметрами. Это не копия данных, а несколько слов метаданных. Ниже пример безопасной масштабирующей операции над матрицей любого layout, любой константности и с проверкой размеров.

template<class Element, class Extents, class Layout, class Accessor> void scale_matrix(std::mdspan<Element, Extents, Layout, Accessor> a, Element alpha) {     static_assert(Extents::rank() == 2);     const auto r = a.extent(0), c = a.extent(1);     for (std::size_t i = 0; i < r; ++i)         for (std::size_t j = 0; j < c; ++j)             a(i, j) *= alpha; }  // Пример использования void demo(std::vector<float>& buf, std::size_t rows, std::size_t cols) {     std::mdspan<float, std::extents<std::size_t, std::dynamic_extent, std::dynamic_extent>,                 std::layout_right> M{buf.data(), rows, cols};     // Проверка буфера     if (M.mapping().required_span_size() != rows * cols) throw std::runtime_error("size mismatch");     scale_matrix(M, 0.5f); }

Такой стиль дружит с inlining и не заставляет выносить логику в шаблон параметров layout»а. При этом вы свободно комбинируете row‑major, column‑major и strided виды.

Собственная политика доступа: встраиваем проверки

По дефолту accessor это просто доступ к T*. Но интерфейс AccessorPolicy позволяет внедрить проверку доступа и затем использовать её на любом layout. Требования к AccessorPolicy прописаны в стандарте: нужны element_type, data_handle_type, reference, offset_policy, а также методы access и offset. Ниже показан минимальный безопасный аксессор, который хранит длину доступного диапазона и проверяет выход за пределы.

#include <mdspan> #include <cassert>  template<class T> struct checked_accessor {     using element_type    = T;     using reference       = T&;      struct data_handle_type {         T* p{};         std::size_t n{}; // доступный диапазон [0, n)     };      using offset_policy = checked_accessor;      constexpr reference access(const data_handle_type& dh, std::size_t i) const noexcept {         assert(i < dh.n && "mdspan out of bounds");         return dh.p[i];     }      constexpr data_handle_type offset(const data_handle_type& dh, std::size_t i) const noexcept {         assert(i <= dh.n);         return { dh.p + i, dh.n - i };     } };  template<class Extents, class Layout> auto make_checked_mdspan(typename checked_accessor<double>::data_handle_type dh,                          const Layout& map) {     using md_t = std::mdspan<double, Extents, Layout, checked_accessor<double>>;     // Предусловие: [0, map.required_span_size()) ⊆ [0, dh.n)     assert(map.required_span_size() <= dh.n);     return md_t{ dh, map, checked_accessor<double>{} }; } 

С таким аксессором можно создавать как обычные, так и strided виды, а проверки будут централизованы и легко отключаемы при сборке без assert»ов.

Производительные штучки

Во‑первых, выбирайте layout осознанно под порядок обхода. Если внешний цикл идёт по строкам, row‑major (layout_right) уменьшит количество cache‑miss; если критично проходить по столбцам — ровно наоборот, layout_left.

Во‑вторых, для фиксированных размеров отдавайте приоритет статическим extents: тип становится конкретнее, компилятор снимает часть арифметики по stride.

В‑третьих, используйте layout_stride вместо ручной индексации при любом нестандартном memory pitch, а затем придерживайтесь линейного обхода во внутреннем цикле.

В‑четвёртых, аккуратно проверяйте размеры при создании view. Для непрерывных раскладок проверяйте rows * cols == required_span_size; для strided — что буфер покрывает [base_offset, base_offset + required_span_size). Не полагайтесь на sizeof(buf) или подобные эвристики.

Сравнение с «самодельными» view и почему стоит перейти

Раньше код выглядел так: «у меня есть T* base, есть stride0 и stride1, а дальше (base + istride0 + j*stride1)». Проблемы очевидны: отсутствует единый тип, не проверяются размеры, неявная зависимость от соглашения о раскладке, всякие constexpr‑возможности не используются.

mdspan стандартизует этот контракт: единый тип view, единая семантика вызова operator(), политики layout и accessors, понятные preconditions, возможность статически зафиксировать размерности, кросс‑платформенное поведение. Появляется возможность писать функции высшего уровня по многомерным данным без привязки к внутренней формуле offset»а. Это и была исходная мотивация авторов mdspan.

Минимальный каркас

Завершим материал компактной утилитой, которой можно пользоваться в проекте. Она инкапсулирует создание матричных view, проверки и несколько удобных представлений без копий.

#include <mdspan> #include <vector> #include <stdexcept>  namespace mds {  using idx = std::size_t; using dyn1 = std::extents<idx, std::dynamic_extent>; using dyn2 = std::extents<idx, std::dynamic_extent, std::dynamic_extent>;  template<class T> using matrix = std::mdspan<T, dyn2, std::layout_right>;  template<class T> using vector = std::mdspan<T, dyn1>;  template<class T> matrix<T> as_matrix(T* data, idx rows, idx cols, idx capacity) {     matrix<T> m{data, rows, cols};     const auto need = m.mapping().required_span_size();     if (need > capacity) throw std::out_of_range("as_matrix: buffer too small");     return m; }  template<class T> vector<T> as_vector(T* data, idx n, idx capacity) {     vector<T> v{data, n};     const auto need = v.mapping().required_span_size();     if (need > capacity) throw std::out_of_range("as_vector: buffer too small");     return v; }  template<class T> auto roi(matrix<T> base, idx row0, idx col0, idx h, idx w) {     using map_t = std::layout_stride::mapping<dyn2>;     // stride_row и stride_col для row-major: (cols, 1)     const idx sr = base.mapping().stride(0);     const idx sc = base.mapping().stride(1);     const idx offset = row0 * sr + col0 * sc;     map_t m{ dyn2{h, w}, std::array<idx,2>{sr, sc} };     // Проверка, что ROI помещается в исходный буфер     if (offset + m.required_span_size() > base.mapping().required_span_size())         throw std::out_of_range("roi: out of range");     return std::mdspan<T, dyn2, std::layout_stride>{ base.data_handle() + offset, m }; }  template<class T> auto transposed(matrix<T> a) {     using map_t = std::layout_stride::mapping<dyn2>;     const idx sr = a.mapping().stride(0);     const idx sc = a.mapping().stride(1);     map_t mt{ dyn2{ a.extent(1), a.extent(0) }, std::array<idx,2>{ sc, sr } };     return std::mdspan<T, dyn2, std::layout_stride>{ a.data_handle(), mt }; }  } // namespace mds

std::mdspan закрывает проблему с адресацией многомерных данных — единый контракт поверх буфера, явные extents, предсказуемые layout_right и layout_left, гибкий layout_stride, настраиваемые аксессоры и понятный путь к будущему <linalg>. Если уже внедряли, поделитесь опытом: где упростили код, какие паттерны заходят, как повели себя бенчмарки на разных раскладках, и что с миграцией на submdspan в C++26.

Если вы следили за тем, как в C++23 появился std::mdspan для аккуратной и безопасной работы с многомерными данными, вы наверняка оцените важность системного подхода к ресурсам и корректной индексации. Те же принципы лежат в основе базовых механизмов языка — управление памятью, обработка ошибок, копирование и перемещение объектов.

В рамках курса C++ Developer. Basic мы проводим три бесплатных открытых урока, которые помогут закрепить эти фундаментальные знания:

Если вы хотите ознакомиться с множеством отзывов о курсе «C++ Developer. Basic», вы можете посетить страницу с отзывами.


ссылка на оригинал статьи https://habr.com/ru/articles/938014/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *