
はじめに
こんにちは、サイバーエージェントゲームエンターテイメント事業部、SGEコア技術本部(コアテク)のグラフィックスチームに所属している清原です。 今回はモバイルGPUにおけるシェーダープログラムでのテクスチャサンプリングの最適化についてお話ししていきます。
キャッシュメモリを活用しよう
多くのGPUではメモリアクセスのパフォーマンスを改善するためにキャッシュメモリと呼ばれる高速なメモリを搭載しています。
今回はテクスチャサンプリングに焦点をあてて、キャッシュメモリについて解説していきます。
シェーダーで次のようなコードでテクスチャサンプリングが行われているとします。
half4 color = SAMPLE_TEXTURE2D(_BaseMap, sampler_BaseMap, IN.uv);
この時、GPUはまず高速なキャッシュメモリを調べに行き、キャッシュにテクスチャがあれば、そこからロードします。これをキャッシュヒットと呼びます。
しかし、キャッシュにデータがない場合は低速なメインメモリからロードします。これをキャッシュミスと呼びます。
また、キャッシュミスが起きたときにメインメモリからテクスチャをサンプリングした場合、そのデータはキャッシュメモリに保存されます。そのため、次に同一のデータをサンプリングした場合はキャッシュからロードされることになり、メモリアクセスが高速化されます。
次の図はこの一連の動作を可視化したものです。

しかし、基本的にキャッシュメモリは容量が小さいため、すべてのテクスチャを保存しておくことはできません。 これによって、例えば長くアクセスされていないメモリなどはキャッシュメモリから破棄されます。
このため、同一のメモリからアクセスするように工夫をすることによって、キャッシュヒット率を向上させることができます。
なお、詳細なキャッシュシステムの仕組みはCPU、デスクトップGPU、モバイルGPUと差異があるため、より詳しく知りたい方は下記のリンクなどを参照してください。
1.10 Key differences CPU-GPU in Design approach & Areas of use (IBDP)
テクスチャパッキング(データの最適化)
まずはデータの工夫から見ていきます。シェーダープログラムでは、次のようなデータをテクスチャに格納することがあります。
- スペキュラ反射強度
- 拡散反射強度
- 金属度
- 粗さ
このようなデータはテクスチャの1チャネルしか利用しないことが多く、次の画像のように一つのテクスチャとしてパッキングすることができます。
このように一つのテクスチャとしてまとめることによって、次のようなコードを実行した場合にキャッシュヒット率が向上します。
half specularIntensity = SAMPLE_TEXTURE2D(_SpecularIntensityMap, sampler_SpecularIntensityMap, IN.uv).r; half diffuseIntensity = SAMPLE_TEXTURE2D(_DiffuseIntensityMap, sampler_DiffuseIntensityMap, IN.uv).g; half metallic = SAMPLE_TEXTURE2D(_MetallicMap, sampler_MetallicMap, IN.uv).b; half roughness = SAMPLE_TEXTURE2D(_RoughnessMap, sampler_RoughnessMap, IN.uv).a;
次のテクスチャはコアテクで開発中のPBRキャラクタシェーダーで使用されているテクスチャです。
Rチャンネルに金属度、Gチャンネルに粗さ、Bチャンネルにオクルージョンの強度が格納されています。

テクスチャパッキング(プログラムの最適化)
データの最適化によって、キャッシュヒット率が向上しメモリアクセスが効率化されることが分かりました。
しかし、先ほどのプログラムは_SpecularIntensityMap、_DiffuseIntensityMap、_MetallicMap、_RoughnessMapとコード上は別々のテクスチャとして扱われるため、コンパイラはこれらが同一であることを判断できず、サンプリング命令が都度発行されてしまいます。
次のコードはHLSLで記述された4つのテクスチャをサンプリングするシェーダーコードを、中間言語のSPIR-Vにコンパイルした結果です。SPIR-Vにコンパイルする際に最適化が行われるのですが、テクスチャサンプリングの処理がそのまま残っていることが分かります。
void _4() { float4* _76; int4* _79; bool* _83; uint4* _86; Image<float2D> _13 = *_12 : [[RelaxedPrecision]]; sampler _17 = *_16 : [[RelaxedPrecision]]; SampledImage<float2D> _19 = SampledImage(_13, _17); float2 _23 = *vs_TEXCOORD0; float* _32 = &_26._child0.x; float _33 = *_32; // テクスチャサンプリングを行っている命令 float4 _34 = ImageSampleImplicitLod(_19, _23, Bias(_33)) : [[RelaxedPrecision]]; float _35 = _34.x : [[RelaxedPrecision]]; float* _37 = &_9.x; *_37 = _35; Image<float2D> _39 = *_38 : [[RelaxedPrecision]]; sampler _40 = *_16 : [[RelaxedPrecision]]; SampledImage<float2D> _41 = SampledImage(_39, _40); float2 _42 = *vs_TEXCOORD0; float* _43 = &_26._child0.x; float _44 = *_43; // テクスチャサンプリングを行っている命令 float4 _45 = ImageSampleImplicitLod(_41, _42, Bias(_44)) : [[RelaxedPrecision]]; float _47 = _45.y : [[RelaxedPrecision]]; float* _48 = &_9.y; *_48 = _47; Image<float2D> _50 = *_49 : [[RelaxedPrecision]]; sampler _51 = *_16 : [[RelaxedPrecision]]; SampledImage<float2D> _52 = SampledImage(_50, _51); float2 _53 = *vs_TEXCOORD0; float* _54 = &_26._child0.x; float _55 = *_54; // テクスチャサンプリングを行っている命令 float4 _56 = ImageSampleImplicitLod(_52, _53, Bias(_55)) : [[RelaxedPrecision]]; float _58 = _56.z : [[RelaxedPrecision]]; float* _59 = &_9.z; *_59 = _58; Image<float2D> _61 = *_60 : [[RelaxedPrecision]]; sampler _62 = *_16 : [[RelaxedPrecision]]; SampledImage<float2D> _63 = SampledImage(_61, _62); float2 _64 = *vs_TEXCOORD0; float* _65 = &_26._child0.x; float _66 = *_65; // テクスチャサンプリングを行っている命令 float4 _67 = ImageSampleImplicitLod(_63, _64, Bias(_66)) : [[RelaxedPrecision]]; float _69 = _67.w : [[RelaxedPrecision]]; float* _70 = &_9.w; *_70 = _69; float4 _73 = *_9 : [[RelaxedPrecision]]; *_72 = _73; }
さて、ここまで高速なメモリとしてキャッシュメモリを紹介してきましたが、キャッシュメモリよりもさらに高速なレジスタと呼ばれるメモリがあります。 レジスタとは、GPUの演算器(ALU)が直接、かつ瞬時に読み書きできる「極めて小容量で超高速なメモリ」のことです。 コード上ではローカル変数がレジスタに該当すると考えてください*1。
先ほどの個別のテクスチャにアクセスするコードでは、サンプリング命令が複数回発行されるため、その都度キャッシュメモリからのロードが発生します。 キャッシュメモリが高速とはいえ、この処理は不要なオーバーヘッドを生み出しています。
そこで、パッキングしたテクスチャを1回の命令でロードするようにプログラムを書き換えてみましょう。
// 4つのデータを1回のサンプリングでまとめて取得 half4 packedData = SAMPLE_TEXTURE2D(_PackedMap, sampler_PackedMap, IN.uv); // レジスタに読み込まれた値をそれぞれの変数に割り当てる half specularIntensity = packedData.r; half diffuseIntensity = packedData.g; half metallic = packedData.b; half roughness = packedData.a;
このように記述することで、GPUは「1回のメモリアクセス」で4つのデータをレジスタに一気に取り込むことができます。
SPIR-Vにコンパイルした結果も次のようにテクスチャサンプリングが1回になっていることが確認できます。
void _4() { float4* _40; int4* _43; bool* _47; uint4* _50; Image<float2D> _13 = *_12 : [[RelaxedPrecision]]; sampler _17 = *_16 : [[RelaxedPrecision]]; SampledImage<float2D> _19 = SampledImage(_13, _17); float2 _23 = *vs_TEXCOORD0; float* _32 = &_26._child0.x; float _33 = *_32; // 一度だけのテクスチャサンプリング float4 _34 = ImageSampleImplicitLod(_19, _23, Bias(_33)) : [[RelaxedPrecision]]; *_9 = _34; float4 _37 = *_9 : [[RelaxedPrecision]]; *_36 = _37; return; }
1回のアクセスで済むということは、GPU内部の配線をデータが移動する回数が減るということです。これは単なる高速化だけでなく、モバイル端末におけるバッテリー消費の削減や、発熱によるパフォーマンス低下(サーマルスロットリング)を防ぐことにも繋がるため、モバイルアプリにとってはより重要な最適化です。
最後に
今回はテクスチャサンプリングの最適化として、データの最適化とコードの最適化についてお話ししました。キャッシュメモリ/レジスタを意識したデータ作成、コーディングを意識することによって、処理速度だけではなく、特にモバイルでは端末の発熱問題なども軽減ができます。この記事が皆様の開発の一助になれば幸いです。