Skip to content

perf!: Replace the children OrderedSet with ComponentList and flatten the update traversal - #3960

Open
spydon wants to merge 21 commits into
perf/sweep-insertion-sortfrom
perf/component-set-backing
Open

perf!: Replace the children OrderedSet with ComponentList and flatten the update traversal#3960
spydon wants to merge 21 commits into
perf/sweep-insertion-sortfrom
perf/component-set-backing

Conversation

@spydon

@spydon spydon commented Jul 22, 2026

Copy link
Copy Markdown
Member

Description

Rebuilds the core of the Flame Component System for performance (implements #3957, together with the stacked PRs listed below).

Children live in a Flame-owned ComponentList

The ordered_set dependency is gone. Children are stored in a single flat array sorted by (priority, insertion order), and each component intrusively stores its container and slot index:

  • remove and contains are O(1), with no hashing or tree walks; add is an O(1) append in the common case.
  • Removals leave null tombstones that are compacted once per parent per tick, so removing k children among n costs O(k + n) instead of O(k*n).
  • A priority change triggers at most one stable sort per parent per tick, skipped entirely when nothing actually reordered.
  • The register<T>()/query<T>() cache surface is kept, and results are now always in priority order.
  • Component.childrenFactory is replaced by an overridable createComponentList(), which accepts an optional Comparator<Component> for custom orderings such as y-sort.

Three backing designs were implemented and benchmarked before settling on this one; see the comparison in the issue.

The update pass runs over a flattened traversal list

Component.updateTree is now @nonVirtual. Components that manage their own subtree traversal implement the CustomTraversal marker interface and override Component.updateSubtree; traversal mixins carry the marker via implements, so plain with HasTimeScale keeps working and implementations compose through super.updateSubtree.

The root updates everything through a flattened pre-order list that is rebuilt lazily, only on ticks where the tree structure changed (and then fused into that tick's update pass, so the rebuild costs no extra traversal). CustomTraversal components act as barriers that drive their own subtrees. The render pass intentionally stays recursive and virtual, since renderTree has many legitimate overriders (decorators, visibility, snapshot, cameras).

New Component.updatePaused: pauses updates for a component and its whole subtree while rendering, event handling, and lifecycle processing continue; paused subtrees cost nothing per tick. Route.stopTime() is built on it.

Stacked on the allocation-hygiene PRs

The tangential hot-path improvements were extracted into a stack of independent PRs, per the review feedback, and this PR is based on top of them (merge order: #3978 -> #3979 -> #3980 -> #3981 -> #3982 -> #3983 -> this):

  1. perf: Skip lifecycle processing work when the queue is empty #3978 lifecycle processing early-out
  2. perf: Cache decorator and camera render closures instead of allocating per frame #3979 decorator and camera render closure caching
  3. perf: Make render contexts and debug caches lazily allocated #3980 lazy render contexts and debug caches
  4. perf: Replace generator-based removal teardown with an explicit collection pass #3981 generator-free removal teardown
  5. perf: Track mounted pointer-event handlers so hit tests can early-out #3982 pointer-event handler counters for hit-test early-out
  6. perf: Insertion-sort the sweep broadphase and swap-remove its active list #3983 sweep broadphase insertion sort

This PR contains only the data-structure and traversal work. Golden tests pin lifecycle-event ordering, hit-test order, and equal-priority ordering across the rewrite.

Benchmarks (JIT, same machine, ms per run, lower is better)

Measured on the full stack (the six PRs above plus this one) against main; the hit-test row is mostly #3982's early-out, the rest is dominated by this PR:

Benchmark main stack + this PR speedup
Update wide tree (10k x 1) 8.09 3.44 2.4x
Update nested tree (1k x 10) 16.96 3.97 4.3x
Update deep tree (100 levels) 18.14 3.36 5.4x
Update barrier tree (10% time-scaled) 20.45 4.47 4.6x
Render wide tree (10k x 1) 12.81 4.64 2.8x
Render nested tree (1k x 10) 23.05 8.25 2.8x
Render deep tree (100 levels) 27.44 6.89 4.0x
Lifecycle churn (100/tick, 1k pop) 5.10 2.54 2.0x
Lifecycle churn (100/tick, 10k pop) 12.56 8.63 1.5x
Mass add/remove (1k per cycle) 3.43 1.31 2.6x
Priority change (1 child per parent) 74.46 6.92 10.8x
Priority change (y-sort, all children) 22.61 5.82 3.9x
Type-query churn (2 registered queries) 6.95 5.16 1.3x
Game-like update pass 185.29 95.31 1.9x
Hit test + delivery (cached) 90.93 39.06 2.3x
Flat collision detection 9.24 3.35 2.8x
Nested collision detection 15.41 3.52 4.4x

AOT device numbers are still pending; expect smaller (but same-ranked) multiples under AOT.

Checklist

  • I have followed the Contributor Guide when preparing my PR.
  • I have updated/added tests for ALL new/updated/fixed functionality.
  • I have updated/added relevant documentation in docs and added dartdoc comments with ///.
  • I have updated/added relevant examples in examples or docs.

Breaking Change?

  • Yes, this PR is a breaking change.
  • No, this PR is not a breaking change.

Migration instructions

  • children is now a ComponentList instead of an OrderedSet. The iterable surface, query<T>(), register<T>(), and reversed() are unchanged, so most code compiles as is. The ordered_set dependency is gone.
  • Component.childrenFactory is removed. Override createComponentList() on the component instead; it accepts an optional Comparator<Component> for custom orderings (for example y-sort).
  • Component.updateTree is non-virtual. If you overrode it, add implements CustomTraversal and override Component.updateSubtree instead; call super.updateSubtree(dt) for the standard traversal. HasTimeScale usage is unchanged (with HasTimeScale still works; the mixin carries the marker itself).
  • Route.stopTime() now sets updatePaused instead of zeroing timeScale: while stopped, timeScale keeps its previous value (a slow-motion factor survives a stop/resume cycle), and assigning a new timeScale no longer resumes a stopped route; use resumeTime() or updatePaused = false. Pending lifecycle events on a stopped route now still complete.
  • Mutating children while iterating it now tolerates removals and tail appends; only position-shifting operations (mid-list insertion, reorder, compaction) throw ConcurrentModificationError.

Related Issues

Closes #3957

@spydon
spydon marked this pull request as ready for review July 22, 2026 12:02
Comment thread packages/flame/lib/src/components/core/component_list.dart Outdated
Comment thread doc/flame/migration.md
@spydon
spydon changed the base branch from main to perf/sweep-insertion-sort August 5, 2026 16:37
@spydon
spydon force-pushed the perf/component-set-backing branch from e187790 to a55e3fb Compare August 5, 2026 16:38
@spydon spydon changed the title perf!: Rebuild the FCS core: ComponentList children, allocation hygiene, and flattened update traversal perf!: Replace the children OrderedSet with ComponentList and flatten the update traversal Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make the Flame Component System core more efficient

2 participants