What is the performance cost of skip and take for paginating Bevy ECS queries?
0 reputation · 06 Jan 2025, 18:17 UTC
0 reputation · 06 Jan 2025, 18:17 UTC
Bevy ECS uses archetype-based storage where queries iterate through matching entities. Unlike relational databases, the Query API does not provide native LIMIT or OFFSET functionality. To implement pagination for large datasets, developers typically chain .skip(n).take(m) onto the iterator returned by iter() or iter_mut().
Because Bevy does not guarantee a stable iteration order across archetype changes or engine runs, pagination often requires an external index or an explicit sorting step to ensure consistent pages. However, the primary concern is the efficiency of the skipping mechanism when dealing with thousands of entities.
It is unclear whether the standard iterator adapters in the current Bevy version short-circuit archetype traversal or if skip(n) always incurs a linear O(n) cost by visiting every entity until the offset is reached.
skip(n) on a QueryIter avoid processing entities in archetypes that are entirely skipped?28775 reputation · 07 Jan 2025, 01:00 UTC
Calling .skip(n) on a QueryIter in Bevy does not skip entire archetypes. The iterator walks the component storage in memory order, advancing one entity at a time until it has skipped n matches. .take(m) simply stops the iterator after m items, adding almost no extra cost. The total complexity is therefore O(n+m), where n is the offset and m is the page size.
skip; it still iterates over every entity in the relevant storage.When you paginate a large set of entities, each page that is not the first forces a linear walk over the preceding n items. For small offsets (tens or hundreds) the overhead is negligible, but for offsets in the thousands or more it becomes noticeable. The take part is essentially free once the skip has finished.
Vec of the query result in a resource and page that vector instead of re‑running the query each frame.Index component and sort by it before paging.skip usage. For very large offsets consider a custom query that directly accesses the underlying storage (e.g., Query::iter().enumerate() and then slice the vector)..skip(n).take(m) on your target data set; if the difference between skip(0) and skip(n) is below a few milliseconds, the built‑in adapters are fine.use bevy::prelude::*;
#[derive(Component)]
struct Foo;
fn setup(mut commands: Commands) {
for _ in 0..100_000 {
commands.spawn((Foo,));
}
}
fn benchmark(mut timer: ResMut) {
// Measure skip(5_000).take(100)
let start = Instant::now();
let count = world.query::<&Foo>().iter(&world).skip(5_000).take(100).count();
println!("count = {} in {:?}", count, start.elapsed());
}
fn main() {
App::new()
.add_startup_system(setup)
.add_system(benchmark)
.run();
}
Run the app twice: once with skip(0) and once with skip(5_000). The difference in elapsed time should scale roughly with the skip offset, confirming the linear cost.
If your query uses multiple components or a filter that excludes most entities, the linear walk still occurs over the matching subset. Knowing the exact component mask can help predict the cost more accurately.
In short, .skip() in Bevy incurs a linear walk over matching entities, and .take() is cheap. For small pages the built‑in adapters are fine; for large offsets consider caching or indexing strategies to avoid repeated linear scans.
Use comments to ask for clarification. Post a solution as an answer.
28,775 reputation · 06 Jan 2025, 22:33 UTC
When you call .skip(n) on a QueryIter, the iterator advances the internal cursor one matching entity at a time, regardless of archetype boundaries. If a query matches only a few entities per archetype, the cursor will hop between many archetypes, causing extra cache‑miss overhead per skipped entity. The .take(m) step then simply stops after m yields, adding negligible cost beyond the per‑entity mask and component reads.