はじめに
こんにちは、サイバーエージェントゲームエンターテイメント事業部、SGEコア技術本部(コアテク)のグラフィックスチームに所属している清原です。
コアテクのグラフィックスチームは、各子会社で利用されるグラフィックス関係の基盤の開発や、開発されているタイトルの描画系の不具合の調査や、パフォーマンスチューニングを日々行っています。
この記事では、シェーダー最適化入門として、基盤の開発や実際に開発されているスマートフォンアプリのパフォーマンスチューニングから得た知見を紹介していきます。
第一回目の連載記事として、何かと話題に上がるシェーダーでの条件分岐について取り上げます。「使ってはダメ」や「今のGPUなら使っても問題ない」など、様々な意見があるかと思いますが、この記事ではGPUの並列処理の仕組みを踏まえて、条件分岐の問題点とその対策についてお話ししていきます。
また、最後にはコアテクのグラフィックスチームでのシェーダーコーディングの指針についてもご紹介します。
なお、この連載はスマートフォン向けのシェーダーを中心にお話ししていきますが、この記事は現在GPUで主流のアーキテクチャのSIMTに言及しているため、PC向けのGPUにも当てはまる部分があるかもしれません。また、Unityを利用したシェーダーコーディングの知識があることも前提にしています。
条件分岐
シェーダープログラミングにおいて、次のような条件分岐のコードを書いてもいいのか?という話は、XなどのSNSでしばしば話題に上がります。
half4 frag( varings data ) { if( data.hoge > 0.5 ){ return half4( 1, 0, 0, 1 ); }else{ return half4( 0, 1, 0, 1 ); } }
「if文を使うとパフォーマンスに悪影響がでるので使わない方がいい」や「今のGPUならif文を使っても問題ない」など。 果たして、これらは何が正しいのでしょうか?
結論から言うと、これらは共に正しく、「if文の利用は用法容量を守って、適切に使いましょう」という話になります。
では、if文を適切に使うためにはどうすればいいのか、現在のGPUのアーキテクチャをもとに考えていきましょう。
条件分岐とは?
さて、一言に条件分岐と言っても、実はいくつかの種類の条件分岐が存在しています。この記事では以下の4つを利用した分岐について説明します。
- プリプロセッサディレクティブ
- uniform定数
- マテリアルパラメータ
- シェーダー実行時の処理によって決定される値
また、これら4つの分岐は次のように静的分岐と動的分岐の二つに分けることができます。
静的分岐 or 動的分岐 | |
---|---|
プリプロセッサディレクティブ | 静的分岐 |
uniform定数 | 静的分岐 |
マテリアルパラメータ | 動的分岐 |
シェーダー実行時の処理によって決定される値 | 動的分岐 |
しばしば話題に上がる、使うべきかどうか?の分岐は、ほとんどの場合で動的分岐を指しています。 しかし、静的分岐と動的分岐の違いが分かっていないと、混乱の元となるので、まずはそれらの違いについてお話しします。
静的分岐
静的分岐とはコンパイル時に分岐パスが決定される分岐のことです。つまり、シェーダーがコンパイルされるときにどのコードブロックが必要かが定まります。 例えば、プリプロセッサディレクティブやコンパイル時点で値が決まるuniform定数を利用する場合は下記のようなコードです。
[プリプロセッサディレクティブ]
half4 frag( varings data ) { #ifdef HOGE // HOGEが定義されていればこちらが実行される return half4( 1, 0, 0, 1 ); #else // HOGEが定義されていなければこちらが実行される return half4( 0, 1, 0, 1 ); #endif }
[uniform定数]
half4 fragCore( varings data, uniform bool hoge ) { if(hoge == 0){ return half4( 1, 0, 0, 1); }else{ return half4( 0, 1, 0, 1); } } half4 frag( varings data ) { return fragCore(data, 1); }
これらの静的分岐はコンパイルすると条件が成立しない方のコードは削除され、条件分岐が存在しない実行コードが生成されます。そのため、実行速度という面では最も優れたパフォーマンスを発揮します。 また、Unityではプロプロセッサディレクティブを利用した静的分岐に、multi_compileやshader_featureを使ってシェーダーバリアントの機能を利用することも多いでしょう。具体的には下記のようなコードです。
[シェーダーバリアントを利用]
#pragma multi_compile _ HOGE FOO half4 frag( varings data ) { #if defined(HOGE) // HOGEが定義されていればこちらが実行される return half4( 1, 0, 0 ,1 ); #elif defined(FOO) // FOOが定義されていればこちらが実行される return half4( 0, 1, 0, 1 ); #else // なにも定義されていない場合 return half4( 0, 0, 1, 0 ); #endif }
上のコードであればHOGEが定義されているコード、FOOが定義されているコード、何も定義されていないコードの3つのシェーダーコードが自動生成されます。 どのコードも条件分岐は存在しないコードになっているので、実行速度という面では最も優れたコードです。
しかし、ここでメモリ使用量とコンパイル速度という点において注意が必要です。シェーダーコードが3つ生成されるため、当然メモリ使用量は増加しますし、コンパイル時間も増加します。コンパイル時間の増加の問題はUnityであれば、ビルドの時間が長くなってしまうという問題が起きることが考えられます。
このシェーダーバリアントの増加(シェーダーの種類の増加)は無視できない問題となることがあります。この問題は後でご紹介するコアテクのシェーダーコーディングの指針に関わってきます。
動的分岐
続いて、動的分岐です。シェーダーでの条件分岐の問題というと、これが最も話題に上がると思います。動的分岐とは、シェーダーコンパイル時には分岐を決定できず、実行時まで動作するコードを確定できない分岐を指しています。
例えば、下記のようなコードです。
half4 frag( varings data ) { if( data.hoge > 0.5 ){ return half4( 1, 0, 0, 1 ); }else{ return half4( 0, 1, 0, 1 ); } }
このようなコードは静的分岐と異なり、コンパイル時に条件文を破棄することができず、静的分岐と比べると実行速度の面では不利になります。
しかし、動的分岐には下記の二つの分岐があります。
- シェーダー実行時の処理によって決定される値
- マテリアルパラメータの値
このように、動的分岐にはシェーダー実行時の計算によって決まる分岐とマテリアルパラメータの値によって決まる分岐があります。マテリアルパラメーターによる分岐はUnityでは下記のように記述されます。
Properties { // マテリアルパラメーターの定義 _Foo ("MaxLoopCount", Float) = 0 } ・ ・ 省略 ・ ・ half4 frag(Varyings In) : SV_Target { // これがマテリアルパラメーターによる分岐 if( _Foo > 0.5 ){ return half4( 1, 0, 0, 1 ); }else{ return half4( 0, 1, 0, 1 ); } }
この二つの分岐のうち、最もパフォーマンスに悪い影響を与えるのはシェーダー実行時の計算によって決まる分岐です。一方、マテリアルパラメータの値による分岐はパフォーマンスに大きな影響を与えることは少なく、問題となることは少ないです。
では、なぜこのようなことが言えるのか、これにはGPUのアーキテクチャと並列処理が関係しています。
GPUのアーキテクチャ
最近のスマートフォンで採用されている新しいGPUではSIMTというアーキテクチャが採用されています。
SIMTは「Single Instruction Multiple Thread」の略で、一つの命令を複数スレッドで実行していくというアーキテクチャです。
多くのCPUで採用されている複数の命令を複数のプロセッサが実行できるMIMD(Multiple lnstruction Multiple Data)と比べると、SIMTは分岐の多い複雑な処理は苦手になりますが、ハードウェアの量が少なくなります。
しかし、GPUはCPUほど複雑な処理を行うことは少ないため、SIMTがよりベターなアーキテクチャとなっていて、最近のGPUではよく採用されています。
GPUの並列処理
SIMTではシェーダープログラムを実行するスレッドグループがあり(NVidiaではワープ、AMDではウェーブフロントと呼ばれる)、スレッドグループに含まれているそれぞれのスレッドが「同じ命令」=「一つの命令」を実行します。
各スレッドが同じ命令を実行する必要があるため、スレッドグループの中で条件分岐が発生すると、条件が成立していないスレッドは何もせず遊んでいる状態になります(これをプレディケート実行と呼びます)。
このように条件が成立していないスレッドは何もせずに遊んでいるだけなのですが、処理時間自体は命令を実行した場合と同等の時間がかかります。
そのため、一つのスレッドグループ内で一つでも異なる分岐パスを実行するスレッドがあると、すべての分岐パスを実行したのと同じ処理時間になってしまいパフォーマンスが大幅に低下します。
一方、マテリアルパラメータの値は一つのドローコールで同じ値になるため、スレッドグループ内の全てのスレッドが同じ分岐パスを実行することになります。このため、マテリアルパラメータの値による分岐は、前述したようなすべての分岐パスが実行されてしまうという問題は起きません。
一部の古い GPU (iPhone 6s SoC の PowerVR GT7600 GPU を含む)のアーキテクチャはSIMDとなっており、命令レベルで並列化(コンパイル時点での並列化とも言えます)が行われます。そのため、動的分岐が存在すると、効率的なコードを出力することができず、パフォーマンスが低下することがあります。そのため、このようなGPUで動作するシェーダーを書く場合は動的分岐自体を避けることが重要です。
コアテクのシェーダーコーディングの指針
コアテクのグラフィックスチームでは、分岐命令のシェーダーコーディングの指針として、次のような指針を設けています。
- シェーダーバリアントによる分岐より、マテリアルパラメータによる分岐を優先する
- しかし、以下の場合はシェーダバリアントを使った条件分岐を検討する
- マテリアルパラメータによる分岐コストすら除去したいヘビーなチューニングを行いたい場合
- clip/discardが発生する
- discard命令が存在する場合に早期Zテストが使えなくなり、パフォーマンスが低下するため
- しかし、以下の場合はシェーダバリアントを使った条件分岐を検討する
- 実行時の計算によって決まる分岐を使う場合はパフォーマンスへの影響が大きいため、慎重に検討する
実際にはもう少し細かい指針があるのですが、大きな指針として、メモリ使用量、コンパイル時間、コードの可読性などを考慮してマテリアルパラメータによる分岐を優先するという基準を設けています。
最後に
シェーダーでの条件分岐の利用の仕方については、色々な議論がありますが、パフォーマンスを考えると、マテリアルパラメータによる分岐は大きな問題とならないことが多いです。一方、実行時の計算によって決定する分岐は大きな問題となることがあります。また、Unityではシェーダーバリアントを使った静的な条件分岐を利用することも多いと思いますが、その際にはメモリ使用量やコンパイル時間、そして可読性が下がってしまい長期的なメンテナスに影響を与えるという点にも注意が必要です。
次回のGPUパフォーマンスチューニング入門 第二回目では、オーバードローについてご紹介します。オーバードローについては皆さんご存じだと思うのですが、実機上でどの程度パフォーマンスが悪化するのか、またそのような問題をどのように解決するのかなどをご紹介していきます。