After publishing part 2 of the ECS back and forth series (if you haven’t read it yet, do it now), I received several requests for further information on sparse sets. In particular, requests for more details on the grouping functionalities and on the support they can offer to different access patterns (sequential, random, …).

The grouping functionalities are a topic that can often be found online in the lines of posts or comments. To be honest, I’ve struggled to put the pieces together in the past and this is why I think it’s useful to collect all the information I gathered so far in this post.

I’m pretty sure that out there, somewhere, there’s another me going down the same road and I hope this can help him.

Introduction

As a wise boy once said in a chat where I was by chance:

50% of time you loop components, 50% you access them random, 50% is other access pattern.

Therefore, supporting a single access pattern would be limiting and that’s why there are techniques for sparse sets to allow users to decide where and how they want to access things.

Be aware that this doesn’t mean sparse sets are a one-fits-all solution to use for all your needs in terms of access patterns. However, as long as they support it elegantly, there is no reason not to use them and to squeeze the best from a sparse set.

Ironically, when you take grouping functionalities to the extreme (that is what EnTT does with its full-owning groups), this technique offers also a way to do perfect SoA (citing the words of another smart person who has contributed largely to this idea).

In the following I will try to explain what EnTT offers with its groups, how they are implemented and why these are available in different flavors (that is full-, partial- and non-owning groups), each with different features.

A quick recap

From part 2 we know how a sparse set works. The most trivial implementation when used as a pool for components is such that it is composed by three vectors:

The sparse array , also known as reverse array.

, also known as reverse array. The packed array , also known as dense or direct array.

, also known as dense or direct array. A component array sorted the same of the packed array.

The implementation will be in charge to keep the packed array and the component array in sync (eg whenever it swaps two elements in the packed array, it must swap the elements at the same positions also in the component array).

So far, so good. Sequential access during iterations is blazing fast for obvious reasons. Random access is fast as well thanks to the properties of the sparse set.

This gives use the best solution ever to iterate a single component and a very good approach for random access, insertion and deletion in general.

When it comes to iterate multiple components at once, that is multiple sparse sets, a pretty fast approach is that of getting the shortest one (the sparse set whose packed array is shorter), then probe the entities before to return them to the caller to test if they have assigned all the components.

The two main problems are that:

The two sets aren’t necessarily sorted the same. In fact, they could be ordered completely differently from each other. Sparse sets allow users to sort the items they contain and sort them in the order dictated by another sparse set is pretty fast if you know the trick to use to do that. However, this doesn’t give us the necessary guarantees anyway, although it helps to increase the performance reducing the cache misses.

We don’t know how many entities have both the components. What we know is that they cannot be more than the size of the shortest packed array, that’s all. In most cases it’s already enough to improve indecently the performance (imagine you’ve 200k entities still alive but only 100 of them have assigned one of the two components), but the feel is that we can do more.

This isn’t as good as it could be at a first glance, right? To be honest, it’s good enough for the majority of cases, I wouldn’t care much of it. This approach is already by far faster than most of the implementations out there and has some nice-to-have features that make it a really good solution.

However, what if we have a very critical path and we do want more on sequential access and iterations in general? Is it possible?

The answer is obviously yes. That’s what I’ve called groups (just because I’ve no idea if a name already exists and this seemed appropriate).

Groups

The idea behind the groups is straightforward. Imagine you have two sparse sets, one for the component A and one for the component B . They already contain some entities, none of which have both the components. A group for (A, B) is empty at this point:

A packed : [e3|e7|e8|e6] A component : [A0|A1|A2|A3] group ___^ B packed : [e4|e5] B component : [B0|B1] group ___^

I omitted the sparse array, because it’s pointless for the purpose of the discussion.

As you can see, the group points (whatever it means to point for a group) to the first element of the array. We can look at it as if the group pointed past the last element contained, that is no elements at all in this case. Another way of looking at it is as if it were pointing to the first available position to use to add more elements to the group.

Let’s add B to entity e7 and see how it works. When you do that, you’re guaranteed that e7 isn’t part of the group yet, mainly because it didn’t have B before.

Immediately after adding B to entity e7 , the sparse sets will appear as follows:

A packed : [e3|e7|e8|e6] A component : [A0|A1|A2|A3] group ___^ B packed : [e4|e5|e7] B component : [B0|B1|B2] group ___^ ^___ assign B to e7

All what is needed now is to literally move the entity e7 within the group.

To do that, we can swap the entity after the one pointed by the group with the one to which we’ve just attached the component, then advance the group. This must be done for both the sparse sets:

A packed : [e7|e3|e8|e6] <-- swap e7 with e3 A component : [A1|A0|A2|A3] group ______^ B packed : [e7|e5|e4] <-- swap e7 with e4 B component : [B2|B1|B0] group ______^

What if we add A to the entity e4 then? That’s easy, same operations and the group keeps growing up:

A packed : [e7|e4|e8|e6|e3] A component : [A1|A4|A2|A3|A0] group _________^ B packed : [e7|e4|e5] B component : [B2|B0|B1] group _________^

By construction, the sparse sets contain the entities that are part of the group at the top of their packed arrays, all ordered the same. We will see shortly what are the benefits due to this aspect.

However, it would be nice if this continued to be true even after removing items from a group. Fortunately, it’s easy to achieve. Whenever we remove a component from an entity that belongs to a group, we know that it’s before the one pointed out by the group itself. This is by construction. To update our implicit list of entities and components of interest, it’s enough to swap the entity that is exiting the group with the last one contained in it, then move the group (that is, it’s pointer, whatever it is) back of one step.

As an example, let’s imagine we want to remove component B from entity e7 . Before to do that, we swap it with entity e4 :

A packed : [e4|e7|e3|e8|e6] <-- swap e7 with e4 A component : [A4|A1|A0|A2|A3] group _________^ B packed : [e4|e7|e5] <-- swap e7 with e4 B component : [B0|B2|B1] group _________^

Then we update the group so that it points to entity e7 (remember, a group points to the element past the last one that belongs to the group itself):

A packed : [e4|e7|e3|e8|e6] <-- reduce the size of the group A component : [A4|A1|A0|A2|A3] group ______^ B packed : [e4|e7|e5] <-- reduce the size of the group B component : [B0|B2|B1] group ______^

Finally, we can easily remove B from entity e7 . It means usually that we want to swap the entity with the last one in the sparse set before to pop out it. This is the best way to keep the internal arrays packed. The result is:

A packed : [e4|e7|e3|e8|e6] A component : [A4|A1|A0|A2|A3] group ______^ B packed : [e4|e5] <-- swap e7 with e5, then pop out e7 and its component B component : [B0|B1] group ______^

That’s all. As we assign a component to an entity or remove it, it potentially becomes part of a group or exits it. Consequently the tail of the group is lengthened and is shortened depending on the case.

Within our sparse set we’ll still have all the entities and components tightly packed for blazing fast iterations. Furthermore, the sparse sets involved by the group will be such that the first N elements of their packed arrays also contain the entities and components of interest, ordered the same. This is kind of the best we can obtain in terms of performance, because we have in fact N arrays of the same size and all ordered the same. All what we have to do is to iterate them from the first to the last element and return everything as-is to the caller. No jumps, no indirection, no test of validity, just pick the elements and return them. That’s all.

Obviously, there is a price to pay for this, as for everything else. In this case, the creation and destruction of components is affected by a group to an extent. Nothing that you’ll really notice in real world applications probably, mainly because construction and destruction aren’t usually done along critical paths and it’s unlikely you’re to construct or destroy 1M entities or components every tick. Note also that this price is paid only when entities enter or exit a group, not all the times they are added or removed a component, like it happens with some other solutions (you’ve read part 2 of this series, right?). Therefore, the overall cost will be in general low in all cases. On the other side, as long as the choice of where to pay the price for what we want remains ours, it is up to us to decide and there is nothing better.

Only one thing remains to be clarified: why does EnTT then offer different types of groups?

It’s a matter of ownership. As some will have guessed, a type of component cannot belong to more than one group, because it’s not possible to induce more than one sub-set in an array (this isn’t properly true, but let’s say that it’s a good approximation if you don’t want to ruin the performance and make things more complex than what is needed). However, the possible optimizations with this tool don’t stop here and this is why EnTT offers more than one type of groups.

Full-owning groups

Full-owning groups are such that they literally own all the pools of their types of components. Therefore, they can create subsets within the packed arrays as described before.

This is the fastest group (as in fast to iterate entities and components) you can construct. For N types of components, we have N packed arrays, each of which we know to contain M elements at the top all ordered in the same way. All what is needed to iterate them is to start from the first element and return instances to the caller, a tuple at a time for M times. Just advance a bunch of pointers and return what you obtain. Cache misses are intuitively reduced to a minimum.

Partial-owning groups

What if I want to define two groups, one for components A and B , the other one for components B and C ?

Ownership of B cannot be shared between two groups, so are we hopeless here? Not yet in fact, we can still give a performance boost to our iterations.

Let our first group take ownership of components A and B and therefore be a full-owning group. The second group will take then the ownership of C and will use B as a free-type, that is a type of component that takes part in the definition of the group but in whose packed array we cannot create a subset.

How does this work? It’s simple. Whenever an entity enters or exits the group, we arrange things as shown previously, this time only in the sparse set for C .

Iterations will benefit quite a lot from this. First of all, the extent of the group tells us the exact number of entities to return. Even more, we know exactly what these entities are, because the sparse set of C has them at the top of its array, ordered the same of their components. This is already a huge boost in terms of performance. To get B , instead, we will have to pay the price of the indirection, fortunately very low due to the properties of the sparse sets. However, we don’t have to test anymore entities to know if they have B because we know that they have it, being them in the group. We can then go directly to the component and return it.

Non-owning groups

The logical consequence is the case in which we have three groups: one for components A and B , one for components B and C and one for components A and C .

Oh oh. the first one is a full-owning group. The second one is a partial-owning group. Right. What about the third one instead?

This is less frequent a case, but still possible. This time, we track only the entities that deserve to belong to the group, even though we cannot create subsets in already existing arrays. Instead, we use an additional sparse set to keep the identifiers packed in an array to iterate later.

We don’t have the benefits of having all or some of the components ordered the same, but at least we know how many entities are in a group and we don’t have to test them before to get the components. Therefore, we can go directly to the instances. We have to pay the price of indirection for that, of course, but the properties of the sparse sets help to keep this price low.

Some further optimizations exist for this case tha can be applied also to the partial-owning groups. As an example, one can cache the indexes used during the first iteration to avoid the indirection later on in absence of changes.

In my experience, since we have full freedom of decision, one tends to use full-owning groups where the performance matter and non-owning groups (if necessary) along less critical paths or where the iterations aren’t the bottleneck. Therefore, these optimizations tend only to complicate the whole thing and to increase memory usage without real benefits.

Free as in free to choose

Sparse sets allow for different solutions to improve sequential access and therefore iterations, from the blazing fast full-owning and partial-owning groups to the slightly slower non-owning groups. This without considering that the model without groups already offers more than sufficient performance for the vast majority of cases.

A full-owning group is probably the best we can obtain in terms of performance with a bunch of sparse sets used as pools: packed arrays all ordered the same and of which the size is known. On the other hand, indirection pulls in a price to pay for partial-owning and non-owning groups. It’s due mainly to the fact that shared ownership isn’t possible with groups.

One of the most interesting aspects is that groups can be created on the basis of our usage patterns and the ownership model can be chosen depending on whether a query is made on a critical path or not. In fact, paths often exist along which bottlenecks aren’t in iterations on entities and components and, in these cases, one can freely decide not to use a group.

This leaves us full control unlike other more automated solutions, but also shifts the responsibility to make the right choice on the users, which is not something for everyone.

Let me know that it helped

I hope you enjoyed what you’ve read so far.

If you liked this post and want to say thanks, consider to star the GitHub project that hosts this blog. It’s the only way you have to let me know that you appreciate my work.

Thanks.