Apple Watch Series 6 と CPU 性能の測定

Apple Watch Series 6 に乗り換えました。4年前のモデル Apple Watch Series 2 からの移行です。4世代分の変化は極めて大きく、その性能の差をを思い知らされました。

Series 2 は操作していて待たされることが結構あります。

アプリ起動には時間がかかり、画面中央でドットの輪が回転する画面をしばらく見続けることになります。地図を起動しても最初に地形が出てくるまで 10秒以上かかりますしスクロールには読み込みが追いついていません。ワークアウトのメニューも画面切り替え直後に一瞬固まっていてタッチの反応が悪いこともあります。SIri の反応も遅く、きちんと聞こえているのか不安になって何度も話しかけがちです。スマートフォンと比べると制約も多いため、ある程度仕方がない部分もあるでしょう。

ところが、Apple Watch Series 6 は画面も操作もハイエンドスマートフォンのように滑らかで快適です。

アプリも即座に起動するし、地図の読み込みもスクロールに追従しており、Siri の呼びかけもすぐに応えてくれます。ワークアウトのメニューも固まらずにタッチに反応しており、操作にストレスがありません。

本当に快適になったので、どれくらいハードウエア性能が上がっているか調べてみました。vfpbench を移植し、実際に測定した結果をまとめています。

Apple Watch Series 2 Apple Watch Series 6
SOC Apple S2 Apple S6
CPU core Coretex-A7 Apple 独自 core
CPU arch armv7k (ARMv7A VFPv4) arm64_32 (ARMv8.3A+)
CPU Core数 2 2
CPU Clock 450 MHz 1.5 GHz
RAM 512 MB 1.5 GB
CPU fp16 49.8 GFLOPS
CPU fp32 1.8 GFLOPS 25.2 GFLOPS
CPU fp64 0.9 GFLOPS 12.5 GFLOPS

Series 6 は RAM を 1.5GB 搭載していることがわかりました。Series 2 と比べると容量は 3倍になっておりだいぶ余裕があります。Series 2 は watchOS 7 から対象外となっており、やはり RAM 容量に無理があったのではないかと思います。

CPU も 64bit (?) になり Clock も上がっています。fp32 の演算速度に限ってみるとピークで 14倍の速度が出ています。

●ログの詳細より

以前も書いていますが Series 2 の CPU core はおそらく Cortex-A7 だと思われます。個々の浮動小数点演算命令のスループットやレイテンシの傾向が Cortex-A7 によく似ているからです。

同時に CPU の clock も推測できます。スカラー命令の MOPS からおよそ 450MHz 前後であることがわかります。

下記はその抜粋です。

Apple Watch Series 2 (Apple S2) fp32

                                      TIME(s)   MFLOPS      MOPS     FOP   IPC
VFP fmuls (32bit x1) n8           :    1.387      432.5      432.5  (  1.0 0.0)
VFP fadds (32bit x1) n8           :    1.354      443.1      443.1  (  1.0 0.0)
VFP fmacs (32bit x1) n8           :    1.332      900.6      450.3  (  2.0 0.0)
~
NEON vmul.f32 (32bit x4) n12      :    8.046      447.4      111.9  (  4.0 0.0)
NEON vadd.f32 (32bit x4) n12      :    7.972      451.6      112.9  (  4.0 0.0)
NEON vmla.f32 (32bit x4) n12      :    8.449      852.1      106.5  (  8.0 0.0)
NEON vfma.f32 (32bit x4) n12      :    8.337      863.6      108.0  (  8.0 0.0)

同じように Series 6 の結果も見てみます。こちらはスカラーもベクターも同速であり、かつ加算命令が乗算の 2倍回っています。128bit SIMD でも速度が落ちないので、同時に実行できる命令は「128bit 加算 + 128 bit 積和」の組み合わせだと考えられます。

これが ARM の 64bit core なら「64bit 積和 x 2」なので、128bit SIMD がスカラーや 64bit SIMD と同じ速度になることがありません。Apple の独自 core であることがわかります。乗算命令の MOPS をみると動作クロックはおそらく 1.5GHz 前後でしょう。

Apple Watch Series 6 (Apple S6) fp32

                                      TIME(s)   MFLOPS      MOPS     FOP   IPC
FPU fmul (32bit x1) n8            :    0.390     1538.2     1538.2  (  1.0 0.0)
FPU fadd (32bit x1) n8            :    0.194     3089.5     3089.5  (  1.0 0.0)
FPU fmadd (32bit x1) n8           :    0.388     3091.1     1545.6  (  2.0 0.0)
~
NEON fmul.4s (32bit x4) n12       :    0.585     6156.1     1539.0  (  4.0 0.0)
NEON fadd.4s (32bit x4) n12       :    0.291    12381.2     3095.3  (  4.0 0.0)
NEON fmla.4s (32bit x4) n12       :    0.581    12386.1     1548.3  (  8.0 0.0)

Apple Watch の 64bit CPU である Apple S4/S5/S6 は fp16 (半精度/16bit 浮動小数点) の演算命令に対応しています。Xcode で arm64_32 (Apple Watch 64bit) をターゲットにビルドを行うと clang が下記のマクロを定義することから判断できます。

#define __ARM_FEATURE_FP16_SCALAR_ARITHMETIC 1
#define __ARM_FEATURE_FP16_VECTOR_ARITHMETIC 1

実際に vfpbench でも fp16 演算命令が走っており、fp32 の倍の速度が出ています。iOS でいえば arm64e に相当するので、arm64_32 も同じ世代だと仮定するなら ARMv8.3A が対象となります。おそらく A12 以降の LITTLE core が用いられているのではないでしょうか。

Apple Watch Series 6 (Aple S6) fp16

                                      TIME(s)   MFLOPS      MOPS     FOP   IPC
NEON fmul.8h (16bit x8) n12       :    0.583    12350.5     1543.8  (  8.0 0.0)
NEON fadd.8h (16bit x8) n12       :    0.290    24794.4     3099.3  (  8.0 0.0)
NEON fmla.8h (16bit x8) n12       :    0.582    24729.8     1545.6  ( 16.0 0.0)

● arm64_32

説明に出てきた arm64_32 は Apple Watch 専用のアーキテクチャタイプです。表にまとめてみます。

watchOS
armv7k ILP32 ARMv7A + VFPv4 (Apple S1~S3 )
arm64_32 ILP32 ARMv8.3A (Apple S4 ~)
iOS/tvOS
armv6 ILP32 ARMv6 (ARM11) (iPhone 2G/3G)
armv7 ILP32 ARMv7A + VFPv3 (3GS~A5)
armv7s ILP32 ARMv7A + VFPv4 (Apple A6)
arm64 LP64 ARMv8A (Apple A7 ~ A11)
arm64e LP64 ARMv8.3A (Apple A12 ~)

arm64_32 は、ARM の 64bit アーキテクチャである ARMv8A Aarch64 の命令セットを採用していながら ILP32 (ポインタサイズが 32bit) の構成を使用しています。そのため 64bit といいつつも実質 32bit です。ILP32 なら ARMv7A もしくは ARMv8A AArch32 と何が違うのか?と思うかもしれませんが、別物になっています。arm64_32 はアドレスが 32bit なだけで AArch64 の命令が動きます。

ARM は生まれたときから 32bit の命令セットでしたが、ARMv8 で 64bit 化するときに全く新しいものに置き換わりました。レジスタも命令の種類もエンコーディングも違います。

例えば ARM 32bit では殆どの命令で条件付き実行やシフタが内蔵されていましたが 64bit 命令にはありません。他の RISC CPU のような Zero Register も登場します。ほぼ別の CPU となっており、Intel の IA-32 と IA-64 の関係に似ているかもしれません。

ARM の 64bit CPU は ARMv7A 互換の AArch32 (32bit) と新しい AArch64 (64bit) の 2 種類の命令デコーダーを持っていることになります。ILP32 として 32bit CPU 相当の使い方をするなら、AArch32 を使った方がコード資産を引き継げるし都合が良いように思えます。しかしながら Apple Watch は AArch64 のまま 32bit 化しているので、何かしらの理由があるのでしょう。

Apple は iOS 11 のタイミングで 32bit アプリを廃止して一切起動できなくなりました。同時に iOS 11 を搭載して出荷されたのが iPhone X (Apple A11) なので、Apple A11 以降は AArch32 mode が不要であると言えます。そもそも実行できない可能性があります。もしくはパフォーマンス面や消費電力、セキュリティなどの面で ARMv8.x 以降の命令に依存しているのかもしれません。

Wear OS (Android Wear) のスマートウォッチは最初に登場した初期モデルからスペックが共通で Snadpragon 400 / 210 ベースのまま変わっていません。新型が出ても性能はほぼ据え置きとなっています。対する Apple Watch は毎年新モデルが登場し、それに合わせて搭載 SoC も進化していることがよくわかります。

ログの全体は下記のページにあります。
VFP Benchmark Log 計測結果まとめ

関連エントリ
MacBook Air Late 2020 / Apple M1 のビルド速度と浮動小数点演算能力
セサミmini、スマートロックを使って1年
2年間使った Apple Watch Series 2
Apple Watch S2 の CPU と浮動小数点演算

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