CORETECH ENGINEER BLOG

株式会社サイバーエージェント SGEコア技術本部 技術ブログ

【Xcode GPUプロファイリング入門 第1回 】シェーダーの負荷を計測してみよう

はじめに

こんにちは、 サイバーエージェントのゲーム・エンターテイメント事業部(SGE)に所属する子会社Colorful Paletteで Unityエンジニアをしている永留です。

この記事では、Xcode15 の Metal System Traceを利用してシェーダーの負荷を調べる方法を紹介します。

Metal System Traceでできること

Metal System Trace を利用することで、iOS実機でのGPUの負荷を調べることができます。

  • 1フレームのGPU負荷の確認
  • ドローコール(APIコール) ごとのGPU負荷の確認
  • シェーダーの行ごとのGPU負荷の確認

開発環境

開発に使用した環境は以下のとおりです。

  • Unity2022.3.21f1
  • Xcode 15.4
  • iPhone11 (iOS17.5)

用語

記事で出てくる用語について記載します。

  • Vertex Function
    • 頂点シェーダー関数のこと。
    • Unityシェーダーで #pragma vertex vert と指定している場合は、vert が Vertex Function
  • Fragment Function
    • フラグメントシェーダー関数のこと。
    • Unityシェーダーで #pragma fragment frag と指定している時は、frag が Fragment Function

Chapter1. 単色を出力するシェーダーの負荷を調べてみよう

カメラの正面にUnity標準のSphereを配置し、単色を出力するシェーダーをアタッチします。
このシーンをiOSバイスで実行した時の負荷を確認してみましょう。

SceneViewの表示

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    return o;
}

half4 frag (v2f i) : SV_Target
{
    return half4(1, 0.5, 0.3, 0.2);
}

シェーダーコード (Test001_UnlitColor.shader)

Shader "MyShader/Test001_UnlitColor"
{
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag

            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
            };

            struct v2f
            {
                float4 vertex : SV_POSITION;
            };
            
            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                return o;
            }

            half4 frag (v2f i) : SV_Target
            {
                return half4(1, 0.5, 0.3, 0.2);
            }
            ENDCG
        }
    }
}

Schemeの編集

Metal System Trace を利用するためには、XcodeにてSchemeの設定を行う必要があります。

Schemeの編集

OptionsタブのGPU Frame CaptureをMetalにします。

GPU Frame CaptureをMetalにする

Diagnosticsタブ の API Validation を有効化しておきます

API Validationを有効化

キャプチャの実行

Xcode上でアプリをビルドしてiOS実機に転送すると、M のような見た目のボタンが出てくるので、これをクリックします。

Captureボタンをクリックすることで、キャプチャが始まります。

キャプチャの確認

キャプチャが完了すると、Metal System Traceが表示されます。

ウィンドウ左側のDebug NavigatorにはGPUの情報が表示されています。

Pipeline Stateによるグルーピング

Group By API Callと書かれた部分を Group By Pipeline Stateに変更することで、パイプラインステートごとの描画内容を確認できます。

Pipeline Stateでグルーピングすることで、Pipeline Stateの負荷が表示されるようになります。

Shader editorの起動

今回のシェーダーを実行しているパイプラインステートを選択し、ウィンドウ右側からShader editorを起動します。

Vertex Function の負荷

Vertex Function を確認した場合は以下のような表示になっています。

Vertex Functionの処理にかかった合計時間や、シェーダーの各行の実行にかかった時間が確認できます。

Vertex Function

円グラフの部分にマウスカーソルを合わせると、処理負荷の内訳を確認できます。
iOSのバージョンによって、表示が異なります。

iPhone11 (iOS14.8.1)
iPhone12 (iOS16.1)

これらの数値の意味を理解するには、頂点シェーダーの実行フローを知っておいた方が良いでしょう。

Vertex Function の実行フロー

Appleプロセッサータイルベース遅延レンダリング (TBDR) を採用しており、以下のようなフローでVertex Functionが実行されます。

① メインメモリから GPU へ頂点データを読み出す (Load)
GPUVertex Function を実行し、頂点を計算する
③ 計算結果を Tiled Vertex Buffer へ書き出す (Vertex Write)

Vertex Functionの実行フロー

Shader editor上で確認できる、処理負荷は以下のような意味を持ちます。

  • ALU : GPU上での算術演算にかかった時間
  • Wait Memory : メモリアクセスの同期までにGPUが待機した時間
  • Load : メモリからGPUへ頂点データを読み出すのにかかった時間
  • Vertex Write : Vertex Function で計算した頂点データをTiled Vertex Bufferへ書き込みのにかかった時間
Vertexの処理負荷

参考 : https://developer.apple.com/documentation/xcode/inspecting-shaders

Fragment Function の負荷

Fragment Function の負荷も見てみましょう。

  • Fragmentの実行時間は 100.67μs
  • SV_Target0の設定にかかった時間は 12.54% (約 12.6μs)
  • Fragmentの出力 (バッファへの書き込み)にかかった時間は 87.46% (約 88μs)

Unityのシェーダーコード(fragment)

half4 frag (v2f i) : SV_Target
{
    return half4(1, 0.5, 0.3, 0.2);
}

Frament Function の実行フロー

Fragmentは以下のように実行されます。

① Tiled Vertex Bufferから頂点データを読み出す
② 読み出した頂点データをラスタライズし、フラグメントを構築
③ フラグメントをFragment Functionへ入力する
④ Fragment Functionでピクセルを計算する (ALU)
⑤ 計算したピクセルをFrame Bufferへ出力する (Store)

Fragment Functionの実行フロー

Frament Function の負荷の内訳

Fragmentの円グラフは以下のような意味を持ちます。

  • ALU : Fragmentの算術演算にかかった時間
  • Store : Fragmentの出力をFrameBufferへ書き込むのにかかった時間

Chapter2. テクスチャサンプリングの負荷を調べてみよう

続いては、テクスチャをサンプリングを行った時のシェーダーの負荷を見てみましょう

テクスチャサンプリングを行うシェーダーをChapter1と同じくSphereに設定し、iOSバイス上でのGPU負荷を計測してみます。

SceneViewの表示
v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = TRANSFORM_TEX(v.uv, _MainTex);
    return o;
}

half4 frag (v2f i) : SV_Target
{
    return tex2D(_MainTex, i.uv);
}

シェーダーコード (Test002_Texture.shader)

Shader "MyShader/Test002_Texture"
{
    Properties
    {
        _MainTex ("Texture", 2D) = "white" {}
    }
    SubShader
    {
        Tags { "RenderType"="Opaque" }
        LOD 100

        Pass
        {
            CGPROGRAM
            #pragma vertex vert
            #pragma fragment frag
            #include "UnityCG.cginc"

            struct appdata
            {
                float4 vertex : POSITION;
                float2 uv : TEXCOORD0;
            };

            struct v2f
            {
                float2 uv : TEXCOORD0;
                float4 vertex : SV_POSITION;
            };

            sampler2D _MainTex;
            float4 _MainTex_ST;

            v2f vert (appdata v)
            {
                v2f o;
                o.vertex = UnityObjectToClipPos(v.vertex);
                o.uv = TRANSFORM_TEX(v.uv, _MainTex);
                return o;
            }

            fixed4 frag (v2f i) : SV_Target
            {
                return tex2D(_MainTex, i.uv);
            }
            ENDCG
        }
    }
}

Vertex負荷

頂点シェーダーの負荷は以下のようになりました。 Chapter1では頂点負荷が29μsほどでしたが、こちらは頂点負荷が3μsほど増加しています。

グラフを比較してみると、Chapter2の方がメモリアクセスの比重が大きくなっていることがわかります。

Chapter1 頂点座標のみ出力
Chapter2 頂点座標とUVを出力

Vertexの負荷が増えた理由

Chapter1では、頂点シェーダーで頂点座標のみを出力していました。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    return o;
}

Chapter2では頂点座標とUVの両方を入出力しているため、メモリアクセスの量が増えます。

v2f vert (appdata v)
{
    v2f o;
    o.vertex = UnityObjectToClipPos(v.vertex);
    o.uv = v.uv;
    return o;
}


Fragment負荷

Fragment Functionの負荷を見てみましょう。

Chapter1 は 100.67μsでしたので、テクスチャサンプリングを追加したことで負荷が72μsほど増えています。

Chapter2 テクスチャサンプリングするFragment

Fragment負荷の比較

Chapter1とChapter2のグラフを比較してみると、 Synchronization(Wait Memory) の負荷が増えていることがわかります。

Chapter1 単色を出力
Chapter2 テクスチャサンプリング

テクスチャサンプリングのメモリ同期

Fragment Function でテクスチャをサンプリングする場合、テクスチャデータをメインメモリから読み出す必要がありますが、
テクスチャはサイズが非常に大きいため、データの読み出しが完了するまでに長い時間がかかります。

テクスチャデータのGPU側への読み出しが完了するまではFragmentを実行できないので、同期までの待機が発生します。

テクスチャサンプリングするとメインメモリからのテクスチャロードが走る

今回の計測では、Fragment時間 172.72μs のうち、Wait Memoryの割合は 57.07% なので 約 98μs の待機時間が発生しています。

Chapter3. Performance Stateを活用しよう

最後に、iOSバイス上での負荷計測を行う際に有用な Performance State について紹介したいと思います。

発熱による性能劣化

iOSバイスは熱を持つと、サーマルスロットリング によって端末の性能が低下します。
例えば、iOSバイスを充電したままゲームを長時間遊んでいると端末が熱を持ち、FPSが大きく下がることがあります。

Performance State

iOSバイスは、端末の熱や品質設定などさまざまな要因によってパフォーマンスが変動します。

Xcodeでは、パフォーマンスの状態( Performance State ) をシミュレートし、GPU速度を計測するという機能が備わっております。 これを利用すれば、「端末が熱くなった時にどれくらいの速度が出るか」を調べるといったことができます。

Xcode15では、3つのPerformance Stateが利用可能です。

  • Maximum : パフォーマンスが最も高い状態をシミュレートして、GPU負荷を計測
  • Medium : パフォーマンスが中くらいの状態をシミュレートして、GPU負荷を計測
  • Minimum : パフォーマンスが最も低い状態をシミュレートして、GPU負荷を計測

発熱によって速度が低下したときのGPU負荷を調べたい場合、Minimum を選ぶと良いでしょう。

Performance State の使い方

時計のような見た目のアイコンをクリックします

Performance Stateの指定ができます。

最後に

今回の記事では、XcodeのMetal System Traceの基本的な使い方について紹介しました。
XcodeMetal System TraceのShader editorを利用すると、シェーダーの各行の処理負荷を詳細に確認することができます。

次回の記事では、シェーダーでアルファクリップを使った時のGPU負荷について触れてみたいと思います。

リンク

Inspecting shaders | Apple Developer Documentation

Optimize Metal apps and games with GPU counters - WWDC20 - Videos - Apple Developer