Skip to content

Performance Features

genar edited this page Dec 5, 2022 · 20 revisions

"Its not fast enough, i wanna simulate millions of entities, Arch sucks..." - No it does not, we got you ! Arch provides several features for especially those cases.

Bulk adding

Arch supports bulk adding of entities, this is incredible fast since it allows us to allocate enough space for a certain set of entities in one go. This reservation happens on top of the already existing entities in an archetype. You only need to reserve space once and than it will be filled later or sooner.

var archetype = new []{ typeof(Position), typeof(Velocity) };

// Create 1k entities
for(var index = 0; index < 1000; index++)
    world.Create(archetype)

world.Reserve(archetype, 1000000);              // Reserves space for additional 1mil entities
for(var index = 0; index < 1000000; index++)    // Create additional 1 mil entities
    world.Create(archetype)

// In total there now 1mil and 1k entities in that certain archetype. 

Batched operations

For complex games its actually pretty common to work on entities directly, especially when there relations between entities. Normally this is often a bottleneck, however we recently implemented generic overloads for this kind of task to improve the performance. This is valid for Entity, World, Archetype and Chunk.

// Entity overloads
entity.Set<T0...T9>();
entity.Get<T0...T9>();
entity.Has<T0...T9>();
entity.Add<T0...T9>();
entity.Remove<T0...T9>();

// World overloads
world.Create<T0...T9>(...);
world.Set<T0...T9>(in entity);
world.Get<T0...T9>(in entity);
world.Has<T0...T9>(in entity);
world.Add<T0...T9>(in entity);
world.Remove<T0...T9>(in entity);

// Archetype overloads
archetype.Set<T0...T9>(in entity);
archetype.Get<T0...T9>(in entity);
archetype.Has<T0...T9>();

// Chunk overloads
chunk.Set<T0...T9>(in entity);
chunk.Get<T0...T9>(in entity);
chunk.Has<T0...T9>();

So it will dramatically increase your games performance when you rewrite

var entity = world.Create(archetype);
entity.Set(new Transform());
entity.Set(new Movement());
ref var t = ref entity.Get<Transform>();
ref var m = ref entity.Get<Movement>();

to this

var entity = world.Create(new Transform(), new Movement());
var refs = entity.Get<Transform, Movement>();

Structural entity changes should not happen during a Query or Iteration ! #17 will introduce this soon.

Highperformance Queries

The default Query API is easy to use and still very fast, perfect for fast prototyping and the most features of your game. However sometimes you need even more power, thats where the highperformance queries kick in.

world.HPQuery<Struct,T0,T1...>(in queryDescription, ref myStruct);
world.HPEQuery<Struct,T0,T1...>(in queryDescription, ref myEStruct);

Those highperformance queries use an interfacee and its struct implementation. This allows the compiler to inline the method call which results in less adress jumping and even faster iteration speed. Therefore you need to know two important interfaces and how to implement them.

public interface IForEach<T0...T10>{
    void Update(ref T0 t0, ref T1 t1, ... ref T10 t10);
}

public interface IForEachEntity<T0...T10>{
    void Update(in Entity entity, ref T0 t0, ref T1 t1, ... ref T10 t10);
}

Those two interfaces provide Update methods with various generic overloads which can be used to implement the entity operations. All you need to do is implementing the interface in a struct and passing that struct to the highperformance query api.

public struct VelocityUpdate : IForEach<Position, Velocity> {

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Update(ref Position pos, ref Velocity vel) { 
        pos.x += vel.x;
        pos.y += vel.y;
    }
}

world.HPQuery<VelocityUpdate, Position, Velocity>(in queryDescription);


// Also possible with a struct reference
public struct VelocityUpdate : IForEach<Position, Velocity> {

    public int counter;

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Update(ref Position pos, ref Velocity vel) { 
        pos.x += vel.x;
        pos.y += vel.y;
        counter++;
    }
}

var velUpdate = new VelocityUpdate();
world.HPQuery<VelocityUpdate, Position, Velocity>(in queryDescription, ref velUpdate);
Console.WriteLine(velUpdate.counter);

Thats all, pretty cool right ? However in some cases you may also need a direct reference to the entity itself. In this case theres the IForEachEntity interface which is required.

public struct VelocityUpdate : IForEachEntity<Position, Velocity> {

    [MethodImpl(MethodImplOptions.AggressiveInlining)]
    public void Update(in Entity entity, ref Position pos, ref Velocity vel) { 
        pos.x += vel.x;
        pos.y += vel.y;
        Console.WriteLine(entity);
    }
}

world.HPEQuery<VelocityUpdate, Position, Velocity>(in queryDescription);  // <- Requires HPEQuery instead of HPQuery

Multithreading

Still not fast enough ? You really want to simulate those million entities, dont you ? Well... i got your back !
Arch uses a selfwritten and alloc free JobScheduler under the hood to dispatch your query logic to a bunch of worker threads.

Before you can use those features you need to create an instance of the JobScheduler, this creates an singleton which the World will use.

var jobScheduler = new JobScheduler.JobScheduler("WorkerThread", threadAmountOptional);  // Is required to be initialized somewhere in your project
jobScheduler.Dispose();  // Once you exit your game/app

The easiest ways to make use of that multithreading are the Parallel Query overloads. The syntax is exactly the same as the normal Query methods you already know.

world.ParallelQuery(in query, ...);
world.HPParallelQuery(in query, ...);
world.HPEParallelQuery(in query, ...);

Therefore you can easily just rewrite your queries like this.

world.Query(in query, (ref Transform t, ref Velocity v) => {
   t.x += v.x;
   t.y += v.y;
});

to

world.ParallelQuery(in query, (ref Transform t, ref Velocity v) => {
   t.x += v.x;
   t.y += v.y;
});

And thats all ! The rest is handled under the hood, however those calls are blocking the mainthread. A parallel query is being scheduled to a bunch of worker-threads and the call waits for all scheduled jobs to finish before proceeding with the next query. Since the jobs are processed by multiple worker-threads simultaneous its incredible fast.

An alternative to those highlevel queries is the IChunkJob interface which can be passed to an specific overload. This inlined interface is then called by each worker thread for the processed chunks and you can define the logic yourself.

public struct VelocityUpdate : IChunkJob{

   public void Execute(int index, ref Chunk chunk) {
      
      var size = chunk.Size;
      var transforms = chunk.GetArray<Transform>();
      var velocities = velocity.GetArray<Velocity>();

      for(var index = 0; index < size; index++){

         var transform = transforms[index];
         ...
      }
   }
}

world.ParallelQuery(in query, new VelocityUpdate());

In multithreaded environment you should NEVER modify the world, archetype or chunk structure. You should not add or remove entities... however its totally fine to update entities.

Performance tipps

Lets talk about some tipps for getting the most out of this ECS framework. Follow these for hotpaths and performance critical code, otherwhise you are free to do whatever you want.

Entity Size

Keep your entities as small as possible, their components should only hold the bare minimum. Remove unecesarry fields and try to outsource common values. The smaller your entity is in terms of byte size, the faster your systems will run.

// Game stats for an RPG
public struct Stats { public float minHealth, maxHealth, physicalDamage, physicalDefence, luck, critical, ... }
entity.Set<Stats>(new Stats(...));

Are you sure that this is really necessary ? In the most cases its not, each e.g. Orc will mostly have the same stats. Try to store a reference to it instead. This also great to reduce your memory usage in general, its called the Flyweight Pattern.

public struct Stats { public float minHealth, maxHealth, physicalDamage, physicalDefence, luck, critical, ... }
public struct StatReference{ public int index; };

public Stats[] StatsArray = new Stats[]{ ... };

orcEntity.Set<StatReference>(new StatReference{ index = 10 });  // Entity references the stats located in the 10th index of the Stats array.
otherOrcEntity.Set<StatReference>(new StatReference{ index = 10 });  // Entity references the stats located in the 10th index of the Stats array.

// In your system you need to acess that array based on your needs

Look, its not that hard, right ? By storing huge structs somewhere else, your entities are becoming smaller... and if they are smaller, more fit into each chunk and less memory is being loaded into the cache. This combination makes the queries much faster.

Divide and Rule

Seperate and divide your entity components based on what you query. This will improve your query performance by a lot.
Lets look at one example.

public struct Transform{ public float x,y,z, rotX,rotY,rotZ };  // Stores position AND rotation

world.query(..., (ref Transform tranform) => {
   // Update position
};


world.query(..., (ref Transform tranform) => {
   // Update rotation
};

What do you notice ? Exactly... you iterate over all Transforms to update the position... and later you iterate over all transforms to update the rotation. This is a no go, why do you iterate over Transform if you really just need to acess the Rotation from it ?

This will slow down your query since the CPU needs to load more data into the cache, unused data. Since when you iterate over Transform to update its Rotation, you also load the Position with it... which in this case in not necessary and slow.

Instead you should always divide components based on your specialised needs.

public struct Position{ public float x,y,z };  // Stores position
public struct Rotation{ public float rotX, rotY, rotZ } // Stores rotation

world.query(..., (ref Position pos) => {
   // Update position
};


world.query(..., (ref Rotation rot) => {
   // Update rotation
};

This will only load and process what you really need, thus it becomes way faster. So always remember, divide your components and only query what you really need at that particular moment.

Clone this wiki locally