-
-
Notifications
You must be signed in to change notification settings - Fork 4k
Synchronized component registration #17569
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Synchronized component registration #17569
Conversation
I realized the new components would need to know about previously registered components, so I simplified.
I deleted a lot of mostly unused utility functions too since it's so painful to implement until https://doc.rust-lang.org/std/sync/struct.MappedRwLockReadGuard.html is stabalized.
Added back in the mutable versions. Keep lock during frequent registrations.
Before, required components could be read directly from ComponentInfo. But sicne there could be staged changes to required components, this could be wrong. Hence, that interface was removed, and now, required components can be requested directly.
The new ones were cloned from the original so merging is not needed.
these were mostly originally inlined, but it was dropped along the way.
CI should fail right now since we still need to create ComponentSparseSets. Right now we aren't doing that, so the dead code causes a failure. The unwrap I mentioned in sparse set queries may also be causing crashes in examples. |
At this point, the PR is feature complete IMO. BenchesHere are the current benches for my M2 Max: Needs Careful ReviewI'm still relatively new to the guts of bevy's ecs, to it is definitely possible that I made a logic mistake somewhere. I would especially appreciate feedback on the following:
Future work
|
I figured this was a more precise name.
@alice-i-cecile do you think it would be reasonable to tackle this in 0.16? I think getting readonly queries for 0.16 would be awesome, but I don't have as much of a bird's eye view as you do. Also, I know this PR is bigger than ideal. If you prefer, I can split it up at your digression. Or we can try to perfect this one. Up to you ;) |
I'm working on doing a full review, but my first thought is that this is a lot of code, and I wonder if we can find a simpler way to achieve this? Is the goal here just to let us query with Or are there cases where we need to read component metadata before we insert values of those components, such that this complexity really is essential? Alternately, ... how bad would it be for performance to just stick the existing |
@chescock Great points! As for why we don't just reserve As for why I didn't just through current If you want more info about the motivation behind the design, the first discussion started here, "Another quick question: Does Bevy consider registering a component to be a mutation to the..." Later in the conversation, I think Cart had expressed concern that even this staging solution could be too much locking. I think Alice had also said that this could also fix a problem Cart ran into when experimenting with assets as entities during assets v2. I would love to unblock that if possible. See #11266, but I don't know enough about that to say anything definite. I'm certainly not saying the architecture I drew up is the best, but hopefully that gives you an idea of the rational behind it. If you have better ideas that would still address this, trust me, I'm all ears. The biggest downside of this architecture IMO is that we have to keep track of two components impls: one for |
Thanks for the comprehensive answer! That's really helpful context! |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I did a first pass, and I think I understand the overall structure. It looks good so far! I left a few questions and suggestions.
I want to look some more and try to see how the implementation changed. It's a little hard to see from the diff since so much moved around.
} else { | ||
RequiredComponentsStagedRef { | ||
cold: None, | ||
working: from_info, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It looks like when there is no staged data, you put the cold data in working
and leave cold: None
. Why not always put cold
in cold
and staged
in working
(and then rename it staged
)?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For RequiredComponentsStagedRef
in particular, you are right. But in RequiredComponentsStagedMut
, the working
is not necessarily staged, and the cold
is not necessarily present. I would be happy to change the naming for RequiredComponentsStagedRef
if that is desired, but my intent here was to make its parallel with RequiredComponentsStagedMut
more clear. IDK which is a better approach.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Yup, I agree that Ref
and Mut
should match! I'm still not quite following the difference between working
and staged
, though.
... Hmm, and looking at get_required_components_mut
, that one does always put cold
in cold
and staged
in working
. So I don't follow how this is consistent between the two. I guess the difference is that the staged
value is never None
because it creates it on demand?
impl ComponentsView for Components { | ||
#[inline] | ||
fn register_component<T: Component>(&mut self) -> ComponentId { | ||
if self.any_staged() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this says that this API will add components to cold storage if there are none staged, but otherwise add them to staging, since updating cold storage while things are staged would invalidate staged IDs. Is that right?
Why not just unconditionally do self.full().register_component()
to clean()
if necessary? We'll need to clean eventually, so it seems cheaper to do it eagerly so that there is less staged.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
since updating cold storage while things are staged would invalidate staged IDs. Is that right?
Bingo! We could reserve IDs as another solution but that seemed too complex for a first pass.
As for why not just use full
, we certainly could. I did not benchmark this approach, but my concern was that for batch registration, the constant calls to clean
could cause performance problems compared to just this if statement. Probably wouldn't be too bad though, so we could do this if desired.
My idea behind this was to abstract the details of how when and if we stage behind different types that implement ComponentsView
. A batch registration function could just take a ComponentsView
, and the caller could decide if we use a clean
up front or a as_mut
or let it check each time like this.
IMO we should keep this as is. If we want the default behavior to be full().register_component()
, I think that default should live in World::register_component
to give more power to the user. Alternatively, we could remove the ComponentsVIew
impl for Components
to better signify that users should either use as_mut
, full
or full_unchecked
.
That's my opinion. I'll leave it up to review as to what we end up doing.
#[inline] | ||
#[expect( | ||
clippy::semicolon_if_nothing_returned, | ||
reason = "This should return whatever the forwarded method returns." |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I'd vote for just adding the semicolons. If the return type ever changes, you'll have to change this method anyway to write out the return type.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Totally fair. If no one expresses other opinions, I'll add the semicolons before merging.
|
||
impl Drop for ComponentsMut<'_> { | ||
fn drop(&mut self) { | ||
self.check_for_changes(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Assuming you do keep this flag: Is this necessary for soundness? Because you can bypass this by mem::forget()
ing the ComponentsMut
.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a really good point. Although, who's going to be manually dropping this? Maybe we should just make check_for_changes
public? Otherwise, this might be grounds to remove the whole flag thing. I'll wait for a consensus here, but I'm fine either way.
impl<'a> ComponentsRef<'a> { | ||
/// See [`ComponentsViewRef::get_required_components`] | ||
#[inline] | ||
fn get_required_components_with_lifetime( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What does with_lifetime
mean here?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great question. Other implementations of ComponentsViewRef
need this in order to avoid code duplication. The lifetimes are needed to ensure the reference is still valid when converting other Components views into ComponentsRef
as a temporary variable (Otherwise the lifetime is assumed to be tied to the temporary variable, which rust can't guarantee is valid). But the lifetime doesn't make sense as part of the trait, so I included it separately.
This is kind of a hacky solution IMO, but I felt it was better than duping the code. If anyone has an idea of a more clear name, that better explains the intent of the lifetime, feel free.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks @chescock for your review! You raised a lot of good points. Let me know if I missed any.
impl<'a> ComponentsRef<'a> { | ||
/// See [`ComponentsViewRef::get_required_components`] | ||
#[inline] | ||
fn get_required_components_with_lifetime( |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great question. Other implementations of ComponentsViewRef
need this in order to avoid code duplication. The lifetimes are needed to ensure the reference is still valid when converting other Components views into ComponentsRef
as a temporary variable (Otherwise the lifetime is assumed to be tied to the temporary variable, which rust can't guarantee is valid). But the lifetime doesn't make sense as part of the trait, so I included it separately.
This is kind of a hacky solution IMO, but I felt it was better than duping the code. If anyone has an idea of a more clear name, that better explains the intent of the lifetime, feel free.
} else { | ||
RequiredComponentsStagedRef { | ||
cold: None, | ||
working: from_info, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For RequiredComponentsStagedRef
in particular, you are right. But in RequiredComponentsStagedMut
, the working
is not necessarily staged, and the cold
is not necessarily present. I would be happy to change the naming for RequiredComponentsStagedRef
if that is desired, but my intent here was to make its parallel with RequiredComponentsStagedMut
more clear. IDK which is a better approach.
impl ComponentsView for Components { | ||
#[inline] | ||
fn register_component<T: Component>(&mut self) -> ComponentId { | ||
if self.any_staged() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
since updating cold storage while things are staged would invalidate staged IDs. Is that right?
Bingo! We could reserve IDs as another solution but that seemed too complex for a first pass.
As for why not just use full
, we certainly could. I did not benchmark this approach, but my concern was that for batch registration, the constant calls to clean
could cause performance problems compared to just this if statement. Probably wouldn't be too bad though, so we could do this if desired.
My idea behind this was to abstract the details of how when and if we stage behind different types that implement ComponentsView
. A batch registration function could just take a ComponentsView
, and the caller could decide if we use a clean
up front or a as_mut
or let it check each time like this.
IMO we should keep this as is. If we want the default behavior to be full().register_component()
, I think that default should live in World::register_component
to give more power to the user. Alternatively, we could remove the ComponentsVIew
impl for Components
to better signify that users should either use as_mut
, full
or full_unchecked
.
That's my opinion. I'll leave it up to review as to what we end up doing.
|
||
impl Drop for ComponentsMut<'_> { | ||
fn drop(&mut self) { | ||
self.check_for_changes(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's a really good point. Although, who's going to be manually dropping this? Maybe we should just make check_for_changes
public? Otherwise, this might be grounds to remove the whole flag thing. I'll wait for a consensus here, but I'm fine either way.
Co-authored-by: Chris Russell <8494645+chescock@users.noreply.github.com>
It sounds like we're going to split up this PR at points that have been recommended during review. (Everyone breaths a sigh of relief). I'll let CI fail and the branch fall behind accordingly. Please continue to review this from a macro perspective. I want to have a really clear plan for this to avoid headache later. If there are any architecture changes or name ideas you want, I'd rather get that feedback now than later. In the meantime, I'll split this up into PRs as follows:
Hopefully that will be much easier to digest. Again, if there are any architecture changes you see as desirable here, please let me know. Otherwise, I'll get to work, hopefully with a much less less megalithic PR. If anyone has more suggestions for an improved road map (break it up more/less), let me know. When we have reached an architecture consensus, etc, feel free to close this PR. |
/// Retrieves this world's [`Components`] collection mutably. | ||
#[inline] | ||
pub fn components_mut(&mut self) -> &mut Components { | ||
&mut self.components | ||
} | ||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Is it necessary for this to be public? As far as I know, all previous &mut Components
accesses were in-engine only, and the lack of a public components_mut
was intentional.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Great point. The reason I did that was to enable users to either do as_mut
or full
at their choosing to speed up batch registration. IDK if that's worth it though. Definitely needs more eyes on this.
Something that should be carefully thought about is what we intend to do with |
@Victoronz I like having that initializer struct, but I think in the future it could be a trait. Ex: |
# Objective Progresses #17569. The end goal here is to synchronize component registration. See the other PR for details for the motivation behind that. For this PR specifically, the objective is to decouple `Components` from `Storages`. What components are registered etc should have nothing to do with what Storages looks like. Storages should only care about what entity archetypes have been spawned. ## Solution Previously, this was used to create sparse sets for relevant components when those components were registered. Now, we do that when the component is inserted/spawned. This PR proposes doing that in `BundleInfo::new`, but there may be a better place. ## Testing In theory, this shouldn't have changed any functionality, so no new tests were created. I'm not aware of any examples that make heavy use of sparse set components either. ## Migration Guide - Remove storages from functions where it is no longer needed. - Note that SparseSets are no longer present for all registered sparse set components, only those that have been spawned. --------- Co-authored-by: SpecificProtagonist <vincentjunge@posteo.net> Co-authored-by: Chris Russell <8494645+chescock@users.noreply.github.com>
# Objective Progresses bevyengine#17569. The end goal here is to synchronize component registration. See the other PR for details for the motivation behind that. For this PR specifically, the objective is to decouple `Components` from `Storages`. What components are registered etc should have nothing to do with what Storages looks like. Storages should only care about what entity archetypes have been spawned. ## Solution Previously, this was used to create sparse sets for relevant components when those components were registered. Now, we do that when the component is inserted/spawned. This PR proposes doing that in `BundleInfo::new`, but there may be a better place. ## Testing In theory, this shouldn't have changed any functionality, so no new tests were created. I'm not aware of any examples that make heavy use of sparse set components either. ## Migration Guide - Remove storages from functions where it is no longer needed. - Note that SparseSets are no longer present for all registered sparse set components, only those that have been spawned. --------- Co-authored-by: SpecificProtagonist <vincentjunge@posteo.net> Co-authored-by: Chris Russell <8494645+chescock@users.noreply.github.com>
Closing in favor of #18155, and its associated PRs. Thanks to everyone who has worked on this. The feedback has been invaluable for this and future implementations. |
Objective
Registering components currently requires mutable access to a world, but conceptually, registering a component doesn't change the world's state. As discussed on discord, we want to fix that discrepancy by allowing component registration with only a read reference to the world.
If that can be done, this will open up the ability to create read only queries and could be the basis of resolving similar battles with the borrow checker.
Solution
Other solutions have been brought up in discord, but this PR attempts to solve the problem by synchronizing component registration itself.
Components
now storesComponentsData
andStagedComponents
.ComponentsData
is effectively the previous components implementation. When a component is registered or required components are set, we lock the staged components, queue those modifications on the stage, and release the lock. Eventually (during SubApp::update for now), we apply those queued changes to the normalComponentsData
, clearing the staged changes. When we are just reading data, including registering an already registered component, the lock is only engaged if the requested data still lives in the staged changes. Effectively, the lock is almost never hit unless there is some registration process happening.For performance, the original Components implementation has been kept, and both the new and the old are available via an abstraction trait,
ComponentsView
. When registering in bulk, the previous implementation may be slightly faster, and it's pretty simple to use that one instead. Additionally, when a lock needs to be used for registration, it uses a newComponentsLock
. That lock is kept between nearby registrations by default, so there is minimal locking overhead.There is some duplicated code between different implementations of
ComponentsView
. We could abstract it away, but I wanted to leave it for now since we may choose to let the implementations diverge.Testing
Current tests pass for me, but no new tests were created.
We will probably want to benchmark this vs the old implementation, but I wanted to leave that up to someone who knows better exactly what situations to benchmark.
Pros
Cons
Migration Guide
To make catching bugs and butterfly effects easier, I went ahead and adapted multiple signatures. For example, many signatures changed from &mut to &. Also,
Component::register_required_components
now accepts aComponentsView
instead ofComponents
. We can spread some of these changes over multiple PRs. It was just easier for me to follow it with the changes.Previously, when a sparse set component was registered, its set was created. Now, the set is created when the component is inserted (or spawned) in an entity. I did this during
BundleInfo creation
(Maybe there's a better place). At any rate, lots of code depended on registered components having valid sets. Now the rule is spawned components have valid sets. I updated relevant usages and safety comments, but more testing should probably be done. For now, it passes my "smell test."