同じ集計なのに何倍も遅い
ある通販会社では、毎晩、大きな売上表を合計する集計を動かしています。担当者が2つのループを入れかえて、行ではなく列の順に足すようにしました。
合計は1円も変わりません。ところが集計にかかる時間が、何倍にもなりました。仕事の量は同じで、変わったのは順番だけです。原因はキャッシュです。
このレッスンのゴール
メモ
キャッシュがあるとメモリが速くなる理由を、説明することができる。
キーワード
| ことば | いみ | れい |
|---|---|---|
| 記憶階層 | 小さく速い記憶から、大きく遅い記憶までの段の並び | |
| キャッシュ | 最近使ったデータの写しを置く、小さく速いメモリ | |
| キャッシュライン | キャッシュが一度に写すバイトのまとまり | 64バイト |
| ヒット | 欲しいデータが、もうキャッシュにあること | |
| ミス | データを、遅いメモリから持ってくる必要があること | |
| 局所性 | プログラムが、同じデータや近くのデータをまた使う性質 |
学生が入力するセルは、このプレビューでは空白です。
メモリは何段にも分かれている
プロセッサは、足し算を1ナノ秒よりずっと短い時間で行います。ところがメインメモリを読むには、その100倍ほどかかります。
そこでコンピュータは記憶階層を持ちます。プロセッサのすぐそばに、小さくて速いキャッシュがあります。L1、L2、そして多くの場合 L3 です。その後ろにメインメモリ、さらに後ろにディスクがあります。下の段ほど大きく、遅くなります。
例: よくあるノートパソコン
| 段 | よくある大きさ | 読むのにかかるおよその時間 |
|---|---|---|
| レジスタ | 8バイトが32個 | 1ナノ秒未満 |
| L1 キャッシュ | 32 から 64 KB | 約1ナノ秒 |
| L2 キャッシュ | 数百 KB から数 MB | 数ナノ秒 |
| L3 キャッシュ | 数 MB から数十 MB | 10 から 20 ナノ秒 |
| メインメモリ | 数 GB | 約100ナノ秒 |
| SSD | 数百 GB | 数万ナノ秒 |
学生が入力するセルは、このプレビューでは空白です。
アーキテクチャのフォルダへ移る
cd ~/archここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ここのキャッシュラインの大きさ
getconf LEVEL1_DCACHE_LINESIZEここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ミスのときは、ライン1本を写す
キャッシュは、1バイトだけを写すことはしません。ミスのときは、下の段からキャッシュライン1本、ふつう64バイトをまとめて写します。
これが得になるのは、局所性があるからです。プログラムは、今読んだデータのすぐ隣をまもなく読みます。これが空間的局所性です。同じデータをまた読むこともあります。これが時間的局所性です。どちらの場合も、次の読みこみはヒットになります。
loop.c で行の順が速い理由
loop.c には、4096行 × 4096列の int の表があります。int は4バイトなので、1行は16384バイトで、64バイトのライン1本には数が16個入ります。
行の順に読むと、1回のミスで次の16個が入るので、16回のうち15回はヒットです。列の順に読むと、次の数はいつも1行先で、別のラインにあります。そのラインに戻ってくるころには、もう追い出されています。
表全体の MB と、ライン1本の数
python3 -c "print(4096 * 4096 * 4 // 1024 // 1024, 64 // 4)"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
ラボの loop プログラムを作る
gcc -O2 -o loop loop.cここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
2つの順番の時間を計る
./loopここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
よくある間違い
注意
「遅いほうのループは、仕事を多くしているはずだ」と考える人が多くいます。そうではありません。どちらも同じ16777216個の数を足し、同じ和を出します。違うのは、プロセッサがメモリを待つ回数です。コードが遅いときは、命令の数だけでなく、ミスの数も数えましょう。
ダイレクトマップ方式で、ラインの場所を決める
ダイレクトマップ方式のキャッシュでは、どの番地も、入れるラインがちょうど1つに決まっています。番地を3つに分けて考えます。
オフセットは、番地をラインの大きさで割った余りで、ラインの中の何バイト目かです。番地をラインの大きさで割った商がブロック番号です。インデックスはブロック番号をライン数で割った余りで、どのラインかを決めます。タグは残りの部分で、今そのラインにどのブロックがあるかを表します。
例: 32バイトのラインが4本
| 番地 | ブロック | インデックス | タグ | 結果 |
|---|---|---|---|---|
| 0 | 0 | 0 | 0 | ミス: ライン0は空 |
| 8 | 0 | 0 | 0 | ヒット |
| 40 | 1 | 1 | 0 | ミス: ライン1は空 |
| 128 | 4 | 0 | 1 | ミス: ブロック0を追い出す |
| 0 | 0 | 0 | 0 | ミス: ブロック0は追い出された |
| 44 | 1 | 1 | 0 | ヒット |
学生が入力するセルは、このプレビューでは空白です。
0x3a7c を、32バイト16本で分ける
python3 -c "print(0x3a7c % 32, 0x3a7c // 32 % 16, 0x3a7c // 512)"ここでは表示のみで、実行されません。無料アカウントを作成すると、実行ボタンで自分のラボのターミナルに入力できます。
さらに挑戦
sed 's/4096/3000/' loop.c > odd.c で、1辺が3000の表を作るコピーを作ってください。gcc -O2 -o odd odd.c で作り、./odd を何回か実行します。
和は、実行する前に予想しましょう。次に、2つの順番の差を ./loop の差と比べます。1行のバイト数が2の累乗でなくなると何が変わるのか、シミュレータでインデックスを調べて説明してください。