Skip to content
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

Open
wants to merge 22 commits into
base: main
Choose a base branch
from

Conversation

MalekiRe
Copy link
Contributor

@MalekiRe MalekiRe commented Feb 13, 2025

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:

pub struct ComponentAccessTree {
    pub this: ComponentAccess,
    pub left: Option<&'static ComponentAccessTree>,
    pub right: Option<&'static ComponentAccessTree> 
}

pub struct WithFilterTree {
    pub this: WithId,
    pub left: &'static Option<WithFilterTree>,
    pub right: &'static Option<WithFilterTree>
}

pub struct WithoutFilterTree {
    pub this: WithoutId,
    pub left: &'static Option<WithoutFilterTree>,
    pub right: &'static Option<WithoutFilterTree>
}

These trees are built up recursively through const evaluation to track:

  • Component access patterns (&T vs &mut T)
  • With/Added/Changed filters
  • Without filters

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:

// Base trait with default opt-out behavior
pub trait SystemParam: Sized {
    const COMPONENT_ACCESS_TREE: ComponentAccessTree = ComponentAccessTree {
        this: ComponentAccess::Ignore,
        left: None,
        right: None,
    };
    const WITH_FILTER_TREE: Option<WithFilterTree> = None;
    const WITHOUT_FILTER_TREE: Option<WithoutFilterTree> = None;
}

// Components must now provide a unique ID
impl Component for MyComponent {
    const UNSTABLE_TYPE_ID: u128 = /* unique random number */;
    #[cfg(feature = "diagnostic_component_names")]
    const STRUCT_NAME: Option<&'static str> = Some("MyComponent");
}

The system builds up validation trees recursively through generic implementations:

// For immutable references
unsafe impl<T: Component> QueryData for &T {
    const COMPONENT_ACCESS_TREE_QUERY_DATA: ComponentAccessTree = Self::COMPONENT_ACCESS_TREE;
}

// For filters
unsafe impl<T: Component> QueryFilter for With<T> {
    const WITH_FILTER_TREE_QUERY_DATA: Option<WithFilterTree> = Some(WithFilterTree {
        this: WithId(Some(T::UNSTABLE_TYPE_ID)),
        left: &None,
        right: &None,
    });
}

Complex queries automatically build their validation trees through modifying the existing and building up their recursive trees:

macro_rules! impl_tuple_query_data {
    ($($param: ident),*) => {
        unsafe impl<$($param: QueryData),*> QueryData for ($($param,)*) {
            const COMPONENT_ACCESS_TREE_QUERY_DATA: ComponentAccessTree = 
                impl_tuple_query_data!(@tree $($param),*);
        }
    };
}

Example Usage

The system can detect invalid query combinations at compile time:

// This compiles - queries are disjoint due to With/Without
fn valid(query1: Query<&mut Age, With<Awa>>, query2: Query<&mut Age, Without<Awa>>) {
}

// This fails to compile - potential mutable access conflict
fn invalid(query1: Query<&mut Age, With<Awa>>, query2: Query<&mut Age>) {
}

With diagnostic_component_names enabled, you get clear error messages:

error: Invalid System Queries, &mut Age conflicts with &mut Age in same system

Performance Impact

Benchmarks show minimal impact:

  • Binary size increase: ~0.006% (1020710736 -> 1020710736 bytes)
  • Runtime overhead: ~2-3 seconds on alien cake addict compiled from scratch
    • Main branch: (1m10s,1m10s,1m10s)
    • My branch: (1m12s, 1m12s, 1m13s)

Cool Things

  1. Earlier Error Detection: Invalid query combinations are caught at compile time rather than runtime
  2. Zero Runtime Cost: All validation happens during compilation, which means we can later rip out our runtime access checks!!!! ( We will have to make the Component trait unsafe in order to do so however )
  3. Clear Error Messages: With diagnostic names enabled, errors clearly identify conflicting components
  4. Opt-In System: Default implementations mean existing code works without modification
  5. Complex Validation: Can detect conflicts in complex scenarios involving multiple queries and filters

Testing

  • Validated against alien cake addict benchmark + whole bevy engine
  • I will work on a comprehensive test suite for various invalid query combinations

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 🙀.

@MalekiRe MalekiRe marked this pull request as draft February 13, 2025 01:36
// 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;
Copy link
Member

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.

Copy link

@MarcGuiselin MarcGuiselin Feb 13, 2025

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?

Copy link
Contributor Author

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.

@alice-i-cecile alice-i-cecile added A-ECS Entities, components, systems, and events C-Usability A targeted quality-of-life change that makes Bevy easier to use M-Needs-Release-Note Work that should be called out in the blog due to impact X-Controversial There is active debate or serious implications around merging this PR S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged labels Feb 13, 2025
@cBournhonesque
Copy link
Contributor

cBournhonesque commented Feb 13, 2025

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.
We also get increases in compilation time, which are probably small when compiling examples but can get large with bigger apps.

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 Without to a query after adding With to another param, and I think it's good enough to catch this at runtime

@JeanMertz
Copy link
Contributor

JeanMertz commented Feb 13, 2025

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.

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 UNSTABLE_TYPE_ID associated const on Component is unfortunate, but I wonder how many people manually implement the trait. I do agree that having that ID be generated in a deterministic way would be favourable.

@cBournhonesque
Copy link
Contributor

If I understand correctly the runtime checks are done only at query/system creation time, so only once and not every frame.
And for sure please do keep cooking :)
The work done here could potentially unblock #13375 for the mutable variants

@MalekiRe MalekiRe marked this pull request as ready for review February 14, 2025 03:25
@MalekiRe
Copy link
Contributor Author

@cBournhonesque

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. We also get increases in compilation time, which are probably small when compiling examples but can get large with bigger apps.

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 😊

Copy link
Contributor

@chescock chescock left a 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,
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

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),+) => {
Copy link
Contributor

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
};

Copy link
Contributor Author

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!

Copy link
Contributor

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,
Copy link
Contributor

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?

Copy link
Contributor Author

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 {
Copy link
Contributor

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?

Copy link
Contributor Author

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 SystemPanicMessages but I don't think it's worth it. What do you think?

Copy link
Contributor

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 {
Copy link
Contributor

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".

Copy link
Contributor Author

@MalekiRe MalekiRe Feb 15, 2025

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.

Copy link
Contributor

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
Copy link
Contributor

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.

@MalekiRe
Copy link
Contributor Author

@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.

@MalekiRe MalekiRe requested a review from chescock February 15, 2025 23:01
@MalekiRe
Copy link
Contributor Author

I'm currently going through and porting over all the tests that should fail now to the ecs compile fail tester

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
A-ECS Entities, components, systems, and events C-Usability A targeted quality-of-life change that makes Bevy easier to use M-Needs-Release-Note Work that should be called out in the blog due to impact S-Waiting-on-Author The author needs to make changes or address concerns before this can be merged X-Controversial There is active debate or serious implications around merging this PR
Projects
None yet
Development

Successfully merging this pull request may close these issues.

7 participants