CPUキャッシュとメモリの整合性
CPUはメモリとは別にキャッシュという専用メモリを持っています。本記事ではその機構のことをCPUキャッシュと呼ぶこととします。
そしてゲームハードにはGPUやDSPなどCPU以外のチップが存在し、これらはCPUと同じくメモリにアクセスします。
CPUキャッシュとメモリ、これらの関係・性質を把握しておかないと、GPUやDSPがおかしな動作をするようなコードを組んでしまうかもしれません。
今回はそんなCPUキャッシュとメモリについてのお話です。
今日のポイント
初っぱなからまとめです。このまとめの意味が分かる方は完璧なので以降の説明は読まなくて大丈夫ですよ!
- **他プロセッサがリード(読み込み)**するメモリレンジには Flush (キャッシュの内容を適用&キャッシュを無効化)
- **他プロセッサがライト(書き込み)**するメモリレンジには Invalidate (キャッシュを無効化)
CPUキャッシュの役割
例えばこんなコードがあったとします。
void func()
{
static int dataS[1000];
static int dataD[1000];
for (int i = 0; i < 1000; ++i) {
dataD[i] = dataS[i];
}
}
func() を呼び出すとdataSの内容がdataDにコピーされます。「memcpy使えばいいのでは?」とか「static変数は元から0だよ?」というツッコミがきそうですが今回は気にしないでください。
まず大前提として、メモリにデータを書き込んだりメモリからデータを読み込む処理というのはそのたびに一定のオーバーヘッドがかかります。ですので、メモリへのアクセスする回数が減れば減るほど処理時間が短くなります。
上の例では1000回dataSの内容を読み込み、1000回dataDに書き込む処理を行っています。これを素直に実現するとメモリへのアクセスが読み込み1000回&書き込み1000回発生していまい2000回分のオーバーヘッドがかかってしまいます。
このオーバーヘッドを減らすためにあるのがCPUキャッシュという機構です。
CPUキャッシュというのはざっくり言うと次のようなものです。
- メモリと比べて高速にアクセスにできるCPU専用のメモリ。
- メモリと比べてサイズは小さい。
メモリよりも高速にアクセスできるがサイズが小さいためメモリ上の全てのデータを一度にCPUキャッシュにのせることはできない、そんなCPU専用のメモリがCPUキャッシュです。
CPUキャッシュがある場合の動作
先ほどの例示したコードについて、CPUキャッシュがない場合とある場合でこのような挙動の差ができます。
- CPUキャッシュ無し
- 1000回のメモリ読み込みが発生する。
- 1000回のメモリ書き込みが発生する。
- CPUキャッシュ有り(動作イメージ)
- dataS のデータ全てをメモリからキャッシュ上にコピーする。(メモリ読み込みが1回発生)
- CPUキャッシュ上で架空の int dataD[1000] の領域を確保。そこに対して dataS の内容をコピーしていく。
- 最後にCPUキャッシュ上の dataD の内容全てを dataD のアドレスにコピーする。(メモリ書き込みが1回発生)
CPUキャッシュがある場合、メモリ読み込みと書き込みは1回ずつで済みました。その結果メモリアクセスのオーバーヘッドも減り、処理時間がより短くなることが想像できます。
ちなみにCPUキャッシュのサイズやキャッシュアルゴリズムによってメモリアクセス回数は変わります。今回は「CPUキャッシュがあるとメモリアクセス数が減らせるんだ」ということがなんとなく分かればOKです。
CPUキャッシュがメモリアクセスするタイミング
メモリ上のデータをCPUキャッシュに読み込んだり、CPUキャッシュのデータをメモリへ書き込んだりするタイミングは不定です。正確にはOS・CPU・コンパイラの組み合わせで変わるため、コードを組む段階では予測不能です。
メモリの内容と処理結果が不定になるケース
CPUキャッシュがメモリアクセスするタイミングについて、CPUからしかメモリ操作しないようなプログラムであればを意識する必要は全くありません。
しかし、GPUやDSPなどCPU以外からもメモリをアクセスするプログラムですと話が変わります。
ここでは、メモリアクセスのタイミングによって処理結果が不定になる2つのケースを紹介します。
ケース1. GPU が描画に使用するテクスチャを読み込む
// ケース1
typedef unsigned int byte_t;
void createWhiteImage(byte_t* aImagePtr, int aImageSize)
{
for (int i = 0; i < aImageSize; ++i) {
aImagePtr[i] = 0xFF;
}
}
GPUが描画に使用するテクスチャを、 createWhiteImage() を用いてCPUが生成したとします。ですが、この処理の結果がCPUキャッシュ上では完成されていたとしても、その結果がメモリに書き込まれているかどうかは不定です。もしメモリに書き込まれていない状態でGPUがこのテクスチャを参照すると、白ではない画像として処理されるかもしれません。
ケース2. GPU が描画結果をメモリに書き込む
// ケース2
typedef unsigned int byte_t;
void clearFrameBuffer(byte_t* aImagePtr, int aImageSize)
{
for (int i = 0; i < aImageSize; ++i) {
aImagePtr[i] = 0x00;
}
}
void draw(byte_t* aImagePtr, int aImageSize)
{
// aImagePtr の内容を既定値クリア
clearFrameBuffer(aImagePtr, aImageSize);
// aImagePtr に対してGPUで描画して描画完了を待つ関数(という想定)
drawScene(aImagePtr, aImageSize);
// 描画結果を画像ファイルに出力
saveToBitmap(aImagePtr, aImageSize);
}
GPUの描画で使うイメージ領域を clearFrameBuffer() を用いてCPUで初期化した後、 drawScene() でGPUを使い描画。そして描画結果を saveToBitmap() を用いてCPUでビットマップファイルとして保存するという内容です。
この例、 clearFrameBuffer() で初期化したデータをGPUが使うことを想定したコードですが、ケース1の場合と同様にクリアされてからGPUが動き出すという保証はありません。
更に、もしGPUの描画が終わった後でCPUキャッシュの内容がメモリに書き込まれた場合、GPUの描画結果は無かったことになってしまう可能性もあります。
あるいは、CPUキャッシュ上に aImagePtr の内容が存在している場合、最新のメモリ上のデータをCPUキャッシュにコピーしなくなります。そのため、GPUの描画結果という最新のメモリ上のデータを取得したつもりが古いデータを取得し正しいビットマップ画像を出力できない、といったこともありえます。
CPUキャッシュのメモリアクセスを制御する方法
先ほど例示した2つのケースを防ぐために、大抵のSDKにはCPUキャッシュのメモリアクセスを制御する手段が用意されています。
代表的なものがこの2つです。
// 補足:関数の名前はSDKによって異なります。
/// 指定のメモリ範囲のCPUキャッシュを無効化する。
/// @details
/// この関数を呼ぶと、指定のメモリ範囲がCPUキャッシュ上から存在しなくなることが保証されます。
/// 本関数は次のタイミングで呼ぶことを想定しています。
/// - CPU以外の機構がデータを変更する前。
/// - CPU以外の機構により変更されたデータをCPUが読み込む前。
void CacheInvalidate(void* aAddr, size_t aSize);
/// 指定のメモリ範囲のCPUキャッシュをメモリに書き込んだ後、CPUキャッシュを無効化する。
/// @details
/// この関数を呼ぶと、指定のメモリ範囲がCPUキャッシュ上から存在しなくなることが保証されます。
/// 本関数は次のタイミングで呼ぶことを想定しています。
/// - CPUが変更したデータをCPU以外の機構が読み込む前。
void CacheFlush(void* aAddr, size_t aSize);
先ほどの2つの例でこれらの関数を使って訂正するとこのようになります。
// ケース1改訂版
typedef unsigned int byte_t;
void createWhiteImage(byte_t* aImagePtr, int aImageSize)
{
for (int i = 0; i < aImageSize; ++i) {
aImagePtr[i] = 0xFF;
}
CacheFlush(aImagePtr, aImageSize); // 確実にメモリに反映させる
}
// ケース2改訂版
typedef unsigned int byte_t;
void clearFrameBuffer(byte_t* aImagePtr, int aImageSize)
{
for (int i = 0; i < aImageSize; ++i) {
aImagePtr[i] = 0x00;
}
}
void draw(byte_t* aImagePtr, int aImageSize)
{
// aImagePtr の内容を既定値クリア
clearFrameBuffer(aImagePtr, aImageSize);
// クリアした結果を確実にメモリに反映
CacheFlush(aImagePtr, aImageSize);
// aImagePtr に対してGPUで描画して描画完了を待つ関数(という想定)
drawScene(aImagePtr, aImageSize);
// GPUの処理結果をメモリから確実に読み込むための準備
CacheInvalidate(aImagePtr, aImageSize);
// 描画結果を画像ファイルに出力
saveToBitmap(aImagePtr, aImageSize);
}
このように適所で適切な関数を呼ぶことにより、処理の結果が不定になってしまう事態を防ぐことができます。
おわり
今回のCPUキャッシュの話は、CPUキャッシュ関連でおこる不具合の対応に必要な知識にしぼって解説しました。CPUキャッシュに関してもっと理解を深めたい方は、『CPU キャッシュ』といったキーワードでインターネット検索してみてください。
リンク:ゲームプログラマの小話-目次