Optionally track changes on a per-column basis so that clients of contiguous iteration can efficiently skip entire tables. - #25157
Conversation
|
Note to self: this needs to also update the column changed tick when new entities are added. |
|
This PR is the culmination of many, many, many discussions both in Discord and PR #23519. The outcome of these discussions is summarized here. Here, "change indexes" means "any solution for the problem of detecting changes at a granularity higher than that of the individual row": i.e. any solution that allows large numbers of rows to be skipped at once without having to fetch the change ticks for each and every row. Design constraintsThe most important design constraint is that the regression in mutating component performance must be kept to a minimum. In particular, It may be surprising at first that table- (or archetype-)level summary ticks require synchronization at all. After all, Unfortunately, fn foo(q: Query<(&mut DenseComponent, &SparseSetComponentA), Without<SparseSetComponentB>>) { ... }
fn bar(q: Query<(&mut DenseComponent, &SparseSetComponentB), Without<SparseSetComponentA>>) { ... }Suppose that some entities have a An additional design constraint is that the proposal should ideally not diverge too far from the existing Finally, any proposal for change indexes should be fast enough to perform well on low-end devices, including those with low DRAM bandwidth, and should ideally be simple to implement and iterate on. How this solution worksThe solution in this PR is based on contiguous iteration. Contiguous iteration is a little-used Bevy feature that takes a dense query and returns query data that represents entire table columns (or subsets thereof). This patch augments the query data representing a column with a new method that allows the consumer of the query to ask whether any component instance in the column has changed since the last time the query was run. This solves the problem, as systems that use such queries can simply ask whether the columns they’re interested in are changed before iterating over those columns’ contents; if all the columns in question are unchanged, then the entire table can be skipped. In essence, contiguous iteration provides objects that allow consumers of a query to ask questions about table columns as a whole, not individual rows, which is precisely what is needed in order to accelerate change detection. There’s another, subtle yet crucial, advantage of contiguous iteration for this problem. Summary ticks can actually run backwards in certain situations involving sparse components. Consider the two systems above: fn foo(q: Query<(&mut DenseComponent, &SparseSetComponentA), Without<SparseSetComponentB>>) { ... }
fn bar(q: Query<(&mut DenseComponent, &SparseSetComponentB), Without<SparseSetComponentA>>) { ... }Suppose that Bevy runs the two systems concurrently. Then they will have different change ticks while simultaneously mutably accessing This seems like an unfortunate complication. However, note that this issue only occurs when sparse queries are involved. Suppose that, in addition to the above, we have this dense query that uses contiguous iteration and checks summary ticks to avoid examining tables that had no changes to any fn baz(q: Query<&mut DenseComponent>) {
for cs in q.iter_contiguous() {
if !cs.any_changes() {
continue;
}
for c in cs {
// … do work …
}
}
}Imagine that all those queries are unordered relative to one another, so Bevy could schedule them in any order. The scenario we want to prevent is a situation in which a change tick for a But observe that the only time a change tick can go backwards is when two queries are scheduled concurrently and both have component filters that mention sparse set components (i.e. both queries are sparse queries). A dense query has exclusive access to the tables that it touches. In fact, this fact is central to what makes contiguous iteration safe at all, as handing out mutable access to an entire table column while other sparse queries are reading the table would be undefined behavior. A dense query that touches a table will never be scheduled concurrently with a sparse query. Therefore, even though summary ticks can run backwards, a summary tick that represents a time after a dense query will never be overwritten with one that represents a time before that dense query. So, if the consumer of a dense query asks the specific question “was any component in this column mutated since the last time this query ran?”, the answer can always be correctly determined by checking the summary tick. Thus, by scoping the use of summary ticks to contiguous iteration, which also require dense queries, the fact that summary ticks can run backwards is rendered harmless. We must, of course, document that summary ticks can only be correctly used to detect changes since the last contiguous iteration. But, as long as we design the supported API appropriately to discourage misuse of summary ticks, this approach constitutes a simple solution that renders the pitfalls of summary ticks harmless. AlternativesThe landscape of potential solutions to this problem is vast, and a huge number of alternatives have been proposed over the months of discussion leading up to this PR. Below are descriptions of other potential solutions and how they fare relative to the design criteria specified above.
Not only is there real-world evidence, but a simple back-of-the-envelope calculation based on DRAM speeds also demonstrates the bottleneck quite clearly. Suppose that we’re on a modern desktop machine with DDR5-4800 RAM, which runs at 38.4 GB/s. Say that we need to check 16 components per frame (which is similar to the number that the expensive passes check), that each change tick is 4 bytes, and that we have 4.5 million entities like the Unity megacity demo. Then we have
Future workThis commit constitutes the minimal version of the feature, and many follow-ups are possible:
Because of the enormous amount of discussion that this feature has generated, I opted to implement only the minimal version of this feature and leave other improvements to follow-ups. |
|
The new commit adds a read before the write, which fixes the performance problem on Tiger Lake. I believe this has to do with the store reorder buffer in the CPU forcing serialization of reads, but I'm unsure. I should caution that, in general, the component mutation benchmarks do so little that microarchitectural quirks on specific models of CPUs can start to dominate the results. We should be careful about making decisions naively from their results, as actual component mutation tends to have a lot more arithmetic intensity than our microbenchmarks. Specifically, microbenchmarks of our mutation perform one vector load, one vector addition, and one vector store, but real systems tend to do a lot more than that, and so the regressions in the |
|
I went ahead and marked this ready for review, pending the results of CI. |
There was a problem hiding this comment.
I think massive increase in efficiency more than makes up for a minor performance loss in iteration speed for normal queries, especially since contiguous_iter_mut is a more preferred api for batch updates either way due to it's better auto-vectorization capabilities.
With the summary_tick infrastructure in place, I also think it would be interesting to try applying alternative 10 (conservative summary tick) to all components since it'll be essentially free compared to #[component(summary_tick)] path during iteration, but might still give some easy wins in general (future work, not this PR).
| let table_id = new_archetype.table_id(); | ||
| if let Some(new_table) = deferred_world.storages().tables.get(table_id) { | ||
| for component_id in new_archetype.table_components() { | ||
| if let Some(summary_tick) = new_table.get_summary_tick(component_id) { | ||
| summary_tick.set(change_tick); | ||
| } | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
Setting summary tick should probably be done in Column::initialize and Column::replace instead as that's where all the other Ticks are set.
Or maybe in BundleInfo::write_components since sparse set also uses these methods and there is no reason to add any additional overhead to it.
| for t in self.changed.iter_mut() { | ||
| *t = this_run; | ||
| } | ||
| } |
There was a problem hiding this comment.
This should also set summary tick (ContiguousComponentTicksMut::mark_all_as_changed, github ate it)
| // Do an unsynchronized read first. | ||
| // This is important on x86-64 to avoid a performance cliff that I | ||
| // believe is related to the store reorder buffer. |
There was a problem hiding this comment.
This needs to be tested on other architectures, and I think it should be cfg-guarded if it actually only makes sense on x86-64.
| layout: Layout::new::<T>(), | ||
| drop: needs_drop::<T>().then_some(Self::drop_ptr::<T> as _), | ||
| mutable: T::Mutability::MUTABLE, | ||
| summary_tick: T::has_summary_tick(), |
There was a problem hiding this comment.
Since summary ticks are only supported for table components, should this be a T::has_summary_tick() && matches!(T::STORAGE_TYPE, StorageType::Table) instead?
| layout, | ||
| drop, | ||
| mutable, | ||
| summary_tick, |
There was a problem hiding this comment.
Same here, maybe even an assert since there's already one for layout?
| /// # Safety | ||
| /// `len` is the actual length of this column | ||
| #[inline] | ||
| pub(crate) unsafe fn check_change_ticks(&mut self, len: usize, check: CheckChangeTicks) { |
There was a problem hiding this comment.
Should this also check summary tick?
| unsafe { | ||
| ComponentTicksMut::from_tick_cells( | ||
| ticks, | ||
| self.last_change_tick(), | ||
| change_tick, | ||
| ) | ||
| }; |
There was a problem hiding this comment.
There are some weird formatting artifacts here it seems
| None, | ||
| true, // is mutable | ||
| true, // is mutable | ||
| false, // has summary tick |
There was a problem hiding this comment.
| false, // has summary tick | |
| false, // no summary tick |
Bevy systems currently have no way to skip processing tables that contain no components that have changed since the last frame. There is extremely strong evidence that these systems are preventing Bevy both from scaling down to normal scenes on very low end hardware, and up to very large scenes on modern hardware. For example, there's currently no feasible way for Bevy to scale to the roughly 4.5 million entities that the Unity megacity demo has, even on modern hardware, much less the 7-year-old hardware that the 2019 demo was designed to run on.
The root cause is that Bevy has a variety of systems that examine every component that changed in some way from the previous frame. These systems are frequently the most expensive systems in typical apps: transform propagation, visibility propagation, and mesh extraction, to name a few. At the moment, these systems have no way to avoid checking the change tick of every entity to determine what changed. This has become a severe bottleneck as Bevy scales to larger scenes, particularly on lower-tier embedded hardware which often has slower memory and shares a single memory bus with the GPU.
This commit provides a way for such systems to avoid checking every row in tables by exposing a per-column summary tick to clients of the contiguous iteration feature. A summary tick is simply a field, present on each table column corresponding to a component that opts in to the feature, that records the value of the last change tick that was written to any cell in the column. Because keeping the change tick up to date adds some overhead to the
Mut<T>type, which is performance-critical, components that wish to track summary ticks must opt into the feature like so:Contiguous iteration is a Bevy feature, seldom used at present, that directly exposes table columns to the query client as wrapped arrays. This makes it a natural mechanism for exposing information about columns. Because contiguous iteration yields entire tables at a time, it provides a straightforward way for clients to examine information about table columns to determine whether the client should process the table. Additionally, contiguous iteration is restricted to dense queries, which is precisely the restriction that we want in order to ensure that the summary tick is useful for systems that need to skip tables if no relevant components in the table have changed since the last time the query ran. Consequently, this PR uses the contiguous iteration feature as the preferred mechanism for exposing the summary tick to clients (but see the end of this commit message for further potential follow-ups).
The design rationale for this feature is covered in the comments in PR #25157, in order to keep this commit message relatively tidy. Please see those comments for more information. Because this PR has had so much discussion, I opted to keep it minimal and to defer potential improvements to follow-ups.
I measured the performance of updating components via
Mutin various scenarios.Without summary ticks, the performance is unchanged (8.8846 µs
mainvs. 8.7340 µs this PR), showing that the correctly predicted branch on whether the component has a summary tick has no effect in a memory-bound task like component iteration.With summary ticks iterating sequentially, the performance is a 1.75x regression (8.7340 µs with no summary tick vs. 15.670 µs with a summary tick) which is to be expected as the summary tick adds a dependent load.
With summary ticks iterating in parallel, the performance is a 1.14x regression (104.91 µs with no summary tick vs. 119.21 µs with a summary tick).
I did a performance test in which I created a version of
extract_meshes_for_gpu_buildingthat leverages parallel contiguous iteration from PR #25264 and summary ticks from this commit. After applying summary ticks to all the relevant components, runningmany_cubes --instance-count 1600000 --no-cpu-cullingresults in an improvement inextract_meshes_for_gpu_buildingof 5.13 ms to 0.039 ms, a 132× speedup.I believe that the small regression in parallel performance--which is what matters for bulk component update--is well worth a massive across-the-board increase of Bevy performance in most applications. Note that
extract_meshes_for_gpu_buildingis just one of the many systems that will benefit from this improvement.All measurements were performed on a 2021 11th Gen Intel(R) Core(TM) i7-1165G7 @ 2.80GHz, 32 GB DDR4 RAM. Note that I quite intentionally chose older hardware here.