Edited at

【Unity】BurstCompilerをJobSystem以外でも使いたい

BurstCompiler(以降、Burstと省略して記載)に関する小ネタです。

※BurstCompilerとは?と言う方はこちらを参照


BurstCompilerとは?

UnityにはPackageManagerから取得可能な拡張機能として、BurstCompilerと言うパッケージがあります。

概要を簡単に説明すると、JobSystemで走らせるコードを特定の条件下の時に最適化(SIMD化、一部の計算の精度を調整)してくれるパッケージになります。

※その為に何でもかんでも早くなってくれるわけではない。

特定の条件下として挙げられる内容としては主に以下のものが有り、いずれもC#を書く上では厳しい制約となりますが...処理が適していれば劇的なパフォーマンス改善が期待できるものであり、最適化の性質上から特に計算処理とは相性が良いかと思われるので、使えるなら積極的に使っていきたい機能ではあります。


  • マネージドオブジェクト(参照型とか)アクセス不可

  • static変数アクセス不可

  • 副作用のないコード


    • ※状態変化のないコード。言わば参照透過性の高いコードとも言い換えられるかも



※「なんで上記の制約があるのか?」について、CEDEC2018の以下の講演にて少し触れられているので興味のある方は御覧ください。


設定するだけなら簡単に設定可能

名前に「コンパイラ」とか付いているので小難しそうな印象を受けるかもしれませんが...実際にはそんなことはなく、設定自体は非常に簡単であり、一言で言うと「コンパイル対象のJob構造体やメソッドに[BurstCompile]と言う属性を付けるだけ」と言えるかもしれません。

Burstの機能としては↑の属性を付ける以外にも計算精度の指定と言った細かいオプションの方も用意されているので、先ずは公式ドキュメントの方をざっくりと目を通してみることをおすすめします。

→ 実装例の他にも内部挙動の話やビルド要件についてなど記載されている。(AndroidならNDKが必要とか)

Burst User Guide


こちらのBurstですが、Jobベースの処理を最適化する機能となるために基本的にはJob以外の処理(例えば通常のMainThreadで行う計算とか)に適用すると言ったことが出来ません。

そこで「何とかBurstを通常のMainThreadで行う計算処理などにも適用できないか?」と思い、調べてみた感じだと、それっぽいやり方を見つけたので備忘録序にメモしていければと思います。


※注意点

まだ実戦投入や様々なケースでのテストなどは出来てません。。

ひょっとしたら地雷が埋まっている可能性も無きにしもあらずなので...実戦投入される方はご注意ください。。

(今後問題点など見つけたら随時更新予定)


バージョン情報



  • Unity


    • 2018.4.7f1




  • Packages


    • Burst 1.1.2




検証環境


  • Standalone(Windows) + IL2CPP

  • CPU : Intel Core i7-8700K


Burst適用前について

先ずはBurst適用前のコード及び実行速度から載せていきます。

内容としては負荷計測用に用意した「千万回近く適当に演算して値(float3)を返すだけ」のコードになります。(もっと良い例を用意したかったが...思いつかなかった..)

public static float3 Calc()

{
var vec = new float3();
for (var i = 0; i < 10000000; i++)
{
var mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);

i += 1;
mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);

i += 1;
mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);

i += 1;
mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);

i += 1;
mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);
}

return vec;
}

上記のCalc関数をUI.Buttonから実行できるようにして冒頭の検証環境で実行したところ、以下の計測結果が出ました。

MainThread上で1803.28ms(大体1.8秒近く)掛かっていることが確認できます。


Burstの適用方法/計測結果について

次に本題であるMainThread上からBurstを適用できるようにしたパターンについて解説していきます。

やり方は調べた範囲だと2つほどありました。

順に解説していきます。


BurstCompiler.CompileFunctionPointerでコンパイル

1つ目に紹介するのはBurstCompiler.CompileFunctionPointerと言うAPIを用いてコンパイルした関数ポインタを受け取る方法についてです。

先ずは実装コードから載せます。

[BurstCompile] // 必要

public static class Math
{
// CompileFunctionPointerに渡すデリゲート.
delegate void CalcDelegate(ref float3 vec);
static CalcDelegate _calcDelegate;

[RuntimeInitializeOnLoadMethod]
static void Initialize()
{
// コンパイルした計算処理の関数ポインタを受け取ってデリゲートに保持.
var funcPtr = BurstCompiler.CompileFunctionPointer<CalcDelegate>(CalcBurstImpl);
_calcDelegate = funcPtr.Invoke;
}

public static float3 CalcBurst()
{
var vec = new float3();
_calcDelegate(ref vec);
return vec;
}

// コンパイル対象のメソッド
// ※現状は戻り値を返すことが出来ないので、「参照渡し」か「ポインタ渡し」にする必要がある.
[BurstCompile]
static void CalcBurstImpl(ref float3 vec) => vec = Calc(); // Calcは上述のと同じ

public static float3 Calc()
{
var vec = new float3();
for (var i = 0; i < 10000000; i++)
{
var mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);

i += 1;
mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);

i += 1;
mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);

i += 1;
mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);

i += 1;
mat = new float4x4(quaternion.Euler(i, i, i), new float3(i, i, i));
vec = math.transform(mat, vec);
}

return vec;
}
}

上記のCalcBurstと言う関数を呼び出すことでBurstで最適化されたCalc関数を呼び出すことが可能です。

詳細についてはコメントにも記載してますが、順に解説していきます。


コンパイル手順

コンパイルまでの手順を以下に載せます。

[BurstCompile] // 必要

public static class Math
{
// CompileFunctionPointerに渡すデリゲート.
delegate void CalcDelegate(ref float3 vec);
static CalcDelegate _calcDelegate;

[RuntimeInitializeOnLoadMethod]
static void Initialize()
{
// コンパイルした計算処理の関数ポインタを受け取ってデリゲートに保持.
var funcPtr = BurstCompiler.CompileFunctionPointer<CalcDelegate>(CalcBurstImpl);
_calcDelegate = funcPtr.Invoke;
}

// コンパイル対象のメソッド
// ※現状は戻り値を返すことが出来ないので、「参照渡し」か「ポインタ渡し」にする必要がある.
[BurstCompile]
static void CalcBurstImpl(ref float3 vec) => vec = Calc(); // Calcは上述のと同じ

肝となるのは表題にもある「BurstCompiler.CompileFunctionPointer」と言うAPIです。

こちらにデリゲートの型を指定した上で対象の関数を渡すことで、コンパイルした関数ポインタを受け取ることが可能です。

※対象のクラスやメソッドにはBurstCompile属性を付ける必要があるので注意。

※「関数ポインタ→デリゲート」の変換はCompileFunctionPointerの実際の戻り値であるFunctionPointer<T>の中で行われている。ここについては記事後半の「おまけ」章で解説。

補足として、現時点ではコンパイル対象の関数は戻り値を返すことが出来ないらしく...返そうとすると以下のエラーが返ってきました。


error: The struct Unity.Mathematics.float3 cannot be returned by value from an external function in burst. The workaround is to return it by reference or pointer. This restriction will be removed in a future version of burst


将来的には制限は無くなるみたい?ですが、現状は「参照渡し」か「ポインタ渡し」を経由する必要があったので、CalcBurstImplと言う関数を経由する形で渡してあります。


呼び出しと実行結果

呼び出しとしてはコンパイル済み関数を保持しているデリゲートを呼び出すだけです。

今回の例ではCalcBurst内部でローカル変数を宣言 → コンパイル済み関数に参照渡しで計算 → 結果を返すと言う流れにしてます。

public static float3 CalcBurst()

{
var vec = new float3();
_calcDelegate(ref vec);
return vec;
}

こちらをUI.Buttonから実行できるようにして冒頭の検証環境で実行したところ、以下の計測結果が出ました。

結果としてはMainThread上で551.27(大体0.55秒近く)になりました。

適用前と比べると1252.01ms(大体1.25秒近く)高速化出来ていることが確認できます。

Jobとは違い、Profiler上に明示的に(Burst)と言った表記は出ていないものの...結果の方から最適化された処理がMainThreadで呼び出されていることが確認できます。


Burstを有効にしたJobをMainThreadで実行

2つ目のやり方です。

正直1つ目のやり方で正しく動いてくれれば問題なさそう感ありますが...ちょっとした補足レベルで解説して行ければと思います。

詳細について解説していくとこちらも大体はコメントに記載してますが、やっている事としては極端な話「計算処理をBurstを適用したJobで動かすようにした」だけです。

肝となるのはIJobExtensions.Runと言うJob向けの拡張メソッドであり、こちらを用いることでJobをScheduleせずに同スレッドで直接実行するようにしてます。

後はJobで動かす都合上、戻り値を直接受け取ることが出来ないので、こちらに関してはローカル変数のポインタをJobに渡して実行後に結果を入れると言った形で対応してます。

※故にこのやり方で動かす場合にはAllow unsafe codeを有効にする必要あり。

public static unsafe float3 CalcBurst()

{
// 結果を入れる変数
var ret = new float3();

// Jobから直接戻り値を受け取れないので結果のポインタを渡して入れる
var job = new CalcJob() {Result = &ret};

// MainThreadで実行
job.Run(); // IJobExtensions.Run
return ret;
}

[BurstCompile] // Burst適用
unsafe struct CalcJob : IJob
{
[NativeDisableUnsafePtrRestriction] public float3* Result;

public void Execute()
{
var ret = Calc();
*Result = ret;
}
}

上記のCalcBurst関数をUI.Buttonから実行できるようにして冒頭の検証環境で実行したところ、以下の計測結果が出ました。

結果としてはMainThread上で549.13ms(大体0.55秒近く)になりました。

適用前と比べると1254.15ms(大体1.25秒近く)高速化出来ていることが確認できます。

他にもMainThread上で(Burst)と付いた処理が走っているので、目的であるMainThread上での実行も出来ているように見受けられます。


おまけ


BurstCompiler.CompileFunctionPointerの中身について

こちらの実装としてはパッケージの中身を見ることで確認可能です。

現時点でのバージョンでは以下のようになってました。


BurstCompiler.cs

/// <summary>

/// Compile the following delegate into a function pointer with burst, only invokable from a burst jobs.
/// </summary>
/// <typeparam name="T">Type of the delegate of the function pointer</typeparam>
/// <param name="delegateMethod">The delegate to compile</param>
/// <returns>A function pointer invokable from a burst jobs</returns>
public static unsafe FunctionPointer<T> CompileFunctionPointer<T>(T delegateMethod) where T : class
{
// We have added support for runtime CompileDelegate in 2018.2+
void* function = Compile(delegateMethod);
return new FunctionPointer<T>(new IntPtr(function));
}

コンパイルしてますね。(見たままの感想)(深そうだったのでこれ以上深追いしてない)

他にも今回は2018.4で動かしているので普通に使えましたが、Unityのバージョンが古いと使えないと言ったようにも見受けられました。

※全部追えてはいないので..ひょっとしたら別名の代替関数があったりするかもしれない。古いバージョンで動作させる方は要チェック。

ちなみに戻り値であるFunctionPointer<T>について、こちらは元からC#にあるクラスではなく、同じくBurst内で定義されているものとなります。

やっている事としては単純であり、関数ポインタ(IntPtr)をGetDelegateForFunctionPointerでデリゲートに変換する為のサポートクラスのように見受けられました。

実装としては以下。


FunctionPointer.cs

namespace Unity.Burst

{
/// <summary>
/// Base interface for a function pointer.
/// </summary>
public interface IFunctionPointer
{
/// <summary>
/// Converts a pointer to a function pointer.
/// </summary>
/// <param name="ptr">The native pointer.</param>
/// <returns>An instance of this interface.</returns>
IFunctionPointer FromIntPtr(IntPtr ptr);
}

/// <summary>
/// A function pointer that can be used from a burst jobs. It needs to be compiled through <see cref="BurstCompiler.CompileFunctionPointer{T}"/>
/// </summary>
/// <typeparam name="T">Type of the delegate of this function pointer</typeparam>
public struct FunctionPointer<T> : IFunctionPointer
{
[NativeDisableUnsafePtrRestriction]
private readonly IntPtr _ptr;

/// <summary>
/// Creates a new instance of this function pointer with the following native pointer.
/// </summary>
/// <param name="ptr"></param>
public FunctionPointer(IntPtr ptr)
{
_ptr = ptr;
}

/// <summary>
/// Invoke this function pointer. Must be called from a Burst Jobs.
/// </summary>
public T Invoke => (T)(object)Marshal.GetDelegateForFunctionPointer(_ptr, typeof(T));

IFunctionPointer IFunctionPointer.FromIntPtr(IntPtr ptr)
{
return new FunctionPointer<T>(ptr);
}
}
}



参考/関連サイト


Forum


API