Edited at

【Unity】IJobProcessComponentDataを拡張する【パッケージ改造】

More than 1 year has passed since last update.

この記事はUnityゆるふわサマーアドベントカレンダー 2018の19日目の記事です。

Unityを初めてまだ2月半ほどの初心者ですので、バッドノウハウも多いかと思います。ご指摘いただけると幸いです。

Unity2017に触ったことがないので最初からECS にどっぷりと浸かっています。 一番最初に動かしたデモはECS製Roll-a-ballで、2番目が重すぎてまともに動かないRTSで、3番目が弾幕ゲーという程度にECSだけやってます。

ECSってなんだよ(PNGK)という方はテラシュールの記事を読んでみて、どうぞ。


執筆者環境


  • Unity


    • Unity 2018.2.3f1 Personal(ECS利用時にWindows Stand Aloneがクラッシュしなくなりました!)

    • ECS version0.0.12-preview8



  • パソコンスペック


    • Windows 10 Home(x64)

    • Intel Core i5-5200U CPU @2.20GHz(2コア4スレ)

    • RAM 8.00GB

    • Intel HD Graphics 5500

    • DirectXバージョン 12

    • SSDなし




問題提起

preview段階のECSにわざわざ手を出す人は皆大量のユニットとかオブジェクトを処理したい物好き 物量狂ですよね?

ECS使っていてC# Job System使わないなんて人は(Web Assemblyターゲットの人以外)いないでしょう。

さて、ECS+Job Systemで最も効率よくComponentDataを処理する方法は間違いなくIJobProcessComponentDataです。

しかし、このインターフェース、処理できるジェネリック型が最大3つまでという厳しい制約が課されています。

「3つとか少なすぎィ!」と、System.Tuple(8つまで)にかつて感じたそれと同質な印象を持った方が多いでしょう。

では、改造してみましょうか。


Unityの配布されているパッケージを改造してよいのかライセンスを確認してみる。

Unity Companion License

ライセンスを表記して著作権をUnityに帰属させて、Unity上で動くゲームを作る限りはECSのソースに手を加えてもいいみたいですね。(法律家ではないのでまるで自信はないです。)


下準備(Unity Package Manager管理下からUnity.EntitiesをAssetsに移管)

タイムリーな記事がありますので、それを参考にしてください。


  • Assetsフォルダ下のmanifest.jsonに下記依存関係を追記します。


    • "com.unity.burst": "0.2.4-preview.25",

    • "com.unity.collections": "0.0.9-preview.2",

    • "com.unity.incrementalcompiler": "0.0.42-preview.18",

    • "com.unity.jobs": "0.0.7-preview.2",

    • "com.unity.mathematics": "0.0.12-preview.11",

    • "com.unity.properties": "0.1.18-preview.2",



  • Package ManagerでUnity.Entitiesをインストールします。

  • PackagesフォルダからAssetsフォルダ下にcom.unity.entitiesをコピー&ペースト


    • この段階でエラーが出てきます。

    • エラーを処理する前にInspectorを2つ並べておきましょう。

    • Assets/com.unity.entities@0.0.12-preview.8/Unity.Entities.Editor/Resources/EntityDebuggerStyles.assetというScriptableObjectファイルとそれに対応するPackagesフォルダのScriptableObjectをそれぞれInspectorに表示し、中身をコピペしましょう。

    • 多分もっといい設定の移行方法が存在するでしょうが、初心者なのでわかりません。

    • Assets/com.unity.entities@0.0.12-preview.8/Unity.Entities.Editor/EntityDebugger/EntityDebuggerStyles.csを開いて以下のように書き換えます。

    • styleAsset = AssetDatabase.LoadAssetAtPath("Assets/com.unity.entities@0.0.12-preview.8/Unity.Entities.Editor/Resources/EntityDebuggerStyles.asset");

    • パス直書きなのです……

    • 書き込み不可ファイルですが、書き込んでしまって問題ありません。



  • Package ManagerでUnity.Entitiesをアンインストールします。

  • エラーがConsoleに出てきますので一つ一つ潰していきましょう。


    • Unity.Mathematicsのバージョンがpreview11に上がり、quaternion構造体がQuaternion構造体にリネームされていますので、一括置換で対応しましょう。UnityEngine.QuaternionとUnity.Mathematics.Quaternionで名前がかぶるのでそこは完全修飾名を使ってエラーを解消してください。



以上でパッケージの移管の完了となります。

ScriptableObjectが含まれているパッケージを扱う際には注意が必要ですね。


極論

ref&unsafe祭りになるのを覚悟する必要はありますが、internalを全てpublicに置換すれば大体性能問題はなんとかなるでしょう。

以上! 閉廷! 解散!


極論ではない解決策

以後この記事に出るIJobProcessComponentDataのソースコードは改造後のコードです。改造前のオリジナルはパッケージマネージャから落としてきたソースで確認してください。


IJobProcessComponentData<T0, T1, T2, T3>

C# Job SystemではIJobとかIJobParallelForを自分で定義できます。

こ↑こ↓を参考にしながらIJobProcessComponentData<T0, T1, T2, T3>をこれから実装していきましょう。


外部に公開するIJobProcessComponentData


Unity.Entities/IJobProcessComponentData.cs

[EditorBrowsable(EditorBrowsableState.Never)]

public interface IBaseJobProcessComponentData{}

[EditorBrowsable(EditorBrowsableState.Never)]
public interface IBaseJobProcessComponentData_4 : IBaseJobProcessComponentData { }

[JobProducerType(typeof(JobProcessComponentDataExtensions.JobStruct_Process4<,,,,>))]
public interface IJobProcessComponentData<T0, T1, T2, T3> : IBaseJobProcessComponentData_4
where T0 : struct, IComponentData
where T1 : struct, IComponentData
where T2 : struct, IComponentData
where T3 : struct, IComponentData
{
void Execute(ref T0 data0, ref T1 data1, ref T2 data2, ref T3 data3);
}


JobをJob Systemに登録処理するのはJobProducerTypeAttributeの引数に渡された型です。

繰り返し部分は省いてます。実際は似たようなものが3回繰り返されています。


JobProcessComponentDataExtensions.Schedule

実際ユーザーがIJobProcessComponentDataを使う場合、以下のようにしていますよね?

[BurstCompile]

struct Job : IJobProcessComponentData<Unity.Transforms.Position, Unity.Transforms.MoveSpeed, Unity.Transforms.Heading>
{/* 略 */}

protected JobHandle OnUpdate(JobHandle inputDeps) => new Job.Schedule(this, 1145141919, inputDeps);

このScheduleは拡張メソッドです。 jobDataが構造体なのにコピーされるとかいう仕様。ref this T jobDataバージョンも提供してほしい。


改造後

// innerLoopBatchCountが-1だとIJobのようにWorkerスレッド1つだけを利用して走る。

public static JobHandle Schedule<T>(this T jobData, ComponentSystemBase system, int innerloopBatchCount = -1, JobHandle dependsOn = default) where T : struct, IBaseJobProcessComponentData
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
if (innerloopBatchCount == 0 || innerloopBatchCount < -1)
throw new ArgumentException($"innerloopBatchCount must be larger than 0 or equal to -1.");
#endif

var typeT = typeof(T);
if (typeof(IBaseJobProcessComponentData_1).IsAssignableFrom(typeT))
return ScheduleInternal_1(ref jobData, system, innerloopBatchCount, dependsOn);
if (typeof(IBaseJobProcessComponentData_2).IsAssignableFrom(typeT))
return ScheduleInternal_2(ref jobData, system, innerloopBatchCount, dependsOn);
if (typeof(IBaseJobProcessComponentData_3).IsAssignableFrom(typeT))
return ScheduleInternal_3(ref jobData, system, innerloopBatchCount, dependsOn);
return ScheduleInternal_4(ref jobData, system, innerloopBatchCount, dependsOn);
}

メソッドシグネチャを多少整えてオーバーロードを一つ減らしていますが、改造前とコードパスは同一です。

インターフェースの継承関係を確認して、それに応じて呼び出すメソッドを変えています。

……毎フレーム、リフレクションやってますね。typeof(T)はまあ毎フレームやっても問題ないでしょうが、IsAssignableFromのパフォーマンスは気になりますね。

調べると大したことないオーバーヘッドですし、目くじら立てるほどでもないでしょう。


JobProcessComponentDataExtensions.ScheduleInternal_4

static unsafe JobHandle ScheduleInternal_4<T>(ref T jobData, ComponentSystemBase system, int innerloopBatchCount, JobHandle dependsOn, ScheduleMode mode = ScheduleMode.Batched) where T : struct

{
var fullData = new JobStruct_ProcessInfer_4<T> { Data = jobData };
var isParallelFor = innerloopBatchCount != -1;
IJobProcessComponentDataUtility.Initialize(system, typeof(T), typeof(JobStruct_Process4<,,,,>), isParallelFor, ref JobStruct_ProcessInfer_4<T>.Cache, out fullData.Iterator);
return Schedule(UnsafeUtility.AddressOf(ref fullData), fullData.Iterator.m_Length, innerloopBatchCount, isParallelFor, ref JobStruct_ProcessInfer_4<T>.Cache, dependsOn, mode);
}

同一のJobを複数のシステムから呼ぶのは辞めましょう。キャッシュが利かなくて毎フレームリフレクション地獄になります。

ref JobStruct_ProcessInfer_4.Cacheの部分にTというIJobProcessComponentDataを実装した構造体に関してJob Systemが利用するデータが諸々キャッシュされているのです。


JobStruct_ProcessInfer_4.Cacheの型の定義

public struct JobProcessComponentDataCache

{
public IntPtr JobReflectionData;
public IntPtr JobReflectionDataParallelFor;
public ComponentType[] Types;
public ComponentType[] FilterChanged;

// void Execute(ref T0 data0, ref T1 data1, ...)とかの引数の個数。
public int ProcessTypesCount;

public ComponentGroup ComponentGroup;
public ComponentSystemBase ComponentSystem;
}


この型にJob Systemをスムーズに駆動させるために必要な情報をキャッシュすることで2フレーム目から高速にJobを発行できるようにしています。

IJobProcessComponentDataUtility.InitializeはJobComponentSystemの型とTに基いてChunkを処理するIteratorを返すメソッドです。内部処理については後述します。

最後にScheduleにfullDataのポインタと処理数、バッチカウント、並列か否か、キャッシュ情報、依存関係情報を渡してJobHandleを生成しています。

正直jobDataはin引数で扱えれば素敵なのですが、UnsafeUtility.AddressOfにin引数を取るオーバーロードがないので割と本当にどうしようもありません。C++との連携上無理なのかもしれません。


Schedule(ポインター版)

static unsafe JobHandle Schedule(void* fullData, int length, int innerloopBatchCount, bool isParallelFor, ref JobProcessComponentDataCache cache, JobHandle dependsOn, ScheduleMode mode)

{
if (isParallelFor)
{
// cache.JobReflectionDataParallelForはJobsUtility.CreateJobReflectionDataで得たIntPtrが入っている。
var scheduleParams = new JobsUtility.JobScheduleParameters(fullData, cache.JobReflectionDataParallelFor, dependsOn, mode);
return JobsUtility.ScheduleParallelFor(ref scheduleParams, length, innerloopBatchCount);
}
else
{
var scheduleParams = new JobsUtility.JobScheduleParameters(fullData, cache.JobReflectionData, dependsOn, mode);
return JobsUtility.Schedule(ref scheduleParams);
}
}

isParallelForに応じて全ワーカースレッド実行か単一ワーカースレッド実行かに分かれます。

JobsUtilityの実態はC++ですので、具体的にどういう内部的な働きをしているのか全然わかりません。

C# Job Systemがよしなに処理してくれると信じましょう。


IJobProcessComponentDataUtility.Initialize

// wrapperJobType == typeof(JobStruct_Process4<T, U0, U1, U2, U3>)

// jobTypeはIJobProcessComponentDat<...>を実装したJob構造体の型
public static unsafe void Initialize(ComponentSystemBase system, Type jobType, Type wrapperJobType, bool isParallelFor, ref JobProcessComponentDataCache cache, out ProcessIterationData it)
{
// 最初の1フレーム目では未初期化状態だから初期化せねばならぬ。
// 2フレーム目ではすっ飛ばせるからリフレクションを省けるのじゃ。
if (isParallelFor && cache.JobReflectionDataParallelFor == IntPtr.Zero || !isParallelFor && cache.JobReflectionData == IntPtr.Zero)
{
// JobProcessComponentData<T0, T1, T2, T3>が代入される。
var iType = GetIJobProcessComponentDataInterface(jobType);
if (cache.Types == null)
// TO, T1, T2, T3を持つEntityを処理対象に選ぶ。
// cache.ProcessTypesCountは4になる。
// cache.FilterChangedについては後述する。
cache.Types = GetComponentTypes(jobType, iType, out cache.ProcessTypesCount, out cache.FilterChanged);

// GetMethodからのInvokeリフレクションはこ↑こ↓
(isParallelFor ? ref cache.JobReflectionDataParallelFor : ref cache.JobReflectionData) = GetJobReflection(jobType, wrapperJobType, iType, isParallelFor);
}

// new Job{}.Schedule(this, 114514, inputDeps);とかするじゃろ?
// アレでComponentSystemBase継承しているクラス渡して、それをUnityがごにょごにょする所。
if (cache.ComponentSystem != system)
{
// 処理対象のComponentType[]を基にしてComponentGroupを作成して代入。
// Systemが既に同一のComponentTypesを処理するComponentGroupをキャッシュしていた場合はそれを得る。
// なおキャッシュの検索は線形探索。
// 1つのSystemに複数のComponentGroupを要求する際には処理するComponentTypeの要素数を別々にすると高速化が図れる。
cache.ComponentGroup = system.GetComponentGroupInternal(cache.Types);
if (cache.FilterChanged.Length != 0)
cache.ComponentGroup.SetFilterChanged(cache.FilterChanged);
else
cache.ComponentGroup.ResetFilter();

cache.ComponentSystem = system;
}

// Readonly
it.IsReadOnlyBitFlags = 0;
for (var i = 0; i != cache.ProcessTypesCount; i++)
if (cache.Types[i].AccessModeType == ComponentType.AccessMode.ReadOnly)
// [ReadOnly] ref T0 data0 と書くとフラグが立つ。
it.IsReadOnlyBitFlags |= (uint)(1 << i);

// Iterator & length
var length = -1;

var group = cache.ComponentGroup;
// it.Iterator0.GetType() == typeof(ComponentChunkIterator)
// ComponentChunkIteratorはChunkArrayを効率的に巡回するための構造体。
it.Iterator0 = it.Iterator1 = it.Iterator2 = it.Iterator3 = default;

// it.Iterator0,1,2,3は連続した並びにあるのでfixedすれば配列的に扱える。
fixed (ComponentChunkIterator* iterators = &it.Iterator0)
{
for (var i = 0; i != cache.ProcessTypesCount; i++)
{
group.GetComponentChunkIterator(out length, out iterators[i]);
// 高速化のために必須。
iterators[i].IndexInComponentGroup = group.GetIndexInComponentGroup(cache.Types[i].TypeIndex);
}
}

it.IsChangedFilterBitFlags = 0;
fixed (ComponentChunkIterator* iterators = &it.Iterator0)
{
foreach (var type in cache.FilterChanged)
{
// GetComponentGroup(typeof(Position))とかした際にComponentGroupが作られ、同時にComponentTypesが登録される。
// type.TypeIndexはTypeManagerというSingletonによって付与されるから重複の心配はない。
var componentIndexInGroup = group.GetIndexInComponentGroup(type.TypeIndex);

for (var iteratorIndex = 0; iteratorIndex < ProcessIterationData.Length; ++iteratorIndex)
if (componentIndexInGroup == iterators[iteratorIndex].IndexInComponentGroup)
it.IsChangedFilterBitFlags |= (uint)(1 << iteratorIndex);
}
}

it.m_IsParallelFor = isParallelFor;
// CalulateNumberOfChunksWithoutFiltering()は内部でリンクリストをたどって要素数を数え上げている。
it.m_Length = cache.FilterChanged.Length > 0 ? group.CalculateNumberOfChunksWithoutFiltering() : length;

#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 省略
#endif
}

Initializeメソッドの目的はキャッシュを更新しつつ、ProcessIterationData型のitを初期化することです。

ifプリプロセッサディレクティブで囲まれた領域は解説してもつまらないので省略します。

TypeManagerでは@neueccさんがかつて書かれたC#でTypeをキーにしたDictionaryのパフォーマンス比較と最速コードの実装に類似した技法でTypeIndexを保管しています。


JobProcessIterationData

[NativeContainer]

[NativeContainerSupportsMinMaxWriteRestriction]
[StructLayout(LayoutKind.Sequential)]
public unsafe struct ProcessIterationData
{
public const int Length = 4;

public ComponentChunkIterator Iterator0;
public ComponentChunkIterator Iterator1;
public ComponentChunkIterator Iterator2;
public ComponentChunkIterator Iterator3;

// 元々のソースコードでは int IsReadOnly0, IsReadOnly1, IsReadOnly2;として並べられていた。
  // ただ、ソース全文を読んでも0と1しか値域がないのでビットフラグとして管理することにした。
public uint IsReadOnlyBitFlags;
public uint IsChangedFilterBitFlags;

public bool m_IsParallelFor;

public int m_Length;
#if ENABLE_UNITY_COLLECTIONS_CHECKS
// 省略
#endif
}



IJobProcessComponentDataUtility.GetJobReflection

static IntPtr GetJobReflection(Type jobType, Type wrapperJobType, Type interfaceType, bool isIJobParallelFor)

{
Assert.AreNotEqual(null, wrapperJobType);
Assert.AreNotEqual(null, interfaceType);

var genericArgs = interfaceType.GetGenericArguments();

// 元々のコードではList<Type>だったが長さがわかってるなら最初から配列でIKEA
var jobTypeAndGenericArgs = new Type[1 + genericArgs.Length];
jobTypeAndGenericArgs[0] = jobType;

// Array.Copy(genericArgs, 0, jobTypeAndGenericArgs, 1, genericArgs.Length);
// x64でならcpblk命令は早い。x86、AMD?なんのこったよ?
System.Runtime.CompilerServices.Unsafe.CopyBlock(ref Unsafe.As<Type, byte>(ref jobTypeAndGenericArgs[1]), ref Unsafe.As<Type, byte>(ref genericArgs[0]), (uint)(Unsafe.SizeOf<Type>() * genericArgs.Length));

// JobStruct_Process4<T, U0, U1, U2, U3>を具象化する。
var resolvedWrapperJobType = wrapperJobType.MakeGenericType(jobTypeAndGenericArgs);

object[] parameters = { isIJobParallelFor ? JobType.ParallelFor : JobType.Single };

// リフレクションおいしいれす……^q^
// staticメソッドの呼び出しだから第一引数はnullでよし。
var reflectionDataRes = resolvedWrapperJobType.GetMethod("Initialize").Invoke(null, parameters);
// var reflectionDataRes = JobsUtility.CreateJobReflectionData(typeof(JobStruct_Process4<T, U0, U1, U2, U3>), typeof(T), jobType, (ExecuteJobFunction)Execute);
// と同一。 Executeだけはリフレクションを使わないとどうしようもないのでshoganai。
return (IntPtr)reflectionDataRes;
}

低速な文字引きのリフレクションががががが。

いや、キャッシュするから大丈夫だと信じたいですが、いや、これは酷いものです。メモリアロケーションも普通に発生していて嫌になっちゃいます。

元のソースではjobTypeAndGenericArgsの型がListでToArrayしていました。それはないだろうと突っ込まざるを得ないです。

System.Runtime.CompilerServices.Unsafe.CopyBlockの性能についてはこちらを御覧ください

fixedステートメントを使いたくなかったのでvoid*を必要とするMemoryCopyは不採用ということで一つ。

resolvedWrapperJobTypeはJobStruct_Process4<T, U0, U1, U2, U3>の具象型ですので、次はJobStruct_Process4<T, U0, U1, U2, U3>.Initializeを見ましょう。


JobStruct_Process4<T, U0, U1, U2, U3>のフィールド定義部

[StructLayout(LayoutKind.Sequential)]

public struct JobStruct_Process4<T, U0, U1, U2, U3> where T : struct, IJobProcessComponentData<U0, U1, U2, U3> where U0 : struct, IComponentData where U1 : struct, IComponentData where U2 : struct, IComponentData where U3 : struct, IComponentData
{
public ProcessIterationData Iterator;
public T Data;
// 後略
}


JobStruct_Process4<T, U0, U1, U2, U3>.Initialize

[Preserve]

public static IntPtr Initialize(JobType jobType)
=> JobsUtility.CreateJobReflectionData(typeof(JobStruct_Process4<T, U0, U1, U2, U3>), typeof(T), jobType, (ExecuteJobFunction)Execute);

引数のjobTypeはJobType.ParallelForまたはJobType.SingleがisParallelForに応じて入っています。


ExecuteJobFunction

delegate void ExecuteJobFunction(ref JobStruct_Process4<T, U0, U1, U2, U3> data, IntPtr additionalPtr, IntPtr bufferRangePatchData, ref JobRanges ranges, int jobIndex);


JobStruct_Process4<T, U0, U1, U2, U3>.ExecuteがIJobProcessComponentData<U0, U1, U2, U3>.Executeを実際に駆動するメソッドです!

ようやくC# Job Systemに働き手を通知する所まで解説できました。


JobStruct_Process4<T, U0, U1, U2, U3>.Execute

static unsafe void Execute(ref JobStruct_Process4<T, U0, U1, U2, U3> jobData, IntPtr additionalPtr, IntPtr bufferRangePatchData, ref JobRanges ranges, int jobIndex)

{
// [ChangedFilter]がExecuteメソッドのパラメータについている場合。
if ((jobData.Iterator.IsChangedFilterBitFlags & 0b1111) != 0)
while (JobsUtility.GetWorkStealingRange(ref ranges, jobIndex, out var begin, out var end))
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
JobsUtility.PatchBufferMinMaxRanges(bufferRangePatchData, UnsafeUtility.AddressOf(ref jobData), begin, end - begin);
#endif
ExecuteInnerLoopByChunk(ref jobData, begin, end);
}
else if (jobData.Iterator.m_IsParallelFor)
while (JobsUtility.GetWorkStealingRange(ref ranges, jobIndex, out var begin, out var end))
{
#if ENABLE_UNITY_COLLECTIONS_CHECKS
JobsUtility.PatchBufferMinMaxRanges(bufferRangePatchData, UnsafeUtility.AddressOf(ref jobData), begin, end - begin);
#endif
ExecuteInnerLoop(ref jobData, begin, end);
}
else ExecuteInnerLoop(ref jobData, 0, jobData.Iterator.m_Length);
}

定義したJobのvoid Execute(ref T0 data0, ref T1 data1, ref T2 data2, ref T3 data3)のパラメータには次の 2つの属性を付与することが可能です。


  • [ReadOnly]


    • この属性が付与されたパラメータに書き込み操作を行うことはできません。



  • [ChangedFilter]


    • この属性に言及する日本語資料はこれが初めてのはず。

    • 一番最初のフレームと、更新された時のみに駆動してほしい場合に付与する属性です。

    • 更新された時というのが具体的にどういう意味なのかは私もよくわかっていませんが、ComponentSystemのプロパティであるuint GlobalSystemVersionよりもComponentGroupFilterのuint RequiredChangeVersionを大きくすると更新されたとみなすようです。

    • GlobalSystemVersionはComponentSystemやJobComponentSystemのBeforeOnUpdateメソッドで1ずつ増えていきます。Systemが作成されてからのフレーム数カウントに使えるでしょう。




  • [WriteOnly]


    • あれ、三沢くん居たの?



殆どの人が辿るコードパスは2番目のif文をtrueにしてExecuteInnerLoop(ref jobData, begin, end);にステップインするでしょう。次の節でExecuteInnerLoopの解説をします。

JobsUtility.GetWorkStealingRangeはC# Job Systemが「今ならWorker Threadでbeginからendまでレースコンディションにならず処理できる」と教えてくれるメソッドです。

どこにも書いていないので推測ですが、おそらくこのExecuteメソッドからようやく実際Worker Threadで動いているのではないでしょうか?


JobStruct_Process4<T, U0, U1, U2, U3>.ExecuteInnerLoop

static unsafe void ExecuteInnerLoop(ref JobStruct_Process4<T, U0, U1, U2, U3> jobData, int begin, int end)

{
const int ProcessCount = 4;
var cache = stackalloc ComponentChunkCache[ProcessCount];
fixed (ComponentChunkIterator* iterator = &jobData.Iterator.Iterator0)
// ループを二重にぐるぐる回してJob Systemから割り当てられた範囲を処理する。
while (begin != end)
{
for (int i = 0; i < ProcessCount; i++)
iterator[i].UpdateCache(begin, out cache[i], ((jobData.Iterator.IsReadOnlyBitFlags >> i) & 1) == 0);

var ptr0 = UnsafeUtilityEx.RestrictNoAlias(cache[0].CachedPtr);
var ptr1 = UnsafeUtilityEx.RestrictNoAlias(cache[1].CachedPtr);
var ptr2 = UnsafeUtilityEx.RestrictNoAlias(cache[2].CachedPtr);
var ptr3 = UnsafeUtilityEx.RestrictNoAlias(cache[3].CachedPtr);

var curEnd = Math.Min(end, cache[0].CachedEndIndex);

for (var i = begin; i != curEnd; i++)
// UnsafeUtilityExを使うことでref戻り値が利用できる。
jobData.Data.Execute(ref UnsafeUtilityEx.ArrayElementAsRef<U0>(ptr0, i), ref UnsafeUtilityEx.ArrayElementAsRef<U1>(ptr1, i), ref UnsafeUtilityEx.ArrayElementAsRef<U2>(ptr2, i), ref UnsafeUtilityEx.ArrayElementAsRef<U3>(ptr3, i));

begin = curEnd;
}
}

私が元のソースを読んだ限りではExecuteInnerLoopで使用されるメソッドはいずれもアロケーションフリーです。

JobsUtility.GetWorkStealingRangeもC#レイヤーとしてはアロケーションフリーなのでGC0で素晴らしいですね。


UnsafeUtilityEx.cs

public static void* RestrictNoAlias(void* ptr)

{
return ptr;
}

UnsafeUtilityEx.RestrictNoAliasの中身は右から左にそのままptrを流すだけです。存在意義がまるでわからないのですが……

UnsafeUtilityEx.ArrayElementAsRefは結構便利なラッパーメソッドですからまだ使う気になれますが、UnsafeUtilityEx.AsRefとかはAggressiveInlining属性もついていないので素のSystem.Runtime.CompilerServices.Unsafeを使った方が良いと思います。

UnsafeUtilityはC++のラッパーですからunsafe塗れになってもいいと言うならガンガン使うべきでしょう。はようrefまみれになろうぜ。

もしかしたら深遠な意味があるかもしれないので弄りませんが、もし手を加えるとするならば var ptr0 = UnsafeUtilityEx.RestrictNoAlias(cache[0].CachedPtr);ref var ptr0 = ref cache[0].chachedPtr; と書き換えましょうか。


JobStruct_Process4<T, U0, U1, U2, U3>.ExecuteInnerLoopByChunk

似たようなコードです。

static unsafe void ExecuteInnerLoopByChunk(ref JobStruct_Process4<T, U0, U1, U2, U3> jobData, int begin, int end)

{
const int ProcessCount = 4;
var cache = stackalloc ComponentChunkCache[ProcessCount];

fixed (ComponentChunkIterator* iterator = &jobData.Iterator.Iterator0)
for (var blockIndex = begin; blockIndex != end; ++blockIndex)
{
for (uint i = 0; i < ProcessCount; i++)
iterator[i].MoveToChunkByIndex(blockIndex);

var processBlock = false;

for (int i = 0; i < 4; i++)
processBlock |= ((jobData.Iterator.IsChangedFilterBitFlags >> i) & 1) != 0 && iterator[i].IsCurrentChunkChanged();

if (!processBlock)
continue;
for (int i = 0; i < ProcessCount; i++)
iterator[i].UpdateCacheToCurrentChunk(out cache[i], ((jobData.Iterator.IsReadOnlyBitFlags >> i) & 1) == 0);

var ptr0 = UnsafeUtilityEx.RestrictNoAlias(cache[0].CachedPtr);
var ptr1 = UnsafeUtilityEx.RestrictNoAlias(cache[1].CachedPtr);
var ptr2 = UnsafeUtilityEx.RestrictNoAlias(cache[2].CachedPtr);
var ptr3 = UnsafeUtilityEx.RestrictNoAlias(cache[3].CachedPtr);

for (var i = cache[0].CachedBeginIndex; i != cache[0].CachedEndIndex; i++)
jobData.Data.Execute(ref UnsafeUtilityEx.ArrayElementAsRef<U0>(ptr0, i), ref UnsafeUtilityEx.ArrayElementAsRef<U1>(ptr1, i), ref UnsafeUtilityEx.ArrayElementAsRef<U2>(ptr2, i), ref UnsafeUtilityEx.ArrayElementAsRef<U3>(ptr3, i));
}
}


【重要】 IJobProcessComponentData専用属性[RequireComponentTag],[RequireSubtractiveComponent]について

早速使用例に行ってみましょう。

[Unity.Burst.BurstCompile]

[Unity.Entities.RequireComponentTag(typeof(Unity.Rendering.MeshCullingComponent), typeof(MeshInstanceRender), typeof(Unity.Transforms.MoveForward))]
[RequireSubtractiveComponent(typeof(MeshCulledComponent), typeof(MeshLODComponent))]
struct Job : IJobProcessComponentData<Position, MoveSpeed, Heading, Rotation>
{
public float deltaTime;
public void Execute(ref Position position, [ReadOnly] ref MoveSpeed speed, [ReadOnly] ref Heading heading, [ReadOnly] ref Rotation rotation)
{
// 省略
}
}

ああ^~

ガチガチにEntityが持つべきComponentType指定できて幸せになってしまいます~:relaxed:

一応解説するとRequireComponentTagに渡したTypeはComponentType.ReadOnlyに渡されてComponentGroupのComponentTypesに登録されます。

RequireSubtractiveComponentの場合はComponentType.Subtractiveですね。

これによりタグとなるIComponentData/ISharedComponentDataによるフィルタリングが可能になって実用性が格段に向上します。

公式のサンプルにしれっと一行だけRequireComponentTagが使われていましたが、見落としていました。

(RequireSubtractiveComponentの方は使用例多分)ないです。


IJobProcessComponentDataUtility.GetComponentTypes

IJobProcessComponentDataUtility.Initialize内部で呼ばれているメソッドです。

ここでJob構造体のCustomAttributeを調べて戻り値のComponentType[]に含ませているのです。

static ComponentType[] GetComponentTypes(Type jobType, Type interfaceType, out int processCount, out ComponentType[] changedFilter)

{
var genericArgs = interfaceType.GetGenericArguments();

var executeMethodParameters = jobType.GetMethod("Execute").GetParameters();

var componentTypes = new List<ComponentType>();
var changedFilterTypes = new List<ComponentType>();

for (var i = 0; i < genericArgs.Length; i++)
{
var isReadonly = executeMethodParameters[i].GetCustomAttribute(typeof(ReadOnlyAttribute)) != null;
var type = new ComponentType(genericArgs[i], isReadonly ? ComponentType.AccessMode.ReadOnly : ComponentType.AccessMode.ReadWrite);
componentTypes.Add(type);

var isChangedFilter = executeMethodParameters[i].GetCustomAttribute(typeof(ChangedFilterAttribute)) != null;
if (isChangedFilter)
changedFilterTypes.Add(type);
}

// このIComponentDataあるいはISharedComponentDataを持つEntityを処理しないよ!
// たとえばstruct UninitializedEntityTag : IComponentData{}とか定義して
// それを[RequireSubtractiveComponent(typeof(UninitializedEntityTag))]とかすれば未初期化Entityを無視できるぞ!
var subtractive = jobType.GetCustomAttribute<RequireSubtractiveComponentAttribute>();
if (subtractive != null)
foreach (var type in subtractive.SubtractiveComponents)
componentTypes.Add(ComponentType.Subtractive(type));

// このIComponentDataあるいはISharedComponentDataを持つEntityを処理対象に含めるよ!
// 実際にそのデータには触れないけど!
// タグ的なComponentDataを扱うのに最適だぜ!
var requiredTags = jobType.GetCustomAttribute<RequireComponentTagAttribute>();
if (requiredTags != null)
foreach (var type in requiredTags.TagComponents)
componentTypes.Add(ComponentType.ReadOnly(type));

processCount = genericArgs.Length;
changedFilter = changedFilterTypes.ToArray();
return componentTypes.ToArray();
}


結論

[RequireSubtractiveComponent]を是非使いましょう!

そして自作のインターフェースでC# Job Systemをより効率的に使いこなしましょう。

IJobProcessComponentDataはリフレクションを使用しているため、初回起動時が特に非効率でした。

2回目以降はキャッシュが効くのでECS+Job Systemの範疇で最高効率という評判に違わぬコードパスでした。

それでもJobを使用するComponentSystemと処理する型を決め打ちして自作IJobを作った方が間違いなく早くなるでしょう。

起動時のどん詰まり解消の最後の奥の手として自作IJobProcessComponentDataXXXは一考に値すると思います。


感想

C# 7.2最高です!

Incremental Compilerを導入して、mcs.rspに-langversion:latestと書き込んだらもう快適なコーディングができることを保証します!


反省

ISharedComponentDataをreadonly refな引数に与えるIJobProcessComponentData拡張も作りたかったのですが、記事が長くなりすぎましたので諦めます。