Skip to content

Commit 2be1c2c

Browse files
committedAug 29, 2021
added ecs back and forth, part 12
1 parent 08cd492 commit 2be1c2c

File tree

1 file changed

+208
-0
lines changed

1 file changed

+208
-0
lines changed
 

‎_posts/2021-08-29-ecs-baf-part-12.md

+208
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
---
2+
layout: post
3+
title: ECS back and forth
4+
subtitle: Part 12 - Introduction to sparse sets and pointer stability
5+
gh-repo: skypjack/entt
6+
gh-badge: [star, follow]
7+
tags: [ecs, entt, cpp]
8+
---
9+
10+
With my [last post](https://skypjack.github.io/2021-06-12-ecs-baf-part-11/) I've
11+
revised the big matrix model and given some hints on pointer stability among the
12+
other things.<br/>
13+
This time, I want to dig a little further into the sparse set model to describe
14+
how we can have pointer stability also in this case.
15+
16+
There are actually many tricks to implement such an useful feature without
17+
sacrificing performance or wasting memory unnecessarily.<br/>
18+
This post will be an introductory and purely descriptive analysis to get to what
19+
is the new and definitive model adopted in
20+
[`EnTT`](https://github.com/skypjack/entt), which I'll describe in the next post
21+
instead.
22+
23+
## Introduction
24+
25+
Pointer stability is a really nice-to-have feature that is often sacrificed when
26+
it comes to working with an entity-component-system architecture.<br/>
27+
It has many advantages that are worth it in my humble opinion. Among the others:
28+
29+
* Hierarchies are as trivial and efficient as they can get. No hidden costs, no
30+
tricks, just a plain pointer to the parent element and that's it.
31+
32+
* Third party libraries integration becomes straightforward, especially with C
33+
ones that expect users to allocate objects and provide them to the library
34+
itself.
35+
36+
Unfortunately, some models make it especially hard to implement pointer
37+
stability, for example archetypes. This is due to the fact that they want to
38+
move entities around every time a component is added or removed. In this case,
39+
unless you are fine with finding a compromise like introducing a sparse set
40+
aside your tables, pointer stability isn't really an option.<br/>
41+
More in general, all models that don't rely on independent pools are in troubles
42+
to support this kind of feature out of the box. On the other side, the big
43+
matrix as well as sparse sets are a perfect fit.
44+
45+
We've already seen how trivial it is to have pointer stability with the big
46+
matrix. Let's see now what it means to support the same with sparse sets.
47+
48+
## Scope of the problem
49+
50+
When working with sparse sets, pointers are usually invalidated in two cases by
51+
many implementations:
52+
53+
* When adding entities and therefore components to pools.
54+
* When removing entities and therefore components from pools.
55+
56+
At least, these are the functionalities for which users may expect pointer
57+
stability. We'll see how the former is trivial to address while the latter
58+
requires some clever tricks instead to get the maximum out of it.<br/>
59+
There exist also other operations which invalidate pointers, of course. For
60+
example, [`EnTT`](https://github.com/skypjack/entt) allows to
61+
[sort pools in-place](https://skypjack.github.io/2019-09-25-ecs-baf-part-5/). It
62+
goes without saying that all references, pointers and iterators are invalidated
63+
when sorting. However, as an user I don't expect pointers to remain stable if I
64+
sort a container and therefore we won't care about this kind of functionalities.
65+
66+
## Component creation
67+
68+
Adding a component can invalidate references if elements are tightly packed in a
69+
single chunk of memory. This is pretty intuitive.<br/>
70+
As soon as we go out of space, we need to allocate a larger piece of memory and
71+
migrate all components from one side to the other. No way a pointer can survive
72+
this operation (well, technically speaking it's somewhat be possible but I
73+
wouldn't rely on this assumption).
74+
75+
To get around reference invalidation as introduced by a reallocation, all we
76+
need to do is to paginate the component array.<br/>
77+
This can introduce a few extra jumps (the number of which depends on the page
78+
size) but all in all their cost during iterations is negligible if not
79+
irrelevant at all. On the other side, _reallocating_ is much cheaper and no
80+
pointer is invalidated when we run out of space.<br/>
81+
The latter is true because moving the components means to only move the pointers
82+
to the component pages. This is both cheaper in terms of performance and safer
83+
in terms of stability. In other terms, no component will be mistreated anymore.
84+
85+
## Component destruction
86+
87+
Having pointer stability during component destruction is trickier instead. What
88+
happens in general with sparse sets is that we _swap-and-pop_ the component to
89+
remove with the last element in the array. This means that there exists a
90+
component somewhere the reference of which is invalidated after this
91+
operation.<br/>
92+
To get around this problem, we must get rid of the _swap_ operation and just
93+
_pop_. That is, we'll be literally creating _holes_ in our pools. However, how
94+
do we recognize these holes? Is it possible to recycle them later on?
95+
96+
With one of my
97+
[previous posts](https://skypjack.github.io/2019-05-06-ecs-baf-part-3/) (a very
98+
old one actually) I described how `EnTT` embeds both the entity and its version
99+
within an identifier.<br/>
100+
Moreover, I also explained how this design can be used to create implicit lists
101+
of entities within an otherwise uniform vector of identifiers. Let's see how far
102+
we can push this trick now for supporting stable pointers in a sparse set.
103+
104+
### Tombstone entity: yay or nay?
105+
106+
The first thing to do is to find a way to _recognize_ our holes. One of the
107+
possibilities is to use the `null` entity as a _tombstone entity_. However, this
108+
has an inherent problem.<br/>
109+
From the post linked above, we know that we can use the entity part of an
110+
identifier to store the position of the next element of our implicit list. In
111+
fact, unless the version occupies half of the size of an identifier (and this
112+
would really be a waste), it's not suitable for the same purpose as well.
113+
Therefore, using the `null` entity to identify tombstones would prevent us from
114+
using our nice trick to create a _list of holes_ to recycle.
115+
116+
It goes without saying that this solution has some advantages though. First of
117+
all, it doesn't require us to introduce anything to support pointer stability.
118+
We already have all we need to create tombstones. Moreover, it makes deletion
119+
trivial and fully thread safe: we can now delete multiple components of the same
120+
type in parallel without risking any conflict.<br/>
121+
On the other side, there are also some disadvantages. For example, we don't know
122+
where our holes are. In theory, we can make the elements in the sparse array
123+
still refer to the holes and recycle them when the entity is re-assigned the
124+
same component, though this risks to waste lot of space in the long run and need
125+
some syncing that may introduce bugs. The other way around is to _search_ the
126+
next hole when needed, even though this could be very expensive more often than
127+
not.<br/>
128+
All in all, our best bet seems to leave holes there and re-compact pools from
129+
time to time, without even trying to re-use them otherwise.
130+
131+
### Tombstone version and in-place delete
132+
133+
What if we introduced a tombstone version instead? Also in this case, we can
134+
still find tombstones in a pool with a check of the versions. However, this time
135+
we can also use the entity parts of our identifiers to construct an implicit
136+
list of holes within our list of entities. To do that, we only need to add an
137+
extra member to our pool to store aside the position of the first element to
138+
recycle.<br/>
139+
Concurrent deletion is only slightly more complex to manage but still not a
140+
problem. in fact, we can only conflict with other threads when it comes to
141+
updating the head of the list of holes during component destruction. An atomic
142+
variable gets the job done and will give us enough guarantees in this sense.
143+
144+
## Iteration policy
145+
146+
How much the solution above affects iteration performance?
147+
148+
First of all, it's worth noting that pointer stability isn't necessarily enabled
149+
for all pools. The `EnTT` library allows to turn it on and off on a per-type
150+
basis. This is possible due to the independent pools nature of its design.<br/>
151+
This is a must have for this kind of feature. If a type is primarily accessed
152+
linearly, it doesn't make sense to enable pointer stability for it. On the other
153+
side, types that are usually queried randomly or that require pointer stability
154+
for other reasons (i.e. hierarchy support or because you want to pass them to a
155+
third party library that expects stable references) are good candidates for this
156+
feature.
157+
158+
Consider now two pools for two different types: `T` for which pointer stability
159+
is enabled and `U` for which it is not.<br/>
160+
If you remember how things are iterated with sparse sets, we usually picks the
161+
shortest pool and perform a validity check on the other pool before returning
162+
entities (and components). There are two cases here:
163+
164+
* `U` leads the iteration. In this case, pointer stability doesn't affect the
165+
performance at all, since we don't have holes in the pools for `U`.
166+
* `T` leads the iteration. In this case, pointer stability introduces a branch
167+
that is meant to recognize and discard tombstones.
168+
169+
In many (likely the vast majority of) cases, this isn't a problem since the
170+
overall cost may still be negligible. However, it may show up in your
171+
measurements in some cases, especially if `T` is linearly iterated very often
172+
and/or along hot paths.<br/>
173+
This should clearly explain why pointer stability is less worth it when types
174+
are primarly accessed linearly. In this case, a tightly packed array without
175+
holes performs better without a doubt.<br/>
176+
However, my advice is to measure, measure, measure! The number of entities and
177+
the type of operation performed on the components could easily hide or overcome
178+
the tombstone check. As a true story, I've seen very large projects with
179+
thousands of entities and components enabling this exact model for all types
180+
without observing any relevant change in performance despite everything.
181+
182+
## No policy is the best policy
183+
184+
If it's true that pointer stability is a super nice feature, it's also true that
185+
no policy is the best policy when it comes to iterating entities and
186+
components.<br/>
187+
Therefore, we can further refine our model and also get rid of the tombstone
188+
check. This will bring us to the old, dear model we already know without any
189+
additional costs.
190+
191+
However, as this post has already gone on for a long time, I'll leave this topic
192+
to a _part 2_ that will follow shortly.<br/>
193+
To give you some context, `EnTT` went through more or less all the steps
194+
described above, up to the policy based implementation. Only recently I've
195+
further refined the model in use to eliminate the _tombstone check_ and I'm moving
196+
the library towards that solution.
197+
198+
Stay tuned if you want to know more!!
199+
200+
## Let me know that it helped
201+
202+
I hope you enjoyed what you've read so far.
203+
204+
If you liked this post and want to say thanks, consider to star the
205+
[GitHub project](https://github.com/skypjack/skypjack.github.io) that hosts this
206+
blog. It's the only way you have to let me know that you appreciate my work.
207+
208+
Thanks.

0 commit comments

Comments
 (0)
Failed to load comments.