> For the complete documentation index, see [llms.txt](https://morningheartgames.gitbook.io/docs/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://morningheartgames.gitbook.io/docs/gameplay-ability-toolkit-ecs/reference/performance.md).

# Performance

The ECS runtime is built to carry very large numbers of actors. It skips actors that nothing changed, runs its heavy passes as Burst jobs across worker threads, and serves requests in batches. This page gives the measured results, shows how to measure your own game, and lists the habits that keep a game fast.

You can read it without any setup. To run the measurements you need the add-on with Entities installed, and either the included ECS Swarm demo or a scene of your own that runs the runtime ([Getting started](/docs/gameplay-ability-toolkit-ecs/start/getting-started.md)).

## Controlled rendered-combat results

These are rendered-throughput results for one workload, the ECS Swarm demo drawn at 1920 x 1080. They are not on-screen frame-rate guarantees, and they do not predict your game, whose frame time depends on its own systems, art and hardware.

| Enemy target | Moving FPS | Combat FPS | Combat mean frame | Combat P95 frame |
| ------------ | ---------- | ---------- | ----------------- | ---------------- |
| 2,000        | 581.9      | 322.4      | 3.10 ms           | 4.13 ms          |
| 10,000       | 394.4      | 328.3      | 3.05 ms           | 4.23 ms          |
| 50,000       | 182.8      | 212.1      | 4.71 ms           | 6.56 ms          |
| 100,000      | 84.6       | 114.6      | 8.73 ms           | 10.95 ms         |
| 200,000      | 54.1       | 67.0       | 14.91 ms          | 16.66 ms         |
| 1,000,000    | 24.9       | 23.1       | 43.22 ms          | 46.97 ms         |

**Test conditions**

|          |                                                                                                                                                        |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Build    | Windows release player, Unity 6000.6.2f1                                                                                                               |
| Hardware | i9-14900K and RTX 5090                                                                                                                                 |
| Graphics | Direct3D 11, PC quality level                                                                                                                          |
| Output   | 1920 x 1080, uncapped, VSync off                                                                                                                       |
| Runs     | Median of three fresh player runs                                                                                                                      |
| Samples  | Each window has at least 400 frames and eight seconds of samples, after an initial warm-up (five seconds) and a warm-up for each phase (three seconds) |

**What each column means**

* **Moving**: the enemies move and no skills are cast.
* **Combat**: a script casts repeated meteors, cataclysm, held flame, fireball barrage, chain lightning and gravity. Dead enemies respawn to the selected population. Combat changes how the crowd is spread out and how much work the runtime does, so it can run faster than the moving phase.
* **FPS** is total sampled frames divided by total sampled frame time. **P95 frame** is the frame time that 95 percent of the sampled frames stay under.

**What is measured**

* Every sampled frame explicitly renders the camera and waits for the GPU to finish, using a one-pixel readback. This is the offscreen mode, and the cost of both the render and the wait is included.
* Included: the animated crowd, enemy separation, shadows, post-processing, effects and damage numbers. The HUD still updates on the CPU.
* Excluded: drawing to the desktop and the HUD's pixels. Screenshots and file writes happen outside the measured windows. GPU instance counts and actual entity counts are recorded after the timing.
* Up to 200,000 enemies the camera is the default gameplay camera. At 1,000,000 the camera is a full-population aerial overview, with the normal distance-based crowd LOD on. A close-up and a view of the whole crowd are different rendering workloads, so do not read the 1,000,000 row as close-up play.

## Runtime cost without rendering

The numbers above include rendering and the demo's own code. This section times only the runtime's passes, so you can estimate what it adds to a frame for a given population. It is a separate CPU test, not a game frame rate.

**Test conditions**

* i9-14900K, Unity 6000.6.2f1, in the Editor in batch mode, with synchronous Burst compilation and Burst safety checks on. Safety checks add cost to jobs and a player build does not have them.
* Every actor has one attribute and one granted ability. The simulation clock advances at 60 Hz.
* Actors "with active effects" are spread evenly through the population. Each carries two duration effects: a buff with modifiers and a granted tag, and a periodic effect that adds 1 to the attribute at 20 Hz. All periods start together, so the periodic work lands on the same updates. That is why the P95 column is far above the median in the last rows.
* Each case runs 20 warm-up updates and 120 measured updates in each of three fresh worlds, in one Editor process. The median is the median of the three run medians. The P95 column shows the range of the three run P95 values.

| Actual actors | Actors with active effects | Work per update                                    | Median ms |  P95 range ms |
| ------------: | -------------------------: | -------------------------------------------------- | --------: | ------------: |
|        10,000 |                          0 | No active effects                                  |     0.010 |   0.011-0.011 |
|        10,000 |                        100 | Two effects per active actor; periodic +1 at 20 Hz |     0.030 |   0.100-0.166 |
|       100,000 |                          0 | No active effects                                  |     0.042 |   0.048-0.052 |
|       100,000 |                      1,000 | Two effects per active actor; periodic +1 at 20 Hz |     0.133 |   0.211-0.238 |
|     1,000,000 |                          0 | No active effects                                  |     0.533 |   0.579-0.861 |
|     1,000,000 |                     10,000 | Two effects per active actor; periodic +1 at 20 Hz |     1.386 |   1.958-2.084 |
|     1,000,000 |                    100,000 | Two effects per active actor; periodic +1 at 20 Hz |     2.617 |   8.777-9.394 |
|     1,000,000 |                  1,000,000 | Two effects per active actor; periodic +1 at 20 Hz |     5.972 | 23.001-24.587 |
|     1,000,000 |                          0 | 10,000 simple activations                          |     4.648 |   4.906-5.354 |
|     1,000,000 |                          0 | 10,000 instant +1 effects                          |     4.917 |   5.407-5.457 |

**What is measured**

* Measured: the runtime's ten core systems, with their jobs completed: time, cooldown, end, activation, commit, instant effects, effect tick, attribute recalculation, waits and triggers.
* Not measured: spawning, clock writes, building the requests, consuming the output, movement, rendering, delivery queries and networking.
* The request rows use a simple ability with no cost, cooldown or granted effect, and an instant effect with one modifier. They show the cost of serving requests, not the cost of a full combat ability.
* Memory: at one million actors the actor chunks reserve 781.27 MiB, about 820 bytes per actor. That counts the 16 KiB actor chunks only. Buffers that grow outside the chunk, the catalog, the queues and the rest of the world are extra.
* The test does not render a million characters or replicate a million players.

### Delivery and montages

Delivery abilities (areas, arcs, nearest targets and projectiles; see [Abilities that reach other actors](/docs/gameplay-ability-toolkit-ecs/build-with-it/delivery.md)) and montages add work on top of the passes above. These rows time them separately.

| Work per update                                                 | Median ms | P95 range ms |
| --------------------------------------------------------------- | --------: | -----------: |
| 100,000 actors; 1,024 moving projectiles; 3,072 fresh hits      |     0.378 |  0.425-0.457 |
| 100,000 actors; 256 filtered nearest searches; retain four each |     0.757 |  0.822-0.927 |
| Montage clock and timeline; 1,000 actors, two slots each        |     0.092 |        0.115 |

* The delivery rows use 10 warm-up updates and 60 measured updates in each of three repetitions. They include building the position snapshot, the finished query jobs, the ordered hit output and the removal of consumed projectiles. They exclude applying the effects, rendering and networking.
* The actors do not move in this test, so the position snapshot is reused. If actors move or are created and destroyed between updates, the snapshot is rebuilt and the cost is higher. See [Delivery searches](#delivery-searches).
* The montage row uses 20 warm-up and 40 measured updates. It excludes posing the characters and rendering.

## Measure your own game

Judge speed in a player build. In the Editor, Burst safety checks and Editor overhead make jobs slower.

### See each system's time

**Window > Entities > Systems** shows the time of every system, including the runtime's. The runtime's systems and their order are listed in [How it works](/docs/gameplay-ability-toolkit-ecs/build-with-it/concepts.md#the-frame).

### Read the runtime's counters

The runtime counts what it did on its last update, but only when the world has an entity with `GasSimulationStats`. Create one when your world starts, then read it after the runtime has run:

```csharp
// Once: turns the counters on.
entityManager.AddComponentData(entityManager.CreateEntity(), new GasSimulationStats());

// Any later frame: the counters of the last update.
if (GasEntityFactory.TryGetSimulationStats(entityManager, out var stats))
{
    // stats.ActorsConsidered, stats.ActorsRecalculated, ...
}
```

| Counter                                      | Counts                                                                                                                            |
| -------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
| `ActorsConsidered`                           | Actors the attribute pass looked at. On a frame where nothing changed it should be close to 0.                                    |
| `ActorsRecalculated`, `ActorsSkipped`        | Of those, the actors whose values were recomputed, and the ones that turned out unchanged.                                        |
| `AttributesRecalculated`, `ModifiersScanned` | The work inside the recalculation.                                                                                                |
| `EffectInstancesScanned`                     | Live effect instances walked while ticking. This grows with the number of active effects.                                         |
| `EffectPeriodicExecutions`                   | Periodic effects that ran.                                                                                                        |
| `EffectGatesEvaluated`, `EffectGatesSkipped` | Actors whose ongoing and inhibition requirements were checked, and actors skipped because their tags and effects had not changed. |
| `WaitsEvaluated`, `WaitsCompleted`           | Waits checked, and waits that completed and did their action.                                                                     |

In Play mode, **Tools > Gameplay Ability Toolkit > Dashboard > ECS Runtime** shows the same counters, and the depth of every queue (from `GasRuntimeSnapshot.Capture`), once the world has the entity.

### Run the scale benchmark in the Editor

**Tools > Gameplay Ability Toolkit > ECS > Run scale benchmark** runs the add-on's Editor benchmark. It builds worlds of 100 to 10,000 actors in three situations (idle, mixed, where one actor in ten changes each frame, and churning, where every actor changes every frame) and times each system, then a series of smaller experiments. A full run can take around 15 minutes, and the Editor is busy while it runs. When it finishes, the report appears in the Console and is saved as `TestResults/ecs-scale-benchmark.txt` in your project folder.

The benchmark runs inside the Editor with whatever Burst settings the Editor has, and it uses its own fixtures, so its numbers do not match the tables above. Use it to compare one machine or one version of the add-on with another.

### Run the Swarm benchmark in a player

The ECS Swarm demo includes `SwarmBenchmark`, which produced the rendered results at the top of this page. It runs on your machine, so you can compare it with the table.

1. Open the add-on's `Demo/EcsSwarm/Scenes/EcsSwarm.unity`, add it to the build and build a **Windows release player** (not a Development build).
2. Close other programs that use the GPU or CPU.
3. Start the player from a command line:

   ```
   EcsSwarm.exe -screen-width 1920 -screen-height 1080 -screen-fullscreen 0 -swarmBenchmark -swarmBenchmarkOut run-1.txt
   ```
4. Wait. The benchmark measures each enemy count with moving enemies and then in combat, and the player quits when it finishes.
5. Repeat in three fresh player processes and take the median of each number.

| Option                             | Does                                                                                                                                                                                                                  |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `-swarmBenchmark`                  | Starts the benchmark. Nothing runs without it.                                                                                                                                                                        |
| `-swarmBenchmarkOut <file>`        | Where to write the report. The default is `SwarmBenchmark.txt` in the player's persistent data path.                                                                                                                  |
| `-swarmBenchmarkCounts 2000,10000` | Measures only these enemy counts, from 1 to 1,000,000. The default is 2,000, 10,000, 50,000, 100,000, 200,000 and 1,000,000.                                                                                          |
| `-swarmBenchmarkOffscreen`         | Renders the gameplay camera at 1920 x 1080 and waits for the GPU every frame, as in the table above. Use it for a hidden or background window. Without it, the benchmark measures the visible window at its own size. |
| `-swarmBenchmarkShots <folder>`    | Saves a screenshot after each measurement.                                                                                                                                                                            |

Next to the report, the benchmark writes a `.json` file (frame time mean, median and P95, average FPS, runtime time and demo time for each step, with the CPU, GPU, graphics API and quality level) and a `.csv` file with every sampled frame. With `-swarmBenchmarkOffscreen` it also saves a `.png` of the last frame. When you compare results, note the CPU, GPU, resolution, quality level and graphics API.

## Habits that keep it fast

### Keep Burst on

The runtime's heavy passes are Burst-compiled jobs. Keep **Jobs > Burst > Enable Compilation** on while you work in the Editor. Without Burst the same code runs as managed C#, which is much slower. A pass over fewer than 256 actors runs on the main thread, where scheduling a job would cost more than the work.

### Read the runtime's buffers read-only

The runtime recalculates attributes, re-checks effect requirements and visits waits only for the chunks that changed since it last looked. Unity marks a chunk as changed whenever a system takes a component or buffer **read-write**, even if the system writes nothing. A system that takes `GasAttributeValue`, `GasTagCount` or `GasAbilitySlot` read-write sends the runtime back to look at every actor in the chunks it touches, every frame.

* In a system: `SystemAPI.GetBufferLookup<GasAttributeValue>(true)`, not `SystemAPI.GetBuffer<GasAttributeValue>`.
* In a `SystemAPI.Query`: a `DynamicBuffer<T>` is taken read-write. Read the buffer through a read-only `BufferLookup<T>` instead, and take components as `RefRO<T>`, not `RefRW<T>`.
* In an `IJobEntity`: `in DynamicBuffer<T>`, not `ref`.
* From a `MonoBehaviour`: `EntityManager.GetBuffer<T>(entity, isReadOnly: true)`.

To check, create the `GasSimulationStats` entity (above). If `ActorsConsidered` stays close to your actor count on a frame where nothing happened, something is marking chunks as changed. Switch your systems off one at a time to find the one.

### Avoid live captures from another actor

An attribute-based modifier can capture an attribute from the source actor of an effect. Three things in its Inspector decide what that costs: **Capture From** (Source or Target), **Snapshot** and **Calculation Type**.

* Capture from the **Target**, or capture from the Source as a **Snapshot** of the **Attribute Base Value**: the runtime keeps checking only the chunks that changed.
* Capture from the Source with **Snapshot** off, or with any **Calculation Type** other than **Attribute Base Value**: if the catalog contains even one such modifier, the runtime checks every actor with attributes on every frame instead of only the changed chunks.
* Capture the Source's current or bonus value rather than its base value (**Attribute Magnitude** or **Attribute Bonus Magnitude**): the attribute pass also runs on a single thread instead of across workers.
* Any modifier that captures from the Source, even as a snapshot of the base value, or that has **Source Tags**, makes the effect pass (expiry and periodic effects) run on a single thread too.

This matters for large populations. With a few hundred actors you will not notice it.

### Look names up once

`FindAbilityIndex`, `FindEffectIndex`, `FindTagIndex` and `FindAttributeIndex` walk a table. Call them when a system starts and keep the index, or keep all of a game's indices in one component, as the ECS Swarm demo's `SwarmIndices` does.

### Ask in batches, and do not reshape actors every frame

A request is cheap. In the table above, 10,000 simple activations or 10,000 instant effects add about 4 ms to a pass over a million actors, which is under half a microsecond each. What is expensive is changing an actor's archetype: adding or removing components, or instantiating and destroying actors one at a time. Spawn and destroy in batches (`EntityManager.Instantiate` with a count, `DestroyEntity` with an array or a query), and reuse actors where you can. The demo sends a dead enemy back to the edge of the arena and restores it with an effect instead of destroying it.

### Keep the catalog to what the game uses

Every actor holds one value for every attribute in the catalog, even from attribute sets it does not use. Up to 16 values fit inside the chunk. More still work, but the values move to separate memory, which is slower to walk and spreads the actors out.

**Auto Collect Definitions** puts every attribute set, effect and ability in the project into the catalog, including those of the toolkit's demos and tutorials. That is convenient while you learn. For a game, delete the sample folders you do not need, or turn auto collection off and list what the game uses.

### Waits, tags and effects

* The wait pass visits an actor only when it has an armed wait or a running ability that declares waits, or when its ability slots or applied-effect records changed. Declaring waits on abilities that most actors rarely use is cheap.
* Tag checks walk an actor's tag list, so cost grows with the number of tags an actor owns. Measure before giving actors hundreds of tags.
* Every live effect instance is visited on each update, so the cost of effects grows with how many are active at once.
* Periodic effects that start together run together. In the test above, a million actors with a periodic effect that started on the same frame had a P95 of 23 to 25 ms against a median of 6 ms. If you apply thousands of periodic effects at once, spread the applications over several frames.

### Delivery searches

* Searches work from a snapshot of the actors' positions. In a world of 20,000 or more actors the snapshot is reused until an actor moves, or one is created or destroyed. In a crowd that moves every frame, expect a rebuild every frame. Smaller worlds rebuild it for each batch of searches.
* Set **Max Targets** on the ability when you only need a few targets. A limited search keeps only the nearest candidates and does not sort the whole crowd.
* A search with a very large radius falls back to scanning every actor.
* If your game already keeps a grid or uses physics queries, take over the search and feed its hits to the same delivery pipeline: see [Using your own queries](/docs/gameplay-ability-toolkit-ecs/build-with-it/delivery.md#using-your-own-queries).

### With Netcode for Entities

A predicting client simulates some ticks more than once, after a rollback, so the runtime can cost a client more than the same population costs in a single-player game. Measure on a client, not only on the server.

## Next steps

* [ECS Swarm demo](/docs/gameplay-ability-toolkit-ecs/start/swarm-demo.md): play the demo and see what each part of its frame costs.
* [How it works](/docs/gameplay-ability-toolkit-ecs/build-with-it/concepts.md): the catalog, requests and the frame order behind these habits.
* [Abilities that reach other actors](/docs/gameplay-ability-toolkit-ecs/build-with-it/delivery.md): the delivery module, and how to replace its searches.
* [Troubleshooting](/docs/gameplay-ability-toolkit-ecs/reference/troubleshooting.md): if a frame is slower than it should be.
