-
-
Couldn't load subscription status.
- Fork 4.2k
Combine Query and QueryLens using a type parameter for state
#18162
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
base: main
Are you sure you want to change the base?
Conversation
|
First of all, thank you for this PR! I've been wanting to do something like this for some time! Let's get into the details: I've mentioned this in the discord discussion before, but I'll properly write it out here again: I think we should only store pointers to The meaning of the Separately, |
| pub fn remaining(&self) -> Self | ||
| where | ||
| D: ReadOnlyQueryData, | ||
| S: Copy, |
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.
We should be using Clone bounds, not Copy, it allows for stuff like Arc<QueryState> in the future.
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 don't want to use Clone here because it makes it too easy to accidentally clone the entire QueryState. This is a fairly niche method, so I'm comfortable declaring that it can't be used with unusual iterators.
Note that iter() and iter_mut() will always return a QueryIter with &QueryState, since they borrow from the Query, so this only prevents calls to remaining() on the result of into_iter() with an exotic state.
| fetch: D::Fetch<'w>, | ||
| filter: F::Fetch<'w>, | ||
| query_state: &'s QueryState<D, F>, | ||
| fetch: <S::Data as WorldQuery>::Fetch<'w>, |
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 wonder whether we could simplify some of these/recover the old names with type aliases
Yup, that's a good idea! I had written this before those discussions, and was trying for the smallest possible change to
Yup, that makes sense, too. I had been thinking that we could just roll that into the safety requirements of the existing |
Use Deref instead of Borrow.
…pl EntitySetIterator for QueryIter`.
crates/bevy_ecs/src/query/state.rs
Outdated
| /// Abstracts over an owned or borrowed [`QueryState`]. | ||
| /// | ||
| /// # Safety | ||
| /// | ||
| /// This must `deref` to a `QueryState` that does not change. | ||
| pub unsafe trait QueryStateDeref: | ||
| Deref<Target = QueryState<Self::Data, Self::Filter>> | ||
| { | ||
| /// The [`QueryData`] for this `QueryState`. | ||
| type Data: QueryData; | ||
|
|
||
| /// The [`QueryFilter`] for this `QueryState`. | ||
| type Filter: QueryFilter; | ||
|
|
||
| /// The type returned by [`Self::storage_ids`]. | ||
| type StorageIter: Iterator<Item = StorageId> + Clone + Default; | ||
|
|
||
| /// A read-only version of the state. | ||
| type ReadOnly: QueryStateDeref< | ||
| Data = <Self::Data as QueryData>::ReadOnly, | ||
| Filter = Self::Filter, | ||
| >; | ||
|
|
||
| /// Iterates the storage ids that this [`QueryState`] matches. | ||
| fn storage_ids(&self) -> Self::StorageIter; | ||
|
|
||
| /// Borrows the remainder of the [`Self::StorageIter`] as an iterator | ||
| /// usable with `&QueryState<D, F>`. | ||
| fn reborrow_storage_ids( | ||
| storage_iter: &Self::StorageIter, | ||
| ) -> iter::Copied<slice::Iter<StorageId>>; | ||
|
|
||
| /// Converts this state to a read-only version. | ||
| fn into_readonly(self) -> Self::ReadOnly; | ||
| } |
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.
Hmm, I said earlier I wasn't sure where the API should live, but I am now certain that it should be on &QueryState.
By putting it on the trait, we would need to describe the correct implementation for each method in the safety contract, or we cannot use these methods ourselves.
This trait should instead have no API for now, and would then only have to describe its supertrait and QueryData/QueryFilter.
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 delegated everything I could, but these methods are here because the types vary between owned and borrowed versions. The S::StorageIter is stored at the same time as the S, so it can't be slice::Iter for owned QueryData. And into_readonly needs to return an owned QueryState, since there's nothing left to borrow from.
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.
Right, it seems QueryIterationCursor code doesn't compile when attempting to borrow the storage iterator from a deref.
However, this means that we are introducing some storage iterator clones into hot iteration code.
We should try to avoid that if possible, these clones shouldn't be necessary!
That does mean changing up the code to allow for this, which I hope isn't too difficult...
The iteration code of QueryIterationCursor is important enough that changing it involves benchmarks/proving it isn't being regressed.
With into_readonly, we run into the problem of needing to cast the original S instead of just the deref.
If we need to put the cast after the deref, then we can just wrap S with a struct that retains the deref, but appends the cast in its own impl!
For that, we don't need a dedicated method on QueryStateDeref, it can be done in Query::into_readonly directly.
If we later want it on QueryStateDeref, then we can put it there as a provided method! Though the more private solution is preferable at first I think.
This would change the returned S type of Query::into_readonly into ReadOnly<S>.
This solution would also transfer to owned transmutes, which into_readonly is essentially a simple case of.
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.
The technique of wrapping an S in a ReadOnly<S>/Transmuted<S> might actually be a nice speedup for QueryState::transmute/Query::transmute_lens in general, because we can then avoid creating a new QueryState like we do now!
We can just keep the "access is a subset" check, then cast S this way!
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.
However, this means that we are introducing some storage iterator clones into hot iteration code.
We should try to avoid that if possible, these clones shouldn't be necessary!
That does mean changing up the code to allow for this, which I hope isn't too difficult...
The iteration code ofQueryIterationCursoris important enough that changing it involves benchmarks/proving it isn't being regressed.
Yup, the clones are unfortunate! They only occur for QueryLens::into_iter(), though. Calling into_iter() on an ordinary Query, or calling iter() or iter_mut() on anything, will use &QueryState. That still uses a slice iterator, so this won't change the behavior of any existing code. And if you're consuming a QueryLens then you're already spending allocations on the QueryState, so one more shouldn't be too bad.
I really don't want to make any big changes to QueryIterationCursor in this PR, exactly because it's important for performance! If performance of QueryLens::into_iter() becomes a problem, we can do a more targeted follow-up to rework QueryIterationCursor.
For that, we don't need a dedicated method on
QueryStateDeref, it can be done inQuery::into_readonlydirectly. ... This would change the returnedStype ofQuery::into_readonlyintoReadOnly<S>.
Oh, wrapping the type is a clever idea! ... Hmm, it would mean that Query<&mut T>::as_readonly() is no longer the same type as Query<&T>, though, since the state type would differ. And I really don't want to make a breaking change like that as part of this PR!
The technique of wrapping an
Sin aReadOnly<S>/Transmuted<S>might actually be a nice speedup forQueryState::transmute/Query::transmute_lensin general, because we can then avoid creating a newQueryStatelike we do now!
Yeah, I think there are some good opportunities in that space! It's a little harder than that because you also need to store a new D::State and F::State for the new D and F. Maybe Transmuted<S> could hold new versions of those and delegate the rest to a wrapped state? Although then you're definitely not a Deref<Target=QueryState>.
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.
However, this means that we are introducing some storage iterator clones into hot iteration code.
We should try to avoid that if possible, these clones shouldn't be necessary!
That does mean changing up the code to allow for this, which I hope isn't too difficult...
The iteration code ofQueryIterationCursoris important enough that changing it involves benchmarks/proving it isn't being regressed.Yup, the clones are unfortunate! They only occur for
QueryLens::into_iter(), though. Callinginto_iter()on an ordinaryQuery, or callingiter()oriter_mut()on anything, will use&QueryState. That still uses a slice iterator, so this won't change the behavior of any existing code. And if you're consuming aQueryLensthen you're already spending allocations on theQueryState, so one more shouldn't be too bad.I really don't want to make any big changes to
QueryIterationCursorin this PR, exactly because it's important for performance! If performance ofQueryLens::into_iter()becomes a problem, we can do a more targeted follow-up to reworkQueryIterationCursor.
True, let us leave it for a follow-up then.
For that, we don't need a dedicated method on
QueryStateDeref, it can be done inQuery::into_readonlydirectly. ... This would change the returnedStype ofQuery::into_readonlyintoReadOnly<S>.Oh, wrapping the type is a clever idea! ... Hmm, it would mean that
Query<&mut T>::as_readonly()is no longer the same type asQuery<&T>, though, since the state type would differ. And I really don't want to make a breaking change like that as part of this PR!
Hmm, I don't quite follow, how would those differ?
But for now, what could be done in this PR is add an unsafe cast method to QueryStateDeref (mirroring ptr::cast), which would serve the purpose we'd need it for. The impl for Box<QueryState> should then also be changed into a into_raw -> cast -> from_raw roundtrip.
The technique of wrapping an
Sin aReadOnly<S>/Transmuted<S>might actually be a nice speedup forQueryState::transmute/Query::transmute_lensin general, because we can then avoid creating a newQueryStatelike we do now!Yeah, I think there are some good opportunities in that space! It's a little harder than that because you also need to store a new
D::StateandF::Statefor the newDandF. MaybeTransmuted<S>could hold new versions of those and delegate the rest to a wrapped state? Although then you're definitely not aDeref<Target=QueryState>.
Right! I forgot that ReadOnly has the equal state restriction. Maybe something can be done to add a similar restriction 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.
Hmm, it would mean that
Query<&mut T>::as_readonly()is no longer the same type asQuery<&T>, though, since the state type would differ.Hmm, I don't quite follow, how would those differ?
If I understood your suggestion, then Query<&mut T, (), &QS>::as_readonly() would return Query<&T, (), ReadOnly<&QS>>. But Query<&T> is Query<&T, (), &QS>, and &QS != ReadOnly<&QS>. So a user couldn't pass the result of as_readonly() to a method that took Query<&T>, which I believe is the main use case for that method today.
But for now, what could be done in this PR is add an unsafe
castmethod toQueryStateDeref(mirroringptr::cast), which would serve the purpose we'd need it for. The impl forBox<QueryState>should then also be changed into ainto_raw->cast->from_rawroundtrip.
I don't think I see how to implement that. Do you mean exposing an equivalent of QueryState::as_transmuted_state instead of QueryState::as_readonly? That would still wind up needing an associated type, but it would need to be generic over NewD, NewF. Since the only uses are as_readonly and as_nop, and as_nop never needs an owned state, it seems simpler to only expose as_readonly.
I can change the Box<QueryState> implementation to do a pointer cast. I like that the current impl doesn't need unsafe, but the safety there isn't any worse than the &QueryState cast, so we might as well avoid reallocating!
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.
But for now, what could be done in this PR is add an unsafe
castmethod toQueryStateDeref(mirroringptr::cast), which would serve the purpose we'd need it for. The impl forBox<QueryState>should then also be changed into ainto_raw->cast->from_rawroundtrip.I don't think I see how to implement that. Do you mean exposing an equivalent of
QueryState::as_transmuted_stateinstead ofQueryState::as_readonly? That would still wind up needing an associated type, but it would need to be generic overNewD, NewF. Since the only uses areas_readonlyandas_nop, andas_nopnever needs an owned state, it seems simpler to only exposeas_readonly.
The intent of a cast method here was to get the benefit of those very NewD/NewF generics!
By ensuring via a where bound that the Data/Filter types must have equal state, I thought that as an unsafe method, this could allow then removing the Self::ReadOnly associated type. It would result in some more complex type paths though.
into_readonly could then be a safe, provided method that internally uses that cast.
However, this doesn't really need to happen in this PR, it is probably better left for a follow-up.
|
Quick note: |
Oh, sorry! I was just trying to give you proper credit :). I didn't actually know that would turn into a ping! Do you want me to force-push a new message, or is it okay here because nobody is cloning my personal fork and the commit will be squashed if it gets merged? |
I think its fine here because of the squash, just noting for the future; some repos don't do that! |
…ding to allocate a new box.
…tate # Conflicts: # crates/bevy_ecs/src/query/iter.rs # crates/bevy_ecs/src/query/par_iter.rs # crates/bevy_ecs/src/system/query.rs
`cargo fmt`.
…tate # Conflicts: # crates/bevy_ecs/src/query/par_iter.rs
I'm broadly in favor of this PR, but I'm not very familiar with this section of the code. (As a side note, the API duplication between |
The main challenge with this is worsening ergonomics for queries when you have |
| /// [`<Self as QueryData>::fetch`](QueryData::fetch) must always return an item whose `Entity` | ||
| /// equals the one the function was called with. | ||
| /// I.e.: `Self::fetch(fetch, entity, table_row).entity() == entity` always holds. | ||
| pub unsafe trait EntityEquivalentQueryData: QueryData |
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 do you think about leaving out the Equivalent from the trait name?
It seems redundant, the bound on Item defines what this trait sees as an entity, and plain EntityQueryData would be easier to parse/understand imo.
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 do you think about leaving out the
Equivalentfrom the trait name? It seems redundant, the bound onItemdefines what this trait sees as an entity, and plainEntityQueryDatawould be easier to parse/understand imo.
I don't have any especially strong opinions here. Most people won't need to look at this trait at all; they'll expect QueryIter<Entity> to be usable as an EntitySet, and it will be, so they won't worry about how that was implemented.
The trickiest part here is that MainEntity and RenderEntity are EntityEquivalent + QueryData, but can't be EntityEquivalentQueryData because they return a different entity. So maybe EntityEquivalentQueryData is misleading on that count? But EntityQueryData seems like it has the same confusion. Maybe something math-y? No, ReflexiveEntityQueryData just sounds awkward.
Since the stakes are low and none of the options seem perfect, I'm inclined to leave it alone for now, but if you argue more then I'll probably change it rather than argue back :).
Yup, I think this is roughly the path I've been trying to take! #15858 introduced |
|
This is a good step in the right direction! Ultimately, That being said, there is some other stuff that interacts or even hinges on this, so I'd like to approve this. |
|
Hmm, it's a bit trickier than I expected to combine them. #15396 started using the |
# Conflicts: # crates/bevy_ecs/src/query/fetch.rs # crates/bevy_ecs/src/query/iter.rs # crates/bevy_ecs/src/relationship/relationship_query.rs # crates/bevy_ecs/src/system/query.rs
Restore `'state` lifetime to iterators so they can provide it to items. Replace `QueryStateDeref` with a `ConsumableQueryState` trait that is only used in consuming query methods, and use ordinary `Deref<Target = QueryState>` for borrowing methods.
|
Okay, I think I got this working again. The main changes are: Remove
|
Add missing safety comment. Remove unnecessary `mut`.
|
Great that the two PRs were not in too heavy a conflict! I do think removing the safety contract from the Deref trait bound is rather dangerous: That also keeps it clearer for future extensions that want to make use of pointers to achieve certain Similarly, if methods in On the note of unsafe traits: |
# Objective Unblock #18162. #15396 added the `'s` lifetime to `QueryData::Item` to make it possible for query items to borrow from the state. The state isn't passed directly to `QueryData::fetch()`, so it also added the `'s` lifetime to `WorldQuery::Fetch` so that we can pass the borrows through there. Unfortunately, having `WorldQuery::Fetch` borrow from the state makes it impossible to have owned state, because we store the state and the `Fetch` in the same `struct` during iteration. ## Solution Undo the change to add the `'s` lifetime to `WorldQuery::Fetch`. Instead, add a `&'s Self::State` parameter to `QueryData::fetch()` and `QueryFilter::filter_fetch()` so that borrows from the state can be passed directly to query items. --------- Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com> Co-authored-by: Emerson Coskey <emerson@coskey.dev>
…tate # Conflicts: # crates/bevy_ecs/src/query/iter.rs # crates/bevy_ecs/src/query/state.rs # crates/bevy_ecs/src/system/query.rs
It's sound today: The There may be future extensions where we need to replace it with an
You can already cause trouble like this without a bad
We don't rely on the implementation of I'm happy to be proven wrong on any of these if you can come up with examples that cause UB, of course! But I'm fairly confident that anything that causes UB will already violate some existing safety contract. |
Well, nobody asked me to, but I split it out anyway: #19787 :). I'm still using safe |
Hmm, I think I need to adjust my language here: I was mixing several considerations, which makes it quite unclear what kinds of "safety" I was referring to: This design and PR is likely safe from the viewpoint of a bevy user! Piping all However, I am moreso trying to consider the maintainability angle: This is in line with the usual wisdom of constraining API to only take types that work correctly with it, and having types only contain instances that are correct. Now, if this is bevy-internal machinery, does it matter that much? This is my own judgement, so it might be flawed, but from having seen and done some work in bevy and elsewhere, I feel that these broad non-local safety contracts are easy to accidentally break, because they are usually not documented over their entire coverage. A relevant example of this is our current usage of This isn't an issue as long as we audit it, whatever needs to be manually audited adds to the mental load of both reviewers and contributors! (Especially for new ones) I reckon we could be better address this by more documentation/well isolated safety requirements! That makes it a lot more "composable" (Manipulating some code sections becomes less likely to invalidate others). As another example, in the previous batch of Ultimately, I've concluded that safety requirements should not be viral whenever possible, and if they need to be, there should be documentation with the same degree of virality! I might be too stingy on this, but I do feel that it would help in lessening the cognitive load of As for For this to work, the compiler would need to reject an incorrect implementation of (Note that |
|
That is a lot of words and I want to take the time to read them more carefully before I respond, but on first look I think we agree on the principles here and just disagree on some of the details. |
I think we mostly agree too! (I do tend to express myself rather wordily, I'll try to work on that) |
No worries! I tend to alternate between writing too many words, and then deleting so many of them that my original point gets lost :). You just started reading one of the ones with too many words :).
I definitely agree with this! One of the really nice things about rust is the way it lets us encapsulate unsafety into types that carry additional proofs!
This, too!
I agree that safety contracts should be verifiable locally. Possibly I'm just conditioned to mistrust documentation, so I assume there is a valid safety contract but it isn't the one written :). Certainly, the current safety constraint on
Yeah, the rules around data vs metadata for What was #13343 again? ... Oh, I see, the fundamental problem was a call to Maybe the issue here is that I agree that's a danger! (I'm not sure how to do better on
Yeah, I really wish rust let you declare that fields or types were Like, we can't prevent safe code in the
Adding a new trait increases the cognitive load, so the question is whether it saves more in reasoning about unsafe. That's why I was happy to make a trait And I don't think it helps all that much, given that we have other safety requirements to satisfy. It mostly serves to split the proof into multiple places. We're using concrete types, so the code calling Also, I think a non-deterministic This does make me realize I'm missing safety requirements on (And, of course, many fields like
So, most types don't use the impl ReleaseStateQueryData for Thing {
fn release_state<'w>(item: Self::Item<'w, '_>) -> Self::Item<'w, 'static> {
item
}
}An impl like that will compile for There may be cases where the types are different but you do something like And you could, of course, do |
# Objective Unblock bevyengine#18162. bevyengine#15396 added the `'s` lifetime to `QueryData::Item` to make it possible for query items to borrow from the state. The state isn't passed directly to `QueryData::fetch()`, so it also added the `'s` lifetime to `WorldQuery::Fetch` so that we can pass the borrows through there. Unfortunately, having `WorldQuery::Fetch` borrow from the state makes it impossible to have owned state, because we store the state and the `Fetch` in the same `struct` during iteration. ## Solution Undo the change to add the `'s` lifetime to `WorldQuery::Fetch`. Instead, add a `&'s Self::State` parameter to `QueryData::fetch()` and `QueryFilter::filter_fetch()` so that borrows from the state can be passed directly to query items. --------- Co-authored-by: Alice Cecile <alice.i.cecile@gmail.com> Co-authored-by: Emerson Coskey <emerson@coskey.dev>
Objective
Make
QueryLenseasier to use by allowing query methods to be called on it directly instead of needing to call thequery()method first.Introduce owned query iterators, so that it's possible for methods to return iterators constructed from
Query::transmuteorQuery::join.Enable #3887. The
World::querymethod returns aQueryState, so the simplest way to create aQueryfrom aWorldtoday is:That requires passing
worldtwice, and it's not possible to do that on a single line, sinceworld.query::<D>().query(&world)"creates a temporary value which is freed while still in use". (Although it does work if you also use the query in the same expression, likefor c in world.query::<D>().query(&world).)We could improve that by having
World::queryreturnQueryLens(and renaming the method that returnsQueryStatetoWorld::query_state). That would eliminate the need to passworldtwice, but would still require a call toQueryLens::query, and would usually require an extra binding to keep theQueryLensand its internalQueryStatealive. But with this PR in place, theQueryLenswould be usable as aQuerywithout any extra calls!Solution
Make
Queryand the various iterator types generic, where normal queries use borrowed&QueryState<D, F>andQueryLensuses ownedQueryState<D, F>. Introduce atrait QueryStateBorrow: Borrow<QueryState>to abstract over the two types.Have
Queryuse a default type for the state so that it continues to work without specifying it explicitly.Note that the
'statelifetime onQuerywas only used in&'state QueryState, so it is now only used in the default type parameter. It still needs to be part of theQuerytype in order to be referenced in the default type, so we need aPhantomDataso that it's actually used. Another option here would be to makeQuerya type alias for some new named type, but I think the user experience will be better with a default type parameter than with a type alias.Testing
I used
cargo-show-asmto verify that the assembly ofQuery::iterdid not change.This rust code:
Run with
Results in the same asm before and after the change