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並列です。4x4 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
oga at 19:20
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
oga at 20:26
前回の続きです。

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
oga at 18:35
前回の続きです。

・前回: 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 のコードを共有する



UE4
oga at 23:00
UE4 ではスクリプト言語として Blueprint を使った開発ができます。Visual 言語となっており、画面上に命令を並べて線でつなぐだけで動作フローを作成することができます。

BP_Prog01

三角マークでつながった白い線が実行順序を表しています。それ以外の丸い終端でつながっている線は、引数や返り値といったデータの流れです。データの型によって線の色が変わります。Blueprint のプログラムはコードではなくグラフと呼ばれます。

高レベルな命令をバッチのように並べるだけでも良いし、Native 言語のように低レベルな命令を駆使して計算やフローを直接記述することもできます。非常に自由度が高くなっており、修正してすぐ Editor 上で実行できるので便利です。

すべて Blueprint だけで記述する必要はなく、C++ と密な連携ができる仕組みになっています。C++ で定義した class を Blueprint で継承したり interface を実装することも可能。命令も C++ で増やせるので、基礎部分やパフォーマンスが必要な処理を C++ で実装し、それらを Blueprint でまとめあげるような役割分担も可能です。


●関数ノードと Pure 関数ノード

白い線で繋いでいくのが通常の関数ノードです。一般的な命令の実行に相当します。線で繋いだ方向に順次呼び出されていき、順番が勝手に入れ替わることはありません。呼び出し回数も白い実行線の接続によって決まります。手続き型言語と同じです。

null

白い実行線を使わずに、引数と返り値だけを持っているのが Pure 関数ノードです。定義時にプロパティの「純粋」にチェックを入れると Pure 関数になります。C++ で作る場合は UFUNCTION() の中に BlueprintPure を入れます。引数と返り値だけを繋いでいくので、Pure 関数ノードの挙動は関数型言語によく似ています。

null

C++ 風の書き方に展開すると違いがわかります。通常の関数ノードの例を展開すると下記のとおり。返り値は必ず一時変数を経由して次の命令に渡されます。

int tempA= FuncA();
int tempB= FuncB( tempA, 3 );
PrintInt( tempB );


Pure 関数ノードの場合は、変数を経由しないで引数にそのまま渡しているケースに相当します。B の例を展開すると下記のとおりです。

PrintInt( FuncB( FuncA(), 3 );


Blueprint では返り値を返す関数の場合、通常の関数か Pure 関数か選ぶことができます。プロパティの参照のなど実行順序を気にしなくてもよい場合は、いちいち実行線を繋がなくてもすむ Pure 関数は便利です。

その代わり Pure 関数ノードは実行順番が明確ではなく、呼び出し回数も直感的な見た目と一致しません。そのため副作用を持つ Pure 関数ノードを作る場合は注意が必要です。

例えば下記の例は、深さ優先で考えると A->D->C->B の順序で呼ばれるような気がしますが、実際に走らせてみると FuncA->FuncC->FuncD->FuncB で呼ばれていました。

null

LogBlueprintUserMessages: [Prog03_2] Pure Func A
LogBlueprintUserMessages: [Prog03_2] Pure Func C
LogBlueprintUserMessages: [Prog03_2] Pure Func D
LogBlueprintUserMessages: [Prog03_2] Pure Func B


Native 変換しても A,C,D,B の順序で呼ばれていることがわかります。

void AProg03_C__pf3730294777::bpf__PureFunc__pf(int32 bpp__A__pf, int32 bpp__B__pf, /*out*/ int32& bpp__R__pf)
{
    int32 bpfv__CallFunc_PureFuncA_R__pf{};
    int32 bpfv__CallFunc_PureFuncC_R__pf{};
    int32 bpfv__CallFunc_PureFuncD_R__pf{};
    int32 bpfv__CallFunc_PureFuncB_R__pf{};
    bpf__PureFuncA__pf(/*out*/ bpfv__CallFunc_PureFuncA_R__pf);
    bpf__PureFuncC__pf(bpp__B__pf, /*out*/ bpfv__CallFunc_PureFuncC_R__pf);
    bpf__PureFuncD__pf(bpp__A__pf, bpfv__CallFunc_PureFuncA_R__pf, /*out*/ bpfv__CallFunc_PureFuncD_R__pf);
    bpf__PureFuncB__pf(bpfv__CallFunc_PureFuncD_R__pf, bpfv__CallFunc_PureFuncC_R__pf, /*out*/ bpfv__CallFunc_PureFuncB_R__pf);
    bpp__R__pf = bpfv__CallFunc_PureFuncB_R__pf;
}



●Pure 関数ノードと呼び出し回数

乱数の値を表示するプログラムを書いてみます。組み込み関数の Random Integer を使います。これは Pure 関数です。同じ Random Integer から値を取り出して 2回表示してみます。

null

実行結果
LogBlueprintUserMessages: [None] 62
LogBlueprintUserMessages: [None] 23


同じノードから線を引っ張っているにもかかわらず、2回とも異なる値が表示されています。画面上には 1個しか置いていないのに「Random Integer」は2回呼ばれているわけです。

「Random Integer」と同じ機能を持つ、「Pure ではない」通常の関数を作ってみます。

null

実行結果
LogBlueprintUserMessages: [None] 20
LogBlueprintUserMessages: [None] 20


今度は 2回とも同じ値が表示されており、見た目通り一回しか呼ばれていないことがわかります。

C++ 風の書き方で違いを表現すると下記のとおりです。

Pure 関数の場合

PrintInt( RandomInteger( 100 ) );
PrintInt( RandomInteger( 100 ) );


通常の関数ノードの場合

int tempA= RandomInt( 100 );
PrintInt( tempA );
PrintInt( tempA );


通常の関数は白い線でつながった順番で一度しか実行せず、また返り値は必ず一時変数を経由します。そのため同じ値を何度も参照することが可能です。

Pure 関数ノードの場合は、同じノードでも「通常の関数が結果を参照」した回数だけ毎回呼び出しが行われます。

もし Pure 関数で乱数の結果を再利用したい場合は、自分で変数に保存しておく必要があります。

null

また、通常の関数ノードの返り値が暗黙の一時変数に束縛されるという性質を利用することもできます。まず、何もしないすダミー関数 TempInt を作っておきます。

null

引数をそのまま返しているだけでですが、通常関数は返り値が一時変数に束縛されるので何度も参照できるようになります。

null

この場合も同じ乱数値を表示します。

このように Pure 関数は実行回数が見た目と異なるので、意図しないところで何度も呼び出してしまう可能性があります。負荷の高い命令や副作用のある命令は Pure 関数にしない方が良いでしょう。


●返り値を複数もつ Pure 関数

全く同じように、複数の値を返す Pure 関数も問題になりがちです。下記のように、一度に Position と Rotation を返す Pure 関数 GetPosition を作ります。

null

返り値を 1度しか参照していないように見えるのですが、この場合も GetPosition 関数は 2回呼ばれます。一度に複数の値をまとめて返した方が一見効率が良さそうな気がしますが、必ずしもそうではありません。

例外もあります。2つの返り値を、同じ命令の引数で同じタイミングで受け取る場合は 1回しか呼ばれません。下記の場合 GetPosition が呼ばれるのは一回だけになります。

null

構造体の展開も同様です。Blueprint では、構造体を展開して要素ごとに個別に参照することができます。(右クリックメニューから展開できます)

null

展開した構造体の値を、下記のように別の命令から個別に参照した場合も複数回呼ばれます。

null

例えば現在時刻を表示する時計のプログラムを作ってみます。現在時刻の取得は組み込み命令の Now です。これは Pure 関数です。

null

上の例では時、分、秒毎に区切って数字の描画を行っています。Now の結果である 時、分、秒 をそれぞれ一回ずつしか参照していないのに、Now はその都度毎回呼ばれてしまいます。

つまり、時、分、秒の描画時にそれぞれ異なる時刻を見ていることになります。タイミングによっては分を表示した直後に秒が桁上りして、秒だけが先に 00 になる場合があり得えます。表示だけならまだしも、アラームのように時刻を厳密に判定したい場合は問題になるでしょう。

10:05:59
  ↓
10:05:00
  ↓
10:06:00


上のようなケースでは、Now の呼び出しを最初の一回だけにして、予め変数に保存しておく必要があります。


・負荷の高い命令、副作用のある命令は Pure 関数にしない。

・複数の値を返す場合も、Pure 関数を避けた方がよい。

・動的に変化する関数の場合、同じ値を複数参照するには通常の関数にするか事前に保存する。


ちなみにゲームで管理している時間 (tick や GetGameTime) はフレーム単位で固定なので、同一のフレームであれば同じ値です。何度呼び出しても問題ありません。



●同じ Pure 関数ノードが再利用される期間を詳しく調べる

複数値を返す Pure 関数でも、同じ命令の引数から同時に参照される場合は一度しか呼ばれませんでした。この挙動をもう少し詳しく調べてみます。

下記の例は、同一式内の同一引数の複数回参照です。このケースでは Random Integer が一回だけ呼ばれて、必ず同じ値を加算した結果を返します。つまり表示される値は常に偶数です。

null

次のように Random Integer ノードを 2個置くとそれぞれ 1回ずつ呼ばれます。つまり乱数は 2回生成され、異なる乱数値の和を返します。もちろん表示される値が奇数になる場合もあります。

null

同じ命令の引数でなくても構いません。下記のように、同じ式であれば異なる場所から参照しても GetPositon2 は一度しか呼ばれません。

null

↓もちろん 2回書けばそれぞれの GetPosition2 が個別に呼ばれます。

null

↓1つの加算から GetPosition2 が 2回参照されていますが、同じ式なので一度しか呼ばれません。ただし異なる SetActorLocation から 2回参照されているので、結局 GetPosition2 は合計 2回呼ばれることになります。

null

↓複数の引数から同じノードを参照しています。式グラフとしては異なりますが、白い実行線を通過する前なので GetPosition2 は一度しか呼ばれません。

null

まとめると

・同じ式の中では、同じノードの Pure 関数を複数参照しても一度しか呼ばれない。

・白い実行線を通過することでリセットされる。同じノードでも再び Pure 関数が呼ばれる。


また同一の式グラフの中で用いる場合は、Pure 関数のノードは共有してできるだけ同じ場所から線を引いた方が効率が良いこともわかります。メモリも呼び出し回数も節約できます。

もちろんプロパティ参照のような単純な命令では全く気にする必要は全く無いと思います。それでも Now 命令のようにバグの原因となる場合もあるので、挙動はある程度理解しておいた方が良いでしょう。


●通常の関数ノードの実行回数

Pure ではない通常の関数ノードは、白い線の接続で呼び出し回数が明示的に決まると書きましたが例外があります。class object のメソッド呼び出しの場合、Target に複数のオブジェクトを与えると複数回実行を繰り返します。

下記の例では ActorA と ActorB それぞれの PrintActorPosition メソッドを呼び出します。つまりグラフ上のノードは 1つでも 2回実行します。

null

Target は関数を呼び出す対象のオブジェクトで、C++ の this pointer に相当します。戻り値がある場合、一時格納変数が共有されるので最後に呼ばれた値が有効となるようです。呼び出し順番は不定です。

次回に続きます。


関連エントリ
4倍速い Ryzen 9 3950X の UE4 コンパイル速度
UE4 UnrealBuildTool の設定 BuildConfiguration.xml
UE4 UnrealBuildTool VisualStudio の選択を行う
UE4 UnrealBuildTool *.Build.cs のコードを共有する


| 次のページ(日付が古い方向)>>