Fast Component Lookup-by-Value with Indexes #1205
Replies: 6 comments 13 replies
-
As a bit of a digression, this is one particularly useful case of caching the results of some computation on our components. Pretty much any attempt to solve similar problems via memoization will run into the same nasty timing / freshness issues, that will be nearly impossible to solve elegantly from outside of the engine itself. Do we want to create a reusable public interface for these sorts of tasks, and then implement |
Beta Was this translation helpful? Give feedback.
-
There is also a It's maintained by the system |
Beta Was this translation helpful? Give feedback.
-
I've spent some time experimenting with the pattern we talked about on Discord (foregoing housekeeping systems and instead keeping data always valid through API or reference coupling of the index and indexed component), and in the process of trying to make it more concurrency-friendly landed on this unsavory snippet: click meuse bevy::prelude::*;
use dashmap::{DashMap, DashSet};
use std::{hash::Hash, sync::Arc};
pub struct Index<T: Hash + Eq> {
map: Arc<DashMap<T, DashSet<Entity>>>,
}
impl<T: Hash + Eq> Default for Index<T> {
fn default() -> Self {
Self::new()
}
}
impl<T: Hash + Eq> Index<T> {
pub fn new() -> Self {
Self {
map: Arc::new(DashMap::new()),
}
}
pub fn entities(&self, key: &T) -> Option<Vec<Entity>> {
self.map
.as_ref()
.get(key)
.map(|entities| entities.iter().map(|entity| *entity).collect())
}
pub fn entities_len(&self, key: &T) -> usize {
self.map
.as_ref()
.get(key)
.map(|entities| entities.len())
.unwrap_or(0)
}
}
pub struct Indexed<T: Hash + Eq + Clone> {
map: Arc<DashMap<T, DashSet<Entity>>>,
entity: Entity,
component: T,
}
impl<T: Hash + Eq + Clone> Indexed<T> {
pub fn new(index: Index<T>, entity: Entity, component: T) -> Self {
index
.map
.as_ref()
.entry(component.clone())
.or_insert_with(DashSet::new)
.insert(entity);
Self {
map: index.map,
entity,
component,
}
}
pub fn get(&self) -> &T {
&self.component
}
pub fn set(&mut self, value: T) {
self.map
.as_ref()
.get(&self.component)
.unwrap()
.remove(&self.entity);
self.component = value;
self.map
.as_ref()
.entry(self.component.clone())
.or_insert_with(DashSet::new)
.insert(self.entity);
}
pub fn unwrap(self) -> T {
self.component.clone()
}
}
impl<T: Hash + Eq + Clone> Drop for Indexed<T> {
fn drop(&mut self) {
self.map
.as_ref()
.get(&self.component)
.unwrap()
.remove(&self.entity);
}
} The I don't particularly like this snippet: creating a new indexed component is awkward; there are no safeguards from folks doing A path that might lend some improvements: give Why am I still convinced against deferred re-indexing (aka housekeeping system): this allows data to exist in an invalid, desynchronized state. It's not unreasonable to imagine a system that makes changes to index-like components in its first half, then tries to do something with them in its latter half, and ends up creating bugs because the changes made in the first half are not observed by the latter one. Note on "Steps to Implement": as I've said, you don't need to touch the macro; unlike I claimed, you don't need to touch I should have probably split this up into several posts... |
Beta Was this translation helpful? Give feedback.
-
With the addition of specialized component types in the upcoming ECS changes, we may be able to selectively impl Getting the details right on this would be tricky but it would be a very elegant and perfectly foolproof solution, which cannot be said of any of the other proposals. |
Beta Was this translation helpful? Give feedback.
-
The solution we used for 'old' index entries in IndexedDB in chromium (built on leveldb) was use just allow the 'old' values to be tombstones in the index, and clean them periodically & during cursor iterator. Each index entry would store the 'version' of the value, and every time the value was written that version would increment, so it was easy to tell if an index entry was a 'tombstone' when iterating the index. I cannot, unfortunately, vouch for how 'better' that solution is than others. But it is a solution. The tricky part is figuring out how to do your tombstone sweeping (essentially garbage collection) at good times. I believe, assuming components are stored in flat arrays, this would be cache friendly, as you would load the index vec (containing the index key, entity, version) and the 'version' field for the entities, and that's all. So two blocks of memory? I'm not an expert in this particular area 😅 Or - perhaps you can guarantee that the index will be iterated frequently enough that tombstones are cleaned up anyways. In which case, this solution might be really good. Edit: I didn't think about detecting when to change the 'version' of the entity, but it looks like this maybe be a universal problem / maybe solved by detecting 'mut' grabs. |
Beta Was this translation helpful? Give feedback.
-
Follow-up from the future: Bevy 0.14 has observers, which work great for maintaining indexes if you forbid direct mutation. |
Beta Was this translation helpful? Give feedback.
-
Motivation
When writing games, it's common to want to find an entity that has a component with a particular value. In one particularly important case, suppose we want to find all entities in a particular
Position
on our grid.In Bevy, this is straightforward enough: query for that component, iterate through it, then see which values match. But doing so incurs a pretty heavy cost: every time we need to do this, we're scanning through every single entity with that component, and this operation takes linear time.
In a game where this is a common operation, and the number of entities to search is large, it would be nice to be able to have a quick way to look this up. Create some sort of hashmap from your component's value to their entity, insert your components when they're changed, and then look it up in nice constant time.
Sounds easy right? But getting all the details right is surprisingly devilish, performance is at a premium and bugs deep in your internal code can be a nightmare to debug.
Hence: a proposal for an Official Bevy Index.
Constraints
Index<T>
needs the same scheduling constraints as one that readsT
directly.Approaches
In all cases, our goal is to provide our system a set of all entities for which a specified component has the given value.
Iteration
As a baseline, here's how you might do this with iteration.
Clean and easy, but the linear time will be a real deal-breaker for some applications.
Naive Hashmap
So, let's use a hashmap!
We want to look up our entities by their component value, so we'll go with a
Hashmap<MyComponent, Entity>
. Stick in your target value, and get out an entity.But wait: what if we have multiple entities with the same value? We'll only get one of them back out, and updating our data structure will overwrite other good values.
Simple Multimap
Alright, so we'll swap to a multimap: so we can store multiple values for each given key. Problem solved, right?
Now we just need to add some nice updating logic and we can get back to actually making our game. So what do we need to do?
We don't want to completely rewrite our index every time we update it (otherwise why aren't you just using iteration?), so we need to modify only the values that we've found. We can grab a list of components that will need to be updated with Bevy's handy
Changed
functionality. We'll need to be particularly careful here to remove the values for entities that have been removed: otherwise we'll end up calling.get
on invalid entities and throwing panics.But then how do we know where the old values that we need to remove are?
Once-per-frame Multimap-Powered Index
We could iterate through the dictionary in order, but now we're back to linear (in the number of unique component values) time. Instead, we need a reverse mapping too. We can't really on Bevy's built-in queries with
.get
here, since we need the old value of our component. So instead, we're left with creating and maintaining a reverse mapping on our own, which can be a simple hashmap since we know every entity will have exactly one component.Wrap both the forward and reverse mappings in a custom
Index
struct and we can move on to scheduling our updates! Update it after startup, and then run it at the end of each frame, spin up a nice little utility system to initialize all this and ta-da, effortless indexing right?Not so much: the values of these components can readily change within the frame, resulting in us working off of stale values to bizarre and catastrophic effect.
Manually-timed Multimap-Powered Index
Alright, so we need to make sure that our index is fresh every time that we're using it. There are two broad approaches here:
If a single value of our component is changed multiple times before we use it though, we're going to be wasting effort, meaning that the second option is preferred.
So then toss an
update_index<T>.system()
before each time we want to call our index, ensuring that it's fresh.The boilerplate isn't great but surely this should always work (until we forget to add the boilerplate), right?
Unfortunately, the magic of parallelism can work in unexpected ways. Suppose our system that uses the resource doesn't actually care about our
IndexComponent
directly: it might just be trying to find all units with a given position, rather than modifying position directly. But that means that it's not querying forIndexComponent
, merely accessingRes<Index<IndexComponent>>
and so isn't getting a hard or soft lock on the values from our scheduler in the same way as if it was reading or writing the component through a query.This means that we can have another system running in parallel, modifying the values of our
IndexComponent
out from underneath us as we're relying on them in a way that could never happen with the iterative approach!Like with most race-condition-flavored problems, tracking down a bug caused by this behavior sounds particularly nasty.
As an extra wart, we still need to keep the end-of-tick updates too, otherwise we might drop changes, since
Changed
is cleared at the end of each tick. Unfortunately, this will duplicate all of the work that we've done previously in the current tick. Keeping around a list of entities that we've updated this tick won't solve this duplication: we can't be sure that the value hasn't changed again on us in the meantime.Index as a Bevy Primitive
I hope that this design discussion has convinced you, dear reader, that this index functionality is natural, appealing and deceptively tricky. If we're going to be mucking around with engine internals anyways, let's make a nice API for our end users.
<T>
in your systems by adding anIndex<T>
to your function arguments. We don't want to use aRes<Index<T>>
here, because manually mucking around with your indexes is a recipe for disaster and it results in the race-condition bug described above.T
should be automatically initialized and maintained (likeLocal
) whenever we have at least one system is added that has an argument ofIndex<T>
. Doing this manually is boilerplate, but also easy to screw up if you add it after values of the component start changing..get(k: T) -> HashSet<Entity>
(which may be empty). The order that entities are stored in our index is completely arbitrary, so we don't want to expose it lest people try an rely on it.This lets us write our initial example code as:
Of course, if n is small or entities change often, performance may be better with simple iteration.
Steps 1, 2 and 3 of this design are easy enough, if we're privileged with access to Bevy's internals.
The real challenge is designing an efficient, foolproof approach to updating.
Here are some ideas:
Index
parameter (as long as there's been an intervening system that could have changed the component). This is much less painful without the boilerplate, and won't lead to race conditions now that we can tweak our scheduler to be smarter. We'll still need to run the end-of-frame update system though.Query
on the appropriate component and combine it into the same step. This lets us avoid modifying the scheduler logic, but it's very hacky and we still need the end-of-tick update system.DerefMut
is called on a component, also update our index if it exists. This wonderful bit of magic is already used by ourChanged
component flag. This works flawlessly, but could have some painful cache issues as we're only inserting one change at a time, and could result in us wasting effort if a component is changed multiple times before our index is used.DerefMut
is called on a component, set itsToIndex
component flag (analogous toChanged
) totrue
. Automatically schedule a system like in 1, then process all of the updates at once in a batch. UnlikeChanged
, don't reset these flags at the end of the tick. This may result in better branch-prediction, as the work done in our tight component-changing loops is always the same.DerefMut
, makingIndex
a zero-cost feature when not used, and allowing much cleaner extension for similar behavior.I prefer 5, but that's contingent on a modification to the underlying
Changed
behavior. Without that, I suspect that option 3 or 4 is going to be correct, as they're elegant, foolproof and avoid likely-redundant end-of-tick cleanup work. Which one is better will likely come down to benchmarking under realistic-ish workloads. I hate 2, and would really like to avoid it.As an extra note: option 3 is the only choice that is fully robust to arbitrarily inserting systems once the app is running. Support of that feature is currently an open question, but as you can see here comes with significant constraints.
Steps to Implement
Rewrite theAdd theIntoSystem
macro to accommodate arguments of typeIndex
.SystemParam
trait toIndex
to get it to play nicely with theIntoSystem
macro.Index<T>
argument as readingT
.Index
argument is run, hand it the appropriateIndex
struct.Beta Was this translation helpful? Give feedback.
All reactions