|
| 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