🍣

Unity: ECS の性能を引き出すデータ設計の勘所

に公開

2023 年に Entities 1.0 がリリースされてしばらく経ちました。現在は Unity 本体と Entities との統合が進められ、これまで ECS を触っていなかったユーザーにとっても ECS の存在感が強まっています。ECS のことを学ぶにはいい頃合いではないでしょうか。


GDC Unity Product Update - YouTube より

この記事では Unity の ECS を使ってパフォーマンスを引き出すためのデータ設計について考えていきます。

ECS の全体像をおさらい

データ設計の前に、まずは利用者から見た ECS の全体像と、なぜこのような仕組みが必要なのかをおさらいしようと思います。

ECS (Entity Component System) は、ゲームの状態を Entity とそれに付随する Component 群、そして Component の状態を更新する System という 3 つの概念で構築するアーキテクチャです。Unity では従来の GameObject と MonoBehaviour よりも性能を引き出すためのアーキテクチャとして ECS (Entities パッケージ) が提供されています。

Entity & Component

GameObject とコンポーネントに相当する概念です。Entity が内部に複数の Component を持つことができます。Component は原則 struct で、マネージドオブジェクトへの参照も持てません。この制約は速度を引き出すために必要なものですが、速度と引き換えにマネージドなコンポーネントを使用することも可能です。

System

MonoBehaviour.Update() に相当する概念です。ECS ではコンポーネント自身が Update() メソッドを持つのではなく、コンポーネントから独立した class / struct の中にコンポーネントの状態を更新する処理を記述します。MonoBehaviour とは異なり、Component と System は 1:1 対応ではなく、M:N の関係になります。

public partial struct HogeSystem : ISystem
{
    // メインスレッドで実行
    private void OnUpdate(ref SystemState state)
    {
        // コンポーネント Hoge, Fuga をもつエンティティをクエリ
        // Hoge は変更可能な参照渡しで受け取る
        foreach (var (hogeRef, fuga) in SystemAPI.Query<RefRW<Hoge>, Fuga>())
        {
            hogeRef.ValueRW.X += fuga.Y;
        }
    }
}

System は明示的に指定可能な実行順を元にメインスレッド上で実行されますが、System を起点として、C# Job System を通じてワーカースレッドに処理を投げることも可能です。この際、ユーザーが自分で JobHandle を Complete しなくても、ECS によってコンポーネントに対する依存関係が判断され、必要なタイミングで自動的に Complete したり、Job の順序を制御する機能が備わっています。

World

Entity や System はいずれかの World に所属します。大抵は単一の World を使用すると思いますが、複数の World を作成することで、独立した複数のシミュレーション空間を管理することができます。

Archetype & Chunk

Entity は所有している Component の型の組み合わせごとにメモリ上で分離して管理されます。この Component の組み合わせを Archetype といいます。この Archetype を基にしたデータ構造が高速な状態更新に役立ちます。

ECS はなぜ、あるいは、いつ必要か

ECS は第一に「メモリ上の連続したデータに対するアクセスは速い」というコンピューターの特性を活かすためのアーキテクチャです。CPU はメモリからデータを読む際、周辺の幾らかの連続した領域をまとめて読み込み CPU 内の高速なキャッシュメモリに保存します。最も高速な L1 キャッシュは、CPU によって異なりますがおおむね 32KiB〜128KiB くらいのサイズであり、メモリアクセスが連続的であればキャッシュヒット率が上がります。キャッシュに収まるメモリ領域で作業が完結している間は CPU は相対的に低速な RAM へのアクセスを回避でき、動作が高速になります。

GameObject MonoBehaviour Transform など Unity でおなじみの概念は全てマネージドオブジェクトであり、これらはメモリ上に散らばって確保されます。また、これらのオブジェクトの実態は C++ 側に class として実装されており、C# と C++ の class のインスタンスは別々に存在しているためメモリ上でも分散します。これではキャッシュ効率は下がってしまいます。

もし ECS なしにキャッシュ効率の高い更新処理を書くなら、Entity が持つ状態は全て一つの struct の中に定義し、その配列に対してループを回すのが効率的でしょう。しかし実際には Entity が持つ状態や属性には微妙なバリエーションがたくさんあり、Transform とか MeshRenderer のようなコンポーネントの組み合わせによって状態を表現できた方が断然便利です。

そこで、ECS ではコンポーネントの組み合わせ = Archetype ごとに配列 = Chunk を用意し、そこに該当する Entity の持つ Component データを格納します。そして、System からコンポーネントに対するクエリを行う際は、まずクエリの条件に合う Archetype 群をフィルタし、次にそれらに属する Chunk 群に対してループを回すことになります。

ある Chunk の中に入っているコンポーネントの種類は Chunk の作成時に決定しているので、コンポーネントへのアクセスは高速です。なぜなら、 Chunk の中にはコンポーネントのデータが AAAABBBBCCCC のような順番で配置されており、例えばエンティティ3・コンポーネントBにアクセスしたい場合は、sizeof(A) * maxNumEntitiesInChunk + sizeof(B) * 3 のようにコンポーネントが存在するオフセットを計算できるからです[1]。実際には 1 つの Chunk のサイズは 16KiB に定められており、溢れたデータは次の Chunk に格納されます。したがって、1 つの Chunk は L1 キャッシュに全て収まります。

コンポーネント A, B, C を持つエンティティに対して A, B のみをクエリする System にとっては、C は不要なので別の配列に入っていた方がデータの局所性が上がるかもしれません。しかし、A, B のみのクエリでも、A, C のみのクエリでも、C のみのクエリでも、ある程度効率的に処理できるメモリレイアウトとしては有用です。具体的にどんなクエリが(どんな頻度で)実行されるかは静的に判断できるとは限らないものであり、割り切って Archetype ごとに Chunk を作成するという戦略は、性能と柔軟性をバランスよく実現します。

必要になるであろうデータ群をできるだけ局所的に配置でき、プログラマ自身は明示的なメモリ管理をせず、Entity と Component と System という従来の MonoBehaviour に似たシンプルなメンタルモデルでコードを書くことができる……性能と生産性をある程度両立できるのが ECS だと言えるでしょう。

とはいえ、MonoBehaviour と比較すると抽象化の能力は劣ります。コンポーネントは連続配置のためには struct である必要があり、継承はできません。原理的に考えても、継承では派生クラスでフィールドが足されることでサイズが増加する可能性があり、ここまで説明したような規則的なメモリレイアウトによる高速化の原則にはそぐわないものです。多態は全てコンポーネントのコンポジションで表現できるようにします。

ここまでの説明を踏まえると ECS の苦手な領域と得意な領域が見えてきます。

ECS が苦手な領域 ECS が得意な領域
複雑な抽象化が必要なロジックは苦手 決まりきったロジックは得意
一つのクエリで少数の Entity しか処理できないゲームは苦手 一つのクエリで大量の Entity を処理できるゲームは得意
Archetype が分散しすぎるゲームは苦手 Archetype が分散しないゲームは得意
複数の Entity に跨った依存関係のある処理は苦手 一つの Entity で完結する処理は得意

ECS は性能を出すためにわざわざデータを規則的に並べますが、これは MonoBehaviour 世界では不要だったコストです。ECS にとって苦手な領域ではこのメモリ管理コストが相対的に大きくなり、むしろ性能が落ちることもあり得るかもしれません。また、ECS は MonoBehaviour を完全に置き換えるものではなく、ECS と MonoBehaviour を併用して相互に状態を反映し合うことも可能です。使い所を見極め、計測によって効果を確認しましょう。


この先は、ECS のユースケースごとにデータ設計を考えるうえで重要な事項を挙げていきます。

処理対象のフィルタ

MonoBehaviour には enabled プロパティがあり、Update 等の呼び出しを止めることができました。ECS でもコンポーネントの状態によって System の処理対象から外したいことがよくあります。

IEnableableComponent はこれを実現するための仕組みです。IComponentData とともに IEnableableComponent を実装すると、コンポーネントに有効化状態を持たせることができます(逆にいうとデフォルトではコンポーネントに有効・無効の区別がありません!)。

struct Hoge : IComponentData, IEnableableComponent { /* ... */ }

entityManager.SetComponentEnabled<Hoge>(entity, isEnabled);

foreach (var hoge in SystemAPI.Query<Hoge>()) { /* ... */ }

IEnableableComponent による有効化状態は Chunk の内部に保存され、クエリを行う際に自動的に対象から外れ、処理を省略できます。

ただし、データの局所性の観点では問題になるケースがあります。例えば、ある Archetype に属する Component が 100 個あり、そのうち Enabled なのが 10 個だけだったらどうでしょうか?たくさんの Chunk を読む必要がある割に、そのうち必要なデータはごく一部だけという状況が発生します。理想的には Enabled な Component のみがシーケンシャルに並んでいてほしいものです。

そんなときは Tag Component を使って有効化状態を表現するのが効果的です。Tag Component はデータを持たない Component ですが、その Tag Component を持っているか否かで Archetype は変化します。つまり有効状態を示す Tag Component を持っている Entity と、そうでない Entity を別々の Archetype に属させることができ、当然 Chunk も分離します。

struct Hoge : IComponentData { /* ... */ }
struct HogeEnabledTag : IComponentData { /* からっぽ! */ }

foreach (var hoge in SystemAPI.Query<Hoge>().WithAll<HogeEnabledTag>()) { /* ... */ }

ただしこれにも弱点があり、Component の追加や削除は Structural Change ……つまり Chunk 上のデータの再レイアウトを伴うので負荷が大きくなります。有効化状態の変化が頻繁に起こる場合には IEnableableComponent を、たまにしか起こらない場合は Tag Component という使い分け方になると思います。

Chunk 内のデータ密度を高める

メモリの局所性を高めるには、ひとつの Chunk の中にできるだけ多くの Entity を詰め込むことが望ましいです。Chunk のサイズは 16KiB に定められているので、コンポーネントのサイズが大きいほど 1 つの Chunk に入る Entity の数は減ります。さらに、Entity が持つコンポーネントの種類が多いほど、Archetype の数も増え、Chunk も分散します。

コンポーネントのサイズを小さくするには、第一には不要なフィールドを削ることが有効ですが、フィールドの宣言順を変えることで、フィールドを削らずにコンポーネントのサイズを小さくできることもあります。

各フィールドはフィールド型のアラインメントに従い、宣言順にレイアウトされるため、フィールドの順序を工夫することでコンポーネントのサイズを最適化できます。

// sizeof(Hoge) は 16 バイト
struct Hoge : IComponentData
{
    public byte A; // 0 バイト目
    public int B; // 4 バイト目
    public byte C; // 8 バイト目
    public int D; // 12 バイト目
}

// sizeof(Hoge) は 12 バイト
struct Hoge : IComponentData
{
    public int B; // 0 バイト目
    public int D; // 4 バイト目
    public byte A; // 8 バイト目
    public byte C; // 9 バイト目
}

また、コンポーネントのサイズを小さくする以外に、そもそも同じ Entity に属する必要のないコンポーネントを別々の Entity に分離することも有効です。例えばある Entity に A, B, C, D 4つのコンポーネントがあり、A, B に触る System と C, D に触る System しか存在しない場合、A, B と C, D は同じ Entity に属する必要はありません。A, B をもつ Entity と C, D をもつ Entity に分離することでメモリ上も分離され、それぞれの System によるイテレーションの効率は高まります。

逆に、ある System が処理する Archetype が分散しすぎないことも重要です。Archetype を分離することで Chunk 内のデータの密度が高まる一方、System が見る必要がある Chunk の総数は増えるかもしれません。Archetype ごとの Entity の数が十分大きければまだいいですが、Archetype はたくさんあるのに、それぞれに含まれている Entity の数が少ないような状況では、System がスカスカの Chunk をたくさん見ることになってしまい、むしろ速度が出ないといったこともありえます。処理対象のフィルタで紹介した Tag Component の利用においても注意が必要なポイントです。

このあたりの最適化には ECS の Archetypes Window を見るのがおすすめです。Archetype ごとの Chunk の数や Chunk ごとの Entity の数を見ることができ、System にとってどのくらい効率のいいメモリ配置になっているかの目安になります。

並列化

ECS では Job から Component にアクセスする方法として IJobEntity IJobChunk というインターフェースが用意されています。 IJobEntity は Entity (コンポーネント) 単位の反復処理、IJobChunk は Chunk 単位の反復処理を行う Job を定義できます。

partial struct HogeSystem : ISystem
{
    private void OnUpdate(ref SystemState state)
    {
        new HogeJob { /* ... */ }.ScheduleParallel();
    }
}

[BurstCompile]
partial struct HogeJob : IJobEntity
{
    public void Execute(ref Hoge hoge) { /* ... */ }
}

それぞれ partial をつけて Source Generator による追加実装を行わせる必要があります。IJobEntity の背後で生成されるソースを見てみると、実際には IJobChunk が実装され、Chunk ごとの反復処理の中で Execute() メソッドを呼び出すコードが生成されていることがわかります。Chunk は連続したメモリ領域なので、ひとつの Chunk はひとつのスレッドで一気に処理するのが効率的というわけですね。逆にいうと Chunk の数が多くなければ、並列化による性能向上の効果も小さくなります(メインスレッドを空けられるメリットはある)。

Job の依存関係

ECS で Job System を使う場合、Job の依存関係を自動で管理する仕組みが備わっています。

  • Job で触っているコンポーネントがメインスレッドで必要になったタイミングで自動的に JobHandle.Complete() が呼び出される
  • 新たに発行する Job が触る Component が以前の Job で触られている場合、自動的に Dependency が設定される

Job の結果が必要になるまで Complete の呼び出しを遅らせることで、その間メインスレッドで別の仕事をし、処理の並列性を高めることができます。

この依存関係の管理は IJobEntity IJobChunk に対しては基本的に自動で行われますが、それ以外の Job を発行したり、Job 間の依存関係を詳細に調整したい場合は、明示的に依存関係を指定することもできます。この場合は System の Dependency プロパティ (JobHandle 型) を dependsOn に指定し、発行した Job の JobHandleDependency プロパティに代入します。

partial struct HogeSystem : ISystem
{
    private void OnUpdate(ref SystemState state)
    {
        var jobHandle = new HogeJob { /* ... */ }.ScheduleParallel(state.Dependency);
        state.Dependency = jobHandle;
    }
}

[BurstCompile]
partial struct HogeJob : IJobEntity
{
    public void Execute(ref Hoge hoge) { /* ... */ }
}

ただし、この Dependency は System 単位での管理になるため、実際にその Job がどの Component を触るかは考慮されないようです。依存関係は保守的に判断され、実際には必要ではない Job の完了を待つことがあるかもしれません。Job を発行する System がたくさんの Component に触っている場合は、System を分割するとより効率的になるかもしれません。

実行中の Job が触っている(可能性がある) Component に対して、後続の System がメインスレッドでアクセスしようとすると Job の待機が発生します。System の実行順によっては、ほかにメインスレッドで処理できる仕事があってもメインスレッドがブロックされるため、ワーカースレッドの利用効率が下がります。System の実行順を調整することで、Job の完了を待つ必要のある System をできるだけ後ろに持っていくと、総合的にフレーム時間を短くできるかもしれません。

マネージド世界との連携

ロジックからレンダリングまで ECS に全体重を預けられるのがパフォーマンス的には理想ですが、いきなりそれをやるのはハードルが高いので、まずは GameObject に主導権を握らせながら部分的に ECS を利用するのがおすすめです。Unity 側も今後のアップデートで GameObject と Entities との統合を進めるとアナウンスしており、GameObject と Entities を混在させる設計が今後増えていきそうです。Unity のロードマップによると、GameObject に直接 ECS のコンポーネントをアタッチできるようになり、PlayerLoop からの GameObject の更新も ECS を通して行うように変更することでパフォーマンスが改善するそうです。


GDC Unity Product Update - YouTube より

The Unity Engine roadmap | Unite 2025 - YouTube

今後このあたりの機能も拡充され状況が変わるかもしれませんが、現状の ECS でもマネージド世界と連携する方法が用意されています。

ECS ではマネージドオブジェクトを Managed Component として通常のコンポーネントと同様に扱うことが可能です。ただし、マネージドオブジェクトの確保は .NET ランタイム (Mono / IL2CPP / CoreCLR) の GC 実装が担うためレイアウトを制御できず、Chunk の外部に置かれることになり、メモリの局所性は下がります。さらに、Chunk の内部にはオブジェクト参照(ポインタ)が直接置かれるわけではなく、World ごとに作成される単一の配列にオブジェクト参照が置かれ、Chunk 上にはその配列に対するインデックスが置かれるようです。これは単にオブジェクト参照の配列に対してイテレーションする以上にコストが高いように思えます。

Unlike unmanaged components, Unity doesn't store managed components directly in chunks. Instead, Unity stores them in one big array for the whole World. Chunks then store the array indices of the relevant managed components. This means when you access a managed component of an entity, Unity processes an extra index lookup. This makes managed components less optimal than unmanaged components.

Managed components | Entities | 6.5.0

これらの事情を考慮すると、Managed Component にアクセスする際は、できるだけ短く、一気に終わらせるのがよさそうです。例えば Managed Component から Unmanaged Component に一気に状態をコピーし、その後 Burst や Job System で高速に Unmanaged Component を更新し、その後 Unmanaged Component から Managed Component に状態を読み戻す、といった具合です。最も処理が重い状態更新の間、一切 Managed Component に触らないようにすることで効率を上げます。なお、この方法は Managed / Unmanaged 間の状態のコピーにかかるコストよりも Burst / Job System による高速化の効果が大きい場合にのみ有効なので注意しましょう。対象のオブジェクトの数が多く、処理が計算集約的で、並列化しやすいほど効果が大きくなりそうです。

おわり

ECS の性能を引き出すための具体的なテクニックをいくつか紹介しました。とはいえ、どれも ECS のメモリ管理やスケジューリングに関する知識から自然に導かれることです。ECS を使いこなすためには、まずは ECS の全体像を理解し、ECS がどのような原理で性能を引き出しているのかを理解することが重要です。そうすれば、自然と性能を引き出すためのデータ設計の勘所も見えてくると思います。

脚注
  1. 実際にはアラインメントの考慮が必要だが高速なことに変わりない ↩︎

Discussion