多次元配列(int[,]
のような配列)は、標準でAsSpan()
が用意されていない。そのため通常では、配列全体を連続した領域のデータとみなしたSpan
として扱えない。
列挙以外に全体を連続として扱える方法は C# ではない……?
MemoryMarshal.CreateSpan
を使う方法がある。
参考:多次元配列の AsSpan · GitHub
public static Span<T> AsSpan<T>(this T[,] array) => MemoryMarshal.CreateSpan(ref array[0, 0], array.Length);
C# は行優先なので、[0, 0], [0, 1], ..., [1, 0], [1, 1], ... と整列したSpan
を得ることができる。
(※ なお少し調べた限り、必ず連続した整列なのか確証がないのは念のため触れておく。こういう環境でダメ、という具体的な情報はなかったので触れる程度で。)
今回、比較用の例として、以下の記事を参考とさせていただいた。
[C#]Span<T>構造体で高速かつ安全に配列へアクセスして、行列積を素早く計算する。
ジャグ配列はオミットし、二次元配列の ① 普通のループ、② 事前に転置、③ インデックスアクセスを減、そして ④ スパン利用を較べてみる。
①・②については BenchmarkDotNet で測る用に調整したものの、基本的に上記記事内容のとおりなので以下折りたたみ。
折りたたみ
public int N = 2022; public int D = 2022; public int M = 2022; public double[,] left2D = default!; public double[,] right2D = default!; [GlobalSetup] public void Setup() { left2D = new double[N, D]; right2D = new double[D, M]; for (int i = 0; i < N; i++) { for (int k = 0; k < D; k++) { left2D[i, k] = i + k; } } for (int k = 0; k < D; k++) { for (int j = 0; j < M; j++) { right2D[k, j] = k + j + 1; } } } [Benchmark] public double[,] ByRegular() { var result = new double[N, M]; for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { for (int k = 0; k < D; k++) { result[i, j] += left2D[i, k] * right2D[k, j]; } } } return result; } [Benchmark] public double[,] ByTransposed() { var transposed = new double[M, D]; for (int j = 0; j < M; j++) { for (int k = 0; k < D; k++) { transposed[j, k] = right2D[k, j]; } } var result = new double[N, M]; for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { for (int k = 0; k < D; k++) { result[i, j] += left2D[i, k] * transposed[j, k]; } } } return result; }
③については、result[i, j]
へ k 回アクセスしていたので変数を置いて1回のみとしたもの。
影響を見たいもののSpan
と関係ないので別段階の枠として、折りたたみ。
折りたたみ
[Benchmark] public double[,] BySum() { var transposed = new double[M, D]; for (int j = 0; j < M; j++) { for (int k = 0; k < D; k++) { transposed[j, k] = right2D[k, j]; } } var result = new double[N, M]; for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { var sum = 0d; for (int k = 0; k < D; k++) { // result[i, j] += left2D[i, k] * transposed[j, k]; sum += left2D[i, k] * transposed[j, k]; } result[i, j] = sum; } } return result; }
④、Span
を用いたもの。
上述の記事だとジャグ配列の配列のAsSpan()
を行っているが、ここでは違ったかたちで扱う。
private static Span<T> AsSpan<T>(T[,] span) => MemoryMarshal.CreateSpan(ref span[0, 0], span.Length); [Benchmark] public double[,] By1DSpan() { var transposed = ArrayPool<double>.Shared.Rent(M * D); Span<double> tSpan = transposed.AsSpan(); try { for (int k = 0; k < M; k++) { for (int j = 0; j < D; j++) { tSpan[(j * D) + k] = right2D[k, j]; } } var result = new double[N, M]; var lSpan = AsSpan(left2D); for (int i = 0; i < N; i++) { for (int j = 0; j < M; j++) { var sum = 0d; var lPart = lSpan.Slice(i * N, D); var tPart = tSpan.Slice(j * M, D); for(var k = 0; k < D; k++) { sum += lPart[k] * tPart[k]; } // result は二次元配列のインデックスアクセスで妥協 result[i, j] = sum; } } return result; } finally { ArrayPool<double>.Shared.Return(transposed); } }
Method | Mean | Error | StdDev | Allocated | ------------- |----------:|---------:|---------:|----------:| ByRegular | 117.189 s | 4.910 s | 0.2691 s | 31.19 MB | ByTransposed | 31.871 s | 10.289 s | 0.5640 s | 62.39 MB | BySum | 19.351 s | 12.898 s | 0.7070 s | 62.39 MB | By1DSpan | 7.772 s | 3.306 s | 0.1812 s | 31.19 MB |
正直かなり違って驚いた。
(一応SequenceEquals
で結果の整合は取ったので変な出力にはなってない、はず)
実施前の見込みでは、裏でよしなに SIMD してくれたりするAPIを使っていなかったり、Span
を通したとはいえ結局は列単位のアクセスと同等だからメリットが薄そうに感じたりと、実はそこまで違いはないんじゃないかと思っていた。
とすると、Span
の最適化やら計算機科学的なメリットやらが今回の例では大きかったということになる。
Span
強し。
多次元配列は基本Span
で扱いたい、と思ったけれど、前述のとおりちょっと確かでない部分があるので、とりあえず引き出し。