エフアンダーバー

個人開発の記録

【Unity】 『ECSだけしかいない世界』の補足 - ForEach -

前回記事の公開後、Twitter上でForEachについて少し議論があったので、 前回雑に流したあたりについてもう少し詳しく書くことにします。

www.f-sp.com

<19/04/17 更新> ECS preview.30対応のコードに更新。

ForEachの問題点

ComponentSystemForEachメソッドは直感的にはラムダ式を用いて次のように利用します。

using Unity.Entities;

[UpdateInGroup(typeof(UpdateSystemGroup))]
public class BallMotionSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Game game = GetSingleton<Game>();

        Entities
            .WithAll<Ball>()
            .WithNone<Frozen>()
            .ForEach((ref Position position, ref Velocity velocity) =>
            {
                position.Y += velocity.Y * game.ElapsedTime;
            });
    }
}

ラムダ式というのは糖衣構文の一種なので、このコードは実際には次のようなコードになります (読みやすいように書いているため正確ではありませんが)。

using Unity.Entities;

[UpdateInGroup(typeof(UpdateSystemGroup))]
public class BallMotionSystem : ComponentSystem
{
    [System.Runtime.CompilerServices.CompilerGenerated]
    private sealed class LambdaClass
    {
        public Game game;

        internal void Body(ref Position position, ref Velocity velocity)
        {
            position.Y += velocity.Y * game.ElapsedTime;
        }
    }

    protected override void OnUpdate()
    {
        Game game = GetSingleton<Game>();

        LambdaClass lambda = new LambdaClass() { game = game };

        Entities
            .WithAll<Ball>()
            .WithNone<Frozen>()
            .ForEach(new EntityQueryBuilder.F_DD<Position, Velocity>(lambda.Body));
    }
}

F_DDというのはEntityQueryBuilder内で定義されているデリゲートの型です。

ここで問題となるのが、キャプチャした変数を保持するためのクラスと、ForEachに引数として渡すデリゲートのインスタンスを生成していることです。 つまり、ヒープメモリの確保が行われており、またこれはOnUpdate終了時に捨てられています。 これが繰り返されるとヒープメモリに使われていないメモリ領域が溜まっていき、 最終的にはガベージコレクション(GC)によって回収されます。

一般にGCにはすべてのスレッドを止めて整合性を保証するためのフェーズが存在し、 これによってゲーム本体の処理が滞るために処理落ちが発生することがあります。 この現象は"Stop the World"と呼ばれています。

それほど長い時間止まるわけではないのでプロジェクトによっては許容される場合もありますが、 リアルタイム性の強いゲームだと致命的になることもあります。 また、あまりに頻繁に起こるようだとリアルタイム性に関わらずゲーム体験を損ねます。

GCの性能は年々向上しているため、 今現在この現象がどれほどゲームに影響を与えるのかは定かではありませんが、 仮に問題が発生した場合に修正しづらいタイプの不具合なので、 可能な限り無駄なヒープメモリの確保はしたくないところです。 特に件のForEachは毎フレーム実行されがちな処理なので、生産性のために犠牲にしてよいものか悩ましいのです。

回避策

ForEachによるメモリアロケーションを回避する最も簡単な方法はForEachを使わないことです。 ForEachは単なるchunk iterationのラッパーであり、chunk iterationを直接使えばラムダ式を使わずに済みます。 ただ何故ForEachが用意されているのかといえば、chunk iterationを書くのが面倒だからであって、 chunk iterationで済むんだからそれでいいじゃん、というのはちょっと違います。

そこで、なんとかメモリアロケーションなしでForEachを使えないかどうか考えてみます。 デリゲートが参照型である以上一切newしないということはできないので、 必然的にnewしてできたインスタンスを使いまわす方針になります。

実はC#にはこの使いまわしをコンパイラが自動的に実装してくれるケースがあります。 それはラムダ式内でthisや外部のローカル変数を参照していない場合です。

using Unity.Entities;

[UpdateInGroup(typeof(UpdateSystemGroup))]
public class BallMotionSystem : ComponentSystem
{
    protected override void OnUpdate()
    {
        Entities
            .WithAll<Ball>()
            .WithNone<Frozen>()
            .ForEach((ref Position position, ref Velocity velocity) =>
            {
                position.Y += velocity.Y * 0.02f;
            });
    }
}

上記のコードは次のようにコンパイルされます。

using Unity.Entities;

[UpdateInGroup(typeof(UpdateSystemGroup))]
public class BallMotionSystem : ComponentSystem
{
    [System.Runtime.CompilerServices.CompilerGenerated]
    private sealed class LambdaClass
    {
        public static readonly LambdaClass Instance = new LambdaClass();

        public static EntityQueryBuilder.F_DD<Position, Velocity> Cache;

        internal void Body(ref Position position, ref Velocity velocity)
        {
            position.Y += velocity.Y * 0.02f;
        }
    }

    protected override void OnUpdate()
    {
        EntityQueryBuilder.F_DD<Position, Velocity> cache = LambdaClass.Cache;
        if (cache == null)
        {
            cache = LambdaClass.Cache = new EntityQueryBuilder.F_DD<Position, Velocity>(LambdaClass.Instance.Body);
        }

        Entities
            .WithAll<Ball>()
            .WithNone<Frozen>()
            .ForEach(cache);
    }
}

コンパイラが自動でやってくれたりはしませんが、 thisやローカル変数を含んでいる場合でも手動で同様の最適化は可能です。

using Unity.Entities;

[UpdateInGroup(typeof(UpdateSystemGroup))]
public class BallMotionSystem : ComponentSystem
{
    private readonly EntityQueryBuilder.F_DD<Position, Velocity> body;

    private Game game;

    public BallMotionSystem()
    {
        this.body = LambdaBody;
    }

    protected override void OnUpdate()
    {
        this.game = GetSingleton<Game>();

        Entities
            .WithAll<Ball>()
            .WithNone<Frozen>()
            .ForEach(this.body);
    }

    private void LambdaBody(ref Position position, ref Velocity velocity)
    {
        position.Y += velocity.Y * this.game.ElapsedTime;
    }
}

しかし、状況が複雑なほどに最適化した結果のコードは汚くなります。 そもそもForEachを使う目的はシンプルに記述することであって、こうなってくると本末転倒です。 素直にchunk iteration使った方がいいんじゃないの、という話になってきます。

改善案

前回記事にてこんなことを書きました。

コンテキストを引数として渡してそれをラムダ式で受け取れればとりあえずキャッシュは効きそうですが、 それはそれでややこしくなりそうな気も……。

つまり、こんな感じの拡張メソッドを定義して、

<19/04/17 注>

ForEachEntityQueryBuilderに移動する前に書いた記事のため、ComponentSystemへの拡張メソッドとして書いています。 EntityQueryBuilderはメンバがprivateなため同様の拡張メソッドを書くことができません。

using Unity.Collections;
using Unity.Collections.LowLevel.Unsafe;
using Unity.Entities;

public static class ComponentSystemExtensions
{
    // ...

    public delegate void F_RDD<R, T0, T1>(in R context, ref T0 c0, ref T1 c1)
        where T0 : struct, IComponentData
        where T1 : struct, IComponentData;

    public static unsafe void ForEach<R, T0, T1>(this ComponentSystem @this, F_RDD<R, T0, T1> operate, in R context, EntityQuery query)
        where T0 : struct, IComponentData
        where T1 : struct, IComponentData
    {
        ArchetypeChunkComponentType<T0> chunkComponentType0 = @this.GetArchetypeChunkComponentType<T0>(false);
        ArchetypeChunkComponentType<T1> chunkComponentType1 = @this.GetArchetypeChunkComponentType<T1>(false);

        using (NativeArray<ArchetypeChunk> chunks = query.CreateArchetypeChunkArray(Allocator.TempJob))
        {
            foreach (ArchetypeChunk chunk in chunks)
            {
                int length = chunk.Count;
                void* array0 = chunk.GetNativeArray(chunkComponentType0).GetUnsafePtr();
                void* array1 = chunk.GetNativeArray(chunkComponentType1).GetUnsafePtr();
                for (int i = 0; i < length; i++)
                {
                    ref T0 c0 = ref UnsafeUtilityEx.ArrayElementAsRef<T0>(array0, i);
                    ref T1 c1 = ref UnsafeUtilityEx.ArrayElementAsRef<T1>(array1, i);
                    operate(in context, ref c0, ref c1);
                }
            }
        }
    }

    // ...
}

こんな風に書けば、C#コンパイラによる最適化が働くんじゃないかという案でした。

using Unity.Entities;

[UpdateInGroup(typeof(UpdateSystemGroup))]
public class BallMotionSystem : ComponentSystem
{
    private EntityQuery query;

    protected override void OnCreate()
    {
        this.query = GetEntityQuery(new EntityQueryDesc
        {
            All = new[] { ComponentType.ReadOnly<Ball>(), ComponentType.ReadWrite<Position>(), ComponentType.ReadOnly<Velocity>() },
            None = new[] { ComponentType.ReadWrite<Frozen>() },
        });
    }

    protected override void OnUpdate()
    {
        Game game = GetSingleton<Game>();

        this.ForEach((in Game context, ref Position position, ref Velocity velocity) =>
        {
            position.Y += velocity.Y * context.ElapsedTime;
        }, game, this.query);
    }
}

一見よさそうに思えますが、状況が複雑になるとやはりイマイチでした。

using Unity.Entities;

[UpdateInGroup(typeof(InteractionSystemGroup))]
public class CollisionSystem : ComponentSystem
{
    private struct Context
    {
        public ComponentSystem System;
        public Game Game;
        public Position PlayerPosition;
        public Size PlayerSize;
        public bool GameOver;
    }

    private EntityQuery query;

    protected override void OnCreate()
    {
        this.query = GetEntityQuery(new EntityQueryDesc
        {
            All = new[] { ComponentType.ReadOnly<Ball>(), ComponentType.ReadOnly<Position>(), ComponentType.ReadOnly<Size>() },
            None = new[] { ComponentType.ReadWrite<Frozen>() },
        });
    }

    protected override void OnUpdate()
    {
        Game game = GetSingleton<Game>();
        Entity player = GetSingletonEntity<Player>();
        Position playerPosition = EntityManager.GetComponentData<Position>(player);
        Size playerSize = EntityManager.GetComponentData<Size>(player);

        Context context = new Context
        {
            System = this,
            Game = game,
            PlayerPosition = playerPosition,
            PlayerSize = playerSize,
            GameOver = false,
        };

        this.ForEach((ref Context ctx, Entity entity, ref Position position, ref Size size) =>
        {
            Position pPos = ctx.PlayerPosition;
            Size pSize = ctx.PlayerSize;
            if (position.X == pPos.X && position.Y - size.Height / 2f < pPos.Y + pSize.Height / 2f)
            {
                ctx.Game.Score += 1;
                ctx.System.PostUpdateCommands.DestroyEntity(entity);
            }
            else if (position.Y < 0f)
            {
                ctx.GameOver = true;
            }
        }, ref context, this.query);

        if (context.GameOver)
        {
            Entity entity = EntityManager.CreateEntity();
            EntityManager.AddComponentData(entity, new GameStateChangedEvent { NextState = GameState.GameOver });
        }

        SetSingleton<Game>(context.Game);
    }
}

Reduceあたりを用意すればもう少しマシになりそうですが、オーバーロードの数が凄まじいことになりそう……。

そもそもC#の詳しい挙動を知らなければこんなシグネチャである意味もわからないわけで、 「それってAPIとしてどうなの?」という問題もあります。

あとラムダ式でシャドーイングできない問題と誤ってキャプチャしちゃう問題がつらいです。 前者は解消される可能性があるみたいですが、後者はどうなんだろう。 静的ラムダ式が欲しいです。

一応、chunk iterationをこのForEachに置き換えたサンプルを前回リポジトリforeach-extブランチに置いてあります。

IL2CPPの検証

前回記事にてIL2CPPなら一時的メモリアロケーションが消えるんじゃないか、という希望的観測だけ述べたのですが、 投げっぱなしもよくないので検証してみました。

検証コード。

using Unity.Entities;

public struct IntComponent : IComponentData
{
    public int Value;
}

[DisableAutoCreation]
public class StaticLambdaSystem : ComponentSystem
{
    private EntityQuery query;

    protected override void OnCreateManager()
    {
        this.query = GetEntityQuery(typeof(IntComponent));
    }

    protected override void OnUpdate()
    {
        UnityEngine.Profiling.Profiler.BeginSample(nameof(StaticLambdaSystem));

        Entities.With(this.query).ForEach((ref IntComponent component) =>
        {
            component.Value = 42;
        });

        UnityEngine.Profiling.Profiler.EndSample();
    }
}

[DisableAutoCreation]
public class ThisLambdaSystem : ComponentSystem
{
    private EntityQuery query;

    private int value = 42;

    protected override void OnCreateManager()
    {
        this.query = GetEntityQuery(typeof(IntComponent));
    }

    protected override void OnUpdate()
    {
        UnityEngine.Profiling.Profiler.BeginSample(nameof(ThisLambdaSystem));

        Entities.With(this.query).ForEach((ref IntComponent component) =>
        {
            component.Value = this.value;
        });

        UnityEngine.Profiling.Profiler.EndSample();
    }
}

[DisableAutoCreation]
public class LocalLambdaSystem : ComponentSystem
{
    private EntityQuery query;

    protected override void OnCreateManager()
    {
        this.query = GetEntityQuery(typeof(IntComponent));
    }

    protected override void OnUpdate()
    {
        UnityEngine.Profiling.Profiler.BeginSample(nameof(LocalLambdaSystem));

        IntComponent local = GetSingleton<IntComponent>();

        Entities.With(this.query).ForEach((ref IntComponent component) =>
        {
            component.Value = local.Value;
        });

        UnityEngine.Profiling.Profiler.EndSample();
    }
}

public static class Boot
{
    [UnityEngine.RuntimeInitializeOnLoadMethod]
    public static void Init()
    {
        World world = new World("Lambda");
        EntityManager manager = world.EntityManager;

        manager.CreateEntity(typeof(IntComponent));

        ComponentSystemGroup group = world.CreateSystem<SimulationSystemGroup>();
        group.AddSystemToUpdateList(world.CreateSystem<StaticLambdaSystem>());
        group.AddSystemToUpdateList(world.CreateSystem<ThisLambdaSystem>());
        group.AddSystemToUpdateList(world.CreateSystem<LocalLambdaSystem>());
        group.SortSystemUpdateList();

        ScriptBehaviourUpdateOrder.UpdatePlayerLoop(world);
    }
}

結果。

計測結果 (Static: 0B, This: 112B, Local: 136B)

IL2CPPでもメモリアロケーションは消えませんでした。 ……まあ難しいですよね。

あとデリゲートのサイズって意外と大きいんですね。

おわりに

やはりForEachって微妙に使いづらいんじゃないかな、というのが個人的な感想です。 GCを気にしすぎるのは時間のムダだと言われそうですが……。

先日、ECSのpreview.24が来てましたね。 ざっとしか見ていませんが、シングルトン関係ではComponentGroupGetSingleton<T>SetSingleton<T>が生え、 ComponentSystemにはHasSingleton<T>が追加されていました。 ForEachはエンティティのみを列挙するオーバーロードが追加されたみたいです。



執筆時のUnityのバージョン:2018.3.5f1
執筆時のECSのバージョン:0.0.12-preview.24
更新時のUnityのバージョン:2019.1.0f2
更新時のECSのバージョン:0.0.12-preview.30