Skip to main content
3 of 4
Adding illustrative tweet
DMGregory
  • 136.4k
  • 22
  • 248
  • 374

Performance will always depend on implementation. It's definitely possible to make well-performing non-ECS games, or poorly-performing ECS games, but there are certain performance tricks that ECS lends itself well to: particularly data-oriented design (DOD).

To have something to contrast against, let's imagine another component-based game written in a more idiomatic Object-Oriented Programming style (arguably naïve, but it helps for the sake of example):

  • Class GameObject contains a list of component instances attached to it.
    • Component is an abstract class (or an IComponent interface) with a virtual Update() method.
    • We implement different derived classes descending from Component (or implementing IComponent) for each different behaviour we want.
  • A scene contains a list of active GameObject instances.
    • Each frame, we iterate over each object in the scene, and each component on that object, calling Component.Update() which has been overridden for each behaviour we need.

In contrast to this, we'll implement our DOD-focused ECS version so that components are struct types, and we'll store them in contiguous arrays of the same type. Game objects with particular mixes of components will be just an index within a set of parallel component arrays commonly called an archetype. Systems will implement update functions that iterate over all archetypes with a specific component signature.

So we've added quite a bit of complexity and given up some flexibility. Now adding/removing a component from a game object isn't just a matter of calling List.Add()/RemoveAt(), instead we need to extract it from one archetype's buffers and append it to another, possibly creating a new bespoke archetype for this combination of components - certainly messy!

But here's what we can gain from this:

  • Good data locality: each cache line you pull in contains not just the component you want to act on now, but also the next component in the buffer, so it's already waiting for us when we want it. The linear access pattern allows the CPU to predict and pre-fetch the next cache line we'll want, letting us make full use of the available CPU and minimizing waiting on the slow memory bus.

    This also works if a single system needs to access/modify multiple component types - using a Structure of Arrays layout, we can race down multiple of these packed buffers in parallel.

    In contrast, our OOP example has strewn the GameObject and Component instances more or less randomly around the heap - taking whatever slot was available each time we called new Foo(). When we want to know what component to access next, we have to look up a pointer from the list, check that it's valid, then try to read the memory at that location. That memory is very likely not already in cache, so we incur a cache miss - burning hundreds of CPU cycles just waiting for the component data to be fetched from main memory - that's time when our CPU could have been doing useful work!

  • Good code locality: because we have one system executing again and again on a big batch of data, the code we need to run is also continually in cache. We might even be able to write our update functions so they update batches of components at the same time - or the processor might be able to rearrange our code on the fly to interleave multiple loop iterations, so the fast parts get done while we're still waiting on the slow parts (it's scary what these chips can do these days!)

    But for our naïve OOP example, we don't know which component class's Update() we want to call until we call it. That means chasing a pointer to the component instance, chasing a pointer to its VTable, and chasing a pointer to the function code to run it just once. Then for the next component we come to, we have to do it all again, since it's very likely a different component type than the previous one. Since we haven't used this new component's code lately, it's been evicted from the instruction cache and we need to burn hundreds of cycles again waiting to read it back in.

In practice, this data-oriented approach can yield large performance wins. I routinely hear from Unity developers who have observed 10x speedups when migrating code from conventional MonoBehaviour scripts (which act similar to the naïve OOP example) into Unity's Job System (which acts similar to the DOD ECS example).

Granted, that's not entirely a fair comparison because Unity's Burst compiler and multithreading give extra advantages to the Job version. But it's also true that it's the clear structure and dependency relationships of the ECS model that allow them to implement those optimizations.

What about public and private?

It's true that a system needs some means to change the internal state of the components, and the simplest way to do that is to make components Plain Old Data Structures with all members public. ECS purists may say this is the only way and that you should never have any code in a component.

But you don't get points for how "pure" your ECS is - what matters is how much you like working with it.

So you could implement private members in a component type, and setter or mutating methods on it that help it maintain some invariants without that responsibility leaking into the corresponding systems (or worse, needing to be shared or duplicated across multiple systems). If these methods are small, they might well get inlined into the system update methods anyway.

The "no code" guideline is mainly to help you step out of the "one update at a time" mentality that member methods encourage, and start to see opportunities for "batch update" approaches when you think of components as a stream of data. But we can bend that rule when it helps us develop more flexibly, avoid bugs, etc.


So... does all this mean we should always implement ECS, and never use convenient OOP features like inheritance, interfaces, etc?

Heck no!

  • The advantages described above assume you have big lists of components - enough to outweigh any overhead you're paying to get them into and manage those lists in the first place. In a game that has just a handful of objects of each type, you're not going to see big wins. Such a game will probably run just fine in the classic OOP style.

    Where ECS shines is in games with huge numbers of similar objects. Think physics simulations with hundreds of dynamic bodies, crowd sims with hundreds of agents, endless procedural worlds that need to generate content over the horizon as fast as you can race towards it. If you're not making the kind of game that needs to cram all that in, you can prioritize ease of development instead. Because...

  • DOD ECS is kind of a pain. There's a lot of up-front complexity to set up, and conceptually simple things like adding/removing/disabling/re-enabling a component suddenly require non-trivial engineering. References between objects are also tricky to manage in this scheme.

    All this makes iterating on a game in development much more complicated, and you might need larger refactoring to implement a design change, compared to how easily you can swap out and experiment with new component behaviours in OOP. So if you're not sure if you need ECS speed, or your game concept is still in flux, it may pay to prioritize development speed over processing speed - at least early in the project. Then once you have the core gameplay needs solidified, you can evaluate whether your performance is adequate, and whether a migration to ECS would be worthwhile.

  • We can take half-steps toward fixing the worst time sinks, without completely abandoning the OOP convenience. Re-using objects from a pool initialized at start-up helps improve data locality. Iterating your components in order by type rather than by what object they're attached to helps improve code locality. Using concrete types when practical saves the overhead of virtual method calls. You can probably think of dozens of other ways the simplistic OOP approach above could be improved, without paying the full engineering cost of the ECS style.

A good tweet I just saw helps put this in perspective:

Juan Linietsky
@reduzio

This is how I see abstraction/optimization balance in sw design:

Code executed less often is the large majority of the codebase and can benefit from more abstraction. Code executed more often needs better algorithms, and critical code (small minority) needs to be data oriented.

Graph comparing execution frequency vs performance

DMGregory
  • 136.4k
  • 22
  • 248
  • 374