-
-
Notifications
You must be signed in to change notification settings - Fork 3.9k
implement the full set of sort methods on QueryIter #13417
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
Conversation
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.
Initial impressions are very positive:
- This should exist.
- Good docs.
- Good tests.
- Idiomatic and comprehensive API.
Bug me for a full review later if you need a second approval please.
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.
Overall looks amazing to me! :)
Awesome job.
I'd like to see more tests in the non-panic case, but this is very cool
crates/bevy_ecs/src/query/iter.rs
Outdated
/// # schedule.add_systems((system_1)); | ||
/// # schedule.run(&mut world); | ||
/// ``` | ||
pub fn sort_unstable<L: QueryData + '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.
The code is basically identical to sort
apart from the call to keyed_query.sort_unstable();
?
I wonder if it would be helpful to put all the duplicate code into a common harness
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.
Yes, each pair of sort, sort_by, and sort_by_key methods should be be mergeable (when we eventually have QueryManyIter
sorts, they could also be merged in), and I did try that. However, doing so would require writing an inner function over a higher trait bound on WorldQuery::Item<'1>
, which I found to be cursed.
Considering that merging isn't necessary for the functionality, I'd like to leave that for follow-up. I'll make a note of it under Next Steps.
where | ||
L::Item<'w>: Ord, | ||
{ | ||
// On the first successful iteration of `QueryIterationCursor`, `archetype_entities` or `table_entities` |
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.
just to be clear, if next()
is called before sort()
then this method is not valid because we could have duplicate mutable access to a component? (once in next()
and once in the query_lens)
Or is it that we would be sorting the entire iterator (in the query_lens) even though part of the iterator has already been 'consumed'?
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.
You're correct, mutable reference aliasing would be possible. This is in addition of us having no immediate way of tracking how many iterations have occurred, without regressing the performance of more general iteration/sorted iteration.
I changed:
|
a08e704
to
78ba402
Compare
(forgot cargo fmt) |
|
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.
Lovely API, docs, tests and code. Really nice stuff. I'm very surprised (positive) about how clean and expressive this is.
I'm really proud of it! As soon as it clicked how satisfying this extension would be, I just had to implement it. |
crates/bevy_ecs/src/query/iter.rs
Outdated
/// # schedule.add_systems((system_1)); | ||
/// # schedule.run(&mut world); | ||
/// ``` | ||
pub fn sort_by<L: ReadOnlyQueryData + 'w, Fn>( |
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.
You can get rid of the generics you need to elide at call site here and elsewhere by using compare: impl FnMut(..)
instead.
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.
Sadly that requires two unstable Rust features, I list those under Future Possibilities above.
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 get any compile errors when I clone the fork and change it locally 🤔
Edit: I'm not on nightly btw.
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 can try again, what I can think of is that I might only have tried on the sort_by_key methods, which have both the Fn trait and its returned key as a generic.
When attempting this, I did get two separate errors specifically for impl Fn traits and impl Trait
returned by Fn traits though. 🤔
Edit: I am on nightly!
crates/bevy_ecs/src/query/iter.rs
Outdated
/// | ||
/// This will panic if `next` has been called on `QueryIter` before, unless the underlying `Query` is empty. | ||
/// | ||
pub fn sort_by_cached_key<L: ReadOnlyQueryData + 'w, K, Fn>( |
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 exactly is cached 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.
These methods all match the slice::sort
methods 1-to-1. What is cached here is the key extraction function.
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.
Maybe I wasn't clear enough here:
These do match the slice functions, but they both take an iterator and return an iterator instead,
akin to the Itertools::sorted_*
methods.
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 point of providing this functionality as a first party solution isn't just for ergonomics but also performance. My problem with this PR is that it is only marginally more ergonomic than collecting & sorting the query yourself. The energy on optimization is misplaced and would create friction with much bigger optimization items.
The most expensive part of sorting queries are:
- The sorting operation: This is going to happen every time the system runs regardless of if components were actually changed. It will be very common for components in sorted queries to rarely change. Caching the result of a sort and completely early outing from this eliminates the whole sort operation. A sorted cache also allows you to do very fast suspendable seeking. Like getting
N
items for rendering a scroll area in UI or any kind of rhythm game (basically very fancy scroll areas). - Fragmented iteration: Simply looking up by
Entity
is going to give you completely random access. We would like to also reorder the underlying tables & then store ranges in the cache from1.
to condense it. This could be done with an explicit accompanyingCommand
initially. It could be followed up by making sorted queries reorder tables by default for better ergonomics. Either by first addingWorld::parallel_commands
or making the world command queue thread local. (Can also add an opt out mechanism for this behavior for systems that request overlapping queries in different orders).
I would like to see at least:
- A command to reorder tables
- The sort cache being condensed for cache friendly iteration
These items can be shelved for later because they're blocked on missing features:
- Sorting tables by default (changes to world commands)
- Using change detection to early out of sorts (archetype level change detection)
Are these not orthogonal concerns? Cases like under the query-sorts test are the simplest they can get. I am personally working on a game-like project that has to sync tree-like state with an external binary, in which I use a lot of sorts to guarantee iteration order. This is currently rather painful as they are mostly the same actions, but cannot be abstracted away by a function over query types across systems from the user side. |
I do agree that it doesn't fully address the concerns of commenters under the issue #1470. Should this issue remain, or a new one be opened? |
It wouldn't block it, just make a future PR & migration guide more annoying. Currently the sort is done by the iterator not the query. If you want to store the result of a sort to early out the next time a system is called you have to store it somewhere (the fn sys(q: Query<(&A, &B)>) {
for (a, b) in q.iter().sort::<&A>() {
// ..
}
for (a, b) in q.iter().sort::<&B>() {
// ..
}
} In some surprising ways. Additionally putting the sort on the query rather than the iterator would mean you get mut iteration of sorted queries with less headaches. |
IMO this will be clearer as follow-ups. |
The problem with sort methods on anything other than the query iterators is that they would explode in API surface.
That makes for a total of 24 sort methods, which is completely unnecessary IMO. For reference, Could you elaborate on mutable iteration being less of a headache with sort methods not on the iterators? I am not sure what you referring to here, as mutable iteration is completely unrestricted with these sorts. |
Isn't the opposite the case? You need to make 6 sorts for each iterator type. If you instead have
You're going to have to take the
As described. You don't need extra mut iterators multiplied by each iteration type. |
We could use a trait for sorting (ala iter_tools) to manage some of that complexity. But I'm more than happy to push that to future work. This is useful and pleasant, and gathering feedback from users + performance benchmarks is a more reasonable step than trying to engineer this further in this PR. I definitely think that some of the ideas above (especially caching!) have value, but I don't see a reason to block on them. |
From the perspective of the user looking at one type at a time, they would still only be six sorts, not 12 "different" ones. Hmm, I get the feeling we are imagining different sets of use cases/APIs here.
Keep in mind that the sort function itself does not mutate anything, it works with read-only data. The mutability I was talking about was for the final returned |
Yes everything I've described doesn't touch the table storage. fn sys(mut q: Query<(&mut A, &B)>) {
// Doesn't change tables
for (a, b) in q.sort::<&A>().iter() {
}
// Iters unsorted
for (a, b) in q.iter_mut() {
}
// Iter by different sort
for (a, b) in q.sort::<&B>().iter() {
}
}
The proposed changes would be no more expensive than what the PR currently has. They would just make the query stateful & you would have less API surface.
It is precisely because unsorted is disproportionately more common that this kind of branch is the kind that would likely completely get optimized out by branch predictors. |
oops, how how do I reopen this? (how did I close this in the first place... I am confused) |
thanks, is the PR in the correct state now? I tried to rebase on main, then add a new Edit: It seems to be. |
Since a |
To me this just tells the user they get a new iterator that has sorted query items. Since it uses another query to decide the ordering it could still apply all the weird obscure optimizations end users could think of.
If info on sorted query performance is the same between methods it makes sense to explain it once in a section on
How is this speculation? I looked at the PR, thought of usecases I've written or seen, then had to look trough a lot of implementation details to see how the performance stacks up the current approaches taken there. The only speculation is that people could assume it's free or way cheaper than it is. I also doubt we'd get meaningful feedback on docs, usually it's just "too little" or "not up-to-date enough" or something vague like that. Most doc improvements are done by observing users running into issues. The more valuable user feedback would probably also come if end users are aware of the performance implications of this feature and can comment on if it's worth it, as well as suggest possible improvements 🤔 |
Looks like it, but you also don't usually want to rebase once you have gotten reviews, since a lot of reviewers seem to hate that. |
This is my first proper contribution to anything, so I am still trying to get used to everything there is to manage. |
Oh, I didn't mean to refer your performance comment suggestion as speculation, I agree we should add one in some form! What I meant by speculation, is assuming what the users will interpret as behavior once they see these methods based on discussions that are present among people already familiar with engine concepts, like here:
(Though speculation might be too harsh a word) |
In my mind, a user unfamiliar with bevy, would travel from |
Would "Sorts all query results into a new iterator [...]" be preferable wording? |
Ah yes, I don't think people will assume it does anything beyond the power of the
That definitely seems clearer 🤔 |
I'm not sure what you mean, here. Do you mean cheap because they could imagine caching across system runs? |
I opened #13443, if someone wants to see what the companion for On the docs: This would partially address the concerns from both the performance and table sort side for now, and we could add a section on (Also my bad on calling your concern speculation @iiYese, I didn't phrase that well) |
I think that sentence would be helpful and appropriately communicate the existing limitations. |
Added. |
…, and sort_by_cached_key for QueryIter
Thank you to everyone involved with the authoring or reviewing of this PR! This work is relatively important and needs release notes! Head over to bevyengine/bevy-website#1322 if you'd like to help out. |
# Objective ~Blocked on #13417~ Motivation is the same as in #13417. If users can sort `QueryIter`, to only makes sense to also allow them to use this functionality on `QueryManyIter`. ## Solution Also implement the sorts on `QueryManyIter`. The implementation of the sorts themselves are mostly the same as with `QueryIter` in #13417. They differ in that they re-use the `entity_iter` passed to the `iter_many`, and internally call `iter_many_unchecked_manual` on the lens `QueryState` with it. These methods also return a different struct, `QuerySortedManyIter`, because there is no longer a guarantee of unique entities. `QuerySortedManyIter` implements the various `Iterator` traits for read-only iteration, as `QueryManyIter` does + `DoubleEndedIterator`. For mutable iteration, there is both a `fetch_next` and a `fetch_next_back` method. However, they only become available after the user calls `collect_inner` on `QuerySortedManyIter` first. This collects the inner `entity_iter` (this is the sorted one, **not** the original the user passed) to drop all query lens items to avoid aliasing. When TAITs are available this `collect_inner` could be hidden away, until then it is unfortunately not possible to elide this without either regressing read-only iteration, or introducing a whole new type, mostly being a copy of `QuerySortedIter`. As a follow-up we could add a `entities_all_unique` method to check whether the entity list consists of only unique entities, and then return a `QuerySortedIter` from it (under opaque impl Trait if need be), *allowing mutable `Iterator` trait iteration* over what was originally an `iter_many` call. Such a method can also be added to `QueryManyIter`, albeit needing a separate, new return type. ## Testing I've switched the third example/doc test under `sort` out for one that shows the collect_inner/fetch_next_back functionality, otherwise the examples are the same as in #13417, adjusted to use `iter_many` instead of `iter`. The `query-iter-many-sorts` test checks for equivalence to the underlying sorts. The test after shows that these sorts *do not* panic after `fetch`/`fetch_next` calls. ## Changelog Added `sort`, `sort_unstable`, `sort_by`, `sort_unstable_by`, `sort_by_key`, `sort_by_cached_key` to `QueryManyIter`. Added `QuerySortedManyIter`.
# Objective ~Blocked on bevyengine#13417~ Motivation is the same as in bevyengine#13417. If users can sort `QueryIter`, to only makes sense to also allow them to use this functionality on `QueryManyIter`. ## Solution Also implement the sorts on `QueryManyIter`. The implementation of the sorts themselves are mostly the same as with `QueryIter` in bevyengine#13417. They differ in that they re-use the `entity_iter` passed to the `iter_many`, and internally call `iter_many_unchecked_manual` on the lens `QueryState` with it. These methods also return a different struct, `QuerySortedManyIter`, because there is no longer a guarantee of unique entities. `QuerySortedManyIter` implements the various `Iterator` traits for read-only iteration, as `QueryManyIter` does + `DoubleEndedIterator`. For mutable iteration, there is both a `fetch_next` and a `fetch_next_back` method. However, they only become available after the user calls `collect_inner` on `QuerySortedManyIter` first. This collects the inner `entity_iter` (this is the sorted one, **not** the original the user passed) to drop all query lens items to avoid aliasing. When TAITs are available this `collect_inner` could be hidden away, until then it is unfortunately not possible to elide this without either regressing read-only iteration, or introducing a whole new type, mostly being a copy of `QuerySortedIter`. As a follow-up we could add a `entities_all_unique` method to check whether the entity list consists of only unique entities, and then return a `QuerySortedIter` from it (under opaque impl Trait if need be), *allowing mutable `Iterator` trait iteration* over what was originally an `iter_many` call. Such a method can also be added to `QueryManyIter`, albeit needing a separate, new return type. ## Testing I've switched the third example/doc test under `sort` out for one that shows the collect_inner/fetch_next_back functionality, otherwise the examples are the same as in bevyengine#13417, adjusted to use `iter_many` instead of `iter`. The `query-iter-many-sorts` test checks for equivalence to the underlying sorts. The test after shows that these sorts *do not* panic after `fetch`/`fetch_next` calls. ## Changelog Added `sort`, `sort_unstable`, `sort_by`, `sort_unstable_by`, `sort_by_key`, `sort_by_cached_key` to `QueryManyIter`. Added `QuerySortedManyIter`.
Objective
Currently, a query iterator can be collected into a
Vec
and sorted, but this can be quite unwieldy, especially when manyComponent
s are involved. Theitertools
crate helps somewhat, but the need to write a closure over all ofQueryData
can sometimes hurt ergonomics, anywhere from slightly to strongly. A key extraction function only partially helps, as
sort_by_key
does not allow returning non-Copy
data.sort_by
does not suffer from theCopy
restriction, but now the user has to write out acmp
function over twoQueryData::Item
s when it could have just been handled by theOrd
impl for the key.sort
requires the entireIterator
Item to beOrd
, which is rarely usable without manual helper functionality. If the user wants to hide away unused components with a..
range, they need to track item tuple order across their function. MutableQueryData
can also introduce further complexity.Additionally, sometimes users solely include
Component
s /Entity
to guarantee iteration order.For a user to write a function to abstract away repeated sorts over various
QueryData
types they use would require reaching for theall_tuples!
macro, and continue tracking tuple order afterwards.Fixes #1470.
Solution
Custom sort methods on
QueryIter
, which take a query lens as a generic argument, liketransmute_lens
inQuery
.This allows users to choose what part of their queries they pass to their sort function calls, serving as a kind of "key extraction function" before the sort call. F.e. allowing users to implement
Ord
for a Component, then callquery.iter().sort::<OrdComponent>()
This works independent of mutability in
QueryData
,QueryData
tuple order, or the underlyingiter/iter_mut
call.Non-
Copy
components could also be used this way, an internalArc<usize>
being an example.If
Ord
impls on components do not suffice, other sort methods can be used. Notably useful when combined withEntityRef
orEntityMut
.Another boon from using underlying
transmute
functionality, is that with the allowed transmutes, it is possible to sort aQuery
withEntity
even if it wasn't included in the originalQuery
.The additional generic parameter on the methods other than
sort
andsort_unstable
currently cannot be removed due to Rust limitations, however their types can be inferred.The new methods do not conflict with the
itertools
sort methods, as those use the "sorted" prefix.This is implemented barely touching existing code. That change to existing code being that
QueryIter
now holds on to the reference toUnsafeWorldCell
that is used to initialize it.A lens query is constructed with
Entity
attached at the end, sorted, and turned into an iterator. The iterator maps away the lens query, leaving only an iterator ofEntity
, which is used byQuerySortedIter
to retrieve the actual items.QuerySortedIter
resembles a combination ofQueryManyIter
andQueryIter
, but it uses an entity list that is guaranteed to contain unique entities, and implementsExactSizeIterator
,DoubleEndedIterator
,FusedIterator
regardless of mutability or filter kind (archetypal/non-archetypal).The sort methods are not allowed to be called after
next
, and will panic otherwise. This is checked usingQueryIterationCursor
state, which is unique on initialization. Empty queries are an exception to this, as they do not return any item in the first place.That is because tracking how many iterations have already passed would require regressing either normal query iteration a slight bit, or sorted iteration by a lot. Besides, that would not be the intended use of these methods.
Testing
To ensure that
next
being called beforesort
results in a panic, I added some tests. I also test that emptyQueryIter
s do not exhibit this restriction.The query sorts test checks for equivalence to the underlying sorts.
This change requires that
Query<(Entity, Entity)>
remains legal, if that is not already guaranteed, which is also ensured by the aforementioned test.Next Steps
Implement the set of sort methods for
QueryManyIter
as well.QuerySortedManyIter
to account for iterationover lists of entities that are not guaranteed to be unique. This new query iterator will need a bit of internal restructuring
to allow for double-ended mutable iteration, while not regressing read-only iteration.
The implementations for each pair of
sort
,sort_unstable
,sort_by
, sort_unstable_by,sort_by_key,
sort_by_cached_key
are the same aside from the panic message and the sort call, so they could be merged with an inner function.
That would require the use of higher-ranked trait bounds on
WorldQuery::Item<'1>
, and is unclear to me whether it is currently doable.Iteration in QuerySortedIter might have space for improvement.
When sorting by
Entity
, an(Entity, Entity)
lensQueryData
is constructed, is that worth remedying?When table sorts are implemented, a fast path could be introduced to these sort methods.
Future Possibilities
Implementing
Ord
for EntityLocation might be useful.Some papercuts in ergonomics can be improved by future Rust features:
Fn -> impl Trait
(impl Trait
inFn
trait return position)QueryData::Item
, allowing the sort methodsto look and behave like
slice::sort
when no query lens is specified.QuerySortedIter
and thus the huge visibleimpl Iterator
type in the sort functionsignatures can be removed.
L
could be relaxed toQueryData
when the underlying iterator is mutable.Changelog
Added
sort
,sort_unstable
,sort_by
,sort_unstable_by
,sort_by_key
,sort_by_cached_key
toQueryIter
.