4
\$\begingroup\$

I've done some research on Entity Component System architectures. Right now I have a convoluted OOP implementation which I'm planning on rewriting, so I was considering switching to a different architecture. Since everything was already composed using components, the switch from OOP to ECS shouldn't be too difficult. However, I'm doing my project in a language with managed memory and GC, and a lot of the benefits of ECS are to do with memory management, processor cache, etc. so I was wondering whether there is any real performance advantage of ECS over OOP in a garbage collected language?

Also, I'm not too familiar with manual memory management and I haven't really used C/C++ for anything big.

\$\endgroup\$
2
  • \$\begingroup\$ Unity pulled it off quite well with their ECS framework in DOTS. But it owes most of its performance benefits to compiling performance-sensitive code with a special compiler with a very reduced feature-set. One feature the burst compiler lacks is garbage collection. \$\endgroup\$
    – Philipp
    Apr 14, 2021 at 8:20
  • \$\begingroup\$ @Philipp That combined with language features that allow constructing a reasonable subset of the language that doesn't invoke the GC. Theres no way you could make DOTS work on the JVM. \$\endgroup\$ Apr 14, 2021 at 16:10

3 Answers 3

6
\$\begingroup\$

The Garbage Collector (GC) is not really an obstacle to implement an Entity-Component-System (ECS) architecture.

All you need is a root object for your ECS. It would hold references to the containers you use for your components (and references to your systems, if that makes sense in your implementation). Those containers will likely hold arrays of components, which is how you have the components contiguous in memory. Which is important for for cache optimization (and yes, that remains important for performance regardless of GC).

As long as you have a reference to the root, the GC will not collect any of it.

In fact, there isn't a lot of manual memory management in ECS. Usually, you will not store your components by de/allocating in the heap (because then you lose control over memory layout), instead you will be storing components as elements of arrays. Then the memory management is about allocating the arrays, for which you would use a an Object Pool pattern.


The obstacles an object oriented language could pose to implement an ECS are others. For instance, if the language does not allow you create custom value types (e.g. C# struct), it means that your arrays would only hold references to the actual components. And those components would be stored elsewhere. Thus, you lose that cache optimization you want.

I also want to mention that depending on the language, you may need to pay attention on whether or not arrays guarantee that values are contiguous in memory. Some runtimes would offer sparse arrays or other structures by default.


An object oriented approach would suggest that an entity is a container of components. And semantically, that is the case. However, implementing it that way means that components of the same kind are not contiguous in memory. Instead, the entity will be an integer, which you would use to find its components. Unless you can't have chunks of contiguous in memory anyway, then go ahead and implement the ECS on more object oriented grounds.

By the way, if your language is one of those that poses obstacles, chances are it is a dynamic language where you can add components to an object at runtime directly. Which means you don't have to implement that part. Also, you would have a more idiomatic syntax.


Which reminds me, cache optimization is not the only benefit of an ECS. Thus, there is still value in an ECS even if you don't have chunks of continuous memory.

What is the value of an ECS without cache optimization? First, composition. For starters, you can add and remove components at runtime, plus you can iterate over all the entities that have components of a given kind. This allows you to have components work both as state and as messages. And second, going against the object oriented approach, having the systems separate from the entities help in keeping your code easier to manage. Without that, in order to not repeat code, behavior tends to accumulate in a base class for all entities. Base class which has to deal with all cases.

By the way, I don't to suggest that ECS is the only solution, nor that everything should be in an ECS. There is also value in having some aspect of a game exist outside of an ECS. Similarly, remember that making games without an ECS is perfectly possible.


I'll recommend you to watch the video: RustConf 2018 - Closing Keynote - Using Rust For Game Development by Catherine West and the response video Rant: Entity systems and the Rust borrow checker ... or something.. Although, these videos are about Rust, they manage to do a good job of explaining the problems of an orthodox object oriented approaches and the memory management challenges in implementing an ECS. You will hear about the Rust borrow checker… If you are not familiar with Rust, suffice to say, that the borrow checker, is part of the Rust compiler and it is there to make sure that your memory management is correct and safe. Code has ownership of memory when it is responsible for de-allocating, and borrowing means to access some memory without taking ownership. Could the owner de-allocated the memory while borrowed? The borrow checker, well, checks that.

\$\endgroup\$
4
  • \$\begingroup\$ You mentioned "behavior tends to accumulate in a base class for all entities." I'm already using a composition OOP approach over inheritance. (I learned that the hard way a little way into the project.) That's not really an issue in my case. My main reason for considering ECS is actually the fact that components need to refer to each other and communicate, which is difficult to implement cleanly using OOP. I was just wondering about the other performance benefits to see whether migrating really would be worth it. \$\endgroup\$ Apr 14, 2021 at 9:14
  • 1
    \$\begingroup\$ @EnderShadow8 I'm not sure what you mean by "components need to refer to each other and communicate". Components with references to components is not common in ECS. You might be interested in Component-Graph-System (CGS). \$\endgroup\$
    – Theraot
    Apr 14, 2021 at 9:17
  • \$\begingroup\$ They don't need to communicate if a System can refer to both components. \$\endgroup\$ Apr 14, 2021 at 9:26
  • 1
    \$\begingroup\$ In Java for example, implementing ECS in any way whatsoever means that your components aren't contiguous in memory. \$\endgroup\$
    – user253751
    Apr 14, 2021 at 16:53
2
\$\begingroup\$

The performance cost of garbage-collection can be greatly reduced by avoiding the creation of garbage.

Garbage is created whenever you create a short-living object. Like in this example:

foreach (var thing in veryLargeCollection) {
    new ConsoleOutputter(new ThingFormatter(new ThingReader(thing).Read()).Format()).Write();
}

Each loop iteration will allocate memory for 3 short-living objects which then immediately become garbage and will need to be garbage-collected later. How can we avoid that? Just the way how responsible people should manage their garbage in real-life: Reduce is better than Reuse is better than Recycle.

Reduce garbage by avoiding the creation of short-living objects. Avoid NounVerber classes in your software architecture and instead try to put their functionality into methods of other classes. Keep in mind that there are also short-living objects which can be created by library-functions. For example, you might be using a vector library which likes to create temporary objects. distance = (vectorA - vectorB).magnitude Subtracting one vector from another might create a short-living new Vector-object.

When you can not reduce object-creation, then the next best thing is to reuse objects.

  • If you do have NounVerber classes in your architecture, give them a way to change their subject, so they can be reused.
thingFormatter.SetThingProvider(thingReader);
consoleOutputter.SetInputStream(thingFormatter);

foreach (var thing in veryLargeCollection) {
    thingReader.SetThing(thing);     
    consoleOutputter.Write();
}
  • Avoid encapsulating small amounts of data in reference-types (like classes in most languages). Try to use value-types instead. Like primitives (in most languages) or struct's in C#.
  • Do object pooling. For example, let's say you have a bullet hell shooter with lots of bullets, and those bullets are reference-type. Don't create a new Bullet() whenever something shoots a bullet and don't let them just become garbage by removing them from the collection of active bullets when they leave the screen. What you should do instead is create a lot of bullets at game start an store the references to them in an array of unused bullets (creating them all at once increases your chances that the VM will put them in relatively unfragmented memory, by the way). When something shoots a bullet, take one from the unused bullet array and initialize its values. When you no longer need a bullet, put a reference back into the array of unused bullets. That way no bullet will ever exist without a reference pointing to it, so it will never become garbage.

Another problem which is not a problem of garbage collection per-se but which is frequently a problem in languages which do garbage collection is to ensure memory locality. In a manged language like C# or Java, you don't have much control over how exactly the VM allocates memory. So you can not force it to keep data which gets processed together in the same section of memory to ensure cache-friendliness. But you can at least try to make its job easier by avoiding data-structures which are designed for dynamic memory allocation and reallocation. For example, consider using fixed-size arrays instead of variable-sized lists. And when you do use dynamically-sized containers, see if there is some way to tell them how large they are going to get when they are created.

\$\endgroup\$
10
  • \$\begingroup\$ That's very interesting. However, I asked about ECS and its advantages in particular. A lot of articles I've found recommend storing components in memory in a particularly cache-friendly way, and without that advantage in a GC language, is there any real reason to use ECS? \$\endgroup\$ Apr 14, 2021 at 9:01
  • \$\begingroup\$ @EnderShadow8 Well, you asked about garbage collection in particular, so I focused my answer on that aspect. But I added an additional paragraph about memory locality. \$\endgroup\$
    – Philipp
    Apr 14, 2021 at 9:03
  • \$\begingroup\$ @EnderShadow8 Also keep in mind that the best advise one could give in this regard would be language-specific. You did not tag your question as any particular language, so I tried my best to keep my answer language-agnostic. \$\endgroup\$
    – Philipp
    Apr 14, 2021 at 9:07
  • 1
    \$\begingroup\$ "Each loop iteration will allocate memory for 3 short-living objects which then immediately become garbage and will need to be garbage-collected later." – Are you sure about that? I vaguely remember reading that in a situation like this, the VM (Microsoft's implementation of it, at least) will allocate the memory for those objects on the stack, and it'll be released immediately when the function returns, making this code no less efficient than if you just had local variables of primitive types. \$\endgroup\$ Apr 14, 2021 at 19:42
  • 1
    \$\begingroup\$ @TannerSwett In many cases you are correct; the key phrase here is "escape analysis", if the compiler (whether AOT or JIT) can determine that the object does not "escape" from the block of code that uses it (which is probably so here) then it can put it on the stack instead of the heap assuming the object is a good candidate for stack allocation anyway (small number of fields, not too coupled to other objects, no weird side effects in the constructors or method calls, etc.) \$\endgroup\$
    – kbolino
    Apr 14, 2021 at 21:12
1
\$\begingroup\$

There is another performance-related aspect of ECS: parallelization.

During the update cycle, each system writes to the limited set of components, often just 1. The components that are not being written at that moment may be safely shared across the multiple threads, which allows multiple systems to run in parallel.

Garbage-collected language may be even more preferable when aiming for parallelization, due to the better multi-threaded safety of a managed memory model.

\$\endgroup\$
1
  • \$\begingroup\$ I've never dabbled in parallel processing, maybe I'll do some research on it now \$\endgroup\$ Apr 14, 2021 at 12:10

You must log in to answer this question.

Not the answer you're looking for? Browse other questions tagged .