Tuning Bevy’s ECS Scheduler: From Automatic Parallelism to Explicit Ordering
Unlock Bevy’s ECS scheduler: learn how automatic conflict detection, Read/Write annotations, and SystemSet ordering let you control parallelism, avoid deadlocks, and boost performance on multi‑core CPUs.
14 Mar 2026, 19:24 UTC

Problem: When does Bevy run your systems in parallel?
Developers building games or simulations in Bevy often wonder why two systems that look independent still run one after the other. In a data‑oriented engine, parallelism is a major performance lever, but the scheduler’s safety guarantees can serialize systems that you think should run concurrently. Understanding how Bevy’s ECS scheduler detects conflicts and how you can influence that behaviour is key to unlocking the full potential of multi‑core CPUs.
Thesis: Let Bevy do the heavy lifting, then fine‑tune with annotations and SystemSet
Bevy’s scheduler automatically groups systems based on component read/write access. Systems that only read the same component can run in parallel, while a write conflicts with any other read or write. This default behaviour removes the need for manual thread management, but it can be overly conservative. By adding Read and Write markers, and by arranging systems inside SystemSet blocks with before / after tags, you can express explicit ordering constraints while still preserving parallelism where possible.
1. Automatic Conflict Detection and Parallel Execution
When you add systems via Schedule::add_systems, Bevy inspects the component types each system reads or writes. The scheduler builds a conflict graph: nodes are systems, edges connect systems that share a component with conflicting access (write vs read/write). Systems that are not connected can be scheduled on separate threads.
Example: two systems that both read Position can run in parallel, but if one writes Position the other must wait.
fn read_position(mut query: Query<(&Position, &Velocity)>) {
for (pos, vel) in query.iter() {
// read only
}
}
fn update_position(mut query: Query<(&mut Position, &Velocity)>) {
for (mut pos, vel) in query.iter_mut() {
pos.x += vel.x;
pos.y += vel.y;
}
}
app.add_systems((read_position, update_position).chain());
In this snippet, read_position and update_position conflict because update_position writes Position. The scheduler will execute them sequentially. If you split the read into a separate system that only reads Velocity, you can free up parallelism.
2. Explicit Ordering with Read/Write and SystemSet
Bevy lets you annotate systems with Read<T> and Write<T> to inform the scheduler about component access patterns. This is especially useful when a system only reads a component but the compiler cannot infer it from the query signature (e.g., when accessing a component indirectly).
System ordering can be controlled by grouping systems into SystemSet instances and using before / after tags. The tags create a partial ordering that the scheduler respects, while still allowing intra‑set parallelism.
use bevy::prelude::*;
#[derive(Resource)]
struct GameState;
fn spawn_entities(mut commands: Commands) {
commands.spawn((Position::default(), Velocity::default()));
}
fn physics(mut query: Query<(&mut Position, &Velocity)>) {
for (mut pos, vel) in query.iter_mut() {
pos.x += vel.x;
pos.y += vel.y;
}
}
fn render(mut query: Query<(&Position, &mut Transform)>) {
for (pos, mut transform) in query.iter_mut() {
transform.translation.x = pos.x;
transform.translation.y = pos.y;
}
}
fn main() {
App::new()
.add_plugins(DefaultPlugins)
.add_systems(
Startup,
spawn_entities,
)
.add_systems(
Update,
SystemSet::new()
.with_system(physics)
.with_system(render.after(physics)),
);
}
Here, render must run after physics because it depends on updated positions. The after tag forces that ordering, but any other systems inside the same SystemSet that do not conflict can still run in parallel.
3. Worked Example: Parallel vs Serialized Systems
Suppose you have a game where enemies move, and you also want to log their positions for analytics. The movement system writes Position, while the logging system only reads it. By default, Bevy will serialize these two systems because Position is written by one and read by the other. Adding a Read<Position> marker to the logging system tells the scheduler it only needs read access, which is already known, but if you also need to read an AnalyticsBuffer resource that is mutated by the movement system, the conflict remains.
To resolve this, you can split the logging into two parts: one that reads Position and writes to a separate buffer (which is a Resource), and another that writes the buffer to disk. By separating the write to the shared resource, you allow the movement and position‑read systems to run in parallel.
fn log_positions(
query: Query<(&Position, &Entity)>,
mut buffer: ResMut<AnalyticsBuffer>
) {
for (pos, entity) in query.iter() {
buffer.add(*entity, *pos);
}
}
fn flush_buffer(mut buffer: ResMut<AnalyticsBuffer>) {
buffer.flush();
}
app.add_systems(
Update,
SystemSet::new()
.with_system(move_system)
.with_system(log_positions.after(move_system))
.with_system(flush_buffer.after(log_positions)),
);
Now move_system and log_positions can run concurrently because they no longer share a write resource. The flush_buffer runs after both to ensure all data is written.
4. Trade‑offs & Limitations
- Conservative Conflict Detection: Bevy treats any shared
ResourceorEventas a conflict, even if a system only reads it. This can serialize systems that could safely run in parallel. - Overhead of Many Small Systems: Each system incurs a task‑spawning cost. If you have hundreds of tiny systems, the scheduler overhead can outweigh the benefits of parallelism.
- Thread Pool Size: By default, Bevy uses a pool sized to the number of logical CPU cores. On hyper‑threaded machines, performance may plateau or degrade if not tuned.
- Debugging Difficulty: Race conditions are hard to spot. Use
#[profile]andProfilerPluginto inspect execution order and thread usage.
Actionable Checklist
- Run your app with
ProfilerPluginorFrameTimeDiagnosticsPluginto see which systems are serialized. - Annotate systems with
Read<T>/Write<T>where the compiler cannot infer access patterns. - Group related systems into
SystemSetblocks and usebefore/aftertags to enforce necessary ordering. - Measure the impact of moving systems between sets or splitting a system that writes a shared resource into two steps.
- If you notice performance hits, consider consolidating very small systems or adjusting the thread pool size via
Schedule::set_thread_pool_size.
By combining Bevy’s automatic conflict detection with explicit annotations and system ordering, you can strike the right balance between safety and performance, ensuring your game runs smoothly on modern multi‑core CPUs.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.