シェーダーのパフォーマンスの問題。
saturate(x)
と max(0, x)
のどちらが速いか? という割とどうでもいい話。でもシェーダーを書く人にとっては知っておくと良いかも。
例えばシェーダーでライティングの計算をするとき、
1 |
fixed lit = max(0, dot(lightDir, normal); |
と書くか、
1 |
fixed lit = saturate(dot(lightDir, normal); |
と書くか、ということ。
saturate(x)
は意味的には max(0, min(1, x))
と同じなので、普通に考えたら max(0, x)
の方が速そうだけど実はそうでもない。
Microsoftが定義しているシェーダーアセンブリにはsaturate修飾子がある。
http://msdn.microsoft.com/en-us/library/windows/desktop/bb219849(v=vs.85).aspx
つまり、r0 = saturate(r1 + r2)
は
1 |
add_sat r0, r1, r2 |
と1命令で書けるのだ。
もちろん、GPUの命令セットにこの修飾子があるかどうかは別の話だけど、基本的にDirectXに対応したGPUならこの仕様にのっかってると考えてよいと思う。なので僕は基本的にはsaturateを使っていたのだけど、Unityのビルトインシェーダーのソースコードを見るとライティングの計算でsaturateではなくmaxを使っていたのである。
確かに、saturate修飾子がなければmaxの方が処理が軽いのはまちがいない。モバイルのGPUなんかはDirectXに対応する必要もないし、ひょっとしたらsaturate修飾子なんかないのかも知れない。
と言うわけで実験してみた。調べたのは、saturate(x)
と max(0, x)
と x
を使った場合のパフォーマンスの違い。以下長くなるので、まず先に結論を。
結論:
- 基本的に
saturate(x)
を使って問題ない。ほとんどの場合でコストフリー、つまりsaturate(x)
とx
は同じ速さ。 - PowerVR系はfixedじゃないとsaturate修飾子がない。つまりhalfやfloatは
max(0, x)
の方が速い。fixedならsaturate(x)
とx
は同じ速さ。 - Tegra 3はsaturate修飾子があるように見えるが、コードによっては
saturate(x)
はmax(0, x)
と同じ速さだったり、x
よりも遅かったりする。条件によっては2命令かかりそうな処理も1サイクルで実行できるようで、どういうコードを書けば速くなるのかはよくわからないが、saturateを使って悪いことはなさそう。 - Adrenoは
saturate(x)
とx
は同じ速さ。つまりコストフリー。でもmax(0, x)
もそんなに遅くない(頂点シェーダーの出力を補間する処理もフラグメントシェーダーに含まれて、1命令増えたところでパフォーマンスにあまり影響しないということなのかも)。 - Maliは
saturate(x)
もmax(0, x)
もx
もみんな同じ速さだった。max(0, x)
修飾子もあると思われる。
おまけの結論:
- やっぱりPowerVR系はfixedを使わないとものすごく遅くなる。halfとfloatは同じ速さ。
- AdrenoやMaliは頂点シェーダからフラグメントシェーダに渡すパラメータの数もパフォーマンスに影響する。halfとfixedが同じ速さ(たぶん精度も同じ)でfloatが少し遅い。
- Tegra 3はfloatを使ってもあまりパフォーマンスに影響しないが、fixedを使った方が速くなる場合がある。
実験1:
まず次のようなシェーダーで実験してみた。(Unityのシェーダーです)
Passが3つあって、それぞれがsaturate(x)
, max(0, x)
, x
に対応している。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 |
Shader "Custom/saturate test" { Properties { _Color ("Main Color", Color) = (1,1,1,1) } CGINCLUDE #include "UnityCG.cginc" fixed4 _Color; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; fixed4 col1 : TEXCOORD0; fixed4 col2 : TEXCOORD1; }; v2f vert(appdata_img v) { v2f o; o.pos = v.vertex; o.col1 = 0.5f + 0.5f * v.vertex.xyxw; o.col2 = 0.5f + 0.5f * v.vertex.yxyw; return o; } fixed4 frag_saturate(v2f i) : COLOR { return _Color * saturate(i.col1 - i.col2); } fixed4 frag_max(v2f i) : COLOR { return _Color * max((fixed4)0, i.col1 - i.col2); } fixed4 frag_none(v2f i) : COLOR { return _Color * (i.col1 - i.col2); } ENDCG SubShader { Pass { ZTest Always Cull Off ZWrite Off Blend Off Fog { Mode off } CGPROGRAM #pragma vertex vert #pragma fragment frag_saturate #pragma fragmentoption ARB_precision_hint_fastest ENDCG } Pass { ZTest Always Cull Off ZWrite Off Blend Off Fog { Mode off } CGPROGRAM #pragma vertex vert #pragma fragment frag_max #pragma fragmentoption ARB_precision_hint_fastest ENDCG } Pass { ZTest Always Cull Off ZWrite Off Blend Off Fog { Mode off } CGPROGRAM #pragma vertex vert #pragma fragment frag_none #pragma fragmentoption ARB_precision_hint_fastest ENDCG } } } |
これを次のように描画してm_pass
を切り替えながらパフォーマンスを比較する。m_quadCount
はデバイス毎に調整。
1 2 3 4 5 6 7 8 9 10 11 12 |
void OnPostRender() { m_material.SetPass(m_pass); GL.Begin(GL.QUADS); for (int i = 0; i < m_quadCount; ++i) { GL.Vertex3(-1.0f, 1.0f, 0.0f); GL.Vertex3(-1.0f,-1.0f, 0.0f); GL.Vertex3( 1.0f,-1.0f, 0.0f); GL.Vertex3( 1.0f, 1.0f, 0.0f); } GL.End(); } |
結果1:
iPod touch
4th gen (PowerVR SGX 535) |
Galaxy Nexus
(PowerVR SGX 540) |
Nexus 7
(2012) (NVIDIA Tegra 3) |
XPERIA M2
(Adreno 305) |
Galaxy SII
(Mali-400) |
|
m_quadCount |
50
|
50
|
15
|
15
|
15
|
saturate(x) |
47 (FPS)
|
53 (FPS)
|
26 (FPS)
|
46 (FPS)
|
38 (FPS)
|
max(0, x) |
47 (FPS)
|
53 (FPS)
|
18 (FPS)
|
42 (FPS)
|
38 (FPS)
|
x |
47 (FPS)
|
53 (FPS)
|
49 (FPS)
|
46 (FPS)
|
38 (FPS)
|
この結果を見ると、AdrenoやTegraではsaturateの方が速いことがわかる。でも、Tegraの場合、saturate(x)
の方が x
よりも遅い。しかも速度が倍近く違う。saturateはコストフリーではないということだろうか? フレームレートを見ると、saturateの場合だと2サイクル, maxの場合だと3サイクル, x の場合で1サイクルかかってるんじゃないかと思う。つまり、Tegra 3の場合、x*(y-z)
みたいな計算を1サイクルでできるようだ。
試しに、
1 2 3 |
fixed4 frag_saturate(v2f i) : COLOR { return _Color * saturate(i.col1 * i.col2); } |
のように x
の部分を乗算に変えてみたところ、全てのケースで26FPSという結果になった。maxもsaturateも同じスピードである。もうどうなってるんだか。乗算と max(0, x)
は1命令でできるということか? 試しに max(0.1, x)
で試したら18FPSになったので、max(0, x*y)
みたいな計算は1サイクルでできるのだろう。
別の実験で明らかになるが、fixedの代わりにfloatやhalfを使った場合には、saturate
と x
が同じ26FPSで、max(0, x)
は18FPSという結果になった。floatやhalfの場合は、x*(y-z)
の計算を1サイクルで行なうことはできないようだ。もう複雑過ぎてよくわからん。
さて、他のGPUの場合、どのケースも同じ速度で違いがない。ひょっとしたらフラグメントシェーダー以外のところがボトルネックになっているかもしれない。考えられるのはフィルレートかインターポレータ(頂点シェーダーからフラグメントシェーダーに渡す変数をフラグメント毎に補間するところ)である。
そこで、まずインターポレータを疑って、頂点シェーダーからフラグメントシェーダーに渡す変数の数を減らして実験することにした。
実験2:
実験1のシェーダーを少し変えて次のようにした。実験1の v2f
には col1
と col2
があったのを col
ひとつだけにした。これでインターポレータの負荷が減るはずである。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 |
CGINCLUDE #include "UnityCG.cginc" fixed4 _Color; struct appdata { float4 vertex : POSITION; }; struct v2f { float4 pos : SV_POSITION; fixed4 col : TEXCOORD0; }; v2f vert(appdata_img v) { v2f o; o.pos = v.vertex; o.col = 0.5f + 0.5f * v.vertex.xyxw; return o; } fixed4 frag_saturate(v2f i) : COLOR { return _Color * saturate(i.col.rgba * i.col.grga); } fixed4 frag_max(v2f i) : COLOR { return _Color * max((fixed4)0, i.col.rgba * i.col.grga); } fixed4 frag_none(v2f i) : COLOR { return _Color * (i.col.rgba * i.col.grga); } ENDCG |
結果2:
iPod touch
4th gen (PowerVR SGX 535) |
Galaxy Nexus
(PowerVR SGX 540) |
Nexus 7
(2012) (NVIDIA Tegra 3) |
XPERIA M2
(Adreno 305) |
Galaxy SII
(Mali-400) |
|
m_quadCount |
50
|
50
|
15
|
30
|
15
|
saturate(x) |
47 (FPS)
|
53 (FPS)
|
26 (FPS)
|
48 (FPS)
|
49 (FPS)
|
max(0, x) |
47 (FPS)
|
53 (FPS)
|
18 (FPS)
|
42 (FPS)
|
49 (FPS)
|
x |
47 (FPS)
|
53 (FPS)
|
49 (FPS)
|
48 (FPS)
|
49 (FPS)
|
AdrenoとMaliでパフォーマンスに実験1との違いが見られた。特にAdreno 305は倍の速さである(m_quadCount
が15から30に変わっていることに注意!)。ということは、Adreno はインターポレータがボトルネックだったということだろうか? いや、saturateとmaxにもパフォーマンスの違いがあるので、インターポレータはフラグメントシェーダと並列に動作するのではなく、直列に(フラグメントシェーダの一部として)動作しているということだろう。
Maliの方もパフォーマンスに違いが見られたとはいえ、依然として saturate(x)
, max(0, x)
, x
は同じ速さである。しかし、パフォーマンスに違いが見られたということは、他の原因(フィルレート)がボトルネックとは考えられない。試しに max(0, x)
の代わりに max(0.1, x)
のように変更して試してみたところFPSが49から38に変化したので、saturate(x)
, max(0, x)
, x
は同じ速さと結論付けても良いだろう。つまり、Maliの場合はsaturate修飾子だけでなく、max(0, x)
修飾子もあると考えられる。
さて、残るはPowerVR系だけである。インターポレータはボトルネックではなかったので、他にボトルネックの原因があると考えられる。やはりフィルレート? しかし、PowerVRなどのモバイルのGPUは(Tegraを除けば)タイルレンダリングを使っていてフィルレートがボトルネックになることはなさそうだけど・・・
しかも、PowerVR系だけやたらパフォーマンスが良い。m_quadCount
の値が50なのである! つまり、m_quadCount
を50にしてようやくFPSが60を切ったってこと。そんなバカな! 僕の中ではiPod touch 第4世代はGPUが遅いデバイスの代名詞だったのに。
この秘密はPowerVR特有のタイルレンダリングの中にありそうだ。タイルレンダリングというのは、レンダリングの結果を大きなフレームバッファに直接書き込むのではなく、高速に書き込みができる小さなメモリに書いて、あとでまとめてフレームバッファにコピーする手法。メモリが小さくて全画面分をカバーできないので、画面をいくつかのタイルに分けてレンダリングするからタイルレンダリングと言う。
PowerVRのユニークなところは頂点シェーダーからの出力をタイル毎にいったんバッファに溜めておいて、最終的に画面に描画されるポリゴンだけフラグメントシェーダーに渡すということ。つまり、不透明なポリゴンを描画する場合、一番手前にあるポリゴンだけが描画されて、その後ろに隠れているポリゴンはフラグメントシェーダーで処理されることはないのである。これによってパフォーマンスを上げることができるのだが、上の実験ではまさにこれにハマったと考えられる。ZテストをAlwaysにしてたから大丈夫かと期待してたんだけど、アルファブレンドを有効にしないとダメみたい。
それにしても、ボトルネックは何だったのだろう? 頂点シェーダーか? いやいや、たかが4 x 50 = 200個の頂点しか処理していないのに60FPSを切るはずがない。おそらく、頂点シェーダーの出力をタイル毎にバッファに溜める処理がボトルネックになってたんだと思う。なんせ、全画面に描画されるポリゴンを50枚も描画してたんだから。それに、途中でバッファがいっぱいになってバッファがフラッシュされてたかもしれない。フラッシュされると、それまでに処理されたポリゴンが描画されるので、フラグメントシェーダーが何回か走っていた可能性はある。
というわけで次の実験ではアルファブレンドを有効にしてやってみます。
実験3:
実験2のシェーダーを次のようなアルファブレンドの設定にする。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 |
SubShader { Pass { ZTest Always Cull Off ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Fog { Mode off } CGPROGRAM #pragma vertex vert #pragma fragment frag_saturate #pragma fragmentoption ARB_precision_hint_fastest ENDCG } Pass { ZTest Always Cull Off ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Fog { Mode off } CGPROGRAM #pragma vertex vert #pragma fragment frag_max #pragma fragmentoption ARB_precision_hint_fastest ENDCG } Pass { ZTest Always Cull Off ZWrite Off Blend SrcAlpha OneMinusSrcAlpha Fog { Mode off } CGPROGRAM #pragma vertex vert #pragma fragment frag_none #pragma fragmentoption ARB_precision_hint_fastest ENDCG } } |
結果3:
iPod touch
4th gen (PowerVR SGX 535) |
Galaxy Nexus
(PowerVR SGX 540) |
Nexus 7
(2012) (NVIDIA Tegra 3) |
XPERIA M2
(Adreno 305) |
Galaxy SII
(Mali-400) |
|
m_quadCount |
5
|
5
|
15
|
30
|
15
|
saturate(x) |
20 (FPS)
|
45 (FPS)
|
29 (FPS)
|
48 (FPS)
|
49 (FPS)
|
max(0, x) |
16 (FPS)
|
38 (FPS)
|
29 (FPS)
|
42 (FPS)
|
49 (FPS)
|
x |
20 (FPS)
|
45 (FPS)
|
29 (FPS)
|
48 (FPS)
|
49 (FPS)
|
やっぱり! 期待通りの遅さになった! m_quadCount
を5にしてこのパフォーマンス。さすがのiPod touchである。そして、しっかりsaturateとmaxにパフォーマンスの違いがあらわれた。PowerVRにもsaturate修飾子があると考えてよさそうである。しかもちゃんとコストフリー。
AdrenoとMaliにはアルファブレンドによるパフォーマンスの違いはなかった。
問題はTegra。どのケースも同じパフォーマンス(29FPS)で、saturateとmaxはアルファブレンドしている方が速い。しかし、実験3で使ったシェーダーは常にアルファが0なのである。ひょっとしたら、カラーとアルファを掛ける処理をシェーダーでやっていて、最適化がかかっているのかも知れない。と思って、アルファブレンドの設定を
1 |
Blend One SrcAlpha |
に変えてやってみたところ、実験1や実験2と同じ結果になった。つまりアルファブレンドしてもしなくても同じパフォーマンスということ。やはり、アルファが常に0ということで最適化がかかってたんだろう。それにしても実験3では x
のケースでパフォーマンスが29FPSとアルファブレンドなしのケースに比べてパフォーマンスが落ちたのかがよくわからない。やはり特殊なコードパターンから外れて特殊な最適化がかからなくなったということだろうか。
さて、これで全てのGPUでsaturate修飾子がありそうだということがわかったのだけど、ひとつ気になることがある。それはPowerVR系はfloatやhalfの計算が苦手ということである。つまり、fixedならばSIMD命令があるけど、floatやhalfだとSIMD命令が使えずにパフォーマンスが極端に落ちるのである。ひょっとしたら、saturate修飾子もないかもしれない。ということで試してみた。
実験4:
実験3のシェーダーのfixed4をfloat4に置き換えて試してみる。ただしTegraで変な最適化がかからないようにアルファブレンドを
1 |
Blend One SrcAlpha |
に変更。
結果4:
iPod touch
4th gen (PowerVR SGX 535) |
Galaxy Nexus
(PowerVR SGX 540) |
Nexus 7
(2012) (NVIDIA Tegra 3) |
XPERIA M2
(Adreno 305) |
Galaxy SII
(Mali-400) |
|
m_quadCount |
2
|
5
|
15
|
30
|
15
|
saturate(x) |
16 (FPS)
|
15 (FPS)
|
26 (FPS)
|
37 (FPS)
|
43 (FPS)
|
max(0, x) |
21 (FPS)
|
19 (FPS)
|
18 (FPS)
|
30 (FPS)
|
43 (FPS)
|
x |
30 (FPS)
|
23 (FPS)
|
26 (FPS)
|
37 (FPS)
|
43 (FPS)
|
fixed4をfloat4に変えるだけで、Tegra 3以外はパフォーマンスが落ちた。特にPowerVR系はひどい。iPod touchは m_quadCount
が2になっていることに注意! パフォーマンスは半分近く低下している。
AdrenoやMaliにもパフォーマンスに違いが見られた。Maliは10%程度の違いだが、Adrenoは20〜30%くらいの違いが見られる。Tegraはfixedもfloatもあまり関係なさそう。
そして注目すべきはPowerVR系ではfloatにすることでsaturateとmaxの速さが逆転しているのである。これはfloatの場合はsaturate修飾子がないと考えて良いだろう。
floatで試したのだから次はhalfである。
実験5:
実験3のシェーダーのfixed4をhalf4に置き換えて試してみる。ただしTegraで変な最適化がかからないようにアルファブレンドを
1 |
Blend One SrcAlpha |
に変更。
結果5:
iPod touch
4th gen (PowerVR SGX 535) |
Galaxy Nexus
(PowerVR SGX 540) |
Nexus 7
(2012) (NVIDIA Tegra 3) |
XPERIA M2
(Adreno 305) |
Galaxy SII
(Mali-400) |
|
m_quadCount |
2
|
5
|
15
|
30
|
15
|
saturate(x) |
16 (FPS)
|
15 (FPS)
|
26 (FPS)
|
48 (FPS)
|
49 (FPS)
|
max(0, x) |
21 (FPS)
|
19 (FPS)
|
18 (FPS)
|
42 (FPS)
|
49 (FPS)
|
x |
30 (FPS)
|
23 (FPS)
|
26 (FPS)
|
48 (FPS)
|
49 (FPS)
|
結果的にはTegra 3の場合、fixedもhalfもfloatもみんな同じパフォーマンス。ただし、x
の場合だけ特殊な最適化(?)がかかって、fixedのときに異様に速い。AdrenoとMaliはfixedとhalfが同じパフォーマンスでfloatが若干遅い。PowerVRの場合は、fixedが断然速く、halfとfloatは同じ速さでとても遅い。また、PowerVRの場合、halfとfloatではsaturate修飾子が使えないということがわかった。
テストに使ったのは古めのGPUばかりなので、最新のものだとどうなるかわからないけど、たぶん同じ傾向にあるんじゃないかな。