Skip to content

Merge SystemParam::validate_param into SystemParam::get_param#23225

Merged
alice-i-cecile merged 24 commits into
bevyengine:mainfrom
alice-i-cecile:param-validation-merging
Mar 11, 2026
Merged

Merge SystemParam::validate_param into SystemParam::get_param#23225
alice-i-cecile merged 24 commits into
bevyengine:mainfrom
alice-i-cecile:param-validation-merging

Conversation

@alice-i-cecile
Copy link
Copy Markdown
Member

@alice-i-cecile alice-i-cecile commented Mar 4, 2026

Objective

As raised in #23174, we currently duplicate working when looking up our system parameters: once during validation, and then again when actually fetching the data.

This is (maybe) slow, and would worsen the performance regression incurred by resources-as-components (#19731).

This strategy also imposes some non-trivial complexity and maintainability costs. Because "validate" is a distinct step from "use", it's possible to skip validation! As far as I could tell, this is the case in a number of places before this PR: particularly in the unconventional "please just run my system" path. While in most cases this will simply result in a crash in a different place, it causes these paths to not handle

Fixes #23179. Fixes #15505.

Solution

Fundamentally, what we're doing is rolling the SystemParam::validate_param behavior into SystemParam::get_param, by making the latter return a Result.

However, there is a tremendous amount of splash damage required to get that to actually compile and expose the correct semantics. The most important of these are:

  • SystemState::get and friends now returns a Result
    • this leads to a fair bit of assorted unwrap spam in our tests and weird internal usages
      • these tests can probably be refactored to not use SystemState directly in the future now that we have better tools like run_system_once, but eh, not this PR's job
    • this is semantically correct, as these params could fail validation
  • System::validate_param_unsafe has been removed, and validation now occurs inside of System::run_unsafe
    • very much a net positive for both abstract robustness and current correctness
    • this impacts the strategy that various executors use: see the next section

There are a lot of moving parts here: I'm sorry that I couldn't get this into a smaller, more incremental refactor. When reviewing this PR, you should begin with the migration guide to help get you oriented on the details: validation_merging.md.

From there, the most important files to review are:

  1. system_param.rs: trait changes and implementers
  2. function_system.rs: primary implementer of System
  3. multithreaded.rs: the parallel executor

NOTE TO REVIEWERS: Please make comments to generate threads; this PR review might get fairly hairy.

Performance discussion

For the parallel MultithreadedExecutor, validation was previously done as a cheap pre-validation step,
while checking run conditions.
Now, tasks will be spawned for systems which would fail or are skipped during validation.

In most cases, avoiding the extra overhead of looking up the required data twice should dominate.
However, this change may negatively affect systems which are frequently skipped (e.g. due to Single).

Paths not taken

In this PR, I've decided not to:

  • Add another variant RunSystemError, distinguishing "validation failed" from "system ran but returned an error".
    • While reusing RunSystemError::Failed for both cases is messy, this PR is already a bit of a nightmare to review.
  • Return a result from ParamSet::get_mut.
    • Instead, we just unwrap.
    • Bubbling up the Result is technically more correct, but these were already panicking before if e.g. a resource is missing, and ParamSet is already an ergonomic abomination.

Testing

I've added a number of new tests to exercise the system param validation paths, ensuring that validation is done when systems are run.

However, I would appreciate some help benchmarking the net impact on realistic-ish scenes. breakout, bevy_city and many_animated_foxes are probably a decent scattering, but I'd be very open to other suggestions.

Having done this refactor, I think that it's a net improvement for robustness and clarity even without the perf benefits however, and that we should proceed unless this is a clear regression.

@alice-i-cecile alice-i-cecile added A-ECS Entities, components, systems, and events C-Performance A change motivated by improving speed, memory usage or compile times C-Code-Quality A section of code that is hard to understand or change C-Usability A targeted quality-of-life change that makes Bevy easier to use labels Mar 4, 2026
@alice-i-cecile alice-i-cecile added D-Complex Quite challenging from either a design or technical perspective. Ask for help! S-Needs-Benchmarking This set of changes needs performance benchmarking to double-check that they help S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Mar 4, 2026
@github-project-automation github-project-automation Bot moved this to Needs SME Triage in ECS Mar 4, 2026
entities.shuffle(&mut deterministic_rand());
let mut query = SystemState::<Query<&Table>>::new(&mut world);
let query = query.get(&world);
let query = query.get(&world).unwrap();
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH this is a pretty weird pattern. Who uses SystemState to query on the World?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH this is a pretty weird pattern. Who uses SystemState to query on the World?

It's silly when there is only one Query since they could just have used QueryState, but using SystemState<(Query<A>, Query<B>)> to split the world seems reasonable. And it's going to feel bad to have to unwrap() there even though Query parameters never fail.

I think we're eventually going to want an InfallibleSystemParam subtrait like

trait InfallibleSystemParam: SystemParam {
    fn get_param(/* same params as `SystemParam::get_param` */) -> Self::Item<'w, 's>;
}

And then have fallible and infallible versions of SystemState::get.

That doesn't need to be in this PR, of course, although it will feel a little silly to add all these unwrap()s if we then get to remove them again soon.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we should instead be pushing users to just call World::run_system more for these sorts of world-splitting use cases. The ergonomics are ultimately always going to be much better, and it's more consistent with other usages.

Then: no more internal complexity!

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that we should instead be pushing users to just call World::run_system more for these sorts of world-splitting use cases. The ergonomics are ultimately always going to be much better, and it's more consistent with other usages.

Then: no more internal complexity!

Yeah, run_system_cached has really nice ergonomics when it works! I don't think it's a complete replacement for SystemState yet, though. It's a little less flexible, since needing a function prevents certain kinds of control flow, and it's a little less efficient, since you need to look up the system state in the world.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mhmm, but for tests and benchmarks it's a much better pattern! It just didn't exist when these were written.

}

#[cfg(test)]
mod validation_tests {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I split this into its own test module for clarity. This seemed like a sensible enough home, but all these tests are related to each other but not really the other tests in this file.


should_run &= system_conditions_met;

if should_run {
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Here's where we're removing the pre-thread spawning validation check.

///
/// [`ExclusiveSystemParamFunction`]: super::ExclusiveSystemParamFunction
fn get_param<'s>(state: &'s mut Self::State, system_meta: &SystemMeta) -> Self::Item<'s>;
fn get_param<'s>(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Exclusive system params were currently infallible, but that's a silly inconsistency. I took the liberty of fixing that up here too.

/// - The caller must ensure that [`world`](UnsafeWorldCell) has permission to access any world data
/// registered in the access returned from [`System::initialize`]. There must be no conflicting
/// simultaneous accesses while the system is running.
unsafe fn validate_param_unsafe(
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two whole methods gone for every system! This might actually improve binary size slightly.

world: UnsafeWorldCell<'world>,
change_tick: Tick,
) -> Self::Item<'world, 'state>;
) -> Result<Self::Item<'world, 'state>, SystemParamValidationError>;
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the core change of the whole PR: everything else flows from this signature change.

Comment thread crates/bevy_ecs/src/system/system_param.rs
);

/// Refer to [`SystemParam::validate_param`].
/// Validates the inner parameter by calling [`SystemParam::get_param`] and discarding the value.
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't confident enough on the usage of DynParamState to make serious changes here. Guidance welcome!

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TBH, I kind of think we should just remove this. This is the same decoupled check-then-use pattern that's really problematic. Not sure how though.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I wasn't confident enough on the usage of DynParamState to make serious changes here. Guidance welcome!

Let's try to keep it consistent with ParamSet. The problem with both DynSystemParam and ParamSet is that they evaluate the inner parameter(s) on demand instead of up front, so we should be able to solve both the same way.

The approach you have here of validating the inner parameter during DynSystemParam::get_param seems great! It preserves the existing behavior, and the cost of calling get_param twice is no more than we paid before this PR.

But if we decide to make ParamSet return Result or have an InfallibleSystemParam bound, then we should make the same change here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's good advice, thank you. I think that I want to change ParamSet to return a Result: it's low enough usage that it's not terrible to do, and frankly I think it's a bit sloppy to have unavoidable panics like that when we could simply not.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I've decided against returning a Result here, and I'm doing early validation of ParamSet in 69cb5c0.

This is now consistent, so I think that's the best we can do in this PR.

/// # Safety
/// Refer to [`SystemParam::validate_param`].
unsafe fn validate_param(
/// Refer to [`SystemParam::get_param`].
Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bad safety comment, but not any worse than before 🙃

Comment thread examples/games/breakout.rs
@alice-i-cecile alice-i-cecile force-pushed the param-validation-merging branch from 30a25cd to d151037 Compare March 6, 2026 01:28
Copy link
Copy Markdown
Contributor

@kfc35 kfc35 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I support the merging of validate_param and get_param, so I’m happy to see this being pursued.

With the caveat of my inability to fully understand more complex rust macros at this point in my journey and my potential unawareness of the subtleties between all the permutations of system parameters, I approve of this. I didn’t notice any inconsistencies in any of the merges that would’ve prompted me to ask a question.

I’ll also add that I did some profiling/benchmarking for the first time using this branch vs main and also vs main before resources, if that helps. I used bevymark cargo run --release --features bevy/trace_tracy --example bevymark -- --waves 60 --per-wave 500 --benchmark --mode mesh2d since that’s what François used. GitHub won’t let me upload my screenshots of tracy ugh but in short:

For frame time (just purely looking at the frame compare mode): main currently vs main before resources as entities - current main has a mean frame time that is 3.35ms more than main before resources as entities
this branch vs main before resources as entities - this branch has a mean time frame that is only 721.7 μs more than main before resources as entities. So, an improvement of 2.63ms in mean frame time off of the original 3.35ms on my machine. (The median time incidentally increased by 18μs between this branch and current main but I assume that’s not that significant?)

edit: I forgot to mention that i also traced using bevycity between current main and this branch and there was an 8.55 ms improvement in mean frame time. The median time increased by 91μs. Bevy city was added 5 days after resources as entities so I wasn’t able to test “main before that change"

}
}

/// Calls a closure for each parameter in the set.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
/// Calls a closure for each parameter in the set.
///
/// # Panics
///
/// If system parameter validation fails for any parameter, a panic will occur.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this should be consistent between get_mut and for_each, but as the validation makes this nearly impossible, I'd be inclined to leave it out of both.

If we do want to mention panics here, we should probably also mention them in the macro-generated pN() methods for ordinary tuple ParamSets.

Comment thread crates/bevy_ecs/src/system/system_param.rs Outdated
Comment thread crates/bevy_ecs/src/system/system_param.rs Outdated
}
}

/// Calls a closure for each parameter in the set.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I agree that this should be consistent between get_mut and for_each, but as the validation makes this nearly impossible, I'd be inclined to leave it out of both.

If we do want to mention panics here, we should probably also mention them in the macro-generated pN() methods for ordinary tuple ParamSets.

Co-authored-by: Chris Russell <8494645+chescock@users.noreply.github.com>
@alice-i-cecile alice-i-cecile added S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it and removed S-Needs-Benchmarking This set of changes needs performance benchmarking to double-check that they help S-Needs-Review Needs reviewer attention (from anyone!) to move forward labels Mar 9, 2026
@alice-i-cecile alice-i-cecile moved this from Needs SME Triage to SME Triaged in ECS Mar 9, 2026
@alice-i-cecile alice-i-cecile added this pull request to the merge queue Mar 10, 2026
@github-merge-queue github-merge-queue Bot removed this pull request from the merge queue due to failed status checks Mar 10, 2026
Comment thread crates/bevy_winit/src/state.rs Outdated
Co-authored-by: Kevin Chen <chen.kevin.f@gmail.com>
@alice-i-cecile alice-i-cecile added this pull request to the merge queue Mar 11, 2026
Merged via the queue into bevyengine:main with commit b265dc0 Mar 11, 2026
38 checks passed
@github-project-automation github-project-automation Bot moved this from SME Triaged to Done in ECS Mar 11, 2026
@chescock chescock mentioned this pull request Mar 12, 2026
ChristopherBiscardi added a commit to ChristopherBiscardi/bevy_enhanced_input that referenced this pull request Apr 4, 2026
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-Code-Quality A section of code that is hard to understand or change C-Performance A change motivated by improving speed, memory usage or compile times C-Usability A targeted quality-of-life change that makes Bevy easier to use D-Complex Quite challenging from either a design or technical perspective. Ask for help! S-Ready-For-Final-Review This PR has been approved by the community. It's ready for a maintainer to consider merging it

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

Consider combining SystemParam::validate_params and get_params Optimize validate_param

3 participants