MacBook Air Late 2020 / Apple M1 のビルド速度と浮動小数点演算能力

2020年後期の新型 MacBook Air (Apple M1 ARM) のビルド速度と vfpbench の結果です。2020年前期発売の Intel モデルと比べて 5倍高速でした。

Device CPU Thread Time
MacBook Air Late2020 Apple M1 arm64 8/8 9 sec
MacBook Air Early2020 Core i5-1030NG7 4/8 45 sec
Mac mini L2012 Core i7-3615QM 4/8 47 sec
MacBook Pro Late2012 Core i5-3210M 2/4 125 sec

・↑コンパイル時間の比較。Time が小さい方が速い。

vfpbench の結果は下記の通りです。macOS では LITTLE core だけ Affinity で固定することができないので Multi-Thread の値はまだ不正確です。そのため Single Thread だけ比較しています。

↓vfpbench の Single Thread の結果のみ抜粋

Device CPU Thread Half Single Double
MacBook Air Early2020 Core i5-1030NG7 4/8 111.3 55.6
MacBook Air Late2020 Apple M1 arm64 8/8 153.1 76.6 38.3
MacBook Air Late2020 Apple M1 x86_64 8/8 34.1 17.1
Pixl 3 Snapdragon 845 Cortex-A75+A55 8/8 44.4 22.3 11.2
PH-1 Snapdragon 835 Cortex-A73+A53 8/8 19.5 9.8

・↑Half/Single/Double の数値は GFLOPS。値が大きい方が速い。

Apple M1 の結果詳細(抜粋)

                                      TIME(s)   MFLOPS      MOPS     FOP   IPC
FPU fmul (32bit x1) n8            :    0.157    12195.9    12195.9  (  1.0 3.8)
FPU fadd (32bit x1) n8            :    0.150    12799.9    12799.9  (  1.0 4.0)
FPU fmadd (32bit x1) n8           :    0.301    12753.6     6376.8  (  2.0 2.0)
NEON fmul.2s (32bit x2) n8        :    0.150    25593.3    12796.7  (  2.0 4.0)
NEON fadd.2s (32bit x2) n8        :    0.150    25570.3    12785.2  (  2.0 4.0)
NEON fmla.2s (32bit x2) n8        :    0.302    25441.6     6360.4  (  4.0 2.0)
NEON fmul.4s (32bit x4) n12       :    0.225    51167.5    12791.9  (  4.0 4.0)
NEON fadd.4s (32bit x4) n12       :    0.225    51086.7    12771.7  (  4.0 4.0)
NEON fmla.4s (32bit x4) n12       :    0.301    76531.6     9566.5  (  8.0 3.0)

この結果より、SIMD (NEON) は 128bit FMA (fmla) が 3命令同時に走っており、ピーク値は 1 cycle あたり 24fop であることがわかります。256bit FMA が 2命令走る Haswell/Zen2/3 は 32 fop 、AVX512 では最大 64 fop なので、単 core でのピーク FLOPS はそれよりも落ちます。

その代わり注目すべきは IPC の方で、FMA で 3命令、ADD/MUL でサイクルあたりのスループットが 4命令です。Intel は 128bit でも最大 2命令、Zen2 では Add + Mul の組合わせのみ 4命令なので、スカラーや 128bit 演算は Apple M1 の方が速度出る可能性があります。

FLOPS 表の「Apple M1 x86_64」は Rosetta によるバイナリ変換で実行した場合のものです。AVX/FMA 命令が動かなかったので SSE4.2 までのオプションでビルドしています。FMA がないのでピーク値は半減していますが、加減算命令の IPC は 3~4 と高い値を維持していました。

コンパイル時間の比較を参考用に載せておきます。OS と SSD、使用したコンパイラが異なるので単純に比較できませんのでご了承ください。また必ずしもあらゆるタスクでこの性能差が生じるわけではありません。特にビルドに時間がかかる巨大なプロジェクトではかなり遅くなると思います。

Device CPU SSD Thread Time
Windows Desktop WSL2 Ryzen 9 3950x SATA 16/32 8 sec
MacBook Air Late2020 Apple M1 arm64 NVMe 8/8 9 sec
WIndows Desktop WSL2 Ryzen 7 PRO 4750G SATA 8/16 18 sec
Linux Desktop Core i7-6700k SATA 4/8 29 sec
Linux Desktop Core i7-4790k SATA 4/8 31 sec
Pxiel3 Snapdragon 845 Coretex-A75+A55 eMMC 8/8 35 sec
PH-1 Snapdragon 835 Coretex-A73+A53 eMMC 8/8 40 sec
MacBook Air Early2020 Core i5-1030NG7 NVMe 8/8 45 sec
Mac mini Late 2012 Core i7-3615QM SATA 4/8 47 sec
MacBook Pro Late 2012 Core i5-3210M SATA 2/4 125 sec
Raspberry Pi 4 Coretex-A72 SD 4/4 146 sec

上記以外の他のデバイスとの比較はこちら↓にあります。

Compile Benchmark

関連ページ
Hyperでんち: Compile Benchmark
Hyperでんち: vfpbench 結果まとめ

関連エントリ
Ice Lake の vfpbench 結果と AVX512 命令
4倍速い Ryzen 9 3950X の UE4 コンパイル速度
Snapdragon 845 ARMv8.2A 半精度 fp16 演算命令を使ってみる / Deep Learning 命令
Snapdragon 835 と 845 のコンパイル時間の比較&浮動小数点演算能力
Snapdragon 845 の浮動小数点演算速度
ARM CPU の浮動小数点演算能力まとめ
HTC 10 Snapdragon 820 Kyro の浮動小数点演算能力
iPhone SE, Apple A9 の浮動小数点演算速度
ARM Cortex-A53 の浮動小数点演算速度とコンパイル時間の比較
iPod touch 6 の浮動小数点演算速度は Core 2 Duo ライン超え
iPad Air 2 (Apple A8X) の浮動小数点演算能力
ARM cpu vfp の種類と fp16 命令を使ってみる

Ice Lake の vfpbench 結果と AVX512 命令

Ice Lake の PC (mac) を手に入れたので vfpbench を AVX512 対応にしてみました。結果は下記のとおりです。

AVX512 reg GFLOPS fop IPC
AVX512VL vmulps ymm 256bit 55.2 8 6.3
AVX512VL vaddps ymm 256bit 55.6 8 6.3
AVX512VL vfmaddps ymm 256bit 111.3 16 6.3
AVX512F vmulps zmm 512bit 53.7 16 3.1
AVX512F vaddps zmm 512bit 54.0 16 3.1
AVX512F vfmaddps zmm 512bit 108.0 32 3.1
AVX512F vfmadd+mulps zmm 512bit 81.0 24 3.1
AVX512F vfmadd+addps zmm 512bit 81.2 24 3.1

・Core i5-1030NG7 (MacBook Air)

AVX512 は、512bit 単位の演算が可能となる Intel の新しい SIMD 命令セットです。AVX/AVX2 は 256bit 幅なので 2倍に増えたことになります。単精度の浮動小数点演算なら 512/32bit = 16並列です。4×4 matrix が 1レジスタに収まります。

SSE から AVX に進化したときと同じように、命令のエンコードも一新されており機能も増えています。SSE → AVX では 3オペランドになり 64bit 時に 16個のレジスタが利用できました。AVX → AVX512 ではレジスタフィールドが 5bit となり、レジスタ数が 32個に増えています。さらに 7個の mask レジスタを併用することができます。

mask レジスタは初期の GPU の Shader にあった書き込みマスクと同じものです。出力レジスタのうち必要な要素のみ置き換えることができます。残りの部分は元の値が残りますが、保存せずにゼロクリアを行うこともできます。

mask レジスタが導入されたことで、大きくて一見小回りがきかないようにみえる 512bit のレジスタも、任意のベクタ長とみなして扱うことができます。単精度なら 16個分ですが、mask を併用すれば 1~15 個の単位でも読み書きができるわけです。

SSE/AVX では少々扱いづらかった x,y,z の 3要素ベクタも簡単にロードすることができます。 下記の例ではベクタ (x,y,z) を 4個まとめて読み込んでいます。長さ 12 のベクタとして読み込んだあと、それぞれ (x,y,z) → (x,y,z,0) に展開しています。

movl    $0x0fff, %eax
kmovw   %eax, %k1
movl    $0x7777, %eax
kmovw   %eax, %k2

movups     data(%rbp), %zmm0{%k1}{z}    ; mask 0xfff で読み込み
vexpandps  %zmm0, %zmm1{%k2}{z}         ; mask 0xfff -> 0x7777 に展開

AVX2 でも gather を使えば似たようなことができますが、どちらかといえば gather 命令は Shader の InputAssembler に相当します。

もちろん常時マスク付きで演算を行うと無駄が生じていることになります。GPU の SIMT のように、SoA で扱う方が AVX512 の本来の形かもしれません。この場合レジスタはベクタではなく 16個(単精度の場合)のスカラーとなり、mask レジスタは 16個のフラグレジスタとみなせます。

float d= n.dot( l );
if( d < 0 ){
    c+= a;
}else{
    c+= b * d;
}

例えば↑こんな感じのコードを 16並列で実行すると↓こうなります。

vmulps        %zmm8, %zmm11, %zmm20
vfmadd231ps   %zmm9, %zmm12, %zmm20
vfmadd231ps   %zmm10, %zmm13, %zmm20
vcmpps        $1, %zmm20, %zmm18, %k1
knotw         %k1, %k2
vfmadd231ps   %zmm20, %zmm19, %zmm21{%k1}
vaddps        %zmm17, %zmm21, %zmm21{%k2}

比較命令の結果であるフラグ値は mask レジスタに入るので、条件成立時と不成立時の演算結果をそのまま合成することができます。

AVX512 の説明が少々長くなりましたが、IceLake の vfpbench の結果を見てみます。ピークの GFLOPS 値は AVX(FMA3) 命令でも AVX512 命令でも変わっていないことがわかります。Ice Lake の場合 zmm (512bit) の AVX512F 命令は同時に 1命令しか実行できないようです。

AVX reg GFLOPS fop IPC
FMA3 vfmaddps ymm 256bit 111.0 16 6.3
AVX512VL vfmaddps ymm 256bit 111.3 16 6.3
AVX512F vfmaddps zmm 512bit 108.0 32 3.1

この結果は Intel のサイトでも確認できます。

Intel: Intrinsics Guide

上記ページの「__m512 _mm512_fmadd_ps (__m512 a, __m512 b, __m512 c)」を見ると、Icelake の throughput は 1 なので実行に 1 cycle かかることがわかります。対して Skylake (server)/Knights Landing の方は 0.5 なので、2 命令実行できることを意味しています。

また同じ AVX512 の命令でも、mask 付きの ymm(256bit) は AVX/FMA 同様 2命令実行できています。Intrinsics Guide で確認してみると throughput は 0.5 なので合っているようです。

よって IceLake の場合は、性能を上げるために無理に AVX512 命令を使う必要は無さそうです。ただし最初に紹介したように、AVX512 ではレジスタが倍増し便利な機能も命令も増えています。mask が使える便利な AVX2 として見ても十分使い物になるのではないでしょうか。

反面 CPU によって対応機能が細かく別れてしまうので、最適化と互換性の両立はますます難しくなりそうです。

なお vfpbench の log で IPC に大きな数値が出ているのは CPU のベースクロックを元にしているためです。今回使用した Core i5-1030NG7 はベースが 1.1GHz で Single Thread の Boost 時に 3.5GHz になります。そのため 3.5/1.1 の 3.18 がおよそ IPC=1 と思ってください。

より詳細なログは下記からどうぞ

Hyperでんち: VFP Benchmark Log 計測結果まとめ

関連エントリ
4倍速い Ryzen 9 3950X の UE4 コンパイル速度
Snapdragon 845 ARMv8.2A 半精度 fp16 演算命令を使ってみる / Deep Learning 命令
Snapdragon 835 と 845 のコンパイル時間の比較&浮動小数点演算能力
Snapdragon 845 の浮動小数点演算速度
ARM CPU の浮動小数点演算能力まとめ
HTC 10 Snapdragon 820 Kyro の浮動小数点演算能力
iPhone SE, Apple A9 の浮動小数点演算速度
ARM Cortex-A53 の浮動小数点演算速度とコンパイル時間の比較
iPod touch 6 の浮動小数点演算速度は Core 2 Duo ライン超え
iPad Air 2 (Apple A8X) の浮動小数点演算能力
ARM cpu vfp の種類と fp16 命令を使ってみる
Intel AVX その3 命令
Intel AVX その2 転送
Intel AVX

UE4 Engine コンパイル時間の比較 4.25.2

UE4 4.25.2 のビルド時間を比較しました。

CPU Core CPU clock Thread RAM ビルド時間T ビルド時間P
Ryzen 9 3950X Zen2 3.5-4.7GHz 16C32T 32GB 18分29秒 16分07秒
Ryzen 7 1800X Zen 3.6-4.0GHz 8C16T 32GB 50分10秒 45分48秒
Core i7-6700K Skylake 4.0-4.2GHz 4C8T 32GB 68分21秒 63分37秒
Core i7-4790K Haswell 4.0-4.4GHz 4C8T 16GB 74分59秒 70分12秒
Ryzen 5 3400G 35W Zen+ 3.7-4.2GHz 4C8T 32GB 101分31秒 95分22秒

・ビルド時間が短い方が高速
・UE4 は GitHub 版 4.25.2 で VisualStudio 2017 を使用

「ビルド時間T」はビルドにかかった合計時間、「ビルド時間P」は ParallelExecutor のみの時間です。T は UnrealBuildTool の依存解析や UnrealHeaderTool など並列度が低い時間を含んでいます。P の方が比較的並列度が高くなり core 数に比例しやすくなります。

一見 Ryzen 5 3400G が遅く見えますが、これは cTDP で 35W に設定したときの値となっています。定格は 65W なので本来はもっと高速です。

SATA の SSD 上でビルドしています。Ryzen 9 3950X の場合 Thread 数に対して I/O 速度が追いついておらず SSD の I/O 待ちが発生することがあるようです。M.2 NVMe (PCIe) の SSD ならもう少し時間を短縮できると思われます。分散ビルドでもこれ以上短縮するのは容易ではないので、ビルド時間に悩まされているならできるだけ core 数が多い CPU を使うことをお勧めします。

過去の比較記事を下記の wiki にまとめてみました。エンジンの Version が上がるにつれてコード量も増えており、ビルド時間が増えていることがわかります。

UE4 Engine Compile 時間の比較

関連エントリ
4倍速い Ryzen 9 3950X の UE4 コンパイル速度
AMD CPU Ryzen とコンパイル時間の比較 (2)
AMD CPU Ryzen とコンパイル時間の比較

関連ページ
UE4 Engine Compile 時間の比較

UE4 プログラミング言語 Blueprint (3)

前回の続きです。

UE4 プログラミング言語 Blueprint (1)
UE4 プログラミング言語 Blueprint (2)

●未実行命令の戻り値

イベントグラフでは複数のイベントのノードを同時に記述できるため、全く関係ないノードの値を参照することもできてしまいます。(↓不正参照に見える例A)

retvalue

↑PrintInt が FuncD 関数の戻り値を参照しています。流れと無関係なスコープ外の参照であり、FuncD の実行前に戻り値を参照することになります。動きが予想できないのですが、実行そのものは問題なくできてしまいます。その理由を Delay 命令の動きから考えてみます。

Delay は即座に呼び出し元に Return しており、後続の命令はあとから別のコンテキストで実行します。(前回の解説参照) 下記のコードはよくある普通のものですが、よく見ると中断前に実行した関数 FuncD の結果を時間差で参照していることになります。もちろん PrintInt では正しく FuncD の結果を読み取ることができます。

retvalue2

初回で触れたように、通常の関数の戻り値は必ず一時変数を経由します。イベントグラフでは途中で中断&再開される可能性があるので、通常の変数だけでなくこのような「戻り値を格納している無名の一時変数」もメンバとして保存しておく必要があります。

これらの違いを C++ 風のコードで表現してみます。(あくまで説明用のもので、実際の UE4 C++ のコードではありません)

Blueprint の Delay の挙動を C++ で表すと下記のとおりです。

// 動作イメージ
void Prog08_RetValue2()
{
    int tempA= FuncD().Value1;
    AddDelayAction( 1.0f, [=](){
            PrintInt( tempA );
      } );
}

Blueprint では C++ のように Lambda で変数の値をキャプチャできないので、下記のようにメンバ変数を経由します。

// Blueprint の挙動
class Prog08 {
    int TempA= 0; // 関数の結果を受け渡す一時変数
public:
    void Prog08_RetValue2()
    {
        TempA= FuncD().Value1; // 関数の結果をメンバに格納
        AddDelayAction( 1.0f, [this](){
                PrintInt( TempA );  // メンバのアクセスは可能
            } );
    }
};

よって最初の不正参照に見える例Aも、実際は戻り値が格納されるメンバ変数を読み取っていることになります。本来なら命令を実行したあとに結果が格納されるのですが、まだ実行していないため初期値(0)が入ります。

class Prog08 {
    int TempA= 0;
public:
    void EndPlay()
    {
        TempA= FuncD().Value1;
    }
    void Prog08_RetValue()
    {
        // 戻り値が入るメンバ変数を読み取っている
	// EndPlay() 実行前に参照しても特に問題はない
        PrintInt( TempA );
    }
};

全く同じように、イベントの引数もメンバとして確保されているため関係ないところから参照することができます。

arg

↑Tick の値を読み取る。初回は 0、以降は最後の tick の引数値が入る。C++ イメージだと下記の通り。

class Prog08 {
    float DeltaSeconds= 0.0f;
public:
    void Tick( float delta_seconds )
    {
        DeltaSeconds= delta_seconds;
    }
    void Prog08_Arg()
    {
        PrintFloat( DeltaSeconds ); // 別の関数の引数の参照
    }
};

もちろん、問題なく動くとはいえ実行前の戻り値や他のイベントの引数に依存するようなコードは避けた方が良いでしょう。

なお中断を考慮する必要がない関数グラフの場合も、未実行ノードの値を参照することができます。関数の場合メンバとして保存する必要がないので、一時変数はローカル変数(stack)に割り当てられています。

retvalue3

↑最初の PrintInt は 0 を表示し、次の PrintInt は FuncD の戻り値を表示します。

●配列と値のコピー

他の多くの言語と同じで UE4 のオブジェクトは参照として扱います。そのオブジェクトが UObject である限り生存期間は GC が管理します。

Vector や Rotator などの基本型はもちろん、構造体は参照でなく値として扱います。変数への代入はコピーです。

配列やマップ、セットなどのコンテナは、Blueprint の場合オブジェクトではなく値として扱われます。変数への代入は構造体と同じようにまるごと複製が行われます。

copy

↑ArrayA と ArrayB は異なる配列になる。Map, Set も同じ。

関数の引数や戻り値もそのままだとコピーになるため、配列が巨大な場合は負荷が高くなります。その回避策として Blueprint では引数にリファレンス渡し(参照渡し)を指定することができます。

↓引数定義の下に「リファレンス渡し」のチェックボックス

reference

使用例

reference2

↑関数内で書き換えた結果が呼び出し元の変数に反映されるため 111 が表示されます。

LogBlueprintUserMessages: [None] 111

リファレンス渡しはパラメータをコピーしません。また書き換えた結果が呼び出し元に反映されるので、戻り値をコピーで返す必要もなくなります。よって何度も複製が発生するのを防ぎます。巨大な配列や巨大な構造体を関数に渡す場合に効率的です。

↓通常の値渡しの場合

copyandreturn

↓リファレンス渡しの場合

referenceonly

ところが Blueprint の場合、パラメータのリファレンス渡しが意図した通りに動かないケースが少なくありません。そのため引数で配列を渡すことを禁止したり、パラメータのリファレンス渡し機能自体を信用していないことが多いのではないかと思います。というのも、UE4 Editor 上のパラメータの「リファレンス渡し」のチェックボックスは無視される場合が多いからです。

 1. イベントの引数は「リファレンス渡し」できない。

 2. 関数の引数は配列の場合常に「リファレンス渡し」になる。

 3. 関数の引数は配列以外では「リファレンス渡し」のチェックボックスに従う。

上の 1. 2. に当てはまるケースでは「リファレンス渡し」のチェックボックスの設定が無視されます。

コピーを減らして効率化しようと思ったのに無駄なコピーが何度も行われていたり、書き換えた結果を返そうと思ったのに反映されていなかったりと混乱やバグの原因になりがちです。

また配列の場合は、変更するつもりがなかったのに内容が書き換わってしまう場合があります。

arraya1_func

arraya1

↑この挙動は配列の場合だけです。同じコンテナでもマップ、セットはデフォルトで値渡しになるため呼び出し元のデータは書き換わりません。配列と同じように関数内の変更を反映させたい場合は「リファレンス渡し」を有効にする必要があります。

配列と異なり、マップ、セットはあとから追加された比較的新しい機能です。配列の挙動だけ異なっているのはおそらく互換性のためだと思われます。

・よくわからないときは「リファレンス渡し」機能は使わない

・カスタムイベントでは引数に巨大なコンテナを渡してはいけない

  代わりに使えるなら関数にする、または直接変数をアクセスするように変更する

・関数の中で引数で渡された配列を書き換えるときは注意

逆に十分理解して使えば、リファレンス渡しは大変強力な機能になります。

●イベント引数を「リファレンス渡し」にできない理由

イベントグラフでパラメータの「リファレンス渡し」が使えない理由は、途中で中断&再開する可能性があるからです。イベントグラフではローカル変数が使えず、命令の戻り値も無名のメンバ変数として保存されています。イベントの引数もメンバ変数になります。

このメンバ変数は、中断したあとも時間差で異なるコンテキストからアクセスする可能性があるため、リファレンス(ポインタ)で保持することができません。例えば下記のような場合、Delay のあとでは呼び出し元の配列のインスタンスがすでに無くなっています。もしリファレンス渡しが使えていたら不正なメモリをアクセスすることになります。

funcg

customeventsub

イベントのインタフェースを C++ で定義した場合は引数をリファレンス(参照)渡しで宣言できますが、Blueprint で実装すると内部で中でコピーが行われるため同じです。

イベントグラフでは定義したカスタムイベントを RPC に変換することができます。Network を通した呼び出しではリファレンス引数も常にコピーされるので、仕様を統一する意味もあるのかもしれません。

●マクロ外でローカル変数を使う

マクロ定義の中では無名のローカル変数を使うことができます。このローカル変数は関数内で定義できるものとは別物で、スタック上に確保されるわけではなく名前の衝突を防ぐ目的で使われます。

macrolocal

↑ForLoop マクロで使われているローカル変数「ローカルInteger」

今回説明した要素を組み合わせると、実はマクロの外でもこの無名のローカル変数を使えることがわかります。実用はおすすめしませんが、Blueprint の挙動の理解につながると思いますので説明してみます。

関数の戻り値は変数に束縛されており無名の左辺値になります。そのため参照を受け取る関数の引数にそのまま渡すことが可能です。この左辺値である「関数の戻り値」をローカル変数として利用することができます。

↓まず適当な整数値を返す関数を定義する

nopint

この NopInt の戻り値がマクロ内の「ローカルInteger」ノードと同じ働きをします。Pure ではない関数なら何でも構いません。

使用例

counter3

↑変数(プロパティ)を消費せずに、加算し続ける値を表示することができます。Counter3 は呼び出すたびに値が 0 → 1 → 2 と増えていきます。branch を使ってるのは絶対に実行しないノードを作るためです。実行すると変数の値が上書きされます。

ローカル変数の値を書き換えるには「Integer(リファレンス渡し)を設定」を使います。もちろん Integer 以外の型も使えます。

実は Pure 関数の戻り値もリファレンス渡しの引数に渡せるので左辺値扱いなのですが、関数を参照するたびに毎回呼ばれるのですぐ値が上書きされてしまいます。そのためローカル変数の用途には向きません。

●イベントグラフと配列の生存期間

「配列を作成」は動的に生成した配列を返します。この命令は Pure 関数なので、最初に変数へ保存しておかないとすぐにアクセスできなくなります。例えば下記のように 2回参照を行うと、それぞれ別の配列が作られることになります。

array1

↑1が表示される。

リサイズしたサイズ10の配列はアクセス手段を持ちません。同じ Pure ノードを2回呼び出したので、サイズ 10の配列は 2回目の呼び出しで上書きされます。RandomInteger は Int 型を指定しているだけで特に意味はありません。

イベントグラフの場合、戻り値や引数など無名の一時変数はメンバ変数なので、実行が終わっても値が残り続けます。

下記の例はおよそ 1GB の配列を作成したときのメモリ使用量の変化です。

array3b

array3_vs

↑関数グラフの場合一時変数がローカル変数(stack)なので、実行が終わるとすぐにメモリが解放されます。

array2

array2_vs

↑イベントグラフの場合一時変数が無名のメンバとして残るため、実行が終わってもメモリが解放されずに残り続けます。

意図せずにメモリを消費している可能性があるので注意が必要です。

複数の命令を経由する場合は更に問題が大きくなります。イベントグラフの引数やノードの戻り値毎に複製が残るためです。

array4

↑1GB の配列が 3つ作られ実行後も残り続ける。ArrayFunc は何もしないで引数を返すだけの関数。

実際に走らせてみると、最初のリサイズ後の消費メモリ 3.4GB、ArrayFunc, Array5_Sub 呼び出し後は 5.4GB になりました。イベントグラフではこのように、Blueprint の一時変数がメモリを無駄に消費し続ける可能性があります。

容量が極端に大きいコンテナ(配列、マップ、セット)を Blueprint で扱う場合は、一時変数の生存期間に十分注意してください。

●イベントグラフの無名変数と UObject の生存期間

UObject は GC が管理するため、配列の参照とは異なり生存期間を気にする必要がありません。ただし UObject を所有できるのはプロパティ(UPROPERTY, FProperty)に限られます。

イベントグラフのノードの引数や戻り値は無名のメンバ変数にコピーされますが、オブジェクトはそのまま参照で保持します。UObject が Blueprint の一時変数上にしかない状態で、生存期間が延命されるかどうか調べてみます。つまりイベントグラフの一時変数がプロパティ扱いかどうかがわかります。

uobject2

Blueprint では直接 UObject を生成する命令がないので、NewProgObject は C++ で実装しています。作成したダミーの UObject を返します。Prog08_UProject2 は Object が一時変数上にしかない状態で Collect Garbage を呼び出しています。

Collect Garbage は実際はフラグを立てるだけで、このタイミングでは GC が走りません。フラグを見てフレーム内の決まったタイミングで GC が呼ばれるため、結果を調べたいときは数フレーム待つ必要があります。

実際に走らせてみると結果は false でした。つまり UObject2_Sub 最後の PrintString のタイミングでは Object は無効であり GC によって削除されています。

LogBlueprintUserMessages: [Prog08_2] false

このことから Blueprint の無名の一時変数はプロパティではなく TWeakObjectPtr 相当になっていることがわかります。そのためイベントグラフが配列のようにオブジェクトを所有し続けてしまうことはありません。UObject を有効なまま保持したい場合は、明示的に変数(プロパティ)に格納しておく必要があります。

ところが Blueprint を Native 変換すると挙動が変わることがわかりました。(UE4 4.25.1) 下記のように一時変数が UPROPERTY 宣言されています。

// Prog08__pf3730294777.h から抜粋
    UPROPERTY(Transient, DuplicateTransient, meta=(OverrideNativeName="K2Node_CustomEvent_Arg"))
    UProgObject* b0l__K2Node_CustomEvent_Arg__pf;

// Prog08__pf3730294777.cpp から抜粋
void AProg08_C__pf3730294777::bpf__UObjeect2_Sub__pf(UProgObject* bpp__Arg__pf)
{
    b0l__K2Node_CustomEvent_Arg__pf = bpp__Arg__pf;
    bpf__ExecuteUbergraph_Prog08__pf_34(46);
}

Native 変換した状態で走らせるとは結果は true となり、UObject は消えていませんでした。

[2020.07.12-05.01.36:883][ 96]LogBlueprintUserMessages: [Prog08_2] true

配列と同じようにメモリ上にオブジェクトが残り続ける可能性があるため注意が必要です。Blueprint と挙動が異なるため、今後仕様が変更される可能性があります。

●プログラミング言語 Blueprint

言語仕様に焦点を当てて解説してきましたが、Blueprint については今回で一旦終了したいと思います。また何か説明できることがあれば続きを書くかもしれません。

Blueprint だけでゲームを作ることもできます。理解して使えばいろいろ応用もできて面白い言語ですので、プログラマの方にも興味を持っていただければと思います。

関連エントリ
UE4 プログラミング言語 Blueprint (2)
UE4 プログラミング言語 Blueprint (1)
4倍速い Ryzen 9 3950X の UE4 コンパイル速度
UE4 UnrealBuildTool の設定 BuildConfiguration.xml
UE4 UnrealBuildTool VisualStudio の選択を行う
UE4 UnrealBuildTool *.Build.cs のコードを共有する

UE4 プログラミング言語 Blueprint (2)

前回の続きです。

・前回: UE4 プログラミング言語 Blueprint (1)

●イベントグラフと関数グラフ

Blueprint 命令の編集画面には 2種類あります。イベントグラフと関数グラフです。

↓イベントグラフの画面。イベント応答、カスタムイベントの定義などを行う。

EventGraph

↓関数グラフの画面。関数単位の定義を行う。

FuncGraph

イベントグラフでは、BeginPlay や Tick といった特定のイベントを受け取るだけでなく、自分でカスタムのイベントを作ることもできます。

関数グラフでは、出力を持った関数の定義が可能です。Pure 関数もこちらの画面で作ります。

イベントグラフや関数グラフで定義した命令はどちらもサブルーチンとして呼び出すことができます。呼び出し方に違いはなく、見た目もほぼ同じです。Blueprint 上は機能差がありますが、C++ API から見ると関数もイベントも同じものです。そのため説明中ではイベントのことも含めて関数と呼ぶ場合があります。

FuncCall

カスタムイベントと関数どちらも親クラスの命令をオーバーロード可能で、どちらもインターフェースの実装を行うことができます。イベントは戻り値を返すことができないので、戻り値がある場合は関数になり、無い場合はイベントが優先されるようです。

Blueprint で最初に触るのはイベントグラフなので、イベントグラフに命令を置いていくだけでも十分プログラムを作ることができます。特にイベントグラフの場合は、1つのグラフに複数のイベントのコードを記述できます。関数グラフでは1関数だけなので、複数まとめて俯瞰しながら作っていく場合もイベントグラフの方が便利になります。

●イベントグラフと関数グラフの大きな違い

イベントグラフと関数グラフの一番大きな違いは、プログラムを途中から再実行できるかどうかにあります。イベントグラフは任意の場所で実行の中断と再開ができます。

その代表例が Delay 命令です。Delay は後続の命令の実行を遅らせるためにその場で一時停止しします。

Delay

↑ FuncA の 1秒後に FuncB を実行

中断した場合、再開できるように途中の状態をどこかに保存して置く必要があります。一般的な言語のクロージャーだと参照している変数の値がキャプチャされるのですが、Blueprint の場合は最初からローカル変数を排除することで仕組みを簡単にしています。

イベントグラフ内ではローカル変数を使うことができません。変数はすべてメンバ変数になるので、中断しても内容は保持されています。その代わりイベントグラフではリエントラントにしづらく再帰呼び出しにも向いていないことになります。

またイベントは関数と違い戻り値を返すことができません。途中で中断した場合はそのまま呼び出し元に返ってくるので、そのタイミングでは戻り値が確定していないからです。結果が必要な場合は、メンバ変数など別の手段を用いることになります。

関数グラフの場合は途中で止めたり再実行することができません。もちろん Delay 系の命令は使えないことになります。

その代わり一度呼び出せば最後まで実行するので戻り値を返すことができます。同様にローカル変数も利用可能です。インスタンスメモリを増やさないし名前の衝突も防げます。再帰呼び出しも容易です。

●中断の仕組みと Delay 命令

イベントグラフの Delay 命令は一見その場で停止して待っているように見えますが、内部では Event (Latent Action) を登録して即座に呼び出し元に戻ります。Action に登録された後続の命令は、あとから異なるコンテキストで実行されます。

もともと Blueprint の関数やイベントは、後続の命令がない場合に暗黙の Return とみなします。

Return

↑後ろの実行ピンがつながっていないと Return 相当。FuncA → FuncB → FuncC の順で実行する。

Delay も全く同じです。Delay の場合は後続の命令と実行線がつながっていても Return になります。

Return2

↑実行順に注意。FuncA → FuncC → 1秒 → FuncB の順で実行する。FuncB は 1秒後に World の Tick から呼ばれる。

一部のフロー制御命令は関数呼び出しに似た働きを内包しています。下記は Sequence ノードの例です。

Sequence

↑FuncB のあと、暗黙の return で次の実行ピン(Then 1)へつながる。FuncA → FuncB → FuncC の順で実行する。

Sequence は複数繋いだ命令実行線を順番に実行します。それぞれの実行線は関数呼び出しと同じ暗黙の Return で終了しています。そのため Delay も Sequence の実行を終了する命令として機能します。

下記のように Sequence を使っても順番に時間待ちをしながら実行することはありません。Delay 命令のたびにすぐ次の実行ピンに飛ぶからです。3つの実行インスタンス FuncA, FuncB, FuncC を同時に Action に登録することになります。(「Sequence の例 A」)

Sequence

↑ 1秒経ってから同じタイミングで FuncA, FuncB, FuncC が呼ばれる

この Sequence の挙動をうまく活用すると、Return の代わりに即時実行線を持った新しい Delay 命令を作ることができます。↓はマクロ “Delay2” です。

Macro Delay2

この Delay2 ↑ は暗黙の Return を行いません。Delay の直後に Then 1 を続けて実行するからです。そのため Delayed を Action に登録したあと Immediate 以降を続けて実行します。実行線が 2分岐しているので、Delay は新しい実行インスタンスを作り出す命令に相当します。

Delay2 で置き換えてみると、既存の命令の挙動もより理解しやすくなります。例えば上に挙げた「Sequence の例 A」は↓次のように Delay2 で置き換えることができます。すべて即時実行線でつながっていることになります。

Delay2

↓もし順番に時間待ちするような Sequence を作りたいなら、次のように実行線の接続先を変更すればよいわけです。

Delay2

試しに、上と同じ働きをする Sequence を MultiGate で作ってみます。↓マクロ “Sequence2” です。

Macro Sequence2

使用例

Macro Sequence2

↑これで 1秒待ちながら 1秒 → FuncA → 1秒 → FuncB → 1秒 → FuncC の順番で実行するようになります。ただし元に戻る線が必要なのであまり使いやすくありません。Sequence 本来の良さが失われてしまうため、これだけだとあまり意味がないかもしれません。

● Loop と非同期処理

Sequence だけでなく、関数呼び出しに似た構造を持っているフロー命令は他にもあります。ForLoop や ForEach などのループ命令です。それもそのはずで、ループ命令はマクロで定義されており内部で Sequence が使われています。

一回のループは暗黙の return で終了します。よって Delay も「即座に次のループに進む命令」として機能することになります。時間待ちしながらループするような処理を作ることができません。

Loop

↑ループ自体は同一フレームで完了し、FuncA → FuncC を呼び出したあと、1秒後 に FuncB が「一回だけ」呼ばれます。Delay はインスタンスのノード毎に Action を多重登録できない仕組みなので、ループ回数に依存せず FuncB は一度しか呼ばれません。(Sequence の例では Delay ノードが 3個あったので 3回呼ばれます)

↓Sequence2 と同じ方法で Delay 待ちしながら loop 実行できる命令を作ってみます。マクロ “ForLoopWithBreak2”

Macro ForLoopWithBreak2

↑これを使うと時間待ちしながら Loop できるようになります。

使用例

Loop2

↑ FuncA → 1秒 → FuncB → 1秒 → FuncB → 1秒 → FuncB → FuncC

LoopBody の最後を必ず Next pin に繋げる必要があります。Sequence2 と違い、この手法はいろいろ応用できます。

中断機能付き Delay を作ってみます。

Delay3

Delay のように時間待ちしますが、内部ではポーリングして条件が成立したら即時終了します。例えば何らかのボタンを押したらスキップして次に進むようなイメージです。上の例では 10秒待ってから FuncB に進みますが、その間に [Q] key が押されるとすぐに FuncC を実行します。

中身は下記の通りです。↓マクロ “Delay3″。

Macro Delay3

もう少しシンプルにして、async/await のような非同期待ち命令を作ってみます。ポーリングなので効率は良くないですが、一連の非同期処理を並べて書けるようになります。↓マクロ “Await”

Macro Await

Interval ごとに Exp をポーリングし、結果が True になるまで待ちます。True になった時点で Completed に進みます。途中で Break が True になった場合は Breaked の方に進みます。何かを待ちながら処理を継続するようなケースを簡単に書くことができます。

必要になることは無いと思いますが、例えば RPC でサーバーを呼び出したあと結果を待つなど。(RepNotify OnRep の代わり)

null

例えば Actor が目的地まで移動し、その後アニメーションを再生、最後にアニメーション流しながら音声が終わるのを待つような処理を考えます。時間のかかる処理を順番に実行しますが、下記のように直列に書けます。

Await

動作効率は良くないですが、パフォーマンスよりも見た目でわかりやすい方を優先したい、といった場合に使えます。C++ だけだとこういうコードを書けないので、スクリプト言語らしくて面白いと思います。

●イベントと Callback

イベントグラフでは、イベントノードから赤い線を引っ張ることで関数オブジェクトを渡せるようになります。登録したイベントは Callback として呼び出されます。

下記の例では TimerEvent (FuncA) が一定時間ごとに呼ばれます。

Timer2

本来想定されている非同期処理のしかたはポーリングではなくこちらです。イベントグラフで複数のイベントをまとめて定義できる理由もわかります。

Event Dispatcher を使えば、Blueprint から Callback 関数を呼び出すこともできます。下記は “Delegate” という名の Event Dispatcher に Delegate_Event を登録し、直後に呼び出しています。

Delegate

↑ FuncA → FuncB → FuncC の順で実行

Event Dispatcher は C# でいう delegate のことです。UE4 でも C++ API では Delegate と呼ばれています。Blueprint の Event Dispatcher は UE4 C++ API の Dynamic Multicast Delegate に相当します。Event Dispatcher はインターフェースが一致していればよいので、呼び出し側が相手の class の詳細を知っている必要がありません。参照の依存を断ち切る働きもあります。

順番に非同期待ちを行う例に Event Dispatcher を使ってみます。各動作が最後に NextAction を呼び出しているものとします。

Dispatcher

↑最後は 2つの動作を待っているため NextAction が2回呼ばれるのを待っています。

Dispatcher への登録が長いのでマクロで置き換えてみます。また、指定回数呼ばれてから次に進むノードも作ってみます。

↓マクロ “BindEventM”

Macro BindEvent

↓マクロ “CounterMacro”

Macro CounterMacro

↓マクロを使ってシンプルになりました。

Dispatcher3

同じように NextEvent を待つ Await3 も作ってみます。↓マクロ “Await3”

Macro Await3

使用例

Await3

こちらもある程度簡単にできました。さらに回数のカウントも自動化できそうです。Blueprint も色々工夫できるので面白いのではないかと思います。

次回に続きます。

関連エントリ
UE4 プログラミング言語 Blueprint (1)
4倍速い Ryzen 9 3950X の UE4 コンパイル速度
UE4 UnrealBuildTool の設定 BuildConfiguration.xml
UE4 UnrealBuildTool VisualStudio の選択を行う
UE4 UnrealBuildTool *.Build.cs のコードを共有する