I’ll leave this topic to a part 2 that will follow shortly.

Well, yeah, for some definitions of shortly at least. :)

Here we are with the second part of the post about sparse sets and pointer stability.
We were left saying that no policy is the best policy during iterations. Let’s see how to achieve it.

Introduction

After using the ECS architecture for a while, I’ve to say that there are some features that I’m more than willing to pay a few cpu cycles for whenever possible.
Among the others, there is pointer stability. It helps to define some things in a simple and intuitive way (such as hierarchies) and avoids the introduction of subtle bugs due to reference invalidation. It also makes it trivial to interact with third-party libraries or use your own components in external data structures of any kind.

Sparse sets and independent pools models in general are pretty good at achieving pointer stability in an elegant way. However, like everything else in computer science, this too has a cost that must be assessed from time to time.
With the last post we saw how enabling pointer stability for a type that is iterated mainly linearly isn’t that good due to the tombstone check. However, even a type worth enabling may find itself leading an iteration over multiple components (for example, because it offers the shortest pool) and therefore introducing an extra cost in the loop.

To fully remove the costs due to the tombstone check, we’ve to take a step back and start with our representation of the entity identifier.

Entity identifier

Long time ago, I talked about the fact that we can embed both the actual entity and its version in a single identifier:

baf13_1

How many bits do we want to reserve for one or the other is an implementation detail and I’ll stick to what EnTT does for simplicity.
With this type of identifier in mind, we can introduce two distinct and equally useful concepts: the null entity and the tombstone version. Once again, the implementation details are a matter of tastes and the following is just one of the possibilities.

The null entity is a reserved value for the entity part of an identifier:

baf13_2

The version doesn’t really matter for a null entity, even though one can play with the imagination to make the most of it and make good use of the countless identities of a null entity.
Similarly, the tombstone version is a reserved value for the version part of an identifier:

baf13_3

This time we have less freedom, as this is usually directly associated with an entity to declare it as unusable.
A fully reserved identifier would therefore be the following:

baf13_4

Let’s see how all these are used in combination with a sparse set.

Sparse sets and reserved values

The null entity

With this post I described a technique to reduce the cost of the lookup in a sparse set.
Briefly, the idea is that of combining the null entity (that is, an identifier with a nullified entity part) with the sparse array, so as to avoid checking the packed array during a lookup. If the element in the sparse array is null, we know for sure that there is no match in the packed array.
This is a simplified version of the resulting lookup function (it doesn’t even take in consideration the fact that the sparse array is probably paginated, as described here):

bool contains(entity_type entity) const {
    const auto index = entity_to_index(entity);
    return index < sparse.size() && sparse[index] != null;
}

As you can see, we don’t need to visit the packed array to get the position of an entity. This saves quite a lot of time during iterations, mainly because we can discard elements earlier and reduce memory accesses.
Perhaps someone has also noticed that there is a problem with this approach but we’ll leave the details for a later paragraph. For years I’ve deliberately ignored it and it has never caused any problems. On the other side, it’s also true that this problem prevents the implementation of certain features and I can finally have them now that it’s fixed upstream.

The tombstone version

In the last post I instead described how we can use tombstones in the packed array to mark elements as deleted without having to swap-and-pop them.
This, combined with pagination, allows us to have pointer stability in any case, with all that goes with it. However, it also affects our contains.
If you remember the details (I recommend rereading the previous post otherwise), an implicit list is built that visits all the entities released up to a given moment. This means that, if we find ourselves iterating a pool with tombstones, all identifiers passed to the contains can have both a special version and a potentially valid entity part.
In other words, we need to update the above function as follows:

bool contains(entity_type entity) const {
    if(entity == tombstone) return false;
    const auto index = entity_to_index(entity);
    return index < sparse.size() && sparse[index] != null;
}

Otherwise, if we want to have an early exit, we can also review the loop at the call site (forgive the pseudocode, luckily I never really had to write it):

for(auto entity: pool.packed()) {
    if(entity != tombstone && check_all_other_pools(entity)) {
        // application code here
    }
}

In both cases, we’ve an additional branch and we know it: branches should be avoided whenever possible in tight loops.
Fortunately, there also exists an alternative. The careful reader may have already noticed that we have some bits left over.

Pitfalls and wastes of a sparse array

A weak contains

Let’s take a second look at the contains function above:

bool contains(entity_type entity) const {
    const auto index = entity_to_index(entity);
    return index < sparse.size() && sparse[index] != null;
}

This works fine as long as you’ve control over all storage objects. However, it also has a problem.
Briefly, if we pass an identifier for an entity with version N and the set contains that entity with version M, we are still returned true.

No one has even noticed this problem in years of EnTT but think what sneaky errors it could cause if users were managing their own pools.
Even more (but this time I’ll leave this for a future article), think about what features we could implement if there were a strict control also on the version (still without having to access the packed array, so as not to lose performance).

Wasted bits

In the sparse array we put either the position of the entity inside the packed array or a null entity (once converted to its underlying type).
Since the entity part of an identifier uses only the first N bits, we know that we cannot have more than N elements in a set. Moreover, an index won’t grow beyond the first N bits. Finally, we also know that the null entity occupies only the entity part of an identifier.

Did you notice anything? Indeed, the version part of an identifier is unused inside the sparse array.
We’re literally wasting a handful of bits on each entity when these could be so useful to us instead.

And finally came the version

I think at this point it’s obvious how having the version of an entity inside the sparse array can solve many problems, not only with regard to pointer stability.
Also, it seems clear to me that there is room for it, so let’s connect the dots.

The rules to follow are few and simple:

  • Whenever you put the null entity in a slot of the sparse array, combine it with the tombstone version (that is, use your reserved identifier).

  • In all other cases, slots must contain values such that their entity part is the index of the element in the packed array while their version part is the actual version of that element.

This is an image that should give you a rough idea of what I’m saying:

baf13_5

Once this is done, a single change to the contains function will fix both of the above problems (again, I’m ignoring the pagination of the sparse array for simplicity here, therefore expect a slightly more elaborated implementation from real world cases):

bool contains(entity_type entity) const {
    const auto index = entity_to_index(entity);
    return (index < sparse.size()) && ((~null & entity) ^ sparse[index]) < null;
}

How does this thing work? Let’s try to figure it out by examples.

Case: valid identifier

For valid identifiers, (~null & entity) returns the version part of the entity. As a consequence, ((~null & entity) ^ sparse[index]) returns a value that is less than null when the two versions match and higher than null otherwise.
There are two cases in which the two versions don’t match:

  • The entity isn’t part of the set and therefore the version set in the sparse array is the tombstone version.

  • The entity in the sparse set has a different version from the one provided for the check.

From what we can see, we’ve already solved all our problems with entity versions and the contains function doesn’t even need to access the packed array. After all, this was our goal, right?
So far, so good. Let’s see what happens in all other cases now.

Case: null entity

This is trivial. entity_to_index(null) returns an index that is always higher than sparse.size().
Therefore, null entities are rejected in all cases as it ought to be, no matter what.

Case: tombstone version.

In this case, (~null & entity) returns the tombstone version.
On the other hand, the entity part of our identifier can identify (by mistake) an element that is either part of the set or not.

If the element is actually part of the set, ((~null & entity) ^ sparse[index]) returns a value that is greater than null (once converted to an integral value). Therefore, the resulting value is false.
This is due to the fact that we’re combining a tombstone version with a valid version and the two will differ at least for a bit.

The other case around is slightly more interesting instead. Remember that we put a null entity with a tombstone version in the sparse array when the element isn’t part of the set. This means that ((~null & entity) ^ sparse[index]) will return null while the whole expression evaluates to false.
This is due to the fact that the two versions will match while the entity part of the identifier is actually null.

In other words, the test always fails for a tombstone version.

No policy is the best policy

If it’s true that pointer stability is a super nice feature, it’s also true that no policy is the best policy when it comes to iterating entities and components.
We’ve finally messed up both our sparse set and our contains function, completely removing the tombstone check (or rather, embedding it inside our check in a hopefully less expensive form). At this point, the caller as well as the sparse set can ignore whether or not we are using tombstones and always perform the same test.
Even more, by playing with tombstone versions and null entities we’ll also be able to introduce new modes in addition to pointer stability while maintaining the contains function fully agnostic.

I’m pretty sure that the solution presented here is neither the only nor the best possible one but it’s still a solution.
Unfortunately I’m not that smart, but I more than willingly accept suggestions to refine or replace this stuff, so get in on it!

Now we just have to talk about what allows us to do having solved the problem of checking the versions inside the contains function… maybe next time? :)

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.