-
-
Notifications
You must be signed in to change notification settings - Fork 3.8k
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
Compile Time Query checking and System Parameters #17837
base: main
Are you sure you want to change the base?
Conversation
// This puts `register_required` before `register_recursive_requires` to ensure that the constructors of _all_ top | ||
// level components are initialized first, giving them precedence over recursively defined constructors for the same component type | ||
TokenStream::from(quote! { | ||
impl #impl_generics #bevy_ecs_path::component::Component for #struct_name #type_generics #where_clause { | ||
const STORAGE_TYPE: #bevy_ecs_path::component::StorageType = #storage; | ||
const UNSTABLE_TYPE_ID: u128 = #unstable_type_id; |
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 possible to make this value deterministic? I think that's preferable from it randomly changing whenever cargo clean && cargo build
is ran.
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 think so. Using something like type_id::<Self>()
would be ideal, but you'd need a nightly feature flag to make it const.
I guess you could do some sort of hashing that takes for input the component's ast, but that still probably isn't enough to avoid collisions with other components. It would also drastically increase the complexity of this pr.
I agree it'd be preferable, but what benefits are there to making it deterministic?
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.
Yeah using type_id:: would be ideal, but we don't want to use any sort of deterministic count because I've heard from the grape vine that rustc might mess with things so we could end up with UB. Unless I have a guarantee that IDs are never going to repeat I would rather safely stick with a random u128.
Personally I am not a fan of this. The PR is technically impressive, and the fact that you reached this point is amazing, but I think that this PR adds a lot of extra complexity that not many people can maintain. On top of that the compile-time benefits are probably limited to a subset of cases as I assume that you weren't able to encode every possible combination of QueryData/QueryFilters. Also I would say that writing invalid queries is relatively rare, rare enough that I wouldn't get any benefit from it compared to the app simply panicking at runtime. The usual tricky case is forgetting to add a |
I assume the end-goal would be for a lot less (or none?) of these checks to be needed at runtime, which would mean we could be looking at getting back runtime performance for the cost of additional code maintenance and a bit of upfront compilation time cost. I don't have the numbers, and I don't know the end-goal here, but depending on how far this can be pushed with as little impact to (non-engine) developers, if we can squeeze out measurable runtime performance by reducing the number of checks done at runtime, then that seems worth exploring. Also, hat off to @MalekiRe for getting this to work with a relatively small diff (not to discount the experimentation it probably took to get to this final diff). 🎩 My proposal would be to let this cook for a bit, let's see how far this can be pushed, and measure the pro's and con's in a week, month or even months from now. There's no need to rush this, but if the numbers stack in favour of this change, then I presume this becomes a lot less "controversial". As for some technical feedback from my side: The |
If I understand correctly the runtime checks are done only at query/system creation time, so only once and not every frame. |
The increase in compilation time here seems pretty small, but I don't have any larger codebases up to date with main bevy to test it on. I don't think the actual complexity is very much. The idea is complex but the code itself is simple, if that makes sense? I was indeed able to encode every possible combination of QueryData and QueryFilters 😊 |
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's really really cool that this is even possible! Nice work!!
unsafe impl<T: Component> QueryData for &T { | ||
unsafe impl<T: Component> QueryData for &T | ||
where | ||
for<'a> &'a T: AccessTreeContainer, |
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.
Why do these bounds need to be higher-ranked, and not just Self: AccessTreeContainer
? It looks like they are only used for Self::COMPONENT_ACCESS_TREE
.
For that matter, do you need AccessTreeContainer
at all? Given that UNSTABLE_TYPE_ID
and STRUCT_NAME
are already on Component
, it looks like all it does is share a bit of code between &T
and Ref<T>
and between &mut T
and Mut<T>
. It might be simpler to remove the trait and do
const COMPONENT_ACCESS_TREE_QUERY_DATA: ComponentAccessTree = <&T as QueryData>::COMPONENT_ACCESS_TREE;
to share the code.
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 think this works? We have an implementation ON &mut T where T is component because we wanna be able to tell if a component access is a ref or a mut.
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.
Doing where Self: AccessTreeContainer
seems to compile when I try it.
And copy-pasting the definition from impl<T: Component> AccessTreeContainer for &T
to unsafe impl<T: Component> QueryData for &T
works other than privacy, which should let you get rid of AccessTreeContainer
completely. If you want to leave them private then I expect you could make a const fn new
, although I didn't try that.
... And I don't see a definition of COMPONENT_ACCESS_TREE
at all in QueryData
for Ref
or Mut
? Well, if I add
const COMPONENT_ACCESS_TREE_QUERY_DATA: ConstTree<ComponentAccess> =
<&T as QueryData>::COMPONENT_ACCESS_TREE_QUERY_DATA;
to unsafe impl<'__w, T: Component> QueryData for Ref<'__w, T> {
then it seems to work, so you could still share the definitions between &T
and Ref<T>
.
) | ||
}; | ||
// Handle three or more items case | ||
(@tree $t0:ident, $t1:ident, $($rest:ident),+) => { |
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.
Why do you need separate cases for two and three items? It seems like you could make the two-item case take (@tree $t0:ident, $($rest:ident),+)
and do the induction there.
Alternately, I think you can do this without recursion. It might be simpler as something like
const COMPONENT_ACCESS_TREE_QUERY_DATA: ComponentAccessTree = const {
let tree = ComponentAccessTree { this: ComponentAccess::Ignore, left: None, right: None };
$(
let tree = ComponentAccessTree::combine(tree, $name);
)*
tree
};
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.
let tree = &ConstTreeInner::<ComponentAccess>::combine(tree, $name::COMPONENT_ACCESS_TREE_QUERY_DATA);
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
| |
| creates a temporary value which is freed while still in use
| argument requires that borrow lasts for `'static`
Sadly it seems like this doesn't work. But if you could elaborate on how to do the two item case instead I would like to switch to that!
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.
Oh, I see. I was expecting it to do static promotion... but that's only possible if the inner values are themselves const
, and tree
isn't const
. And if you try to make a bunch of nested const TREE =
then it complains that they "can't use generic parameters from outer item". Yuck.
But if you could elaborate on how to do the two item case instead I would like to switch to that!
The simplest change is
(@tree) => {
&ConstTreeInner::Empty
};
(@tree $t0:ident) => {
$t0::COMPONENT_ACCESS_TREE_QUERY_DATA
};
(@tree $t0:ident, $($rest:ident),+) => {
&ConstTreeInner::<ComponentAccess>::combine(
$t0::COMPONENT_ACCESS_TREE_QUERY_DATA,
impl_tuple_query_data!(@tree $($rest),+)
)
};
But you could go one further with
// Base case
(@tree) => {
&ConstTreeInner::Empty
};
// Inductive case
(@tree $t0:ident, $($rest:ident),*) => {
&ConstTreeInner::<ComponentAccess>::combine(
$t0::COMPONENT_ACCESS_TREE_QUERY_DATA,
impl_tuple_query_data!(@tree $($rest),*)
)
};
for the cost of an extra combine(Empty)
.
... and for that matter, since we're already generating that same code for smaller tuples, you could use their trees rather than doing a second recursion:
// Base case
(@tree) => {
&ConstTreeInner::Empty
};
// Inductive case
(@tree $t0:ident, $($rest:ident),*) => {
&ConstTreeInner::<ComponentAccess>::combine(
$t0::COMPONENT_ACCESS_TREE_QUERY_DATA,
<($($rest,)*)>::COMPONENT_ACCESS_TREE_QUERY_DATA
)
};
That should generate a little less code.
/// A compile-time representation of how this system parameter accesses components | ||
/// Used for validating access patterns during const evaluation | ||
const COMPONENT_ACCESS_TREE: ComponentAccessTree = ComponentAccessTree { | ||
this: ComponentAccess::Ignore, |
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 build this empty tree in three places. Is it worth making a const
ComponentAccessTree::EMPTY
?
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.
Updated the code so I don't think this comment is relevant anymore. Resolve at your discretion.
@@ -570,6 +579,15 @@ macro_rules! impl_system_collection { | |||
where | |||
$($sys: IntoSystemConfigs<$param>),* | |||
{ | |||
const INTO_SYSTEM_CONFIGS_PANIC_CHECKER: Option<SystemPanicMessage> = const { | |||
let mut val = None; | |||
$(if let Some(awa) = $sys::INTO_SYSTEM_CONFIGS_PANIC_CHECKER { |
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.
This will only report one error, even if multiple systems have errors. Is it worth trying to aggregate them?
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 think so? If we really want to then we can by doing a similar tree structure with SystemPanicMessage
s but I don't think it's worth it. What do you think?
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.
Yeah, reporting only one is fine. It's unlikely someone will have multiple bad systems, and I think we'll panic on the first one today, so it's still a strict improvement!
&'static Option<WithoutFilterTree>, | ||
), | ||
) -> Option<SystemPanicMessage> { | ||
if let Some(left_without_tree) = left.2 { |
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.
This is essentially a const
version of FilteredAccess::get_conflicts
, right? The model here is slightly different, and doesn't look like it can handle Option
, Or
, AnyOf
, or Changed
/Added
correctly.
Do you want to handle those cases?
If you do, then I think you want to move all three trees from QueryData
and QueryFilter
up to WorldQuery
.
Changed
and Added
perform read access, so, for example, Query<&mut T>
and Query<(), Changed<T>>
should conflict. Moving the component access to WorldQuery
will let you populate that on those filters. (Note that Query<&mut T, Changed<T>>
is fine, since the filter is evaluated before the item.)
Option<T>
performs read access, but doesn't filter. So, for example Query<(Option<&T>, &mut U)>
and Query<&mut U, Without<T>>
do conflict, but I think your current check can't express that. To be consistent with the run-time check, you'll want to have &T
/&mut T
/Ref<T>
/Mut<T>
also register a With
filter, and then have Option
ignore the nested With
filters. Moving the "with" filters to WorldQuery
will let you populate those.
Or
and AnyOf
are a little more complicated. The model here has a list of filters that are "and"ed together, but the full access model supports a list of those that is "or"ed together. If you want to support those, I think you need a new kind of tree whose leaves have both a tree of "with" filters and a tree of "without" filters, just as FilteredAccess
has filter_sets: Vec<AccessFilters<T>>
, and AccessFilters
has a list of "with" and "without" filters.
Oh, and if you want to handle EntityRef
and EntityMut
, then you need to express "all access". And if you want to handle EntityRefExcept<T>
then you need to express "all access except".
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.
If you reread the code now it might be clearer. It does indeed handle things like Changed and Added and Option, and it doesn't look for conflicts on the same query with the same query filter so things like Query<&mut Age, With<Age>>
don't conflict but things like query1: Query<&mut Age>, query2: Query<With<Age>>
do.
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'll have to look at it more. It looked like With<T>
and Changed<T>
had the same implementation, though. Is that right? Because this should be fine:
fn good_system(query1: Query<&mut Age>, query2: Query<(), With<Age>>)
But this should conflict:
fn bad_system(query1: Query<&mut Age>, query2: Query<(), Changed<Age>>)
And it looked like Option<T>
just uses the access from T
. Is that right? Because this should be fine:
fn good_system(query1: Query<&mut Name, Without<Age>>, query2: Query<(&mut Name, &Age)>)
But this should conflict:
fn bad_system(query1: Query<&mut Name, Without<Age>>, query2: Query<(&mut Name, Option<&Age>)>)
Mut, | ||
} | ||
|
||
/// A tree structure representing the `With<T>`, `Added<T>`, and `Changed<T>` filters |
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.
If I understand correctly, these are semantically unordered sets, and are implemented as trees rather than lists to allow efficient concatenation. Is that right? It might be useful to have a comment to that effect.
@chescock I'm gonna request a re-review now that I updated the code structure significantly, because perhaps you have new thoughts. I am going to continue to work through your other comments though. |
I'm currently going through and porting over all the tests that should fail now to the ecs compile fail tester |
Objective
Compile-Time Query Validation
This introduces compile-time validation for Query and System Parameters to catch invalid system configurations during compilation rather than at runtime. This provides earlier feedback to developers and eliminates a class of potential runtime errors.
Solution
Solution Architecture
Core Mechanism
The solution leverages Rust's const evaluation capabilities to detect invalid query and system parameter combinations at compile time through a tree-based validation system. Three key structures form the backbone of this validation:
These trees are built up recursively through const evaluation to track:
Implementation Details
The validation system is built on a set of traits that provide default no-op implementations and allow system parameters to opt-in to validation:
The system builds up validation trees recursively through generic implementations:
Complex queries automatically build their validation trees through modifying the existing and building up their recursive trees:
Example Usage
The system can detect invalid query combinations at compile time:
With diagnostic_component_names enabled, you get clear error messages:
Performance Impact
Benchmarks show minimal impact:
Cool Things
Testing
Migration Guide
When implementing Component manually you must now include a value for CONST_UNSTABLE_TYPEID: u128
Make sure it's unique by randomly generating it before hand, if we rip out access checks then not making sure it's unique will be Undefined Behavior 🙀.