C++23 把連續資料當成多維陣列存取的容器:mdspan

| | 0 Comments| 10:10
Categories:

std::mdspanC++ Reference)是 C++23 中,個人相當期待的一個新的標準函示庫容器;它的名稱是「multi-dimensional span」、基本上算是 C++20std::span 的多維度版本。

簡單講,它可以讓開發者把一個連續的資料(陣列)當成二維、三維、甚至更多維度的資料來存取;這點對於要做矩陣計算、影像處理(二維陣列)的人來說,是相當方便的東西!

而由於之前 Heresy 習慣使用的編譯器(g++)都沒有支援,所以 Heresy 也還沒有去玩過;而微軟在 Visual Studio 2022 17.9 也終於在標準函式庫裡面加入這個模組了,所以也就可以來玩看看了!

基本使用

首先,他最簡單的使用情境大概會是下面的樣子:

#include <array>
#include <mdspan>
#include <iostream>
 
int main()
{
  // 1D data
  std::array<char, 24> aData;
  for (size_t i = 0; i < aData.size(); ++i)
    aData[i] = 'a' + i;
 
  // 2D view as matrix
  std::mdspan matrix(aData.data(), 4, 6);
 
  // output matrix
  for (size_t i = 0; i != matrix.extent(0); ++i)
  {
    for (size_t j = 0; j != matrix.extent(1); ++j)
      std::cout << matrix[i, j] << " ";
    std::cout << "\n";
  }
}

這樣的程式最後輸出的結果會是下面的樣子:

a b c d e f
g h i j k l
m n o p q r
s t u v w x

在上面的例子裡面,是先定義一個長度是 24 的一維陣列資料 aData 出來,然後裡面填入 a – x 的字元。

接下來,則是透過 std::mdspanaData 從新封裝成一個 4×6 的二維陣列 matrix
這邊要注意的是,和 std::span 一樣,他並沒有資料的所有權、只是一個「view」而已。

這邊宣告的型別雖然只有寫 std::mdspan,但是實際上他是 template 型別,他會根據初始化條件和預設值來決定 template 參數;在 MSVC 他實際上的型別會是

std::mdspan<char,
std::extents<unsigned __int64, -1, -1>,
std::layout_right,
std::default_accessor<char>>

不過細節的部份,就之後再說明了。

在把 std::array 的資料封裝成 std::mdspan 之後呢,就可以把 matrix 當作二維陣列來操作、進行存取了!

這邊可以透過 extent() 這個函式、來取得指定軸向的大小;而之後,則可以透過 operator[] 來存取資料,使用上應該還算直覺。

Visual Studio 的支援不足

不過很可惜的,雖然 Visual Studio 2022 17.9 已經支援了 std::mdspan,但是上面的程式是編譯不過的。

這是因為就算是目前最新的 Visual Studio 2022 17.11、C++ 核心語言的部分還是不支援多維度 subscript operator([] ,所以 matrix[ i, j ] 這樣的存取方法是不能使用的… orz

現階段要使用 MSVC 的 std::mdspan 需要把索引值先封裝成 std::array 或是使用 std::span 才行,也就是說要存取 (i, j) 的時候,需要透過 matrix[std::array{i,j}] 這樣的形式才行。

這邊大概就只能看微軟什麼時候要支援了?

更高維度

std::mdspan 的支援性不限於二維,也可以應用在更高維度。像是如果以上面同樣的 aData 來說,這邊也可以封裝成三維的形式來存取。

// 3D view as volume
std::mdspan volume(aData.data(), 2, 3, 4);

// output volume
for (size_t i = 0; i != volume.extent(0); ++i)
{
  for (size_t j = 0; j != volume.extent(1); ++j)
  {
    for (size_t k = 0; k != volume.extent(2); ++k)
      std::cout << volume[i, j, k] << " ";
    std::cout << "\n";
  }
  std::cout << "\n";
}

這邊基本上就是把大小是 24 的一維陣列、封裝成 2 x 3 x 4 的三維陣列了~它的結果會是:

a b c d
e f g h
i j k l

m n o p
q r s t
u v w x

型別的細節說明

上面算是最基本的使用情境。而實際上,std::mdspan 也是一個彈性滿大的 template 容器,也有相當大的使用彈性。

他實際上的型別是:

template<
  class T,
  class Extents,
  class LayoutPolicy = std::layout_right,
  class AccessorPolicy = std::default_accessor<T>
> class mdspan;

他的 template 參數總共有四個:

  • 代表資料型別的 T
  • 代表維度資訊的 Extents
  • 代表記憶體布局方法的 LayoutPolicy
  • 代表存取方式的 AccessorPolicy

在上面的使用情境,基本上這些參數就都是根據建構時的參數來自動推論出來的了。

下面則是一些說明:

維度資訊(extents)

維度資訊的部分,主要是透過 std::extents<> 來指定有幾個維度、每個維度的大小(參考);大小的部分可以在編譯階段決定(static)、也可以在執行階段決定(dynamic)。

比如說前面的例子,同樣是要用 2×3 的二維陣列來看,可以寫成下面幾種形式:

std::mdspan view1(aData.data(), 2, 3);
std::mdspan<char, std::dextents<int, 2>> view2(aData.data(), 2, 3);
std::mdspan<char, std::extents<int, 2, 3>> view3(aData.data());
std::mdspan<char, std::extents<int, 2, std::dynamic_extent>> view4(aData.data(), 3);

其中,view1view2 都是兩個維度的大小都是動態的、可以在執行階段(runtime)決定大小;而 view3 則是兩個維度的大小都是靜態的、需要在編譯階段就決定大小。

至於 view4 則是比較特別的用法,可以其中某些維度是靜態的、有的是動態的。

另外,extent 的資訊也可以當作建構子的參數來傳遞,像是 view2view3 也可以寫成下面的形式:

std::mdspan view2(aData.data(), std::dextents< int, 2 >(2, 3));
std::mdspan view3(aData.data(), std::extents< int, 2, 3 >());

而這邊 std::extents<> 的第一個 template 型別(這邊是 int)代表他的索引值的型別;他同時也會是 extent() 這個函式回傳的型別,有需要也可以自己調整。

不過一般的狀況下,應該可以不用這麼複雜,直接用第一種方法就可以了。

資料配置(layout)

LayoutPolicy 的部分(文件),基本使用時有代表 row-major 的 std::layout_right 和 column-major 的 std::layout_left 兩種;預設不指定的話,會是使用 row-major 的 std::layout_right

下面是使用時的狀況:

std::mdspan view1(aData.data(), 2, 3);
// a b c
// d e f

std::mdspan<char, std::extents<int,2,3>, std::layout_left> view2(aData.data());
// a c e
// b d f

不過,寫起來感覺就變麻煩了。 XD

除了基本的 layout_rightlayout_left 外,如果遇到部分的資料可能會需要固定跳過某些元素的話,則也可以透過 std::layout_stride 來做一定程度的客製化,這部分就之後再說吧。

而 C++26 還預計加入 layout_left_paddedlayout_right_padded,這個要能用大概就得好一段時間了。

存取方法(accessor)

這邊是用來定義要怎麼從傳入的資料裡面來存取需要的元素(文件)。絕大部分的狀況,應該是都會直接使用預設的 std::default_accessor<T>文件),但是在有需要的狀況下,也是可以自定義的。

這部分目前沒有看到什麼範例,不過基本上應該是透過建立一個符合規範的類別、然後自定義 access()offset() 這兩個函式來根據需求做資料存取方法的調整。或許以後有機會認真玩看看後,再來記錄一下了。


請注意存取方式

最後,這邊再提一下 std::mdspan 的軸向的順序。

如果是要把一維陣列當作二維陣列存取的時候,Heresy 這邊其實是習慣用 x + y * width 這樣的計算公式。比較簡單的例子,大概會是下面這樣:

std::array<char, 12> aData;
for (size_t i = 0; i < aData.size(); ++i)
  aData[i] = 'a' + i;

size_t width = 4, height = 3;
std::cout << "Matrix " << width << '/' << height << ":\n";
for (size_t y = 0; y < height; ++y)
{
  for (size_t x = 0; x < width; ++x)
    std::cout << aData[x + y * width] << " ";
  std::cout << "\n";
}

這樣的結果會是:

Matrix 4/3:
a b c d
e f g h
i j k l

而使用 std::mdspan 的時候,如果很直覺地寫成下面的型式的話:

std::mdspan matrix(aData.data(), 4, 3);
std::cout << "Matrix " << matrix.extent(0) << " / " << matrix.extent(1) << "\n";
for (size_t y = 0; y != matrix.extent(1); ++y)
{
  for (size_t x = 0; x != matrix.extent(0); ++x)
    std::cout << matrix[std::array{ x, y }] << " ";
  std::cout << "\n";
}

結果會是:

Matrix 4 / 3
a d g j
b e h k
c f i l

可以看到內容是方向是反的。

如果要按照本來的習慣使用的話,一個方法就是改用 std::layout_left

std::mdspan<char, std::extents<size_t, 4, 3>, std::layout_left> matrix(aData.data());

否則就是要把 X / Y 的定義反過來、也就是把第一個 extent 當作 Y、第二個 extent 當成 X 了~

這部分基本上有點偏向於習慣的問題,要使用的話最好還是確認一下是不是和自己預期的一樣了。(OpenCV 的 cv::Mat 或是 Eigen 的 Matrix 應該都和 mdspan 是一樣的形式?)


理論上,std::mdspan 普及之後,應該是可以像 std::sapn 一樣,作為一個統一、單一的陣列傳遞型別、來方便開發以及使用。(不過話說,g++ 到現在還沒支援…)

比較理想的狀況,是當我們要寫一個用來處理矩陣的函式的時候,可以統一變成傳 std::mdspan 進去;而遇到其他含式庫提供的矩陣型別(例如 Eigen 或 glm),都可以簡單地轉換過去。

但是實際上,考慮到矩陣本身的性質(row-major/column-major 等等),其實要做到快速轉換其實應該是沒那麼方便就是了…

另外,C++26 也還會加入可以快速存取局部區域的 submdspan(),理論上也是會讓這類型的資料在操作上更方便的~


參考:

Leave a Reply

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *