e.blog

主にUnity/UE周りのことについてまとめていきます

ECSの仕組みを理解し、使いどころを把握する

もともとECS/DOTSには興味があって知りたいと思っていたのですが、なかなか実プロジェクトで使うタイミングがなく放置してしまっていたのですが、今開発している中で利用できそうな箇所があったので、改めて入門して、備忘録的に学んだことをまとめていきたいと思います。

unity.com

ちなみに、ECSを使ってTextMesh Proの文字を大量に(80,000文字)出してみたら、余裕で60FPS出る状態でした。(PCではありますが、プロファイラで見ると4msくらいしかかかってなかったので全然まだ余裕があった)



概要

本記事ではECSの内容を概観し、全体像を把握して使い所を把握することを目的に書いています。ECSの実装方法については簡易的なサンプルを示しつつ、ECSのコアであるデータ指向の部分について、なぜ高速化するのか、どういうところで真価を発揮するのかについて書いていきたいと思います。

ECSとは

ECSは「Entity / Component / System」の頭文字を取ったものです。これは、EntityとComponentでオブジェクトとデータを定義し、Systemによって振る舞いが処理されることからついている名前のソフトウェアアーキテクチャです。ECS自体の概念はUnity特有のものではなく、効率的な処理を目的としたソフトウェア開発で採用されているアーキテクチャです。

なぜECSアーキテクチャだと高速化するのか

ではなぜ、ECSアーキテクチャだと処理が高速化するのでしょうか。その謎を紐解くには、現代のコンピュータの状況を考える必要があります。2024年時点でのCPUは大体1GHz~5GHzのクロック周波数で動きます。ざっくり計算で、1GHzだとすると1秒間に10億回の演算ができることになるので、1クロックあたりの時間は 1 / 1,000,000,000 = 0.000000001秒 となり、1ナノ秒となります。

かなり高速に演算することができることが分かりますね。では次に、メモリからデータを取得するための時間はどれくらいかかるか見てみましょう。以下の記事から引用すると、

各種メモリ/ストレージのアクセス時間,所要クロックサイクル,転送速度,容量の目安 #コンピュータアーキテクチャ - Qiita

下記記事によると,前述のInetl Core i9-13900K からアクセスした 実測値だと86.8ns でした.
【Hothotレビュー】6GHzのマイルストーンに達したCore i9-13900KSの性能をチェック - PC Watch

と書かれていて、メインメモリからの転送時間は実に 86.8ns もかかっています。もちろん状況によって変化することはありますが平均的には60~80nsくらいでしょう。とすると、1GHz程度のCPUから見ても実に60~80倍以上遅い結果となっています。

このことから、コンピュータによる計算における最大のボトルネックはCPUとメモリ間のデータ転送と見ることができます。

ECSはCPUのキャッシュを最大活用する

前述のように、CPUとメモリ間でのデータのやり取りが大きなボトルネックになることが分かりました。しかし一般的に、CPUにはメインメモリ以外にもL1, L2, L3というキャッシュ機構が用意されています。L1から順にCPUに近いキャッシュメモリとなります。前述の記事からさらに引用させてもらうと各キャッシュのアクセスに必要なクロックは以下のように記載されています。

1次キャッシュメモリ(level 1 (L1) cache memories) について,2022年で最も高性能な部類に入るCPUである,Intel Core i9-13900Kでは,4クロックサイクル でアクセスできます.


2次キャッシュメモリ(level 2 (L2) cache memories) について,2022年で最も高性能な部類に入るCPUである,Intel Core i9-13900Kでは,10クロックサイクル でアクセスできます.


3次キャッシュメモリ(level 3 (L3) cache memories) について,2022年で最も高性能な部類に入るCPUである,Intel Core i9-13900Kでは,34クロックサイクル以内,10.27ナノ秒以内 でアクセスできます.

一番近いL1キャッシュでは実に4サイクル(CPUが4回演算する時間)でデータを取り出すことができます。1GHzのCPUであれば 4ns ですね。メインメモリと比較して20倍以上高速です。しかし、CPUに近いキャッシュメモリほど容量が小さく、一度に保存できるデータ量が制限されてしまいます。記事によると2022年時点でもL1キャッシュは64KB程度しかありません。メインメモリが最近ではGB単位あることを考えると相当に小さいことが分かります。

メインメモリを本棚、キャッシュを机として考えてみる

キャッシュを利用するイメージを例え話で考えてみましょう。

L1キャッシュは自身の机で、メインメモリは本棚だと考えてみます。こう考えると、メインメモリにアクセスするのは本棚に本(資料)を取りに行くこと、L1キャッシュに保存するのはそれを机に置くこと、と考えることができます。

こう考えると、本棚から持ってくる本がデータで、机に置いておく本がキャッシュされたデータ、ということになりますね。

さてでは、どうやったら効率的に本を利用できるでしょうか。言い換えると、どうやったら本棚との往復を最小限にできるでしょうか。まず思いつくのは一度の往復で本をたくさん持ってくることです。しかしそれだけだと、使わない本ばかりを持ってきてしまっても意味がありません。なので、できるだけたくさんの使える本を持ってきて机に置いておくことが重要ですね。

CPUとメインメモリ・キャッシュ間の関係もまったく同じなので、できるだけ、メインメモリから取得したデータを効率よく扱えるようにしたい、と思うのは自然な発想でしょう。

そしてまさにこの「データのキャッシュ性を高める」ことを実現しているのがECSというアーキテクチャなわけです。


ECS向けのデータ構造

なぜECSが高速に動くのか、その理由がメモリのキャッシュ効率を最大化することだ、というのは前述した通りです。ではECSではどのようにこれを実現しているのでしょうか。

データ構造を定義するArchetype

ECSではデータはC、つまりComponentが担います。UnityのECSではEntityの持つComponent群を Archetype というタイプごとに管理をするようになっています。アーキタイプは構造のタイプというですね。ここで言う構造は言い換えると「どんなコンポーネントを持っているエンティティか」となります。

例えばコンポーネントの種類が A B C と3種類あるとしましょう。そしてEntityは任意のコンポーネントを持つことができます。例えば、Entity1はコンポーネントA, B、Entity2はコンポーネントA, B, Cを持つ、という具合です。

そしてこの「コンポーネント A, B, Cを持つ」という事実を Archetype として定義することで、あとからデータを取得しやすくしているわけです。

公式の動画で分かりやすい図が紹介されていたので引用させていただきます。

データを実際に配置するChunk

前述の Archetype はいわば概念です。「こういうコンポーネント郡を持っているエンティティにラベルを貼る」という感じですね。しかし概念だけではコンピュータは動きません。特に、メモリのキャッシュ効率を最大化することが目的なので、メモリレイアウトにはかなり気を使う必要があります。そしてこの「どういうふうにデータをメモリ上に配置するか」という実装に関するものが Chunk となります。

このチャンクの仕組みを視覚的に説明してくれている動画がUnityの公式にあります。

www.youtube.com

上の動画から、該当部分のアニメーションを抜き出すと以下のように説明されています。(動画ではKeijiroさんが詳しく解説してくれているので、興味がある方はぜひ観てみてください)

まずは、一般的なオブジェクト指向な場合のメモリレイアウト、メモリアクセスの様子です。

次に、ECSによるデータ指向なメモリレイアウト、メモリアクセスの様子です。

前者はメモリ上にデータがバラバラに点在しているためアクセスがあちこちに飛んでいるのが分かります。一方後者はデータがメモリ上に連続的に並んでおり、効率よくアクセスできていることが見て取れます。

CPUのアーキテクチャは通常、メインメモリアクセスが発生した場合、キャッシュラインという単位でまとめてデータを取得し、それをキャッシュに載せます。CPUのアーキテクチャでは「空間的局所性」と「時間的局所性」に基づいてこうしたキャッシュを利用しようとします。

ここで言う局所性とは、演算対象のデータの近く(空間)のものはすぐに使われる、一度アクセスしたデータは近く(時間)アクセスされる、ということをベースとして考えられています。つまり、ECSのデータ指向なメモリレイアウトはこの考え方に非常にマッチしている、というわけですね。メモリ上に連続してデータが並んでおり、かつ処理もそのデータ単位でまとめて行ってしまおう、というのがコンセプトなのですから。

前述のように、メモリアクセスは演算からすると数十倍もの開きがあります。これを最適化できれば、単純計算で処理が数十倍になる、というわけですね。(とはいえ、そこまで単純な話ではありませんが)

メモリレイアウトを深堀りする

さて、局所性に基づいてメモリレイアウトが決まるということを話しました。では実際、 Chunk はどういう形でデータを保持しているのでしょうか。

Unity公式動画の図を再掲します。

以下が公式の動画です。興味がある方はこちらも観てみてください。

www.youtube.com

上記の図をイメージしつつ、中身を見ていきましょう。

ChunkはArchetypeごとのデータとEntityの配列を持つ

前述したように、ECSでは Archetype と呼ばれる型によってチャンクが決まり、そのチャンクの中にコンポーネントが配列として保持されるということでした。

そしてそのChunk内にはコンポーネントだけではなく、Entityのリストも保持されるようです。以下のフォーラムでのやり取りから抜粋します。

forum.unity.com

Entities
All entities are stored in a single EntityData struct array. Entity.index is the index into this array and EntityData provides a direct address to its Components. Is an Entity struct also stored in the chunk so it can refer back to the entities array? This is what EntityArray is generated from?
As a user can store Entity, am I right in assuming that the items in the entities array never change position? If you add 1000 entities and remove the first 999, that last entity is still going to be at the 1000th index?

特に太字の部分ですね。これは、エンティティ構造体がChunk内に含まれるか、という問いです。そしてそのEntity構造体から、 EntityArray にアクセスできるか、という質問です。

この質問に対するUnityの中の人の回答は以下でした。

Yes. In fact we have essentially an Entity as the 0 component. This is what EntityArray is using internally.

Entity 自体は実際に 0 コンポーネントとして保持しているとのこと。よく考えてみればある意味で自明ですね。チャンク内にコンポーネントデータの配列があったとしても、そのコンポーネントがどのエンティティのものなのか分からなければ使いようがないですからね。

実装方法

以上で概念的なところは終了です。以下からは実際にどうやって実装していくかについて見ていきます。ただ、ここでは簡単に状況を整理するだけにとどめます。細かな実装方法や実際に活用する方法については別の記事にゆずります。あくまで概観することを目的にしています。

データを定義する

まず最初に説明するのはデータについてです。データ指向とも呼ばれているのでデータが中心に存在します。UnityのECSではデータは IComponentData インターフェースを実装することになっています。


ちなみにコンポーネントのコンセプトとして、ドキュメントにはこう記載されています。

Use the IComponentData interface, which has no methods, to mark a struct as a component type. This component type can only contain unmanaged data, and they can contain methods, but it's best practice for them to just be pure data. If you want to create a managed component, you define this as a class. For more information, refer to Managed components.

太字の部分を見てみると、インターフェースを利用する意味はただのマーカーとしてのようですね。


具体的には以下のように定義します。

public struct SampleTransformData : IComponentData
{
    public float3 Position;
    public quaternion Rotation;
    public float3 Scale;
}

IComponentData インターフェースを実装し、姿勢制御に必要な3つの要素を持つデータを定義している様子です。注目してもらいたいのは struct で定義している点です。基本的にECSでは struct、つまり構造体を使って構築していくのがベストプラクティスです。

理由は前述したメモリレイアウトの問題にあります。クラスを利用してしまうと、これはマネージドな領域にデータが確保されてしまうため、結果的にECSの最大のメリットであるメモリ効率性に影響が出てしまいます。

しかし構造体で定義した場合、その配列はメモリ上に連続的に配置され、ECSのコンセプトであるメモリアクセスの効率化につながるというわけです。

データはstruct / class両方で定義できる

ただ、現実問題として必ずすべてを構造体で、というのはむずかしい局面もあるでしょう。その場合にはクラスで定義することが可能となっています。これをマネージドコンポーネントと呼び、このコンポーネントではマネージドなデータ、つまりクラスの参照を持つことができます。

ドキュメントから引用すると以下のようになります。

public class ExampleManagedComponent : IComponentData
{
    public int Value;
}

ただし当然デメリットもあり、メモリレイアウトの問題もそうですが、こうしたデータは BurstCompilerコンパイルすることができなかったりするので使用は一部にとどめておくのがいいと思います。

システムを構築する

次に解説するのはECSの「S」、Systemについてです。(ECSの最初のEntityが最後なんかい、と思うかもしれませんが、後述しますがEntityはあまり書くことがないのでこの順番にしています)

ECSにおけるシステムは、一言で言い表すならば「データを処理する担当者」です。ECSの世界では(実際、 World というクラスが存在する)様々なシステムが多数存在し、それぞれが自身の役割をまっとうしていきます。

データは基本的に独立しており、それぞれが独自に処理されても問題ない、言い換えると並列化可能なものが多くあるということです。

もちろん、レンダリングに必要なデータの更新などは先にしてから描画を行うなどの「順番」はあります。しかし、とあるシステムの処理に介入してなにかをする、ということはありません。そのため各システムを独立して実装し、結果として整合性が取れていればいいということになります。

以下のキャプチャはECSで実行されているシステムのリストです。Unity から始まるのはUnityが用意しているシステムです。中央やや下あたりに赤線を引いた場所がありますが、これは自分で作成したシステムです。

このように、様々なシステムが駆動して処理を行っているのが分かるかと思います。

システムはクエリを利用してデータを取得して処理する

大まかにシステムの処理がどういうフローになるかを概観しておきます。

システムは毎フレーム実行されます。MonoBehavior のように毎フレーム OnUpdate が呼び出されます。ここに処理を書いていくことになります。

そしてこの処理の中で、「自分が必要とするデータをクエリして取得」し「取得したデータを加工する」というのが大まかな流れになります。

ざっくりしたコード例は以下です。

public void OnUpdate(ref SystemState state)
{
    float deltaTime = SystemAPI.Time.DeltaTime;

    foreach (var localTransform  in SystemAPI.Query<RefRW<LocalToWorld>>())
    {
        // do something
    }
}

システムの実装方法は2種類

コンポーネントのところでも書きましたが、マネージド・アンマネージドの2種類に応じて実装方法が異なります。

アンマネージドなシステム

アンマネージドなシステムの場合は、コンポーネントと同様に struct で定義します。簡単な例を示すと以下のような形です。

public partial struct TmpSystem : ISystem
{
    public void OnUpdate(ref SystemState state)
    {
        float deltaTime = SystemAPI.Time.DeltaTime;

        foreach (var localTransform  in SystemAPI.Query<RefRW<LocalToWorld>>())
        {
            // do something
        }
    }
}

注目ポイントとして partial struct で定義をしているところです。これは、Unityエンジン側がソースジェネレータ機能を用いて他の必要な部分を自動生成するために partial が必要になっています。そして ISystem を実装することで自動的にシステムとして認識され、起動されるようになります。

マネージドなシステム

続いてマネージドなシステムです。マネージドシステムは SystemBase を継承することで実現します。

ドキュメントから一部引用すると以下のような形になります。

public partial class ECSSystem : SystemBase
{
    protected override void OnUpdate()
    {
        // do something.
    }
}

マネージドなシステムも同様に partial として定義する必要があります。それ以外は基本的に ISystem のものと同じです。大きな違いとしてはマネージドなオブジェクトを持てること、そしてバーストコンパイラを利用できないことが挙げられます。

しかしながら、ECSを利用するモチベーションはそのパフォーマンスの高さにあるため、できるだけ ISystem で実装するのがいいでしょう。

ドキュメントにも以下のように記載されています。

In general, you should use ISystem over SystemBase to get better performance benefits.

一般に、高いパフォーマンスを得るためには SystemBase ではなく ISystem を利用すべきです。

実際の実装例

次回の記事で書く予定の内容から、実際に実装したシステムのコードを例として示します。以下は、TextMesh Proの文字をメッシュ化してたくさん表示する、というのをECSで実現した際のコードです。なんとなく、どういう流れで処理をするのか分かると思います。

using Unity.Burst;
using Unity.Entities;
using Unity.Mathematics;
using Unity.Transforms;

public partial struct TmpSystem : ISystem
{
    public void OnCreate(ref SystemState state)
    {
        state.RequireForUpdate<MeshInstanceData>();
        state.RequireForUpdate<LocalToWorld>();
    }
    
    [BurstCompile]
    public void OnUpdate(ref SystemState state)
    {
        float deltaTime = SystemAPI.Time.DeltaTime;
        double time = SystemAPI.Time.ElapsedTime;

        foreach (var (meshData, localTransform)  in SystemAPI.Query<RefRW<MeshInstanceData>, RefRW<LocalToWorld>>())
        {
            quaternion rotation = math.mul(meshData.ValueRW.Rotation, quaternion.RotateY(10f * deltaTime));
            float3 position = meshData.ValueRW.Position;
            position += new float3(math.sin(time) * 0.1);
            meshData.ValueRW.Position = position;
            meshData.ValueRW.Rotation = rotation;
            localTransform.ValueRW.Value = float4x4.TRS(meshData.ValueRW.Position, rotation, meshData.ValueRW.Scale);
        }
    }
}

OnCreateのタイミングで、どんなデータが必要かを通知できる

コードを見てみると OnCreate のタイミングで state.RequireForUpdate<T>() を実行しているのが分かります。これは、このシステムが要求するデータを示しています。そのため、現在のワールド内に該当のコンポーネントを持っているEntityがない場合は OnUpdate がスキップされます。

クエリで取得したデータを加工する

OnUpdate 内では SystemAPI を通してコンポーネントをクエリして取得し、取得されたコンポーネントに対して更新処理を行っている様子です。

このシステムの OnUpdate 内でまとめてデータを処理するために、メモリアクセス的に効率よく処理が行えている、というわけですね。さらにアンマネージドなコンポーネントを使っている場合は [BurstCompile] 属性を付与することでさらに高速に処理することができるようになります。

EntityはID

最後にECSの「E」であるEntityについてです。

Entity についてはあまり書くことがないと書いたのは、 Entity は実質ただの ID でしかないからです。

実際に実装内容を見てみると、実質的には IndexVersion しかありません。(実際には IComparable<T> などのインターフェースを実装しているためメソッドは定義されていますが、前述のふたつの値を比較するなどの目的なので本質的には無視して問題ない内容となります)

以下は実際のコードから変数部分だけを抜き出したものです。

public struct Entity : IEquatable<Entity>, IComparable<Entity>
{
    /// <summary>
    /// The ID of an entity.
    /// </summary>
    /// <value>The index into the internal list of entities.</value>
    /// <remarks>
    /// Entity indexes are recycled when an entity is destroyed. When an entity is destroyed, the
    /// EntityManager increments the version identifier. To represent the same entity, both the Index and the
    /// Version fields of the Entity object must match. If the Index is the same, but the Version is different,
    /// then the entity has been recycled.
    /// </remarks>
    public int Index;
    /// <summary>
    /// The generational version of the entity.
    /// </summary>
    /// <remarks>The Version number can, theoretically, overflow and wrap around within the lifetime of an
    /// application. For this reason, you cannot assume that an Entity instance with a larger Version is a more
    /// recent incarnation of the entity than one with a smaller Version (and the same Index).</remarks>
    /// <value>Used to determine whether this Entity object still identifies an existing entity.</value>
    public int Version;
}

Index が実質的にIDになっており、このIDを利用してコンポーネントのデータを取得したり、ということが内部的に行われているわけです。

言い換えると、 MonoBehaviourGameObject のように、オブジェクト自身がデータを持っているわけではない、ということさえ理解しておけば大丈夫です。

まとめ

ECSがなぜ高速に動くのか、その理由が分かったかと思います。

大事な点を再掲すると、

  1. メモリ効率が大事
  2. メモリレイアウトを工夫することで最適化
  3. データ単位で処理を行う「データ指向」アーキテクチャ

となります。

ざっくり言ってしまえば、効率的に管理できるようにデータをまとめて用意し、さらに効率的に処理できるようにシステムがまとめてデータを加工する、という流れを実現しているのがECSということができるでしょう。

さらに、本来は色々な制約があって実現するのがむずかしいBurstコンパイラ向けの設定が、仕組みに沿って実装するだけで簡単に実現できるというのも大きなポイントでしょう。

大量にオブジェクトを処理する必要があるようなプロジェクトの場合はぜひ導入を検討してみてください。