C言語&転置処理でメモリについて考えてみた
最近、パタヘネの第5版を読んでいます!
結構ボリュームはあるのですが、なんとか第5章まで読みました。
名著と謳われているように、ページを進めるほどにコンピュータの奥深さが身にしみます。
もっと早く読めばよかったなぁ。昔の自分は何をやっていたんだ!?
(ブログを読み返す)
そっか、Bilateral Solverをやっていたのか。
・・・一応、Bilateral Solverについて説明します。
Bilateral Solverとは、Googleの研究者が発表した画像処理用の高速ソルバです。
かつて僕は論文を読みながら、C++による実装に取り組んでいました。
僕はこれまでBilateral Solverを知っている人に会ったことはないですw
そうそう、パタヘネの話でしたね。
記憶階層について読んでいると、Bilateral Solver実装時の疑問を、ふと思い出しました。
「行列の転置って処理速度遅いよな・・・」
Bilateral Solverでは10万x10万サイズの行列を扱います。
最終的な解を求めるまでに様々な計算をするのですが、
なぜか行列の転置にかかる時間がめちゃくちゃ長いのです!
ホント謎でした。
当時の僕は、コンピュータの中では不思議な現象が起きる、という結論に至ったのですが・・・
冷静に考えたら、そんな訳はありませんw
今思うと、あの時の現象はキャッシュミスが原因だったのかもしれません。
そうとなれば、さっそく調査だーーーー!
転置のパフォーマンス
最初に、不要とは思いますが、行列の転置とはどのような処理かを説明します。
転置前の行列があります。
\begin{pmatrix}
1 & 2 & 3 \\
4 & 5 & 6 \\
7 & 8 & 9
\end{pmatrix}
転置後はこうなります。 \begin{pmatrix} 1 & 4 & 7\\ 2 & 5 & 8 \\ 3 & 6 & 9 \end{pmatrix}
それでは調査開始です!
本当に転置処理は遅いのか、以下の3パターンの速度を比較してみます。
- 行列Aの転置処理
- 行列Aと行列Bの加算
- 行列Aと行列Bの乗算
そんなの実行環境やプログラムの構造、行列のサイズに依るって??
せやな!!!
今回は調査はあくまで一例ということで。
★使用するパソコンのスペック
- Macbook Air(13-inch, Mid 2011)
- CPU:1.7GHz Intel Core i5、32bit、Sandy Bridge?
- 一次キャッシュ:32kB
- 二次キャッシュ:256kB
- 三次キャッシュ:3MB
- メモリ:4GB
いつの時代のスペックだよって感じですが、普段使いはこれで十分なんだからね!!
★Cプログラム
各処理の実行時間を図るプログラムはこんな感じにしました。
#include <stdio.h> #include <stdlib.h> #include <time.h> void transposeMatrix(float **mat_a, int size) { float tmp; for (int i = 0; i<size; i++) for (int j = i+1; j<size; j++) { tmp = mat_a[i][j]; mat_a[i][j] = mat_a[j][i]; mat_a[j][i] = tmp; } } void addMatrix(float **mat_a, float **mat_b, int size) { for (int i = 0; i<size; i++) for (int j = 0; j<size; j++) mat_a[i][j] = mat_a[i][j] + mat_b[i][j]; } void multiplyMatrix(float **mat_a, float **mat_b, int size) { for (int i = 0; i<size; i++) for (int j = 0; j<size; j++) mat_a[i][j] = mat_a[i][j] * mat_b[i][j]; } int main(int argc, char *argv[]) { int size = atoi(argv[1]); int mode = atoi(argv[2]); float **mat_a, **mat_b; int tmp = 0; mat_a = (float**)malloc(sizeof(float*) * size); for(int i = 0; i<size; i++) mat_a[i] = (float*)malloc(sizeof(float) * size); mat_b = (float**)malloc(sizeof(float*) * size); for(int i = 0; i<size; i++) mat_b[i] = (float*)malloc(sizeof(float) * size); clock_t start, end; double time; start = clock(); switch (mode) { case 0:transposeMatrix(mat_a, size); break; case 1:addMatrix(mat_a, mat_b, size); break; case 2:multiplyMatrix(mat_a, mat_b, size); break; } end = clock(); time = (double)(end - start)/CLOCKS_PER_SEC; printf("%0d %0.6f\n", size, time); }
シェルの引数を利用して、行列のサイズを10~5000と変えながら、実行時間を計測してみます。
さて、結果は如何に!?
グラフにまとめたので、下にスクロールしてください。
グラフの横軸は行列のサイズ、縦軸が所要時間です。
転置が緑色、加算が水色、乗算が橙色のグラフです。
参考までに、紫色はプログラムとは関係のない2乗曲線です。
グラフから明らかなように、転置処理が最も遅いという結果になりました!
やっぱり昔の記憶は間違っていませんでした。
よかった、よかった。
転置処理が遅い理由
そっかー、転置処理は遅いのかー
よくわかんないけど覚えておこっと。
・・・で終わると、昔の僕と変わりませんw
考察してみたいと思います。
パタヘネで記憶階層について学んだ僕は、この現象の原因をキャッシュミスであると推測しました。
キャッシュは、CPUがアクセス可能な記録素子のうち、特に応答時間が短い素子になります。
もしキャッシュに欲しいデータがある場合は、CPUのデータアクセス時間は短くなります。
もしキャッシュに欲しいデータがない場合は、CPUは代わりに、メインメモリやストレージまでデータを取りに行く必要があります。これはキャッシュミスと呼ばれ、CPUのデータアクセス時間は長くなります。取ってきたデータはキャッシュに格納され、次に同じデータにアクセスする際、アクセス時間は短くなります。
僕も詳しくないので、詳細はwikiや関連本を読んでください。
キャッシュによるアクセス時間短縮の効果は、データの時間的・空間的局所性に依存すると言われています。
その観点で、今回の3つの処理を見てみます。
★加算、乗算のプログラム
図のモデルはだいたいのイメージです。厳密なこと言いだすときりがないですが、大筋はあっていると信じています。
キャッシュの割当を以下のようにします。
- ブロック1:1行目のデータまたは3行目のデータ
- ブロック2:2行目のデータまたは4行目のデータ
加算、乗算プログラムは、メモリ空間に対して、上から順番にデータにアクセスしています。
キャッシュのデータの入れ替えは、
- ブロック1:なし→1行目のデータ→3行目のデータ
- ブロック2:なし→2行目のデータ→4行目のデータ
程度で済みます。
このプログラムは、データの時間的局所性、空間的局所性の両方に優れていると言えます。
★転置のプログラム
転置のプログラムは、メモリ空間に対して、ジグザグにデータにアクセスしています。
するとキャッシュのデータの入れ替えは、
- ブロック1:なし→1行目のデータ→3行目のデータ→1行目のデータ→3行目のデータ→・・・
- ブロック2:なし→2行目のデータ→4行目のデータ→2行目のデータ→4行目のデータ→・・・
のように何度も発生します。
このプログラムは、データの空間的局所性には優れていますが、時間的局所性には優れていません。
さて、もし上の説明が正しいと仮定するとだ。
キャッシュの容量が不足すると、プログラムの処理時間が遅くなるってことですよね。
実行環境の3次キャッシュの容量は3MByteです。
float型は4Byteなので、
行列のサイズが約887x887を超えたあたりから、処理時間が悪化するはずです。
そのあたりのグラフを拡大してみます。
面白いぐらいに、800後半あたりからグラフが乱れているのがわかります。
キャッシュミスが原因である証拠ではないでしょうか!?
ついでにもう一つの謎にも触れておきます。 全体のグラフをもう一度掲載します。
転置のグラフは凸凹してますよね。
行列のサイズが増えると、処理時間は2乗オーダで増えてほしいのですが、なぜでしょうか?
何度もプログラムを実行しても、ほぼほぼ同じ結果になります。
偶然という可能性はなさそうです。
グラフを見ていると、行列のサイズが500増えるごとに似たようなパターンを繰り返しています。
500ということは、一番近い2のべき乗は512・・・
float型なので・・・512 * 4 = 2kByte
この2kByteという数字に何か意味があるのかな・・・?
応急措置として、プログラムを以下のように書き換えます。
mat_a = (float**)malloc(sizeof(float*) * size); for(int i = 0; i<size; i++) mat_a[i] = (float*)malloc(sizeof(float) * 5230); //mat_a[i] = (float*)malloc(sizeof(float) * size);
5230である理由は、行列のサイズが4610の時に性能が良かったので、そこに512を足しただけです。
本当はもっといい方法があるはずなので、真似しないように。
これで処理時間を計測してみます。
グラフの緑色が変更前、水色が変更後です。
グラフの凹凸がなくなり、二乗曲線のような特性に近づきました。
予想通りといえば予想通りなんだけど、どうしてなんだろうね・・・?
パタヘネを読んでいると、一番関係ありそうなのはTLB( Translation Lookaside Buffer)です。
いわゆるメモリ仮想化技術における、ページのキャッシュですね。
ただ今回の調査はメモリに対して負荷をかけていないつもりだし、これが原因なのか?
コンピュータに詳しい人、ご意見よろしくお願いします m(._.)m
まとめ
行列の転置処理が遅い理由について、キャッシュミスを前提に調査してみました。
結果は、ほぼほぼ予想通り。
キャッシュミスが性能に直結することは知っていたけど、こんなに性能差が出ることは驚きでした。
しかし得られた結果にはまだ疑問が残ります。
原因は探りたいのですが、簡単にいきそうにありません。
ここは初心に戻って「コンピュータの中では不思議な現象が起きる」という結論にしようかな・・・。
おまけ: 転置処理の高速プログラム
せっかくなので、転置処理を高速で行うプログラムについてググりました。
行列の転置って、普通に使われている処理ですからね。
誰かがいい方法をまとめてくれているはず!
すると案の定、stackover flowに該当する記事を見つけることができました。 stackoverflow.com
このページに記載の方法を使えば、高速な転置プログラムを組めるかも。。。