Skip to content

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

Open
spydon wants to merge 20 commits into
mainfrom
perf/component-set-backing
Open

perf!: Replace the children OrderedSet with ComponentList and flatten the update traversal#3960
spydon wants to merge 20 commits into
mainfrom
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.

Relation to the allocation-hygiene PRs

The tangential hot-path improvements were extracted from this PR and reviewed independently, per the review feedback. Merged: #3978 (lifecycle early-out), #3979 (camera render closures; the decorator tear-off caching was deferred during its review), #3980 (lazy render contexts), #3981 (tree teardown and propagation), #3983 (sweep broadphase). #3982 (pointer-handler counters) was closed in favor of an upcoming events rework. This PR is rebased on main with all of that included and 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 against current main, which already includes the merged hygiene PRs, so this table isolates this PR's own effect:

Benchmark main this PR speedup
Update wide tree (10k x 1) 8.65 3.66 2.4x
Update nested tree (1k x 10) 22.45 4.00 5.6x
Update deep tree (100 levels) 18.81 3.41 5.5x
Update barrier tree (10% time-scaled) 21.04 4.51 4.7x
Render wide tree (10k x 1) 12.63 7.17 1.8x
Render nested tree (1k x 10) 22.66 10.80 2.1x
Render deep tree (100 levels) 24.96 9.47 2.6x
Lifecycle churn (100/tick, 1k pop) 4.02 2.74 1.5x
Lifecycle churn (100/tick, 10k pop) 11.42 9.24 1.2x
Mass add/remove (1k per cycle) 2.47 1.54 1.6x
Priority change (1 child per parent) 75.10 7.08 10.6x
Priority change (y-sort, all children) 23.25 6.37 3.6x
Type-query churn (2 registered queries) 6.73 5.85 1.2x
Game-like update pass 202.71 107.48 1.9x
Hit test + delivery (cached) 84.24 77.25 1.1x
Flat collision detection 9.57 3.35 2.9x
Nested collision detection 15.59 3.36 4.6x

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
@spydon
spydon force-pushed the perf/component-set-backing branch from a55e3fb to d009510 Compare August 5, 2026 20:18
spydon added a commit that referenced this pull request Aug 16, 2026
# Description
<!-- End of exclude from commit message -->
`processLifecycleEvents` now returns immediately when the queue is empty
instead of allocating a set and a closure on every tick, the
reorder-parents set is only allocated when a priority change is actually
queued, and the blocked-set hash lookups are skipped while the set is
empty (the common single-pass case).

Extracted from #3960 so the data-structure change there stands alone (as
requested in [this
comment](#3957 (comment))).
Behavior is unchanged; this only removes per-tick allocations and
lookups from the game loop.
@spydon
spydon force-pushed the perf/component-set-backing branch from d009510 to 8684bd0 Compare August 16, 2026 13:51
spydon added a commit that referenced this pull request Aug 16, 2026
# Description
<!-- End of exclude from commit message -->
Every `Component` eagerly allocated a `QueueList` for render contexts
plus two debug-paint `ValueCache`s. The context stack is now a lazily
created plain list (most components never provide or receive a render
context) and the debug caches are `late final`, so plain components
allocate none of them.

Extracted from #3960 so the data-structure change there stands alone (as
requested in [this
comment](#3957 (comment))).
Stacked on #3979.
@spydon
spydon force-pushed the perf/component-set-backing branch from 8684bd0 to f28286c Compare August 16, 2026 15:08
spydon added a commit that referenced this pull request Aug 16, 2026
…xplicit collection pass (#3981)

# Description
<!-- End of exclude from commit message -->
The removal teardown and `propagateToChildren` walked the subtree
through the recursive `descendants` sync* generator, allocating
generator frames per tree level on every traversal. The teardown now
collects the subtree into a local buffer (same leaves-first order) via
`_collectDescendants` and iterates that snapshot, since `onRemove`
callbacks may mutate the tree mid-walk. `propagateToChildren` instead
walks the tree with direct recursion and unwinds as soon as a handler
stops propagation, so it neither allocates a buffer nor visits more
components than the lazy generator did. Event delivery
(`deliverToComponents`) is routed through `propagateToChildren`, so tap,
drag, and keyboard propagation benefit as well.

The public `descendants()` method keeps its documented lazy semantics,
since user code relies on early stopping and live iteration there.

Extracted from #3960 so the data-structure change there stands alone (as
requested in [this
comment](#3957 (comment))).
Base automatically changed from perf/sweep-insertion-sort to main August 16, 2026 16:02
spydon added a commit that referenced this pull request Aug 16, 2026
…list (#3983)

# Description
<!-- End of exclude from commit message -->
`Sweep.update` re-sorted its items with a full `List.sort` and a closure
comparator every tick, although the list is nearly sorted between ticks
(hitboxes only move a little per frame); an insertion sort makes that
near-linear. `Sweep.query` also pruned its active list with a searching
`List.remove`; since active-list order does not matter, that is now an
O(1) swap-remove.

Extracted from #3960 so the data-structure change there stands alone (as
requested in [this
comment](#3957 (comment))).
Stacked on #3982.
@spydon
spydon force-pushed the perf/component-set-backing branch from ad27f0a to 3276a7e Compare August 16, 2026 16:12
spydon added 20 commits August 17, 2026 09:46
@spydon
spydon force-pushed the perf/component-set-backing branch from 3276a7e to cc84561 Compare August 17, 2026 07:46
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